ActiveModel::Serializersを使ってみた
ActiveModel::Serializers(AMS) という、出力する JSON をいい感じにコントロールできるというライブラリを使ってみた記録。多分、Rails で API を作るときなんかによく使われるライブラリ、くらいの認識からスタートした。
出鼻をくじかれる
「よーし使ってみるか」という気持ちで意気揚々と調べ始めたところ、2つの点で出鼻をくじかれた。
README.md を読んだ
master ブランチの README.md を見ると、ざっくり次のようなことが書かれていた。
- master ブランチへの PR は close やめて、0-10-stable ブランチへ頼むよ
- 0.10.x バージョンは巨大なメンテナンスバージョンになっているよ
- 0.10.x バージョンは安定版だけど、積極的なメンテナンスはしていないよ
- バージョン1.0に向けての開発はしているよ
要するに、現在の安定バージョンは 0.10.x であるが積極的なメンテナンスはされていないし、PR も基本的に受け付けていない。今は、色々あって1.0に向けて大規模な改修(開発)をしています…、といった感じ。
fast_jsonapi の登場
NetFlix 社から、fast_jsonapi というライブラリを開発したというブログが公開された
https://medium.com/netflix-techblog/fast-json-api-serialization-with-ruby-on-rails-7c06578ad17f
これは ActiveModel::Serializers が提供する多くの機能を受け継ぎつつ、パフォーマンスを改善したライブラリらしい。AMS(ActiveModel::Serializers の略称)と比較して、パフォーマンスは25倍近く向上したと書かれている…。
…といった感じで出鼻をくじかれてしまった。
しかし AMS は有名なライブラリ(のよう)だし、fast_jsonapi は AMS に影響を受けているようなので、まぁやって無駄にはならないだろうと考えて当初の予定通り使ってみることにした。
Getting Started
まずは動かすところまでやってみる。ガイド を参考に進める。
まずは、DB スキーマ、モデル、コントローラを適当に一式用意する。
DB スキーマ
create_table "users", force: :cascade do |t|
t.string "name"
t.integer "age"
t.string "sex"
t.boolean "premium"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name"], name: "index_users_on_name", unique: true
end
モデル
# app/models/user.rb
class User < ApplicationRecord
end
コントローラ
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
render json: User.all
end
def show
render json: User.find_by(id: params[:user_id])
end
end
ルーティング
# config/routes.rb
Rails.application.routes.draw do
get 'users' => 'users#index'
get 'users/:user_id' => 'users#show'
end
ここまでで、サーバを起動してリクエストを送ると JSON 形式で user データを返してくれる状態になった(※ マイグレーションやテストデータの挿入は省略している)。
>> curl -s localhost:3000/users/1 | jq .
{
"id": 1,
"name": "user-0",
"age": 1,
"sex": "male",
"premium": true,
"created_at": "2018-02-03T01:59:54.243Z",
"updated_at": "2018-02-03T01:59:54.243Z"
}
ここから AMS を使ってみる。まずはシリアライザクラスを作る。
bin/rails g serializers User
これで、app/serializers/user_serializers.rb が作られる。ファイルの中身は次のようになっている。
# app/serializers/user_serializers.rb
class UserSerializer < ActiveModel::Serializer
attributes :id
end
attributes メソッドに指定した値だけをレスポンスするようになるので、この状態で再びリクエストを送ると次のような結果になる。
>> curl -s localhost:3000/users/1 | jq .
{
"id": 1
}
attributes メソッドに id しか指定してないので、レスポンスに含まれるデータも id のみとなった。
attributes に複数の引数を指定することももちろん可能。
# app/serializers/user_serializers/user_serializers.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :age
end
レスポンスも合わせて変化する。
>> curl -s localhost:3000/users/1 | jq .
{
"id": 1,
"name": "user-0",
"age": 1
}
ここまでで、とりあえず AMS がどんなものかのイメージができた。
アソシエーションに対応させる
次は、モデルのアソシエーションに対応させてみる。 User は0個以上の Deck を持てるものとして、データモデルを変更する。
DB スキーマ
create_table "decks", force: :cascade do |t|
t.string "name"
t.string "description"
t.string "image_url"
t.boolean "favorite"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_decks_on_user_id"
end
モデル
# app/models/user.rb
class User < ApplicationRecord
has_many :decks
end
# app/models/deck.rb
class Deck < ApplicationRecord
belongs_to :user
end
シリアライザ
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :age
has_many :decks # ★ ここ追加
end
# app/serializers/deck_serializer.rb
class DeckSerializer < ActiveModel::Serializer
attributes :id, :name
end
この状態でリクエストする。すると、次のように関連するモデルのデータもレスポンスにのってくる。
>> curl -s localhost:3000/users/1 | jq .
{
"id": 1,
"name": "user-0",
"age": 1,
"decks": [
{
"id": 1,
"name": "deck-0"
},
{
"id": 11,
"name": "deck-10"
}
]
}
同様に、 has_one や belongs_to というメソッドもある。ActiveRecord の規則に従っているようでわかりやすい。
アダプタを切り替える
標準で以下3種類の adapter が提供されていて、それらに切り替えることができる。
:attribuets (default):json:json_api
rendering 時の adapter パラメータに指定することで切り替え可能(※ グローバルな設定として持たせることも可能)。
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
render json: User.all
end
def show
render json: User.find_by(id: params[:user_id]), adapter: :json # ★ ここ
end
end
アダプタごとに出力形式に特徴がある。
例えば JSON アダプタ(:json)の場合はレスポンスの root 要素が作られるといった違いがある。
>> curl -s localhost:3000/users/1 | jq .
{
"user": {
"id": 1,
"name": "user-0",
"age": 1,
"decks": [
{
"id": 1,
"name": "deck-0"
},
{
"id": 11,
"name": "deck-10"
}
]
}
}
JSON API アダプタ(:json_api)は、jsonapi という JSON 形式でデータを返す API の仕様を決めている団体?のようなものがあり、そこで作られた version1.0の仕様に従っているようだ。
各アダプタの詳細は Adapters に書かれている。
※ 余談:JSON アダプタという名称にはちょっと違和感を感じる。デフォルトの attributes アダプタも JSON 返してるし…。
独自の属性を追加する
メソッドを定義するなどして、Model の属性としては存在しない独自の属性を追加できる。 以下は、foo というキー名で bar という値を返すよう、メソッドの定義と attributes メソッドの引数変更をしたときの例。
# app/models/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :age, :foo # ★ ココと..
has_many :decks
# ★ ココ
def foo
"bar"
end
end
レスポンスは次のように変わる。
>> curl -s localhost:3000/users/1 | jq .
{
"id": 1,
"name": "user-0",
"age": 1,
"foo": "bar", # ★ 増えた
"decks": [
{
"id": 1,
"name": "deck-0"
},
{
"id": 11,
"name": "deck-10"
}
]
}
その他気になった機能
- 関連先のモデルは
includeオプションでも指定できるrender json: User.all, include: ['decks']
- include の値として
*を渡すと、関連先のモデルを1階層分だけ出力するrender json: User.all, include: "*"
- 同様に、
**を渡すと、関連先のモデルを全て辿って出力するrender json: User.all, include: "**"
- ApplicationController のように、各シリアライザ共通の処理を ApplicationSerializer などとして括り出せる(クラスの名称は何でも良さそう)
- cache 機能がある
- key_transform options オプションで、出力するキー名のフォーマットを変更できる
- camel, camel_lower, dash, unaltered, underscore, nil などが指定できる
- 例:
render json: User.all, key_transform: :camel
使ってみての雑感
直感的な設計になっていて、大体こんなかんじで動いてくれるだろうな〜っていうのがその通りに動いてくれる感じが良かった。
v1.0に向けて開発中のようだが、この辺の使い勝手の良さはそのまま維持されると嬉しい。