LoginSignup
198
136

More than 3 years have passed since last update.

Rails における JSON のシリアライズと向き合う 🦜

Last updated at Posted at 2019-12-09

Rails で JSON を返す API サーバーを開発する際に、
選択肢となるシリアライズ方法をまとめてみました。
それぞれ Pros/Cons や好みがあると思います。
こういう選択肢があるんだよっていうのを提示できればいいかなーと思っています。
みんながどれを使ってるかアンケートとかも取ってみたい👨‍💻
(※記事の最後に載せました)

スキーマ

サンプルとしてTwitterをモデルにする。
User と Tweet のテーブルを作成する。

create_table :users do |t|
  t.string :name, :null => false
  t.string :screen_name, :null => false # @で始まるユーザー名
  t.string :description # 自己紹介
  t.integer :friends_count, :null => false # フォロー数
  t.integer :followers_count, :null => false # フォロワー数
  t.datetime :created_at, :null => false
end
create_table :tweets do |t|
  t.references :user, :null => false
  t.bigint :in_reply_to_status_id # リプライの場合、元のツイートのID
  t.string :text, :null => false
  t.string :source # どのクライアントからツイートしたか
  t.integer :retweet_count, :null => false # リツイート数
  t.integer :favorite_count, :null => false # ふぁぼ数
  t.datetime :created_at, :null => false
end

関連はユーザーとツイートが 1:N

基本

render:json を指定して、オブジェクトを渡す。

def pass_hash
  render :json => { name: "yamada" }
end

出力は以下のようになる。

{"name":"yamada"}

上の例ではHashを渡したが、内部的には to_json がコールされるので、
それが実装されているオブジェクトであれば何でも渡せる。

def pass_object_implemented_to_json
  hoge = Hoge.new("yamada")
  render :json => hoge
end

class Hoge
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def to_json(options)
    {
      name: "#{@name}さん"
    }.to_json
  end
end

# => {"name":"yamadaさん"}

自前で to_json を実装するときは引数を1つもらうようにする。
こんなのが渡されてくるので。

{:prefixes=>["users"], :template=>"index", :layout=>#<Proc:0x0000561d82dd2cc8@/usr/local/bundle/gems/actionview-6.0.1/lib/action_view/layouts.rb:392>}

また、 to_json を実装したオブジェクト以外にも、単なる文字列も渡せます。

def to_json(options)
  @name
end
# => yamadaさん

どんな実装になっているのか覗いてみると、

action_controller/metal/renderers.rb
add :json do |json, options|
  json = json.to_json(options) unless json.kind_of?(String)

  if options[:callback].present?
    if media_type.nil? || media_type == Mime[:json]
      self.content_type = Mime[:js]
    end

    "/**/#{options[:callback]}(#{json})"
  else
    self.content_type = Mime[:json] if media_type.nil?
    json
  end
end

kind_of? で型を見て String じゃなかったら to_json を呼んでる。

なのであらかじめJSON文字列化された値を渡しても動くという。

def to_json(options)
  "{\"name\":\"#{@name}\"}"
end

あまり実用的ではなさそうだけど。

シンプルな方法というか、基本原理はこんな感じで、
あとはJSONの中身の生成/整形をどこでやるかが勘所になってくる。

ActiveModel::Serialization

ActiveRecordは ActiveModel::Serialization を include しているので、そのまま利用できる。
Active Modelの基礎 1.8 シリアライズ

app/controllers/user_controller.rb
class UserController < ApplicationController
  def sample
    user = User.first
    render :json => user
  end
end

モデルに特別何も定義していなければ、全てのカラムがそのまま出力される。

{
  "id":1,
  "name":"山田",
  "screen_name":"@yamada",
  "description":"システムエンジニアです",
  "friends_count":100,
  "followers_count":50,
  "created_at":"2019-12-09T10:46:29.000Z"
}

モデルに attributes を定義することでシリアライズのされ方が変わる。

app/models/user.rb
class User < ApplicationRecord
  def attributes
    {'name' => nil, 'screen_name' => nil }
  end 
end
{
  "name":"山田",
  "screen_name":"@yamada",
}

キーにシンボルが使えなかったり、値をnilにしたり、使い勝手としてはなんか気持ち悪い。

実装自体は ActiveModel のもので、 serializable_hash というメソッドが生える。
対応するインスタンス変数やメソッドをいい感じに探してくれるらしい。

app/models/user.rb
class User < ApplicationRecord
  # 新しいメソッドを定義する
  def created_date
    created_at.strftime("%Y年%m月%d日")
  end
end
user.serializable_hash(only: :name)   # => {"name"=>"山田"}
user.serializable_hash(except: :name) # => {"screen_name"=>"@yamada"}
user.serializable_hash(methods: :created_date) # => {"name"=>"山田", "screen_name"=>"yamada", "created_date"=>"2019年12月09日"}

Jbuilder

generate JSON objects with a Builder-style DSL

テンプレートファイルを用意して、独自のDSLで記述する。
Railsに標準で同梱されている。

app/controllers/jbuilder_controller.rb
class JbuilderController < ApplicationController
  def sample
    @user = User.first
    render :formats => :json
  end
end

または :handlers を明示する。

render :formats => :json, :handlers => :jbuilder
app/views/jbuilder/sample.json.jbuilder
json.name @user.name
json.screen_name @user.screen_name
json.followers_count "#{@user.followers_count}人"
{
  "name":"山田",
  "screen_name":"@yamada",
  "followers_count":"50人"
}

コントローラー側でインスタンス変数にデータを格納して、
jbuilderでいじくりまわすので普通にビュー作るときと同じノリで書ける。

とにかく小回りが利く、柔軟、自由度が高いっていうのがたぶんProsで、
複数モデルを取り回すのとか、複雑な分岐とか凝ったことやりたいときに向いているのかなーというのが所管なんだけど、
あまりに好きに書けすぎるので気をつけないとごちゃごちゃになって破綻しそう。

ネストさせたり、関連や条件分岐を足してみる。

app/views/jbuilder/sample.json.jbuilder
json.name @user.name
json.screen_name @user.screen_name

json.count do
  json.follows "#{@user.friends_count}人"
  json.followers "#{@user.followers_count}人"
end

json.tweets @user.tweets, :id, :text, :created_at

if (Time.now - @user.created_at) / 24 / 60 / 60 < 7
  json.new_user true 
end
{
  "name":"山田",
  "screen_name":"@yamada",
  "count":{
    "follows":"100人",
    "followers":"50人"
  },
  "tweets":[
    {
      "id":1,
      "text":"おなかへった",
      "created_at":"2019-12-09T10:46:29.000Z"
    }
  ],
  "new_user":true
}

レイヤーとしてはビューなのでビジネスロジック書くのにもちょっと抵抗がある。
そうなると結局間に decorator とか プレゼンター層挟むの?みたいになってしまう。

まあでもこういう書き方が好みの人もいるのかなーという感じ。
DSLも掘ったら奥が深いので、Jbuilder職人みたいな人が爆誕するたぶん。

あとは、jbuilder遅い問題とどう向き合うか。

Jb

Jbuilder 遅い & DSL気持ち悪い問題に挑んでいるライブラリ。

A simple and fast JSON API template engine for Ruby on Rails
A simpler and faster Jbuilder alternative.

Gemfile
gem 'jb'

Rubyを気持ちよく書けるようになっている。
The Ruby.
Jbuilderの例と同じようにシリアライズしてみる。

app/views/jb/sample.json.jb
json = {
  name: @user.name,
  screen_name: @user.screen_name,
  count: {
    follows: "#{@user.friends_count}人",
    followers: "#{@user.followers_count}人"
  }
}

json[:tweets] = @user.tweets.map do |tweet|
  {
    id: tweet.id,
    text: tweet.text,
    created_at: tweet.created_at
  }
end

if (Time.now - @user.created_at) / 24 / 60 / 60 < 7
  json[:new_user] = true 
end

json

template を render する

app/controllers/jb_controller.rb
class JbController < ApplicationController
  def sample
    @user = User.first
    render :template => "jb/sample.json.jb"
  end
end

Featuresの

No ugly builder syntax

に重みがある😂

ActiveModelSerializers

シリアライズ専用のモデルにJSONの定義を委譲する形のもの。
Jbuilderみたいなテンプレートはなく、Rubyのオブジェクトにマクロで記述していく。

Gemfile
gem 'active_model_serializers'
app/controllers/active_model_serializer_controller.rb
class ActiveModelSerializerController < ApplicationController
  def sample
    user = User.first
    render :json => user
  end
end
app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
  attributes :name, :screen_name, :description
end
{
  "name":"山田",
  "screen_name":"@yamada",
  "description":"システムエンジニアです"
}

シリアライズという目的だけに特化したオブジェクトなので、綺麗に責務が分離できるのが気持ちいい。
JSONの整形に必要なロジックを、モデルから取り除いてここに全部詰め込めばよい。

シリアライズに使用するクラスは render の引数で自由に変更できるので、
1つのモデルに対して複数のシリアライズを表現できるのも便利。

render :json => user, :serializer => NeoUserSerializer
app/serializers/neo_user_serializer.rb
class NeoUserSerializer < ActiveModel::Serializer
  attribute :neo_name do
    "ネオ#{object.name}"
  end
end
{"neo_name":"ネオ山田"}

コントローラーからオプションの引数を渡すこともできるし、

render :json => user, :with_tweets => true

それを使って条件付きでシリアライズに含めるかどうかを制御するのはこうやる。

app/serializers/user_serailizer.rb
class UserSerializer < ActiveModel::Serializer
  attributes :name, :screen_name, :description

  def initialize(object, **option)
    @with_tweets = option[:with_tweets]
    super
  end

  attribute :tweets, if: -> { @with_tweets }

  def tweets
    object.tweets
  end
end

{
  "name":"山田",
  "screen_name":"@yamada",
  "description":"システムエンジニアです",
  "tweets":[
    {
      "id":1,
      "user_id":1,
      "in_reply_to_status_id":null,
      "text":"おなかへった",
      "source":"iPhone",
      "retweet_count":0,
      "favorite_count":0,
      "created_at":"2019-12-09T10:46:29.000Z"
    }
  ]
}

has_many, belongs_to, has_one がサポートされているので、
関連は以下のようにも表現できる。

app/serializers/user_serailizer.rb
has_many :tweets

この時、子レコードはどうシリアライズされるのかというと、
これまた対応するモデルのシリアライザーを勝手に呼び出してくれる。

なので、1モデル1シリアライザーを定義しておけば、
アプリケーション内で一貫して統一の取れたリソース指向のレスポンスを構築することができる。

テストもしやすい。

そしてPOROにも使うことができる。

よい😇

Fast JSON API

Active Model Serializer をベースに、パフォーマンスを改善して高速化させたNetflix社謹製のライブラリ。
ベンチマークで25倍の速さが出たそう。

書き方は ActiveModelSerializers に近いのだが、
シリアライズされたものの体裁がだいぶ異なる。

app/controllers/fast_json_api_controller.rb
class FastJsonApiController < ApplicationController
  def sample
    user = User.first
    options = { :include => [:tweets] }
    serializer = UserSerializer.new(user, options)
    render :json => serializer.serialized_json
  end
end
app/serializers/user_serializer.rb
class UserSerializer
  include FastJsonapi::ObjectSerializer
  attributes :name, :screen_name
  has_many :tweets
end
app/serializers/tweet_serializer.rb
class TweetSerializer
  include FastJsonapi::ObjectSerializer
  attributes :text
end
{
  "data": {
    "id": "1",
    "type": "user",
    "attributes": {
      "name": "山田",
      "screen_name": "@yamada"
    },
    "relationships": {
      "tweets": {
        "data": [
          {
            "id": "1",
            "type": "tweet"
          }
        ]
      }
    }
  },
  "included": [
    {
      "id": "1",
      "type": "tweet",
      "attributes": {
        "text": "おなかへった"
      }
    }
  ]
}

これは何かと言うと、ライブラリの説明にも書いてある通り、
JSON:API の仕様に沿ったシリアライズらしい。

A lightning fast JSON:API serializer for Ruby Objects.

新し目のライブラリなのでまだまだ足りないところはありそうですが、
今後に期待といったところですかね。

JSONAPI::Resources

A resource-focused Rails library for developing JSON:API compliant servers

JSONAPI::Resources, or "JR", provides a framework for developing an API server that complies with the JSON:API specification.

これも JSON:API の仕様に則ったシリアライズを提供するライブラリ?フレームワーク?
現在の最新バージョンは0.10

Gemfile
gem 'jsonapi-resources'

モデルは ActiveRecord でも他のオブジェクトでもいいらしいが、
app/resources というディレクトリに、 ***_resource.rb という名前で置く。
app/resources なんてディレクトリ初めて見たかもしれない。

app/resources/user_resource.rb
class UserResource < JSONAPI::Resource
  attributes :name, :screen_name
  has_many :tweets
end
app/resources/tweet_resource.rb
class TweetResource < JSONAPI::Resource
  attributes :text, :source, :retweet_count, :favorite_count
end

ルーティングのヘルパーも提供されていて、 routes.rb に以下のように書いてみると、

config/routes.rb
Rails.application.routes.draw do
  jsonapi_resources :users
  jsonapi_resources :tweets
end

こういうルーティングができあがる。

user_relationships_tweets GET       /users/:user_id/relationships/tweets(.:format)                                           users#show_relationship {:relationship=>"tweets"}
                          POST      /users/:user_id/relationships/tweets(.:format)                                           users#create_relationship {:relationship=>"tweets"}
                          PUT|PATCH /users/:user_id/relationships/tweets(.:format)                                           users#update_relationship {:relationship=>"tweets"}
                          DELETE    /users/:user_id/relationships/tweets(.:format)                                           users#destroy_relationship {:relationship=>"tweets"}
      user_related_tweets GET       /users/:user_id/tweets(.:format)                                                         tweets#index_related_resources {:relationship=>"tweets", :source=>"users"}
                          GET       /users(.:format)                                                                         users#index
                          POST      /users(.:format)                                                                         users#create
                     user GET       /users/:id(.:format)                                                                     users#show
                          PATCH     /users/:id(.:format)                                                                     users#update
                          PUT       /users/:id(.:format)                                                                     users#update
                          DELETE    /users/:id(.:format)                                                                     users#destroy
                   tweets GET       /tweets(.:format)                                                                        tweets#index
                          POST      /tweets(.:format)                                                                        tweets#create
                    tweet GET       /tweets/:id(.:format)                                                                    tweets#show
                          PATCH     /tweets/:id(.:format)                                                                    tweets#update
                          PUT       /tweets/:id(.:format)                                                                    tweets#update
                          DELETE    /tweets/:id(.:format)                                                                    tweets#destroy

JSON:API っぽいやつですね。
詳しくはここを => Routing

次はコントローラー。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  include JSONAPI::ActsAsResourceController
end
app/controllers/tweets_controller.rb
class TweetsController < ApplicationController
  include JSONAPI::ActsAsResourceController
end

アクション書く必要はありません。
これで試しに /users にアクセスしてみます。

/users

{
  "data": [
    {
      "id": "1",
      "type": "users",
      "links": {
        "self": "http://localhost:3000/users/1"
      },
      "attributes": {
        "name": "山田",
        "screen-name": "@yamada"
      },
      "relationships": {
        "tweets": {
          "links": {
            "self": "http://localhost:3000/users/1/relationships/tweets",
            "related": "http://localhost:3000/users/1/tweets"
          }
        }
      }
    },
    {
      "id": "2",
      "type": "users",
      "links": {
        "self": "http://localhost:3000/users/2"
      },
      "attributes": {
        "name": "ボブ",
        "screen-name": "@bob"
      },
      "relationships": {
        "tweets": {
          "links": {
            "self": "http://localhost:3000/users/2/relationships/tweets",
            "related": "http://localhost:3000/users/2/tweets"
          }
        }
      }
    },
  ]
}

えっ、すげえ。
せっかくなのでリンクをたどる。

/users/1/relationships/tweets

{
  "data": [
    {
      "type": "tweets",
      "id": "1"
    }
  ],
  "links": {
    "self": "http://localhost:3000/users/1/relationships/tweets",
    "related": "http://localhost:3000/users/1/tweets"
  }
}

/users/1/tweets

{
  "data": [
    {
      "id": "1",
      "type": "tweets",
      "links": {
        "self": "http://localhost:3000/tweets/1"
      },
      "attributes": {
        "text": "おなかへった",
        "source": "iPhone",
        "retweet-count": 0,
        "favorite-count": 0
      }
    }
  ]
}

はえ〜。便利そうだけど初見ではかなりブラックボックスだな〜。

Resources とか奥が深そう。
シリアライズだけじゃなくて書き込みもあるわルーティングもやってくれるわ、フレームワークですね。

これ Rails に組み込むのは色んな意味でありなのか... :thinking:

結局どれがいいのか

ただライブラリ紹介しただけでは芸がないので界隈の意見を見てみます。

Ruby on RailsのJSON API Responseについて考える。あるいはjbuilderについての個人的違和感とその解

人によると思いますが、個人的にはjbuilderのDSLには違和感があります。
これは個人差だと思いますが、下記のif文を見てみます。

if current_user.admin?
 json.visitors calculate_visitors(@message)
end

current_userがadminであれば、生成されるJSONにvisitorsというattributeが追加されます。
逆に言えば、current_userがadminではない場合は、visitorsというattributteは付きません。

サーバサイドであればJSON Responseを返すだけで後はクライアント(JavaScript、iOS・Android etc)でいい感じにやってくれ。
という事も出来ますが、userの属性によってattributeがついたり消えたりするのはクライアントのコードが複雑になることに繋がります。

DSL に違和感があるのは同意だが、attributeが付く付かないに関して懸念はない。
OpenAPI 信者なので、値がないのにキーとして含める方がめんどくさいと思っている。
■ 参考:そのフィールド、nullable にしますか、requiredにしますか

一つの解として、ActiveModelSerializerというgemがあります。
が、2018/12 現在、ActiveModelSerializerは積極的にメンテナンスはされていません。

確かに。いま現在でも master の最終コミットが 28 Aug 2018 だぞ。
 2019-12-09 22.36.24.png

すごいみんな使ってるし、メンテしたいなー。

記事の結論としては fast_jsonapi 推しだけど、
JSON:API をどう捉えるかですかねー。

Rails API開発におけるJSONレスポンス生成方法と内部実装について

jbuilderのfind_template_pathsのN+1問題を解決しつつ、DSLではなくRubyのハッシュを書いて自然に書けるようにしたり、値の設定にmethod_missingを使わないようにしたのがjbになります。

Jbの話。
ベンチマークでは3~30倍くらいの速さが出たらしい。
個人的には Jbuilder よりはこちらを推したいな :thinking:

速度はさておき、あのDSLが好きかどうかみたいなところもありそう。
潔癖な人は Jb のとにかく Ruby で殴るみたいな感じを嫌いそうだなーとも思ったり。

リソース指向が強いAPIであれば、active_model_serializersが良さそうです。ViewObject的な立ち回りでテスト可能であることも強みだと思います。

一方で、リソース指向が強くなければjbuilderやjbが書きやすいです。速度的にはjbが良さそうですが、ビジネス要件としての速度を満たすかどうかという点では大抵のケースにおいて、jbuilderでも問題無いです。コレクションレンダリングのパフォーマンスとテンプレートの共通化の部分がjbuilderを使うかどうかの論点になると思います。

やはりきれいにリソース指向にするなら Active Model Serializers、
複雑なことやるなら JBuilder みたいなテンプレートエンジンなんだろうか

まとめ

  • リソース指向かテンプレートか
  • JSON:APIに準拠するかどうか
  • Ruby か DSL か
  • 速度はどこまで許容できるか
  • テスタビリティ
  • シリアライズをどの層に位置づけるか

あたりが選定基準かな :thinking:

アンケートやってみるか

他にもあったら教えてください。
以上でした。

198
136
1

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
198
136