在ASP.Net Core中使用HTTPClient的最佳方式作为DI单例

26

我正在尝试弄清楚如何在ASP.Net Core中最好地使用HttpClient类。

根据文档和几篇文章的说法,最好在应用程序的生命周期内实例化一次该类,并共享多个请求。不幸的是,在Core中我找不到如何正确执行此操作的示例,因此我想出了以下解决方案。

我的特定需求需要使用2个不同的端点(我有一个业务逻辑的APIServer和一个API驱动的ImageServer),因此我的想法是拥有2个HttpClient单例,我可以在应用程序中使用。

我已经在appsettings.json中配置了我的服务点,如下所示:

"ServicePoints": {
"APIServer": "http://localhost:5001",
"ImageServer": "http://localhost:5002",
}

接下来,我创建了一个HttpClientsFactory,它将实例化我的2个 HttpClient 并将它们保存在静态 Dictionary 中。

public class HttpClientsFactory : IHttpClientsFactory
{
    public static Dictionary<string, HttpClient> HttpClients { get; set; }
    private readonly ILogger _logger;
    private readonly IOptions<ServerOptions> _serverOptionsAccessor;

    public HttpClientsFactory(ILoggerFactory loggerFactory, IOptions<ServerOptions> serverOptionsAccessor) {
        _logger = loggerFactory.CreateLogger<HttpClientsFactory>();
        _serverOptionsAccessor = serverOptionsAccessor;
        HttpClients = new Dictionary<string, HttpClient>();
        Initialize();
    }

    private void Initialize()
    {
        HttpClient client = new HttpClient();
        // ADD imageServer
        var imageServer = _serverOptionsAccessor.Value.ImageServer;
        client.BaseAddress = new Uri(imageServer);
        HttpClients.Add("imageServer", client);

        // ADD apiServer
        var apiServer = _serverOptionsAccessor.Value.APIServer;
        client.BaseAddress = new Uri(apiServer);
        HttpClients.Add("apiServer", client);
    }

    public Dictionary<string, HttpClient> Clients()
    {
        return HttpClients;
    }

    public HttpClient Client(string key)
    {
        return Clients()[key];
    }
  } 

然后,我创建了一个接口,以后在定义我的DI时可以使用它。注意,HttpClientsFactory类继承自这个接口。

public interface IHttpClientsFactory
{
    Dictionary<string, HttpClient> Clients();
    HttpClient Client(string key);
}

现在我已准备好将此内容注入到我的依赖容器中,在“ConfigureServices”方法下的“Startup”类中按照以下方式进行。

// Add httpClient service
        services.AddSingleton<IHttpClientsFactory, HttpClientsFactory>();

现在已经准备好在我的控制器中开始使用它了。
首先,我引入依赖关系。为此,我创建了一个私有类属性来保存它,然后将其添加到构造函数签名中,并最后通过将传入的对象分配给本地类属性来完成。

private IHttpClientsFactory _httpClientsFactory;
public AppUsersAdminController(IHttpClientsFactory httpClientsFactory)
{
   _httpClientsFactory = httpClientsFactory;
}

最后,我们现在可以使用工厂来请求 htppclient 并执行调用。以下是一个示例,在此示例中我使用 httpclientsfactory 请求图像服务器上的图像:

[HttpGet]
    public async Task<ActionResult> GetUserPicture(string imgName)
    {
        // get imageserver uri
        var imageServer = _optionsAccessor.Value.ImageServer;

        // create path to requested image
        var path = imageServer + "/imageuploads/" + imgName;

        var client = _httpClientsFactory.Client("imageServer");
        byte[] image = await client.GetByteArrayAsync(path);

        return base.File(image, "image/jpeg");
    }

完成了!

我已经在我的开发环境中进行了测试,效果很好。但是,我不确定这是否是实现此功能的最佳方法。我仍然有以下问题:

  1. 这种解决方案是否线程安全?(根据 Microsoft 文档:“此类型的任何公共静态成员(在 Visual Basic 中为 Shared)都是线程安全的。”)
  2. 这种设置能否处理大量负载而不打开许多单独的连接?
  3. 在 ASP.Net Core 中如何处理“Singleton HttpClient?Beware of this serious behaviour and how to fix.”中描述的 DNS 问题,该问题位于http://byterot.blogspot.be/2016/07/singleton-httpclient-dns.html
  4. 有其他改进或建议吗?

有趣的方法,我将HTTPClient静态方法视为服务,但我没有考虑过工厂模式。我想知道你是否尝试在需要身份验证的API下使用过这种方法,或者你的API是一个公开API?对于需要不同令牌的不同请求,你要如何处理? - Muqeet Khan
@MuqeetKhan,我对你的问题的回答比预期的要长一些。因此,请在下面找到它并附有一个示例。 - Laobu
你不需要两个 HttpClient 实例。只需注册一个单例并使用它即可。DNS 问题仍然存在。顺便说一下,Factory创建 对象实例。在此处阅读有关工厂模式的更多信息:https://msdn.microsoft.com/en-us/library/ee817667.aspx。 - galdin
1
@gldraphael 但创建实例的一个原因是可以重用诸如BaseAddress、凭据和连接等内容。如果我们调用不同的API,这并没有什么帮助。 - Simon_Weaver
1
@Simon_Weaver 确实如此。我更喜欢为单个请求设置这些参数,而不是为客户端设置。 - galdin
@gldraphael 我还在苦苦挣扎,因为我认为这些问题现在应该已经被框架解决了,但它们似乎并没有。真的很奇怪,考虑到正确性有多么重要!幸运的是,这是一个内部应用程序,所以我现在可以随便试试,可能会按照你说的去做 :) - Simon_Weaver
3个回答

35

如果使用 .NET Core 2.1 或更高版本,则最佳方法是使用新的 HttpClientFactory。我猜微软意识到人们遇到的所有问题,所以他们已经为我们做好了艰苦的工作。请参见下面的设置方法。

注意:将引用添加到 Microsoft.Extensions.Http

1 - 添加使用 HttpClient 的类

public interface ISomeApiClient
{
    Task<HttpResponseMessage> GetSomethingAsync(string query);
}

public class SomeApiClient : ISomeApiClient
{
    private readonly HttpClient _client;

    public SomeApiClient (HttpClient client)
    {
        _client = client;
    }

    public async Task<SomeModel> GetSomethingAsync(string query)
    {
        var response = await _client.GetAsync($"?querystring={query}");
        if (response.IsSuccessStatusCode)
        {
            var model = await response.Content.ReadAsJsonAsync<SomeModel>();
            return model;
        }
        // Handle Error
    }
}

2 - 在 Startup.cs 的 ConfigureServices(IServiceCollection services) 方法中注册您的客户端。

var someApiSettings = Configuration.GetSection("SomeApiSettings").Get<SomeApiSettings>(); //Settings stored in app.config (base url, api key to add to header for all requests)
services.AddHttpClient<ISomeApiClient, SomeApiClient>("SomeApi",
                client =>
                {
                    client.BaseAddress = new Uri(someApiSettings.BaseAddress);
                    client.DefaultRequestHeaders.Add("api-key", someApiSettings.ApiKey);
                });

3 - 在您的代码中使用客户端

public class MyController
{
    private readonly ISomeApiClient _client;

    public MyController(ISomeApiClient client)
    {
        _client = client;
    }

    [HttpGet]
    public async Task<IActionResult> GetAsync(string query)
    {
        var response = await _client.GetSomethingAsync(query);

        // Do something with response

        return Ok();
    }
}

使用 services.AddHttpClient,您可以在启动时添加尽可能多的客户端并注册尽可能多的客户端。

感谢 Steve Gordon 和他的这篇文章,帮助我在我的代码中使用它!


3
这种方法会在每次使用SomeAPIClient时创建一个新的HttpClient吗?我没有看到在如何配置IHttpClientFactory的地方有SingletonTransient关键字的说明。虽然存在一些模式可以明确地将HttpClient定义为DI容器中的单例(尽管在多租户场景中更改HttpClients的端点时,这可能会给API的不同消费者需要不同的端点带来问题)。 - Youp Bernoulli
你怎么知道?你可以通过在 DI 容器中使用 AddHttpClient 提供它作为服务,每次构造 MyController(每次请求它时)都会从 DI 容器中获取一个新的 (I)SomeApiClient。或者 AddHttpClient 在底层与我们用于其他(普通)服务的 AddService 不同吗? - Youp Bernoulli
1
简而言之,不要只听我的话,源代码链接在上面的答案文章中。抱歉,我远非专家,您可以忽略我上次评论(因错误信息已删除)。从记忆中来看,SomeApiClient应该是短暂的,而底层的HttpClient是带有过期计时器的单例模式。如果您已经读到这里,请再次阅读本评论中的第一句话。 - garethb
2
关于 HttpClient(Factory) 和相关的 HttpMessageHandler 的许多框架信息可以在这里阅读:https://learn.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests#httpclient-lifetimes - Youp Bernoulli
3
文档的关键摘录表明这不是一个单例,但可以重复使用一些对象:“一个类型化客户端实际上是一个短暂的对象,意味着每次需要一个新实例时都会创建一个新的实例,并且每次构造时它将接收一个新的 HttpClient 实例。然而,在池中的 HttpMessageHandler 对象是被多个 HttpClient 实例重复使用的对象。” - Noah Stahl

2
当您无法使用 DI 时的情况:
    using System.Net.Http;

    public class SomeClass
    {
        private static readonly HttpClient Client;

        static SomeClass()
        {
            var handler = new SocketsHttpHandler
            {
                // Sets how long a connection can be in the pool to be considered reusable (by default - infinite)
                PooledConnectionLifetime = TimeSpan.FromMinutes(1),
            };

            Client = new HttpClient(handler, disposeHandler: false);
        }
        
        ...
    }

参考 https://learn.microsoft.com/zh-cn/aspnet/core/fundamentals/http-requests?view=aspnetcore-5.0#alternatives-to-ihttpclientfactory


1

回答@MuqeetKhan的问题,关于使用httpclient请求进行身份验证。

首先,我使用DI和工厂的动机是为了让我轻松地将应用程序扩展到不同的API,并且在我的代码中很容易地访问它。我希望能够多次重复使用这个模板。

对于上面原始问题中描述的“GetUserPicture”控制器,出于简单起见,我确实删除了身份验证。但是,老实说,我仍然怀疑是否需要在那里使用它来简单地从图像服务器检索图像。无论如何,在其他控制器中我肯定需要它,所以...

我已经实现了Identityserver4作为我的身份验证服务器。这为我提供了在ASP Identity之上的身份验证。

对于授权(在这种情况下使用角色),我在我的MVC '和' API项目中实现了IClaimsTransformer(您可以在如何将ASP.net Identity角色放入Identityserver4 Identity token中阅读更多信息)。

现在,我进入控制器的瞬间就有一个经过身份验证和授权的用户,可以检索访问令牌来调用我的 API,当然,这个 API 调用的是同一个 IdentityServer 实例以验证用户是否已经通过身份验证。
最后一步是允许我的 API 验证用户是否被授权调用所请求的 API 控制器。在 API 的请求管道中,使用 IClaimsTransformer 如前所述,我检索调用用户的授权并将其添加到传入的声明中。请注意,在 MVC 调用和 API 调用的情况下,我因此要两次检索授权;一次在 MVC 请求管道中,一次在 API 请求管道中。
使用这种设置,我能够使用带有授权和身份验证的 HttpClientsFactory。
我缺少的一个重要安全部分当然是 HTTPS。我希望我能够将其添加到我的工厂中。我实施后会更新它。
像往常一样,欢迎任何建议。
以下是一个示例,其中我使用身份验证(用户必须已登录并具有管理员角色)将图像上传到 Imageserver。
我的 MVC 控制器调用“UploadUserPicture”:
    [Authorize(Roles = "Admin")]
    [HttpPost]
    public async Task<ActionResult> UploadUserPicture()
    {
        // collect name image server
        var imageServer = _optionsAccessor.Value.ImageServer;

        // collect image in Request Form from Slim Image Cropper plugin
        var json = _httpContextAccessor.HttpContext.Request.Form["slim[]"];

        // Collect access token to be able to call API
        var accessToken = await HttpContext.Authentication.GetTokenAsync("access_token");

        // prepare api call to update image on imageserver and update database
        var client = _httpClientsFactory.Client("imageServer");
        client.DefaultRequestHeaders.Accept.Clear();
        client.SetBearerToken(accessToken);
        var content = new FormUrlEncodedContent(new[]
        {
            new KeyValuePair<string, string>("image", json[0])
        });
        HttpResponseMessage response = await client.PostAsync("api/UserPicture/UploadUserPicture", content);

        if (response.StatusCode != HttpStatusCode.OK)
        {
            return StatusCode((int)HttpStatusCode.InternalServerError);
        }
        return StatusCode((int)HttpStatusCode.OK);
    }

处理用户上传的API。
    [Authorize(Roles = "Admin")]
    [HttpPost]
    public ActionResult UploadUserPicture(String image)
    {
     dynamic jsonDe = JsonConvert.DeserializeObject(image);

        if (jsonDe == null)
        {
            return new StatusCodeResult((int)HttpStatusCode.NotModified);
        }

        // create filname for user picture
        string userId = jsonDe.meta.userid;
        string userHash = Hashing.GetHashString(userId);
        string fileName = "User" + userHash + ".jpg";

        // create a new version number
        string pictureVersion = DateTime.Now.ToString("yyyyMMddHHmmss");

        // get the image bytes and create a memory stream
        var imagebase64 = jsonDe.output.image;
        var cleanBase64 = Regex.Replace(imagebase64.ToString(), @"^data:image/\w+;base64,", "");
        var bytes = Convert.FromBase64String(cleanBase64);
        var memoryStream = new MemoryStream(bytes);

        // save the image to the folder
        var fileSavePath = Path.Combine(_env.WebRootPath + ("/imageuploads"), fileName);
        FileStream file = new FileStream(fileSavePath, FileMode.Create, FileAccess.Write);
        try
        {
            memoryStream.WriteTo(file);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(LoggingEvents.UPDATE_ITEM, ex, "Could not write file >{fileSavePath}< to server", fileSavePath);
            return new StatusCodeResult((int)HttpStatusCode.NotModified);
        }
        memoryStream.Dispose();
        file.Dispose();
        memoryStream = null;
        file = null;

        // update database with latest filename and version
        bool isUpdatedInDatabase = UpdateDatabaseUserPicture(userId, fileName, pictureVersion).Result;

        if (!isUpdatedInDatabase)
        {
            return new StatusCodeResult((int)HttpStatusCode.NotModified);
        }

        return new StatusCodeResult((int)HttpStatusCode.OK);
    }

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