Сразу же сообщу, что в данной публикации не сравниваются 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, разбитое на части, разделённые /), останавливаемся при выполнении одного из условий:
- Объект является Singleton — тогда эффективный scope — Singleton,
- Объект получен из session.RequestServices или session.SessionServices — тогда эффективный scope соответствующий,
если не остановились, то эффективный scope соответствует session.RequestServices;
- количество элементов списка равно
.
Файл здесь помещать не будем, он довольно велик, исходник доступен: https://github.com/Leksiqq/FullState/blob/v2.0.0/Tests/FullStateTestProject/UnitTest1.cs.
Запустим тест.