文章目录
- 项目概述
- 项目前置准备
- 短信登陆
- 基于Session实现登录流程
- 实现发送短信验证码功能
- 实现短信验证码登录和注册功能
- 实现登录校验拦截器
- 隐藏用户敏感信息
- 集群的Session共享问题
- 基于Redis实现共享Session登录
- 登录拦截器的优化
项目概述
- 短信登录
这一块我们会使用redis共享session来实现
- 商户查询缓存
通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容
- 优惠卷秒杀
通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列
- 附近的商户
我们利用Redis的GEOHash来完成对于地理坐标的操作
- UV统计
主要是使用Redis来完成统计功能
- 用户签到
使用Redis的BitMap数据统计功能
- 好友关注
基于Set集合的关注、取消关注,共同关注等等功能,这一块知识咱们之前就讲过,这次我们在项目中来使用一下
- 达人探店
基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能
项目的架构
本项目核心在于理解redis的用法,所以没有使用微服务技术。此项目采用的是前后端分离的模式,也就是说我们会把前端和后端分别部署,而不是全部放在一起往tomcat一扔。后端部署在tomcat上,前端部署在Nginx服务器上,这也是一般企业级开发的标准做法。移动端和PC端在发请求的时候首先请求我们的页面其实都是向我们的Nginx发起请求得到一些相关的静态资源,然后页面再通过Ajax向我们的服务端发起请求去查询数据,这些数据可能来自与我们的Redis集群或者Mysql集群,然后再把查询到的数据返回给前端,前端进行相关的渲染就可以了(这也是一种典型的前后端分离的架构模式)。
手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的是HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。
在tomcat支撑起并发流量后,我们如果让tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。
项目前置准备
一些前置的数据库、前端项目、后端项目准备这里就不提及了,直接在b站的视频下方有相关的网盘资源:
导入后端项目的时候要记得把application.yml中mysql、redis的相关配置改成自己的。在访问http://localhost:8081/shop-type/list
之后显示下图数据即代表导入成功;
然后我们简单的来看一下这个后端项目,首先在pom文件里可以看到如下几个依赖;
- spring-boot-starter-data-redis:redis启动依赖
- commons-pool2:redis的连接池依赖
- lombok:提供一些快速开发注解
- mybatis-plus-boot-starter:简化Mybatis开发
- hutool-all:里面包含各种各样的工具类,例如:json处理、字符串工具类、数字工具类、日期工具类等等···
- MybatisConfig中是MyBatisPlus的分页配置
- WebExceptionAdvice中是通用的异常处理
MyBatisPlus可以帮助我们做一些简单的单表查询,而复杂的表查询我们还是要借助mapper文件来实现,也就是图中resource下的mapper文件
其他的结构也很常规在瑞吉外卖中也都出现过,这里就不多赘述
这里的mapper相当于Dao层接口,在原来我们会在每一个接口上使用@Mapper注解,但是这里没有:
这是因为此项目在启动文件中配置了@MapperScan,他扫描到了我们的mapper文件夹,会自动为文件夹中的mapper接口创建实现类:
还要注意这里前、后端项目要部署到同一个主机上。如果你在本机部署后端项目,在自己虚拟机的Nginx上去部署自己的前端项目是不行的!
短信登陆
基于Session实现登录流程
发送验证码:
用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
如果手机号合法,后台此时生成对应的验证码,同时将验证码保存到Session中(为将来的登录验证做准备),然后再通过短信的方式将验证码发送给用户
这个地方其实服务端和浏览器都作了很多工作没有提及,比如说服务器端将验证码保存到了Session(web服务器内存中的某一个Session)中以后,会将响应头中塞入一个cookie,这个cookie会记录这个Session对应的jsessionid。在以后浏览器请求对应网站的时候都会带上这个cookie,然后我们就可以凭借它使用这个Session中的数据。
短信验证码登录、注册:
用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息
session机制采用的是在服务器端保持 HTTP 状态信息的方案(它是基于cookie的)。为了加速session的读取和存储,web服务器中会开辟一块内存用来保存服务器端所有的session,每个session都会有一个唯一标识sessionid,根据客户端传过来的jsessionid(cookie中),找到对应的服务器端的session。为了防止服务器端的session过多导致内存溢出,web服务器默认会给每个session设置一个有效期, (30分钟)若有效期内客户端没有访问过该session,服务器就认为该客户端已离线并删除该session。
(要从一次会话的角度去理解Session)
校验登录状态:
用户在请求时候,会从cookie中携带着JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有用户信息,则进行拦截,如果有用户信息,则将用户信息保存到threadLocal中,并且放行
如果Session中有用户信息,证明这个用户是曾经登陆过的,当然判断完之后不能直接放行,我们这个登录校验不能白校验,在后续的业务当中一定会用到当前登录的用户的信息,所以我们此时将用户的信息缓存起来再放行是一个更好的选择。那我们的用户信息缓存到哪里呢?我们一般情况下会将用户信息缓存到ThreadLocal当中,ThreadLocal是一个线程域对象,在我们的业务当中,每一个请求到达我们的微服务都会是一个独立的线程。如果说我们没有用ThreadLocal,而是直接将用户信息保存到本地变量,那就可能会出现多线程并发的修改问题,而ThreadLocal会将数据保存到每一个线程的内部,在内部创建一个map进行保存。这样一来每一个线程都有自己独立的空间,相互之间没有干扰。
实现发送短信验证码功能
页面流程
注意:我们可以发现请求路径是
http://localhost:8080/api/user/code?phone=xxxxx
,这个/api是一个标记用来被Nginx拦截。
还有一个注意点:我们的后端服务器处于8081端口但是为什么我们请求的却是8080端口的Nginx服务器?
因为这里使用了Nginx的反向代理,它最终会把你的请求转发给8081端口
首先我们在Controller层中找到对应的接口(在UserController中):
我们在这里直接返回Service层的处理结果,然后在Service层中去编写具体的逻辑:
/**
* 发送手机验证码
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
// TODO 发送短信验证码并保存验证码
return userService.sendCode(phone,session);
}
完善一下Service层的接口:
public interface IUserService extends IService<User> {
Result sendCode(String phone, HttpSession httpSession);
}
在对应的实现类中完善业务逻辑:
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession httpSession) {
return null;
}
}
接下来我们的业务逻辑就根据如下的这张图来写;
首先是校验手机号,这个在我们的项目中已经提供了对应的工具类;
-
RegexPatterns:这个工具类中主要定义了几个不同场景下的正则匹配规则
-
RegexUtils:这个工具类中根据正则匹配规则进行相关的校验
mismatch是校验方法,校验是否不符合正则格式;
在校验完手机号码之后,会生成手机验证码,生成手机验证码我们可以使用hutool-all。接下来我们将验证码通过HttpSession 对象存储在session中。在短信发送这一块,一般是使用阿里云或者华为云等服务实现,这里我们为了简化开发,直接使用的日志记录一下。最后我们返回成功结果。
Result是一个通用的结果对象;
最后整体代码如下
- 发送验证码
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到 session
session.setAttribute("code",code);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
实现短信验证码登录和注册功能
我们发现在点击登录按钮之后,浏览器会发送一个POST请求给/user/login,其中请求参数中有电话号码以及验证码用于和服务端session中的验证码进行比对
接下来我们就按照浏览器的请求接口完善我们Controller层的相关代码:
我们发现它会接收requestbody中的数据对象:
这里之所以有一个password是因为用户除了可以使用短信验证码登录也可以使用密码登录:
和前面一样我们还是把业务逻辑放在Service层去做,在这里我们只是简单的调用Service层的接口。
这里的代码流程我们还是参考下图做:
首先我们验证验证码是否吻合:
//用户手机号
String phone = loginForm.getPhone();
//用户验证码
String code = loginForm.getCode();
//用户密码
String password = loginForm.getPassword();
//验证验证码
if (!((String)session.getAttribute("code")).equals(code)){
return Result.fail("您输入的验证码错误");
}
优化:其实在验证验证码是否吻合之前,我们还应该对手机号进行验证。也就是说我们在session中不仅要存放code验证码,还要存放phone手机号码,否则用户可能在拿到验证码之后修改手机号,登录到别人的账号中。
接下来我们根据手机号去查找用户。这里有三种方法:
方法一:
//一致,根据手机号查询用户
User user = query().eq("phone", phone).one();
注意这里的query()并不是凭空而来,它是IService中的一个default方法:
我们当前的UserServiceImpl类实现了IUserService,而IUserService继承了IService所以是可以直接使用的。
方法二:
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("phone",phone);
User user = userMapper.selectOne(queryWrapper);
方法三:
QueryWrapper的基础上使用lambda:
LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(User::getPhone,phone);
User user = userMapper.selectOne(lambdaQueryWrapper);
整体代码如下:
@Autowired
private UserMapper userMapper;
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//用户手机号
String phone = loginForm.getPhone();
//用户验证码
String code = loginForm.getCode();
//用户密码
String password = loginForm.getPassword();
//验证验证码
if (!((String)session.getAttribute("code")).equals(code)){
return Result.fail("您输入的验证码错误");
}
//然后我们再去数据库中查询是否存在这个用户
// 方法一
// User user = query().eq("phone", phone).one();
// 方法二
// QueryWrapper<User> queryWrapper = new QueryWrapper<>();
// queryWrapper.eq("phone",phone);
// User user = userMapper.selectOne(queryWrapper);
// 方法三
LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(User::getPhone,phone);
User user = userMapper.selectOne(lambdaQueryWrapper);
// 判断用户是否存在
if (user == null){
log.debug("此用户不存在,正在创建新用户");
//创建新用户
user = createUserByPhone(phone);
}
//如果存在则直接将用户保存到session
session.setAttribute("user",user);
return Result.ok();
}
private User createUserByPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
userMapper.insert(user);
return user;
}
注意我们这里的随机用户名前缀,使用的是SystemConstants中的常量:
此时我们登陆进去之后会发现立马退出来了,这是因为我们还没有做登陆校验功能
实现登录校验拦截器
登陆验证事实上就是以下的一个请求:
他回来查询当前登录的用户信息,如果你能给它返回用户校验就算是成功了,其对应的流程我们之前也分析过:
用户的请求会带上cookie,登陆的凭证其实就是sessionID,就在cookie当中。带着这个信息到了服务端之后,服务端会基于这个ID得到session,再从session中取出用户判断一下这个用户是否存在,如果有则代表登陆成功,服务端再把用户返回给前端。
不过实际上这么做是有问题的。我们之前所说的/user/me
是Usercontroller里面的,前端会向我们的UserController发送请求,UserController中编写相关的校验逻辑。但是如果随着后续业务的开发,越来越多的业务中可能都要校验用户,那么难道我们在每一个Controller中都去写校验逻辑吗?
其实我们有更好的实现方法,在我们的SpringMVC中,拦截器
可以帮我们在所有的Controller执行之前实现一些需求:
但是这里还是有一点小问题,拦截器确实可以帮我们完成对登陆用户的校验,但是我们后续的业务之中是需要业务信息的,我们在拦截器中进行校验,拦截器是拿到用户数据了,但是我们的Controller却没有拿到。我们需要一种方案可以把拦截其中拦截到的用户数据传递到Controller里面去。而且在传递的过程中需要注意线程的安全问题。
我们将前面的方案进行改进:
我们在拦截器里拦截到了用户信息之后,可以把它保存在ThreadLocal中,因为ThreadLocal是一个线程域对象,每一个进入tomcat的请求都是一个独立的线程。ThreadLocal就会在线程内开辟一块内存空间去保存对应的用户,这样的话每个线程相互不干扰
我们了解一下tomcat的运行原理:
当用户发起请求时,会访问我们给tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应.
通过以上讲解,我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据
我们将这个拦截器写在Utils工具包中,同时SpringMVC的拦截器我们需要实现一个接口HandlerInterceptor中的方法:
- preHandle:前置拦截
- postHandle:在Controller执行之后
- afterCompletion:是在渲染之后,返回给用户之前
什么是在渲染之后?可以了解一下SpringMVC的执行原理加深理解
这里我们实现第一、三两个方法就可以了。在preHandle中我们进行登录校验(也就是我们流程图中的内容),在afterCompletion中我们销毁对应的用户信息,避免内存的泄露
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从session中获取用户
UserDTO user = (UserDTO) request.getSession().getAttribute("user");
//如果用户不存在就拦截
if (user == null){
response.setStatus(401);
//return false就代表拦截 return true代表放行
return false;
}
//如果用户存在则存储+放行
UserHolder.saveUser(user);
response.setStatus(200);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
这里的UserHolder是我们对ThreadLocal的封装;
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
这样我们每次使用的时候就不用单独去创建ThreadLocal了。注意我们在ThreadLocal中存储东西的时候是不需要记录键的,直接存储值就可以了,具体原因可以参考ThreadLocal的原理。
定义好了拦截器之后,我们还要使用拦截器,我们创建一个SpringMVC的配置类MvcConfig,这个配置类要实现WebMvcConfigurer接口,然后使用@Configuration注解就可以生效了。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration interceptorRegistration = registry.addInterceptor(new LoginInterceptor());
interceptorRegistration.excludePathPatterns(
"/user/code",
"/user/login",
"blog/hot",
"/upload/**",
"/shop-type/**",
"/voucher/**",
"/shop/**"
);
}
}
我们在这个SpringMVC配置类中实现了addInterceptors方法,也就是添加拦截器的方法。我们在这个方法中注册我们的拦截器实例,然后使用excludePathPatterns配置了哪些请求路径不进行拦截。
做完这些之后我们还差一步:/user/me接口的完善。在这个接口中我们获取用户信息然后返回给前端。
@GetMapping("/me")
public Result me(){
// TODO 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
隐藏用户敏感信息
可以发现我们通过浏览器可以观察到用户的全部信息:
这是因为我们当时在处理/user/me接口的时候直接返回了用户的实例;
这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了
在登录方法处修改
//7.保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));
在拦截器处:
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);
在UserHolder处:将user对象换成UserDTO
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
上面是完整的处理过程
这个问题在前面我们其实已经进行了处理,但是有一个地方需要完善一下
我们只需要改进在登陆的时候将UserDTO存到Session中去就行:
本来我们应该是new 一个UserDTO然后手动往里面添加属性,不过这里我们可以使用一个工具类帮我们完成这一个任务;
//7.保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));
此方法的第一个参数是数据源,然后再把目标的数据类型告诉他(可以给一个实例对象,或者class字节码),就可以帮你完成拷贝。
注意这里是BeanUtil而不是BeanUtils,否则会发现copyProperties返回的是void
改进后我们再在浏览器中查看返回的数据:
集群的Session共享问题
每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了
但是这种方案具有两个大问题
1、每台服务器中都有完整的一份session数据,服务器压力过大。
2、session拷贝数据时,可能会出现延迟
所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了
基于Redis实现共享Session登录
先来看看我们前面做的发送短信验证码功能,我们本来是将验证码保存到session中,现在我们保存到redis中即可:
但是这里有几个注意点:我们利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
确定了value的结构,接下来我们key用什么结构呢?前面我们在session中直接使用的code
作为key,因为session有一个特点:每一个不同的浏览器都有一个独立的session,也就是说在我们的tomcat内部维护了很多很多的session,不同的浏览器携带手机号来的时候都有自己独立的session,他们都用code
作为key但是互相之间不干扰。而我们的redis则不同,他是一个共享的内存空间,不管是谁发请求过来在我们的服务端只有一个redis,如果我们的key都为code
,那么验证码就会相互覆盖造成数据的丢失。
一句话说,我们使用redis的时候key要有以下两个要求:
- 唯一性
- 方便携带
如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了
整体流程如下图:
这里有个小注意点:
我们把token返回给了前端,前端以后每次请求都要携带token,怎么做到这一点的呢?
(这里和session的区别要清楚,我们的session是把服务端的jsessionid给响应头返回给客户端,然后客户端再发送请求的时候会把这个jsessionid放在请求头的cookie中。而token与他们不一样,我们token的传递是借助于我们自定义的请求头authorization
)
我们来看看前端代码:
localStorage 和 sessionStorage 属性允许在浏览器中存储 key/value 对的数据。
sessionStorage 用于临时保存同一窗口(或标签页)的数据,在关闭窗口或标签页之后将会删除这些数据。
提示: 如果你想在浏览器窗口关闭后还保留数据,可以使用 localStorage 属性, 该数据对象没有过期时间,今天、下周、明年都能用,除非你手动去删除。
很明显这里就是前端将后端返回的token存储到了本地浏览器中,接下来:
这里使用了axios的拦截器,每当发送请求的时候都会增加一个请求头authorization
,里面存放着我们的token。
确认了redis的实现流程之后,我们来修改我们的代码:
发送短信验证码部分
我们改进的地方有两个:
- 将验证码保存到redis中
- 将保存的key-value设置过期时间
在UserServiceImpl的sendCode方法中,我们注入stringRedisTemplate:
//接下来我们将验证码保存到Session中
// httpSession.setAttribute("code",randomString);
// 改进后将验证码保存到redis中
stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone,randomString,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
我们在这里不能直接把手机号当作key,因为有可能其他业务也是这么做的,所以我们在这里加一个前缀。在Redis中使用的常量我们单独放在一个类RedisConstants中:
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
}
我们在这里设置了key的前缀常量以及键值对过期时间常量。
给我们的数据添加过期时间是因为如果不加的话,数据堆积终有一天redis会被占满,同时也避免了用户申请了验证码之后可以一直使用的情况。
短信登录与注册部分
修改内容概述:
- 在校验验证码的时候从redis中获取验证码
- 查询完用户之后,将用户信息保存到redis中
- 存储的时候要用hash结构去存储
- key是随机的一个token
- 设置有效期(否则也会有过度占用内存的情况出现)
- 这里的有效期我们参考session的30分钟(在不做任何操作的情况下session在30分钟之后会被踢出)
- 这里存储的时候token最好也要加一个前缀
- 将token返回给前端
在UserServiceImpl的login方法中:
//从redis中获取验证码进行校验
String s = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
//验证验证码
if (s == null || !s.equals(code)){
return Result.fail("您输入的验证码错误");
}
随机的token这里建议使用uuid来实现
UUID类java.util以及我们导入的hutool里面都有,这里我们使用hutool里面的:
//使用UUID生成token
String token = UUID.randomUUID().toString(true);
这里的toString里面使用true,代表不会生成带有中划线的字符串
在我们用hash结构存储用户信息的时候发现:
这里有三种put方法,如果我们选择第一个依次进行填充的话会与服务器进行多次交互,这样不太好影响效率.所以这里我们使用putAll方法,然后我们再借助BeanUtil这个工具类(也是hutool包下的)将我们的UserDTO转化为Map类型,完整代码如下:
//改进:将用户数据保存到redis中
//使用UUID生成token
String token = UUID.randomUUID().toString(true);
//使用hash结构存储用户信息userdto
String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
Map<String, Object> userMap = BeanUtil.beanToMap(dto);
stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
return Result.ok(token);
改到这里其实还有漏洞,我们的session有效期30分钟是指用户无操作30分钟之后 ,session才会退出.而我们设置的却是只要过了30分钟我们就把用户信息删除。所以我们要实现的是只要用户在不断的访问,我们就要不断更新redis中token的有效期。但是我们怎么知道用户有没有访问呢?这里我们就可以借助前面我们写过的校验登录状态来进行确认。我们可以这么理解:所有的请求进来以后都要经过拦截器的拦截和校验,只要经过了这个校验就可以证明,这个用户登陆着且是活跃状态的。既然满足了这两个条件我们就可以更新一下我们redis的有效期。也就是说只要是登录的用户在不断的访问,我们就可以不断地去更新有效期。只有当用户什么操作都不干,他就不会触发拦截器,redis的有效期不会更新,则30分钟之后被移除。
所以我们在修改校验登陆状态的业务逻辑的时候,在原有逻辑的基础上我们还要添加更新token有效期的业务逻辑。
校验登陆状态部分
修改内容概述:
- 获取请求头中的token
- 根据token获取redis中的用户信息
- 将查询到的Hash数据转化为UserDTO对象(我们ThreadLocal规定的泛型是UserDTO,我们只有转化了之后才能存进ThreadLocal中)
- 刷新token的有效期
我们在拦截器中要大量用到redis所以我们要注入StringRedisTemplate。但是这个地方的注入我们不能使用@Resource、@Autowired等注解进行注入,我们只能使用构造函数来去注入(也就是说谁用谁就把依赖注入进来)。因为拦截器类的对象是我们自己手动new出来的,不是我们加注解构建的。也就是说这个类的对象不是由Spring创建的,而是由我们自己创建的,而我们自己创建的对象是不能使用Spring中的自动装配的。
拦截器类上直接加@Component然后再使用相关注解进行注入是行不通的,因为拦截器在bean初始化之前就执行了,所以就算加了@Component注解把拦截器放入IOC容器中进行管理也是拿不到容器里面的内容的。
我们在MVC的配置类中使用到了这个拦截器,那么我们就在那里进行注入:
然后我们就可以去完成我们的需求了。
首先我们在请求头中拿到token,如果没有则进行拦截;
String token = request.getHeader("authorization");
// 如果token为空则进行拦截
if (StrUtil.isBlank(token)){
response.setStatus(401);
//return false就代表拦截 return true代表放行
return false;
}
然后我们再基于token去取redis里面的用户信息,注意这里我们不能使用opsForHash()里的get方法,因为我们要的是一个完整的map,而get只能通过key拿到对应的值:
所以这里我们使用的是entries方法,它返回的就是一个map;
我们将返回值进行一个判空,如果为空则进行拦截:
这里不需要进行null的判定,因为entries这个方法如果得到的是个null,则会返回一个空map
//然后我们再基于token去取redis里面的用户信息
Map<Object, Object> map = redisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
//如果map为空就拦截
if (map.isEmpty()){
response.setStatus(401);
//return false就代表拦截 return true代表放行
return false;
}
接着我们将拿到的map数据转化为UserDTO类型,这里我们还是使用BeanUtil工具类中的fillBeanWithMap(mapToBean已经弃用):
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//如果用户存在则存储+放行
UserHolder.saveUser(userDTO);
最后一步就是刷新token有效期;
//刷新token有效期
redisTemplate.expire(RedisConstants.LOGIN_CODE_KEY+token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
接下来我们运行看看效果,会发现登陆异常了,原因是类型转化发生异常:
定位到我们的Service层代码中,报错位置如下:
原因是因为我们的userMap里面id是Long类型的。那么为什么Long不能转String呢?因为我们使用的是StringRedisTemplate,它的特点就是他要求我们不管是key还是value都是String类型的:
这里我们有两种处理办法:
- 方法一:不使用BeanUtil.beanToMap,自己手动new一个map,然后一个个往里面填充,不是String的转成string
- 方法二:还是使用beanToMap这个工具,这个工具是可以自定义的,默认你的值是什么数据类型就用什么数据类型,但是我们可以借助copyOption做一些改变:
这里我们采用方法二;
Map<String, Object> userMap = BeanUtil.beanToMap(dto,new HashMap<>(), CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
总结:
Redis代替session需要考虑的问题:
- 选择合适的数据结构
- 选择合适的key
- 设置好存储时间
- 选择合适的存储粒度
登录拦截器的优化
我们原先登录拦截器的逻辑如下:
那么这样能不能真正的达成用户一直在访问token就不会过期呢?
不行,因为这个拦截器拦截的路径不是一切路径,它拦的是那些需要做登录校验的路径。也就是说如果用户访问了那些没有被拦截的路径,他们的token是不会被刷新的。
那么我们怎么处理这种情况呢?我们可以在原有的拦截器之前新加一个拦截器,让这个拦截器拦截一切路径,用来做刷新token有效期的动作
改进之后如下:
我们新建一个RefreshTokenInterceptor:
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate redisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("authorization");
//token为空直接放行,如果不为空则进行token的更新
if (StrUtil.isBlank(token)){
return true;
}
//然后我们再基于token去取redis里面的用户信息
Map<Object, Object> map = redisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
//如果map为空直接放行
if (map.isEmpty()){
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
//如果用户存在则存储在ThreadLocal中+放行
UserHolder.saveUser(userDTO);
//刷新token有效期
redisTemplate.expire(RedisConstants.LOGIN_CODE_KEY+token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
response.setStatus(200);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
然后将原来的LoginInterceptor的冗余代码进行删除:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//直接判断是否需要拦截,判断依据就是ThreadLocal里面是否有我们的用户信息
if(UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
return true;
}
定义好拦截器之后,我们接下来在MvcConfig中配置拦截器:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration interceptorRegistration = registry.addInterceptor(new LoginInterceptor());
interceptorRegistration.excludePathPatterns(
"/user/code",
"/user/login",
"blog/hot",
"/upload/**",
"/shop-type/**",
"/voucher/**",
"/shop/**"
);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**");
}
}
完成这个之后我们还要思考一个问题,如何控制拦截器的执行顺序呢?
在我们的添加拦截器的时候不设置order,那么默认都是0:
我们的拦截器顺序就按照添加的顺序来。
当然我们也可以通过设置order来控制拦截器的执行顺序:
- order越大,优先级越低
- order越小,优先级越高
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//登录拦截器
InterceptorRegistration interceptorRegistration = registry.addInterceptor(new LoginInterceptor());
interceptorRegistration.excludePathPatterns(
"/user/code",
"/user/login",
"blog/hot",
"/upload/**",
"/shop-type/**",
"/voucher/**",
"/shop/**"
).order(1);
//token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}