只有累積,沒有奇蹟

2021年11月17日 星期三

[NETCore] 如何在 ASP.NET Core Middleware 加上單元測試 Unititest

前言
Middleware 在 ASP.NET Core 開發時是個很常見的功能,概念很像 ASP.NET Application Life cycle 管線的 Handler 機制 (若對於 Life Cycle 想了解更多可以看之前寫的文章 Application Life Cycle),提供開發者可以在 Request 進入到 Application 前加上客製化的邏輯,實務上用起來挺方便的也蠻好用的,在加上 middleware 相關邏輯後也會加上單元測試,確保所撰寫的邏輯有驗證過且是沒有問題,也避免在 middleware 異常時 Devops 同仁半夜叫你起床處理(萬萬不可阿,這篇文章要說明的是如何在 middleware 加上 unittest,對有經驗的開發者相信是一塊小蛋糕,內容若有問題歡迎提出來一起討論。


建立檢查 Token Middleware
舉一個常用的例子,實務上在與第三方在做串接時需要 token 作為對方請求的驗證,作為過濾不必要的請求以節省伺服器的資源。全部 API 接口都需要做 token 檢查因此將此邏輯檢查可以放在 Middleware,首先建立一個新的 .NET Core 3.1 WebAPI 專案,接著建立 TokenVerificationMiddleware.cs 類別,在此類別中加入下列代碼
public class TokenVerificationMiddleware
{
	private readonly RequestDelegate _next;

	public TokenVerificationMiddleware(RequestDelegate next)
	{
		_next = next;
	}

	public async Task Invoke(HttpContext context)
	{
		if (context.Request.Headers.ContainsKey("token") && context.Request.Headers["token"] == "marcusblog")
		{
			await _next.Invoke(context);
		}
		else
		{
			context.Response.StatusCode = 403;  // UnAuthorized
			await context.Response.WriteAsync("Invalid request token");
			return;
		}
	}
}
在此類別的代碼中定義了當請求來時檢查 Request 的 Header 資訊是否有包含 token 關鍵字,token 有的話是否等於 marcusblog,如果都符合就讓此請求往下一步進行進入到下一個流程像是 Action 邏輯;相反的如果不符合 token 檢查邏輯,就會回傳 403 等 Status Code。
當建立好檢查 Token 的 Middleware 之後,接著開啟專案中的 StartUp.cs 類別在 Configure 方法中加上以下代碼
app.UseMiddleware();
上述是使用內建的 UseMiddleware 方法並定義指定的 Middleware 類別,另外還可以使用擴充方法(Extensions Method)達到使用 Middleware,也是個人比較推薦的方式,將要使用的 Middleware 統一集中在特定的class中代碼在閱讀上較為容易理解,使用方式是新增 ApplicationBuilderExtension 類別,裡面新增 UseTokenVerificationMiddleware 方法並定義回傳 IApplicationBuilder 型別,內容與在 startup.cs 的一樣
public static class ApplicationBuilderExtension
{
	public static IApplicationBuilder UseTokenVerificationMiddleware(this IApplicationBuilder builder)
	{
		return builder.UseMiddleware();
	}
}
接著再回到 Configure 方法將 UseTokenVerificationMiddleware 取代原本的方法
//app.UseMiddleware();
app.UseTokenVerificationMiddleware();
開啟偵錯進行測試,可以發現網頁打開會因為 Request 中沒有在 Header 中帶 token 參數出現 Invalid request token,畫面如下


加上單元測試
一般來說請求進到 Application 的 Request 請求的內容會存在於 HttpContext 中,接著再把 HttpContext 的請求傳遞到所指定的 Middleware 邏輯中,也就是在建構子中的參數 HttpContext,那麼要進行單元測試的話第一步是要先模擬請求的 HttpContext 物件,這邊可以使用 DefaultHttpContext 達到這目的,DefaultHttpContext 繼承了 HttpContext 抽象類別,讓我們在單元測試中更為方便,舉例來說如果要模擬 hpptContext 物件並在 Header 加上 token 內容用下列方式就可以達到
var context = new DefaultHttpContext();
context.Request.Headers.Add("token", "this is a book");
DefaultHttpContext 還可以指定測試的路徑(Path)、內容(Body)、ContentType 或是可以指定 Cookie Form 及 QueryString 等常見的設定值,使用上相當的簡易與方便,放在測試案例中代碼如下
public class TokenVerificationMiddlewareTest
{
    private TokenVerificationMiddleware _target;
    [SetUp]
    public void Initial()
    {
        _target = new TokenVerificationMiddleware(null);
    }

    [Test]
    public void Correct_Header_Should_Return_Success()
    {
        var context = new DefaultHttpContext();
        context.Request.Headers.Add("token", "marcusblog");

        var result = _target.Invoke(context);
        context.Response.StatusCode.Should().Be(200);
    }

    [Test]
    public void Without_Header_Should_Return_UnAuthorized()
    {
        var context = new DefaultHttpContext();

        var result = _target.Invoke(context);
        context.Response.StatusCode.Should().Be(403);
    }

    [Test]
    public void Empty_Header_Should_Return_UnAuthorized()
    {
        var context = new DefaultHttpContext();

        var result = _target.Invoke(context);
        context.Response.StatusCode.Should().Be(403);
    }

    [Test]
    public void Wrong_Header_Should_Return_UnAuthorized()
    {
        var context = new DefaultHttpContext();
        context.Request.Headers.Add("token", "wrongtoken");

        var result = _target.Invoke(context);
        context.Response.StatusCode.Should().Be(403);
    }
}
以上是使用 nUnit 搭配 FluentAssertions 套件,測試幾種常見的情境像是 token 為空、header 為空、帶錯誤的 token及帶正確的 token 值 測試後的結果 Pass 成功 !
大功告成,打完收工

感想
在 ASP.NET Core 有了 httpDefaultContext 之後,在針對 Middleware 撰寫單元測試變得方便許多,對有經驗的開發者來說也是小蛋糕一塊,如果有不清楚的地方歡迎一起討論,hope it helps !

參考
為 .NET Core Middleware 加上 Unit Test
DefaultHttpContext Class
ASP.NET Core 執行原理解剖[4]:進入HttpContext的世界
ASP.NET Core 中介軟體
ASP.NET Core 2.1 middlewares part 2: Unit test a custom middleware




2021年11月3日 星期三

[NETCore] ASP.NET Core 使用強型別取代 IOption 注入配置

前言
之前的 如何取得 appsettings.json 組態設定 文章中有介紹在 ASP.NET Core 中透過 IOptions 方法取得設定檔的方法,在需要用到的地方注入 IOptions 取得設定類別的資訊,相信使用上並不困難在 MSDN 官方推薦作法也是如此,但如果開發一陣子之後可以發現到處都是 IOptios 類別,這篇文章介紹如何使用擴充 IServiceCollection 的方法來降低對 IOptios 的依賴,若有問題或是錯誤的地方歡迎各方高手大大一起討論或給予指導

IOption
之首先先來簡單回顧  IOptions<T>  的傳統用法,透過微軟 MSDN 中 IOptions 介紹得知要使用前需先引用 Microsoft.Extensions.Options,為了方便快速了解差異性,這裡建立一個 ASP.NET Core Web 應用程式範例來說明,在新增完應用程式後在 appsetting.json 加入自己定義的  mySettings  設定資訊提供 Name 以及 Title 屬性
"MySettings": {
    "Name": "Marcus",
    "Title": "9527"
  } 
接著要取得設定檔的內容,這邊建立與 Config 內容欄位相同的強型別的 class 物件
public class MySettings
{
    public string Name { get; set; }
    public string Title { get; set; }
} 
在 ConfigureServices 中加入下列代碼,用意是將 appsettinss.json 中的資訊加載到 MySettings 中
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
    services.AddOptions();
    services.Configure<MySettings>(Configuration.GetSection("MySettings"));
} 
接著就可以在 Value Controll 使用 IOptions<T> 取得設定檔的資訊,代碼如下
public class ValuesController : ControllerBase
{
    public MySettings _mySettings { get; set; }

    public ValuesController(IOptions<MySettings> settings)
    {
        _mySettings = settings.Value;
    }

    // GET api/values
    [HttpGet]
    public ActionResult<IEnumerable<string>> Get()
    {
        return new string[] { _mySettings.Name, _mySettings.Title };
    } 

不使用 IOptions 注入
以上快速地回顧 IOptions<T> 的用法,就可以簡單使用 IOption<T> 取得 appsettings.json 設定資訊的方法,但是這意味著你的代碼與 IOptions 有著強制依賴的關係,你有多少 Controller 就需要在各別的 Controller 中都 using 所需要的 Microsoft.Extensions.Options,除非 appsettings.json 中的設定很常異動需要進行重新載入( 這時可以使用  IOptionMonitor  而不是 IOptions ),否則大部分的使用情境中 config 設定都是較少異動的,參考此文章之後有了新的解法,我們可以新增一個類別並針對 IServiceCollection 加入擴充方法,代碼如下
public static class ServiceCollectionExtensions
{
    public static TConfig ConfigurePOCO<TConfig>(this IServiceCollection services, IConfiguration configuration) where TConfig : class, new()
    {
        if (services == null) throw new ArgumentNullException(nameof(services));
        if (configuration == null) throw new ArgumentNullException(nameof(configuration));

        var config = new TConfig();
        configuration.Bind(config);
        services.AddSingleton(config);
        return config;
    }
} 
這裡我們在 startup.cs 時手動使用  Microsoft.Extensions.Configuration.Binder  來綁定設定檔,並指定服務容器生命週期 (LifeTime) 為 Singleton,在 ASP.NET Core DI 預設提供的 Lifetime 有下列三種
  • Transient : 每次請求時都會產生新的 Instance
  • Scoped : 每個 http Request 都會產生一份 Instance
  • Singleton : 整個 Application 只會有一份 Instance
可以依據所需要情境作調整,如果想了解差異可以參考之前的文章 : 傳送門 

新增完擴充方法之後,接著下一步就是在 ConfigureServices 改用新增的擴充方法 confugurePOCO
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
    services.AddOptions();
    services.ConfigurePOCO<MySettings>(Configuration.GetSection("MySettings"));
    //services.Configure<MySettings>(Configuration.GetSection("MySettings"));
} 
接著在回到要使用的地方也就是範例的 ValueController, 將 IOptions 依賴移除改為強行別的 MySettings
public MySettings _mySettings { get; set; }

public ValuesController(MySettings settings)
{
    _mySettings = settings;
} 
在重新執行應用程式,可以發現應用程式執行正常無誤
如果你對於擴充方法很熟悉,也可以針對自己的需求來自訂所需的方法簽章,例如新增一個 TConfig 參數做為繫結設定檔代碼如下
public static class ServiceCollectionExtensions
{
    public static TConfig ConfigurePOCO<TConfig>(this IServiceCollection services, IConfiguration configuration, TConfig config) where TConfig : class
    {
        if (services == null) throw new ArgumentNullException(nameof(services));
        if (configuration == null) throw new ArgumentNullException(nameof(configuration));
        if (config == null) throw new ArgumentNullException(nameof(config));
 
        configuration.Bind(config);
        services.AddSingleton(config);
        return config;
    }
} 
使用方式先 new 之後作為參數傳入
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
 
    var mySettings = new MySettings("foo"); 
    services.ConfigurePOCO(Configuration.GetSection("MySettings"), mySettings);
} 
或者是新增 Func<TConfig> 參數透過委派方法新增 TConfig 的 instance
public static class ServiceCollectionExtensions
{
    public static TConfig ConfigurePOCO<TConfig>(this IServiceCollection services, IConfiguration configuration, Func<TConfig> pocoProvider) where TConfig : class
    {
        if (services == null) throw new ArgumentNullException(nameof(services));
        if (configuration == null) throw new ArgumentNullException(nameof(configuration));
        if (pocoProvider == null) throw new ArgumentNullException(nameof(pocoProvider));

        var config = pocoProvider();
        configuration.Bind(config);
        services.AddSingleton(config);
        return config;
    } 
}
使用方式如下
public void ConfigureServices(IServiceCollection services)
{
    //...
    services.ConfigurePOCO(Configuration.GetSection("MySettings"), () => new MySettings("foo"));
    //...
} 
如果想了解更多細節,可以參考 Strongly typed configuration in ASP.NET Core without IOptions<T> 取得更多資訊,希望透過以上的介紹,可以讓跟我有一樣困擾的開發者得到新的作法,Hope it helps :)

Copyright © m@rcus 學習筆記 | Powered by Blogger

Design by Anders Noren | Blogger Theme by NewBloggerThemes.com