微服务网关与用户身份识别,服务提供者之间的会话共享关系

2022-10-28 11:40:22 浏览数 (1)

服务提供者之间的会话共享关系

一套分布式微服务集群可能会运行几个或者几十个网关(gateway),以及几十个甚至几百个Provider微服务提供者。如果集群的节点规模较小,那么在会话共享关系上,同一个用户在所有的网关和微服务提供者之间共享同一个分布式Session是可行的,如图6-8所示。

图6-8 共享分布式Session

如果集群的节点规模较大,分布式Session在IO上就会存在性能瓶颈。除此之外,还存在一个架构设计上的问题:在网关(如Zuul)和微服务提供者之间传递Session ID,并且双方依赖了相同的会话信息(如用户详细信息),将导致网关和微服务提供者、微服务提供者与微服务提供者之间的耦合度很高,这在一定程度上降低了微服务的移植性和复用性,违背了系统架构高内聚、低耦合的原则。

架构的调整方案是:缩小分布式Session的共享规模,网关(如Zuul)和微服务提供者之间按需共享分布式Session。网关和微服务提供者不再直接传递Session ID作为用户身份标识,而是改成传递用户ID,如图6-9所示。

图6-9 Session共享的架构与实现方案

以上介绍的Session共享的架构,第一种可理解为全局共享,第二种可理解为局部按需共享。无论如何,Session共享的架构与实现方案肯定不止以上两种,而且以上第二种方案也不一定是最优的。疯狂创客圈的crazy-springcloud脚手架对上面的第二种分布式Session架构方案提供了实现代码,供大家参考和学习。

分布式Session的起源和实现方案

HTTP本身是一种无状态的协议,这就意味着每一次请求都需要进行用户的身份信息查询,并且需要用户提供用户名和密码来进行用户认证。为什么呢?服务端并不知道是哪个用户发出的请求。所以,为了能识别是哪个用户发出的请求,需要在服务端存储一份用户身份信息,并且在登录成功后将用户身份信息的标识传递给客户端,客户端保存好用户身份标识,在下次请求时带上该身份标识。然后,在服务端维护一个用户的会话,用户的身份信息保存在会话中。通常,对于传统的单体架构服务器,会话都是保存在内存中的,而随着认证用户增多,服务端的开销会明显增大。

大家都知道,单体架构模式最大的问题是没有分布式架构,无法支持横向扩展。在分布式微服务架构下,需要在服务节点之间进行会话的共享。解决方案是使用一个统一的Session数据库来保存会话数据并实现共享。当然,这种Session数据库一定不能是重量级的关系型数据库,而应该是轻量级的基于内存的高速数据库(如Redis)。

在生产场景中,可以使用成熟稳定的Spring Session开源组件作为分布式Session的解决方案,不过Spring Session开源组件比较重,在简单的Session共享场景中可以自己实现一套相对简单的RedisSession组件,具体的实现方案可以参考疯狂创客圈的社群博客“RedisSession自定义”一文。从学习角度来说,自制一套RedisSession方案可以帮助大家深入了解Web请求的处理流程,使得大家更容易学习Spring Session的核心原理。

Spring Session作为独立的组件将Session从Web容器中剥离,存储在独立的数据库中,目前支持多种形式的数据库:内存数据库(如Redis)、关系型数据库(如MySQL)、文档型数据库(如MogonDB)等。通过合理的配置,当请求进入Web容器时,Web容器将Session的管理责任委托给Spring Session,由Spring Session负责从数据库中存取Session,若其存在,则返回,若其不存在,则新建并持久化至数据库中。

Spring Session的核心组件和存储细节

这里先介绍Spring Session的3个核心组件:Session接口、RedisSession会话类、SessionRepository存储接口。

1.Session接口

Spring Session单独抽象出Session接口,该接口是SpringSession对会话的抽象,主要是为了鉴定用户,为HTTP请求和响应提供上下文容器。Session接口的主要方法如下:

(1)getId:获取Session ID。

(2)setAttribute:设置会话属性。

(3)getAttribte:获取会话属性。

(4)setLastAccessedTime:设置会话过程中最近的访问时间。

(5)getLastAccessedTime:获取最近的访问时间。

(6) setMaxInactiveIntervalInSeconds:设置会话的最大闲置时间。

(7) getMaxInactiveIntervalInSeconds:获取最大闲置时间。

(8)isExpired:判断会话是否过期。

Spring Session和Tomcat的Session在实现模式上有很大不同,Tomcat中直接实现Servlet规范的HttpSession接口,而SpringSession中则抽象出单独的Session接口。问题是:Spring Session如何处理自己定义的Session接口和Servlet规范的HttpSession接口的关系呢?Spring Session定义了一个适配器类,可以将Session实例适配成Servlet规范中的HttpSession实例。

Spring Session之所以要单独抽象出Session接口,主要是为了应对多种传输、存储场景下的会话管理,比如HTTP会话场景(HttpSession)、WebSocket会话场景(WebSocket Session)、非Web会话场景(如Netty传输会话)、Redis存储场景(RedisSession)等。

2.RedisSession会话类

RedisSession用于使用Redis进行会话属性存储的场景。在RedisSession中有两个非常重要的成员属性,分别说明如下:

(1)cached:实际上是一个MapSession实例,用于进行本地缓存,每次在进行getAttribute操作时优先从本地缓存获取,没有取到再从Redis中获取,以提升性能。而MapSession是由Spring SecurityCore定义的一个通过内部的HashMap缓存键-值对的本地缓存类。

(2)delta:用于跟踪变化数据,目的是保存变化的Session的属性。

RedisSession提供了一个非常重要的saveDelta方法,用于持久化Session至Redis中:当调用RedisSession中的saveDelta方法后,变化的属性将被持久化到Redis中。

3.SessionRepository存储接口

SessionRepository为管理Spring Session的存储接口,主要的方法如下:

(1)createSession:创建Session实例。

(2)findById(String id):根据id查找Session实例。

(3)void delete(String id):根据id删除Session实例。

(4)save(S session):存储Session实例。

根据Session的实现类不同,Session存储实现类分为很多种。

RedisSession会话的存储类为 RedisOperationsSessionRepository,由其负责Session数据到Redis数据库的读写。

接下来简单看一下Redis中的Session数据存储细节。

RedisSession在Redis缓存中的存储细节大致有3种Key(根据版本不同可能不完全一致),分别如下:

代码语言:javascript复制
spring:session:SESSION_KEY:sessions:0cefe354-3c24-40d8-a859-fe7d9d3c0dbaspring:session:SESSION_KEY:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fespring:session:SESSION_KEY:expirations:1581695640000

第一种Key(键)的Value(值)用来存储Session的详细信息,Key的最后部分为Session ID,这是一个UUID。这个Key的Value在Redis中是一个hash类型,内容包括Session的过期时间间隔、最近的访问时间、属性等。Key的过期时间为Session的最大过期时间 5分钟。如果设置的Session过期时间为30分钟,那么这个Key的过期时间为35分钟。第二种Key用来表示Session在Redis中已经过期,这个键-值对不存储任何有用数据,只是为了表示Session过期而设置。

第三种Key存储过去一段时间内过期的Session ID集合。这个Key的最后部分是一个时间戳,代表计时的起始时间。这个Key的Value所使用的Redis数据结构是set,set中的元素是时间戳滚动至下一分钟计算得出的过期Session Key(第二种Key)。

Spring Session的使用和定制

结合Redis使用Spring Session需要导入以下两个Maven依赖包:

代码语言:javascript复制
 <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-core</artifactId> </dependency>

按照Spring Session官方文档的说明,在添加所需的依赖项后,可以通过以下配置启用基于Redis的分布式Session:

代码语言:javascript复制
@EnableRedisHttpSessionpublic class Config { //创建一个连接到默认Redis (localhost:6379)的RedisConnectionFactory @Bean public LettuceConnectionFactory connectionFactory() { return new LettuceConnectionFactory(); }}

@EnableRedisHttpSession注释创建一个名为 springSessionRepositoryFilter的过滤器,它负责将原始的HttpSession替换为RedisSession。为了使用Redis数据库,这里还创建了一个连接Spring Session到Redis服务器的RedisConnectionFactory实例,该实例连接的默认为Redis,主机和端口分别为localhost和6379。有关Spring Session的具体配置可参阅参考文档,地址为

代码语言:javascript复制
https://www.springcloud.cc/spring-session.html。

在crazy-springcloud脚手架的共享Session架构中,网关和微服务提供者之间、微服务提供者和微服务提供者之间所传递的不是SessionID而是User ID,所以目标Provider收到请求之后,需要通过User ID找到Session ID,然后找到RedisSession,最后从Session中加载缓存数据。整个流程需要定制3个过滤器,如图6-10所示。

图6-10 crazy-springcloud脚手架共享Session架构中的过滤器

第一个过滤器叫作SessionIdFilter,其作用是根据请求头中的用户身份标识User ID定位到分布式会话的Session ID。

第二个过滤器叫作 CustomedSessionRepositoryFilter,这个类的源码来自Spring Session,其主要的逻辑是将request(请求)和response(响应)进行包装,将HttpSession替换成RedisSession。

第三个过滤器叫作SessionDataLoadFilter,其判断RedisSession中的用户数据是否存在,如果是首次创建的Session,就从数据库中将常用的用户数据加载到Session,以便控制层的业务逻辑代码能够被高速访问。

在crazy-springcloud脚手架中,按照高度复用的原则,所有和会话有关的代码都封装在base-session基础模块中。如果某个Provider模块需要用到分布式Session,只需要在Maven中引入base-session模块依赖即可。

通过用户身份标识查找Session ID

通过用户身份标识(User ID)查找Session ID的工作是由SessionIdFilter过滤器完成的。在前面介绍的UAA提供者服务(crazymakeruaa)中,用户的User ID和Session ID之间的绑定关系位于缓存Redis中。

base-session借鉴了同样的思路。当带着User ID的请求进来时,SessionIdFilter会根据User ID去Redis查找绑定的Session ID。如果查找成功,那么过滤器的任务完成;如果查找不成功,后面的两个过滤器就会创建新的RedisSession,并将在Redis中缓存User ID和Session ID之间的绑定关系。

SessionIdFilter的代码如下:

代码语言:javascript复制
package com.crazymaker.springcloud.base.filter;//省略import@Slf4jpublic class SessionIdFilter extends OncePerRequestFilter{ public SessionIdFilter(RedisRepository redisRepository, RedisOperationsSessionRepository sessionRepository) { this.redisRepository = redisRepository; this.sessionRepository = sessionRepository; } /** *RedisSession DAO */ private RedisOperationsSessionRepository sessionRepository; /** *Redis DAO */ RedisRepository redisRepository; /** *返回true代表不执行过滤器,false代表执行 */ @Override protected boolean shouldNotFilter(HttpServletRequest request) { String userIdentifier = request.getHeader(SessionConstants.USER_IDENTIFIER); if (StringUtils.isNotEmpty(userIdentifier)) { return false; } return true; } /** *将session userIdentifier(用户id)转成session id * *@param request请求 *@param response响应 *@param chain过滤器链 */ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { /** *从请求头中获取session userIdentifier(用户id) */ String userIdentifier = request.getHeader(SessionConstants.USER_IDENTIFIER); SessionHolder.setUserIdentifer(userIdentifier); /** *在Redis中,根据用户id获取缓存的session id */ String sid = redisRepository.getSessionId(userIdentifier); if (StringUtils.isNotEmpty(sid)) { /** *判断分布式Session是否存在 */ Session session = sessionRepository.findById(sid); if (null != session) { //保存session id线程局部变量,供后面的过滤器使用 SessionHolder.setSid(sid); } } chain.doFilter(request, response); }}

SessionIdFilter过滤器中含有两个DAO层的成员:一个RedisRepository类型的DAO成员,负责根据User ID去Redis查找绑定的Session ID;另一个DAO成员的类型为Spring Session专用的 RedisOperationsSessionRepository,负责根据Session ID去查找RedisSession实例,用于验证Session是否真正存在。

查找或创建分布式Session

SessionIdFilter过滤处理完成后,请求将进入下一个过滤器 CustomedSessionRepositoryFilter。这个类的源码来自Spring Session,其主要的逻辑是将request(请求)和response(响应)进行包装,并将原始请求的HttpSession替换成RedisSession。定制之后的过滤器稍微做了一点过滤条件的修改:如果请求头中携带了用户身份标识,就开启分布式Session,否则不会进入分布式Session的处理流程。

CustomedSessionRepositoryFilter的部分代码如下:

代码语言:javascript复制
package com.crazymaker.springcloud.base.filter;//省略importpublic class CustomedSessionRepositoryFilter<S extends Session> extends OncePerRequestFilter{ //执行过滤 @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { ... //包装上一个过滤器的HttpServletRequest请求至SessionRepositoryRequestWrapper SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(request, response, this.servletContext); //包装上一个过滤器的HttpServletResponse响应至SessionRepositoryResponseWrapper SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest, response); try { filterChain.doFilter(wrappedRequest, wrappedResponse); } finally { //会话持久化到数据库 wrappedRequest.commitSession(); } } /** *返回true代表不执行过滤器,false代表执行 */ @Override protected boolean shouldNotFilter(HttpServletRequest request) { //如果请求中携带了用户身份标识 if (null == SessionHolder.getUserIdentifer()) { return true; } return false; } ...}

SessionRepositoryFilter首先会根据一个sessionIds清单进行Session查找,查找失败才创建新的RedisSession。它会调用CustomedSessionIdResolver实例的resolveSessionIds方法获取sessionIds清单。

作为Session ID的解析器,CustomedSessionIdResolver的部分代码如下:

代码语言:javascript复制
package com.crazymaker.springcloud.base.core;...@Datapublic class CustomedSessionIdResolver implements HttpSessionIdResolver{ ... /** *解析session id,用于在Redis中进行Session查找 *@param request请求 *@return session id列表 */ @Override public List<String> resolveSessionIds(HttpServletRequest request) { //获取第一个过滤器保存的session id String sid = SessionHolder.getSid(); return (sid != null) ? Collections.singletonList(sid) : Collections.emptyList(); }...}

CustomedSessionRepositoryFilter会对sessionIds清单进行判断,然后根据结果进行分布式Session的查找或创建:

(1)如果清单中的某个Session ID对应的Session存在于Redis,过滤器就会将分布式RedisSession查找出来作为当前Session。

(2)如果清单为空,或者所有Session ID对应的RedisSession都不在于Redis,过滤器就会创建一个新的RedisSession。

加载高速访问数据到分布式Session

CustomedSessionRepositoryFilter处理完成后,请求将进入下一个过滤器SessionDataLoadFilter。这个类的主要逻辑是加载需要高速访问的数据到分布式Session,具体如下:

(1)获取前面的SessionIdFilter过滤器加载的Session ID,用于判断Session ID是否变化。如果变化就表明旧的Session不存在或者旧的Session ID已经过期,需要更新Session ID,并且在Redis中进行缓存。

(2)获取前面的 CustomedSessionRepositoryFilter创建的Session,如果是新创建的Session,就加载必要的需要高速访问的数据,以提高后续操作的性能。

需要高速访问的数据比较常见的有用户的基础信息、角色、权限等,还有一些基础的业务信息。

CustomedSessionRepositoryFilter的部分代码如下:

代码语言:javascript复制
package com.crazymaker.springcloud.base.filter;...@Slf4jpublic class SessionDataLoadFilter extends OncePerRequestFilter{ UserLoadService userLoadService; RedisRepository redisRepository; public SessionDataLoadFilter(UserLoadService userLoadService, RedisRepository redisRepository) { this.userLoadService = userLoadService; this.redisRepository = redisRepository; }... @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //获取前面的SessionIdFilter过滤器加载的session id String sid = SessionHolder.getSid(); //获取前面的CustomedSessionRepositoryFilter创建的session,加载必要的数据到session HttpSession session = request.getSession(); /** *之前的session不存在 */ if (StringUtils.isEmpty(sid) || !sid.equals(request.getSession().getId())) { //取得当前的session id sid = session.getId(); //user id和session id作为键-值保存到redis redisRepository.setSessionId(SessionHolder.getUserIdentifier(), sid); SessionHolder.setSid(sid); } /** *获取session中的用户信息为空表示用户第次发起请求加载用户信息到中 *为空表示用户第一次发起请求,加载用户信息到session中 */ if (null == session.getAttribute(G_USER)) { String uid = SessionHolder.getUserIdentifier(); UserDTO userDTO = null; if (SessionHolder.getSessionIDStore().equals(SessionConstants.SESSION_STORE)) { //用户端:装载用户端的用户信息 userDTO = userLoadService.loadFrontEndUser(Long.valueOf(uid)); } else { //管理控制台:装载管理控制台的用户信息 userDTO = userLoadService.loadBackEndUser(Long.valueOf(uid)); } /** *将用户信息缓存起来 */ session.setAttribute(G_USER, JsonUtil.pojoToJson(userDTO)); } /** *将session请求保存到SessionHolder的ThreadLocal本地变量中,方便统一获取 */ SessionHolder.setSession(session); SessionHolder.setRequest(request); filterChain.doFilter(request, response); } /** *返回true代表不执行过滤器,false代表执行 */ @Override protected boolean shouldNotFilter(HttpServletRequest request) { if (null == SessionHolder.getUserIdentifier()) { return true; } return false; }}

本文给大家讲解的内容是 微服务网关与用户身份识别,服务提供者之间的会话共享关系

  1. 下篇文章给大家讲解的是 Nginx/OpenResty详解,Nginx简介;
  2. 觉得文章不错的朋友可以转发此文关注小编;
  3. 感谢大家的支持!

本文就是愿天堂没有BUG给大家分享的内容,大家有收获的话可以分享下,想学习更多的话可以到微信公众号里找我,我等你哦。

0 人点赞