Pull to refresh

Comments 27

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

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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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


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

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

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

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


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

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


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

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

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

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

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

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().

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

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

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

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

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

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

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

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


Во-первых, таймер для очистки кеша. Вы его создаёте в мидлваре при первом обращении. Это точно адекватное место для таймера? А останавливать вы его не собираетесь?


Существует же такая штука как Hosted Services!


class MemoryCacheCleaner : BackgroundService {
    private readonly IMemoryCache cache;

    public MemoryCacheCleaner(IMemoryCache cache) {
        this.cache = cache;
    }

    protected override Task ExecuteAsync (CancellationToken stopToken) {
        for(;;) {
            await Task.Delay(TimeSpan.FromSeconds(1), stopToken);
            cache.TryGetValue("", out var _);
        }
    }
}

// …

services.AddSingleton<IHostedService, MemoryCacheCleaner>();



Теперь второе. Вы храните сессию в SessionHolder, который регистрируете как Scoped-сервис. Однако, стандартная сессия ASP.NET Core хранится не в скоупе, а в фичах HTTP контекста. В стандартном случае где хранить сессию не особо важно (скоупы и контексты имеют 1:1 отображение), но вот если вы собираетесь создавать отдельный скоуп для сессии — то хранить её следует только в контексте, иначе будут глупые циклические ссылки.


context.Features.Set(new SessionHolder { Session = session});

// …

var session = context.Features.Get<SessionHolder>().Session;

И никаких больше session.SessionServiceProvider.GetRequiredService<SessionHolder>().Session = session;!




Третье. По поводу вашей идеи хранить в сессии свойство RequestServiceProvider. Вы понимаете, что сессия — разделяемый объект, и к ней в один момент времени может обращаться несколько запросов?


Если вам так нужен доступ к текущему запросу из сессии — добавьте в сессию семафор, что ли. И заодно circuit beaker, чтобы выдать 503ю ошибку при слишком большой очереди на семафоре. Но лучше просто убрать RequestServiceProvider из сессии, оно там не требуется.




Наконец, четвёртое. Два однотипных скоупа (один для запроса, второй для сессии) выглядят странно, и могут приводить к глупым ошибкам (как в вашем примере с Another). Попробуйте заменить стандартный контейнер DI на что-нибудь более продвинутое, вроде Autofac — тогда вы сможете различать Request-scoped сервисы и Session-scoped. Ещё при желании можно сделать скоуп сервиса родительским для скоупа запроса — это потребует позднего создания второго и замены IHttpContextFactory, но ничего принципиально сложного.

Семафор уже успел добавить сам, остальные предложения изучу, спасибо!

Третье. По поводу вашей идеи хранить в сессии свойство RequestServiceProvider. Вы понимаете, что сессия — разделяемый объект, и к ней в один момент времени может обращаться несколько запросов?

У меня скоуп запроса в сессии не хранится. Сессия при каждом запросе обёртывается в объект времени жизни запроса, а скоуп текущего запроса подключается туда же. Я его ввёл только на тот крайний случай, если объект, порождённый скоупом сессии захочет во время запроса воспользоваться объектом времени жизни запроса.

У меня скоуп запроса в сессии не хранится.

Из статьи я вижу, что у вас объекты класса Session хранятся в MemoryCache (а значит разделяются между запросами), и имеют внутрях ссылку на RequestServiceProvider.


Что это тогда, если не хранение скоупа запроса в сессии?

Да, это в процессе исследования было, ближе к концу статьи после подзаголовка "Создание библиотеки" это уже не так.

Посмотрел код на Гитхабе из того коммита, на который ссылка в статье: нет там реально хранения ссылки на RequestServiceProvider. Поле SessionServiceProvider типа IServiceProvider в Session инициализуется параметром единственным конструктора:
session = new(context.RequestServices.CreateScope().ServiceProvider);
Вызов метода расширения CreateScope для контейнера сервисов ограниченной области («скоупа») для запроса — который HttpContext.RequestServices — транслируется в запрос на получение реализации IServiceScopeFactory из этого контейнера с последующим вызовом метода CreateScope этого интерфейса. Но для встроенного контейнера сервисов .NET запрос на IServiceScopeFactory к контейнеру ограниченной области транслируется в такой же запрос к корневому контейнеру сервисов (если интересны детали — могу указать на конкретный код в библиотеке .NET, который это делает). Соответственно, получаемый в дальнейшем из свойства IServiceScope.ServiceProvider контейнер ограниченной области является непосредственным потомком корневого контейнера сервисов приложения. И, таким образом, HttpContext.RequestServices, которому положено жить только в контексте запроса и только то время, пока запрос обрабатывается, из списка объектов, от которых зависит Session, исчезает.
Но следует заметить, что такое решение — оно довольно хрупкое. Оно работает только потому, что встроенный в .NET контейнер сервисов не поддерживает концепцию родительских/дочерних контейнеров сервисов ограниченных областей — все такие контейнеры находятся на одном уровне. Однако контейнер сервисов приложения — это заменяемый компонент, и замена встроенного контейнера по каким-то причинам на сторонний контейнер, который поддерживает вложение областей, может принести очень неприятный сюрприз. И если для чисто внутреннего применения это, может, и сгодится, то писать такое внутри более-менее универсальной библиотеки — IMHO нельзя.
Небольшое уточнение: запрос к IServiceScopeFactory контейнера ограниченной области транслируется в корневой контейнер не через метод реализации интерфейса IServiceProvider в этом контейнере (его реальный тип — ServiceContainer) — GetService(Type serviceType), — а через его одноименный перегруженный метод ServiceProvider.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope). Второй параметр здесь — это, собственно, ядро реализации IServiceProvider, объект класса ServiceProviderEngineScope, реализация этого интерфейса в корневом контейнере работает тоже через него же, передавая в качестве второго параметра корневой ServiceProviderEngineScope (хранится в свойстве ServiceProvider.Root). Но спосо регистрации во внутренних структурах контенера сделан таким образом, что результат оказывается тем же самым: возвращается тот же самый объект — корневой ServiceProviderEngineScope — реализующий IServiceScopeFactory, что и для корневого контейнера.
Код
app.Use(async (context, next) =>
{
		IMemoryCache sessions = context.RequestServices.GetRequiredService<IMemoryCache>();
    string key = context.Request.Cookies[_fullStateOptions.Cookie.Name];
    //...
  	object? sessionObj = null;
    Session? session = null;
    bool isNewSession = false;
  	// Берём новую обёртку из скоупа запроса
    FullState fullState = (context.RequestServices.GetRequiredService<IFullState>() as FullState)!;
    if (
    		string.IsNullOrEmpty(key)
        || !sessions.TryGetValue(key, out sessionObj)
        || (session = sessionObj! as Session) is null
    )
    {
    		key = $"{Guid.NewGuid()}:{Interlocked.Increment(ref _cookieSequenceGen)}";
        session = new();
        session!.SessionServices = context.RequestServices.CreateScope().ServiceProvider;
        context.Response.Cookies.Append(_fullStateOptions.Cookie.Name, key, _fullStateOptions.Cookie.Build(context));
        isNewSession = true;
    }

  	// Добавляем в обёртку скоуп запроса
    fullState.RequestServices = context.RequestServices;
  	// Добавляем в обёртку скоуп сессии новой или ранее сохранённой
		fullState.Session = session;
		//...
});

Sign up to leave a comment.