使用特定的HttpMessageHandler注入单个实例的HttpClient

15
作为我正在开发的ASP.Net Core项目的一部分,我需要从WebApi内与许多不同的基于Rest的API终端进行通信。为了实现这一点,我使用了许多服务类,每个类都实例化了一个静态的HttpClient。本质上,我为WebApi连接的每个基于Rest的终端都有一个服务类。
下面是每个服务类中静态HttpClient的实例化示例。
private static HttpClient _client = new HttpClient()
{
    BaseAddress = new Uri("http://endpointurlexample"),            
};
虽然以上方法很有效,但它不允许对使用HttpClient的服务类进行有效的单元测试。为了进行单元测试,我有一个假的HttpMessageHandler,我希望在我的单元测试中使用它来代替HttpClient,而HttpClient则像上面实例化一样。然而,我无法将假的HttpMessageHandler应用于我的单元测试中。
有什么办法可以使服务类中的HttpClient在整个应用程序中保持单一实例(每个端点一个实例),但允许在单元测试期间应用不同的HttpMessageHandler呢?
我想到的一种方法是不使用静态字段来保存服务类中的HttpClient,而是允许通过构造函数注入以单例生命周期方式引入,这将允许我在单元测试期间指定一个带有所需HttpMessageHandlerHttpClient。我想到的另一种选择是使用一个HttpClient工厂类,该类在静态字段中实例化HttpClient,然后可以通过将HttpClient工厂注入服务类来检索它们,从而允许返回具有相关HttpMessageHandler的不同实现,以进行单元测试。然而,以上任何一个方法都不够完美,似乎还有更好的方法?如果您有任何问题,请告诉我。

如果这个被用在控制器中,那么在构造函数中让控制器接受一个httpClient并使用Moq进行单元测试如何? - Golois Mouelet
它通过控制器使用,但我注入了一个服务而不是将HttpClient注入到控制器中,其中一个实现使用了HttpClient。未来可能需要使用除Http之外的其他机制进行额外的实现,这就是为什么HttpClient没有直接提供给控制器的原因。 - Chris Lawrence
5个回答

21

从评论中了解到,您需要一个 HttpClient 工厂来补充对话。

public interface IHttpClientFactory {
    HttpClient Create(string endpoint);
}

核心功能的实现可能如下所示。

public class DefaultHttpClientFactory : IHttpClientFactory, IDisposable
{
    private readonly ConcurrentDictionary<string, HttpClient> _httpClients;

    public DefaultHttpClientFactory()
    {
        this._httpClients = new ConcurrentDictionary<string, HttpClient>();
    }

    public HttpClient Create(string endpoint)
    {
        if (this._httpClients.TryGetValue(endpoint, out var client))
        {
            return client;
        }

        client = new HttpClient
        {
            BaseAddress = new Uri(endpoint),
        };

        this._httpClients.TryAdd(endpoint, client);

        return client;
    }

    public void Dispose()
    {
        this.Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        foreach (var httpClient in this._httpClients)
        {
            httpClient.Value.Dispose();
        }
    }
}

话虽如此,如果您对上述设计不是特别满意。您可以将HttpClient依赖项抽象为一个服务,以使客户端不成为实现细节。

服务的消费者无需知道数据的检索方式。


14

你想得太复杂了。你只需要使用一个带有HttpClient属性的HttpClient工厂或访问器,并以ASP.NET Core允许注入HttpContext的方式使用它。

public interface IHttpClientAccessor 
{
    HttpClient Client { get; }
}

public class DefaultHttpClientAccessor : IHttpClientAccessor
{
    public HttpClient Client { get; }

    public DefaultHttpClientAccessor()
    {
        Client = new HttpClient();
    }
}

并将其注入到您的服务中

public class MyRestClient : IRestClient
{
    private readonly HttpClient client;

    public MyRestClient(IHttpClientAccessor httpClientAccessor)
    {
        client = httpClientAccessor.Client;
    }
}

在 Startup.cs 中注册:

services.AddSingleton<IHttpClientAccessor, DefaultHttpClientAccessor>();

对于单元测试,只需模拟它即可

// Moq-esque

// Arrange
var httpClientAccessor = new Mock<IHttpClientAccessor>();
var httpHandler = new HttpMessageHandler(..) { ... };
var httpContext = new HttpContext(httpHandler);

httpClientAccessor.SetupGet(a => a.Client).Returns(httpContext);

// Act
var restClient = new MyRestClient(httpClientAccessor.Object);
var result = await restClient.GetSomethingAsync(...);

// Assert
...

3
我喜欢这里的方法,但这将给我一个用于访问所有3个端点的单个HttpClient。由于一些原因,我希望每个端点有一个单独的HttpClient。例如,Endpoint1将由HttpClient instance1访问,Endpoint2将由HttpClient instance2访问。也许可以使用相同的方法,将HttpClients存储在HttpClientAccessor内的字典中?使用基于字符串的键进行检索的方法。你觉得呢? - Chris Lawrence
2
那么,您需要一个具有Create(string endpoint)方法的工厂,对于每个端点,您都需要创建一个HttpContext并将其缓存,以便在对Create方法进行最终调用时返回它,并使用端点作为字典的键。 - Tseng
@NaveedAhmed:在这个例子中,我使用了一个后备字段,但是你也可以使用C#的新语言特性,比如只读属性的赋值。这适用于DefaultHttpClientAccessor。对于RestClient来说,没有必要将client变量暴露给外部。 - Tseng
1
@NaveedAhmed:只读属性和只读字段不同。只读属性HttpContext Client { get; }; 只读字段:private readonly HttpContext client;。字段没有getter/setters。 - Tseng
1
这与IHttpClientFactory有什么不同?那时它不可用还是完全不同的想法? - Simon_Weaver
显示剩余6条评论

4
我的建议是,针对每个目标端点域名,我会从HttpClient派生出一个类,并使用依赖注入将其设置为单例,而不是直接使用HttpClient。比如,如果我要向example.com发送HTTP请求,我会创建一个名为ExampleHttpClient的类,它继承自HttpClient,并具有与HttpClient相同的构造函数签名,使您可以像往常一样传递和模拟HttpMessageHandler
public class ExampleHttpClient : HttpClient
{
   public ExampleHttpClient(HttpMessageHandler handler) : base(handler) 
   {
       BaseAddress = new Uri("http://example.com");

       // set default headers here: content type, authentication, etc   
   }
}

我将在依赖注入注册中设置ExampleHttpClient为单例,并将HttpMessageHandler的注册设置为短暂,因为它将仅为每个http客户端类型创建一次。使用此模式,我不需要为HttpClient或基于目标主机名构建它们的智能工厂进行多个复杂的注册。
任何需要与example.com通信的内容都应该对ExampleHttpClient进行构造函数依赖,然后它们都共享同一个实例,并且您可以按设计进行连接池。
这种方式还提供了一个更好的位置来放置默认标题、内容类型、授权、基本地址等内容,并有助于防止一个服务的http配置泄漏到另一个服务。

我喜欢这个想法。通常我只访问2-3个不同的端点,所以这可能非常有效。但如果你有很多端点或更复杂的情况,我不禁想知道解决方案是什么。 - Simon_Weaver

1

我可能有点晚了,但我创建了一个Helper nuget包,可以让你在单元测试中测试HttpClient端点。

NuGet: install-package WorldDomination.HttpClient.Helpers
Repo: https://github.com/PureKrome/HttpClient.Helpers

基本思路是创建假的响应负载,并将其传递给FakeHttpMessageHandler实例,该实例包括该假响应负载。然后,当你的代码尝试实际命中该URI端点时,它不会...而只返回假响应。神奇!

这里有一个非常简单的例子:

[Fact]
public async Task GivenSomeValidHttpRequests_GetSomeDataAsync_ReturnsAFoo()
{
    // Arrange.

    // Fake response.
    const string responseData = "{ \"Id\":69, \"Name\":\"Jane\" }";
    var messageResponse = FakeHttpMessageHandler.GetStringHttpResponseMessage(responseData);

    // Prepare our 'options' with all of the above fake stuff.
    var options = new HttpMessageOptions
    {
        RequestUri = MyService.GetFooEndPoint,
        HttpResponseMessage = messageResponse
    };

    // 3. Use the fake response if that url is attempted.
    var messageHandler = new FakeHttpMessageHandler(options);

    var myService = new MyService(messageHandler);

    // Act.
    // NOTE: network traffic will not leave your computer because you've faked the response, above.
    var result = await myService.GetSomeFooDataAsync();

    // Assert.
    result.Id.ShouldBe(69); // Returned from GetSomeFooDataAsync.
    result.Baa.ShouldBeNull();
    options.NumberOfTimesCalled.ShouldBe(1);
}

-3

HttpClient被用于内部:

public class CustomAuthorizationAttribute : Attribute, IAuthorizationFilter
{
    private string Roles;
    private static readonly HttpClient _httpClient = new HttpClient();

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接