单例服务中的多个上下文

Multiple contexts in a Singleton service

目前我正在设计一个记录器服务来记录 HttpClient 制作的 HttpRequests

此记录器服务是 Singleton,我想在其中包含 scoped 个上下文。

这是我的记录器:


public Logger
{
    public LogContext Context { get; set; }    

    public void LogRequest(HttpRequestLog log)
    {  
        context.AddLog(log);
    }
}

我在 DelegatingHandler:

中使用记录器

private readonly Logger logger;

public LoggingDelegatingHandler(Logger logger)
{
  this.logger = logger;
}

protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
   await base.SendAsync(request, cancellationToken);
   this.logger.LogRequest(new HttpRequestog());
}

然后当我使用 HttpClient 发出一些请求时,我想获得此特定调用的日志:

private void InvokeApi()
{
  var logContext = new LogContext();
  this.Logger.LogContext = logContext;
  var httpClient = httpClientFactory.CreateClient(CustomClientName);
  await httpClient.GetAsync($"http://localhost:11111");

  var httpRequestLogs = logContext.Logs;
}

问题是,它可以工作,但不是线程安全的。如果我并行执行 InvokeApicontext 将不正确。

How can i have a attached context for each execution properly?

我正在这样注册 HttpClient:

services.AddSingleton<Logger>();
services.AddHttpClient(CentaurusHttpClient)
        .ConfigurePrimaryHttpMessageHandler((c) => new HttpClientHandler()
        {
             AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
        })
        .AddHttpMessageHandler(sp => new LoggingDelegatingHandler(sp.GetRequiredService<Logger>()));

我正在使用这个测试这段代码:

public void Test_Parallel_Logging()
{
    Random random = new Random();

    Action test = async () =>
    {
        await RunScopedAsync(async scope =>
        {
            IServiceProvider sp = scope.ServiceProvider;

            var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
            using (var context = new HttpRequestContext(sp.GetRequiredService<Logger>()))
            {
                try
                {
                    var httpClient = httpClientFactory.CreateClient();
                    await httpClient.GetAsync($"http://localhost:{random.Next(11111, 55555)}");
                }
                catch (HttpRequestException ex)
                {
                    Output.WriteLine("count: " + context?.Logs?.Count);
                }
            }
        });
    };

    Parallel.Invoke(new Action[] { test, test, test });
}

This logger service is Singleton and i want to have scoped contexts inside it.

Logger有一个单例生命周期,所以它的LogContext属性也有一个单例生命周期。

对于您想要的那种范围内的数据,AsyncLocal<T> 是一个合适的解决方案。我倾向于遵循类似于 React 上下文的“提供者”/“消费者”模式,尽管在 .NET 世界中“消费者”更常见的名称是“访问者”。在这种情况下,您可以使 Logger 本身成为提供程序,但我通常会尽量将其保留为单独的类型。

IMO 通过提供您自己的显式作用域,并且 绑定到您的 DI 容器的“作用域”生命周期中,这是最干净的做法。可以使用 DI 容器范围来做到这一点,但你最终会得到一些奇怪的代码,比如解析生产者然后对它们不做任何事情 - 如果未来的维护者删除(看起来是)未使用的注入类型,那么访问器就会中断。

所以我推荐你自己的范围,例如:

public Logger
{
  private readonly AsyncLocal<LogContext> _context = new();

  public void LogRequest(HttpRequestLog log)
  {
    var context = _context.Value;
    if (context == null)
      throw new InvalidOperationException("LogRequest was called without anyone calling SetContext");
    context.AddLog(log);
  }

  public IDisposable SetContext(LogContext context)
  {
    var oldValue = _context.Value;
    _context.Value = context;
    // I use Nito.Disposables; replace with whatever Action-Disposable type you have.
    return new Disposable(() => _context.Value = oldValue);
  }
}

用法(注意 using 提供的明确范围):

private void InvokeApi()
{
  var logContext = new LogContext();
  using (this.Logger.SetContext(logContext))
  {
    var httpClient = httpClientFactory.CreateClient(CustomClientName);
    await httpClient.GetAsync($"http://localhost:11111");
    var httpRequestLogs = logContext.Logs;
  }
}