第十四章 : Spring Boot 整合spring-session,使用redis共享
前沿
本文重点讲述:spring boot工程中使用spring-session机制进行安全认证,并且通过redis存储session,满足集群部署、分布式系统的session共享。
基于SPringBoot 2.3.2.RELEASE
背景
在传统单机web应用中,一般使用tomcat/jetty等web容器时,用户的session都是由容器管理。浏览器使用cookie中记录sessionId,容器根据sessionId判断用户是否存在会话session。这里的限制是,session存储在web容器中,被单台服务器容器管理。
但是网站主键演变,分布式应用和集群是趋势(提高性能)。此时用户的请求可能被负载分发至不同的服务器,此时传统的web容器管理用户会话session的方式即行不通。除非集群或者分布式web应用能够共享session,尽管tomcat等支持这样做。但是这样存在以下两点问题:
1、需要侵入web容器,提高问题的复杂
2、web容器之间共享session,集群机器之间势必要交互耦合
基于这些,必须提供新的可靠的集群分布式/集群session的解决方案,突破traditional-session单机限制(即web容器session方式,下面简称traditional-session),spring-session应用而生。
traditional-session和spring-session的区别
springboot-session 集成redis示例
- 添加依赖:在pom.xml文件中添加Spring Session Redis的依赖。
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency><groupId>io.lettuce</groupId><artifactId>lettuce-core</artifactId>
</dependency><!-- 对象池,使用redis时必须引入 -->
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId>
</dependency>
- 配置Redis:在application.yaml文件中添加Redis的配置信息,包括Redis的地址、端口号、密码等。
server:port: 8080servlet:context-path: /# session超时时间 默认30分钟session:timeout: 30m
spring:session:store-type: redisredis:# 会话刷新模式flush-mode: immediate# 用于存储会话的键的命名空间namespace: "spring:session"redis:host: 192.168.92.105port: 6379password: foobared# 连接超时时间(记得添加单位,Duration)timeout: 10000ms# Redis默认情况下有16个分片,这里配置具体使用的分片# database: 0lettuce:pool:# 连接池最大连接数(使用负值表示没有限制) 默认 8max-active: 8# 连接池最大阻塞等待时间(使用负值表示没有限制) 默认 -1max-wait: -1ms# 连接池中的最大空闲连接 默认 8max-idle: 8# 连接池中的最小空闲连接 默认 0min-idle: 0
-
注解开启session功能:并使用@EnableRedisHttpSession注解开启session功能。同时,可以设置session的超时时间。
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;@SpringBootApplication @EnableRedisHttpSession public class SpringbootDay09Application {public static void main(String[] args) {SpringApplication.run(SpringbootDay09Application.class, args);}}
-
创建Controller:在需要使用session的Controller中,注入HttpSession对象,并使用它来存储session数据。
@RestController public class SessionController { @Autowired private HttpSession httpSession; @GetMapping("/set") public String set(String name, String value) { httpSession.setAttribute(name, value); return "set " + name + "=" + value; } @GetMapping("/get") public String get(String name) { return (String) httpSession.getAttribute(name); } }
-
测试:分别调用各应用接口,查看sessionId是否一致。同时,可以查看Redis缓存信息,缓存中的sessionId与接口返回信息一致。
spring-session特点与工作原理
特点
spring-session在无需绑定web容器的情况下提供对集群session的支持。并提供对以下情况的透明集成:
- HttpSession:容许替换web容器的HttpSession
- WebSocket:使用WebSocket通信时,提供Session的活跃
- WebSession:容许以应用中立的方式替换webflux的webSession
工作原理
spring-session分为以下核心模块:
-
SessionRepositoryFilter
:Servlet规范中Filter的实现,用来切换HttpSession至Spring Session,包装HttpServletRequest和HttpServletResponse
-
HttpServerletRequestWrapper
、HttpServletResponseWrapper
、HttpSessionWrapper
包装器:包装原有的HttpServletRequest、HttpServletResponse和Spring Session,实现切换Session和透明继承HttpSession的关键之所在 -
Session
:Spring Session模块 -
SessionRepository
:管理Spring Session的模块 -
HttpSessionStrategy
:映射HttpRequest和HttpResponse到Session的策略
-
SessionRepositoryFilter
SessionRepositoryFilter
继承OncePerRequestFilter
实现Filter
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {// 设置SessionRepository至Request的属性中request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);// 包装原始HttpServletRequest至SessionRepositoryRequestWrapperSessionRepositoryFilter<S>.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response);// 包装原始HttpServletResponse响应至SessionRepositoryResponseWrapperSessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);try {filterChain.doFilter(wrappedRequest, wrappedResponse);} finally {// 提交sessionwrappedRequest.commitSession();}}
2、 SessionRepository
public interface SessionRepository<S extends Session> {S createSession();void save(S var1);S findById(String var1);void deleteById(String var1);
}
创建、保存、获取、删除Session的接口行为。根据Session的不同,分为很多种Session操作仓库。
当创建一个RedisSession,然后存储在Redis中时,RedisSession的存储细节如下:
spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe
spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
spring:session:expirations:1439245080000
Redis会为每个RedisSession存储三个k-v。
第一个:k-v用来存储Session的详细信息,包括Session的过期时间间隔、最近的访问时间、attributes等等。这个k的过期时间为Session的最大过期时间 + 5分钟。如果默认的最大过期时间为30分钟,则这个k的过期时间为35分钟
第二个:k-v用来表示Session在Redis中的过期,这个k-v不存储任何有用数据,只是表示Session过期而设置。这个k在Redis中的过期时间即为Session的过期时间间隔
第三个:k-v存储这个Session的id,是一个Set类型的Redis数据结构。这个k中的最后的1439245080000值是一个时间戳,根据这个Session过期时刻滚动至下一分钟而计算得出。
3、 Session
spring-session和tomcat中的Session的实现模式上有很大不同,tomcat中直接对HttpSession接口进行实现,而spring-session中则抽象出单独的Session层接口,让后再使用适配器模式将Session适配层Servlet规范中的HttpSession。spring-sesion中关于session的实现和适配整个UML类图如下:
MapSession的代码源码片段
public final class MapSession implements Session, Serializable {public static final int DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS = 1800;private String id;private final String originalId;private Map<String, Object> sessionAttrs;private Instant creationTime;private Instant lastAccessedTime;private Duration maxInactiveInterval;private static final long serialVersionUID = 7160779239673823561L;public MapSession() {this(generateId());}public MapSession(String id) {this.sessionAttrs = new HashMap();this.creationTime = Instant.now();this.lastAccessedTime = this.creationTime;this.maxInactiveInterval = Duration.ofSeconds(1800L);this.id = id;this.originalId = id;}public MapSession(Session session) {this.sessionAttrs = new HashMap();this.creationTime = Instant.now();this.lastAccessedTime = this.creationTime;this.maxInactiveInterval = Duration.ofSeconds(1800L);if (session == null) {throw new IllegalArgumentException("session cannot be null");} else {this.id = session.getId();this.originalId = this.id;this.sessionAttrs = new HashMap(session.getAttributeNames().size());Iterator var2 = session.getAttributeNames().iterator();while(var2.hasNext()) {String attrName = (String)var2.next();Object attrValue = session.getAttribute(attrName);if (attrValue != null) {this.sessionAttrs.put(attrName, attrValue);}}this.lastAccessedTime = session.getLastAccessedTime();this.creationTime = session.getCreationTime();this.maxInactiveInterval = session.getMaxInactiveInterval();}}public void setLastAccessedTime(Instant lastAccessedTime) {this.lastAccessedTime = lastAccessedTime;}public Instant getCreationTime() {return this.creationTime;}public String getId() {return this.id;}public String getOriginalId() {return this.originalId;}public String changeSessionId() {String changedId = generateId();this.setId(changedId);return changedId;}public Instant getLastAccessedTime() {return this.lastAccessedTime;}public void setMaxInactiveInterval(Duration interval) {this.maxInactiveInterval = interval;}public Duration getMaxInactiveInterval() {return this.maxInactiveInterval;}public boolean isExpired() {return this.isExpired(Instant.now());}boolean isExpired(Instant now) {if (this.maxInactiveInterval.isNegative()) {return false;} else {return now.minus(this.maxInactiveInterval).compareTo(this.lastAccessedTime) >= 0;}}public <T> T getAttribute(String attributeName) {return this.sessionAttrs.get(attributeName);}public Set<String> getAttributeNames() {return new HashSet(this.sessionAttrs.keySet());}public void setAttribute(String attributeName, Object attributeValue) {if (attributeValue == null) {this.removeAttribute(attributeName);} else {this.sessionAttrs.put(attributeName, attributeValue);}}public void removeAttribute(String attributeName) {this.sessionAttrs.remove(attributeName);}public void setCreationTime(Instant creationTime) {this.creationTime = creationTime;}public void setId(String id) {this.id = id;}public boolean equals(Object obj) {return obj instanceof Session && this.id.equals(((Session)obj).getId());}public int hashCode() {return this.id.hashCode();}private static String generateId() {return UUID.randomUUID().toString();}
}
RedisSession的代码源码片段
final class RedisSession implements Session {private final MapSession cached;private final Map<String, Object> delta = new HashMap();private boolean isNew;private String originalSessionId;RedisSession(MapSession cached, boolean isNew) {this.cached = cached;this.isNew = isNew;this.originalSessionId = cached.getId();if (this.isNew) {this.delta.put("creationTime", cached.getCreationTime().toEpochMilli());this.delta.put("maxInactiveInterval", (int)cached.getMaxInactiveInterval().getSeconds());this.delta.put("lastAccessedTime", cached.getLastAccessedTime().toEpochMilli());}if (this.isNew || RedisSessionRepository.this.saveMode == SaveMode.ALWAYS) {this.getAttributeNames().forEach((attributeName) -> {this.delta.put(RedisSessionRepository.getAttributeKey(attributeName), cached.getAttribute(attributeName));});}}public String getId() {return this.cached.getId();}public String changeSessionId() {return this.cached.changeSessionId();}public <T> T getAttribute(String attributeName) {T attributeValue = this.cached.getAttribute(attributeName);if (attributeValue != null && RedisSessionRepository.this.saveMode.equals(SaveMode.ON_GET_ATTRIBUTE)) {this.delta.put(RedisSessionRepository.getAttributeKey(attributeName), attributeValue);}return attributeValue;}public Set<String> getAttributeNames() {return this.cached.getAttributeNames();}public void setAttribute(String attributeName, Object attributeValue) {this.cached.setAttribute(attributeName, attributeValue);this.delta.put(RedisSessionRepository.getAttributeKey(attributeName), attributeValue);this.flushIfRequired();}public void removeAttribute(String attributeName) {this.setAttribute(attributeName, (Object)null);}public Instant getCreationTime() {return this.cached.getCreationTime();}public void setLastAccessedTime(Instant lastAccessedTime) {this.cached.setLastAccessedTime(lastAccessedTime);this.delta.put("lastAccessedTime", this.getLastAccessedTime().toEpochMilli());this.flushIfRequired();}public Instant getLastAccessedTime() {return this.cached.getLastAccessedTime();}public void setMaxInactiveInterval(Duration interval) {this.cached.setMaxInactiveInterval(interval);this.delta.put("maxInactiveInterval", (int)this.getMaxInactiveInterval().getSeconds());this.flushIfRequired();}public Duration getMaxInactiveInterval() {return this.cached.getMaxInactiveInterval();}public boolean isExpired() {return this.cached.isExpired();}private void flushIfRequired() {if (RedisSessionRepository.this.flushMode == FlushMode.IMMEDIATE) {this.save();}}private boolean hasChangedSessionId() {return !this.getId().equals(this.originalSessionId);}private void save() {this.saveChangeSessionId();this.saveDelta();if (this.isNew) {this.isNew = false;}}private void saveChangeSessionId() {if (this.hasChangedSessionId()) {if (!this.isNew) {String originalSessionIdKey = RedisSessionRepository.this.getSessionKey(this.originalSessionId);String sessionIdKey = RedisSessionRepository.this.getSessionKey(this.getId());RedisSessionRepository.this.sessionRedisOperations.rename(originalSessionIdKey, sessionIdKey);}this.originalSessionId = this.getId();}}private void saveDelta() {if (!this.delta.isEmpty()) {String key = RedisSessionRepository.this.getSessionKey(this.getId());RedisSessionRepository.this.sessionRedisOperations.opsForHash().putAll(key, new HashMap(this.delta));RedisSessionRepository.this.sessionRedisOperations.expireAt(key, Date.from(Instant.ofEpochMilli(this.getLastAccessedTime().toEpochMilli()).plusSeconds(this.getMaxInactiveInterval().getSeconds())));this.delta.clear();}}}
}
在RedisSession中有两个非常重要的成员属性:
cached:实际上是一个MapSession实例,用于做本地缓存,每次在getAttribute时无需从Redis中获取,主要为了improve性能
delta:用于跟踪变化数据,做持久化
4、SessionRepositoryRequestWrapper
对于开发人员获取HttpSession的api
HttpServletRequest request = ...;
HttpSession session = request.getSession(true);
在spring session中request的实际类型SessionRepositoryRequestWrapper。调用SessionRepositoryRequestWrapper的getSession方法会触发创建spring session,而非web容器的HttpSession。
SessionRepositoryRequestWrapper用来包装原始的HttpServletRequest实现HttpSession切换至Spring Session。是透明Spring Session透明集成HttpSession的关键。
SessionRepositoryRequestWrapper继承HttpServletRequestWrapper,在构造方法中将原有的HttpServletRequest通过调用super完成对HttpServletRequestWrapper中持有的HttpServletRequest初始化赋值,然后重写和session相关的方法。这样就保证SessionRepositoryRequestWrapper的其他方法调用都是使用原有的HttpServletRequest的数据,只有session相关的是重写的逻辑。
private final class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper {private final HttpServletResponse response;private S requestedSession;private boolean requestedSessionCached;private String requestedSessionId;private Boolean requestedSessionIdValid;private boolean requestedSessionInvalidated;private SessionRepositoryRequestWrapper(HttpServletRequest request, HttpServletResponse response) {super(request);this.response = response;}private void commitSession() {SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper wrappedSession = this.getCurrentSession();if (wrappedSession == null) {if (this.isInvalidateClientSession()) {SessionRepositoryFilter.this.httpSessionIdResolver.expireSession(this, this.response);}} else {S session = wrappedSession.getSession();this.clearRequestedSessionCache();SessionRepositoryFilter.this.sessionRepository.save(session);String sessionId = session.getId();if (!this.isRequestedSessionIdValid() || !sessionId.equals(this.getRequestedSessionId())) {SessionRepositoryFilter.this.httpSessionIdResolver.setSessionId(this, this.response, sessionId);}}}private SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper getCurrentSession() {return (SessionRepositoryFilter.SessionRepositoryRequestWrapper.HttpSessionWrapper)this.getAttribute(SessionRepositoryFilter.CURRENT_SESSION_ATTR);}private void setCurrentSession(SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper currentSession) {if (currentSession == null) {this.removeAttribute(SessionRepositoryFilter.CURRENT_SESSION_ATTR);} else {this.setAttribute(SessionRepositoryFilter.CURRENT_SESSION_ATTR, currentSession);}}public String changeSessionId() {HttpSession session = this.getSession(false);if (session == null) {throw new IllegalStateException("Cannot change session ID. There is no session associated with this request.");} else {return this.getCurrentSession().getSession().changeSessionId();}}public boolean isRequestedSessionIdValid() {if (this.requestedSessionIdValid == null) {S requestedSession = this.getRequestedSession();if (requestedSession != null) {requestedSession.setLastAccessedTime(Instant.now());}return this.isRequestedSessionIdValid(requestedSession);} else {return this.requestedSessionIdValid;}}private boolean isRequestedSessionIdValid(S session) {if (this.requestedSessionIdValid == null) {this.requestedSessionIdValid = session != null;}return this.requestedSessionIdValid;}private boolean isInvalidateClientSession() {return this.getCurrentSession() == null && this.requestedSessionInvalidated;}public SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper getSession(boolean create) {SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper currentSession = this.getCurrentSession();if (currentSession != null) {return currentSession;} else {S requestedSession = this.getRequestedSession();if (requestedSession != null) {if (this.getAttribute(SessionRepositoryFilter.INVALID_SESSION_ID_ATTR) == null) {requestedSession.setLastAccessedTime(Instant.now());this.requestedSessionIdValid = true;currentSession = new SessionRepositoryFilter.SessionRepositoryRequestWrapper.HttpSessionWrapper(requestedSession, this.getServletContext());currentSession.markNotNew();this.setCurrentSession(currentSession);return currentSession;}} else {if (SessionRepositoryFilter.SESSION_LOGGER.isDebugEnabled()) {SessionRepositoryFilter.SESSION_LOGGER.debug("No session found by id: Caching result for getSession(false) for this HttpServletRequest.");}this.setAttribute(SessionRepositoryFilter.INVALID_SESSION_ID_ATTR, "true");}if (!create) {return null;} else {if (SessionRepositoryFilter.SESSION_LOGGER.isDebugEnabled()) {SessionRepositoryFilter.SESSION_LOGGER.debug("A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for " + SessionRepositoryFilter.SESSION_LOGGER_NAME, new RuntimeException("For debugging purposes only (not an error)"));}S session = SessionRepositoryFilter.this.sessionRepository.createSession();session.setLastAccessedTime(Instant.now());currentSession = new SessionRepositoryFilter.SessionRepositoryRequestWrapper.HttpSessionWrapper(session, this.getServletContext());this.setCurrentSession(currentSession);return currentSession;}}}public SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper getSession() {return this.getSession(true);}public String getRequestedSessionId() {if (this.requestedSessionId == null) {this.getRequestedSession();}return this.requestedSessionId;}public RequestDispatcher getRequestDispatcher(String path) {RequestDispatcher requestDispatcher = super.getRequestDispatcher(path);return new SessionRepositoryFilter.SessionRepositoryRequestWrapper.SessionCommittingRequestDispatcher(requestDispatcher);}private S getRequestedSession() {if (!this.requestedSessionCached) {List<String> sessionIds = SessionRepositoryFilter.this.httpSessionIdResolver.resolveSessionIds(this);Iterator var2 = sessionIds.iterator();while(var2.hasNext()) {String sessionId = (String)var2.next();if (this.requestedSessionId == null) {this.requestedSessionId = sessionId;}S session = SessionRepositoryFilter.this.sessionRepository.findById(sessionId);if (session != null) {this.requestedSession = session;this.requestedSessionId = sessionId;break;}}this.requestedSessionCached = true;}return this.requestedSession;}private void clearRequestedSessionCache() {this.requestedSessionCached = false;this.requestedSession = null;this.requestedSessionId = null;}
5、 SessionRepositoryResponseWrapper
private final class SessionRepositoryResponseWrapper extends OnCommittedResponseWrapper {private final SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper request;SessionRepositoryResponseWrapper(SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper request, HttpServletResponse response) {super(response);if (request == null) {throw new IllegalArgumentException("request cannot be null");} else {this.request = request;}}protected void onResponseCommitted() {this.request.commitSession();}}
5、 SessionRepositoryResponseWrapper ```java
private final class SessionRepositoryResponseWrapper extends OnCommittedResponseWrapper {private final SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper request;SessionRepositoryResponseWrapper(SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper request, HttpServletResponse response) {super(response);if (request == null) {throw new IllegalArgumentException("request cannot be null");} else {this.request = request;}}protected void onResponseCommitted() {this.request.commitSession();}}
从注释上可以看出包装响应时为了:确保如果响应被提交session能够被保存。