Redis:02.应用一:短信登录-使用redis解决session共享问题

黑马的实战篇教程是用一个点评项目来运用Redis,这里记录一下

1. 导入后端项目

导入黑马点评项目,先用sql文件建表,然后复制idea项目到工作目录,然后在IDEA里打开项目,在配置文件中修改MySQL和Redis的相关配置

再把资料里的nginx文件夹拷贝到工作目录,在nginx所在目录下打开一个CMD窗口,输入命令:

1
start nginx.exe

打开chrome浏览器,访问: http://127.0.0.1:8080 ,即可看到页面。在空白页面点击鼠标右键,选择检查,即可打开开发者工具,然后打开手机模式

2. 基于Session实现登录

2.1 发送短信验证码

功能展示

image-20220613234932578

功能逻辑

image-20220613235129586

功能代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) { // 内部是手机号正则判断
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);

// 4.保存验证码到 session
session.setAttribute("code",code);

// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
}

其中发送验证码只是简单的后台打印下,没有真正的发送

2.2 短信验证码登录

功能展示

image-20220614124619440

功能逻辑

image-20220614124832196

功能代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 登录和注册功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}

// 4.一致,根据手机号查询用户 select * from tb_user where phone = ? 这里使用了MybatisPlus,代码比较简洁
User user = query().eq("phone", phone).one();

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

// 7.保存用户信息到Session里
session.setAttribute("user",user);
return Result.ok();
}


/**
* 创建并保存用户
* @param phone
* @return User
*/
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;
}

2.3 拦截器登录验证

用户登录成功之后,以后每次请求资源自然不能都要登录,而是改为每次请求之前都要校验登录状态。而我们自然不希望在每个Controller里都编写校验登录的代码,耦合性太强,这里需要使用拦截器进行登录验证。

功能逻辑

image-20220614130627614

值得一提的是,为了确保获取当前访问的用户,拦截器需要将每个用户信息传递到Controller里,这里选择将用户保存到当前线程的 ThreadLocal 里,保证线程的安全问题。(每收到一个http请求,服务端就会新开一个线程来处理)

拦截器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.hmdp.utils;
...
public class LoginInterceptor implements HandlerInterceptor {

/**
* 进入controller之前进行登录校验
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取Session
HttpSession session = request.getSession();
// 2.获取session中的用户
Object user = session.getAttribute("user");
// 3.判断用户是否存在
if (user == null) {
// 4. 不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
// 5.存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) user);
// 6.放行
return true;
}

/**
* 业务执行完毕销毁对应的用户信息,避免threadlocal内存泄漏
* @param request
* @param response
* @param handler
* @param ex
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}

配置拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.hmdp.config;
...
@Configuration
public class MvcConfig implements WebMvcConfigurer {

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

这里黑马给的nginx代码 login.html 跳转返回的是index.html首页,视频演示的是跳转到info.html详情页,如果要和视频保持一致,就去nginx文件夹里找到 login.html 把跳转代码改了

1
2
3
4
5
6
7
8
axios.post("/user/login", this.form)
.then(({data}) => {
if(data){
// 保存用户信息到session
sessionStorage.setItem("token", data);
}
// 跳转到首页
location.href = "/info.html"

如果还是有问题,建议继续往后面看视频,还有一些数据类型转换的东西没有讲完,可能是那一部分原因导致的跳转失败。并且此处应该更关注后端尤其是数据库里的现象,不用太在意前端现象

2.4 隐藏用户敏感信息

之前Session里存取的都是完整的User,包括密码等属性,一方面这是敏感信息,不应该在HTTP里传输,另一方面多余的信息也会带来额外的内存消耗,因此可以建立UserDTO,仅使用必要的信息完成数据传输

1
2
3
4
5
6
7
8
9
10
package com.hmdp.dto;

import lombok.Data;

@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}

随后可以将Session里存取的User也换成UserDTO

3. 集群的session共享问题

session共享问题:多台Tomcat并不共享session存储空间,由于负载均衡,集群会把请求切换到不同的tomcat服务,这时其他的tomcat没有之前的session,导致数据丢失。

tomcat也有过一些解决方案,比如在tomcat集群里拷贝所有的Session,但这会导致内存和时间上的浪费

image-20220615111417359

session的替代方案应该满足:

  • 数据共享
  • 内存存储
  • key、value结构

所以可以使用Redis作为替代方案

4. 基于Redis实现共享session登录

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

  • 为value选择合适的Redis数据结构
  • 选择合适的key方便存取
  • 选择合适的存储粒度

4.1 发送短信验证码

之前是将验证码保存到Session里,现在要将验证码保存到Redis里

  • 为value选择合适的Redis数据结构:由于验证码就是6位数字,所以value可以选择String类型

  • 选择合适的key:key显然不能是之前session里的”code”了,因为每个session里有自己的code,现在如果再用code作为key,显然不能满足不同请求的需求。这里可以将手机号作为key,有两方面的原因:

    • 存:每个用户提交的手机号是不同的,根据不同手机号往redis里存验证码,能够保证key的唯一性

    • 取:后续的功能里,即之前讲的2.2用户使用短信验证码登录时,直接使用session里的信息,不用考虑取信息的问题,tomcat自动帮你维护了,但现在用Redis得考虑怎么取数据。而用手机号作为key,刚好能够在用户使用短信验证码登录时提供的手机号去Reids里取出验证码

img

代码:更新 sendCode() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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 // set key value ex 120
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}

4.2 短信验证码登录

之前短信验证码登录有一个必不可缺的逻辑就是校验验证码之后总需要保存用户对象信息到session里,方便后续每次请求里对该信息进行处理,现在不用session了,可以将用户对象信息存在redis里,还是考虑两个问题:

  • 为value选择合适的Redis数据结构:保存一个对象,Redis通常有两种数据结构可选,一是String结构,以JSON字符串来保存,比较直观。二是Hash结构,它可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少(JSON里有不必要的符号)。一般如果数据较少的话,两个都行,数据较多从优化的角度,Hash结构更适合。此处我们选择Hash结构
  • 选择合适的key:这里也可以将手机号作为key,但不推荐,原因后面讲,而是用一个随机字符串 token,作为key,作为用户登录状态。也有两方面原因:
    • 存:随机字符串能够保证key的唯一性,能够存数据
    • 取:后续的功能里,即之前讲的2.3登录验证时,用户每次请求时,是携带cookie的,里面有sessionID,这样就能用session里的用户信息进行判断,这是tomcat帮我们维护的。现在不用session了,得去redis里取出用户信息,这里就可以根据 token 取出,即把 token 作为用户的登陆凭证,取代之前的cookie。那么如何让用户每次请求时都带上 token 呢,这里需要我们在保存用户信息到Redis之后,不能结束流程,而是要将 token 通过响应信息返回给客户端,以便于客户端以后每次请求时都带上这个token。这里也是不将手机号作为key的原因,在响应中放这种敏感信息是不合理的。

image-20220615125514215

代码:更新 login() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* 登录和注册功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
*/
@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 = ? 这里使用了MybatisPlus,代码比较简洁
User user = query().eq("phone", phone).one();

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

// 7.保存用户信息到 redis中
// 7.1.随机生成token,作为登录令牌。UUID是工具类,toStirng如果给参数true表示不生成带下划线的随机字符串
String token = UUID.randomUUID().toString(true);
// 7.2.将User对象先转为UserDTO再转为HashMap存储到redis里,都是用的hutool包下BeanUtil里的工具。这里由于UserDTO有long型的id,转换时需要自定义规则
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create() // 自定义规则
.setIgnoreNullValue(true) // 忽略空的值(UserDTO里有icon属性暂时还没管)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString())); // 将所有的属性都转成String类型
// 7.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期,模仿session设置为30min
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

// 8.返回token给客户端,这里很重要!不返回给客户端,下次请求就没办法携带token访问了
return Result.ok(token);
}

这里关于token有效期有一个问题是,expire这个函数是设定死了30min之后失效,不管这期间有没有继续访问请求,但实际上的逻辑应该是如果用户继续不断访问,则应该不断更新有效期,只有用户完全不访问了,再等30min才失去登录状态。这里的逻辑写在后续的拦截器中

4.3 拦截器登录验证

拦截器的功能逻辑如下图所示

image-20220615164223203

代码:更新 LoginInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Component
public class LoginInterceptor implements HandlerInterceptor {

private StringRedisTemplate stringRedisTemplate;

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


/**
* 进入controller之前进行登录校验
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization"); // 前端代码里这样写的
if (StrUtil.isBlank(token)) {
// token为空,拦截,返回401状态码
response.setStatus(401);
return false;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
// 4.用户不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
// 5.将查询到的hash数据转为UserDTO
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;
}

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

更新WebMvcConfigurer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Resource
private StringRedisTemplate stringRedisTemplate;

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

测试:

redis 的内容

Snipaste_2022-06-15_18-43-43

每次请求中登录验证的 token

Snipaste_2022-06-15_18-44-25

注意,在拦截器类 LoginInterceptor 注入 StringRedisTemplate可能会失败,参考本站博客:《问题解决-springboot拦截器无法注入StringRedisTemplate》

4.4 优化拦截器登陆验证

在前面的拦截器思路中(如下图所示),拦截器负责更新 token 的有效期,但其实有些网页请求是不用拦截的,比如商户信息。现在假设一个用户登录了之后浏览了30分钟的商户信息,这30分钟没有用到拦截器,token就不会刷新,然后 token 就过期了,用户得重新登录,这显然是不合理的,因此我们需要对拦截器做出优化。

image-20220615221757251

优化方法:

可以新增加一个拦截器拦截一切信息,在这个拦截器里负责获取、查询、更新 token,原先的 LoginInterceptor 拦截器只负责查询用户是否存在,逻辑图如下,这样就可以解决前面提到的问题。

image-20220615222048131

代码:

新添加拦截器 RefreshTokenInterceptor,注意此处是拦截一切请求,所以查不到 token 也没事,放行就可以。查到用户就放入ThreadLocal,查不到就放行。需要登录的路径放到原先的 LoginInterceptor 拦截器去判断ThreadLocal是否有用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class RefreshTokenInterceptor implements HandlerInterceptor {

private StringRedisTemplate stringRedisTemplate;

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

// 拦截一切请求,获取、查询、更新 token
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true; // 注意这里的变化,此处是拦截一切请求,所以查不到token也没事,放行就行
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true; // 同上,放行
}
// 5.将查询到的hash数据转为UserDTO
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,只需要去ThreadLocal中查询是否有用户

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
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;
}
}

更新拦截器配置类,注意添加拦截器 registry.addInterceptor() 时,用 order() 方法指定顺序,确保 token刷新拦截器比登录验证拦截器先执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public void addInterceptors(InterceptorRegistry registry) {
// token刷新拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
// 登录验证拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
}
}

测试:

登录之后不断访问 个人主页 和 首页,去查看Redis里 token 的TTL ,应该是不断被刷新重置的。这样就能够解决登录状态刷新的问题


Redis:02.应用一:短信登录-使用redis解决session共享问题
http://jswanyu.github.io/2022/05/06/Redis/Redis:02.实战应用一:短信登录-解决session共享/
作者
万宇
发布于
2022年5月6日
许可协议