上篇 有提到可以透過 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 的運作原理及注意事項,可以參考
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
};
}
}
範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET