.NET Core Session原始碼探究
阿新 • • 發佈:2020-06-05
### 前言
隨著網際網路的興起,技術的整體架構設計思路有了質的提升,曾經Web開發必不可少的內建物件Session已經被慢慢的遺棄。主要原因有兩點,一是Session依賴Cookie存放SessionID,即使不通過Cookie傳遞,也要依賴在請求引數或路徑上攜帶Session標識,對於目前前後端分離專案來說操作起來限制很大,比如跨域問題。二是Session資料跨伺服器同步問題,現在基本上專案都使用負載均衡技術,Session同步存在一定的弊端,雖然可以藉助Redis或者其他儲存系統實現中心化儲存,但是略顯雞肋。雖然存在一定的弊端,但是在.NET Core也並沒有拋棄它,而且藉助了更好的實現方式提升了它的設計思路。接下來我們通過分析原始碼的方式,大致瞭解下新的工作方式。
### Session如何使用
.NET Core的Session使用方式和傳統的使用方式有很大的差別,首先它依賴儲存系統IDistributedCache來儲存資料,其次它依賴SessionMiddleware為每一次請求提供具體的例項。所以使用Session之前需要配置一些操作,相信介紹情參閱微軟官方文件[會話狀態](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/app-state?view=aspnetcore-3.1#session-state)。簡單來說大致配置如下
```cs
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddDistributedMemoryCache();
services.AddSession(options =>
{
options.IdleTimeout = TimeSpan.FromSeconds(10);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseSession();
}
}
```
### Session注入程式碼分析
註冊的地方設計到了兩個擴充套件方法AddDistributedMemoryCache和AddSession.其中AddDistributedMemoryCache這是藉助IDistributedCache為Session資料提供儲存,AddSession是Session實現的核心的註冊操作。
#### IDistributedCache提供儲存
上面的示例中示例中使用的是基於本地記憶體儲存的方式,也可以使用IDistributedCache針對Redis和資料庫儲存的擴充套件方法。實現也非常簡單就是給IDistributedCache註冊儲存操作例項
```cs
public static IServiceCollection AddDistributedMemoryCache(this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddOptions();
services.TryAdd(ServiceDescriptor.Singleton());
return services;
}
```
關於IDistributedCache的其他使用方式請參閱官方文件的[分散式快取篇](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed?view=aspnetcore-3.1),關於分散式快取原始碼實現可以通過[Cache的Github地址](https://github.com/dotnet/extensions/tree/v3.1.4/src/Caching)自行查閱。
#### AddSession核心操作
AddSession是Session實現的核心的註冊操作,具體實現程式碼來自擴充套件類[SessionServiceCollectionExtensions](https://github.com/dotnet/aspnetcore/blob/v3.1.4/src/Middleware/Session/src/SessionServiceCollectionExtensions.cs),AddSession擴充套件方法大致實現如下
```cs
public static IServiceCollection AddSession(this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
services.TryAddTransient();
services.AddDataProtection();
return services;
}
```
這個方法就做了兩件事,一個是註冊了Session的具體操作,另一個是添加了資料保護保護條例支援。和Session真正相關的其實只有ISessionStore,話不多說,繼續向下看[DistributedSessionStore實現](https://github.com/dotnet/aspnetcore/blob/v3.1.4/src/Middleware/Session/src/DistributedSessionStore.cs)
```cs
public class DistributedSessionStore : ISessionStore
{
private readonly IDistributedCache _cache;
private readonly ILoggerFactory _loggerFactory;
public DistributedSessionStore(IDistributedCache cache, ILoggerFactory loggerFactory)
{
if (cache == null)
{
throw new ArgumentNullException(nameof(cache));
}
if (loggerFactory == null)
{
throw new ArgumentNullException(nameof(loggerFactory));
}
_cache = cache;
_loggerFactory = loggerFactory;
}
public ISession Create(string sessionKey, TimeSpan idleTimeout, TimeSpan ioTimeout, Func tryEstablishSession, bool isNewSessionKey)
{
if (string.IsNullOrEmpty(sessionKey))
{
throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(sessionKey));
}
if (tryEstablishSession == null)
{
throw new ArgumentNullException(nameof(tryEstablishSession));
}
return new DistributedSession(_cache, sessionKey, idleTimeout, ioTimeout, tryEstablishSession, _loggerFactory, isNewSessionKey);
}
}
```
這裡的實現也非常簡單就是建立Session例項DistributedSession,在這裡我們就可以看出建立Session是依賴IDistributedCache的,這裡的sessionKey其實是SessionID,當前會話唯一標識。繼續向下找到[DistributedSession實現](https://github.com/dotnet/aspnetcore/blob/v3.1.4/src/Middleware/Session/src/DistributedSession.cs),這裡的程式碼比較多,因為這是封裝Session操作的實現類。老規矩先找到我們最容易下手的Get方法
```cs
public bool TryGetValue(string key, out byte[] value)
{
Load();
return _store.TryGetValue(new EncodedKey(key), out value);
}
```
我們看到呼叫TryGetValue之前先呼叫了Load方法,這是內部的私有方法
```cs
private void Load()
{
//判斷當前會話中有沒有載入過資料
if (!_loaded)
{
try
{
//根據會話唯一標識在IDistributedCache中獲取資料
var data = _cache.Get(_sessionKey);
if (data != null)
{
//由於儲存的是按照特定的規則得到的二進位制資料,所以獲取的時候要將資料反序列化
Deserialize(new MemoryStream(data));
}
else if (!_isNewSessionKey)
{
_logger.AccessingExpiredSession(_sessionKey);
}
//是否可用標識
_isAvailable = true;
}
catch (Exception exception)
{
_logger.SessionCacheReadException(_sessionKey, exception);
_isAvailable = false;
_sessionId = string.Empty;
_sessionIdBytes = null;
_store = new NoOpSessionStore();
}
finally
{
//將資料標識設定為已載入狀態
_loaded = true;
}
}
}
private void Deserialize(Stream content)
{
if (content == null || content.ReadByte() != SerializationRevision)
{
// Replace the un-readable format.
_isModified = true;
return;
}
int expectedEntries = DeserializeNumFrom3Bytes(content);
_sessionIdBytes = ReadBytes(content, IdByteCount);
for (int i = 0; i < expectedEntries; i++)
{
int keyLength = DeserializeNumFrom2Bytes(content);
//在儲存的資料中按照規則獲取儲存設定的具體key
var key = new EncodedKey(ReadBytes(content, keyLength));
int dataLength = DeserializeNumFrom4Bytes(content);
//將反序列化之後的資料儲存到_store
_store[key] = ReadBytes(content, dataLength);
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_sessionId = new Guid(_sessionIdBytes).ToString();
_logger.SessionLoaded(_sessionKey, _sessionId, expectedEntries);
}
}
```
通過上面的程式碼我們可以得知Get資料之前之前先Load資料,Load其實就是在IDistributedCache中獲取資料然後儲存到了_store中,通過當前類原始碼可知_store是本地字典,也就是說Session直接獲取的其實是本地字典裡的資料。
```cs
private IDictionary _store;
```
這裡其實產生兩點疑問:
1.針對每個會話儲存到IDistributedCache的其實都在一個Key裡,就是以當前會話唯一標識為key的value裡,為什麼沒有采取組合會話key單獨儲存。
2.每次請求第一次操作Session,都會把IDistributedCache裡針對當前會話的資料全部載入到本地字典裡,一般來說每次會話操作Session的次數並不會很多,感覺並不會節約效能。 接下來我們在再來檢視另一個我們比較熟悉的方法Set方法 ```cs public void Set(string key, byte[] value) { if (value == null) { throw new ArgumentNullException(nameof(value)); } if (IsAvailable) { //儲存的key是被編碼過的 var encodedKey = new EncodedKey(key); if (encodedKey.KeyBytes.Length > KeyLengthLimit)
{
throw new ArgumentOutOfRangeException(nameof(key),
Resources.FormatException_KeyLengthIsExceeded(KeyLengthLimit));
}
if (!_tryEstablishSession())
{
throw new InvalidOperationException(Resources.Exception_InvalidSessionEstablishment);
}
//是否修改過標識
_isModified = true;
//將原始內容轉換為byte陣列
byte[] copy = new byte[value.Length];
Buffer.BlockCopy(src: value, srcOffset: 0, dst: copy, dstOffset: 0, count: value.Length);
//將資料儲存到本地字典_store
_store[encodedKey] = copy;
}
}
```
這裡我們可以看到Set方法並沒有將資料放入到儲存系統,只是放入了本地字典裡。我們再來看其他方法
```cs
public void Remove(string key)
{
Load();
_isModified |= _store.Remove(new EncodedKey(key));
}
public void Clear()
{
Load();
_isModified |= _store.Count > 0;
_store.Clear();
}
```
這些方法都沒有對儲存系統DistributedCache裡的資料進行操作,都只是操作從儲存系統Load到本地的字典資料。那什麼地方進行的儲存呢,也就是說我們要找到呼叫_cache.Set方法的地方,最後在[這個地方](https://github.com/dotnet/aspnetcore/blob/v3.1.4/src/Middleware/Session/src/DistributedSession.cs#L236)找到了Set方法,而且看這個方法名就知道是提交Session資料的地方
```cs
public async Task CommitAsync(CancellationToken cancellationToken = default)
{
//超過_ioTimeout CancellationToken將自動取消
using (var timeout = new CancellationTokenSource(_ioTimeout))
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken);
//資料被修改過
if (_isModified)
{
if (_logger.IsEnabled(LogLevel.Information))
{
try
{
cts.Token.ThrowIfCancellationRequested();
var data = await _cache.GetAsync(_sessionKey, cts.Token);
if (data == null)
{
_logger.SessionStarted(_sessionKey, Id);
}
}
catch (OperationCanceledException)
{
}
catch (Exception exception)
{
_logger.SessionCacheReadException(_sessionKey, exception);
}
}
var stream = new MemoryStream();
//將_store字典裡的資料寫到stream裡
Serialize(stream);
try
{
cts.Token.ThrowIfCancellationRequested();
//將讀取_store的流寫入到DistributedCache儲存裡
await _cache.SetAsync(
_sessionKey,
stream.ToArray(),
new DistributedCacheEntryOptions().SetSlidingExpiration(_idleTimeout),
cts.Token);
_isModified = false;
_logger.SessionStored(_sessionKey, Id, _store.Count);
}
catch (OperationCanceledException oex)
{
if (timeout.Token.IsCancellationRequested)
{
_logger.SessionCommitTimeout();
throw new OperationCanceledException("Timed out committing the session.", oex, timeout.Token);
}
throw;
}
}
else
{
try
{
await _cache.RefreshAsync(_sessionKey, cts.Token);
}
catch (OperationCanceledException oex)
{
if (timeout.Token.IsCancellationRequested)
{
_logger.SessionRefreshTimeout();
throw new OperationCanceledException("Timed out refreshing the session.", oex, timeout.Token);
}
throw;
}
}
}
}
private void Serialize(Stream output)
{
output.WriteByte(SerializationRevision);
SerializeNumAs3Bytes(output, _store.Count);
output.Write(IdBytes, 0, IdByteCount);
//將_store字典裡的資料寫到Stream裡
foreach (var entry in _store)
{
var keyBytes = entry.Key.KeyBytes;
SerializeNumAs2Bytes(output, keyBytes.Length);
output.Write(keyBytes, 0, keyBytes.Length);
SerializeNumAs4Bytes(output, entry.Value.Length);
output.Write(entry.Value, 0, entry.Value.Length);
}
}
```
那麼問題來了當前類裡並沒有地方呼叫CommitAsync,那麼到底是在什麼地方呼叫的該方法呢?姑且彆著急,我們之前說過使用Session的三要素,現在才說了兩個,還有一個UseSession的中介軟體沒有提及到呢。
### UseSession中介軟體
通過上面註冊的相關方法我們大概瞭解到了Session的工作原理。接下來我們檢視UseSession中介軟體裡的程式碼,探究這裡究竟做了什麼操作。我們找到UseSession方法所在的地方[SessionMiddlewareExtensions](https://github.com/dotnet/aspnetcore/blob/v3.1.4/src/Middleware/Session/src/SessionMiddlewareExtensions.cs)找到第一個方法
```cs
public static IApplicationBuilder UseSession(this IApplicationBuilder app)
{
if (app == null)
{
throw new ArgumentNullException(nameof(app));
}
return app.UseMiddleware();
}
```
SessionMiddleware的原始碼
```cs
public class SessionMiddleware
{
private static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create();
private const int SessionKeyLength = 36; // "382c74c3-721d-4f34-80e5-57657b6cbc27"
private static readonly Func ReturnTrue = () => true;
private readonly RequestDelegate _next;
private readonly SessionOptions _options;
private readonly ILogger _logger;
private readonly ISessionStore _sessionStore;
private readonly IDataProtector _dataProtector;
public SessionMiddleware(
RequestDelegate next,
ILoggerFactory loggerFactory,
IDataProtectionProvider dataProtectionProvider,
ISessionStore sessionStore,
IOptions options)
{
if (next == null)
{
throw new ArgumentNullException(nameof(next));
}
if (loggerFactory == null)
{
throw new ArgumentNullException(nameof(loggerFactory));
}
if (dataProtectionProvider == null)
{
throw new ArgumentNullException(nameof(dataProtectionProvider));
}
if (sessionStore == null)
{
throw new ArgumentNullException(nameof(sessionStore));
}
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
_next = next;
_logger = loggerFactory.CreateLogger();
_dataProtector = dataProtectionProvider.CreateProtector(nameof(SessionMiddleware));
_options = options.Value;
//Session操作類在這裡被注入的
_sessionStore = sessionStore;
}
public async Task Invoke(HttpContext context)
{
var isNewSessionKey = false;
Func tryEstablishSession = ReturnTrue;
var cookieValue = context.Request.Cookies[_options.Cookie.Name];
var sessionKey = CookieProtection.Unprotect(_dataProtector, cookieValue, _logger);
//會話首次建立
if (string.IsNullOrWhiteSpace(sessionKey) || sessionKey.Length != SessionKeyLength)
{
//將會話唯一標識通過Cookie返回到客戶端
var guidBytes = new byte[16];
CryptoRandom.GetBytes(guidBytes);
sessionKey = new Guid(guidBytes).ToString();
cookieValue = CookieProtection.Protect(_dataProtector, sessionKey);
var establisher = new SessionEstablisher(context, cookieValue, _options);
tryEstablishSession = establisher.TryEstablishSession;
isNewSessionKey = true;
}
var feature = new SessionFeature();
//建立Session
feature.Session = _sessionStore.Create(sessionKey, _options.IdleTimeout, _options.IOTimeout, tryEstablishSession, isNewSessionKey);
//放入到ISessionFeature,給HttpContext中的Session資料提供具體例項
context.Features.Set(feature);
try
{
await _next(context);
}
finally
{
//置空為了在請求結束後可以回收掉Session
context.Features.Set(null);
if (feature.Session != null)
{
try
{
//請求完成後提交儲存Session字典裡的資料到DistributedCache儲存裡
await feature.Session.CommitAsync();
}
catch (OperationCanceledException)
{
_logger.SessionCommitCanceled();
}
catch (Exception ex)
{
_logger.ErrorClosingTheSession(ex);
}
}
}
}
private class SessionEstablisher
{
private readonly HttpContext _context;
private readonly string _cookieValue;
private readonly SessionOptions _options;
private bool _shouldEstablishSession;
public SessionEstablisher(HttpContext context, string cookieValue, SessionOptions options)
{
_context = context;
_cookieValue = cookieValue;
_options = options;
context.Response.OnStarting(OnStartingCallback, state: this);
}
private static Task OnStartingCallback(object state)
{
var establisher = (SessionEstablisher)state;
if (establisher._shouldEstablishSession)
{
establisher.SetCookie();
}
return Task.FromResult(0);
}
private void SetCookie()
{
//會話標識寫入到Cookie操作
var cookieOptions = _options.Cookie.Build(_context);
var response = _context.Response;
response.Cookies.Append(_options.Cookie.Name, _cookieValue, cookieOptions);
var responseHeaders = response.Headers;
responseHeaders[HeaderNames.CacheControl] = "no-cache";
responseHeaders[HeaderNames.Pragma] = "no-cache";
responseHeaders[HeaderNames.Expires] = "-1";
}
internal bool TryEstablishSession()
{
return (_shouldEstablishSession |= !_context.Response.HasStarted);
}
}
}
```
通過SessionMiddleware中介軟體裡的程式碼我們瞭解到了每次請求Session的建立,以及Session裡的資料儲存到DistributedCache都是在這裡進行的。不過這裡仍存在一個疑問由於呼叫CommitAsync是在中介軟體執行完成後統一進行儲存的,也就是說中途對Session進行的Set Remove Clear的操作都是在Session方法的本地字典裡進行的,並沒有同步到DistributedCache裡,如果中途出現程式異常結束的情況下,儲存到Session裡的資料,並沒有真正的儲存下來,會出現丟失的情況,不知道在設計這部分邏輯的時候是出於什麼樣的考慮。
### 總結
通過閱讀Session相關的部分原始碼大致瞭解了Session的原理,工作三要素,IDistributedCache儲存Session裡的資料,SessionStore是Session的實現類,UseSession是Session被建立到當前請求的地方。同時也留下了幾點疑問
2.每次請求第一次操作Session,都會把IDistributedCache裡針對當前會話的資料全部載入到本地字典裡,一般來說每次會話操作Session的次數並不會很多,感覺並不會節約效能。 接下來我們在再來檢視另一個我們比較熟悉的方法Set方法 ```cs public void Set(string key, byte[] value) { if (value == null) { throw new ArgumentNullException(nameof(value)); } if (IsAvailable) { //儲存的key是被編碼過的 var encodedKey = new EncodedKey(key); if (encodedKey.KeyBytes.Length >
- 針對每個會話儲存到IDistributedCache的其實都在一個Key裡,就是以當前會話唯一標識為key的value裡,為什麼沒有采取組合會話key單獨儲存。
- 每次請求第一次操作Session,都會把IDistributedCache裡針對當前會話的資料全部載入到本地字典裡,一般來說每次會話操作Session的次數並不會很多,感覺並不會節約效能。
- 呼叫CommitAsync是在中介軟體執行完成後統一進行儲存的,也就是說中途對Session進行的Set Remove Clear的操作都是在Session方法的本地字典裡進行的,並沒有同步到DistributedCache裡,如果中途出現程式異常結束的情況下,儲存到Session裡的資料,並沒有真正的儲存下來,會出現丟失的情況。