こんにちは、フロントエンドエンジニアの樫福です。マネーフォワードクラウド経費(以下、クラウド経費)というプロダクトのフロントエンドの開発をしています。
クラウド経費は多言語対応をしています。YAML や JSON 形式で書かれた翻訳ファイルを用意し、ユーザの設定に応じて画面表示を日本語か英語に切り替えるようにしています。
このたび、 i18n-locale-lint という翻訳ファイルの linter を開発しクラウド経費の開発に組み込みました。 npm パッケージとして公開しているので、ぜひ使ってみてください。
この記事では、ライブラリの開発に至った経緯や苦労話をシェアしようと思います。
利用方法
この記事の投稿時点の最新バージョンである v0.3.3 を例に紹介します。
次のような翻訳ファイルがあるとします。
- src `- i18n |- navigation | |- en.json | `- ja.json |- message | |- en.json | `- ja.json `- user |- en.json `- ja.json
npx コマンドを使って次のように実行できます。
npx i18n-locale-lint@v0.3.3 "src/i18n/**/*.json"
共通のディレクトリに含まれるファイル同士がペアとして扱われ、ペア同士の差分がある場合に検知してくれます。
detected a type difference with this key: user.name in src/i18n/user/en.json "Name" in src/i18n/user/ja.json { firstName: "名", lastName: "姓" } Checked 6 files, 3 groups Found 1 mismatched group
開発に至った経緯
多言語対応するプロダクトでは、次のような翻訳ファイルを用意します。この例では日本語(ja)と英語(en)に対応しています。
// i18n/messages/ja.json { "hello": "ハローワールド!", "post": { "button": "投稿", "posting": "投稿中", "completed": "完了しました", "failed": "失敗します" } } // i18n/messages/en.json { "hello": "Hello World!", "post": { "button": "post", "posting": "posting...", "completed": "completed!", "failed": "failed" } }
使用する言語の情報をページのパスや search params に含めたり Cookie に格納したりしてユーザに表示する言語を決定します。決定した言語をもとに参照するファイルを決定し、そのファイルの単語を利用します。
翻訳ファイルを利用する場合は次のような実装をします。実装の詳細は省きますが、先ほどの JSON ファイルから "hello"
と "post.button"
の値を参照しようとしているのがわかると思います。
function Display({ t }) { return ( <div> {t("hello")} <button>{t("post.button")}</button> </div> ); }
では、ここで en.json から "post.button"
の単語を誤って消してしまうとどうなるでしょう。
ライブラリによって挙動は異なりますが、たとえば rails-i18n というライブラリでは単語のキー(今回だと "post.button"
)の文字列が表示されてしまいます。ほかにも、事前に設定しているフォールバック先の言語の単語が使われることもあります。いずれにしても、ユーザが読めない文字列が表示されることになります。
こうならないためにも、言語ごとの翻訳ファイルの型は一致している必要があります。 これまでは動作確認やレビューでの指摘で解決してきましたが、やはり機械的にチェックができるといいなと考えました。
実は1年半くらい前に、同じチームで同じ課題に取り組んだことがありました。クラウド経費では Ruby on Rails (以下、 Rails) と React を組み合わせて使っていて、この取り組みでは Rails 向けの YAML 形式で書かれた翻訳ファイルの差分を検知できるようにしました。
YAML にも JSON にも対応したツールがあると嬉しいのになあ…。 そこで、翻訳ファイルをチェックする linter を自作することにしました。
考えたこと
以下のようなことを考えながら linter を開発しました。
- YAML、 JSON のどちらの形式でも利用できること
- Rails のフォーマットで使えること
- 社外に公開できること
- 新しい技術にチャレンジできること
1. YAML、 JSON のどちらの形式でも利用できること
前述の通り、クラウド経費は Rails と React で実装しています。 Rails で使っている rails-i18n は YAML 形式を、 React で使っている react-intl というライブラリでは JSON 形式で翻訳ファイルを作成しています。形式こそ違えどそれぞれ言語間の差分のないデータを持ちたいことには変わりないので、どちらにも対応できる実装にしたいなと考えました。
2. Rails のフォーマットで使えること
さきほど、言語ごとの翻訳ファイルの型は一致している必要があると述べましたが、実は rails-i18n で使う YAML ファイルでは型の情報が一致しません。
以下はそれぞれ ja.yml、 en.yml というファイルの組ですが、先頭のキーがそれぞれ ja
と en
と異なります。
ja: hello: "ハローワールド!"
en: hello: "Hello World!"
Rails での多言語対応のために使っている YAML ファイルのために「言語ごとの翻訳ファイルの型は最上位のキーを除いて一致する」ことも検査できるようにする必要があります。
3. 社外に公開できること
linter を自作する前に、いい感じの npm パッケージがないかなあと探してみました。しかし、いい感じのがあると思ったら脆弱性の問題を抱えていたり YAML 形式か JSON 形式のどちらかにしか対応していなかったり、、、すこし使いづらいなと感じました。
そこで、せっかくなら社外に公開して誰でも使えるパッケージにしようと考えました。 npm パッケージを自作した経験はなかったので、勉強になればいいなとも思いました。
4. 新しい技術にチャレンジできること
linter の機能には関係ないですが、せっかくなので新しい技術に触れる機会にしようと思いました。
最近、巷では Rust という言語が流行しているらしいですね。また、 Biome のような Rust で書かれたホットなライブラリも登場していて、このビッグウェーブに乗りたいミーハー心がありました。 というわけで、 Rust を使って実装することにしました。
実際に作ってみて
実際に作ったものはこちらのリポジトリにあります。
実装で工夫した点をいくつかシェアしたいと思います。
YAML 形式、 JSON 形式から共通の AST に変換して検査した
実装を楽にするために、 YAML 形式、 JSON 形式のデータのまま検査するのではなく、独自の AST に変換してから検査するようにしました(実装はこちら)。
そうすることで検査ロジックの実装が一度で済んで楽になりました。また、万が一ほかのファイル形式の翻訳ファイルに対応する場合にも、そのファイル形式から AST への変換を実装するだけで済みそうです。
Rails のフォーマットに使えるようなオプションを実装した
rails-i18n の仕様上、 YAML 形式のファイルは最上位のキーをスキップする必要があります。そこで、 skip-top-level
というオプションを追加して対応しました。
YAML 形式のファイルが指定されたときに暗黙的に最上位のキーをスキップする仕様にしてもいいかなと考えたのですが、なるべく動作が明示的になるようにオプションを追加することにしました。
実際にはこんな感じで使います。
npx i18n-locale-lint "config/locales/**/*.yml" --skip-top-level
特定のファイルをスキップするオプションを実装した
Rails の翻訳ファイルのうち一部のファイルは検査をせずにスキップできるようなオプションを追加しました。
rails-i18n では日付などのよく使う表現にはデフォルトのテキストが用意されています(たとえば ja のデフォルト設定はこちら)。デフォルトの指定以外を使う場合は同じキーで上書きすることができます。
クラウド経費は日本の法人向けのサービスなので日本語でも英語でも通貨単位は「円」を利用していますが、英語のデフォルトの通貨単位は「$」になってしまいます。そこで、 "config/locales/en.yml" に次のように定義して通貨単位が「JPY」と表示されるようにします。
en: number: currency: format: unit: "JPY"
このようなデフォルトの上書きは対応が必要な言語だけで十分で、わざわざ他の言語にまで変更を加えたくないです。
そこで、デフォルトの上書きのために作った翻訳ファイルを丸々無視できればいいなと考えて ignore
オプションを追加しました。次のように指定することで、デフォルトのフォーマットの上書きに利用している "config/locales/en.yml" と "config/locales/ja.yml" の間のずれは許容しています。
npx i18n-locale-lint "config/locales/**/*.yml" --skip-top-level --ignore "config/locales/*.yml"
この例のように意図的に無視するときだけではなく、すでに翻訳ファイルに差分があるプロジェクトで CI を組み込むために一時的に差分を無視することにも使えます。
リリースでバイナリを配布する
Rust で実装したコードはコンパイルしてバイナリを配布しています。 GitHub Actions でリリースプロセスを自動化し、 Git の tag を作成すると自動的に利用可能な状態を作るようにしました。利用者が npm パッケージを npm install
でインストールすると postinstall スクリプトが実行されてバイナリをダウンロードするようになっています。
このあたりの実装は Biome を参考にしました。
得られた効果
クラウド経費の CI に組み込む前にローカルで実行して、翻訳ファイルの差分を見つけることができました。必要なくなった単語を、日本語だけ削除して英語には残りっぱなしになってしまってるようなケースもあれば、意図していない文字列がユーザに表示されているケースもありました。これらは全て修正済みです。
現在は CI に翻訳ファイルのチェックを組み込んでいるので、翻訳ファイルのズレがリリースされる前に気づけるようになりました。これによりレビュー時に必死で差分を探す必要がなくなり、意図しない文字列を表示してしまうリスクも減りました。
今後の展望
まずはリファクタリングをして機能追加をしやすい仕組みを作りたいです。 Rust の概念になんとなく触れた段階なので、よりよい書き方を学んでいければいいなと思います。 Windows には対応できていないので、これは早急に解決したいです。
また、せっかく作ったので多くの人に使ってもらいたいなと思っています。そのためにも、ドキュメントの整備やテストの拡充を進めて安心して使ってもらえるパッケージに育てていきたいです。
おわりに
取り組みに協力してくださったチームの皆さんや Rust の技術的な課題の解決に協力してくださった社内の皆さんにこの場を借りて感謝申し上げます。
初めて npm にパッケージを公開して、また Rust で実装したりバイナリを配布する仕組みを構築したり、関わっているプロダクトに組み込んだり、とても勉強になる取り組みでした。
全体を通しての所感ですが、結局のところ Rust が一番難しいと感じました。所有権とかライフタイム参照とか、難しい概念が多くて何度か挫折しました。最終的には動くところまで持っていけてホッとしています。 ただ難しかったという感想ばかりではなくて、 Rust の面白さに触れることができました。とくに AST の処理の実装は Rust の言語機能のおかげか簡単に実装できたなと思います。もっとよく理解してよりよい実装ができるように精進していきたいです。
自分自身が抱えている課題を、社内のコードだけではなく社外からも利用できる形で作り上げるのはよい取り組みだと感じました。 OSS やテックブログもそうですが、今後もこういう活動は続けていきたいなと思います。
宣伝
私の所属するマネーフォワード福岡開発拠点ではエンジニアを募集しています。
↓ 求人情報はこちら ↓
福岡拠点のサイトもぜひご覧ください〜