Ruby
Rails
JSON
RubyOnRails
ActiveModelSerializer

ActiveModel::Serializersの痒い所に手が届く使い方

はじめに

ここ半年ぐらいActiveModel::Serializersを使った開発をしていて
痒い所に手が届く書き方をいくつか見つけたので紹介していきます
基本的な使い方はすでにいろんな方の記事があるので省略します

1. キー名を変えたい

  • id
  • first_name
  • last_name
  • employee_number

といったカラムを持つUserモデルがあったときに
first_namenameとして出力したい場合

a. :keyオプションを使う

class UserSerializer < ActiveModel::Serializer
  attributes :id, :employee_number
  attribute :first_name, key: :name
end

b. メソッドを定義する

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :employee_number

  def name
    object.first_name
  end
end

c. ブロックで渡す

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :employee_number

  attribute :name do
    object.first_name
  end
end

※objectはSerializernewなどで渡されたオブジェクト(レコード)自身

  • attributeだけでなくbelongs_to has_one has_manyでも同様の使い方が可能
  • メソッドやブロックは処理も書けるので分岐や加工がしやすくて便利

2. has_manyで紐づくモデルをSerializerの中でorderするとSQLキャッシュのN+1が起きる問題をなんとかしたい

before
class User < ActiveRecord::Base
  has_many :posts
end

class Post < ActiveRecord::Base
  belongs_to :user
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :employee_number
  has_many :posts, serializer: PostSerializer do
    object.posts.order(created_at: :desc)
  end
end

と書くと事前にpreloadなどをしてN+1対策をしていても
CollectionSerializerで大量に出力する際にSQLキャッシュのN+1が発生します
ログの中にCACHE (0.0ms) SELECT ....みたいなのが大量に出て
キャッシュされているとはいえやはり遅くなる

そんな時の対策はこちら

a. モデルのアソシエーションにデフォルトのオーダーを仕込む

after
class User < ActiveRecord::Base
  has_many :posts, -> { order(created_at: :desc) }
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :employee_number
  has_many :posts, serializer: PostSerializer
end

としてやると最初にオーダーされてキャッシュのN+1が発生しません

ただ、これだと他のところでこのorderが効いて欲しくない時があるかも
そんな時はモデルにもう一個別のアソシエーションを定義します

b. モデルにオーダー用のアソシエーションを定義する

after
class User < ActiveRecord::Base
  has_many :posts
  has_many :for_order_posts, -> { order(created_at: :desc) }, class_name: :Post
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :employee_number
  has_many :for_order_posts, serializer: PostSerializer
end

もちろんpreloadでのアソシエーションも:for_order_postsに直します
個人的にはこれが好きです

3. has_manyのアソシーションの最新1件を含めたいけどこれまたSQLキャッシュのN+1が起きる問題

before
class User < ActiveRecord::Base
  has_many :posts
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :employee_number
  attribute :newest_post, serializer: PostSerializer do
    object.posts.order(updated_at: :desc).limit(1)
  end
end

ってやるとやっぱりSQLキャッシュのN+1が発生するので
やっぱりここも別のアソシエーションを定義します

a. has_onescopeをモデル側に作る

after
class User < ActiveRecord::Base
  has_many :posts
  has_one :newest_post, -> { order(updated_at: :desc) }, class_name: :Post
end

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :employee_number
  attribute :newest_post, serializer: PostSerializer
end

has_oneなので勝手にlimit(1)がかかります

4. attributeで出力するか否かの条件をつけたい

a. :ifオプションを使う

class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :employee_number
  attribute :name, if: -> { object.has_attribute?(:name) }
end

ってやるとnameカラムが存在するときだけ出力してくれたり

ポリモーフィックで
UserモデルがChildBoyChildGirlというモデルをChildという名前で持っていた時に
ChildBoyの時はChildGirlを出さない、みたいな使い方もできます

5. おまけ(入れ子になったSerializerのテストコード)

当たり前かもしれませんが自分はいつも入れ子になったSerializerのrspecを書く時はいつもこうやってます

it 'serializes User' do
  expect(UserSerializer.new(user).to_json).to be_json_including(
    id: user.id,
    first_name: user.first_name,
    last_name: user.last_name,
    posts: user.posts.map do |post|
      JSON.parse(PostSerializer.new(post).to_json)
    end
  )
end

もちろんPostSerializerがテストされている前提です

PostSerializer.new(post).to_hashでもいいんですが
jsonに変換した時に日時のフォーマットが変わっちゃったりするのでJSON.parseしてます