この記事は STORES Advent Calendar 2023 の30日目の記事です。
はじめに
STORES 予約でエンジニアをしている望月です。
近年、Webアプリケーションのフロントエンド開発において、Reactなどのモダンな技術がリッチなユーザーインターフェースの実現を目指して頻繁に採用されるようになりました。 これに伴いRailsアプリケーションの開発方法も変化しています。 従来のRailsによるView層でのフロントエンド実装から脱却し、Railsは主にAPIサーバーとしての役割を果たす構成が増えてきました。
Railsを基盤に構築されているSTORES 予約でも、従来のRailsのView層の代わりにNext.jsを用いたフロントエンドのリニューアルが進行中で、バックエンドのRailsはAPIサーバーとしてのJSONによるリクエスト処理に注力しつつあります。
今回は、RailsをAPIサーバーとして使用する上で欠かせない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*2にActiveModel::Serialization
、ActiveModel::Naming
を include/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
を継承すると、RailsのActiveRecordのモデル定義と同じような形式でシリアライザを定義することができます。
これにより、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::JSON
をinclude
して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::JSON
をinclude
し、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_json
とserializable_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が存在します。
jbuilderはRubyのDSLを用いたテンプレートエンジンで、Rails標準の機能です。
こちらは記述が冗長になりがちなことや、パフォーマンスの懸念もあり、STORES 予約では新たに利用することはしていません。
まとめ
RailsアプリケーションにおけるJSONシリアライズのアプローチを紹介しました。
今回の方法では、Rails標準のActiveModel::Serializers::JSONモジュールを利用することで、Serializerクラスを用意する必要がなくなり、attribute_names_for_serialization
メソッドに属性を定義していく素朴な方法でシリアライズをしています。
積極的なメンテナンスが行われていないライブラリを使わずに実装ができるようになることもメリットになると思います。