この記事は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::Model
と ActiveModel::Attributes
と ActiveModel::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段カラムを潜った先に定義することになるので、多少使い勝手は違いますが、似た様に利用することが可能です。
今回の車とエンジンの構造を例にするならエンジンの種別毎にクラスを分けて、特定の形式のエンジンしか持ちえない諸元を個別にフィールドに残すとかでしょうか?
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のメリットを削いでしまう部分もあるので、用法、用量を守って、適切に利用していきましょう。