分布式 session

分布式系统中,登录的 session 信息一般都是存放在 redis 中的。

本文记录一下 spring-boot 整合的 spring-session 和 redis 实现。

快速开始

准备工作

本地启动 redis 服务

  [plaintext]
1
2
3
4
[21496] 15 Sep 09:24:37.508 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo [21496] 15 Sep 09:24:37.510 # Redis version=5.0.9, bits=64, commit=9414ab9b, modified=0, pid=21496, just started [21496] 15 Sep 09:24:37.510 # Configuration loaded [21496] 15 Sep 09:24:37.513 # Could not create server TCP listening socket 127.0.0.1:6379: bind: 操作成功完成。
  • 进入客户端
  [plaintext]
1
2
$ redis-cli.exe 127.0.0.1:6379>

此时保证 redis 中数据是空的,或者没有干扰数据:

  [plaintext]
1
2
127.0.0.1:6379> keys * (empty list or set)

基本代码

pom.xml

  [xml]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<dependencies> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>

目录结构

  [plaintext]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
D:. ├─java │ └─com │ └─github │ └─houbb │ └─spring │ └─boot │ └─session │ │ Application.java │ │ │ ├─config │ │ HttpSessionConfig.java │ │ │ └─controller │ ExampleController.java │ └─resources application.properties

后端代码

  • 启动 session
  [java]
1
2
3
@EnableRedisHttpSession public class HttpSessionConfig { }
  • Controller 示例代码
  [java]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController public class ExampleController { @RequestMapping("/set") public String set(HttpServletRequest req) { req.getSession().setAttribute("testKey", "testValue"); return "设置session:testKey=testValue"; } @RequestMapping("/query") public String query(HttpServletRequest req) { Object value = req.getSession().getAttribute("testKey"); return "查询Session:\"testKey\"=" + value; } }

配置文件

主要指定 redis 的配置信息。此处为本地 redis。

  [plaintext]
1
2
3
spring.redis.host=127.0.0.1 spring.redis.password= spring.redis.port=6379

启动测试

设置

浏览器访问 http://localhost:8080/set

页面返回:

  [plaintext]
1
设置session:testKey=testValue

查询

浏览器访问 http://localhost:8080/query

页面返回:

  [plaintext]
1
查询Session:"testKey"=testValue

信息的存储

我们看一下 Redis 中的存储信息

  [plaintext]
1
2
3
4
127.0.0.1:6379> keys * 1) "spring:session:sessions:d37d1c0a-5c4a-4c60-906b-7657be1e6bc7" 2) "spring:session:expirations:1600135380000" 3) "spring:session:sessions:expires:d37d1c0a-5c4a-4c60-906b-7657be1e6bc7"

这是 spring-session 为我们创建的 3 个 key

我们也可以看一下对应的值

  • spring:session:sessions:d37d1c0a-5c4a-4c60-906b-7657be1e6bc7
  [plaintext]
1
2
3
4
5
6
7
8
9
127.0.0.1:6379> hgetall spring:session:sessions:d37d1c0a-5c4a-4c60-906b-7657be1e6bc7 1) "lastAccessedTime" 2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01t\x8fd_\xef" 3) "maxInactiveInterval" 4) "\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\a\b" 5) "sessionAttr:testKey" 6) "\xac\xed\x00\x05t\x00\ttestValue" 7) "creationTime" 8) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01t\x8fd_\xef"
  • spring:session:expirations:1600135380000
  [plaintext]
1
2
127.0.0.1:6379> smembers spring:session:expirations:1600135380000 1) "\xac\xed\x00\x05t\x00,expires:d37d1c0a-5c4a-4c60-906b-7657be1e6bc7"
  • spring:session:sessions:expires:d37d1c0a-5c4a-4c60-906b-7657be1e6bc7
  [plaintext]
1
2
127.0.0.1:6379> get spring:session:sessions:expires:d37d1c0a-5c4a-4c60-906b-7657be1e6bc7 ""

基于拦截器的处理

实际开发过程中,我们肯定不希望每一次请求都自己去实现 session 的校验,查询等处理。

关于这一点,可以直接交给 mvc 的拦截器实现。

核心代码

实际上就是一个 servlet 的拦截器,每次请求获取对应的 session 信息。

这个可以基于 cookies/session/token 等等。

  [java]
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
import org.springframework.util.StringUtils; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * @author binbin.hou * @since 1.0.0 */ public class SessionInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception { String token = httpServletRequest.getParameter("token"); String roleInfo = mockTokenResp(token); if(StringUtils.isEmpty(roleInfo)) { // 登录信息非法,跳转到登录页面等操作 return false; } // 根据信息设置等操作 return true; } /** * 根据 token 去 redis 等取 session 信息,此处直接 mock 掉 * @param token 请求参数,可以是 sessionId, JWT 等 * @return 结果 */ private String mockTokenResp(String token) { if("ryo".equals(token)) { return "admin"; } return ""; } @Override public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception { } }

基于方法注解的实现

权限校验

在使用SpringMVC进行项目的时候用到了权限验证。

验证无非就是收到请求后,在拦截器循环判断用户是否有权限执行操作。

url 判断

通过request获得用户的URI,再逐一循环判断是否可以操作。

这种方法适合格式较为固定的校验,但是不太适合细化的。

通过用户要访问的方法来判断是否有权限

preHandle方法中handler实际为HandlerMethod,(看网上说的有时候不是HandlerMethod),加个 instanceof 验证即可。

可以得到方法名:h.getMethod().getName()

可以得到 RequestMapping 注解中的值:h.getMethodAnnotation(RequireRole.class)

这种方法还是不太方便

注解实现

注解定义

  [java]
1
2
3
4
5
6
7
8
9
10
11
12
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RequireRole { /** * 权限列表 * @return 列表 */ String[] value() default {}; }

注解使用

可以放在方法上,用于指定需要的权限。

  [java]
1
2
3
4
5
6
7
8
9
10
@RequestMapping("hello") @RequireRole({"admin"}) public String hello() { return "hello"; } @RequestMapping("hello2") public String hello2() { return "hello2"; }

拦截器实现

  [java]
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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import com.github.houbb.springboot.learn.interceptor.annotation.RequireRole; import com.sun.deploy.util.ArrayUtil; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.Arrays; /** * https://blog.csdn.net/howroad/article/details/80220320 * (1)通过request获得用户的URI,再逐一循环判断是否可以操作。只是这种方法很让人难受。 * (2) * @author binbin.hou * @since 1.0.0 */ @Component public class SessionInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception { String token = httpServletRequest.getParameter("token"); String roleInfo = mockTokenResp(token); // 根据信息设置等操作 // 进行权限校验 if(o instanceof HandlerMethod) { HandlerMethod h = (HandlerMethod)o; RequireRole requireRole = h.getMethodAnnotation(RequireRole.class); if(requireRole != null) { String[] strings = requireRole.value(); boolean contains = containsRole(strings, roleInfo); System.out.println("需要:" + Arrays.toString(strings) + "; 实际:" + roleInfo + ": 结果:" + contains); if(!contains) { return false; } } //判断后执行操作... } return true; } /** * 是否包含对应的角色 * @param strings 需要角色 * @param currentRole 当前角色 * @return 是否满足 */ private boolean containsRole(String[] strings, String currentRole) { if(strings.length <= 0) { return true; } for(String requireRole : strings) { if(requireRole.equals(currentRole)) { return true; } } return false; } /** * 根据 token 去 redis 等取 session 信息,此处直接 mock 掉 * @param token 请求参数,可以是 sessionId, JWT 等 * @return 结果 */ private String mockTokenResp(String token) { if("ryo".equals(token)) { return "admin"; } return ""; } @Override public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception { } }

类级别

当然,如果你觉得每个方法都添加比较麻烦,则可以直接添加类级别的。

注解

简单调整注解:

  [java]
1
2
3
4
5
6
7
8
9
10
11
12
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RequireRole { /** * 权限列表 * @return 列表 */ String[] value() default {}; }

使用时就可以定位在类上。

拦截器处理逻辑调整

  [java]
1
2
3
4
5
6
7
8
HandlerMethod h = (HandlerMethod)o; RequireRole requireRole = h.getMethodAnnotation(RequireRole.class); if(requireRole != null) { // 方法级别的处理 } else { // 获取类上面的注解,进行类似的处理逻辑 requireRole = h.getMethod().getDeclaringClass().getAnnotation(RequireRole.class); }

小结

session 是 web 登录中必备的功能,redis 存放 session 是分布式系统中比较成熟的方案。

当然也并不是唯一的解决方案,使用 jwt 也可以达到类似的效果,不过二者各有优缺点,个人更加倾向于使用 redis 存储分布式 session。

后续有机会打开一篇讲解下 jwt 如何实现分布式系统的登录验证。

本实战系列用于记录 springboot 的实际使用和学习笔记。

希望本文对你有所帮助,如果喜欢,欢迎点赞收藏转发一波。

我是老马,期待与你的下次相遇。

拓展阅读

面试官:知道 springboot 的启动原理吗?

5 分钟入门 springboot 实战学习笔记

实现你的自定义 springboot starter 实战

springboot 整合阿里 druid 数据库连接池实战

springboot+mybatis+druid 整合实战

参考资料

SpringBoot 之Actuator

SpringMVC拦截器中获得Controller方法名和注解信息(用于验证权限)