STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

データベースのJSON型をRailsらしく扱う方法の提案

データベースのJSON型をRailsらしく扱う方法の提案

この記事はSTORES Advent Calendar 2024の13日目の記事です。

はじめに

STORES 予約のエンジニアの@ucksです。 なぜかブログはDBネタばかり書いていますが、今回もDBネタになります。

多くのRailsアプリケーションで利用されているActive RecordはRDB用のORMです。 RDBは正規化したテーブル設計を行うことが基本ですが、昨今のRDBでは非正規化データを扱えるJSON型のサポートも増えてきており、Active Recordでもサポートされています。 JSON型を活用する事で、正規化の必要性が低いデータをより便利に扱うことが可能になります。 Active RecordのJSONサポートは、JSONライクなHash(やString、Number、Boolean)等をDBに保存することができます。

しかし、これらの型にはActiveRecordのバリデーションやコールバック等の便利な機能を定義することができません。 今回は、Active Modelの機能を活用して、よりRailsらしくJSONを扱う手法を紹介します。

JSONカラムの使い方

Railsを触っている方なら知っている方も多いとは思いますが、まずはActive RecordのJSON型の使い方を確認しておきましょう。 といっても、使い方は簡単でテーブル定義にJSON型でカラムを定義するだけです。

今回は、車とエンジン情報を持たせるデータ構造で、エンジンの情報をJSON型として扱う例で進めていきます。

create_table "cars", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
  t.string "model"
  t.json "engine"
end

JSON型でカラム定義すると、RDBには適切な型でカラムが作られ、Rails上では、基本的にHashとしてデータをやり取りすることが可能になります。

Car.new(model: 'GC8', engine: { model: 'EJ20' }).save!
Car.last.egine[:model] # => "EJ20"
Car.new(model: 'GXPA16', engine: { model: 'G16E-GTS' }).save!
Car.last.egine[:model] # => "G16E-GTS"

Railsでは、JSON形式のリクエストは基本的にHash形式(Strong Parameters形式)に変換されるので、この値を代入することで、様々な値を1箇所に保存することができます。

JSONカラムは便利な機能ではありますが、いろんな値を入れることができてしまうため、何も検証を行わないと思わぬ不具合を引き起こしてしまうリスクがあります。 このカラムを検証するやり方は、色々ありますが、今回はActive Modelを使い、Active Recordと同じ様にバリデーションを行う方法を紹介します。

JSONの形式定義とバリデーション

Active Modelは、Active Recordからデータベースに関連する機能以外を抜き出したライブラリです。 Active Modelは様々な方法で利用できますが、今回は ActiveModel::ModelActiveModel::AttributesActiveModel::Validations を利用して扱うJSON形式の定義を行なっていきます。

まず、 ActiveModel::Attributes をincludeすることでattributeで属性名とキャスト型、デフォルト値等を定義することができます。

class Engine
  include ActiveModel::Attributes

  attribute :model, :string, default: -> { '' }
end

また、 ActiveModel::Validations をincludeすることで、ActiveRecordと同じ様にバリデーション定義を行うことができます。

class Engine
  include ActiveModel::Attributes
  include ActiveModel::Validations # 追加

  attribute :model, :string, default: -> { '' }

  validates :model, presence: true # 追加
end
engine = Engine.new
engine.model # => ""
engine.valid? # => false
engine.model = 'EJ20'
engine.valid? # => true

この状態だと、ActiveRecordの様にインスタンス生成時にHashを渡して初期化できません。

Engine.new({ model: 'EJ20' }) # `new': wrong number of arguments (given 1, expected 0) (ArgumentError)

ActiveRecordの様にnewの引数にHashを渡して初期化できる様にするために ActiveModel::Model もincludeしておきます。

class Engine
  include ActiveModel::Model # 追加
  include ActiveModel::Attributes
  include ActiveModel::Validations

  attribute :model, :string
  validates :model, presence: true
end

これらの定義を組み合わせることで、HashをActiveRecordの様に検証することができます。

Engine.new({ model: 'EJ20' }).valid? # => true

当然、ActiveRecordの様にメソッドを生やすことも可能です。

class Engine
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations

  attribute :model, :string
  validates :model, presence: true

  def start
    # すまん…いい例が思いつかんかった
  end
end

これで扱うデータ形式の定義が完了しました。 このままだと、 #as_json した際に定義した属性以外も付いてきてしまいます。

Engine.new({ model: 'EJ20' }).to_json # => {"attributes"=>{"model"=>"EJ20"}}

ActiveModel::Serializers::JSONを入れておくと、モデルインスタンスからJSONライクなHashに変換するための便利なメソッドが定義してくれるのでincludeしておきましょう。

class Engine
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations
  include ActiveModel::Serializers::JSON # 追加

  attribute :model, :string
  validates :model, presence: true
end

定義した属性のみで構成されたJSONライクなHashが返るので扱いやすくなります。

Engine.new({ model: 'EJ20' }).to_json # => {"model"=>"EJ20"}

この様に、Active Modelを使うことでJSON(Hash)にメソッドを生やしたりバリデーションすることが出来る様になりました。 この方法はFormオブジェクトの実装にも役立つかと思います。

代入するだけで型変換

定義したモデルに値を渡すことで、ActiveRecordと同じ様にJSONのバリデーションを行うことができる様になりました。 しかし、JSONカラムの値を毎回取り出してインスタンス化するのは面倒で非効率的です。

# 保存時
car = Car.new(car_params)
Engine.new(car.engine).validate!
car.save!
# 利用時
car = Car.first
engine = Engine.new(car.engine)
engine.start

ActiveRecordを継承したモデルインスタンスに代入した際や、DBから取得した際に変換された方がより自然にデータ操作が行えます。

# 保存時
Car.new(car_params).save!
# 利用時
Car.first.engine.start

これを実現するために、ActiveModel::Type::Valueを使って、変換ロジックを準備します。

ActiveModel::Type::Valueは、独自の型変換を定義するための基底クラスです。 castやserialize、deserializeといったメソッドを定義しておき、ActiveRecordやActive Modelでattributeでプロパティを定義する際に渡しておくことで、値を代入した際やDBから読み書きした際のデータ変換ロジックを定義することができます。

例えば、シンプルにJSON型に変換する場合は、下記の様な定義になります。

class EngineType < ActiveRecord::Type::Value
  # モデルに代入した際の変換処理
  # valueには代入した値が入っているのでモデルインスタンスに変換する
  def cast(value)
    case value
    when Engine
      value
    when Hash
      Engine.new(value)
    end
  end

  # DBから取得した際の変換処理
  # value にはJSON文字列が入っているのでパースしてモデルをインスタンス化する
  def deserialize(value)
    value.presence && Engine.new(JSON.parse(value))
  end

  # DBに保存した際の変換処理
  # valueにはモデルインスタンスが入っているのでJSON文字列に変換する
  def serialize(value)
    value&.to_json
  end
end

このクラスのインスタンスをモデルの定義でattributesメソッドの第2引数に渡すことで、自然に変換される様になります。

class Car
  attribute :engine, EngineType.new, default: -> { Engine.new }
end

Car.new(model: 'GC8', engine: { model: 'EJ20' }).save!
Car.last.engine.model # => "EJ20"

というわけで、ActiveModel::Type::Valueを使うことでRailsらしく自然にJSON型を扱える様になりました。

この手法はActiveRecordのデータ変換だけでなく、Active Modelでネストしたデータ構造を表現する際にも使えるため、深い構造のFormオブジェクトのデータ変換等にも応用できます。

保存時の自動化

これまでの定義でRailsらしく型変換とバリデーション定義ができました。 ですが、このままでは2つ問題が残っています。 1つは、保存時に小要素のバリデーションが行われていないこと、もう1つはJSON型の中身を更新した際に、DBに差分が更新されないことです。

Car.new(model: 'GC8', engine: {}).save! # engine.model が空欄なのにすり抜けて保存されてしまう。
car = Car.last
car.engine.model = 'EJ20'
car.save! # car.engine の差分が保存されない
Car.last.engine.model # => ""

まず、保存時に小要素のバリデーションが行われていない問題に関しては、ActiveRecordのバリデーション時に小要素のバリデーションを呼び出すことで解決できます。

class Car
  attribute :engine, EngineType.new, default: -> { Engine.new }

  validate { errors.add(:engine) if engine&.invalid? }
end

次に、JSON型の中身を更新した際にDBに差分が更新されない問題に関しては、ActiveRecord::Type::Valueに便利な機能が用意されており、 #changed_in_place? をオーバーライドし、差分があった際に true を返す様に実装することで対応できます。

例えば、JSON文字列にして比較したり

class EngineType < ActiveRecord::Type::Value
  # …略

  def changed_in_place?(raw_old_value, new_value)
    raw_old_value != new_value&.as_json
  end
end

ActiveModel::Dirtyをincludeして、 #changed? で判定する方法等があります。

class Engine
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations
  include ActiveModel::Serializers::JSON
  include ActiveModel::Dirty # 追加

  attribute :model, :string
  validates :model, presence: true
end

class EngineType < ActiveRecord::Type::Value
  # …略

  def changed_in_place?(_, new_value)
    new_value.changed?
  end
end

StringやArray等も扱う場合は、ActiveModel::Dirtyをincludeできないので、扱う値に合わせて各自適切な実装してもらえればと思います。

JSON型の使い所と実例

この様に、Active Modelの機能を利用することでRailsらしく自然に安全にJSON型を扱える様になりました。

では、最後にJSON型の使い所や使った時のメリットを紹介していきます。

1つは、様々な形式のデータを扱う可能性がある時に、Nullableなカラムを許容せずにテーブル定義を行うことができます。 こちらについては、以前にActiveRecordではなくMongoidを利用した記事で紹介しています。 レコードに対して、1段カラムを潜った先に定義することになるので、多少使い勝手は違いますが、似た様に利用することが可能です。

product.st.inc

今回の車とエンジンの構造を例にするならエンジンの種別毎にクラスを分けて、特定の形式のエンジンしか持ちえない諸元を個別にフィールドに残すとかでしょうか?

Car.new(model: 'GC8', engine: { type: 'reciprocating', model: 'EJ20', cylinder_diameter: 92, cylinder_stroke: 75 })
Car.new(model: 'FD3S', engine: { type: 'rotary', model: '13B', eccentricity: 15, generating_radius: 105, housing_width: 80})

もう1つは、配列形式のデータ操作が簡単になるメリットがあります。 RDBでは、配列形式のデータは各配列要素毎にレコードを作り、順序が重要な時は順序を保持するカラムを追加することが多いと思います。 この時、同じ配列内で順序を保持するカラムが重複しない様に制約をつけたり、保存時に順序の値を更新したり、必要なくなった値は #mark_for_destruction 等を用い、削除するロジックや全て削除して再インサートするロジックが必要になります。

data_params = {
  children: [
    { data: 'hoge' },
    { data: 'fuga' },
  ],
}

# 更新処理
Data.transaction do
  data = Data.find(params[:id])
  data.chidlren.destroy_all
  data_params[:children].each.with_index do |child_params, i|
    data.chidlren.build(**child_params, index: i)
  end
  data.save!
end

# 並べ替え処理
Data.transaction do
  data = Data.find(params[:id])
  data.children.sort_by { |child| child.data }.each.with_index do |child, i|
    child.update!(index: i)
  end
end

これがJSON形式の場合、配列の順序順にJSONテキストが書き出され、順序が保持されます。 つまり、JSONをモデルインスタンスに渡して保存するだけで、勝手に並び順が更新され、必要なくなった値は消えてくれます。 もちろん各要素にidを付与して前後の値を確認したい場合は、相応の処理が必要になりますが…それでも配列の要素毎にレコードを分割するよりもシンプルにロジックを記述することが可能になります。

data_params = {
  children: [
    { data: 'hoge' },
    { data: 'fuga' },
  ],
}

# 更新処理
data = Data.find(params[:id])
data.update!(data_params)

# 並べ替え処理
data = Data.find(params[:id])
data.children.sort_by! { |child| child.data }
data.save!

STORES 予約では、一部のメッセージ配信機能にこの仕組みを利用しています。 1つのメッセージには、テキストが画像等、種類の異なるデータを保持する必要があり、再編集で並べ替えも頻繁に行われるため、この仕組みを導入することでデータ操作をシンプルに実現することができました。          

// 型表現はTypescriptの方がわかりやすいかなと思ってここだけTypescript表現です。
type MessageTempate = {
  title: string,
  messages: (
    { type: 'text', message: string } |
    { type: 'image', src: string, alt: string, link: string | null }
  )[]
}

配列の要素毎にtypeを見て、ポリモーフィックな変換を行なっています。

class Message; end
class TextMessage < Message; end
class ImageMessage < Message; end

class MessagesType < ActiveRecord::Type::Value
  def cast(value)
    case value
    when Message
      value
    when Hash
      case elem['type']
      when 'text'
        TextMessage.new(elem)
      when 'image'
        ImageMessage.new(elem)
      end
    end
  end
end

class ArrayType < ActiveRecord::Type::Value
  def initialize(subtype)
    @subtype = subtype
  end

  def cast(value)
    case value
    when Array
      value.map do |elem|
       @subtype.cast(elem)
      end
    end
  end
end

class MessageTempate < ActiveRecord
  attribute :message, ArrayType.new(MessagesType.new), default: -> { [] }
end

また、将来的に新しい種類(例えばイメージギャラリー等)の要素が出てきても、テーブルの追加や更新を行うことなく柔軟に対応することが可能になっています。

おわりに

今回は、RDBのJSON型をRailsらしく扱う手法の紹介を行いました。 JSON型は便利ですが、インデックスやスキーマ定義等に制限があるため、RDBのメリットを削いでしまう部分もあるので、用法、用量を守って、適切に利用していきましょう。