mybook

シリアライゼーション — レスポンスの形を整える

「このJSON、使いづらい」

iOSエンジニアの林さんからSlackにメッセージが届いた。

「ナツミさん、APIのレスポンスなんですが……created_at"2024-01-15T09:23:45.123Z" で来てるんですけど、アプリ側でパース大変で。あと user_id はIDだけじゃなくてユーザー名も欲しいです。それと、物件の写真URLは配列で来てますか?」

ナツミは気づいた。render json: @propertyActive Recordのattributesをそのまま垂れ流すだけ。本番で必要なレスポンス設計はもっと丁寧に行う必要がある。

# この実装では全フィールドが漏れる
def show
  @property = Property.find(params[:id])
  render json: @property  # 危険!password_digestも含まれる可能性
end

シリアライゼーションの選択肢

Railsには複数のシリアライゼーション手段がある。

Loading diagram...

jbuilder — テンプレートベースのアプローチ

jbuilderはRailsにデフォルトで含まれるgemで、ERBテンプレートと同じ感覚でJSONを組み立てられる。

インストール

# Gemfile(Rails標準で含まれている)
gem 'jbuilder'

実装例

# app/views/api/v1/properties/show.json.jbuilder
json.data do
  json.id @property.id
  json.name @property.name
  json.price @property.price
  json.area @property.area
 
  json.location do
    json.prefecture @property.prefecture
    json.city @property.city
    json.full_address "#{@property.prefecture}#{@property.city}#{@property.address}"
  end
 
  json.owner do
    json.id @property.user.id
    json.name @property.user.name
    json.avatar_url @property.user.avatar_url
  end
 
  json.photos @property.photos do |photo|
    json.id photo.id
    json.url photo.url
    json.thumbnail_url photo.thumbnail_url
  end
 
  json.published_at @property.published_at&.strftime('%Y-%m-%d')
  json.created_at @property.created_at.iso8601
end
# app/views/api/v1/properties/index.json.jbuilder
json.data @properties do |property|
  json.partial! 'api/v1/properties/property', property: property
end
 
json.meta do
  json.current_page @properties.current_page
  json.total_pages @properties.total_pages
  json.total_count @properties.total_count
end
# app/views/api/v1/properties/_property.json.jbuilder
json.id property.id
json.name property.name
json.price property.price
json.area property.area
json.thumbnail_url property.photos.first&.thumbnail_url
json.created_at property.created_at.iso8601

INFO

jbuilderのメリット:学習コストが低く、RailsのViewレイヤーと統一感がある。部分テンプレートで再利用しやすい。N+1クエリが発生しやすいので includes を忘れずに。


ActiveModelSerializers — クラスベースのアプローチ

ViewではなくSerializerクラスにシリアライゼーションロジックを集約するアプローチ。

インストール

# Gemfile
gem 'active_model_serializers'

Serializerクラスの実装

# app/serializers/property_serializer.rb
class PropertySerializer < ActiveModel::Serializer
  attributes :id, :name, :price, :area, :published_at, :created_at
 
  belongs_to :user, serializer: UserSerializer
  has_many :photos, serializer: PhotoSerializer
 
  attribute :location do
    {
      prefecture: object.prefecture,
      city: object.city,
      full_address: "#{object.prefecture}#{object.city}#{object.address}"
    }
  end
 
  attribute :published_at do
    object.published_at&.strftime('%Y-%m-%d')
  end
 
  attribute :created_at do
    object.created_at.iso8601
  end
end
 
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
  attributes :id, :name, :avatar_url
  # password_digestなどは含めない
end
 
# app/serializers/photo_serializer.rb
class PhotoSerializer < ActiveModel::Serializer
  attributes :id, :url, :thumbnail_url
end

コントローラでの使い方

def show
  @property = Property.includes(:user, :photos).find(params[:id])
  render json: @property, serializer: PropertySerializer
end
 
def index
  @properties = Property.includes(:user, :photos).published
                         .page(params[:page]).per(20)
 
  render json: @properties,
         each_serializer: PropertySerializer,
         meta: pagination_meta(@properties),
         adapter: :json
end

jsonapi-serializer — JSON:API準拠

JSON:APIは標準化されたJSONフォーマット仕様。大規模なAPIや外部公開APIに向いている。

JSON:APIのレスポンス構造

{
  "data": {
    "id": "1",
    "type": "properties",
    "attributes": {
      "name": "渋谷マンション",
      "price": 150000,
      "area": 35.5
    },
    "relationships": {
      "user": {
        "data": { "id": "42", "type": "users" }
      }
    }
  },
  "included": [
    {
      "id": "42",
      "type": "users",
      "attributes": {
        "name": "田中太郎"
      }
    }
  ]
}

インストールと実装

# Gemfile
gem 'jsonapi-serializer'
# app/serializers/property_serializer.rb
class PropertySerializer
  include JSONAPI::Serializer
 
  set_type :properties
  set_id :id
 
  attributes :name, :price, :area
 
  attribute :location do |property|
    {
      prefecture: property.prefecture,
      city: property.city
    }
  end
 
  attribute :published_at do |property|
    property.published_at&.strftime('%Y-%m-%d')
  end
 
  belongs_to :user
  has_many :photos
end
# コントローラ
def show
  @property = Property.includes(:user, :photos).find(params[:id])
  render json: PropertySerializer.new(@property, include: [:user, :photos]).serializable_hash
end

WARNING

JSON:APIはオーバーエンジニアリングになりやすい。チームやクライアントがJSON:API仕様を理解していないと、構造が複雑すぎて使いにくい。社内APIや小規模なAPIにはシンプルなJSONで十分な場合が多い。


N+1クエリ問題の解決

シリアライゼーションで最も注意すべきはN+1クエリ。

# N+1が発生するコード
Property.all.each do |property|
  property.user.name    # ← userをN回クエリ
  property.photos.count # ← photosをN回クエリ
end
-- SQLログ(N+1の証拠)
SELECT * FROM properties;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
-- ... N件分繰り返される
# includesで解決
@properties = Property
  .includes(:user, :photos)
  .where(published: true)
  .page(params[:page])
 
# これで2クエリに
# SELECT * FROM properties WHERE ...
# SELECT * FROM users WHERE id IN (1, 2, 3, ...)
# SELECT * FROM photos WHERE property_id IN (1, 2, 3, ...)

INFO

Bullet gemでN+1クエリを自動検出できる。開発環境でのみ有効にして、N+1を見つけたら includes で解消する。

# Gemfile
group :development do
  gem 'bullet'
end
 
# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.rails_logger = true
  Bullet.add_footer = true
end

どのシリアライザーを選ぶか

ナツミのチームは最終的にjsonapi-serializerを選んだ。理由は以下の通り。

観点jbuilderAMSjsonapi-serializer
学習コスト
テストしやすさ△(View依存)
パフォーマンス
標準化○(JSON:API)
外部公開API
# 最終実装:コントローラがスッキリ
module Api
  module V1
    class PropertiesController < Api::V1::BaseController
      def index
        @properties = Property.includes(:user, :photos)
                               .published
                               .page(params[:page]).per(20)
 
        render json: PropertySerializer.new(
          @properties,
          include: [:user, :photos],
          meta: pagination_meta(@properties),
          params: { current_user: current_user }
        ).serializable_hash
      end
 
      def show
        @property = Property.includes(:user, :photos).find(params[:id])
        render json: PropertySerializer.new(
          @property,
          include: [:user, :photos]
        ).serializable_hash
      end
    end
  end
end

まとめ

  • render json: @model は危険。センシティブなフィールドが漏れる可能性がある
  • jbuilder:学習コスト低、テンプレートで直感的。小規模プロジェクト向け
  • ActiveModelSerializers:クラスベースで整理しやすい。中規模プロジェクト向け
  • jsonapi-serializer:JSON:API標準準拠、テストしやすい。外部公開APIや大規模向け
  • N+1クエリは includes で解決し、Bullet gemで自動検出する

次章では、「APIを使えるのは誰か」を制御する認証とアクセス制御を学ぶ。