OpenAPIを使ったTVer APIのスキーマ駆動開発 – TVer Tech Blog


こんにちは。 id:takanamito です。
以前書いた記事「TVerバックエンドAPIのリアーキテクチャ」では、TVerAPIアーキテクチャを移行した背景と全体設計について紹介しました。
本記事では、アーキテクチャから1段ブレークダウンして、OpenAPIによるスキーマ駆動開発の実践について現場レベルの具体的な運用の話を書きます。

TVerにはcontents-api, user-api, manager-apiという3種のAPIが存在します。それぞれの詳細は前回の記事でご確認ください。
この記事ではcontents-apiを例に紹介を進めます。

APIのリアーキテクチャ時点でAPIスキーマ管理にOpenAPIを採用しコード生成の仕組みを用意して、人力で書くコードの量を減らす取り組みをしています。
新規APIが登場する開発では、以下のようなフローで進みます。

  • フロントエンド(アプリ/Web)のエンジニアと共に画面仕様を読み取り、OpenAPIによるスキーマ定義
  • APIスキーマの合意が取れれば、バックエンドとフロントエンドで並列で開発をスタート
    • バックエンド:スキーマからコード生成して実装を進める
    • フロントエンド:OpenAPIのスキーマから生成したモックサーバーを使って実装を進める
  • 双方の開発が終われば、APIとフロントエンド実装の結合をしてリリース

今回はバックエンドの開発にフォーカスして開発のフローを記載します。

APIスキーマ管理をしたいのであれば、GraphQLやgRPC + protocol bufferなどの選択肢もありますが、TVerではOpenAPIの導入を決めました。
大きくは以下のような理由です。

gRPCはWebブラウザからのリクエストにいまだenvoyなどプロキシサーバーが必要で、インフラ構成から見直す必要がありました。
ConnectなどgRPC互換のプロトコルを使えばenvoyは不要になりますが、HTTP POSTリクエストでやりとりをすることになり、CDNキャッシュを多用する構成を前提としているため採用が難しかったです。素直にHTTP GETリクエストをキャッシュしたい。
細かい話をすればConnectにはHTTP GETで通信する機能がありますが、全ての技術検証を終えて本番投入するには相応に時間がかかり、我々が目標とする期間内で本番投入することは難しそうだったので導入を見送っています。

GraphQLも社内に経験者が複数いましたが、CDNキャッシュ問題・有名なN+1問題など、解決策自体はあるものの技術的な検証も含めると
gRPC同様、技術検証などに費やす時間が多くなりそうなことから選びませんでした。

反面OpenAPIでスキーマ管理するAPIは既存APIからのマイグレーションもしやすく、それを呼び出すフロントエンドチームも移行するコストが減ることから採用に至っています。

とはいえ、gRPCやGraphQLを採用することによるメリットもあるので、小さく試せる新環境の開発ではOpenAPI以外の選択肢も含めた検証が社内では行われています。

ここからは具体的なOpenAPI環境の仕組みの話です。

3種のAPIに対応する形でOpenAPIのファイルも3つ作成するようにしています。
1つの大きなYAMLファイルを触らなくていいようにファイルは分割して配置されており、一部のobject定義は共通化されています。

path/to/openapi/
├── build/              # バンドル後のファイル
│   └── contents.yml
├── contents.yml         # エントリーポイント
├── components/
│   └── schemas/        # 共通スキーマ定義
│       ├── error.yml
│       ├── episode.yml
│       └── series.yml
└── paths/
    └── contents/
        └── v1/
            ├── episodes.yml
            └── series.yml

細かいスキーマ設計のローカルルール

スキーマ設計をする際に、バックエンドチームのローカルルールとしていくつか意識しているものがあります。

  • HTTPレスポンスのrootには必ずキーを持たせる
  • oneOf, anyOfを使わない

HTTPレスポンスのrootには必ずキーを持たせる

レスポンスのスキーマの拡張性を踏まえたルールです。
rootにキーを持っておけば、後から新たな要素を追加したくなった際に拡張が可能です。

リソースの一覧を返すAPIなどではいきなりリストを返却しがちですが、当初の予定にはなかった新たな要素を追加できなくなってしまうため、必ずrootにキーをもたせるようにしています。

// GOOD
{
  "episodes": [
    { "id": "ep123", "title": "第1話" },
    { "id": "ep124", "title": "第2話" }
  ],
  // "count": 100, ←後から新たなキーを追加したくなったら足せる
}

// BAD
[
  { "id": "ep123", "title": "第1話" },
  { "id": "ep124", "title": "第2話" }
]

oneOf, anyOfを使わない

OpenAPIには複数の型のうちいずれかを返すことを表現できる oneOf, anyOf が提供されていますが、利用しないことにしています。
これは各言語のコードジェネレーターとの相性が理由です。ユニオン型のような型が提供されていればいいのですが、そうでない言語では複数の型を合成した抽象的な型としてコード生成されたりして、結局開発者が型を判定するようなロジックを書く必要がありました。

結果として、複数の型のデータを返す可能性があるAPIは「返却される型を識別するenum」を提供することで表現しています。

// TVerが扱うVODエピソード(通常の番組配信)/LIVEエピソード(リアルタイム番組配信)のうち、いずれかの一覧を返すAPI
{
  "episode_type": "live", // type: string, enum: [live, vod]
  "vod_episodes": [],
  "live_episodes": [
    {...},
    {...},
  ],
}

3つのツールを組み合わせてOpenAPIのコード生成ワークフローを構築しています。

redoclyを使ってymlファイル結合

分割されたOpenAPIファイルを1つにバンドルします。$refの解決と<<: の展開を行います。

npx @redocly/cli@latest bundle path/to/openapi/api.yml -o path/to/openapi/build/api.yml

Spectralを使ったLint

OpenAPIの記述が社内ルールに準拠しているかを自動チェックします。

formats: ["oas3_0"]
extends:
  - [spectral:oas, all]
rules:
  contact-properties: false
  openapi-tags-alphabetical: error
  oas3-api-servers: false
  info-contact: false
  info-license: false
  license-url: false
 
  tver-example-exists-in-responses:
    description: responsesスキーマにはexampleを定義してください
    severity: error
    message: "example が定義されていません"
    given: $.paths..responses..properties.[?(@.type == 'integer' || @.type == 'string' || @.type == 'boolean' || @.type == 'number')]
    then:
      - field: example
        function: defined

oapi-codegenを使ったGoのコード生成

バンドルされたOpenAPIファイルからGoのコードを生成します。
生成対象のAPIはOperationIDを記述することで表現します。よってスキーマ定義だけ先行しているAPIはコード生成されません。

package: oaigen
generate:
  models: true
  chi-server: true
  strict-server: true
  embedded-spec: true
compatibility:
  always-prefix-enum-values: true
output-options:
  skip-prune: true
  include-operation-ids:
    - ...

生成フローの自動化

上記の3種の操作はmakeコマンドを提供しています。
OpenAPIスキーマを書いたあとは、このmakeコマンドを上から順番に実行してコード生成とAPI実装を進めることになります。
contents-api, user-api, manager-apiという3種のAPIが存在するため、TARGETという環境変数にいずれかの名前を渡すことで実行可能です。

# OpenAPIのバンドル
bundle-oai:
    @echo "Bundling OpenAPI for $(TARGET)..."
    @redocly bundle path/to/openapi/$(TARGET).yml \
        -o path/to/openapi/build/$(TARGET).yml

# Lint実行
lint-oai:
    @echo "Linting OpenAPI for $(TARGET)..."
    @spectral lint path/to/openapi/build/$(TARGET).yml

# コード生成
generate:
    @echo "Generating code..."
    @go generate ./...

API開発はフロントエンドの開発と並行で進むことが多いです。
OpenAPIのスキーマだけ提供してもフロントエンドからAPIを呼び出して動作確認ができないため、モックサーバーを自動生成してフロントエンドの開発が円滑に進むようにしています。

モックサーバーはprism-cliを使って起動します。

mock-api:
    npx @stoplight/prism-cli mock path/to/openapi/build/$(TARGET).yml

モックサーバーが返すレスポンスはOpenAPIスキーマexample に設定された値を利用します。
なるべくTVerサービスっぽい値でレスポンスを返すために、Lintでexampleの記載を強制しています。

ローカルでも起動できますが、gitのmainブランチにある最新のAPIスキーマを使ったモックサーバーをECS上にデプロイするGitHub Actionsも整備しているため
フロントエンド開発者はいつでも、最新のAPIスキーマを参照したモックサーバーを利用することができます。

運用する中で課題も出てきています。

モックサーバーのレスポンスの表現力不足

先述の通りモックサーバーはOpenAPIスキーマのexampleの値を利用するため、固定値のレスポンスを返すことになります。
ところが、これだとリクエストパラメータによって返却値が変わるケースに対応できません。
わかりやすい例を挙げると、ページネーションが存在するAPIレスポンスの1ページ目、中間ページ、最終ページといったバリエーションを表現できなかったりします。

現状こういったケースはprism-cliを使った解決を諦めて、各クライアントごとに個別のモックデータを生成して対応している状況です。

OpenAPIによるスキーマ駆動開発を導入して1年弱が経過し、効果を実感しています。

  1. 型安全性の確保:自動生成される型により、コンパイル時にエラーを検出
  2. ドキュメントとコードの乖離を防ぐ:コードと仕様書の乖離がなく、常に最新の状態を維持

TVerではこれからもユーザーに最高の動画視聴体験を提供するため、技術的な改善を続けていきます。
本記事が、OpenAPIを活用したAPI開発を検討されている方々の参考になれば幸いです。


TVerでは、一緒にサービスを成長させていく仲間を募集しています。ぜひ以下の採用ページをご覧ください。

TVer採用情報




Source link

関連記事

コメント

この記事へのコメントはありません。