SpringBoot集成Shiro实现多数据源认证授权与分布式会话(三)

前言

前面我们利用springboot结合shiro在项目中解决了多数据源认证的问题,接下来我们来看看如何在之前的框架基础上实现分布式session管理.在一般情况下web服务器与客户端采用http协议进行通讯,大家都知道http协议本身是一种无状态的协议,即每次请求之间都是相互独立的,服务器无法记住当前请求的用户是谁,可想而知在绝大部分业务场景下,对于用户来讲这种体验是非常糟糕的,而session便是一种让web服务器与客户端之间进行状态保持的解决方案.

session与cookie

session即会话,会话是存储在web服务器端内存中的,类似于map这样的一种数据结构,session与cookie之间的关系简单点说就是用户在第一次通过浏览器访问服务端时,服务端会为当前用户创建一个session对象并将生成的唯一标识sessionid返回给客户端浏览器存储到cookie中,当用户登录之后,服务端通过浏览器请求头中传递过来的sessionid在web容器中找到对应session并将用户的个人信息与登录状态和其绑定起来,这样子以后每次请求,服务端都能与客户端建立起有效的状态保持了.

什么是session共享

通常在单体应用下是无需考虑session的共享问题的,因为这种架构一般是集中式部署的,即所有的代码都部署到一台web服务器上,代码分层也是很经典的MVC三层架构.单体架构图.png

随着业务的发展以及应用的迭代开发,单体架构臃肿的代码结构不但难以维护而且无法满足迅速变化的业务需求,所以集中式部署的架构必须演进为分布式架构来突破原有架构的瓶颈,以应付高并发的访问量.在分布式架构中把原来的单体架构按照功能模块解耦成若干个微服务的形式对外提供api接口,并且每个微服务都独立的部署到各自的服务器上面.分布式架构图.png
此时应用虽然不存在单点问题,但是也由于服务器是多个节点同时向外提供服务的,如果此时前端负载均衡器将客户端请求分发到不同的服务器节点上面,就会因为其中某些节点上没有用户session而导致请求失效.所以在单体架构中依赖于容器自身所提供的session管理方案已经无法满足分布式或集群场景下的需求,于是我们需要有一套机制来保证当服务器在多节点的情况下session数据可以共享.

session一致性解决方案

接下来我们来看看主要有哪些常见的session共享方案.
1.session复制
这种方案主要依赖于tomcat等web服务器,可在多个服务器之间自动实时复制session数据,如利用Terracotta来实现tomcat间session共享,配置对原来的应用完全透明,原有程序几乎不用做任何修改,而且Terracotta本身支持HA或者使用tomcat自带的cluster也可以,但是这些方案效率较低,用户量大并发量大时会大量占用网络带宽而且可能有延迟,整体上来说非常消耗系统资源.
2.nginx配置ip的hash路由策略
利用nginx的基于访问ip的hash路由策略,其原理就是同一个ip的所有请求都会被nginx进行ip_hash进行计算,通过结果定位到指定的后台服务器即一个用户如果ip不变,那么每次请求的都是同一后台服务器.但是最外层的代理要保证源ip在请求的过程不会被修改,如果你的架构里在最外层不单单是nginx服务,而是类似于请求分发的服务那么一个用户的请求可能被定位到不同的服务器上或者说一个局域网有许多用户同时登录系统的话,那么ip_hash就没有什么用了.
3.存储在cookie中
session也可存储于客户端cookie中,但数据大小有限制,且用户有可能禁用cookie,存在安全隐患.
4.Spring Session
spring提供的一整套支持分布式session管理的方案,默认采用外置的redis来存储数据,以此来解决会话共享的问题。
5.实现独立的会话中心
利用如数据库、redis或者memcache等第三方存储介质来保存会话信息,负责session数据共享,并实现独立的会话中心来管理session的生命周期,让其不再与web容器耦合在一起.
在项目中我们采取了自建会话中心的方式来支持session共享.

shiro如何结合redis实现session共享

首先shiro提供了可扩展会话管理的顶层接口AbstractSessionDAO,我们可以自定义自己的RedisSessionDao类继承自AbstractSessionDAO,在此实现类中分别覆写以下方法:
doCreate():用户第一次访问系统时创建会话信息
doReadSession():读取会话信息
update():更新用户会话信息
delete():删除用户会话信息
getActiveSessions():获取所有的在线会话信息
这几个方法即提供了对会话的基本管理crud以及session超时策略的控制,主要代码如下:

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
@Override
protected Serializable doCreate(Session session) {
Serializable sessionId = SessionCons.TOKEN_PREFIX
+ UUID.randomUUID().toString();
assignSessionId(session, sessionId);
redisTemplate.opsForValue().set(sessionId, session);
redisTemplate.expire(sessionId, PC_EXPIRE_TIME, TimeUnit.SECONDS);
if (logger.isDebugEnabled()) {
logger.debug("create shiro session ,sessionId is :{}",
sessionId.toString());
}
return sessionId;
}
@Override
protected Session doReadSession(Serializable sessionId) {
Session session = redisTemplate.opsForValue().get(sessionId);
if (null != session) {
String deviceType = (String) session
.getAttribute(SessionCons.DEVICE_TYPE);
if (StringHelpUtils.isNotBlank(deviceType)) {
if (deviceType.equals(DeviceType.PC.toString())) {
// PC会话信息
session.setTimeout(PC_EXPIRE_TIME * 1000);
if (logger.isDebugEnabled()) {
logger.debug("read pc session ,sessionId is :{}",
sessionId.toString());
}
} else {
// APP会话信息
session.setTimeout(APP_EXPIRE_TIME * 1000);
if (logger.isDebugEnabled()) {
logger.debug("read app session ,sessionId is :{}",
sessionId.toString());
}
}
}
}
return session;
}
@Override
public void update(Session session) throws UnknownSessionException {
if (null != session && null != session.getId()) {
String deviceType = (String) session
.getAttribute(SessionCons.DEVICE_TYPE);
if (StringHelpUtils.isBlank(deviceType))
deviceType = DeviceType.PC.toString();
redisTemplate.opsForValue().set(session.getId(), session);
if (deviceType.equals(DeviceType.PC.toString())) {
// PC会话信息
session.setTimeout(PC_EXPIRE_TIME * 1000);
redisTemplate.expire(session.getId(), PC_EXPIRE_TIME,
TimeUnit.SECONDS);
if (logger.isDebugEnabled()) {
logger.debug("update pc session ,sessionId is :{}", session
.getId().toString());
}
} else {
// APP会话信息
session.setTimeout(APP_EXPIRE_TIME * 1000);
redisTemplate.expire(session.getId(), APP_EXPIRE_TIME,
TimeUnit.SECONDS);
if (logger.isDebugEnabled()) {
logger.debug("update app session ,sessionId is :{}",
session.getId().toString());
}
}
}
}
@Override
public void delete(Session session) {
if (logger.isDebugEnabled()) {
logger.debug("delete shiro session ,sessionId is :{}", session
.getId().toString());
}
redisTemplate.opsForValue().getOperations().delete(session.getId());
}
@Override
public Collection<Session> getActiveSessions() {
Set<Serializable> keys = redisTemplate
.keys(SessionCons.TOKEN_PREFIX_KEY);
if (keys.size() == 0) {
return Collections.emptySet();
}
List<Session> sessions = redisTemplate.opsForValue().multiGet(keys);
return Collections.unmodifiableCollection(sessions);
}

然后在shiro的配置类中配置自定义的sessionDAO.

1
2
3
4
@Bean
public RedisSessionDao redisSessionDAO() {
return new RedisSessionDao();
}

设置shiro的会话管理器.

1
2
3
4
5
6
7
@Bean
public CustomerWebSessionManager sessionManager() {
CustomerWebSessionManager sessionManager = new CustomerWebSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
......
return sessionManager;
}

接着定义一个会话常量类SessionCons,内部包含会话key前缀等系统常量.

1
2
3
4
5
6
String LOGIN_USER_PERMISSIONS = "session_login_user_permissions";
String LOGIN_USER_SESSION = "session_login_user";
String TOKEN_PREFIX = "web_session_key-";
String TOKEN_PREFIX_KEY = "web_session_key-*";
String DEVICE_TYPE = "device_type";
......

最后在用户登录之后的时候就可以把相关的用户session信息和权限数据存储到redis中了.

1
2
3
4
subject.getSession().setAttribute(SessionCons.LOGIN_USER_SESSION,loginAccinfo);
subject.getSession().setAttribute(SessionCons.DEVICE_TYPE,DeviceTypePC.toString());
subject.getSession().setAttribute(SessionCons.LOGIN_USER_PERMISSIONS, permissions);
......

到此我们已经在之前的框架基础上实现了分布式session管理了.