文字コードと正規化 — 同じに見える文字が、一致しない
「ガ」で検索しても出てこない。目で見ると同じ文字。バイト列は違う。長さも、比較も、見た目とはずれる。
この記事の進み方
What — 「文字」には3つの数え方がある
文字を扱うとき、混同されやすい3つの単位があります。
保存や転送で実際に占める大きさ。エンコーディングで変わる。
UTF-8 の「ガーゼ」
ガ = E3 82 AC (3 バイト)
ー = E3 83 BC (3 バイト)
ゼ = E3 82 BC (3 バイト)
合計 9 バイト- 「ガーゼ」の値
- 9
- 何に使うか
- 保存領域・通信量
- 見た目との一致
- しない
- –日本語は UTF-8 で1文字3バイトが基本。絵文字は4バイト。
- –DB のカラム長が「バイト数」なのか「文字数」なのかは、DB と設定による。
どれも正しい数え方だが、答えが違う。何を数えているかを意識しないと、文字数制限も切り詰めもずれる。
- 符号位置
- Unicode が各文字に振った番号。U+30AC のように書く。
- UTF-8
- 符号位置をバイト列にする方式のひとつ。ASCII は1バイト、日本語は3バイト、絵文字は4バイト。
- 結合文字
- 前の文字に付いて1つの見た目を作る文字。濁点やアクセント記号など。
- 正規化
- 同じ意味の文字列を、決まった形に揃えること。NFC は合成、NFD は分解。
- 書記素クラスタ
- 人が1文字と認識する単位。結合文字や絵文字の組み合わせをまとめたもの。
- ZWJ
- ゼロ幅接合子(U+200D)。絵文字どうしを繋いで1つの絵にする。幅を持たず、目には見えない。
- 照合順序
- DB が文字を比較・並べ替えるときの規則。大文字小文字や全角半角の扱いがここで決まる。
正規化は「同じに見えるものを揃える」操作
Unicode には、同じ文字を表す方法が複数あるものがあります。
ガ = U+30AC (合成済み・NFC)
ガ = U+30AB + U+3099 (分解・NFD)
どちらも画面では「ガ」に見えます。しかしバイト列が違うので、= では一致しません。
正規化は、これをどちらかの形に統一する操作です。
| 形式 | 何をするか |
|---|---|
| NFC | 合成できるものは合成する(ガ = 1つの符号位置) |
| NFD | 分解できるものは分解する(ガ = カ + 濁点) |
| NFKC | NFC に加え、互換文字も変換(① → 1、ア → ア) |
| NFKD | NFD に加え、互換文字も変換 |
Web では NFC を使うのが基本です。HTML の仕様でも NFC が推奨されています。
Why — 「一致する」の定義が、層ごとに違う
同じ文字列の比較でも、どの層でやるかで結果が変わります。
入力・アプリ・DB のどこで正規化するかを決めないと、層ごとに違う答えが出る。冒頭の事故は、どこでも正規化していなかった。
照合順序は正規化の代わりにならない
DB には**照合順序(collation)**という設定があり、比較の規則を決めます。
utf8mb4_general_ci のような設定なら、大文字小文字を区別しません。ai(アクセント無視)が付けば、アクセント記号の違いも無視します。
しかし、合成済みと分解済みの差を吸収するかどうかは、DB と照合順序によります。吸収しない設定のほうが多いと考えて設計するほうが安全です。
そして照合順序はインデックスの効き方も変えます。設定を後から変えるとインデックスの作り直しが要り、大きなテーブルでは止まる時間が長くなります。
切り詰めると、文字が壊れる
「100文字まで」という制限を、バイト数で実装すると文字が割れます。
UTF-8 の「あ」= E3 81 82(3バイト)
100 バイトで切ると、E3 81 で終わるかもしれない
→ 不正なバイト列になり、表示が壊れる
絵文字ではもっと露骨に出ます。家族の絵文字は、人の絵文字を ZWJ(ゼロ幅接合子)で繋いだものです。途中で切ると別の絵文字になるか、バラバラの人型に分かれます。
利用者に見せる「残り○文字」は書記素で数え、保存の上限はバイトで持つ。 この2つは別の話です。
演習 — まず自分で判断する
家族の絵文字(人の絵文字を ZWJ で繋いだもの)1つを、JavaScript の `.length` で数えるといくつになりますか?
正規化はどこでやるのが基本ですか?
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。