Skip to main content

文字コード — 文字とバイト列の対応

登録フォームで「𩸽」を含む名前を入れたら、途中で切れて保存された。同じ名前のユーザーが 2 件登録できてしまい、一意制約をすり抜けていた。検索でヒットしない語がある。

いずれも「文字を 1 個ずつ数えられるもの」と考えていると説明できません。**文字とバイト列の対応は 1 対 1 ではなく、数え方も比べ方も複数あります。**この章はその対応関係を扱います。

この章で学ぶこと

  • 文字集合符号化方式が別物であること
  • 文字の数え方がバイト・コードユニット・コードポイント・書記素の 4 通りあること
  • 同じ文字に見えて違うバイト列になる場合と、その揃え方
  • 並べ替えと比較を決めているのは文字コードではなく照合順序であること
  • 保存するときに効くのは文字数ではなくバイト数であること
  • 解釈の境目が複数あると、片方をすり抜ける入力が生まれること
前提知識

特別な前提はありません。最後の節で Web アプリの主要な攻撃と対策 の「エスケープ漏れと文字コードの罠」を掘り下げるので、その章を読んでいると話がつながります。

この章で扱わないこと

観点参照先
SQL インジェクションの対策そのものWeb アプリの主要な攻撃と対策
列の文字数をどう決めるかの設計判断データベース設計ガイド — データベース設計の重要性
インデックスが効く条件と、キー長の設計インデックス / データベース設計ガイド — インデックス設計入門
日時の表し方でずれる問題日時とタイムゾーン

文字集合と符号化方式は別のもの

混同されやすい 2 つを分けます。

何を決めるか
文字集合どの文字に、どの番号 (コードポイント) を割り当てるかUnicode
符号化方式その番号を、どのバイト列で表すかUTF-8、UTF-16

「Unicode で保存している」だけでは、バイト列は決まりません。同じ「あ」でも UTF-8 なら 3 バイト、UTF-16 なら 2 バイトです。ファイルやネットワークに載るのはバイト列のほうなので、読む側と書く側で符号化方式が食い違うと文字化けします。

選ぶ方式については、WHATWG の Encoding Standard が明確に定めています。

New protocols and formats, as well as existing formats deployed in new contexts, must use the UTF-8 encoding exclusively.

WHATWG Encoding Standard

同標準は、他の符号化方式を残しているのは既存の資産を読むためであり、UTF-8 だけを使えばここで挙げた問題は消えるとも述べています。新しく決められる場所では UTF-8 以外を選ぶ理由がありません。

数え方は 4 通りある

「文字数」と言ったとき、実際には少なくとも 4 つの異なる数え方があります。

数え方何を数えているかどこで効くか
バイト符号化した後の長さ保存の上限、通信量、インデックスのキー長
コードユニット符号化方式の最小単位。JavaScript の .length はこれ文字列の切り出し、slice の位置
コードポイント文字集合が割り当てた番号の個数正規表現、文字ごとの処理
書記素クラスタ人が「1 文字」と認識するまとまり表示幅、カーソル移動、入力欄の残り文字数
const utf8 = (s: string) => new TextEncoder().encode(s).length;
const graphemes = (s: string) =>
[...new Intl.Segmenter('ja', {granularity: 'grapheme'}).segment(s)].length;

for (const s of ['abc', '日本語', '𩸽', '👨‍👩‍👧']) {
console.log(
`${s} → UTF-8 ${utf8(s)} バイト / .length ${s.length} / コードポイント ${[...s].length} / 書記素 ${graphemes(s)}`,
);
}

// .length で切ると、文字の途中で割れる
const fish = '𩸽の刺身';
console.log('先頭 1 文字のつもりで切ると', JSON.stringify(fish.slice(0, 1)));
console.log('コードポイントで切ると ', JSON.stringify([...fish].slice(0, 1).join('')));
abc → UTF-8 3 バイト / .length 3 / コードポイント 3 / 書記素 3
日本語 → UTF-8 9 バイト / .length 3 / コードポイント 3 / 書記素 3
𩸽 → UTF-8 4 バイト / .length 2 / コードポイント 1 / 書記素 1
👨‍👩‍👧 → UTF-8 18 バイト / .length 8 / コードポイント 5 / 書記素 1

ASCII だけなら 4 つとも一致するので、英数字でテストしている限り違いが表に出ません

𩸽.length2 なのは、JavaScript の文字列が UTF-16 のコードユニットの並びだからです。UTF-16 で 2 バイトに収まらない文字は 2 つのコードユニット (サロゲートペア) で表されます。その途中で切ると、単独では文字にならない値が残ります。

先頭 1 文字のつもりで切ると "\ud867"
コードポイントで切ると "𩸽"

家族の絵文字はさらに極端で、5 つのコードポイントを見えない結合子でつないで 1 つの見た目にしています。**「入力は 10 文字まで」を .length で判定すると、絵文字 2 個で上限に達します。**人が数える単位に合わせたいなら書記素で数えます。

同じ文字に見えて、違うバイト列

「が」は 2 通りの表し方があります。1 つのコードポイントで表す形と、「か」と濁点の 2 つで表す形です。

Unicode はこの状態を正規化で揃えます。

形式何をするか
NFC分解してから合成する。結合された短い形になる
NFD分解する。基底文字と結合文字に分かれる
NFKC / NFKD互換分解を伴う。全角と半角のような見た目の違う文字まで同一視する
const composed = 'が'; // 1 コードポイント
const decomposed = 'が'.normalize('NFD'); // 「か」+ 濁点の 2 コードポイント

console.log('見た目 ', composed, decomposed);
console.log('length ', composed.length, decomposed.length);
console.log('=== で比較 ', composed === decomposed);
console.log('NFC に揃えて比較', composed === decomposed.normalize('NFC'));
見た目 が が
length 1 2
=== で比較 false
NFC に揃えて比較 true

**見た目が同じで、比較すると別物になります。**これが「同じ名前で 2 件登録できた」「検索でヒットしない」の正体です。

対処は 1 つで、入り口で正規化して、以降は揃った形だけを扱うことです。保存時と検索時で別の形になっていると、一意制約もインデックスも効きません。

どの形式に揃えるかは用途で決めます。保存と比較には NFC が既定でよく、NFKC は「株式会社」と「㍿」を同一視したいような検索用の索引に向きます。NFKC は情報を落とすので、元の表記を残したい場所では使いません。

分解された形はどこから来るか

入力の経路によって、同じキーボード操作でも違う形になります。macOS のファイル名が分解された形になりやすいのはよく知られた例です。**自分の環境で再現しないことは、その形が来ない証拠になりません。**入り口で揃えるのは、来る前提で守るためです (Unicode Standard Annex #15 — Unicode Normalization Forms)。

並べ替えと比較を決めているのは照合順序

「あいうえお順」や「アルファベット順」は文字コードの番号順ではありません。並べ替えと比較の規則は照合順序 (collation) が別に決めています。

const words = ['Zebra', 'apple', 'Ähre'];
console.log('コードポイント順', [...words].sort());
console.log('ドイツ語の照合 ', [...words].sort(new Intl.Collator('de').compare));
console.log('大小を無視した照合', new Intl.Collator('en', {sensitivity: 'accent'}).compare('ADMIN', 'admin'));
コードポイント順 [ 'Zebra', 'apple', 'Ähre' ]
ドイツ語の照合 [ 'Ähre', 'apple', 'Zebra' ]
大小を無視した照合 0

番号順では ASCII の大文字 (A-Z) がすべて ASCII の小文字より前に来るので Zebra が先頭になり、ASCII の外にある Ä は最後に落ちます。人が期待する順序ではありません。

3 行目が実務では危険です。compare0 を返すのは「等しい」という意味で、この照合順序では ADMINadmin が同じ文字列として扱われます。データベースの照合順序も同じ考え方で動くので、次のことが起きます。

  • 大文字小文字を区別しない照合順序の列に一意制約を張ると、adminADMIN は重複と判定される
  • 逆に、区別する照合順序なら両方登録できる

どちらが正しいかは要件次第です。メールアドレスやログイン名は区別しないほうが安全で、表示名は区別してよいことが多いでしょう。決めるべきは「どちらか」であって、既定に任せて意識しないことが事故になります。

照合順序は並べ替えとインデックスにも効きます。インデックスは値を並べた構造なので (インデックス)、照合順序を変えるとインデックスの並びも作り直しになります

保存の上限はバイトで効く

列の定義は文字数で書きますが、実際に効く上限にはバイト単位のものがあります

MySQL の例で見ます。utf8mb3 は 3 バイトまでしか使わないため、補助文字を保存できません。

The utf8mb3 character set is deprecated. ... The recommended character set for MySQL is utf8mb4. All new applications should use utf8mb4.

MySQL 8.4 Reference Manual — The utf8mb3 Character Set

utf8mb3 の列に絵文字や 𩸽 を入れると、設定によってエラーになるか、そこで切り捨てられます。「名前が途中で切れて保存された」の典型的な原因がこれです。

インデックスのキー長にもバイトの上限があります。この値は行フォーマットで変わります。

The index key prefix length limit is 3072 bytes for InnoDB tables that use DYNAMIC or COMPRESSED row format.

The index key prefix length limit is 767 bytes for InnoDB tables that use the REDUNDANT or COMPACT row format. For example, you might hit this limit with a column prefix index of more than 191 characters on a TEXT or VARCHAR column, assuming a utf8mb4 character set and the maximum of 4 bytes for each character.

MySQL 8.4 Reference Manual — InnoDB Limits

utf8mb4 は 1 文字あたり最大 4 バイトなので、古い行フォーマットでは 191 文字を超える列に索引を張れません。「VARCHAR(255) にインデックスを張ろうとしたら怒られる」という現象はここから来ています。上限も既定の行フォーマットもバージョンで変わるので、使っている環境のドキュメントで確かめてください。

境目が 2 つあると、片方をすり抜ける

Web アプリの主要な攻撃と対策 は、エスケープを「次善。エスケープ漏れと文字コードの罠がある」と位置づけました。その罠の中身がここにあります。

**バイト列を文字として解釈する箇所が 2 つあり、その 2 つが違う規則で動くと、片方だけをすり抜ける入力が作れます。**エスケープ処理が「この 1 バイトは引用符だ」と判断した位置が、データベース側では「1 つの文字の 2 バイト目」として読まれる、という食い違いです。

MySQL のクライアント API はこの依存関係を明示しています。

The mysql argument must be a valid, open connection because character escaping depends on the character set in use by the server.

MySQL 8.4 C API Developer Guide — mysql_real_escape_string()

さらに、接続の文字セットを SET NAMES で変えてもエスケープ関数が使う文字セットは変わらないと注意しています。この 2 つがずれた状態が、まさに「境目が 2 つある」状態です。

だから 17 章の結論が第一選択になります。**プレースホルダを使えば、値がバイト列として構文の解釈に混ざらないので、この経路そのものが無くなります。**エスケープを選ぶ場合は、接続の文字セットとエスケープ処理の文字セットが同じであることを、環境ごとに確かめる必要があります。

同じ形の食い違いは他の層にも出ます。ファイル名の検証を正規化前に行い、保存を正規化後に行えば、検証をすり抜けたパスが保存されます。検証と実行を同じ形の上で行うのが共通する原則です。

よくある誤解

「Unicode にすれば文字化けしない」 — 文字集合を決めても、バイト列にする方式が決まりません。読む側と書く側で符号化方式が食い違えば化けます。決めるべきは UTF-8 のような符号化方式のほうです。

「1 文字 = 1 バイト、または 1 文字 = 2 バイト」 — UTF-8 は 1 バイトから 4 バイトまでの可変長です。日本語の多くは 3 バイト、補助文字は 4 バイトになります。

.length で文字数が分かる」 — 分かるのは UTF-16 のコードユニット数です。サロゲートペアの文字は 2、結合された絵文字はさらに大きくなります。人が数える単位に合わせたいなら書記素で数えます。

「見た目が同じ文字列は === で等しくなる」 — 正規化の形が違えば別物として扱われます。入り口で揃えないと、一意制約も検索も期待どおりに動きません。

「並び順は文字コードの番号で決まる」 — 決めているのは照合順序です。番号順では Zebraapple より前に来ますが (ASCII の大文字が小文字より前だから)、Ähre はどちらより後ろになります。大小を区別しない照合順序では、adminADMIN が同じ値として扱われます。

確認問題

問 1. ユーザー登録で「同じ表示名は登録できない」仕様なのに、一覧に同じ名前が 2 件並んでいます。データベースには一意制約があり、エラーも出ていません。何を疑いますか。

保存されている文字列のバイト列が違うことを疑います。

一意制約はバイト列 (と照合順序) の比較で効くので、見た目が同じでも別のバイト列なら重複になりません。候補は 2 つあります。

  1. **正規化の形が違う。**片方が結合済みの形、もう片方が分解された形で保存されていると、別の値として通ります。入力の経路によって形が変わるので、片方は Web、片方はアプリからの登録、といった差で起きます
  2. **見た目の似た別の文字が混ざっている。**全角と半角、あるいは異なる文字集合の似た字形が使われている場合です

確かめ方は、該当する 2 行の長さとコードポイントを出すことです。表示すると同じでも、length やコードポイントの列が違えば 1 番目、同じ長さで別の番号が並んでいれば 2 番目です。

直し方は、入り口で正規化してから保存することです。既存データも同じ形へ揃える移行が要ります。照合順序を変えるだけでは足りません — 照合順序は大小や濁点の扱いを決めますが、正規化の形そのものを揃えるわけではないからです。

問 2. 「プロフィール文は 100 文字まで」という制限を text.length <= 100 で実装しました。利用者から「もっと短いのに入らない」と「100 文字より多く入る」の両方が報告されています。どちらも起こりうるのはなぜですか。

.length が数えているのが、人の数える「文字」ではないからです。

.length は UTF-16 のコードユニット数です。ここから 2 種類の食い違いが同時に生まれます。

  • 「もっと短いのに入らない」 — 絵文字や補助文字は 1 文字で 2 以上を数えます。家族の絵文字なら 1 つで 8 になるので、13 個ほどで 100 に達します
  • 「100 文字より多く入る」 — 保存側の上限がバイトで効いている場合、あるいは分解された形で入力された場合に、判定と実際の扱いがずれます

**判定と保存が別の単位で動いているのが根本原因です。**揃えるべきは次の 2 点です。

  1. 利用者に見せる制限は書記素で数える。「1 文字」の認識に合います
  2. **保存側の上限はバイトで確かめる。**列の文字セットと定義から、最悪何バイトになるかを見積もります

**どちらか一方だけでは足りません。**表示上の制限を通っても保存で切られる、という組み合わせが残るからです。

問 3. 検索機能で、SET NAMES を使って接続の文字セットを切り替え、値はエスケープ関数で処理しています。「プレースホルダに変えるべきだ」と指摘されました。どう説明しますか。

エスケープが正しく効く前提が崩れているからです。

エスケープ処理は「どのバイトが引用符か」を判断するために、接続で使われている文字セットを知っている必要があります。ところが SET NAMES はサーバー側の文字セットを変えるだけで、エスケープ関数が使う文字セットは変わりません。この 2 つがずれると、エスケープ処理が「独立した 1 バイト」と読んだ位置を、データベースが「1 つの文字の一部」として読む状況が作れます。引用符が値の中へ紛れ込み、構文として解釈されます。

**プレースホルダはこの経路そのものを無くします。**値は構文とは別の通り道で渡るので、バイト列の解釈が食い違っても構文にはなりません (Web アプリの主要な攻撃と対策)。

エスケープを続けるなら、接続の文字セットを API の側から設定し、エスケープ処理が同じ文字セットを見ていることを確かめる必要があります。確認すべき箇所が増えるだけで、得られる安全性はプレースホルダに届きません。

まとめ

  • 文字集合は番号の割り当て、符号化方式はバイト列にする方法です。新しく決められる場所では UTF-8 だけを使います
  • 文字の数え方はバイト・コードユニット・コードポイント・書記素の 4 通りあります。ASCII では一致するので、英数字のテストでは違いが出ません
  • JavaScript の .lengthコードユニット数です。その位置で切ると文字の途中で割れます
  • 見た目が同じで違うバイト列があります。入り口で正規化して、以降は揃った形だけを扱います
  • 並べ替えと比較を決めているのは照合順序です。大小を区別しない設定では adminADMIN が同じ値になり、一意制約の意味が変わります
  • 保存の上限はバイトで効きます。utf8mb4 は 1 文字最大 4 バイトで、インデックスのキー長の上限は行フォーマットで変わります
  • **バイト列を文字として解釈する箇所が 2 つあり、規則が食い違うと片方をすり抜けます。**プレースホルダはこの経路そのものを無くします
関連リファレンス

次に読む

これで第 14 部が終わりです。