本稿はJCB Advent Calendar 2024の12月23日の記事です。
JCB デジタルソリューション開発部 アプリ チームの関口です。
この記事では、 NestJS アプリケーション内で使用している HTTP クライアントを fetch から axios に変更した際の話を書いていこうと思います。
経緯/前提
まずは今回の変更することとなった経緯と環境を説明します。
経緯
今回我々が fetch から axios に乗り換えた理由は、「Node.js の dd-trace を使用した際、我々の実装では、fetch で行った通信のトレースログが取れなかったから」になります。
dd-trace とは、Datadog が提供している APM 用の SDK のパッケージ名であり、我々のアプリケーションではこの APM を採用していました。
fetch 自体は素晴らしいユーティリティでありましたし、
また dd-trace が使用できない点についても、今現在その issue が発見できないことから我々のアプリケーションの不備であった可能性はありますが、
当時は性能改善のためこのトレースログの取得が急務であり、既に我々の中でトレースログ取得の実績のあった axios への乗り換えに至りました。
前提
今回は、実際に fetch/axios による HTTP 通信によって API を呼び出す Node.js(NestJS) 製のサーバと、
その NestJS サーバの処理で呼び出される API を提供する Go 製のサーバを作成します。
それぞれのバージョンなどは以下となります。
OS : macOS 14.6.1 ├── Node.js : 20.9.0 │ ├─ NestJS : 10.4.9 │ │ └─@nestjs/axios : 3.1.3 │ └─ axios: 1.7.9 └── Go : 1.22.4
また、HTTP クライアントとして Postman、エディタとして Visual Studio Code を使用させていただいております。
前提:NestJS サーバ
実際のアプリケーションに近い状況を再現するため、以下の条件でサーバを実装します
- '/test1','/test2'という POST エンドポイントを持つ
- Test1Module,Test2Module という 2 つの module で構成されている
- Test1Module は'/test1'エンドポイントの処理に対応している
- Test2Module は'/test2'エンドポイントの処理に対応している
- Test1Module は Test1Controller,Test1Service で構成されている(Test2 も同様)
- Service で API を呼び出す
- 取得した HTTP ステータスコードをログ出力する
- これは実際のコードにおけるエラーハンドリングに該当します
- Test1Service,Test2Service それぞれに fetch による処理を直接実装している
- ただし今回は両方が同じ Go サーバを向いている
- Go サーバへの連携のため、status:string の型でリクエストボディを受け付ける
- ポートは 11104 ポートを指定
NestJSサーバの実装コード
test1.service.ts
import { Injectable } from "@nestjs/common"; @Injectable() export class Test1Service { async getMessage(status: string): Promise<string> { try { const res = await fetch("http://localhost:41104/message-a", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ status, }), }); console.log("status:", res.status); const body = await res.json(); return body.message; } catch (error) { console.log("error:", error); return "error"; } } }
test1.controller.ts
import { Body, Controller, HttpCode, Post } from "@nestjs/common"; import { Test1Service } from "./test1.service"; import { StatusRequest } from "src/class/statusRequest"; import { ApiResponse } from "src/class/response"; @Controller() export class Test1Controller { constructor(private readonly service: Test1Service) {} @Post("test1") @HttpCode(200) async getMessageA(@Body() req: StatusRequest): Promise<ApiResponse> { const message = await this.service.getMessage(req.status); return { message, }; } }
test1.module.ts
import { Module } from "@nestjs/common"; import { Test1Controller } from "./test1.controller"; import { Test1Service } from "./test1.service"; @Module({ imports: [], controllers: [Test1Controller], providers: [Test1Service], }) export class Test1Module {}
app.module.ts
import { Module } from "@nestjs/common"; import { Test1Module } from "./test1/test1.module"; import { Test2Module } from "./test2/test2.module"; @Module({ imports: [Test1Module, Test2Module], }) export class AppModule {}
※main.ts は 一般的なため、test2 は test1 とほぼ同様のため割愛します。
前提:Go サーバ
こちらは以下の機能でサーバを実装します。
- '/message-a','/message-b'という POST エンドポイントを持つ
- レスポンスボディとして JSON を返却する。型は{message:string}
- 受信した HTTP リクエストをそのままログ出力する
- リクエストボディの JSON から status を取得し、その status を HTTP ステータスコードとして返却する
- 存在しない場合は 200
- パースに失敗した際は 500(ボディが空の場合もこちらに該当する)
- 999 など存在しないステータスを指定されてもそのまま返却
- ポートは 41104 ポートを指定
Goサーバの実装コード
main.go
package main import ( "encoding/json" "fmt" "io" "log" "net/http" "net/http/httputil" "strconv" ) type MyRequest struct { Status string `json:"status"` } type MyResponse struct { Messeage string `json:"message"` } func judgeStatus(r *http.Request) int { requestDump, err := httputil.DumpRequest(r, true) if err != nil { fmt.Println(err) return 500 } fmt.Println(string(requestDump)) bodyByte, err := io.ReadAll(r.Body) if err != nil { fmt.Println(err) return 500 } body := MyRequest{} err = json.Unmarshal(bodyByte, &body) if err != nil { fmt.Println(err) return 500 } if body.Status == "" { return 200 } status, err := strconv.Atoi(body.Status) if err != nil { fmt.Println(err) return 500 } return status } func returnMessage(w http.ResponseWriter, r *http.Request, message string) { status := judgeStatus(r) jsonResponse := MyResponse{Messeage: message} w.Header().Set("Content-Type", "application/json") w.Header().Set("connection", "close") w.WriteHeader(status) json.NewEncoder(w).Encode(jsonResponse) } func returnMessageA(w http.ResponseWriter, r *http.Request) { returnMessage(w, r, "This is A") } func returnMessageB(w http.ResponseWriter, r *http.Request) { returnMessage(w, r, "This is B") } func handleRequests() { http.HandleFunc("/message-a", returnMessageA) http.HandleFunc("/message-b", returnMessageB) log.Fatal(http.ListenAndServe(":41104", nil)) return } func main() { handleRequests() }
置き換え
では、実際に置き換えていきます。
NestJS では axios を使用した module が公式で提供されていますのでそちらを使用します。 公式サイト(HTTP module)
yarn add @nestjs/axios axios
test1.module.ts
// importなど略 import { HttpModule } from "@nestjs/axios"; @Module({ imports: [ // 追加 HttpModule, ], controllers: [Test1Controller], providers: [Test1Service], }) export class Test1Module {}
test1.service.ts
// importなど略 import { HttpService } from "@nestjs/axios"; @Injectable() export class Test1Service { // 追加 constructor(private readonly httpService: HttpService) {} async getMessage(status: string): Promise<string> { // 略 } }
この後の置き換えについては、開発中のアプリケーションによって必要な内容は変わると思いますが、今回は実際の開発で行った、
- 型定義変更への対応
- HTTP ステータスハンドリングへの対応
- 上記設定のグローバル化
のみを行っていきます。
置き換え:型定義変更への対応
実際にリクエストを行う部分を変更し、型チェックを通します。
HttpService は RxJs を用いてレスポンスを処理するメソッドも用意してくれていますが、
今回は置き換えがメインのためそちらは使用せず、axiosRef の提供するメソッドを使用していきます。
test1.service.ts
// importなど略 @Injectable() export class Test1Service { constructor(private readonly httpService: HttpService) {} async getMessage(status: string): Promise<string> { try { // 従来のaxios同様(HTTPの)メソッドごとにメソッドが存在 // fetchに合わせてメソッドも引数で指定するrequestを使用しても良いが、 // 今回は可読性の観点からpostを使用 const res = await this.httpService.axiosRef.post( // 従来のaxios同様、 // 第1引数: URL // 第2引数: リクエストボディ // 第3引数: オプション(リクエストヘッダもここで指定) "http://localhost:41104/message-a", { status, }, { headers: { "Content-Type": "application/json", }, } ); console.log("status:", res.status); // 従来のaxios同様レスポンスボディはres.dataに格納されている const body = res.data; return body.message; } catch (error) { console.log("error:", error); return "error"; } } }
この時点で status200 のレスポンスについては、正常に処理できることがわかります。
置き換え:HTTP ステータスハンドリングへの対応
次は HTTP ステータスハンドリングへの対応を行なっていきます。
ご存知の通り、axios はデフォルトでは 2xx 系の応答のみを resolve として扱い、
これは HTTP ステータスが返却されれば全て resolve とする fetch とは異なりますので、対応します。
test1.service.ts
// 他実装は略 const res = await this.httpService.axiosRef.post( "http://localhost:41104/message-a", { status, }, { headers: { "Content-Type": "application/json", }, // 従来のaxios同様、validateStatusを指定することでステータスコードの範囲を指定できる // HTTPステータスコードが返却される場合、全てresolveされるfetchに合わせる validateStatus: (_) => true, } );
上記実装により、fetch 同様 2xx 以外の HTTP ステータスでも resolve され、実装修正を最小限にできました。
置き換え:上記設定のグローバル化
HTTP ステータスハンドリングの設定を入れたことで 2xx 以外も正常に処理できるようになりましたが、
今回は Test1Service,Test2Service 両方で fetch を使用しているため、両方で対応が必要となります。
設定のグローバル化については賛否両論あると思いますが、今回は fetch の HTTP ステータスハンドリングに合わせるためには必須となるため、上記設定をグローバル化していこうと思います。
モジュールのグローバル化については公式サイトに手順がありますのでそちらを参照します。 公式サイト(Global modules)
以下のような myhttp.module.ts を作成します。
import { HttpModule } from "@nestjs/axios"; import { Global, Module } from "@nestjs/common"; @Global() @Module({ imports: [ HttpModule.register({ validateStatus: (_) => true, }), ], exports: [HttpModule], }) export class MyHttpModule {}
後はこれを app.module.ts でインポートすれば、
@Module({ imports: [ Test1Module, Test2Module, // 追加 MyHttpModule, ], }) export class AppModule {}
Test1Module からインポートを削除しても、Test1Service から設定を削除しても、
Test2Service についても型エラーへ対応するだけで、置き換えが可能になります。
最後に念の為、Go サーバのログから新旧の HTTP リクエストを比較してみます。(左: fetch、右: axios)
概ね差異がなく、問題なく置き換えができていることがわかります。
おわりに
今回の記事では、fetch から axios への置き換えについて書かせていただきました。
fetch の方が新しいこともありそちらから置き換える記事は少なく、NestJS はそもそも日本語の記事が少ないため、
実際の作業時にはほぼ手探りで行いました。
今後同様の作業をする方の助けになれば幸いです。
最後になりますが、JCB では我々と一緒に働きたいという人材を募集しています。 詳しい募集要項等については採用ページをご覧下さい。
本文および図表中では、「™」、「®」を明記しておりません。 記載されているロゴ、システム名、製品名は各社及び商標権者の登録商標あるいは商標です。