【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 過多,無法投入生產環境使用。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章