メッセージ定数クラスを多言語環境に対応させたいでござるの巻

今日はJavaのお話です。

問題コーナー

  1. システム出力用の固定メッセージを、定数クラスに一元的にハードコーディングしている既存システムがある
  2. この定数クラスには、現状、日本語の固定メッセージしか記述されていない
  3. システムバージョンアップの一環として、英語メッセージの出力(つまり多言語化)に対応する必要が生じた
  4. ただし、定数クラスを利用する側(呼び出す側)の既存プログラムは、極力変更したくない

ナイスな例示

現状は、システムで出力されるメッセージの文言が定数クラス Message に集約されています。

Message.java
package com.tercel_s.Util;

/**
 * システムで使用されるメッセージを一元的に定義してある定数クラスです
 * @author Tercel
 */
public final class Message {
    public static final String MSG001 = "こんにちは世界";
    public static final String MSG002 = "致命的なエラーが発生しました";
    public static final String MSG003 = "ファイルが存在しません";

    // ……こういう日本語のメッセージが延々と定義されている
}

また、メッセージを出力するときには、以下のようにメッセージクラスのフィールドに直接アクセスしています。

Main.java
package com.tercel_s.Api;
import com.tercel_s.Util.Message;

public class Main {
    public static void main(String[] args) {
        // 現状、定数クラスに定義されているフィールドに直アクセスする形で
        // 固定メッセージを取得・表示している

        System.out.println(Message.MSG001);
        // => こんにちは世界

        System.out.println(Message.MSG002);
        // => 致命的なエラーが発生しました

        System.out.println(Message.MSG003);
        // => ファイルが存在しません
    }
}

定数クラス Message は、システム内のあらゆるクラスから利用されうる共通部品(のつもり)です。

言い換えれば、どれだけのクラスが Message クラスにアクセスしているか、その影響範囲は未知数ということです。

つまらないバグを防ぐためにも、Message クラス以外の既存ソースを一切修正せずに、環境に応じて適切なメッセージが出力されるようにしたいというのが今回のテーマです。

        System.out.println(Message.MSG001);
        // => こんにちは世界               ※ 日本語環境の場合
        // => Hello, World!                ※ 英語環境の場合

果たして可能でしょうか。

今日は、この問題を解決するために用いた方法を3ステップでご紹介します。

ぼくはこう考えた

Step1. 定数クラスから、列挙型へ

まずは既存コードのリファクタリングです。定数クラス Message を、以下のような列挙型 (enum) に書き換えます。

Message.java
package com.tercel_s.Util;

/**
 * システムで使用されるメッセージを一元的に定義してある列挙型です
 * @author Tercel
 */

public enum Message {

    MSG001 {
        @Override public String toString() {
            return "こんにちは世界";
        }
    },
    MSG002 {
        @Override public String toString() {
            return "致命的なエラーが発生しました";
        }
    },
    MSG003 {
        @Override public String toString() {
            return "ファイルが存在しません";
        }
    }

    // ……まずはこういうふうに書き換える
}

Main.java 側は何も変更する必要はありません。

なんということでしょう。 (←サザエさんの声で

今まで、フィールドの定数値をそのまま返却する融通の利かない作りだったプログラムが、匠のリフォームにより、返却する値を toString() メソッドで制御できるようになりました

Step 2. 列挙型を多言語に対応させる

続いて、一つの列挙子に対して言語別に用意した複数のメッセージを紐づけてみます。

新版 toString() の仕掛けは以下の通りです。

  1. java.util.Locale にて定義されている言語コードと、当該言語用のメッセージ定数の対応を HashMap に格納する
  2. そのHashMapから、現在のロケール言語コードに対応するメッセージを取得して返却する
Message.java
package com.tercel_s.Util;
import java.util.HashMap;
import java.util.Locale;

/**
 * システムで使用されるメッセージを一元的に定義してある列挙体(二か国語対応版)です
 * @author Tercel
 */
public enum Message {
    MSG001 {
        @Override public String toString() {
            return new HashMap<String, String>() {{
                // ここに、日本語環境用・英語環境用のメッセージを記述します
                put(Locale.JAPANESE.getLanguage(), "こんにちは世界");
                put(Locale.ENGLISH.getLanguage(),  "Hello, World!");
            }}.get(Locale.getDefault().getLanguage());
        }
    },
    MSG002 {
        @Override public String toString() { 
            return new HashMap<String, String>() {{
                // ここに、日本語環境用・英語環境用のメッセージを定義します
                put(Locale.JAPANESE.getLanguage(), "致命的なエラーが発生しました");
                put(Locale.ENGLISH.getLanguage(),  "Fatal error occured!");
            }}.get(Locale.getDefault().getLanguage());
        }
    },
    MSG003 {
        @Override public String toString() { 
            return new HashMap<String, String>() {{
                // ここに、日本語環境用・英語環境用のメッセージを定義します
                put(Locale.JAPANESE.getLanguage(), "ファイルが存在しません");
                put(Locale.ENGLISH.getLanguage(),  "File not found!");
            }}.get(Locale.getDefault().getLanguage());
        }
    };
}

こうすると、デフォルトロケールの内容に応じて出力されるメッセージが自動的に切り替わるようになります。

Main.java
package com.tercel_s.Api;
import java.util.Locale;
import com.tercel_s.Util.Message;

public class Main {

    public static void main(String[] args) {
        Locale defaultLocale = Locale.getDefault();
        
        // デフォルトロケールを日本に設定
        Locale.setDefault(Locale.JAPAN);
        
        System.out.println(Message.MSG001);
        // => こんにちは世界

        System.out.println(Message.MSG002);
        // => 致命的なエラーが発生しました

        System.out.println(Message.MSG003);
        // => ファイルが存在しません

        
        // デフォルトロケールをアメリカに設定
        Locale.setDefault(Locale.US);
        
        System.out.println(Message.MSG001);
        // => Hello, World!

        System.out.println(Message.MSG002);
        // => Fatal error occurred!

        System.out.println(Message.MSG003);
        // => File not found!
        
        
        // デフォルトロケールをもとに戻す
        Locale.setDefault(defaultLocale);
    }
}
実行結果
こんにちは世界
致命的なエラーが発生しました
ファイルが存在しません
Hello, World!
Fatal error occured!
File not found!

これで一応、当初の目的は達成できました。

ここからはさらに一歩進んで、Javaでサポートされているローカライズの仕組みを使ってみようと思います。

Step 3. リソースバンドルでプログラムの見通しを改善する

最後の仕上げに、メッセージ定義をロケール別にプロパティ化してみましょう。

Step 2. では全言語用のメッセージが一つのソースコード内にまとめて書かれているので、メンテナビリティは良好とは言えませんでした。

たとえば英語版のメッセージだけをメンテナンスしたいときを考えてみましょう。英語版のメッセージ部分だけを見たいにも関わらず、一つのソースファイルに複数ロケール向けのデータがすべて集約されていたら、非常に見通しが悪くなってしまいます。

また、本来は定数データを保持する役割だったはずの Message.java ですが、ソースが必要以上に肥大化してしまっていますし、同じようなコードが繰り返し出現していることも気になります。

上記の問題を踏まえ、以下のように修正してみましょう。

  1. メッセージ定義をJavaのソースではなく、ロケール別のプロパティファイルに書く
  2. Java側は、現在のロケールに対する適切なプロパティファイルからメッセージを拾うようにする

なお、ここで新たに登場したプロパティですが、いくつか守らねばならないルールがあります。くわしいことは「ResourceBundle (Java Platform SE 8)」を参照してください。

  • プロパティのファイル名は「[基底名]_[言語コード].properties」または「[基底名]_[言語コード]_[国名コード].properties」などの形式に従います(あくまで一例)
    • 例1) HOGEHOGE_ja_JP.properties
    • 例2) HOGEHOGE_en.properties
  • プロパティファイルには、1行ずつ「[キー]:[値]」または「[キー]=[値]」形式に従って書きます
    • 例1) MSG001:Hello, World!
  • ただし、行の最後が「\」で終わる場合は、次の行も継続行とみなされます
  • プロパティファイル内にASCIIコード文字のみが使用できます
    • 日本語などを使用したい場合はUnicode参照「\u…」で書きます
      • 例) MSG001:\u3053\u3093\u306B\u3061\u306F\u4E16\u754C

それではお待ちかね、本日最終版のソースコードです。

MessageList_ja_JP.properties
MSG001:\u3053\u3093\u306B\u3061\u306F\u4E16\u754C
MSG002:\u81F4\u547D\u7684\u306A\u30A8\u30E9\u30FC\u304C\u767A\u751F\u3057\u307E\u3057\u305F
MSG003:\u30D5\u30A1\u30A4\u30EB\u304C\u5B58\u5728\u3057\u307E\u305B\u3093
MessageList_en_US.properties
MSG001:Hello, World!
MSG002:Fatal error occured!
MSG003:File not found!
Message.java
package com.tercel_s.Util;

import java.util.Locale;
import java.util.ResourceBundle;

/**
 * システムで使用されるメッセージを一元的に定義してある列挙体(二か国語対応版)です
 * @author Tercel
 */

public enum Message {
    MSG001,
    MSG002,
    MSG003;
    
    @Override public String toString() {
        try {
            return ResourceBundle.getBundle("com.tercel_s.Util.MessageList", Locale.getDefault()).getString(name());
        }catch(Exception ex) {
            ex.printStackTrace();
            return null;
        }
    }
}
Main.java
package com.tercel_s.Api;
import java.util.Locale;

import com.tercel_s.Util.Message;

public class Main {

    public static void main(String[] args) {
        Locale defaultLocale = Locale.getDefault();
        
        // デフォルトロケールを日本語に設定
        Locale.setDefault(Locale.JAPAN);
        
        System.out.println(Message.MSG001);
        // => こんにちは世界

        System.out.println(Message.MSG002);
        // => 致命的なエラーが発生しました

        System.out.println(Message.MSG003);
        // => ファイルが存在しません

        
        // デフォルトロケールを英語に設定
        Locale.setDefault(Locale.US);
        
        System.out.println(Message.MSG001);
        // => Hello, World!

        System.out.println(Message.MSG002);
        // => Fatal error occurred!

        System.out.println(Message.MSG003);
        // => File not found!
        
        
        // デフォルトロケールをもとに戻す
        Locale.setDefault(defaultLocale);
    }
}
実行結果
こんにちは世界
致命的なエラーが発生しました
ファイルが存在しません
Hello, World!
Fatal error occured!
File not found!

わーい!できた!

まとめ

  • 環境依存の値を定数に決め打ちしてはいけない(後ですごく困るよ)
  • レガシーな定数クラスは enum に置き換えてみると柔軟性が増すことがある
  • ロケール依存のメッセージはプロパティファイルに書こうそうしよう

というわけで、長々とお読みいただきありがとうございました。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.