不使用 HttpContext 傳遞 Context 的寫法

上篇 有提到可以透過 ASP.NET / ASP.NET Core 的 HttpContext 來傳遞狀態,由於他的生命週期很短,每一個調用者擁有獨立的狀態,很適合用來跨層傳遞狀態;現在,我想要降低對 HttpContext 的依賴,改由自訂的 ContextAccessor 保留物件的狀態,傳遞系統所必要的狀態,統一由一個點進行修改,比如登入帳號、追蹤 Id,其他的點,只能取用不能修改。

開發環境

  • Windows 11
  • ASP.NET Core 7
  • Rider 2023.2

實作

定義讀、寫的合約

public interface IContextGetter<T>
{
    T? Get();
}

public interface IContextSetter<T>
{
    void Set(T value);
}

 

實作 IContextSetter<T>, IContextGetter<T>

public class ContextAccessor<T> : IContextSetter<T>, IContextGetter<T>
    where T : class
{
    private T _value;

    public void Set(T value)
    {
        this._value = value;
    }

    public T? Get()
    {
        return this._value;
    }
}

 

配置 ValueObject

確保狀態是唯讀,這裡用 init 關鍵字實現

public record AuthContext
{
    public string TraceId { get; init; }

    public string UserId { get; init; }
}

 

設定 Context

在 TraceContextMiddleware,從 DI Container 取出 IContextSetter<TraceContext> 實例,透過 IContextSetter<TraceContext>.Set 方法建立一個新的 TraceContext 實例,在這裡,我用兩個欄位作為例子

  • UserId:(假)授權後的帳號,這裡我是用 ClaimsPrincipal,當然,也可以直接賦予 UserId 一個值。
  • TraceId:由調用端決定(最好是由 API Gateway 統控),或是由服務自身產生,產生出來的 TraceId,傳給其他依賴的服務,服務自身也回傳 TraceId 的內容,讓調用端可以記錄它。在這裡,我特意使用 IContextGetter<T> 取出狀態,在不同的 Request,它的狀態都不一樣。
  • Log:用 logger.BeginScope,在每一筆 log 都額外附加上 UserId、TraceId 資訊
public class TraceContextMiddleware
{
    private readonly RequestDelegate _next;

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

    public async Task Invoke(HttpContext httpContext, ILogger<TraceContextMiddleware> logger)
    {
        var traceId = httpContext.Request.Headers[SysHeaderNames.TraceId].FirstOrDefault();

        //// 若調用端沒有傳入 traceId,則產生一個新的 traceId
        if (string.IsNullOrWhiteSpace(traceId))
        {
            traceId = httpContext.TraceIdentifier;
        }

        // 模擬登入
        Signin(httpContext);

        if (httpContext.User.Identity.IsAuthenticated == false)
        {
            httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
            await httpContext.Response.WriteAsJsonAsync(new Failure
            {
                Code = FailureCode.Unauthorized,
                Message = "not login",
            });
            return;
        }

        var userId = httpContext.User.Identity.Name;

        // 寫入 trace context 到 object context setter
        var contextSetter = httpContext.RequestServices.GetService<IContextSetter<TraceContext>>();
        contextSetter.Set(new TraceContext
        {
            TraceId = traceId,
            UserId = userId
        });

        // 附加 traceId 與 userId 到 log 中
        using var _ = logger.BeginScope("{Location},{TraceId},{UserId}",
            "TW", traceId, userId);

        // 附加 traceId 到 response header 中
        IContextGetter<TraceContext?>? contextGetter = httpContext.RequestServices.GetService<IContextGetter<TraceContext>>();
        var traceContext = contextGetter.Get();
        httpContext.Response.Headers.TryAdd(SysHeaderNames.TraceId, traceContext.TraceId);

        await this._next.Invoke(httpContext);
    }

    /// <summary>
    /// 假的登入
    /// </summary>
    /// <param name="context"></param>
    private static void Signin(HttpContext context)
    {
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, "yao"),
            new Claim(ClaimTypes.Name, "yao"),
        };
        var identity = new ClaimsIdentity(claims, "Bearer");
        var principal = new ClaimsPrincipal(identity);
        context.User = principal;
    }
}

 

讀取 Context

透過 IContextGetter<TraceContext?> 傳遞系統所必須要使用的狀態,下面的例子,則是透過 TraceContext.UserId 進行資料查詢

[ApiController]
[Route("[controller]")]
public class DemoController : ControllerBase
{
    private readonly ILogger<DemoController> _logger;
    private readonly IContextGetter<TraceContext?> _contextGetter;

    public DemoController(ILogger<DemoController> logger,
        IContextGetter<TraceContext?> contextGetter)
    {
        _logger = logger;
        this._contextGetter = contextGetter;
    }

    [HttpGet(Name = "GetDemo")]
    public ActionResult Get()
    {
        var traceContext = this._contextGetter.Get();
        var userId = traceContext.UserId;

        // 由 Context 取得 UserId
        var member = Member.GetFakeMembers().FirstOrDefault(p => p.UserId == userId);

        this._logger.LogInformation(2000, "found {@Data}", member);

        return this.Ok(member);
    }
}

 

在 DI Container 配置 

ContextAccessor 是 Scop (pre request instance ) 的生命週期以及 TraceContextMiddleware pipeline

builder.Services.AddScoped<ContextAccessor<TraceContext>>();
builder.Services.AddScoped<IContextGetter<TraceContext>>(p => p.GetService<ContextAccessor<TraceContext>>());
builder.Services.AddScoped<IContextSetter<TraceContext>>(p => p.GetService<ContextAccessor<TraceContext>>());
var app = builder.Build();

app.UseMiddleware<TraceContextMiddleware>();
...

 

執行後,可以觀察出每一個 log 都有附加 UserId、TraceId 欄位

 

回傳 x-trace-id 狀態,它通常交由調用端紀錄,若需要除錯時,調用端就拿著這個值,去問

 

通過 AsyncLocal 傳遞狀態

上一個例子,其實是搭配 HttpContext + DI Container,來確保每一個 Request(執行緒) 的狀態都是獨立的,通過 AsyncLocal 也可以確保每一個執行緒的狀態都是獨立,並用 ContextHolder 來保存 AsyncLocal 的狀態,這樣可以避免觸發  ExecutionContext 的 COW (Copy On Write),代碼如下:

public class ContextAccessor2<T> : IContextSetter<T>, IContextGetter<T>
    where T : class
{
    private static readonly AsyncLocal<ContextHolder<T>> s_current = new();

    public T? Get()
    {
        var contextHolder = s_current.Value;
        return contextHolder?.Value;
    }

    public void Set(T value)
    {
        s_current.Value ??= new ContextHolder<T>();
        s_current.Value.Value = value;
    }
}

當沒有 HttpContext 時,AsyncLocal 也可以讓不同的執行緒,有各自的狀態

 

有關 AsyncLocal 的運作原理及注意事項,可以參考

揭秘 .NET 中的 AsyncLocal (qq.com)

.NET AsyncLocal 避坑指南 - 黑洞视界 - 博客园 (cnblogs.com)

 

DI Container 的配置如下:

雖然這裡的生命週期用 Single,但骨子裡面的 AsyncLocal 可以讓每一個執行緒的狀態都不一樣 

builder.Services.AddSingleton<ContextAccessor2<AuthContext>>();
builder.Services.AddSingleton<IContextGetter<AuthContext>>(p => p.GetService<ContextAccessor2<AuthContext>>());
builder.Services.AddSingleton<IContextSetter<AuthContext>>(p => p.GetService<ContextAccessor2<AuthContext>>());

 

執行結果跟上述一樣,就不在贅述。

 

驗證每一個 Request 所得到的 IContextGetter<T> 的資料不重複

新增 Test Project 並安裝測試套件

dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 7.0.11


實作 WebApplicationFactory

public class TestServer : WebApplicationFactory<Program>
{
    private void ConfigureServices(IServiceCollection services)
    {

    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(this.ConfigureServices);
    }
}

 

在測試方法裡面同時送出 10000 個請求,蒐集  Response.Header["x-trace-id"] 的狀態,若有重複,則失敗

[TestClass]
public class UnitTest1
{
    [TestMethod]
    public async Task TestMethod1()
    {
        var server = new TestServer();
        var httpClient = server.CreateDefaultClient();
        
        var url = "https://localhost:7004/demo";

        var tasks = new List<Task<Data>>();
        for (var i = 0; i < 10000; i++)
        {
            tasks.Add(SendAsync(httpClient, url));
        }

        var data = await Task.WhenAll(tasks);

        var duplicateData = data.GroupBy(p => p.TraceId)
            .Where(p => p.Count() > 1)
            .Select(p => p.Key);
        
        foreach (var item in duplicateData)
        {
            Console.WriteLine(item);
        }

        if (duplicateData.Any())
        {
            Assert.Fail("有重複的 trace id");
        }
    }

    static async Task<Data> SendAsync(HttpClient httpClient, string url)
    {
        var response = await httpClient.GetAsync(url);
        response.Headers.TryGetValues("x-trace-id", out var traceIds);
        var traceId = traceIds.FirstOrDefault();
        return new Data()
        {
            TraceId = traceId
        };
    }
}

範例位置

sample.dotblog/Trace/Lab.Context.Trace at 3a8e160fba1046d956b0afeab151589bc5b50635 · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo