モデルからJSON生成するときこうやってます2016

最近RubyとReact.jsをよく利用していて、Rubyで扱っている値をJSONとして表現したいケースが増えてきた。こういうのどうやっていますかと人に聞きたいので、自分はこうやっていますよというのを説明のためにまとめておくことにする。

概観

自分の場合、次のような方法で実装することが多い。

  1. JSONとして表現したいオブジェクトをコンストラクタで受け取るクラスを定義する
  2. クラスに #as_json を定義して適当なHashを返すようにする
  3. Object#to_json再帰的に #as_json を利用するようにする (ActiveSupportがやってくれる)

コード

具体的には、以下のようなクラスをつくっている。これは最近つくっている掲示板での例で、Megaboard::Resources::Comment はコメントのJSON表現のためのクラスである。いわばコメントのJSON表現におけるViewがこのクラスということになる。

module Megaboard
  module Resources
    class Base
      include ::JsonWorld::DSL

      def initialize(model = nil, current_user: nil)
        @current_user = current_user
        @model = model
      end

      private

      def current_user
        @current_user
      end

      def model
        @model
      end
    end
  end
end

上記のものは共通部分を集めた基底クラスで、実際にはこれを継承した具象クラスを定義して利用する。

module Megaboard
  module Resources
    class Comment < Base
      property :body
      property :created_at
      property :deletable
      property :editable
      property :fullpath
      property :incremental_id
      property :liked
      property :likes_count
      property :rendered_body
      property :topic
      property :user

      delegate(
        :body,
        :created_at,
        :incremental_id,
        :likes_count,
        :rendered_body,
        :topic,
        :user,
        to: :model,
      )

      def deletable
        model.deletable_by?(current_user)
      end

      def editable
        model.editable_by?(current_user)
      end

      def fullpath
        "#{::Rails.application.routes.url_helpers.topic_path(model.topic)}#comment-#{model.incremental_id}"
      end

      def liked
        model.liked_by?(current_user)
      end
    end
  end
end

使い方

雑多な部分は取り除いてあるが、例えばRailsでコメントのJSONを返すactionを定義したい場合はこういう風にして使う。Railsrender(json: ...) では内部でJSONを生成するときに勝手に #to_json が呼ばれるが、このときに #as_json が利用される。

class CommentsController < ApplicationController
  def show
    comment = ::Comment.find(params[:comment_id])
    render json: ::Megaboard::Resources::Comment.new(comment, current_user: current_user)
  end
end

JsonWorld::DSL

前述したコードに登場した JsonWorld::DSL というのは https://github.com/r7kamura/json_world というライブラリが提供しているmodule。これをincludeすると .property というクラスメソッドと、#as_json というインスタンスメソッドが定義される。クラスに対して #as_json の実装を支援するためだけに利用しているライブラリなので、無くても構わないが、見通しがよくなるので使っている。

ちなみにJsonWorldの .property には型情報を指定することもできて、これを指定しておくとクラスからJSON Schemaが生成できるようになるが、型情報の指定はやはり手間が掛かるので公開APIを提供する場合以外はこの機能は使っていない。

current_user

Webアプリケーションでの用途だと「誰がアクセスしているか」によってレスポンスを変えたい場合が多く、これはJSONの場合も例外ではない。例えば、Likeを付けたコメントにはアイコンを付けておきたいとか、削除しても良いコメントの場合は削除ボタンを出したいとか。こういった情報がクライアントサイドで逐一計算されるのではなく、JSON表現自体に含まれていると、重要なロジックも一元管理できて便利な場合が多い。

そういう要求から、上述したJSON表現用のクラスのコンストラクタでは、current_userというコンテキストに依存した値も受け付けられるようにしている。昔は「JSONを返すようなエンドポイントでは、キャッシュしやすくするために常にコンテキストに依存しない値を返すべきではないか」と考えることもあった。しかし実際にこの手の処理を利用したアプリケーションを何度も実装してみると、文脈に依存してデータを返せる利便性を優先したほうが現実的に感じるようになった。

コンストラクタで値を受け取って #to_jsonJSONに変換するというのは、一種の関数のようなもので、current_userを渡す場合も関数で受け取る値が複数個になっただけという気持ちで見ている。

ちなみに上記のコード例だと「いちいちLikeしてるかどうか見てたら、N+1クエリ発生してまずくない?」という疑問が発生するかもしれない。実際にN+1クエリが発生していて困っているかと言うと別にそういうことは無くて、comment.liked_by?(current_user) の実装がeager-loadingに対応していて問題が無かったり、そもそもコンテキストに依存した値が必要ない場合はcurrent_userを指定しないなどの方法で上手く回避している。

GraphQLなどを利用すれば、求められているデータだけ計算すれば良いということになり、この手の問題は解消されるはず。前職で開発当初に携わっていた https://github.com/cookpad/garage にも、GraphQL同様に取得したいプロパティを指定するための簡易言語があり、同じ問題を解決できる。

おしまい

要点だけ説明したので伝わりにくかったと思うけれど、とりあえず説明は以上です。この方法は、直近で携わったものだと、掲示板、amakan、WikiHub、Qiitaなどで利用されている。皆さんどうやってますか。良かったらTwitterやブログ記事などに書いて教えてください。

追記1

あるオブジェクトをJSONとして表現するときにどう表現したいかというのは、例に挙げたcurrent_userの他に、用途によってもかなり変わってくる。例えば簡単な例だと、内部用APIと3rd Party用REST APIでは恐らく表現方法が異なってくる。User#to_json を定義してAPI用のレスポンスに使うようなやり方は考えられる限りほぼ最悪の実装方法だと思うが、無理矢理Reactを導入する都合で対応しようとした結果、User#to_react_props みたいなものが生やされてしまう例も稀によく見る。User#to_api_hashUser#to_api_v2_json とか。他にも、1つのJSONのObjectを生成するのに複数のモデルが必要な場合もあって、モデル自体に定義しようとすると厄介なケースが多い。

例えば先日 ユーザーアイコンにカーソルを合わせると、ポップアップでユーザー情報を確認できるようになりました - Qiita Blog という機能が実装されたが、これには「ユーザのポップアップの表示に必要なデータ」をJSONで表現するクラスが利用されている。このユーザをフォローしているかどうか、記事投稿数、Contributionの値などを計算して含める必要があるので、この用途専用の表現が必要になっている。こういう風に用途によってJSONの表現方法が変わってくることを考えると、用途と表現用クラスを1対1にして管理するというのは、悪くない考えだと思う。

追記2

使っている人はコード読んでいるので大体理解してくれそうだけど、active_model_serializersはやりたいことに対して実装が豪華すぎると思っている (仮にactive_model_serializersを自分で書けと言われると少し面倒な規模感)。このくらいの用途であればもっと軽量な実装の方が良いだろうというのが、これを使っていない理由です。ちなみにJsonWorldからJSON Schemaの実装を抜いた、さらに軽量なGemも用意していて、用途に応じてこれと使い分けている。