ASP.NET Core 中文文檔 第三章 原理(13)管理應用程序狀態


原文: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秒鍾。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM