ASP.NET Core 6框架揭祕範例演示[35]:利用Session保留語境

2022-09-06 09:01:13

使用者端和伺服器基於HTTP的訊息交換就好比兩個完全沒有記憶能力的人在交流,每次單一的HTTP事務體現為一次「一問一答」的對話。單一的對話毫無意義,在在同一語境下針對某個主題進行的多次對話才會有結果。對談的目的就是在同一個使用者端和伺服器之間建立兩者交談的語境或者上下文,ASP.NET Core利用一個名為SessionMiddleware的中介軟體實現了對談。本篇提供了幾個簡單的範例來演示如何在一個ASP.NET Core應用中利用對談來儲存使用者的狀態。(本文提供的範例演示已經同步到《ASP.NET Core 6框架揭祕-範例演示版》)。

[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中顯示的則不同。

image

圖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。

image

圖2 呈現當前對談的Session Key

如果有這個儲存當前對談狀態的Session Key,我們就可以按照圖3所示的方式採用命令列的形式將儲存在Redis資料庫中的對談狀態資料提取出來。當對談狀態在採用預設的分散式快取進行儲存時,整個資料字典(包括Key和Value)會採用預定義的格式序列化成位元組陣列,這基本上可以從圖3體現出來。我們還可以看出基於對談狀態的快取預設採用的是基於滑動時間的過期策略,預設採用的滑動過期時間為20分(12 000 000 000納秒)。

image

圖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並不能唯一標識一個對談。