
Зачем всё это
Представьте себе классическое такое ынтерпрайз приложжение с несколькими слоями логики. Хоть те самые презентация, домен, инфраструктура. Хотя большинство реальных приложений имеют еще больше слоев, лазанья обзавидуется.
На самом глубоком уровне вам неожиданно нужна информация о текущем пользователе: его ID и его пермишны. В результате вы получаете множество вложенных пробросов, когда UserContext передается через каждый слой:
[ApiController] [Route("[controller]")] public class ExampleController(ApplicationService application) : ControllerBase { public IActionResult List() { var userId = User.Claims.FirstOrDefault(c => c.Type == "uid")?.Value; var permissions = User.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList(); var userContext = new UserContext { UserId = userId, Roles = permissions }; application.PerformApplicationOperation(userContext); return Ok(); } } public class ApplicationService(Layer2Service layer2) { public void PerformApplicationOperation(UserContext userContext) { layer2.PerformLayerOperation(userContext); } } public class Layer2Service(Layer3Service layer3) { public void PerformLayerOperation(UserContext userContext) { layer3.PerformLayer3Operation(userContext); } } public class Layer3Service { public void PerformLayer3Operation(UserContext userContext) { // some logic here } } public class UserContext { public string? UserId { get; set; } public List<string> Roles { get; set; } = []; }
Это работает, но в каждом из промежуточных методов неожиданно оказывается UserId, даже если этим сервисам сам он вообще не особо нужен.
С CQRS всё становится еще хуже
С CQRS все становится еще хуже, так как там из ниоткуда появляется ещёё более глубокая иерархия вызовов. И каждая команда и каждый запрос в итоге тащат за собой пользовательские параметры:
public record ImportDocumentsCommand( Guid ExternalSystemDocumentIג Guid? UserId, HashSet<InspectionCompany> UserCompanies, bool IsSuperAdmin ) : IQuery<List<MyAmazingDocuments>;
Давайте вместе поможем Даше понять зачем тут все пераметры кроме первого, ага.
Более чистый подход: Scoped‑сервис через DI
Как это можно улучшись, особенно для ситуаций, когда вам нужен UserContext только в Layer 3?
Тут можем помочь DI‑сервис, который хранит UserContext и делает его доступным в течение всего запроса. В ASP.NET Core у чаще всего запрос = di scope, поэтому сервис должен быть зарегистрирован как Scoped.
builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi builder.Services.AddOpenApi(); builder.Services.AddScoped<ApplicationService>(); builder.Services.AddScoped<Layer2Service>(); builder.Services.AddScoped<Layer3Service>();
К счастью, или к сожалению (в зависимости от взгляда) после создания ServiceProvider вы не можете напихать в него новые сервисы. Но мутабельность пропертей в серсиах никто не отменял:
public class ExampleController(ApplicationService application, UserContext userContext) : ControllerBase { [HttpGet] public IActionResult List() { userContext.UserId = User.Claims.FirstOrDefault(c => c.Type == "uid")?.Value; userContext.Roles = User.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList(); application.PerformApplicationOperation(); return Ok(); } }
Менять промерти у UserScope относительно безопасно, так как он scoped и соседние запросы не затронет. Тут было бы конечно здорово сделать UserContext immutable за пределами контроллера, но это для целей данной статьи — оверкилл.
public class Layer3Service(UserContext userContext) { public void PerformDeepestOperation() { Log.LogInformation($"Тут был {userContext.UserId}. И всё испортил(а)"); } }
Ну и теперь больше никакого проброса UserContext через ApplicationService и Layer2Service.
Убираем бойлерплейт с помощью Middleware
Копипастить логику создания пользовательского контекста в каждый экшн контроллера — фу, поэтому вынесем ее в middleware:
public async Task Invoke(HttpContext httpContext, UserContext userContext) { userContext.UserId = httpContext.User.Claims.FirstOrDefault(c => c.Type == "uid")?.Value; userContext.Roles = httpContext.User.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList(); await next(httpContext); }
Теперь контекст заполняется один раз за запрос, до того как он дойдет до контроллера, и любой сервис в цепочке может просто заинжектить UserContext, когда он ему надо.
А минусы будут
У этого подхода есть очевидный минус, так как тут работает старое правило: явное лучше неявного. Когда вы передаете параметры пользователя как параметры метода, зависимость полностью видна в сигнатуре. Это прям сильно упрощает отслеживание потока данных, анализ и тестирование. С мутабельным контекстом у новичка на проекте возникнет очевидный вопрос «а это вообще откуда». И кто‑нибудь обязательно воспользуется методом, в окружении без пользователя. В большой семье это легком может деградировать с нормальную такую глобальную переменную посыпанную сахаром, скрывающую реальные архитектурные зависимости.
for7raid
Какой жуть!
Для решения этой проблемы давно придумали паттерн Provider, именно он инжектится и методом а-ля GetCurrentUserId возвращает вам пользователя из http контекста.
alehcka Автор
и вот только сейчас, после много лет написания кода, я нагуглил что IHttpContextAccessor вообще говоря существует и инжектится. Штош