Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails: JSON Patchでパフォーマンスを向上(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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は多くの場合任意の順序で適用することができます。replaceinsert操作しか含まれていない場合は特にそうです。衝突が発生した場合、最新のデータをリロードしてユーザーにやり直してもらうだけで済みます。また、WebSocketを使えばサーバーからブラウザにJSON Patchを送信して全クライアントを同期することもできます。

お読みいただきありがとうございました。コメントはHacker Newsまでどうぞ。

関連記事

Rails: ActiveModelSerializersでAPIを作る–Part 1(翻訳)

RailsでGraphQL APIをつくる: Part 1 – GraphQLとは何か(翻訳)

Ruby on Railsで使ってうれしい19のgem(翻訳)


  1. FormAPIの最初のバージョンではフィールド名記述に独自文法を使っていましたが、その後でJSON Pointersというものを発見しました。
    それまでスラッシュ文字/をエスケープするなど思いもよりませんでしたが、仕様の一部に含まれています。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。