Redis:原理+项目实战——Redis实战2(Redis实现短信登录(原理剖析+代码优化))

👨‍🎓作者简介:一位大四、研0学生,正在努力准备大四暑假的实习
🌌上期文章:Redis:原理+项目实战——Redis实战1(session实现短信登录(并剖析问题))
📚订阅专栏:Redis速成
希望文章对你们有所帮助

Redis实现短信登录

  • 基于Redis实现共享session项目
    • Redis替代session的业务流程
      • 发送短信验证码
      • 短信验证码登录与注册
      • 校验登录状态
      • 关键点实现
    • 基于Redis实现短信登录
      • 发送验证码
      • 登录验证功能
    • 解决状态登录刷新的问题——登录拦截器的优化

基于Redis实现共享session项目

Redis替代session的业务流程

发送短信验证码

这个大致的流程是跟session的业务流程差不多的,无非就是验证码不再保存到session中,而是保存到了Redis中,Redis的结构是key-value的,且value是很多种类型的,在这里我们选择最简单的String类型即可。

一个需要考虑的问题是key的选取,在session中我们选用了“code”来作为key,但在这里却不行。这是因为每一个不同的浏览器在发送请求的时候都会有一个不同的独立的session,也就是说Tomcat的内部维护了很多的session,互相之间是不会干扰的。但是Redis是一个共享的内存空间,如果直接使用key是肯定会造成覆盖这种不好的局面的,所以我们不能直接选用“code”来作为key。

容易发现,每个手机号都不一样,因此我们可以直接用手机号作为key。

短信验证码登录与注册

最终的用户信息不再保存到session中,而是保存都Redis中去了,同样要考虑key跟value的选择:
(1)value的选取:
我们要保存的是用户的信息,这是一个对象。Redis中的String可以将用户信息以JSON字符串的形式来保存,Hash可以将对象中的每个字段独立存储。具体的大家可以看我之前的文章:
Redis:原理速成+项目实战——Redis常见命令(数据结构、常见命令总结)
明显我们用Hash结构是最合适的。
(2)key的选取:
这里并不建议用phone作为key,而是以随机token(服务器生成的令牌)为key来存储用户数据,具体原因会在后面进行解释。

在之前我们校验登录状态的时候,是从cookie中获取session再得到用户信息,而现在我们校验登录的时候要访问的凭证就是这个随机token了,但Tomcat不会将这个token自动写到浏览器上面。
所以我们把数据保存到Redis以后还需要手动的把token返回到前端,流程就得修改:

1、提交手机号和验证码
2、校验验证码
3、根据手机号查询用户信息
4、用户保存到Redis
5、返回token给客户端(重要一步)

校验登录状态

我们不再是从浏览器中的cookie指定的session来获取用户信息,而是以随机token为key来从Redis中获取信息,流程如下:

1、用户发送请求并携带token
2、从Redis中获取用户(以随机token为key)
3、判断用户是否存在:
(1)没有这个用户就拦截
(2)有这个用户就保存用户信息到ThreadLocal,并放行

关键点实现

我们在校验登录状态的时候,需要携带token,这是如何做到的呢?这就涉及到了前端的知识了:
在这里插入图片描述
在login方法的axios请求的相应里,我们将登录凭证直接放到了session中,而我们之后的每次请求都要携带这个token,我们可以在axios里面进行实现:
在这里插入图片描述
所以,我们的key肯定不能再选择手机号了,因为这种存储到前端代码的行为并不是安全的。

基于Redis实现短信登录

发送验证码

直接将上一篇文章的代码进行修改:

	//通过Resource注解注入SpringData提供的API
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone, HttpSession session) {
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误");
        }
        String code = RandomUtil.randomNumbers(6);
        /**
         * session.setAttribute("code", code);
         * 保存验证码到session,这个过程被替代成保存到Redis!
         */
        /**
         * 保存验证码到Redis,其中key是phone(加一下业务前缀防止冲突),value是String类型
         * 我们要对key设置一下有效期为2分钟,防止网站被无限的注册攻击而导致内存爆炸
         * 代码中的前缀、有效时间用常量来替代,常量另外保存到其他的类中,看起来更规范
         */
        //stringRedisTemplate.opsForValue().set("login:code" + phone, code, 2, TimeUnit.MINUTES);
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        log.debug("成功发送短信验证码:{}", code);
        return Result.ok();
    }

登录验证功能

根据流程更新login方法:

	@Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            //不符合,返回错误信息
            return Result.fail("手机号格式错误");
        }
        // TODO 从Redis中获取验证码来做校验
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.equals(code)){
            //不一致,报错
            return Result.fail("验证码错误");
        }
        //一致,根据手机号查询用户,这里要单表查询
        //mybatis-plus可以帮助我们很快的实现:
        //1、继承类ServiceImpl<实体类的Mapper,实体类>
        //2、实体类中要加入注解@TableName(),表示从哪个数据库取的
        //3、调用query()方法可以直接实现select * from 表
        //4、调用eq方法验证查询出来的数据中,列名为phone的列有没有值与phone相同的
        //5、可以通过one()查询出一个用户,也可以list()查询出多个用户,这里显然只会有一个
        User user = query().eq("phone", phone).one();
        //判断用户是否存在
        if (user == null){
            //不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }
        /**
         * session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
         * 之前保存用户信息到session中,现在改成保存到Redis中去
         */
        //随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString(true);
        /**
         *  将User对象转换为Hash存储
         *  1、转换成UserDTO
         *  2、将其转换成Map的形式
         *  3、用Hash结构的putAll方法,因为UserDTO还是包含了多个字段和字段值
         *  4、要给token设置一个有效期,30min没操作就退出登录(效仿session),putAll没有对应的参数选择,要单独用expire()设
         *  5、要注意一个细节,每次用户操作了就要重新去更新这30min(这里我们可以用拦截器来看用户什么时候操作了系统)
         */
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
        String tokenKey = LOGIN_USER_KEY + token;//LOGIN_USER_KEY="login:login"
        //存储
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        //设置有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);//LOGIN_USER_TTL=30L

        //返回token
        return Result.ok(token);
    }

从之前的session改为现在的token,我们拦截器当然也要进行修改,将放行的一些条件进行修改:

	private StringRedisTemplate stringRedisTemplate;

    //这里只能自己写构造函数来注入,没办法用@autowired注解,因为LoginInterceptor的对象是手动new出来的
    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {//是否为空
            response.setStatus(401);
            return false;
        }
        //基于token获取Redis中的用户
        String key = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
                .entries(key);
        //判断用户是否存在
        if (userMap.isEmpty()) {
            //不存在,拦截,并返回401错误码
            response.setStatus(401);
            return false;
        }
        //将查询到的Hash数据转回DTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //存在,保存用户信息到ThreadLocal
        UserHolder.saveUser((UserDTO) userDTO);
        //刷新token的有效期
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        //放行
        return true;
    }

容易发现,上面的代码没有使用@AutoWired进行注入,这是因为我们的这个类并不是Spring托管的类。
我们的MvcConfig也要进行修改:
在这里插入图片描述
这里我们可以用@Resource注解来获取StringRedisTemplate对象,这是因为我们的类已经加上了@Configuration注解,这样类已经是被Spring给托管了,可以使用该注解。
运行后,我们打开Redis的数据库,确实是把验证码给成功保存下来了:
在这里插入图片描述
但是我们在登录的时候会报类型转换错误的异常,这个出错出现在

stringRedisTemplate.opsForHash().putAll(tokenKey + token, userMap);

报错信息显示Long无法转换为String类型,说明我们的UserMap的类型出现了问题,UserMap来自于UserDTO,因此问题出现在了UserDTO这里:
在这里插入图片描述
这边的UserDTO中的id是Long类型的,而查看我们的StringRedisTemplate的源码:
在这里插入图片描述
它要求我们的key和value都是String类型的,因此我们需要修改代码,使得两者的类型要能够匹配:

方法一:不用BeanUtil.beanToMap方法,自行创建一个方法,手动将UserDTO里面的id先转换成String类型,然后存入Map。(这是我的方法,其实我觉得这个方法是最适合的,也容易想到)
方法二:继续使用BeanUtil.beanToMap这个方法,这个方法它允许我们对key和value做自定义。(这个方法是黑马程序员的讲解者提出的方法,我感觉跟炫技一样,这就是大佬)

这里就写一下第二个方法:

Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create() //CopyOptions表示做数据拷贝时候的选项
                        .setIgnoreNullValue(true) //忽略空值
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));//转换为字符串

在这里插入图片描述
成功登录,同时我们也可以直接看到token的信息,登录验证也正是用这里的token来进行逻辑判断的。
我们可以总结一下Redis代替session需要考虑的三个问题:
1、数据结构的选取
2、key的选取
3、选择合适的存储粒度

解决状态登录刷新的问题——登录拦截器的优化

上述代码实现完还有一点小问题,之前的拦截器并不会拦截掉一切路径,而是所有需要登录的路径,那么会出现一个问题:我们的首页并不需要登录就可以直接访问,那么已经登录过的用户一直在首页进行操作,拦截器中的登录状态并不会刷新,就可能造成明明一直在操作系统,却被视为不算是在登录状态。
解决方法是再加上一个拦截器,用户的请求要先经过这个拦截器,这个拦截器会拦截一切的路径,所以我们可以在这个拦截器里面进行token有效期的刷新操作:

1、获取token
2、查询Redis的用户
3、保存到ThreadLocal
4、刷新token有效期
5、放行

这样的话,一切的请求都会触发刷新的操作。
那么之前的拦截器只需要查询ThreadLocal的用户,存在则继续,不存在则拦截。
我们可以在之前代码的基础上这样修改代码:
1、新增加一个拦截器,放行一切:

package com.hmdp.utils;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;

public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    //这里只能自己写构造函数来注入,没办法用@autowired注解,因为LoginInterceptor的对象是手动new出来的
    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {//为空也直接放行,判断交给下一个拦截器
            return true;
        }
        //基于token获取Redis中的用户
        String key = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
                .entries(key);
        //判断用户是否存在
        if (userMap.isEmpty()) {//不存在也放行
            return true;
        }
        //将查询到的Hash数据转回DTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);
        //刷新token的有效期
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        //放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //移除用户,防止内存泄漏
        UserHolder.removeUser();
    }
}

2、修改之前的拦截器,只需要进行用户的判断就可以了

package com.hmdp.utils;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断是否需要去拦截(ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null){
            //没有,需要拦截,设置状态码
            response.setStatus(401);
            //拦截
            return false;
        }
        //有用户,则放行
        return true;
    }
}

3、重新配置拦截器:

package com.hmdp.config;

import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.Resource;

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    //添加拦截器
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "'user/me",
                        "/blog/hot",
                        "/shop/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/voucher/**"
                ).order(1);//order越大,执行优先级越小,表示更靠后的拦截器
        //token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

现在就实现了需求了,大家可以去不断的对系统进行操作,并且观察每个key的TTL。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/284896.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

免费的 MP4 压缩器榜单大全——16大视频压缩软件

阅读最佳在线视频压缩器的综合评论和比较及其功能和定价&#xff0c;以选择免费的 MP4 压缩器&#xff1a; 如果您长期担任专业视频编辑器&#xff0c;那么您就会知道一个简单的 1 MB 视频最终会如何占用系统中的大量空间。因此&#xff0c;您很清楚压缩视频的重要性&#xff…

数据隐私:技术和法律的双重挑战

当前&#xff0c;数据已成为企业和个人最宝贵的资产之一。然而&#xff0c;随着数据的广泛收集和共享&#xff0c;数据隐私问题也日益突出。保护个人信息的隐私不仅是法律规定的义务&#xff0c;也是维护社会公正、保护个人权益的必要措施。本文将从数据隐私的概念、重要性、面…

机器学习计算题——svm支持向量机

引入一点高中的线性规划&#xff0c;我在接下来的题目确实可以用这种思想计算&#xff0c;并且非常快得到答案。 正文开始&#xff1a; 例题1 例题2

网站提示“不安全”怎么解决

在互联网中&#xff0c;安全问题至关重要。访问某些网站时&#xff0c;可能会遇到“不安全”警告&#xff0c;通常是由于缺乏SSL证书。SSL证书是数字证书&#xff0c;用于确保互联网通信的安全和保密。 “不安全”问题通常源于缺少SSL证书。SSL通过加密通信&#xff0c;防止第三…

浅析锂电池保护板(BMS)系统设计思路(四)SOC算法-扩展Kalman滤波算法

1 SOC估算方法介绍 电池SOC的估算是电池管理系统的核心&#xff0c;自从动力电池出现以来&#xff0c;各种各样的电池SOC估算方法不断出现。随着电池管理系统的逐渐升级&#xff0c;电池SOC估算方法的效率与精度不断提高&#xff0c;下面将介绍常用几种电池SOC估算方法[1]&…

个体诊所软件方案,农村医疗服务站社区门诊电子处方管理系统软件教程

个体诊所软件方案&#xff0c;农村医疗服务站社区门诊电子处方管理系统软件教程 一、软件程序问答 1、处方单软件有病历汇总吗 如下图&#xff0c;软件以 佳易王电子处方软件V17.2版本为例说明 点击 病历汇总统计 按钮&#xff0c; 可以按明细查询或病历汇总查询&#xf…

Javaweb之JDBC的详细解析

2. JDBC介绍(了解) 2.1 介绍 通过Mybatis的快速入门&#xff0c;我们明白了&#xff0c;通过Mybatis可以很方便的进行数据库的访问操作。但是大家要明白&#xff0c;其实java语言操作数据库呢&#xff0c;只能通过一种方式&#xff1a;使用sun公司提供的 JDBC 规范。 Mybatis…

2023年12月随笔之‘千年光盘‘(Millennial Disc)

1. 回头看 日更坚持了365天。 读《像火箭科学家一样思考&#xff1a;将不可能变为可能》更新完成 读《程序员的README》开更并更新完成 读《算法霸权》开更并持续更新中 12月码字71992字&#xff0c;日均码字数2322字&#xff0c;累计码字717963字&#xff0c;累积日均码字…

C#编程-使用变量

使用变量 请考虑以下场景:您必须创建一个程序,接受来自用户的两个数字并在屏幕上显示着两个数字之和。现在,读取用户提供的数字时,您需要将这些数字存储在内存中的某个位置,以便您能对这些数字执行加操作。您可以使用变量将这些数字存储在内存中。 下图显示了使用变量将…

SQL性能优化-索引

1.性能下降sql慢执行时间长等待时间长常见原因 1&#xff09;索引失效 索引分为单索、复合索引。 四种创建索引方式 create index index_name on user (name); create index index_name_2 on user(id,name,email); 2&#xff09;查询语句较烂 3&#xff09;关联查询太多join&a…

MYSQL 索引结构 B+树 hash索引

B-Tree树 当节点存在五个key时&#xff0c;中间的key向上分裂形成树 B树 所有的数据都会出现在叶子节点&#xff0c;叶子节点形成一个单向链表 哈希索引 优点

T 检验和 Z 检验之间的区别

在统计学领域&#xff0c;假设检验在从数据中得出有意义的结论方面发挥着至关重要的作用。两种常用的统计检验是 T 检验和 Z 检验。虽然这两种检验都用于评估假设&#xff0c;但它们的应用和假设有所不同。 t 检验和 z 检验都假设数据呈正态分布&#xff08;或近似正态分布&…

【网络面试(6)】IP协议对网络包的转发

在前面的博客中&#xff0c;我们提到过&#xff0c;网络传输的报文是有真实的数据包和一些头部组成&#xff0c;目前我们了解的头部就有TCP头、IP头、MAC头&#xff0c;而且这三个头部信息都是在应用程序委托给协议栈之后&#xff0c;被写入的相关信息&#xff0c;这些头部都是…

30 UVM Adder Testbench Example

1 Adder Design 加法器设计在时钟的上升沿产生两个变量的加法。复位信号用于clear out信号。注&#xff1a;加法器可以很容易地用组合逻辑开发。引入时钟和重置&#xff0c;使其具有测试台代码中时钟和重置的样子/风格。 module adder(input clk, reset, input [7:0] in1, in…

go语言语法基础

文章目录 前言一、输入和输出常用的字符串格式化符号 二、注释三、Go常用基本语言数据类型数字类型布尔类型字符类型变量与常量数组和切片数组切片 map类型创建map增删改查特别提醒 指针 四、运算符五、条件判断语句if系列switch六、循环语句for循环标准写法死循环while循环do …

2024.1.1 hive_sql 题目练习,开窗,行列转换

重点知识: 在使用group by时&#xff0c;select之后的字段要么包含在聚合函数里&#xff0c;要么在group by 之后 进行行转列,行转列的核心就是使用concat_ws函数拼接(分隔符,内容), -- 以及collect_list函数进行收集,list不去重, set去重无序 列转行,核心就是使用炸裂函数把东…

【深入浅出RocketMQ原理及实战】「云原生升级系列」打造新一代云原生“消息、事件、流“统一消息引擎的融合处理平台

打造新一代云原生"消息、事件、流"统一消息引擎的融合处理平台 云原生架构RocketMQ的云原生架构实现RocketMQ的云原生发展历程互联网时期的诞生无法支持云原生的能力 云原生阶段的升级云原生升级方向促进了Mesh以及多语言化发展可分合化的存算分离架构存储分离架构的…

message: 没有找到可以构建的 NPM 包,请确认需要参与构建的 npm 都在 `miniprogra

第一步&#xff1a;修改 project.config.json 文件 "packNpmRelationList": [{"packageJsonPath": "./package.json","miniprogramNpmDistDir": "./miniprogram/"}], "packNpmManually": true 第二步&#xff1a;…

QDialog

属性方法 样式表 background-color: qlineargradient(spread:reflect, x1:0.999896, y1:0.494136, x2:1, y2:1, stop:0 rgba(0, 0, 0, 255), stop:1 rgba(255, 255, 255, 255));border: 1px groove rgb(232, 232, 232);border-radius: 20px; QDialog 的常用方法&#xff1a; e…

35--JDK新特性

1、新语法结构 新的语法结构&#xff0c;为我们勾勒出了 Java 语法进化的一个趋势&#xff0c;将开发者从复杂、繁琐的低层次抽象中逐渐解放出来&#xff0c;以更高层次、更优雅的抽象&#xff0c;既降低代码量&#xff0c;又避免意外编程错误的出现&#xff0c;进而提高代码质…