微服務閘道器與使用者身份識別,服務提供者之間的會話共享關係

服務提供者之間的會話共享關係

一套分散式微服務叢集可能會執行幾個或者幾十個閘道器(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(根據版本不同可能不完全一致),分別如下:

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依賴包:

org。springframework。session spring-session-data-redis org。springframework。session spring-session-core

按照Spring Session官方文件的說明,在新增所需的依賴項後,可以透過以下配置啟用基於Redis的分散式Session:

@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的具體配置可參閱參考文件,地址為

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的程式碼如下:

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的部分程式碼如下:

package com。crazymaker。springcloud。base。filter;//省略importpublic class CustomedSessionRepositoryFilter 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的部分程式碼如下:

package com。crazymaker。springcloud。base。core;。。。@Datapublic class CustomedSessionIdResolver implements HttpSessionIdResolver{ 。。。 /** *解析session id,用於在Redis中進行Session查詢 *@param request請求 *@return session id列表 */ @Override public List 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的部分程式碼如下:

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; }}

本文給大家講解的內容是 微服務閘道器與使用者身份識別,服務提供者之間的會話共享關係

下篇文章給大家講解的是 Nginx/OpenResty詳解,Nginx簡介;

覺得文章不錯的朋友可以轉發此文關注小編;

感謝大家的支援!