news 2026/4/4 19:49:36

【Redis-day02-黑马点评短信登录】

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
【Redis-day02-黑马点评短信登录】

《Redis-day02-短信登陆》

0. 今日总结

  1. 了解了项目大致结构和待实现的功能

  2. 复习了会话及会话跟踪技术,主要复习Cookie技术和Session技术

  3. 实现了发送短信验证码业务功能

  4. 实现了短信验证码登录、注册功能,了解了mybatis-plus的基础用法

  5. 深入理解了ThreadLocal的原理以及ThreadLocalMap中Entry弱引用而key强引用可能导致的内存泄露问题以及该问题的解决方案。

  6. 基于ThreadLocal, Intercepter实现了登录状态校验

  7. 使用redis实现了共享session登录

    1. 实现了发送短信验证码,并设置了有效时间为两分钟
    2. 实现了短信验证码登录,理解了RedisTemplate和StringRedisTemplate的区别
    3. 深入理解了spirng的自动依赖注入,并实现了登陆状态的校验
  8. 对登录拦截器进行了优化,添加了一个新的拦截器专门负责对所有请求进行token刷新操作,原本的拦截器则专门进行登录状态校验

1. 导入黑马点评项目

  • 后端部署在tomcat服务器上,前端部署在NGINX服务器上

1.1 导入数据库

  • 涉及的表

1.2 导入后端项目

  1. 修改数据库和Redis的配置

  2. 修改mybatis-plus配置

1.3 导入前端

直接粘贴打包好的nginx服务器即可

2. 基于Session实现登录

2.1 会话及会话跟踪技术复习

2.1.1 会话

用户打开浏览器,访问web服务器的资源,会话建立,直到有一方断开连接,会话结束。在一次会话中可以包含多次请求和响应。

一个浏览器与服务器的连接就是一个会话,下图包含三给会话

2.1.2 会话跟踪

一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据

2.1.2.1 Cookie

存储在客户端

缺点:

  1. 移动端APP无法使用Cookie
  2. 不安全,用户可以自己禁用Cookie
  3. 不能跨域

  1. 请求头Cookie,用于携带Cookie数据
  2. 响应头Set-Cookie,用于设置Cookie数据

  1. 通过HttpServletResponse获得响应对象response
  2. 通过response.addCookie设置Cookie(Set-Cookie
  3. 通过HttpServletRequest获得响应对象request
  4. 通过request的getCookies获取所有cookies(Cookie
2.1.2.2 Session

存储在服务器端

缺点:

  1. 服务器集群环境下无法直接使用Session

  2. 移动端APP(Android、IOS)中无法使用Cookie

  3. 用户可以自己禁用Cookie

  4. 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令牌技术(以及拦截器和过滤器)

实现步骤

  1. 在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,我们需要生成一个jwt令牌,将生成的 jwt令牌返回给前端。

  2. 前端拿到jwt令牌之后,会将jwt令牌存储起来(JWT令牌存储在浏览器的本地存储空间local storage中)。在后续的每一次请求中都会将jwt令牌携带到服务端。

  3. 服务端统一拦截请求之后,先来判断一下这次请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要校验一下令牌是否是有效。如果有效,就直接放行进行请求的处理。

    1. Filter过滤器

      过滤器当中我们拦截到了请求之后,如果希望继续访问后面的web资源,就要执行放行操作,放行就是调用 FilterChain对象当中的doFilter()方法,在调用doFilter()这个方法之前所编写的代码属于放行之前的逻辑。

    2. Interceptor拦截器

      通过实现HandlerInterceptor接口或继承HandlerInterceptorAdapter类,主要重写三个方法:

      1. preHandle:在Controller方法之前执行。返回true则放行,返回false则中断流程
      2. postHandle:在Controller方法执行之后,视图渲染之前执行。
      3. afterCompletion:在整个请求完成,视图渲染完毕之后执行,常用于资源清理

2.2 发送短信验证码

  • controller

    1. 获取前端发来的phone和session
    2. 将phone和session传入service层
  • service

    1. 校验手机号,通过RegexUtils工具类的方法
    2. 生成验证码,通过RandomUtil的方法
    3. 调用session的setAttribute将该sessionId的数据添加一条"code",值为刚刚生成的验证码
    4. 模拟发送验证码功能
    5. 返回发送从成功

2.3 短信验证码登录、注册

  • controller

    1. 接收DTO数据
    2. 接收session对象
  • service

    1. 通过RegexUtils工具类检验手机号是否正确
    2. 获取session中的"code",并将其与前端传给后端的code进行对比,如果不一致则报错
    3. 如果一直,则根据手机号通过mybatis-plus的query()方法查询用户
    4. 如果用户不存在,则创建用户,给用户的手机号、创建时间、昵称赋值,并通过mybatis-plus的save方法将用户保存到数据库中
    5. 如果用户存在,则将该用户信息保存到session会话中

2.4 校验登陆状态

2.4.1 ThreadLocal详解

2.4.1.1 ThreadLocal原理

提供线程之间的局部变量,不同线程的变量不会相互干扰

下面是其基本原理:

  1. Thread 类中有一个成员变量 ThreadLocalMap,它是一个 Map 结构
  2. ThreadLocalMap 的 key 是 ThreadLocal 对象的弱引用,value 是具体的值
  3. 当调用 ThreadLocal 的 set(T value) 方法时,会先获取当前线程,然后将值存储在当前线程的 ThreadLocalMap 中
  4. 当调用 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 使用不当可能导致内存泄漏,主要原因有两点:

    1. ThreadLocalMap 的 Entry 是弱引用:ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,这意味着当没有强引用指向 ThreadLocal 变量时,它会被垃圾回收。但是,对应的 value 是强引用,如果没有手动删除,就无法被回收。

      1. 假设你在方法中创建了一个ThreadLocal局部变量并使用set()存入一个大对象。
      2. 方法结束后,ThreadLocal实例的强引用消失。
      3. 在下次垃圾回收(GC)时,由于 key 是弱引用,它会被回收,于是这个Entry的 key 变为null
      4. 然而,value 因为仍是强引用,所以不会被 GC 回收。
      5. 只要这个线程(例如 Web 服务器中的工作线程)本身不死(比如被线程池回收复用),这个线程强引用 →ThreadLocalMapEntry→ value 的强引用链就会一直存在,导致这个 value 对应的对象永远无法被回收,造成内存泄漏 。如果这种情况频繁发生,就可能耗尽内存。

      一般的应用都是强引用弱引用是用WeakReference类创建的对象

      // 强引用ObjectstrongObj=newObject();// 弱引用WeakReference<Object>weakObj=newWeakReference<>(newObject());
    2. 线程池中的线程生命周期很长:在使用线程池的场景下,线程的生命周期可能很长,甚至与应用程序的生命周期一样长。如果不清理 ThreadLocal 变量,那么这些变量会随着线程一直存在于内存中。

  • 解决方案

    正因为上述原因,在使用完ThreadLocal后,必须手动调用remove()方法。这个方法会直接清除当前线程的ThreadLocalMap中对应 key 的整个Entry,从而彻底打破引用链,让 value 能够被 GC 回收 。

2.4.2 登录状态校验实现

  • 拦截器实现

    1. 实现HandlerInterceptor类,并重写preHandle和afterCompletion方法
    2. preHandle方法内,通过request.getSession获得请求体的session,然后调用getAttribute获得服务器中的session保存的"user"的值
    3. 如果"user"为空,则表示不存在该用户,return false;进行拦截
    4. 如果user不为空,则表示该session中确实存在"user",则意味着已经完成了登录,则将当前用户信息保存到ThreadLocal中,并放行拦截
    5. 当前线程结束后移除ThreadLocal中的内容,以避免内存泄露
  • 配置类实现自动拦截

    1. 拦截器会在springboot项目启动时自动拦截Controller类中的请求,除了上述排除的请求
  • ThreadLocal实现

    1. saveUser方法调用了ThreadLocal的set方法,将传入的User对象保存在当前线程中
    2. getUser方法调用了ThreadLocal的get方法,用于获取当前线程中的User对象
    3. removeUser方法调用了ThreadLocal的remove方法,用于清空当前线程

2.4.3 优化

将登录过程存到Session中"user"的对象由User改为UserDTO以减小存储压力,并同时修改了一系列由于该改动引发的问题

3. 集群的session共享问题

3.1 共享问题

**session共享问题:**多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。

session的替代方案应该满足:

  1. 数据共享
  2. 内存存储
  3. key、value结构

3.2 替代方案

使用Redis替代session

3.2.1 Redis数据结构选择

  1. 验证码

    可以用String来存储,key表示电话号码,value表示验证码

  2. 用户信息

    可以用Hash来存储,key表示随机token,value表示用户对象各个字段及字段对应值,这样可以更加方便的获取或修改具体字段

  1. 实现流程

    1. 发送短信验证码后,将发送验证码的手机号作为key并将验证码作为值存储在redis中
    2. 通过短信验证码登录、注册进行校验验证码时,根据提交的手机号和验证码去redis中进行比对
    3. 如果用户存在则将用户保存到redis,如果用户不存在,则创建新用户,将用户保存到数据库,接着保存到redis,key为随机token,value为用户对象的各个字段和数据,接着将token返回给客户端(如果在此之前已经存在token,则当前token会覆盖之前的token)
    4. 在校验登录状态时,从客户端获取token,直接从redis获取用户信息

4. 基于Redis实现共享session登录

4.1 发送短信验证码

  1. 校验手机号
  2. 生成验证码
  3. 将验证码保存到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);}
  1. 校验手机号

  2. 从redis中获取验证码,调用stringRedisTemplate的opsForValue方法操作字符串类型的redis数据,再调用get方法获取key = "login:code:phone"的value值,保存为cacheCode

  3. 如果cacheCode=null说明redis中该手机号不存在对应的验证码,如果cacheCode!=code说明Redis中的验证码和用户端发送给服务端的验证码不一致,以上两种情况都报错

  4. 如果验证码成功,则根据手机号查询用户,并判断用户是否存在,不存在则创建新用户

  5. 存在或创建完新用户之后,随机生成token

  6. 调用Beanutil的copyProperties将user转换为userDTO对象

  7. 为了能够一次性将userDTO中的所有字段一次性保存到Redis的Hash结构中,要将userDTO转化为Map,但是由于stringRedisTemplate所有键和值都必须是字符串或可转为字符串的形式,而userDTO中的id是Long类型,会出现类型转换异常,因此在将userDTO转化为Map时要将所有字段都转化为String类型

    1. 调用BeanUtil的beanToMap方法,将userDTO转化为hashMap结构

      1. CopyOptions.create()

        精细修改map中的每个字段

      2. setIgnoreNullValue

        忽略空值。如果userDTO的某个属性值为null,它将不会放入Map

      3. .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())

        将value转化为String,即将map中每个字段都转化为String

      stringRedisTemplate和RedisTemplate的区别

      1. stringRedisTemplate支持手动序列化,RedisTemplate只能自动序列化
      2. stringRedisTemplate所有键和值都必须是字符串或可转为字符串的形式,RedisTemplate则不需要

      或改为以下形式,不用工具类,而是挨个字段put,并将id转化为String字符串

  8. 一次性将map存储到redis

  9. 将token返回给前端

4.3 校验登陆状态

  1. 创建stringRedisTemplate对象,并通过构造器初始化(因为该类不是spring容器,在创建该类的对象时是直接new出来的,因此不能通过@autowierd自动注入)

    你的LoginInterceptor不能注入StringRedisTemplate:问题出在MvcConfigaddInterceptors方法中,你是通过new LoginInterceptor(stringRedisTemplate)来创建拦截器实例的。这个new关键字创建的是一个全新的、普通的Java对象,不是Spring容器管理的Bean。

    因此,即使在LoginInterceptor类内部使用了@Autowired,Spring也不会为这个手动创建的对象执行依赖注入流程 。其内部的StringRedisTemplate字段自然是null。

  2. 获取请求头中的token

  3. 基于token获取redis中的用户,用entries方法,获得map集合

  4. 判断用户是否存在

  5. 如果存在,则转化为userDTO对象,并将用户保存在ThreadLocal中

  6. 刷新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是否有用户,如果没有则拦截

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/2 1:46:34

KubePi:让Kubernetes集群管理变得简单直观的现代化面板

KubePi&#xff1a;让Kubernetes集群管理变得简单直观的现代化面板 【免费下载链接】KubePi KubePi 是一个现代化的 K8s 面板。 项目地址: https://gitcode.com/gh_mirrors/kub/KubePi 在云原生技术快速发展的今天&#xff0c;Kubernetes已经成为容器编排的事实标准&…

作者头像 李华
网站建设 2026/4/2 8:13:12

机器视觉工控一体机厂商

机器视觉工控一体机厂商如何选择&#xff1f;索腾工控专业解析在工业自动化快速发展的今天&#xff0c;机器视觉工控一体机已成为智能制造的核心设备之一。这类设备集成了图像采集、处理和控制功能&#xff0c;广泛应用于质量检测、定位引导、尺寸测量等场景。面对市场上众多的…

作者头像 李华
网站建设 2026/4/2 21:41:02

误删微信好友后聊天记录怎么恢复

手机屏幕上那个熟悉的绿色图标&#xff0c;每天承载着我们多少重要的对话&#xff1f;工作文件的传输、家人的叮嘱、朋友的欢笑&#xff0c;都藏在那些小小的对话框里。但你有没有过这样的经历&#xff1a;手滑删除了微信好友&#xff0c;想找回聊天记录时却发现空空如也&#…

作者头像 李华
网站建设 2026/4/3 4:39:24

24、数据备份、恢复与网络安全指南

数据备份、恢复与网络安全指南 在当今数字化时代,数据备份与恢复以及网络安全是企业运营中至关重要的环节。有效的数据备份策略能确保在系统故障或数据丢失时迅速恢复业务,而完善的网络安全措施则可保护企业信息免受非法访问和攻击。本文将详细介绍相关的技术细节和关键路径…

作者头像 李华
网站建设 2026/3/31 5:24:16

这个制冷站集控系统的开发过程挺有意思。三台不同品牌的制冷机要协同工作,还得考虑四个用冷点的动态需求,当时设计控制策略时没少折腾PLC的定时器和数据块

一套制冷冰水机集控程序 制冷机 冰水机 制冷机集控程序 三台制冷机&#xff0c;其中两台日立&#xff0c;一台海尔&#xff0c;4个用冷点&#xff0c;程序使用西门子200smart plc实现&#xff0c;配合西门子触摸屏&#xff0c;共有两种控制模式&#xff0c;第一是通过冷量&…

作者头像 李华
网站建设 2026/4/2 15:09:58

2、开启Sparrow开发之旅

开启Sparrow开发之旅 在深入开发之前,我们需要搭建开发环境并在系统上配置Sparrow。下面将详细介绍如何操作。 了解Sparrow基础 Sparrow是一个游戏框架,对于有ActionScript、Flash API和/或Starling使用经验的人来说可能会感到熟悉。它与Starling的相似并非巧合,二者核心…

作者头像 李华