如今,基于云、微服务或物联网的应用程序通常依赖于通过网络与其他系统通信。每个服务都在自己的进程中运行,并解决一组有限的问题。服务之间的通信是基于一种轻量级的机制,通常是一个 HTTP 资源 API。
从 .NET 开发人员的角度来看,我们希望以可分发包的形式提供一种一致的、可管理的方式来集成特定的服务。最好的方法是将我们开发的服务集成代码以 NuGet 包的形式提供,并与其他人、团队、甚至组织分享。在这篇文章中,我将分享在 .NET 6 中创建和使用 HTTP 客户端 SDK 的方方面面。.
客户端 SDK 在远程服务之上提供了一个有意义的抽象层。本质上,它允许进行远程过程调用(RPC)。客户端 SDK 的职责是序列化一些数据,将其发送到远端目的地,以及反序列化接收到的数据,并处理响应。
HTTP 客户端 SDK 与 API 一同使用:
- 加速 API 集成过程;
- 提供一致、标准的方法;
- 让服务所有者可以部分地控制消费 API 的方式。
编写一个 HTTP 客户端 SDK
在本文中,我们将编写一个完备的 Dad Jokes API 客户端,为的是提供老爸笑话;让我们来玩一玩。源代码在 GitHub 上。
在开发与 API 一起使用的客户端 SDK 时,最好从接口契约(API 和 SDK 之间)入手:
public interface IDadJokesApiClient
{
Task<JokeSearchResponse> SearchAsync (
string term, CancellationToken cancellationToken);
Task<Joke> GetJokeByIdAsync (
string id, CancellationToken cancellationToken);
Task<Joke> GetRandomJokeAsync (CancellationToken cancellationToken);
}
public class JokeSearchResponse
{
public bool Success { get; init; }
public List<Joke> Body { get; init; } = new();
}
public class Joke
{
public string Punchline { get; set; } = default!;
public string Setup { get; set; } = default!;
public string Type { get; set; } = default!;
}
契约是基于你要集成的 API 创建的。我一般建议遵循健壮性原则和最小惊奇原则开发通用的 API。但如果你想根据自己的需要修改和转换数据契约,也是完全可以的,只需从消费者的角度考虑即可。HttpClient 是基于 HTTP 进行集成的基础。它包含你处理 HTTP 抽象时所需要的一切东西。
public class DadJokesApiClient : IDadJokesApiClient
{
private readonly HttpClient httpClient;
public DadJokesApiClient (HttpClient httpClient) =>
this.httpClient = httpClient;
}
通常,HTTP API 会使用 JSON,这就是为什么从 .NET 5 开始,BCL 增加了System.Net.Http.Json命名空间。它为HttpClient和HttpContent提供了许多扩展方法,让我们可以使用System.Text.Json进行序列化和反序列化。如果没有什么复杂的特殊需求,我建议你使用System.Net.Http.Json,因为它能让你免于编写模板代码。那不仅很枯燥,而且也很难保证高效、没有 Bug。我建议你读下 Steves Gordon 的博文“使用 HttpClient 发送和接收 JSON”:
public async Task<Joke> GetRandomJokeAsync (CancellationToken cancellationToken)
{
var jokes = await this.httpClient.GetFromJsonAsync<JokeSearchResponse>(
ApiUrlConstants.GetRandomJoke, cancellationToken);
if (jokes is { Body.Count: 0 } or { Success: false })
{
// 对于这种情况,考虑创建自定义的异常
throw new InvalidOperationException ("This API is no joke.");
}
return jokes.Body.First ();
}
小提示:你可以创建一些集中式的地方来管理端点 URL,像下面这样:
public static class ApiUrlConstants
{
public const string JokeSearch = "/joke/search";
public const string GetJokeById = "/joke";
public const string GetRandomJoke = "/random/joke";
}
小提示:如果你需要处理复杂的 URI,请使用 Flurl。它提供了流畅的 URL 构建(URL-building)体验:
public async Task<Joke> GetJokeByIdAsync (string id, CancellationToken cancellationToken)
{
// $"{ApiUrlConstants.GetJokeById}/{id}"
var path = ApiUrlConstants.GetJokeById.AppendPathSegment (id);
var joke = await this.httpClient.GetFromJsonAsync<Joke>(path, cancellationToken);
return joke ?? new();
}
接下来,我们必须指定所需的头文件(和其他所需的配置)。我们希望提供一种灵活的机制来配置作为 SDK 组成部分的HttpClient。在这种情况下,我们需要在自定义头中提供证书,并指定一个众所周知的“Accept”。小提示:将高层的构建块暴露为HttpClientExtensions。这更便于发现特定于 API 的配置。例如,如果你有一个自定义的授权机制,则 SDK 应提供支持(至少要提供相关的文档)。
public static class HttpClientExtensions
{
public static HttpClient AddDadJokesHeaders (
this HttpClient httpClient, string host, string apiKey)
{
var headers = httpClient.DefaultRequestHeaders;
headers.Add (ApiConstants.HostHeader, new Uri (host) .Host);
headers.Add (ApiConstants.ApiKeyHeader, apiKey);
return httpClient;
}
}
客户端生命周期
为了构建DadJokesApiClient,我们需要创建一个HttpClient。如你所知,HttpClient实现了IDisposable,因为它有一个非托管的底层资源——TCP 连接。在一台机器上同时打开的并发 TCP 连接数量是有限的。这种考虑也带来了一个重要的问题——“我应该在每次需要时创建HttpClient,还是只在应用程序启动时创建一次?”
HttpClient是一个共享对象。这就意味着,在底层,它是可重入和线程安全的。与其每次执行时新建一个HttpClient实例,不如共享一个HttpClient实例。然而,这种方法也有一系列的问题。例如,客户端在应用程序的生命周期内会保持连接打开,它不会遵守 DNS TTL 设置,而且它将永远无法收到 DNS 更新。所以这也不是一个完美的解决方案。
你需要管理一个不定时销毁连接的 TCP 连接池,以获取 DNS 更新。这正是HttpClientFactory所做的。官方文档将HttpClientFactory描述为“一个专门用于创建可在应用程序中使用的HttpClient实例的工厂”。我们稍后将介绍如何使用它。
每次从IHttpClientFactory获取一个HttpClient对象时,都会返回一个新的实例。但是,每个HttpClient都使用一个被IHttpClientFactory池化并重用的 HttpMessageHandler,减少了资源消耗。处理程序的池化是值得的,因为通常每个处理程序都要管理其底层的 HTTP 连接。有些处理程序还会无限期地保持连接开放,防止处理程序对 DNS 的变化做出反应。HttpMessageHandler有一个有限的生命周期。
下面,我们看下在使用由依赖注入(DI)管理的HttpClient时,HttpClientFactory是如何发挥作用的。

消费 API 客户端
在我们的例子中,消费 API 的一个基本场景是无依赖注入容器的控制台应用程序。这里的目标是让消费者以最快的方式来访问已有的 API。
创建一个静态工厂方法来创建一个 API 客户端。
public static class DadJokesApiClientFactory
{
public static IDadJokesApiClient Create (string host, string apiKey)
{
var httpClient = new HttpClient ()
{
BaseAddress = new Uri (host);
}
ConfigureHttpClient (httpClient, host, apiKey);
return new DadJokesApiClient (httpClient);
}
internal static void ConfigureHttpClient (
HttpClient httpClient, string host, string apiKey)
{
ConfigureHttpClientCore (httpClient);
httpClient.AddDadJokesHeaders (host, apiKey);
}
internal static void ConfigureHttpClientCore (HttpClient httpClient)
{
httpClient.DefaultRequestHeaders.Accept.Clear ();
httpClient.DefaultRequestHeaders.Accept.Add (new("application/json"));
}
}
这样,我们可以从控制台应用程序使用IDadJokesApiClient :
var host = "https://dad-jokes.p.rapidapi.com";
var apiKey = "<token>";
var client = DadJokesApiClientFactory.Create (host, apiKey);
var joke = await client.GetRandomJokeAsync ();
Console.WriteLine ($"{joke.Setup} {joke.Punchline}");

消费 API 客户端:HttpClientFactory
下一步是将HttpClient配置为依赖注入容器的一部分。关于这一点,网上有很多不错的内容,我就不做详细讨论了。Steve Gordon 也有一篇非常好的文章“ASP.NET Core 中的 HttpClientFactory”。
为了使用 DI 添加一个池化的HttpClient实例,你需要使用来自Microsoft.Extensions.Http的IServiceCollection.AddHttpClient。
提供一个自定义的扩展方法用于在 DI 中添加类型化的HttpClient。
public static class ServiceCollectionExtensions
{
public static IHttpClientBuilder AddDadJokesApiClient (
this IServiceCollection services,
Action<HttpClient> configureClient) =>
services.AddHttpClient<IDadJokesApiClient, DadJokesApiClient>((httpClient) =>
{
DadJokesApiClientFactory.ConfigureHttpClientCore (httpClient);
configureClient (httpClient);
});
}
使用扩展方法的方式如下:
var host = "https://da-jokes.p.rapidapi.com";
var apiKey = "<token>";
var services = new ServiceCollection ();
services.AddDadJokesApiClient (httpClient =>
{
httpClient.BaseAddress = new(host);
httpClient.AddDadJokesHeaders (host, apiKey);
});
var provider = services.BuildServiceProvider ();
var client = provider.GetRequiredService<IDadJokesApiClient>();
var joke = await client.GetRandomJokeAsync ();
logger.Information ($"{joke.Setup} {joke.Punchline}");
如你所见,IHttpClientFactory 可以在 ASP.NET Core 之外使用。例如,控制台应用程序、worker、lambdas 等。让我们看下它运行:

有趣的是,由 DI 创建的客户端会自动记录发出的请求,使得开发和故障排除都变得非常容易。
如果你操作日志模板的格式并添加SourceContext和EventId,就会看到HttpClientFactory自己添加了额外的处理程序。当你试图排查与 HTTP 请求处理有关的问题时,这很有用。
{SourceContext}[{EventId}] // 模式
System.Net.Http.HttpClient.IDadJokesApiClient.LogicalHandler [{ Id: 100, Name: "RequestPipelineStart" }]
System.Net.Http.HttpClient.IDadJokesApiClient.ClientHandler [{ Id: 100, Name: "RequestStart" }]
System.Net.Http.HttpClient.IDadJokesApiClient.ClientHandler [{ Id: 101, Name: "RequestEnd" }]
System.Net.Http.HttpClient.IDadJokesApiClient.LogicalHandler [{ Id: 101, Name: "RequestPipelineEnd" }]
最常见的场景是 Web 应用程序。下面是 .NET 6 MinimalAPI 示例:
var builder = WebApplication.CreateBuilder (args);
var services = builder.Services;
var configuration = builder.Configuration;
var host = configuration["DadJokesClient:host"];
services.AddDadJokesApiClient (httpClient =>
{
httpClient.BaseAddress = new(host);
httpClient.AddDadJokesHeaders (host, configuration["DADJOKES_TOKEN"]);
});
var app = builder.Build ();
app.MapGet ("/", async (IDadJokesApiClient client) => await client.GetRandomJokeAsync ());
app.Run ();

{
"punchline": "They are all paid actors anyway,"
"setup": "We really shouldn't care what people at the Oscars say,"
"type": "actor"
}
扩展 HTTP 客户端 SDK,通过 DelegatingHandler 添加横切关注点
HttpClient还提供了一个扩展点:一个消息处理程序。它是一个接收 HTTP 请求并返回 HTTP 响应的类。有许多问题都可以表示为横切关注点。例如,日志、身份认证、缓存、头信息转发、审计等等。面向方面的编程旨在将横切关注点封装成方面,以保持模块化。通常情况下,一系列的消息处理程序被链接在一起。第一个处理程序接收一个 HTTP 请求,做一些处理,然后将请求交给下一个处理程序。有时候,响应创建后会回到链条上游。
// 支持大部分应用程序最常见的需求
public abstract class HttpMessageHandler : IDisposable
{}
// 将一个处理程序加入到处理程序链
public abstract class DelegatingHandler : HttpMessageHandler
{}

任务:假如你需要从 ASP.NET Core 的HttpContext复制一系列头信息,并将它们传递给 Dad Jokes API 客户端发出的所有外发请求
public class HeaderPropagationMessageHandler : DelegatingHandler
{
private readonly HeaderPropagationOptions options;
private readonly IHttpContextAccessor contextAccessor;
public HeaderPropagationMessageHandler (
HeaderPropagationOptions options,
IHttpContextAccessor contextAccessor)
{
this.options = options;
this.contextAccessor = contextAccessor;
}
protected override Task<HttpResponseMessage> SendAsync (
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (this.contextAccessor.HttpContext != null)
{
foreach (var headerName in this.options.HeaderNames)
{
var headerValue = this.contextAccessor
.HttpContext.Request.Headers[headerName];
request.Headers.TryAddWithoutValidation (
headerName, (string[]) headerValue);
}
}
return base.SendAsync (request, cancellationToken);
}
}
public class HeaderPropagationOptions
{
public IList<string> HeaderNames { get; set; } = new List<string>();
}
我们想把一个DelegatingHandler“插入”到HttpClient请求管道中。对于非IttpClientFactory场景,我们希望客户端能够指定一个DelegatingHandler列表来为HttpClient建立一个底层链。
//DadJokesApiClientFactory.cs
public static IDadJokesApiClient Create (
string host,
string apiKey,
params DelegatingHandler[] handlers)
{
var httpClient = new HttpClient ();
if (handlers.Length > 0)
{
_ = handlers.Aggregate ((a, b) =>
{
a.InnerHandler = b;
return b;
});
httpClient = new(handlers[0]);
}
httpClient.BaseAddress = new Uri (host);
ConfigureHttpClient (httpClient, host, apiKey);
return new DadJokesApiClient (httpClient);
}
这样,在没有 DI 容器的情况下,可以像下面这样扩展 DadJokesApiClient :
var loggingHandler = new LoggingMessageHandler (); //最外层
var authHandler = new AuthMessageHandler ();
var propagationHandler = new HeaderPropagationMessageHandler ();
var primaryHandler = new HttpClientHandler (); // HttpClient 使用的默认处理程序
DadJokesApiClientFactory.Create (
host, apiKey,
loggingHandler, authHandler, propagationHandler, primaryHandler);
// LoggingMessageHandler ➝ AuthMessageHandler ➝ HeaderPropagationMessageHandler ➝ HttpClientHandler
另一方面,在 DI 容器场景中,我们希望提供一个辅助的扩展方法,使用IHttpClientBuilder.AddHttpMessageHandler轻松插入HeaderPropagationMessageHandler。
public static class HeaderPropagationExtensions
{
public static IHttpClientBuilder AddHeaderPropagation (
this IHttpClientBuilder builder,
Action<HeaderPropagationOptions> configure)
{
builder.Services.Configure (configure);
builder.AddHttpMessageHandler ((sp) =>
{
return new HeaderPropagationMessageHandler (
sp.GetRequiredService<IOptions<HeaderPropagationOptions>>() .Value,
sp.GetRequiredService<IHttpContextAccessor>());
});
return builder;
}
}
扩展后的 MinimalAPI 示例如下所示:
var builder = WebApplication.CreateBuilder (args);
var services = builder.Services;
var configuration = builder.Configuration;
var host = configuration["DadJokesClient:host"];
services.AddDadJokesApiClient (httpClient =>
{
httpClient.BaseAddress = new(host);
httpClient.AddDadJokesHeaders (host, configuration["DADJOKES_TOKEN"]);
}) .AddHeaderPropagation (o => o.HeaderNames.Add ("X-Correlation-ID"));
var app = builder.Build ();
app.MapGet ("/", async (IDadJokesApiClient client) => await client.GetRandomJokeAsync ());
app.Run ();
有时,像这样的功能会被其他服务所重用。你可能想更进一步,把所有共享的代码都提取到一个公共的 NuGet 包中,并在 HTTP 客户端 SDK 中使用它。
第三方扩展
我们可以编写自己的消息处理程序,但 .NET OSS 社区也提供了许多有用的 NuGet 包。以下是我最喜欢的。
弹性模式——重试、缓存、回退等:很多时候,在一个系统不可靠的世界里,你需要通过加入一些弹性策略来确保高可用性。幸运的是,我们有一个内置的解决方案,可以在 .NET 中构建和定义策略,那就是 Polly。Polly 提供了与IHttpClientFactory开箱即用的集成。它使用了一个便捷的方法 IHttpClientBuilder.AddTransientHttpErrorPolicy。它配置了一个策略来处理 HTTP 调用的典型错误:HttpRequestException HTTP 5XX 状态码(服务器错误)、HTTP 408 状态码(请求超时)。
services.AddDadJokesApiClient (httpClient =>
{
httpClient.BaseAddress = new(host);
}) .AddTransientHttpErrorPolicy (builder => builder.WaitAndRetryAsync (new[]
{
TimeSpan.FromSeconds (1),
TimeSpan.FromSeconds (5),
TimeSpan.FromSeconds (10)
}));
例如,可以使用重试和断路器模式主动处理瞬时错误。通常,当下游服务有望自我纠正时,我们会使用重试模式。重试之间的等待时间对于下游服务而言是一个恢复稳定的窗口。重试经常使用指数退避算法。这纸面上听起来不错,但在现实世界的场景中,重试模式的使用可能过度了。额外的重试可能导致额外的负载或峰值。在最坏的情况下,调用者的资源可能会被耗尽或过分阻塞,等待永远不会到来的回复,导致上游发生了级联故障。这就是断路器模式发挥作用的时候了。它检测故障等级,并在故障超过阈值时阻止对下游服务的调用。如果没有成功的机会,就可以使用这种模式,例如,当一个子系统完全离线或不堪重负时。断路器的理念非常简单,虽然你可能会以它为基础构建一些更复杂的东西。当故障超过阈值时,调用就会断开,因此,我们不是处理请求,而是实践快速失败的方法,立即抛出一个异常。
Polly 真的很强大,它提供了一种组合弹性策略的方法,见 PolicyWrap。
下面是一个可能对你有用的策略分类:

设计可靠的系统可能是一项非常具有挑战性的任务,我建议你自己研究下这个问题。这里有一个很好的介绍——.NET 微服务架构电子书:实现弹性应用程序。
OAuth2/OIDC 中的身份认证:如果你需要管理用户和客户端访问令牌,我建议使用 IdentityModel.AspNetCore。它可以帮你获取、缓存和轮换令牌,详情参见文档。
// 添加用户和客户端访问令牌管理
services.AddAccessTokenManagement (options =>
{
options.Client.Clients.Add ("identity-provider", new ClientCredentialsTokenRequest
{
Address = "https://demo.identityserver.io/connect/token",
ClientId = "my-awesome-service",
ClientSecret = "secret",
Scope = "api"
});
});
// 使用托管的客户端访问令牌注册 HTTP 客户端
// 向 HTTP 客户端注册添加令牌访问处理程序
services.AddDadJokesApiClient (httpClient =>
{
httpClient.BaseAddress = new(host);
}) .AddClientAccessTokenHandler ();
测试 HTTP 客户端 SDK
至此,对于设计和编写 HTTP 客户端 SDK,你应该已经比较熟悉了。剩下的工作就只是写一些测试来确保其行为符合预期了。请注意,跳过广泛的单元测试,编写更多的集成或 e2e 来确保集成的正确性,或许也不错。现在,我将展示如何对DadJokesApiClient进行单元测试。
如前所述,HttpClient是可扩展的。此外,我们可以用测试版本代替标准的HttpMessageHandler。这样,我们就可以使用模拟服务,而不是通过网络发送实际的请求。这种技术提供了大量的可能,因为我们可以模拟各种在正常情况下是很难复现的HttpClient行为。
我们定义一个可重用的方法,用于创建一个 HttpClient 模拟,并作为一个依赖项传递给DadJokesApiClient。
public static class TestHarness
{
public static Mock<HttpMessageHandler> CreateMessageHandlerWithResult<T>(
T result, HttpStatusCode code = HttpStatusCode.OK)
{
var messageHandler = new Mock<HttpMessageHandler>();
messageHandler.Protected ()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync (new HttpResponseMessage ()
{
StatusCode = code,
Content = new StringContent (JsonSerializer.Serialize (result)),
});
return messageHandler;
}
public static HttpClient CreateHttpClientWithResult<T>(
T result, HttpStatusCode code = HttpStatusCode.OK)
{
var httpClient = new HttpClient (CreateMessageHandlerWithResult (result, code) .Object)
{
BaseAddress = new("https://api-client-under-test.com"),
};
Return httpClient;
}
}
从这点来看,单元测试是个非常简单的过程:
public class DadJokesApiClientTests
{
[Theory, AutoData]
public async Task GetRandomJokeAsync_SingleJokeInResult_Returned (Joke joke)
{
// Arrange
var response = new JokeSearchResponse
{
Success = true,
Body = new() { joke }
};
var httpClient = CreateHttpClientWithResult (response);
var sut = new DadJokesApiClient (httpClient);
// Act
var result = await sut.GetRandomJokeAsync ();
// Assert
result.Should () .BeEquivalentTo (joke);
}
[Fact]
public async Task GetRandomJokeAsync_UnsuccessfulJokeResult_ExceptionThrown ()
{
// Arrange
var response = new JokeSearchResponse ();
var httpClient = CreateHttpClientWithResult (response);
var sut = new DadJokesApiClient (httpClient);
// Act
// Assert
await FluentActions.Invoking (() => sut.GetRandomJokeAsync ())
.Should () .ThrowAsync<InvalidOperationException>();
}
}
使用HttpClient是最灵活的方法。你可以完全控制与 API 的集成。但是,也有一个缺点,你需要编写大量的样板代码。在某些情况下,你要集成的 API 并不重要,所以你并不需要HttpClient、HttpRequestMessage、HttpResponseMessage所提供的所有功能。
优点:
-
可以完全控制行为和数据契约。你甚至可以编写一个“智能”API 客户端,如果有需要的话,在特殊情况下,你可以把一些逻辑移到 SDK 里。例如,你可以抛出自定义的异常,转换请求和响应,提供默认头信息,等等。
-
可以完全控制序列化和反序列化过程。
-
易于调试和排查问题。堆栈容易跟踪,你可以随时启动调试器,看看后台正在发生的事情
缺点:
-
需要编写大量的重复代码。
-
需要有人维护代码库,以防 API 有变化和 Bug。这是一个繁琐的、容易出错的过程。
使用声明式方法编写 HTTP 客户端 SDK
代码越少,Bug 越少。Refit 是一个用于 .NET 的、自动化的、类型安全的 REST 库。它将 REST API 变成一个随时可用的接口。Refit 默认使用
System.Text.Json作为 JSON 序列化器。
每个方法都必须有一个 HTTP 属性,提供请求方法和相对应的 URL。
using Refit;
public interface IDadJokesApiClient
{
/// <summary>
/// 根据词语搜索笑话。
/// </summary>
[Get ("/joke/search")]
Task<JokeSearchResponse> SearchAsync (
string term,
CancellationToken cancellationToken = default);
/// <summary>
/// 根据 id 获取一个笑话。
/// </summary>
[Get ("/joke/{id}")]
Task<Joke> GetJokeByIdAsync (
string id,
CancellationToken cancellationToken = default);
/// <summary>
/// 随机获取一个笑话。
/// </summary>
[Get ("/random/joke")]
Task<JokeSearchResponse> GetRandomJokeAsync (
CancellationToken cancellationToken = default);
}
Refit 根据Refit.HttpMethodAttribute提供的信息生成实现IDadJokesApiClient接口的类型。
消费 API 客户端:Refit
该方法与平常的HttpClient集成方法相同,但我们不是手动构建一个客户端,而是使用 Refit 提供的静态方法。
public static class DadJokesApiClientFactory
{
public static IDadJokesApiClient Create (
HttpClient httpClient,
string host,
string apiKey)
{
httpClient.BaseAddress = new Uri (host);
ConfigureHttpClient (httpClient, host, apiKey);
return RestService.For<IDadJokesApiClient>(httpClient);
}
// ...
}
对于 DI 容器场景,我们可以使用Refit.HttpClientFactoryExtensions.AddRefitClient扩展方法。
public static class ServiceCollectionExtensions
{
public static IHttpClientBuilder AddDadJokesApiClient (
this IServiceCollection services,
Action<HttpClient> configureClient)
{
var settings = new RefitSettings ()
{
ContentSerializer = new SystemTextJsonContentSerializer (new JsonSerializerOptions ()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true,
})
};
return services.AddRefitClient<IDadJokesApiClient>(settings) .ConfigureHttpClient ((httpClient) =>
{
DadJokesApiClientFactory.ConfigureHttpClient (httpClient);
configureClient (httpClient);
});
}
}
用法如下:
var builder = WebApplication.CreateBuilder (args);
var configuration = builder.Configuration;
Log.Logger = new LoggerConfiguration () .WriteTo.Console () .CreateBootstrapLogger ();
builder.Host.UseSerilog ((ctx, cfg) => cfg.WriteTo.Console ());
var services = builder.Services;
services.AddDadJokesApiClient (httpClient =>
{
var host = configuration["DadJokesClient:host"];
httpClient.BaseAddress = new(host);
httpClient.AddDadJokesHeaders (host, configuration["DADJOKES_TOKEN"]);
});
var app = builder.Build ();
app.MapGet ("/", async Task<Joke> (IDadJokesApiClient client) =>
{
var jokeResponse = await client.GetRandomJokeAsync ();
return jokeResponse.Body.First (); // unwraps JokeSearchResponse
});
app.Run ();
注意,由于生成的客户端其契约应该与底层数据契约相匹配,所以我们不再控制契约的转换,这项职责被托付给了消费者。让我们看看上述代码在实践中是如何工作的。MinimalAPI 示例的输出有所不同,因为我加入了 Serilog 日志。

{
"punchline": "Forgery.",
"setup": "Why was the blacksmith charged with?",
"type": "forgery"
}
同样,这种方法也有其优缺点:
优点:
-
便于使用和开发 API 客户端。
-
高度可配置。可以非常灵活地把事情做好。
-
不需要额外的单元测试。
缺点:
-
故障排查困难。有时候很难理解生成的代码是如何工作的。例如,在配置上存在不匹配。
-
需要团队其他成员了解如何阅读和编写使用 Refit 开发的代码。
-
对于中/大型 API 来说,仍然有一些时间消耗。感兴趣的读者还可以了解下 RestEase。
使用自动化方法编写 HTTP 客户端 SDK
有一种方法可以完全自动地生成 HTTP 客户端 SDK。OpenAPI/Swagger 规范使用 JSON 和 JSON Schema 来描述 RESTful Web API。NSwag 项目提供的工具可以从这些 OpenAPI 规范生成客户端代码。所有东西都可以通过 CLI(通过 NuGet 工具、构建目标或 NPM 分发)自动化。
Dad Jokes API 不提供 OpenAPI,所以我手动编写了一个。幸运的是,这很容易:
openapi: '3.0.2'
info:
title: Dad Jokes API
version: '1.0'
servers:
- url: https://dad-jokes.p.rapidapi.com
paths:
/joke/{id}:
get:
description: ''
operationId: 'GetJokeById'
parameters:
- name: "id"
in: "path"
description: ""
required: true
schema:
type: "string"
responses:
'200':
description: successful operation
content:
application/json:
schema:
"$ref": "#/components/schemas/Joke"
/random/joke:
get:
description: ''
operationId: 'GetRandomJoke'
parameters: []
responses:
'200':
description: successful operation
content:
application/json:
schema:
"$ref": "#/components/schemas/JokeResponse"
/joke/search:
get:
description: ''
operationId: 'SearchJoke'
parameters: []
responses:
'200':
description: successful operation
content:
application/json:
schema:
"$ref": "#/components/schemas/JokeResponse"
components:
schemas:
Joke:
type: object
required:
- _id
- punchline
- setup
- type
properties:
_id:
type: string
type:
type: string
setup:
type: string
punchline:
type: string
JokeResponse:
type: object
properties:
sucess:
type: boolean
body:
type: array
items:
$ref: '#/components/schemas/Joke'
现在,我们希望自动生成 HTTP 客户端 SDK。让我们借助 NSwagStudio。生成的IDadJokesApiClient 类似下面这样(简洁起见,删除了 XML 注释):

[System.CodeDom.Compiler.GeneratedCode ("NSwag", "13.10.9.0 (NJsonSchema v10.4.1.0 (Newtonsoft.Json v12.0.0.0))")]
public partial interface IDadJokesApiClient
{
System.Threading.Tasks.Task<Joke> GetJokeByIdAsync (string id);
System.Threading.Tasks.Task<Joke> GetJokeByIdAsync (string id, System.Threading.CancellationToken cancellationToken);
System.Threading.Tasks.Task<JokeResponse> GetRandomJokeAsync ();
System.Threading.Tasks.Task<JokeResponse> GetRandomJokeAsync (System.Threading.CancellationToken cancellationToken);
System.Threading.Tasks.Task<JokeResponse> SearchJokeAsync ();
System.Threading.Tasks.Task<JokeResponse> SearchJokeAsync (System.Threading.CancellationToken cancellationToken);
}
同样,我们希望把类型化客户端的注册作为一个扩展方法来提供。
public static class ServiceCollectionExtensions
{
public static IHttpClientBuilder AddDadJokesApiClient (
this IServiceCollection services, Action<HttpClient> configureClient) =>
services.AddHttpClient<IDadJokesApiClient, DadJokesApiClient>(
httpClient => configureClient (httpClient));
}
用法如下:
var builder = WebApplication.CreateBuilder (args);
var configuration = builder.Configuration;
var services = builder.Services;
services.AddDadJokesApiClient (httpClient =>
{
var host = configuration["DadJokesClient:host"];
httpClient.BaseAddress = new(host);
httpClient.AddDadJokesHeaders (host, configuration["DADJOKES_TOKEN"]);
});
var app = builder.Build ();
app.MapGet ("/", async Task<Joke> (IDadJokesApiClient client) =>
{
var jokeResponse = await client.GetRandomJokeAsync ();
return jokeResponse.Body.First ();
});
app.Run ();
让我们运行它,并欣赏本文最后一个笑话:
{
"punchline": "And it's really taken off,"
"setup": "So I invested in a hot air balloon company...",
"type": "air"
}
优点:
-
基于众所周知的规范。
-
有丰富的工具和活跃的社区支持。
-
完全自动化,新 SDK 可以作为 CI/CD 流程的一部分在每次 OpenAPI 规范有变化时生成。
-
可以生成多种语言的 SDK。
-
由于可以看到工具链生成的代码,所以相对来说比较容易排除故障。
缺点:
-
如果不符合 OpenAPI 规范就无法使用。
-
难以定制和控制生成的 API 客户端的契约。感兴趣的读者还可以了解下 AutoRest、Visual Studio Connected Services。
选择合适的方法
在这篇文章中,我们学习了三种不同的构建 SDK 客户端的方法。简单来说,可以遵循以下规则选用正确的方法:
我是一个简单的人。我希望完全控制我的 HTTP 客户端集成。使用手动方法。
我是个大忙人,但我仍然希望有部分控制权。使用声明式方法。
我是个懒人。最好能帮我做。使用自动化方法。
决策图如下:

总结
在这篇文章中,我们回顾了开发 HTTP 客户端 SDK 的不同方式。请根据具体的用例和需求选择正确的方法,希望这篇文章能让你有一个大概的了解,使你在设计客户端 SDK 时能做出最好的设计决策。感谢阅读。
作者简介:
Oleksii Nikiforov 是 EPAM Systems 的高级软件工程师和团队负责人。他拥有应用数学学士学位和信息技术硕士学位,从事软件开发已有 6 年多,热衷于 .NET、分布式系统和生产效率,是N+1 博客的作者。
原文链接:Creating and Using HTTP Client SDKs in .NET 6