使用状态拦截带有第三方扩展的 HttpClient

Intercept HttpClient with third party extensions using state

在使用 IHttpClientFactory 时将状态注入 HttpRequest 可以通过填充 HttpRequestMessage.Properties 来实现,请参阅

现在,如果我在 HttpClient 上有第三方扩展(例如 IdentityModel),我将如何使用自定义状态拦截这些 http 请求?

public async Task DoEnquiry(IHttpClientFactory factory)
{
    var id = Database.InsertEnquiry();
    var httpClient = factory.CreateClient();
    // GetDiscoveryDocumentAsync is a third party extension method on HttpClient
    // I therefore cannot inject or alter the request message to be handled by the InterceptorHandler
    var discovery = await httpClient.GetDiscoveryDocumentAsync();
    // I want id to be associated with any request / response GetDiscoveryDocumentAsync is making
}

我目前唯一可行的解​​决方案是覆盖 HttpClient。

public class InspectorHttpClient: HttpClient
{
    
    private readonly HttpClient _internal;
    private readonly int _id;

    public const string Key = "insepctor";

    public InspectorHttpClient(HttpClient @internal, int id)
    {
        _internal = @internal;
        _id = id;
    }
    
    public override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        // attach data into HttpRequestMessage for the delegate handler
        request.Properties.Add(Key, _id);
        return _internal.SendAsync(request, cancellationToken);
    }

    // override all methods forwarding to _internal
}

然后我就可以拦截这些请求了。

public async Task DoEnquiry(IHttpClientFactory factory)
{
    var id = Database.InsertEnquiry();
    var httpClient = new InspectorHttpClient(factory.CreateClient(), id);
    var discovery = await httpClient.GetDiscoveryDocumentAsync();
}

这是一个合理的解决方案吗?现在告诉我不要重写 HttpClient。引用自 https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httpclient?view=net-5.0

The HttpClient also acts as a base class for more specific HTTP clients. An example would be a FacebookHttpClient providing additional methods specific to a Facebook web service (a GetFriends method, for instance). Derived classes should not override the virtual methods on the class. Instead, use a constructor overload that accepts HttpMessageHandler to configure any pre- or post-request processing instead.

我差点把它作为替代解决方案包含在 中,但我认为它已经太长了。 :)

该技术实际上是相同的,但不是 HttpRequestMessage.Properties,而是使用 AsyncLocal<T>。 “异步本地”有点像线程本地存储,但用于特定的异步代码块。

有一些使用 AsyncLocal<T> 的注意事项,这些注意事项并没有特别详细地记录:

  1. T 使用不可变的可空类型。
  2. 设置异步本地值时,return 一个 IDisposable 重置它。
    • 如果您不这样做,则仅通过 async 方法设置异步本地值。

不必遵循这些准则,但它们会让您的生活更轻松。

除此之外,解决方案与上一个类似,只是使用 AsyncLocal<T> 代替。从辅助方法开始:

public static class AmbientContext
{
  public static IDisposable SetId(int id)
  {
    var oldValue = AmbientId.Value;
    AmbientId.Value = id;
    // The following line uses Nito.Disposables; feel free to write your own.
    return Disposable.Create(() => AmbientId.Value = oldValue);
  }

  public static int? TryGetId() => AmbientId.Value;

  private static readonly AsyncLocal<int?> AmbientId = new AsyncLocal<int?>();
}

然后更新调用代码以设置环境值:

public async Task DoEnquiry(IHttpClientFactory factory)
{
  var id = Database.InsertEnquiry();
  using (AmbientContext.SetId(id))
  {
    var httpClient = factory.CreateClient();
    var discovery = await httpClient.GetDiscoveryDocumentAsync();
  }
}

请注意,该环境 ID 值有明确的 范围。该范围内的任何代码都可以通过调用 AmbientContext.TryGetId 来获取 id。使用此模式可确保 any 代码也是如此:同步、asyncConfigureAwait(false) 等 - 该范围内的所有代码都可以获得 id 值。包括您的自定义处理程序:

public class HttpClientInterceptor : DelegatingHandler
{
  protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
  {
    var id = AmbientContext.TryGetId();
    if (id == null)
      throw new InvalidOperationException("The caller must set an ambient id.");

    // associate the id with this request
    Database.InsertEnquiry(id.Value, request);
    return await base.SendAsync(request, cancellationToken);
  }
}

后续阅读:

  • Blog post on "async local" - 在 AsyncLocal<T> 存在之前编写,但详细说明了它的工作原理。这回答了“为什么 T 应该是不可变的?”的问题。和“如果我不使用 IDisposable,为什么我必须通过 async 方法设置值?”。