【ASP.NET Core】自定義Session的存儲方式

在開始今天的表演之前,老周先跟大夥伴們說一句:「中秋節快樂」。

今天咱們來聊一下如何自己動手,實現會話(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 過多,無法投入生產環境使用。