1
/
5

Rails アプリに RESTful API のレールを敷いて生産性が大きく上がった話

こんにちは、Wantedly Visit の開発をやっている竹野(@Altech_2015)です。

今年の1月に Wantedly に当初からあるクライアント向けの API を再検討し RESTful API に移行しました。これまで曖昧だった部分を RESTful の制約を活かしていくつかレールを敷いた結果、以前の Rails API と比較して生産性が2倍以上になったと感じています。

Wantedly Visit は一番大きなアプリケーションなので大掛かりなフレームワークを入れることは難しかったのですが、やってみると ActiveModelSerializers やいくつかの汎用モジュールを組み合わせるだけで簡単に生産性が上がったので、この記事ではそれについて紹介します。

特に、API を RESTful にすることを考えた際、リクエスト本数が増えすぎたり、関連データを一緒に取ってくる際にデータベースに対して N+1 クエリになる、と言った現実的に向き合う必要がある問題に対してどう対処しているかにも触れて、Rails で RESTful API を導入する際の一例として参考になれば良いなと思っています。

概要

最初に既存の API でどういった問題が起きていたのかを説明して、その解決策としての RESTful API を実際に導入する上で特に重要だった3点を紹介します。

  1. JSON 生成にもリソース指向のライブラリを使う
  2. アソシエーション・フィールドを動的に指定する
  3. データベースからの preloading を自動化する

また、細かい部分ですがページネーション周りやリクエスト本数の議論や、React の Web フロントエンド向けの API との統合についても言及します。

既存の API で起きていた問題

僕は Wantedly Visit の Web の開発をやっています。Wantedly Visit のユーザー向けアプリケーションは Web, iOS, Android の各プラットフォームで提供されていて、且つ iOS と Android は Web API を利用してコンテンツや動作パターンを決定します。

このため Web の方では API の開発と、ユーザー向けの Web アプリケーションの開発のそれぞれを行う必要があります。どちらかと言えば後者が主要な任務だったため、前者を継続的に素早く開発できる体制が必要でしたが、既存の API の生産性は非常に低いものだったため時間的資源を追加投入して解決せざるおえない状況でした。

ここで既存の API が生産性を下げていた要因は、主に2つありました。

  1. 画面に必要な情報を(抽象化せずに)そのまま返すアーキテクチャであること
  2. レスポンス形式やクエリパラメータなどプロトコルレベルでの一貫した設計がない

1 はそれ自体は一つの選択ですが、画面(UI)の詳細を熟知していないサーバーサイドのエンジニアに対してはコードの可読性が低く、従って変更・追加の際にも余分なオーバーヘッドがかかるという問題がありました(初期は一人のエンジニアが両方ガッと作るということをやっていたためこの問題は顕在化していなかったのだと思います)。加えて、UI ベースの設計と言っても共通で使うリソースは shared template の形で出力されるので、本当にそのケースでは必要なのか分からないクエリが最大公約数的に走ると言ったことも懸念されました。

また、画面に必要な情報を羅列して返すエンドポイントは一見するとフロントエンドに対しては楽なものに見えますが、過去に開発した画面や別プラットフォームで開発された画面は熟知はしていないという点ではサーバーサイドのエンジニアと立場は同じであるということや、フロントエンドでも型を定義してモデルにマッピングするということから、移行してみるとリソース指向である方が良いという意見が大きかったです。

2 に関しては普通にやりましょうという話ですが、規約として用意するだけでなく実装側にレールを敷くことでそれが守られる状況を用意するのが重要だと思っています。

1. JSON の生成にリソース指向のライブラリを使う

API を RESTful にするために一番重要だったのはリソース指向のライブラリを使うことでした(もっと言うと jbuilder を使わないことでした)。jbuilder は HTML に対する view テンプレートと同じ感覚で JSON を構築するためのもので API が RESTful かどうかについて一切の制約を置いていないため、なんとなく共通なものが shared template になるが全体としては統一されていないと言ったことが起こり得ます。

代わりに今回採用したのは ActiveModelSerializers という gem です。正直絶対これでなければいけないという点はないですが、今回実現したい API に必要な機能をおおよそ提供していたため、採用しました。これを使うと以下のように、シリアライザを定義してモデルから JSON を生成することができます。

User モデルに対するシリアライザ:

# app/serializers/user_serializer.rb
class UserSerializer < ApplicationSerializer # ActiveModel::Serializer を継承
  attributes :id, :name
  has_many :articles
end

生成される JSON:

// コントローラで `render json: @user` を行う
{
  "id": 1,
  "name": "Altech",
  "articles": [
    {
      "id": 1001,
      "title": "Rails の API を RESTful にして生産性を2倍にした話"
    }
  ]
}

render json: @userUserSerializer が使われているのは、規約に従ってモデルに対して適切なシリアライザが選ばれるようになっているためです。

これはシリアライザが内包するアソシエーション(関連)に対しても同様で、UserSerializer に書かれた has_many :articles が Article モデルのコレクションを返すのであれば、その各要素に対して ArticleSerializer が自動的に適用されます。

この方法が優れている点の一つは、一度あるモデルに対してシリアライザを定義してしまえば、それ以降はノーコストでそれを使いまわせることです。User や Image など重要なモデルに対するシリアライザは、一度作ってしまえば関連を通じて様々なシリアライザから利用されるため、レスポンス部分の開発コストは新しいリソースやフィールドを提供する場合にのみに発生します。

例えば、あるユーザーに似たユーザーを返すエンドポイント GET /users/:user_id/similar_users を作る場合、ルーティングの他に必要な処理は以下だけです。

class UsersController < ApplicationController
  def similar_users
    @uesrs = UserSimilarityService.top_k(User.find(params[:id]), k: params[:per])
    render json: @users,
      fields: @fields, # 次節で説明
      include: @include # 次節で説明
  end
end

レスポンス作成部分をあまり考える必要がないのがポイントで、以前の API ではその都度それっぽい shared template がないか探し回る必要があったり、発見してもそのユースケースに合うのかを周辺コードをよく読んで判断する必要がありました。

2. 必要な属性・関連をホワイトリストで受け取る

API を RESTful にするということは、ある特定のエンドポイントをクライアントが利用するシチュエーションが本質的にいくつも存在することになります。そしてその利用シチュエーションに応じて、例えばユーザーの名前だけが必要なのか?アバター画像も必要なのか?所属している会社の名前も必要なのか?と言ったことは当然異なります。従ってどこまでの情報が欲しいかをクライアントから都度もらってその範囲で返さないと、個々のAPIの動作が最大公約数的なものになり、データベースへのクエリやレスポンスサイズも肥大化してスケールできないものになります。

ActiveModelSerializers ではどういったフィールド・アソシエーションをシリアライズ対象にするのかをオプションで指定できるため、これをクライアントからパラメータで渡すようにしました。

例えば、users#show のエンドポイントに対してユーザーの ID と名前、 そしてユーザーの書いた記事(articles)の ID とタイトルを一緒に取得する場合のリクエストは以下のようになります。

https://www.wantedly.com/api/v2/users/1?include=articles&fields=id,name,articles.id,articles.title

このパラメータはパースされてシリアライズのための引数として利用されます。動作的には以下のような形です。

render json: @user,
  fields: @fields, # before_action でパース済み
  include: @include # before_action でパース済み

なお、少し細かい話をすると、ActiveModelSerializers にはアソシエーションの制御周りのオプションがまだ足りてなくてこれを実現する上で少し手を加えています。この点については Qiita に書いたので気になる方はそちらを参照していただければと思います。

3. 必要な属性・関連に応じて自動で preload する

ここまでで論理的な動作はある程度期待するものができました。ここでユーザーの一覧を取得するエンドポイントを考えてみます。アソシエーションの定義が "A user has many articles." かつ "A article has one image." だったとして、クライアントがそれらを一気に取得したいという状況を想定します。

まず何らかのソート・ページネーションが行われてユーザーのリストが用意されます(@users)。この @users をそのままシリアライザにかけると、内部的にはそれぞれの user に対して articles が呼ばれ、個々の article に対して image が呼ばれます。そして、その度にデータベースにクエリが発行されます。これは防ぐ必要があります。

もしこのエンドポイントに特化して実装するならば、パラメータで articles が要求されていたら @users.includes(:articles) が行われるようにし、更に article の image も要求されていたら、@users.includes(articles: :image) が行われるようにするという案が浮かびます。例えばこんな感じ。

if params[:include].include? 'articles'
  @users = @users.includes(:articles)
  if params[:include].include? 'articles.image'
    @users = @users.incldues(articles: :image)
  end
end
# ...

しかしこういったアソシエーションは再現なく辿っていけるので、この条件分岐はどこまで対応すれば良いのかが分かりません。更にユーザーをアソシエーションとして持つエンドポイントは無数にあるので、それらのエンドポイント全てにこの条件分岐を書くのも現実的ではありません。

一見これは RESTful API の難点のように思えますが、結論としては RESTful API の場合は対象となるリソースと要求されている情報に応じたシステマチックな preloading が可能であり、適切な基盤を用意することでむしろ通常の Rails 開発よりも圧倒的に使い勝手が良くなります。

前置きが長くなってしまいましたが、具体的な手順は2つです。まずシリアライザにフィールドやアソシエーションを追加する際、それに対してどの(ActiveRecord で定義された)アソシエーションを preload する必要があるのかを書くようにします。こんな風に。

class UserSerializer < ApplicationSerializer
  # ...
  preload do
    attribute :localized_name, includes: :user_names
    association :articles, includes: :articles
  end
end
class ArticleSerializer < ApplicationSerializer
  # ...
  preload do
    association :image, includes: :image
  end
end

これさえやっておけば、あとは常にコントローラで fields パラメータと include パラメータに応じて適切な preload を行ったリレーションを返す汎用メソッドをかませるだけです。

# 内部で Api::Preloader.preload_for(relation, @fields, @include) を呼び出す
@users = preload_for(@users) 

ここでやっていることは単純で、型が決まればシリアライザが決まり、シリアライザが決まればどのフィールド・アソシエーションに対して何を preload すべきかが最初に記述した情報によって分かります。それとホワイトリストで渡された今必要なフィールド・アソシエーションのリストを突き合わせて preloading を行う。これをアソシエーションに対しても再帰的に実行しています。

通常の Rails 開発では、view や partial view で呼び出しているアソシエーションを(間接的なものも含めて) controller が把握して preload を行う必要があり、これは本質的に難しく壊れやすいものでした。今回、この preload 情報をシリアライザに書いて実際の preload は汎用モジュールに任せることで、かなりその点が安定して見通しも良くなったと感じています。

その他のポイント・議論

これまで説明した点以外で、ここはちょっと考える必要があるよね、という話になったポイントについていくつか書いていきます。

ページネーション

ページネーションやエラー形式と言った部分も全体として統一されていると都合が良い部分のため、基盤として用意しています。例えば、ActiveRecord::Relation のページネーションは setup_collection というメソッドをかませば良いくらいに抽象化しています。この中では page_count=true 指定した場合にのみ全体件数をヘッダーで返す、と言った細かいけど共通の挙動も担保しています。オプトインになっているのは件数が本当に必要なケースは限定的だしカウントするコストが無駄だからということですね。

render setup_collection(@users),
  fields: @fields,
  include: @include

あとこの API でやらないと決めていることについても言及すると、アソシエーションに対してもページネーションしたい!みたいなユースケースがあります。これは理論上は考えられるし、実際 GraphQL とかだとその辺上手い抽象を用意していたりするけれど、インターフェースが複雑になるので今のところは別リクエストでお願いします、というので統一しています。例えば API 提供者と API 利用者の距離が非常に遠い場合とかだと、この辺も一般的に出来るようにしておくと良いのかもしれない。

リクエスト本数について

RESTful API を採用せずに画面に一対一対応する形で API を作りたくなる動機として、リクエストを増やしたくないということがあります。パフォーマンス面もあるのかもしれないけど、部分的失敗をどう扱うかみたいなのはそもそも難しいので考えたくないですよね。ただ、そこだけなら単純に両方待てば良いという話で、最近は RxSwift とか RxJava みたいなリアクティブ系のライブラリもフロントエンドに入っているので、非同期処理を両方待つとかはそんなに苦ではなく問題にはなりにくいかなと思っています。

あとはそもそもアソシエーションは自由に同時取得できるので、Wantedly Visit の場合だと1画面で2-4リクエストくらいに収まっています。マイクロサービス化がとても進んでいるのであればリクエストをまとめるバッチリクエストサーバーを置いても良いのかもしれないですが、開発環境とか運用を考えると今のところはこれくらいで良いかなと考えています。

Web フロントエンド向けの API

Wantedly のフロントエンドは最近 jQuery で書く機会が少なくなり、React (Redux) が増えています。

元々はフロントエンド用に /api/internal、ネイティブアプリ用に /api/v1 とエンドポイントが別れていて同じようなコードを書くことも多く、挙動の一貫性の観点からもあまり良くなかったのですが、jQuery で作る場合にはネイティブアプリのモデルレイヤーに相当するものがないので責務の切り方が違うというのもあって別れていました。

ただ React の導入が進むに連れて本当に同じような API を作る機会が増えたことや、画面の情報量の差は新しい API ではパラメータで吸収できることから、今後は Web フロントエンドも新しく作った RESTful API に統一されていきそうです。

最後に

自分が既存の API に問題を感じて新たに RESTful な API を作ろうと思ったとき、メジャーなテーマなのでベストプラクティスに近いものがあるかなと思っていたのですが、意外と探してみると情報が少ないという印象を受けました。

API の開発はチーム構造やサービスのアーキテクチャなど個々の技術以外の要素も多いのであまり表に出ないのかもしれませんが、この記事ではそういった背景も含めつつ事例として共有できれば良いかなと思って書きました。

あと書いてみて思いましたが、こういった基盤の導入は、実はフロントエンドの設計が前進しているから容易になっていたり、こういう API が良いのではというリサーチ(マイクロサービスのための綺麗なAPI設計 \| Wantedly Engineer Blog など)で予め認識が揃っていたりと、社内の色々な取り組みが組み合わさって出来ているものだなと感じました。今後もそういった形で良いサービスを作れる環境を作っていけると良いなと思っています。

Wantedly, Inc.'s job postings
39 Likes
39 Likes

Weekly ranking

Show other rankings
Like Sohei Takeno's Story
Let Sohei Takeno's company know you're interested in their content