2017-12-19

Rails API開発におけるJSONレスポンス生成方法と内部実装について

Ruby on Rails Advent Calendar 2017 19日目の記事です。

RailsでAPI開発するときのJSONレスポンスの生成方法についてまとめてみました。

JSONレスポンスの生成方法について

RailsのHTTPレスポンスをJSONで生成する方法は以下の2つに分類されます ライブラリでいうとactive_model_serializersは1のモデル方式、jbuilderjbは2のビュー方式になります。

モデル方式はrenderメソッドにjson引数が指定されているとActionController::Renderers#_render_with_renderer_json が呼び出され、指定したオブジェクトのto_jsonメソッドが呼び出されることでシリアライズされています。

add :json do |json, options|
  json = json.to_json(options) unless json.kind_of?(String)

  if options[:callback].present?
    if content_type.nil? || content_type == Mime[:json]
      self.content_type = Mime[:js]
    end

    "/**/#{options[:callback]}(#{json})"
  else
    self.content_type ||= Mime[:json]
    json
  end
end

ビュー方式はテンプレートハンドラを使ってHTMLテンプレートと同じような感覚でビューファイルを記述していき、返すべきJSON文字列を定義していきます。

今回は、JSONレスポンス生成の代表的なライブラリであるactive_model_serializers, jbuilder, jb について、どういった方法でシリアライズを実現しているかをコードベースで紹介していきます。

ライブラリのバージョンは以下になります。

active_model_serializers 0.10.6
jbuilder 2.7.0
jb 0.4.1

active_model_serializersの実装について

jsonに指定したオブジェクトを事前に定義した {モデル名}Serializer クラスやAdapterクラス等で自動的にラップして、Adapterクラスのto_jsonメソッドによってJSONレスポンスが生成されます。Serializerクラスに返却したい属性を定義することでto_jsonのレスポンスも定義されることになります。

自動的にラップする部分は以下のようにモンキーパッチで実装されており、元のオブジェクトをラップした ActiveModelSerializers::SerializableResource オブジェクトを引数として元のメソッドを呼び出しています。

[:_render_option_json, :_render_with_renderer_json].each do |renderer_method|
  define_method renderer_method do |resource, options|
    options.fetch(:serialization_context) do
      options[:serialization_context] = ActiveModelSerializers::SerializationContext.new(request, options)
    end
    serializable_resource = get_serializer(resource, options)
    super(serializable_resource, options)
  end
end

ちなみにRailsだとActiveSupport::ToJsonWithActiveSupportEncoder をObjectクラスがprependしており、ActiveSupport::ToJsonWithActiveSupportEncoder#to_json がas_jsonを呼び出しているので、as_jsonを定義すればto_jsonを間接的に定義していることになります。active_model_serializersのas_jsonは以下のように実装されています。

module ActiveModelSerializers
  module Adapter
    class Base
...
      def as_json(options = nil)
        serializable_hash(options)
      end

jbuilderの実装について

テンプレートハンドラを実装することでビューとしてjsonをレンダリングする仕組みです。テンプレートハンドラの中身はこのようになっています。
class JbuilderHandler
  cattr_accessor :default_format
  self.default_format = Mime[:json]

  def self.call(template)
    # this juggling is required to keep line numbers right in the error
    %{__already_defined = defined?(json); json||=JbuilderTemplate.new(self); #{template.source}
      json.target! unless (__already_defined && __already_defined != "method")}
  end
end

template.source にはjbuilderで記述したビューファイルの文字列が入ります。暗黙的に使っていたjson変数はJbuilderTemplateクラスのインスタンスになり、このインスタンス内で属性を管理して最後の#target!メソッドで属性をJSONに展開するような作りになっています。

JbuilderTemplateのインスタンスメソッドにはmethod_missingが定義されており、これを使って任意の名前のメソッド呼び出しによって、指定した名前をキーとしたハッシュを作っています。

def method_missing(*args)
  if ::Kernel.block_given?
    set!(*args, &::Proc.new)
  else
    set!(*args)
  end
end

API開発でリソース指向なAPIにする場合、リソースが単一のときと複数のときで返す属性値を共通化したいというニーズがあります。jbuilderでもビューの一部を共通化して異なるビューで使いまわすパーシャルレンダリングの機能がありますが、複数件のレコードをレンダリングするときにパフォーマンスの問題が生じます。具体的には以下のような書き方をした場合に件数に応じてパフォーマンスが劣化します。

json.partial! 'posts/post', collection: @posts, as: :post

内部的には配列の数だけ_render_partialが呼び出されることになり、N+1 find_template_pathsが発生してレンダリング速度が遅くなります。

def _render_partial_with_options(options)
  options.reverse_merge! locals: {}
  options.reverse_merge! ::JbuilderTemplate.template_lookup_options
  as = options[:as]

  if as && options.key?(:collection)
    as = as.to_sym
    collection = options.delete(:collection)
    locals = options.delete(:locals)
    array! collection do |member|
      member_locals = locals.clone
      member_locals.merge! collection: collection
      member_locals.merge! as => member
      _render_partial options.merge(locals: member_locals)
    end
  else
    _render_partial options
  end
end

jbuilderではcollectionのオプションをわざわざ削除(options.delete(:collection))してから個別にrenderメソッドを呼び出しているわけですが、これはcollectionの引数を渡してパーシャルレンダリングをするとレンダリング結果を結合(#json)してエスケープする(#html_safe)処理が走ってしまうためです。

module ActionView
  class PartialRenderer < AbstractRenderer
...
    private

      def render_collection
        instrument(:collection, count: @collection.size) do |payload|
          return nil if @collection.blank?

          if @options.key?(:spacer_template)
            spacer = find_template(@options[:spacer_template], @locals.keys).render(@view, @locals)
          end

          cache_collection_render(payload) do
            @template ? collection_with_template : collection_without_template
          end.join(spacer).html_safe
        end
      end

例えば {"a": 123}というハッシュが3つ入った配列をレンダリングしたい場合には

{"a":123}{"a":123}{"a":123}

というようにjsonが文字列として連結された結果が返ることになります。

挙動的にRailsのコレクションレンダリングはHTML/XMLを前提としているような感じなので、partial!という内部のメソッドを提供することで文字列連結されるのを回避しています。

jbの実装について

jbuilderのfind_template_pathsのN+1問題を解決しつつ、DSLではなくRubyのハッシュを書いて自然に書けるようにしたり、値の設定にmethod_missingを使わないようにしたのがjbになります。

面白いのは ActionView::PartialRenderercollection_with_templateメソッドやcollection_without_templateメソッドで返される配列に対してjoinとhtml_safeを局所的にモンキーパッチすることで文字列を結合した結果ではなく、配列のまま返していることです。これによって最終的にテンプレートハンドラの処理結果はハッシュが返されることになります。

module Jb
  module PartialRenderer
    module JbTemplateDetector
      # A monkey-patch to inject StrongArray module to Jb partial renderer
      private def find_partial
        template = super
        extend RenderCollectionExtension if template && (template.handler == Jb::Handler)
        template
      end
    end

    # A horrible monkey-patch to prevent rendered collection from being converted to String
    module StrongArray
      def join(*)
        self
      end

      def html_safe
        self
      end
    end
...

これだけだとレスポンスとしてハッシュのto_sが返されるのでrender_template側もモンキーパッチを行い、そこでJSONにしています。

module TemplateRenderer
  module JSONizer
    def render_template(template, *)
      template.respond_to?(:handler) && (template.handler == Jb::Handler) ? MultiJson.dump(super) : super
    end
  end
end

テンプレートハンドラもとてもシンプルでビューファイルをそのままevalしています。

module Jb
  class Handler
    class_attribute :default_format
    self.default_format = :json

    def self.call(template)
      template.source
    end
  end
end

どれを使えばよいか

リソース指向が強いAPIであれば、active_model_serializersが良さそうです。ViewObject的な立ち回りでテスト可能であることも強みだと思います。

一方で、リソース指向が強くなければjbuilderやjbが書きやすいです。速度的にはjbが良さそうですが、ビジネス要件としての速度を満たすかどうかという点では大抵のケースにおいて、jbuilderでも問題無いです。コレクションレンダリングのパフォーマンスとテンプレートの共通化の部分がjbuilderを使うかどうかの論点になると思います。

いずれにせよビジネスの要件やフェーズによってこれらの方法を使い分けていくのが良いと思います。責務を切り分けた設計・実装ができていれば、複数のJSON生成方法を共存させて順次移行していくこともできそうです。

このエントリーをはてなブックマークに追加