如今,基于云、微服务或物联网的应用程序通常依赖于通过网络与其他系统通信。每个服务都在自己的进程中运行,并解决一组有限的问题。服务之间的通信是基于一种轻量级的机制,通常是一个 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.cspublic 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.compaths: /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博客的作者。