【ASP.NET Core】自定义Session的存储方式
- 2022 年 9 月 10 日
- 筆記
- .NET, Asp.Net Core, 个人文章
在开始今天的表演之前,老周先跟大伙伴们说一句:“中秋节快乐”。
今天咱们来聊一下如何自己动手,实现会话(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 过多,无法投入生产环境使用。