🍳

サクッと作る型安全なBFF開発環境 - マイクロサービス × OpenAPI自動生成

2024/12/04に公開

これは 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月)でも引き続き開発が活発ですね。
https://zenn.dev/micin/articles/openapi-typescript-with-type-puzzles

今回のシナリオとして、各マイクロサービスが各々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
# 一部抜粋/加工済

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のタイプミスやパラメータの誤りを完全に防ぐことができるようになりました。

https://openapi-ts.dev/openapi-fetch/

client.ts
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-typescriptopenapi-fetchの2つのライブラリのおかげです。openapi-typescriptで型生成だけを利用しても良いし、APIクライアントだけ別ライブラリと組み合わせることも可能なので、シンプルかつ柔軟な活用方法があります。型自動生成の仕組みを新規で作成する場合にはおすすめできます。

この2つのライブラリを組み合わせることで、FE <-> BFF <-> BEという関係性の中で、特にBFF <-> BE 間の型定義と通信部分を完全に自動化することができました。これにより、インフラストラクチャ層の実装における認知負荷が大幅に低減され、開発者がビジネスロジックに集中できる環境が改善されたと思っています。

今回のような「複数のマイクロサービスが存在する環境でのBFF実装」において、型自動生成の仕組みを検討されている方には、ぜひ参考にしていただければと思います。

株式会社エスマット

Discussion