人に優しい識別子を使おう

この記事は Akerun Advent Calendar 2020 - Qiita の22日目の記事です。

こんにちは。@ps-tsh です。フォトシンスでは API Server などバックエンドシステムの開発を担当しています。先日、設計レビュー中に「識別子として UUID を使うのは妥当か」という話でちょっと盛り上がったので、きょうはそのあたりの話をしたいと思います。

連番IDのデメリット

「データの識別子にはどのような値を使うべきか」というテーマに対し、まずはもっとも一般的な連番IDの話から入りましょう。

たとえば Restful な API であれば、id=123 のユーザがいたとして GET /users/123 のようにアクセスします。実装レベルでは id として RDBMS が生成した連番を使うことが多いので、フレームワークのサポートも手厚く開発するのも楽です。しかし、連番のIDは数値を増減させるだけで他のデータにアクセスすることができるため、いわゆる総当たり攻撃を受けやすいというデメリットがあります。また、商用サービスであれば連番の最大値、すなわちデータ量からおおよその事業規模を推測されてしまうリスクもあるといえるでしょう。

ならばランダムIDだ

そこで、上記の問題を解決するためのアイディアとして「IDをランダムに発番する」という方法が出てきます。ID の本来の役割は識別子(identifier)なので、他と重複しないランダムな文字列を生成して使えばいいんじゃないかと考えるわけです。最近のプログラミング言語であれば、なんらかのランダムな値を生成する方法が提供されていると思います。たとえば Ruby では SecureRandom モジュールのメソッドを使うことで簡単にランダムな文字列を生成することができます。

SecureRandom モジュールの利用例

irb(main):001:0> require 'securerandom'
=> true
irb(main):002:0> SecureRandom.uuid # ランダムなUUIDを生成する
=> "5308b22c-2692-4b99-a404-316eaaf3088a"
irb(main):003:0> SecureRandom.base64 # ランダムなBase64文字列を生成する
=> "e/5gLBPoaxntIDeRwZlWPA=="
irb(main):004:0> SecureRandom.random_number(100000000) # 最大100000000のランダムな数値を生成する
=> 35831984

連番のかわりにランダムな値を使用することで、連番IDで課題となっていた総当たり攻撃やデータ量の推測は難しくなります。

ランダムIDのデメリット

連番のIDをランダム値に置き換えることで、エンジニアリング視点では問題が解決されたように見えます。では、ランダムなIDはあらゆる点でパーフェクトな解決策なのでしょうか。もちろんそんなことはなく、ランダムなIDにもデメリットがあります。

デメリット1: 長い

ランダムなIDは登録時の衝突を回避する必要があるので、連番の数値と比べると長い文字列になってしまいます。コードで処理する分には変わりませんが、人間が目視して入力する必要がある環境では、長いID文字列はどうしても使い勝手が悪くなってしまいます。たとえばUUIDを目視一回で覚えて、ミスせずにすべて入力するのはなかなか難しいですよね。

ランダムなIDの例: UUID

irb(main):009:0> SecureRandom.uuid
=> "033f9297-4393-463d-b63d-d6e3b4b05339"

また、長いランダム値はAPI設計にも影響をおよぼします。APIエンドポイントにパラメータとしてIDを含める場合、数値型のidであれば

GET /organizations/123/members/456?option=789

のようになりますが、これがUUIDだと

GET /organizations/648df1f4-03a7-450a-9d0a-352e742a5d57/members/83f50878-3726-4cb2-ab25-6db4f994ccb1?option=fd1b42bb-226c-4242-a667-13b13523147d

のようにとても長い文字列になってしまいます。頻繁に起こるケースではありませんが、パスパラメータやクエリパラメータに長大なIDが多数並ぶとブラウザやWebサーバが許容するURLの上限を超えてしまうこともあるため、IDはできるだけ短くしておいたほうが良いといえるでしょう。

この他、ストレージ効率が悪いとかインデックスサイズが増えて検索パフォーマンスが落ちるといった話もありますが、これらはあくまで副次的な話で、計算機リソースが安い現在においては最初に考えることではないと思います。

デメリット2: 紛らわしい文字が入る

「ランダムIDの文字列長が長くなってしまうなら短くすればいいじゃないか」とうことで、今度は使用する文字を増やして桁数を減らすことを考えてみましょう。たとえば Base64で使用する範囲の文字(0-9A-Za-z, +, /)を用いたランダムIDを生成してみます。

ランダムなIDの例: Base64

irb(main):008:0> SecureRandom.base64(16)
=> "ClnkCQEMel0XeDLYjgI0bg=="

同じ16byteのデータですが、Base64で表現することで文字列としてはUUIDと比べて(36→24と)短くなりました。しかし、文字の種類が増えたことで今度はIDに紛らわしい文字が含まれてしまいます。これも人間が手入力する上で困難なポイントになりえます。紛らわしい文字はITリテラシーの高くない人ほど間違えやすく、そうでなくてもフォントによっては非常に見分けがつきにくいため、クーポンコードや問い合わせ番号のようなものに採用すべきではありません。以下に、数字と紛らわしい英字の例を挙げます。

数字 紛らわしい英字の例
1 I, i, l
2 Z, z
5 S, s
6 b
9 q, g
8 B
0 O, o

また、英字には大文字・小文字で同じ形のものが多いため、文字種を増やしたいからといって混在させることも避けた方がよいでしょう。

デメリット3: 並べ替えられない

これは必ずしもランダムIDのデメリットとして挙げるべきものではないのですが、連番IDに期待されている性質として「並べ替えができる」というものがあります。たとえば「注文履歴を注文番号で並べ替えたら時系列順に並んで欲しい」といった要件がある場合、ランダムIDを採用してしまうと目的を果たすことができなくなります。もちろんこのケースは「注文日時で並べ替える」という方法で解決すべきですが、「IDに大小関係(並べ替え可能性)が期待されていることは多い」と心得ておいて損はないと思います。

人にやさしいIDをつくろう

ここまで、ランダム値ベースのIDについてデメリットをいくつか挙げてみました。ここからは、ランダム値の性質を保持しつつ、かつ人にもやさしいIDを設計するためのポイントについて紹介したいと思います。

ポイント1: 名前空間のサイズを考える

最初に考えるべきことは名前空間のサイズ想定です。IDを短くするためには必要以上に広い名前空間を使わないことがポイントになります。本記事の前半ではランダムIDの例としてUUIDを紹介しましたが、IDとして本当にUUIDが必要なケースははたして多いのでしょうか。UUIDの名前空間は128bitもあります。対象データの件数が数十万〜数千万程度であれば、32bit(正の整数で約21億)もあれば十分に収まります。多少上振れしても64bitあれば十分であることがほとんどです。

ポイント2: 表記上の桁数を減らす

UUID→Base64のところでも少し触れましたが、ID文字列の桁数を減らすためには、使用する文字数を増やす方法が効果的です。以下に示すように、同じ数値を表す場合でも2進数より8進数、10進数、16進数の方が桁が短くなりますよね。数字と英字の組み合わせであれば36進数まで表現できます。Rubyの場合、Numeric#to_s(n)を使えば36進数までの数を文字列に変換してくれます。

同じ数値(12345678)を2, 8, 10, 16, 36進数で出力したもの

irb(main):010:0> 12345678.to_s(2)
=> "101111000110000101001110"
irb(main):011:0> 12345678.to_s(8)
=> "57060516"
irb(main):012:0> 12345678.to_s(10)
=> "12345678"
irb(main):013:0> 12345678.to_s(16)
=> "bc614e"
irb(main):014:0> 12345678.to_s(36)
=> "7clzi"

ポイント3: 文字を増やした上で紛らわしい文字は除外する

上記で紹介した36進数を使うと、紛らわしい文字が含まれてしまいますよね。そこで、以下のように紛らわしい文字を除外したエンコーダを作ってn進数を出力することを考えます。

# 所定の文字セットを使って数値をn進数表記に変換する
def encode(n)
  # 例として数字の0, 1と英大文字のI, O を除外したものを使う。英小文字は使わない
  # 並べ替え可能性を保つため、文字の順番はASCIIコード順を保っておく
  chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'.split('')
  result = []
  while n > 0 do
    div, mod = n.divmod(chars.length)
    result.unshift(chars[mod])
    n = div
  end
  # 連結して出力
  result.join
end

出力例

irb(main):067:0> encode(12345678)
=> "DSSCG"

ポイント4: 並べ替え可能にする

前述の通りこれはIDとしての必須要件ではありませんが、ランダムIDを並べ替え可能にするためには、先頭に並べ替え可能なパートを付与する必要があります(厳密にはこの時点でもはやランダムではないのですが、そこは置いておきましょう)。以下はタイムスタンプを含めたランダムIDの例です。

# 前半にタイムスタンプのパートが含まれているため並べ替え可能になっている。
# 実際はID文字列を連結する場合「各パートを固定長にする」「タイムスタンプの精度が秒単位でよいか確認する」など
# もうひと手間かける必要がある
irb(main):097:0> encode(Time.now.to_i) + encode(SecureRandom.random_number(100000000))
=> "3HY23UC34C4EF"
irb(main):098:0> encode(Time.now.to_i) + encode(SecureRandom.random_number(100000000))
=> "3HY23UE3AQUGQ"
irb(main):099:0> encode(Time.now.to_i) + encode(SecureRandom.random_number(100000000))
=> "3HY23UF3QBUMH"

ポイント5: 桁区切りを入れる

最後になりますが、人間からみた可読性を上げるテクニックとして、適切な文字数で桁区切りを入れる方法も有効です。たとえば3HY23UF3QBUMH であれば 3HY2-3UF3Q-BUMH のように分割することで入力値の確認がしやすくなると思います。

おわりに

今回は「人に優しい識別子を使おう」ということで、識別子としてランダムIDを採用する場合に考えておきたいことをいくつか紹介しました。id設計においてちょっとでも役立ったと思っていただけたら嬉しいです。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com