モデルからJSON生成するときこうやってます2016
最近RubyとReact.jsをよく利用していて、Rubyで扱っている値をJSONとして表現したいケースが増えてきた。こういうのどうやっていますかと人に聞きたいので、自分はこうやっていますよというのを説明のためにまとめておくことにする。
概観
自分の場合、次のような方法で実装することが多い。
- JSONとして表現したいオブジェクトをコンストラクタで受け取るクラスを定義する
- クラスに
#as_json
を定義して適当なHashを返すようにする 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を定義したい場合はこういう風にして使う。Railsの render(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_json
でJSONに変換するというのは、一種の関数のようなもので、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
to_jsonは予約語だし as_json で react用の値を返すのは誰にとってのjsonか明示されないからやめてほしいという気持ちがある
— 現場の声 (@mizchi) October 2, 2016
to_react_props だったらわかるけどそもそも mutable で時系列で値が変わるようなオブジェクトは他の方法でそれを明示してほしい
— 現場の声 (@mizchi) October 2, 2016
誰にとってのJSONかというのは .to_react_propsみたいな方法より、目的ごとにクラスを用意して表現した方がいいと思う。React用(?)のJSONが欲しいのであれば Reactyounomodel#to_json とか https://t.co/dtXMu6gIzr
— ホームページビルダー (@r7kamura) October 2, 2016
あるオブジェクトをJSONとして表現するときにどう表現したいかというのは、例に挙げたcurrent_userの他に、用途によってもかなり変わってくる。例えば簡単な例だと、内部用APIと3rd Party用REST APIでは恐らく表現方法が異なってくる。User#to_json
を定義してAPI用のレスポンスに使うようなやり方は考えられる限りほぼ最悪の実装方法だと思うが、無理矢理Reactを導入する都合で対応しようとした結果、User#to_react_props
みたいなものが生やされてしまう例も稀によく見る。User#to_api_hash
、User#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も用意していて、用途に応じてこれと使い分けている。