morishitaです。
今回はActive FlagというGemを紹介します。
このGemはActiveRecordのモデルでBIt Arrayなカラムを扱いやすくしてくれます。
こういう要件ってありますよね?
- ON/OFFできるユーザ設定をたくさん持たせたい
- 選択肢を複数選択できる選択項目を持たせたい
これらをDBに保存できるように実現するにはどのような実装をするでしょうか?
前者の場合、素朴に実装するとBooleanを格納する属性を設定項目分だけ作る方法が考えられます。
1つ、2つの項目ならそれでもいいでしょう。
でもそれ以上になると、テーブルのカラムがやたら増えてしまうのでもっとスッキリ実装できないかなぁと考えてしまいます。
なんとか1カラムに押し込めようとすると、JSONにしたり、true/false
のカンマ区切りリストを格納する方法もあるでしょう。
そして、そんなことをすると後で設定項目を見直す際に「ある設定をONにしているユーザはどれくらいいるのか集計してほしい」とか言われ、SQLで解決しようとするとLIKE
や正規表現を駆使することになります。
JSONのキーの命名次第では割と辛かったりしますし、true/false
のカンマ区切りリストだと…考えたくもないですね。
(モデルにロードして全件スキャンっていう手もありますが、小さいサービスならそれもいいでしょう。でも数百万とかになると…。うわぁ)
後者の場合、選択された項目のIDをカンマ区切りで格納したりするのでしょうか?
これも、抽出や集計する必要があるとなかなか辛いと思います。
選択肢そのものが別モデルとして独立できるほどであれば、別テーブルにして1対多の関係で管理してもいいかと思います。
でも、ON/OFF を管理したいだけで別テーブル作るのもなぁ…って場合もありますよね。
Bit Arrayという解決
Bit Arrayを使えば、テーブル的にはスッキリ解決できます。
例えば、Person
モデルがあり、話せる言語を英語、スペイン語、中国語、フランス語、日本語 から選択するケースを考えます。もちろん複数選択ありです。
このデータを格納するためにinteger
型のカラム languages
を設けます。
それぞれの言語を次の数で表します。
- 英語
english
= 1 - スペイン語
spanish
= 2 - 中国語
chinese
= 4 - フランス語
french
= 8 - 日本語
japanese
= 16
例えば、英語が話せるPerson
は languages=1
、英語と中国語と日本語が話せればlanguages=21
というふうに上記で示した数を合計した数を格納します。これで複数の言語が選択された場合も問題なくデータを保存できます。
ん? それで、ちゃんとどんな選択の場合も表現できるの? って思うかもしれませんが、大丈夫です。
それぞれの言語の数字を2進数に変換するとよくわかります。
言語 | 10進数表現 | 2進数表現 | |
---|---|---|---|
英語 | english |
1 | 00001 |
スペイン語 | spanish |
2 | 00010 |
中国語 | chinese |
4 | 00100 |
フランス語 | french |
8 | 01000 |
日本語 | japanese |
16 | 10000 |
で、英語と中国語と日本語を選択する場合は次のようになります。
english: 00001 chinese: 00100 japanese: 10101 ---------------- ビットOR 10101 (2進数) => 21 (10進数)
各言語の数字を2進数で表すと各桁がそれぞれの言語を表しているとみなせます。
そしてビットORを取ることにより複数の選択肢の選択状態を表現できます。
これですべての選択の組み合わせを表現できるということがわかりますね。
クエリー
検索するときにはビットANDを使うことにより検索可能です。
例えば、スペイン語(2)またはフランス語(8)のどちらかを選択しているperson
を抽出するには次の様なSQLとなります。
SELECT * FROM person WHERE languages & 10 > 0;
スペイン語(2)とフランス語(8)の両方を選択しているperson
を抽出するには次の様なSQLとなります。
SELECT * FROM person WHERE languages = 10;
メリット・デメリット
- メリット
- 1カラムで複数の選択肢の選択状態を格納できる
- 選択肢が増えてもDBのスキーマは変わらない
- JSONなどで格納する場合に比べ検索しやすい
- index を付けても肥大しにくい
- index を付けても肥大しにくい
- デメリット
- DBの値からどれが選択されているのかぱっと見わかりにくい
- ビット計算に慣れないと値の格納、レコード抽出が分かりづらい
Active Flag
さて、この様なカラムの実装をサポートしてくれるライブラリがactive_flagです。
Active Flagを使った実装
まずはGemfileに次を追加して bundle install
でインストールします。
gem 'active_flag'
そしてマイグレーションですが、Bit Arrayを格納するカラムは単なる整数型のカラムを使います。
次のような感じで実装します。
class CreatePerson < ActiveRecord::Migration[5.2] def change create_table :peaple, comment: 'パーソン' do |t| t.integer :languages, null: false, default: 0, limit: 8 end end
次にモデルの実装は、次のとおりです。:languages
に対する選択肢を定義するだけです。簡単ですね。
class Person < ApplicationRecord flag :languages, [:english, :spanish, :chinese, :french, :japanese] end
これで、インスタンスとクラスには次のメソッドが追加されます。
# インスタンスメソッド ## 設定されている値の取得 profile.languages #=> #<ActiveFlag::Value: {:english, :japanese}> profile.languages.english? #=> true profile.languages.set?(:english) #=> true profile.languages.unset?(:english) #=> false ## 値のセット、アンセット profile.languages.set(:spanish) profile.languages.unset(:japanese) profile.languages.raw #=> 3 profile.languages.to_a #=> [:english, :spanish] ## 複数の値の同時設定 profile.languages = [:spanish, :japanese] # クラスメソッド ## 選択肢の取得 Profile.languages.maps #=> {:english=>1, :spanish=>2, :chinese=>4, :french=>8, :japanese=>16 } Profile.languages.humans #=> {:english=>"English", :spanish=>"Spanish", :chinese=>"Chinese", :french=>"French", :japanese=>"Japanese"} Profile.languages.pairs #=> {"English"=>:english, "Spanish"=>:spanish, "Chinese"=>:chinese, "French"=>:french, "Japanese"=>:japanese} # スコープメソッド ## 抽出 Profile.where_languages(:french, :spanish) #=> SELECT * FROM profiles WHERE languages & 10 > 0 ## バルクセット Profile.languages.set_all!(:chinese) #=> UPDATE "profiles" SET languages = COALESCE(languages, 0) | 4 ## バルクアンセット Profile.languages.unset_all!(:chinese) #=> UPDATE "profiles" SET languages = COALESCE(languages, 0) & ~4
どの選択肢の値がいくつなのかを自分で管理しなくてもよしなにやってくれます。 そして自分でビット演算子を使ってクエリを作らなくても便利なメソッドで値の取得・設定、レコードの抽出が可能となります。 しかもi18nにも対応しており、言語ファイルを用意すれば複数言語対応で表示も可能です。
プロダクトでの利用
いこレポでは現在サイトリニューアル中です。
記事の種別や分類を整理して訪問ユーザが記事の分類や目的別に記事を探しやすくなる予定です。
段階的にリリースするスケジュールですでに一部は公開済みです。
そのため、Active Flagを使って記事にフラグを追加し、分類に利用しています。
最後に
いこレポももうすぐ2周年。おかげさまで月間数百万PVを超えるサイトに成長しました。
アクトインディでは一緒にサービスをグロースさせていくエンジニアを募集しています。