客戶端和服務器基于HTTP的消息交換就好比兩個完全沒有記憶能力的人在交流,每次單一的HTTP事務體現為一次“一問一答”的對話。單一的對話毫無意義,在在同一語境下針對某個主題進行的多次對話才會有結果。會話的目的就是在同一個客戶端和服務器之間建立兩者交談的語境或者上下文,ASP.NET Core利用一個名為SessionMiddleware的中間件實現了會話。本篇提供了幾個簡單的實例來演示如何在一個ASP.NET Core應用中利用會話來存儲用戶的狀態。[本文節選《ASP.NET Core 6框架揭秘》第23章]。
[S2301]設置和提取會話狀態(源代碼)
[S2302]查看存儲的會話狀態(源代碼)
[S2303] 查看Cookie(源代碼)
[S2301]設置和提取會話狀態
每個會話都有一個被稱為Session Key的標識(但不是唯一標識),會話狀態以一個數據字典的形式將Session Key保存在服務端。當SessionMiddleware中間件在處理會話的第一個請求時,它會創建一個Session Key,并據此創建一個獨立的數據字典來存儲會話狀態。這個Session Key最終以Cookie的形式寫入響應并返回客戶端,客戶端在每次發送請求時會自動附加這個Cookie,那么應用程序能夠準確識別會話并成功定位存儲會話狀態的數據字典。
下面我們利用一個簡單的實例來演示會話狀態的讀寫。ASP.NET應用在默認情況下會利用分布式緩存來存儲會話狀態。我們采用基于Redis數據庫的分布式緩存,所以需要添加針對NuGet包“Microsoft.Extensions.Caching.Redis”的依賴。下面的演示程序調用了AddDistributedRedisCache擴展方法添加了基于DistributedRedisCache的服務注冊,SessionMiddleware中間件則通過調用UseSession擴展方法進行注冊。
using?System.Text;var?builder?=?WebApplication.CreateBuilder();
builder.Services.AddDistributedRedisCache(options?=>?options.Configuration?=?"localhost").AddSession();
var?app?=?builder.Build();
app.UseSession();
app.MapGet("/{foobar?}",?ProcessAsync);
app.Run();static?async?ValueTask<IResult>?ProcessAsync(HttpContext?context)
{var?session?=?context.Session;await?session.LoadAsync();string?sessionStartTime;if?(session.TryGetValue("__SessionStartTime",?out?var?value)){sessionStartTime?=?Encoding.UTF8.GetString(value);}else{sessionStartTime?=?DateTime.Now.ToString();session.SetString("__SessionStartTime",?sessionStartTime);}var?html?=?$@"
<html><head><title>Session?Demo</title></head><body><ul><li>Session?ID:{session.Id}</li><li>Session?Start?Time:{sessionStartTime}</li><li>Current?Time:{DateTime.Now}</li><ul></body>
</html>";return?Results.Content(html,?"text/html");
}
我們針對路由模板“/{foobar?}”注冊了一個終結點,后者的處理器指向ProcessAsync方法。該方法當前HttpContext上下文中獲取表示會話的Session對象,并調用其TryGetValue方法獲取會話開始時間,這里使用的Key為“__SessionStartTime”。由于TryGetValue方法總是以字節數組的形式返回會話狀態值,所以我們采用UTF-8編碼轉換成字符串形式。如果會話開始時間尚未設置,我們會調用SetString方法采用相同的Key進行設置。我們最終生成一段用于呈現Session ID和當前實時時間HTML,并封裝成返回的ContentResult對象。程序啟動之后,我們利用Chrome和IE訪問請求注冊的終結點,從圖1可以看出針對Chrome的兩次請求的Session ID和會話狀態值都是一致的,但是IE中顯示的則不同。
圖1 以會話狀態保存的“會話開始時間”
[S2302]查看存儲的會話狀態
會話狀態在默認情況下采用分布式緩存的形式來存儲,而我們的實例采用的是基于Redis數據庫的分布式緩存,那么會話狀態會以什么樣的形式存儲在Redis數據庫中的呢?由于緩存數據在Redis數據庫中是以散列的形式存儲的,所以我們只有知道具體的Key才能知道存儲的值。緩存狀態是基于作為會話標識的Session Key進行存儲的,它與Session ID具有不同的值,到目前為止我們不能使用公布出來的API來獲取它,但可以利用反射的方式來獲取Session Key。在默認情況下,表示Session的是一個DistributedSession對象,它通過如下所示的字段_sessionKey表示這個用來存儲會話狀態的Session Key。
public?class?DistributedSession?:?ISession
{private?readonly?string?_sessionKey;...
}
接下來我們對上面演示的程序做簡單的修改,從而使Session Key能夠呈現出來。如下面的代碼片段所示,我們可以采用反射的方式得到代表當前會話的DistributedSession對象的_sessionKey字段的值,并將它寫入響應HTML文檔的主體內容中。
static?async?ValueTask<IResult>?ProcessAsync(HttpContext?context)
{var?session?=?context.Session;await?session.LoadAsync();string?sessionStartTime;if?(session.TryGetValue("__SessionStartTime",?out?var?value)){sessionStartTime?=?Encoding.UTF8.GetString(value);}else{sessionStartTime?=?DateTime.Now.ToString();session.SetString("__SessionStartTime",?sessionStartTime);}var?field?=?typeof(DistributedSession).GetTypeInfo().GetField("_sessionKey",?BindingFlags.Instance?|?BindingFlags.NonPublic)!;var?sessionKey?=?field.GetValue(session);var?html?=?$@"
<html><head><title>Session?Demo</title></head><body><ul><li>Session?ID:{session.Id}</li><li>Session?Start?Time:{sessionStartTime}</li><li>Session?Key:{sessionKey}</li><li>Current?Time:{DateTime.Now}</li><ul></body>
</html>";return?Results.Content(html,?"text/html");
}
按照同樣的方式啟動應用后,我們使用瀏覽器訪問目標站點得到的輸出結果如圖2所示,可以看到,Session Key的值被正常呈現出來,它是一個不同于Session ID的GUID。
圖2 呈現當前會話的Session Key
如果有這個保存當前會話狀態的Session Key,我們就可以按照圖3所示的方式采用命令行的形式將存儲在Redis數據庫中的會話狀態數據提取出來。當會話狀態在采用默認的分布式緩存進行存儲時,整個數據字典(包括Key和Value)會采用預定義的格式序列化成字節數組,這基本上可以從圖3體現出來。我們還可以看出基于會話狀態的緩存默認采用的是基于滑動時間的過期策略,默認采用的滑動過期時間為20分(12 000 000 000納秒)。
圖3 存儲在Redis數據庫中的會話狀態
[S2303]?查看Cookie
雖然整個會話狀態數據存儲在服務端,但是用來提取對應會話狀態數據的Session Key需要以Cookie的形式由客戶端來提供。如果請求沒有以Cookie的形式攜帶Session Key,SessionMiddleware中間件就會將當前請求視為會話的第一次請求。在此情況下,它會生成一個GUID作為Session Key,并最終以Cookie的形式返回客戶端。
HTTP/1.1?200?OK
...
Set-Cookie:.AspNetCore.Session=CfDJ8CYspSbYdOtFvhKqo9CYj2vdlf66AUAO2h2BDQ9%2FKoC2XILfJE2bk
IayyjXnXpNxMzMtWTceawO3eTWLV8KKQ5xZfsYNVlIf%2Fa175vwnCWFDeA5hKRyloWEpPPerphndTb8UJNv5R68bGM8jP%2BjKVU7za2wgnEStgyV0ceN%2FryfW;?path=/;?httponly
如上所示的代碼片段是響應報頭中攜帶Session Key的Set-Cookie報頭在默認情況下的表現形式。可以看出Session Key的值不僅是被加密的,更具有一個httponly標簽以防止Cookie值被跨站讀取。在默認情況下,Cookie采用的路徑為“/”。當我們使用同一個瀏覽器訪問目標站點時,發送的請求將以如下形式附加上這個Cookie。
GET?http://localhost:5000/?HTTP/1.1
...
Cookie:?.AspNetCore.Session=CfDJ8CYspSbYdOtFvhKqo9CYj2vdlf66AUAO2h2BDQ9%2FKoC2XILfJE2bkIayyjXnXpNxMzMtWTceawO3eTWLV8KKQ5xZfsYNVlIf%2Fa175vwnCWFDeA5hKRyloWEpPPerphndTb8UJNv5R68bGM8jP%2BjKVU7za2wgnEStgyV0ceN%2FryfW
除了Session Key,前面還提到了Session ID,讀者可能不太了解兩者具有怎樣的區別。Session Key和Session ID是兩個不同的概念,上面演示的實例也證實了它們的值其實是不同的。Session ID可以作為會話的唯一標識,但是Session Key不可以。兩個不同的Session肯定具有不同的Session ID,但是它們可能共享相同的Session Key。當SessionMiddleware中間件接收到會話的第一個請求時,它會創建兩個不同的GUID來分別表示Session Key和Session ID。其中Session ID將作為會話狀態的一部分被存儲起來,而Session Key以Cookie的形式返回客戶端。
會話是具有有效期的,會話的有效期基本決定了存儲的會話狀態數據的有效期,默認過期時間為20分鐘。在默認情況下,20分鐘之內的任意一次請求都會將會話的壽命延長至20分鐘后。如果兩次請求的時間間隔超過20分鐘,會話就會過期,存儲的會話狀態數據(包括Session ID)會被清除,但是請求攜帶可能還是原來的Session Key。在這種情況下,SessionMiddleware中間件會創建一個新的會話,該會話具有不同的Session ID,但是整個會話狀態依然沿用這個Session Key,所以Session Key并不能唯一標識一個會話。