close

作者 | Oleksii Nikiforov
譯者 | 平川
策劃 | 丁曉昀

如今,基於雲、微服務或物聯網的應用程序通常依賴於通過網絡與其他系統通信。每個服務都在自己的進程中運行,並解決一組有限的問題。服務之間的通信是基於一種輕量級的機制,通常是一個 HTTP 資源 API。

從.NET 開發人員的角度來看,我們希望以可分發包的形式提供一種一致的、可管理的方式來集成特定的服務。最好的方法是將我們開發的服務集成代碼以 NuGet 包的形式提供,並與其他人、團隊、甚至組織分享。在這篇文章中,我將分享在.NET 6 中創建和使用 HTTP 客戶端 SDK 的方方面面。

客戶端 SDK 在遠程服務之上提供了一個有意義的抽象層。本質上,它允許進行遠程過程調用(RPC)。客戶端 SDK 的職責是序列化一些數據,將其發送到遠端目的地,以及反序列化接收到的數據,並處理響應。

HTTP 客戶端 SDK 與 API 一同使用:

加速 API 集成過程;

提供一致、標準的方法;

讓服務所有者可以部分地控制消費 API 的方式。

1 編寫一個 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是如何發揮作用的。

2 消費 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"}
3 擴展 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.Add HttpMessageHandler輕鬆插入HeaderPropagationMessageHandler。

publicstaticclassHeaderPropagationExtensions{ 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 調用的典型錯誤:HttpRequestExceptionHTTP 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 微服務架構電子書:實現彈性應用程序。(https://docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/)

OAuth2/OIDC 中的身份認證:如果你需要管理用戶和客戶端訪問令牌,我建議使用 IdentityModel.AspNetCore。它可以幫你獲取、緩存和輪換令牌,詳情參見文檔。(https://identitymodel.readthedocs.io/en/latest/aspnetcore/overview.html)

// 添加用戶和客戶端訪問令牌管理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();
4 測試 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。這是一個繁瑣的、容易出錯的過程。

5 使用聲明式方法編寫 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。

6 使用自動化方法編寫 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。

7 選擇合適的方法

在這篇文章中,我們學習了三種不同的構建 SDK 客戶端的方法。簡單來說,可以遵循以下規則選用正確的方法:

我是一個簡單的人。我希望完全控制我的 HTTP 客戶端集成。使用手動方法。
我是個大忙人,但我仍然希望有部分控制權。使用聲明式方法。
我是個懶人。最好能幫我做。使用自動化方法。

決策圖如下:

8 總結

在這篇文章中,我們回顧了開發 HTTP 客戶端 SDK 的不同方式。請根據具體的用例和需求選擇正確的方法,希望這篇文章能讓你有一個大概的了解,使你在設計客戶端 SDK 時能做出最好的設計決策。感謝閱讀。

作者簡介:

Oleksii Nikiforov 是 EPAM Systems 的高級軟件工程師和團隊負責人。他擁有應用數學學士學位和信息技術碩士學位,從事軟件開發已有 6 年多,熱衷於.NET、分布式系統和生產效率,是 N+1 博客的作者。

原文鏈接:

https://www.infoq.com/articles/creating-http-sdks-dotnet-6/

點擊底部閱讀原文訪問 InfoQ 官網,獲取更多精彩內容!

今日好文推薦

我放棄了年薪200萬的崗位,因為「複製粘貼」的技術活讓人厭惡

作業幫基於 StarRocks 畫像系統的設計及優化實踐

戰火之下,烏克蘭開發者還在提交代碼

曾經是「殺手級」桌面語言,Java桌面開發為何走向衰落?

點個在看少個 bug👇

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()