Play Framework 2.9.0 に送ったPull Request

そろそろPlay Framework 2.9.0が出そうですね。ということで自分の送ったPull Requestをまとめてみました。

大きいところだとTwirlのScala 3対応ですね。いい感じにエラーメッセージ出すのにdottyとかsbtまで調べることになって沼でしたがたぶんなんとかなりました。

playframework/playframework

playframework/twirl

sbt/zinc

おまけ

今年もかぼちゃ育てたので見て

Play Frameworkの開発チームに入れてもらいました

Play FrameworkがLightbendの手から離れる という発表が昨年の10月にあったんですが、それをきっかけにやりとりして Play FrameworkのGitHub Team に入れてもらいました。 しばらく待ちの状態だったんですが、先週くらいから動き初めたところです。とりあえず当面はTravis CIからGitHub Actionsへの移行やScala3対応になるのかなと思います。

PlayはLightbendから離れる決定をしたあとからOpenCollectiveで寄付を募っています。 寄付してくれてる方々を見ると日本の方も多いですね。ありがとうございます。 mkurzさんからも感謝のメッセージをいただいてます。引き続きご支援お願いします 🙏

// mkurzさんは私が何か働きかけたように思ってそうだが何もしていない
Also, a high proportion of the current Play backers on Open Collective are Japanese,
so it seems you reached out to the Japanese community already? If so, 
and if you have a mailing list or similar set up, 
would you mind to say thank you to them from me and the other contributors ;)

Modules should be fast and side-effect free と Guice は言っているがPlay Frameworkはそうなってはいない話

github.com

Modules should be fast and side-effect free

Modules should be fast and side-effect free(モジュールは速くて副作用なしであるべき)と GuiceWiki にはあります。

これは Guice モジュールの中で DB に接続したりスレッド開始したりするなという話で、これに従うのならば Guice の Module (extends AbstractModule したクラスの中とか)の他にも Module を構成する各クラスのコンストラクタの中でも副作用を起こしてはいけないことになります。

副作用を起こしてはいけない理由としては

  • Modules start up, but they don't shut down.
  • Modules should be tested.
  • Modules can be overridden.

Guice は挙げています。

Guice の意図に沿ったコードを書くと初期化処理、終了処理のためのメソッドをコンストラクタ以外に用意して、アプリケーションの起動、終了時になんらかの形で呼んであげることになります。

public interface Service {
  void start() throws Exception;
  void stop();
}
 public static void main(String[] args) throws Exception {
    Injector injector = Guice.createInjector(
        new WebserverModule(),
        ...
    );

    Service webserver = injector.getInstance(
        Key.get(Service.class, WebserverService.class));
    webserver.start();
    addShutdownHook(webserver);
  }

サンプルでは単純ですが徹底するとなると例えば設定ファイルを読み込むとかも禁止となるのでなかなか厳しい制限にも感じます。(実際には設定ファイル読み込みとかは OK にして、スレッド開始とかは NG にする、くらいが落とし所かも?)

Play Framework の場合

Play Framework は Guice を使っていますが、Guice のこの主張を完全に無視して副作用をバリバリにつかっています。Play を構成するクラスは大体コンストラクタでスレッド開始したり設定読み込んだりとやって、終了処理はコンストラクタ内で ApplicationLifecycle に登録します。

@Singleton
class NanrakanoService @Inject()(lifecycle: ApplicationLifecycle) {
  val resource = new NanrakanoBackgroundResource().start()
  lifecycle.addStopHook(() => Future.successful(resource.shutdown()))
}

Guicewiki を見たあとでこれを見るとギョッとするんですが実際のところそんなに困ることはないです。

とはいえやっぱり時々は困るし、Guice の推奨しない方法で Guice をヘビーに使っているというのは精神衛生上あまり良くはありません。そして実はコンストラクタで副作用を起こすようになったのは Play が Guice を使い始めた Play 2.4 からの話で、Guice を使っていなかった Play 2.3 以前の Plugin の仕組みでは副作用のためのメソッドが分かれていたのです。

package plugins

import play.api.{Plugin, Application}

class MyPlugin(app: Application) extends Plugin {
  val myComponent = new MyComponent()

  override def onStart() = {
    myComponent.start()
  }

  override def onStop() = {
    myComponent.stop()
  }

  override def enabled = true
}

Guice を使う前は副作用が分かれていたのに、互換性を壊してまで Guice を使い始めたら副作用起こすようになったというのがなんかチグハグだなあって思います。

PlayのCache APIでMemcachedやRedisの代わりにRDBを使う

Play frameworkはキャッシュのためのAPIplay.api.cahe というパッケージで提供しています。play.api.cache はそのバックエンドとして、CaffineやEhcacheが選択でき、またサードバーティのライブラリを使えばMemcached, Redisなどをバックエンドにすることができます。キャッシュと言うと高速化のためのものですが、Playのキャッシュは高速化の他に、単なるデータの一時保存のため、つまり単なるKey-Value Storeとして使われていたりもします。

Caffeineを使うとインメモリのキャッシュが簡単に実現できますが、アプリケーションサーバーのインスタンスが複数ある場合、各サーバーでキャッシュした内容を共有することができません。MemcachedやRedisを使えばインスタンス間でキャッシュした内容を共有できますが、MemcachedやRedisを管理する手間が発生します。

PlayのキャッシュAPIは使いたいけれど、MemcachedやRedisなどの新たなミドルウェアを導入したくない。という状況で、ならばRDBをバックエンドにしてしまおうと作ったのがdbcacheというライブラリです。実際のところ、MemcachedやRedisを使わなくてもRDBで中小規模のサービスでは事足りてしまいます。

https://github.com/tototoshi/dbcache

設定

テーブル追加

MySQLをバックエンドとして使う場合の例です。まずデータベースにcache_entriesテーブルを作成します。

CREATE TABLE `cache_entries` (
  `cache_key` varchar(191) PRIMARY KEY,
  `cache_value` mediumblob NOT NULL,
  `expired_at` datetime,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  INDEX (`expired_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

Play2.7.xの場合

Play 2.7.xを使っている場合は dbcache-mysqldbcache-play をbuild.sbtで追加します。

libraryDependencies ++= Seq(
  "com.github.tototoshi" %% "dbcache-mysql" % "0.3.0",
  "com.github.tototoshi" %% "dbcache-play" % "0.3.0"
)

Play2.6.xの場合

dbcache-play は最近追加したモジュールなので、Play 2.6.xには対応していません。Play2.6.xを使っている場合は dbcache-mysql だけを追加し、以下のコードをアプリケーションコードに追加します。

libraryDependencies ++= Seq(
  "com.github.tototoshi" %% "dbcache-mysql" % "0.2.0"
)
// Create adapter for play.api.cache
class DBCacheApi(myCache: DBCache) extends CacheApi {
  def set(key: String, value: Any, expiration: Duration): Unit =
    myCache.set(key, value, expiration)
  def get[A](key: String)(implicit ct: ClassTag[A]): Option[A] =
    myCache.get[A](key)
  def getOrElse[A: ClassTag](key: String, expiration: Duration)(orElse: => A) =
    myCache.getOrElse(key, expiration)(orElse)
  def remove(key: String) = myCache.remove(key)
}

DIの設定

あとはProviderを作ってDIしてあげるだけです。Scalikejdbcを使っている場合はこんな感じになります。

import java.sql.Connection

import com.github.tototoshi.dbcache.ConnectionFactory
import com.github.tototoshi.dbcache.mysql.MySQLCache
import com.github.tototoshi.dbcache.play.DBCacheApi
import javax.inject.{Inject, Provider}
import play.api.Environment
import play.api.cache.SyncCacheApi
import scalikejdbc.DB

class MySQLCacheApiProvider @Inject()(environment: Environment) extends Provider[SyncCacheApi] {

  private val connectionFactory = new ConnectionFactory {
    override def get(): Connection = {
      DB.autoCommitSession().connection
    }
  }
  override def get(): SyncCacheApi = {
    val mysqlCache = new MySQLCache(connectionFactory, environment.classLoader)
    new DBCacheApi(mysqlCache)
  }
}

EhCacheモジュールは無効にしておきます。

play.modules.disabled += "play.api.cache.ehcache.EhCacheModule"

あとは普通にPlayのキャッシュAPIを使えばデータベースに保存されるようになります。

運用について

期限切れのキャッシュデータがDBに残るのでバッチか何か動かしてたまに掃除してあげてください。

DELETE FROM cache_entries where expired_at < ${n日前}

いつもJavaConvertersでうまくいくとは限らない

ScalaJavaのコレクションを相互に変換するには scala.collection.JavaConverters が便利です。例えばJavaのコレクションに .asScala をつければ、JavaのコレクションをラップしたScalaのコレクションが作られます。ただし、いつも .asScala で済むとは限りません。コレクションのインターフェイスではなくデータ構造が重要な場合は注意が必要です。

次のコード例は java.util.LinkedHashMap.asScala に変換して、 +=, + の操作を行ったものです。 LinkedHashMap は要素が挿入された順番を保持するはずですが、 += を使った場合は順番が保持されるものの、 + を使った場合は順番が保持されていません。

val m = new java.util.LinkedHashMap[String, Int]()
m.put("field1", 1)
m.put("field2", 2)

import scala.collection.JavaConverters._
val m2 = m.asScala
println(m2) // Map(field1 -> 1, field2 -> 2)

m2 += "field3" -> 3
m2 += "field4" -> 4
println(m2) // Map(field1 -> 1, field2 -> 2, field3 -> 3, field4 -> 4)
val m = new java.util.LinkedHashMap[String, Int]()
m.put("field1", 1)
m.put("field2", 2)

import scala.collection.JavaConverters._
val m2 = m.asScala
println(m2) // Map(field1 -> 1, field2 -> 2)

val m3 = m2 + ("field3" -> 3) + ("field4" -> 4)
println(m3) // Map(field1 -> 1, field3 -> 3, field2 -> 2, field4 -> 4)

+= を使った場合、新しい要素は元のLinkedHashMap に挿入されます。一方、 + を使った場合は新たな Mapインスタンスが作られます。その新たな MapインスタンスJavaConverters で定義してあるラッパークラスが作るのですが、それが LinkedHashMap ではなく順番を保持しない HashMap なのです。

これは実際にplay-json 2.6.11に入り込んだバグです。play-jsonJsonのフィールドを Map として保持していますが、その MapScalaのものから java.util.LinkedHashMapインスタンスとして生成して、 JavaConverters でラップするという実装に変えたところ、Jsonのフィールドの順序が狂うという現象が起きました。

https://github.com/playframework/play-json/issues/236

というわけで、気をつけましょう。

AirframeでPlayを動かそうとした

taroleoさんが開発しているAirframeが最近Twitterのタイムラインで話題になっていることが多いので、そろそろ触ってみようかと思い、とりあえずPlay frameworkのDIコンテナをGuiceからAirframeに置き換えるというのをやって見ました。結果は失敗に終わったんですが、GuiceとAirframeの違いを知ることはできました。

PlayではデフォルトではRuntime DIにはGuiceが利用されますが、Play2.6ではDIコンテナのためのインターフェイスが作られたのでGuice以外のDIコンテナも利用できます。ApplicationLoaderというクラスがPlayのApplicationオブジェクトをロードする役割を担っているのでこれを好きなDIコンテナで実装します。

class AirframeApplicationLoader extends ApplicationLoader {
  override def load(context: ApplicationLoader.Context): Application = {
    import wvlet.airframe._
    // ここでAirframeでApplicationオブジェクトを構築する
  }
}

実際にはPlayとの橋渡し的なコードをそれなりに書く必要があり、そんなに簡単ではないです。例えばPlayが定義しているInjectorインターフェイスを実装する必要があったり、Playのビルトインモジュールを利用したいDIコンテナで使えるように変換するようなコードを書いたりです。実用的なことを考えるとPlayが提供しているGuiceApplicationBuilderみたいなコードも書く必要があり、なかなか骨が折れます。

Airframeの場合、実際にPlayの各モジュールをロードする段階でもつまづきます。Playの各モジュールは @Inject アノテーションでDIコンテナに対してクラスの生成方法のヒントを与えていますが、Airframeは @Inject アノテーションには対応していない方針のようでそれが機能しません。自分でクラスの生成方法を記述する必要があるのですが、Playの各モジュールの構築を手動でやるのはかなり面倒、ということで挫折しました。

その他Guiceとの違いで重要そうなのはsessionという概念でしょうか。Airframeではsessionが有効な間だけ機能を利用でき、また@PostConstruct, @PreDestroy アノテーションを利用するとsessionの開始時、終了時をフックして処理を挟みこめます。例えばスレッドの開始や終了などをAirframeに任せると便利ですね。Guiceにはこの機能はないので、Playでは @PostConstruct@PreDestroy の代わりに各クラスのコンストラクタとApplicationLifecycleというクラスでライフサイクルフックが実装されています。

同じDIコンテナという種類のライブラリでも比較して見るとそれなりに差異があります。それにDIコンテナはアプリケーションの基盤部分を担うようなものなので、気軽に置き換えられるようなものではないですね。でも気が向いたら再チャレンジしてみようと思います(多分やらない)。

play-jsonでReads/Writes/Formatを定義しなくてもよくする

play-jsonはReads/Writes/Formatを定義するのが面倒とよく言われるので、shapelessの練習も兼ねて作ってみました。

https://github.com/tototoshi/play-json-generic

scala> import play.api.libs.json._
import play.api.libs.json._

scala> case class Person(firstName: String, lastName: String, friends: List[Person])
defined class Person

scala> val people = Person("John",
     |                     "Lennon",
     |                     List(Person("Paul", "McCartney", Nil),
     |                          Person("George", "Harrison", Nil),
     |                          Person("Ringo", "Starr", Nil)))
people: Person = Person(John,Lennon,List(Person(Paul,McCartney,List()), Person(George,Harrison,List()), Person(Ringo,Starr,List())))

scala> Json.prettyPrint(Json.toJson(people))
<console>:18: error: No Json serializer found for type Person. Try to implement an implicit Writes or Format for this type.
       Json.prettyPrint(Json.toJson(people))
                                   ^

Reads/Writes/Formatを定義していないcase classをシリアライズしようとすると当然こんなエラーが出ますが、

import com.github.tototoshi.play.json.generic._ することでシリアライズ/デシリアライズできるようになります。

scala> import com.github.tototoshi.play.json.generic._
import com.github.tototoshi.play.json.generic._

scala> Json.prettyPrint(Json.toJson(people))
res1: String =
{
  "firstName" : "John",
  "lastName" : "Lennon",
  "friends" : [ {
    "firstName" : "Paul",
    "lastName" : "McCartney",
    "friends" : [ ]
  }, {
    "firstName" : "George",
    "lastName" : "Harrison",
    "friends" : [ ]
  }, {
    "firstName" : "Ringo",
    "lastName" : "Starr",
    "friends" : [ ]
  } ]
}

scala> Json.parse(res1).as[Person]
res2: Person = Person(John,Lennon,List(Person(Paul,McCartney,List()), Person(George,Harrison,List()), Person(Ringo,Starr,List())))

play-jsonJson.formatとかはマクロで実装されているので、そのマクロのためのオプションとかには一部対応できていないです。JsonNamingには対応できました。

Shapeless使ってみたいという人には The Type Astronaut's Guide to Shapeless - Underscore がすごくわかりやすくてオススメです。

追記

指摘されて気づいたんですが、HListを使っているので23個以上のフィールドを持つcase classにまつわる問題が解決しちゃってました。

https://github.com/tototoshi/play-json-generic/commit/0382c149efb4ed9fd4b4bcea99841641f2ca7949