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さん
どんな実装になっているのか覗いてみると、
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 シリアライズ
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
を定義することでシリアライズのされ方が変わる。
class User < ApplicationRecord
def attributes
{'name' => nil, 'screen_name' => nil }
end
end
{
"name":"山田",
"screen_name":"@yamada",
}
キーにシンボルが使えなかったり、値をnilにしたり、使い勝手としてはなんか気持ち悪い。
実装自体は ActiveModel のもので、 serializable_hash
というメソッドが生える。
対応するインスタンス変数やメソッドをいい感じに探してくれるらしい。
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に標準で同梱されている。
class JbuilderController < ApplicationController
def sample
@user = User.first
render :formats => :json
end
end
または :handlers
を明示する。
render :formats => :json, :handlers => :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で、
複数モデルを取り回すのとか、複雑な分岐とか凝ったことやりたいときに向いているのかなーというのが所管なんだけど、
あまりに好きに書けすぎるので気をつけないとごちゃごちゃになって破綻しそう。
ネストさせたり、関連や条件分岐を足してみる。
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遅い問題とどう向き合うか。
- jbuilderで無理矢理N+1 partial renderingを回避する方法
- jbuilderのpartialを高速化する
- ActiveModel::Serializers vs Jbuilder
Jb
Jbuilder 遅い & DSL気持ち悪い問題に挑んでいるライブラリ。
A simple and fast JSON API template engine for Ruby on Rails
A simpler and faster Jbuilder alternative.
gem 'jb'
Rubyを気持ちよく書けるようになっている。
The Ruby.
Jbuilderの例と同じようにシリアライズしてみる。
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 する
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のオブジェクトにマクロで記述していく。
gem 'active_model_serializers'
class ActiveModelSerializerController < ApplicationController
def sample
user = User.first
render :json => user
end
end
class UserSerializer < ActiveModel::Serializer
attributes :name, :screen_name, :description
end
{
"name":"山田",
"screen_name":"@yamada",
"description":"システムエンジニアです"
}
シリアライズという目的だけに特化したオブジェクトなので、綺麗に責務が分離できるのが気持ちいい。
JSONの整形に必要なロジックを、モデルから取り除いてここに全部詰め込めばよい。
シリアライズに使用するクラスは render
の引数で自由に変更できるので、
1つのモデルに対して複数のシリアライズを表現できるのも便利。
render :json => user, :serializer => NeoUserSerializer
class NeoUserSerializer < ActiveModel::Serializer
attribute :neo_name do
"ネオ#{object.name}"
end
end
{"neo_name":"ネオ山田"}
コントローラーからオプションの引数を渡すこともできるし、
render :json => user, :with_tweets => true
それを使って条件付きでシリアライズに含めるかどうかを制御するのはこうやる。
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
がサポートされているので、
関連は以下のようにも表現できる。
has_many :tweets
この時、子レコードはどうシリアライズされるのかというと、
これまた対応するモデルのシリアライザーを勝手に呼び出してくれる。
なので、1モデル1シリアライザーを定義しておけば、
アプリケーション内で一貫して統一の取れたリソース指向のレスポンスを構築することができる。
テストもしやすい。
そしてPOROにも使うことができる。
よい😇
Fast JSON API
Active Model Serializer をベースに、パフォーマンスを改善して高速化させたNetflix社謹製のライブラリ。
ベンチマークで25倍の速さが出たそう。
書き方は ActiveModelSerializers に近いのだが、
シリアライズされたものの体裁がだいぶ異なる。
class FastJsonApiController < ApplicationController
def sample
user = User.first
options = { :include => [:tweets] }
serializer = UserSerializer.new(user, options)
render :json => serializer.serialized_json
end
end
class UserSerializer
include FastJsonapi::ObjectSerializer
attributes :name, :screen_name
has_many :tweets
end
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
gem 'jsonapi-resources'
モデルは ActiveRecord でも他のオブジェクトでもいいらしいが、
app/resources
というディレクトリに、 ***_resource.rb
という名前で置く。
app/resources
なんてディレクトリ初めて見たかもしれない。
class UserResource < JSONAPI::Resource
attributes :name, :screen_name
has_many :tweets
end
class TweetResource < JSONAPI::Resource
attributes :text, :source, :retweet_count, :favorite_count
end
ルーティングのヘルパーも提供されていて、 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
次はコントローラー。
class UsersController < ApplicationController
include JSONAPI::ActsAsResourceController
end
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 に組み込むのは色んな意味でありなのか...
結局どれがいいのか
ただライブラリ紹介しただけでは芸がないので界隈の意見を見てみます。
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 だぞ。
すごいみんな使ってるし、メンテしたいなー。
記事の結論としては fast_jsonapi 推しだけど、
JSON:API をどう捉えるかですかねー。
Rails API開発におけるJSONレスポンス生成方法と内部実装について
jbuilderのfind_template_pathsのN+1問題を解決しつつ、DSLではなくRubyのハッシュを書いて自然に書けるようにしたり、値の設定にmethod_missingを使わないようにしたのがjbになります。
Jbの話。
ベンチマークでは3~30倍くらいの速さが出たらしい。
個人的には Jbuilder よりはこちらを推したいな
速度はさておき、あのDSLが好きかどうかみたいなところもありそう。
潔癖な人は Jb のとにかく Ruby で殴るみたいな感じを嫌いそうだなーとも思ったり。
リソース指向が強いAPIであれば、active_model_serializersが良さそうです。ViewObject的な立ち回りでテスト可能であることも強みだと思います。
一方で、リソース指向が強くなければjbuilderやjbが書きやすいです。速度的にはjbが良さそうですが、ビジネス要件としての速度を満たすかどうかという点では大抵のケースにおいて、jbuilderでも問題無いです。コレクションレンダリングのパフォーマンスとテンプレートの共通化の部分がjbuilderを使うかどうかの論点になると思います。
やはりきれいにリソース指向にするなら Active Model Serializers、
複雑なことやるなら JBuilder みたいなテンプレートエンジンなんだろうか
まとめ
- リソース指向かテンプレートか
- JSON:APIに準拠するかどうか
- Ruby か DSL か
- 速度はどこまで許容できるか
- テスタビリティ
- シリアライズをどの層に位置づけるか
あたりが選定基準かな
アンケートやってみるか
Rails で JSON のシリアライズになに使ってますか
— 青いエンジニア🦋 (@itmono_sakuraya) December 9, 2019
他にもあったら教えてください。
以上でした。