Javaにおける文字コードまわりの話(5)

文字コードの検証の話、ずいぶんとさぼっていたが、ようやく少しやる気になった(というか、早く終わらせてすっきりしたかった)。ひとまず今回で終わり。
ちょっと間があいたので、これまでの話を整理しておく。


まず、外部設計時に実施する検証および調整の内容について、「Javaにおける文字コードまわりの話」に書いた。実際に、どのように検証したのかについて、まとめてみることにした。
Javaにおける文字コードまわりの話(2)」にて、用いる用語(「コードポイント」、「バイト列」、「エンコード」、「デコード」などの用語をどのように使うか)と、BMP基本多言語面)のみを対象にするという前提条件を述べた。さらに、プラットフォームでサポートされているエンコーディングセットを出力する方法と、エンコーディングセットで扱える文字の一覧を取得する方法を書いた。
Javaにおける文字コードまわりの話(3)」では、バイト列をデコードして文字の一覧を取得する方法を書いた。「Javaにおける文字コードまわりの話(4)」では、文字ストリームを用いてエンコードとデコードを行う方法を書いた。何でもかんでもメモリに展開できるわけではないため、実際、ストリームを用いた処理は、よく使うのではないか(もっとも、バイトストリームは用いないが)。


ここまでで、エンコードとデコードの方法について、次を説明した。今回出てくるメソッド、getChars(String encordingSetName, byte... bytes) および、getBytes(String encordingSetName, char... chars) は、次のどの方法を用いてもよい。


以降では、これまでのプログラムを踏まえて(これまでに出てきた便利メソッドも用いて)、自分がよくやるJavaにおける文字コードの検証について簡単に整理する。ここでは、JRE(32bitのWindows上で動作するSunのJRE)に閉じた話をする。もしデータベースなどの(Java以外のサードパーティの)ドライバを使っているなら*1、別途、検証を実施しなければならない。

さて、「Javaにおける文字コードまわりの話(2)」で書いた通り、コードポイントとバイト列が、1対多あるいは多対1にマッピングされると、バイナリレベルで文字やファイルそのものを突合する際や、Javaの内部で、同一文字かどうかを判定する際に、問題が発生する可能性がある。

少なくとも、このような処理を行う箇所では、ある文字を、どちらのコードポイントあるいはバイト列で取り扱うかを整理しておかなければならない。なお、UTF-8であれば、(冗長なエンコードを除いては)Unicodeのコードポイントとバイト列が1対1に対応するため、以下で実施する検証は、必要ないかもしれない(それでも念のためやるのだが)。しかし、ユーザの要求や、既存のシステムやアプリケーションとの関係で、全ての文字エンコーディングUTF-8にそろえられないこともある。例えば、次図のようなシステムにおいて、エンドユーザがWindows XP上で、ごく普通に(業務で用いる)テキストデータを作成するとしよう。この場合、Javaのアプリケーションで作成するダウンロードファイルは、エンドユーザによって作成されたバイト列と、同じバイト列にしなければならない。このような要求がある場合には、図の(a)のように、Javaエンコーディングセットとして、Windows-31Jを用いるのではないか*2





そこで、次のような簡単な検証プログラムを作成してみた。2つ目のメソッドは、バイト配列の比較プログラムが作成できないという体たらくぶり*3だったので、2バイト文字に限定したものになってしまった。

/**
 * コードポイントとバイトが多対1である文字を表示する。<br/>
 * 指定されたエンコーディングセットで扱うことのできる全ての文字について、エンコード、
 * デコード後に、元の文字に戻るかどうかを確認する。
 * @param encordingSetName エンコーディングセット名
 */
public static void printManyToOneCheckResult(String encordingSetName) {
    for (Character ch : getAllChars(encordingSetName)) {
        byte[] bytes = getBytes(encordingSetName, ch.charValue());
        char[] chars = getChars(encordingSetName, bytes);
        if (chars.length == 1 && chars[0] == ch.charValue()) {
            ; // 1対1に対応する
        } else {
            System.out.println(String.format("%s -> 0x%s -> %s",
                    getCharsDetailsString(ch),
                    bytesToHexString(bytes),
                    getCharsDetailsString(chars)));
        }
    }
}

/**
 * コードポイントとバイトが1対多である文字を表示する。<br/>
 * 指定されたバイト列を、2バイトずつデコードし、エンコード後に元の文字に戻るかどうかを
 * 確認する(XXX 今後、2バイトに限定しないようにする)。
 * @param encordingSetName 2バイト文字を扱うエンコーディングセット名
 * @param bytes 2バイト文字を表したバイト列
 */
public static void printOneToManyCheckResult(String encordingSetName, byte... bytes) {
    for (int i = 0; i < bytes.length;) {
        int i0 = i++; int i1 = i++;
        char[] decChars = getChars(encordingSetName, bytes[i0], bytes[i1]);
        byte[] encBytes = getBytes(encordingSetName, decChars);
        if (encBytes.length == 2 &&
            bytes[i0] == encBytes[0] && bytes[i1] == encBytes[1]) {
            ; // 1対1に対応する
        } else {
            System.out.println(String.format("0x%s -> %s -> 0x%s",
                    bytesToHexString(bytes[i0], bytes[i1]),
                    getCharsDetailsString(decChars),
                    bytesToHexString(encBytes)));
        }
    }
}

ここで、重要ではないコードを少なくするために、以下のメソッドを用いた。

private static String getCharsDetailsString(char... chars) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < chars.length; i++) {
        sb.append(chars[i]).append('(');
        sb.append(getUnicodeCodePointString(chars[i]));
        sb.append(')');
    }
    return sb.toString();
}


まず、Java内部の文字をエンコード後、デコードして、元にもどるかどうか試してみる。例えば、Windows-31Jならば、次のように検証する。

    printManyToOneCheckResult("windows-31j");

結果は、次のようになる。


半角文字は次の通り、オーバーライン(U+203E)およびバックスラッシュ(U+00A5)が、元に戻らない。なお、これらの文字は、以前に書いた通り、Windowsで普通に入力すると(ASCIIともJIS X 0201とも異なり)チルダと円記号になる。

        ‾(U+203E) -> 0x7e -> ˜(U+007E)
        \(U+00A5) -> 0x5c -> \(U+005C)

全角文字は次の10文字が元に戻らない。ここで■は、「う゛」(濁点つきの「う」)を表す。

    ¢(U+00A2) -> 0x8191 -> ¢(U+FFE0)
    £(U+00A3) -> 0x8192 -> £(U+FFE1)
    ¬(U+00AC) -> 0x81ca -> ¬(U+FFE2)
     ̄(U+00AF) -> 0x8150 ->  ̄(U+FFE3)

    ≪(U+00AB) -> 0x81e1 -> ≪(U+226A)
    ≫(U+00BB) -> 0x81e2 -> ≫(U+226B)

    μ(U+00B5) -> 0x83ca -> μ(U+03BC)
    ・(U+00B7) -> 0x8145 -> ・(U+30FB)
    ,(U+00B8) -> 0x8143 -> ,(U+FF0C)

    ■(U+3094) -> 0x8394 -> ヴ(U+30F4)

Unicodeとしては別々に定義されているが、プラットフォームとしては、同じ文字(バイト)として扱っているために、このようなことになる。例えば、U+00B5 はマイクロ記号として定義されており、U+03BC はギリシア文字のミューとして定義されている。各コードポイントに割り当てられている文字については、Unicode Consortiumで調べられる。このようになった歴史や経緯を知らなくとも、とりあえず開発はできる。


さて、エンコーディングセットとしてWindows-31Jを用いるのであれば、Javaの内部で扱いたい文字は、次によって取得される。

    public static Set<Character> getFixedWindows31jChars() {
        Set<Character> charSet = getAllChars("windows-31j");
        charSet.remove('\u00a2');
        charSet.remove('\u00a3');
        charSet.remove('\u00a5');
        charSet.remove('\u00ab');
        charSet.remove('\u00ac');
        charSet.remove('\u00af');
        charSet.remove('\u00b5');
        charSet.remove('\u00b7');
        charSet.remove('\u00b8');
        charSet.remove('\u00bb');
        charSet.remove('\u203e');
        charSet.remove('\u3094');
        return charSet;
    }

外部システムからデータを受信しない(誰かが作成したバイト列を読み込まない)システムであれば、これで終わりである。デコードしてエンコードしても、エンコードしてデコードしても、1対1に対応するため、前述したような問題は発生しない。

しかし、上図のようなシステム構成*4であれば、そうはいかない。例えば、円記号については、エンドユーザが作成したファイルは「0x5c」となっており、Java内部では U+005C にマッピングされる。従って、Java内部で U+005A を扱わないと安全だ。後は、図の(b)のWebサービス側との調整である。円記号は U+005C に統一するか、Webサービス側で円記号を U+00A5 で扱っているのであれば、バイト列「0xc2a5」( U+00A5 のUTF-8によるエンコード)がきたら、Javaの内部で U+005C に変換するなどの対応が必要である。


次に、外部からのバイト列をデコードし、エンコードして、元のバイト列に戻るかどうかを試してみる。

    // NEC特殊文字(13区)
    printOneToManyCheckResult("windows-31j", getNecSpecialBytes());
    // IBM拡張文字(115区〜119区)
    printOneToManyCheckResult("windows-31j", getIbmExtensionsBytes());

getNecSpecialBytesメソッド、getIbmExtensionsBytesメソッドについては、「Javaにおける文字コードまわりの話(3) - あしのあしあと」で書いたものだ。実は、これらのメソッドで取得されるバイト列には、デコードできない余計なバイト列が含まれている。このようなバイト列をデコードすると、Java内部では、U+FFFD (REPLACEMENT CHARACTER)にマッピングされる*5。次に示す結果は、U+FFFD にマッピングされたものを削除している。結果、NEC特殊文字(13区)については、次のようになった。

    0x8790 -> ≒(U+2252) -> 0x81e0
    0x8791 -> ≡(U+2261) -> 0x81df
    0x8792 -> ∫(U+222B) -> 0x81e7
    0x8795 -> √(U+221A) -> 0x81e3
    0x8796 -> ⊥(U+22A5) -> 0x81db
    0x8797 -> ∠(U+2220) -> 0x81da
    0x879a -> ∵(U+2235) -> 0x81e6
    0x879b -> ∩(U+2229) -> 0x81bf
    0x879c -> ∪(U+222A) -> 0x81be

IBM拡張文字(115区〜119区)については、次のようになった。

    0xfa4a -> Ⅰ(U+2160) -> 0x8754
    0xfa4b -> Ⅱ(U+2161) -> 0x8755
    0xfa4c -> Ⅲ(U+2162) -> 0x8756
    0xfa4d -> Ⅳ(U+2163) -> 0x8757
    0xfa4e -> Ⅴ(U+2164) -> 0x8758
    0xfa4f -> Ⅵ(U+2165) -> 0x8759
    0xfa50 -> Ⅶ(U+2166) -> 0x875a
    0xfa51 -> Ⅷ(U+2167) -> 0x875b
    0xfa52 -> Ⅸ(U+2168) -> 0x875c
    0xfa53 -> Ⅹ(U+2169) -> 0x875d
    0xfa54 -> ¬(U+FFE2) -> 0x81ca
    0xfa58 -> ㈱(U+3231) -> 0x878a
    0xfa59 -> №(U+2116) -> 0x8782
    0xfa5a -> ℡(U+2121) -> 0x8784
    0xfa5b -> ∵(U+2235) -> 0x81e6

NEC特殊文字IBM拡張文字を、上記の左側のバイトで扱っているシステムと連携する場合には、エンコーディングセットをWindows-31Jからx-IBM942(cp942)などに変更するか、バイト列を変換する必要がある*6。ホストとの通信であれば、EBCDICShift_JISあるいはUTF-8の基本的なコード変換は、MQやHULFTで行うことが多い。それでも適切に変換されることは、検証しなければならない。


このあたりの話、Microsoftコードページ932 - Wikipediaを見ると、いくつかの文字については、同じ文字が二重、三重に定義されている旨が説明されている。これは有名な話だが、まぁ二の次だ。文字コードの歴史を紐解きながら、問題が起こりそうな箇所を発見するなんて(自分には)無理。今のプラットフォームでどうなるかが重要で、それを知るには、やってみるしかない*7

実際、プログラムを動かすだけであれば、たいした手間でもないし、すぐに調べ終わる。だから、無駄な議論をする前に、実際に動かそう*8。それより、その後の調整の手間の方がかかる。。総じて、毎回こんなことをやりたくはない。ゴールデンパターンがあるのかもしれないが、まだ教えてもらっていない。

*1:たいてい使っているが。

*2:同様に、Webサービスの仕様次第で(b)が決まるし、一時ファイルを共有するアプリケーションや、ログの監視システム次第で(d)が決まる。このあたり、ウォーターフォールであれば、基本設計フェーズで詰め切らなければならないので、基本設計は設計と検証と調整で追われまくることになる(はず)。しかも、大概もっと重要なことが詰まっていないので、そちらを優先することになる。結局難しいのは、技術の話ではなくって、まるで時間がなかったり、あったとしても、調整先がなかったり、調整先とコミュニケーションがとれなかったり、関連システムの仕様がわからなかったり、それを調べる体制がなかったり、それを調べさせるように立ちまわってもらえなかったり、、あぁ、大変。

*3:2つのバイト配列を比較する」で書いてみたが、やっぱりうまく機能しない。。

*4:自分のイメージでは、シンプルな(エンタープライズ系)業務システムって、こんな感じ。

*5:そしてこれは「?」(0x3f)にエンコードされる。

*6:それでも当然、エンドユーザにダウンロードさせるファイルは、Windows-31Jエンコードし、上記右端のバイト列で作成する。エンドユーザがバイナリエディタで、左端のバイトにしてきたら、、Java内では同じ文字として扱われるので問題ない。エンドユーザがファイル比較をする時に問題が発生するだけだ。

*7:そもそもエンコーディングセットは、Java特有の概念なのだから、やるしかないと思う。

*8:どうせ許容文字チェックで用いる文字の一覧を取得するプログラムを書くことになるのだ。