图 1:ASP.NET 1.1 中的会话状态体系结构
在完成请求所需的时间内,会话状态的物理值处于锁定状态。该锁定由 HTTP 模块在内部管理并用于同步对会话状态的访问。
会话状态模块实例化应用程序的状态提供程序,并使用从 web.config 文件中读取的信息对其进行初始化。接下来,每个提供程序将继续自己的初始化操作。提供程序的类型不同,其初始化操作会大不相同。例如,SQL Server 状态管理器将打开与给定数据库的连接,而进程外管理器将检查指定的 TCP 端口。另一方面,InProc 状态管理器将存储对回调函数的引用。从缓存中删除元素时将执行此操作,并用于触发应用程序的 Session_OnEnd 事件。
同步访问会话状态
当 Web 页对 Session 属性进行非常简单且直观的调用时,究竟会出现什么情况呢?许多操作都是在后台进行的,如下面的繁琐代码所示:
int siteCount = Convert.ToInt32(Session["Counter"]);
上述代码实际上访问的是 HTTP 模块创建的会话值在本地内存中的副本,从特定状态提供程序(参见图 1)中读取数据。如果其他页面也试图同步访问该会话状态,又会如何呢?这种情况下,当前的请求可能会停止处理不一致的数据或过时的数据。为了避免这种情况,会话状态模块将实现一个读取器/写入器锁定机制,并对状态值的访问进行排队。对会话状态具有写入权限的页面将保留该会话的写入器锁定,直到请求终止。
通过将 @Page 指令的 EnableSessionState 属性设置为 true,页面可以请求会话状态的写入权限。(这是默认设置)。但是,页面还可以拥有会话状态的只读权限,例如,当 EnableSessionState 属性被设置为 ReadOnly 时。在这种情况下,模块将保留该会话的读取器锁定,直到该页面的请求结束。结果将发生并发读取。
如果页面请求设置一个读取器锁定,同一会话中同时处理的其他请求将无法更新会话状态,但是至少可以进行读取。也就是说,如果当前正在处理会话的只读请求,那么等候的只读请求要比需要完全访问权限的请求具有更高的优先权。如果页面请求为会话状态设置一个写入器锁定,那么所有其他页面都将被阻止,无论它们是否要读取或写入内容。例如,如果同时有两个框架试图在 Session 中写入内容,一个框架必须等到另一个框架完成后才能写入。
比较状态提供程序
默认情况下,ASP.NET 应用程序将会话状态存储在辅助进程的内存中,特别是 Cache 对象的专用槽中。选中 InProc 模式时,会话状态将存储在 Cache 对象内的槽中。此槽被标记为专用槽,无法通过编程方式进行访问。换句话说,如果枚举 ASP.NET 数据缓存中的所有项目,将不会返回类似于给定会话状态的任何对象。Cache 对象提供两类槽:专用槽和公用槽。编程人员可以添加和处理公用槽,但专用槽只能由系统(特别是 system.web 部件中定义的类)专用。
每个活动会话的状态都占用缓存中的一个专用槽。槽的名称根据会话 ID 进行命名,其值是名为 SessionStateItem 的内部未声明类的一个实例。InProc 状态提供程序获取会话 ID 并在缓存中检索对应的元素。然后将 SessionStateItem 对象的内容输入 HttpSessionState 词典对象,并由应用程序通过 Session 属性进行访问。请注意,ASP.NET 1.0 中存在一个错误,使 Cache 对象的专用槽可以通过编程方式进行枚举。如果您在 ASP.NET 1.0 下运行以下代码,则能够枚举与每个当前活动会话状态中包含的对象对应的项目。
foreach(DictionaryEntry elem in Cache)
{
Response.Write(elem.Key + ": " + elem.Value.ToString());
}
此错误已经在 ASP.NET 1.1 中得到解决,当您枚举缓存的内容时,将不再列出任何系统槽。
到目前为止,InProc 可能是最快的访问选项。但请记住,会话中存储的数据越多,Web 服务器所消耗的内存就越多,这样会潜在地增加性能降低的风险。如果您计划使用任何进程外解决方案,应该认真考虑一下序列化和反序列化可能带来的影响。进程外解决方案使用 Windows NT 服务 (aspnet_state.exe) 或 SQL Server 表来存储会话值。因此,会话状态保留在 ASP.NET 辅助进程之外,并且需要使用额外的代码层,在会话状态和实际的存储介质之间进行序列化和反序列化操作。只要处理请求就会发生此操作,而且随后必须对其进行最高程度的优化。
因为需要将会话数据从外部储备库复制到本地会话词典中,所以请求导致性能下降了 15%(进程外)到 25% (SQL Server)。请注意,虽然这只是一种粗略的估计,但它应该接近于最低程度的影响,最高程度的影响将远高于此。实际上,这种估计并没有完全考虑到会话状态中实际保存的类型的复杂程度。
在进程外存储方案中,会话状态存活的时间较长,使应用程序的功能更强大,因为它可以防止 Microsoft® Internet 信息服务 (IIS) 和 ASP.NET 失败。通过将会话状态与应用程序相分离,您还可以更容易地将现有应用程序扩展到 Web Farm 和 Web Garden 体系结构中。另外,会话状态存储在外部进程中,从根本上消除了由于进程循环而导致的周期性数据丢失的风险。
下面介绍如何使用 Windows NT 服务。正如上文所述,NT 服务是一个名为 aspnet_state.exe 的进程,通常位于 C:\WINNT\Microsoft.NET\Framework\v1.1.4322 文件夹中。
实际目录取决于您实际运行的 Microsoft® .NET Framework 版本。使用状态服务器之前,应确保该服务就绪并正运行在用作会话存储设备的本地或远程计算机上。状态服务是 ASP.NET 的组成部分并与之一起安装,因此您无需运行其他安装程序。默认情况下,状态服务并没有运行,需要手动启动。ASP.NET 应用程序将在加载状态服务器之后立即尝试与之建立连接。因此,该服务必须准备就绪且正在运行,否则将引发 HTTP 异常。下图显示了该服务的属性对话框。
图 2:ASP.NET 状态服务器的属性对话框
ASP.NET 应用程序需要指定会话状态服务所在的计算机的 TCP/IP 地址。必须将以下设置输入该应用程序的 web.config 文件中。
stateConnectionString 特性包含计算机的 IP 地址以及用来进行数据交换的端口。默认的计算机地址为 127.0.0.1(本地主机),默认端口为 42424。您也可以按名称指示计算机。对于代码来说,使用本地或远程计算机是完全透明的。请注意,不能在该名称中使用非 ASCII 字符,并且端口号是强制的。
如果您使用进程外会话存储,会话状态将仍然存在并且可供将来使用,无论 ASP.NET 辅助进程出现何种情况。如果该服务被中断,数据将被保留下来,并且在该服务恢复时自动进行检索。但是,如果状态提供程序服务停止或失败,数据将丢失。如果您希望应用程序具有强大的功能,请使用 SQLServer 模式,而不要使用 StateServer 模式。
您可以通过 sqlConnectionString 特性指定连接字符串。请注意,特性字符串必须包含用户 ID、密码和服务器名称。它不能包含 Database 和 Initial Catalog 之类的标记,因为此信息默认为固定名称。用户 ID 和密码可以替换为集成的安全设置。
如何创建数据库?ASP.NET 提供两对脚本来配置数据库环境。第一对脚本名为 InstallSqlState.sql 和 UninstallSqlState.sql,与会话状态 NT 服务位于同一个文件夹中。它们创建名为 ASPState 的数据库和几个存储的过程。但是,数据存储在 SQL Server 临时存储区域 TempDB 数据库中。这意味着,如果重新启动 SQL Server 计算机,会话数据将丢失。
要解决这一局限性,请使用第二对脚本。第二对脚本名为 InstallPersistSqlState.sql 和 UninstallPersistSqlState.sql。在这种情况下,将创建 ASPState 数据库,但是会在同一个数据库中创建数据表,而且这些数据表同样是持久的。为会话安装 SQL Server 支持时,还将创建一个作业,以删除会话状态数据库中过期的会话。该作业名为 ASPState_Job_DeleteExpiredSessions 并且一直运行。请注意,要使该作业正常进行,需要运行 SQLServerAgent 服务。
无论您选择哪种模式,为会话状态操作进行编码的方式都不会改变。您可以始终针对 Session 属性进行工作并像平常一样读取和写入值。所有行为上的差异都是在较低的抽象层上处理的。状态序列化或许是会话模式之间的最重要差异。
状态序列化和反序列化
使用进程内模式时,对象作为各自类的活动实例存储在会话状态中。如果未发生真正的序列化和反序列化,则表示您实际上可以在 Session 中存储您创建的任何对象(包括无法序列化的对象和 COM 对象),并且访问它们的开销也不会太高。如果您选择进程外状态提供程序,又是另外一种情况。
在进程外体系结构中,会话值将从本地存储介质(外部 AppDomain 数据库)复制到处理请求的 AppDomain 的内存中。需要使用序列化/反序列化图层完成该任务,并表示进程外状态提供程序的某项主要成本。这种情况对代码产生的主要影响是只能在会话词典中存储可序列化的对象。
根据所涉及的数据类型,ASP.NET 使用两种方法对数据进行序列化和反序列化。对于基本类型,ASP.NET 使用经过优化的内部序列化程序;对于其他类型(包括对象和用户定义的类),ASP.NET 使用 .NET 二进制格式化程序。基本类型包括字符串、日期时间、布尔值、字节、字符以及所有的数字类型。对于这些类型,使用量身制作的序列化程序要比使用默认的常用 .NET 二进制格式化程序更快。
经过优化的序列化程序没有公开发布,也没有以文档形式提供。它仅仅是二进制读取器/写入器,并且使用简单但有效的存储架构。该序列化程序使用 BinaryWriter 类写入一个字节表示类型,然后写入一个字节表示该类型对应的值。读取序列化的字节时,该类首先提取一个字节,检测要读取的数据类型,然后对 BinaryReader 类调用特定类型的 ReadXxx 方法。
请注意,布尔值和数字类型的大小是众所周知的,但对字符串并非如此。在基础数据流上,字符串始终带有一个固定长度的前缀(一次编写 7 位整数代码),读取器根据这一事实来确定字符串的正确大小。而日期值是通过只写入构成日期的标记总数来保存的。因此,要对会话执行序列化操作,日期应为 Int64 类型。
只要将包含的类标记为可序列化的类,便可以使用 BinaryFormatter 类对更复杂的对象(以及自定义对象)执行序列化操作。所有非基本类型都采用相同的类型 ID 进行标识并与基本类型存储在同一个数据流中。总之,序列化操作会导致性能下降 15% 至 25%。但请注意,这是基于假定使用基本类型所进行的粗略估计。使用的类型越复杂,开销越大。
如果不大量使用基本类型,很难实现有效的会话数据存储。因此,至少在理论上,使用三个会话槽保存对象的三个不同的字符串属性要比对整个对象进行序列化好。但是,如果要序列化的对象包含 100 个属性,那该怎么办呢?是要使用 100 个槽,还是只使用一个槽?在许多情况下,更好的方法是将复杂的类型转换为多个简单的类型。这种方法基于类型转换器。“类型转换器”是一种轻便的序列化程序,它以字符串集合的形式返回类型的关键属性。类型转换器是使用特性与基类绑定在一起的外部类。由类型编写者决定保存哪些属性以及如何保存。类型转换器对于 ViewState 存储也有帮助,它代表的是比二进制格式化程序更有效的会话存储方法。
会话的生命周期
关于 ASP.NET 会话管理,重要的一点是,仅当将第一个项目添加到内存词典中时,会话状态对象的生命周期才开始。仅在执行如下代码片断后,才可以认为 ASP.NET 会话开始。
Session["MySlot"] = "Some data";
Session 词典通常包含 Object 类型,要向后读取数据,需要将返回的值转换为更具体的类型。
string data = (string) Session["MySlot"];
当页面将数据保存到 Session 中时,会将值加载到 HttpSessionState 类包含的特制的词典类中。完成当前处理的请求时,会将词典的内容加载到状态提供程序中。如果由于未通过编程方式将数据放入词典而导致会话状态为空,则不会将数据序列化到存储介质中,而且更重要的是,不会在 ASP.NET Cache、SQL Server 或 NT 状态服务中创建槽来跟踪当前会话。这是出于性能方面的原因,但会对处理会话 ID 的方式产生重要影响:将为每个请求生成一个新的会话 ID,直到将某些数据存储到会话词典中。
需要将会话状态与正在处理的请求连接时,HTTP 模块会检索会话 ID(如果它不是启动请求),并在配置的状态提供程序中寻找它。如果没有返回数据,HTTP 模块将为请求生成一个新的会话 ID。这可以很容易地通过以下页面进行测试:
<%@ Page Language="C#" Trace="true" %>