概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Better Rails Performance with JSON Patch
- 原文公開日: 2017/11/19
- 著者: Form API
- サイト: https://formapi.io/ -- PDF生成APIサービスの会社です
Rails: JSON Patchでパフォーマンスを向上(翻訳)
FormAPIはPDFを生成するサービスです。バックエンドはRuby on Railsで書かれており、PDFテンプレートエディタはReactで作られています。PostgreSQLを用いてテンプレートのフィールドデータをjsonb
カラムに保存しています。
テンプレートエディタの最初のバージョンでは、素朴なアプローチでフィールドデータを保存していました。フィールドが変更されるたびにarray全体をサーバーにpostしていたのです。立ち上げ当初のMVP(Minimum Viable Product)はこの方法で十分動きましたが、やがて顧客がPDFテンプレートに500個ものフィールドを設定するようになり、実行に5〜10秒も要するリクエストにアラートが出始めました。
明らかな解決法は、変更点だけをサーバーに送信することです。ReactアプリではReduxを使っているので、Reduxのアクションを送信することも考えましたが、Reduxのアクションには複雑なロジックが若干含まれているため、私たちのFormAPIではその手が使えませんでした。コードをRubyで書き直したくなかったのですが、仮にNode.jsを使っていたなら同じJavaScriptコードをサーバー側で再利用できたかもしれません。
他の選択肢としては、純粋なJSONオブジェクトの差分を取り出してそれをサーバーに送信する方法が考えられます。その場合JSON Patchを利用できます。
JSON Patchは、JSONドキュメントの変更点を記述するフォーマットです。JSONの一部だけが変更された場合にドキュメント全体の送信を避けるために利用できます。
こういうものは独自にこしらえる1よりも、IETF標準(RFC6902)を利用する方がほとんどの場合よい結果が得られると思います。以下のオープンソースライブラリを利用しました。
- fast-json-patch --- クライアント側でJSON Patchを生成するNPMパッケージです
- hana --- RailsサーバーでJSON Patchを適用するRuby gemです
以下のようにしてRailsモデルのjson
カラムにJSON Patchを適用しました。
class Template < ApplicationRecord
# エラーが発生したらここでエラーを保存し、
# バリデーション中にそれらを追加する
attr_accessor :json_patch_errors
validate :add_json_patch_errors
after_save :clear_json_patches
attr_reader :fields_patch
# メソッドが呼び出されると即座にJSON Patchが適用される
def fields_patch=(patch_data)
# 後でアクセスしたい場合
@fields_patch = patch_data
self.json_patch_errors ||= {}
json_patch_errors.delete :fields
unless patch_data.is_a?(Array)
json_patch_errors[:fields] =
'JSON patch data was not an array.'
return
end
hana_patch = Hana::Patch.new(patch_data)
begin
hana_patch.apply(fields)
rescue Hana::Patch::Exception => ex
json_patch_errors[:fields] =
"Could not apply JSON patch to \"fields\": #{ex.message}"
end
end
# データ再読み込み時にすべてのJSON Patchとエラーをクリア
def reload
super
clear_json_patches
end
private
def add_json_patch_errors
return unless json_patch_errors.present?
json_patch_errors.each do |attribute, errors|
errors.add(attribute, errors)
end
end
def clear_json_patches
@fields_patch = nil
self.json_patch_errors = nil
end
end
こちらのRSpecテストをコピーして実装が正しいことを確認できます。そのうちこれをgemとしてリリースするかもしれません。
permitted
パラメータとしてfields_patch
をコントローラに追加しました。
params.require(:template).permit(
fields: {},
).tap do |permitted|
# arrayやhashのネストはややこしい
if params[:template][:fields_patch].is_a?(Array)
permitted[:fields_patch] = params[:template][:fields_patch].
map(&:permit!)
end
end
上のコードは、:fields_patch
を通常の属性として扱い、update_attributes
の間にJSON Patchを適用することを示しています。JSON Patchの適用に失敗すると、バリデーション中にエラーが追加されます。
フロントエンド側の実装は実に簡単です。改修前のコードは次のようになっていました。
if (!Immutable.is(template.fields, previousTemplate.fields)) {
data.fields = template.fields.toJS()
}
新しいコードでは、JSON Patchをfields_patch
属性として送信します。
import { compare as jsonPatchCompare } from 'fast-json-patch'
if (!Immutable.is(template.fields, previousTemplate.fields)) {
data.fields_patch = jsonPatchCompare(
previousTemplate.fields.toJS(), template.fields.toJS())
}
以下は新しいコードから送信されるAJAXリクエストの例です。
{
"template": {
"fields_patch": [
{
"op": "replace",
"path": "/2/name",
"value": "image_field_2"
}
]
}
}
コードの変更はわずか数行で済んだにもかかわらず、送信されるデータを大きく削減できました。
JSON Patchのもうひとつのメリットは、複数ユーザーによる同時編集をずっと楽にサポートできることです。JSON Patchは多くの場合任意の順序で適用することができます。replace
やinsert
操作しか含まれていない場合は特にそうです。衝突が発生した場合、最新のデータをリロードしてユーザーにやり直してもらうだけで済みます。また、WebSocketを使えばサーバーからブラウザにJSON Patchを送信して全クライアントを同期することもできます。
お読みいただきありがとうございました。コメントはHacker Newsまでどうぞ。