客户端和服务器基于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中显示的则不同。
图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。
图2 呈现当前会话的Session Key
如果有这个保存当前会话状态的Session Key,我们就可以按照图3所示的方式采用命令行的形式将存储在Redis数据库中的会话状态数据提取出来。当会话状态在采用默认的分布式缓存进行存储时,整个数据字典(包括Key和Value)会采用预定义的格式序列化成字节数组,这基本上可以从图3体现出来。我们还可以看出基于会话状态的缓存默认采用的是基于滑动时间的过期策略,默认采用的滑动过期时间为20分(12 000 000 000纳秒)。
图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并不能标识一个会话。