スキーマ駆動開発を試してみた

経緯

API 開発において、ドキュメントは重要です。ではそれをどう作るのか。

(RESTful) API のマシンリーダブルな仕様書に OpenAPI/Swagger を採用し、Swagger UI でドキュメントを閲覧する、というのは近年よく見られる技術スタックです。一方、いざ開発においてスキーマをいつどのように定義するのか、というのは以下の2つに大別されると思われます。

1 つ目のアプローチは、実際の API の実装から OpenAPI を作り出す方法です。自分のアルバイト先でもこの方法が採用されており、Rswag を用いて rspec のテストコードから Swagger ファイルを自動生成しています。このアプローチは、 API サーバの実装から絶対に乖離しないという圧倒的なメリットを持っており、フロント側では安心して Swagger ファイルを利用することができます。一方のデメリットとして、開発全体から見ると「API の実装 → フロントの実装」というフローが固定されてしまい、ともすれば API 開発がボトルネックとなってしまう恐れがあります。

そこで 2 つ目のアプローチとして、ピュアな Schema Driven Development (SDD) を採用するという方法があります。すなわち OpenAPI の仕様書を先に作り、それに則り API 開発 / フロント開発を進めるという手法です。フロント側は特に変更点がありませんが、API 側としては Test Driven Development のような開発スタイルを求められるようになります。

後者の SDD について試したことがなかったので、Pros/Cons を理解するためにも超簡単に実装してみました。

実装

環境

(committee の GitHub スター数は rswag の半分ほどなので、やっぱり前者のアプローチを用いることが多いのでしょうか 🤔 1)

OpenAPI のドキュメント

とりあえず適当に書きます。

openapi: 3.0.1
info:
  title: Swagger Example
  description: Example API
  version: 1.0.0
tags:
  - name: Channel
    description: Channels on YouTube
  - name: Video
    description: Videos on YouTube
paths:
  /channels:
    get:
      tags:
        - Channel
      summary: Get an index of channels
      operationId: getChannels
      parameters:
        - name: page
          in: query
          description: page
          required: false
          schema:
            type: integer
        - name: per
          in: query
          description: per
          required: false
          schema:
            type: integer
      responses:
        200:
          description: successfully operated
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/channel"
        404:
          description: failed to operate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/error"
  /videos:
    get:
      tags:
        - Video
      summary: Get an index of videos
      operationId: getVideos
      parameters:
        - name: page
          in: query
          description: page
          required: false
          schema:
            type: integer
        - name: per
          in: query
          description: per
          required: false
          schema:
            type: integer
      responses:
        200:
          description: successfully operated
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/video"
        404:
          description: failed to operate
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/error"
components:
  schemas:
    error:
      type: object
      properties:
        status:
          type: integer
        message:
          type: string
      additionalProperties: false
      required:
        - status
        - message
    channel:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        channel_id:
          type: string
        description:
          type: string
          nullable: true
      required:
        - id
        - title
        - channel_id
    video:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        video_id:
          type: string
        channel_id:
          type: integer
        channel:
          $ref: "#/components/schemas/channel"
        description:
          type: string
          nullable: true
        published_at:
          type: string
          nullable: true
      required:
        - id
        - title
        - video_id
        - channel_id

モデル

Channel モデルと Video モデルを用意します。各カラムの意味は某動画投稿サイトをイメージして頂けると分かると思います。

f:id:puyobyee18:20210609021735p:plain
SDD モデル ER図

コントローラー

お試しなので Channel Video のレコード全てを返す 脳死 GET /channels GET /videos を用意します(kaminari のページネーションを導入しているので page, per は受け取ります)。

JSON シリアライズ

ActiveModelSerializer を使います。 とりあえず工夫することも無いので attributes(*Channel(or Video).column_names) で全部返します。

committee 導入

公式の README だけでは詰まることも多かったので、詳しく記述していきたいと思います。

gem インストール

Gemfile に以下を記述。

gem 'committee'
gem 'committee-rails'

E2Eテストでのみ用いる場合は group は dev, test で十分ですが、リクエストのバリデーション機能も提供しているので :default グループにインストールしていいと思います。

config を書く

rswag のようなインストールコマンドは存在しないので、config は自分で設定します。 書く場所は config/initializers/ 下でもよいのですが、とりあえずテスト時だけでよいので spec/spec_helper.rb に書くことにしました(と言いつつ helper ファイルが何者なのか分かってない & リクエストバリデーションのために config ファイルを作ったほうがいいかも)。

require "committee"
require "committee/rails"

RSpec.configure do |config|
  ...
  (中略)
  ...
  # Config committee
  config.include Committee::Rails::Test::Methods
  config.add_setting :committee_options
  config.committee_options = {
    schema_path: Rails.root.join('schema/schema.yml').to_s,
    old_assert_behavior: false,
    # prefix: "/v1",
    parse_response_by_content_type: false
  }
end

ここに定義している committee_options が committee-rails の README に書いてあるメソッドです。

また注意点として 2 つ挙げられます。

  • committee-rails のインポートは require committee/rails と書く
    • ラッパーライブラリを使う経験があまり無かったので詰まった
  • spec_helper.rb のインポートの順番
    • rspec インストール時に自動生成される rails_helper.rb は、以下のようになっていると思います。
    • しかし先程 committee の設定で Rails をインポートしているため、これではダメなよう。順番を変えて require 'spec_helper'require File.expand_path('../config/environment', __dir__) の後ろにしましょう。
# This file is copied to spec/ when you run 'rails generate rspec:install'
require 'spec_helper' # この行を後ろに持っていく
ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../config/environment', __dir__)
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'
...

検証

spec を実行してみます。

$ bundle exec rspec spec/requests/
..

Finished in 0.07942 seconds (files took 0.7994 seconds to load)
2 examples, 0 failures

成功したけどこれじゃ分からんので型情報を改変します。

components/schema/channel の description にある nullable: true を消してみましょう。

$ bundle exec rspec spec/requests/channels_spec.rb
F

Failures:

  1) Channels GET /channels returns an index of channels
     Failure/Error: assert_response_schema_confirm

     Committee::InvalidResponse:
       #/components/schemas/channel/properties/description does not allow null values
     # ./spec/requests/channels_spec.rb:13:in `block (3 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     # OpenAPIParser::NotNullError:
     #   #/components/schemas/channel/properties/description does not allow null values
     #   ./spec/requests/channels_spec.rb:13:in `block (3 levels) in <top (required)>'

Finished in 0.04507 seconds (files took 0.78499 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/requests/channels_spec.rb:11 # Channels GET /channels returns an index of channels

お~

次は video_id を string → integer にしてみましょう。

$ bundle exec rspec spec/requests/videos_spec.rb
F

Failures:

  1) Videos GET /videos returns an index of videos
     Failure/Error: assert_response_schema_confirm

     Committee::InvalidResponse:
       #/components/schemas/video/properties/video_id expected integer, but received String: "HOGEHOGE123"
     # ./spec/requests/videos_spec.rb:13:in `block (3 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     # OpenAPIParser::ValidateError:
     #   #/components/schemas/video/properties/video_id expected integer, but received String: "HOGEHOGE123"
     #   ./spec/requests/videos_spec.rb:13:in `block (3 levels) in <top (required)>'

Finished in 0.06637 seconds (files took 0.8193 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/requests/videos_spec.rb:11 # Videos GET /videos returns an index of videos

ええやん

感想

微妙。

何より OpenAPI 書くのがだるい。

広義「プロダクト開発に直接関係ないもの」の心理的生産コストが高い場合、120%使われなくなるのが定めではないでしょうか。Stoplight Studio などを使えば GUI でも OpenAPI を錬成できるようですが、書き心地はしっくりこない。 rswag からわざわざ移行するほどかと言われれば……。

考察

とは言え、やはり大規模開発になるとスキーマ駆動開発が圧倒的優位に立つことは間違いないです。

そもそも Rails 一つ取っても、routes から controller、serializer、テストなど逐一スキーマの整合性を取りながら開発を進めていくのはかなり骨が折れます。しかも手作業。さらにアプリケーションの規模が大きくなり、責務が複雑になるからと言ってアプリケーション / スキーマを分割したくなったら?モノリシックな API では対応しきれず、新たに Go でマイクロサービスを始めたい!と決まったら?………などなど。実装 to OpenAPI には限界があります。

OpenAPI はスキーマ定義として確立しており、随する codegen も数多く開発されています。 スキーマファーストな開発スタイルに移行し、モデルもコントローラも極力 codegen で自動生成、可能であれば E2E テスト時だけではなく、モックサーバや実際のリクエストのバリデーションにも2使えたらな~というのがやはり理想的な開発になるのではないでしょうか?大規模開発している企業さんってどうしているんでしょうね。気になるところです。

逆に言えば、中規模かつモノリシックな API に対しては、「実装からスキーマを生成する」アプローチはかなりいい感じに機能すると思います。長蛇の YAML ファイルを読み解く必要もなく、仕様書と開発との打ち返しもありません。「API → フロント」に固定される開発フローが問題ない限り、ある程度開発スピードが求められるベンチャー初期のプロダクトや、規模感が小さい個人開発では有効な手段であると感じました。

結論

API ドキュメントの信頼性を高く、保守し続けられる運用を考えよう。

参考


  1. ブログ書き終えてから思ったのですが、モノリシックがちな Rails と openapi-codegen を組み合わせることは少なく、やはり相性良さそうな go-swagger は実用例が多そうですね。というか go-swagger のスター数 6.5k じゃん。

  2. committee はこの機能も提供しているよう。けっこう強力だな。参考 → committee を使った OpenAPI3 のバリデーション - Qiita