news 2026/5/1 5:05:16

【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/18 1:27:10

人工智能之数学基础 线性代数:第三章 特征值与特征向量

人工智能之数学基础 线性代数 第三章 特征值与特征向量 文章目录人工智能之数学基础 线性代数前言一、定义二、几何与物理意义1. 几何解释&#xff08;线性变换视角&#xff09;2. 物理意义举例三、数学推导&#xff1a;如何求特征值与特征向量&#xff1f;步骤总结&#xff1…

作者头像 李华
网站建设 2026/4/25 4:30:35

30、系统安全与认证全解析

系统安全与认证全解析 在网络安全领域,保障系统安全是一项至关重要且复杂的任务。下面我们将详细探讨系统安全的多个方面,包括无线网络安全、系统强化包、自动化系统强化以及安全认证等内容。 无线网络安全策略 无线网络的广泛应用带来了便捷,但也带来了安全风险。为了确…

作者头像 李华
网站建设 2026/4/18 9:18:29

3、基于Sparrow框架的游戏开发入门指南

基于Sparrow框架的游戏开发入门指南 1. 开发环境准备与文档集成 在开始使用Sparrow进行游戏开发前,我们需要完成一些基础的准备工作,包括将应用配置到真机运行以及集成Sparrow的文档。 - 真机运行配置 :将设备连接到Mac,并把构建配置设置为该设备,这样应用就能在真机…

作者头像 李华
网站建设 2026/4/6 10:59:15

PaddlePaddle工业级模型库应用:借助git下载最新开源项目案例

PaddlePaddle工业级模型库应用&#xff1a;借助Git高效落地开源AI项目 在当前AI技术加速向产业渗透的背景下&#xff0c;如何快速将前沿算法转化为可运行、可部署的工业级系统&#xff0c;已成为企业构建智能能力的核心挑战。尤其是在中文自然语言处理、文档识别、视觉检测等场…

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

13、游戏中的人工智能与进度系统实现

游戏中的人工智能与进度系统实现 1. 让敌舰移动和攻击 在游戏开发中,我们可以利用模糊逻辑和状态机来实现人工智能机制,首先要让敌舰能够移动,包括随机移动和向玩家舰船移动。 1.1 让敌舰移动的步骤 若Xcode项目未打开,则打开它。 打开 Battlefield.h 文件。 定义所…

作者头像 李华
网站建设 2026/4/30 8:46:02

为什么你的useEffect总是出bug?一文讲清楚依赖数组的坑

你是不是遇到过这样的问题&#xff1a;明明把某个值放进了state里&#xff0c;但在effect里拿到的还是旧值一个定时器反复启动、停止&#xff0c;代码看起来没毛病埋点数据在测试环境正常&#xff0c;上线就乱套了某个功能在本地好用&#xff0c;用户那边却数据混乱如果是&…

作者头像 李华