IDをLongやStrnigで宣言した場合の問題点
Kotlinに限らない話ですが、アプリケーション内でIDを扱うとき、Long型、String型のようなGeneralな型を使うと、実装ミスで恐ろしいセキュリティバグにつながる可能性があります。
例えば以下のようなケースを考えます。
※ 以降のコードはすべてKotlinで書かれていますが、Kotlinに限らない別のプログラム言語に通じる一般的な話だと思っています。
data class User(val id: Long)
data class Company(val id: Long)
val userId = 1L
val companyId = 100L
//userIdとcompanyIdを間違えて指定!!別ユーザのデータが取得されてしまうセキュリティ事故に!
val user = User(companyId)
普通にテストしていればバグは防げるだろうと思われる人もいるかもしれませんが、
分岐の通りにくい条件だったり、
あるいはたまたま動くケース(上の例だとuserId=100の人がたまたまいた場合。ローカル環境のテストデータでidをauto incrementにしているとよく起きる)でテスト時に見逃してしまったりあると思います。
対処法がライトな順に書いていきます。
対応方法
1. idを使わない
クソリプ感がややある回答ですw
ユニークに判別すべき理由がなければidは必要ないため、idを使わないというのも選択肢になります。
なおここまでで話しているIdはサロゲートキーと呼ばれる「データ的には意味がないが、あるエンティティをユニークに判別するための値」を指しているのですが、ナチュラルキーと呼ばれる「データ的な意味としてあるエンティティをユニークに判別できる値または複数値の組み合わせ(例えばUserにおけるemail, Employeeにおける)」を使えばサロゲートキーIdは不要という考え方もできます。
とはいえ、この方法を取ることはほぼないと思います。
2. UUIDを使う
UUIDをidとして利用すれば、仮に前述したようなバグがあったとしても重複することは無いので安心という考え方です。
以下例だとuserId(仮に6f2fd261-8250-45db-9198-037df55c982aとします)の代わりに、companyid(仮に069d4c2e-e1c5-4cae-b689-a9919d87e2e6とします)を使ってUserを定義していますが、そのUUIDのUserがいる可能性はほぼない(これによると230京分の1)のでバグにはなりにくいという考え方です。
- メリット
- idはまず衝突しないという、セキュリティ面での安心感
- デメリット
- DBのデータとしてすでにIdが入っている場合、列をUUID型に変えるのは難しい(既存のIdも許容するためIdの列の型はString型にし、その列にUUIDを入れていくという形になる)
- URLにidが入る場合、UUIDを使うとURLが異常に長くなる(ただし、これについてはQitaのこの記事のURLのようにUUIDv4ではないものを使えば短くできる)
import java.util.UUID
data class User(val id: UUID)
data class Company(val id: UUID)
val userId = UUID.randomUUID()
val companyId = UUID.randomUUID()
//userIdとcompanyIdを間違えて指定!!バグではあるが、UUIDが被る可能性は無いに等しいので安心。
val user = User(companyId)
3. 各々のIdを表すWrapperクラスを定義する
以下のようなやつです。どのプログラム言語でも使える汎用的な技です。
data class UserId(val raw: Long)
data class CompanyId(val raw: Long)
data class User(val id: UserId)
data class Company(val id: CompanyId)
val userId = UserId(1L)
val companyId = CompanyId(100L)
//userIdとcompanyIdを間違えて指定すると、コンパイルエラーで検知できる!!
val user = User(companyId)
- メリット
- 型安全なのでコンパイルエラーで検知できる
- 文法的に簡単でKotlinに詳しくない人でも一目瞭然
- 今までこの方式で書いていなかったとしても、この方式への移行は安全にできる(コードの変更のみでDBのデータ型は変わらない。)
- デメリット
- Idを振りたいものができるたびにIdを定義しなければいけないので毎回コードを書かなければいけない
なおKotlin固有のTipsとして、Kotlin1.3からExperimentalな機能としてサポートされているinline classを使えば、Javaバイトコードに変換時にパフォーマンスの良いコードになります。
公式ドキュメントを見るとWrapperクラスのオーバーヘッドを無くすための機能と記載されており、まさに今回のようなUserId、CompanyIdに対して使うのは良いことだとわかります。
Sometimes it is necessary for business logic to create a wrapper around some type. However, it introduces runtime overhead due to additional heap allocations. Moreover, if the wrapped type is primitive, the performance hit is terrible, because primitive types are usually heavily optimized by the runtime, while their wrappers don't get any special treatment.
To solve such issues, Kotlin introduces a special kind of class called an inline class, which is declared by placing an inline modifier before the name of the class:
コードは以下のようになります。
inline classの場合はdata classにすることはできないようなので、data classの利点は失われるという欠点はあります。
//差分はこの2行!data class⇒inline classに変えただけ。
inline class UserId(val raw: Long)
inline class CompanyId(val raw: Long)
data class User(val id: UserId)
data class Company(val id: CompanyId)
val userId = UserId(1L)
val companyId = CompanyId(100L)
//userIdとcompanyIdを間違えて指定すると、コンパイルエラーで検知できる!!
val user = User(companyId)
4. 統一的なId型を作り、それを利用して各Id型を利用する
以下のようにIdentifier型を作って使うことで型安全にします。
data class Identifier<EntityT, RawT>(val raw: RawT)
data class User(val id: Identifier<User, Long>)
data class Company(val id: Identifier<Company, Long>)
val userId = Identifier<User, Long>(1L)
val companyId = Identifier<Company, Long>(100L)
//userIdとcompanyIdを間違えて指定すると、コンパイルエラーで検知できる!!
val user = User(companyId)
上のコードだとコードがわかりづらいので、typealiasを利用してUserId, CompanyId型を定義すると
グッとわかりやすくなります。
data class Identifier<EntityT, RawT>(val raw: RawT)
typealias UserId = Identifier<User, Long>
typealias CompanyId = Identifier<Company, Long>
data class User(val id: UserId)
data class Company(val id: CompanyId)
val userId = UserId(1L)
val companyId = CompanyId(100L)
//userIdとcompanyIdを間違えて指定すると、コンパイルエラーで検知できる!!
val user = User(companyId)
個人的にはこの方法が一番いいのではないかと思っています。
- メリット
- 型安全なのでコンパイルエラーで検知できる
- 短くシンプルで、可読性が非常に高い
- 今までこの方式で書いていなかったとしても、この方式への移行は安全にできる(コードの変更のみでDBのデータ型は変わらない。)
- デメリット
- Idを振りたいものができるたびにIdを定義しなければいけないので毎回コードを書かなければいけない
ちなみにKotlinにおいてこの方法をとる場合は、3のようにinline classを使うことはできません。
(Identifierは型パラメータを持っているため)
終わりに
上で紹介した方式はどれか一つを選択するのではなく、Idをどの程度安全にしたいかによって複数の方式を柔軟に選択するのがいいのではないかと思います。
例えばUserIdは値を間違えると重大な事故になりうるので型安全にする、といったような判断です。
もしかすると抜け漏れているパターンやもっと良いパターンがあるかもしれません。
そういったパターンを考えている人の参考になれば幸いです。
参考
Enforcing type safety of IDs in Kotlin というタイトルのブログ記事の内容を一部参考にしてこの問題について考えてみました。