天天看點

ASP.NET Core Session源碼探究

前言

    随着網際網路的興起,技術的整體架構設計思路有了質的提升,曾經Web開發必不可少的内置對象Session已經被慢慢的遺棄。主要原因有兩點,一是Session依賴Cookie存放SessionID,即使不通過Cookie傳遞,也要依賴在請求參數或路徑上攜帶Session辨別,對于目前前後端分離項目來說操作起來限制很大,比如跨域問題。二是Session資料跨伺服器同步問題,現在基本上項目都使用負載均衡技術,Session同步存在一定的弊端,雖然可以借助Redis或者其他存儲系統實作中心化存儲,但是略顯雞肋。雖然存在一定的弊端,但是在.NET Core也并沒有抛棄它,而且結合了更好的實作方式提升了設計思路。接下來我們通過分析源碼的方式,大緻了解下新的工作方式。

Session如何使用

    .NET Core的Session使用方式和傳統的使用方式有很大的差别,首先它依賴存儲系統IDistributedCache來存儲資料,其次它依賴SessionMiddleware為每一次請求提供具體的執行個體。是以使用Session之前需要配置一些操作,詳細介紹可參閱微軟官方文檔會話狀态。大緻配置流程,如下

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注冊存儲操作執行個體

public static IServiceCollection AddDistributedMemoryCache(this IServiceCollection services)     {         if (services == null)         {             throw new ArgumentNullException(nameof(services));         }         services.AddOptions();         services.TryAdd(ServiceDescriptor.Singleton<IDistributedCache, MemoryDistributedCache>());         return services;     }           

    關于IDistributedCache的其他使用方式請參閱官方文檔的分布式緩存篇,關于分布式緩存源碼實作可以通過Cache的Github位址自行查閱。

AddSession核心操作

AddSession是Session實作的核心的注冊操作,具體實作代碼來自擴充類SessionServiceCollectionExtensions,AddSession擴充方法大緻實作如下

public static IServiceCollection AddSession(this IServiceCollection services)     {         if (services == null)         {             throw new ArgumentNullException(nameof(services));         }         services.TryAddTransient<ISessionStore, DistributedSessionStore>();         services.AddDataProtection();         return services;     }           

這個方法就做了兩件事,一個是注冊了Session的具體操作,另一個是添加了資料保護保護條例支援。和Session真正相關的其實隻有ISessionStore,話不多說,繼續向下看DistributedSessionStore實作

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<bool> 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實作,這裡的代碼比較多,因為這是封裝Session操作的實作類。老規矩先找到我們最容易下手的Get方法

public bool TryGetValue(string key, out byte[] value)     {         Load();         return _store.TryGetValue(new EncodedKey(key), out value);     }           

我們看到調用TryGetValue之前先調用了Load方法,這是内部的私有方法

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直接擷取的其實是本地字典裡的資料。

private IDictionary<EncodedKey, byte[]> _store;           

這裡其實産生兩點疑問:

1.針對每個會話存儲到IDistributedCache的其實都在一個Key裡,就是以目前會話唯一辨別為key的value裡,為什麼沒有采取組合會話key單獨存儲。

2.每次請求第一次操作Session,都會把IDistributedCache裡針對目前會話的資料全部加載到本地字典裡,一般來說每次會話操作Session的次數并不會很多,感覺并不會節約性能。

接下來我們在再來檢視另一個我們比較熟悉的方法Set方法

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方法并沒有将資料放入到存儲系統,隻是放入了本地字典裡。我們再來看其他方法

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方法的地方,最後在這個地方找到了Set方法,而且看這個方法名就知道是送出Session資料的地方

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找到第一個方法

public static IApplicationBuilder UseSession(this IApplicationBuilder app)     {         if (app == null)         {             throw new ArgumentNullException(nameof(app));         }         return app.UseMiddleware<SessionMiddleware>();     }           

SessionMiddleware的源碼

public class SessionMiddleware     {       private static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create();       private const int SessionKeyLength = 36; // "382c74c3-721d-4f34-80e5-57657b6cbc27"       private static readonly Func<bool> 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<SessionOptions> 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<SessionMiddleware>();           _dataProtector = dataProtectionProvider.CreateProtector(nameof(SessionMiddleware));           _options = options.Value;          //Session操作類在這裡被注入的           _sessionStore = sessionStore;       }       public async Task Invoke(HttpContext context)       {           var isNewSessionKey = false;           Func<bool> 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<ISessionFeature>(feature);           try           {               await _next(context);           }           finally           {               //置空為了在請求結束後可以回收掉Session               context.Features.Set<ISessionFeature>(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被建立到目前請求的地方。同時也留下了幾點疑問

  • 針對每個會話存儲到IDistributedCache的其實都在一個Key裡,就是以目前會話唯一辨別為key的value裡,為什麼沒有采取組合會話key單獨存儲。
  • 每次請求第一次操作Session,都會把IDistributedCache裡針對目前會話的資料全部加載到本地字典裡,一般來說每次會話操作Session的次數并不會很多,感覺并不會節約性能。
  • 調用CommitAsync是在中間件執行完成後統一進行存儲的,也就是說中途對Session進行的Set Remove Clear的操作都是在Session方法的本地字典裡進行的,并沒有同步到DistributedCache裡,如果中途出現程式異常結束的情況下,儲存到Session裡的資料,并沒有真正的存儲下來,會出現丢失的情況。

對于以上疑問,不知道是個人了解不足,還是在設計的時候出于别的考慮。歡迎在評論區多多溝通交流,希望能從大家那裡得到更好的解釋和答案。

👇歡迎掃碼關注👇

ASP.NET Core Session源碼探究

繼續閱讀