Сразу же сообщу, что в данной публикации не сравниваются Fullstate и Stateless парадигмы построения серверов. Также отсутствует какая-либо агитация в пользу Fullstate. Мы исходим из ситуации, в которой мы приняли решение, что для конкретного проекта сервер ASP.NET должен между запросами не только хранить какие-то статические данные, но и возможно выполнять какую-то полезную работу.
При этом мы, разумеется, хотим использовать всю мощь DI-контейнера .NET!


Сессии


Для решения поставленной задачи нужно уметь при первом запросе с клиента создавать некоторый контекст, который затем будет оставаться в памяти сервера и при последующих запросах, содержащих некий ключ в виде куки или заголовка, опять предоставляться для обработки. Если таких запросов некоторое определённое время не было, этот контекст удаляется.


ASP.NET Core предоставляет инфраструктуру для хранения статических данных в виде строк, то есть остальные объекты нужно сериализовать/десериализовать, а уж выполнение полезной работы придётся реализовать самостоятельно.


Вариант такой необходимости, например, докачка данных из БД, для передачи на клиента результата одного запроса несколькими партиями.


Так как встроенный вариант удовлетворяет наши запросы не полностью, мы решаем всё реализовать сами. Мы пройдём путь от идеи до релиза.


Для простоты восприятия мы будем проверять наши идеи на примерах, полностью содержащихся в одном файле. В конце исследования оформим всё красиво, как в Microsoft.


Также для упрощения во всех примерах сервисы DI используются напрямую, не через интерфейсы, очевидно, через интерфейсы всё будет работать так же.


Управление сессиями


Будем исходить из того, что сессия — это некоторый объект, который должен храниться и находиться по ключу, который является значением специальной куки. Неплохо для этой цели подходит IMemoryCache, так как он:


  • обладает возможностью удалять значение, к которому не было обращения определённое время,
  • не предоставляет список ключей, что гарантирует изоляцию сессий,
  • встраивается с помощью расширения в ASP.NET Core DI с временем жизни ServiceLifetime.Singleton.

Создадим в Visual Studio пустой проект ASP.NET Core. Весь код напишем в файл Program.cs.


Придумаем имя для кук, установим время простоя сессии до закрытия 20 секунд, чтобы не ждать долго:


const string cookieName = "qq";
TimeSpan idleTimeout = TimeSpan.FromSeconds(20);

Будем строить ключ, то есть значение куки как $"{Guid.NewGuid()}:{Interlocked.Increment(ref cookieSequenceGen)}".


Это гарантирует уникальность и стойкость к подбору. Инициализируем генератор последовательности:


int cookieSequenceGen = 0;

Создадим опцию для добавления элементов в IMemoryCache. Зарегистрируем в ней callback, чтобы после устаревания сессии вызывать её Dispose().


var entryOptions = new MemoryCacheEntryOptions()
    .SetSlidingExpiration(idleTimeout);
PostEvictionCallbackRegistration postEvictionCallbackRegistration = 
    new PostEvictionCallbackRegistration();
postEvictionCallbackRegistration.State = typeof(Program);
postEvictionCallbackRegistration.EvictionCallback = (k, v, r, s) =>
{
    if (r is EvictionReason.Expired && s is Type sType 
        && sType == typeof(Program) 
        && v is IDisposable disposable)
    {
        disposable.Dispose();
    }
};
entryOptions.PostEvictionCallbacks.Add(postEvictionCallbackRegistration);

Настроим сервер, поднимем службу MemoryCache, настроим логирование:


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMemoryCache(op =>
{
    op.ExpirationScanFrequency = TimeSpan.FromSeconds(1);
});

builder.Logging.ClearProviders();
builder.Logging.AddConsole(op =>
{
    op.TimestampFormat = "[HH:mm:ss:fff] ";
});

var app = builder.Build();

Установим middleware для создания и поиска сессий. Пока ничего с сессией делать не будем, просто выведем на консоль информацию о запросе: ID соединения, путь, сессию и её hashCode.


app.Use(async (context, next) =>
{
    IMemoryCache sessions = 
        context.RequestServices.GetRequiredService<IMemoryCache>();
    Session session = null;
    int caseMatch = 0;
    string key = context.Request.Cookies[cookieName];
    bool isNewSession = false;
    if (
            key is null
             || !sessions.TryGetValue(key, out object sessionObj)
           || (session = sessionObj as Session) is null
    )
    {
            key = 
                $"{Guid.NewGuid()}:{Interlocked.Increment(ref cookieSequenceGen)}";
            session = 
                new(context.RequestServices.GetRequiredService<ILogger<Session>>());
             context.Response.Cookies.Append(cookieName, key);
            isNewSession = true;
    }

    ILogger<Program> logger = 
        context.RequestServices.GetRequiredService<ILogger<Program>>();
    logger.LogInformation($"{context.Request.Path}: {session}({session.GetHashCode()})");
     try
    {
            await next?.Invoke();
             if (isNewSession)
             {
               sessions.Set(key, session, entryOptions);
            }
    }
    catch (Exception ex)
    {
            throw;
    }
});

Замапим роут и стартуем сервер.


app.MapGet("/", async context =>
{
    await context.Response.WriteAsync($"Hello, World!");
});

app.Run();

В самой сессии ничего нет, только пишет в лог при вызове Dispose():


public class Session : IDisposable
{
    private readonly ILogger<Session> _logger;

    public Session(ILogger<Session> logger) => _logger = logger;
    public void Dispose()
    {
            _logger.LogInformation($"{this}({GetHashCode()}) disposed");
    }
}

Исходный файл: https://github.com/Leksiqq/FullState/blob/sm1/Tests/WebApplication1/Program.cs.


Теперь запустим сервер в консоли:


Сразу по умолчанию запускается и браузер и запрашивает / и /favicon.ico. Видим, что сессия одна и та же. Но через 20 секунд ничего не происходит.


Зайдём ещё раз:


Завелась новая сессия, а у старой Dispose() вызвался только сейчас. Очевидно, callback срабатывает только при очередном обращении. Это не очень хорошо, особенно в случае, если у нас сессия производит какую-то работу.


Попробуем завести будильник для периодического контакта с MemoryCache:


...
entryOptions.PostEvictionCallbacks.Add(postEvictionCallbackRegistration);

#region добавлено
System.Timers.Timer checkSessions = null!;
TimeSpan checkSessionsInterval = TimeSpan.FromSeconds(1);
#endregion добавлено
var builder = WebApplication.CreateBuilder(args);
...

И при первом вызове middleware сконфигурируем и запустим его:


app.Use(async (context, next) =>
{
    IMemoryCache sessions = context.RequestServices.GetRequiredService<IMemoryCache>();
    #region добавлено
    if (checkSessions is null)
    {
        lock (app)
        {
            if (checkSessions is null)
            {
                checkSessions = new(checkSessionsInterval.TotalMilliseconds);
                checkSessions.Elapsed += (s, e) =>
                {
                    sessions.TryGetValue(string.Empty, out object dumb);
                };
                checkSessions.Enabled = true;
                checkSessions.AutoReset = true;
            }
        }
    }
    #endregion  добавлено
    Session session = null;
   ...

Обновлённый исходный файл: https://github.com/Leksiqq/FullState/blob/sm2/Tests/WebApplication1/Program.cs.


Теперь Dispose() вызывается через ~20 секунд, как и планировалось.



Доступ к сессии


Теперь, когда мы научились создавать и находить сессии, нам нужно куда-то их помещать так, чтобы в контроллере и на остальных уровнях не нужно было опять запрашивать MemoryCache с куками, но брать их из контейнера DI более-менее непосредственно. Так как контейнер DI управляет временем жизни своих служб, то если получить напрямую, то мы её лишимся в конце обработки запроса.


То есть, если добавить класс:


public class SessionHolder
{
    public Session Session { get; set; }
}

Добавить в конфигурацию контейнера DI:


...
builder.Services.AddMemoryCache(op =>
{
    op.ExpirationScanFrequency = TimeSpan.FromSeconds(1);
});

#region добавлено
builder.Services.AddScoped<SessionHolder>();
builder.Services.AddScoped<Session>(op => op.GetRequiredService<SessionHolder>().Session);
#endregion добавлено

builder.Logging.ClearProviders();
...

Добавить в middleware:


    ...
    logger.LogInformation($"{context.Connection.Id}: {context.Request.Path}: {session}({session.GetHashCode()})");
    #region добавлено
    context.RequestServices.GetRequiredService<SessionHolder>().Session = session;
    #endregion добавлено

    try
   ...

Изменить контроллер:


app.MapGet("/", async context =>
{
    Session session = context.RequestServices.GetRequiredService<Session>();
    await context.Response.WriteAsync($"Hello, World! {session}({session.GetHashCode()})");
});

Обновлённый исходный файл: https://github.com/Leksiqq/FullState/blob/sm3/Tests/WebApplication1/Program.cs.


При запуске получим:




Как мы видим, сразу же после первого запроса контейнер DI вызывает Dispose() у сессии, потом через 20 секунд у неё вызывается Dispose() в нашем колбэке. Это очень плохо и так делать нельзя. Поэтому попробуем из контейнера доставать SessionHolder, а уж сессию брать из него.


Меняем конфигурацию контейнера DI:


...
builder.Services.AddMemoryCache(op =>
{
    op.ExpirationScanFrequency = TimeSpan.FromSeconds(1);
});

builder.Services.AddScoped<SessionHolder>();
#region удалено
//builder.Services.AddScoped<Session>(op => op.GetRequiredService<SessionHolder>().Session);
#endregion удалено

builder.Logging.ClearProviders();
...

Меняем контроллер:


app.MapGet("/", async context =>
{
    Session session = context.RequestServices.GetRequiredService<SessionHolder>().Session;
    await context.Response.WriteAsync($"[{DateTime.Now.ToString("HH:mm:ss.fff")}] Hello, World! {session}({session.GetHashCode()})");
});

Обновлённый исходный файл: https://github.com/Leksiqq/FullState/blob/sm4/Tests/WebApplication1/Program.cs.


Сделаем несколько запросов с двух браузеров:







Всё отработало по плану.


Проблемы с контейнером DI


Однако, есть проблема. Мы не можем брать из контейнера, предоставленного Http контекстом объекты для использования в течение сессии. Ведь с ними будет то же самое, что с сессией в случае когда мы её пытались получать так же. И если с одним классом — Session ещё можно смириться, то с тем, чтобы напрямую создавать все классы, которые будут использоваться в сессиях, мириться мы уже не можем. Надо идти другим путём!


Интуиция нам подсказывает, что если агрегировать в объект сессии новый scope провайдера служб контейнера DI, то взятые из него объекты выживут после завершения запроса. Проверим, так ли это.


Поменяем класс сессии, теперь он в конструкторе будет получать свой scope провайдера служб и вызывать его Dispose() в конце жизни:


public class Session : IDisposable
{
    private readonly ILogger<Session> _logger;
    public IServiceProvider SessionServiceProvider { get; init; }

    public Session(IServiceProvider serviceProvider) =>
        (SessionServiceProvider, _logger) = (serviceProvider,        serviceProvider.GetRequiredService<ILogger<Session>>());
    public void Dispose()
    {
            if(SessionServiceProvider is IDisposable disposable)
            {
               disposable.Dispose();
            }
        _logger.LogInformation($"{this}({GetHashCode()}) disposed");
    }
}

Добавим класс InfoProvider, который будет жить в сессии и раз в секунду добавлять в список новый элемент, а при вызове метода Get(), отдавать строку с информацией, включающей накопленные элементы списка.


public class InfoProvider : IDisposable
{
    private ConcurrentQueue<int> _queue = new();
    private readonly CancellationTokenSource _cancellationTokenSource = new();
    private Task _fill = null;
    private readonly ILogger<InfoProvider> _logger;
    private readonly IServiceProvider _serviceProvider;

    public InfoProvider(IServiceProvider serviceProvider)
    {
            _serviceProvider = serviceProvider;
            _logger = _serviceProvider.GetRequiredService<ILogger<InfoProvider>>();
            int value = 0;
            CancellationToken cancellationToken = _cancellationTokenSource.Token;
            _fill = Task.Run(async () =>
            {
               while (!cancellationToken.IsCancellationRequested)
               {
                  await Task.Delay(1000);
                  _queue.Enqueue(++value);
               }
            });
    }

    public string Get()
    {
            Another another = _serviceProvider.GetRequiredService<Another>();
            _logger.LogInformation($"{this}({GetHashCode()}) {another}({another.GetHashCode()})");
            List<int> result = new();
            while (_queue.TryDequeue(out int k))
            {
               result.Add(k);
            }
            return $"{this}({GetHashCode()}) {another}({another.GetHashCode()}), {string.Join(", ", result)}";
    }

    public void Dispose()
    {
            _cancellationTokenSource.Cancel();
            _fill.Wait();
            _logger.LogInformation($"{this}({GetHashCode()}) disposed");
    }

}

Также добавим класс Another, который агрегируется в InfoProvider, а также используется в контроллере — новые для каждого запроса.


public class Another : IDisposable
{
    private readonly ILogger<Another> _logger;

    public Another(ILogger<Another> logger) => _logger = logger;
    public void Dispose()
    {
            _logger.LogInformation($"{this}({GetHashCode()}) disposed");
    }
}

В конфигурацию контейнера DI добавим новые классы.


...
builder.Services.AddMemoryCache(op =>
{
    op.ExpirationScanFrequency = TimeSpan.FromSeconds(1);
});

builder.Services.AddScoped<SessionHolder>();
#region добавлено
builder.Services.AddScoped<InfoProvider>();
builder.Services.AddScoped<Another>();
#endregion добавлено

builder.Logging.ClearProviders();
...

В middleware поменяем создание объекта сессии.


...
    if (
        key is null
        || !sessions.TryGetValue(key, out object sessionObj)
        || (session = sessionObj as Session) is null
    )
    {
        key = $"{Guid.NewGuid()}:{Interlocked.Increment(ref cookieSequenceGen)}";
        #region удалено
        // session = new(context.RequestServices.GetRequiredService<ILogger<Session>>());
        #endregion удалено
        #region добавлено
        session = new(context.RequestServices.CreateScope().ServiceProvider);
        #endregion добавлено
        context.Response.Cookies.Append(cookieName, key);
        isNewSession = true;
    }

    ILogger<Program> logger = context.RequestServices.GetRequiredService<ILogger<Program>>();

...

Поменяем контроллер, добавив использование объектов классов Another и InfoProvider.


app.MapGet("/", async context =>
{
    Session session = context.RequestServices.GetRequiredService<SessionHolder>().Session;
    Another another = context.RequestServices.GetRequiredService<Another>();
    await context.Response.WriteAsync($"[{DateTime.Now.ToString("HH:mm:ss.fff")}] Hello, World! {session}({session.GetHashCode()})"
        + $", controller {another}({another.GetHashCode()}), "
        + session.SessionServiceProvider.GetRequiredService<InfoProvider>().Get());
});

Обновлённый исходный файл: https://github.com/Leksiqq/FullState/blob/sm5/Tests/WebApplication1/Program.cs.


Сделаем несколько запросов.




Мы видим, что Another, который используется в контроллере, меняется при каждом запросе, но тот, который используется в InfoProvider, живёт до конца сессии. Это логично, так как тот Another производится тем же scope, что и InfoProvider.


Попробуем сохранить в объекте Session также контейнер DI из Http-контекста.


Поменяем Session:


public class Session : IDisposable
{
    private readonly ILogger<Session> _logger;
    public IServiceProvider SessionServiceProvider { get; init; }
    #region добавлено
    public IServiceProvider RequestServiceProvider { get; set; }
    #endregion добавлено

    public Session(IServiceProvider serviceProvider) =>
        (SessionServiceProvider, _logger) = (serviceProvider, serviceProvider.GetRequiredService<ILogger<Session>>());
    public void Dispose()
    {
        if (SessionServiceProvider is IDisposable disposable)
        {
            disposable.Dispose();
        }
        _logger.LogInformation($"{this}({GetHashCode()}) disposed");
    }
}

Поменяем middleware. Мы теперь присваиваем Session не только в холдеру в контейнере DI из Http-контекста, но и холдеру из scope, передаваемого в сессию. Это нужно для того, чтобы мы могли получить объект сессии через DI внутри объектов, живущих в сессии.


...
    if (
        key is null
        || !sessions.TryGetValue(key, out object sessionObj)
        || (session = sessionObj as Session) is null
    )
    {
        key = $"{Guid.NewGuid()}:{Interlocked.Increment(ref cookieSequenceGen)}";
        session = new(context.RequestServices.CreateScope().ServiceProvider);
        #region добавлено
        session.SessionServiceProvider.GetRequiredService<SessionHolder>().Session = session;
        #endregion добавлено
        context.Response.Cookies.Append(cookieName, key);
        isNewSession = true;
    }
    #region добавлено
    session.RequestServiceProvider = context.RequestServices;
    #endregion добавлено
    context.RequestServices.GetRequiredService<SessionHolder>().Session = session;

    ILogger<Program> logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
    logger.LogInformation($"{context.Connection.Id}: {context.Request.Path}: {session}({session.GetHashCode()})");

    try
...

Поменяем InfoProvider, чтобы он получал Another из контейнера DI Http-контекста:


public string Get()
    {
        #region удалено
        // Another another = _serviceProvider.GetRequiredService<Another>();
        #endregion удалено
        Session session = _serviceProvider.GetRequiredService<SessionHolder>().Session;
        #region добавлено
        Another another = session.RequestServiceProvider.GetRequiredService<Another>();
        #endregion добавлено
        _logger.LogInformation($"{this}({GetHashCode()}) {another}({another.GetHashCode()})");
        List<int> result = new();
        while (_queue.TryDequeue(out int k))
        {
            result.Add(k);
        }
        return $"{this}({GetHashCode()}) {another}({another.GetHashCode()}), {string.Join(", ", result)}";
    }

Обновлённый исходный файл: https://github.com/Leksiqq/FullState/blob/sm6/Tests/WebApplication1/Program.cs.


Сделаем несколько запросов.





Теперь всё работает, как было задумано!


Создание библиотеки


Ну что ж, мы провели исследование, получили необходимые знания, теперь нужно постараться оформить это в соответствии с SOLID и бритвой Оккама, и в итоге прийти к созданию соответствующей библиотеки.


Вот несколько соображений:


  • мы храним в MemoryCache объект Session из-за одного свойства SessionServiceProvider, логичнее хранить просто это значение, а сам объект сессии регистрировать как Scoped и в middleware присваивать его свойства;
  • нам нужно извлекать объект Session из Http контекста, хотя мы можем находиться при этом в любом уровне нашего приложения, даже в том, знание об Http контексте в котором нежелательно (скорее всего, это любое место, кроме контроллера и middlewares). Поэтому нам следует использовать IHttpContextAccessor, но спрятать это в библиотеку. По той же причине нам нужно сохранить в сесии поле RequestServiceProvider: хотя мы можем получать этот провайдер из Http контекста через IHttpContextAccessor, но тогда мы опять упираемся в знание об этом контексте на всех уровнях;
  • чтобы не путаться с сессией, которая уже есть в ASP.NET (которая хранит строковые данные), будем использовать для нашей название FullState;
  • так как в Http контексте сервис-провайдер называется RequestServices, переименуем и мы RequestServiceProvider в RequestServices, и по аналогии SessionServiceProvider в SessionServices;
  • инкапсулируем параметры, которые мы использовали для конфигурирования в класс FullStateOptions подобно классу SessionOptions, использующегося в ранее упоминавшейся реализации сессий;
  • создадим расширение для IServiceCollection, которое будет включать IMemoryCache, если он ещё не включен, IHttpContextAccessor, если ещё не включен, создавать MemoryCacheEntryOptions, общие для всех сессий, регистрировать в контейнере DI наш класс FullState как интерфейс IFullState;
  • создадим расширение для IApplicationBuilder, которое будет добавлять соответствующее middleware;
  • создадим расширение для IServiceProvider, которое будет извлекать нашу сессию из любого сервис-провайдера с помощью IHttpContextAccessor.

Итак, интерфейс:


public interface IFullState
{
    IServiceProvider RequestServices { get; }
    IServiceProvider SessionServices { get; }
}

реализация:


internal class FullState : IFullState
{
    public IServiceProvider RequestServices { get; internal set; }

    public IServiceProvider SessionServices { get; internal set; }
}

опции:


public class FullStateOptions
{
    public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(1);
    public TimeSpan ExpirationScanFrequency { get; set; } = TimeSpan.FromSeconds(1);
    public CookieBuilder Cookie { get; init; } = new()
    {
        Name = SessionDefaults.CookieName,
        Path = SessionDefaults.CookiePath,
        SameSite = SameSiteMode.Lax,
        IsEssential = false,
        HttpOnly = true,
    };
}

расширения:


public static class FullStateExtensions
{

    private static readonly FullStateOptions _fullStateOptions = new();
    private static MemoryCacheEntryOptions _entryOptions = null!;
    private static System.Timers.Timer _checkSessions = null!;
    private static int _cookieSequenceGen = 0;

    public static IServiceCollection AddFullState(this IServiceCollection services, 
          Action<FullStateOptions>? configure = null)
    {
        configure?.Invoke(_fullStateOptions);
        if (!services.Any(sd => sd.ServiceType == typeof(IMemoryCache)))
        {
            services.AddMemoryCache(op =>
            {
                op.ExpirationScanFrequency = _fullStateOptions.ExpirationScanFrequency;
            });
        }
        if (!services.Any(sd => sd.ServiceType == typeof(IHttpContextAccessor)))
        {
            services.AddHttpContextAccessor();
        }
        _entryOptions = new 
              MemoryCacheEntryOptions().SetSlidingExpiration(_fullStateOptions.IdleTimeout);
        PostEvictionCallbackRegistration postEvictionCallbackRegistration = new 
              PostEvictionCallbackRegistration();
        postEvictionCallbackRegistration.State = typeof(FullStateExtensions);
        postEvictionCallbackRegistration.EvictionCallback = (k, v, r, s) =>
        {
            if (r is EvictionReason.Expired && s is Type stype && stype == typeof(FullStateExtensions) 
                && v is IDisposable disposable)
            {
                disposable.Dispose();
            }
        };
        _entryOptions.PostEvictionCallbacks.Add(postEvictionCallbackRegistration);

        services.AddScoped<IFullState, FullState>();

        return services;
    }

    public static IApplicationBuilder UseFullState(this IApplicationBuilder app)
    {
        app.Use(async (context, next) =>
        {
            IMemoryCache sessions = context.RequestServices.GetRequiredService<IMemoryCache>();
            if (_checkSessions is null)
            {
                lock (app)
                {
                    if (_checkSessions is null)
                    {
                        _checkSessions = new(_fullStateOptions.ExpirationScanFrequency.TotalMilliseconds);
                        _checkSessions.Elapsed += (s, e) =>
                        {
                            sessions.TryGetValue(string.Empty, out object dumb);
                        };
                        _checkSessions.Enabled = true;
                        _checkSessions.AutoReset = true;
                    }
                }
            }
            object? sessionObj = null;
            IServiceProvider? session = null!;
            string key = context.Request.Cookies[_fullStateOptions.Cookie.Name];
            bool isNewSession = false;
            FullState fullState = (context.RequestServices.GetRequiredService<IFullState>() as FullState)!;
            if (
                key is null
                || !sessions.TryGetValue(key, out sessionObj)
                || (session = sessionObj as IServiceProvider) is null
            )
            {
                key = $"{Guid.NewGuid()}:{Interlocked.Increment(ref _cookieSequenceGen)}";
                session = context.RequestServices.CreateScope().ServiceProvider;
                context.Response.Cookies.Append(_fullStateOptions.Cookie.Name, key, _fullStateOptions.Cookie.Build(context));
                isNewSession = true;
            }

            fullState.RequestServices = context.RequestServices;
            fullState.SessionServices = session!;
            try
            {
                await (next?.Invoke() ?? Task.CompletedTask);
                if (isNewSession)
                {
                    sessions.Set(key, session, _entryOptions);
                }
            }
            catch (Exception)
            {
                throw;
            }
        });
        return app;
    }

    public static IFullState GetFullState(this IServiceProvider serviceProvider)
    {
        IHttpContextAccessor ca = serviceProvider.GetRequiredService<IHttpContextAccessor>();
        return ca.HttpContext.RequestServices.GetRequiredService<IFullState>();
    }

}

Исходники библиотеки: https://github.com/Leksiqq/FullState/tree/v2.0.0/Library.


Поменяем наш сервер, используя новую библиотеку.


Конфигурация контейнера DI:


builder.Services.AddFullState(op =>
{
    op.Cookie.Name = "qq";
    op.ExpirationScanFrequency = TimeSpan.FromSeconds(1);
    op.IdleTimeout = TimeSpan.FromSeconds(20);
});

builder.Services.AddScoped<InfoProvider>();

builder.Services.AddScoped<Another>();

builder.Logging.ClearProviders();
builder.Logging.AddConsole(op =>
{
    op.TimestampFormat = "[HH:mm:ss:fff] ";
});

Конфигурация приложения:


var app = builder.Build();

app.UseFullState();

app.MapGet("/", async context =>
{
    Another another = context.RequestServices.GetRequiredService<Another>();
    await context.Response.WriteAsync($"[{DateTime.Now.ToString("HH:mm:ss.fff")}] Hello, World! "
        + $", controller {another}({another.GetHashCode()}), "
        + context.RequestServices.GetFullState().SessionServices.GetRequiredService<InfoProvider>().Get());
});

app.Run();

Небольшое изменение InfoProvider:


    public string Get()
    {
        #region удалено
        // Session session = _serviceProvider.GetRequiredService<SessionHolder>().Session;
       #endregion удалено
        #region добавлено
        IFullState session = _serviceProvider.GetFullState();
       #endregion добавлено
        Another another = session.RequestServices.GetRequiredService<Another>();
        _logger.LogInformation($"{this}({GetHashCode()}) {another}({another.GetHashCode()})");
        List<int> result = new();
        while (_queue.TryDequeue(out int k))
        {
            result.Add(k);
        }
        return $"{this}({GetHashCode()}) {another}({another.GetHashCode()}), {string.Join(", ", result)}";
    }

Обновлённый исходный файл: https://github.com/Leksiqq/FullState/blob/v2.0.0/Tests/WebApplication1/Program.cs.


Сделаем несколько запросов.






Всё работает так же, как раньше, за исключением того, что мы не выводим в лог “disposed” от сессии, так как в библиотечном классе это не предусмотрено.


Тестирование библиотеки с NUnit


Ну и напоследок о тестировании нашей библиотеки. Мы видели в браузере, что вроде всё работает как надо, но хотелось бы увеличить количество объектов, вариантов, глубину вложенности, количество клиентов и запросов. В этом нам поможет пакет NUnit.


Сервер


Для начала создадим сервер, который на каждый запрос будет получать из контейнера DI объекты трёх времён жизни: Transient, Scoped и Singleton. У каждого из этих объектов будет вызван метод, который из трёх доступных провайдером сервиса (одного внедрённого через конструктор и двух, полученных из объекта сессии) будет опять получать объекты разных времён жизни, пока не будет достигнута определённая глубина вложенности. Также при вызове этого метода будут добавляться в список путь от контроллера к текущему объекту, уникальный идентификатор объекта и, возможно, информацию об ошибке (попытка доступа к диспозированному объекту или исключение при получении объекта из контейнера). Этот список будет возвращаться клиенту.


Интерфейсы для регистрации в контейнере DI:


public interface ITransient {}
public interface IScoped {}
public interface ISingleton {}

Класс, чьи обекты мы будем получать:


public class Probe : IDisposable, ITransient, IScoped, ISingleton
{
    private static int _genId = 0;
    private readonly IServiceProvider _services;
    private readonly Type[] _types = new[] { typeof(ITransient), typeof(IScoped), typeof(ISingleton) };

    internal static int Depth { get; set; } = 4;
    public int Id { get; private set; }
    public bool IsDisposed { get; private set; } = false;
    public Probe(IServiceProvider services)
    {
        Id = Interlocked.Increment(ref _genId);
        _services = services;
    }

    private void AddTrace(string trace, int value, string? error = null)
    {
        IFullState session = _services.GetFullState();
        session.RequestServices.GetRequiredService<List<TraceItem>>().Add(new TraceItem
        {
            Trace = trace,
            ObjectId = value,
            Error = error
        });
    }

    public void DoSomething(string trace)
    {
        if (!string.IsNullOrEmpty(trace))
        {
            AddTrace(trace, Id, IsDisposed ? "disposed" : null);
        }
        if (trace.Where(c => c == '/').Count() < Depth)
        {
            IFullState session = _services.GetFullState();

            IServiceProvider[] services = new[] { _services, session.RequestServices, session.SessionServices };

            foreach (Type type in _types)
            {
                for (int i = 0; i < services.Length; i++)
                {
                    string nextTrace = $"{trace}/{type.Name}{i}";
                    try
                    {
                        Probe probe = (Probe)services[i].GetRequiredService(type);
                        probe.DoSomething(nextTrace);
                    }
                    catch (Exception ex)
                    {
                        AddTrace(nextTrace, -1, ex.ToString());
                    }
                }
            }
        }

    }

    public void Dispose()
    {
        IsDisposed = true;
    }
}

Носитель информации о получении объекта:


public class TraceItem
{
    public int Client { get; set; }
    public int Request { get; set; }
    public string Session { get; set; }
    public string Trace { get; set; }
    public int ObjectId { get; set; }
    public string? Error { get; set; }

    public override string ToString()
    {
        return $"{{Client: {Client}, Request: {Request}, Session: {Session}, Trace: {Trace}, ObjectId: {ObjectId}{(Error is { } ? $", Error: {Error}" : string.Empty)}}}";
    }
}

Сам сервер:


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddFullState(op =>
{
    op.IdleTimeout = TimeSpan.FromSeconds(20);
    op.Cookie.Name = "qq";
});

builder.Services.AddScoped<IScoped, Probe>();
builder.Services.AddSingleton<ISingleton, Probe>();
builder.Services.AddTransient<ITransient, Probe>();

builder.Services.AddScoped<List<TraceItem>>();

WebApplication app = builder.Build();

app.UseFullState();

app.MapGet("/", async (HttpContext context) =>
{
    new Probe(context.RequestServices).DoSomething(string.Empty);

    context.RequestServices.GetRequiredService<List<TraceItem>>().ForEach(h => h.Session = context.Request.Cookies["qq"]);

    JsonSerializerOptions options = new();

    await context.Response.WriteAsJsonAsync(context.RequestServices.GetRequiredService<List<TraceItem>>(), options);
});

if(args is { })
{
    string url = args.Where(s => s.StartsWith("applicationUrl=")).FirstOrDefault();
    if(url is { })
    {
        app.Urls.Clear();
        app.Urls.Add(url.Substring("applicationUrl=".Length));
    }
    string depth = args.Where(s => s.StartsWith("depth=")).FirstOrDefault();
    if(depth is { })
    {
        Probe.Depth = int.Parse(depth.Substring("depth=".Length));
    }
}

app.Run();

Сервер при запуске получает свой Url и глубину вложенности из аргументов командной строки, так как мы планируем стартовать его из тестового метода.


Исходники сервера: https://github.com/Leksiqq/FullState/tree/v2.0.0/Tests/FullStateTestServer.


Тестовый метод


В тестовом методе мы стартуем сервер через Process, дожидаемся, когда он начнёт отвечать, и из нескольких потоков, имитирующих разных клиентов, совершаем по несколько запросов в пределах одной сессии. Полученные TraceItem'ы мы помечаем номером клиента и номером запроса. Когда все клиенты отработали, мы гасим сервер и начинаем обрабатывать TraceItem'ы. Проверяем следующие условия:


  • ошибка должна быть null;
  • в запросе номер 0 для каждого клиента свойство Session равно null;
  • в запросах > 0 для каждого клиента свойства Session не равны null и равны между собой;
  • все Singleton равны;
  • все Transient разные;
  • Scoped, полученные из session.RequestServices, равны в одном запросе, но различны в разных;
  • Scoped, полученные из session.SessionServices, равны в запросах одного клиента, но различны в запросах разных клиентов;
  • Scoped, полученные из провайдера сервисов, внедрённого через конструктор, отвечают более хитрому условию: вычисляем его эффективный scope следующим образом: двигаемся к началу пути (свойство Trace, разбитое на части, разделённые /), останавливаемся при выполнении одного из условий:
    1. Объект является Singleton — тогда эффективный scope — Singleton,
    2. Объект получен из session.RequestServices или session.SessionServices — тогда эффективный scope соответствующий,
      если не остановились, то эффективный scope соответствует session.RequestServices;
  • количество элементов списка равно $numberOfClients * numberOfRequests * (9^{depth + 1} - 9) / 8$.

Файл здесь помещать не будем, он довольно велик, исходник доступен: https://github.com/Leksiqq/FullState/blob/v2.0.0/Tests/FullStateTestProject/UnitTest1.cs.


Запустим тест.




Комментарии (19)


  1. lair
    31.05.2022 15:53
    +3

    Какую задачу вы решаете? Почему вам не подходит стандартный механизм сессий? Что вы будете делать, когда у вас больше одного веб-сервера в кластере?


    1. leksiq Автор
      31.05.2022 16:15

      Задача кратко описана здесь:

      https://habr.com/ru/post/653395/

      Это решение не для распределённой системы, а для системы масштаба предприятия.

      Стандартный механизм не хранит объекты, только состояние в виде сериализации, мне же нужно, чтобы был объект и выполнял какую-то работу между запросами. Об этом я в заметке даже упомянул.


      1. lair
        31.05.2022 16:16

        Это решение не для распределённой системы, а для системы масштаба предприятия.

        Так в системах масштаба предприятия кластер — норма.


        мне же нужно, чтобы был объект и выполнял какую-то работу между запросами

        Если вам надо выполнять работу, есть hosted services. Правда, проблемы кластера это не решает.


  1. leksiq Автор
    31.05.2022 16:39

    Наверное, сессия может обслуживаться всё время своей жизни на кластере, на котором началась.

    Насколько я себе представляю, ссылку на hosted service всё равно надо как-то хранить, чтобы потом в сессии получить результат этой работы, или даже частичный результат, наработанный между запросами. Стандартный механизм не позволяет это делать (сохранить ссылку на объект).


    1. lair
      31.05.2022 19:14
      +1

      Наверное, сессия может обслуживаться всё время своей жизни на кластере, на котором началась.

      Для этого вам надо гарантировать, что все запросы от одной сессии будут приходить на одну и ту же ноду кластера.


      Насколько я себе представляю, ссылку на hosted service всё равно надо как-то хранить

      А в чем проблема? Hosted service — это синглтон, вам его DI-контейнер всегда вернет.


      1. leksiq Автор
        31.05.2022 19:25

        Наверное, менеджер можно так настроить, но утверждать не буду

        Пока мне кажется проблемой, что мы должны получить именно нашу задачу или ее промежуточные результаты, а не сам сервис.


        1. lair
          31.05.2022 19:31

          Наверное, менеджер можно так настроить, но утверждать не буду

          Менеджер чего?


          Пока мне кажется проблемой, что мы должны получить именно нашу задачу или ее промежуточные результаты, а не сам сервис.

          Это не проблема. Вы сейчас описываете совершенно типовой диспетчер фоновых задач (Hangfire какой-нибудь). Сам диспетчер лежит в синглтоне, конкретные запросы получают сначала сам диспетчер, а потом из него по идентификатору — задачу, ее состояние и результаты.


          1. leksiq Автор
            31.05.2022 19:52

            Который запросы по годам распределяет

            Хорошо, спасибо за информацию


            1. leksiq Автор
              01.06.2022 06:06

              По Нодам


      1. mvv-rus
        01.06.2022 05:46

        А в чем проблема? Hosted service — это синглтон, вам его DI-контейнер всегда вернет.

        Но вернет ли он разные экземпляры Session (ну, или оберток для нее) для разных сессий (с разными именами-ключами)? А автору, как я понял, это надо. Стандартный DI-контейнер из .NET (ex-Core) этому не обучен, AFAIK. А потому ему что-то придется сказать дополнительно, я так думаю.
        Если бы мне пришлось решать такую задачу с нуля (помечтаю немного), то я бы для хранения сесссий взял бы IOptionsMonitor<Session>, который тоже вытаскивается из DI-контейнера и там всегда есть(если нет — скажите AddOptions() ): в нем изначально есть поддержка именованных значений. Вот я и брал бы в качестве ссылок на Session результат Get с именем сессии. Чтобы управлять списком сессий — а именно, удалять их, из того же DI контейнера следует вытащить IOptionsMonitorCache<Session> — он един и неделим как хранилище для любого (точнее, единственного, потому что Singleton) IOptionsMonitor<Session>. Для добавления лучше использовать IMHO все-таки стандартный путь — стандартную реализацию IOptionsFactory плюс немного свою реализацию IConfigureNamedOptions/IPostConfigureOptions<Session> которая будет вызвана стандартной IOptionsFactory. Эта реализация — она делается через делегат — обычно регистрируется одним из многочисленных методов расширения IServiceCollection.Configure/PostConfigure<Session> с этим самым делегатом, который, собственно и задает значение.
        В качестве задачи, которая должна заполнять Session в фоне, я сделал бы Task в которую передал бы и в которой проверял бы CancellationToken, срабатывающий по времени (при необходимости — продлевая это время через CancellationTokenSource.CancelAfter по факту запроса сессии). Обработку удаления ссылок на сессии — а там, например, кроме самого удаления, для CancellationTokenSource желательно вызывать Dispose(): он, вообще-то, unmanaged handle где-то у себя косвенно держит — я бы сделал через продолжение (.ContinueWith ) той задачи, которая, которая, собственно, Session заполняет и по времени снимается. Ну, а Task в наше время говорить Dispose() почти никогда не требуется.
        В общем, если грубо, на словах — то как-то так. Но код писать — откровенно лень, вы уж извините. Надеюсь, идея и так понятна.
        PS Плюс такой реализации — в том, что на ASP.NET Core она не завязана.


        1. lair
          01.06.2022 14:17

          Но вернет ли он разные экземпляры Session (ну, или оберток для нее) для разных сессий (с разными именами-ключами)? А автору, как я понял, это надо.

          Непонятно, зачем ему это надо. Я уже выше описал, как такое делается стандартными средствами.


          то я бы для хранения сесссий взял бы IOptionsMonitor

          Это противоречит его семантике. Брать целую инфраструктуру, предназначенную для другого, ради одного метода, который принимает на вход ключ — это излишнее все.


          1. mvv-rus
            01.06.2022 17:11

            Непонятно, зачем ему это надо.
            Очевидно же, зачем — чтобы для кажого пользователя была своя сессия. А пользователь у него явно не один.
            Я уже выше описал, как такое делается стандартными средствами.
            Совсем стандартными средствами то, что нужно автору статьи, не делается: при любом варианте реализации нужно что-то дополнительно допиливать. Например, при реализации кэша именованных объектов через Hosted Services нужно поддерживать раздельное хранение таких объктов и управление ими. При реализации фоновой задачи подгрузки нестандартными средствами неплохо было бы отслеживать событие останова приложения (что ЕМНИП в Hosted Services есть стандартная функциональность). Если подгрузка выполняется в рамках задачи со скоординированной отменой через отслеживаемый в коде задачи CancellationToken, то сделать это несложно: в задачу передается вместо оригинального составной CancellationToken, объединяющий и оригинальный, и полученный через IApplicationLifetime (который достается из DI-контейнера). Но про это надо не забыть, и я не вижу в коде из статьи, что про это не забыто. А если там какой-то свой велосипед — тады вообще ой.
            Это противоречит его семантике. Брать целую инфраструктуру, предназначенную для другого, ради одного метода, который принимает на вход ключ — это излишнее все.
            У этой инфраструктуры богатая семантика — она предназначена далеко не только для передачи значений из источников конфигурации, она имеет довольно общее назначение.
            И в веб-приложении эта инфраструктура есть всегда: через нее, например, передается из кода инициализации делегат, вызывающий Configure, в Generic Host (и в WebAppliation — AFAIK тоже: у него под капотом все тот же Generic Host).
            Общая семантика Options Pattern, как я это себе представляю — это передача не слишком часто меняющихся объектов между разными частями приложения, имеющими связь только через DI. Что за объекты передаются — это не специфицируется: это могут быть и чисто объекты данных из конфигурации, могут быть и объекты, имеющие поля-делегаты, и вообще — объекты совершенно общего назначения.
            PS Кстати, я не стал писать об этом в предыдущем ответе, но в Options Pattern есть ещё и механизм отслеживания изменений в источнике данных для Options. Стандартно он используется для отслеживания изменений конфигурации, но может быть использован, например, и для удаления истекших сессий в обсуждаемой задаче. Но об этом — лучше не здесь: там все непросто.


            1. lair
              01.06.2022 19:43

              Очевидно же, зачем — чтобы для кажого пользователя была своя сессия.

              Неа, не очевидно. Зачем сессия? Почему нельзя отслеживать задачи, а не сессии?


              Например, при реализации кэша именованных объектов через Hosted Services нужно поддерживать раздельное хранение таких объктов и управление ими.

              Поэтому не надо реализовывать кэш объектов через Hosted Services, для этого есть IMemoryCache и IDistributedCache. А через hosted services делаются задачи, то есть что-то, что работу делает.


              Общая семантика Options Pattern, как я это себе представляю — это передача не слишком часто меняющихся объектов между разными частями приложения, имеющими связь только через DI.

              Неа. Семантика — это передача настроек, не важно, откуда они берутся. Настройки могут быть сколь угодно сложными, да (например, можно сделать "настройку" авторизации на конкретном пути со сложным делегатом и чем угодно еще). Но это все равно настройки, а не данные. Поэтому, в частности, ожидается, хотя и не всегда выполняется, что эти данные меняются только через Configure-оверлоады, а не прямыми вызовами из кода приложения.


              1. mvv-rus
                01.06.2022 21:05

                Писал я вам ответ, писал, потому что я с вами во многом несогласен, хотя резонного в вашем ответе вижу тоже немало. А потом подумал: а этот ответ — он точно кому-нибудь нужен? Вам, например? Или автору статьи? Мне вот, например, он не нужен, и писать его сложно — уж больно статью тяжело читать. А брать код из статьи в качестве основы для своего кода я точно не буду. Вы — наверное, тоже. Автор статьи тоже не проявляется, видать ему это обсуждение не интересно. В общем, если вам интересно продолжить обсуждать эту тему — я продолжу. А так — ну его.


                1. leksiq Автор
                  01.06.2022 21:28

                  Добрый день, мне трудно проявиться, потому что я изложил свой взгляд, а то, что вы пишете, мне ещё нужно обдумать, возможно, проверить. Но ваши комментарии - повод двигаться дальше.


                  1. mvv-rus
                    01.06.2022 21:30

                    ОК, как обдумаете и проверите — пишите комментарий. Или даже статью.
                    Обсудим. А пока пусть так полежит.


                    1. leksiq Автор
                      02.06.2022 13:02

                      IOptionsMonitor посмотрел, да доставать сессии удобно, складывать вообще не надо - сами создаются через стандартную фабрику:

                      Program.cs
                      using Microsoft.Extensions.Options;
                      
                      string cookieName = "qq";
                      int cookieSequenceGen = 0;
                      
                      var builder = WebApplication.CreateBuilder(new string[] { });
                      
                      builder.Services.AddScoped<FullState1>();
                      
                      WebApplication app = builder.Build();
                      
                      app.Use(async (HttpContext context, Func<Task> next) =>
                      {
                          string? key = context.Request.Cookies[cookieName];
                          if(key is null)
                          {
                              key = $"{Guid.NewGuid()}:{Interlocked.Increment(ref cookieSequenceGen)}";
                              context.Response.Cookies.Append(cookieName, key, new CookieBuilder().Build(context));
                          }
                          context.RequestServices.GetRequiredService<FullState1>().Session = context.RequestServices.GetRequiredService<IOptionsMonitor<Session1>>().Get(key);
                          ++context.RequestServices.GetRequiredService<FullState1>().Session.RequestsCounter;
                      
                          next?.Invoke();
                      });
                      
                      app.MapGet("/api", async (HttpContext context) =>
                      {
                          Session1 session = context.RequestServices.GetRequiredService<FullState1>().Session;
                          await context.Response.WriteAsync($"Hello, Client {session.GetHashCode()} (#{session.RequestsCounter})!");
                      });
                      
                      app.Run();
                      
                      public class FullState1
                      {
                          internal Session1 Session { get; set; }
                      }
                      
                      internal class Session1
                      {
                          public int RequestsCounter { get; set; } = 0;
                      }

                      Запросы с разных клиентов поделал:

                      Скриншоты

                      Минус, на мой взгляд, в том, что видимо время простоя сессий нужно отслеживать, тогда как в MemoryCache это встроено. Его только трогать надо периодически, я в заметке упоминал об этом.

                      Насчёт CancellationTokenSource мне ваша идея понравилась, я ее реализовал. Так как это у меня условно фреймворк, то заранее неизвестно, какие задачи будут запускаться в сессиях, поэтому я храню в сессии родительский CancellationTokenSource, а выдаю связанные. Разработчик модели может сам их канцелировать, когда требуется, а вместе с родительским они канцелируются при истечении времени простоя.

                      Объект, который хранится в кэше:

                      Session.cs
                      internal class Session: IDisposable
                      {
                          internal IServiceProvider SessionServices { get; set; } = null!;
                      
                          internal CancellationTokenSource CancellationTokenSource { get; init; } = new();
                      
                          public void Dispose()
                          {
                              if (!CancellationTokenSource.IsCancellationRequested)
                              {
                                  CancellationTokenSource.Cancel();
                              }
                              CancellationTokenSource.Dispose();
                              if (SessionServices is IDisposable disposable)
                              {
                                  disposable.Dispose();
                              }
                          }
                      
                      }
                      

                      Интерфейс для доступа к скопу сессии из скопа запроса и наоборот, а также для получения связанного с сессией CancellationTokenSource:

                      Hidden text

                      IFullState.cs

                      public interface IFullState
                      {
                          IServiceProvider RequestServices { get; }
                          IServiceProvider SessionServices { get; }
                          CancellationTokenSource CreateCancellationTokenSource();
                      }
                      

                      Реализация:

                      FullState.cs
                      internal class FullState : IFullState
                      {
                          internal Session Session { get; set; } = null!;
                      
                          public IServiceProvider RequestServices { get; internal set; } = null!;
                      
                          public IServiceProvider SessionServices => Session.SessionServices;
                      
                          public CancellationTokenSource CreateCancellationTokenSource()
                          {
                              return CancellationTokenSource.CreateLinkedTokenSource(Session.CancellationTokenSource.Token);
                          }
                      }
                      

                      При истечении сессии CancellationTokenSource канцелируется и вызывается его Dispose().


  1. RouR
    31.05.2022 19:59
    +1

    Мы исходим из ситуации, в которой мы приняли решение, что для конкретного проекта сервер ASP.NET должен между запросами не только хранить какие-то статические данные, но и возможно выполнять какую-то полезную работу.

    Хранить данные между запросами - как правило для этого используют БД, но можно и кэш.

    Статья в основном про кэш. Он может быть: на клиенте, в redis и аналогах, просто in-memory. На тему кэширования написано много статей и лучше. Тема инвалидации кэша вообще не затронута.

    "выполнять какую-то полезную работу" в фоне - это background jobs или scheduled jobs.

    У вас в статье это всё смешано в кучу и ещё добавлено про DI и юнит тесты.

    По большому счёту претензия - "зачем, о чём статья"? Как обучающая - нет, всё поверхностно и "новичково". Как демонстрация лично вашего опыта? Ну не знаю, не хватает чего-то, что нельзя прочитать в учебнике по .Net.


  1. mvv-rus
    01.06.2022 21:07

    Если бы в причинах минуса к статье была причина «Неясность изложения» или, там, «Очень трудно читать», то я бы этой статье поставил минус. Но такой причины нет, потому минус ставить не буду, просто в комментарии это укажу.