国際化プログラミング(書籍「パーフェクトJava」:「国際化」の章の原稿の後半)
書籍「パーフェクトJava」に掲載予定だった「国際化」の章の原稿の後半です。「パーフェクトJava」にはページ数の関係で掲載しませんでした。
リンク
- 文字コードの詳細は割愛しているので掲載予定だった「国際化」の章の前半を参照してください: http://dev.ariel-networks.com/column/tech/i18n/view
- 書籍「パーフェクトJava」: http://www.amazon.co.jp/dp/4774139904/
本文
■■■■18章 国際化
[勉強用のソースコードでは、日本語を直接コードに書くことも悪くありません。しかし、実務のコードでは国際化を意識する必要があります。この章ではソフトウェアの国際化一般の話とJavaプログラミングにおける国際化の説明をします。]
■■■18-1 国際化と地域化
ソフトウェアの国際化の基本的な発想は、言語や国に依存する部分を分離してソースコードの外に追い出すことです。出力メッセージを例にすると、出力メッセージ用の文字列をソースコードにハードコードせずに外部に出します。英語や日本語など言語ごとに出力メッセージファイルを用意します。こうして実行時に必要な言語のメッセージ文字列を読むようにします。
言語や国や文化のような地域依存性を総称して「ロケール(locale)」と呼びます。ソフトウェアの国際化とは、プログラムからロケール依存の情報を外部に追い出し、実行時にロケール依存の情報を読み出せるようにすることです。
Javaは内部コードとしてUTF-16を採用しています。UTF-16は16ビット長のコード化文字集合なので、コードポイントは16ビット長になります(UTF-16およびUnicodeの詳細は http://dev.ariel-networks.com/column/tech/i18n/view を参照してください)。
■■18-2-8 日本語の読み書き
ファイルの日本語の読み書きの方法を説明します。
ファイルの読み書きにストリームを使います(「ストリーム」の章を参照)。ストリームはバイト単位で処理するバイトストリームと文字単位で処理する文字ストリームの2つに分類できます。文字ストリーム(ReaderとWriter)にはエンコーディング規則を指定できます(注8)。次のようにコンストラクタの引数で指定します。引数でエンコーディング規則を指定しない場合、システムのデフォルトエンコーディング規則が適用されます。
// InputStreamReaderのコンストラクタの抜粋 public InputStreamReader(InputStream in, String charsetName) throws UnsupportedEncodingException; public InputStreamReader(InputStream in, Charset cs) public InputStreamReader(InputStream in, CharsetDecoder dec)
入力ファイルをUTF-8のエンコーディング規則で読み、Shift-JISで出力ファイルに書き出す例を示します。文字コード変換しながらファイルコピーをします。
▼リスト2.1 FileCharI18n.java
import java.io.*; public class FileCharI18n { public static void main(String[] args) { Reader in = null; Writer out = null; try { if (args.length != 2) { System.out.println("usage: FileCharI18n input-filename output-filename"); System.exit(0); } in = new InputStreamReader(new FileInputStream(args[0]), "UTF-8"); out = new OutputStreamWriter(new FileOutputStream(args[1]), "Shift_JIS"); char[] buf = new char[1024]; int len; while ((len = in.read(buf)) != -1) { out.write(buf, 0, len); } } catch (IOException e) { e.printStackTrace(); } finally { try { if (in != null) { in.close(); } if (out != null) { out.close(); } } catch (IOException e) {} } } }
システムのデフォルトエンコーディング規則とサポートしているエンコーディング規則は次のコードで確認可能です。
▼リスト2.2 CharsetList.java
import java.nio.charset.Charset; import java.util.*; class CharsetList { public static void main(String[] args) { Charset default_charset = Charset.defaultCharset(); System.out.println(default_charset); Map<String,Charset> charsets = Charset.availableCharsets(); for (String cs : charsets.keySet()) { System.out.println(cs); } } }
Javaプログラムで文字コードを扱う時の指針を示します。
Javaプログラムで文字コードを扱う時の指針
- Javaの内部では、文字は常にchar型、文字列はStringやStringBuilderで扱う。つまり内部文字コードは常にUnicode(UTF-16)とする
- 外部からUnicode以外の文字列を受け取った場合、入力直後に内部文字コードに変換する
- 外部にUnicode以外で文字列を出力する場合、外部に出す直前に内部文字コードから変換する
- 文字コード変換をする層を外部との入出力層の近くに置き、文字コード変換をコードに分散させない
- 外部文字コードのバイト列のまま文字列処理を行うことは可能な限り避ける
Stringオブジェクト(内部文字コードの文字列)から、非Unicodeの外部文字コードに変換するには、次のgetBytesメソッドを使います。
// StringのgetBytesメソッドの定義 byte[] getBytes(String charsetName) throws UnsupportedEncodingException byte[] getBytes(Charset charset)
非Unicodeの外部文字コードから、Stringオブジェクトに変換するには、次のコンストラクタを使います。
// Stringのコンストラクタの抜粋 String(byte bytes[], String charsetName) throws UnsupportedEncodingException String(byte bytes[], int offset, int length, String charsetName) throws UnsupportedEncodingException String(byte bytes[], int offset, int length, Charset charset) // Java6 String(byte bytes[], Charset charset) // Java6
■■18-2-9 日本語固有の話
歴史的な事情で日本語には様々なエンコーディング規則が存在します。Unicode以外の、日本語に関連する主なエンコーディング規則を表にまとめます。
▼表2.2 日本語に関連するエンコーディング規則
Javaでのエンコーディング規則名 説明 EUC-JP 主にUnixで使われるエンコーディング ISO-2022-JP JISコードとして知られる、インターネットで使われるエンコーディング windows-31j コードページ932として知られる、主にMicrosoft Windowsで使われるエンコーディング Shift_JIS コードページ932のベース規格 x-JISAutoDetect EUC-JP、Shift_JIS、ISO-2022-JPのエンコーディング規則を自動判定
Javaで日本語を扱う時の注意点を書きます。
Javaで日本語を扱う注意点
- x-JISAutoDetectによる自動判定の精度は100%ではない(注9)
- 異なる文字集合をエンコーディングしている場合、エンコーディング規則の変換により情報が失われることがある
- 外部コードをUnicode(内部文字コード)に変換してその後外部コードに逆変換すると、違う文字に変換される場合がある(注10)
■■■18-3 メッセージの国際化と地域化
プログラムが出力する文字列(メッセージ)の国際化のためにリソースバンドルと呼ぶ仕組みを使います。リソースバンドルの仕組みは、文字列(メッセージ)ごとにキーを振り、ロケールごとにキーと文字列の対応表を用意します。コード中では文字列を直接記述する代わりにキーから文字列を検索します。検索はロケールに応じた文字列を返すので、コードを変更することなく、複数の言語のメッセージを出力することが可能です。
リソースバンドルを使う典型的な手順は次のようになります。
リソースバンドルを使う手順
- ロケールに応じたResourceBundleオブジェクトを取得
- ResourceBundleオブジェクトのgetStringメソッドにキーを渡して、ロケールに応じた文字列を取得
リソースバンドルを使う具体例を示します。"Foo.One"がメッセージキーです。
▼リスト3.1 リソースバンドルの使用例
import java.util.*; class My { public static void main(String[] args) { ResourceBundle resourceBundle = ResourceBundle.getBundle("appname"); //Java6以降 ResourceBundle resourceBundle = ResourceBundle.getBundle("appname", ResourceBundle.Control.getControl(ResourceBundle.Control.FORMAT_PROPERTIES)); System.out.println(resourceBundle.getString("Foo.One")); } } // 英語用プロパティファイルの例(appname_en.properties) Foo.One=one Foo.Two=two Foo.Three=three // 日本語用プロパティファイルの例(appname_ja.properties) Foo.One=いち Foo.Two=に Foo.Three=さん
日本語用プロパティファイルはnative2asciiコマンドでUnicodeエスケープシーケンスに変換する必要があります。日本語メッセージをそのまま記述したファイルのファイル名をappname_ja.properties.inとすると、次のように実行してappname_ja.propertiesファイルを生成できます。
$ native2ascii appname_ja.properties.in > appname_ja.properties
実行すると環境変数に応じて異なるメッセージを出力します。実行例を示します。
$ LANG=en_US java My one $ LANG=ja_JP java My いち
ResourceBundleオブジェクトを取得するには、ResourceBundleクラスのクラスメソッドであるgetBundleメソッドを呼びます。ResourceBundleクラスは抽象クラスで、getBundleメソッドはResourceBundleを継承した具象クラスのオブジェクトを返すファクトリメソッドです。ResourceBundleクラスの継承クラスは、PropertyResourceBundleクラスとListResourceBundleクラスのふたつが標準ライブラリに存在します。一般的にはメッセージを外部ファイルに持つPropertyResourceBundleクラスを使います。getBundleメソッドが具象クラスの存在を隠蔽するので、通常は気にする必要はありません。
getBundleメソッドの第1引数にはリソースバンドルのベース名をあたえます。第2引数を省略するとデフォルトロケールを使います(注12)。PropertyResourceBundleクラスはプロパティファイルから翻訳メッセージを取得します。プロパティファイルのファイル名は、「ベース名_ロケール名.properties」です。プロパティファイルの中は key=value のようにキーと翻訳メッセージを=で結びます。キーを識別しやすくするために、Foo.Oneのように.(ドット)で名前を区切って階層管理する慣習があります(ドット文字は必須ではありません)。
プロパティファイルはロケール名から決まるファイル名の順序で検索します。たとえば、ベース名appnameのリソースバンドルを環境変数LANG=ja_JPで実行すると次の順序でプロパティファイルを検索します。
プロパティファイルの検索順序 1. appname_ja_JP.properties 2. appname_ja.properties 3. appname.properties
getBundleメソッドの第2引数で明示的にロケールを指定できます。サーバなど複数ユーザを扱う場合、ユーザに使用言語を設定させて、言語ごとにResourceBundleオブジェクトを生成してください。
▼リスト3.2 ロケールを明示したリソースバンドルの使用例
import java.util.*; class My { public static void main(String[] args) { ResourceBundle resourceBundle = ResourceBundle.getBundle("appname", Locale.JAPAN); System.out.println(resourceBundle.getString("Foo.One")); } }
■■18-3-1 書式化処理(フォーマット処理)
日本語と英語では語順が異なることがあります。この場合、java.util.MessageFormatクラスを使います。具体例を示します。
▼リスト3.3 語順の異なる翻訳メッセージの例
import java.util.*; import java.text.MessageFormat; class My { public static void main(String[] args) { ResourceBundle resourceBundle = ResourceBundle.getBundle("appname"); String msg = resourceBundle.getString("Foo.Born"); MessageFormat format = new MessageFormat(msg); msg = format.format(new String[]{ resourceBundle.getString("Foo.Tokyo"), resourceBundle.getString("Foo.Japan")}); System.out.println(msg); } } // appname_en.properties Foo.Japan=Japan Foo.Tokyo=Tokyo Foo.Born=He was born in {0}, {1} // appname_ja.properties.in Foo.Japan=日本 Foo.Tokyo=東京 Foo.Born=彼は{1}の{0}で生まれた
実行例 $ native2ascii appname_ja_JP.properties.in > appname_ja_JP.properties $ javac My.java $ LANG=en_US java My He was born in Tokyo, Japan $ LANG=ja_JP java My 彼は日本の東京で生まれた
翻訳メッセージの中の{数字}の部分が、MessageFormatのformatメソッドの引数に渡す文字列の配列で置き換わります。上記例では文字列の配列も翻訳メッセージにしていますが、これは必須ではありません。
MessageFormatクラスのような処理を総称して書式化処理と呼びます。書式化処理の中には国際化に関係のある処理とそうでない処理があります。
国際化に関係する主な書式化処理
- 数字
- 日付
- 名前(姓と名の順序。ミドルネーム対応)
- 住所(表記順序)
- 通貨
- 各種単位(長さ、重さなど)
他の書式化処理としてNumberFormatクラスの具体例を示します。
▼リスト3.4 NumberFormatの書式化処理
import java.text.*; class My { public static void main(String[] args) { NumberFormat nf = NumberFormat.getInstance(); System.out.println(nf.format(1000000.33)); NumberFormat cf = NumberFormat.getCurrencyInstance(); System.out.println(cf.format(1000000.33)); } }
実行例 $ LANG=ja_JP java My 1,000,000.33 ¥1,000,000 $ LANG=en_US java My 1,000,000.33 $1,000,000.33 $ LANG=de_DE java My 1.000.000,33 1.000.000,33 (ドイツマルクの記号)
MessageFormatは{}の中にサブフォーマットタイプを指定できます。サブフォーマットとして日付の書式化処理を組み合わせた例を示します。
▼リスト3.5 サブフォーマットを使う翻訳メッセージの例
import java.util.*; import java.text.*; class My { public static void main(String[] args) { ResourceBundle resourceBundle = ResourceBundle.getBundle("appname"); String msg = resourceBundle.getString("Foo.Born"); MessageFormat format = new MessageFormat(msg); msg = format.format(new Object[]{ resourceBundle.getString("Foo.Tokyo"), resourceBundle.getString("Foo.Japan"), new Date()}); System.out.println(msg); } } // appname_en.properties Foo.Japan=Japan Foo.Tokyo=Tokyo Foo.Born=He was born in {0}, {1} at {2,date} // appname_ja.properties.in Foo.Japan=日本 Foo.Tokyo=東京 Foo.Born=彼は{2,date}に{1}の{0}で生まれた
実行例 $ native2ascii appname_ja_JP.properties.in > appname_ja_JP.properties $ javac My.java $ LANG=en_US java My He was born in Tokyo, Japan at Apr 12, 2009 $ LANG=ja_JP java My 彼は2009/04/12に日本の東京で生まれた
指定可能なサブフォーマットタイプはMessageFormatクラスのAPIドキュメントを参照してください。
■■■ 18-4 日付と時間
日付と時間のプログラミングは国際化に関わらず扱うことが多い領域です。ここでは国際化を考慮しつつ、日付と時間を扱う一般的な説明を行います。以下、日付や時間や時刻をすべて総称する意味で「時間」の用語を使います。
Javaで時間を扱うには多くの方法が存在します。その中で一番基本となるのが、現在時刻を基準時からの経過ミリ秒で時間を表現する方法です。基準時はGMTの1970年1月1日の0時0分です。この基準時をepoch(エポック)と呼ぶことから、基準時からの経過時間をエポック値と呼びます。現在時刻のエポック値はSystemクラスのcurrentTimeMillisメソッドで取得できます(注13)。エポック値はGMTの基準時からの絶対的な経過時間なのでタイムゾーンによらず同じ値です。日本とアメリカで同じ瞬間にエポック値を取得すると同じ値が返ってきます。
時間を扱うプログラミングの原則のひとつは、時刻を保持する必要がある場合、可能な限りエポック値で持つことです。必要に応じてエポック値から他の形式に変換します。Javaのエポック値の型はlong型です。「クラス」の章で紹介した書物クラスのpublishedフィールドはエポック値で保持する方が適切です。
// 書物クラスの定義例(推奨) class Book { String title; int price; String author; long published; // 必要に応じてDate型に変換する }
エポック値で直接できることは経過時間の計算(単なる引き算)や大小比較程度です。現実のプログラミングでは次のようなことが必要になります。
時間を扱うプログラミングで必要な処理
- エポック値からローカル時刻への変換(年月日などの取得)
- 現在時刻以外(特定の時刻)のエポック値の取得
- 曜日判定など
- 日付や時間の表示用フォーマットへの変換
- 文字列から時間データへの変換
- 和暦(昭和や平成の年号)への変換
エポック値から年月日情報などを取得するにはjava.util.Dateクラスもしくはjava.util.Calendarクラスを使います。Calendarクラスの利用が推奨なので、本書ではCalendarクラスのみを説明します。次のコードは現在時刻のエポック値から年月日と時分を取得して表示します。
// エポック値から年月日を取得 import java.util.*; class My { public static void main(String[] args) { long epoch = System.currentTimeMillis(); Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(epoch); System.out.println(cal.get(Calendar.YEAR) + " " + (cal.get(Calendar.MONTH) + 1) + " " + cal.get(Calendar.DAY_OF_MONTH) + " " + cal.get(Calendar.HOUR_OF_DAY) + " " + cal.get(Calendar.MINUTE) + " " + cal.get(Calendar.SECOND)); } }
実行例 $ date; java My Sun Apr 12 17:07:28 JST 2009 2009 4 12 17 7 28
実行例では、Unixコマンドのdateコマンドと一緒に実行しています。同じJST(日本標準時)になっていることがわかります。環境変数TZによりGMTでの年月日、EST(アメリカ東部標準時)での時刻も得られます。
実行例 $ TZ=GMT date; TZ=GMT java My Sun Apr 12 08:13:16 GMT 2009 2009 4 12 8 13 16 $ TZ=EST date; TZ=EST java My Sun Apr 12 03:13:31 EST 2009 2009 4 12 3 13 31
それぞれ時差(タイムゾーン)に応じて表示時間が異なりますが、内部的には同じエポック値です。
Calendarクラスから年月日を得るコードの注意点は月(Calendar.MONTH)の値です。月の数値は0から始まります。つまり1月の値が0で12月の値が11です。信じられない設計かもしれませんが、C言語の標準ライブラリから引き続く伝統なので我慢してください(注14)。1月に1、12月に12が欲しい場合は、Calender.MONTHで得られる値に1を加算する必要があります。
CalendarクラスのgetInstanceメソッドはCalendarオブジェクトを返すファクトリメソッドです。上記コードではCalendarオブジェクトにsetTimeInMillisメソッドでエポック値をセットしています。setTimeメソッドを使うとDateオブジェクトをセット可能です。これによりDateオブジェクトからCalendarオブジェクトに変換できます。CalendarクラスのgetInstanceメソッドを引数なしで呼ぶと、デフォルトタイムゾーンのCalendarオブジェクトが返ります。引数で明示的にタイムゾーンを指定すると、指定タイムゾーンのCalendarオブジェクトが返ります。サーバアプリなどユーザごとに複数のタイムゾーンに対応する必要がある場合、タイムゾーンを明示的に指定してCalendarオブジェクトを生成してください。
年月日時分(ついでにミリ秒)からエポック値を取得するコード例を示します。デフォルトタイムゾーンのCalendarオブジェクトを使っているので、年月日時分はローカル時刻です。月の数値は人間の都合の数値(1月の場合、monthは1)を想定したメソッドです。
▼リスト4.1 年月日からエポック値を取得
import java.util.*; class My { public static long getEpoch(int year, int month, int day, int hour, int minute, int second, int milliSecond) { Calendar cal = Calendar.getInstance(); cal.set(year, month - 1, day, hour, minute, second); cal.set(Calendar.MILLISECOND, milliSecond); return cal.getTimeInMillis(); } public static void main(String[] args) { System.out.println(getEpoch(2009, 4, 12, 17, 30, 0, 0)); } }
Calendarクラスを使うと曜日判定も簡単にできます。コード例を示します。
▼リスト4.2 曜日判定
import java.util.*; class My { public static void main(String[] args) { long epoch = System.currentTimeMillis(); Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(epoch); switch (cal.get(Calendar.DAY_OF_WEEK)) { case Calendar.SATURDAY: case Calendar.SUNDAY: System.out.println("off"); break; default: System.out.println("on"); break; } } }
曜日判定以外にも、Calendarクラスのメソッドを使うことで、月の中の何日目や年の中の何日目かなども取得できます。
時間と表示用文字列の間の相互変換は書式化処理の1種です。いくつかの方法がありますが、ここではjava.text.DateFormatを紹介します。DateFormatクラスのgetDateInstanceメソッドおよびgetDateTimeInstanceメソッドはファクトリメソッドです。それぞれ引数で明示的にロケールを指定できます。下記の例では引数なしでデフォルトロケールを使います。
▼リスト4.3 時間から表示用文字列に変換
import java.util.*; import java.text.*; class My { public static void main(String[] args) { long epoch = System.currentTimeMillis(); Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(epoch); DateFormat dformat_full = DateFormat.getDateInstance(DateFormat.FULL); System.out.println(dformat_full.format(cal.getTime())); DateFormat dformat_short = DateFormat.getDateInstance(DateFormat.SHORT); System.out.println(dformat_short.format(cal.getTime())); DateFormat dtformat_full = DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.FULL); System.out.println(dtformat_full.format(cal.getTime())); DateFormat dtformat_short = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT); System.out.println(dtformat_short.format(cal.getTime())); } }
実行例 $ LANG=ja_JP java My 2009年4月12日 09/04/12 2009年4月12日 20時51分56秒 JST 09/04/12 20:51 $ LANG=en_US java My Sunday, April 12, 2009 4/12/09 Sunday, April 12, 2009 8:52:03 PM JST 4/12/09 8:52 PM
日付文字列から時間データに変換する例を示します。ただし、現実的にはロケール依存の日付文字列を受け入れるプログラムは稀で、あまり使う場面はありません。
▼リスト4.4 日付文字列から時間データへの変換
import java.util.*; import java.text.*; class My { public static void main(String[] args) { DateFormat dformat_short = DateFormat.getDateInstance(DateFormat.SHORT); try { Date dt = dformat_short.parse("4/12/09"); // 2009年4月12日のen_USロケール表記 long epoch = dt.getTime(); } catch (ParseException e) { e.printStackTrace(); } } }
和暦(昭和や平成の年号)への変換はJava6で導入されたja_JP_JPロケールを使います。内部的にはJapaneseImperialCalendarを使います。コード例を示します。
▼リスト4.5 和暦の例
import java.util.*; import java.text.*; class My { public static void main(String[] args) { Locale jp_locale = new Locale("ja", "JP", "JP"); long epoch = System.currentTimeMillis(); Calendar cal = Calendar.getInstance(jp_locale); cal.setTimeInMillis(epoch); System.out.println(cal.get(Calendar.YEAR)); DateFormat dformat_full = DateFormat.getDateInstance(DateFormat.FULL, jp_locale); System.out.println(dformat_full.format(cal.getTime())); } }
実行例 $ java My 21 平成21年4月12日
▼注9 判定のコードにバグがあるという意味ではなく、原理的に判定不能なパターンが存在します。
▼注10 Unicodeのラウンドトリップ問題として知られます。
▼注12 デフォルトロケールはLocale.getDefault()で得られます。
▼注13 「Javaプログラムの実行と制御構造」の章の「java.lang.System」を参照してください。
▼注14 月の数値が0ベースである歴史的な理由は、月の表示名("January"や"February"など)を文字列の配列に持たせて、月の数値をその配列のインデックス値に使う用途を想定していたからです。