仕組みから学ぶ Web
応用読了目安 15#Unicode#UTF-8#正規化#絵文字#照合順序

文字コードと正規化 — 同じに見える文字が、一致しない

「ガ」で検索しても出てこない。目で見ると同じ文字。バイト列は違う。長さも、比較も、見た目とはずれる。

この記事の進み方

What — 「文字」には3つの数え方がある

文字を扱うとき、混同されやすい3つの単位があります。

Compare「ガーゼ」を数える

保存や転送で実際に占める大きさ。エンコーディングで変わる。

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分解できるものは分解する(ガ = カ + 濁点)
NFKCNFC に加え、互換文字も変換(① → 1、ア → ア)
NFKDNFD に加え、互換文字も変換

Web では NFC を使うのが基本です。HTML の仕様でも NFC が推奨されています。

Why — 「一致する」の定義が、層ごとに違う

同じ文字列の比較でも、どの層でやるかで結果が変わります

Figure「ガーゼ」が各層でどう扱われるか
入力ブラウザ・OSアプリ比較・保存DB照合順序「ガ」を送信(分解された形かもしれない)正規化する? しない?INSERT / SELECT照合順序で比較一致しない
1/5
「ガ」を送信(分解された形かもしれない)入力の経路によって、合成済みか分解済みかが変わる。macOS の一部経路は分解、Windows は合成が多い。ここは制御できない。

入力・アプリ・DB のどこで正規化するかを決めないと、層ごとに違う答えが出る。冒頭の事故は、どこでも正規化していなかった。

照合順序は正規化の代わりにならない

DB には**照合順序(collation)**という設定があり、比較の規則を決めます。

utf8mb4_general_ci のような設定なら、大文字小文字を区別しません。ai(アクセント無視)が付けば、アクセント記号の違いも無視します。

しかし、合成済みと分解済みの差を吸収するかどうかは、DB と照合順序によります。吸収しない設定のほうが多いと考えて設計するほうが安全です。

そして照合順序はインデックスの効き方も変えます。設定を後から変えるとインデックスの作り直しが要り、大きなテーブルでは止まる時間が長くなります。

切り詰めると、文字が壊れる

「100文字まで」という制限を、バイト数で実装すると文字が割れます

UTF-8 の「あ」= E3 81 82(3バイト)
100 バイトで切ると、E3 81 で終わるかもしれない
→ 不正なバイト列になり、表示が壊れる

絵文字ではもっと露骨に出ます。家族の絵文字は、人の絵文字を ZWJ(ゼロ幅接合子)で繋いだものです。途中で切ると別の絵文字になるか、バラバラの人型に分かれます。

利用者に見せる「残り○文字」は書記素で数え、保存の上限はバイトで持つ。 この2つは別の話です。

演習 — まず自分で判断する

確認 — ここまで読めたか

家族の絵文字(人の絵文字を ZWJ で繋いだもの)1つを、JavaScript の `.length` で数えるといくつになりますか?

まず選ぶ(解答例は a〜d の記号で説明します)

確認 — ここまで読めたか

正規化はどこでやるのが基本ですか?

まず選ぶ(解答例は a〜d の記号で説明します)

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。