Play 2 (Scala) i18n Patterns


Agenda

i18n Basic Pattern

  • Play 2.3
  • Play 2.4

i18n Path Variable Pattern

  • Simple Pattern

    • /en/page
    • /ja/page
  • Optional Lang Path Variable Pattern

    • /page
    • /ja/page

i18n sbt 💪 Pattern

  • code generating

i18n Basic Pattern

  • Play 2.3
  • Play 2.4

i18n basic in Play 2.3

  • controller 側 : Lang を生成して view に渡す
def index = Action { implicit request =>
  ... // request2lang (trait Controller)
}

  • view
@()(implicit lang: Lang)

<h1>@Messages("my.message")</h1>

Messages.applyimplicit lang: Lang を取る

Play 2.3 つらいの話

  • Playframework の Lang でつらいところ

    • Langを渡さなくてもコンパイルが通る
    • (object Langimplicit lazy val defaultLang: Lang が存在するため)

      • e.g. controller側で Lang を渡し忘れる (implicit req 忘れ)

        • 🙅渡し忘れた箇所全てがデフォルト言語で展開される
      • e.g. view側で Lang を渡し忘れる ↓

        • 🙅渡し忘れた箇所のみデフォルト言語で展開される
index.scala.html
@()(implicit lang: Lang)

<h1>@Messages("hello")</h1>
@partial()
partial.scala.html
@()

<h2>@Messages("world")</h2>

結果

<h1>こんにちは</h1>
<h2>World</h2>

対策案 in Play 2.3

  • controller (trait MyLangSupport or object MyImplicits)
case class MyLang(val lang: Lang)
implicit def request2lang(implicit req: RequestHeader): MyLang = ...
  • view 用ヘルパーオブジェクト
package my.views
object ViewHelpers {
  // (ここでrequestなどを使って優先する言語を変更したり等も簡単に出来る)
  def i18n(key: String, args: Any*)(implicit myLang: MyLang): String =
    messages(key, args: _*)(myLang.lang)
}
  • build.sbt
play.twirl.sbt.TwirlKeys.templateImports ++= Seq(
  "my.views.ViewHelpers"
)
  • view
@()(implicit lang: MyLang)

@ViewHelpers.i18n("my.message")
  • 🙆 MyLang 渡し忘れは コンパイルエラー
  • 🙅 Messages を必要とする箇所には別途対応が必要

    • views.html.helper...
    • play.api.data.validation.Constraints...

i18n basic in Play 2.4

  • controller 側 : Messages (not Lang) を生成してviewに渡す
class Application @Inject() (val messagesApi: MessagesApi) extends Controller with I18nSupport {
  def index = Action { implicit request =>
    ... // request2Messages (I18nSupport)
  }
}
  • view
@()(implicit messages: Messages)

<h1>@Messages("my.message")</h1>

I18nSupport

implicit def request2Messages(implicit request: RequestHeader): Messages =
  messagesApi.preferred(request)
  • controller に implicit request 忘れ

    • 🙆 コンパイルエラー
  • view に implicit messages 忘れ

    • 🙆 コンパイルエラー

[TIP] preferred(request) in DefaultMessagesApi

リクエスト側の候補順を基に、サーバ側にマッチするものを探す

サーバ側の候補

  • application.conf に書かれたもの全て

リクエスト側の候補

  1. langCookieName に含まれる言語
  2. Accept-Language ヘッダ (前に書かれたものが優先)

マッチしない場合…

  1. application.conf に書かれた最初のもの
  2. Lang.defaultLang

    • java.util.Locale.getDefault

[TIP] Cookie に Lang を保存 in Play 2.4

MessagesApi
def setLang(result: Result, lang: Lang): Result
DefaultMessagesApi
def setLang(result: Result, lang: Lang) =
  result.withCookies(Cookie(langCookieName, lang.code, path = Session.path, domain = Session.domain, secure = langCookieSecure, httpOnly = langCookieHttpOnly))

i18n in Play 2.4 with DI

  • 独自の MessageApi をInjectさせると、先ほどの ViewHelper の代替も可能

    • DefaultMessageApi を継承して、override させるのが簡単
    • 🙆 リクエスト以外の設定値から Lang を決定する事も可能
package my.components

@Singleton
class MyMessagesApi @Inject() (
  environment: Environment,
  configuration: Configuration,
  langs: Langs
  ) extends DefaultMessagesApi(environment, configuration, langs)
       with MyRequestToUserSupport {

  override def preferred(request: RequestHeader) =
    getUser(request).fold(super.preferred(request))(user => new Messages(user.lang, this))

)

: I18nSupport を独自のものに変えた方が楽な事もある)

2 DI patterns in Play 2.4

1. DIの差し替え (recommended)

  • MyI18nModule
package modules
class MyI18nModule extends Module {
  def bindings(environment: Environment, configuration: Configuration) = {
    Seq(
      bind[Langs].to[DefaultLangs],
      bind[MessagesApi].to[MyMessagesApi] // instead of DefaultMyMessagesApi
    )
  }
}
  • application.conf
play.modules.enabled += "modules.MyI18nModule"
play.modules.disabled += "play.api.i18n.I18nModule"
  • 🙆 DefaultMessagesApiが誤って呼ばれる事は無い, コントローラ側の変更が無い
  • 🙅 誤った名前のモジュール名を指定しても、コンパイルエラーにはならない

2. controller で直接指定 (not recommended)

class Application @Inject() (val messagesApi: MyMessagesApi) extends Controller with I18nSupport ...
  • 🙅 MesagesApiと書いたらDefaultMessagesApiが呼ばれる

    • 絶対間違えるので、やめた方が良い

[TIP] MessageApi Tips in Play 2.4

e.g. jodatime を利用する際に、MessageFormatdate を利用する

  • messages.en (java.text.MessageFormat)
my.time=Time is {0,date}
  • MessagesApi
package my.components

@Singleton
class MyMessagesApi ... {
  override def preferred(request: RequestHeader) = ...

  override def apply(key: String, args: Any*)(implicit lang: Lang): String = {
    super.apply(key, args.map {
      case localdate: org.joda.time.LocalDate => localdate.toDate
      case arg => arg
    }: _*)(lang)
  }
)

i18n Path Variable Pattern

  • /en/page
  • /ja/page

Simple path variable way

  • routes : Lang を利用
GET  /$lang<[a-zA-Z]{2,3}>/page  controllers.NoI18nSupportController.page(lang: Lang)
  • Lang 用の PathBindable
    • (e.g. Langs を Inject して langs.availables のみ通す)
package components
object MyImplicits {
  implicit def langPathBindable = new PathBindable[Lang] {
    override def bind(key: String, value: String) = Lang.get(value) match {
      case Some(lang) => Right(lang)
      case None       => Left("Invalid lang path value.")
    }

    override def unbind(key: String, value: Lang) = value.language
  }
}
  • build.sbt
routesImport ++= Seq("components.MyImplicits._", "play.api.i18n.Lang")

i18n Optional Path Variable Pattern

/page/ja/page

  • routes : 😇 2行書く 😇
GET  /page                controllers.NoI18nSupportController.page(lang: Option[Lang] = None)
GET  /$lang<[a-zA-Z]{2,3}>/page  controllers.NoI18nSupportController.page(lang: Option[Lang])
  • PathBindable for Option
package components
object MyImplicits {
  implicit def langPathBindable = new PathBindable[Lang] {...}

  // =====>> New!! <<=====
  implicit def optionPathBindable[A: PathBindable] = new PathBindable[Option[A]] {
    def bind(key: String, value: String): Either[String, Option[A]] =
      implicitly[PathBindable[A]].bind(key, value).fold(
        left  => Left(left),
        right => Right(Some(right))
      )

    def unbind(key: String, value: Option[A]): String =
      value.map(_.toString).getOrElse("")
  }
  ...
}

Another solution with Play 2.4 custom router

/contents/ja/.../contents/en/...

# GET  /contents/:lang/       controllers...
->   /contents   routers.MyContentsRouter
  • routers.MyContentsRouter
package routers

class MyContentsRouter @Inject()(controller: ContentsController) extends SimpleRouter {
  override def routes = Function.unlift {
    case p"/$lang<[a-zA-Z]{2,3}>" =>
      Lang.get(lang).map(controller.index)
    case _ => None
  }
}

Another solution with Play 2.4 custom router

🙌 こころをこめて 🙌

package routers

// call like `@routers.ContentsReverseRouter.index` in views and so on
object ContentsReverseRouter {
  private def instance: ReverseRouter = play.api.Play.current.injector.instanceOf[ReverseRouter]
  def index(maybeLang: Option[Lang]) = instance.index(maybeLang)
}

@Singleton
class ReverseRouter @Inject() (routerProvider: Provider[Router]) {
  private def router: Router = routerProvider.get
  def index(maybeLang: Option[Lang]) = {
    maybeLang.fold("/contents/")(lang => s"/contents/${lang.code}")
  }
}

🙇 redundant definitions 🙇

GET  /                             controllers.MyController.index(lang: Option[Lang] = None)
GET  /$lang<[a-zA-Z]{2,3}>/        controllers.MyController.index(lang: Option[Lang])
GET  /profile                      controllers.MyController.profile(lang: Option[Lang] = None)
GET  /$lang<[a-zA-Z]{2,3}>/profile controllers.MyController.profile(lang: Option[Lang])
GET  /access                       controllers.MyController.access(lang: Option[Lang] = None)
GET  /$lang<[a-zA-Z]{2,3}>/access  controllers.MyController.access(lang: Option[Lang])
package routers

class MyContentsRouter ... {
  override def routes = Function.unlift {
    case p"/$lang<[a-zA-Z]{2,3}>"         => controller.index
    case p"/$lang<[a-zA-Z]{2,3}>/profile" => controller.profile
    case p"/$lang<[a-zA-Z]{2,3}>/access"  => controller.access
    case _ => None
  }
}
object ContentsReverseRouter {
  def index(maybeLang: Option[Lang])   = instance.index(maybeLang)
  def profile(maybeLang: Option[Lang]) = instance.profile(maybeLang)
  def access(maybeLang: Option[Lang])  = instance.access(maybeLang)
}
class ReverseRouter ... {
  def index(maybeLang: Option[Lang])   = ...
  def profile(maybeLang: Option[Lang]) = ...
  def access(maybeLang: Option[Lang])  = ...
}

i18n sbt 💪 Pattern

🙏 Let’s generate Router/routes by sbt 🙏

  • sbt を用いて、routes 関連のコードの重複を無くす

方針: sbt でコード生成

  1. 言語別に出し分けする設定ファイル(routes)を用意 (or something else)
  2. コンパイル前に言語別に出し分けする用の Router or routes を作成
  • Play 2.4 で routes をパースする方法

    • play.routes.compiler.RoutesFileParser.parse
    • routes の場所: baseDirectory / "conf" / "routes" (or another solution?)
  • Play 2.3 で routes をパースする方法

    • private[router] play.router.RouteFileParser.parse
    • play.router パッケージに置いて誤魔化す or 自作パーサー
    • routes の場所: confDirectory / "routes"

e.g. i18n 用 Router の自動生成 in Play 2.4

/contents/, /contents/ja/, /contents/profile, /contents/ja/profile, /contents/access, /contents/ja/access, …


  • routes
->   /contents   routers.MyGeneratedRouter

  • routes.i18n (it’s ok to write with any format)
GET  /         controllers.ContentsController.index(lang: Option[play.api.i18n.Lang])
GET  /profile  controllers.ContentsController.profile(lang: Option[play.api.i18n.Lang])
GET  /access   controllers.ContentsController.access(lang: Option[play.api.i18n.Lang])

  • build.sbt
sourceGenerators in Compile <+= (baseDirectory, sourceManaged) map { case (base, source) =>
  val countries = Seq("en", "ja") // it should be taken from play.i18n.langs in application.conf
  val langRoutesFile = base / "conf" / "routes.i18n"
  MyI18NContentsRouterGenerator.generate(langRoutesFile, countries, source).toSeq
}

e.g. i18n 用 Router の自動生成 in Play 2.4 続き

  • conf/MyRouterGenerator.scala
import play.routes.compiler.{RoutesFileParser, Route, Rule}
import sbt.File
import sbt.Path._

object MyI18NContentsRouterGenerator {
  val LANG_ROUTER_CLASS = "MyGeneratedRouter"
  val LANG_ROUTER_FILE = LANG_ROUTER_CLASS + ".scala"
  def generate(langRoutes: File, countries: Seq[String], sourceManaged: File): Option[File] = {
    RoutesFileParser.parse(langRoutes) match {
      case Right(rules: List[Rule]) =>
        val outputFile = sourceManaged / LANG_ROUTER_FILE
        sbt.IO.write(outputFile, _generate(rules))
        Some(outputFile)
      case Left(_) => None
    }
  }

  private def _generate(rules: List[Rule]): String = {
    val routesValues = StringBuilder.newBuilder
    val reverseRouterObjValues = StringBuilder.newBuilder
    val reverseRouterValues = StringBuilder.newBuilder
    rules.foreach {
      case route: Route =>
        routesValues.append(generateRoutesValue(route))
        reverseRouterObjValues.append(generateReverseRouterObjValue(route))
        reverseRouterValues.append(generateReverseRouterValue(route))
      case _ => // ignore
    }

    s"""
      |package routers
      |
      |import javax.inject.{Singleton, Provider, Inject}
      |
      |import controllers.ContentsController
      |import play.api.i18n.Lang
      |import play.api.routing.{Router, SimpleRouter}
      |import play.api.routing.sird._
      |
      |class ${LANG_ROUTER_CLASS} @Inject()(controller: ContentsController) extends SimpleRouter {
      |  val DEFAULT_LANG = Lang("en")
      |  override def routes = Function.unlift {
      |    ${routesValues}
      |    case _ => None
      |  }
      |}
      |
      |object LangReverseRouter {
      |  private def instance: ReverseRouter = play.api.Play.current.injector.instanceOf[ReverseRouter]
      |  ${reverseRouterObjValues}
      |}
      |
      |@Singleton
      |class ReverseRouter @Inject() (routerProvider: Provider[Router]) {
      |  private def router: Router = routerProvider.get
      |  ${reverseRouterValues}
      |}
    """.stripMargin
  }

  private def generateRoutesValue(route: Route): String = {
    s"""
      |    case p"/${route.call.method}" =>
      |      Some(controller.${route.call.method}(DEFAULT_LANG))
      |    case p"/$$lang<[a-zA-Z]{2,3}>/${route.call.method}" =>
      |      Lang.get(lang).map(controller.${route.call.method})
    """.stripMargin
  }

  private def generateReverseRouterObjValue(route: Route): String =
    s"""
       |  def ${route.call.method}(maybeLang: Option[Lang]) = instance.${route.call.method}(maybeLang)
    """.stripMargin

  private def generateReverseRouterValue(route: Route): String =
    s"""
       |  def ${route.call.method}(maybeLang: Option[Lang]) =
       |    maybeLang.fold("/${route.call.method}")(lang => "/" + lang.code + "/${route.call.method}")
     """.stripMargin
}

i18n sbt 💪 Pattern

  • It’s possible to see generated src easily.

Play 2 (Scala) i18n Patterns

i18n Basic Pattern

  • Play 2.3
  • Play 2.4

i18n Path Variable Pattern

  • Simple Pattern

    • /en/page
    • /ja/page
  • Optional Lang Path Variable Pattern

    • /page
    • /ja/page

i18n sbt 💪 Pattern

  • code generating