クロスプラットフォームな Java コードの書き方

仕事で、Ubuntu 上のみで稼働する Java 製のプログラムを Windows で動くよう対応したので、対応するのに必要だった点を記録しておく。

改行コードの違い

Linux では通常、改行コードは LF (\n) が用いられる。対して Windows は CRLF (\r\n) が用いられており、この差によってコードが動かなくなることがある。

例えば下記のようなテストを考える。

assertEquals("Hello, World!\n", getMessage());

getMessage() がシステムによって異なる改行コードを用いる場合、このテストはクロスプラットフォームではなくなる。
対策としては下記のいくつかが考えられる。


1. 改行コードの差をライブラリに吸収させる
例えば JSON が返される処理を考える。この時、返された JSON をそのまま文字列で比較するのではなく、JSON をデコード後に比較するという手段が考えられる。こうすれば、改行コードの違いはライブラリが吸収してくれる。

// NG
assertEquals("{\"message\" : \"Hello\"}\n", getJSON()); 

// OK
Map<String,Object> json = JSON.decode( getJSON() );
assertEquals(1, json.size());
assertEquals("Hello", json.get("message"));


2. 最後の改行だけを取り除くユーティリティ関数を使う
org.apache.commons.lang.StringUtils.chomp メソッドは最後の改行を取り除いてくれる。これを使えば結果文字列の最後に改行コードが付与されるような場合に対処できるだろう。

assertEquals("Hello, World!", StringUtils.chomp(getMessage()));


3. 改行を正規化してしまう
それでもダメなら、CRLF を LF に変換するようなユーティリティ関数を用意して、それを通してから比較するしかない。

public static String normalizeLineBreak(String value) {
    if (value == null)
        return null;

    return value.replaceAll("\\r\\n", "\n").replaceAll("\\r", "\n");
}

ファイルセパレーターの違い

Linux のパス形式は /path/to/file であるが、Windows は C:\path\to\resource である。つまり、セパレーターが / と \ で異なる。厳密にはこれらの値はハードコーディングせず、File.separator を用いるべきだが、実は Windows はセパレーターに / を用いることができる。パスを表すのに

String path = "path" + File.separator + "to" + File.separator + "resource";

と書くのも面倒だし読みにくいので、何か理由が無い限りは / で十分だ。
対応すべき箇所は、既存のコードと File.getPath() 等で返されるパスのセパレーターの差を吸収するようなコードのみで事足りるだろう。


ちなみに上記にも書いたとおり、セパレーターは File.separator で取得可能。

print( File.separator ); 

// 実行結果 (Windows 環境)
\

// 実行結果 (Linux 環境)
/

jar ファイル内のリソース取得時のファイルセパレーター

jar ファイル内のリソースにアクセスする際のセパレーターは Windows, Linux 共に / だ。本来対応しなくて良い部分だが、上記ファイルセパレーターの対応時に全てのセパレーターを File.separator で書いてしまうと動かなくなってしまう。通常のファイルシステム上のファイルにアクセスしているのか、jar ファイル内のリソースにアクセスしているのか分別しよう。


環境変数のパスセパレーター

環境変数の区切り文字は Linux では : (コロン) 、Windows では ; (セミコロン)だ。環境変数を取得してコロンで split、のような処理をしている箇所があると処理内容に差が出てきてしまうため、対応しなければならない。

これらの値は、java.util.File.pathSeparator で取得できる。

print( File.pathSeparator ); 

// 実行結果 (Windows 環境)
;

// 実行結果 (Linux 環境)
:

文字コード

Linux では Unicode が基本だ。が、Windows では Shift-JIS や EUC-JP やその他が入り交じるカオスになっているので、ファイルにテキストを書き込んだり読み込んだりする場合は必ずエンコーディングを指定する。UTF-8 前提のコードでは Windows では動かないのだ。

String value = ...
write( value.getBytes() ); // NG
write( value.getBytes("UTF-8") ); // OK
OutputStream output = ...
OutputStreamWriter writer = new OutputStreamWriter(output); // NG
OutputStreamWriter writer = new OutputStreamWriter(output, "UTF-8"); // OK
InputStream input = ...
InputStreamReader reader = new InputStreamReader(input); // NG
InputStreamReader reader = new InputStreamReader(input, "UTF-8"); // OK

ファイルリネーム時の挙動

Windows では、File をオープンした状態ではリネームが行えない。例えばあるストリームを一時ファイルに書き込み、書き込み終了後にリネームしたいとする。するとコードは下記のような感じになるだろう。(例のごとく例外処理は省略)

public static void copy(InputStream input, File file) throws IOException {
    File temp = File.createTempFile("temporary-", null);
    OutputStream output = new FileOutputStream(temp);

    // 一時ファイルにコピー後、指定されたファイルにリネーム
    IOUtils.copy(input, output);
    temp.renameTo(file);

    output.close();
    temp.delete();
}

しかしこれは Linux では動くが、Windows では動かない。理由は上記にあるとおり、OutputStream が閉じられていない状態で renameTo を行なっているからだ。これをクロスプラットフォームにするには、下記のように FileOutputStream を閉じてから renameTo しなければならない。

public static void copy(InputStream input, File file) throws IOException {
    File temp = File.createTempFile("temporary-", null);
    FileOutputStream output = new FileOutputStream(temp);

    // 一時ファイルにコピー後、指定されたファイルにリネーム
    IOUtils.copy(input, output);
    output.close(); // <- renameTo 前に close する。

    temp.renameTo(file);

    temp.delete();
}

ただしこのコードをそのまま使ってはならない。あまりに例外処理がお粗末だし、ファイルに書き込んだら fsync をしなければならない ためだ。ちなみに Windows では fsync システムコールは無く、_commit() または FlushFileBuffers() という関数がその役割を担っている。


タイマーの性能

原因不明だが、手元の VMWarePlayer で Windows 2003 32bit で Java の移植テストを行った際、Java のタイマー性能が猛烈に悪いことが判明した。ゲストマシンだったのでホストでも同様の結果が出るのかは不明だが、とにかくタイマーの性能が悪い。通常のホスト OS で稼働させるより 10 倍以上悪く、10ms や 20ms 程度の時間を扱うことはとても無理だった。

下記にタイマーの誤差を記す。10ms ごとに経過時間(ms)を表示するコードを実行した結果。Ubuntu のほうは誤差がたかだか 2ms 程度だが、Windows では2回目の時点で 12ms の誤差が出ている。

OS 1回目 2回目 3回目 4回目 5回目 6回目 7回目 8回目 9回目 10回目
Ubuntu 11 22 31 41 51 66 72 81 91 101
Windows 16 32 32 47 63 63 79 94 94 101


テストに使ったコードは下記の通り。

public static void main(String[] args) throws IOException {
    final long startTime = System.currentTimeMillis();
    Timer timer = new Timer();
    timer.scheduleAtFixedRate(new TimerTask() {
        @Override
        public void run() {
            long currentTime = System.currentTimeMillis();
            System.out.println(currentTime - startTime);
        }
    }, 10, 10);
}

補助プログラムの Windows 対応

Java プログラムは WORA を謳ってるだけあって少ない変更で Windows 対応できるのだが、Java コードから外部のツールを呼び出している場合には当然それらのツールWindows 対応しなければならない。

また、単に外部ツールWindows 版のものに置き換えただけでは上手く行かない場合がある。例えば、同じツールであっても Linux 版と Windows 版で挙動が異なる場合だ。社内のコードの移植中、ImageMagick という画像編集ツールを用いているのだが、このツールLinux 版も Windows 版もあるものの編集後の画像が OS によって微妙に異なるという結果が出た。このような挙動は厄介だ。対応方法は外部ツールによって大きく変わるだろうが、それなりに工数がかかると見積もっておいたほうが良い。


まとめ

Write once, debug everywhere.

EFFECTIVE JAVA 第2版 (The Java Series)

EFFECTIVE JAVA 第2版 (The Java Series)