サクッと作る型安全なBFF開発環境 - マイクロサービス × OpenAPI自動生成
これは SMat Advent Calendar 2024 の12/4分の記事です。
はじめに
こんにちは、株式会社エスマット エンジニアの hi6okuni です。
今回は、OpenAPI Specification(以降、OpenAPIと呼ぶ)からTypeScriptの型自動生成の仕組みをサクッと作った時の振り返りです。
型の自動生成自体はすでに多くの事例や記事が公開されていますが、本記事では特にマイクロサービスアーキテクチャにおいて「クライアントサイド(Next.js)とマイクロサービス(Go)の間にBFF(tRPCサーバー)が存在する」という構成に焦点を当て、実装例と得られた知見を紹介します。
特に以下のような課題をお持ちの方に参考になれば幸いです。
- 複数のマイクロサービスのOpenAPIから型を自動生成したい
- BFFレイヤーでの型定義の保守コストを削減したい
- tRPCサーバーとマイクロサービスとの通信部分をタイプセーフにしたい
背景と課題
- クライアント側(Next.jsなど)とバックエンド側(Goのマイクロサービス)の中間にBFFとしてtRPCサーバが存在している
- BFFのコードベースでは、各マイクロサービスから取得するデータの型定義もしばらく手書き状態だった
- 最近バックエンド側で改めてスキーマファーストを徹底するため、OpenAPIが各マイクロサービスで整頓されるようになった
型自動生成のねらい
型手書き -> 型自動生成への変化は誰が見てもポジティブに捉えられると思いますが、改めて以下のような狙いがありました。
- 手書き減少による作業効率向上
- レスポンスの意図しない型ズレの発生を抑止
- 自動生成された型を利用してAPIクライアントまで自動生成することで、BFFコードベース上の外部との通信に関わるコードを削減する
やったこと
openapi-typescript
を利用して型自動生成
いくつかzennの記事を参考にさせていただきopenapi-typescript
を採用しました。現時点(2024年11月)でも引き続き開発が活発ですね。
今回のシナリオとして、各マイクロサービスが各々OpenAPIをyamlファイルで管理しているため、複数のyamlファイルを参照して型を自動生成する必要がありますが、公式サイトにredocly.yaml
を利用する方法が明記されている点もスピーディな実装が求められている時にプラスでした。
型自動生成のコマンド作成
新規マイクロサービスが追加される際に変更するべきファイル数が増えることも避けたかったので、前述の複数のyamlファイルを参照する際に利用するredocly.yaml
自体も自動生成時に動的に生成&破棄するようにしました。
手元で以下のコマンドを動かして型ファイルを自動生成します。
# 全てのマイクロサービスの型定義を一括で生成
make generate-openapi-types
# 特定のマイクロサービスの型定義のみを生成
make generate-openapi-types TARGET_REPO=microservice-A
Makefileを利用することで、複数のコマンドを順序立てて実行し、かつ以下のような利点が得られました。
- 新規マイクロサービス追加時は REPOS 変数に1行追加するだけで型生成の仕組みに組み込める
- 一時ファイル(redocly.yaml)は生成・使用・破棄を自動で行うため、リポジトリを汚さない
- TARGET_REPO パラメータで特定のマイクロサービスの型のみを更新可能
Makefileの実装内容
# 一部抜粋/加工済
MAKEFILE_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
REPOS := microservice-A=https://github.com/example.com/service-a.git|docs/openapi|root.yaml \
microservice-B=https://github.com/example.com/service-b.git|docs/openapi|root.yaml
# Output directory for TypeScript type definitions
OUTPUT_DIR := infrastructure/types/generated
TEMP_DIR := .temp
# 引数を取得
TARGET_REPO ?= all
.PHONY: generate-openapi-types setup generate-redocly-config generate-typescript cleanup format-generated
generate-openapi-types: setup fetch-specs generate-redocly-config generate-typescript cleanup format-generated
setup:
@mkdir -p $(OUTPUT_DIR)
@mkdir -p $(TEMP_DIR)
cleanup:
@echo "Cleaning up temporary files..."
@rm -rf $(TEMP_DIR)
@rm -f redocly.yaml
fetch-specs:
$(if $(filter all,$(TARGET_REPO)), \
$(foreach repo,$(REPOS),$(call fetch_repo,$(repo))), \
$(foreach repo,$(filter $(TARGET_REPO)=%,$(REPOS)),$(call fetch_repo,$(repo))))
# ref. https://openapi-ts.dev/cli#multiple-schemas
generate-redocly-config:
@echo "Generating Redocly config..."
@echo "apis:" > redocly.yaml
@$(if $(filter all,$(TARGET_REPO)), \
$(foreach repo,$(REPOS),$(call generate_redocly_entry,$(repo))), \
$(foreach repo,$(filter $(TARGET_REPO)=%,$(REPOS)),$(call generate_redocly_entry,$(repo))))
@echo "extends:" >> redocly.yaml
@echo " - minimal" >> redocly.yaml
generate-typescript:
@echo "Generating TypeScript types..."
@npx openapi-typescript --config redocly.yaml
# Function to generate Redocly config entry
define generate_redocly_entry
$(eval REPO_NAME := $(word 1,$(subst =, ,$(1)))) \
echo " $(REPO_NAME)@latest:" >> redocly.yaml; \
echo " root: $(TEMP_DIR)/$(REPO_NAME)/docs/openapi/root.yaml" >> redocly.yaml; \
echo " x-openapi-ts:" >> redocly.yaml; \
echo " output: $(OUTPUT_DIR)/$(REPO_NAME).ts" >> redocly.yaml;
endef
# Function to fetch repository
define fetch_repo
@$(eval REPO_NAME := $(word 1,$(subst =, ,$(1))))
@$(eval REPO_DETAILS := $(word 2,$(subst =, ,$(1))))
@$(eval REPO_URL := $(word 1,$(subst |, ,$(REPO_DETAILS))))
@$(eval DIR_PATH := $(word 2,$(subst |, ,$(REPO_DETAILS))))
@$(eval SPEC_ENTRY := $(word 3,$(subst |, ,$(REPO_DETAILS))))
@echo "Fetching: $(REPO_NAME) from $(REPO_URL)"
# Clone repository with sparse checkout
@git clone --filter=blob:none --sparse "$(REPO_URL)" "$(TEMP_DIR)/$(REPO_NAME)"
@cd "$(TEMP_DIR)/$(REPO_NAME)" && git sparse-checkout init --cone && git sparse-checkout set "$(DIR_PATH)"
endef
format-generated:
@echo "Formatting generated TypeScript files..."
@npx --prefix "$(MAKEFILE_DIR)" biome format --write $(OUTPUT_DIR)/*.ts --no-errors-on-unmatched --files-ignore-unknown=true
openapi-fetch
を利用して、APIクライアント作成
APIクライアントにはopenapi-typescript
と開発元が同じライブラリで、openapi-fetch
を採用しました。
こちらも軽量(6kb)かつ、openapi-typescript
の出力ファイルを型引数に組み込むだけでAPIリクエストの型安全性が保証され、URLのタイプミスやパラメータの誤りを完全に防ぐことができるようになりました。
import createClient from 'openapi-fetch'
import type { CombinedPaths } from '../types/api'
const client = createClient<CombinedPaths>({
baseUrl,
headers: {
Authorization: `Bearer ${token}`,
},
})
APIクライアントのカスタム
tRPCに沿ったエラーハンドリング
openapi-fetch
では、middlewareをシンプルに追加する仕組みが提供されているため、tRPC特有のエラーハンドリングを実装しました。
tRPCのエラーハンドリングは、固有のtRPC Error Code
を含んだTRPCError
クラスをthrowすることでエラーコードに応じて適切なHTTPステータスコードが自動的にマッピングされる仕組みです。したがってこのミドルウェアでは、バックエンドから受け取ったエラーレスポンスを、tRPC用にマッピングしてクライアントに対して適切なHTTPステータスコードが送信される役割を担っています。
また、500番台のエラーでクライアント側に詳細なエラーメッセージが漏れ出さないマスキングも加えました。(なお、このようなセキュリティに関わるエラーメッセージのマスキング処理は、BFFレイヤーで一元的に管理する設計の方が良さそうです。今回は既存のエラーハンドリング構造を活かしつつ、段階的な改善を進めていく中での補助的な実装として取り入れています。)
参考: 実際のtRPC Error Code
// reference: https://www.jsonrpc.org/specification
/**
* JSON-RPC 2.0 Error codes
*
* `-32000` to `-32099` are reserved for implementation-defined server-errors.
* For tRPC we're copying the last digits of HTTP 4XX errors.
*/
export const TRPC_ERROR_CODES_BY_KEY = {
/**
* Invalid JSON was received by the server.
* An error occurred on the server while parsing the JSON text.
*/
PARSE_ERROR: -32700,
/**
* The JSON sent is not a valid Request object.
*/
BAD_REQUEST: -32600, // 400
// Internal JSON-RPC error
INTERNAL_SERVER_ERROR: -32603,
NOT_IMPLEMENTED: -32603,
// Implementation specific errors
UNAUTHORIZED: -32001, // 401
FORBIDDEN: -32003, // 403
NOT_FOUND: -32004, // 404
METHOD_NOT_SUPPORTED: -32005, // 405
TIMEOUT: -32008, // 408
CONFLICT: -32009, // 409
PRECONDITION_FAILED: -32012, // 412
PAYLOAD_TOO_LARGE: -32013, // 413
UNPROCESSABLE_CONTENT: -32022, // 422
TOO_MANY_REQUESTS: -32029, // 429
CLIENT_CLOSED_REQUEST: -32099, // 499
} as const;
// 一部簡略化したmiddlewareを紹介
// 利用するときはこれだけ
client.use(errorMiddleware)
const errorMiddleware: Middleware = {
async onResponse({ response }) {
if (response.ok) {
return response
}
// レスポンスのステータスコードからtRPC Error Codeを算出
const trpcErrorCode = statusToTrpcCode[response.status] || 'BAD_REQUEST'
// レスポンスがJSONではない場合(プロキシエラーなど)の対応
if (response.headers.get('content-type') === 'text/plain') {
const textResponse = await response.text()
throw new TRPCError({
code: trpcErrorCode,
message: textResponse,
})
}
let errorBody: ServerErrorResponse | undefined
try {
errorBody = await response.json()
} catch (e) {
throw new TRPCError({
code: 'PARSE_ERROR',
message: 'Failed to parse error response',
cause: e,
})
}
// サーバーからのエラーレスポンスが500番台の場合、エラーメッセージをマスクする
const shouldMaskErrorMessage =
response.status >= 500 &&
response.status < 600 &&
process.env.ENVIRONMENT === 'production'
throw new TRPCError({
code: trpcErrorCode,
message: shouldMaskErrorMessage
? 'An unexpected error occurred. Please try again later.'
: errorBody?.message ??
'An unexpected error occurred. Please try again later.',
details: {
code: errorBody?.code ?? ('GenericError' as any),
},
cause: { bodyResult: errorBody, status: response.status },
})
},
}
// HTTPステータスコードからTRPCエラーコードへのマッピング
const statusToTrpcCode: Record<number, TRPC_ERROR_CODE_KEY> = {
400: 'BAD_REQUEST',
401: 'UNAUTHORIZED',
403: 'FORBIDDEN',
404: 'NOT_FOUND',
...
}
リトライ
コードの紹介が長くなるので説明のみとしますが、GETでのリクエストの際にExponential Backoffなアプローチでリトライする機構も追加しました。openapi-fetch
が提供しているcreateClient
関数をラップすることで実現ができました。
検討したが今回はやらなかったこと
バックエンド側のOpenAPI変更を通知してpush型で型を自動生成
当初、手元環境で手動で自動型生成の処理を走らせるpull型だけでなく、スキーマ変更をトリガーとして自動型生成処理をpush型の構築も検討していましたが、時間制約以外にも以下の理由から見送りました。
- 各マイクロサービスのOpenAPIを別場所で一括管理する可能性があり、すぐに作り直しになるリスクが見えていた
- 基本的に少人数のチーム構成でフロントエンド、バックエンド開発がお互いに密なコミュニケーションを取っている or フルスタックに1人で横断的に作業しているため、手元でのpull型だけでもしばらくの間は困ることがなさそう
具体的な方法としては、BFF側に型自動生成&PR作成するGitHub Actionsを定義しておき、各バックエンドのマイクロサービス側でOpenAPIを変更したPRがmainにマージされたタイミングでGitHub Actionsのrepository_dispatchを利用してBFF側の当該Actionsをトリガーする方法を構想していました。(マイクロサービス側のGitHub Actionsは共通化)
まとめ
早く対応しないとな〜と思っていた「OpenAPI -> TypeScript型自動生成」の仕組みを体験良くサクッと実現できたのは、openapi-typescript
とopenapi-fetch
の2つのライブラリのおかげです。openapi-typescript
で型生成だけを利用しても良いし、APIクライアントだけ別ライブラリと組み合わせることも可能なので、シンプルかつ柔軟な活用方法があります。型自動生成の仕組みを新規で作成する場合にはおすすめできます。
この2つのライブラリを組み合わせることで、FE <-> BFF <-> BE
という関係性の中で、特にBFF <-> BE
間の型定義と通信部分を完全に自動化することができました。これにより、インフラストラクチャ層の実装における認知負荷が大幅に低減され、開発者がビジネスロジックに集中できる環境が改善されたと思っています。
今回のような「複数のマイクロサービスが存在する環境でのBFF実装」において、型自動生成の仕組みを検討されている方には、ぜひ参考にしていただければと思います。
Discussion