Play 2.6の新機能

Play 2.6のドキュメントにある「What’s new in Play 2.6」からScalaに関する部分をざっと日本語にしてみました。

細かい部分はマイグレーションガイドも見ないとダメそうですね。こちらはかなり分量があるのでさくっと日本語にするのは厳しそう…。

グローバルステートが非推奨に

アプリケーションではplay.api.Play.current / play.Play.application()でグローバルアプリケーションにアクセスできるけど非推奨。以下のようにして禁止することもできる。

play.allowGlobalApplication=false

こうしておくとPlay.currentが例外をスローするようになる。

Akka HTTPベースのバックエンド

Akka HTTPベースのバックエンドがデフォルトになった。Nettyのバックエンドも利用可能。Nettyを使用する場合は以下を参照:

HTTP/2サポート (Experimental)

PlayAkkaHttp2Supportモジュールを使うことでHTTP/2サポートを利用可能。

lazy val root = (project in file("."))
  .enablePlugins(PlayJava, PlayAkkaHttp2Support)

デフォルトではrunコマンドでは動作しない。詳細は以下を参照:

リクエストの属性

リクエストが属性を含むようになった。属性にリクエストに関する追加の情報を格納することができる。たとえばフィルタで値をセットしておき、後続のアクションでその値を参照するといったことができる。

属性はTypedMapというタイプセーフかつイミュータブルなマップに格納される。

// Create a TypedKey to store a User object
object Attrs {
  val User: TypedKey[User] = TypedKey[User].apply("user")
}

// Get the User object from the request
val user: User = req.attrs(Attrs.User)
// Put a User object into the request
val newReq = req.addAttr(Attrs.User, newUser)

これに伴ってリクエストタグは非推奨になった。リクエストタグはリクエスト属性に置き換えることが望ましい。

Route modifierタグ

routeファイルに、routeごとのカスタム動作を指定するための"modifiers"を記述できるようになった。たとえば"nocsrf"というタグを記述しておくとCSRFフィルタが適用されなくなる。

+ nocsrf # Don't CSRF protect this route 
POST /api/foo/bar ApiController.foobar

独自のmodifierを作成することもでき、+のあとに空白で区切っていくつでもタグを記述することができる。

modifierタグはHandlerDefリクエスト属性で参照可能(これはroutesファイルで定義された他のメタデータも含んでいる)。

import play.api.routing.{ HandlerDef, Router }
import play.api.mvc.RequestHeader

val handler = request.attrs(Router.Attrs.HandlerDef)
val modifiers = handler.modifiers

TwirlテンプレートでのDI

Twirlテンプレートで@thisと記述するとコンストラクタを作成できるようになった。これによってコントローラ経由ではなくテンプレートに直接DIが可能になった。

たとえば以下のテンプレートはコントローラでは使用しないTemplateRenderingComponentというコンポーネントをDIしている。@this@()より前に記述する必要がある点に注意。

@this(trc: TemplateRenderingComponent)
@(item: Item)

@{trc.render(item)}

デフォルトではすべての@thisが記述されたテンプレートクラスには自動的に@javax.inject.Inject()が付与される。この挙動はbuild.sbtで変更できる。

// Add one or more annotation(s):
TwirlKeys.constructorAnnotations += "@java.lang.Deprecated()"

// Or completely replace the default one with your own annotation(s):
TwirlKeys.constructorAnnotations := Seq("@com.google.inject.Inject()")

コントローラではテンプレートクラスをDIして使用する。

public MyController @Inject()(indexTemplate: views.html.IndexTemplate,
                              cc: ControllerComponents)
  extends AbstractController(cc) {

  def index = Action { implicit request =>
    Ok(indexTemplate())
  }
}

フィルタの改善

以下のフィルタはデフォルトで有効。

  • play.filters.csrf.CSRFFilter
  • play.filters.headers.SecurityHeadersFilter
  • play.filters.hosts.AllowedHostsFilter

application.confでは+=でフィルタを追加することができる。

play.filters.enabled+=MyFilter

テストなどの目的でフィルタを無効にしたい場合はこんな感じで。

play.filters.disabled+=MyFilter

詳細は以下を参照:

NOTE: CSRF.formFieldのようなCSRFフォームヘルパーを使用していない既存のプロジェクトはおそらくPUTおよびPOSTリクエストが"403 Forbidden"になるはず。この挙動をチェックするには<logger name="play.filters.csrf" value="TRACE"/>logback.xmlに追加する。また、もしlocalhost以外でPlayアプリケーションを実行している場合、AllowedHostsFilterでアクセス元のホスト名/IPアドレスを設定する必要がある。

gzipフィルタ

gzipフィルタを使っている場合、独自のフィルタを書く代わりにapplication.confで適用するContentTypeを指定できるようになった。

play.filters.gzip {

    contentType {

        # If non empty, then a response will only be compressed if its content type is in this list.
        whiteList = [ "text/*", "application/javascript", "application/json" ]

        # The black list is only used if the white list is empty.
        # Compress all responses except the ones whose content type is in this list.
        blackList = []
    }
}

JWTクッキー

セッションとFlashのクッキーにデフォルトでJSON Web Tokenを使用するようになった。

詳細は以下を参照:

Logging Marker API

play.Loggerplay.api.LoggerでSLF4Jのマーカーがサポートされた。

import play.api._
logger.info("some info message")(MarkerContext(someMarker))

これによってマーカーを暗黙的に渡すことが可能になる。たとえばLogstash Logback Encoderを使う場合、こんなトレイトを定義しておき、

trait RequestMarkerContext {

  implicit def requestHeaderToMarkerContext(request: RequestHeader): MarkerContext = {
    import net.logstash.logback.marker.LogstashMarker
    import net.logstash.logback.marker.Markers._

    val requestMarkers: LogstashMarker = append("host", request.host)
      .and(append("path", request.path))

    MarkerContext(requestMarkers)
  }

}

コントローラで使用すると、以下のような感じで異なるExecutionContextを使用するFutureにも持ち回ることができる。

def asyncIndex = Action.async { implicit request =>
  Future {
    methodInOtherExecutionContext()
  }(otherExecutionContext)
}

def methodInOtherExecutionContext()(implicit mc: MarkerContext): Result = {
  logger.debug("index: ") // same as above
  Ok("testing")
}

マーカーコンテキストは"tracer bullet"スタイルのロギング(明示的にログレベルを変更せずに特定のリクエストのログを出力したい場合)に便利。たとえば特定の条件の場合のみマーカーを追加することができる。

trait TracerMarker {
  import TracerMarker._

  implicit def requestHeaderToMarkerContext(implicit request: RequestHeader): MarkerContext = {
    val marker = org.slf4j.MarkerFactory.getDetachedMarker("dynamic") // base do-nothing marker...
    if (request.getQueryString("trace").nonEmpty) {
      marker.add(tracerMarker)
    }
    marker
  }
}

object TracerMarker {
  private val tracerMarker = org.slf4j.MarkerFactory.getMarker("TRACER")
}

class TracerBulletController @Inject()(cc: ControllerComponents)
  extends AbstractController(cc) with TracerMarker {
  private val logger = play.api.Logger("application")

  def index = Action { implicit request: Request[AnyContent] =>
    logger.trace("Only logged if queryString contains trace=true")

    Ok("hello world")
  }
}

logback.xmlで以下のTurboFilterでログをトリガーできる。

<turboFilter class="ch.qos.logback.classic.turbo.MarkerFilter">
  <Name>TRACER_FILTER</Name>
  <Marker>TRACER</Marker>
  <OnMatch>ACCEPT</OnMatch>
</turboFilter>

詳細は以下を参照。

また、マーカーを使ったロギングについては以下を参照:

Configurationの改善

play.api.Configurationにカスタム型で読み取るための新しいメソッドが追加された。任意の型に対応したimplicitなConfigLoaderを使用することができる。詳細は以下を参照:

セキュリティログ

Playのセキュリティに関するオペレーションにはsecurityマーカーが追加され、セキュリティチェックに失敗するとWARNレベルでログが出力されるようになった。これによって開発者がなぜリクエストが失敗したのかを把握できるようになった。Play 2.6ではセキュリティフィルタがデフォルトで有効になったため、これは重要なことである。

securityマーカーを通常のログと分けてトリガーしたりフィルタすることができる。たとえばsecurityマーカーがセットされているログを無効にするにはlogback.xmlに以下の設定を追加する。

<turboFilter class="ch.qos.logback.classic.turbo.MarkerFilter">
    <Marker>SECURITY</Marker>
    <OnMatch>DENY</OnMatch>
</turboFilter>

I18Nサポートの改善

PlayではMessagesApiLangクラスが国際化のために使用されており、フォームでのエラーメッセージの表示にも必要だったが、これまではいろいろ面倒なステップが必要だった。Play 2.6ではI18N APIがリファインされた。

MessagesActionBuilderが追加された。これはMessagesRequestMessageProviderを継承したWrappedRequest)を提供する。テンプレートには1つのimplicitパラメータを定義するだけでよく、コントローラはI18nSupportを継承する必要はない。

class FormController @Inject()(messagesAction: MessagesActionBuilder, components: ControllerComponents)
  extends AbstractController(components) {

  import play.api.data.Form
  import play.api.data.Forms._

  val userForm = Form(
    mapping(
      "name" -> text,
      "age" -> number
    )(UserData.apply)(UserData.unapply)
  )

  def index = messagesAction { implicit request: MessagesRequest[AnyContent] =>
    Ok(views.html.displayForm(userForm))
  }

  def post = ...
}

この場合のdisplayForm.scala.htmlはこんな感じ。

@(userForm: Form[UserData])(implicit request: MessagesRequestHeader)

@import helper._

@helper.form(action = routes.FormController.post()) {
  @CSRF.formField                     @* <- takes a RequestHeader    *@
  @helper.inputText(userForm("name")) @* <- takes a MessagesProvider *@
  @helper.inputText(userForm("age"))  @* <- takes a MessagesProvider *@
}

詳細以下を参照:

テストサポート

MessagesApiインスタンスの作成も改善されており、DefaultMessagesApi()DefaultLangs()をデフォルト引数で生成できるようになった。メッセージを指定したい場合は以下のようにできる。

val messagesApi: MessagesApi = {
    val env = new Environment(new File("."), this.getClass.getClassLoader, Mode.Dev)
    val config = Configuration.reference ++ Configuration.from(Map("play.i18n.langs" -> Seq("en", "fr", "fr-CH")))
    val langs = new DefaultLangsProvider(config).get
    new DefaultMessagesApi(testMessages, langs)
  }

Futureのタイムアウトとディレイのサポート

play.api.libs.concurrent.Futuresトレイトを使用する。

import play.api.libs.concurrent.Futures._

class MyController @Inject()(cc: ControllerComponents)(implicit futures: Futures) extends AbstractController(cc) {

  def index = Action.async {
    // withTimeout is an implicit type enrichment provided by importing Futures._
    intensiveComputation().withTimeout(1.seconds).map { i =>
      Ok("Got result: " + i)
    }.recover {
      case e: TimeoutException =>
        InternalServerError("timeout")
    }
  }
}

同様に指定した時間後にFutureを実行するdelayedメソッドも利用可能。詳細は以下を参照:

CustomExecutionContextとスレッドプールサイズ

CustomExecutionContextakka.actor.ActorSystemにデリゲートするカスタムExecutionContextを定義する。これはデフォルトのExecutionContextを使用するべきではない状況(たとえばデータベースアクセスやブロッキングI/Oなど)で便利。

Playのテンプレートプロジェクトを更新

PlayのダウンロードページにあるブロッキングAPI(AnormやJPAなど)を使用するサンプルテンプレートはカスタムExecutionContextを使うように変更された。たとえばJPAのサンプルではDatabaseExecutionContextを使用するようになっている。

JDBCのコネクションプールに関わるスレッドプールは、ThreadPoolExecutorを使用してコネクションプールのサイズにあわせて固定したいはず。HikariCPではコネクションプールのサイズは物理コア数の2倍+ディスクのスピンドル数に設定するべきとされている。dispatcherの設定はこんな感じ。

# db connections = ((physical_core_count * 2) + effective_spindle_count)
fixedConnectionPool = 9

database.dispatcher {
  executor = "thread-pool-executor"
  throughput = 1
  thread-pool-executor {
    fixed-pool-size = ${fixedConnectionPool}
  }
}

CustomExecutionContextの定義はこんな感じ。

@Singleton
class DatabaseExecutionContext @Inject()(system: ActorSystem)
   extends CustomExecutionContext(system, "database.dispatcher")

implicitパラメータで渡す。

class DatabaseService @Inject()(implicit executionContext: DatabaseExecutionContext) {
  ...
}

WSClientの改善

PlayのWSClientスタンドアロンで利用可能なplay-wsのラッパーになった。また、play-wsで使用されているライブラリはシャードされるようになったのでNettyがSpark、Playなどが使用しているNettyとコンフリクトすることがなくなった。

また、キャッシュ実装が存在する場合はHTTP Cachingがサポートされるようになった。

詳細は以下を参照:

Play JSONの改善

タプルのシリアライズ

タプルは配列にシリアライズされる。たとえば("foo", 2, "bar")["foo", 2, "bar"]レンダリングされる。

Scala.jsサポート

Play JSON 2.6.0はScala.jsもサポートしている。

libraryDependencies += "com.typesafe.play" %%% "play-json" % version

テストの改善

play.api.testパッケージにテストのためのいくつかのユーティリティクラスが追加された。DIされるコンポーネントを使用したファンクショナルテストを簡単に行うことができるようになった。

Injecting

今まではこんな感じで書いていたものが、

"test" in new WithApplication() {
  val executionContext = app.injector.instanceOf[ExecutionContext]
  ...
}

Injectingトレイトを使って以下のように記述できるようになった。

"test" in new WithApplication() with Injecting {
  val executionContext = inject[ExecutionContext]
  ...
}

StubControllerComponents

StubControllerComponentsFactoryControllerComponentsのスタブを生成することができ、コントローラーのユニットテストで以下のように使用できる。

val controller = new MyController(stubControllerComponents())

StubBodyParser

同様にStubBodyParserFactoryBodyParserのスタブを生成することができ、コントローラーのユニットテストで使用できる。

val stubParser = stubBodyParser(AnyContent("hello"))

ファイルアップロードの改善

ファイルアップロードはTemporaryFile(テンポラリファイルシステムに保存される)を使用する。理想的な挙動は不要になったTemporaryFileは可能な限り早く削除されることだが、Play 2.5ではファイナライザを使用してGC時に削除されていた。しかし特定の条件下ではGCはタイムリーに発生しないため、バックグラウンドのクリーンアップ処理はFinalizableReferenceQueueとPhantomReferenceを使用するように変更されていた。

TemporaryFileは再度修正され、TemporaryFileの参照はTemporaryFileCreatorトレイトから来るようになり、必要に応じて実装を差し替えることができるようになった。また、StandardCopyOption.ATOMIC_MOVEを使用するatomicMoveWithFallbackメソッドが追加された。

TemporaryFileReaper

Akkaスケジューラを使用して定期的にテンポラリファイルを削除できるplay.api.libs.Files.TemporaryFileReaperが追加された。デフォルトでは無効だがapplication.confで以下のようにすることで有効にできる。

play.temporaryFile {
  reaper {
    enabled = true
    initialDelay = "5 minutes"
    interval = "30 seconds"
    olderThan = "30 minutes"
  }
}

この設定は30分以上前のファイルを削除する。reaperはアプリケーションが起動してから5分後に開始し、30秒ごとにファイルシステムをチェックする。きちんと設定しないと時間のかかっているアップロード中のファイルを削除してしまう可能性があるので注意すること。