情報畑でつかまえてロゴ
本サイトは NTTテクノクロスが旬の IT をキーワードに
IT 部門が今知っておきたい最新テクノロジーに関する情報をお届けするサイトです

Grailsでi18nメッセージを外部ファイル化する

今回は「ニッチだけど実用的なシリーズ」ということで、Grailsでi18nメッセージを外部ファイル化する方法を紹介します。i18nとは「internationalization」の略で、「国際化」のことです。

Grailsでi18nメッセージを外部ファイル化する

はじめに

こんにちは。NTTソフトウェアの中野です。今回は「ニッチだけど実用的なシリーズ」ということで、Grailsでi18nメッセージを外部ファイル化する方法を紹介します。

i18nとは

i18nとは「internationalization」の略で、「国際化」のことです。 ここでは「エンドユーザ向けのメッセージを言語(ロケール)ごとに用意しておき、エンドユーザごとに適切な言語が表示されるようにする」という機構のことを指すものとします。

Grailsは標準でi18nをサポートしています。grails-app/i18nディレクトリ配下に言語ごとのmessages_XX.propertiesファイルを用意しておけば、エンドユーザからの指定(Accept-Languageヘッダ等)に従って適切な言語のファイルからメッセージが解決されます。

GSPファイルやコントローラでは、messageタグライブラリなどを利用して、i18nメッセージを使うようにコーディングしておく必要があります。

// HelloController.groovy
def index() {
def user = ...
// Grailsではコントローラでもタグライブラリをメソッドのように使える。
// 以下は   と同じ。
render([hello: message(code: "hello", args: [user.name])] as JSON)
}
# messages.properties
hello=Hello, {0}!!
# messages_ja.properties
hello=こんにちは、{0}さん!!

なお、エンドユーザの指定した言語に対応するメッセージファイルがない場合は、デフォルトのmessages.propertiesが使われます。 通常は、国際共通語の英語(en)のメッセージをこのデフォルトのファイルで設定しておきます。

また、万が一、このデフォルトのメッセージファイル中にも探索対象のキー(messageタグライブラリのcode属性で指定する値)のエントリが存在しない場合は、キーそのもの(上の例だとhelloという文字列)が表示されます。

外部ファイル化する目的

Grailsアプリケーションは、通常grails warコマンドでWARファイルにパッケージングします。 メッセージファイル一式もこのWARファイルに含まれていて、実行時にはWARファイル内のメッセージが使われます。 実際これで十分であれば、何も問題ありません。

しかし、ときどきですが、運用上の都合により文言変更の要望があがる場合があります。 たとえば、「社内の用語が変わったので表示される文言も変えたい」とか「あそこのメッセージををもうちょっとわかりやすくしたい」とかです。

このような場合に、適切にi18n化をしてあるアプリケーションであれば、更にここで紹介するちょっとした一手間をかけておくだけで、「外部ファイルでメッセージを自由に差し替えられるようにしてあるので、運用でご自由にどうぞ」と回答できます。 開発チーム側での、修正・ビルド・再パッケージングなどの手間はまったく発生しません。

対応方法

早速、設定方法を見てみましょう。

なお、以下の場合には、Grails標準のメッセージ解決方法にフォールスルーするようにしておきます。

  • 追加指定した外部ファイル自体が存在しない場合
  • ファイル(バンドル)は存在するがその中に探索対象のキーが存在しない場合

Grails 2.x

2.x系の場合はとても簡単です。grails-app/conf/Config.groovyに以下のような設定を追加するだけでOKです。簡単ですね。

// i18nメッセージリソースの指定
//   リソースの順番について
//     メッセージキーの解決は先に見つかったキーが優先される。
//     そのため同じキーが複数のファイルに存在する場合は、先に読み込んだファイルのキーが優先される。
//   ファイルの指定方法について
//     指定方法は以下の3つが存在する。
//      - 無印
//          ServletContext#getResourceで解決されるようにWARの展開ディレクトリを起点に解決される。
//          先頭に/がついていてもついていなくても(例: /WEB-INF/xxx と WEB-INF/xxx)同じ動作をするが、正式には/を付けるのが正解。
//      - file:
//          ファイルパスに従い解決される。
//          WARの展開ディレクトを起点に相対パスで解決したい場合はfile:を使用せず無印でパスを指定すること。
//      - classpath:
//          クラスパスに従い解決される。
beans {
messageSource {
basenames = [
"file:///tmp/my",                      // ファイル名は自由につけて良い。拡張子(.properties)は省略して指定する。
"/WEB-INF/grails-app/i18n/my",         // my.properties, my_ja.properties などのように各言語ごとのファイルを配置できる
"classpath:grails-app/i18n/messages",  // 上記の追加ファイル内にエントリが見つからなかったキーは最終的に標準のmessagesファイルのエントリを採用する
]
defaultEncoding = "UTF-8"
}
}

Grails 3.x

3.x系の場合は、実装が少々変わっており、messageSourceビーンのbasenamesプロパティを直接変更できません。 正確に言えば、PluginAwareResourceBundleMessageSourceafterPropertiesSet()basenamesを自動的に再構築してしまうため、設定で変更しても無視されて上書きされてしまうのです。 このため、ビーンの初期化後のBootStrapなどのタイミングで、設定を反映する必要があります。

まず、設定ファイルであるgrails-app/conf/application.ymlに以下の設定を追加します。

# i18nメッセージリソースの指定
#   リソースの順番について
#     メッセージキーの解決は先に見つかったキーが優先される。
#     そのため同じキーが複数のファイルに存在する場合は、先に読み込んだファイルのキーが優先される。
#   ファイルの指定方法について
#     指定方法は以下の3つが存在する。
#      - 無印
#          ServletContext#getResourceで解決されるようにWARの展開ディレクトリを起点に解決される。
#          先頭に/がついていてもついていなくても(例: /WEB-INF/xxx と WEB-INF/xxx)同じ動作をするが、正式には/を付けるのが正解。
#      - file:
#          ファイルパスに従い解決される。
#          WARの展開ディレクトを起点に相対パスで解決したい場合はfile:を使用せず無印でパスを指定すること。
#      - classpath:
#          クラスパスに従い解決される。
i18n:
messageSource:
additionalBasenames:
- file:///tmp/my              # ファイル名は自由につけて良い。拡張子(.properties)は省略して指定する。
- /WEB-INF/grails-app/i18n/my # my.properties, my_ja.properties などのように各言語ごとのファイルを配置できる
defaultEncoding: UTF-8

それから、grails-app/init/BootStrap.groovyに以下のような設定変更処理を追加します。

import org.grails.spring.context.support.ReloadableResourceBundleMessageSource
import grails.util.Holders
class BootStrap {
def messageSource
def init = { servletContext ->
setupAdditionalMessageSources(messageSource)
}
def destroy = {
}
private setupAdditionalMessageSources(messageSource) {
// 追加ファイル内にエントリが見つからなかったキーは、最終的にGrailsが自動的にセットアップする標準方法で解決する。
// このためにprivateフィールドになっているオリジナル設定を強引ではあるが取得しておく。
List originalBasenames = ReloadableResourceBundleMessageSource.getDeclaredField("basenames").with { field ->
field.accessible = true
field.get(messageSource)
}
// 設定を変更する。
messageSource.basenames = Holders.config.i18n.messageSource.additionalBasenames + originalBasenames
messageSource.defaultEncoding = Holders.config.i18n.messageSource.defaultEncoding
}
}

確認してみる

早速、確認してみましょう。

grails run-appでアプリケーションを起動します(Grails 2.x系、3.x系どちらの場合でも同様です)。run-app では開発環境(development)としてアプリケーションが起動します。 開発環境では5秒ごとにi18nメッセージのキャッシュは破棄されて再度読み込まれるので、いちいち再起動しなくても、サクサクと変更の確認ができます。

アプリケーションが起動したら、別のコンソールを開きます。

前回に引き続き、httpieコマンドで確認してみましょう。
(Grails 3.x系の場合は、URLパスをlocalhost:8080/helloと読み替えてください。)

$ http localhost:8080/grails2x-external-i18n/hello Accept-Language:ja
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Thu, 05 Nov 2015 02:28:08 GMT
Server: Apache-Coyote/1.1
Transfer-Encoding: chunked
{
"hello": "こんにちは、i18nさん!!"
}

httpieであれば、このようにAccept-Languageヘッダで言語を指定してもよいのですが、Webブラウザで確認する場合などにいちいちブラウザの設定を変更するのは大変です。 Grailsの場合はlangパラメータでも言語を指定できます。

$ http "localhost:8080/grails2x-external-i18n/hello?lang=en"
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Thu, 05 Nov 2015 02:22:15 GMT
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=DF930CE77B543BD3ACC73EBBC01BC866; Path=/grails2x-external-i18n/; HttpOnly
Transfer-Encoding: chunked
{
"hello": "Hello, i18n!!"
}
$ http "localhost:8080/grails2x-external-i18n/hello?lang=ja"
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Thu, 05 Nov 2015 02:22:17 GMT
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=46566C34F8CCB33B81C4574411D71F9C; Path=/grails2x-external-i18n/; HttpOnly
Transfer-Encoding: chunked
{
"hello": "こんにちは、i18nさん!!"
}

さて、外部メッセージファイルを追加してみましょう。

/tmp/my.propertiesファイルと/tmp/my_ja.propertiesを作成して、それぞれ以下の内容にします。

# my.properties
hello=Hi, {0}!!
# my_ja.properties
hello=やあ、{0}!!

キャッシュが破棄されて自動リロードされるように、5秒ほどまってからアクセスしてみます。

$ http "localhost:8080/grails2x-external-i18n/hello?lang=en"
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Thu, 05 Nov 2015 02:37:27 GMT
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=7E752B2F64CA6B896E4C1C41A834F844; Path=/grails2x-external-i18n/; HttpOnly
Transfer-Encoding: chunked
{
"hello": "Hi, i18n!!"
}
$ http "localhost:8080/grails2x-external-i18n/hello?lang=ja"
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Date: Thu, 05 Nov 2015 02:37:29 GMT
Server: Apache-Coyote/1.1
Set-Cookie: JSESSIONID=163AE8BCE9950682475AFEBF717EF232; Path=/grails2x-external-i18n/; HttpOnly
Transfer-Encoding: chunked
{
"hello": "やあ、i18n!!"
}

期待通り、メッセージが変わっていることがわかりますね。

おわりに

今回のサンプルコードは、GitHubに公開してあります。

コメントや不具合等があればIssuesやPull Requestでお願いいたします。

連載シリーズ
中野靖治のGroovy活用術
著者プロフィール
中野 靖治
中野 靖治
JVM上で動作する動的型付け言語であるGroovyと、Groovyで記述するWebアプリケーションフレームワークのGrailsを社内外へ推進するために日々奮闘している。 Groovyスクリプトの起動時間を短縮するGroovyServや、GroovyスクリプトでのExcel操作を劇的に楽にするGExcelAPIなどのOSSを業務/プライベートで開発、 一般に公開。Groovy/Grails/GradleなどのOSSへのバグフィックスや機能パッチの提供などにも積極的に貢献している。 また、国内外(JJUG CCC、Java Day Tokyo, Gr8conf EUなど)での講演や書籍執筆などでも活動中。 著書に『プログラミングGroovy』(技術評論社/共著)がある。自他共に認めるビール党。