短信登录,基于 Redis 实现

目录

1、为什么使用 Redis 实现而不使用 Session 实现?

1.1 如果使用的是 session存在以下问题:

1.2 为什么使用 Redis 进行存储?

2、手机短信登录流程

3、短信验证码登录、注册代码实现

4、校验当前用户的登录状态代码实现

5、拦截器的优化 

6、Redis 代替 Session 的注意事项



 

1、为什么使用 Redis 实现而不使用 Session 实现?

1.1 如果使用的是 session存在以下问题:

session的数据是就是的变量,放在nodejs进程中


        正式线上运行时多进程,进程之间的数据无法共享:比如,有三个进程都有个session,当我第一次登陆成功的时候命中的是第一个进程,他把我的登录信息放在自己session中去了,第二次登录命中的是第二个进程的话,结果登录失败了,这就是 session 中的共享问题

1.2 为什么使用 Redis 进行存储?

        因为 redis 数据是存放在内存中的,不存在数据共享问题;同时,Redis 具备一定持久层的功能,也可以作为一种缓存工具。对于 NoSQL 数据库而言,作为持久层,它存储的数据是半结构化的,这就意味着计算机在读入内存中有更少的规则,读入速度更快。对于那些结构化、多范式规则的数据库系统而言,它更具性能优势。作为缓存,它可以支持大数据存入内存中,只要命中率高,它就能快速响应,因为在内存中的数据读/写比数据库读/写磁盘的速度快几十到上百倍


2、手机短信登录流程


3、短信验证码登录、注册代码实现

定义的常量配置类:

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 = 36000L;

    public static final Long CACHE_NULL_TTL = 2L;

    public static final Long CACHE_SHOP_TTL = 30L;
}

 提交手机验证码,并且设置有效期,防止他人恶意盗刷验证码,导致 redis 数据存储量飙升

//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)) {
    //1.1.若不正确,则提示信息
    return Result.fail("手机号验证有误,请重新输入!");
}

//2.正确,则生成对应手机号的验证码
String code = RandomUtil.randomNumbers(6);  //表示随机生成六位验证码

//TODO 2.1将生成的验证码保存到 redis 中,并设置验证码有效期
stringRedisTemplate.opsForValue()
        .set(LOGIN_CODE_KEY +phone,code,RedisConstants.LOGIN_CODE_TTL
                , TimeUnit.MINUTES);  //这里使用 String 类型进行存储

从 redis 中根据对应的 phone(key 值),获取到保存的验证码并与当前输入的验证码作比较

String resCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
        if(resCode==null||!resCode.equals(code)){
            //2.1若不正确,则提示信息
            return Result.fail("验证码有误,请重新输入!");
        }

若校验验证码通过,则根据手机号查询当前用户在数据库中是否存在,若不存在,则自动创建一个新用户保存到数据库中

//3.若一致,则根据手机号查询对应的用户是否存在
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone,phone);

        User user = userService.getOne(queryWrapper);
        if(user==null){
            //3.1若不存在,则自动创建一个新用户到数据库
            user = createUserByPhone(phone);
        }
private User createUserByPhone(String phone) {

        User user = new User();
        user.setPhone(phone);
        user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX
                +RandomUtil.randomString(10));  //这里使用 hutool 依赖进行生成随机字符串

        userService.save(user);

        return user;
    }

若存在,则将用户的信息以 token - map 的 hash 形式保存到 redis 中,map 中为用户的信息,以 key-value 形式保存,并设置有效期

注意!!!若直接这样将 userDTO 对象转换为 map 类型可能会报 类型转换异常!!!因为这里 redis 是使用 StringRedisTemplate 类型进行存储的,所需要的字段类型都要求为 String ;而我这里的 userDTO 中的属性类型不全是 String 类型,其中的 id 就为 Long 类型,显然这样直接转换是不可取的

//4.1随机生成 token,作为登录令牌
String token = UUID.randomUUID().toString(true);    //这里使用 hutool 来生成不带有 "-" 中划线的显示格式,作为 key
/4.2将 user 对象作为 hash 结构进行存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //这里进行类型的转换
Map<String, Object> map = BeanUtil.beanToMap(userDTO);   //将 userDTO 对象转换为 hash 类型,作为 value
//4.3将信息存储到 redis 中
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
//4.4这里进行设置 token 的有效期
stringRedisTemplate.expire(token,CACHE_SHOP_TTL,TimeUnit.MINUTES);  //这里将对应的 token 值设置 30 min 有效期

 解决方法如下:

这里使用 CopyOptions 来解决此问题 ,相关用法请参考 https://blog.csdn.net/moshowgame/article/details/82826535

Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(),   //将 userDTO 对象转换为 hash 类型,作为 value
        CopyOptions.create()
                .setIgnoreNullValue(true)    //忽略null值/只拷贝非null属性
                .setFieldValueEditor((fieldName,fieldValue)->
                                        fieldValue.toString()));   //这里是字段值的修改器, 将字段类型转换为 String 类型

完整代码:

@Resource
    private UserServiceImpl userService;

    @Resource
    private StringRedisTemplate stringRedisTemplate;


    /**
     * 这里是发送手机验证码的功能
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {

        //1.校验手机号
        if(RegexUtils.isPhoneInvalid(phone)) {
            //1.1.若不正确,则提示信息
            return Result.fail("手机号验证有误,请重新输入!");
        }

        //2.正确,则生成对应手机号的验证码
        String code = RandomUtil.randomNumbers(6);  //表示随机生成六位验证码

        //TODO 2.1将生成的验证码保存到 redis 中,并设置验证码有效期
        stringRedisTemplate.opsForValue()
                .set(LOGIN_CODE_KEY +phone,code,RedisConstants.LOGIN_CODE_TTL
                        , TimeUnit.MINUTES);  //这里使用 String 类型进行存储

//        //2.1将生成的验证码保存到 session 中
//        session.setAttribute("code",code);

        //3.发送验证码
        log.info("发送短信验证码成功!您的验证码为:{}",code);

        return Result.ok();
    }


    /**
     * 这里是登录的功能,并且返回 token 以用来验证当前用户是否存在 (将该 token 返回给前端)
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {

        //1.由于不同的请求,这里需要重新校验手机号
        String phone = loginForm.getPhone();
        if(RegexUtils.isPhoneInvalid(phone)){
            //1.1若不正确,则提示信息
            return Result.fail("手机号验证失败,请输入正确的格式!");
        }

//        //2.校验验证码(session 形式)
        String code = loginForm.getCode();
//        if(!code.equals(session.getAttribute("code").toString())||session.getAttribute("code").toString()==null){
//            //2.1若不正确,则提示信息
//            return Result.fail("验证码有误,请重新输入!");
//        }
        //TODO 2.校验验证码 (redis形式)
        String resCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
        if(resCode==null||!resCode.equals(code)){
            //2.1若不正确,则提示信息
            return Result.fail("验证码有误,请重新输入!");
        }

        //3.若一致,则根据手机号查询对应的用户是否存在
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getPhone,phone);

        User user = userService.getOne(queryWrapper);
        if(user==null){
            //3.1若不存在,则自动创建一个新用户到数据库
            user = createUserByPhone(phone);
        }

//        //4.将用户信息保存到 session 中
//        session.setAttribute("user",user);

        //TODO 4.若存在,则将用户信息保存到 redis 中,并设置有效期,避免恶意刷满数据
        //4.1随机生成 token,作为登录令牌
        String token = UUID.randomUUID().toString(true);    //这里使用 hutool 来生成不带有 "-" 中划线的显示格式,作为 key
        //4.2将 user 对象作为 hash 结构进行存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class); //这里进行类型的转换
        Map<String, Object> map = BeanUtil.beanToMap(userDTO,new HashMap<>(),   //将 userDTO 对象转换为 hash 类型,作为 value
                CopyOptions.create()
                        .setIgnoreNullValue(true)   //忽略 null 值,只传入非 null 值
                        .setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));   //这里将字段类型全部转换为 String 类型
        //4.3将信息存储到 redis 中
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY+token,map);
        //4.4这里进行设置 token 的有效期
        stringRedisTemplate.expire(LOGIN_USER_KEY+token,CACHE_SHOP_TTL,TimeUnit.MINUTES);  //这里将对应的 token 值设置 30 min 有效期

        return Result.ok(token);
    }

4、校验当前用户的登录状态代码实现

这里使用 Interceptor 拦截器进行判断

由于登录方法中已经将 token 返回给了前端,这里需要从前端获取 token 

//TODO 1.获取 redis 中的 token 键对应的值
        String token = request.getHeader("authorization");  //获取前端请求头中的 token
        if(StrUtil.isBlank(token)){
            //1.1若不存在,则进行拦截,并给出提示信息
            response.setStatus(403);
            return false;
        }

从 redis 中获取对应 token 的用户信息,若存在,则进行类型转换并进行存储

//TODO 2.从 redis 中根据 token 获取用户的信息
        Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
        //2.1判断该用户是否存在
        if(map.isEmpty()){
            //2.2若不存在,则给出提示信息
            response.setStatus(403);
            return false;    //进行拦截
        }

        //TODO 3.将查询到的 map 中的 hash 数据,重新转换为 userDTO 对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);//这里的 false 表示不忽略转换过程中产生的异常
        //3.1将信息保存到 ThreadLocal 中
        UserHolder.saveUser(userDTO);

问题产生: 

同时,这里需要注意的是之前设置了对应用户的 token 有效期,只有之前的设置是远远不够的,因为它表示无论用户是否正在页面操作,只要过了设置的有效期,对应的 token 都会被删除,这样是很不友好的;

需求:当用户在页面没有进行任何操作时,过了规定的时间就会被删除,反之,若用户在页面上存在相应的操作时,对应的 token 会被刷新过期时间

解决方法:

需要在拦截器中,进行校验当前用户是否之前登录,并且 token 还处于有效期之内时,进行当前 token 有效期的刷新操作

//TODO 4.进行刷新 token 的有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token,CACHE_SHOP_TTL, TimeUnit.MINUTES);

return true;  //放行

5、拦截器的优化 

原因:由于之前的 Interceptor 拦截器所拦截的是当前登录状态的路径,但是无需登录就可以访问的路径未被拦截,这样就导致用户在登录后,访问未被拦截的路径时,其 token 有效期突然过期,所带来的不友好的影响

解决方法:

        使用两个拦截器,一个是拦截所有的路径;一个是拦截需要登录的路径,并且查看当前线程的用户是否存在来判断是否进行 放行/拦截

 这里是拦截一切路径的拦截器:

package com.hmdp.MyInterceptor;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.utils.UserHolder;
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.CACHE_SHOP_TTL;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;

/**
 * 这里进行拦截一切请求路径
 */

public class EveryInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;    //由于EveryInterceptor不属于 Spring 容器管理,所以 redis 类不能直接注入

    public EveryInterceptor(StringRedisTemplate stringRedisTemplate) {  //这里在 MvcConfig 类中进行 redis 对象注入,反向注入容器
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //TODO 1.获取 redis 中的 token 键对应的值
        String token = request.getHeader("authorization");  //获取前端请求头中的 token
        if(StrUtil.isBlank(token)){
            return true;    //这里进行放行不需要登录就可以访问的路径
        }

        //TODO 2.从 redis 中根据 token 获取用户的信息
        Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
        //2.1判断该用户是否存在
        if(map.isEmpty()){
            return true;    //这里进行放行不需要登录就可以访问的路径
        }

        //TODO 3.将查询到的 map 中的 hash 数据,重新转换为 userDTO 对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);//这里的 false 表示不忽略转换过程中产生的异常
        //3.1将信息保存到 ThreadLocal 中
        UserHolder.saveUser(userDTO);

        //TODO 4.进行刷新 token 的有效期
        stringRedisTemplate.expire(LOGIN_USER_KEY + token,CACHE_SHOP_TTL, TimeUnit.MINUTES);

        return true;
    }


    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

        UserHolder.removeUser();    //将用户信息从当前线程中移除
    }

}

这里是进行拦截需要登录的路径的拦截器:

package com.hmdp.MyInterceptor;


import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.utils.UserHolder;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Invocation;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

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

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

/**
 * 这里是登录拦截器配置类,即拦截需要登录才能访问的路径
 */

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //1.判断当前线程中是否存在用户信息
        UserDTO user = UserHolder.getUser();
        if(user==null){

            response.setStatus(401);    //设置状态码
            return false;
        }

        return true;
    }


    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

        UserHolder.removeUser();    //将用户信息从当前线程中移除
    }

}

在拦截器配置类中,用 order 进行配置对应的拦截器的执行路径以及执行顺序:

package com.hmdp.config;

import com.hmdp.MyInterceptor.EveryInterceptor;
import com.hmdp.MyInterceptor.LoginInterceptor;
import org.springframework.context.annotation.Bean;
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) {

        //1.这里是拦截一切路径的拦截器
        registry.addInterceptor(new EveryInterceptor(stringRedisTemplate))
                .addPathPatterns("/**").order(-1);


        //2.这里是进行拦截需要登录才能访问的拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(   //这里进行配置不需要进行拦截的路径
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);

    }

}


6、Redis 代替 Session 的注意事项

猜你喜欢

转载自blog.csdn.net/qq_66862911/article/details/130663858