.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 里的数据,并没有真正的存储下来,会出现丢失的情况。

  对于以上疑问,不知道是个人理解不足,还是在设计的时候出于别的考虑。欢迎在评论区多多沟通交流,希望能从大家那里得到更好的解释和答案。

Published by

风君子

独自遨游何稽首 揭天掀地慰生平

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注