在我用过的所有基于 .NET 的应用中,访问外部(REST)API 的功能是通过 HTTP 协议实现的。针对这一需求,.NET 框架提供了多种实现方案,本文将介绍其中我最喜欢的实现方式。

在 .NET framework 时代调用 HTTP 请求并处理响应比现在复杂得多。一种流行的解决方案是使用 RestSharp 库,它隐藏了很多复杂性。

现在使用 dotnet(Core)HttpClient API 来进行 HTTP 请求则要容易得多,也没有必要再使用第三方库了。虽然有诸多的好处,但我们仍需注意一些问题。

HttpClient 的局限

如今,如果您正在构建与 API 通信的 dotnet 应用程序,您很可能使用 HttpClient 类来执行 HTTP 请求。

HttpClient 使用起来非常简单,但也容易滥用。常见的错误之一是开发人员直接在其代码中实例化新的 HttpClient 实例来启动请求。这可能会导致一些问题,这些问题起初可能并不明显并且难以调试。

一个常见的问题是端口耗尽, 看起来, 这并不只在我身上发生。同时,在使用 HttpClient 时还可能发生文档中描述的其他问题

在本文中,我们将使用公共的星球大战 API 作为示例来演示使用 API 的不同方式。

我们从初始代码开始,该代码直接在代码中更新 HttpClient 实例。

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId) =>
{
    var httpClient = new HttpClient();
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"https://swapi.dev/api/people/{peopleId}");
    return Results.Ok(people);
});

starwarsGroup.MapGet("species/{speciesId}", async (string speciesId) =>
{
    var httpClient = new HttpClient();
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"https://swapi.dev/api/species/{speciesId}");
    return Results.Ok(species);
});

starwarsGroup.MapGet("planets/{planetId}", async (string planetId) =>
{
    var httpClient = new HttpClient();
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"https://swapi.dev/api/planets/{planetId}");
    return Results.Ok(planet);
});

app.Run();

既然我们知道了需要注意的可能问题,并且已经看到了初始代码,那么让我们看看可以做出的改进。

为什么拥有 Singleton 并不是解决方案

想到的第一个解决方案可能是不要每次创建新的 HttpClient 实例,而是在应用程序的整个生命周期中使用单个实例(单例)。

这似乎是一个不错的解决方案,但它并不是最好的解决方案,因为您仍然需要自己管理客户端的生命周期。否则,您可能仍然会遇到上面链接的文档中提到的 DNS 问题。

当您的应用程序需要多个客户端,例如与不同的 API 进行通信时,Singleton 也不是最合适的。当出现这种情况时,很难对客户端进行不同的配置。

IHttpClientFactory

一个更好的解决方案是使用 IHttpClientFactoryIHttpClientFactory 是一个工厂,它为您创建和管理 HttpClient 实例。这样,dotnet 会处理所有细节,您可以专心编写代码而不会被打扰。

然而,我们再次面临一个要做的决定,因为有多个选项可供选择。 让我们一个一个地查看这些选项。

重构到 IHttpClientFactory

最简单的方法是在我们需要的时候直接使用 IHttpClientFactory 来创建一个 HTTP 客户端。

要做到这一点,首先使用 AddHttpClient 方法在依赖注入容器中注册 IHttpClientFactory。 然后,在需要客户端的地方注入工厂,使用 CreateClient 创建客户端,并用它来发出 HTTP 请求。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();

var app = builder.Build();

var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient();
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"https://swapi.dev/api/people/{peopleId}");
    return Results.Ok(people);
});

starwarsGroup.MapGet("species/{speciesId}",  async (string speciesId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient();
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"https://swapi.dev/api/species/{speciesId}");
    return Results.Ok(species);
});

starwarsGroup.MapGet("planets/{planetId}",  async (string planetId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient();
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"https://swapi.dev/api/planets/{planetId}");
    return Results.Ok(planet);
});

app.Run();

这种解决方案可行,我们解决了以前遇到的一些问题,但它并不是理想的。

我们解决的主要问题是,我们不必再考虑管理 HttpClient 的生命周期了。 但是,当我们查看代码时,我们能发现一些问题。

乍一看,我们可以看到这里存在重复,因为在不同的请求中都(重新)声明了 API 的域(https://swapi.dev/)。

我不喜欢这一点, 第二个原因是,我们没有任何方法来配置客户端。 此外,如果应用程序需要与多个 HTTP API 通信,我们依旧会重复使用同一个 HTTP 客户端。

最后,当 HTTP 客户端发送请求或接收响应时,我们无法挂接到 HTTP 客户端的事件。 这可能是我们将来想添加的内容,为什么不积极主动地去做呢。 而且剧透一下,改进的解决方案与这个解决方案相比并没有多费什么工夫(正如您将在接下来的示例中看到的那样)。

重构到命名的 HTTP 客户端

改进的解决方案是使用所谓的命名的 HTTP 客户端。这个解决方案与之前的解决方案有许多相似之处,除了给客户端增加了名称。

要创建一个命名的客户端,只需将一个名称(只是一个字符串)传递给 AddHttpClient 方法。然后,当您想要创建一个客户端时,将相同的客户端名称传递给 CreateClient 方法。

在下面的示例中,我们创建并使用了一个名为 “starwars” 的命名的客户端。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("starwars");

var app = builder.Build();

var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"https://swapi.dev/api/people/{peopleId}");
    return Results.Ok(people);
});

starwarsGroup.MapGet("species/{speciesId}",  async (string speciesId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"https://swapi.dev/api/species/{speciesId}");
    return Results.Ok(species);
});

starwarsGroup.MapGet("planets/{planetId}",  async (string planetId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"https://swapi.dev/api/planets/{planetId}");
    return Results.Ok(planet);
});

app.Run();

就这么简单。

配置 HTTP 客户端

为了优化应用程序的性能和可维护性,我们可以进一步配置 HttpClient 客户端。通过在 AddHttpClient 方法的回调函数中配置客户端,我们可以在应用程序中的任何地方使用已配置的客户端。

这样做的一个好处是,我们可以集中地配置客户端,并在应用程序中任何地方重用它。这避免了在应用程序的不同位置重复代码,使代码更加简洁和易于维护。

下面的重构版本配置了域。 要做到这一点,在回调方法中设置 BaseAddress 属性。 本示例保持简单,但除了基本地址之外,还可以配置有关客户端的更多信息,例如,将请求标头包括到 HTTP 请求中。

结果是我们可以删除调用时的重复域。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("starwars", (client) => {
        // 在这里配置 HTTP 客户端
        client.BaseAddress = new Uri("https://swapi.dev/api/");
    });

var app = builder.Build();

var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"people/{peopleId}");
    return Results.Ok(people);
});

starwarsGroup.MapGet("species/{speciesId}", async (string speciesId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"species/{speciesId}");
    return Results.Ok(species);
});

starwarsGroup.MapGet("planets/{planetId}", async (string planetId, IHttpClientFactory httpClientFactory) =>
{
    var httpClient = httpClientFactory.CreateClient("starwars");
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"planets/{planetId}");
    return Results.Ok(planet);
});

app.Run();

我们还可以进一步优化使用 HttpMessageHandler 来调整 HTTP 客户端的行为,方法是使用 DelegatingHandler

HttpMessageHandler 的一些作用是重试失败请求的,为请求添加速率限制器,在 HTTP 客户端中插入缓存层或添加断路器。

幸运的是,我们不必手动编写这个,但我们可以使用流行的 Polly 包。 通过使用 Polly,我们可以轻松地创建弹性的 HTTP 客户端。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("starwars", (client) => {
        client.BaseAddress = new Uri("https://swapi.dev/api/");
    })
    .AddHttpMessageHandler<MyCustomHttpMessageHandler>() // 使用自定义处理器
    .AddPolicyHandler(GetRetryPolicy()); // 使用 Polly 处理程序

我们还可以使用 DelegatingHandler 将数据附加到传出请求,例如,添加标头。 请查看 Jimmy Bogard文章(使用 Azure AD 保护 Web API:连接外部客户端),了解有关此内容的更多信息,其中包括有关如何包含身份验证标头的实用示例。

Header 转发

除了手动向发送的 HTTP 请求添加 Header,我们还可以使用 Microsoft.AspNetCore.HeaderPropagation 包自动转发 Header。

我们在创建 HTTP 客户端时可以配置特定客户端来转发 Header。这是很有用的,因为我们不想将 Header 转发给每个客户端,而只想传播给需要这些 Header 并且我们拥有的客户端。否则,我们可能会向第三方服务泄露敏感信息。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("starwars", (client) => {
        client.BaseAddress = new Uri("https://swapi.dev/api/");
    })
    .AddHeaderPropagation();

更多信息请参阅 documenten

重构为类型化 HTTP 客户端

经过前面的调整,我们的代码已经有了很大的改善,但我们仍有优化空间。 最后一步,我们将具名客户端重构为类型化客户端。

重构的原因和之前一致 —— 这是为了改善代码的可维护性。 在这种情况下,我们希望重复使用 API 接口的调用和逻辑。 此外,我们还可以保留应用程序中所有使用接口的位置的引用。

如果接口在多个地方被使用,则我们需要使用具名客户端复制该接口,并且追踪我们所有的使用位置,这就需要进行手动搜索。

类型化客户端版本在 HTTP 客户端之上添加了一个抽象层,可以类比为另一个服务。 我喜欢通过一个方法为每个接口实现服务,因为这样我们就可以使用“查找所有引用”功能了。

要使用类型化客户端,首先在类中包装 HTTP 客户端。 该类在构造函数中接收一个 HttpClient 实例,该实例由 DI 容器注入。 在构造函数内,我们可以配置客户端。

在下面的示例中,StarWarsHttpClient 用作 Star Wars API 的包装器。

public class StarWarsHttpClient : IStarWarsService
{
    private readonly HttpClient _httpClient;

    public StarWarsHttpClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
        _httpClient.BaseAddress = new Uri("https://swapi.dev/api/");
    }

    public async ValueTask<StarWarsPeople> GetPeople(string peopleId)
    {
        return await _httpClient.GetFromJsonAsync<StarWarsPeople>($"people/{peopleId}");
    }

    public async ValueTask<StarWarsPlanet> GetPlanet(string planetId)
    {
        return await _httpClient.GetFromJsonAsync<StarWarsPlanet>($"planets/{planetId}");
    }

    public async ValueTask<StarWarsSpecies> GetSpecies(string speciesId)
    {
        return await _httpClient.GetFromJsonAsync<StarWarsSpecies>($"species/{speciesId}");
    }
}

public interface IStarWarsService
{
    ValueTask<StarWarsPeople> GetPeople(string peopleId);
    ValueTask<StarWarsPlanet> GetPlanet(string planetId);
    ValueTask<StarWarsSpecies> GetSpecies(string speciesId);
}

然后,更新 AddHttpClient 方法以使用类型化客户端。 最后,在使用者中注入客户端,而不是使用 IHttpClientFactory。 使用者无需再关心接口,而只需使用类型化客户端的方法即可。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<IStarWarsService, StarWarsHttpClient>();

var app = builder.Build();

var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IStarWarsService starwarsService) =>
{
    var people = await starwarsService.GetPeople(peopleId);
    return Results.Ok(people);
});

starwarsGroup.MapGet("species/{speciesId}", async (string speciesId, IStarWarsService starwarsService) =>
{
    var species = await starwarsService.GetSpecies(speciesId);
    return Results.Ok(species);
});

starwarsGroup.MapGet("planets/{planetId}", async (string planetId, IStarWarsService starwarsService) =>
{
    var planet = await starwarsService.GetPlanet(planetId);
    return Results.Ok(planet);
});

app.Run();

结论

现在我们来比较一下最初的解决方案与最终的重构后的解决方案。

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId) =>
{
    var httpClient = new HttpClient();
    var people = await httpClient.GetFromJsonAsync<StarWarsPeople>($"https://swapi.dev/api/people/{peopleId}");
    return Results.Ok(people);
});

starwarsGroup.MapGet("species/{speciesId}", async (string speciesId) =>
{
    var httpClient = new HttpClient();
    var species = await httpClient.GetFromJsonAsync<StarWarsSpecies>($"https://swapi.dev/api/species/{speciesId}");
    return Results.Ok(species);
});

starwarsGroup.MapGet("planets/{planetId}", async (string planetId) =>
{
    var httpClient = new HttpClient();
    var planet = await httpClient.GetFromJsonAsync<StarWarsPlanet>($"https://swapi.dev/api/planets/{planetId}");
    return Results.Ok(planet);
});

app.Run();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<IStarWarsService, StarWarsHttpClient>()
    .AddHttpMessageHandler<MyCustomHttpMessageHandler>() // Using a custom handler
    .AddPolicyHandler(GetRetryPolicy()); // using a Polly handler;

var app = builder.Build();

var starwarsGroup = app.MapGroup("starwars");
starwarsGroup.MapGet("people/{peopleId}", async (string peopleId, IStarWarsService starwarsService) =>
{
    var people = await starwarsService.GetPeople(peopleId);
    return Results.Ok(people);
});

starwarsGroup.MapGet("species/{speciesId}", async (string speciesId, IStarWarsService starwarsService) =>
{
    var species = await starwarsService.GetSpecies(speciesId);
    return Results.Ok(species);
});

starwarsGroup.MapGet("planets/{planetId}", async (string planetId, IStarWarsService starwarsService) =>
{
    var planet = await starwarsService.GetPlanet(planetId);
    return Results.Ok(planet);
});

app.Run();

通过将这两种解决方案并排比较,我们可以发现最终解决方案更具可读性和可维护性。

我们不必再对重复的基础地址和接口 URL感到头痛,现在只需在类型化客户端中定义一次接口 URL,然后在整个应用程序中重复利用。由于类型化客户端是一个类,我们就能轻松找到接口 URL的所有引用。通过将接口添加到类型化客户端中,我们还可以将客户端模拟在我们的测试用例中,就像其他任何接口一样。

最后,可以通过添加自定义处理程序或使用Polly等第三方工具来自定义客户端的行为。

除了上述的技术重构和优点外,我们还解决了基础设施方面的问题。因为客户端的生命周期由IHttpClientFactory来管理,因此我们不会再遇到端口耗尽和DNS问题了。

更多参考资料