.NET Core Session源碼探究

.NET Core Session源碼探究

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

Session如何使用#
.NET Core的Session使用方式和傳統的使用方式有很大的差別,首先它依賴存儲系統IDistributedCache來存儲數據,其次它依賴SessionMiddleware爲每一次請求提供具體的實例。所以使用Session之前需要配置一些操作,相信介紹情參閱微軟官方文檔會話狀態。簡單來說大致配置如下

Copy
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註冊存儲操作實例

Copy
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擴展方法大致實現如下

Copy
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實現

Copy
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方法

Copy
public bool TryGetValue(string key, out byte[] value)
{

Load();
return _store.TryGetValue(new EncodedKey(key), out value);

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

Copy
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直接獲取的其實是本地字典裏的數據。

Copy
private IDictionary _store;
這裏其實產生兩點疑問:
1.針對每個會話存儲到IDistributedCache的其實都在一個Key裏,就是以當前會話唯一標識爲key的value裏,爲什麼沒有采取組合會話key單獨存儲。
2.每次請求第一次操作Session,都會把IDistributedCache裏針對當前會話的數據全部加載到本地字典裏,一般來說每次會話操作Session的次數並不會很多,感覺並不會節約性能。接下來我們在再來查看另一個我們比較熟悉的方法Set方法

Copy
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方法並沒有將數據放入到存儲系統,只是放入了本地字典裏。我們再來看其他方法

Copy
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數據的地方

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

Copy
public static IApplicationBuilder UseSession(this IApplicationBuilder app)
{

if (app == null)
{
    throw new ArgumentNullException(nameof(app));
}
return app.UseMiddleware<SessionMiddleware>();

}
SessionMiddleware的源碼

Copy
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<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裏的數據,並沒有真正的存儲下來,會出現丟失的情況。
對於以上疑問,不知道是個人理解不足,還是在設計的時候出於別的考慮。歡迎在評論區多多溝通交流,希望能從大家那裏得到更好的解釋和答案。

作者: yi念之間

出處:https://www.cnblogs.com/wucy/p/13044467.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章