#409 Active Model Serializers
- Download:
- source codeProject Files in Zip (67 KB)
- mp4Full Size H.264 Video (20.7 MB)
- m4vSmaller H.264 Video (11.5 MB)
- webmFull Size VP8 Video (15.6 MB)
- ogvFull Size Theora Video (25 MB)
今回のエピソードでは、RailsでJSON APIを作成するのに利用されるActive Model Serializerを紹介します。下のスクリーンショットは、標準的なブログアプリケーションで、複数の記事を持ち、それぞれの記事が複数のコメントを持ちます。HTMLビューに加えてJSON APIも提供し、URLに.json
を追加することで記事のデータを取得できるようにします。
今これを実行すると、アプリケーションはJSONリクエストに対して何を返すべきかわからないので、例外が表示されます。これを修正するのは簡単です。ArticlesController
でshowアクションにrespond_to
ブロックを追加して、記事をJSON形式で表示できるようにします。
def show @article = Article.find(params[:id]) respond_to do |format| format.html format.json { render json: @article } end end
記事のページをロードすると、JSON形式でデータが表示されます。
出力をカスタマイズする
これはRailsでJSON APIを作成する一般的な方法ですが、さらに出力をカスタマイズしなくてはいけない場合もあります。その場合はコントローラを介してオプションを渡すか、モデルでas_json
メソッドをオーバーライドする方法がありますが、どちらの方法もすぐに複雑化して扱いにくくなってしまいます。このような場合にはActive Model Serializer gemのようなツールが便利なので、アプリケーションに追加してみましょう。通常の方法でGemfileに追加後にbundle
コマンドを実行してインストールします。
gem 'active_model_serializers'
このgemが提供するジェネレータを、APIを介して公開したいモデルごとに実行します。Railsのresource generatorを使用した場合は、これが自動で実行されます。これを用いて記事データのserializerを作成します。
$ rails g serializer article
このジェネレータが、新しいapp/serializers
ディレクトリにファイルを一つ作成します。これはJSON形式の出力を自由にカスタマイズするための専用のクラスができたということです。また便利なことにこのgemにはフックが含まれているので、モデルをJSON形式で出力しようとすると、自動的に同じ名前のserializerを探してもし見つかったらそれを用いてJSONデータを取得します。このクラスの中で出力に含む属性を指定します。
class ArticleSerializer < ActiveModel::Serializer attributes :id, :name, :content end
ページをリロードすると、記事のJSONがserializerクラスを介して出力されています。
ここで大きな違いが一つあります。すべての属性はarticle
というルートノードに含まれるようになりましたが、これはRailsがデフォルトで生成するJSONとは異なります。APIの利用側の都合によっては、この振る舞いが問題になる場合もあります。その場合は、コントローラアクションでrootオプションを渡してfalse
に設定することによって、ルートノードを無効にすることもできます。
format.json { render json: @article, root: false }
すべてのシリアライズされたオブジェクトがこの振る舞いをしてほしい場合には、default_serializer_options
メソッドを定義してデフォルトのオプションを設定します。
def default_serializer_options {root: false} end
これをActive Model Serializerが自動的に検出するので、ApplicationController
に移動したらすべてのコントローラに含まれることになります。今回の例ではルートノードをそのままにしたいので、アプリケーションにこのメソッドを追加することはしません。その代わりにserializerクラスに戻って、出力をどうカスタマイズできるかを見てみます。例えば、記事のURLのようにメソッドではない属性をモデルに追加したいとしましょう。serializerにメソッドを定義すれば、モデルに委譲する代わりにそれを利用できます。URLヘルパーメソッドを利用できるので、article_url
を使って記事のURLを取得します。serializerが対象としているモデルを表すこのオブジェクトを渡します。
class ArticleSerializer < ActiveModel::Serializer attributes :id, :name, :content, :url def url article_url(object) end end
メソッドを介して属性をカスタマイズできるという点が、このserializerを使いやすくしています。もう一つの便利な機能が、associationのサポートです。記事のコメントからデータを取り込むために、has_manyを使ってassociationの名前を渡します。
class ArticleSerializer < ActiveModel::Serializer attributes :id, :name, :content, :url has_many :comments def url article_url(object) end end
ページをリロードすると、JSONに関連のコメントのデータが含まれています。
察しがつくと思いますが、もう一つのserializerを作成することでコメントの属性をカスタマイズできます。
$ rails g serializer comment
複雑にしないように、ここでは属性のid
とcontent
だけを追加することにします。
class CommentSerializer < ActiveModel::Serializer attributes :id, :content end
serializerが見つからない場合にコントローラがデフォルトのRails serializationにフォールバックするというこの振る舞いのおかげで、カスタムの振る舞いが必要なときだけserializerを追加すればいいので、とても便利です。
ここまでの作業で、ルートのarticle
のノードにコメントのデータがネストされました。これをルートレベルに上げたい場合は、そのようにもできます。そのようなデータ構造にすることによってJavaScriptクライアントサイドフレームワークによってはパフォーマンスが向上する場合があります。そのためにArticleSerializer
を修正してembedの呼び出しを追加し、ids
を指定することで、いずれのassociationも記事のJSONデータに含まれるinclude: true
も一緒に渡すと、コメントのデータがルートレベルに含まれます。
embed :ids, include: true
ページをリロードすると、コメントデータがトップに含まれて、article
のルートノードの外側に移動ししています。記事のデータにcomment_ids
属性が追加され、そこには関連のコメントのid
が含まれています。このようにしてコメントのデータを別にして、必要な場合だけ含むようにできますが、これはAPIをどのように使用するかに依存します。
条件付き属性
条件によって属性を含むようにしたい場合はそれも可能です。例えば、現在のユーザがadminだった場合のみedit_url
を含みたいとします。attributes
を介してこれを行なうことはできませんが、attributes
メソッドをオーバーライドすることによって、返されるすべてのハッシュを変換してJSON出力に付加することができます。
def attributes data = super data[:edit_url] = edit_article_url(object) data end
現在の振る舞いをそのままにしたいので、まずsuper
を呼び出してハッシュデータを取得し、これを修正して返します。edit_url
属性を追加してこれを記事のURLに設定します。これをまだ条件分岐させていないので、動作するかどうか試してみます。
うまくいきました。edit_url
属性が出力の中に表示されました。次にこの属性を条件によって現れるようにして、現在のユーザがadminのときだけ表示されるようにします。serializerはコントローラとビュー層の外にあるので、ここで簡単にcurrent_user
を取得することはできません。この問題を解決するために、すべてのserializerに渡されるscopeというオブジェクトがあり、これがデフォルトではcurrent userオブジェクトになります。
def attributes data = super data[:edit_url] = edit_article_url(object) if scope.admin? data end
しかしedit_url
属性を条件付きで表示するためにこれを使用しようとすると、ページをリロードしたときに、scope
オブジェクトに未定義のadmin?メソッドがあるといって例外が投げられます。これはscopeオブジェクトが現在のユーザに設定されていないということなので、問題はApplicationController
でどうやってcurrent_user
メソッドを定義するかです。
private def current_user OpenStruct.new(admin?: false) end helper_method :current_user
とりあえずはこれがOpenStruct
を使ってcurrent_user
オブジェクトをstub化します。これはアプリケーションの開発中に手早く擬似的な認証機能を付加する便利な方法です。このメソッドはprivate
と指定されているのでserializerには検出されません。これを代わりにprotected
にすると今度はうまく動作して、偽のユーザがadminではないのでedit_url
は表示されません。
これでserializerはうまく機能しましたが、scopeの機能でまだ問題があります。一つの問題は、アプリケーション内でJSONリクエストを作成するたびに、たとえserializerでユーザレコードにアクセスされていなくても、現在のユーザのレコードがロードされます。これは不必要なデータベースクエリを発生させ、パフォーマンスの問題を引き起こす可能性があります。もう一つの問題はscope
という名前が一般的すぎて、scope
に対してそれを呼び出したときにadmin?
メソッドが現在のユーザに対して呼び出されるということが自明ではありません。serializerで直接current_user
を呼び出せる方がずっといいでしょう。これを実現するためには、serializerに渡されたscopeオブジェクトをカスタマイズして、ApplicationController
を変更し、serialization_scope
を呼び出してcurrent user以外の何か、例えばview_context
を使用します。
serialization_scope :view_context
このビューコンテキストや、serializerで利用するその他のヘルパーメソッドで、現在のユーザを呼び出すことができます。serializerに戻って、 current_user
メソッドを委譲するよう指示して、それに対してadmin?
を呼び出します。
delegate :current_user, to: :scope def attributes data = super data[:edit_url] = edit_article_url(object) if current_user.admin? data end
ページに以前と同じ機能を持たせることができましたが、今度は現在のユーザは必要なときだけロードされます。このアプローチの一つの欠点は、serializerに対してビューコンテキスト全体へのアクセス権を与えなくてはいけないので、テストが少し難しくなります。この問題を回避するには、ヘルパーメソッドをテストするのと同じような方法でActionView::TestCase
を継承してテストを行うことができます。それによって自動的にビューコンテキストが設定され、serializerに渡すことができます。
JSONリクエスト以外でJSONを生成する
今回のエピソードの最後にチップスを一つ紹介します。このJSONデータをJSONリクエスト以外で作成したい場合にはどうすればいいでしょう? 例えばindex
ページの記事用にJSONデータを埋め込みたい場合などです。ページの要素のdata
属性でこれを行なうことができます。これは複雑になるので、属性の内容を生成するためのヘルパーメソッドを作成します。
<div id="articles" data-articles="<%= json_for @articles %>">
データを生成するヘルパーメソッドは以下のようになります。
module ApplicationHelper def json_for(target, options = {}) options[:scope] ||= self options[:url_options] ||= url_options target.active_model_serializer.new(target, options).to_json end end
このメソッドはターゲットのオブジェクトとして、ActiveRecordリレーションかモデルを受け入れます。まずserializerの:scope
オプションをself
(つまりview context)にし、:url_options
を設定します。これはホストオプションが未定義のときにエラーを出さないようにするために重要です。最後に、渡されたオブジェクトに対してactive_model_serializer
を呼び出します。これはgemがrelationとモデルに追加したメソッドで、これによってserializerを自由に指定することができます。このserializerのインスタンスを生成してオプションを渡し、JSONに変換します。
ページをリロードしてソースを見ると、data-articles
属性に記事のデータが入っています。
data-articles="[{"id":1,"name":"Superman"...
ActiveModel Serializerに関する今回のエピソードは以上です。合わせてJbuilderとRABLについて解説したエピソード320と322も参照してください。これらのgemは今回とは別の方法で、serializerオブジェクトの代わりにビューテンプレートを利用することによってJSONを生成します。どちらのアプローチにもそれぞれ利点があります。Active Model Serializerのオブジェクト指向の特性がよりふさわしいシナリオがある一方で、ビュー層でserializationを行なう方がよりよいアプローチである場合もあるでしょう。