gettextとバージョン管理システムの相性の悪さを解消する案 - 2013-11-14 - ククログ

ククログ

株式会社クリアコード > ククログ > gettextとバージョン管理システムの相性の悪さを解消する案

gettextとバージョン管理システムの相性の悪さを解消する案

Gettextという翻訳の仕組み1はフリーソフトウェアではよく使われています。いくつか不便な点はありますが、長年使われている仕組みでツールが揃っていることが理由でしょう。不便な点の1つである、「バージョン管理システムとの相性の悪さ」を解消する案が浮かんだので紹介します。

どうしてバージョン管理システムと相性が悪いか

gettextでは.poファイルに翻訳したテキストを書きます。翻訳したテキストは自動生成ではない情報なのでバージョン管理対象です。ということで、.poファイルはリポジトリーに入れます。

バージョン管理システムと相性が悪い原因は、.poファイルに翻訳したテキスト以外のいろいろな情報が入っていることです。中でも、「どこに翻訳対象のメッセージがあったか」は自動生成できる情報で、量も多く、通常であればバージョン管理しない情報です。しかし、バージョン管理したい.poファイルの中に含まれているので一緒にバージョン管理対象になってしまいます。これが相性が悪い点です。

ただ、「どこに翻訳対象のメッセージがあったか」は翻訳時には有用な情報なので単に.poファイルから消してしまうと翻訳時に不便です。翻訳時の利便を減らさずにこの相性の悪さを解決しようと試みるのがこの記事で説明する案です。

相性が悪い具体例

gettextとバージョン管理システムを一緒に使うと相性が悪いケースを具体的に確認しましょう。

まず、リポジトリーを用意します。

% mkdir -p /tmp/gettext-and-vcs
% cd /tmp/gettext-and-vcs
% git init

gettextを使ったプログラムを用意します。

hello.c:

#include <stdio.h>
#include <libintl.h>

int
main(void)
{
  puts(gettext("Hello"));
  return 0;
}

この中のgettext("Hello")の「Hello」がgettextで翻訳するメッセージです。

リポジトリーに追加します。

% git add hello.c
% git commit

次の作業の前に、gettextでどのように翻訳するかのざっくりとした流れを示します。

  1. 翻訳したい対象を用意する(今回の場合はhello.c)

  2. 翻訳したい対象から翻訳対象のメッセージを抽出する(今回の場合はHello)

  3. 抽出した翻訳対象のメッセージから.poファイルを作る

  4. .poファイルの中の翻訳対象のメッセージを翻訳する

  5. .poファイルから.moファイルを作る

  6. .moファイルにあるデータを使って翻訳対象のメッセージを翻訳する

今は1.の「翻訳したい対象を用意する」を用意した段階です。

それでは、2.の「翻訳したい対象から翻訳対象のメッセージを抽出する」をやってみましょう。xgettextというツールで抽出し、抽出したメッセージをpo/hello.potに出力します2

% mkdir -p po/
% (cd po && xgettext --package-name Hello --package-version 1.0.0 --output hello.pot ../hello.c)
% cat po/hello.pot
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Hello 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2013-11-14 22:27+0900\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"

#: ../hello.c:7
msgid "Hello"
msgstr ""

最後の部分が大事なところです。

#: ../hello.c:7
msgid "Hello"
msgstr ""

hello.cの7行目の「Hello」を翻訳対象のメッセージとして抽出しました。

次に、3.の「抽出した翻訳対象のメッセージから.poファイルを作る」をやりましょう。

% msginit --locale ja_JP.UTF-8 --input po/hello.pot --output-file po/ja.po
... (メールアドレスを入力) ...
% cat po/ja.po
# Japanese translations for Hello package
# Hello パッケージに対する英訳.
# Copyright (C) 2013 THE Hello'S COPYRIGHT HOLDER
# This file is distributed under the same license as the Hello package.
# Kouhei Sutou <kou@clear-code.com>, 2013.
#
msgid ""
msgstr ""
"Project-Id-Version: Hello 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2013-11-14 22:27+0900\n"
"PO-Revision-Date: 2013-11-14 22:28+0900\n"
"Last-Translator: Kouhei Sutou <kou@clear-code.com>\n"
"Language-Team: Japanese\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"

#: ../hello.c:7
msgid "Hello"
msgstr ""

翻訳対象のメッセージの前にはメタデータがいろいろ入っています。2.でできた.potファイルはバージョン管理しませんが、ここで作った.poファイルはバージョン管理対象です。

% echo '*.pot' >> .gitignore
% git add .gitignore
% git add po/ja.po
% git commit

いよいよ翻訳です。4.の「.poファイルの中の翻訳対象のメッセージを翻訳する」になります。.poファイルを編集するためのエディターは、翻訳対象のメッセージがどのように使われているかをすぐに確認できる機能がある3ので、それを使いながら翻訳します。このとき、翻訳対象のメッセージのすぐ上にある「../hello.c:7」という「どこに翻訳対象のメッセージがあったか」という情報を使います。本来であればバージョン管理対象としない情報ですが、翻訳時には便利なので.poファイルに入っている情報です。

% editor po/ja.po
% git diff
diff --git a/po/ja.po b/po/ja.po
index 0a90c72..49e9f76 100644
--- a/po/ja.po
+++ b/po/ja.po
@@ -9,7 +9,7 @@ msgstr ""
 "Project-Id-Version: Hello 1.0.0\n"
 "Report-Msgid-Bugs-To: \n"
 "POT-Creation-Date: 2013-11-14 22:27+0900\n"
-"PO-Revision-Date: 2013-11-14 22:28+0900\n"
+"PO-Revision-Date: 2013-11-14 22:34+0900\n"
 "Last-Translator: Kouhei Sutou <kou@clear-code.com>\n"
 "Language-Team: Japanese\n"
 "Language: ja\n"
@@ -20,4 +20,4 @@ msgstr ""

 #: ../hello.c:7
 msgid "Hello"
-msgstr ""
+msgstr "こんにちは"

この変更4はバージョン管理対象です。せっかくの翻訳を失いたくありません。

% git add po/ja.po
% git commit

ここからは翻訳したメッセージを使うための作業です。

まず、.moファイルを作ります。5.の「.poファイルから.moファイルを作る」という作業です。.moファイルは.poファイルをコンパイルした実行に都合のよいファイルだと理解すれば十分です。locale/ja/LC_MESSAGES/hello.moに置きます5

% mkdir -p locale/ja/LC_MESSAGES/
% msgfmt --output-file locale/ja/LC_MESSAGES/hello.mo po/ja.po

なお、.moファイルは.poファイルから自動生成できるためバージョン管理対象外です。

% echo /locale/ >> .gitignore
% git add .gitignore
% git commit

いよいよ翻訳したメッセージを使います。6.の「.moファイルにあるデータを使って翻訳対象のメッセージを翻訳する」です。

% cc -o hello hello.c
% LANG=ja_JP.UTF-8 ./hello
Hello

あれ、英語のままですね。実は、元のプログラムは必要な関数呼び出しが足りません。

% editor hello.c
% git diff
diff --git a/hello.c b/hello.c
index b210f06..9852314 100644
--- a/hello.c
+++ b/hello.c
@@ -1,9 +1,13 @@
 #include <stdio.h>
 #include <libintl.h>
+#include <locale.h>

 int
 main(void)
 {
+  setlocale(LC_ALL, "");
+  bindtextdomain("hello", "locale");
+  textdomain("hello");
   puts(gettext("Hello"));
   return 0;
 }

実行してみましょう。

% cc -o hello hello.c
% LANG=ja_JP.UTF-8 ./hello
こんにちは

翻訳できたのでコミットします。

% git add hello.c
% git commit

実行ファイル「hello」は自動生成のファイルなので無視しておきましょう。

% echo /hello >> .gitignore
% git add .gitignore
% git commit

それではもうひとつメッセージを追加しましょう。

% git diff
diff --git a/hello.c b/hello.c
index 9852314..862f1df 100644
--- a/hello.c
+++ b/hello.c
@@ -9,5 +9,6 @@ main(void)
   bindtextdomain("hello", "locale");
   textdomain("hello");
   puts(gettext("Hello"));
+  puts(gettext("World"));
   return 0;
 }
% cc -o hello hello.c
% LANG=ja_JP.UTF-8 ./hello
こんにちは
World

追加した方はまだ翻訳されていませんが、プログラムは動いているのでコミットしましょう。

% git add hello.c
% git commit

それでは翻訳しましょう。

まず、.poファイルを更新します。

% (cd po && xgettext --package-name Hello --package-version 1.0.0 --output hello.pot ../hello.c)
% msgmerge --update po/ja.po po/hello.pot
... 完了.
% git diff | cat
diff --git a/po/ja.po b/po/ja.po
index 49e9f76..af14c60 100644
--- a/po/ja.po
+++ b/po/ja.po
@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: Hello 1.0.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2013-11-14 22:27+0900\n"
+"POT-Creation-Date: 2013-11-14 23:10+0900\n"
 "PO-Revision-Date: 2013-11-14 22:34+0900\n"
 "Last-Translator: Kouhei Sutou <kou@clear-code.com>\n"
 "Language-Team: Japanese\n"
@@ -18,6 +18,10 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=1; plural=0;\n"

-#: ../hello.c:7
+#: ../hello.c:11
 msgid "Hello"
 msgstr "こんにちは"
+
+#: ../hello.c:12
+msgid "World"
+msgstr ""

はい、注目!「World」用のエントリーが追加されただけではなく、「Hello」の出現位置情報(「../hello.c:7」)も更新されています。今はメッセージ数が少ないのであまり気になりませんが、通常はメッセージ数は数十や数百などもっと多くなります。そうすると出現位置の変更が数十や数百以上になります。例えば、Groongaのドキュメントで.poファイルを更新すると変更業は2000行くらいになります。メッセージを変更するたびにこのくらいの変更をバージョン管理するのはどうなんだろうと思うようになります。

バージョン管理システムとの相性の悪さを解消する案

ようやく本題です。バージョン管理システムとの相性の悪さを解消する案を紹介します。

まず状況を整理します。

  • .poファイルには出現位置情報があった方が翻訳時には便利
    • 翻訳時に簡単に出現位置を確認して、どのように翻訳するのが適切かを確認できるため
  • 出現位置情報は自動生成できる情報なので本来であればバージョン管理する必要はない
    • .poファイルにバージョン管理すべき情報もあるため出現位置情報もバージョン管理しないといけない

この状況を解決する案を思いつくヒントになったのがgrosser/gettext_i18n_rails#103でのやりとりです。このやりとりで.poファイルに出現位置情報を含めないという使い方をしている人がいることを知りました。

このヒントから、バージョン管理する.poファイルには出現位置情報を含めず、バージョン管理しない作業用の.poファイルを導入する案を思いつきました。作業用の.poファイルには出現位置情報を含めます。そのため、この.poファイルを使えば翻訳時の利便は失われません。作業用の.poファイルの名前をja.edit.poとすると以下のような使い分けです。

  • po/ja.po ← バージョン管理する。出現位置情報なし。
  • po/ja.edit.po ← バージョン管理しない。出現位置情報あり。

これを実現するためには以下のような作業の流れにします。

% msgmerge --output po/ja.edit.po po/ja.po po/hello.pot
... 完了.
% editor po/ja.edit.po # ← 出現位置情報付きなので今まで通りの利便性で翻訳できる
% msgcat --no-location --output po/ja.po po/ja.edit.po
% git diff
diff --git a/po/ja.po b/po/ja.po
index 49e9f76..81b1127 100644
--- a/po/ja.po
+++ b/po/ja.po
@@ -8,8 +8,8 @@ msgid ""
 msgstr ""
 "Project-Id-Version: Hello 1.0.0\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2013-11-14 22:27+0900\n"
-"PO-Revision-Date: 2013-11-14 22:34+0900\n"
+"POT-Creation-Date: 2013-11-14 23:10+0900\n"
+"PO-Revision-Date: 2013-11-14 23:34+0900\n"
 "Last-Translator: Kouhei Sutou <kou@clear-code.com>\n"
 "Language-Team: Japanese\n"
 "Language: ja\n"
@@ -18,6 +18,8 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=1; plural=0;\n"

-#: ../hello.c:7
 msgid "Hello"
 msgstr "こんにちは"
+
+msgid "World"
+msgstr "世界"

出現位置情報がなくなったので大量の不必要な変更行をバージョン管理しなくてもよくなりました。作業用の.poファイルはバージョン管理対象外にしてコミットしましょう。

% echo '*.edit.po' >> .gitignore
% git add .gitignore
% git commit
% git add po/ja.po
% git commit

作業用の.poファイルとバージョン管理用の.poファイルを作る手間が増えるというデメリットはありますが、これは自動化できるので気にならないのではないかと予想しています。

ただ、翻訳中に「バージョン管理システムの機能を使ってdiffを見る」ことがしづらくなるデメリットが解消できるか微妙なところです。実際にやってみないとなんとも言えません。

まとめ

gettextとバージョン管理システムは以下のように相性が悪いことを具体例と共に示しました。

  • 翻訳時には.poファイルに翻訳対象メッセージの出現位置情報が入っていると便利
  • 本来は出現位置情報はバージョン管理する必要はない
  • バージョン管理対象の.poファイルに出現位置情報が含まれているため、出現位置情報もバージョン管理せざるを得ない
  • 出現位置情報もバージョン管理するとノイズが大きい

これを解決する以下の案を紹介しました。

  • バージョン管理しない作業用の.poファイルを導入する
  • 作業用の.poファイルには出現位置情報を入れるため翻訳時の利便性は維持
  • 作業用の.poファイルから出現位置情報を抜いた.poファイルを作り、それをバージョン管理対象とする

この案により、gettextとバージョン管理システムの相性問題は解決しますが、もしかしたら、作業時に別の不便なことが発生するかもしれません。そのため、実際にこの案を試してみてよさそうかどうかを確認する必要があります。gettext gemにこの案の実践を支援する機能を入れたいところです。

なお、ここで作成したリポジトリーはGitHubにあります。コミットメッセージの書き方意図が伝わるコミットのしかたに興味のある人はのぞいてみてください。ライセンスはCC0(パブリックドメイン)です。

  1. gettextという仕組みの1つの実装がGNU gettextです。この記事では「gettext」を実装ではなく仕組みのことを指すために使います。

  2. cdしてからxgettextを実行しているのは、hello.cへのパスをこれから作る.poファイルからの相対パスにするためです。こうすると翻訳時にツールの助けを得られます。

  3. Emacs用の.poファイル編集モードであるpo-modeは「s」(たぶん、sourceからの連想)でメッセージの使用箇所を表示します。

  4. 「PO-Revision-Date」の値はpo-modeが自動的に更新します。

  5. 置き場所のルールがありますが、この記事の本質とは関係ないため省略します。