はじめに
表示名の上限を「3文字」と決めたとき、ABCは通るのにA😀Bだけが拒否されることがあります。見た目にはどちらも3文字ですが、JavaのString.length()は利用者が意図する文字数ではなく、UTF-16のコード単位数を返します。
この記事では、表示名を登録する小さなJava APIで、A😀Bを4文字として拒否する問題を再現します。受理結果だけでなく、判定に使った測定値と登録後の最終状態を分けて観測し、codePointCount()を使う最小修正で直します。Javaの文字数制限で、どの単位を数えるべきかも整理します。再現コードはjava-unicode-character-limit-debug-labにあります。
先に結論
原因は、Unicodeコードポイント数で定義した表示名の上限に対して、String.length()のUTF-16コード単位数を使っていたことです。JavaのStringはUTF-16形式で補助文字をサロゲートペアとして表し、補助文字はString内で2つのchar位置を使います。Java SE 21 API: String
修正では、displayName.length()をdisplayName.codePointCount(0, displayName.length())へ置き換えます。String.codePointCount()は指定範囲のUnicodeコードポイント数を返します。Java SE 21 API: String
| 入力 | バグあり | 修正後 |
|---|---|---|
ABC |
3として受理 | 同じ |
A😀B |
4として拒否 | 3として受理・登録 |
A😀BC |
5として拒否 | 4として拒否 |
再現プロジェクト
このラボはJava 21の標準ツールだけで動きます。外部ライブラリやWebフレームワークを使わず、文字列の計測単位だけを原因に保っています。
表示名の上限は3コードポイントです。A😀BはA、😀、Bの3コードポイントなので受理し、登録済み集合へ入ることを契約にします。
最初に失敗するテストを書く
テストでは、同じ操作に対して3つの観測を行います。まず直接結果のaccepted、次に判定に使われたmeasuredCharacterCount、最後にレジストリへ保存された最終状態です。
void acceptsThreeUnicodeCodePointsAndStoresTheDisplayName() {
DisplayNameRegistry registry = new DisplayNameRegistry(3);
RegistrationResult result = registry.register("A😀B");
assertTrue(result.accepted(), "3コードポイントの表示名を受け入れる");
assertEquals(3, result.measuredCharacterCount(), "コードポイント数を結果へ返す");
assertTrue(registry.contains("A😀B"), "受理した表示名を登録する");
assertEquals(Set.of("A😀B"), registry.registeredNames(), "最終状態は受理した表示名だけを持つ");
}
バグを含むコミット8610ca6でテストを実行すると、設定やコンパイルではなく、期待対実際の差分で失敗します。
Exception in thread "main" java.lang.AssertionError: expected=true actual=false: 3コードポイントの表示名を受け入れる
expected=3 actual=4: コードポイント数を結果へ返す
expected=true actual=false: 受理した表示名を登録する
expected=[A😀B] actual=[]: 最終状態は受理した表示名だけを持つ
expected=4 actual=5: 超過判定にもコードポイント数を使う
ここで重要なのは、単に拒否されたことではありません。計測値が4であることと、レジストリが空であることを確認したため、入力、判定、保存後状態のつながりを確認できます。
観測と切り分け
「登録処理が呼ばれていない」「集合への追加を忘れた」「文字数を誤って数えた」という候補を、観測を分けて比較します。
| 確認したこと | 観測結果 | 分かったこと |
|---|---|---|
| 入力 | A😀B |
上限ちょうどの3コードポイントを意図した入力である。 |
| 直接結果 | accepted=false |
上限判定の結果が契約と異なる。 |
| 測定値 | measuredCharacterCount=4 |
拒否の直接原因は保存後の処理ではなく、判定前の数え方にある。 |
| 最終状態 | registeredNames=[] |
レスポンスだけの問題ではなく、登録が実行されていない。 |
| 実装 | displayName.length() |
UTF-16コード単位を文字数として利用している。 |
レジストリへの追加漏れなら、測定値は3で受理結果だけが異なるはずです。しかし実測値は4です。したがって、集合実装や保存順序ではなく、文字の単位を取り違えたことが直接原因だと絞り込めます。
原因
修正前の実装です。
public RegistrationResult register(String displayName) {
int measuredCharacterCount = displayName.length();
if (measuredCharacterCount > maximumCharacterCount) {
return RegistrationResult.rejected(
measuredCharacterCount,
"表示名は%d文字以内で入力してください".formatted(maximumCharacterCount)
);
}
registeredNames.add(displayName);
return RegistrationResult.accepted(measuredCharacterCount);
}
JavaのStringでは、インデックスやcharはUTF-16コード単位を基準にします。補助文字はサロゲートペアで表現されるため、😀のような補助文字はString中で2つの位置を使います。Java SE 21 API: String その結果、A😀Bのlength()は4です。
この挙動はJavaとして正しい一方、今回の表示名上限の契約とは異なります。問題はlength()が壊れていることではなく、「上限を何の単位で数えるか」を決めずに利用者向けの文字数へ流用したことです。
修正
上限契約に合わせ、Unicodeコードポイント数を数えます。
int measuredCharacterCount = displayName.codePointCount(0, displayName.length());
codePointCount()を使うと、A😀Bは3、A😀BCは4になります。既存の登録処理はそのままにし、数える単位だけを変える最小修正です。
public RegistrationResult register(String displayName) {
int measuredCharacterCount = displayName.codePointCount(0, displayName.length());
if (measuredCharacterCount > maximumCharacterCount) {
return RegistrationResult.rejected(
measuredCharacterCount,
"表示名は%d文字以内で入力してください".formatted(maximumCharacterCount)
);
}
registeredNames.add(displayName);
return RegistrationResult.accepted(measuredCharacterCount);
}
再発防止テスト
修正後も同じテストを残します。テストは成功結果だけでなく、上限を超えるA😀BCが拒否されることも検証します。
| 観点 | 検証内容 |
|---|---|
| 受理 |
A😀Bが受理される。 |
| 計測 |
A😀Bが3、A😀BCが4と報告される。 |
| 最終状態 | 受理した名前だけが集合へ追加される。 |
| 拒否 |
A😀BCは集合へ追加されない。 |
この形にすると、将来length()へ戻してしまう変更だけでなく、受理結果と保存後状態が食い違う変更も検出できます。
文字数の単位を決める
codePointCount()はlength()より適切ですが、画面上で利用者が1文字と感じる単位と常に一致するわけではありません。文字数制限では、次の単位を区別して仕様を決めます。
| 数える単位 | Javaでの例 |
A😀Bの数 |
用途 |
|---|---|---|---|
| UTF-16コード単位 | String.length() |
4 |
charやUTF-16の位置を扱う処理 |
| Unicodeコードポイント | String.codePointCount() |
3 | コードポイント単位の入力制限 |
| グラフェムクラスタ | Unicode Text Segmentation | 3 | 利用者が知覚する文字に近い入力制限 |
例えば、👨👩👧👦は画面上では1つの家族の絵文字に見えますが、複数のコードポイントで構成されます。また、éも、1つのコードポイントで表す場合と、eと結合文字で表す場合があります。Unicode Standard Annex #29: Unicode Text Segmentation
今回の修正は、「上限をUnicodeコードポイントで数える」という契約には正しい修正です。画面上の1文字を上限にしたい場合は、グラフェムクラスタに基づく別の方針を設計し、UIとサーバーで同じ規則を使う必要があります。
バグを自分で再現する
git clone https://github.com/tonbiattack/java-unicode-character-limit-debug-lab.git
cd java-unicode-character-limit-debug-lab
git checkout 8610ca6
./scripts/test.sh
# 失敗: A😀Bが4として拒否される
git checkout main
./scripts/test.sh
# 成功: DisplayNameRegistryTest
まとめ
JavaのString.length()は、利用者向けの文字数をそのまま返すAPIではありません。UTF-16コード単位で扱うことが正しい場面もありますが、表示名の上限をUnicodeコードポイントで数える契約とは異なります。
デバッグでは、受理結果、測定値、保存後状態を別々に観測しました。これにより、集合への追加漏れではなく、上限判定に使った単位が原因だと確定できます。文字数制限では、最初に数える単位を仕様として決め、その単位を直接検証するテストを残すことが重要です。