スキーマ駆動開発を試してみた
経緯
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
モデルを用意します。各カラムの意味は某動画投稿サイトをイメージして頂けると分かると思います。
コントローラー
お試しなので 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
のインポートの順番
# 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 ドキュメントの信頼性を高く、保守し続けられる運用を考えよう。
参考
- 各公式ドキュメント、レポジトリ
- Rails + RSpec + OpenAPI3 + Committee でスキーマ駆動開発を運用する Tips - Timee Product Team Blog
- API がカオスってたプロダクトで OpenAPI 対応やってみた
- committee を使った OpenAPI3 のバリデーション - Qiita
-
ブログ書き終えてから思ったのですが、モノリシックがちな Rails と openapi-codegen を組み合わせることは少なく、やはり相性良さそうな go-swagger は実用例が多そうですね。というか go-swagger のスター数 6.5k じゃん。↩
-
committee はこの機能も提供しているよう。けっこう強力だな。参考 → committee を使った OpenAPI3 のバリデーション - Qiita↩