背景
趣味で作っているサイトで国際化・多言語化(i18n)をして、日本語と英語に対応した ( 前回の記事を参照 )。
ただ、翻訳についてコンテンツのリソース管理が言語ごとに2重になっていて、それを直そうとしてどのような設計・実装にすればよいのか迷走した。
翻訳でよく使われるのは辞書式翻訳 ( e.g. npmのi18n もしくはそれをベースに各種フレームワーク向けに使いやすくしたもの)。
こうした辞書式翻訳に感じている不便さは
- とにかくどこもかしこも辞書式翻訳の翻訳関数に辞書のキーを入れる処理だらけになる
- コンテンツと翻訳辞書(yamlなりjsonなりコード)が分離している
- 今回開発しているサイトの場合、DBが必要にならないようなコンテンツしかないのに翻訳のためにエンジニアが頭の中でコンテンツに翻訳キーを紐づける(SQLでテーブルをJOINさせるかのような)作業をしないといけない
結局どうしたのか
- 多言語対応の翻訳文言データをクラス化して、コンテンツのマスターデータの属性として持たせた
- コンテンツと翻訳のデータが同じコード上にある
- 翻訳文言の漏れがあるとインスタンス作成時にエラーになるので、CIでのテスト実行で翻訳漏れが検知できる
- BFFでデータを返す時に翻訳文言クラスのインスタンスの翻訳メソッドにロケールを指定して翻訳後の文言を取り出す
- コード内でひたすらに辞書式翻訳の翻訳関数にキーを指定しなくてよい
export type SupportedLocale = "ja" | "en";
export const SUPPORTED_LOCALES: SupportedLocale[] = ["ja", "en"];
import { SupportedLocale, SUPPORTED_LOCALES } from "../../constants/i18n";
export class TranslatableValues {
protected values: Map<SupportedLocale,string>;
private constructor(values: Map<SupportedLocale,string>) {
this.values = values;
}
getLocalizedValue(locale: SupportedLocale): string {
const result = this.values.get(locale)
if (result != null ) {
return result;
} else {
throw new Error(`Specified locale ${locale} missing.`);
}
}
/**
* ファクトリメソッド
*
* 実際にInMemory*Service系の実装で使うデータはこちらで生成する。
* createValuesメソッドによる対応ロケールの網羅がチェックがされる。
* このため、翻訳漏れは実行時にエラーになる。
* @param candidates [SupportedLocale, string][]
* @returns TranslatableValues
* @throws Error
*/
static create(candidates: [SupportedLocale, string][]): TranslatableValues {
return new TranslatableValues(TranslatableValues.createValues(candidates));
}
/**
* 単体テストコード用のファクトリメソッド
*
* createValuesメソッドによる対応ロケールのチェックがされない。
* @param candidates [SupportedLocale, string][]
* @returns TranslatableValues
*/
static createForTest(candidates: [SupportedLocale, string][]): TranslatableValues {
return new TranslatableValues(new Map<SupportedLocale, string>(candidates));
}
static createValues(candidates: [SupportedLocale, string][]): Map<SupportedLocale, string> {
const locales = candidates.map((pair: [SupportedLocale, string]) => {
return pair[0];
});
const dintinctLocales = new Set(locales);
if (candidates.length != dintinctLocales.size) {
throw new Error(`locale is duplicated: ${candidates}`);
}
if (dintinctLocales.size != SUPPORTED_LOCALES.length) {
throw new Error(`locale size is invalid: ${candidates}`);
}
return new Map<SupportedLocale, string>(candidates);
};
}
マスタデータを作る場合
BFFのデータ構造
import { SupportedLocale } from "../../constants/i18n";
export type NewsItemLink = {
name: string;
url: string;
}
export type NewsItem = {
text: string;
links?: NewsItemLink[];
};
export interface NewsService {
listNews(locale: SupportedLocale): NewsItem[];
}
作ったマスタデータ
import { SupportedLocale } from "../../constants/i18n";
import { TranslatableValues } from "../i18n/TranslatableValues";
import { NewsItem, NewsItemLink, NewsService } from "./NewsService";
export class NewsLinkMaster {
private name: TranslatableValues;
private url: string;
constructor(props: {name: TranslatableValues, url: string}) {
this.name = props.name;
this.url = props.url;
}
getNewsItemLink(locale: SupportedLocale): NewsItemLink {
return {
name: this.name.getLocalizedValue(locale),
url: this.url,
}
}
}
export class NewsMaster {
private text: TranslatableValues;
private links?: NewsLinkMaster[];
constructor(props: {text: TranslatableValues, links?: NewsLinkMaster[]}) {
this.text = props.text;
this.links = props.links;
}
getNewsItem(locale: SupportedLocale): NewsItem {
return {
text: this.text.getLocalizedValue(locale),
links: this.links?.map((linkMaster) => {
return linkMaster.getNewsItemLink(locale);
})
}
}
}
const newsMasterData: NewsMaster[] = [
new NewsMaster({
text: TranslatableValues.create([
["ja", "2022秋M3 (2022-10-30) パンケーキキャッツ 1stEP ノンストップエモーション! 1曲担当"],
["en", "M32022Autumn (2022-10-30) pancakecats 1stEP Non-Stop Emotion!"],
]),
links: [
new NewsLinkMaster({
name: TranslatableValues.create([
["ja", "特設サイト"],
["en", "Web site"],
]),
url: "https://pccs-vtuber.studio.site/",
}),
new NewsLinkMaster({
name: TranslatableValues.create([
["ja", "クロスフェードデモ"],
["en", "Crossfade Demo"],
]),
url: "https://youtu.be/stfsWwIFDtE",
}),
],
}),
new NewsMaster({
text: TranslatableValues.create([
["ja", "柚子花主催LIVE -Planet Station- STAGE.5 (2022-11-26) 出演予定"],
["en", "LIVE sponsored by Yuzuha -Planet Station- STAGE.5 (2022-11-26) "],
]),
links: [
new NewsLinkMaster({
name: TranslatableValues.create([
["ja", "Z-aN"],
["en", "Z-aN"],
]),
url: "https://www.zan-live.com/live/detail/10251"
}),
]
}),
];
export class InMemoryNewsService implements NewsService {
listNews(locale: SupportedLocale): NewsItem[] {
return newsMasterData.map((master) => {
return master.getNewsItem(locale);
})
}
}
作ってみたものの
コンテンツと翻訳文言は1か所にまとまったが、本当にこれでよかったのかといわれるとあまり自信がない。
今回の翻訳のやりかたは開発者がコード上でだけコンテンツを管理する(=コンテンツ更新のリリースはコード変更してデプロイする)場合でだけ成り立つ。
BFFのinterfaceと実装は分けているので、運用担当者がデータ入稿してBFFがサーバ間通信してそのデータを取得するような場合にも対応はできるけど、果たして運用担当者にどのような形式でデータ入稿してもらうのかが想像できない。
提供コンテンツが大きくなった後で対応ロケール追加するときに今の実装ではエラーになってしまい一度に修正するコードが多くなってしまう(たぶん、部分的な翻訳漏れを許容して非対応ロケールなら警告をログに出しつつデフォルトロケールの文言を返す実装に変更になるはず)。