This post is Private. Only a writer or those who know its URL can access this post.

Sytematic Preload for RESTful API

More than 1 year has passed since last update.

自己紹介


Web アプリケーションにおける preload

  • リレーションに関連するオブジェクト群を予め取ってくる
  • 目的:DB負荷の軽減、高速化
    • N+1問題

例:ユーザーをプロフィール情報付きで表示したい

users = User.first(10)
profiles_hash = Profile
  .where(user_id: users.map(&:id))  # 一気に取ってくる
  .index_by(&:user_id) # user_id で引けるようにする

Ruby on Rails における prelaod

  • ActiveRecord::QueryMethods#includes などのメソッドがある
    • アソシエーションと協働する
  • 効能:集合指向ではなく、オブジェクト指向で書ける
    • リレーショナルモデルに引っ張られない
users.includes(:profile).each do |user|
  user.profile.age # クエリが発行されない
end

便利。


しかし、、


正しく preload するのが難しい問題


  • 例:view で controller で想定していなかったアソシエーションを呼び出した
  • 例:view で呼んだモデルのメソッド内で、controller で想定していなかったアソシエーションを呼び出していた
  • 例:view で呼んだパーシャルメソッドで呼んだモデルのメソッド内で、…
  • 例:広汎に使われるモデルに対して、色んなエンドポイントで似たような includes(...) を書く羽目になる

正しく preload するのが難しい問題

=> 直接依存していないものも全て把握していないといけないのが難しい ><
=> includes の情報が DRY じゃない ><


話は変わって RESTful API


"The" RESTful API

  • 最近仕事でAPIのアーキテクチャを以降した
  • 既存のモデル・ヘルパー等の資産はそのまま使いたいので Go とかは使わない
  • api/v2 として endpoint / controller / view を新たに作成
  • 完全 whitelist 方式
    • 画面やクライアントによって必要な情報量や実装度合いが違うので、スケールするために必要。

通常のリクエスト:

GET /api/v2/users

[{ "id": 1 }, { "id": 2 }, ...]

アトリビュート:fields パラメータで指定:

GET /api/v2/users?fields[]=name&fields[]=email

[{ "id": 1, "name": "Sohei Takeno", "email": "altech@wantedly.com" }, ...]

アソシエーション:include パラメータで指定:

GET /api/v2/users?include[]=profile&fields[]=profile.age

[{ "id": 1, "profile": { "age": 26 } }, ...]

JSON の生成には ActiveModelSerializers を利用:

class UserSerializer < ApplicationSerializer
  attributes :id, :name, :facebook_uid
  has_one :profile
end

whitelist 方式

  • 必要な情報をクライアントが明示的に渡す
  • => preload すべき association もそれぞれ異なる

どうするか?


ナイーブな方法1:全部のパラメータをチェックする

if 'profile'.in? params[:include]
  @users = @users.includes(:profile)
end
if 'name'.in? params[:fields]
  @users = @users.incldues(:user_names)
end
...

問題点:

アソシエーションは何個もネストできるので、あり得るパターンを全て書くのはほぼ無理。


ナイーブな方法2:最大公約数的にロードしておく

@users = @users.includes(:profile, :user_names, ..................)

問題点:

一つのエンドポイントを複数の用途で利用しようとすると、無駄なクエリが多くなりスケールしない。

また、画面での利用情報が増減する度に controller の変更が必要になる(例:avatar 画像が必要になったら image を include する)。

=> そもそも RESTful API にする意味がなくなる。


どうすればよいか?

本来、要求されているモデル群とフィールド群が分かれば、何を preload すれば良いかは決まるはず。


手順

  1. フィールド、アソシエーションに対応する preload 情報を記述する。
  2. (自動で prelaod する)

1. preload 情報の記述


アトリビュートやアソシエーションをシリアライザに追加したときに、preload ブロックに include すべきアソシエーションを書いておく。

class UserSerializer < ApplicationSerializer
  attribute :wanted_score
  association :avatar
  ...
  preload do
    attribute :wanted_score, includes: :score
    association :avatar
  end
end

追加するときはそのロジックを使うことで、どういうクエリが発行されるかは知っているはずなので、preload 情報を一緒に書くのは難しくない。


2. (自動で prelaod する)


こういうユーティリティがあれば良い:

def preload_for(rel)
  Api::Preloader.preload_for(
    rel, 
    params[:fields],
    params[:include]
  )
end

実際のコントローラのアクション部分:

  def users
    @users = User.all

    @users = preload_for(@users)

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

動作:

プロフィール情報とそこに含まれるアバター画像を同時に取得するリクエストでのケース:

@users = @users.includes({ profiles: { image: {} } })

↕ 等価

@users = preload_for(@users)

再:正しく preload するのが難しい問題


=> 直接依存していないものも全て把握していないといけないので、本質的に難しい><

シリアライザに属性・関連に対応する preload 情報さえ書いておけば、include されたモデルも含めて全部 sytematic に preload される。


=> includes の情報が DRY じゃない

シリアライザに一回だけ書けば良い


まとめ

Rails API の preload にレールを敷きました。 :golf:
なんかオレオレで作ってしまったけど世の中のことが分かってないので教えてください :bow:

Code : https://gist.github.com/Altech/6e3af74e00de2e70f38ee35cea431346
Integration : https://github.com/Altech/rails-restful-api/pull/4
API protocol : https://github.com/Altech/rails-restful-api#api-v2---protocol-and-implementation

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.