2018-02-03

ActiveModel::Serializersを使ってみた

ActiveModel::Serializers(AMS) という、出力するJSONをいい感じにコントロールできるというライブラリを使ってみた記録。多分、RailsでAPIを作るときなんかによく使われるライブラリ、くらいの認識からスタートした。

https://github.com/rails-api/active_model_serializers

出鼻をくじかれる

「よーし使ってみるか」という気持ちで意気揚々と調べ始めたところ、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_onebelongs_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に向けて開発中のようだが、この辺の使い勝手の良さはそのまま維持されると嬉しい。