Play Framework 2.9.0 に送ったPull Request
そろそろPlay Framework 2.9.0が出そうですね。ということで自分の送ったPull Requestをまとめてみました。
大きいところだとTwirlのScala 3対応ですね。いい感じにエラーメッセージ出すのにdottyとかsbtまで調べることになって沼でしたがたぶんなんとかなりました。
playframework/playframework
- Let the Java Form validator use the locale selected by Play's i18n. #11736 https://github.com/playframework/playframework/pull/11736
- Update caffeine, jcache to 3.0.1 #11444 https://github.com/playframework/playframework/pull/11444
playframework/twirl
- Added support for switching the default "import" between Scala 2 and Scala 3 #613 https://github.com/playframework/twirl/pull/613
- Handle an exception from scalameta when parsing template args fail #512 https://github.com/playframework/twirl/pull/512
- Call toIndexedSeq explicitly to reduce warnings #487 https://github.com/playframework/twirl/pull/487
- Add a testcase for By-name parameters #485 https://github.com/playframework/twirl/pull/485
- CI with GitHub Actions (using playframework/.github) #483 https://github.com/playframework/twirl/pull/483
- Add syntax related fixes for Scala3 cross build #479 https://github.com/playframework/twirl/pull/479
- Update scalafmt to 3.3.1 #478 https://github.com/playframework/twirl/pull/478
- feat(scala3): WIP working on scala 3 cross compilation. #390. #461 https://github.com/playframework/twirl/pull/461
sbt/zinc
- Display error messages based on the transformed positions when source mapping is perfomed #1082 https://github.com/sbt/zinc/pull/1082
おまけ
今年もかぼちゃ育てたので見て
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はそうなってはいない話
Modules should be fast and side-effect free
Modules should be fast and side-effect free(モジュールは速くて副作用なしであるべき)と Guice の Wiki にはあります。
これは 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())) }
Guice の wiki を見たあとでこれを見るとギョッとするんですが実際のところそんなに困ることはないです。
とはいえやっぱり時々は困るし、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はキャッシュのためのAPIを play.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-mysql
と dbcache-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でうまくいくとは限らない
ScalaとJavaのコレクションを相互に変換するには 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-jsonはJsonのフィールドを Map
として保持していますが、その Map
をScalaのものから 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-jsonのJson.formatとかはマクロで実装されているので、そのマクロのためのオプションとかには一部対応できていないです。JsonNamingには対応できました。
Shapeless使ってみたいという人には The Type Astronaut's Guide to Shapeless - Underscore がすごくわかりやすくてオススメです。
追記
指摘されて気づいたんですが、HListを使っているので23個以上のフィールドを持つcase classにまつわる問題が解決しちゃってました。
https://github.com/tototoshi/play-json-generic/commit/0382c149efb4ed9fd4b4bcea99841641f2ca7949