アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Active Flagで効率的にフラグを実装する

morishitaです。

今回はActive FlagというGemを紹介します。
このGemはActiveRecordのモデルでBIt Arrayなカラムを扱いやすくしてくれます。

github.com

こういう要件ってありますよね?

  • 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

例えば、英語が話せるPersonlanguages=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 を付けても肥大しにくい

  • デメリット
    • 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を使って記事にフラグを追加し、分類に利用しています。

report.iko-yo.net

最後に

いこレポももうすぐ2周年。おかげさまで月間数百万PVを超えるサイトに成長しました。

アクトインディでは一緒にサービスをグロースさせていくエンジニアを募集しています。