《Redis-day02-短信登陆》
0. 今日总结
了解了项目大致结构和待实现的功能
复习了会话及会话跟踪技术,主要复习Cookie技术和Session技术
实现了发送短信验证码业务功能
实现了短信验证码登录、注册功能,了解了mybatis-plus的基础用法
深入理解了ThreadLocal的原理以及ThreadLocalMap中Entry弱引用而key强引用可能导致的内存泄露问题以及该问题的解决方案。
基于ThreadLocal, Intercepter实现了登录状态校验
使用redis实现了共享session登录
- 实现了发送短信验证码,并设置了有效时间为两分钟
- 实现了短信验证码登录,理解了RedisTemplate和StringRedisTemplate的区别
- 深入理解了spirng的自动依赖注入,并实现了登陆状态的校验
对登录拦截器进行了优化,添加了一个新的拦截器专门负责对所有请求进行token刷新操作,原本的拦截器则专门进行登录状态校验
1. 导入黑马点评项目
- 后端部署在tomcat服务器上,前端部署在NGINX服务器上
1.1 导入数据库
- 涉及的表
1.2 导入后端项目
修改数据库和Redis的配置
修改mybatis-plus配置
1.3 导入前端
直接粘贴打包好的nginx服务器即可
2. 基于Session实现登录
2.1 会话及会话跟踪技术复习
2.1.1 会话
用户打开浏览器,访问web服务器的资源,会话建立,直到有一方断开连接,会话结束。在一次会话中可以包含多次请求和响应。
一个浏览器与服务器的连接就是一个会话,下图包含三给会话
2.1.2 会话跟踪
一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据。
2.1.2.1 Cookie
存储在客户端
缺点:
- 移动端APP无法使用Cookie
- 不安全,用户可以自己禁用Cookie
- 不能跨域
- 请求头Cookie,用于携带Cookie数据
- 响应头Set-Cookie,用于设置Cookie数据
- 通过HttpServletResponse获得响应对象response
- 通过response.addCookie设置Cookie(Set-Cookie)
- 通过HttpServletRequest获得响应对象request
- 通过request的getCookies获取所有cookies(Cookie)
2.1.2.2 Session
存储在服务器端
缺点:
服务器集群环境下无法直接使用Session
移动端APP(Android、IOS)中无法使用Cookie
用户可以自己禁用Cookie
Cookie不能跨域
@Slf4j@RestControllerpublicclassSessionController{@GetMapping("/s1")publicResultsession1(HttpSessionsession){log.info("HttpSession-s1: {}",session.hashCode());session.setAttribute("loginUser","tom");//往session中存储数据returnResult.success();}@GetMapping("/s2")publicResultsession2(HttpServletRequestrequest){HttpSessionsession=request.getSession();log.info("HttpSession-s2: {}",session.hashCode());ObjectloginUser=session.getAttribute("loginUser");//从session中获取数据log.info("loginUser: {}",loginUser);returnResult.success(loginUser);}}流程
客户端请求 GET /s1 ↓ 服务器: 接收到请求,没有JSESSIONID ↓ 服务器: 创建新的Session对象,自动生成唯一Session ID ↓ 服务器: 在Session对象中设置属性: loginUser → "tom" ↓ 服务器响应: Set-Cookie: JSESSIONID=A329DBD06E63DF28EBD2029916575565 ↓ 浏览器: 保存JSESSIONID到Cookie ------------------------------------------------ 客户端请求 GET /s2 ↓ 浏览器: 自动附加Cookie: JSESSIONID=A329DBD06E63DF28EBD2029916575565 ↓ 服务器: 收到JSESSIONID,去Session存储表中查找 ↓ 服务器: 找到对应的Session对象 ↓ 服务器: 调用session.getAttribute("loginUser")返回"tom"2.1.2.3 jwt令牌技术(以及拦截器和过滤器)
实现步骤
在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,我们需要生成一个jwt令牌,将生成的 jwt令牌返回给前端。
前端拿到jwt令牌之后,会将jwt令牌存储起来(JWT令牌存储在浏览器的本地存储空间local storage中)。在后续的每一次请求中都会将jwt令牌携带到服务端。
服务端统一拦截请求之后,先来判断一下这次请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要校验一下令牌是否是有效。如果有效,就直接放行进行请求的处理。
Filter过滤器
过滤器当中我们拦截到了请求之后,如果希望继续访问后面的web资源,就要执行放行操作,放行就是调用 FilterChain对象当中的doFilter()方法,在调用doFilter()这个方法之前所编写的代码属于放行之前的逻辑。
Interceptor拦截器
通过实现
HandlerInterceptor接口或继承HandlerInterceptorAdapter类,主要重写三个方法:preHandle:在Controller方法之前执行。返回true则放行,返回false则中断流程postHandle:在Controller方法执行之后,视图渲染之前执行。afterCompletion:在整个请求完成,视图渲染完毕之后执行,常用于资源清理
2.2 发送短信验证码
controller
- 获取前端发来的phone和session
- 将phone和session传入service层
service
- 校验手机号,通过RegexUtils工具类的方法
- 生成验证码,通过RandomUtil的方法
- 调用session的setAttribute将该sessionId的数据添加一条"code",值为刚刚生成的验证码
- 模拟发送验证码功能
- 返回发送从成功
2.3 短信验证码登录、注册
controller
- 接收DTO数据
- 接收session对象
service
- 通过RegexUtils工具类检验手机号是否正确
- 获取session中的"code",并将其与前端传给后端的code进行对比,如果不一致则报错
- 如果一直,则根据手机号通过mybatis-plus的query()方法查询用户
- 如果用户不存在,则创建用户,给用户的手机号、创建时间、昵称赋值,并通过mybatis-plus的save方法将用户保存到数据库中
- 如果用户存在,则将该用户信息保存到session会话中
2.4 校验登陆状态
2.4.1 ThreadLocal详解
2.4.1.1 ThreadLocal原理
提供线程之间的局部变量,不同线程的变量不会相互干扰
下面是其基本原理:
- Thread 类中有一个成员变量 ThreadLocalMap,它是一个 Map 结构
- ThreadLocalMap 的 key 是 ThreadLocal 对象的弱引用,value 是具体的值
- 当调用 ThreadLocal 的 set(T value) 方法时,会先获取当前线程,然后将值存储在当前线程的 ThreadLocalMap 中
- 当调用 ThreadLocal 的 get() 方法时,会从当前线程的 ThreadLocalMap 中获取值
publicvoidset(Tvalue){// 获取当前线程Threadt=Thread.currentThread();// 获取当前线程的 ThreadLocalMapThreadLocalMapmap=getMap(t);// 如果 map 存在,则直接设置值if(map!=null)map.set(this,value);else// 否则创建 map 并设置值createMap(t,value);}publicTget(){// 获取当前线程Threadt=Thread.currentThread();// 获取当前线程的 ThreadLocalMapThreadLocalMapmap=getMap(t);// 如果 map 存在if(map!=null){// 获取与当前 ThreadLocal 对象关联的 EntryThreadLocalMap.Entrye=map.getEntry(this);if(e!=null){@SuppressWarnings("unchecked")// 返回值Tresult=(T)e.value;returnresult;}}// 如果 map 不存在或 entry 不存在,则返回初始值returnsetInitialValue();}publicvoidremove(){// 获取当前线程的 ThreadLocalMapThreadLocalMapm=getMap(Thread.currentThread());// 如果 map 存在,则从中删除当前 ThreadLocal 对应的 entryif(m!=null)m.remove(this);}2.4.1.2 内存泄漏问题和解决方法
原因
ThreadLocal 使用不当可能导致内存泄漏,主要原因有两点:
ThreadLocalMap 的 Entry 是弱引用:ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,这意味着当没有强引用指向 ThreadLocal 变量时,它会被垃圾回收。但是,对应的 value 是强引用,如果没有手动删除,就无法被回收。
- 假设你在方法中创建了一个
ThreadLocal局部变量并使用set()存入一个大对象。 - 方法结束后,
ThreadLocal实例的强引用消失。 - 在下次垃圾回收(GC)时,由于 key 是弱引用,它会被回收,于是这个
Entry的 key 变为null。 - 然而,value 因为仍是强引用,所以不会被 GC 回收。
- 只要这个线程(例如 Web 服务器中的工作线程)本身不死(比如被线程池回收复用),这个线程强引用 →
ThreadLocalMap→Entry→ value 的强引用链就会一直存在,导致这个 value 对应的对象永远无法被回收,造成内存泄漏 。如果这种情况频繁发生,就可能耗尽内存。
一般的应用都是强引用,弱引用是用WeakReference类创建的对象
// 强引用ObjectstrongObj=newObject();// 弱引用WeakReference<Object>weakObj=newWeakReference<>(newObject());- 假设你在方法中创建了一个
线程池中的线程生命周期很长:在使用线程池的场景下,线程的生命周期可能很长,甚至与应用程序的生命周期一样长。如果不清理 ThreadLocal 变量,那么这些变量会随着线程一直存在于内存中。
解决方案
正因为上述原因,在使用完
ThreadLocal后,必须手动调用remove()方法。这个方法会直接清除当前线程的ThreadLocalMap中对应 key 的整个Entry,从而彻底打破引用链,让 value 能够被 GC 回收 。
2.4.2 登录状态校验实现
拦截器实现
- 实现HandlerInterceptor类,并重写preHandle和afterCompletion方法
- preHandle方法内,通过request.getSession获得请求体的session,然后调用getAttribute获得服务器中的session保存的"user"的值
- 如果"user"为空,则表示不存在该用户,return false;进行拦截
- 如果user不为空,则表示该session中确实存在"user",则意味着已经完成了登录,则将当前用户信息保存到ThreadLocal中,并放行拦截
- 当前线程结束后移除ThreadLocal中的内容,以避免内存泄露
配置类实现自动拦截
- 拦截器会在springboot项目启动时自动拦截Controller类中的请求,除了上述排除的请求
ThreadLocal实现
- saveUser方法调用了ThreadLocal的set方法,将传入的User对象保存在当前线程中
- getUser方法调用了ThreadLocal的get方法,用于获取当前线程中的User对象
- removeUser方法调用了ThreadLocal的remove方法,用于清空当前线程
2.4.3 优化
将登录过程存到Session中"user"的对象由User改为UserDTO以减小存储压力,并同时修改了一系列由于该改动引发的问题
3. 集群的session共享问题
3.1 共享问题
**session共享问题:**多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。
session的替代方案应该满足:
- 数据共享
- 内存存储
- key、value结构
3.2 替代方案
使用Redis替代session
3.2.1 Redis数据结构选择
验证码
可以用String来存储,key表示电话号码,value表示验证码
用户信息
可以用Hash来存储,key表示随机token,value表示用户对象各个字段及字段对应值,这样可以更加方便的获取或修改具体字段
实现流程
- 发送短信验证码后,将发送验证码的手机号作为key并将验证码作为值存储在redis中
- 通过短信验证码登录、注册进行校验验证码时,根据提交的手机号和验证码去redis中进行比对
- 如果用户存在则将用户保存到redis,如果用户不存在,则创建新用户,将用户保存到数据库,接着保存到redis,key为随机token,value为用户对象的各个字段和数据,接着将token返回给客户端(如果在此之前已经存在token,则当前token会覆盖之前的token)
- 在校验登录状态时,从客户端获取token,直接从redis获取用户信息
4. 基于Redis实现共享session登录
4.1 发送短信验证码
- 校验手机号
- 生成验证码
- 将验证码保存到Redis,保存类型为String类型,key=“login:code:phone”,value=code值,并设置有效时间为两分钟
4.2 短信验证码登录、注册
@OverridepublicResultlogin(LoginFormDTOloginForm,HttpSessionsession){//1. 校验手机号Stringphone=loginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)){//2. 如果不符合,返回错误信息returnResult.fail("手机号格式错误!");}//3. 从redis获取验证码并校验StringcacheCode=stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY+phone);Stringcode=loginForm.getCode();if(cacheCode==null||!cacheCode.toString().equals(code)){//3. 不一致,直接报错returnResult.fail("验证码错误");}//4. 根据手机号查询用户Useruser=query().eq("phone",phone).one();//5. 判断用户是否存在if(user==null){//6. 不存在,创建新用户user=createUserWithPhone(phone);}//7. 存在,保存用户信息到redis中//7.1 随机生成tokenStringtoken=UUID.randomUUID().toString(true);//7.2 将User对象转为HashMap存储UserDTOuserDTO=BeanUtil.copyProperties(user,UserDTO.class);Map<String,Object>userMap=BeanUtil.beanToMap(userDTO,newHashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));//7.3 存储数据到redisStringtokenKey=RedisConstants.LOGIN_USER_KEY+token;stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);//7.4设置token有效期stringRedisTemplate.expire(tokenKey,30,TimeUnit.MINUTES);//8. 返回tokenreturnResult.ok(token);}校验手机号
从redis中获取验证码,调用stringRedisTemplate的opsForValue方法操作字符串类型的redis数据,再调用get方法获取key = "login:code:phone"的value值,保存为cacheCode
如果cacheCode=null说明redis中该手机号不存在对应的验证码,如果cacheCode!=code说明Redis中的验证码和用户端发送给服务端的验证码不一致,以上两种情况都报错
如果验证码成功,则根据手机号查询用户,并判断用户是否存在,不存在则创建新用户
存在或创建完新用户之后,随机生成token
调用Beanutil的copyProperties将user转换为userDTO对象
为了能够一次性将userDTO中的所有字段一次性保存到Redis的Hash结构中,要将userDTO转化为Map,但是由于stringRedisTemplate所有键和值都必须是字符串或可转为字符串的形式,而userDTO中的id是Long类型,会出现类型转换异常,因此在将userDTO转化为Map时要将所有字段都转化为String类型
调用BeanUtil的beanToMap方法,将userDTO转化为hashMap结构
CopyOptions.create()
精细修改map中的每个字段
setIgnoreNullValue
忽略空值。如果
userDTO的某个属性值为null,它将不会放入Map中.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())
将value转化为String,即将map中每个字段都转化为String
stringRedisTemplate和RedisTemplate的区别
- stringRedisTemplate支持手动序列化,RedisTemplate只能自动序列化
- stringRedisTemplate所有键和值都必须是字符串或可转为字符串的形式,RedisTemplate则不需要
或改为以下形式,不用工具类,而是挨个字段put,并将id转化为String字符串
一次性将map存储到redis
将token返回给前端
4.3 校验登陆状态
创建stringRedisTemplate对象,并通过构造器初始化(因为该类不是spring容器,在创建该类的对象时是直接new出来的,因此不能通过@autowierd自动注入)
你的
LoginInterceptor不能注入StringRedisTemplate:问题出在MvcConfig的addInterceptors方法中,你是通过new LoginInterceptor(stringRedisTemplate)来创建拦截器实例的。这个new关键字创建的是一个全新的、普通的Java对象,不是Spring容器管理的Bean。因此,即使在
LoginInterceptor类内部使用了@Autowired,Spring也不会为这个手动创建的对象执行依赖注入流程 。其内部的StringRedisTemplate字段自然是null。获取请求头中的token
基于token获取redis中的用户,用entries方法,获得map集合
判断用户是否存在
如果存在,则转化为userDTO对象,并将用户保存在ThreadLocal中
刷新token有效期
4.4 登录拦截器的优化
问题
当前拦截器只会拦截部分业务,如果用户登录完成后始终停留在没有被拦截的界面,则不会启动token有效期自动刷新
解决方案
再加一层拦截器,拦截所有的请求,但是只在该拦截器进行token刷新业务,不进行实际拦截
packagecom.hmdp.interceptor;importcn.hutool.core.bean.BeanUtil;importcn.hutool.core.util.StrUtil;importcom.hmdp.dto.UserDTO;importcom.hmdp.entity.User;importcom.hmdp.utils.RedisConstants;importcom.hmdp.utils.UserHolder;importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.web.servlet.HandlerInterceptor;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;importjavax.servlet.http.HttpSession;importjava.util.Map;importjava.util.concurrent.TimeUnit;publicclassRefreshTokenInterceptorimplementsHandlerInterceptor{privateStringRedisTemplatestringRedisTemplate;publicRefreshTokenInterceptor(StringRedisTemplatestringRedisTemplate){this.stringRedisTemplate=stringRedisTemplate;}@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{//1.获取请求头中的tokenStringtoken=request.getHeader("authorization");if(StrUtil.isBlank(token)){returntrue;}//2.基于Token获取redis中的用户Map<Object,Object>userMap=stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY+token);//3.判断用户是否存在if(userMap.isEmpty()){returntrue;}//5.将查询到了Hash数据转换为UserDTO对象UserDTOuserDTO=BeanUtil.fillBeanWithMap(userMap,newUserDTO(),false);//6.存在,保存用户信息 到ThreadLocalUserHolder.saveUser((UserDTO)userDTO);//7.刷新token有效期stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+token,30,TimeUnit.MINUTES);//8.放行returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{//线程执行结束后处理UserHolder.removeUser();}}上述拦截器仅负责token刷新业务
上述拦截器仅判断当前ThreadLocal是否有用户,如果没有则拦截