i18nを正しく実装する方法

i18nは「国際化 (internationalization)」の略で、ソフトウェアアプリケーションを設計する際に、エンジニアリングの変更なしにさまざまな言語や地域に適応できるようにするプロセスを指します。

本記事では、i18nの基本を紹介し、その際に注意すべき一般的な課題について議論し、最後にGNU Artanisにおけるi18nの実装方法をお見せします。

# i18nの基本

i18nの基本的な考え方は、テキストをコードから分離することです。これを行うには、テキストをキーにマッピングする辞書を使用します。このキーをコード内で使用することで、コードを変更することなくテキストを簡単に翻訳に置き換えることができます。

最も簡単な例を見てみましょう。

例えば、ウェブサイトのタイトルが「Hello」で、これを複数の言語で利用可能にしたい場合を考えます。仮に次のような辞書を作成するとします。

```json
{
  "Hello": "こんにちは"
}
```

そして、コード内で「Hello」というキーを使用してテキストを参照します。テキストを表示したい場合、このキーを辞書で検索し翻訳を取得します。この作業が終わると、「i18nを行った」と言えます。

簡単ですよね？しかし、実際の世界ではそれほど簡単ではありません。考慮しなければならない多くの課題があります。

# 一般的な課題

異なる言語では、通貨記号、日付フォーマット、数字フォーマットなどが異なります。これらを考慮してi18nシステムを設計する必要があります。

単純にJSONベースの辞書に全てを保存するだけでは不十分で、以下の点も考慮する必要があります。

- 日付と時間のフォーマット
- 数字のフォーマット
- 通貨のフォーマット
- 複数形対応

# GNU Gettext

GNU Gettextはi18nにおける最も重要なライブラリです。これはGNUプロジェクトの一部であり、広く使用されているi18nライブラリで、一連のツールとAPIを提供してソフトウェアを国際化するのを支援します。上述した課題全てに対応し、それ以上の機能も備えています。

ここでは、GNU Gettextを自作の辞書よりも使用すべき理由を説明します。

## 事前コンパイルされた辞書

辞書を作成するには2つのステップがあります。

### POファイル

最初のステップは、POファイルを作成することです。POファイルは、元のテキストとその翻訳を含むテキストファイルです。以下のような形式になります。

```bash
msgid "Hello"
msgstr "こんにちは"
```

この内容をmydict.poという名前のファイルに保存します。

### MOファイル

次に、POファイルをMOファイルにコンパイルします。MOファイルは、翻訳を含むバイナリファイルです。msgfmtコマンドを使用してPOファイルをコンパイルできます。

```bash
msgfmt mydict.po -o i18n-test/locale/ja_JP/LC_MESSAGES/mydict.mo
```

このとき、パス`ja_JP/LC_MESSAGES`は推奨される形式です。`i18n-test/locale/`部分は自由に変更可能です。

### MOファイルの使用

次に、コード内でMOファイルを使用します。GNU Guileの例を以下に示します。

```scheme
(import (ice-9 i18n))
;; MOファイルのディレクトリを指定
(bindtextdomain "mydict" "./i18n-test/locale")
;; MOファイルのドメインを指定
(textdomain "mydict")

;; ja_JP/LC_MESSAGESを検索対象に指定
(setlocale LC_MESSAGES "ja_JP.UTF-8")

(display (gettext "Hello"))
;; => こんにちは
```

# もう少し難しい例：複数形

もしあなたが中国、日本、韓国など狭義の東アジアで生まれたなら、複数形の扱いは簡単です。それらの言語では複数形という概念が存在しません。例えば、1つのリンゴは「リンゴ」であり、2つのリンゴも「リンゴ」です。

しかし、英語では少し複雑になります。たとえば、英語では「apple（リンゴ）」の複数形は「apples（リンゴたち）」になります。他の言語では、複数形が数字に応じて変化する場合があります。たとえば、ポーランド語では、「apple（リンゴ）」の複数形は、1つの場合は「jabłko」、2～4の場合は「jabłka」、5以上の場合は「jabłek」になります。

では、これを自分の辞書でどう処理しますか？その通り、もう一つ半端なGNU Gettextを作らなければならないでしょう。

## GNU Gettextを使って複数形を処理する方法

GNU Gettextは、複数形を扱うためのngettext()という関数を提供しています。この関数は3つの引数を取ります：単数形、複数形、そして数字です。そして、その数字に基づいて正しい形の単語を返します。

GNU Guileでの使用例は以下の通りです：

```scheme
(import (ice-9 i18n))

(format #t (ngettext "apple" "apples" 1) 1)
;; => apple

(format #t (ngettext "apple" "apples" 2) 2)
;; => apples
```

次にポーランド語を試してみましょう。以下はPOファイルの例です：

```bash
# これはPOファイルの標準ヘッダーです
msgid ""
msgstr ""
"Project-Id-Version: My Dict 1.0\n"
"Report-Msgid-Bugs-To: example@example.com\n"
"POT-Creation-Date: 2025-01-04 12:00+0000\n"
"PO-Revision-Date: 2025-01-04 12:00+0000\n"
"Last-Translator: Your Name <your.email@example.com>\n"
"Language-Team: Polish <team@example.com>\n"
"Language: pl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2));\n"

# リンゴの例
msgid "You have %d apple."
msgid_plural "You have %d apples."
msgstr[0] "Masz %d jabko."
msgstr[1] "Masz %d jabka."
msgstr[2] "Masz %d jabek."
```

重要な部分はPlural-Formsです。これはGNU Gettextに複数形をどう扱うかを教えます。nplurals=3は3種類の複数形があることを示します。`plural=(n==1 ? 0 : (n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2))`はどの形を使うべきかのルールです。

POファイルをMOファイルにコンパイルし、コードで使うことができます：

```bash
msgfmt mydict.po -o i18n-test/locale/pl_PL/LC_MESSAGES/mydict.mo
```

GNU Guileでのテストは以下のようになります：

```scheme
(import (ice-9 i18n)
        (artanis irregex))

;; %dを~aに置き換えて数字を表示する
(define (fix-print str)
  (irregex-replace/all "$d" str "~a"))

;; MOファイルのディレクトリを指定
(bindtextdomain "mydict" "./i18n-test/locale")
;; MOファイルのドメインを指定（複数ある場合）
(textdomain "mydict")

;; ロケールをポーランド語に変更
(setlocale LC_MESSAGES "pl_PL.UTF-8")

;; 標準的なprintfのような`format`関数を使用
(format #t (gettext "You have %d apple." "You have %d apples." 0) 0)
;; => Masz 0 jabek

(format #t (gettext "You have %d apple." "You have %d apples." 1) 1)
;; => Masz 1 jabko

(format #t (gettext "You have %d apple." "You have %d apples." 2) 2)
;; => Masz 2 jabka
```

簡単ですよね？GNU Gettextは非常に強力で、i18nに関するすべての懸念を処理できます。GNUに感謝です！

# GNU Gettext の問題点

GNU Gettext は古いシステム向けに設計されており、ほとんどのオペレーティングシステムで包括的にメンテナンスされています。しかし、完璧というわけではありません。

## スレッドセーフではない

gettext や ngettext を呼び出す前に、(setlocale LC_MESSAGES "pl_PL.UTF-8") を呼び出す必要があります。これにより、現在のプロセスのグローバルロケールが上書きされます。そのため、複数のスレッドやスケジュールされたコルーチンが存在する場合は、gettext のコンテキスト全体をロックする必要があります。

```scheme
  (monitor
   (let ((old-locale ""))
     (dynamic-wind
         (lambda ()
           (let ((cur-locale (setlocale LC_MESSAGES "")))
             (set! old-locale cur-locale)
             (setlocale LC_MESSAGES lang)))
         (lambda ()
           (fix-num (gettext key (current-domain) LC_MESSAGES)))
         (lambda ()
           (setlocale LC_MESSAGES old-locale)))))
```

monitor は GNU Guile によって提供されており、コンテキストをロックするモダンな方法です（おそらく現代の OS 教科書で学んだことを覚えているかもしれません）。monitor ブロック内では、1つのスレッドのみがコンテキストにアクセスできることが保証されます。公式ドキュメントは [こちら](https://www.gnu.org/software/guile/manual/html_node/Mutexes-and-Condition-Variables.html) にあります。

もちろん、昔ながらの方法が好みであれば mutex を使用してコンテキストをロックすることもできます。

dynamic-wind は Scheme の標準関数で、他の言語での try-catch-finally のように機能します。これは、fix-num の前後で setlocale が確実に呼び出されることを保証するために使用されます。

待ってください。GNU Artanis のサーバーコアは、区切られた継続のコルーチンに基づいており、単一スレッドで非同期・非ブロッキングで動作しますよね。それなら、ロックする必要はないのでは？

その通りです。しかし、そうでもありません。

## 非同期セーフではない

コードが適切に整理されていない場合、gettext や ngettext を誤った位置で呼び出す可能性があります。また、I/O をブロックする状況が発生した場合、Ragnarok サーバーコアがそれをスケジュールして別のコルーチンに切り替えることがあり、そのコンテキストがロックされていないと、コンテキストが破損する可能性があります。

では、どうすればよいでしょうか？

gettext や ngettext を直接呼び出すのではなく、常に GNU Artanis の i18n API を使用してください。GNU Artanis の i18n API は非同期セーフで設計されており、monitor によってスレッドセーフでもあります。

でも、GNU Artanis にはスレッドがないのに、なぜコンテキストをロックする必要があるのですか？

もはやその必要はありません。GNU Artanis 1.1.0 では、スレッドで動作するマルチワーカーが実装されました。ただし、この機能はまだ本番環境では非常に実験的なものです…。

# GNU Artanis i18n API
GNU Artanis の i18n API は、非同期セーフかつスレッドセーフとして設計されています。この API は、ウェブアプリケーションを国際化するための一連の機能を提供します。

## 基本 API
GNU Artanis i18n API を使用する基本的なモードは以下の3つです。

```scheme
(define (handler rc)
  (let* ((_G (:i18n rc))
         (money (_G `(money 15000)))
         (smoney (_G `(moneysign 15000)))
         (num (_G `(number 15000 2)))
         (local-date (_G `(local-date ,*virtual-time*)))
         (global-date (_G `(global-date ,*virtual-time*)))
         (weekday (_G `(weekday ,(date-week-day *virtual-date*))))
         (month (_G `(month ,(date-month *virtual-date*)))))
    (:mime rc `(("money" . ,money)
                ("smoney" . ,smoney)
                ("num" . ,num)
                ("local-date" . ,local-date)
                ("global-date" . ,global-date)
                ("weekday" . ,weekday)
                ("month" . ,month)))))

;; 言語はURLで指定：/index/ja_JP
(get "/index/:lang"
  #:i18n "lang" #:mime 'json
  handler)

;; 言語はヘッダーで指定：Accept-Language: ja-JP
;; 言語形式はRFC 4646（例: ja-JP）に基づいており、
;; GNU Artanis がそれを GNU Gettext 形式（例: ja_JP）に変換します。
(get "/test/header"
  #:i18n 'header #:mime 'json
  handler)

;; 言語はクッキーで指定：Cookie: lang=ja_JP
(get "/test/cookie"
  #:i18n '(cookie "lang") #:mime 'json
  handler)
```

## 一般的なケース
一般的なケースには以下が含まれます：

- 日付と時刻の形式
- 数値の形式
- 通貨の形式

これらは GNU Gettext によって処理されます。幸いにも、この部分は GNU Guile の gettext API を使用することでグローバル環境に影響を与えません。これは GNU Guile のメンテナンスのおかげです！

**注意：一般的なケースは常に GNU Gettext によって処理されます。独自の辞書モードを選んでも、この部分は変わりません。**

**注意：関連するロケールをサーバーにインストールする必要があります。**

## 独自の辞書を使う場合
辞書を定義する方法は以下の2つで、conf/artanis.conf で選択できます。

```python
session.i18n = locale
# または
session.i18n = json
```

### ロケールモード
GNU Gettext に基づいており、MOファイルを sys/i18n/locale ディレクトリに配置する必要があります。MOファイルは lang_COUNTRY/LC_MESSAGES/domain.mo という名前で保存します。

- メリット
  - 機能が豊富な i18n
  - 既存の PO/MO ファイルを再利用可能
- デメリット
  - ロックコストが発生
    - スレッドセーフでない
    - 非同期セーフでない
  - mmap を使用して MO ファイルを読み込む
    - パフォーマンスは OS の状態に依存
  - メンテナンスが難しい
    - PO ファイルを MO ファイルにコンパイルする必要がある
  - サーバーにロケールをインストールする必要がある

### JSONモード
独自の辞書に基づいており、JSONファイルを sys/i18n/json ディレクトリに配置する必要があります。JSONファイルは lang_COUNTRY.json という名前で保存します。

- メリット
  - メンテナンスが簡単
    - JSONファイルを編集するだけでOK
  - スレッドセーフかつ非同期セーフ
  - 高いパフォーマンス
    - 初期化後、JSONファイルは常にメモリにキャッシュされる
- デメリット
  - 複数形の対応がない
    - 自分で処理する必要がある

# 結論
GNU Artanis における i18n 機能はすでに実装され、十分にテストされています。そして、次期リリースの GNU Artanis 1.1.0 に含まれる予定です。

フィードバックをお待ちしております。また、皆様からの貢献を楽しみにしています。

フィードバックやご提案は、artanis@gnu.org までメールでお送りいただくか、[GitLab](https://gitlab.com/hardenedlinux/artanis) にて Issue や MR を作成してください。

よろしくお願いします！😊