在開始今天的表演之前,老周先跟大夥伴們說一句:「中秋節快樂」。
今天咱們來聊一下如何自己動手,實現對談(Session)的儲存方式。預設是存放在分散式記憶體中。由於HTTP訊息是無狀態的,所以,為了讓伺服器能記住使用者的一些資訊,就用到了對談。但對談資料畢竟是臨時性的,不宜長久存放,所以它會有過期時間。過期了資料就無法使用。比較重要的資料一般會用資料庫來長久儲存,對談一般放些狀態資訊。比如你登入了沒?你剛才刷了幾個貼子?
每一次對談的建立都要分配一個唯一的標識,可以叫 Session ID,或叫 Session Key。為了讓伺服器與使用者端的對談保持一致的上下文,伺服器在分配了新對談後,會在響應訊息中設定一個 Cookie,裡面包含對談標識(一般是加密的)。使用者端在發出請求時會攜帶這個 Cookie,到了伺服器上就可以驗證是否在同一個對談中進行的通訊。Cookie的過期時間也有可能與伺服器上快取的對談的過期時間不一致。此時應以伺服器上的資料為準,哪怕使用者端攜帶的 Cookie 還沒過期。只要伺服器快取的對談過期,儲存標識的 Cookie 也相應地變為無效。
由於對談僅僅是些臨時資料,所以在儲存方式上,你擁有可觀的 DIY 空間。只要腦洞足夠大,你就能做出各種儲存方案——存記憶體中,存檔案中,存某些流中,存資料庫中……多款套餐,任君選擇。
ASP.NET Core 或者說面向整個 .NET ,服務容器和依賴注入為程式擴充套件提供了許多便捷性。不管怎麼擴充套件,都是通過自行實現一些介面來達到目的。就拿今天要做的儲存 Session 資料來說,也是有兩個關鍵介面要實現。
介面一:ISessionStore。這個介面的實現型別會被新增到服務容器中用於依賴注入。它只要求你實現一個方法:
ISession Create(string sessionKey, TimeSpan idleTimeout, TimeSpan ioTimeout, Func<bool> tryEstablishSession, bool isNewSessionKey);
sessionKey:對談標識。
idleTimeout:對談過期時間。
ioTimeout:讀寫對談的過期時間。如果你覺得你實現的讀寫操作不花時間,也可以忽略不處理它。
tryEstablishSession:這是個委託,返回 bool。主要檢查能不能設定對談,在 ISession.Set 方法實現時可以呼叫它,要是返回 false,就拋異常。
isNewSessionKey:表示當前對談是不是新建立的,還是已有的。
這個Create方法的實現會引出第二個介面。
介面二:ISession。此介面實現 Session 讀寫的核心邏輯,前面的 ISessionStore 只是負責返回 ISession 罷了。ISession 的實現型別不需要新增到服務容器中。原因就是剛說的,因為 ISessionStore 已經在容器中了,用它就能獲得 ISession 了,所以 ISession 就沒必要再放進容器中了。
ISession 介面要實現的成員比較多。
1、IsAvailable 屬性。唯讀,布林型別。它用來表示這個 Session 能不能載入到資料,可不可用。如果返回 false,表示這個 Session 載入不到資料,用不了。
2、Id 屬性。字串型別,唯讀。這個返回當前 Session 的標識。
3、Keys 屬性。返回當前 Session 中資料的鍵集合。這個和字典資料一樣的道理,Session 也是用字典形式的存取方式。Key 是字串,Value 是位元組陣列。
4、Clear 方法。清空當前 Session 的資料項。只是清空資料,不是幹掉對談本身。
5、CommitAsync 方法。呼叫它儲存 Session 資料,這個就是靠我們自己實現了,存檔案或存記憶體,或存資料庫。
6、LoadAsync 方法。載入 Session。這也是我們自己實現,從資料庫中載入?記憶體中載入?檔案中載入?
7、Remove 方法。根據 Key 刪除某項對談資料,不是刪除對談本身。
8、Set 方法。設定對談的資料項,就像字典中的 dict[key] = value。
9、TryGetValue 方法。獲取與給定 Key 對應的資料。類似字典物件的 dict[key]。
為了簡單,老周這裡就只是實現一個用靜態字典變數儲存 Session 的例子。嗯,也就是儲存在記憶體中。
1、實現 ISession 介面。
public class CustSession : ISession { #region 私有欄位 private readonly string _sessionId; private readonly CustSessionDataManager _dataManager; private readonly TimeSpan _idleTimeout, _ioTimeout; private readonly Func<bool> _tryEstablishSession; private readonly bool _isNewId; // 這個欄位表示是否成功載入資料 private bool _isLoadSuccessed = false; // 當前正在使用的對談資料 private SessionData _currentData; #endregion // 建構函式 public CustSession( string sessionId, // 對談標識 TimeSpan idleTimeout, // 過期時間 TimeSpan ioTimeout, // 讀寫過期時間 bool isNewId, // 是否為新對談 // 這個委託表示能否設定對談 Func<bool> tryEstablishSession, // 用於管理對談資料的自定義類 CustSessionDataManager dataManager ) { _sessionId = sessionId; _idleTimeout = idleTimeout; _ioTimeout = ioTimeout; _isNewId = isNewId; _tryEstablishSession = tryEstablishSession; _dataManager = dataManager; _currentData = new(); } public bool IsAvailable { get { // 嘗試載入一次 LoadCore(); return _isLoadSuccessed; } } public string Id => _sessionId; public IEnumerable<string> Keys => _currentData?.Data?.Keys ?? Enumerable.Empty<string>(); public void Clear() { _currentData.Data?.Clear(); } public Task CommitAsync(CancellationToken cancellationToken = default) { _currentData.CreateTime = DateTime.Now; _currentData.Expires = _currentData.CreateTime + _idleTimeout; SessionData newData = new(); newData.CreateTime = _currentData.CreateTime; newData.Expires = _currentData.Expires; // 複製資料 foreach(string k in _currentData.Data.Keys) { newData.Data[k] = _currentData.Data[k]; } // 新增新記錄 _dataManager.SessionDataList[_sessionId] = newData; return Task.CompletedTask; } public Task LoadAsync(CancellationToken cancellationToken = default) { LoadCore(); return Task.CompletedTask; } // 內部方法 private void LoadCore() { // 條件1:還沒載入過資料 // 條件2:對談不是新的,新建對談不用載入 if (_isNewId) { return; } if (_isLoadSuccessed) return; if (_currentData.Data == null) { _currentData.Data = new Dictionary<string, byte[]>(); } // 臨時變數 SessionData? tdata = _dataManager.SessionDataList.FirstOrDefault(k => k.Key == _sessionId).Value; if (tdata != null) { _currentData.CreateTime = tdata.CreateTime; _currentData.Expires = tdata.Expires; // 複製資料 foreach(string k in tdata.Data.Keys) { _currentData.Data[k] = tdata.Data[k]; } _isLoadSuccessed = true; } } public void Remove(string key) { LoadCore(); _currentData.Data.Remove(key); } public void Set(string key, byte[] value) { if (_tryEstablishSession() == false) { throw new InvalidOperationException(); } if (_currentData.Data == null) { _currentData.Data = new Dictionary<string, byte[]>(); } _currentData.Data.Add(key, value); } public bool TryGetValue(string key, [NotNullWhen(true)] out byte[]? value) { value = null; LoadCore(); return _currentData.Data.TryGetValue(key, out value); } }
建構函式的引數基本是接收從 ISessionStore.Create方法處獲得的引數。
這裡涉及兩個自定義的類:
第一個是 SessionData,負責存對談,關鍵資訊有建立時間和過期時間,以及對談資料(用字典表示)。儲存過期時間是方便後面實現清理——過期的刪除。
internal class SessionData { /// <summary> /// 對談建立時間 /// </summary> public DateTime CreateTime { get; set; } /// <summary> /// 對談過期時間 /// </summary> public DateTime Expires { get; set; } /// <summary> /// 對談資料 /// </summary> public IDictionary<string, byte[]> Data { get; set; } = new Dictionary<string, byte[]>(); }
我們的伺服器肯定不會只有一個人存取,肯定會有很多 Session,所以自定義一個 CustSessionDataManager 類,用來管理一堆 SessionData。
public class CustSessionDataManager { private readonly static Dictionary<string, SessionData> sessionDatas = new(); internal IDictionary<string, SessionData> SessionDataList { get { CheckAndRemoveExpiredItem(); return sessionDatas; } } /// <summary> /// 掃描並清除過期的對談 /// </summary> private void CheckAndRemoveExpiredItem() { var now = DateTime.Now; foreach(string key in sessionDatas.Keys) { SessionData data = sessionDatas[key]; if(data.Expires < now) sessionDatas.Remove(key); } } }
CustSessionDataManager 待會兒會把它放進服務容器中,用於注入其他物件中使用。SessionDataList 屬性獲取已快取的 Session 列表,字典結構,Key 是 Session ID,Value是SessionData範例。
老周這裡的刪除方案是每當存取 SessionDataList 屬性時就呼叫一次 CheckAndRemoveExpiredItem 方法。這個方法會掃描所有已快取的對談資料,找到過期的就刪除。這個是為了省事,如果你認為這樣不太好,也可以寫個後臺服務,用 Timer 來控制每隔一段時間清理一次資料,也可以。只要你開動腦子,啥方案都行。
好了,下面輪到實現 ISessionStore 了。
public class CustSessionStore : ISessionStore { // 用於接收依賴注入 private readonly CustSessionDataManager _dataManager; public CustSessionStore(CustSessionDataManager manager) { _dataManager = manager; } public ISession Create(string sessionKey, TimeSpan idleTimeout, TimeSpan ioTimeout, Func<bool> tryEstablishSession, bool isNewSessionKey) { return new CustSession(sessionKey, idleTimeout, ioTimeout, isNewSessionKey, tryEstablishSession, _dataManager); } }
核心程式碼就是 Create 方法裡的那一句。
剛才我為啥說要把 CustSessionDataManager 也放進服務容器呢,你看,這就用上了,在 CustSessionStore 的建構函式中就可以直接獲取了。
最後一步,咱封裝一套擴充套件方法,就像 ASP.NET Core 裡面 AddSession、AddRazorPages 那樣,只要簡單呼叫就行。
public static class CustSessionExtensions { public static IServiceCollection AddCustSession(this IServiceCollection services, Action<SessionOptions> options) { services.AddOptions(); services.Configure(options); services.AddDataProtection(); services.AddSingleton<CustSessionDataManager>(); services.AddTransient<ISessionStore, CustSessionStore>(); return services; } public static IServiceCollection AddCustSession(this IServiceCollection services) { return services.AddCustSession(opt => { }); } }
因為伺服器在響應時要對 Cookie 加密,所以要依賴資料保護功能,因此記得呼叫 AddDataProtection 擴充套件方法。另外的兩行,就是向服務容器新增我們剛寫的型別。
好了,回到 Program.cs,在應用程式初始化過程中,我們就可以用上面的擴充套件方註冊自定義 Session 功能。
var builder = WebApplication.CreateBuilder(args); builder.Services.AddCustSession(opt => { // 設定過期時間 opt.IdleTimeout = TimeSpan.FromSeconds(4); }); var app = builder.Build();
為了能快速看到過期效果,我設定過期時間為 4 秒。
測試一下。
app.UseSession(); app.MapGet("/", (HttpContext context) => { ISession session = context.Session; string? val = session.GetString("mykey"); if (val == null) { // 設定對談 session.SetString("mykey", "官倉老鼠大如鬥"); return "你是首次存取,已設定對談"; } return $"歡迎回來\n對談:{val}"; }); app.Run();
請大夥伴們記住:在任何要使用 Session 的中介軟體/終結點之前,一定要呼叫 UseSession 方法。這樣才能把 ISessionFeature 新增到 HttpContext 物件中,然後 HttpContext.Session 屬性才能存取。
執行一下看看。現在沒有設定對談,所以顯示是第一次存取本站的訊息。
一旦對談設定了,再次存取,就是歡迎回來了。
好了,就這樣了。本範例僅作演示,由於 bug 過多,無法投入生產環境使用。