在.NET 6 中如何创建和使用 HTTP 客户端 SDK
如今,基于云、微服務(wù)或物聯(lián)網(wǎng)的應(yīng)用程序通常依賴于通過(guò)網(wǎng)絡(luò)與其他系統(tǒng)通信。每個(gè)服務(wù)都在自己的進(jìn)程中運(yùn)行,并解決一組有限的問(wèn)題。服務(wù)之間的通信是基于一種輕量級(jí)的機(jī)制,通常是一個(gè) HTTP 資源 API。
從.NET 開(kāi)發(fā)人員的角度來(lái)看,我們希望以可分發(fā)包的形式提供一種一致的、可管理的方式來(lái)集成特定的服務(wù)。最好的方法是將我們開(kāi)發(fā)的服務(wù)集成代碼以 NuGet 包的形式提供,并與其他人、團(tuán)隊(duì)、甚至組織分享。在這篇文章中,我將分享在.NET 6 中創(chuàng)建和使用 HTTP 客戶端 SDK 的方方面面。
客戶端 SDK 在遠(yuǎn)程服務(wù)之上提供了一個(gè)有意義的抽象層。本質(zhì)上,它允許進(jìn)行遠(yuǎn)程過(guò)程調(diào)用(RPC)。客戶端 SDK 的職責(zé)是序列化一些數(shù)據(jù),將其發(fā)送到遠(yuǎn)端目的地,以及反序列化接收到的數(shù)據(jù),并處理響應(yīng)。
HTTP 客戶端 SDK 與 API 一同使用:
加速 API 集成過(guò)程;
提供一致、標(biāo)準(zhǔn)的方法;
讓服務(wù)所有者可以部分地控制消費(fèi) API 的方式。
編寫一個(gè) HTTP 客戶端 SDK
在本文中,我們將編寫一個(gè)完備的Dad Jokes API客戶端,為的是提供老爸笑話;讓我們來(lái)玩一玩。源代碼在GitHub上。
在開(kāi)發(fā)與 API 一起使用的客戶端 SDK 時(shí),最好從接口契約(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!;}復(fù)制代碼
契約是基于你要集成的 API 創(chuàng)建的。我一般建議遵循健壯性原則和最小驚奇原則開(kāi)發(fā)通用的 API。但如果你想根據(jù)自己的需要修改和轉(zhuǎn)換數(shù)據(jù)契約,也是完全可以的,只需從消費(fèi)者的角度考慮即可。HttpClient 是基于 HTTP 進(jìn)行集成的基礎(chǔ)。它包含你處理HTTP抽象時(shí)所需要的一切東西。
public class DadJokesApiClient : IDadJokesApiClient{ private readonly HttpClient httpClient;public DadJokesApiClient(HttpClient httpClient) => this.httpClient = httpClient;}復(fù)制代碼
通常,HTTP API 會(huì)使用 JSON,這就是為什么從.NET 5 開(kāi)始,BCL 增加了System.Net.Http.Json命名空間。它為HttpClient和HttpContent提供了許多擴(kuò)展方法,讓我們可以使用System.Text.Json進(jìn)行序列化和反序列化。如果沒(méi)有什么復(fù)雜的特殊需求,我建議你使用System.Net.Http.Json,因?yàn)樗茏屇忝庥诰帉懩0宕a。那不僅很枯燥,而且也很難保證高效、沒(méi)有 Bug。我建議你讀下 Steves Gordon 的博文“使用HttpClient發(fā)送和接收J(rèn)SON”:
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 }) { // 對(duì)于這種情況,考慮創(chuàng)建自定義的異常 throw new InvalidOperationException("This API is no joke."); }return jokes.Body.First();}復(fù)制代碼
小提示:你可以創(chuàng)建一些集中式的地方來(lái)管理端點(diǎn) URL,像下面這樣:
public static class ApiUrlConstants{ public const string JokeSearch = "/joke/search";public const string GetJokeById = "/joke";public const string GetRandomJoke = "/random/joke";}復(fù)制代碼
小提示:如果你需要處理復(fù)雜的 URI,請(qǐng)使用Flurl。它提供了流暢的 URL 構(gòu)建(URL-building)體驗(yàn):
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();}復(fù)制代碼
接下來(lái),我們必須指定所需的頭文件(和其他所需的配置)。我們希望提供一種靈活的機(jī)制來(lái)配置作為 SDK 組成部分的HttpClient。在這種情況下,我們需要在自定義頭中提供證書,并指定一個(gè)眾所周知的“Accept”。小提示:將高層的構(gòu)建塊暴露為HttpClientExtensions。這更便于發(fā)現(xiàn)特定于 API 的配置。例如,如果你有一個(gè)自定義的授權(quán)機(jī)制,則 SDK 應(yīng)提供支持(至少要提供相關(guān)的文檔)。
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; }}復(fù)制代碼
客戶端生命周期
為了構(gòu)建DadJokesApiClient,我們需要?jiǎng)?chuàng)建一個(gè)HttpClient。如你所知,HttpClient實(shí)現(xiàn)了IDisposable,因?yàn)樗幸粋€(gè)非托管的底層資源——TCP 連接。在一臺(tái)機(jī)器上同時(shí)打開(kāi)的并發(fā) TCP 連接數(shù)量是有限的。這種考慮也帶來(lái)了一個(gè)重要的問(wèn)題——“我應(yīng)該在每次需要時(shí)創(chuàng)建HttpClient,還是只在應(yīng)用程序啟動(dòng)時(shí)創(chuàng)建一次?”
HttpClient是一個(gè)共享對(duì)象。這就意味著,在底層,它是可重入和線程安全的。與其每次執(zhí)行時(shí)新建一個(gè)HttpClient實(shí)例,不如共享一個(gè)HttpClient實(shí)例。然而,這種方法也有一系列的問(wèn)題。例如,客戶端在應(yīng)用程序的生命周期內(nèi)會(huì)保持連接打開(kāi),它不會(huì)遵守DNS TTL設(shè)置,而且它將永遠(yuǎn)無(wú)法收到 DNS 更新。所以這也不是一個(gè)完美的解決方案。
你需要管理一個(gè)不定時(shí)銷毀連接的 TCP 連接池,以獲取 DNS 更新。這正是HttpClientFactory所做的。官方文檔將HttpClientFactory描述為“一個(gè)專門用于創(chuàng)建可在應(yīng)用程序中使用的HttpClient實(shí)例的工廠”。我們稍后將介紹如何使用它。
每次從IHttpClientFactory獲取一個(gè)HttpClient對(duì)象時(shí),都會(huì)返回一個(gè)新的實(shí)例。但是,每個(gè)HttpClient都使用一個(gè)被IHttpClientFactory池化并重用的 HttpMessageHandler,減少了資源消耗。處理程序的池化是值得的,因?yàn)橥ǔC總€(gè)處理程序都要管理其底層的 HTTP 連接。有些處理程序還會(huì)無(wú)限期地保持連接開(kāi)放,防止處理程序?qū)?DNS 的變化做出反應(yīng)。HttpMessageHandler有一個(gè)有限的生命周期。
下面,我們看下在使用由依賴注入(DI)管理的HttpClient時(shí),HttpClientFactory是如何發(fā)揮作用的。
消費(fèi) API 客戶端
在我們的例子中,消費(fèi) API 的一個(gè)基本場(chǎng)景是無(wú)依賴注入容器的控制臺(tái)應(yīng)用程序。這里的目標(biāo)是讓消費(fèi)者以最快的方式來(lái)訪問(wèn)已有的 API。
創(chuàng)建一個(gè)靜態(tài)工廠方法來(lái)創(chuàng)建一個(gè) 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")); }}復(fù)制代碼
這樣,我們可以從控制臺(tái)應(yīng)用程序使用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}");消費(fèi) API 客戶端:HttpClientFactory
下一步是將HttpClient配置為依賴注入容器的一部分。關(guān)于這一點(diǎn),網(wǎng)上有很多不錯(cuò)的內(nèi)容,我就不做詳細(xì)討論了。Steve Gordon 也有一篇非常好的文章“ASP.NET Core中的HttpClientFactory”。
為了使用 DI 添加一個(gè)池化的HttpClient實(shí)例,你需要使用來(lái)自Microsoft.Extensions.Http的IServiceCollection.AddHttpClient。
提供一個(gè)自定義的擴(kuò)展方法用于在 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); });}復(fù)制代碼
使用擴(kuò)展方法的方式如下:
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}");復(fù)制代碼
如你所見(jiàn),IHttpClientFactory 可以在 ASP.NET Core 之外使用。例如,控制臺(tái)應(yīng)用程序、worker、lambdas 等。讓我們看下它運(yùn)行:
有趣的是,由 DI 創(chuàng)建的客戶端會(huì)自動(dòng)記錄發(fā)出的請(qǐng)求,使得開(kāi)發(fā)和故障排除都變得非常容易。
如果你操作日志模板的格式并添加SourceContext和EventId,就會(huì)看到HttpClientFactory自己添加了額外的處理程序。當(dāng)你試圖排查與 HTTP 請(qǐng)求處理有關(guān)的問(wèn)題時(shí),這很有用。
{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" }]復(fù)制代碼
最常見(jiàn)的場(chǎng)景是 Web 應(yīng)用程序。下面是.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"}復(fù)制代碼
擴(kuò)展 HTTP 客戶端 SDK,通過(guò) DelegatingHandler 添加橫切關(guān)注點(diǎn)
HttpClient還提供了一個(gè)擴(kuò)展點(diǎn):一個(gè)消息處理程序。它是一個(gè)接收 HTTP 請(qǐng)求并返回 HTTP 響應(yīng)的類。有許多問(wèn)題都可以表示為橫切關(guān)注點(diǎn)。例如,日志、身份認(rèn)證、緩存、頭信息轉(zhuǎn)發(fā)、審計(jì)等等。面向方面的編程旨在將橫切關(guān)注點(diǎn)封裝成方面,以保持模塊化。通常情況下,一系列的消息處理程序被鏈接在一起。第一個(gè)處理程序接收一個(gè) HTTP 請(qǐng)求,做一些處理,然后將請(qǐng)求交給下一個(gè)處理程序。有時(shí)候,響應(yīng)創(chuàng)建后會(huì)回到鏈條上游。
// 支持大部分應(yīng)用程序最常見(jiàn)的需求public abstract class HttpMessageHandler : IDisposable{}// 將一個(gè)處理程序加入到處理程序鏈public abstract class DelegatingHandler : HttpMessageHandler{}任務(wù):假如你需要從 ASP.NET Core 的HttpContext復(fù)制一系列頭信息,并將它們傳遞給 Dad Jokes API 客戶端發(fā)出的所有外發(fā)請(qǐng)求。
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>();}復(fù)制代碼
我們想把一個(gè)DelegatingHandler“插入”到HttpClient請(qǐng)求管道中。對(duì)于非IttpClientFactory場(chǎng)景,我們希望客戶端能夠指定一個(gè)DelegatingHandler列表來(lái)為HttpClient建立一個(gè)底層鏈。
//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);}復(fù)制代碼
這樣,在沒(méi)有 DI 容器的情況下,可以像下面這樣擴(kuò)展?DadJokesApiClient :
var loggingHandler = new LoggingMessageHandler(); //最外層var authHandler = new AuthMessageHandler();var propagationHandler = new HeaderPropagationMessageHandler();var primaryHandler = new HttpClientHandler(); // HttpClient使用的默認(rèn)處理程序 DadJokesApiClientFactory.Create( host, apiKey, loggingHandler, authHandler, propagationHandler, primaryHandler); // LoggingMessageHandler ? AuthMessageHandler ? HeaderPropagationMessageHandler ? HttpClientHandler復(fù)制代碼
另一方面,在 DI 容器場(chǎng)景中,我們希望提供一個(gè)輔助的擴(kuò)展方法,使用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; }}復(fù)制代碼
擴(kuò)展后的 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();復(fù)制代碼
有時(shí),像這樣的功能會(huì)被其他服務(wù)所重用。你可能想更進(jìn)一步,把所有共享的代碼都提取到一個(gè)公共的 NuGet 包中,并在 HTTP 客戶端 SDK 中使用它。
第三方擴(kuò)展
我們可以編寫自己的消息處理程序,但.NET OSS 社區(qū)也提供了許多有用的 NuGet 包。以下是我最喜歡的。
彈性模式——重試、緩存、回退等:很多時(shí)候,在一個(gè)系統(tǒng)不可靠的世界里,你需要通過(guò)加入一些彈性策略來(lái)確保高可用性。幸運(yùn)的是,我們有一個(gè)內(nèi)置的解決方案,可以在.NET 中構(gòu)建和定義策略,那就是Polly。Polly 提供了與IHttpClientFactory開(kāi)箱即用的集成。它使用了一個(gè)便捷的方法IHttpClientBuilder.AddTransientHttpErrorPolicy。它配置了一個(gè)策略來(lái)處理 HTTP 調(diào)用的典型錯(cuò)誤:HttpRequestException HTTP 5XX 狀態(tài)碼(服務(wù)器錯(cuò)誤)、HTTP 408 狀態(tài)碼(請(qǐng)求超時(shí))。
services.AddDadJokesApiClient(httpClient =>{ httpClient.BaseAddress = new(host);}).AddTransientHttpErrorPolicy(builder => builder.WaitAndRetryAsync(new[]{ TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)}));復(fù)制代碼
例如,可以使用重試和斷路器模式主動(dòng)處理瞬時(shí)錯(cuò)誤。通常,當(dāng)下游服務(wù)有望自我糾正時(shí),我們會(huì)使用重試模式。重試之間的等待時(shí)間對(duì)于下游服務(wù)而言是一個(gè)恢復(fù)穩(wěn)定的窗口。重試經(jīng)常使用指數(shù)退避算法。這紙面上聽(tīng)起來(lái)不錯(cuò),但在現(xiàn)實(shí)世界的場(chǎng)景中,重試模式的使用可能過(guò)度了。額外的重試可能導(dǎo)致額外的負(fù)載或峰值。在最壞的情況下,調(diào)用者的資源可能會(huì)被耗盡或過(guò)分阻塞,等待永遠(yuǎn)不會(huì)到來(lái)的回復(fù),導(dǎo)致上游發(fā)生了級(jí)聯(lián)故障。這就是斷路器模式發(fā)揮作用的時(shí)候了。它檢測(cè)故障等級(jí),并在故障超過(guò)閾值時(shí)阻止對(duì)下游服務(wù)的調(diào)用。如果沒(méi)有成功的機(jī)會(huì),就可以使用這種模式,例如,當(dāng)一個(gè)子系統(tǒng)完全離線或不堪重負(fù)時(shí)。斷路器的理念非常簡(jiǎn)單,雖然你可能會(huì)以它為基礎(chǔ)構(gòu)建一些更復(fù)雜的東西。當(dāng)故障超過(guò)閾值時(shí),調(diào)用就會(huì)斷開(kāi),因此,我們不是處理請(qǐng)求,而是實(shí)踐快速失敗的方法,立即拋出一個(gè)異常。
Polly 真的很強(qiáng)大,它提供了一種組合彈性策略的方法,見(jiàn)PolicyWrap。
下面是一個(gè)可能對(duì)你有用的策略分類:
設(shè)計(jì)可靠的系統(tǒng)可能是一項(xiàng)非常具有挑戰(zhàn)性的任務(wù),我建議你自己研究下這個(gè)問(wèn)題。這里有一個(gè)很好的介紹——.NET微服務(wù)架構(gòu)電子書:實(shí)現(xiàn)彈性應(yīng)用程序。
OAuth2/OIDC 中的身份認(rèn)證:如果你需要管理用戶和客戶端訪問(wèn)令牌,我建議使用IdentityModel.AspNetCore。它可以幫你獲取、緩存和輪換令牌,詳情參見(jiàn)文檔。
// 添加用戶和客戶端訪問(wèn)令牌管理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" });});// 使用托管的客戶端訪問(wèn)令牌注冊(cè)HTTP客戶端// 向HTTP客戶端注冊(cè)添加令牌訪問(wèn)處理程序services.AddDadJokesApiClient(httpClient =>{ httpClient.BaseAddress = new(host);}).AddClientAccessTokenHandler();復(fù)制代碼
測(cè)試 HTTP 客戶端 SDK
至此,對(duì)于設(shè)計(jì)和編寫 HTTP 客戶端 SDK,你應(yīng)該已經(jīng)比較熟悉了。剩下的工作就只是寫一些測(cè)試來(lái)確保其行為符合預(yù)期了。請(qǐng)注意,跳過(guò)廣泛的單元測(cè)試,編寫更多的集成或 e2e 來(lái)確保集成的正確性,或許也不錯(cuò)。現(xiàn)在,我將展示如何對(duì)DadJokesApiClient進(jìn)行單元測(cè)試。
如前所述,HttpClient是可擴(kuò)展的。此外,我們可以用測(cè)試版本代替標(biāo)準(zhǔn)的HttpMessageHandler。這樣,我們就可以使用模擬服務(wù),而不是通過(guò)網(wǎng)絡(luò)發(fā)送實(shí)際的請(qǐng)求。這種技術(shù)提供了大量的可能,因?yàn)槲覀兛梢阅M各種在正常情況下是很難復(fù)現(xiàn)的HttpClient行為。
我們定義一個(gè)可重用的方法,用于創(chuàng)建一個(gè) HttpClient 模擬,并作為一個(gè)依賴項(xiàng)傳遞給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; }}復(fù)制代碼
從這點(diǎn)來(lái)看,單元測(cè)試是個(gè)非常簡(jiǎn)單的過(guò)程:
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 的集成。但是,也有一個(gè)缺點(diǎn),你需要編寫大量的樣板代碼。在某些情況下,你要集成的 API 并不重要,所以你并不需要HttpClient、HttpRequestMessage、HttpResponseMessage所提供的所有功能。優(yōu)點(diǎn)?:
- 可以完全控制行為和數(shù)據(jù)契約。你甚至可以編寫一個(gè)“智能”API 客戶端,如果有需要的話,在特殊情況下,你可以把一些邏輯移到 SDK 里。例如,你可以拋出自定義的異常,轉(zhuǎn)換請(qǐng)求和響應(yīng),提供默認(rèn)頭信息,等等。 
- 可以完全控制序列化和反序列化過(guò)程。 
- 易于調(diào)試和排查問(wèn)題。堆棧容易跟蹤,你可以隨時(shí)啟動(dòng)調(diào)試器,看看后臺(tái)正在發(fā)生的事情。缺點(diǎn)?: 
- 需要編寫大量的重復(fù)代碼。 
- 需要有人維護(hù)代碼庫(kù),以防 API 有變化和 Bug。這是一個(gè)繁瑣的、容易出錯(cuò)的過(guò)程。 
使用聲明式方法編寫 HTTP 客戶端 SDK
代碼越少,Bug 越少。Refit是一個(gè)用于.NET 的、自動(dòng)化的、類型安全的 REST 庫(kù)。它將 REST API 變成一個(gè)隨時(shí)可用的接口。Refit 默認(rèn)使用System.Text.Json作為 JSON 序列化器。
每個(gè)方法都必須有一個(gè) HTTP 屬性,提供請(qǐng)求方法和相對(duì)應(yīng)的 URL。
using Refit; public interface IDadJokesApiClient{ /// <summary> /// 根據(jù)詞語(yǔ)搜索笑話。 /// </summary> [Get("/joke/search")] Task<JokeSearchResponse> SearchAsync( string term, CancellationToken cancellationToken = default);/// <summary> /// 根據(jù)id獲取一個(gè)笑話。 /// </summary> [Get("/joke/{id}")] Task<Joke> GetJokeByIdAsync( string id, CancellationToken cancellationToken = default);/// <summary> /// 隨機(jī)獲取一個(gè)笑話。 /// </summary> [Get("/random/joke")] Task<JokeSearchResponse> GetRandomJokeAsync( CancellationToken cancellationToken = default);}復(fù)制代碼
Refit 根據(jù)Refit.HttpMethodAttribute提供的信息生成實(shí)現(xiàn)IDadJokesApiClient接口的類型。
消費(fèi) API 客戶端:Refit
該方法與平常的HttpClient集成方法相同,但我們不是手動(dòng)構(gòu)建一個(gè)客戶端,而是使用 Refit 提供的靜態(tài)方法。
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); } // ...}對(duì)于 DI 容器場(chǎng)景,我們可以使用Refit.HttpClientFactoryExtensions.AddRefitClient擴(kuò)展方法。
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();注意,由于生成的客戶端其契約應(yīng)該與底層數(shù)據(jù)契約相匹配,所以我們不再控制契約的轉(zhuǎn)換,這項(xiàng)職責(zé)被托付給了消費(fèi)者。讓我們看看上述代碼在實(shí)踐中是如何工作的。MinimalAPI 示例的輸出有所不同,因?yàn)槲壹尤肓?Serilog 日志。
{ "punchline": "Forgery.", "setup": "Why was the blacksmith charged with?", "type": "forgery"}復(fù)制代碼
同樣,這種方法也有其優(yōu)缺點(diǎn):優(yōu)點(diǎn)?:
- 便于使用和開(kāi)發(fā) API 客戶端。 
- 高度可配置。可以非常靈活地把事情做好。 
- 不需要額外的單元測(cè)試。缺點(diǎn)?: 
- 故障排查困難。有時(shí)候很難理解生成的代碼是如何工作的。例如,在配置上存在不匹配。 
- 需要團(tuán)隊(duì)其他成員了解如何閱讀和編寫使用 Refit 開(kāi)發(fā)的代碼。 
- 對(duì)于中/大型 API 來(lái)說(shuō),仍然有一些時(shí)間消耗。感興趣的讀者還可以了解下RestEase。 
使用自動(dòng)化方法編寫 HTTP 客戶端 SDK
有一種方法可以完全自動(dòng)地生成 HTTP 客戶端 SDK。OpenAPI/Swagger 規(guī)范使用 JSON 和 JSON Schema 來(lái)描述 RESTful Web API。NSwag項(xiàng)目提供的工具可以從這些 OpenAPI 規(guī)范生成客戶端代碼。所有東西都可以通過(guò) CLI(通過(guò) NuGet 工具、構(gòu)建目標(biāo)或 NPM 分發(fā))自動(dòng)化。
Dad Jokes API 不提供 OpenAPI,所以我手動(dòng)編寫了一個(gè)。幸運(yùn)的是,這很容易:
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'復(fù)制代碼
現(xiàn)在,我們希望自動(dòng)生成 HTTP 客戶端 SDK。讓我們借助NSwagStudio。生成的
IDadJokesApiClient?類似下面這樣(簡(jiǎn)潔起見(jiàn),刪除了 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); }復(fù)制代碼
同樣,我們希望把類型化客戶端的注冊(cè)作為一個(gè)擴(kuò)展方法來(lái)提供。
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();復(fù)制代碼
讓我們運(yùn)行它,并欣賞本文最后一個(gè)笑話:
{ "punchline": "And it's really taken off," "setup": "So I invested in a hot air balloon company...", "type": "air"}復(fù)制代碼
優(yōu)點(diǎn)?:
- 基于眾所周知的規(guī)范。 
- 有豐富的工具和活躍的社區(qū)支持。 
- 完全自動(dòng)化,新 SDK 可以作為 CI/CD 流程的一部分在每次 OpenAPI 規(guī)范有變化時(shí)生成。 
- 可以生成多種語(yǔ)言的 SDK。 
- 由于可以看到工具鏈生成的代碼,所以相對(duì)來(lái)說(shuō)比較容易排除故障。缺點(diǎn)?: 
- 如果不符合 OpenAPI 規(guī)范就無(wú)法使用。 
- 難以定制和控制生成的 API 客戶端的契約。感興趣的讀者還可以了解下AutoRest、Visual Studio Connected Services。 
選擇合適的方法
在這篇文章中,我們學(xué)習(xí)了三種不同的構(gòu)建 SDK 客戶端的方法。簡(jiǎn)單來(lái)說(shuō),可以遵循以下規(guī)則選用正確的方法:
我是一個(gè)簡(jiǎn)單的人。我希望完全控制我的 HTTP 客戶端集成。使用手動(dòng)方法。
我是個(gè)大忙人,但我仍然希望有部分控制權(quán)。使用聲明式方法。
我是個(gè)懶人。最好能幫我做。使用自動(dòng)化方法。
決策圖如下:
總結(jié)
在這篇文章中,我們回顧了開(kāi)發(fā) HTTP 客戶端 SDK 的不同方式。請(qǐng)根據(jù)具體的用例和需求選擇正確的方法,希望這篇文章能讓你有一個(gè)大概的了解,使你在設(shè)計(jì)客戶端 SDK 時(shí)能做出最好的設(shè)計(jì)決策。感謝閱讀。
作者簡(jiǎn)介:
Oleksii Nikiforov 是 EPAM Systems 的高級(jí)軟件工程師和團(tuán)隊(duì)負(fù)責(zé)人。他擁有應(yīng)用數(shù)學(xué)學(xué)士學(xué)位和信息技術(shù)碩士學(xué)位,從事軟件開(kāi)發(fā)已有 6 年多,熱衷于.NET、分布式系統(tǒng)和生產(chǎn)效率,是N+1博客的作者。
總結(jié)
以上是生活随笔為你收集整理的在.NET 6 中如何创建和使用 HTTP 客户端 SDK的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
 
                            
                        - 上一篇: Avalonia跨平台入门第二十二篇之人
- 下一篇: 3月更新来了!Windows 11正式版
