• 基于 Redis 实现共享 Session 登录


     那么又该选用什么形式的 key 来存储用户数据呢?
            在这我们选择随机的 token 作为 key 来存储用户数据。在短信验证码登录时,我们还需要将这个随机 token 返回给客户端,这是因为后期我们在访问各个页面时都是需要校验登录状态,来判断哪些页面用户可以在未登录状态下访问,哪些页面需要登录后才能访问。

    来看下前端是如何存储 token 的。
    当我们访问接口 /user/login 时,如果访问成功,会将 token 返回给前端登录页面,而前端则会将该 token 保存到 session 中(通过 sessionStorage.setItem 方法)。

     再来看下 common.js
    可以看到,common.js 中将 token 数据保存到了请求头中,该请求头的名字叫做”authorization“,这样在后续所有的 Ajax 请求中,都会在请求头中携带该 token。

             而在此处为什么没有使用手机号作为 token 呢?这是因为 token 需要保存在客户端,如果以手机号作为 token,会有泄露用户隐私的风险。   

    代码实现

    1. public class UserHolder {
    2. private static final ThreadLocal tl = new ThreadLocal<>();
    3. public static void saveUser(UserDTO user){
    4. tl.set(user);
    5. }
    6. public static UserDTO getUser(){
    7. return tl.get();
    8. }
    9. public static void removeUser(){
    10. tl.remove();
    11. }
    12. }
    1. package com.hmdp.service.impl;
    2. import cn.hutool.core.bean.BeanUtil;
    3. import cn.hutool.core.lang.UUID;
    4. import cn.hutool.core.util.RandomUtil;
    5. import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
    6. import com.hmdp.dto.LoginFormDTO;
    7. import com.hmdp.dto.Result;
    8. import com.hmdp.dto.UserDTO;
    9. import com.hmdp.entity.User;
    10. import com.hmdp.mapper.UserMapper;
    11. import com.hmdp.service.IUserService;
    12. import com.hmdp.utils.RegexUtils;
    13. import lombok.extern.slf4j.Slf4j;
    14. import org.springframework.beans.factory.annotation.Autowired;
    15. import org.springframework.data.redis.core.StringRedisTemplate;
    16. import org.springframework.stereotype.Service;
    17. import javax.servlet.http.HttpSession;
    18. import java.util.Map;
    19. import java.util.concurrent.TimeUnit;
    20. import static com.hmdp.utils.RedisConstants.*;
    21. import static com.hmdp.utils.SystemConstants.USER_NICK_NAME_PREFIX;
    22. @Slf4j
    23. @Service
    24. public class UserServiceImpl extends ServiceImpl implements IUserService {
    25. @Autowired
    26. private StringRedisTemplate stringRedisTemplate;
    27. @Override
    28. public Result sendCode(String phone, HttpSession session) {
    29. // 1、校验手机号
    30. if (RegexUtils.isPhoneInvalid(phone)) {
    31. // 2、如果不符合,返回错误信息
    32. return Result.fail("手机号格式错误!");
    33. }
    34. // 3、符合,生成验证码
    35. String code = RandomUtil.randomNumbers(6);
    36. // 4、保存验证码到 Redis 中
    37. stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    38. // 5、发送验证码
    39. log.debug("发送短信验证码成功,验证码:{}", code);
    40. return Result.ok();
    41. }
    42. @Override
    43. public Result login(LoginFormDTO loginForm, HttpSession session) {
    44. // 1、校验手机号
    45. String phone = loginForm.getPhone();
    46. String code = loginForm.getCode();
    47. if (RegexUtils.isPhoneInvalid(phone)) {
    48. return Result.fail("手机号格式错误!");
    49. }
    50. // 2、从 Redis 中获取验证码
    51. String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    52. // 3、不一致,报错
    53. if(cacheCode == null || !cacheCode.equals(code)){
    54. return Result.fail("验证码错误!");
    55. }
    56. // 4、一致,根据手机号去查询用户
    57. User user = query().eq("phone", phone).one();
    58. // 5、判断用户是否存在
    59. if(user == null){
    60. // 6、不存在,创建新用户并保存
    61. user = createUserWithPhone(phone);
    62. }
    63. // 7、将用户信息保存到 Redis 中
    64. // 7.1 生成 token,此处使用 UUID 作为 token
    65. String tokenKey = UUID.randomUUID().toString(true);
    66. // 7.2 将 User 对象转为 HashMap 存储
    67. UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    68. Map userMap = BeanUtil.beanToMap(userDTO);
    69. // 7.3 将用户信息存储到 Redis 中
    70. stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + tokenKey, userMap);
    71. // 7.4 设置过期时间
    72. stringRedisTemplate.expire(LOGIN_USER_KEY + tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
    73. // 将 token 返回给客户端
    74. return Result.ok(tokenKey);
    75. }
    76. /**
    77. * 根据手机号创建用户
    78. * */
    79. private User createUserWithPhone(String phone) {
    80. // 创建用户
    81. User user = new User();
    82. user.setPhone(phone);
    83. user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    84. // 保存用户
    85. save(user);
    86. return user;
    87. }
    88. }

    代码实现分析:
    1、sendCode 方法,将 session 存储验证码改为 Redis 存储,同时设置过期时间
    2、login 方法中,将 session 存储用户信息,修改为 Redis 存储,value 值采用 Hash 类型,同时设置过期时间,模拟 session 过期时间。但是 session 过期是在用户未作任何操作的情况下,而 Redis 则是从用户登录开始计时,到指定时间后自动过期。我们应当保证只要用户在不断访问,就不断更新 Redis 中的 token 过期时间。那我们如何知道用户什么时候访问,有没有访问呢?我们所有的请求都要经过拦截器进行校验,只要同过了拦截器的校验就说明用户是已经登录的且在活跃的状态。那么我们就可以在拦截器中对 token 的过期时间进行刷新操作。只有什么都不操作的情况下,才不会走拦截器的校验,也就不会刷新 token 的过期时间。

    修改拦截器
    这里要注意一点就是,LoginInterceptor 是我们自定义的一个类,并非 Spring 进行管理的类,所以在使用 StringRedisTemplate 的时候,无法使用 @Autowired 或者 @Resource 进行注入。但是,MvcConfig 是由 Spring 进行管理的,可以由 Spring 注入 StringRedisTemplate 的实例

    1. package com.hmdp.utils;
    2. import cn.hutool.core.bean.BeanUtil;
    3. import cn.hutool.core.util.StrUtil;
    4. import com.hmdp.dto.UserDTO;
    5. import org.springframework.data.redis.core.StringRedisTemplate;
    6. import org.springframework.web.servlet.HandlerInterceptor;
    7. import javax.servlet.http.HttpServletRequest;
    8. import javax.servlet.http.HttpServletResponse;
    9. import java.util.Map;
    10. import java.util.concurrent.TimeUnit;
    11. public class LoginInterceptor implements HandlerInterceptor {
    12. private StringRedisTemplate stringRedisTemplate;
    13. public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
    14. this.stringRedisTemplate = stringRedisTemplate;
    15. }
    16. @Override
    17. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    18. // 1、从请求头中获取 token
    19. String token = request.getHeader("authorization");
    20. // 2、判断token是否为空
    21. if (StrUtil.isBlank(token)) {
    22. // 不存在报401
    23. response.setStatus(401);
    24. return false;
    25. }
    26. // 3、根据 token 从 redis 中获取用户信息
    27. String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
    28. // 使用 entries 方法获取所有的 field-value
    29. Map userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
    30. // 4、判断 userMap 是否为空
    31. if (userMap.isEmpty()) {
    32. // 不存在报401
    33. response.setStatus(401);
    34. return false;
    35. }
    36. // 4、将 userMap 转换为 UserDTO
    37. UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
    38. // 5、将 user 保存到 ThreadLocal 中
    39. UserHolder.saveUser(user);
    40. // 6、刷新 token 过期时间
    41. stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
    42. return true;
    43. }
    44. @Override
    45. public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    46. UserHolder.removeUser();
    47. }
    48. }

    修改 MvcConfig 配置类

    在 MvcConfig 中注入 StringRedisTemplate,同时在添加拦截器时,将 StringRedisTemplate 的实例通过 LoginInterceptor 的构造器传入。

    1. package com.hmdp.config;
    2. import com.hmdp.utils.LoginInterceptor;
    3. import org.springframework.beans.factory.annotation.Autowired;
    4. import org.springframework.context.annotation.Configuration;
    5. import org.springframework.data.redis.core.StringRedisTemplate;
    6. import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    7. import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    8. @Configuration
    9. public class MvcConfig implements WebMvcConfigurer {
    10. @Autowired
    11. private StringRedisTemplate stringRedisTemplate;
    12. @Override
    13. public void addInterceptors(InterceptorRegistry registry) {
    14. registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
    15. .excludePathPatterns(
    16. "/shop/**",
    17. "/voucher/**",
    18. "/user/login",
    19. "/user/code",
    20. "/shop-type/**",
    21. "/upload/**"
    22. );
    23. }
    24. }

    重启项目后点击登录时,发现后台报了如下的错误。

     这是因为 userMap 中的 id 为 Long 类型,但是 Redis 中存储的都是 String 类型。

    login 方法改进,将 UserDTO 实例转换为 HashMap 时,将每一个属性转换为 String 类型。

    1. @Override
    2. public Result login(LoginFormDTO loginForm, HttpSession session) {
    3. // 1、校验手机号
    4. String phone = loginForm.getPhone();
    5. String code = loginForm.getCode();
    6. if (RegexUtils.isPhoneInvalid(phone)) {
    7. return Result.fail("手机号格式错误!");
    8. }
    9. // 2、从 Redis 中获取验证码
    10. String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    11. // 3、不一致,报错
    12. if(cacheCode == null || !cacheCode.equals(code)){
    13. return Result.fail("验证码错误!");
    14. }
    15. // 4、一致,根据手机号去查询用户
    16. User user = query().eq("phone", phone).one();
    17. // 5、判断用户是否存在
    18. if(user == null){
    19. // 6、不存在,创建新用户并保存
    20. user = createUserWithPhone(phone);
    21. }
    22. // 7、将用户信息保存到 Redis 中
    23. // 7.1 生成 token,此处使用 UUID 作为 token
    24. String tokenKey = UUID.randomUUID().toString(true);
    25. // 7.2 将 User 对象转为 HashMap 存储
    26. UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    27. Map userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
    28. CopyOptions.create().setIgnoreNullValue(true).
    29. setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    30. // 7.3 将用户信息存储到 Redis 中
    31. stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + tokenKey, userMap);
    32. // 7.4 设置过期时间
    33. stringRedisTemplate.expire(LOGIN_USER_KEY + tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
    34. // 将 token 返回给客户端
    35. return Result.ok(tokenKey);
    36. }

    总结

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

    • 选择合适的数据结构
    • 选择合适的 key
    • 选择合适的存储粒度

    登录拦截器的优化

            登录功能是基于拦截器做的校验功能,但是当前拦截器拦截的并不是所有的路径,而是拦截的需要登录的路径,如果用户登录后,一直访问的是首页这种不需要拦截的路径,那么拦截器就会一直不执行,token 的过期时间就不会刷新,那么当 token 过期后,用户访问例如像个人主页时就会出现问题,很不友好。那如何解决?可以在当前拦截器的基础再添加一个拦截器,让新的拦截器拦截一切路径,在该拦截内做 token 的刷新动作。 

     创建 RefreshTokenInterceptor 拦截器

    该拦截器用于拦截所有请求,并刷新 token 过期时间

    1. package com.hmdp.utils;
    2. import cn.hutool.core.bean.BeanUtil;
    3. import cn.hutool.core.util.StrUtil;
    4. import com.hmdp.dto.UserDTO;
    5. import org.springframework.data.redis.core.StringRedisTemplate;
    6. import org.springframework.web.servlet.HandlerInterceptor;
    7. import javax.servlet.http.HttpServletRequest;
    8. import javax.servlet.http.HttpServletResponse;
    9. import java.util.Map;
    10. import java.util.concurrent.TimeUnit;
    11. public class RefreshTokenInterceptor implements HandlerInterceptor {
    12. private StringRedisTemplate stringRedisTemplate;
    13. public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
    14. this.stringRedisTemplate = stringRedisTemplate;
    15. }
    16. @Override
    17. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    18. // 1、从请求头中获取 token
    19. String token = request.getHeader("authorization");
    20. // 2、判断token是否为空
    21. if (StrUtil.isBlank(token)) {
    22. return true;
    23. }
    24. // 3、根据 token 从 redis 中获取用户信息
    25. String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
    26. Map userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
    27. // 4、判断 userMap 是否为空
    28. if (userMap.isEmpty()) {
    29. return true;
    30. }
    31. // 4、将 userMap 转换为 UserDTO
    32. UserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
    33. // 5、将 user 保存到 ThreadLocal 中
    34. UserHolder.saveUser(user);
    35. // 6、刷新 token 过期时间
    36. stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
    37. return true;
    38. }
    39. @Override
    40. public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    41. UserHolder.removeUser();
    42. }
    43. }

    修改 LoginInterceptor 拦截器

    LoginInterceptor 则只需要判断当前访问用户是否已经登录,已经登录则放行,未登录则拦截。

    1. package com.hmdp.utils;
    2. import cn.hutool.core.bean.BeanUtil;
    3. import cn.hutool.core.util.StrUtil;
    4. import com.hmdp.dto.UserDTO;
    5. import org.springframework.data.redis.core.StringRedisTemplate;
    6. import org.springframework.web.servlet.HandlerInterceptor;
    7. import javax.servlet.http.HttpServletRequest;
    8. import javax.servlet.http.HttpServletResponse;
    9. public class LoginInterceptor implements HandlerInterceptor {
    10. @Override
    11. public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    12. // 判断当前用户是否登录
    13. if (UserHolder.getUser() == null) {
    14. // 未登录,设置状态码
    15. response.setStatus(401);
    16. // 拦截
    17. return false;
    18. }
    19. return true;
    20. }
    21. }

    MvcConfig 设置拦截规则

    拦截器执行顺序应当是先执行 RefreshTokenInterceptor,而后再执行 LoginInterceptor,通过 orde 方法设置拦截器执行顺序,值越小,则执行顺序越优先。

    1. package com.hmdp.config;
    2. import com.hmdp.utils.LoginInterceptor;
    3. import com.hmdp.utils.RefreshTokenInterceptor;
    4. import org.springframework.beans.factory.annotation.Autowired;
    5. import org.springframework.context.annotation.Configuration;
    6. import org.springframework.data.redis.core.StringRedisTemplate;
    7. import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    8. import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    9. import javax.annotation.Resource;
    10. @Configuration
    11. public class MvcConfig implements WebMvcConfigurer {
    12. @Autowired
    13. private StringRedisTemplate stringRedisTemplate;
    14. @Override
    15. public void addInterceptors(InterceptorRegistry registry) {
    16. registry.addInterceptor(new LoginInterceptor())
    17. .excludePathPatterns(
    18. "/shop/**",
    19. "/voucher/**",
    20. "/user/login",
    21. "/user/code",
    22. "/shop-type/**",
    23. "/upload/**"
    24. ).order(1);
    25. registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    26. }
    27. }

  • 相关阅读:
    Kubernetes调度器:资源分配与优化之道
    6种应用部署策略对比
    golang 发起 http 请求,获取访问域名的 ip 地址(net, httptrace)
    java源码系列:链表是什么?数组和它有何不同?(2022-07-28更新完毕)
    TeamViewer 可信设备的信任管理
    贷款五级分类
    (附源码)基于微服务架构的餐饮系统的设计与实现-计算机毕设 86393
    mysql——mysqlbinlog
    【基本数据结构 三】线性数据结构:栈
    分享大数据培训班班型
  • 原文地址:https://blog.csdn.net/weixin_51472505/article/details/126360496