一、前言 從 18 年開始接觸 .NET Core 開始,在私底下、工作中也開始慢慢從傳統的 mvc 前後端一把梭,開始轉向 web api + vue,之前自己有個半成品的 asp.net core 2.2 的項目模板,最近幾個月的時間,私下除了學習 Angular 也在對這個模板基於 asp.n ...
一、前言
從 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 和錯誤日誌,所以這裡我需要將 ILogger
和 IHttpContextAccessor
註入到 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 版本後,你會發現程式會報如下的錯誤,很常見的一個依賴註入的錯誤,源頭直指我們通過構造函數註入的 ILogger
、IHttpContextAccessor
介面
2.2、解決方法
根本原因
通過查閱 stackoverflow 發現了這樣的一個問題:How do I write logs from within Startup.cs,在最高贊的回答中提到了在泛型主機(GenericHostBuilder)中,沒辦法註入除 IConfiguration
之外的任何服務到 Startup
類中,而泛型主機則是在 asp.net core 3.0 中添加的功能
查了下升級日誌,從中可以看到,在泛型主機中, Startup
類的構造函數註入只支持 IHostEnvironment
、 IWebHostEnvironment
、IConfiguration
,嗯,不好好看別人文檔的鍋
為什麼使用 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
};
});
}