ReadableなOpenAPI定義ファイルを書く

一行要約

OpenAPIの定義ファイルがyamlのままで読みやすくなるように、ファイル分割方法などを工夫した話です。

はじめに

以前にお話した通りN予備校の開発ではマイクロサービスアーキテクチャを採用し、バックエンドとフロントエンドの間に統一的なAPIGatewayを設けています。 そこで用いるAPI定義は長年社内wiki内で管理してきたのですが、2021年の前半にOpenAPIによる定義へ移行を行いました。

N予備校のバックエンド構成
N予備校のバックエンド構成

また、バックエンドチームでは2021年後半から全体的なリアーキテクチャリング計画を進めており、その一貫として,教材管理サービスのAPIのスキーマの全面整理に現在取り組んでいます。 その際に、APIGatewayと教材管理サービスの間のスキーマ定義もOpenAPIで行い、スキーマ駆動開発を取り入れていこうということになりました。 (スキーマ駆動開発についての取り組みについては、また今度の記事で詳しくお話ししたいと思います。)

ただ、チーム内で初めての取り組みだったこともあり、2021年前半に作成したフロント-APIGateway間のOpenAPI定義ファイルの書き方のルールはいろいろやりづらい点を感じていたので、そちらの反省を踏まえて、新しい書き方のルールを導入しました。

今回の記事は古い書き方で辛かった点と新しい書き方で工夫した点のお話です。 目指したところは、ツールを使わずYAMLのままでも読みやすいAPI定義ファイル、"Readable OpenAPI"です。

OpenAPI定義の標準仕様については、こちらのページを参考にしています。

Readable OpenAPIとは?

新ルール策定のコンセプトを一言で表すために、今回の記事を書くにあたってでっち上げた造語です。

OpenAPI定義ファイルは最終的には1つのJSONやYAMLのファイルとして結合されることが想定されており、そのファイルは非常に行数が多くなります。 (結合した状態のN予備校の教材管理サービスの外部向けAPI定義のJSONファイルは、約5000行ありました。)

これでは人間が読んで参照することは現実的ではないので、多くの場合、Swagger UIのようなドキュメンテーションツールに読み込ませて、HTMLページなどに変換して参照します。 しかし、この場合ツールを噛ませるひと手間が発生し、変換したファイルのホスティング環境が必要になるなど、定義ファイルを直接参照する場合に比べて取り回しは確実に悪くなります。 また、定義ファイルはGit管理することが多いと思いますが、そこではのレビューや差分表示などはツールを経由せずに見るので、人間が直接定義ファイルを読む機会はどうしても発生します。

OpenAPIでは$refを利用して複数のファイルに定義を分割することができますが、一般的なプログラミング言語のような名前空間の仕組みや慣例的なディレクトリ構成のテンプレートが存在しません。 さらに、ファイルを分割可能な境界も多く、めちゃくちゃ細かくファイルを分けることも、巨大な数個のファイルに分けることも可能になっています。 そのため、分割のルール次第ではむしろ何がどこに定義されているかがわかりづらくなり、ルールに慣れない新規メンバーなどにとって、かえって読みづらいものになってしまう可能性もあります。

OpenAPIの書き方の新ルールを決めるにあたっては、GitHubのWebUI上でも閲覧しやすく、ルールを知らなくても目的の情報がどこにあるかがわかりやすいようなドキュメントになるように心がけました。

既存ルールの不満点

APIGatewayの開発で用いていた、既存のopenAPIドキュメントの書き方のルールは以下のようなものです。 (今回の記事で用いる例は、コース1とチャプター2に関する部分を極限まで簡略化しています。openAPI定義の各ディレクティブも、必要最低限以外は実際の定義内容から省略しています。)

root/
  ├ openapi.yml
  |
  ├ paths/
  |  ├ api/
  |  |  └ v2/
  │  │     ├ courses.yml
  |  |     └ courses/
  │  │        ├ {id}.yml
  |  |        └ {course_id}/
  |  |           └ chapters/
  |  |              └ {id}.yml
  |  └ index.yml # パスの一覧
  |
  └ components/
     ├ request_bodies/
     |
     ├ responses/
     |  ├ api/
     |  |  └ v2/
     │  │     ├ courses.yml
     |  |     └ courses/
     │  │        ├ {id}.yml
     |  |        └ {course_id}/
     |  |           └ chapters/
     |  |              └ {id}.yml
     |  └ 404_resource_not_found.yml # ステータスごとのエラー定義
     |
     └ schemas/
        └ api/
           └ v2/
              ├ courses.yml
              └ courses/
                 ├ {id}.yml
                 └ {course_id}/
                    └ chapters/
                       └ {id}.yml

主な特徴としては、次の3点が挙げられます。

  1. paths/以下にindex.ymlが存在する
  2. ディレクトリ構造がAPIのパス構造と正確に対応する
  3. components/request_bodies/responses/schemas/の3つから構成される

各ファイルの内容はだいたいこのような感じです

# openapi.yml

openapi: 3.0.2
info:
  title: Sample API
  version: 1.0.0
paths:
  $ref: "fixed/paths/index.yml"
# paths/index.yml

/api/v2/courses:
  $ref: "./api/v2/courses.yml"
/api/v2/courses/{id}:
  $ref: "./api/v2/courses/{id}.yml"
/api/v2/courses/{course_id}/chapters/{id}:
  $ref: "./api/v2/courses/{course_id}/chapters/{id}.yml"
# paths/api/v2/courses/{id}.yml

get:
  summary: Course API
  operationId: getCoursesId
  parameters:
    - name: id
      in: path
      schema:
        type: integer
      required: true
  responses:
    $ref: "#/components/responses/api/v2/courses/{id}.yml#/get"
# components/responses/api/v2/courses/{id}.yml

get:
  200:
    description: 成功
    content:
      application/json:
        schema:
          $ref: "#/components/schemas/api/v2/courses/{id}.yml#/schema"
        examples: {} # 省略。実際はこのファイルに直接定義される
  404:
    $ref: "#/components/responses/404_resource_not_found.yml"
# components/schema/api/v2/courses/{id}.yml

schema:
  type: object
  properties:
    course:
      $ref: "#/course"

course:
  type: object
  properties:
    id:
      type: integer
    title:
      type: string
    chapters:
      type: array
      items:
        $ref: "#/chapter"

chapter:
  type: object
  properties:
    id:
      type: integer
    title:
      type: string

これらの特徴によって、以下のような使いにくさが生まれていました。

不満点1: 標準仕様外の分割を行っている

上記の特徴1、さらっと書いてありますが、この分割方法はOpenAPIの仕様では認められていません。 既存のルールでは仕様外の方法で分割したファイルを1つに結合するために、自前でスクリプトを書いて利用していました。

これによって、結合スクリプトの保守の余計な手間が生まれ、ファイル分割に対応したツールを利用する場合でも必ず定義を結合しなければならなくなってしまいました。

不満点2: ディレクトリ階層が深い

上記特徴2のおかげで、各エンドポイントのYAMLファイルはディレクトリ階層の非常に深いところに置かれます。 今回の例だと、一番深いところはapi/v2/courses/{course_id}/chapters/{chapter_id}.ymlのディレクトリ5つですが、 実際のサービスだと、api/v2/courses/{course_id}/chapters/{chapter_id}/movies/{movie_id}/playback.ymlみたいにもっと長いパスがあるので、もっと大変です。 各種パスパラメータは{}でくくって表現しているので、コマンドライン上でファイル名にエスケープが必要になることも、地味な煩わしさでした。

なお、OpenAPIにおける$refによる参照は、オブジェクトツリー上の絶対パスか、ファイルツリー上の相対パスで書く必要があるので、例えばパス定義ファイルからresponse定義ファイルを参照するためには、以下のように書かれなければならないはずです。

response:
  $ref: "../../../../../../../components/responeses/api/v2/courses/{course_id}/chapters/{id}.yml"

上記のサンプルはこのようになっていません。 これは、「#から始まるファイルパスは定義のルートディレクトリからの絶対パスとして扱う」という独自ルールをを設定し、前述の独自結合スクリプトで書き換えを行っていたためです。 階層の深いディレクトリ構造も、独自結合スクリプトを必要とする理由の1つになっていました。

不満点3: 1つのAPI定義を参照する際にたくさんのファイルを参照する必要がある

上記、特徴3に関する話です。 通常、あるエンドポイントの定義を参照したい場合は、最初にpaths/以下のファイルを見にいくと思います。 すると、レスポンス定義が外部参照になっているので、responses/以下のファイルへ移動します。 今度はYAMLのschema:以下が外部参照になっているので、schemas/以下のファイルを探しにいきます。 schemaの定義が分割されていれば、さらに別の定義ファイルへ適宜ジャンプします。

当たり前のことを書いているように見えますが、これが上記の深いディレクトリ構造と組み合わさると、非常に面倒くさくなります。 最初にルートディレクトリからpaths/以下のエンドポイント定義まで下りていきます。 通常、ディレクトリの階層は5段以上あります。 次にレスポンス定義を参照するために、一度ルートまで上ってから、今度はresponses/以下のディレクトリを下りていきます。 スキーマ定義への移動は、また上がって下りての繰り返しです。 まるで、階段上り下りのトレーニングです。

今回の例ではGETしか取り上げていないのでレスポンス定義だけですが、POSTやPUTの場合はリクエストボディもあるので、もう1セット追加です。 上り下りの途中で現在位置や目的地がわからなくなって迷子になることもよくあります。

また、移動の手間を抜きにしても、レスポンスのスキーマを確認しようとしてresponses/のファイルを確認しにいったら、そこではスキーマについては何もわからずschemas/を参照するように言われるのは、たらい回しにされているようでなかなかイライラさせられるものです。 レスポンススキーマの最上部の構造ぐらいはresponses/以下のファイルに直接定義してあってほしいものだと感じました。

不満点4: コンポーネントスキーマの同一性が不明瞭

パス構造と対応したディレクトリ構造を採用した帰結として、一部の共有コンポーネントを除いて、レスポンスやリクエストのスキーマ構造は各エンドポイントごとに定義ファイルを持ちます。 すると、内部的に同じデータを返すためのスキーマ定義が複数個所に存在することになります。

通常それらはほぼ同じ構造を定義しているのですが、それは一見してわかりづらくなっており、 一方で一部エンドポイントでは細かな違いが存在することがあるので、それを返すサーバ側の実装も、コードの共通化がやりにくくなっています。 そもそも定義ファイルが異なっているので、たまたま今は全く同じスキーマであっても、後の変更で分化しない保証がありません。 結果として、定義上も実装上も変更に弱いAPIスキーマとなってしまいました。

新ルールで工夫した点

上記の反省点を踏まえて、新ルールでのディレクトリ構造は以下のようになりました。

root/
  ├ openapi.yml
  |
  ├ paths/
  |  ├ course_index.yml
  |  ├ course.yml
  |  └ chapter.yml
  |
  └ components/
     ├ course.yml
     ├ chapter.yml
     └ error.yml
# openapi.yml

openapi: 3.0.3
info:
  title: Sample API
  version: 1.0.0
paths:
  /api/v3/courses:
    $ref: "./paths/course_index.yml"
  /api/v3/courses/{course_id}:
    $ref: "./paths/course.yml"
  /api/v3/courses/{course_id}/chapters/{chapter_id}:
    $ref: "./paths/chapter.yml"
# paths/course.yml

parameters:
  - name: course_id
    in: path
    schema:
      type: integer
    required: true
get:
  summary: Course API
  operationId: get_course
  responses:
    "200":
      description: 成功
      content:
        application/json:
          schema:
            title: get_course_response
            type: object
            properties:
              course:
                $ref: "../components/course.yml#/course"
              chapters:
                type: array
                items:
                  $ref: "../components/chapters.yml#chapter"
    "404":
      description: 指定されたidのコースが存在しない
      content:
        application/json:
          schema:
            $ref: "../components/error.yml#/error_response"
# components/course.yml

course:
  type: object
  properties:
    id:
      type: integer
    title:
      type: string

新ルールの特徴は基本的に前述の各不満点を解消したところにありますが、特に工夫したところとしては以下の3点になります。

工夫1: operationIdと対応したパス定義のファイル名を採用し、フラットなディレクトリ構造を実現した

URLパスに対応したディレクトリ構造は、「ディレクトリ階層が深い」という不満はあるものの、「どこにどのエンドポイント定義が書いてあるかが明らか」という利点がありました。 不満を解消しつつこの利点を残すため、新ルールではURLではなく、openAPI定義のoperationIdをパス定義ファイルのファイル名に関連づけ、<HTTPメソッド>_<パス定義ファイル名>というパターンのoperationIdの命名規則を作りました。 パス定義ファイルには同一パスの各HTTPメソッドの定義が書かれるので、例えばcourse.ymlにはget_coursepost_courseのようなoperationIdのエンドポイントが定義されることになります。

また、パスパラメータでIDを指定する単体取得のエンドポイントと、指定しない複数取得のエンドポイントを区別しつつも関連性を明確にするために、複数取得のエンドポイントのパス定義ファイル名は、<単体取得のファイル名>_indexとする命名規則も同時につくりました。

これにより、全てのパス定義ファイルをpaths/直下に置くことができ、全体像の見通しが格段に良くなり、ディレクトリという名の階段を何度も上り下りする必要がなくなりました。 さらに、パス定義ファイルからコンポーネント定義ファイルへの相対パスによる参照も簡潔になり、../の数に怯える必要もなくなりました。

# レスポンス定義からコンポーネントを参照する場合の比較
## 旧ルールでの正式な書き方をした場合
course:
  $ref: "../../../../../../schemas/api/v2/courses/{course_id}/chapters/{id}.yml"
## 新ルールの場合
course:
  $ref: "../components/course.yml#/course"

パス定義のファイル名とoperationIdを対応付けた副次的な効果として、パス一覧で参照先のファイル名がわかるため、openapi.ymlを見るだけで各パスに対応するoperationIdを確認することができるようになり、検索性が向上しました。

# openapi.yml (一部)
paths:
  /api/v3/courses:
    $ref: "./paths/course_index.yml" # GETのoperationIdはget_course_index
  /api/v3/courses/{course_id}:
    $ref: "./paths/course.yml" # GETのoperationIdはget_course
  /api/v3/courses/{course_id}/chapters/{chapter_id}:
    $ref: "./paths/chapter.yml" # GETのoperationIdはget_chapter

また、エンドポイントのパスが変化した場合もopenAPI定義上の影響箇所はopenapi.ymlのみで、ファイル移動などの必要が無かったため、開発初期のURLパス設計の試行錯誤がやりやすかったです。

工夫2: パス定義ファイルに含まれる情報量を増やした

旧ルールでのパス定義ファイルは記述されている情報量が少なく、parametersぐらいしかAPI利用者が直接利用できる情報が書いてありませんでした。 summarydescriptionもこのファイルに記述されていましたが、responses以下が丸々別ファイルにあるため、説明内容をスキーマ定義と突き合わせて理解するためには、パス定義とレスポンス定義、2つのファイルを読み比べる必要がありました。 (そして、2つのファイルの間にはディレクトリ階層の深い谷間が存在していました。)

# 再掲: 旧ルールのパス定義ファイル

get:
  summary: Course API
  operationId: getCoursesId
  parameters:
    - name: id
      in: path
      schema:
        type: integer
      required: true
  responses:
    $ref: "#/components/responses/api/v2/courses/{id}.yml#/get"

新ルールではパス定義ファイルの内容を拡張して、レスポンスステータスの一覧と、リクエストボディや成功時のレスポンススキーマのトップレベルの構造まで含めるようにしました。

これによって、後述の一元化されたコンポーネント定義の効果も相まって、パス定義ファイルがそれ単体でエンドポイント定義のドキュメントとして成立するようになりました。 また、リクエストやレスポンススキーマの定義場所を用意する必要がなくなり、components/以下の構造がすっきりしました。

# 再掲: 新ルールのパス定義ファイル

parameters:
  - name: course_id
    in: path
    schema:
      type: integer
    required: true
get:
  summary: Course API
  operationId: get_course
  responses:
    "200":
      description: 成功
      content:
        application/json:
          schema:
            title: get_course_response
            type: object
            properties:
              course:
                $ref: "../components/course.yml#/course"
              chapters:
                type: array
                items:
                  $ref: "../components/chapters.yml#chapter"
    "404":
      description: 指定されたidのコースが存在しない
      content:
        application/json:
          schema:
            $ref: "../components/error.yml#/error_response"

このようにresponsesrequestBody内に直接スキーマ定義を埋め込んだ場合、OpenAPIGeneratorでコード生成したときにInlineObject1のような無意味なクラス名がつけられてしまったため、titleディレクティブでoperationIdを元にしたオブジェクト名を指定しています。

工夫3: 再利用性を重視したcomponent定義

APIのリクエストボディやレスポンスで用いるコンポーネント定義は、全エンドポイントで共通のものを利用するようにしました。 これによって、旧ルールのURLパスによるディレクトリ分けのように、コンポーネント定義のコンテキストを指定する必要がなくなり、全てのコンポーネントファイルをcomponents/以下にフラットに置くことができます。

さらに、例の中では省略していますが、コンポーネントのプロパティは全てrequiredとして指定してコンテキストの影響を排し、同じ種類で同じidのコンポーネントはどのエンドポイントから返却された場合も全く同じ内容となるようにしました。

特に意識したのは、ネストの扱いです。

例えば、コース(course)の配下には複数のチャプター(chapters)が紐づきますが、コースをリストとして複数取得するときには、各コースのチャプター一覧は必要ないので効率化のために取得をスキップしたいです。 このときに、chapterscourseのプロパティとして定義してしまうと、「単体取得のときはchaptersを含み、複数取得の時にはchaptersを含まない」というコンテキストによる内容の差異が生まれてしまいます。

新ルールでのAPI定義ではこれを防ぐために、コンポーネント間の階層関係は極力コンポーネント自身の定義からは排し、各エンドポイントのレスポンススキーマのトップレベル構造として表現するようにしました。 その結果、サーバー側ではコンポーネント生成コードに分岐が不要になり、シンプルな形でコードの共通化が可能になりました。 一方クライアント側では、取得結果のキャッシュをコンポーネント単位で、種類とIDのみに基づいて気軽に行えるようになりました。

# コース単体取得APIのレスポンススキーマ比較

## 旧ルール: chaptersがcourseの下に存在する
schema:
  type: object
  properties:
    course:
      type: object
      properties:
        chapters:
          type: array
            items:
              type: object

## 新ルール: courseとchpatersが並立している
schema:
  type: object
  properties:
    course:
      type: object
    chapters:
      type: array
      items:
        type: object

コンポーネントが特定のエンドポイントとの結びつきを持たないようにしたため、パス定義のようにoperationIdを利用することはできず、ファイルの分割はcoursechapterのようなモデル単位でふんわりとしています。 今回の例は簡略化されているために、1ファイルに1コンポーネントずつ定義されいますが、実際は例えば、course.ymlのコンポーネントファイルには基本となるcourseコンポーネントの他に、course_typeのenum定義などの関連する定義も一緒に含まれています。

ただ、パス定義ファイルのような明確な規則化ができていないので、コンポーネント名から定義ファイルを探したり新たなコンポーネントの定義場所を決めたりするときに現段階でも多少の混乱が起きることがあり、この辺りは改善できる課題だなと感じています。

できなかったこと、やらなかったこと、やりたいこと

定義ファイルのhttpメソッドごとの分割ができなかった

初めの構想では下記の例3のように、特定パスに対応するエンドポイントごとにディレクトリを用意し、HTTPメソッド単位でファイルを分割したいと考えていました。 ファイル一覧を見るだけで、どのエンドポイントがどのHTTPメソッドに対応しているかわかるようにするためです。

paths/
 └ course/
   ├ get_course.yml
   └ post_course.yml

しかし、OpenAPI定義の分割ルールではPathItemオブジェクトのHTTPメソッド単位での分割は認められていなかったので、これは叶いませんでした。

結局、HTTPメソッドの対応状況を一覧化するための代案として、下記のようにパス一覧の定義部分で、対応しているHTTPメソッドをコメントする形になりました。

paths:
  /api/v3/courses:
    $ref: "./paths/course_index.yml" # GET
  /api/v3/courses/{course_id}:
    $ref: "./paths/course.yml" # GET, POST

ルートの定義ファイルにcomponentディレクティブを置かなかった

OpenAPI定義では、ルートとなるopenapi.ymlファイルにcomponentsディレクティブを定義してそこでコンポーネントの一覧やコンポーネント定義ファイルの参照を行い、各エンドポイント定義では外部ファイルではなくツリー構造上のcomponentsを参照する形が一般的になるかと思います。 しかし、今回の新しい定義ルールではあえて、componentsディレクティブを用意せず、各エンドポイント定義が直接外部ファイルを参照する形を採用しました。

理由の1つはopenapi.ymlのファイルを短くするためで、もしこのファイルにcomponentsの一覧まで含めると、個々の定義を$ref参照にしたとしても、含めない場合に比べて2倍以上の長さになってしまうことが予想されます。 原則として、長いファイルは短いファイルにくらべて可読性が低下します。

もう1つの理由は生ファイルを見たときのジャンプ回数の削減のためです。 定義ファイルを結合せずに参照ファイルを読む場合、ルートのcomponents一覧がある時には「各レスポンス定義 => openapi.yml` => 各コンポーネント定義ファイル」と2回ファイルを開く必要がありますが、レスポンス定義が直接外部ファイル参照すれば、「書くレスポンス定義 => 各コンポーネント定義ファイル」というようにファイルを開く回数を1回減らすことができます。

上記2つの理由から、直接yamlファイルを読む場合はcomponentsディレクティブが無い方が可読性が高くなるだろうと考え、利用しない決断をしました。

現在利用しているOpenAPIGeneratorでは、componentsディレクティブを用意しなくても$refの参照パスや前述のtitleディレクティブによってコンポーネントの単位を正しく補足してくれています。 ドキュメント生成系のツールだと、コンポーネントの同一性を把握して正しくドキュメント化してくれないことが予想されますが、可読性を保ってyamlを直接ドキュメントとして参照する運用ができている限りは問題にならないはずです。

exampleの定義は余力があればやりたい

今回のOpenAPIの導入ではモックサーバーの自動構築などの仕組みは利用しなかったため、exampleによるAPIレスポンス例の定義は記述しませんでした。 APIモックが自動で用意できるようになれば、API定義更新からフロント動作確認可能になるまでのリードタイムが短縮できるので、スキーマ駆動開発の導入が軌道に乗っていったら挑戦したい気持ちは有ります。

exampleを定義に含める場合は下記のように、paths/, componetns/と並べてexamples/のディレクトリを作り、その中でresponses/, request_bodies/, components/に分けて定義するのかなと、現状ではぼんやり考えています。

root/
  ├ openapi.yml
  ├ paths/
  ├ components/
  └ examples/
     ├ responses/
     │ └ get_course_response.yml
     ├ request_bodies/
     └ components/
       └ course.yml

ただし、この場合はコンポーネント間のidなどの整合性の担保や、ユースケースによるレスポンスの違いをどのように表現するかに一工夫必要そうです。

おわりに

新ルールで開発を始めてまだ日は浅いですが、現状ドキュメント化ツールを用いずとも支障なく運用できているので、試みは概ね成功しているかなと感じています。 GitHub上のyamlソースを直接ドキュメントとして利用すると、変更履歴や変更者が簡単に確認できる辺りが特に便利ですね。

皆様も、「OpenAPIといえばドキュメンテーションツール」という既存概念にとらわれずに、ReadableなOpenAPIの書き方を追求してみてはいかがでしょうか。

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。

カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com

脚注


  1. 「数学I ハイレベル」のように、科目やレベルに応じて設けられた一連の学習内容のまとまりです。

  2. 「因数分解」「2次関数の最大最小」のように、学習内容を単元レベルでまとめたものです。1対多の関係でコースに所属します。

  3. 実際はコースAPIにPOSTメソッドはありませんが、説明の都合上この節のみ定義しています。