BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル .NET 6によるHTTP Client SDKの作成と利用

.NET 6によるHTTP Client SDKの作成と利用

キーポイント

  • Writing and maintaining HTTP Client SDKs is a very important skill for modern .NET developers working with distributed systems.
  • In order to properly manage HTTP connections, you need to design your API Clients to be ready to be consumed from any Dependency Injection container.
  • A good client SDK is composable, providing straightforward ways to configure and extend it.
  • Testing HTTP Client SDKs can be very beneficial in certain scenarios and it gives you additional confidence in your code.
  • There are many ways of developing HTTP Client SDKs. This article helps you to choose the right one according to your scenario.

原文(投稿日:2022/02/07)へのリンク

 

今日のクラウドベース、マイクロサービスベース、あるいはIoT(Internet of Things)アプリケーションは、ネットワークを越えた他システムとの通信に依存する場合が少なくありません。こうしたシステムでは、各サービスが自身のプロセス内で動作して、特定の問題群の解決に当たります。サービス間の通信はライトウェイトなメカニズム、多くの場合はHTTPリソースAPIに基づいて行われます。 

.NET開発者という観点からは、特定のサービスと統合するための一貫性と管理性を持った手段を、配布可能なパッケージの形式で提供することが望まれます。さらに開発したサービス統合コードをNuGetパッケージとして提供して、他の人々やチーム、さらには他の組織とも共有することができれば、それに越したことはありません。今回の記事では、.NET 6を使ったHTTP Client SDKの開発と利用について、さまざまな観点からお伝えしたいと思います。

Client SDKは、リモートサービス上に有意義な抽象化層を提供します。その基本は、リモートプロシージャコール(RPC)を実現することです。データをシリアライズしてリモートの宛先に送信すること、到着したデータをデシリアライズして応答を処理すること、この2つがClient SDKの役割です。

HTTP Client SDKは、APIと組み合わせて、次のような目的で使用されます。

  1. API統合プロセスのスピードアップ
  2. 一貫性のある標準的アプローチの提供
  3. APIがコンシュームされる方法に関するコントロールの一部をサービスオーナに提供

HTTP Client SDKの開発

今回の記事では、本格的なDad Jokes API Clientを開発します。サービスの目的は"dad jokes(おやじギャグ)"を提供することです。それでは始めましょう。ソースコードはGitHubから入手可能です。

APIで使用するClient 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!;
}

コントラクトは統合するAPに基いて作成されます。一般論として私は、汎用的なAPIを開発することと、ロバストネス原則(Robustness Principle)驚き最小の原則(Principle of least astonishment)に従うことを推奨していますが、コンシューマの立場から自身のニーズを考えた上で、それに基いたデータコントラクトの修正と変換を行うのであれば、それもよいでしょう。

HTTPベースのインテグレーションにおいて、HttpClientは必要不可欠なものです。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ネームスペースが追加されたのも、そのような理由によるものです。このネームスペースでは、HttpClientHttpContentに対して、Sysrtem.Text.Jsonを使用したシリアライズとデシリアライズを行う拡張メソッドが数多く提供されています。何らかの問題やこだわりを持っているのでなければ、System.Net.Http.Jsonを使うとよいでしょう。ボイラプレートコードを書く作業から、あなたを解放してくれます。こうした作業は退屈なだけではなく、最初から最も効率的でバグのない方法で正しく作るのは容易ではありません。この件については、Steve Gordon氏のブログ記事 "sending and receiving JSON using HttpClient" が参考になるのでしょう。

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 })
	{
    	// consider creating custom exceptions for situations like this
    	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を使うとよいでしょう。フルーエント(fluent)なURL構築エクスペリエンスを提供してくれます。

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" ヘッダを指定する必要があります。

ヒント: 高レベルのビルディングブロックはHttpClientExtensionとして提供します。これによって、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は、管理対象外のリソースであるTCP接続を下位に持つことから、IDisposableを実装しています。ひとつのマシン上で同時にオープン可能なTCP接続の数には限りがあります。この考えはさらに重要な疑問をもたらします。すなわち、"HttpClientは必要時毎に生成するべきなのか、あるいはアプリケーション起動時にひとつだけにするべきか?"ということです。

HttpClientは共有オブジェクトです。これはつまり、内部的にはリエントラントでスレッドセーフである、ということです。ですから、実行毎に新たなHttpClientインスタンスを生成するのではなく、ひとつのHttpClientを共有した方がよいでしょう。ただし、このアプローチにも問題がない訳ではありません。例えば、クライアントはアプリケーションの実行中、接続をオープンしたままにするため、DNS TTLの設定が反映されず、DNSが更新されなくなります。つまりこれも、完璧なソリューションではないのです。

DNSの更新を反映するためには、TCP接続を時々廃棄するように、接続プールを管理する必要があります。これはHttpClientFactoryが行っていることとまったく同じです。公式資料にはHttpClientFactoryについて、"アプリケーションで使用するHttpClientインスタンスを生成するオプションのファクトリ"であると説明されています。その使い方を、これから見ていきましょう。

HttpClientFactoryからHttpClientオブジェクトを取得すると、毎回新たなインスタンスが返されます。しかし、それぞれのHttpClientが使用するHttpMessageHandlerを、IHttpClientFactoryでプールおよび再利用することで、リソース消費を低く抑えているのです。ハンドラは通常、それぞれが下位のHTTP接続を管理しているので、プールする必要があるのですが、接続を永続的にオープンしているハンドラもあります。このような場合は、DNSの変更に対応できません。HttpMessageHandlerでは、ライフタイムを制限しています。

下の図は、依存性注入(DI)の管理するHttpClientを使用する時、HttpClientFactoryがどのように関与しているのかを示したものです。

APIクライアントをコンシュームする

私たちの例でAPIをコンシュームする基本的な利用シナリオは、依存性注入コンテナを持たないコンソールアプリケーションです。今回の目標は、既存APIに可能な限り早くアクセスする手段をコンシューマに提供することなので、

API Clientを生成するスタティックなファクトリメソッドを用意します。

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 — HttpClientFactory in ASP.NET Coreに素晴らしい記事がたくさんあります。

DIを使ってプールされたHttpClientインスタンスを追加するには、Microsost.Extenions.HttpIServiceCollection.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}");

このように、HttpClientFactoryはASP.NET Core外のコンソールアプリケーションやワーカ、ラムダなどでも使用することができます。

では実行してみましょう。

注目されるのは、DIによって自動生成されたクライアントは、自身が出力するリクエストを自動的にログするので、開発やトラブルシュートが非常に容易になる、という点です。

ログテンプレートのフォーマットを変更してSourceContextEventIdを追加すれば、HttpClientFactoryにもハンドラが追加されていることが分かります。HTTP要求処理に関連する問題に対処する場合、これが非常に役に立ちます。

{SourceContext}[{EventId}] // pattern

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 Client SDKの拡張 — DelegatingHandlerによる横断的関心事の追加

HttpClientにも拡張ポイントとしてメッセージハンドラがあります。メッセージハンドラは、HTTP要求を受信してHTTP応答を返すクラスです。さまざまな種類の問題を総称して、"横断的関心事(cross-cutting concerns)"と呼ぶことがあります。ログ、認証、キャッシュ、ヘッダフォワード、監査といったものがこれに当たります。横断的関心事をアスペクト(aspect)にカプセル化してモジュラリティを維持する手法は、アスペクト指向プログラミング(AOP)と呼ばれています。一般的には、一連のメッセージハンドラをチェーンする方法を取ります。最初のハンドラがHTTP要求を受信し、何らかの処理を実施した後、次のハンドラに要求を渡すのです。連鎖のある時点で応答が生成されると、それがチェーンを遡ることになります。

// supports the most common requirements for most applications
public abstract class HttpMessageHandler : IDisposable
{}
// plug a handler into a handler chain
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>();
}

HttpClientリクエストのパイプラインにDelegatingHandlerを"プラグイン"できれば便利です。

IHttpClientFactory以外のシナリオであれば、HttpClientの基盤となるチェーンを構築するDelegatingHandlerリストをクライアントで特定できるとよいでしょう。

//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(); //outermost
var authHandler = new AuthMessageHandler();
var propagationHandler = new HeaderPropagationMessageHandler();
var primaryHandler = new HttpClientHandler();  // the default handler used by 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 Client SDKで使用できるようにしたい、と思うかも知れません。

サードパーティエクステンション

メッセージハンドラは自分たちで作ったものだけではありません。.NET OSSコミュニティが提供とサポートを行っている、便利なNuGetパッケージがたくさんあります。私のお気に入りを紹介しましょう。

Resiliency patterns — リトライ、キャッシュ、フォールバックなど、分散システムの世界においては、いくつかのレジリエンスポリシを取り入れることで、高可用性を保証する必要のある場面が頻繁にあります。幸運にも.NETには、ポリシの構築と定義を行うビルトインソリューションが存在します。それがPollyです。Pollyは、IHttpClientFactoyとのアウトオブボックスなインテグレーションを提供します。これが使用するのは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)
}));

例えば、一時的なエラーであれば、RetryCircuitBrakerといったパターンを使うことで、プロアクティブに処理することも可能です。一般的にリトライパターンが使用されるのは、ダウンストリームのサービスが最終的に自己修正することを期待できる場合であり、リトライ間に待ち時間を設けるのは、ダウンストリームサービスが安定化する機会を提供するためです。ここでは一般的に、指数バックオフ(Exponential Backoff)アルゴリズムに基づくリトライが使用されます。理論上はすばらしいものに思われるのですが、実世界のシナリオにおいては、リトライパターンは乱用される傾向があります。リトライが増えることは、ロードやスパイクの原因になり兼ねませんし、最悪の場合、呼び出し側のリソースの枯渇や過剰なブロック、戻ることのない応答を待つことによるアップストリームの連鎖的な障害が発生します。

このような場合に有効なのがCircuit Brakerパターンです。さまざまなレベルの障害を検出して、しきい値を超過した場合にはダウンストリームサービスの呼び出しを回避する、というものです。サブシステムが完全にオフラインになった場合や、高負荷な状況下にある場合など、成功する見込みのない時にこのパターンを使用します。 Circuit Brakerのアイデアは極めて単純ですが、それをベースとして、その上にもっと複雑な処理を構築することも可能です。障害がしきい値を越えるとサーキットに呼び出しがかかるので、要求を処理するのではなく、例外を即時スローすることによるフェイルファーストなアプローチを選択しました。

Pollyは非常にパワフルで、レジリエンスストラテジを組み合わせる方法を提供してくれます。PolicyWrapを見てください。
通常使用されるストラテジの分類は、次のようなものです。

信頼性のあるシステムの設計は大変なことなので、自身で調べてみることをお勧めします。".NET microservices — Architecture e-book: Implement resilient applications"がよい入門書になるでしょう。

OAuth2/OIDCにおける認証: ユーザとクライアントのアクセストークンを管理する必要のある場合は、IdentityModel.AspNetCoreを使用するとよいでしょう。トークンの取得とキャッシュ、ローテーションを行ってくれます。詳細は資料をご覧ください。

// adds user and client access token management
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" 
	});
});
// registers HTTP client that uses the managed client access token
// adds the access token handler to HTTP client registration
services.AddDadJokesApiClient(httpClient =>
{
	httpClient.BaseAddress = new(host);
}).AddClientAccessTokenHandler();

HTTP Client SDKのテスト

ここまで来れば、HTTP Client SDKの設計や記述には十分に慣れているでしょう。残っているのは、動作が期待どおりであることを確認するためのテストの記述です。時間を要するユニットテストを飛ばして、インテグレーションの適切さを確認するための統合テストやe2eテストに注力する、という方法も悪くはないのですが、ここではDadJokesAppClientをユニットテストする方法を紹介します。

すでに見てきたようにHttpClientには拡張性がありますし、標準のHttpMessageHandlerをテストバージョンに置き換えるという方法もあります。ですので、通信上で実際に要求を送るのではなく、モックを使用したいと思います。この方法は非常に汎用性があります。通常の状況では再現が難しい、あらゆる種類のHttpClientの動作をシミュレーション可能だからです。

まずはDadJokesApiClientに依存関係として渡すHttpClientのモックを生成する、再利用可能なメソッドを定義しましょう。

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が単純なものであれば、HttpClientHttpRequestMessageHttpResponseMessageの全ての機能を提供する必要はありません。

利点➕:

  • 動作やデータコントラクトの完全なコントロールが可能であること。対象とするシナリオに適していれば、"スマート"なAPIクライアントを記述して、SDK内部のロジックの一部を移動することも可能です。独自の例外をスローしたり、要求や応答を書き換えたり、ヘッダにデフォルト値を設定するような方法が考えられます。
  • シリアライズ、デシリアライズ処理を完全にコントロールできること。
  • デバッグやトラブルシュートが容易であること。スタックトレースは簡単ですし、いつでもデバッガを立ち上げて、内部で何が起きているかを確認することができます。

欠点➖

  • 同じコードを何度も書かなければならないこと。
  • APIの変更やバグが発生した場合、誰かがコードベースをメンテナンスしなければならないこと。このプロセスは煩雑で、エラーの可能性も高くなります。

宣言的アプローチによるHTTP Client SDKの記述

コードが少なければ、バグも少ない

Refitは、REST APIを実際のインターフェースに変換する、.NET用の自動タイプセーフRESTライブラリです。JSONシリアライザとして、System.Text.Jsonをデフォルトで使用します。

すべてのメソッドにHTTP属性を設定して、要求メソッドと関連するURLを定義する必要があります。

using Refit;

public interface IDadJokesApiClient
{
	/// <summary>
	/// Searches jokes by term.
	/// </summary>
	[Get("/joke/search")]
	Task<JokeSearchResponse> SearchAsync(
    	string term,
    	CancellationToken cancellationToken = default);

	/// <summary>
	/// Gets a joke by id.
	/// </summary>
	[Get("/joke/{id}")]
	Task<Joke> GetJokeByIdAsync(
    	string id,
    	CancellationToken cancellationToken = default);

	/// <summary>
	/// Gets a random joke.
	/// </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();

生成されたクライアントのコントラクトは、下位のデータコントラクトと一致する必要があるため、コントラクトの変換をコントロールすることはできません。この責任はコンシューマに委ねられることになります。

上記のコードが実際にどのような動作をするのかを見ていきましょう。Serilongロギングを追加したため、MinimalAPI例の出力は違うものになっています。

{
  "punchline": "Forgery.",
  "setup": "Why was the blacksmith charged with?",
  "type": "forgery"
}

例によって、メリットとデメリットがあります。

利点➕:

  • APIクライアントの利用と開発が容易であること。
  • 構成の自由度が高いこと。何かを行う上で、十分な柔軟性があります。
  • ユニットテストの追加が不要であること。

欠点➖

  • トラブルシュートが難しいこと。生成されたコードの動作を理解することが難しい場合があります。設定にミスマッチのあった場合などがそうです。
  • 他チームのメンバにも、Refitで開発したコードを読み書きする方法を理解してもらう必要があります。
  • 中規模~大規模APIにおいては、まだ処理時間を要する。

その他の推奨: RestEase

自動化アプローチによるHTTP Client SDKの記述

HTTP Client SDKを完全に自動化する方法があります。OpenAPI/Swagger仕様では、RESTfulなWeb APIの記述にJSONとJSON Schemaを使用していますが、これらのOpenAPI仕様からクライアントコードを生成するツールを、NSwagプロジェクトが提供しているのです。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 Client 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"
}

利点➕:

  • よく知られた仕様に基づいていること。
  • 豊富なツールセットと活発なコミュニティのサポートがあること。
  • 完全に自動化されていて、OpenAPI仕様が変更される毎に、CI/CDプロセスの一部として新たなSDKを生成可能であること。
  • 複数のプログラミング言語を対象としたSDKの生成。
  • ツールチェインの生成したコードを確認できるので、トラブルシュートが比較的容易であること。

欠点➖

  • 適切なOpenAPI仕様がなければ適用不可能であること。
  • 生成されたAPIクライアントのコントラクトのカスタマイズやコントロールが困難であること。

その他の推奨: AutoRestVisual Studio Connected Services

適切なアプローチを選ぶ

今回の記事では、SDKクライアントを開発する3つの方法について学びました。適切なアプローチを選択するプロセスは、次のように簡略化できます。

私は人間なのだから、自分のHTTP Client統合はすべて自分でコントロールしたい。

マニュアルアプローチを使いましょう。

私は多忙だが、それでもある程度のコントロールは可能にしておきたい。

宣言的アプローチを使用しましょう。

私は怠け者なので、代わりにやってほしい。

自動化アプローチを選びましょう。

判断チャートは次のようになります。

要約

今回の記事では、HTTP Client SDKを開発するさまざまな方法について検討しました。どのアプローチが適切なのかはユースケースと要件によりますが、自身のClient SDKを設計する上で、今回の記事が最適な設計判断を行うための基礎を提供できればと思っています。それでは。

作者について

この記事に星をつける

おすすめ度
スタイル

BT