ASP.NET Core管理应用程序状态

原文:Managing Application State
作者:Steve Smith
翻译:姚阿勇(Dr.Yao)
校对:高嵩

在 ASP.NET Core 中,有多种途径可以对应用程序的状态进行管理,取决于检索状态的时机和方式。本文简要介绍几种可选的方式,并着重介绍为 ASP.NET Core 应用程序安装并配置会话状态支持。

查看或下载示例代码

应用程序状态的可选方式

应用程序状态 指的是用于描述应用程序当前状况的任意数据。包括全局的和用户特有的数据。之前版本的ASP.NET(甚至ASP)都内建了对全局的 ApplicationState 以及其他很多种状态存储的支持。

Application 储存和ASP.NET的 Cache 缓存的特性几乎一样,只是少了一些功能。在 ASP.NET Core 中,Application 已经没有了;可以用Caching 的实现来代替 Application 的功能,从而把之前版本的 ASP.NET 应用程序升级到 ASP.NET Core 。

应用程序开发人员可以根据不同因素来选择不同的方式储存状态数据:

  • 数据需要储存多久?

  • 数据有多大?

  • 数据的格式是什么?

  • 数据是否可以序列化?

  • 数据有多敏感?能不能保存在客户端?

根据这些问题的答案,可以选择不同的方式储存和管理 ASP.NET Core 应用程序状态。

HttpContext.Items

当数据仅用于一个请求之中时,用 Items 集合储存是最好的方式。数据将在每个请求结束之后被丢弃。它可以作为组件和中间件在一个请求期间的不同时间点进行互相通讯的最佳手段。

QueryString 和 Post

在查询字符串( QueryString )中添加数值、或利用 POST 发送数据,可以将一个请求的状态数据提供给另一个请求。这种技术不应该用于敏感数据,因为这需要将数据发送到客户端,然后再发送回服务器。这种方法也最好用于少量的数据。查询字符串对于持久地保留状态特别有用,可以将状态嵌入链接通过电子邮件或社交网络发出去,以备日后使用。然而,用户提交的请求是无法预期的,由于带有查询字符串的网址很容易被分享出去,所以必须小心以避免跨站请求伪装攻击( Cross-Site Request Forgery (CSRF))。(例如,即便设定了只有通过验证的用户才可以访问带有查询字符串的网址执行请求,攻击者还是可能会诱骗已经验证过的用户去访问这样的网址)。

Cookies

与状态有关的非常小量的数据可以储存在 Cookies 中。他们会随每次请求被发送,所以应该保持在最小的尺寸。理想情况下,应该只使用一个标识符,而真正的数据储存在服务器端的某处,键值与这个标识符关联。

Session

会话( Session )储存依靠一个基于 Cookie 的标识符来访问与给定浏览器(来自一个特定机器和特定浏览器的一系列访问请求)会话相关的数据。你不能假设一个会话只限定给了一个用户,因此要慎重考虑在会话中储存哪些信息。这是用来储存那种针对具体会话,但又不要求永久保持的(或者说,需要的时候可以再从持久储存中重新获取的)应用程序状态的好地方。详情请参考下文 安装和配置 Session

Cache

缓存( Caching )提供了一种方法,用开发者自定义的键对应用程序数据进行储存和快速检索。它提供了一套基于时间和其他因素来使缓存项目过期的规则。详情请阅读 Caching

Configuration

配置( Configuration )可以被认为是应用程序状态储存的另外一种形式,不过通常它在程序运行的时候是只读的。详情请阅读 Configuration

其他持久化

任何其他形式的持久化储存,无论是 Entity Framework 和数据库还是类似 Azure Table Storage 的东西,都可以被用来储存应用程序状态,不过这些都超出了 ASP.NET 直接支持的范围。

使用 HttpContext.Items

HttpContext 抽象提供了一个简单的 IDictionary<object, object> 类型的字典集合,叫作 Items。在每个请求中,这个集合从 HttpRequest 开始起就可以使用,直到请求结束后被丢弃。要存取集合,你可以直接给键控项赋值,或根据给定键查询值。

举个例子,一个简单的中间件 Middleware可以在 Items 集合中增加一些内容:

  app.Use(async (context, next) =>
    {
      // perform some verification
      context.Items["isVerified"] = true;
      await next.Invoke();
    });

而在之后的管道中,其他的中间件就可以访问到这些内容了:

  app.Run(async (context) =>
  {
    await context.Response.WriteAsync("Verified request? "
      + context.Items["isVerified"]);
  });

Items 的键名是简单的字符串,所以如果你是在开发跨越多个应用程序工作的中间件,你可能要用一个唯一标识符作为前缀以避免键名冲突。(如:采用"MyComponent.isVerified",而非简单的"isVerified")。

安装和配置 Session

ASP.NET Core 发布了一个关于会话的程序包,里面提供了用于管理会话状态的中间件。你可以在 project.json 中加入对 Microsoft.AspNetCore.Session 的引用来安装这个程序包:

当安装好程序包后,必须在你的应用程序的 Startup 类中对 Session 进行配置。Session 是基于 IDistributedCache 构建的,因此你也必须把它配置好,否则会得到一个错误。

如果你一个 IDistributedCache 的实现都没有配置,则会得到一个异常,说“在尝试激活 'Microsoft.AspNetCore.Session.DistributedSessionStore' 的时候,无法找到类型为 'Microsoft.Extensions.Caching.Distributed.IDistributedCache' 的服务。”

ASP.NET 提供了 IDistributedCache 的多种实现, in-memory 是其中之一(仅用于开发期间和测试)。要配置会话采用 in-memory ,需将 Microsoft.Extensions.Caching.Memory 依赖项加入你的 project.json 文件,然后再把以下代码添加到 ConfigureServices

services.AddDistributedMemoryCache();
services.AddSession();

然后,将下面的代码添加到 Configureapp.UseMVC()之前 ,你就可以在程序代码里使用会话了:

  app.UseSession();

安装和配置好之后,你就可以从 HttpContext 引用Session了。

如果你在调用 UseSession 之前尝试访问 Session ,则会得到一个 InvalidOperationException 异常,说“ Session 还没有在这个应用程序或请求中配置好。”

警告: 如果在开始向 Response 响应流中写入内容之后再尝试创建一个新的 Session (比如,还没有创建会话 cookie),你将会得到一个 InvalidOperationException 异常,说“不能在开始响应之后再建立会话。”

实现细节

Session 利用一个 cookie 来跟踪和区分不同浏览器发出的请求。默认情况下,这个 cookie 命名为 ".AspNet.Session"并使用路径 "/"。此外,在默认情况下这个 cookie 不指定域,而且对于页面的客户端脚本是不可使用的(因为 CookieHttpOnly 的默认值是 True)。

这些默认值,包括 IdleTimeout (独立于 cookie 在服务端使用),都可以在通过 SessionOptions 配置 Session 的时候覆盖重写,如下所示:

services.AddSession(options =>
{
  options.CookieName = ".AdventureWorks.Session";
  options.IdleTimeout = TimeSpan.FromSeconds(10);
});

IdleTimeout 在服务端用来决定在会话被抛弃之前可以闲置多久。任何来到网站的请求通过 Session 中间件(无论这中间件对 Session 是读取还是写入)都会重置会话的超时时间。

Session无锁 的,因此如果两个请求都尝试修改会话的内容,最后一个会成功。此外,Session 被实现为一个内容连贯的会话,就是说所有的内容都是一起储存的。这就意味着,如果两个请求是在修改会话中不同的部分(不同的键),他们还是会互相造成影响。

ISession

一旦 Session 安装和配置完成,你就可以通过 HttpContext 的一个名为 Session,类型为 ISession 的属性来引用会话了。

public interface ISession
{
  bool IsAvailable { get; }
  string Id { get; }
  IEnumerable<string> Keys { get; }
  Task LoadAsync();
  Task CommitAsync();
  bool TryGetValue(string key, out byte[] value);
  void Set(string key, byte[] value);
  void Remove(string key);
  void Clear();
  IEnumerable<string> Keys { get; }
}

因为 Session 是建立在 IDistributedCache 之上的,所以总是需要序列化被储存的对象实例。因此,这个接口使用 byte[] 而不是直接使用 object。不过,有扩展方法可以让我们在使用诸如 StringInt32 的简单类型时更加容易。

// session extension usage examples
context.Session.SetInt32("key1", 123);
int? val = context.Session.GetInt32("key1");
context.Session.SetString("key2", "value");
string stringVal = context.Session.GetString("key2");
byte[] result = context.Session.Get("key3");

如果要储存更复杂的对象,你需要把对象序列化为一个 byte[] 字节流以便储存,而后在获取对象的时候,还要将它们从 byte[] 字节流进行反序列化。

使用 Session 的示例

这个示例程序演示了如何使用 Session ,包括储存和获取简单类型以及自定义对象。为了便于观察会话过期后会发生什么,示例中将会话的超时时间配置为短短的10秒:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDistributedMemoryCache();
    services.AddSession(options =>
    {
        options.IdleTimeout = TimeSpan.FromSeconds(10);
    });
}

当你首次访问这个网页,它会在屏幕上显示说还没有会话被建立:

这个默认的行为是由下面这些 Startup.cs 里的中间件产生的,当有尚未建立会话的请求来访的时候,这些中间件就会执行(注意高亮部分):

 // 主要功能中间件
app.Run(async context =>
{
    RequestEntryCollection collection = GetOrCreateEntries(context);

    if (collection.TotalCount() == 0)
    {
        await context.Response.WriteAsync("<html><body>");
        await context.Response.WriteAsync("你的会话尚未建立。<br>");
        await context.Response.WriteAsync(DateTime.Now.ToString() + "<br>");
        await context.Response.WriteAsync("<a href=\"/session\">建立会话</a>。<br>");
    }
    else
    {
        collection.RecordRequest(context.Request.PathBase + context.Request.Path);
        SaveEntries(context, collection);

        // 注意:最好始终如一地在往响应流中写入内容之前执行完所有对会话的存取。
        await context.Response.WriteAsync("<html><body>");
        await context.Response.WriteAsync("会话建立于: " + context.Session.GetString("StartTime") + "<br>");
        foreach (var entry in collection.Entries)
        {
            await context.Response.WriteAsync("路径: " + entry.Path + " 被访问了 " + entry.Count + " 次。<br />");
        }

        await context.Response.WriteAsync("你访问本站的次数是:" + collection.TotalCount() + "<br />");
    }
    await context.Response.WriteAsync("<a href=\"/untracked\">访问不计入统计的页面</a>.<br>");
    await context.Response.WriteAsync("</body></html>");
});

GetOrCreateEntries 是一个辅助方法,它会从 Session 获取一个 RequestEntryCollection 集合,如果没有则创建一个空的,然后将其返回。这个集合保存 RequestEntry 对象实例,用来跟踪当前会话期间,用户发出的不同请求,以及他们对每个路径发出了多少请求。

public class RequestEntry
{
    public string Path { get; set; }
    public int Count { get; set; }
}
public class    RequestEntryCollection
{
    public List<RequestEntry> Entries { get; set; } = new List<RequestEntry>();

    public void RecordRequest(string requestPath)
    {
        var existingEntry = Entries.FirstOrDefault(e => e.Path == requestPath);
        if (existingEntry != null) { existingEntry.Count++; return; }

        var newEntry = new RequestEntry()
        {
            Path = requestPath,
            Count = 1
        };
        Entries.Add(newEntry);
    }

    public int TotalCount()
    {
        return Entries.Sum(e => e.Count);
    }
}

储存在会话中的类型必须用 [Serializable] 标记为可序列化的。

获取当前的 RequestEntryCollection 实例是由辅助方法 GetOrCreateEntries 来完成的:

 private RequestEntryCollection GetOrCreateEntries(HttpContext context)
{
    RequestEntryCollection collection = null;
    byte[] requestEntriesBytes;
    context.Session.TryGetValue("RequestEntries",out requestEntriesBytes);

    if (requestEntriesBytes != null && requestEntriesBytes.Length > 0)
    {
        string json = System.Text.Encoding.UTF8.GetString(requestEntriesBytes);
        return JsonConvert.DeserializeObject<RequestEntryCollection>(json);
    }
    if (collection == null)
    {
        collection = new RequestEntryCollection();
    }
    return collection;
}

如果对象实体存在于 Session 中,则会以 byte[] 字节流的类型获取,然后利用 MemoryStreamBinaryFormatter 将它反序列化,如上所示。如果 Session 中没有这个对象,这个方法则返回一个新的 RequestEntryCollection 实例。

在浏览器中,点击"建立会话"链接发起一个对路径"/session"的访问请求,然后得到如下结果:

刷新页面会使计数增加;再刷新几次之后,回到网站的根路径,如下显示,统计了当前会话期间所发起的所有请求:

建立会话是由一个中间件通过处理 "/session" 请求来完成的。

// 建立会话
app.Map("/session", subApp =>
{
    subApp.Run(async context =>
    {
        // 把下面这行取消注释,并且清除 cookie ,在响应开始之后再存取会话时,就会产生错误
        // await context.Response.WriteAsync("some content");
        RequestEntryCollection collection = GetOrCreateEntries(context);
        collection.RecordRequest(context.Request.PathBase + context.Request.Path);
        SaveEntries(context, collection);
        if (context.Session.GetString("StartTime") == null)
        {
            context.Session.SetString("StartTime", DateTime.Now.ToString());
        }
        await context.Response.WriteAsync("<html><body>");
        await context.Response.WriteAsync("统计: 你已经对本程序发起了"+ collection.TotalCount() +"次请求.<br><a href=\"/\">返回</a>");
        await context.Response.WriteAsync("</body></html>");

    });
});

对该路径的请求会获取或创建一个 RequestEntryCollection 集合,再把当前路径添加到集合里,最后用辅助方法 SaveEntries 把集合储存到会话中去,如下所示:

private void SaveEntries(HttpContext context, RequestEntryCollection collection)
{
    string json = JsonConvert.SerializeObject(collection);
    byte[] serializedResult = System.Text.Encoding.UTF8.GetBytes(json);

    context.Session.Set("RequestEntries", serializedResult);            
}

SaveEntries 演示了如何利用 MemoryStreamBinaryFormatter 将自定义类型对象序列化为一个 byte[] 字节流,以便储存到 Session 中。

这个示例中还有一段中间件的代码值得注意,就是映射 "/untracked" 路径的代码。可以在下面看看它的配置:

 // 一个配置于 app.UseSession() 之前,完全不使用 session 的中间件的例子
app.Map("/untracked", subApp =>
{
    subApp.Run(async context =>
    {
        await context.Response.WriteAsync("<html><body>");
        await context.Response.WriteAsync("请求时间: " + DateTime.Now.ToString() + "<br>");
        await context.Response.WriteAsync("应用程序的这个目录没有使用 Session ...<br><a href=\"/\">返回</a>");
        await context.Response.WriteAsync("</body></html>");
    });
});

app.UseSession();

注意这个中间件是在 app.UseSession 被调用(第13行)之前 就配置好的。因此, Session 的功能在中间件中还不能用,那么访问到这个中间件的请求将不会重置会话的 IdleTimeout 。为了证实这一点,你可以在 /untracked 页面上反复刷新10秒钟,再回到首页查看。你会发现会话已经超时了,即使你最后一次刷新到现在根本没有超过10秒钟。