ActiveRecord serialize / store の甘い誘惑を断ち切ろう

More than 1 year has passed since last update.

はじめに

みなさん、ActiveRecordの serializestore は好きですか?
僕は 嫌い です。
serializestore は原則として使わない方がみんな幸せになれると思っています。

なのでみなさんも serializestore は使わないようにしてください。

以上!

・・・で終わったら意味がわからないと思うので、この件についてなぜダメなのかをちょっと詳しく掘り下げてみます。

そもそも serialize / store とは?

serializestore は ActiveRecord の機能の一つです。
text型のカラムに配列やハッシュなど、好きな形式のデータを放り込めます。
テーブルやカラムを追加しなくても自由にデータが保存できる 魔法のような機能 (注:皮肉)です。

サンプルコードを使ってこの機能を確認してみましょう。
以下の例では emails と address でそれぞれ serialize / store を使っています。
(実際はこういうケースでわざわざ serialize を使ったりすることはないと思います。あくまで単純な使用例です。)

db/schema.rb
ActiveRecord::Schema.define(version: 20150610000419) do
  create_table "users", force: :cascade do |t|
    t.string   "name"
    t.text     "emails"
    t.text     "address"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end
end
user.rb
class User < ActiveRecord::Base
  serialize :emails
  store :address, accessors: %i(postal_code prefecture city address_number)
end

ちなみに storeserialize のラッパーです。モデルに自動的にアクセッサ(readerメソッドとwriterメソッド)を生やしてくれます。
(これ以降はまとめて serialize と呼びます)

実際にUserモデルを使ってみましょう。

user = User.new name: 'Alice'
user.emails = %w(alice@example.com alice-2@example.com)
user.postal_code = '123-1234'
user.prefecture = 'Hyogo'
user.city = 'Nishiwaki'
user.address_number = '123-1'
user.save!

user.reload
user.emails 
# => ["alice@example.com", "alice-2@example.com"]
user.postal_code
# => "123-1234"
user.prefecture
# => "Hyogo"
user.city
# => "Nishiwaki"
user.address_number
=> "123-1"

おお、すごい!
カラムが1個しかないのに、複数のメールアドレスが入ってる!
住所の情報も1カラムにまとめることができた!
新しいテーブルを作らずに済んだ!カラムもたくさん追加しなくて済んだ!
ばんざーい、ばんざーい!!\(^O^)/・・・ではありません。

データベースの中にはどんなデータが入っているのか?

Railsの裏側で何が起きているのか確認しておきましょう。
rails dbconsole で直接SQLを実行してみます。

sqlite> select * from users;
1|Alice|---
- alice@example.com
- alice-2@example.com
|--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess
postal_code: 123-1234
prefecture: Hyogo
city: Nishiwaki
address_number: 123-1
|2015-06-10 00:11:02.301871|2015-06-10 00:42:35.756088

改行が入ってしまうのでちょっとわかりにくいですが、実はYAML形式でデータが格納されます。(オプションでJSONに変更することもできます)

emails にはこんなデータ文字列が入っています。

---
- alice@example.com
- alice-2@example.com

address の場合はこんなデータです。

--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess
postal_code: 123-1234
prefecture: Hyogo
city: Nishiwaki
address_number: 123-1

結局のところRailsは配列やハッシュをYAML(またはJSON)に変換してデータを出し入れしているということがわかります。

さて、これを踏まえて、serialize がなぜダメなのかを考えていきます。

ダメなポイント1. シリアライズの形式が突然変更される可能性がある

YAMLやJSONの形式はRubyやRailsの都合である日突然変わる可能性があります。
というか、これは実際にあった話です。

Ruby 1.9.3 から標準のYAMLライブラリがSyckからPsychに変わりました。
この変更によって、Syckで書き込んだ日本語のデータをPsychで読み出すときに文字化けするようになってしまいました。

この変更に対応するため、serialize を使って日本語を格納していた場合は、既存レコードを全件Syckで読み出してPsychで保存し直すという非常に面倒くさいメンテナンスを実施しなければなりませんでした。

参考: PsychとSyckについて - Qiita

こういった互換性のない変更がこの先に絶対に起きないという保証はどこにもありません。

ダメなポイント2. 検索ができない、ソートもできない、集計もできない、インデックスも貼れない

serialize で格納されたデータは非常に検索がしづらいです。というかほぼ不可能です。
シリアライズの形式変更(前述のポイント1)は滅多に起きないかもしれませんが、この問題は日常的に遭遇する可能性が高いです。

たとえば兵庫県に住んでいるユーザを検索したいと思っても User.where(prefecture: 'Hyogo') と書くことはできません。

User.where("address LIKE ?", "%prefecture: Hyogo%") と書けばかろうじてそれらしきデータは取得できますが、しょせんLIKE検索なので検索条件によっては返ってくるデータが多すぎたり少なすぎたりすることがあるでしょう。

トラブル調査のためにデータベースのデータを確認したりするときも serialize されたデータを確認する場合は目的のデータを抽出するだけで一苦労します。

また、 serialize されていると郵便番号順にソートしたりすることもできません。
User.order(:postal_code) とは書けない)

郵便番号ごとにユーザーの件数を集計したいと思っても不可能です。
User.group(:postal_code).count(:id) とは書けない)

大量のデータから特定の郵便番号のデータを素早く取得したいと思っても、郵便番号にインデックスを貼ることはできません。
add_index :users, :postal_code とは書けない)

・・・などなど、serialize されているととにかくデータの扱いが難しくなります。

ダメなポイント3. データ構造やデータの内容が簡単にわからない

データベース上に存在するテーブルやカラムは簡単に確認可能です。
schema.rb で確認することもできますし、RDBMS用の管理ツール(pgAdmin等)でも確認できます。
ツールを使えばER図を自動生成したりすることもできます。
また、どのカラムにどんな値がどんな形式で入っているのかもデータを見ればすぐにわかります。
(例えば、ユーザーテーブルの性別カラムはどういう形式で男女の区分を格納しているのか確認したいときなど)

が、 serialize されているとそれが一気に確認しづらくなります。
最初に作った本人は構造を覚えているので大丈夫かもしれませんが、あとからプロジェクトに入った人はコード上の使われ方を調べたり、 rails console を叩いてモデルの中身を確認したりしないとデータ構造や格納されている値を確認できません。

serialize が使われているコードは一言でいうと「理解しづらいコード」になります。

ダメなポイント4. 仕様変更に弱い

モデルを適切にテーブルやカラムに分割している場合はRailsのマイグレーションを使ってDB上のデータの形式(つまりスキーマ)に一貫性を与えることができます。

たとえば、もともと数値しか入らない想定だったが、あとから自由な文字列を入力したくなった場合は次のようなマイグレーションを書けばOKです。

# 年齢欄に「20代」のような文字列も入力可能にする
change_column :users, :age, :string

こうすれば過去のデータはすべて数値型から文字列型に変更されます。

が、 serialize されていると簡単にはいきません。
データを一気に変換する手段がないからです。
一貫性を保とうと思うと、以下のように1件ずつデータを修正していく必要があります。

User.all.each do |user|
  # age が serialize されている場合
  user.age = user.age.to_s
  user.save!
end

これと同じ理由で新しいカラムを追加したり、不要になったカラムを削除したりする場合も、マイグレーションを使えば一発で終わる処理が serialize だと毎回変換用のスクリプトを書かなければいけなくなります。

おそらく現実的には「変換用のスクリプトを書いたり流したりするのも面倒だから、古いデータは古いまま放置」みたいな対応がなされるのではないでしょうか?
そうすると、データの一貫性が失われ、運用中ある日突然「古い形式を読み出したために発生するバグ」が出現するかもしれません。

そして時間が経てば経つほど当時の状況を理解している人が少なくなり、「なんでこのデータだけ形式が違うの?」とみんなが首をかしげる「負の遺産」を残すことになります。。。(怖い怖い)

ダメなポイント5. 遅い

serialize は文字通り、RubyのオブジェクトをYAMLやJSONを使ってシリアライズ(直列化)してデータベースに保存するオプションです。
データを読み出す場合は反対にデシリアライズ(復元)が必要になります。

普通にデータをDBのカラムに出し入れするときに比べると、 serialize では余計な処理が発生するのでどうしても遅くなります。

@joker1007 さんも「Railsパフォーマンス基本のキ」という発表資料の中で「serializeのコストが一番ヤバい」と書いています。(24-25枚目)

Kobito.jQBPtf.png

Kobito.R5q0Ut.png

すべての元凶は「とにもかくにも正規化されていない」こと

上記のようなデメリットの元凶は「データベース上のデータが正規化されていないこと」、これに尽きます。

リレーショナルデータベースの能力を最大限発揮するためには、 データを正規化すること が大原則です。

もちろん、状況によっては「正規化崩し」が必要になる場合もありますが、それはあくまで最終手段であり、気安く手を出すオプションではありません。

以前こんなポエム(ツイート)を書いたことがあるので、こちらに載せておきます。

serialize を使う "間違った" 動機

次のような動機で serialize を使おうとしたときは 間違った動機 である可能性が高いです。

  1. 新しいカラムをたくさん追加するのはイヤだから serialize して1つのカラムにまとめたい
  2. has_many で新しい関連テーブルを増やすのが面倒だから serialize して配列やハッシュを放り込みたい
  3. チェックボックスの選択肢ぐらいなら serialize しちゃってもいいんじゃない?

serialize を使ったことのあるみなさん、心当たりはありませんか??

serialize に代わる、より適切な代替案

serialize を使うなって言うけど、じゃあどうしたらいいんだよ!?」という方のために代替案を載せておきます。

  1. 面倒くさがらずに1つずつカラムを追加する。1つのテーブルに何十個もカラムができてしまう場合は、適切な単位で has_one のテーブルに切り出す。
  2. 一対多で関連するデータが必要になったら、面倒くさがらずに関連テーブルを追加する。
  3. チェックボックスの選択項目はそれぞれに独立した boolean型のカラムを用意する。
# 利用中のサービスにチェックを付けてください
# □ Qiita □ GitHub □ Stack Overflow
# 
# ・・・みたいな画面を想定
add_column :users, :using_qiita, :boolean, null: false, default: false
add_column :users, :using_github, :boolean, null: false, default: false
add_column :users, :using_stack_overflow, :boolean, null: false, default: false

項目を定義するときはちょっと面倒でも、ちゃんと正規化しておけば必ずあとで「ペイ」できます。
逆に面倒くさがって雑に項目を定義するとあとで痛い目に遭います。

もうひとつの代替案?

最近のRailsとPostgreSQLを組み合わせるとJSONをはじめ、いろんなデータ型を扱えるようになっているようです。

Active Record and PostgreSQL — Ruby on Rails Guides

# db/migrate/20131220144913_create_events.rb
create_table :events do |t|
  t.json 'payload'
end

# app/models/event.rb
class Event < ActiveRecord::Base
end

# Usage
Event.create(payload: { kind: "user_renamed", change: ["jack", "john"]})

event = Event.first
event.payload # => {"kind"=>"user_renamed", "change"=>["jack", "john"]}

## Query based on JSON document
# The -> operator returns the original JSON type (which might be an object), whereas ->> returns text
Event.where("payload->>'kind' = ?", "user_renamed")

が、僕は実際に使ったことはないので評価しづらいですし、特定のRDBMSにロックインされてしまうことにもなるので、ここでは議論の対象外とします。

「serialize の使いどころってあるの?」

これだけ「serialize 使うな」と言い続けると、「逆にどういうときに使ったらええねん!?」ってなりますよね。
・・・うーん、いつ使うんでしょう?(苦笑)
僕は3年ぐらい毎日のようにRailsを使ってきていますが、「serialize があって良かった」と思った場面は一度もありません。

もし使うとするなら以下のような条件を満たすときだと思います。

  • 非常に特殊なデータ型である
    • Rubyでは表現できて、なおかつシリアライズ/デシリアライズもできるが、RDBMS上では表現できないデータ型を使っている
  • 前述のデメリットがアプリケーションがリタイアするまで、すべて該当しない(またはトレードオフとして許容できる)。すなわち、
    • シリアライズ形式の変更が起きない(RubyやRailsのバージョンを上げない)
    • 検索できなくても、ソートできなくても、集計できなくても、インデックスが貼れなくても問題ない
    • 他のメンバーにとっての「わかりやすさ」を考慮しなくて良い
    • 格納しているデータのデータ構造が未来永劫変更されない
    • パフォーマンスは考慮しなくてよい

・・・となるんですが、どうやったらそんな条件を満たすのか僕には見当がつきません。
もしかすると「serialize を使った方が便利!」というユースケースもあるのかもしれませんが、たぶん日常的に遭遇するユースケースではないはずです。
なので、 serialize を使おうと思ったときはどこか間違っている(何か手を抜こうとしている!) と考えた方が良いと思います。

参考: Railsのドキュメントに書いてある説明

Railsのドキュメントに書かれている serialize メソッドの説明は以下のようになっています。

ActiveRecord::AttributeMethods::Serialization::ClassMethods

If you have an attribute that needs to be saved to the database as an object, and retrieved as the same object, then specify the name of that attribute using this method and it will be handled automatically.

(訳) データベースにオブジェクトを保存し、それを同じオブジェクトとして取り出す属性がほしい場合、このメソッドを使って属性の名前を指定してください。そうすればオブジェクトの出し入れが自動的に処理されます。

このように書いてありますが、「オブジェクト」がどのようなオブジェクトなのかは具体的に書かれていません。

僕の個人的な見解では、それがActiveRecordや普通の属性として表現可能なオブジェクトなのであれば、serialize ではなくテーブルやカラムとして追加すべきだと思います。

余談:「スキーマを一つに決められないアプリケーションだってあるんですよ!!」

はい、そういうWebアプリケーションもあるみたいですね。
たしかにそういう場合は serialize が活躍するかも・・・ですが、そういうアプリケーションの開発に携わったことがないのでちょっと評価しにくいです。
あと、もしそういうアプリケーションを開発するのであれば serialize 以外の実現方法も検討すべきかなーと思います。
いずれにしてもこの記事の範疇を超えてしまうので、ここでは議論の対象外とします。

まとめ

というわけで、この記事では serializestore を気軽に使うべきではない理由をあれこれ説明してみました。
Railsが提供している以上、serialize が必要になるユースケースもおそらくあるんでしょうが、少なくともカラムやテーブルの数を節約するために使うものではないと僕は信じています。
みなさんも serializestore を使う前にまず、 データを正規化 して格納することを検討してください。

「なるほど、正規化ね!!・・・で、正規化ってなんですのん?」

データの正規化についてはネットや書籍にたくさん情報が載っているので、そちらを参考にしてください。

ちなみに僕は以下の技術書を読んで勉強しました。

51FC57MJ0HL._SX389_BO1,204,203,200_.jpg 519XG19RGRL._SX353_BO1,204,203,200_.jpg

あわせて読みたい

Railsではなく前職で.NETを使っていた頃のエピソードです。

Design Horror! - give IT a try

いやー、ひどい設計ですねえ。笑っちゃいましたか?
でも、 serialize でやってることって半分これと同じなんですよね。