【Redis】基于Redis实现短信登录

发送短信验证码的流程中,只有一个地方发生了变化,就是保存验证码的时候,不再是保存到session,而是保存到redis。

而且保存到redis时,key不再是code了,而是以手机号为key。

RedisConstants.java

PS:阅读的时候这个类先不用管,先看下面代码

public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L; // 有效期
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 30L;
}

UserServiceImpl.java

短信登录

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4.保存验证码到redis
    // 4.1 跟之前保存到session的代码差不多,不过要注意的是,这个key需要加一个业务前缀,形成层次。因为之前也分析过了,redis是大家都来存的一个空间,如果大家都直接将手机号作为key,很有可能其他业务也是这么做的,就产生冲突了
    // 这里是做login业务,然后这个业务是做code验证的
    // 4.2 这里的key最好设置一个有效期,例如:验证码五分钟内有效。如果有人没事干,一直在这里狂点,redis中就存了无数条数据,而且还不删除,终有一天redis就会被占满。因此为了避免这样的问题发生,我们存入redis的key一定要设置一个有效期
    // set方法参数列表中就可以传入有效期,ctrl + p可以查看参数。第一种方式:时间(long) + 单位(TimeUnit);第二种方式:Duration
    // 这里就设置为2分钟。这个有点类似于redis中的 set key value ex

    // 4.3 但是需要注意的是:login:code 和 2 尽量都给它定义为常量。这样写看起来有点漏,万一取的时候key写错了怎么办。
    // stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 2, TimeUnit.MINUTES);
    // 因此我们就可以将常量定义在在utils.RedisConstants
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    log.debug("发送短信验证码成功,验证码:{}", code);

    return Result.ok();
}

登录注册

PS:存储value的时候

opsForValue() 拿到的是对字符串的操作,opsForHash() 拿到的就是跟哈希有关的操作。

PS:在spring中并不是以命令作为方法名,方法名类似于Java的HashMap的方法名。

hashKey和value就是对应字段和字段值,事实上value中可能会有多个字段,一个 put() 只能存一个字段,如果有多个字段,put() 就需要执行多次,这样其实就不太好了,等于你是与服务器做多次交互。

因此推荐大家的方案是使用 putAll()putAll() 相当于 hmset,里面可以存好几个键值对,上面的 UserDTO 中有多个属性,就可以一次性的将属性全部存进去了。

image-20240524072528015

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.从redis获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        // 不一致,报错
        return Result.fail("验证码错误");
    }

    // 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
    User user = query().eq("phone", phone).one();

    // 5.判断用户是否存在
    if (user == null) {
        // 6.不存在,创建新用户并保存
        user = createUserWithPhone(phone);
    }

    // 7.保存用户信息到 redis中
    // 7.1.随机生成token,这里使用的是UUID,作为登录令牌
    // UUID有两:一个Java自带的,一个Hutool提供的。toString()有两个重载方法,toString(true)表示不带下划线,参数名叫isSimple。如果是toString(),那就是带下划线。
    String token = UUID.randomUUID().toString(true);
    // 7.2.以token为key,将User对象转为HashMap存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    // 使用BeanUtil(hutool提供的),它里面有个方法叫beanToMap,它的作用就是将一个bean转为Map
    // 随着时间增长,用户会越来越多
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
            CopyOptions.create()
                    .setIgnoreNullValue(true)
                    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 7.3.存储
    // token最好也加一个前缀
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 7.4.设置token有效期,那这个有效期设置多久呢?可以参考session,以前基于session登录,有效期是30分钟,因此我们也可以给token设置30分钟的有效期,但是这里和string类型的set方法不一样,不能存的时候同时设置有效期,只能先存,然后再来设置有效期
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

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

但是session登录不是到了30分钟就结束了,只要你不停的访问,有效期就一直是30分钟,只有你超过30分钟不访问我,我才会将登录状态剔除。但是我们这个有效期是只要过了30分钟,redis就会把你剔除。那我们该如何解决这个问题呢?


解决状态登录刷新问题

我们所有的请求进来后都需要经过拦截器拦截校验,只要经过了校验,第一:证明它是登录的用户;第二:它在活跃着(在访问)。

既然满足了这两个条件,就可以去更新一下redis的有效期。这样redis的token就不会过期了。

只有当用户什么都不干,它就不会触发拦截器,那么这种情况下超过了30分钟,这个key才会被剔除。

一、初始方案思路总结

在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的

1653320822964

二、优化方案

既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

1653320764547


三、代码

RefreshTokenInterceptor

public class RefreshTokenInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;
    // 这里只能使用构造函数,因为这个类的对象是我们手动new出来的,而不是通过Component一些注解构建的,即不是spring创建的,此时是没有人帮你自动做依赖注入的,因此不能再使用@Autowired或者@Resource之类的注解。
    // 那么我们使用构造函数注入,谁来帮我们注入?那就看是谁使用了RefreshTokenInterceptor,即在MvcConfig拦截器中用到它了。在MvcConfig类上加了@Configuration,代表这个类将来是spring帮我们注入的,那么由spring来构建这个类的对象,它可以直接做依赖注入。
    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        // 使用hutool中的StrUtil类
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.基于TOKEN获取redis中的用户
        String key = LOGIN_USER_KEY + token;
        // 注意取的时候不能用get方法,因为get方法只能取哈希中的一个键值。但我们现在想取的是哈希中所有的键值对,此时就应该使用entries(),它的返回值就是一个Map,一个key中整个哈希键值都会返回。
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 5.将查询到的hash数据转为UserDTO,由于存进去的时候是一个Map,因此取出来的时候肯定也是一个Map。存进去的时候使用的是BeanUtil,因此取出来的时候应该也使用BeanUtil。
        // 第三个参数:isIgnoreError,你要不要忽略转换中的错误。那肯定不忽略,因此写false
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到 ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

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

LoginInterceptor

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

MvcConfig.java

PS:我们希望 RefreshTokenInterceptor拦截器 先执行,因为只有它先执行了,拿到了用户保存到了ThreadLocal,那么 LoginInterceptor拦截器 才能做拦截的判断,那么控制拦截器的顺序呢?

在添加拦截器的时候我们可以跟进去看一眼

image-20240525140541807

这个拦截器其实会被注册为 InterceptorRegistration

image-20240525140634128

继续跟进,这个注册器中有一个order,就是拦截器的执行顺序。默认情况下所有拦截器的执行顺序都是零,都是0的情况下就是按照添加顺序执行,因此简单来说我们只需要先添加 RefreshTokenInterceptor拦截器,但是如果想控制的严谨一些,就可以使用 order() 进行修改执行顺序,数字越小先执行。

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(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
        // token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

重启服务器进行测试

image-20240525132113575

回到redis图形化界面中查看,可以发现存储成功

image-20240525132334352


四、可能遇到的错误

image-20240525131954187

回到代码区查看报错:class java.lang.Long cannot be cast to class java.lang.String 不能将Long转化为String。

UserMap来自UserDTO,UserDTO中只有id是Long,也就是说Long类型的id无法存储到redis中去,因此报错。往下看,这个错是StringRedisSerializer中报的错误。

image-20240525132558098

那为什么会有这样的错误呢?我们使用的RedisTemplate是StringRedisTemplate,StringRedisTemplate要求你的key和value都是String。

image-20240525132918736

而我们把数据转成Map的时候,那个字段id是Long类型,因此就出现这个问题。

因此存储到哈希结构中的Map的key和value都应该要为String结构。

Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);

有两种解决办法:

  • 不用工具类了,我们直接自己new一个Map,然后将对象里面的字段名作为key,值作为value,value不为字符串的需要改为字符串。
  • 还是用这个工具,这个工具是可以自定义的,默认情况下你的值是什么数据类型,这里就用什么数据类型。但它也允许你做自定义,copyOptions 参数。

做法:首先传对象,再传一个Map,这里直接new一个空的HashMap,copyOptions就是数据拷贝时的一个选项。

image-20240525133610001

通过 CopyOptions.create() 就创建出来了一个 CopyOptions,但是这个地方创建出来的是默认的,但我们要自定义,它后面允许你有各种各样的set。

image-20240525133815874

例如:

setIgnoreNullValue —— 忽略一些空的值;

setFieldValueEditor —— 对字段值的一个修改器,它允许你修改字段值,参数需要传入一个函数:BiFunction,这个函数有两个参数:字段名和字段值,返回值是你修改后的字段值

image-20240525134057536

完整代码

Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

因此,当我们碰到一个错误的时候不要着急,而是自己分析,产生问题的原因在哪。

此时重启代码,然后登陆,可以发现登陆成功。

image-20240525134716207

回到redis客户端,可以发现也存储成功,存储用户的时候就是用哈希进行存储的。

并且右上角也能看见有效期的时间。

我们也可以重新请求,看看有效期会不会刷新。

image-20240525141059229

查看 /user/me 请求头,会发现携带了 Authorization,这样一来后台才能验证我们是否登录。

image-20240525141213195

此时这个登录流程完全按照我们所期待的方式运行了。


总结

Redis代替session需要考虑的问题:

  • 选择合适的数据结构

  • 选择合适的key

  • 选择合适的存储粒度。

    我们在存储的时候并没有存完整的用户信息,而是将敏感信息去掉了,并且还节约了内存空间。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值