最近 Node.js な Web アプリケーションの多言語対応について調べる機会があったので、自分なりの考えをまとめておく。
結論としては、特にライブラリを使わず、自前の実装で片付けてしまうことにした。
誰でも思いつきそうな至極単純な発想なのだけれど、調べた限りこういう情報が出てこなかったので、記事にすることで誰かのお役に立てれば幸い。
既存ライブラリに対する不満: 静的型付を実現できない1
TypeScript ユーザーにとっては、以下のようなものがコンパイル時ではなく、 実行時のエラーになるのは耐え難い。
- message の key の typo
- message の引数の typo, 過不足, 型違い
- 異なる言語間で message の定義に一貫性がない
ピンと来ない人向けに、それなりに人気のありそうな i18next という package の記法を例として書いておく。
[参考] i18next の記法
辞書ファイル (JSON):
{
"en": {
"translation": {
"key1": "value of key1 in en",
"interpolation": "{{what}} is {{how}}",
"dateFormat": "The current date is {{date, MM/DD/YYYY}}",
"keyWithCount": "{{count}} item",
"keyWithCount_plural": "{{count}} items"
}
},
"ja": {
"translation": {
"key1": "日本語の鍵1の値",
"interpolation": "{{what}} は {{how}} です",
"dateFormat": "今日は {{date, YYYY/MM/DD}} です",
"keyWithCount": "{{count}} 個のアイテム"
}
}
}
※ dateFormat
のところは、 format 用の関数の実装を別途用意しなければならないが、割愛している (詳細は http://i18next.com/translate/formatting/ )。
利用方法:
i18next.t('key1'); // -> value of key1 in en
i18next.t('interpolation', { what: 'i18next', how: 'great' }); // -> i18next is great
i18next.t('dateFormat', { date: new Date() }); // -> The current date is 08/20/2016
i18next.t('keyWithCount', { count: 0 }); // -> 0 items
i18next.t('keyWithCount', { count: 1 }); // -> 1 item
i18next.t('keyWithCount', { count: 2 }); // -> 2 items
細かい説明は省くが、この使い方を見れば、上で挙げたようなものがコンパイルエラーにならないことはご理解いただけると思う。
辞書を JSON で定義するのが間違い、 TypeScript で書こう
arrow 関数と string template literal を使えば、 JSON と同等の可読性を持った辞書ファイルを TypeScript で記述できる。
export const messages = {
key1: 'value of key1 in en',
interpolation: (what: string, how: string) => `${what} is ${how}`,
dateFormat: (date: Date) => `The current date is ${formatDate(date, 'MM/DD/YYYY')}`,
keyWithCount: (count: number) => `${count} item${count === 1 ? '' : 's'}`
};
function formatDate(date: Date, format: string): string {
// format 実装
}
言語に応じた利用辞書の解決
ライブラリを使う場合、このあたりは簡単な方法が提供されているはずだが、ここを自前で実装する必要がある。
しかし、大した手間ではない。
辞書ファイルの配置
src
|
|- messages
|
|- index.ts // 後述
|
|- en.ts // 英語の辞書ファイル
|
|- ja.ts // 日本語の辞書ファイル
|
|- // その他の対応言語の辞書ファイル...
server side
src/messages/index.ts を以下のように書いておき、言語に応じた辞書を動的に解決できるようにしておく。
import * as path from 'path';
import * as glob from 'glob';
import { messages as en } from './en';
export const messages = en;
export namespace Server {
// src/messages 下に存在する (index.ts 以外の) ファイルが対応言語を表す
export const acceptableLanguages = glob.sync(`${__dirname}/*.js`)
.map((file) => path.basename(file, '.js'))
.filter((language) => language !== 'index');
// それぞれの言語を require, キャッシュしておく
const map = acceptableLanguages.reduce((acc, language) => {
acc[language] = require(`./${language}`).messages;
return acc;
}, {} as {[language: string]: typeof messages});
/**
* 指定された言語に対応する辞書を返す
*/
export function messagesOf(language: string): typeof messages {
return map[language];
}
}
すると、以下のようにして request の言語に対応する辞書を取得できる (Express の req.acceptsLanguages
を使った例)。
import * as express from 'express';
import { Server } from './messages';
const
app = express(),
DEFAULT_LANGUAGE = 'en';
app.get('/', (req: express.Request, res: express.Response) => {
const
language = (req.acceptsLanguages(Server.acceptableLanguages) || DEFAULT_LANGUAGE) as string,
messages = Server.messagesOf(language);
// ...
});
client side
webpack.NormalModuleReplacementPlugin
を利用して、 require('./path/to/messages')
がビルド時に対応言語ごとに require('./path/to/messages/{language}')
として扱われるようにする。
// webpack.config.js
const webpack = require('webpack');
const { Server } = require('./src/messages');
module.exports = Server.acceptableLanguages.map((language) => ({
entry: './src/client.js',
output: {
path: './build',
filename: `bundle_${language}.js` // 言語ごとにバンドルした JS を出力
},
plugins: [
new webpack.NormalModuleReplacementPlugin(/^\..*\/messages$/, (result) => result.request += `/${language}`)
]
}));
server side の template で、 request の言語に応じたバンドル済み JS をアサインする。
const language = (req.acceptsLanguages(Server.acceptableLanguages) || DEFAULT_LANGUAGE) as string;
// ...
<script type='text/javascript' charSet='utf-8' src={`bundle_${language}.js`}></script>
こうしておくと、 client side の TS からは、単に以下のように書くだけで必要な辞書を取得できる。
// 一応説明しておくと、下記は import { messages } from './path/to/messages/index'; と同じ意味に解釈されるので、
// コンパイル時は src/messages/index.ts に定義した messages の型に基づいた型チェックが行われる
import { messages } from './path/to/messages';
server side rendering
server side rendering を行う場合、 component のコードは server side でも client side でも動くことになるため、 component の中から直接辞書を取得してはならない (server side と client side で辞書の解決方法が異なるため)。
そこで、辞書を引数にして、最上位の component を返すような関数を定義する。
// React の場合
import * as React from 'react';
import { messages } from './messages';
export const createApp = (_messages: typeof messages) =>
class extends React.Component<{ name?: string; unread?: number; }, void> {
render(): JSX.Element {
return <div>
<h1>{_messages.title}</h1>
<p>{_messages.greeting(this.props.name)}</p>
<p>{_messages.unreadNotification(this.props.unread)}</p>
</div>;
}
}
;
server side, client side, それぞれのやり方で取得した辞書をこの関数に渡すことで、最上位の component を取得すればよい。
子 component からは、最上位の component から props や context (または利用する view のライブラリに応じた何らかの機構) によって伝播された辞書を使うようにする。
動作する例
上記の内容を実装したものを、以下の repository に上げた
-
私の調査が甘いだけかもしれないので、実現できるものをご存知の方がいたら教えてください ↩