43
42

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Rails に RESTful API のレールを敷く

Posted at

背景

今年の1月に弊社のネイティブアプリ向け API を刷新し、API v2 として導入した。機能開発やリニューアルなどのタイミングで徐々に移行して行っていて、振り返ると体感で生産性が2倍くらいになっていたように思う。やったのは基本的に少しきちんと RESTful API のプロトコルを定義し、それに則る形で API を書くためのレールを敷くということだった。

RESTful API は割とこなれたテーマかなと思いつつ、意外と Rails で RESTful API を導入しようとすると、そんなにメジャーな方法があるわけではなく、一個一個自分で考えてレールを敷く必要があった。

もちろん、それができるだけの融通性があるのが Rails の良いところではあるのだけど、逆に言うとあんまり全体のデザインを考えずに作って微妙なものが出来るということもまた同時に起こりうる(今回移行するきっかけになった元の API もそういう感じだった)。なので一例として、こういうカスタマイズをすればこのくらい便利になったというのを少しまとめておく。

特に、実際に RESTful API を実装しようとすると、リクエストの回数が増えないように関連モデルまで含めて一気に取って来たいだとか、その際にクエリが N+1 にならないように preload するだとかは考えなければいけない課題としてあって、その辺を整合的に解決するのはちゃんとプロダクションで使えて生産性も高い API を用意する上でかなり重要だったように思う。

TL; DR

  • JSON 生成にはリソース指向のライブラリを使う
  • 必要な属性・関連をホワイトリストで受け取る
  • 要求されている属性・関連に応じて自動で preload する
  • ここで作ろうとしてる API はこんな感じ

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

Rails で普通に rails new AwesomeApp --api を行うとデフォルトで jbuilder という JSON 生成のためのライブラリが入ってるけれど、これは HTML に対する view テンプレートと同じ感覚で JSON を構築するためのもので、何でも出来るが故に RESTful API を作る場合には制約が不十分だったりする。

JSON の生成だと、ActiveModel::Serializer という gem が RESTful API に必要な機能をおおよそ提供していて、今回はこれを使った。これを使うと、以下のようなことができる。

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

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

生成される JSON:

// Result of `render json: @user`
{
  "id": 1,
  "name": "Altech",
  "articles": [
    {
      "id": 2,
      "title": "Rails に RESTful API のレールを敷く"
    }
  ]
}

例によって、規約に従ってモデルに対して適切なシリアライザが選ばれるようになっている。これはシリアライザが内包する関連(アソシエーション)に対しても同様で、has_many :articlesArticle モデルのコレクションを返すのであれば、その各要素に対して ArticleSerializer が自動的に適用される。

この方法が jbuilder のような view テンプレートと比べて優れている点の一つは、一度あるモデルに対してシリアライザを定義してしまえば、それ以降は暗黙的にそれを使いまわせることにある。UserImage など重要なモデルに対するシリアライザは、一度作ってしまえば関連を通じて様々なシリアライザから利用される。

なお、デフォルトの状態ではこの gem は ActionController に対してグローバルに変更を加えるので、特定のネームスペースでのみ使うのであればイニシャライザから外して、必要な範囲で include ActionController::Serialization を行う必要がある。

# config/environment.rb
ActiveModelSerializers::Railtie.initializers.delete_if { |i| i.name == 'active_model_serializers.action_controller' }

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

API を RESTful に作ると言うことは、ある特定のエンドポイントをクライアントが利用したいシチュエーションが少なくとも潜在的にはいくつも存在することになる。そして、シチュエーションごとに利用したい情報の多寡は異なるため、どこまでの情報が欲しいかをクライアントから適宜もらってその範囲で返さないと、個々のAPIの動作が最大公約数的なものになり、データベースへのクエリやレスポンスサイズが肥大化してスケールが難しくなる。

ActiveModel::Serializer の場合、指定すべきは属性と関連になる。具体的な API の I/F としては、どの属性を使うのかを fields パラメータで指定し、どの関連を使うのかを include パラメータで指定するようにした1

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

このパラメータはパースされてシリアライズのための引数として利用される2

render json: @user,
  fields: @fields,
  include: @include

ActiveModel::Serializer を拡張する

実はいま利用している ActiveModel::Serializer は、 JsonApi と Attributes という二つのシリアライズのためのアダプターを用意している。JsonApi と言うのは一般名詞ではなく JSON API という特定の仕様の名前で、HATEOAS に踏み込んでいて未来感があるのだけど、ここまでやるにはクライアント側も本格的にそれに合わせた基盤実装が必要なため、今回は採用しなかった。

ただし gem として推しているアダプターは JsonApi であり、Attributes アダプターはいくつか今回必要なシリアライズのためのオプションが存在しない。具体的には、Attributes アダプターは関連モデルの属性の絞り込みを行うことができないのと、2段階以上のネストした関連モデルを返すことができない。

この辺はイシューとかも立っているのだけど他の機能も含めた全体のオプションの設計とかが結構難しいっぽくて進んでいない。ただし、今回やりたいことはトップレベルに対して現状できている属性の絞り込み・関連の指定を再帰的に行うことだけなので、実は簡単なパッチを書くだけで実現でき、ちゃんと機能として入るまでこの拡張で対応している。

リクエスト本数について

RESTful API を採用せずに画面に一対一対応する形で API を作る動機として、リクエストを増やしたくないということがある。パフォーマンス面もあるし、部分的失敗をどう扱うかみたいなのはそもそも難しい。でそれって実際の許容本数だとどのくらいなんだろう?という話を社内のネイティブアプリのエンジニアともして、肌感覚だけど多くて画面あたり4-5本くらいまでにはしたいねという話になった。

これは実際やってみてどうなったかと言うと、多くの画面では単一のリソースやリソースのコレクションに紐付けるかたちで大部分の情報を取得でき、現状でもそれほど問題にはなっていない。ただし、本質的にはポータルのような画面は関連では取ってこれないので、何らかの方法で複数のリソースを返したいシチュエーションはあるだろうと思う。その時は名前空間を区切って、複数のリソースをシリアライズして返すエンドポイントを作ることで対応できる。

render json: {
    users: @users,
    recommended_articles: @articles,
    awesome_data: @data,
  }

実際、ネイティブアプリではなく Web フロントエンドである React のバックエンドに今回作成した API v2 を導入しようとしているのだが、このときに First Meaningful Paint time の最適化するためにこう言ったアプローチを採用した。振り返ると、シリアライザという抽象化レイヤーを新たに追加したことで、こういった Backend for Frontend 的なエンドポイントの作成が容易になるという効果があった。

要求されている属性・関連に応じて自動で preload する

例えば、"A user has many articles." かつ "A article has one image." だったとする。この時、ユーザーの一覧を取得するエンドポイントで、N+1クエリが発生しないためには、どうすればいいだろうか。

もしこのエンドポイントに特化して実装するならば、関連パラメータで 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
# ...

しかしこうった関連は再現なく辿って行けるので、どこまで対応すれば良いのかという問題がある。また、例えば User を関連として持つエンドポイントは無数にあるので、それに対して全て同じ preload の分岐を書くのは現実的ではない。

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

具体的な手順としては、シリアライザの属性や関連を追加する際、それに対してどの 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 を行うヘルパーをかませるようにすれば良い。

@users = preload_for(@users)

仕組み

簡単にどういったカスタマイズが必要だったかも書いておく。preload のためのルールと、それを解釈して preload を行うモジュールを用意し、それをアプリケーションから簡単に使えるようにしてある。

シリアライザ

DSL として preload を追加して Api::PreloadRule を各シリアライザが持てるようにする。Api::PreloadRule は単純に preload ブロックに記述したことをデータ構造として保持する。

class ApplicationSerializer < ActiveModel::Serializer
  class << self
    def preload(&block)
      @preload_rule = Api::PreloadRule.new
      @preload_rule.instance_exec(&block)
      @preload_rule
    end
    attr_reader :preload_rule
  end

コントローラー

Api::Preloader にリソースと要求されてる属性・関連を渡すヘルパーを追加する4Api::Preloader は渡されたモデルに対して対応するシリアライザを取得し、その preload rule を元にして includes メソッドを発行する。関連を通じて別のモデルが取得された場合は、更にそのシリアライズを取得して再帰的に includes メソッドを発行していく。

class ApplicationController < ActionController::Base
  def preload_for(rel)
    Api::Preloader.preload_for(rel, @fields, @include)
  end
end

その他のこと

ソートの指定とかページネーション

この辺も汎用的な処理なので統一的に処理できる基盤を用意する価値はあるかもしれない。自分の場合だと、setup_collection というのを常にかますようにしていて、ここでソートやページネーションのパラメータを元に order や pagination を行っている。この辺の実装のやりようは色々あるので割愛。

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

GraphQL

最近だと GraphQL が次の API として話題になっている。実際、GraphQL は RESTful API を作るときに課題になることを少なくともインターフェースとしては結構解決していることが、作ってみると分かる。

例えば、リクエスト本数を絞るための関連の取得はより柔軟性高く書けるし、関連モデルのページネーションやソートと言ったことも新しい概念を導入することで可能にしている。また、データの作成・更新については今回必要になる頻度が低くてまだ特別なルールは提供していないけれども、その辺もしっかり mutation という概念が導入されている。

ただ GraphQL はあくまでインターフェイスなのでそれの実装がサーバー・クライアント双方で必要になるのと、多くのサーバー・クライアントの開発者を想定したときにどちらが良いかや、内部APIなのか外部APIなのかとかでも違うので、その辺を加味してどちらが良いかは決めれば良さそう。

Swagger

GraphQL にはクライアントのモデルの自動生成の話もあるみたいだけど、それ自体は Swagger などを使えば解決できる。Swagger は RESTful API の仕様を形式的に書くためのフォーマットで、それに紐付いてコード生成のエコシステムなども存在する。

以前少し試してみた感じ割とこのフォーマット自体は書きやすいなと思ったのと、クライアントモデルの自動生成みたいなのもそろそろやりたいみたいな話も出ているので、ちょっとどこかでインテグレーションできると良いなあと思っている。

まとめ

  • RESTful API を使うと、
    • リソース指向の制約を加えたライブラリを使えるので、JSON 生成の記述が簡単
    • フロントエンドの開発者とのコミュニケーションの内容が、具体的な画面から離れて一段階抽象化され、リソースのレベルでできる
    • クライアントが必要な情報を渡してくるので N+1 を防ぐ preloading が半自動的にできる
    • API を変更しようとした際にリソースに紐付けて探せるので変更箇所の特定が早い/追加箇所に迷いにくい
    • API の細かいレスポンス形式やクエリパラメータを都度議論せずに済む
  • GraphQL, Swagger など最近出てきているツールもやはりそれなりの意味があって作られてる
  1. include という名前は今から考えると色々と微妙だった気がしているけど。

  2. https://gist.github.com/Altech/34691e415fef894e2201e24bfd7c0a73#file-api_controller_helpers-rb-L6

  3. view template とか view helper とか、 controller が直接的に把握してない箇所を網羅的に考慮して preload するのってそもそも難しいと思いませんか。

  4. これは今だと ActiveRecord::Relation を受け付けることを想定しているが、ActiveRecord::Base も受け付けられるようにするべきだろう。

43
42
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
42

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?