# 数値をランダムっぽい桁数固定の文字列に可逆変換する ## 要件 - `id` の代わりに `code` を使いたい - `id` は auto increment - URL に含めるので推測を困難にしたい - 推測不可能である必要はない - `code` - ユニークな値 - ランダムっぽい文字列 - ユーザーが紙に記載またはシステムに入力する可能性がある - 見分けにくい文字を除外する - 除外する文字 - `I` , `O` , `0` , `1` - 使える文字(32 文字) - `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` - 桁数固定 - とりあえず 4 桁を想定 - プライバシーへの配慮は不要 - `code` で対象のデータを取得したい - パフォーマンスを重視したい ## 一般的な解法 ### UUID を利用する - `123e4567-e89b-12d3-a456-426614174000` - 重複の可能性はないものとして扱える - 4 桁という要件に合わない ### がんばって生成する - ランダムな文字列を生成 - 重複した場合は再生成 - データが増えるにつれパフォーマンスが悪化する ## Knuth's Multiplicative Hash の利用 最小完全ハッシュ関数を使ってみるというアイデア。 👉 [[Knuth's Multiplicative Hash]] 👉 [[完全ハッシュ関数と最小完全ハッシュ関数]] ### 完全ハッシュ関数 ![[完全ハッシュ関数と最小完全ハッシュ関数#完全ハッシュ関数]] ### 最小完全ハッシュ関数 ![[完全ハッシュ関数と最小完全ハッシュ関数#^b525c9]] ![[Hash_table_4_1_0_0_0_0_0_LL.svg|400]] ## Knuth's Multiplicative Hash [[ドナルド・クヌース|ドナルド・クヌース (Donald Ervin Knuth)]] による最小完全ハッシュ関数。 ![[Knuth's Multiplicative Hash#関数]] - 大昔に近いことをやった時にはここのアイデアがなかった - 今(2024 年)調べたらメルカリでこのアプローチを使っていた - [Knuth multiplicative hash が最小完全ハッシュ関数であることの証明 | メルカリエンジニアリング](https://engineering.mercari.com/blog/entry/2017-08-29-115047/) - 2017 年の記事 - 正しくは Knuth's Multiplicative Hash だと思う - 情報セキュリティの用途では使えない - [暗号学的ハッシュ関数](https://ja.wikipedia.org/wiki/暗号学的ハッシュ関数) を使うこと 👉 [[Knuth's Multiplicative Hash]] ## 定義と実装 ### 定義 ![[Knuth's Multiplicative Hash#^6f8585]] ### Go による実装 とりあえず、 $\rm MAX$ は $2$ のべき乗にしておく。 ![[Knuth's Multiplicative Hash#Go による実装]] ## 逆関数 生成結果から元の数値に戻したい。 最小完全ハッシュ関数は全単射なので逆関数が存在する。 ### 定義 ![[Knuth's Multiplicative Hash#^da67d9]] ![[Knuth's Multiplicative Hash#^7c8846]] ### 求め方(初心者 ver) ![[Knuth's Multiplicative Hash#求め方]] ### 拡張ユークリッドの互除法 $\rm PRIME$ と $\rm MAX$ は互いに素なので、拡張ユークリッドの互助法を利用して $\mathrm{PRIME}$ の逆元を求める。 まずは、拡張ユークリッドの互助法についての説明。 👉 [[拡張ユークリッドの互除法]] ![[拡張ユークリッドの互除法#定義]] Go による実装(再帰 ver) ![[拡張ユークリッドの互除法#再帰 ver]] ### 逆元を求める `extGCD` を利用して逆元を求める。 ![[拡張ユークリッドの互除法#逆元を求める]] `x` が負数になったら法(この例では `13`)を足して正数にするのがポイント。 ### Go による逆関数の実装 上記を踏まえて、Knuth's Multiplicative Hash の逆関数を実装する。 👉 [[mod 逆元を求める]] ![[Knuth's Multiplicative Hash#^8a17d8]] `PRIME` の逆元 `primeInv` を求めるために `modInv` を実装する。 - 下記の`movInv` は非再帰 ver (ループで処理) - Go は再帰が遅い - Goランタイムはスタックサイズのデフォルトが小さい - スタックサイズの最適化を行わない - ちなみに、末尾最適化もない - Stack Overflow が発生する ![[mod 逆元を求める#非再帰 ver]] 出力してみる。 ```go func main() { for n := 0; n < 10; n++ { v := f(n) inv := invF(v) fmt.Printf("%d => %2d => %d\n", n, v, inv) } } ``` ``` 0 => 1 => 0 1 => 18 => 1 2 => 3 => 2 3 => 20 => 3 4 => 5 => 4 5 => 22 => 5 6 => 7 => 6 7 => 24 => 7 8 => 9 => 8 9 => 26 => 9 ``` 元の値を取得できている。 ## 利点 - auto increment された id から即座に `code` を生成できる - データ量に応じて桁数を自由に指定できる - 可逆変換を実現しつつランダムっぽい文字列になる - 連番を比較しても気付かれない - `code` から `id` に戻せるので、 `id` を primary key とした通常運用ができる - `code` から `id` へは外部( DB など)へのアクセスなしに変換できる ## 注意点 秘密情報なしに復号できてしまうので、情報セキュリティの用途では使えない。 ## RDBMS での利用 ### 問題 INSERT 文を発行して auto increment された `id` を取得後に `code` を UPDATE する必要がある。 - 新規登録時にクエリを 2 回発行する必要がある - `code` に `NOT NULL` 制約を付けられない ### 解決案 - `PRIME` は固定 - 桁数が決まっていれば `MAX` は事前計算できる - `MAX` が事前計算できれば `PRIME` の逆元も事前計算できる `encode` と `decode` をストアドファンクションとして用意しておくことが可能。 ロジックが分散されるので、web 開発ではストアドファンクションの利用が避けられるが、この場合はドメインロジックというよりも汎用ユーティリティ関数なので OK でお願いします! (強いて言えば、使える文字種がドメイン依存と言えなくはないけど…) 例えば、MySQL の場合 - `CREATE FUNCTION` で `encode` と `decode` のストアドファンクションを作成しておく - ネーミングはよく考えて - 案 - ストアドプロシージャーで INSERT と `code` の UPDATE をまとめる - クエリは 2 回分だけど、アプリケーションから DB へのアクセスは 1 回 - AWS Lambda や Cloud Function と RDBMS の組み合わせに優しい - こうなるとストアドプロシージャーのパラメーターがドメイン依存になるのでつらい - AFTER INSERT のトリガーを使う - これはこれでつらい - どちらにせよ、`code` に `NOT NULL` 制約を付けられない… - ちなみに、 CREATE TABLE で DEFAULT にストアドファンクションは指定できない - 組み込みのファンクションは使える 何の成果も!!得られませんでした!! `id` と `code` の相互変換ができるので、そもそも `code` を保存する必要はないのだけど、トラブル対応の時を考えると保存しておきたい気持ち。 (一手間の差ではあるけれど) なので、ガンガン新規作成されるようなテーブルでの利用ははばかられる。 (NoSQL の出番?) ## N を超えない 2 のべき乗の計算 最後に、 `MAX` の値を決定するために必要となる計算について。 👉 [[N を超えない 2 のべき乗の計算]] ![[N を超えない 2 のべき乗の計算#N を超えない 2 のべき乗の計算]] ## コード全体 ここまでの材料を組み合わせて ```go package main import ( "fmt" "math/bits" "strings" ) // 32 ビット環境向けの Knuth 推奨の乗数 const prime = 2654435761 type Encoder struct { chars []string digit int min int max int // 2 の累乗 } func NewEncoder(digit int) *Encoder { return &Encoder{ // 見分けがつきにくいのでI, O, 1, 0 を除外した 32 文字 chars: strings.Split("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", ""), digit: digit, min: min(digit), // 4 桁の場合は 32768 max: max(digit), // 4 桁の場合は 524288 } } func (enc *Encoder) Max() int { return enc.max } func (enc *Encoder) Encode(n int) string { if enc.max <= n { // max と同じでもダメなはず panic("out of range") } // 桁数が足りるように min を足す h := hash(n, enc.max) + enc.min str := "" for 0 < h { str = enc.chars[h%32] + str h /= 32 } // 桁が逆順になってるけど本質的な問題ではないので気にしない return str } func (enc *Encoder) Decode(str string) int { chars := strings.Join(enc.chars, "") h := 0 for _, c := range str { // Encode 時に桁が逆順になってるので注意 h = h*32 + strings.Index(chars, string(c)) } return unhash(h-enc.min, enc.max) } func min(digit int) int { // 32 は 5bit return int(1 << (5 * (digit - 1))) } // digit が決まっていれば事前計算できる func max(digit int) int { // 32 は 5bit // 桁数未満になる min を除く m := int(1<<(5*(digit))-1) - min(digit) // m を超えない 2 の累乗 return largestPowerOfTwoLessThanOrEqual(m) } // n を超えない最大の 2 の累乗を計算する func largestPowerOfTwoLessThanOrEqual(n int) int { if n == 0 { return 0 } // 最も高いビットの位置を見つける pos := bits.Len(uint(n)) - 1 // その位置にあるビットが表す 2 の累乗を計算する return 1 << int(pos) } // Knuth's Multiplicative Hash func hash(n int, max int) int { return (n*prime)%max + 1 } // Knuth's Multiplicative Hash の逆関数 func unhash(h int, max int) int { primeInv := modInv(prime, max) // 事前計算できる(4 桁の場合は 208721) n := (h - 1) * primeInv % max return n } // 法 m での a の逆元を求める関数 // 拡張ユークリッドの互除法を利用 func modInv(a int, m int) int { var b, u, v = m, 1, 0 for b != 0 { t := a / b a -= t * b a, b = b, a u -= t * v u, v = v, u } u %= m if u < 0 { u += m } return u } func main() { // 4 桁のランダムっぽい文字列を生成したいとする enc := NewEncoder(4) var n int for n = 0; n < 100; n++ { code := enc.Encode(n) hashValue := hash(n, enc.max) + enc.min decorded := enc.Decode(code) fmt.Printf("%2d => %s (%6d) => %2d\n", n, code, hashValue, decorded) } } ``` 桁が決まっていれば事前計算できる値もある。 結果 ``` 0 => BAAB ( 32769) => 0 1 => R8PU (522674) => 1 2 => Q65D (488291) => 2 3 => P5JW (453908) => 3 4 => N3YF (419525) => 4 5 => M2DY (385142) => 5 6 => LYTH (350759) => 6 7 => KW82 (316376) => 7 8 => JVNK (281993) => 8 9 => HT34 (247610) => 9 10 => GSHM (213227) => 10 11 => FQW6 (178844) => 11 12 => EPCP (144461) => 12 13 => DMR8 (110078) => 13 14 => CK7R ( 75695) => 14 15 => BJMA ( 41312) => 15 16 => SG2T (531217) => 16 17 => RFGC (496834) => 17 18 => QDVV (462451) => 18 19 => PCBE (428068) => 19 20 => NAQX (393685) => 20 21 => L86G (359302) => 21 22 => K7KZ (324919) => 22 23 => J5ZJ (290536) => 23 24 => H4E3 (256153) => 24 25 => G2UL (221770) => 25 26 => FY95 (187387) => 26 27 => EXPN (153004) => 27 28 => DV47 (118621) => 28 29 => CUJQ ( 84238) => 29 30 => BSX9 ( 49855) => 30 31 => SRDS (539760) => 31 32 => RPTB (505377) => 32 33 => QM8U (470994) => 33 34 => PLND (436611) => 34 35 => NJ3W (402228) => 35 36 => MHHF (367845) => 36 37 => LFWY (333462) => 37 38 => KECH (299079) => 38 39 => JCR2 (264696) => 39 40 => HA7K (230313) => 40 41 => F9L4 (195930) => 41 42 => E72M (161547) => 42 43 => D6F6 (127164) => 43 44 => C4VP ( 92781) => 44 45 => B3A8 ( 58398) => 45 46 => SZQR (548303) => 46 47 => RX6A (513920) => 47 48 => QWKT (479537) => 48 49 => PUZC (445154) => 49 50 => NTEV (410771) => 50 51 => MRUE (376388) => 51 52 => LP9X (342005) => 52 53 => KNPG (307622) => 53 54 => JL4Z (273239) => 54 55 => HKJJ (238856) => 55 56 => GHX3 (204473) => 56 57 => FGDL (170090) => 57 58 => EES5 (135707) => 58 59 => DC8N (101324) => 59 60 => CBM7 ( 66941) => 60 61 => S93Q (556846) => 61 62 => R8G9 (522463) => 62 63 => Q6WS (488080) => 63 64 => P5CB (453697) => 64 65 => N3RU (419314) => 65 66 => MZ7D (384931) => 66 67 => LYLW (350548) => 67 68 => KW2F (316165) => 68 69 => JVFY (281782) => 69 70 => HTVH (247399) => 70 71 => GSA2 (213016) => 71 72 => FQQK (178633) => 72 73 => EN54 (144250) => 73 74 => DMKM (109867) => 74 75 => CKY6 ( 75484) => 75 76 => BJEP ( 41101) => 76 77 => SGT8 (531006) => 77 78 => RE9R (496623) => 78 79 => QDPA (462240) => 79 80 => PB4T (427857) => 80 81 => NAJC (393474) => 81 82 => L8XV (359091) => 82 83 => K7DE (324708) => 83 84 => J5SX (290325) => 84 85 => H38G (255942) => 85 86 => G2MZ (221559) => 86 87 => FY3J (187176) => 87 88 => EXG3 (152793) => 88 89 => DVWL (118410) => 89 90 => CUB5 ( 84027) => 90 91 => BSRN ( 49644) => 91 92 => SQ67 (539549) => 92 93 => RPLQ (505166) => 93 94 => QMZ9 (470783) => 94 95 => PLFS (436400) => 95 96 => NJVB (402017) => 96 97 => MHAU (367634) => 97 98 => LFQD (333251) => 98 99 => KD5W (298868) => 99 ```