Redis实战-基于Session实现分布式登录

1.流程分析

1.1发送短信验证码

提交手机号的时候要进行校验手机号,校验成功才会去生成验证码,将验证码保存到session,发生他把这部分那。

1.2短信验证码登录/注册

如果提交手机号和验证码之后,校验一致才进行根据手机号查询用户,进行创建新用户/登录成功,将信息保存到session进行返回。

1.3校验登录状态

前端传递过来cookie,携带其中的sessionID,从session中获取到用户信息,校验是否存在,存在就将用户保存到TheadLocal,不存在就拦截。

2.实现发送短信验证码

利用一些封装的工具生成验证码和校验手机号。

利用session进行存储验证码,方便校验,比较不错。

java 复制代码
@Override
public Result sendCode(String phone, HttpSession session) {
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误!");
    }
    // 3. 生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 2. 保存验证码到session
    session.setAttribute(LOGIN_CODE_KEY + phone, code);

    // 发送验证码
    // todo 实现发送验证码
    log.debug("发送短信验证码成功: {}", code);

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

3.短信验证码登录

最重要的可能是封装的思想吧,封装一定的常量和借助mybatis-plus的高级的功能。

重点:抽取逻辑,mybatis-plus高级功能。

java 复制代码
/**
 * 实现登录功能
 *
 * @param loginForm
 * @param session
 * @return
 */
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {

    // 1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式不正确");
    }

    // 2. 校验验证码
    // 从session中获取到验证码
    String phoneForCode = (String) session.getAttribute(LOGIN_CODE_KEY + phone);
    // 校验验证码
    String code = loginForm.getCode();
    if (!StrUtil.equals(code, phoneForCode)) {
        return Result.fail("验证码错误!");
    }

    // 3. 查询用户是否存在
    // QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
    // userQueryWrapper.eq("phone", phone);
    // User user = userMapper.selectOne(userQueryWrapper);
    User user = query().eq("phone", phone).one();
    // 不存在则注册
    if (user == null) {
        user = createUserWithPhone(phone);
    }

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

    return Result.ok();
}

/**
 * 封装一个创建用户的逻辑
 *
 * @param phone
 * @return
 */
private User createUserWithPhone(String phone) {
    // 1. 创建用户
    User user = new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    // 2. 保存用户
    save(user);
    return user;
}

4.登录验证功能

需要封装一个登录校验的功能供前端进行调取使用。

重点:通过SpringMVC进行拦截请求,封装数据到ThreadLocal中。

使用SpringMVC统一拦截请求可以方便将数据存储到ThreadLocal中,这样就无需在每个接口中进行配置了十分方便。

ThreadLocal是每个tomcat创建的请求线程中独有的,不会被其它线程访问到的。

封装拦截器,从session中获取到数据即可,最后一定要在请求后拦截器中将ThreadLocal中的数据删除。

java 复制代码
package com.hmdp.utils;

import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.beans.BeanUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

/**
 * 登录拦截器
 */
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取sesson
        HttpSession session = request.getSession();

        // 2. 获取用户信息
        User user = (User) session.getAttribute("user");
        // 3. 处理用户不存在
        if (user == null) {
            response.setStatus(401);
            return false;
        }
        // 4. 存储数据到ThreadLocal
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(user, userDTO);
        UserHolder.saveUser(userDTO);

        return true;
    }

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

将拦截器配置到SpringBoot中,可以进行配置什么路径需要排除,什么无需排除。

java 复制代码
/**
 * MVC拦截器配置
 */
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
                "/shop/**",
                "/shop-type/**",
                "/upload/**",
                "/voucher/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        ).order(1);
    }
}

通过拦截器可以完成登录校验功能。

5.集群session共享问题

多台tomcat是并不进行共享session存储空间的,虽然tomact提供了将session共享到多台tomcat,但是这样性能太差了,还会有很多问题,所以为了进行实现分布式服务器tomcat共享session,建议使用redis进行替代session,这样就可以做到分布式session了。

6.基于redis实现

其实就是使用redis去利用键值对的形式进行存储用户的信息.

key的设计:项目名:业务名:类型:id。

这里只使用项目名,类型和id,使用这种结构式key可以大大的帮助到我们。

其实就是把使用session的部分换为使用redis了,存储key-value,取出key-value都使用redis即可。

重要点:使用token令牌替代了cookie。

使用redis的时候,一定要使用token吗?未必的,只是说将token作为一个帮助客户端和服务端之间进行身份认证的手段,完全也可以进行使用分布式session,使用redis存储session,前端依然携带cookie而来,所以这只是一种手段而已,各有所长。

6.1获取验证码

session => redis。

没有太多亮点,就简单分析一下key的构造吧。

login:code:phone,典型的业务+类型+分辨标识key,这样就很好的能架构出合理的key了。

java 复制代码
public Result sendCode(String phone, HttpSession session) {
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式错误!");
    }
    // 1. 生成验证码
    String code = RandomUtil.randomNumbers(6);
    // 2. 保存验证码到session
    // session.setAttribute(LOGIN_CODE_KEY, code);
    // 2. 保存验证码到redis
    // 构造key
    String key = LOGIN_CODE_KEY + phone;
    stringRedisTemplate.opsForValue().set(key, code, LOGIN_CODE_TTL, TimeUnit.SECONDS);

    // 3. 发送验证码
    // todo 实现发送验证码
    log.debug("发送短信验证码成功: {}", code);

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

6.2注册/登录

java 复制代码
public Result login(LoginFormDTO loginForm, HttpSession session) {

    // 1. 校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        return Result.fail("手机号格式不正确");
    }

    // 2. 校验验证码
    // 从session中获取到验证码
    // String phoneForCode = (String) session.getAttribute(LOGIN_CODE_KEY);
    // 从redis中获取到验证码
    String codeKey = LOGIN_CODE_KEY + phone;
    String phoneForCode = stringRedisTemplate.opsForValue().get(codeKey);
    // 校验验证码
    String code = loginForm.getCode();
    if (!StrUtil.equals(code, phoneForCode)) {
        return Result.fail("验证码错误!");
    }

    // 3. 查询用户是否存在
    // QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
    // userQueryWrapper.eq("phone", phone);
    // User user = userMapper.selectOne(userQueryWrapper);
    User user = query().eq("phone", phone).one();
    // 不存在则注册
    if (user == null) {
        user = createUserWithPhone(phone);
    }

    // 4. 保存信息到session中
    // session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

    // 4. 保存信息到redis中
    // 4.1 随机生成token, 作为登录令牌
    String token = UUID.randomUUID().toString();
    // 4.2 将User对象转换为Hash存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
            CopyOptions.create().setIgnoreNullValue(true)
                    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    // 4.3 存储数据到redis
    String userKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(userKey, userMap);
    // 4.4 设置
    stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

    return Result.ok(token);
}

6.2.1key的设计

其中key的设计是login:token:token,也是遵循的业务+类型+标识的设计思想。

6.2.2token的设计

这里的token很值得分析一下,尤其是可以对比苍穹外卖的进行分析。

这里的key仅仅是用来作为一个获取redis中数据使用的,并不是加密携带payload负载数据的,

其实就是使用了token替代了sessionID,但是其实还是有一个更为方便的解决方法的。

6.2.3存储hash数据到redis中的注意事项

1.使用什么API?

使用的是StringRedisTemplate中的opsForHash.putAll(),这个方法接收两个参数,key => 字符串,value => Map,它可以将Map中的key-value全部存入redis的hash中,十分方便。

2.Map中数据规范是什么样的?

由于我们进行使用的Redis客户端是stringRedisTemplate,这就限制了我们存储hash数据的时候,map中的key-value都必须是string类型的,如果出现了其它类型:比如Long类型,就会抛出错误,所以我们在将DTO转换为Map的时候,必须对value进行处理。

借助hutool工具类中的BeanUtil.beanToMap就可以完成这个操作。

java 复制代码
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
        CopyOptions.create().setFieldValueEditor((filedName, filedValue) -> filedValue.toString()));

6.2.4Redis中Key设置时间的问题

使用stringRedisTemplate中的expire进行设置key的存活时间,传入key,time,TimeUnit即可。

6.3登录认证拦截器

我们需要将以前使用session进行将数据取出存入ThreadLocal的逻辑变更为使用前端传递来的token进行获取数据,从redis中获取用户数据DTO进行使用。

6.3.1思考:没有被SpringIOC托管的对象如何注入Bean

鉴于我们的登录拦截器配置类是我们自定义的,并且没有托管到SpringIOC容器,所以我们不能使用Resouce/Autowired。

那应该怎么办呢?我们发现MVC拦截器配置类是使用@Configuration进行注解的,这个类会被托管到SpringIOC容器,而且我们的自定义拦截器也被该类实例化了一个对象,所以完全可以通过该类将Bean在自定义拦截器实例化的时候,传递进来。

在构造函数被回调的时候,接收StringRedisTemplate对象,进行赋值给自身字段。

java 复制代码
/**
 * 登录拦截器
 */
public class LoginInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

}

将StringRedisTemplate进行注入到MVC配置类中,在调用拦截器构造函数的时候进行注入StringRedisTemplate进去即可。

java 复制代码
/**
 * MVC拦截器配置
 */
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns(
                "/shop/**",
                "/shop-type/**",
                "/upload/**",
                "/voucher/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        ).order(1);
    }
}

6.3.2整体登录校验流程

java 复制代码
/**
 * 登录拦截器
 */
public class LoginInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取sesson
        // HttpSession session = request.getSession();

        // 1. 获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            response.setStatus(401);
            return false;
        }

        // 2. 获取用户信息
        // UserDTO user = (UserDTO) session.getAttribute("user");
        String userKey = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(userKey);

        // 3. 处理用户不存在
        if (userMap.isEmpty()) {
            response.setStatus(401);
            return false;
        }

        // 4. 将用户数据map -> userDTO
        UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 5. 存储数据到ThreadLocal
        UserHolder.saveUser(user);

        // 6. 刷新token有效期
        stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 7. 放行
        return true;
    }


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

1.整体流程:先从request请求头中获取到authorization中的token数据 => 然后封装一个userkey => 然后封装出来一个KEY,在redis客户端中获取到userDTO数据 => 然后处理用户不存在(redis获取不到数据的情况)=> 将用户数据map转换为userDTO => 将用户DTO存储到ThreadLocal中即可 => 最后进行刷新token有效期。

6.3.3刷新token时间

token在一定时间后会过期,但是如果在用户持续使用的过程中过期,那真是一个糟糕的事件,所以要采用拦截器刷新token时间的方式,这样就是在用户持续使用的时候,可以帮用户进行刷新token,延期,不会导致用户持续使用的时候过期。

在拦截器中进行token续期,是一个非常聪明的决策,这里采用的续期策略是,只要发送了请求,在token有效期内还在使用,就将时间续期到原始状态。

java 复制代码
// 6. 刷新token有效期
stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

7.登录拦截器和刷新缓存拦截器如何设置?

7.1拆分的思路

主要是因为有一些请求是打不到登录拦截器的,一些不需要登录的请求根本打不到登录校验拦截器,所以我们不能将刷新token时间放在登录拦截器中去做,因为这样就会有可能用户看的都是不需要登录的接口数据,这样就会导致token无法进行续期,有可能出现用户看着看着就token过期了,所以为了避免这种情况的发生,可以进行设置两个拦截器:1.刷新token拦截器,所有接口都可以进行刷新token请求,当用户进行发送请求之后,从redis中获取到用户的token数据(因为前端会将token传递过来),当查询到数据的时候,就会去更新token时间,如果有数据将数据存储到ThreadLocal中。2.登录状态拦截器,仅仅进行拦截需要登录状态才能进行访问的接口,在拦截器中进行看一下ThreadLocal是否有数据,有就放行,无则滚蛋。

7.2实现token刷新拦截器

java 复制代码
/**
 * 更新拦截器
 */
public class RefreshInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取sesson
        // HttpSession session = request.getSession();

        // 1. 获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }

        // 2. 获取用户信息
        // UserDTO user = (UserDTO) session.getAttribute("user");
        String userKey = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(userKey);

        // 3. 处理用户不存在
        if (userMap.isEmpty()) {
            return true;
        }

        // 4. 将用户数据map -> userDTO
        UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 5. 存储数据到ThreadLocal
        UserHolder.saveUser(user);

        // 6. 刷新token有效期
        stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 7. 放行
        return true;
    }

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

7.3实现登录拦截器

java 复制代码
/**
 * 登录拦截器
 */
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (UserHolder.getUser() == null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }

}

7.4SpringMVC配置拦截器

拦截器的优先级:可以在registry注册的时候进行指定order,里面的排序数越小的越先执行,如果不进行指定优先级,就按代码的顺序进行注册,越靠上进行注册的拦截器,越先执行。

这里将刷新token的拦截器放在最前面,指定的顺序数是最小的。

java 复制代码
/**
 * MVC拦截器配置
 */
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
                "/shop/**",
                "/shop-type/**",
                "/upload/**",
                "/voucher/**",
                "/blog/hot",
                "/user/code",
                "/user/login"
        ).order(1);
        registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}