こんにちは。Inflabのバックエンド開発者、INTです。
現代のWebアプリケーションでは、APIを通じてさまざまなデータやサービスと連携することが当たり前になりました。
こうしたAPIを利用するための通信方法はいろいろありますが、最も一般的に使われているのはHTTPです。
私たちは、このHTTP通信のためにさまざまな外部ライブラリを使っています。
本記事では、NestJS環境で外部ライブラリに依存せず、簡単にテストできるHTTPクライアントを作った経験を共有したいと思います。
インターフェースの分離や抽象化など、今では当たり前になった概念が中心の話です。
後半ではさらに踏み込んで宣言的HTTPクライアントを取り上げ、それを実装したライブラリも紹介します。
NestJS HTTP Moduleの欠点
本題に入る前に、NestJSが提供するHTTP Moduleについてお話ししたいと思います。
NestJSを使っている方なら、このモジュールの導入を一度は検討したことがあるでしょう。
公式ドキュメントには、次のような例があります。
@Injectable()
export class CatsService {
constructor(private readonly httpService: HttpService) {
}
findAll(): Observable<AxiosResponse<Cat[]>> {
return this.httpService.get('http://localhost:3000/cats');
}
}
短いコードですが、このモジュールの使い方と提供されるインターフェースを把握できます。
私たちもこのモジュールの適用を検討しましたが、次のような理由で使いませんでした。
Observableを返す
HttpModuleが提供するHttpServiceのHTTPリクエストメソッドは、すべてObservableを返します。
Observableには、複数の非同期処理が発生する状況で、rxjsが提供するオペレーターを活用して簡単にチェーンできるというメリットがあります。
しかし私たちの場合、一回限りのHTTPリクエストを処理するケースがほとんどだったので、Promiseを返すほうが扱いやすいと考えています。
なお、公式ドキュメントで案内されているPromiseを返す方法もありますが、これだと内部でPromise -> Observable -> Promiseという不要な変換が挟まります。
AxiosResponseを返す
HttpServiceがObservableを返すことに加えて、その中身としてAxiosResponseを返すことも問題でした。
AxiosResponseはaxiosライブラリが提供する型なので、HttpServiceに依存するサービスは結局axiosに依存することになります。
Node.jsを長く使ってきた方なら、requestというライブラリを使ったことがあるでしょう。
一時はコミュニティで最も人気のあるHTTPクライアントライブラリで、広く使われていました。
しかし、callback APIの限界から現在は完全にdeprecatedな状態となり、axios、got、node-fetchなどさまざまなライブラリが使われています。
事実上の標準と呼べるHTTPクライアントライブラリがない状況では、axiosもいつかdeprecatedになる可能性があります。
仮にそうなって、新しいライブラリに置き換えることになったとしましょう。
HttpServiceの内部ロジックだけを新しいライブラリに置き換えれば、簡単に解決できそうだけど?
と思うかもしれませんが、思ったほど簡単ではないかもしれません。
まず、HttpServiceに依存しているサービスは高い確率でAxiosResponseをimportしているはずなので、これらをすべて新しいライブラリが返すものに置き換える必要があります。
もちろんテキスト置換機能を使えば簡単に置き換えられますが、サービス内でAxiosResponseにしか存在しないプロパティを使っている場合、それを修正するのは容易ではないでしょう。
結局、既存のロジックが正しく動作することを保証するために、新しいレスポンスをそのまま返すことを諦め、新しいレスポンスをAxiosResponseに変換する処理を追加するという事態が起きるかもしれません。
そもそも、rxjsやaxiosなど自分たちが制御できない外部ライブラリの事情に合わせて、サービスのコードも一緒に修正しなければならない状況こそが問題だと考えます。
これは開放閉鎖の原則(OCP)を守れていない設計だと言えます。
WebClientModuleの構造
こうした理由から、私たちはWebClientModuleと呼ぶHTTPクライアントモジュールを自作して使っています。
このモジュールの役割は、内部実装を隠蔽し、核となるHTTPインターフェースだけを公開して外部から使えるようにすることです。
まず、モジュールの構成要素とその関係をお見せします。
外部から依存するインターフェースが2つあり、それぞれに実装クラスがあります。
また、HTTPリクエストとレスポンスを扱うための2つのクラスがあります。
では、各構成要素について詳しく見ていきましょう。
わかりやすくするため、実際の本番コードとは異なる部分や簡略化した部分がある点をご了承ください。
WebClient
export interface WebClient {
get(): this;
head(): this;
post(): this;
put(): this;
patch(): this;
delete(): this;
options(): this;
uri(uri: string): this;
header(param: Record<string, string>): this;
contentType(mediaType: MediaType): this;
body<T>(inserter: BodyInserter<T>): this;
retrieve(): Promise<ResponseSpec>;
}
外部のサービスが依存するインターフェースで、GET、POSTなどのHTTPメソッドを提供し、header、bodyなどHTTPリクエストに必要な情報を設定できます。
builderパターンを適用しているため各メソッドはthisを返し、retrieveメソッドを呼び出すとHTTPリクエストを実行してResponseSpecを返します。
このインターフェースの命名と仕様は、Spring WebClientを参考にしました。
そのためbuilderパターンを適用しましたが、好みに応じてaxiosのAPI仕様を参考にしてもよいと思います。
BodyInserter
BodyInserterは、contentTypeに応じて異なる形式のbodyを生成するためのクラスです。
WebClientのcontentTypeを直接呼び出さなくても、BodyInserterを通じて自動で設定できます。
また、ファクトリメソッドの名前で、どのような形式のbodyを生成するのかを表現できるというメリットもあります。
export class BodyInserter<T> {
private constructor(
private readonly _mediaType: MediaType,
private readonly _data: T,
) {
}
static fromJSON(json: Record<string, unknown>) {
return new BodyInserter(MediaType.APPLICATION_JSON, json);
}
static fromFormData(form: Record<string, unknown>) {
return new BodyInserter(MediaType.APPLICATION_FORM_URLENCODED, form);
}
static fromText(text: string | Buffer) {
return new BodyInserter(MediaType.TEXT_PLAIN, text);
}
// ...省略
}
ResponseSpec
ResponseSpecは、HTTPリクエストに対するレスポンスを表すクラスです。
HTTPレスポンスに必ず存在するステータスコードとレスポンスボディを提供し、さらにtoEntityメソッドでレスポンスを特定のクラスのインスタンスに変換できます。
export class ResponseSpec {
constructor(
private readonly _statusCode: number,
private readonly _body: string,
) {
}
toEntity<T>(entity: ClassConstructor<T>): T {
return plainToInstance(entity, JSON.parse(this._body));
}
get statusCode() {
return this._statusCode;
}
get rawBody(): string {
return this._body;
}
}
レスポンスオブジェクトをクラスのインスタンスにすると、メソッドを活用してレスポンスの値を処理するロジックを持たせることができます。
また、外部APIのレスポンスのプロパティ名がわかりにくかったり、社内の規約に合っていなかったりする場合でも、好きな名前に簡単に変換できます。
import {Expose} from 'class-transformer';
export class UserApiResponse {
@Expose({name: 'user_id'})
userId: string;
@Expose({name: 'usr_eml'})
userEmail: number;
get emailDomain() {
return this.userEmail.split('@')[1];
}
}
ResponseSpecはclass-transformerライブラリに依存しているので、結局レスポンスオブジェクトもこのライブラリに依存するのでは?
そのとおりです。class-transformerも外部ライブラリなので、先ほど述べた問題が起こりえます。
これを解決するには、ResponseSpecもインターフェースとして抽象化し、Exposeをラップしたカスタムデコレーターを作るという方法があります。
しかし、class-transformerが置き換えられる可能性と想定される作業工数、そしてすでに多くのコントローラーのリクエスト・レスポンスオブジェクトがこのライブラリに依存している点を考慮し、これは適用しませんでした。
GotClient
WebClientの実装として、gotライブラリを使っています。
状況に応じて、axiosやnode-fetchなど他のライブラリを使うこともできます。
import got, {ExtendOptions} from 'got';
export class GotClient implements WebClient {
private static readonly TIMEOUT = 10_000;
private readonly _option: ExtendOptions;
constructor(url?: string, timeout = GotClient.TIMEOUT) {
this._option = {
method: 'GET',
url: url,
timeout: {
request: timeout,
response: timeout,
},
};
}
uri(uri: string): this {
this._option.url = uri;
return this;
}
get(): this {
this._option.method = 'GET';
return this;
}
post(): this {
this._option.method = 'POST';
return this;
}
async retrieve(): Promise<ResponseSpec> {
const response = await got({
...this._option,
isStream: false,
resolveBodyOnly: false,
responseType: 'text',
});
return new ResponseSpec(response.statusCode, response.body);
}
// ...省略
}
WebClientService
WebClientはbuilderパターンを適用しているので、別のHTTPリクエストを送るには新しいインスタンスを生成する必要があります。
そのためにWebClientServiceクラスを作りました。このクラスはWebClientのインスタンスを生成する役割を担います。
これは、TypeORMのDataSourceがcreateQueryBuilderでQueryBuilderのインスタンスを生成するのと似ています。
createメソッドは任意でURLを受け取り、WebClientのuriメソッドを呼び出すのと同じ効果があります。
abstract class WebClientService {
abstract create(url?: string): WebClient;
}
export class GotClientService extends WebClientService {
override create(url?: string): WebClient {
return new GotClient(url);
}
}
WebClientServiceのほうは、なぜ抽象クラスとして宣言したのですか?
WebClientServiceをNestJSのproviderとして登録するためです。
インターフェースとして宣言した場合、ビルド時に消えてしまうため正しく登録されず、依存関係が見つからないというエラーが発生します。
インターフェースとして宣言したい場合は、カスタムトークンを自分で作り、@Injectデコレーターで注入する必要があります。
そして、このサービスを外部から使えるようにするモジュールがWebClientModuleです。
@Global()
@Module({
providers: [{
provide: WebClientService,
useClass: GotClientService,
}],
exports: [WebClientService],
})
export class WebClientModule {
}
使用例
それでは、内部のサービスでWebClientServiceを使うサンプルコードを見てみましょう。
import {Injectable} from '@nestjs/common';
import {BodyInserter, WebClientService} from '@app/web-client';
@Injectable()
export class UserService {
constructor(private readonly webClientService: WebClientService) {
}
async create(email: string, password: string): Promise<void> {
const response = await this.webClientService
.create(`https://user.domain.com/users`)
.post()
.body(BodyInserter.fromJSON({email, password}))
.retrieve()
.then((spec) => spec.toEntity(UserApiResponse));
// その他のロジックを実行
}
}
UserServiceは注入されたWebClientServiceでWebClientのインスタンスを生成し、postメソッドでHTTPリクエストを実行します。
import文を見ると、このサービスがgotライブラリに依存していないことがわかります。
ここまで、モジュールの各要素について見てきました。
先ほどお見せした各要素の関係をもう一度見てみると、より理解しやすいと思います。
WebClientModuleのメリット
ほとんどの方がご存じだと思いますが、上記のような設計によって次のような効果が得られます。
- ユニットテストが書きやすくなります。
- 外部ライブラリの置き換えが簡単です。
それぞれの項目を、サンプルコードを使って詳しく見ていきましょう。
純粋なユニットテストの作成
NestJSが提供するHttpServiceを使ってロジックを書いたとしましょう。
このロジックを、実際のサービスにリクエストを送らずにテストするには、次のような方法が考えられます。
- 偽のサーバーを立ち上げ、そのサーバーにリクエストを送る
- HttpServiceの内部メソッドをmockに置き換える
- nockのようなライブラリを使ってHTTPリクエストをインターセプトする
上記のような方法では、テストコードのために別の外部ライブラリが必要になります。
テストコードも本番コードと同じく継続的なメンテナンスが必要なので、外部ライブラリを置き換える必要が出た場合、大量のテストコードも一緒に修正しなければならない事態が起こりえます。
そのため、テストコードも外部ライブラリに依存しない純粋なコードで書くのが良いと考えています。
これは個人的な見解であり、外部ライブラリが提供する利便性を無視することはできないと思います。
もしjestを使わずにテストコードを書かなければならないとしたら、かなりつらい作業になりそうです。
まず、WebClientの実装であるMockWebClientを作ってみましょう。
必要な実装メソッドの一部は省略しています。
export class MockWebClient implements WebClient {
// builderメソッドを通じて受け取った引数を保存するためのフィールド
urls: string[] = [];
methods: Method[] = [];
requestBodies: string[];
responses: {
statusCode: number;
body: string;
}[];
get(): this {
this.methods.push('GET');
return this;
}
uri(url: string): this {
this.urls.push(url);
return this;
}
async retrieve(): Promise<ResponseSpec> {
const response = this.#responses.shift();
if (!response) {
throw new Error('設定されたレスポンスがありません');
}
return new ResponseSpec(response.statusCode, response.body);
}
body<T>(inserter: BodyInserter<T>): this {
this.requestBodies.push(inserter.data);
return this;
}
// 各テストケースの実行前に必要な初期化処理
clear(): this {
this.urls = [];
this.methods = [];
this.requestBodies = [];
this.responses = [];
return this;
}
// 実際のリクエストを送らずに、あらかじめ用意したレスポンスを設定するためのメソッド
addResponse(
statusCode: HttpStatus,
body: string,
): this {
this.#responses.push({statusCode, body});
return this;
}
}
WebClientServiceも同様に、テスト用の実装を作ってみましょう。
export class MockWebClientService extends WebClientService {
constructor(private readonly mockWebClient: MockWebClient) {
super();
}
override create(url?: string): WebClient {
return this.mockWebClient;
}
}
2つのクラスの実装はシンプルですが、jestのmockでよく使われる次の機能を同じようにサポートしています。
- あらかじめ用意したレスポンスを設定する
- 途中で受け取った引数を追跡する
- 各テストケースの実行前に初期化する
これを活用したテストコードは、次のような形になるでしょう。
describe('UserService', () => {
const mockWebClient = new MockWebClient();
const mockWebClientService = new MockWebClientService(mockWebClient);
const userService = new UserService(mockWebClientService);
beforeEach(() => {
mockWebClient.clear();
});
it('success', async () => {
// given
mockWebClient.addResponse(HttpStatus.CREATED, '{"userId": 100}');
// when
await userService.create()
// then
expect(mockWebClient.urls[0]).toBe('https://user.domain.com/users');
expect(mockWebClient.methods[0]).toBe('POST');
// その他の検証
})
})
上記のコードではdescribe、itといったjestの基本機能しか使っておらず、特にNestJSが提供する@nestjs/testingパッケージすら使っていません。
これにより、将来jestからmochaやvitestなど別のテストフレームワークに乗り換える際のコストを抑えられます。
柔軟な外部ライブラリの置き換え
ここまで繰り返し述べてきたとおり、インターフェースの実装を差し替えるだけで、外部ライブラリを簡単に変更できます。
例えば、gotライブラリをnode-fetchに置き換える状況を考えてみましょう。
この場合、2つのインターフェースを実装したファイルが新たに追加され、修正が必要なファイルはWebClientModuleだけです。
// FetchClient.ts
export class FetchClient implements WebClient {
// fetchを使ってメソッドを実装
}
// FetchClientService.ts
export class FetchClientService extends WebClientService {
override create(url?: string): WebClient {
return new FetchClient(url);
}
}
// WebClientModule.ts
@Global()
@Module({
providers: [{
provide: WebClientService,
useClass: FetchClientService, // 修正が必要な唯一の箇所
}],
exports: [WebClientService],
})
export class WebClientModule {
}
宣言的HTTPクライアント
ここまで、WebClientModuleを使ってHTTPクライアントを作る方法を紹介しました。
次に、現在の構造をさらに改善するための方法を紹介したいと思います。
先ほど紹介したUserServiceのコードをもう一度見てみましょう。
export class UserService {
constructor(private readonly webClientService: WebClientService) {
}
async create(email: string, password: string): Promise<void> {
const response = await this.webClientService
.create(`https://user.domain.com/users`)
.post()
.body(BodyInserter.fromJSON({email, password}))
.retrieve()
.then((spec) => spec.toEntity(UserApiResponse));
// その他のロジックを実行
}
}
実のところ、UserServiceは必要なロジックを実行するためにUserApiResponseが必要なだけで、そのために特定のサーバーへHTTPリクエストを送ることは主な関心事ではありません。
しかし、WebClientServiceを使っている以上、サービスにはHTTPに依存するコードが含まれています。
もし通信方法をgRPCに変更するとしたら、このサービスも一緒に修正しなければなりません。
私たちは普段、こうした外部インフラに依存するコードをサービスロジックから分離して書いてきました。
例えば、データベースに依存するコードはRepositoryという名前のクラスに分離し、それをサービスに注入して使っていました。
HTTPクライアントも同じ方法で分離できます。
@Injectable()
export class UserRepository {
constructor(private readonly webClientService: WebClientService) {
}
async create(email: string, password: string): Promise<UserApiResponse> {
return this.webClientService
.create(`https://user.domain.com/users`)
.post()
.body(BodyInserter.fromJSON({email, password}))
.retrieve()
.then((spec) => spec.toEntity(UserApiResponse));
}
}
このように分離するだけでも、UserServiceのユニットテストが書きやすくなるというメリットがあります。
しかし、数多くのAPIについて毎回このように分離するのは面倒な作業になりかねません。
JVMの世界には、こうした問題を改善したライブラリがあります。
OpenFeignやRetrofitなどがあり、最近ではSpring 6.0にもHTTP Interfaceという機能が追加されました。
これらのライブラリでは、HTTPリクエストのロジックを直接書かずに、どんなデータが必要なのかをインターフェースに書くだけで、それをもとにHTTPリクエストを実行する実装を自動で生成します。
ちょうどSQLのように、データを取得するロジックを具体的に書かず、ただ「この条件を満たすデータを取得して」と書くだけでDBMSが処理するのと似ています。
こうした方式は一般に宣言的(Declarative)と呼ばれ、上記のようなライブラリは宣言的HTTPクライアントと呼ばれることもあります。
@HttpExchange(url = "/repos/{owner}/{repo}", accept = "application/vnd.github.v3+json")
interface RepositoryService {
@GetExchange
Repository getRepository(@PathVariable String owner, @PathVariable String repo);
@PatchExchange(contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
void updateRepository(@PathVariable String owner, @PathVariable String repo,
@RequestParam String name, @RequestParam String description, @RequestParam String homepage);
}
NestJS用の宣言的HTTPクライアント
実は、この概念に初めて触れたとき、NestJSでもこれを実装できるのではないかと考えました。
TypeScriptにもデコレーターがあり、NestJSのDiscoveryServiceを使えば実行時にインターフェースの実装を生成できるからです。
ただし、TypeScriptのインターフェースは実行時には存在せず、デコレーターも適用できないため、クラスを使わなければならないという欠点があります。
そこで、次のようなコードの形を考えてみました。
@HttpInterface('https://example.com/api') // base url
class UserHttpService {
@GetExchange('/users/{id}') // path
@ResponseBody(UserResponse) // レスポンスのDTO
async request(@PathVariable('id') id: number): Promise<UserResponse> {
return {} as any; // 型エラーを防ぐためのコード
}
}
上記のようなクラスをproviderとして登録すると、実装部分を自動で生成する方式です。
既存のWebClientModuleのBodyInserterやResponseSpecのようなクラスは、@ResponseBodyのようなデコレーターで置き換えられます。
内部のサービスは、WebClientServiceやWebClientのようなインターフェースではなく、自分で書いたインターフェース(UserHttpService)だけに依存することになります。
ただし、UserHttpServiceをクラスとして宣言しているため、各メソッドの実装部分を必ず書かなければならず、型エラーを防ぐためにreturn {} as anyのようなコードを書く必要があるという物足りなさは残ります。
それでも、HTTPリクエストのロジックを直接書く方式よりは簡潔なので、十分に使える方式だと考えています。
上記のような機能を実装したライブラリを作ったので、興味があれば見てみてください。
nest-http-interface
※この記事は2023年7月23日に執筆されたものです。
