如何在 asp.net core 3.x 的 startup.cs 文件中获取注入的服务

一、前言

从 18 年开始接触 .NET Core 开始,在私底下、工作中也开始慢慢从传统的 mvc 前后端一把梭,开始转向 web api + vue,之前自己有个半成品的 asp.net core 2.2 的项目模板,最近几个月的时间,私下除了学习 Angular 也在对这个模板基于 asp.net core 3.1 进行慢慢补齐功能

因为涉及到底层框架大版本升级,由于某些 breaking changes 必定会造成之前的某些写法没办法继续使用,趁着端午节假期,在改造模板时,发现没办法通过构造函数注入的形式在 Startup 文件中注入某些我需要的服务了,因此本篇文章主要介绍如何在 asp.net core 3.x 的 startup 文件中获取注入的服务

二、Step by Step

2.1、问题案例

这个问题的发现源于我需要改造模型验证失败时返回的错误信息,如果你有尝试的话,在 3.x 版本中你会发现在 Startup 类中,我们没办法通过构造函数注入的方式再注入任何其它的服务了,这里仅以我的代码中需要解决的这个问题作为案例

在定义接口时,为了降低后期调整的复杂度,在接收参数时,一般会将参数包装成一个 dto 对象(data transfer object – 数据传输对象),不管是提交数据,还是查询数据,对于这个 dto 中的某些属性,都会存在一定的卡控,例如 xxx 字段不能为空了,xxx 字段的长度不能超过 30

而在 asp.net core 中,因为会自动进行模型验证,当不符合 dto 中的属性要求时,接口会自动返回错误信息,默认的返回信息如下图所示

模型验证失败

可以看到,因为这里其实是按照 rfc7231这个 RFC 协议返回的错误信息,这个并不符合我的要求,因此这里我需要改写这个返回的错误信息

自定义 asp.net core 的模型验证错误信息方法有很多种,我的实现方法如下,因为我需要记录请求的标识 Id 和错误日志,所以这里我需要将 ILoggerIHttpContextAccessor 注入到 Startup 类中

/// <summary>
/// 修改模型验证错误返回信息
/// </summary>
/// <param name="services">服务容器集合</param>
/// <param name="logger">日志记录实例</param>
/// <param name="httpContextAccessor"></param>
/// <returns></returns>
public static IServiceCollection AddCustomInvalidModelState(this IServiceCollection services,
    ILogger<Startup> logger, IHttpContextAccessor httpContextAccessor)
{
    services.Configure<ApiBehaviorOptions>(options =>
    {
        options.InvalidModelStateResponseFactory = actionContext =>
        {
            // 获取验证不通过的字段信息
            //
            var errors = actionContext.ModelState.Where(e => e.Value.Errors.Count > 0)
                .Select(e => new ApiErrorDto
                {
                    Title = "请求参数不符合字段格式要求",
                    Message = e.Value.Errors.FirstOrDefault()?.ErrorMessage
                }).ToList();

            var result = new ApiReturnDto<object>
            {
                TraceId = httpContextAccessor.HttpContext.TraceIdentifier,
                Status = false,
                Error = errors
            };

            logger.LogError($"接口请求参数格式错误: {JsonConvert.SerializeObject(result)}");

            return new BadRequestObjectResult(result);
        };
    });

    return services;
}

在 asp.net core 2.x 版本中,你完全可以像在别的类中采用构造函数注入的方式一样直接注入使用

public class Startup
{
    /// <summary>
    /// 日志记录实例
    /// </summary>
    private readonly ILogger<Startup> _logger;

    /// <summary>
    /// Http 请求实例
    /// </summary>
    private readonly IHttpContextAccessor _httpContextAccessor;

    /// <summary>
    /// ctor
    /// </summary>
    /// <param name="configuration"></param>
    /// <param name="logger"></param>
    /// <param name="httpContextAccessor"></param>
    public Startup(IConfiguration configuration, ILogger<Startup> logger, IHttpContextAccessor httpContextAccessor)
    {
        Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
    }

    /// <summary>
    /// 配置实例
    /// </summary>
    public IConfiguration Configuration { get; }

    /// <summary>
    /// This method gets called by the runtime. Use this method to add services to the container.
    /// </summary>
    public void ConfigureServices(IServiceCollection services)
    {
        //注入的其它服务

        // 返回自定义的模型验证错误信息
        services.AddCustomInvalidModelState(_logger, _httpContextAccessor);
    }
}

但是当你直接迁移到 asp.net core 3.x 版本后,你会发现程序会报如下的错误,很常见的一个依赖注入的错误,源头直指我们通过构造函数注入的 ILoggerIHttpContextAccessor 接口

迁移之后的报错信息

2.2、解决方法

根本原因

通过查阅 stackoverflow 发现了这样的一个问题:How do I write logs from within Startup.cs,在最高赞的回答中提到了在泛型主机(GenericHostBuilder)中,没办法注入除 IConfiguration 之外的任何服务到 Startup类中,而泛型主机则是在 asp.net core 3.0 中添加的功能

查了下升级日志,从中可以看到,在泛型主机中, Startup 类的构造函数注入只支持 IHostEnvironmentIWebHostEnvironmentIConfiguration ,嗯,不好好看别人文档的锅

泛型主机

为什么使用 WebHostBuilder可以,换成 GenericHostBuilder 就不行了呢

按照正常的逻辑来说,对于一个 asp.net core 应用,原则上来说只有有一个根级(root)的依赖注入容器,但是因为我们在 Startup 类中通过构造函数注入的形式注入服务时,告诉程序了我需要这个服务的实例,从而导致在构建 WebHost 时存在了一个单独的容器,并且这个容器只包含了我们需要使用到的服务信息,之后,因为会创建了一个包含完整服务的依赖注入容器,这里就会存在一个服务哪怕是单例的也可能会存在注册两次的问题,这无疑有些不太合乎规范

在推行泛型主机之后,严格控制了只会存在一个依赖注入容器,而所有的服务都是在 Startup.ConfigureServices 方法执行完成后才会注册到依赖注入容器中,因此没办法像之前一样在根容器注册完成之前通过构造函数注入的形式使用

解决方案

如果你需要在 Startup.Configure 方法中使用自定义的服务,因为这里已经完成了各种服务的注册,和之前一样,我们直接在方法签名中包含需要使用到的服务即可

public void Configure(IApplicationBuilder app, IHostEnvironment env, ILogger<Startup> logger)
{
    logger.LogInformation("在 Configure 中使用自定义的服务");
}

如果你需要在 Startup.ConfigureServices 中使用的话,则需要换一种方法

最简单的方法,直接替换泛型主机为原来的 WebHostBuilder,这样就可以直接在 Startup 类中注入各种服务接口了,不过,考虑到这一改动其实是在开倒车,所以这里不推荐采用这种方法

既然没办法正向通过依赖注入容器来自动创建我们需要的服务实例,是不是可以通过服务容器,手动去获取我们需要的服务,也就是被称为服务定位(Service Locator)的方式来获取实例

当然,这似乎与依赖注入的思想相左,对于依赖注入来说,我们将所有需要使用的服务定义好,在应用启动前完成注册,之后在使用时由依赖注入容器提供服务的实例即可,而服务定位则是我们已经知道存在这个服务了,从容器中获取出来然后由自己手动的创建实例

虽然服务定位是一种反模式,但是在某些情况下,我们又不得不采用

这里对于本篇文章开篇中需要解决的问题,我也是采用服务定位的方式,通过构建一个 ServiceProvider 之后,手动的从容器中获取需要使用的服务实例,调整后的代码如下

/// <summary>
/// 添加自定义模型验证失败时返回的错误信息
/// </summary>
/// <param name="services">服务容器集合</param>
/// <returns></returns>
public static IServiceCollection AddCustomInvalidModelState(this IServiceCollection services)
{
    // 构建一个服务的提供程序
    var provider = services.BuildServiceProvider();

    // 获取需要使用的服务实例
    //
    var logger = provider.GetRequiredService<ILogger<Startup>>();
    var httpContextAccessor = provider.GetRequiredService<IHttpContextAccessor>();

    services.Configure<ApiBehaviorOptions>(options =>
    {
        options.InvalidModelStateResponseFactory = actionContext =>
        {
            // 获取失败信息
            //
            var errors = actionContext.ModelState.Where(e => e.Value.Errors.Count > 0)
                .Select(e => new ApiErrorMessageDto
                {
                    Title = "Request parameters do not meet the field requirements",
                    Message = e.Value.Errors.FirstOrDefault()?.ErrorMessage
                }).ToList();

            var result = new ApiResponseDto<object>
            {
                TraceId = httpContextAccessor.HttpContext.TraceIdentifier,
                Status = false,
                Error = errors
            };

            logger.LogError($"接口请求参数格式错误: {JsonSerializer.Serialize(result)}");

            return new BadRequestObjectResult(result);
        };
    });

    return services;
}

对于配置一些需要基于某些服务的服务,这里也可以通过委托的形式获取到需要使用的服务实例,示例代码如下

public void ConfigureServices(IServiceCollection services)
{
    services.AddSingleton<IMyService>((container) =>
    {
        var logger = container.GetRequiredService<ILogger<MyService>>();
        return new MyService
        {
            Logger = logger
        };
    });
}

三、参考资料