1.4、实现登录拦截功能
温馨小贴士:tomcat的运行原理
当用户发起请求时,会访问我们像tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket像互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应
通过以上讲解,我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据
温馨小贴士:关于threadlocal
如果小伙伴们看过threadLocal的源码,你会发现在threadLocal中,无论是他的put方法和他的get方法, 都是先从获得当前用户的线程,然后从线程中取出线程的成员变量map,只要线程不一样,map就不一样,所以可以通过这种方式来做到线程隔离
这样controller不需要判断用户是否登录,都由拦截器来判断
谈谈HandlerInterceptor
HandlerInterceptor
—>spring-webmvc
项目,org.springframework.web.servlet.HandlerInterceptor
从包路径可以看出,这个是处理客户端http servlet请求的,此项目spring-webmvc与spring-mvc项目关闭密切,HandlerInterceptor可以对请求的各个阶段进行拦截,可以说是非常全面了。这个也是常规项目中用的最多的,对http请求进行拦截
public interface HandlerInterceptor {
/**前置处理:在业务处理器处理请求之前被调用*/
boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception;
/**中置处理:在业务处理器处理请求执行完成后,生成视图之前执行。后处理(调用了Service并返回ModelAndView,但未进行页面渲染),有机会修改ModelAndView ,现在这个很少使用了*/
void postHandle(
HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)
throws Exception;
/**后置处理:在DispatcherServlet完全处理完请求后被调用,可用于清理资源等*/
void afterCompletion(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception;
}
拦截器代码
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取session
HttpSession session = request.getSession();
//2.获取session中的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if(user == null){
//4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((User)user);
//6.放行
return true;
}
}
让拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
1.7 Redis代替session的业务流程
1.7.1、设计key的结构
首先我们要思考一下利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
1.7.2、设计key的具体细节
所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code了
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了
1.7.3、整体访问流程
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。
1.7.2、设计key的具体细节
所以我们可以使用String结构,就是一个简单的key,value键值对的方式,但是关于key的处理,session他是每个用户都有自己的session,但是redis的key是共享的,咱们就不能使用code了
在设计这个key的时候,我们之前讲过需要满足两点
1、key要具有唯一性
2、key要方便携带
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了
1.7.3、整体访问流程
当注册完成后,用户去登录会去校验用户提交的手机号和验证码,是否一致,如果一致,则根据手机号查询用户信息,不存在则新建,最后将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。
1.8 基于Redis实现短信登录
这里具体逻辑就不分析了,之前咱们已经重点分析过这个逻辑啦。
UserServiceImpl代码
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户信息到 redis中
// 7.1.随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 7.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
RedisTemplate中定义了5种数据结构操作
redisTemplate.opsForValue(); //操作字符串
redisTemplate.opsForHash(); //操作hash
redisTemplate.opsForList(); //操作list
redisTemplate.opsForSet(); //操作set
redisTemplate.opsForZSet(); //操作有序set
1.9 解决状态登录刷新问题
1.9.2 优化方案
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
1.9.3 代码
RefreshTokenInterceptor
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
怎么获取token
String token = request.getHeader(“Authorization”);
这里的Authorization存放的就是token
怎么设置值到header中
response.setHeader(String s1,String s2)
相当于key-value ,s1是header中的key,s2是header的value
list 怎么放到header中
使用list.toString(),返回一个数组格式的String
response.setHeader(“list”,list.toString())
//获取key对应的map
stringRedisTemplate.opsForHash().entries("key");
上传接口
@Slf4j
@RestController
@RequestMapping("upload")
public class UploadController {
@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
// 获取原始文件名称
String originalFilename = image.getOriginalFilename();
// 生成新文件名
String fileName = createNewFileName(originalFilename);
// 保存文件
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
// 返回结果
log.debug("文件上传成功,{}", fileName);
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}
}
MultipartFile
首先我们要认识一下Java中的流
1、“流”是一个抽象的概念,它是对输入输出设备的一种抽象理解,在java中,对数据的输入输出操作都是以“流”的方式进行的。
2、“流”具有方向性,输入流、输出流是相对的。当程序需要从数据源中读入数据的时候就会开启一个输入流,相反,写出数据到某个数据源目的地的时候也会开启一个输出流。
3、数据源可以是文件、内存或者网络等。
接着我们一起看看:MultipartFile工具类
MultipartFile是SpringMVC提供简化上传操作的工具类。
在不使用框架之前,都是使用原生的HttpServletRequest来接收上传的数据,文件是以二进制流传递到后端的,然后需要我们自己转换为File类。使用了MultipartFile工具类之后,我们对文件上传的操作就简便许多了。
来学习一下MultipartFile工具类全部的接口方法(我已经备注好了哈哈)
package org.springframework.web.multipart;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.core.io.InputStreamSource;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.util.FileCopyUtils;
public interface MultipartFile extends InputStreamSource {
//getName() 返回参数的名称
String getName();
//获取源文件的昵称
@Nullable
String getOriginalFilename();
//getContentType() 返回文件的内容类型
@Nullable
String getContentType();
//isEmpty() 判断是否为空,或者上传的文件是否有内容
boolean isEmpty();
//getSize() 返回文件大小 以字节为单位
long getSize();
//getBytes() 将文件内容转化成一个byte[] 返回
byte[] getBytes() throws IOException;
//getInputStream() 返回InputStream读取文件的内容
InputStream getInputStream() throws IOException;
default Resource getResource() {
return new MultipartFileResource(this);
}
//transferTo是复制file文件到指定位置(比如D盘下的某个位置),不然程序执行完,文件就会消失,程序运行时,临时存储在temp这个文件夹中
void transferTo(File var1) throws IOException, IllegalStateException;
default void transferTo(Path dest) throws IOException, IllegalStateException {
FileCopyUtils.copy(this.getInputStream(), Files.newOutputStream(dest));
}
}
InputStreamSource 这个接口本质上返回的还是一个InputStream 流对象
package org.springframework.core.io;
import java.io.IOException;
import java.io.InputStream;
public interface InputStreamSource {
//定位并打开资源,返回资源对应的输入流。
//每次调用都会返回新的输入流,调用者在使用完毕后必须关闭该资源。
InputStream getInputStream() throws IOException;
}
正常的文件上传接口你可以写一些帮助类这样就可以帮助你更好的筛选文件了,当然很多条件最好在前端也完成,这里我分享过el-upload组件可以简单参考
Element-UI中el-upload上传组件(demo详解)
比如:你要现在文件类型,大小等等都可以上传组件完成限制,这样减轻服务器的压力
当然Java中也可以写一些校验相关的帮助类,帮助我们筛选,如下:仅供参考
//简单的分享一下吧,仅仅给参考
private String validateImg(MultipartFile file) {
if (file == null) {
throw new RuntimeException("图片不能为空");
}
if (file.getSize() >= 20 * 1024 * 1024) {
throw new RuntimeException("图片大小超出最大限制");
}
boolean isPic = FileUtil.isPic(file.getOriginalFilename());
if (!isPic) {
throw new RuntimeException("图片格式错误");
}
String fileName = Objects.requireNonNull(file.getOriginalFilename()).length() > 50 ? file.getOriginalFilename().substring(0, 50) : file.getOriginalFilename();
return fileName;
}
@RequestParam使用
加与不加的区别
@RequestMapping("/list1")
public String test1(int userId) {
return "list";
}
@RequestMapping("/list2")
public String test2(@RequestParam int userId) {
return "list";
}
(1)不加@RequestParam前端的参数名需要和后端控制器的变量名保持一致才能生效
(2)不加@RequestParam参数为非必传,加@RequestParam写法参数为必传。但@RequestParam可以通过@RequestParam(required = false)设置为非必传。
(3)@RequestParam可以通过@RequestParam(“userId”)或者@RequestParam(value = “userId”)指定传入的参数名。(最主要的作用)
(4)@RequestParam可以通过@RequestParam(defaultValue = “0”)指定参数默认值
(5)如果接口除了前端调用还有后端RPC调用,则不能省略@RequestParam,否则RPC会找不到参数报错
(6)Get方式请求,参数放在url中时:
不加@RequestParam注解:url可带参数也可不带参数,输入 localhost:8080/list1 以及 localhost:8080/list1?userId=xxx 方法都能执行
加@RequestParam注解:url必须带有参数。也就是说你直接输入localhost:8080/list2 会报错,不会执行方法。只能输入localhost:8080/list2?userId=xxx 才能执行相应的方法