権限を列挙値で表現するのをやめてみる
権限を列挙値で表現した場合
みなさんは権限というものを表現するのに、どのような手法を用いているだろうか?
もしかすると一番多い方法は列挙値かもしれない。
例えば、あなたが入館管理システムを作っているとして、
- 社員(Staff)
- ゲスト(Guest)
という2つの区分が必要だということが分かっているとする。
そのために「社員」と「ゲスト」という2つの列挙値を定義したとする。
コードはこのようになるだろう。(1や2でなく文字列リテラルを用いる場合もあるかもしれないが)
type Account = {
// 1 社員
// 2 ゲスト
role: 1 | 2
}
この表現には問題が発生する場合がある。
例えば、複数の建物に入館管理システムが導入されているとする。
これらをA館・B館としよう。
ユーザーである田中さんは、A館においては社員だが、B館においてはゲスト扱いだったとしよう。
この状態を表現しようとすると、A館とB館で別々のアカウントを用意しなければいけなくなる。
さらに別の問題が起きる場合もある。
それは「社員かつゲスト」という状態を表すときに、新しい列挙値を用意しなければいけないことだ。
type Account = {
// 1 社員
// 2 ゲスト
// 3 社員かつゲスト
role: 1 | 2 | 3
}
まずい感じが伝わってくるだろうか?
例えば、
- 「社員のみが3階以上(社員エリアと呼ぶことにする)に行ける」
- 2階の応接エリアにあるカフェは「ゲストのみがフリードリンク」だとする。
こうした制約を表す関数は以下のようになるだろう。
// 社員エリアに行けるかどうか
function canGotoStaffArea(account: Account) {
return account.type === 1 || account.type === 3
}
// フリードリンクを利用できるかどうか
function canUseFreeDrink(account: Account) {
return account.type === 2 || account.type === 3
}
「かつ」の組み合わせに対して、2重に判定を加えることになってしまった。
このように、列挙値による表現には問題があることは理解してもらえたと思うので、ここからは解決の方法を解説していこう。
関係性を表現する
具体的な方法を考える前に、関係性を整理してみよう。
- アカウント
- 社員
- ゲスト
この3つについて、上位概念・下位概念の関係が成り立つかどうかを考えてみよう。
上位概念とは、例えば「動物」という概念は「犬」という概念に対して上位概念になる、というもので、逆に「犬」は「動物」の下位概念になる。
システムに登場する概念に上位・下位という関係性を与えると、それらはクラスシステムや型などで表すことができる。
図にすると下記のようになる。
「動物」と「犬」や「猫」の関係性
実はこれは、「is-a」の関係でもある。
- 犬は動物である(犬 is 動物)
- 猫は動物である(猫 is 動物)
では「アカウント」と「社員」や「ゲスト」の関係性はどうなるだろうか。
これは「社員」や「ゲスト」を、「アカウント」の一種として捉えた場合である。
つまり、アカウントが上位概念で社員やゲストがその下位概念となる。
これをTypescriptのクラスで表現してみよう。
// Account.ts
abstract class Account {
abstract get canGotoStaffArea(): boolean;
abstract get canUseFreeDrink(): boolean;
}
// Staff.ts
class Staff extends Account {
get canGotoStaffArea() {
return true;
}
get canUseFreeDrink() {
return false;
}
}
// Guest.ts
class Guest extends Account {
get canGotoStaffArea() {
return false;
}
get canUseFreeDrink() {
return true;
}
}
メソッドがメンバーとしてアクセスできるのはメリットだが、いささか冗長であることは否めない。
なので、単純なデータ構造と関数による表現も紹介しておこう。
// Account.ts
type Account = {};
// Staff.ts
type Staff Account & {}
export function canGotoStaffArea(staff: Staff) {
return true;
}
export function canUseFreeDrink(staff: Staff) {
return false;
}
// Guest.ts
type Guest = Account & {}
export function canGotoStaffArea(guest: Guest) {
return false;
}
export function canUseFreeDrink(guest: Guest) {
return true;
}
(バリアントを使わなかったのは「ゲストかつ社員」を許しているからである)
これで、型システム上でどのように表現するかが決まった。
ここで終わりにしてもいいのだが、せっかくなのでこれらをDB上にどのようにシリアライズするかも検討しておこう。
というのも「物理テーブル上では列挙値を使うことになりました」というのも悲しいからである。
データベースへのシリアライズ
実はDB上にこれらを展開するのは非常に簡単で、それぞれのテーブルを作り、リレーションを張ればいい。
- ACCOUNT テーブル
- ID 主キー
- STAFF テーブル
- ID 主キー
- ACCOUNT_ID 外部キー
- GUEST テーブル
- ID 主キー
- ACCOUNT_ID 外部キー
ここで、下記のようなREST APIが定義されていたとしよう。
/staff/account/:accountId
この要求に対して、下記のようなSQLを流すだろう。
SELECT *
FROM STAFF
JOIN ACCOUNT
ON STAFF.ACCOUNT_ID=ACCOUNT.ID
WHERE STAFF.ACCOUNT_ID=:accountId
(※ :accountId
は名前付きパラメータです)
社員としての情報があれば該当レコードが返ってくるし、そうでなければレコードは0件になる。
これらは、社員権限アリ・ナシの2状態に対応すると考えてもいいだろう。
もし社員の権限を与えたければ、STAFFテーブルにレコードを作ればよい。
より哲学的な表現をするなら、「社員」としての「アカウント」の存在が発生した、と言ってもよいし、実際その方がしっくりくる。
パフォーマンス面はどうだろうか?
例えば、ある人が社員専用のエレベータに乗り、磁気カードをリーダーにかざしたとする。
社員専用エレベータなので、社員情報が存在するかどうかを/staff/account/:accountId
のAPIに問い合わせ、404が帰ってくれば使用を禁止するだろう。
このとき、STAFFテーブルのACCOUNT_ID列にインデックスを作成しておけば、返答はすぐだ。
列挙値を使っていたなら、ACCOUNTテーブルの主キーを基に該当する行を取得し、アプリケーション側で比較演算子を使って社員であるかどうかを判定するだろう。
どちらも実パフォーマンスに顕著な差はなさそうだが、後者は全アカウントが入ったレコードのインデックスを走査するのに比べ、前者はSTAFFテーブルのみで済むことを考えると、多少は前者に軍配が上がりそうだ。
社員権限を付与する時はどうだろうか。
これはもう明らかで、ACCOUNTテーブルの列挙値の列をUPDATEするよりも、STAFFテーブルにINSERTする方が早い。
つまり、パフォーマンス面においても列挙値に軍配は上がらないのだ。
結論
ここまでの検討から、権限を表現するにあたり列挙値を使うことは、保守性やパフォーマンス双方においてメリットがないことが分かる。
代わりに、権限が付与された状態をアカウントの一種と捉え、別の概念として扱う方が良いだろうということが分かった。
もちろん、どちらを使うかは皆さんの自由だが、もし興味を持ったら実践してみてほしい。
それにより皆さんの権限に対する見方が変わったり、更に良い方法が生まれてくることを願っている。
最後まで読んでいただき、どうもありがとうございました。
おまけ
ところで、複数オフィスの入館管理問題については言及しなかった。(というか忘れていた)
おそらく下記のようなAPIを追加すれば解決だろう。
/office/:officeId/staff/account/:accountId
このAPIに対応する型(またはクラス)やDBへのシリアライズは読者への課題とする。
ぜひ解いてみてほしい。(ヒント:オフィスと社員は多対多)
解答
追加する型
type Office = {}
テーブル
- OFFICE テーブル
- ID
- STAFF_ADMISSION_CONTROL テーブル(社員の入館管理テーブル)
- ID
- OFFICE_ID 外部キー
- STAFF_ID 外部キー
SQL
SELECT *
FROM STAFF
JOIN ACCOUNT
ON STAFF.ACCOUNT_ID=ACCOUNT.ID
WHERE STAFF.ACCOUNT_ID=:accountId
JOIN STAFF_ADMISSION_CONTROL
ON STAFF_ADMISSION_CONTROL.OFFICE_ID=:officeId
AND STAFF.ID=ADMISSION_CONTROL.STAFF_ID
Discussion