読者です 読者をやめる 読者になる 読者になる

Septeni Engineer's Blog

セプテーニエンジニアが綴る技術ブログ

scalaでgettextした

最近、国際化(i18n)というものの存在を知りました。新卒のno_sugiyamaです。

はじめはplayframeworkに標準で入ってるi18nの機能を使ってたんですが、調べてみたらgettextなるものがほぼ標準のi18nライブラリらしく、編集用のエディターもあるということなので、scalagettextできるライブラリのscaposerをざっくり使ってみました。ついでにテンプレート出力するためのscala-xgettextというライブラリも使ってみました。

翻訳

まずは使用するライブラリについてざっくり。 scaposerは、翻訳情報を持つpoファイルを読み込んで文字列を翻訳できるようにしてくれるライブラリです。 今回はこのscaposerを使って、言語を指定したらメッセージを対応する言語に翻訳するサンプルを書きます。開発環境はsbtプロジェクトを作って実施しました。

はじめにライブラリを突っ込むためにbuild.sbtに依存性を追加します。

build.sbt

libraryDependencies += "tv.cntt" %% "scaposer" % "1.7"

次に翻訳情報を書くpoファイルを用意します。今回は"はろーわーるど"と入力されたら"hello world"に置き換えるようにします。 ファイルの場所はプロジェクトのルート直下でいけると思います。

en.po

#: ../../../../src/main/scala/com/example/i18n/Sample.scala:3
msgid "はろーわーるど"
msgstr "hello world"

application.confに言語設定を追加しておきます。この言語設定をプログラムから読み込みます。

application.conf

langs = ["en"]

やっとscaposerを使った実装です。 scaposerの使い方ですが、あらかじめ翻訳情報: Seq[Translation]を用意しておいて、
scaposer.I18n(翻訳情報).t(翻訳したい文字列)みたいな感じで使います。(tメソッド以外にもいくつかありますが、今回はtメソッドのみ扱います。) また、後述のscala-xgettextでテンプレート出力するために、このtメソッドを使っている部分をクラスまたはトレイトで定義する必要があります。

I18n.scala

package com.example.i18n

import com.typesafe.config.ConfigFactory
import scaposer.Translation
import scala.io.Source
import scala.util.Try

// アプリケーション実行時に1度だけpoファイルを読み込みtranslationMapに値を設定する
private[i18n] object I18n {
  private val langs: Array[String] = ConfigFactory.load().getStringList("langs").toArray(Array[String]())
  private val fileType = ".po"

  // 言語をキーに対応する翻訳情報を持つマップ。これがI18nトレイトで使われる。
  val translationMap: Map[String, Seq[Translation]] = createTranslationMap(langs, Map())

  // translationMapに値を設定する末尾再帰。langsの要素の数だけ繰り返す。
  @scala.annotation.tailrec
  private def createTranslationMap(langs: Array[String], translationMap: Map[String, Seq[Translation]]): Map[String, Seq[Translation]] = {
    if (langs.isEmpty) {
      translationMap
    } else {
      val filePath = langs.head + fileType
      val translationString = using(filePath) { source => source.mkString }.getOrElse("")
      val translation = scaposer.Parser.parse(translationString).fold(_ => Seq(), translation => translation)
      val result = translationMap.updated(langs.head, translation)
      createTranslationMap(langs.tail, result)
    }
  }

  private def using(filePath: String)(f: Source => String) = Try {
    val source = Source.fromFile(filePath)
    try {
      f(source)
    } catch {
      case _ => ""
    } finally {
      source.close()
    }
  }
}

// このトレイトを継承して翻訳メソッドを使う
trait I18n {
  def t(message: String)(implicit lang: String = ""): String = {
    if (I18n.translationMap.contains(lang))
      scaposer.I18n(I18n.translationMap(lang)).t(message)
    else
      message
  }
}

今回は言語を指定して翻訳できるようにしたかったので、Map[言語->翻訳情報]といった辞書っぽい変数translationMapを定義してます。 翻訳情報生成にあたってファイル読み込みが必要だったので、オブジェクトで定義してます。 I18nトレイトでは用意した翻訳情報(translationMap変数)を使って翻訳できるtメソッドを定義してます。

定義したトレイトを実際に使ってみるとこんな感じになります。

Sample.scala

object Sample extends App with I18n {
  implicit val lang = "en"
  println(t("はろーわーるど")) // hello world
  println(t("はろーわーるど")("")) // はろーわーるど(言語に対応する.poファイルがない場合)
}

テンプレート(i18n.pot)出力

このままだとtメソッドでくくった部分を探してpoファイルにまとめる作業が発生しそうですが、この抽出作業を自動でやってくれるのがscala-xgettextです。これを使うとテンプレートファイル(i18n.pot)に抽出してくれます。

scala-xgettextは手順を踏んで使う必要があって、だいたいこんな感じで使います。

  1. build.sbtにいろいろ書く
  2. 実装する
  3. テンプレート出力
    1. 空のi18n.potファイルを用意する
    2. sbt clean && sbt compilei18n.potファイルにテンプレートが出力される)

というわけで手順1から、build.sbtに以下を追加

autoCompilerPlugins := true
addCompilerPlugin("tv.cntt" %% "xgettext" % "1.3")
scalacOptions += "-P:xgettext:com.example.i18n.I18n"

scalacOptions += "-P:xgettext:com.example.i18n.I18n"について、com.example.i18n.I18nの部分にはi18nを扱うクラスまたはトレイトのパッケージを指定する必要があります。

次に手順2にいきます。といって実装することほとんどないんですが、さっきのSample.scalaをちょっと修正してtメソッドの引数に変数を取るようにしてみます。

Sample.scala

object Sample extends App with I18n {
  implicit val lang = "en"
  println(t("はろーわーるど")) // hello world

  val hello = "はろーわーるど"
  println(t(hello)) // hello world
}

最後に手順3です。 コンソール上で以下を打っておわりです。

touch i18n.pot
sbt clean && sbt compile

i18n.potという名前は決まりごとですので必ずこの名前にする必要があります。 あと、再実行するときはi18n.potの中身を空にする必要があります。

出力されるi18n.potはこんな感じになります。

i18n.pot

(プロパティ値が色々書いてるけど不要なので省略)

#: ../../../../src/main/scala/com/example/i18n/Sample2.scala:3
msgid "はろーわーるど"
msgstr ""

#: ../../../../src/main/scala/com/example/i18n/Sample2.scala:6
msgid Sample2.this.hello()
msgstr ""

これをpoEditなどのエディタに突っ込めるので、エディタで言語に対応した文言を追加していってpoファイルを出力すればいけます。 ただ、変数に変えた部分が変な出力になっちゃってます。ここにに変換後の文言を設定しても翻訳されないので注意が必要です。

ほかにも実際に動かしてみてわかった注意点が2つあります

  • マルチプロジェクト環境だと1つのプロジェクト分しかテンプレート出力できない
  • メソッド名はtにしないとテンプレート出力されない(自由なメソッド名を使うにはbuild.sbtで設定が必要)

おわり

翻訳便利だなって思いました。