在 ASP.NET Core 中,有多種途徑可以對應用程式狀態進行管理,取決於檢索狀態的時機和方式。本文簡要介紹幾種可選的方式,並著重介紹為 ASP.NET Core 應用程式安裝並配置會話狀態支持。 ...
原文:Managing Application State
作者:Steve Smith
翻譯:姚阿勇(Dr.Yao)
校對:高嵩
在 ASP.NET Core 中,有多種途徑可以對應用程式的狀態進行管理,取決於檢索狀態的時機和方式。本文簡要介紹幾種可選的方式,並著重介紹為 ASP.NET Core 應用程式安裝並配置會話狀態支持。
應用程式狀態的可選方式
應用程式狀態
指的是用於描述應用程式當前狀況的任意數據。包括全局的和用戶特有的數據。之前版本的ASP.NET(甚至ASP)都內建了對全局的 Application
和 State
以及其他很多種狀態存儲的支持。
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();
然後,將下麵的代碼添加到 Configure
中 app.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
。不過,有擴展方法可以讓我們在使用諸如 String
和 Int32
的簡單類型時更加容易。
// 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[]
位元組流的類型獲取,然後利用 MemoryStream
和 BinaryFormatter
將它反序列化,如上所示。如果 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
演示瞭如何利用 MemoryStream
和 BinaryFormatter
將自定義類型對象序列化為一個 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秒鐘。