はじめに
みなさん、ActiveRecordの serialize
や store
は好きですか?
僕は 嫌い です。
serialize
や store
は原則として使わない方がみんな幸せになれると思っています。
なのでみなさんも serialize
や store
は使わないようにしてください。
以上!
・・・で終わったら意味がわからないと思うので、この件についてなぜダメなのかをちょっと詳しく掘り下げてみます。
そもそも serialize / store とは?
serialize
や store
は ActiveRecord の機能の一つです。
text型のカラムに配列やハッシュなど、好きな形式のデータを放り込めます。
テーブルやカラムを追加しなくても自由にデータが保存できる 魔法のような機能 (注:皮肉)です。
サンプルコードを使ってこの機能を確認してみましょう。
以下の例では emails と address でそれぞれ serialize
/ store
を使っています。
(実際はこういうケースでわざわざ serialize
を使ったりすることはないと思います。あくまで単純な使用例です。)
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
class User < ActiveRecord::Base
serialize :emails
store :address, accessors: %i(postal_code prefecture city address_number)
end
ちなみに store
は serialize
のラッパーです。モデルに自動的にアクセッサ(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で保存し直すという非常に面倒くさいメンテナンスを実施しなければなりませんでした。
こういった互換性のない変更がこの先に絶対に起きないという保証はどこにもありません。
ダメなポイント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枚目)
すべての元凶は「とにもかくにも正規化されていない」こと
上記のようなデメリットの元凶は「データベース上のデータが正規化されていないこと」、これに尽きます。
リレーショナルデータベースの能力を最大限発揮するためには、 データを正規化すること が大原則です。
もちろん、状況によっては「正規化崩し」が必要になる場合もありますが、それはあくまで最終手段であり、気安く手を出すオプションではありません。
以前こんなポエム(ツイート)を書いたことがあるので、こちらに載せておきます。
ActiveRecordのserialize機能、一見便利そうなんですぐ飛びつく人がいるけど「よっぽど」の理由がないと使うべきじゃないな。まともに検索できないし、仕様変更にも弱い。RDBMSを使うならデータは正規化させるのが原則。Serializeでやってることは完全な非正規化。
— Junichi Ito (伊藤淳一) (@jnchito) May 22, 2015
Serializeを使うとカラムやテーブルが減ってなんかすごく得した気分になったり、DBをとてもシンプルにできた気がしたりするかもしれないけど、それは幻想。喜びを味わえるのは作った瞬間だけ。数ヶ月後、数年後に「やめときゃ良かった」と後悔するときが必ず来るよ。
— Junichi Ito (伊藤淳一) (@jnchito) May 22, 2015
繰り返しになるけど(Railsに限らず)RDBMSのデータ構造は正規化させるのが原則。正規化崩しは大人の上級テクニック。DB初心者が気やすく手を出すと大やけどするよ!
— Junichi Ito (伊藤淳一) (@jnchito) May 22, 2015
serialize を使う "間違った" 動機
次のような動機で serialize
を使おうとしたときは 間違った動機 である可能性が高いです。
- 新しいカラムをたくさん追加するのはイヤだから
serialize
して1つのカラムにまとめたい -
has_many
で新しい関連テーブルを増やすのが面倒だからserialize
して配列やハッシュを放り込みたい - チェックボックスの選択肢ぐらいなら
serialize
しちゃってもいいんじゃない?
serialize
を使ったことのあるみなさん、心当たりはありませんか??
serialize に代わる、より適切な代替案
「serialize
を使うなって言うけど、じゃあどうしたらいいんだよ!?」という方のために代替案を載せておきます。
- 面倒くさがらずに1つずつカラムを追加する。1つのテーブルに何十個もカラムができてしまう場合は、適切な単位で
has_one
のテーブルに切り出す。 - 一対多で関連するデータが必要になったら、面倒くさがらずに関連テーブルを追加する。
- チェックボックスの選択項目はそれぞれに独立した 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
以外の実現方法も検討すべきかなーと思います。
いずれにしてもこの記事の範疇を超えてしまうので、ここでは議論の対象外とします。
まとめ
というわけで、この記事では serialize
や store
を気軽に使うべきではない理由をあれこれ説明してみました。
Railsが提供している以上、serialize
が必要になるユースケースもおそらくあるんでしょうが、少なくともカラムやテーブルの数を節約するために使うものではないと僕は信じています。
みなさんも serialize
や store
を使う前にまず、 データを正規化 して格納することを検討してください。
「なるほど、正規化ね!!・・・で、正規化ってなんですのん?」
データの正規化についてはネットや書籍にたくさん情報が載っているので、そちらを参考にしてください。
ちなみに僕は以下の技術書を読んで勉強しました。
あわせて読みたい
Railsではなく前職で.NETを使っていた頃のエピソードです。
Design Horror! - give IT a try
いやー、ひどい設計ですねえ。笑っちゃいましたか?
でも、 serialize
でやってることって半分これと同じなんですよね。