こんにちは、ミツカリCTOの塚本こと、つかびー(@tsukaby0) です。
弊社では数年前からWeb API開発においてOpenAPIおよびスキーマファーストの開発スタイルをとっています。
今回の記事ではスキーマファーストの開発に interagent/committee を使っていましたが、その中の機能の一つである RequestValidation を廃止した話をします。
- スキーマファースト
- スキーマファーストの利点
- スキーマファーストを実現する interagent/committee
- committeeのRequestValidationをやめた話
- RequestValidationを廃止した
- RequestValidationが不要ならばcommitteeも不要なのか
スキーマファースト
スキーマファーストの開発とは、Web APIを開発する際にまずOpenAPI(旧Swagger)仕様やGraphQLスキーマなどの形式で仕様を先に定義し、その後で実装を行うことを指します。
より具体的なイメージとしては、以下のような形です(※サンプルコードは生成AIコードですし、本筋ではないので深く読み込む必要はないです)。
まず以下のようなOpenAPI YAMLを定義して、
paths: /books: get: summary: 書籍一覧取得 description: 書籍の一覧を取得します。検索条件やページネーションに対応しています。 operationId: getBooks tags: - books parameters: - name: title in: query description: 書籍タイトルで検索(部分一致) required: false schema: type: string example: "JavaScript" - name: author in: query description: 著者名で検索(部分一致) required: false schema: type: string example: "山田太郎" - name: genre in: query description: ジャンルで検索 required: false schema: type: string enum: [programming, business, novel, science, history] example: "programming" - name: page in: query description: ページ番号(1から開始) required: false schema: type: integer minimum: 1 default: 1 example: 1 - name: limit in: query description: 1ページあたりの取得件数 required: false schema: type: integer minimum: 1 maximum: 100 default: 20 example: 20 - name: sort in: query description: ソート順 required: false schema: type: string enum: [title_asc, title_desc, published_date_asc, published_date_desc, price_asc, price_desc] default: title_asc example: "published_date_desc" responses: '200': description: 書籍一覧取得成功 content: application/json: schema: type: object properties: books: type: array items: $ref: '#/components/schemas/Book' pagination: $ref: '#/components/schemas/Pagination' required: - books - pagination example: books: - id: 1 title: "JavaScript完全ガイド" author: "山田太郎" isbn: "978-4-12-345678-9" published_date: "2023-06-15" price: 3200 genre: "programming" description: "JavaScript言語の基礎から応用まで詳しく解説" stock: 25 - id: 2 title: "React実践入門" author: "佐藤花子" isbn: "978-4-98-765432-1" published_date: "2023-08-20" price: 2800 genre: "programming" description: "Reactを使ったWebアプリケーション開発の実践的な手法" stock: 15 pagination: current_page: 1 per_page: 20 total_pages: 5 total_items: 98 has_next_page: true has_previous_page: false '400': description: リクエストパラメータが不正 content: application/json: schema: $ref: '#/components/schemas/Error' example: error: code: "INVALID_PARAMETER" message: "pageパラメータは1以上の整数である必要があります" '500': description: サーバーエラー content: application/json: schema: $ref: '#/components/schemas/Error' example: error: code: "INTERNAL_SERVER_ERROR" message: "サーバー内部エラーが発生しました"
(任意で)このAPIの仕様をレビューし、次に、実装を行うことをスキーマファーストと言います。
class Api::V1::BooksController < ApplicationController before_action :authenticate_user! # GET /api/v1/books def index @books = Book.all # 検索条件の適用 apply_search_filters # ソート条件の適用 apply_sort_order # ページネーション @total_items = @books.count @books = @books.page(page_param).per(limit_param) # レスポンス作成 render json: { books: books_json, pagination: pagination_json }, status: :ok rescue StandardError => e Rails.logger.error "Books index error: #{e.message}" render json: { error: { code: 'INTERNAL_SERVER_ERROR', message: 'サーバー内部エラーが発生しました' } }, status: :internal_server_error end private def apply_search_filters # タイトル検索 if params[:title].present? @books = @books.where('title ILIKE ?', "%#{params[:title]}%") end # 著者検索 if params[:author].present? @books = @books.where('author ILIKE ?', "%#{params[:author]}%") end # ジャンル検索 if params[:genre].present? @books = @books.where(genre: params[:genre]) end end def apply_sort_order case params[:sort] when 'title_asc' @books = @books.order('title ASC') when 'title_desc' @books = @books.order('title DESC') when 'published_date_asc' @books = @books.order('published_date ASC') when 'published_date_desc' @books = @books.order('published_date DESC') when 'price_asc' @books = @books.order('price ASC') when 'price_desc' @books = @books.order('price DESC') else @books = @books.order('title ASC') # デフォルト end end def page_param page = params[:page].to_i page > 0 ? page : 1 end def limit_param limit = params[:limit].to_i case limit when 1..100 limit else 20 # デフォルト end end def books_json @books.map do |book| { id: book.id, title: book.title, author: book.author, isbn: book.isbn, published_date: book.published_date&.strftime('%Y-%m-%d'), price: book.price, genre: book.genre, description: book.description, stock: book.stock } end end def pagination_json { current_page: @books.current_page, per_page: @books.limit_value, total_pages: @books.total_pages, total_items: @total_items, has_next_page: @books.next_page.present?, has_previous_page: @books.prev_page.present? } end # パラメータのバリデーション def validate_params errors = [] # ジャンルのバリデーション if params[:genre].present? && !Book::GENRES.include?(params[:genre]) errors << 'genreパラメータが不正です' end # ソートのバリデーション valid_sorts = %w[title_asc title_desc published_date_asc published_date_desc price_asc price_desc] if params[:sort].present? && !valid_sorts.include?(params[:sort]) errors << 'sortパラメータが不正です' end # ページのバリデーション if params[:page].present? && params[:page].to_i < 1 errors << 'pageパラメータは1以上の整数である必要があります' end # リミットのバリデーション if params[:limit].present? && (params[:limit].to_i < 1 || params[:limit].to_i > 100) errors << 'limitパラメータは1以上100以下の整数である必要があります' end if errors.any? render json: { error: { code: 'INVALID_PARAMETER', message: errors.join(', ') } }, status: :bad_request return false end true end end
スキーマファーストの利点
スキーマファーストの利点は色々あります。
- 実装の前に設計や仕様が明確になる
- 仕様がYAML or JSONとして残るため、コードよりは仕様が明瞭(人によってはコードの方が理解しやすい場合もある)
- 関連ツールによって仕様ファイル(YAML or JSON)からモックサーバーを立ち上げたり、コードの自動生成ができる
- バックエンド側の実装完了を待たずにモックサーバーだけでフロントエンドの開発が進められる (※2015ごろから流行った記憶で、最近はあまり聞きませんが、まだまだやっているところはあるはず)
- ミツカリでは実際にフロントエンドのTS用に仕様ファイルから型およびAPI clientのコードをgenerateしています
ただし、スキーマファーストは完璧ではなく人によってはコードファーストのスタイルを好みます。私はどちらかというとスキーマファーストですが、ある程度辛さも体験しているので気持ちはわかります。本筋から逸れるので詳細には書きませんが、スキーマファーストのよくある問題としてはスキーマと実装が乖離することです(fooというjsonフィールドが仕様としてはあるはずなのに、実装はされていない、というようなもの)。この点はコードファーストの場合はほとんどの場合、コードから仕様ファイルを生成する形を取るため、実装の乖離は起きないです。
スキーマファーストを実現する interagent/committee
interagent/committee というRubyのライブラリがあります。これについては既に多数の記事が出ているため、詳細は割愛しますが、一言で言うとOpenAPIの仕様ファイルを読み取ってバリデーションなどを行ってくれるものです。
@ota42y さんがコミッターであり、2018年ごろから日本で流行らせてくれたような認識です。
ota42yさんは ota42y/openapi_parser のメンテナ(オーナー)でもあり、こちらはOpenAPIのパーサーです。committeeも以下の通り依存しているので、状況次第ではこちらのライブラリコードを読むこともあると思います。私の場合、過去に2度ほどありました。意図した挙動にならないケースやOpenAPI 3.0と3.1の違いや対応状況など、微妙に困ることがあるのでOpenAPIのパースで困ったらこちらのリポジトリのコードを読みましょう。
なお、Railsでなくても使えますが、Railsで使う場合は以下のライブラリも併用することになると思います。詳細や使い方は調べれば沢山出てくるため、ここでは割愛します。
committeeのRequestValidationをやめた話
前置きがかなり長くなりましたが、ここからがこの記事の本題です。
committeeには Committee::Middleware::RequestValidation と言う機能が備わっています。
これはその名の通りRequestがValidであるか検証してくれるものです。例えばOpenAPIでは POST books APIのnameフィールドは30文字まで (maxLength: 30)と言うような定義ができます。このとき、31文字以上のPOSTがされたらそれをOpenAPIの定義に従ってRequestValidationが自動で弾く(400 BadRequestを返す)、と言うようなことが実現できます。
Middlewareとして機能するため、Railsで使う場合はControllerの処理に到達する前にRequestValidationが動きます。
当初からこの機能は便利だと思っていたのですが、微妙に感じる点もありました。
Validationの責務の分散
まず前提として、弊社のコードベースではFormオブジェクトによるバリデーションを行なっています。Modelのバリデーションとは別に用意しており、Controllerレイヤのバリデーションのために使っています。バリデーションはModelレイヤだけで良いという考え方もあると思いますが、私はそうは考えなかったので、別々で用意しています。
Formオブジェクトについては以下の記事が参考になります。
そのため、committeeのRequestValidationを使ってしまうとControllerのバリデーション(Form)とは別にさらにバリデーションが行われてしまうため、Formに責務を集約できません。
集約できないため、例えば「maxLengthはRequestValidationでやるから、Formには書く必要がないよな・・・」と言うようなことをいちいち考える必要があって開発としてはやりづらいです。そのため、弊社ではRequestValidationはおまけ程度に考えておいて、基本的にはForm側にもれなく(maxLengthなどでも)実装しようと言うスタンスをとっていました。これではRequestValidationはほぼ意味がないですね。
RequestValidationエラーの不便さ
エラー時にRequestValidationが返すjsonは開発者・APIユーザーにとってはとっては理解できても、エンドユーザー(顧客)にはわかりづらいと言う問題があります。具体的にはバリデーションエラーがあった場合、例えばこのようなエラーが表示されます。
{
"id": "bad_request",
"message": "#/paths/~1account~1app-transfers/post/requestBody/content/application~1json/schema missing required parameters: recipient"
}
特定のAPIのrecipient必須パラメタが無い、というエラーですね。
これは以下の部分のコードで生成されています。
このエラーは開発者にとっては理解できる内容でも、仮にこの内容をそのまま何らかのサービスのエンドユーザーに表示したとしたらほぼ理解できません(一旦ハンドルして翻訳しろと言う話もでありますが)。
デフォルトではおそらくこれは多言語対応できないという問題もあります。この点は調べるともしかするとできる可能性はあると思います。
ただ、このエラークラスはカスタマイズ可能です。詳細はREADMEのValidationErrorの部分を参照してください。
https://github.com/interagent/committee?tab=readme-ov-file#validation-errors
複雑なValidationを実現できない
RequestValidation、というかOpenAPIのバリデーションがコードほど柔軟ではないと言う問題もあります。maxLengthやregexなどの基礎的なバリデーションはOpenAPIで定義できますが、field_aがfooという条件だった場合に・・・というような条件付きバリデーションや何らかの外部リソース(例えばDB)に依存したバリデーションなどはOpenAPIでは定義できません。そのため、committeeのRequestValidationでも実現できません。
RequestValidationを廃止した
これらの理由からRequestValidationは使わない方が良いという結論になったため、廃止することにしました。実際には移行作業は他のメンバーに行なっていただきましたが、Form側には定義されていないけど、OpenAPI側には定義されているバリデーションというのがいくつかあり、簡単には行きませんでした。現時点ではまだ廃止作業中です。
これは私の落ち度ですが、ここまで先を見通せなかったので、RequestValidationの導入にはより慎重になるべきでした。あるいはより柔軟に、問題が発覚したタイミングで素早く廃止できていればもう少し移行作業は簡単だったかもしれません。今回の問題点は導入から早々に気づいていたのですが、他のタスクを優先したばかりに2年以上放置する事態になってしまいました・・・。
RequestValidationが不要ならばcommitteeも不要なのか
今回はあくまでRequestValidationを使わないようにしただけで、他の機能は使っています。引き続きRESTfulなjsonのAPIをRubyで作る限りはcommitteeを使い続けたいと思います。
前の章でスキーマファースト開発の問題として実装との乖離が発生するという話をしました。committeeではこれを解消することができます。
具体的にはREADMEを参照すると良いですが、例えば ResponseValidation という仕組みがあります。これはレスポンスが仕様通りであるかどうかをチェックする機能です。これによって例えば Rails.env.development の開発モードでだけチェックを行なって早期に仕様の不一致に気づけるようにする、などができます。
また、 Rails.env.development に限らず全ての環境で有効化するというのも全然アリだと思います。本番環境(Rails.env.production)で問題があるのに動作し続けるケースと、問題がある場合はエラーを吐いて問題を検知できるケース、どちらが良いかはアプリケーションやビジネスの特性次第ですが気づけるのは基本良いことです。
他にも実行時ではなくテスト時に気づくこともできます。それもREADMEの Test Assertions を参照すると良いです。
assert_request_schema_confirm や assert_response_schema_confirm を使うことで仕様通りのリクエスト、レスポンスが行われているかをチェックすることができます。今回はランタイム時に検証を行う RequestValidation を廃止しましたが、テスト時は前述のアサーションでレスポンスだけでなくリクエストについても検証すると良いかもしれません。
現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!