Rails 產生 JSON 有內建的 as_json 可以使用之外,對於 JSON API 處理也有預設安裝的 jbuilder 套件,也有其他不同套件可以選擇。本篇文章將比較下面幾種解決方案:

  1. as_json
  2. jbuilder
  3. active_model_serializers
  4. oj_serializers

依據個人的經驗考慮以下需求:

  1. 容易整理維護。
  2. 定義好的結構能夠方便重用。
  3. 要能在程式中產生 JSON 物件使用。
  4. 效能。
  5. 方便測試。

情境

假設有兩個 model:PostUser。想要分別產生下面的 JSON 結果:

Post index

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"posts": [
{
"id": 1,
"title": "Post Title",
"timeago": "19 minutes",
"user": {
"id": 1,
"name": "User Name"
}
}
]
}

Post show

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"posts": [
{
"id": 1,
"title": "Post Title",
"timeago": "19 minutes",
"content": "Post Content...",
"user": {
"id": 1,
"name": "User Name"
}
}
]
}

User index

1
2
3
4
5
6
7
8
{
"users": [
{
"id": 1,
"name": "User Name"
}
]
}

這邊是模擬列表頁顯示部分資料,進入內頁後則吐出完整資料,以及巢狀資料結構的情境。

功能

下面分別以四種不同方式來實作上面的例子。

as_json

post.rb

1
2
3
4
5
6
7
8
9
10
class Post < ApplicationRecord
# 為了使用 time_ago_in_words
include ActionView::Helpers::DateHelper

belongs_to :user

def timeago
time_ago_in_words(created_at)
end
end

posts_controller.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class PostsController < ApplicationController
def index
render json: {
posts: Post.all.as_json(
only: %i[id title],
methods: %i[timeago],
include: {
user: {
only: %i[id name]
}
}
)
}
end

def show
render json: {
posts: Post.find(params[:id]).as_json(
only: %i[id title content],
methods: %i[timeago],
include: {
user: {
only: %i[id name]
}
}
)
}
end
end

users_controller.rb

1
2
3
4
5
6
7
8
9
class UsersController < ApplicationController
def index
render json: {
users: User.all.as_json(
only: %i[id title]
)
}
end
end

作為內建的函式,可以很簡單地將 model 資料轉成 JSON 輸出,不過欄位要處理後才輸出的情況並不好使用。上面例子可以看到需要在 model 實作 timeago。而在重用結構的部分,則需要自行設計一套方式來整理,否則像上面會有很多程式重複。

jbuilder

users/_user.json.jbuilder

1
json.extract! user, *%i[id name]

users/index.json.jbuilder

1
2
3
json.users do
json.array! users, partial: 'user', as: :user
end

posts/_post.json.jbuilder

1
2
3
4
5
json.extract! post, *%i[id title]
json.extract! post, *%i[content] if local_assigns[:detail]
json.timeago time_ago_in_words(post.created_at)

json.user post.user, partial: 'users/user', as: :user

posts/index.json.jbuilder

1
2
3
json.posts do
json.array! posts, partial: 'post', as: :post
end

posts/show.json.jbuilder

1
2
3
json.post do
json.partial! post, as: :post, detail: true
end

posts_controller.rb

1
2
3
4
5
6
7
8
9
class PostsController < ApplicationController
def index
render locals: { posts: Post.all }
end

def show
render locals: { post: Post.find(params[:id]) }
end
end

users_controller.rb

1
2
3
4
5
class UsersController < ApplicationController
def index
render locals: { users: User.all }
end
end

jbuilder 使用 Render View 的方式來輸出 JSON,將如何產生 JSON 的邏輯都移到 view 去。由於是 Render View 的使用方式,在重用的部分使用 partial 來達成,上面範例可以將 User 和 Post 結構重複使用。不過在 Post Show 要輸出詳細資料的情況,需要搭配參數來做不同輸出的判斷,才能重用結構。

由於使用 Render View 來產生 JSON,所以在不是 API 的地方要產生 JSON 就會比較麻煩一點。另外測試需使用 Controller 來測試,在 View 的程式邏輯測試覆蓋率也不會列入計算。

如果想要在程式中利用現有的 View 產生 JSON 的寫法,可以這樣寫

1
2
3
4
ActionController::Base.new.render_to_string(
partial: 'posts/post',
locals: { post: post, detail: true }
)

這樣產生的是 JSON 字串,如果需要物件進一步操作,要先 JSON.parse

active_model_serializers

user_serializer.rb

1
2
3
class UserSerializer < ActiveModel::Serializer
attributes :id, :name
end

post_serializer.rb

1
2
3
4
5
6
7
8
9
10
class PostSerializer < ActiveModel::Serializer
include ActionView::Helpers::DateHelper

attributes :id, :title
attribute :timeago do
time_ago_in_words(object.created_at)
end

has_one :user
end

post_detail_serializer.rb

1
2
3
class PostDetailSerializer < PostSerializer
attributes :content
end

posts_controller.rb

1
2
3
4
5
6
7
8
9
class PostsController < ApplicationController
def index
render json: Post.all
end

def show
render json: Post.find(params[:id]), serializer: PostDetailSerializer
end
end

users_controller.rb

1
2
3
4
5
class UsersController < ApplicationController
def index
render json: User.all
end
end

active_model_serializers 使用定義 Serializer 類別的方式來輸出,重用的部分也可以使用繼承的方式來輸出詳細內容,如上面的 PostDetailSerializer

如果要在程式中產生 JSON 可以這樣寫:

1
2
3
ActiveModelSerializers::SerializableResource.new(post, {
serializer: PostDetailSerializer
}).as_json

不過其實使用上存在蠻多問題:

nil 要額外處理

當取得單筆資料時,如果不是回傳 404 而是期望輸出 null 時,會發生錯誤。例如下面情況,

1
render json: post, serializer: PostDetailSerializer

postnil 時,原本期望輸出

1
2
3
{
"post": null
}

實際上會發生錯誤

1
undefined method `read_attribute_for_serialization' for nil

變成要寫成這樣

1
2
3
4
5
if post
render json: post, serializer: PostDetailSerializer
else
render json: { post: nil }
end

預設單一 root

預設用法會自動產生 posts 或 post 之類的 root,例如:

1
2
3
{
"posts": [{ ... }]
}

想要輸出額外的資料,例如

1
2
3
4
{
"posts": [{ ... }],
"total_pages": 10
}

預設做不到,只能使用上面生成 JSON 的方式,或使用 meta 參數

1
render json: Post.all, meta: { total_pages: 10 }

但這樣會多一層

1
2
3
4
5
6
{
"posts": [{ ... }],
"meta": {
"total_pages": 10
}
}

oj_serializers

user_serializer.rb

1
2
3
class UserSerializer < Oj::Serializer
attributes :id, :name
end

post_serializer.rb

1
2
3
4
5
6
7
8
9
10
class PostSerializer < Oj::Serializer
include ActionView::Helpers::DateHelper

attributes :id, :title
attribute :timeago do
time_ago_in_words(post.created_at)
end

has_one :user
end

post_detail_serializer.rb

1
2
3
class PostDetailSerializer < PostSerializer
attributes :content
end

posts_controller.rb

1
2
3
4
5
6
7
8
9
class PostsController < ApplicationController
def index
render json: { posts: PostSerializer.render(Post.all) }
end

def show
render json: { post: PostDetailSerializer.render(Post.find(params[:id])) }
end
end

users_controller.rb

1
2
3
4
5
class UsersController < ApplicationController
def index
render json: { users: UserSerializer.render(User.all) }
end
end

和 active_model_serializers 一樣式定義類別來產生,但用起來更直覺。透過呼叫定義好的 Serializer 中的函式就可以直接產生 JSON,在 Controller 自行組成 JSON,分頁等其他資訊也可以很容易的加上去。至於單一資料 nil 的問題,如果想要輸出 nil,可透過 one_if 函式達成:

1
render json: { post: PostDetailSerializer.one_if(post) }

效能

接著來測試一下效能,這邊我建立了一個測試專案,跑出來的結果大概是這樣

1
2
3
4
5
             as_json      0.116 (± 0.0%) i/s     (8.62 s/i) -      1.000 in   8.623844s
jbuilder 0.103 (± 0.0%) i/s (9.70 s/i) - 1.000 in 9.698932s
active_model_serializers 0.057 (± 0.0%) i/s (17.55 s/i) - 1.000 in 17.549307s
oj_serializer 0.155 (± 0.0%) i/s (6.45 s/i) - 1.000 in 6.453107s
oj_serializer.to_json 0.187 (± 0.0%) i/s (5.36 s/i) - 1.000 in 5.358214s

oj_serializer 的效能最好,其中我又湊巧發現加上 to_json 的效能又更好,以上面的例子來說,就是改成這樣

1
render json: { posts: PostSerializer.render(Post.all) }.to_json

上面的測試是已經加上了 oj 的優化結果。

總結

將需求做成表格進行比較

需求as_jsonjbuilderactive_model_serializersoj_serializer
容易整理維護XOOO
方便重用XOOO
在程式中產生 JSONOOO
效能XO
方便測試OOO

oj_serializer 是個不錯的選擇。