0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenAPIのbundleファイルを各featureブランチで生成したらコンフリクトだらけになった話

0
Posted at

はじめに

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コードを再生成

ここで生成されたbundleapi.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.tsdocsまで増幅していく状態は減らせます。

デプロイ対象が統合後のブランチや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運用はかなり整理しやすくなります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?