Как получить пользователя на сервисном уровне

Я использую ASP.NET Core 2.1 и хочу получить User на уровне обслуживания.

Я видел примеры, когда HttpContextAccessor внедряется в какой-то сервис, а затем мы получаем текущий User через UserManager.

var user = await _userManager.GetUserAsync(accessor.HttpContext.User);

или в контроллере

var user = await _userManager.GetUserAsync(User);

Проблемы:

  • Внедрение HttpContextAccessor в сервис кажется неправильным – просто потому, что мы нарушаем SRP, а уровень сервиса не изолирован (он зависит от контекста http). ).

  • Конечно, мы можем извлекать пользователя в контроллере (это несколько лучший подход), но мы сталкиваемся с дилеммой — мы просто не хотим передавать User в качестве параметра в каждом отдельном сервисном методе.

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

Делюсь своим кодом в надежде получить рекомендации от сообщества StackOverflow.

Идея заключается в следующем:

Во-первых, я представляю SessionProvider, который зарегистрирован как Singleton.

services.AddSingleton<SessionProvider>();

SessionProvider имеет свойство Session, которое содержит User, Tenant и т. д.

Во-вторых, я ввожу SessionMiddleware и регистрирую его

app.UseMiddleware<SessionMiddleware>();

В методе Invoke я разрешаю HttpContext, SessionProvider и UserManager.

  • Я приношу User

  • Затем я инициализирую свойство Session синглтона ServiceProvider:

sessionProvider.Initialise(user);

На данном этапе ServiceProvider имеет Session объект, содержащий нужную нам информацию.

Теперь мы внедряем SessionProvider в любой сервис, и его объект Session готов к использованию.


Код:

SessionProvider:

public class SessionProvider
{
    public Session Session;

    public SessionProvider()
    {
        Session = new Session();
    }

    public void Initialise(ApplicationUser user)
    {
        Session.User = user;
        Session.UserId = user.Id;
        Session.Tenant = user.Tenant;
        Session.TenantId = user.TenantId;
        Session.Subdomain = user.Tenant.HostName;
    }
}

Session:

public class Session
{
    public ApplicationUser User { get; set; }

    public Tenant Tenant { get; set; }

    public long? UserId { get; set; }

    public int? TenantId { get; set; }

    public string Subdomain { get; set; }
}

SessionMiddleware:

public class SessionMiddleware
{
    private readonly RequestDelegate next;

    public SessionMiddleware(RequestDelegate next)
    {
        this.next = next ?? throw new ArgumentNullException(nameof(next));
    }

    public async Task Invoke(
        HttpContext context,
        SessionProvider sessionProvider,
        MultiTenancyUserManager<ApplicationUser> userManager
        )
    {
        await next(context);

        var user = await userManager.GetUserAsync(context.User);

        if (user != null)
        {
            sessionProvider.Initialise(user);
        }
    }
}

А теперь код Service Layer:

public class BaseService
{
    public readonly AppDbContext Context;
    public Session Session;

    public BaseService(
        AppDbContext context,
        SessionProvider sessionProvider
        )
    {
        Context = context;
        Session = sessionProvider.Session;
    }
}

Итак, это базовый класс для любого сервиса, как видите, теперь мы можем легко получить объект Session, и он готов к использованию:

public class VocabularyService : BaseService, IVocabularyService
{
    private readonly IVocabularyHighPerformanceService _vocabularyHighPerformanceService;
    private readonly IMapper _mapper;

    public VocabularyService(
        AppDbContext context,
        IVocabularyHighPerformanceService vocabularyHighPerformanceService,
        SessionProvider sessionProvider,
        IMapper mapper
        ) : base(
              context,
              sessionProvider
              )
    {
        _vocabularyHighPerformanceService = vocabularyHighPerformanceService;
        _mapper = mapper; 
    }

    public async Task<List<VocabularyDto>> GetAll()
    {
        List<VocabularyDto> dtos = _vocabularyHighPerformanceService.GetAll(Session.TenantId.Value);
        dtos = dtos.OrderBy(x => x.Name).ToList();
        return await Task.FromResult(dtos);
    }
}

Сосредоточьтесь на следующем фрагменте:

.GetAll(Session.TenantId.Value);

также мы можем легко получить текущего пользователя

Session.UserId.Value

or

Session.User

Ну это все.

Я протестировал свой код, и он хорошо работает, когда открыто несколько вкладок — каждая вкладка имеет свой поддомен в URL-адресе (клиент разрешается из поддомена — данные извлекаются правильно).


person Alex Herman    schedule 04.08.2018    source источник
comment
Если это рабочий код и действительно нет реальной проблемы, кроме мнения о том, что это плохой дизайн, то я бы сказал, что вопрос не по теме для SO, поскольку это скорее обзор кода, который должен соответствовать codereview.stackexchange.com   -  person Nkosi    schedule 04.08.2018
comment
@Nkosi А, я понимаю. В следующий раз я буду задавать такие вопросы на codereview. Спасибо!   -  person Alex Herman    schedule 04.08.2018
comment
Обратите внимание, что вы слишком много внимания уделяете проблемам реализации, где хорошо работали бы абстракции. абстрагирование поставщика сеанса упрощает зависимости служб. Так что даже не имеет значения, что вы используете IHttpContextAccessor для получения текущего пользователя. Опять же, это только мое мнение по теме этого вопроса.   -  person Nkosi    schedule 04.08.2018
comment
Я также только что заметил, что вы настраиваете сеанс в промежуточном программном обеспечении ПОСЛЕ передачи контекста по конвейеру, что означает, что сеанс не будет доступен для других обработчиков в конвейере.   -  person Nkosi    schedule 04.08.2018
comment
привет @Nkosi, ты абсолютно прав. Я только что попытался зарегистрировать ServiceProvider как ограниченный, а Session все поля равны нулю.   -  person Alex Herman    schedule 04.08.2018
comment
@Nkosi Однако, если я передам контекст по конвейеру в конце - невозможно получить данные из HttpContext, необходимые для получения текущего пользователя. Так что это еще одна дилемма.   -  person Alex Herman    schedule 04.08.2018
comment
Который показывает себя как недостаток в вашем текущем дизайне. Переходя к промежуточному программному обеспечению, вы слишком рано находитесь в конвейере, чтобы получить желаемое поведение.   -  person Nkosi    schedule 04.08.2018
comment
@Nkosi Реализовал фильтр вместо MiddleWare - сейчас он работает - обновил свой пост. Еще раз спасибо за указание на проблему.   -  person Alex Herman    schedule 04.08.2018
comment
Пожалуйста, не редактируйте свой вопрос, чтобы включить решение. Если ответы не являются окончательными, опубликуйте свой собственный ответ   -  person Camilo Terevinto    schedule 05.08.2018
comment
Как бы вы использовали репозиторий на уровне вашего сервиса, если бы вы заявили, что внедрение зависимости в ваш сервис нарушает SRP, а уровень сервиса не изолирован?   -  person João Paiva    schedule 20.10.2018
comment
@JoãoPaiva можно использовать зависимость более высокого уровня в зависимости более низкого уровня, но не наоборот. Внедрение репо в какой-либо сервис звучит правильно и не нарушает SRP. Но напр. внедрение уровня контроллера HttpContextAccessor в зависимость более высокого уровня, такую ​​​​как репо или служба, звучит неправильно.   -  person Alex Herman    schedule 21.10.2018


Ответы (3)


Использование фильтра действий гарантирует, что желаемое поведение вызывается достаточно поздно в конвейере вызова действий, когда необходимые зависимости уже реализованы, (например, HttpContext.User)

Справочник по Фильтры в ASP.NET Core

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

public class SessionFilter : IAsyncActionFilter {
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next) {

        // do something before the action executes

        var serviceProvider = context.HttpContext.RequestServices;    
        var sessionProvider = serviceProvider.GetService<SessionProvider>();
        var userManager = serviceProvider.GetService<MultiTenancyUserManager<ApplicationUser>>()

        var user = await userManager.GetUserAsync(context.HttpContext.User);    
        if (user != null) {
            sessionProvider.Initialise(user);
        }

        //execute action
        var resultContext = await next();
        // do something after the action executes; resultContext.Result will be set
        //...
    }
}
person Nkosi    schedule 04.08.2018
comment
Спасибо, очень полезно. Сегодня удалил .Result, но случайно скопировал из буфера обмена. Исправил мой код и протестировал - РАБОТАЕТ! - person Alex Herman; 04.08.2018
comment
идеальное решение - person ArunPratap; 06.08.2018

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

Обратите внимание, что время жизни Session равно Per Request — когда начинается запрос, мы подключаемся к нему, разрешаем экземпляр SessionContext, затем заполняем его UserID и TenantID — после этого, куда бы мы ни вставляли наш Session (с учетом того же запроса) — он будет содержать нужные нам значения.

services.AddScoped<Session>();

Session.cs

public class Session
{
    public long? UserId { get; set; }

    public int? TenantId { get; set; }

    public string Subdomain { get; set; }
}

AppInitializationFilter.cs

public class AppInitializationFilter : IAsyncActionFilter
{
    private Session _session;
    private DBContextWithUserAuditing _dbContext;
    private ITenantService _tenantService;

    public AppInitializationFilter(
        Session session,
        DBContextWithUserAuditing dbContext,
        ITenantService tenantService
        )
    {
        _session = session;
        _dbContext = dbContext;
        _tenantService = tenantService;
    }

    public async Task OnActionExecutionAsync(
        ActionExecutingContext context,
        ActionExecutionDelegate next
        )
    {
        string userId = null;
        int? tenantId = null;

        var claimsIdentity = (ClaimsIdentity)context.HttpContext.User.Identity;

        var userIdClaim = claimsIdentity.Claims.SingleOrDefault(c => c.Type == ClaimTypes.NameIdentifier);
        if (userIdClaim != null)
        {
            userId = userIdClaim.Value;
        }

        var tenantIdClaim = claimsIdentity.Claims.SingleOrDefault(c => c.Type == CustomClaims.TenantId);
        if (tenantIdClaim != null)
        {
            tenantId = !string.IsNullOrEmpty(tenantIdClaim.Value) ? int.Parse(tenantIdClaim.Value) : (int?)null;
        }

        _dbContext.UserId = userId;
        _dbContext.TenantId = tenantId;

        string subdomain = context.HttpContext.Request.GetSubDomain();

        _session.UserId = userId;
        _session.TenantId = tenantId;
        _session.Subdomain = subdomain;

        _tenantService.SetSubDomain(subdomain);

        var resultContext = await next();
    }
}

AuthController.cs

[Route("api/[controller]/[action]")]
[ApiController]
public class AuthController : Controller
{
    public IConfigurationRoot Config { get; set; }
    public IUserService UserService { get; set; }
    public ITenantService TenantService { get; set; }

    [AllowAnonymous]
    [HttpPost]
    public async Task<AuthenticateOutput> Authenticate([FromBody] AuthenticateInput input)
    {
        var expires = input.RememberMe ? DateTime.UtcNow.AddDays(5) : DateTime.UtcNow.AddMinutes(20);

        var user = await UserService.Authenticate(input.UserName, input.Password);

        if (user == null)
        {
            throw new Exception("Unauthorised");
        }

        int? tenantId = TenantService.GetTenantId();
        string strTenantId = tenantId.HasValue ? tenantId.ToString() : string.Empty;

        var tokenHandler = new JwtSecurityTokenHandler();

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Expires = expires,
            Issuer = Config.GetValidIssuer(),
            Audience = Config.GetValidAudience(),
            SigningCredentials = new SigningCredentials(Config.GetSymmetricSecurityKey(), SecurityAlgorithms.HmacSha256),
            Subject = new ClaimsIdentity(new[]
            {
                new Claim(ClaimTypes.Name, user.UserName),
                new Claim(ClaimTypes.NameIdentifier, user.Id),
                new Claim(CustomClaims.TenantId, strTenantId)
            })
        };

        var token = tokenHandler.CreateToken(tokenDescriptor);
        string tokenString = tokenHandler.WriteToken(token);

        return new AuthenticateOutput() { Token = tokenString };
    }
}
person Alex Herman    schedule 01.09.2018

Ваш подход кажется правильным. Единственная проблема - нельзя прописывать SessionProvider как Singleton, иначе будут проблемы с одновременными запросами. Зарегистрируйте его как Scoped, чтобы получать новый экземпляр для каждого запроса. Кроме того, вы должны заполнить SessionInfo перед вызовом следующего промежуточного программного обеспечения. Как упомянул Никоси, промежуточное программное обеспечение следует заменить фильтром для получения правильных данных о пользователе. Что касается реализации фильтра, то он использует шаблон локатора сервисов, который считается антипатерном. Лучший способ - внедрить его с помощью конструктора, и он уже поддерживается фреймворком. Если вы используете его глобально, вам просто нужно зарегистрировать его как:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(options =>
    {
        options.Filters.Add<SessionFilter>();
    });
}

или если вам это нужно только с некоторыми действиями, вы можете применить фильтр с помощью

[ServiceFilter(typeof(SessionFilter))]

В этом случае фильтр также должен быть зарегистрирован:

public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddScoped<SessionFilter>();
    ...
}
person Alex Riabov    schedule 04.08.2018
comment
Привет Алекс, спасибо за ответ! Пробовал и Scoped - почему-то не работает. Позже я опубликую свой код на GitHub — вы сможете ознакомиться с ним более подробно. - person Alex Herman; 04.08.2018
comment
Кстати, мы регистрируем HttpContextAccessor как синглтон, и он отлично работает для множественных запросов, верно? Значит, и с SessionProvider синглтоном проблем быть не должно? (если я что-то не пропустил) - person Alex Herman; 04.08.2018
comment
@AlexHerman HttpContextAccessor не имеет состояния, а SessionProvider имеет - person Alex Riabov; 04.08.2018