Redis实战篇(一:项目导入和短信登录)

目录

一、实战内容概述

1.导入 SQL

2.有关当前模型 

3.导入后端项目

4.导入前端项目

二、短信登录

1.基于 Session 实现登录流程

2.实现发送短信验证码

3.实现短信验证码登录和注册

4.实现登录校验功能

5.隐藏用户敏感信息

6.集群的 session 共享问题

7.Redis 替代 session 的业务流程

8.基于 Redis 实现短信登录

(1)发送验证码

(2) 短信验证码登录、注册

(3)登录状态校验

(4)解决状态登录刷新问题


一、实战内容概述

32ad3971166a49179410b967b907eb94.png

导入项目:

有关资料,可以进入百度网盘下载(提取码:eh11):百度网盘 请输入提取码百度网盘为您提供文件的网络备份、同步和分享服务。空间大、速度快、安全稳固,支持教育网加速,支持手机端。注册使用百度网盘即可享受免费存储空间icon-default.png?t=O83Ahttps://pan.baidu.com/s/1189u6u4icQYHg_9_7ovWmA&pwd=eh11

1.导入 SQL

 创建一个 database 为 hmdp,然后将资料中的 hmdp.sql 中的 sql 语句执行一遍即可。

f0bb3b4bdcbb4a69952f006199d80b58.png

35699ade734045f98dc68674df4f4d2e.png

说明
tb_user用户表
tb_user_info用户详情表
tb_shop商户信息表
tb_shop_type商户类型表
tb_blog用户日记表(达人探店日记)
tb_follow用户关注表
tb_voucher优惠券表
tb_voucher_order优惠券的订单表

2.有关当前模型 

该项目采用的是前后端分离开发模式

后端部署在 tomcat 上,前端会部署在 nginx 服务器上。

移动端 / PC 端发起请求时,也就是向 nginx 发起请求,nginx 再向服务端发起请求去查询数据,数据可能来自 Redis 集群,也可能来自 Mysql 集群。再把查询到的数据返回给前端,前端完成渲染即可。

虽然本项目是单体项目,但是将来也会考虑到项目的一个并发能力,所以该项目必须具备水平扩展的能力。

498a8a9dcb83440d8885287f9a633c18.png

所以我们需要再多台 tomcat 上都来部署我们的代码,形成一个集群。如果单台 tomcat 扛不住压力,nginx 就可以通过负载均衡访问其他 tomcat。

但一旦形成集群,将来就会存在集群间数据共享的一些问题,所以我们后续会进行分析和解决。

3.导入后端项目

将资料中的 hm-dianping.zip 解压之后,放在我们自己的 workspace 里即可。

9bf21bbece2743dc9be4776f975ac6ae.png

注意:需要修改 application.yml 中的 MySQL 和 Reids 的连接要素为自己的

21bfb5e796ce48a490444e0188a14337.png

 启动项目,访问地址:http://localhost:8081/shop-type/list,如果可以看到JSON数据,则说明导入成功。

a2ac361efd81480d848833cb7c35e078.png

4.导入前端项目

将资料中的 nginx-1.18.0.zip 解压之后,放在任意目录下即可(不含中文、特殊字符和空格)。

b4791e35d2844920841d9b6da9984e1a.png

 然后在 nginx 所在目录打开一个 cmd 窗口,输入命令:start nginx.exe,即可启动项目

4a503ca1ce554bd3a95e6d1612ac96a7.png

1d57975308f7448abcef1a11d07b7061.png

浏览器中 F12 打开开发者模式,然后打开手机模式:

e686bff06aae4f99a3238a4cc48db8d9.png

访问地址:http://localhost:8080/,能够看到页面,说明项目部署成功

54ad6efe5f0b4d42b91e4ad9709a2587.png

二、短信登录

1.基于 Session 实现登录流程

① 发送验证码:

  • 用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号
  • 如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存,然后再通过短信的方式将验证码发送给用户。

2ef13dda574b422fa079fa0f40c44441.png

② 短信验证码登录、注册:

  • 用户将验证码和手机号进行输入,后台从 session 中拿到当前验证码,然后和用户输入的验证码进行校验。如果不一致,则无法通过校验;如果一致,则后台根据手机号查询用户。
  • 如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到 session 中,方便后续获得当前登录信息。

c4fc9dfd9c634deeb706c112fd94cfd1.png

 ③ 校验登录状态:

  • 用户在登录成功后,在访问某些关键业务的时候,我们都需要进行登录状态的校验
  • 用户在请求的时候,会从 cookie 中携带 JsessionId 到后台,后台通过 JsessionId 从 session中拿到用户信息,如果没有 session 信息,则进行拦截,如果有 session 信息,则将用户信息保存到 threadLocal 中,并放行。

a654834b2a284611b602b787a91d2915.png

2.实现发送短信验证码

e4592540d8b84435b953f20e5ce9eb1e.png

在请求头中,我们可以看到:

  • 请求网址: http://localhost:8080/api/user/code?phone=13566775566
  • 请求方法: POST 

很显然,是调用 UserController 中的 code 方法,携带参数是 phone。

e0bb90fbbd84474d8099d8a1bf06b8d6.png

 接下来,我们修改 UserController

    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }

我们将发送验证码的业务放在 service 层,所以要在 IUserSevice 中创建该方法:

public interface IUserService extends IService<User> {

    Result sendCode(String phone, HttpSession session);
}

同时,实现类中也需要重写该方法:

@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.校验手机号(利用util下RegexUtils进行正则验证)
        if(RegexUtils.isPhoneInvalid(phone)){
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式不正确!");
        }
        // 3.符合,生成验证码(hutool工具包中的RandomUtil)
        String code = RandomUtil.randomNumbers(6);
        // 4.保存验证码到session
        session.setAttribute("code",code);
        // 5.发送验证码(暂时不接入第三方短信 API 接口)
        log.debug("发送短信验证码成功,验证码为:{}",code);
        // 结束,返回ok
        return Result.ok();
    }
}

重启项目,刷新浏览器,再次发送验证码:

bd1408955a9c4ee78990d08ccb9d7464.png

0417ca9b6d864366ad1982d5b2eec94e.png

 控制台中打印出了生成的验证码,发送验证码功能完成!

3.实现短信验证码登录和注册

6c6bafc6dc854317ac9648fbf94535e2.png

 UserController:

    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
        // 实现登录功能
        return userService.login(loginForm, session);
    }

 IUserService:

public interface IUserService extends IService<User> {

    Result sendCode(String phone, HttpSession session);

    Result login(LoginFormDTO loginForm, HttpSession session);
}

UserServiceImpl:

@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    ...

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号(有可能短信获取验证码时手机号是对的 登录时填个错的)
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 如果不符合 返回错误信息
            return Result.fail("手机号格式错误");
        }
        // 2.校验验证码
        String cacheCode = (String) session.getAttribute("code");// 获取保存在session中的code
        String code = loginForm.getCode();// 获取用户输入的code
        if (cacheCode == null || !cacheCode.equals(code)) {
            // 3.验证码不一致,报错
            return Result.fail("验证码错误");
        }
        // 4.一致,根据手机号查询用户
        User user = query().eq("phone", phone).one();
        // 5.判断用户是否存在
        if (null == user) {
            // 6.不存在,创建用户并且保存
            user = createUserWithPhone(phone);
        }
        // 7.保存用户到session
        session.setAttribute("user", user);
        return Result.ok();
    }

    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;
    }
}

启动项目,刷新网址,验证登录功能。

我们发现,登录成功后,页面一闪而过,又回到了未登录的页面。

打开控制台,发现已经插入成功,证明登录没有任何问题。

36ee63d37f4a46679f17acd34089b290.png

这是因为,登录状态校验还没有实现,所以登录成功后,校验异常,就自动退出了登录。

4.实现登录校验功能

用户的登录凭证就是 JSessionId,而这个 JSessionId 就存在 cookie 中。

当请求携带 cookie 到服务端,服务端需要通过 cookie 中的 JSessionId 找到 session,再从 session 中找到存入的 user 信息,就可以判断用户是否存在。 

f1bb81a9b6e449a3809ea34fc6734819.png

为了校验登录状态是否正常,我们需要在 UserController 中定义一个方法,处理该业务。 

dc2a1d2f19cb4c44b596be3cd94af743.png

但是后续随着业务开发,对于其他模块也有可能需要校验登录状态,我们不可能在每一个 controller 中实现相同的业务逻辑,这样会造成代码冗余。

b07badae650943edbd201996285afd17.png

此时,就会联想到拦截器,我们可以将登录校验的操作放在拦截器中进行, 它会在所有 controller 执行之前触发!

9b3d3b9a97844459aaff2f14a0afa7be.png

但同时又产生了其他问题:

拦截器确实可以完成校验,但在后续的 controller 中,我们是需要用户信息的,拦截器该如何将这个用户信息传递给后续的 controller 呢?传递过程中的线程安全问题又该如何保障?

我们之前提到了 ThreadLocal:

  • 每个用户其实对应都是去找 tomcat 线程池中的一个线程来完成工作的, 使用完成后再进行回收
  • 每个请求都是独立的,在每个用户去访问工程时,可以使用 threadlocal 来做到线程隔离,每个线程操作自己的一份数据
  • 在 threadLocal 中,无论是 put 方法和 get 方法, 都是获得当前用户的线程,然后从线程中取出线程的成员变量 map
  • 只要线程不一样,map 就不一样,所以可以通过这种方式来做到线程隔离

43724995fd8947209620c36cd1214115.png

 LoginInterceptor:

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取session
        HttpSession session = request.getSession();
        // 2.获取session中的用户信息
        User user = (User) session.getAttribute("user");
        // 3.判断用户是否存在
        if (user == null) {
            // 4.不存在,则拦截,返回401状态码(未授权)
            response.setStatus(401);
            return false;
        }
        // 5.存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
        UserHolder.saveUser(user);
        // 6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //业务执行完毕,销毁ThreadLocal中的用户信息,避免内存泄露
        UserHolder.removeUser();
    }
}

注意:提供的资料中 UserHolder 中定义的 user 是 UserDTO 类型,因为后续会改。这里暂时全部将其改为 User 类型,相关调用处也改成 User 类型,就不会报错了。

MvcConfig:

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

 UserController:

    @GetMapping("/me")
    public Result me() {
        // 获取当前登录的用户并返回
        User user = UserHolder.getUser();
        return Result.ok(user);
    }

重启项目,运行结果:

f6a15ea0f35f40e69b46b5a19663ec95.png

可以看到,登录校验功能已经成功实现,并且返回了用户信息。 

5.隐藏用户敏感信息

从上面的结果中可以看到,虽然用户信息返回了,但是返回了用户全部的信息,甚至连密码都有!

这些敏感信息对外暴露是极其不安全的,我们需要隐藏这些敏感信息。

归根究底,这是因为我们在向 Session 中保存用户信息时,保存的就是一个完整的 User 对象。

78b8ef9cb887490fbfc9875dab27b358.png

所以,我们需要保存的时候,隐藏某些信息。

我们有一个 UserDTO 类,使用 hutool 工具包中的 BeanUtil 类的 copyProperties 方法,可以将User 对象按属性自动拷贝到 UserDTO 对象当中。

0e358dc2c63d4e33a0e542f299e2b1f5.png

同时,因为此时存的是 UserDTO 对象,所以在拦截器中取出的 user 也应该是 UserDTO 类型,存进 ThreadLocal 的对象类型也应该是 UserDTO 类型。

e27f7c320741475ca87e1bb733f649d6.png

此时,UserHolder 中定义的 user 也要全部改回 UserDTO 类型,相关调用处也改回 UserDTO 类型。

b10d8d94b56f4e9f9bdcca26e9342187.png

34fd075c4115478c8f851e0cee682b2a.png

重启项目,运行结果:

263da26332fc41cfa0759a5f344da2e8.png

可以看出,获取到的用户信息中已经隐藏了敏感信息, 同时这样也可以减小内存占用。

6.集群的 session 共享问题

session 共享问题:多台 Tomcat 并不共享 session 存储空间,当请求切换到不同的 tomcat 服务器时,导致数据丢失的问题。

ac761e2fd91b424eb7cc84dbad75c017.png

每个 tomcat 中都有一份属于自己的 session,假设用户第一次访问第一台 tomcat,并且把自己的信息存放到第一台服务器的 session 中。

但是第二次这个用户访问到了第二台 tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的 session,所以此时整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?

早期的解决方案: session 拷贝,就是说虽然每个 tomcat 上都有不同的 session,但是每当任意一台服务器的 session 修改时,都会同步给其他的 Tomcat 服务器的 session,这样的话,就可以实现 session 的共享了。

缺点:

  • 每台服务器中都有完整的一份 session 数据,造成资源浪费,同时服务器压力过大。
  • session 拷贝数据时,可能会出现延迟,可能会出现数据不一致。

所以,我们不得不换一种 session 的替代方案,它应该满足:

  • 数据共享
  • 内存存储(session就是基于内存存储的,登录校验这种操作的访问频率非常高,如果读写性能较差,很难满足高并发的需求) 
  • key、value 结构

综上,基于以上三点,我们不难想到,这个替代方案就是 Redis!!! 

0fd065ce79b44f488f72a2adb9a7e998.png

7.Redis 替代 session 的业务流程

因为 Redis 是 key,value 结构的,所以每次在存入数据时,我们都需要考虑:

  • key 的命名:
    • 唯一性
    • 方便携带(因为要根据 key 取 value)
  • value 的数据结构
  • 数据的有效期

① 对于验证码:

value 的数据结构验证码是一个 6 位数随机的字符串,所以我们用 string 存储即可、

key 的命名不能像 session一样用 code 来当做 key,因为 redis 是数据共享的,多个用户验证码的 key 都是 code 就乱套了。

我们需要确保每一个用户使用手机号发送验证码时,存储验证码的 key 都是不一样的。所以,我们干脆使用手机号 phone 来作为验证码的 key。

这样的话,不仅可以保证 key 的唯一性,还可以有助于将来获取验证码进行验证。

过去我们使用 session,可以通过 cookie 找到 sessionId。但现在是 redis,我们需要手动用 key 获取 value。

而我们在登录时,刚好使用的就是手机号和验证码,所以正好可以用手机号作为 key 来取出验证码!

0d65e061d0434a1990b4c67d4dc7887f.png

② 对于用户信息:

value 的数据结构用户信息是一个对象,为了方便后续进行修改,推荐使用 Hash 结构

df25eebfcbaf4d678db4d7b13978202c.png

key 的命名:这里用手机号 phone 当然也可以,但是一般不推荐。我们在设计 key 的命名时,不光要考虑唯一性,还要考虑方不方便携带。

一般情况下,建议生成一个随机的 token 作为 key 存储用户数据,这样唯一性的要求就满足了。

此外,我们还要考虑到,后续进行登录校验时,我们需要根据 key 来获取用户数据进行校验。

 在过去登录校验时,我们的请求会携带 cookie,从 cookie 中获取 sessionId,找到 session 进而找到用户信息。因为现在已经没有 session 了,所以我们登录校验时携带的登录凭证就不再是 sessionId,而是这个 token了。

但登录校验时如何取到这个 token 进行携带呢?它并不像 sessionId 一样,浏览器会帮我们自动维护,所以我们只能手动保存,然后手动获取。为此,登录完成后,我们还需要将 token 返还给客户端进行保存。

8733c12d67274d5da2fca1f363c0ce9e.png

 前端是如何做到每次请求都会携带 token 的呢?

06b2b99a53754a4b8f7c92d2a73c839a.png

481050569c284b2cbcadccee8252f218.png

同时,这又解释了为什么不能用手机号作为 key,如果我们将手机号存在浏览器中,就会存在一定的安全性问题,可能会有泄露的风险。 

8.基于 Redis 实现短信登录

(1)发送验证码

 UserServiceImpl 中修改 sendCode 方法

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.校验手机号(利用util下RegexUtils进行正则验证)
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式不正确!");
        }
        // 3.符合,生成验证码(hutool工具包中的RandomUtil)
        String code = RandomUtil.randomNumbers(6);
        // 4.保存验证码到redis当中
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        // 5.发送验证码(暂时不接入第三方短信 API 接口)
        log.debug("发送短信验证码成功,验证码为:{}", code);
        // 结束,返回ok
        return Result.ok();
    }

这里的 key 使用 "login:code" 的前缀形式,并设置有效期 2 分钟,我们可以定义一个常量类来替换这里的 "login:code" 和 2 ,让代码显得更专业一点。

(2) 短信验证码登录、注册

UserServiceImpl 中修改 login 方法

@Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号(有可能短信获取验证码时手机号是对的,登录时填个错的)
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 如果不符合 返回错误信息
            return Result.fail("手机号格式错误");
        }
        // 2.从redis中获取验证码并校验
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();// 获取用户输入的code
        if (cacheCode == null || !cacheCode.equals(code)) {
            // 3.验证码不一致,报错
            return Result.fail("验证码错误");
        }
        // 4.一致,根据手机号查询用户
        User user = query().eq("phone", phone).one();
        // 5.判断用户是否存在
        if (null == user) {
            // 6.不存在,创建用户并且保存
            user = createUserWithPhone(phone);
        }
        // 7.保存用户信息到redis中
        // 7.1.随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString(true);//不带中划线
        // 7.2.将User对象转为HashMap
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
        // 7.3.存储
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // 7.4.设置token有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.返回token给客户端
        return Result.ok(token);
    }

我们这里参考 session,也给 token 设置了一个有效期,时间为 30 分钟。

但 session 失效的前提是 30 分钟不访问,才会失效,一旦访问,时间就会重新计算。而我们这里的时间就是彻彻底底的 30 分钟后失效,不合理,我们也希望做到只要有请求访问,就会重置时间。

我们不难想到,可以通过拦截器,只要用户经过登录校验的拦截器,就证明他处于登录状态,我们就可以重置有效期。

这个重置有效期的操作,我们就放在下方和登录拦截校验一起实现。

(3)登录状态校验

 LoginInterceptor:

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.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            // 不存在,则拦截,返回401状态码(未授权)
            response.setStatus(401);
            return false;
        }
        // 2.基于token获取redis中的用户信息
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 3.判断用户是否存在(不能直接判断等于null,如果没数据entries方法会返回一个空map)
        if (userMap.isEmpty()) {
            // 4.不存在,则拦截,返回401状态码(未授权)
            response.setStatus(401);
            return false;
        }
        // 5.将查询到的Hash数据转为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
        // 6.存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //业务执行完毕,销毁ThreadLocal中的用户信息,避免内存泄露
        UserHolder.removeUser();
    }
}

注意:

在拦截器 LoginInterceptor 类中,不能使用 @Resource 注解直接注入 StringRedisTemplate 对象。

因为 LoginInterceptor 类的对象是我们在 MvcConfig 中手动 new 出来的,不是由 spring 创建的,所以拦截器对象不在 springIOC 容器中,自然也就不能使用 spring 的相关注解。


同时,由于 LoginInterceptor 的构造函数发生修改,MvcConfig 也需要修改:

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

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

由于 MvcConfig 有 @Configuration 注解,已经加入了 ioc 容器,所以这里可以直接使用 @Resource 注解注入 StringRedisTemplate 对象。

运行结果:

我们发现运行出错,显示服务器异常,打开控制台:

控制台显示我们在存入数据的时候,类型转换异常,long 类型无法转换为 string 类型。 

在我们的 userDTO 对象里,只有 id 是 long 类型的,说明是它出现了问题,为什么呢?

原因分析:

我们使用的是 StringRedisTemplate,它限定了 RedisTemplate 的 key 和 value 都只能是 String 类型,也就是只能操作 String 类型的数据,因为它使用 StringRedisSerializer 作为序列化器。

在之前基础篇的案例里,我们可以将 user 对象存进去,是因为在存之前我们使用 ObjectMapper 将 user 对象序列化成了 json 字符串,这个字符串是 String 类型,所以满足要求。

在这里,我们传入的是一个 userMap,它在底层是将 userMap 中的 key 和 value 作为Redis 中的 field 和 value 进行操作的。

  • userMap 中的 key 也就是 user 的属性名("id","nickname","icon"),是 String 类型满足要求。
  • userMap 中的 value 也就是 user 的属性值,所以这里的 id 值是一个 long 类型,显然不满足只能操作 String 类型的数据的要求。

所以,为了满足要求,我们必须要将 userMap 中的 value 全部转变成 String类型:

Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        // 忽略空的值
                        .setIgnoreNullValue(true)
                        // 修改字段值 字段名 字段值 -> 修改后的字段名 修改后的字段值
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

 此时,重启项目,就可以正常运行了

我们发现,每次校验登录状态的时候,请求头中就会携带一个名叫 authorization 的 token。

我们查看 redis 数据库,也能够清晰的看到存储了两条数据 code 和 token。

(4)解决状态登录刷新问题

上面我们已经完成了 token 有效期实时刷新的问题,也就是只要用户一直在访问页面,就可以一直刷新 token 的有效期。

但事实上,真的完成了吗?

我们的刷新逻辑写在了 LoginInterceptor 中,它只拦截需要登录校验的那些路径。

对于商品浏览这些不需要验证登录状态的路径,它并没有做出拦截,自然也就不会刷新 token 的有效期。

所以,这个功能还并没有完全实现,我们需要优化!

我们可以新增一个拦截器,在第一个拦截器里面拦截一切路径,负责刷新 token 有效期。第二个拦截器 LoginInterceptor 根据 ThreadLocal 是否存在用户信息决定放不放行。

RefreshTokenInterceptor(刷新token全局拦截器):

public class RefreshTokenInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;

    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");
        if (StrUtil.isBlank(token)) {
            // 没有token,提前放行
            return true;
        }
        // 2.基于token获取redis中的用户信息
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(tokenKey);
        // 3.判断用户是否存在(不能直接判断等于null,如果没数据entries方法会返回一个空map)
        if (userMap.isEmpty()) {
            // 4.不存在,则拦截,返回401状态码(未授权)
            response.setStatus(401);
            return false;
        }
        // 5.将查询到的Hash数据转为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap,new UserDTO(),false);
        // 6.存在,保存用户信息到ThreadLocal,UserHolder是提供好了的工具类
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //业务执行完毕,销毁ThreadLocal中的用户信息,避免内存泄露
        UserHolder.removeUser();
    }
}

Question:这里有人会疑惑,为什么 token 为空,就直接放行了呢?放行不就没有刷新 token 了吗?

我一开始也很疑惑,有的人说放行意味着它不需要登录校验,所以就没有 token,那这样解释又回到前面的逻辑了,不需要登录校验就不刷新有效期。所以这个解释是错的!!!

其实原因很简单,因为第一个拦截器只负责刷新有效期,不用管其他的。所以遇到任何异常,我们都不管,直接放行就好,下面会有其他拦截器管这些问题的,这不是它该负责的事。

如果还不理解,看下方: 

要弄清楚这个问题,我们先得明白,什么情况下 token 为空?查看前端代码:

481050569c284b2cbcadccee8252f218.png

这段代码是定义在 common.js 中,它规定了:所有用 ajax 发起的异步请求,都会携带一个 token,可以近似理解为所有请求都会携带 token,哪怕是商品浏览

在浏览器的结果中,也证实了这一点,确实会携带 token,这跟登录校不校验根本无关!

所以只要你发起任何请求,都会携带 token,也就是任何请求都会进入后续刷新 token 的逻辑。

那什么时候 token 不为空呢?

前端将 token 传给服务端,token 为空,说明 sessionStorage 中没有 token 这个东西,前端取不到。

8733c12d67274d5da2fca1f363c0ce9e.png

我们分析一下 sessionStorage 中没有 token 的原因:

  1. 浏览器关闭,会话结束,token 失效
  2. 根本没有登录,没登录自然也就不会将 token 返回前端(客户端),让前端把  token 保存在 sessionStorage 中。

问题找到了,那开始分析后果:在这两种情况下,携带为空的 token 的请求直接放行会造成什么样的后果?

这两种情况其实可以归为一类,浏览器关了,整个会话结束。重新打开浏览器访问网址,需要重新登录,自然也是没有登录的状态。所以可以一并纳入情况 ② 分析。

要知道,第一个拦截器只负责刷新 token 有效期 和 保存 / 删除 ThreadLocal 中的用户信息。

没登陆应该拦截还是放行,这个逻辑是在登录校验拦截器里面实现的,第一个拦截器可以理解为他只是一个过滤器,只是对 token 做一些前置的操作罢了,不做任何拦截和放行的逻辑实现。

所以不管遇到什么样的情况,第一个拦截器都会放行。这里因为 token 为空放行,是因为如果 token 为空(没登陆),根本没有必要(或者说无法)执行下面的逻辑,我们提前放行了而已。


同时,由于前置处理都放进了 RefreshTokenInterceptor,我们的 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;
    }
}

修改拦截器的配置(WebMvcConfigurer):

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

细节:

① 拦截器的执行顺序可以由 order 来指定,order的值越小,优先级越高。

② 如果未设置拦截路径,则默认是拦截所有路径

至此,短信登录业务全部完成。我们重启服务器,登录,然后去 Redis 的图形化界面查看 token 的ttl,如果每次切换任何界面之后,ttl 都会重置,那么说明我们的代码没有问题。

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

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

相关文章

国际荐酒师Peter助力第六届地博会,推动地理标志产品国际化发展

国际荐酒师Peter Lisicky助力第六届知交会暨地博会&#xff0c;推动地理标志产品国际化发展 第六届粤港澳大湾区知识产权交易博览会暨国际地理标志产品交易博览会于2024年12月9日至11日在中新广州知识城盛大举行&#xff0c;吸引了全球众多行业专家、企业代表及相关机构齐聚一…

vue自定义弹窗点击除了自己区域外关闭弹窗

这里使用到vue的自定义指令 <div class"item" v-clickoutside"clickoutside1"><div click"opencity" class"text":style"{ color: popup.iscitypop || okcitylist.length ! 0 ? #FF9500 : #000000 }">选择地区…

2024 年 11 月区块链游戏研报:牛市加持下的 GameFi 破局之路

2024 年 11 月区块链游戏研报 作者&#xff1a;Stella L (stellafootprint.network) 数据来源&#xff1a;Footprint Analytics 区块链游戏 Research 页面 2024 年 11 月 Web3 游戏行业市场增长显著但大规模采用策略仍在演进。随着比特币创下历史新高并接近 10 万美元里程碑…

做题时HashSet、TreeSet、LinkedHashSet的选择

一、HashSet 此类实现 Set 接口&#xff0c;由哈希表&#xff08;实际上是一个 HashMap 实例&#xff09;支持。它不保证 set 的迭代顺序&#xff1b;特别是它不保证该顺序恒久不变。 代码&#xff1a; import java.util.HashSet; import java.util.LinkedHashSet; import ja…

【Rive】Android与Rive交互

1 Android与Rive交互的常用接口 1.1 RiveAnimationView参数 <app.rive.runtime.kotlin.RiveAnimationViewandroid:id"id/rive_view"android:layout_width"match_parent"android:layout_height"match_parent"android:adjustViewBounds"…

【Linux】通过crond服务设置定时执行shell脚本,实际执行时间却延迟了8小时

一、问题描述 通过使用crond服务设置定时任务&#xff0c;在每天凌晨的2:00执行脚本&#xff0c;但检查结果时发现&#xff0c;实际执行时间却在上午10点。 检查shell脚本执行结果发现&#xff0c;实际执行脚本时间在上午10:00&#xff0c;延迟了8小时。 检查系统时间&#xf…

Kubesphere上搭建Nacos集群

Kubesphere上搭建Nacos集群 使用最新版本&#xff1a;nacos/nacos-server 1&#xff09;先创建一个有状态副本集&#xff0c;获取到dns 2&#xff09;创建集群配置文件 cluster.conf&#xff1a; mp-nacos-0.mp-nacos-lex8.mp-prod.svc.cluster.local:8848 mp-nacos-1.mp-n…

鸿蒙ArkTS如何实现数据可视化:饼状图/柱状图/折线图

鸿蒙数据可视化系列 在我们鸿蒙APP开发中&#xff0c;经常需要使用到一些图表的开发&#xff0c;本文主要介绍使用 McCharts 框架绘制柱状图的方法 配套指导视频地址&#xff1a;鸿蒙数据可视化系列-McCharts使用介绍_哔哩哔哩_bilibili 1. 第三方库介绍 1.1. McCharts Mc…

锐捷网络设备常用命令(交换机、路由器)

因公司办公网络设备转为锐捷产品&#xff0c;特此学习一些锐捷交换机和路由器的相关配置&#xff1a; enable 进入特权模式 configure terminal 进入全局模式 logging on 日志显示&#xff08;默认&#xff09; no logging on 关闭日志显示 exit 返回上一层 end 返回到特权模式…

一文读懂模型的数据集Datasets

众所周知&#xff0c;数据集&#xff08;Datasets&#xff09;对于模型来说非常重要&#xff0c;它是大模型这台庞大机器的原材料&#xff0c;在机器学习领域中所有的原生模型都是通过数据集训练出来的。本文将以通俗易懂的方式为大家介绍一下大模型的数据集&#xff0c;让大家…

如何通过自学成长为一名后端开发工程师?

大家好&#xff0c;我是袁庭新。最近&#xff0c;有星友向我提出了一个很好的问题&#xff1a;如何通过自学成为一名后端开发工程师&#xff1f; 为了解答这个疑问&#xff0c;我特意制作了一个视频来详细分享我的看法和建议。 戳链接&#xff1a;如何通过自学成长为一名后端开…

C语言 静态变量 +静态函数

静态局部变量 特点 静态局部变量常用于需要在函数调用之间保持数据持久性和状态的情况&#xff0c;例如计数器、缓存等。 静态全局变量 特点 静态全局变量适用于在当前源文件内共享数据&#xff0c;但希望限制其它源文件的访问权限的情况。它们可以避免全局命名空间的干扰&…

A6481 基于Java+mysql+Vue+MySQL+uni-app在线商城系统微信小程序的设计与实现 配置 源码 全套资料

在线商城系统微信小程序 1.摘要2. 系统开发的目的和意义3.功能结构4.界面展示5.源码获取 1.摘要 随着信息时代的发展&#xff0c;用户的消费水平也在不断的上升&#xff0c;传统超市以及电子商务在线上推广和购物体验等方面也到了一个瓶颈期。淘宝、京东等购物平台需要占手机更…

TEA系列例题

解析 TEA 加密算法(C语言、python)&#xff1a;_tea加密-CSDN博客 CTF-RE 从0到N: TEA_tea加密原理-CSDN博客 1 字节 8 位 牢记密文的64位和密钥的128位,最好可以自己独立的写出tea解密代码 相当于密文是传入8个字符类型数据或者是2个整型数据, 密钥是16个字符数据或者4个…

静态属性与实例属性:深入理解Java中的属性类型

在Java编程中&#xff0c;属性&#xff08;也称为字段&#xff09;是类的重要组成部分&#xff0c;它们用于存储对象的状态。根据属性的作用范围和生命周期&#xff0c;Java中的属性可以分为两类&#xff1a;静态属性&#xff08;Static Attributes&#xff09;和实例属性&…

洞察企业税务全面风险!

2020年底OECD《税收征管3.0&#xff1a;税收征管的数字化转型》的发布&#xff0c;拉开了全球各国税收征管数字化转型帷幕。中国政府积极响应&#xff0c;依托金税四期工程陆续推出全国统一电子税务局、乐企平台等系统&#xff0c;为企业税务数智化转型提供有效支撑。 用友积极…

快速搭建SpringBoot3+Vue3+ElementPlus管理系统

快速搭建SpringBoot3Vue3管理系统 前端项目搭建&#xff08;默认开发环境&#xff1a;node20,Jdk17&#xff09;创建项目并下载依赖--执行以下命令 前端项目搭建&#xff08;默认开发环境&#xff1a;node20,Jdk17&#xff09; 创建项目并下载依赖–执行以下命令 创建项目 y…

链式设计模式——装饰模式和职责链模式

一、装饰模式 1、概述 动态地给一个对象添加一些额外的职责&#xff0c;就增加功能来说&#xff0c;装饰模式比生成子类更为灵活。 ConcreteComponent &#xff1a;是定义了一个具体的对象&#xff0c;可以给这个对象添加一些职责&#xff1b;Decorator &#xff1a;装饰抽象…

【Elasticsearch】实现用户行为分析

&#x1f9d1; 博主简介&#xff1a;CSDN博客专家&#xff0c;历代文学网&#xff08;PC端可以访问&#xff1a;https://literature.sinhy.com/#/literature?__c1000&#xff0c;移动端可微信小程序搜索“历代文学”&#xff09;总架构师&#xff0c;15年工作经验&#xff0c;…

Linux评估网络性能

网络性能直接影响应用程序对外提供服务的稳定性和可靠性 ping命令检测网络的连通性 如果网络反应缓慢&#xff0c;或连接中断&#xff0c;可以用ping来测试网络的连通情况 time值(单位为毫秒)显示了两台主机之间的网络延时情况。如果此值很大&#xff0c;则表示网络的延时很大…