はじめに
OpenAPIの定義を1つのbundleファイルにまとめ、そのbundleを入力にクライアント向けのAPIコードを生成する運用は、実務でよくあります。
一見すると便利な設計ですが、featureブランチごとにその生成物までGitで管理していると、並行開発のときにコンフリクトが一気に増えます。
複数の機能開発を並行して進め、長く分岐したブランチを順番にマージすると、OpenAPI由来の自動生成物が大量に競合することがあります。
今回は、そのときに感じたことを整理して、次の視点で考えます。
- どこまでが意味のある競合なのか
- どこからが再生成可能な差分なのか
- 「生成する」と「Gitで管理する」は別物なのか
- 生成物をどこで扱うべきか
何が起きたのか
構成は大きく次のような流れでした。
OpenAPIの元ファイル
↓
bundle生成
↓
bundleファイル
↓
クライアント向けAPIコード生成
├── api.ts
├── DTO / interface
├── docs/*.md
└── generatorの管理ファイル
各featureブランチでOpenAPI定義を変更し、そのたびにbundleや生成済みコードまでコミットしていました。
この状態でブランチを長く放置してからマージすると、同じ生成ファイルを複数のブランチが更新しているので、マージごとに大量の競合が発生します。
生成物の差分だけを見ても、修正したのが本当にAPIの意図なのか、生成ツールの差分なのかが分かりづらくなります。
元ファイルの競合と生成物の競合は分けて考える
最初に重要なのは、次の2つを分けることです。
- 元ファイルそのものの競合
- bundleや生成コードの競合
元ファイルが1つであれば、そのファイル自体を複数のfeatureブランチが触ると競合は起きます。APIの意味が変わる差分なら、どちらを採用するか確認する必要があります。一方で、同じ行の整形だけが重なった競合まで、常に設計判断になるわけではありません。
ただし、1つの競合を「元ファイルの変更」として扱うのと、「その結果として生成されたファイルまで手動で競合解消する」のでは、意味がかなり違います。
OpenAPI元ファイル ← 意図を確認して解消する競合
↓
bundle生成
↓
bundleファイル ← 再生成できる競合
↓
APIコード生成
↓
api.ts / DTO / docs ← 同じく再生成できる競合
元ファイルの競合は、多くの場合、どのAPI定義を採用するかという話です。
一方、bundleや生成コードは、元ファイルが決まれば再生成できるものが多いです。つまり、元ファイルの競合を解消したあとで再生成すればよい場合がある、ということです。
ここは理解しておく価値があります。
「生成が必要」と「Gitで管理する必要」は別
利用するgeneratorが入力としてbundleを必要とするなら、featureブランチでもbundleを生成する必要があります。外部$refを解決して元ファイルを直接読めるgeneratorであれば、必ずしもbundleは必要ありません。
ただし、次の2つは同じではありません。
bundleを生成する
≠
bundleをfeatureブランチにコミットする
featureブランチの実務では、たいてい次の流れで十分です。
OpenAPI元ファイルを変更
↓
bundleをローカルまたはCIで一時生成
↓
クライアント向けAPIコードを生成
↓
生成物の中でGit管理が不要なものはコミットしない
APIコード生成の入力として必要だからといって、途中生成物を必ずGitの履歴に載せる必要はありません。
もし生成物が完全に再生成可能で、Git管理する明確な理由がなければ、.gitignoreに入れてしまうのもシンプルな選択肢です。
「閲覧用に見たいからGit管理する」は別の理由
一方で、bundleをGit管理している理由が「統合後のOpenAPI定義をViewerで見たいから」というケースもあります。
OpenAPIでは$refを使って定義を複数ファイルに分けることができます。
components:
schemas:
User:
$ref: './schemas/user.yaml'
Viewerによっては、リポジトリ内の外部$refを解決できません。利用するViewerが、相対パスの外部参照と単一ファイル内の$refをそれぞれ扱えるかは、事前に確認が必要です。
そのとき、外部ファイルの内容を1ファイルへ集約したbundleがあると、Viewer側は単一ファイルを読むだけで済みます。通常のbundleは、外部$refを内部$refへ置き換えても、$ref自体は残ることがあります。$refを扱えないツール向けにすべて展開する処理は、bundleとは区別してdereferenceと呼びます。
openapi.yaml
├── $ref: schemas/user.yaml
├── $ref: schemas/order.yaml
└── $ref: paths/users.yaml
↓
bundle
↓
openapi-bundled.yaml
↓
Viewer
この用途自体は明確なメリットがあります。
ただし、ここでも次の2つは分けて考えるべきです。
Viewerで見たい
≠
各featureブランチでbundleをコミットしたい
閲覧用途で必要なのは、基本的には「確認したい時点のOpenAPI定義から生成されたbundle」だけです。
そのため、統合ブランチのあとでCIがbundleを生成し、Swagger UIやRedocに公開する、もしくはartifactとして保存する設計にするほうが自然です。
feature-A ─┐
feature-B ─┼→ 統合ブランチ
feature-C ─┘
↓
CIでbundle生成
↓
外部ファイルを1ファイルへ集約
↓
Viewerへ公開
この方式なら、「Viewerでbundleが必要」という要件は満たしつつ、各featureブランチで同じbundleを更新し続ける必要はありません。
生成物まで手動で競合解消するのは避けたい
bundleが元ファイルから再生成できるなら、本来の流れは次のようになります。
元となるOpenAPI定義をマージ
↓
必要な競合を解消
↓
bundleを再生成
↓
APIコードを再生成
ここで生成されたbundle、api.ts、DTO、docs、generatorの管理ファイルの内容を、いちいち人間がマージしていては、次のような問題が出ます。
- 元のOpenAPI定義と生成物の内容がズレる
- 競合解消時にAPI定義を片方だけ残してしまう
- 再生成したときに大量の差分がまた出る
- レビュー時に、本質的な変更と生成差分を区別しにくい
- 同じ意味の競合を複数の生成ファイルで繰り返す
生成物をGit管理している場合でも、内容を手作業でマージする必要はありません。正本の競合を解消したあと、生成物は片方を採用または削除してGit上の競合を解消済みにし、生成コマンドの結果で置き換えます。人間が意味を判断すべき対象を正本の差分へ絞れるため、管理しやすくなります。
生成コードも必ずGit管理するわけではない
bundleだけでなく、OpenAPI Generatorが出力するAPIクライアントやDTO、ドキュメント、管理ファイルにも同じ考え方が使えます。
たとえば次のようなものです。
api.ts
models/*.ts
docs/*.md
.openapi-generator/FILES
これらが完全に再生成可能なら、まずは次を確認します。
- Git管理する明確な理由があるか
- CIやビルド環境で再生成可能か
- 生成差分をレビューする価値があるか
- 生成物そのものを配布対象にしているか
もちろん、生成済みコードをコミットする設計にも理由があります。
- CIやビルド環境でgeneratorを実行したくない
- 差分をレビューしたい
- 生成済みコードをそのままパッケージとして扱いたい
ただ、単に「generatorが出力したからそのままコミットする」という運用は、構成が崩れやすいです。Git管理しない場合は、ローカル開発・テスト・本番ビルドのどこで生成するかを、スクリプトとして固定しておく必要があります。
改善案: featureでは生成するがコミットしない
最も現実的な解決策は、featureブランチでは生成するが、Git管理が不要な生成物はコミットしない、という運用です。
各featureを統合
↓
OpenAPI元ファイルの競合を解消
↓
bundleを再生成
↓
APIコードを再生成
↓
必要な生成物だけ成果物として利用
↓
release / deploy
この運用なら、元ファイルに起きる競合は残りますが、同じ競合がbundleからapi.ts、docsまで増幅していく状態は減らせます。
デプロイ対象が統合後のブランチやreleaseブランチであれば、生成物をどの段階で必要とするかを整理しなおす余地もあります。
CIで整合性を守る
生成物を最後までGit管理したい場合でも、CIで元ファイルとの整合性を確認できます。ただし、generatorのバージョン、設定、テンプレート、依存関係を固定し、同じ入力から同じ出力が得られる状態にしておくことが前提です。
npm ci
npm run openapi:lint
npm run openapi:bundle
npm run openapi:generate
git diff --exit-code -- path/to/openapi-bundled.yaml path/to/generated
この確認は、比較対象の生成物がGitで追跡されている場合に使えます。生成物が元ファイルから再生成できる内容と違っていれば、CIを失敗にできます。
逆に、生成物をGit管理しない場合は、同一ジョブ内で後続のビルドやViewer公開まで行うか、artifactとしてアップロードして後続ジョブで明示的にダウンロードします。CIのジョブ間で作業ディレクトリが共有されるとは限りません。
まとめ
今回の教訓は、再生成可能な生成物については、通常のソースコードと同じように扱わないほうがよい、ということです。
- OpenAPIの元ファイルは、APIの意図を確認して解消する競合対象になることが多い
- しかし、
bundleや生成コードの競合まで手動で解消する必要はないことがある - 「生成する」と「Gitで管理する」は別問題だ
- bundleとdereferenceは別の処理であり、利用するツールが必要とする形式を確認する
- Viewer用途とfeatureブランチでのGit管理は分けられる
- 統合後のCIでbundleを再生成する設計は十分に有効だ
- APIクライアント、DTO、docs、generator管理ファイルも、Git管理の必要性を個別に判断するべきだ
結局のところ、競合を解消する技術だけでなく、「なぜこの生成物をGit管理しているのか」を確認することが大事です。
この視点を持っておくと、featureブランチでのOpenAPI bundle運用はかなり整理しやすくなります。