STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

RailsでのJSON Serializationをもっと簡単にやる

この記事は STORES Advent Calendar 2023 の30日目の記事です。

はじめに

STORES 予約でエンジニアをしている望月です。

近年、Webアプリケーションのフロントエンド開発において、Reactなどのモダンな技術がリッチなユーザーインターフェースの実現を目指して頻繁に採用されるようになりました。 これに伴いRailsアプリケーションの開発方法も変化しています。 従来のRailsによるView層でのフロントエンド実装から脱却し、Railsは主にAPIサーバーとしての役割を果たす構成が増えてきました。

Railsを基盤に構築されているSTORES 予約でも、従来のRailsのView層の代わりにNext.jsを用いたフロントエンドのリニューアルが進行中で、バックエンドのRailsAPIサーバーとしてのJSONによるリクエスト処理に注力しつつあります。

今回は、RailsAPIサーバーとして使用する上で欠かせないJSONレンダリングする方法について、STORES 予約ではどのような手法を取っているかを書いてみようと思います。

RailsアプリケーションにおけるJSONの簡単な使用方法

はじめに、RailsのモデルがデフォルトでサポートしているJSONの扱い方について軽く触れようと思います。

シンプルなJSONの扱い方の一例として、Active Recordのオブジェクトをレスポンスとして返す方法があります。

この方法では、renderメソッドを使用して jsonオプションにActive Recordのオブジェクトを渡すだけで、Railsは自動的にそのオブジェクトをJSON形式でレンダリングします。 この時、指定されたオブジェクトに対して内部的にto_jsonメソッドが呼び出されます。

Railsのモデルは、デフォルトでto_jsonメソッドを実装しているため、開発者が追加の設定を行うことなく、簡単にJSONレスポンスを生成することが可能です。*1

 class BooksController < ApplicationController
   def index
     books = Book.all
     render json: books
   end
 end

この方法は、基本的なJSONレンダリングのニーズには適していますが、全てのモデル属性がレンダリングされるため、露出したくない情報が含まれてしまう場合があります。

また、クライアント側に不要な情報を送信することもあるため、より細かい制御が必要な場合には他の手法を検討する必要があります。

ActiveModelSerializers

前述のような不都合があるので、ActiveRecord組み込みのJSON変換サポートを使わずに、よく利用されているのがActiveModelSerializersというgemです。

ActiveModelSerializersは、データをJSON形式に変換するための機能を提供してくれます。 Serializerクラスを定義することでカスタマイズされたJSON出力を生成でき、これによってAPIの要求に柔軟に応えることを可能にします。

Serializerクラスを定義してモデルを書くのと同じような感覚で書くことができるのが特徴です。

ActiveModelSerializersを使った実装例

ユーザーデータをJSON形式で提供するRailsコントローラの実装を例にします。

APIEntity

まずAPIのためのデータ構造を定義します。 STORES 予約では、APIのためのデータ構造をAPI Entityと呼んでいます。 これはPORO*2ActiveModel::SerializationActiveModel::Naminginclude/extendして作成しています。

必要に応じて新たな属性を計算したり、関連データを組み合わせたりして、APIが要求する特定のデータ構造を作成します。

module APIEntity
  class UserList
    include ActiveModel::Serialization
    extend ActiveModel::Naming

    def initialize(users:, pagination:)
      @users = users.map { |user| APIEntity::User.new(user:) }
      @pagination = pagination
    end
    
    attr_reader :users, :pagination
  end
end
module APIEntity
  class User
    include ActiveModel::Serialization
    extend ActiveModel::Naming

    delegate :public_id, :status to: :@user

    def initialize(user:)
      @user = user
      @option = { names: user.option_names }
      @user_memos = 
        user.user_memos.map do |user_memo|
          APIEntity::UserMemo.new(user_memo:)
        end
    end
    
    attr_reader :user_memos, :option
  end
end
module APIEntity
  class UserMemo
    include ActiveModel::Serialization
    extend ActiveModel::Naming
    
    delegate :public_id, :text, to: :@user_memo
    
    def initialize(user_memo:)
      @user_memo = user_memo
    end
  end
end

Serializer

次にActiveModelSerializersのシリアライザを定義します。 ActiveModel::Serializerを継承すると、RailsActiveRecordのモデル定義と同じような形式でシリアライザを定義することができます。

これにより、Entityによって構築されたデータをAPIレスポンスとして適切なJSON形式に変換します。

class UserListSerializer < ActiveModel::Serializer
  has_many :users, serializer: ::UserSerializer
  has_one :pagination, serializer: ::PaginationSerializer
end
class UserSerializer < ActiveModel::Serializer
  has_many :user_memos, serializer: ::UserMemoSerializer
  
  attribute :public_id
  attribute :status
  attribute :option
end
class UserMemoSerializer < ActiveModel::Serializer
  attribute :public_id
  attribute :text
end

このように、受け取ったuser_listシリアライズするための情報をActiveModel::Serializerを継承したクラスで定義していきます。

Controller

定義されたシリアライザを使用して、コントローラからAPIレスポンスを返します。 APIEntityはデータをAPI用に準備し、Serializerはそのデータを最終的なJSON形式に変換する役割を担います。

class UserController < ApplicationController
  def index
    # ...
    
    users = users_query.page(params[:page]).per(params[:per_page])
    pagination = PaginationEntity.new(page: users.current_page, total: users.total_pages)
    user_list = APIEntity::UserList.new(users:, pagination:)
    
    render json: user_list, serializer: UserListSerializer
  end
end

これによって、例えば以下のようなJSON形式のデータがレスポンスされます。

{
  users: [
    {
      public_id: 1,
      status: 'pending',
      option: { names: 'option_name1' },
      user_memos: [
        { public_id: 1, text: 'text1' },
        { public_id: 2, text: 'text2' },
      ]
    },
    {
      public_id: 2,
      status: 'accepted',
      option: { names: 'option_name2' },
      user_memos: [
        { public_id: 3, text: 'text3' },
        { public_id: 4, text: 'text4' },
      ]
    },
    ...
    
  ],
  pagination: {
    current_page: 1, total_pages: 2, total_count:20, has_prev: false, has_next: true
  }
}

ActiveModelSerializersの懸念点

ActiveModelSerializersは、JSONシリアライズを容易にする強力なツールですが、いくつかの懸念点があります。

ライブラリのメンテナンスが長年されていない

ActiveModelSerializersは2017年あたりで開発のコミット履歴が止まっており、メンテナンスが行われていません。 パフォーマンスや互換性、セキュリティの観点からも、継続的にメンテナンスが行われているものをできるだけ使用することが望ましいです。

複雑になりやすい

ActiveModelSerializersでは、シリアライザの定義に多くの追加コードが必要です。 特にモデルやAPI用のEntityクラスに属性やロジックを記述するか、シリアライザに記述するかなどルールも曖昧になりがちです。

例えば、先ほどの例ではAPIEntity::Userクラスでoptionの定義をしていましたが、これを以下のようにUserSerializerクラスで定義するような書き方もできます。

module APIEntity
  class User
    include ActiveModel::Serialization
    extend ActiveModel::Naming

    delegate :public_id, :status, :option_name to: :@user

    def initialize(user:)
      @user = user
      @user_memos = 
        user.user_memos.map do |user_memo|
          APIEntity::UserMemo.new(user_memo:)
        end
    end

    attr_reader :user_memos
  end
end
class UserSerializer < ActiveModel::Serializer
  has_many :user_memos, serializer: ::UserMemoSerializer
  
  attribute :public_id
  attribute :status
  attribute :option
  
  def option
    { names: object.option_names }
  end
end

新たなアプローチ:ActiveModel::Serializers::JSONの利用

従来のActiveModelSerializersを使った実装では、複雑性とライブラリのメンテナンスがされていないという問題を抱えています。 そのため、私たちはRails標準のActiveModel::Serializers::JSONモジュールを利用するアプローチを検討しました。

ActiveModel::Serializers::JSONとは

ActiveModel::Serializers::JSONモジュールは、RailsのActiveModelライブラリの一部として提供されるモジュールで、RubyオブジェクトをJSON形式にシリアライズするための機能を提供します。

このモジュールは、as_jsonメソッドを使用してオブジェクトをJSON表現に変換する機能を備えており、JSONシリアライズが可能になります。

ActiveModel::Serializers::JSONを使った実装例

先程の例を ActiveModel::Serializers::JSON を使って書き直してみます。

APIEntity

APIEntityモジュールでは、ActiveModel::Serializers::JSONincludeしてJSON出力を生成します。 ここでは、attribute_names_for_serializationメソッドをオーバーライドして、シリアライズする属性を指定しています。

module APIEntity
  class UserList
    include ::ActiveModel::Serializers::JSON

    attr_reader :users, :pagination

    def initialize(users:, pagination:)
        @users = 
          users.map do |user|
            APIEntity::User.new(user:)
          end
        @pagination = APIEntity::Pagination.new(pagination:)
      )
    end

    private

    def attribute_names_for_serialization = %i[users pagination]
  end
end
module APIEntity
  class User
    include ::ActiveModel::Serializers::JSON
    
    attr_reader :option
    delegate :public_id, :status, to: :@user
    
    def initialize(user:)
      @user = user
      @option = { names: user.option_names }
      @user_memos = 
        user.user_memos.map do |memo|
          APIEntity::UserMemo.new(user_memo:)
        end
    end
    
    private
    
    def attribute_names_for_serialization = %i[public_id status option user_memos]
  end
end
module APIEntity
  class UserMemo
    include ::ActiveModel::Serializers::JSON
    
    delegate :public_id, :text, to: :@user_memo
    
    def initialize(user_memo:)
      @user_memo = user_memo
    end
    
    private
    
    def attribute_names_for_serialization = %i[public_id text]
  end
end

Controller

APIEntityでActiveModel::Serializers::JSONincludeし、attribute_names_for_serializationメソッドによってシリアライズする属性を定義しました。

これによってSerializerの定義が不要になり、コードが簡潔になりました。

class UserController < ApplicationController
  def index
    users = users_query.page(params[:page]).per(params[:per_page])
    pagination = PaginationEntity.new(page: users.current_page, total: users.total_pages)
    user_list = APIEntity::UserList.new(users:, pagination:)
    
    render json: user_list
  end
end

ActiveModelSerializersとの違い

ActiveModelSerializersとの大きな違いは、APIEntityで宣言する attribute_names_for_serializationメソッドです。

ActiveModelSerializersではシリアライザをクラスとして別途定義する必要がありましたが、ActiveModel::Serializers::JSONではAPIEntity自身がシリアライズされるべき属性をこのメソッドで示すという書き方になっています。

これにより、render json:を呼び出す際にserializerオプションを指定する必要がなくなり、シリアライズがより直接的かつ単純化されます。

ActiveModel::Serializers::JSONの実装を見てみる

ActiveModel::Serializers::JSONモジュールは、ActiveModel::Serializationモジュールを含んでいます。 このActiveModel::Serializationモジュールは、オブジェクトのシリアライズ機能を提供するための基本的なインターフェースとメソッドを提供します。 具体的には、オブジェクトの属性をハッシュ形式に変換するserializable_hashメソッドなどが含まれています。

ここでの重要なポイントは、as_jsonserializable_hashの役割とそれらがどのように連携するかです。

as_jsonメソッドの役割

as_jsonメソッドは、オブジェクトをJSONシリアライズする際に内部的に使用されます。 Railsにおいてrender json:すると、指定されたオブジェクトに対してto_jsonが呼ばれますが、to_jsonは内部でas_jsonを呼び出してその結果をJSON文字列に変換しています。 これにより、オブジェクトのデータがJSON形式で表現されます。

serializable_hashメソッドとの関連

as_jsonメソッドはserializable_hashメソッドを使ってオブジェクトの属性をハッシュ化します。 serializable_hashは、シリアライズされる属性を決定し、それらをハッシュとして返します。

ActiveModel::Serializationモジュールの実装の一部が以下です。

rails/activemodel/lib/active_model/serialization.rb at main · rails/rails · GitHub

module ActiveModel
  module Serialization
    def serializable_hash(options = nil)
      attribute_names = attribute_names_for_serialization

      # ...

      hash = serializable_attributes(attribute_names)

      # ...
    end

    private
      def attribute_names_for_serialization
        attributes.keys
      end

      # ...
  end
end

このメソッドは、attribute_names_for_serializationで指定された属性名に基づき、ハッシュを生成します。

他の選択肢:jbuilder

RailsでのJSONシリアライズの選択肢として、jbuilderが存在します。

jbuilderRubyDSLを用いたテンプレートエンジンで、Rails標準の機能です。

こちらは記述が冗長になりがちなことや、パフォーマンスの懸念もあり、STORES 予約では新たに利用することはしていません。

まとめ

RailsアプリケーションにおけるJSONシリアライズのアプローチを紹介しました。

今回の方法では、Rails標準のActiveModel::Serializers::JSONモジュールを利用することで、Serializerクラスを用意する必要がなくなり、attribute_names_for_serializationメソッドに属性を定義していく素朴な方法でシリアライズをしています。

積極的なメンテナンスが行われていないライブラリを使わずに実装ができるようになることもメリットになると思います。