Hello PlayFramework2
ちょっと仕事でWeb API周りを用意する必要がありまして。流れでScalaだろうなぁという話もあって、PlayFrameworkをチラ見していました。ん、Play2 Framework? PlayFramework2? どっちだろう。まあよい。
JSONを返すエンドポイントを作成するロールプレイ
プロジェクトを作成する
$ sbt new playframework/play-scala-seed.g8
ここでは play-api
としました。
プレーンテキストなエンドポイントを作る
まずはエンドポイントに対応するコントローラとメソッドを作成します。ドキュメントによると、「Controllers are action generators」だそうです。かっこいいですね。もともと雛形として HomeControllerとか作られていますが、適当に新しいファイルを作っても作らなくてもいいです。controllersの下に作りましょう。
以下のような簡単な新しいコントローラを作成しました。アクセスすると hello world と挨拶してくれるイメージです。
package controllers import javax.inject._ import play.api._ import play.api.mvc._ @Singleton class GoodController @Inject() extends Controller{ def hello = Action { implicit request => Ok("hello world") } }
で、次にスペックを書きます。GoodControllerSpec.scala
とか作りましょう。
package controllers import org.scalatestplus.play._ import play.api.test.Helpers._ import play.api.test._ class GoodControllerSpec extends PlaySpec with OneAppPerTest { "GoodController GET" should { "render the hello page from a new instance of controller" in { val controller = new GoodController val hello = controller.hello().apply(FakeRequest()) status(hello) mustBe OK contentType(hello) mustBe Some("text/plain") contentAsString(hello) must include ("hello world") } } }
テストを走らせて確認します。ここで動いているのは scalatestplus
(http://www.scalatest.org/plus) さん。
$ sbt test
ところでここではコントローラのロジックを同期処理で書いていますが、外部APIやファイルシステムとのやりとりなど、時間のかかる処理が必要な場合、Futureを返すデザインになっています。
import play.api.libs.concurrent.Execution.Implicits.defaultContext @Singleton class GoodController @Inject() extends Controller { def hello = Action.async { implicit request => scala.concurrent.Future { Ok("hello async world") } } }
ここら辺は自然な感じで楽しいですね。
Okは200番のレスポンスを返すヘルパーだけど、他にNotFoundとかRedirectなどが用意されているのは予想される通りです。(https://playframework.com/documentation/2.0/api/scala/play/api/mvc/Results.html)
URLルーティングを定義する
URLルーティングは conf/routes
に記述します。
GET /hello controllers.GoodController.hello
URLの構成部分をパラメータとして受け取りたい場合とかいろいろあるけどこのあたりを参考にしました。(https://www.playframework.com/documentation/ja/2.4.x/ScalaRouting)
サーバ立ち上げる
動かす。組み込みのサーバはデフォルトでは 9000 番のボート使うんだけどノートン先生が9000番を使っているのでSBT_OPTSを経由してポート番号を指定してます。
$ SBT_OPTS="-Dhttp.port=9876" sbt run
(ここは、.sbtopts に-Dhttp.port=9876
を記述した方が楽かもしれない。)
サーバが立ち上がるので、curlで叩いてみます。
$ curl -i localhost:9876/hello HTTP/1.1 200 OK Set-Cookie: PLAY_SESSION=095572471e39d21cddc1c884553b5f798104b954-csrfToken=92df512f2e93248145774b60894fab4722e58d1d-1494037575800-1e7a8115f17d3f765a7d99ad; Path=/; HTTPOnly X-Frame-Options: DENY X-XSS-Protection: 1; mode=block X-Content-Type-Options: nosniff Content-Security-Policy: default-src 'self' X-Permitted-Cross-Domain-Policies: master-only Content-Length: 11 Content-Type: text/plain; charset=utf-8 Date: Sat, 06 May 2017 02:26:15 GMT hello world
PLAY_SESSIONというクッキーが発行されているがこれはセッションキーではなくデータストアそのものなので注意すること。デフォルトでPlayFrameworkはセッションをクライアント内に保存する。X-Frame-OptionsがデフォルトでDENYなのはポイント高いです。けど、ハマることはありそうだなぁ。
JSON返すようにする
Playのドキュメントでplay.api.libs.json使えと書いているので素直に採用してみましょう。
import play.api.libs.json.{JsObject, JsString} @Singleton class GoodController @Inject() extends Controller{ def hello = Action { implicit request => Ok(JsObject(Seq("hello" -> JsString("World")))) } }
ちゃんと Content-Type
も application/json
になってる。えらい。
$ curl -i localhost:9876/hello HTTP/1.1 200 OK Set-Cookie: PLAY_SESSION=b613e92210b44c312fe0f9af2e413d7bdf213d18-csrfToken=59841d99cb92482f03d0a5415f8b22d5ff5279d4-1494039127986-505c7f52f1f22530a6d70730; Path=/; HTTPOnly X-Frame-Options: DENY X-XSS-Protection: 1; mode=block X-Content-Type-Options: nosniff Content-Security-Policy: default-src 'self' X-Permitted-Cross-Domain-Policies: master-only Content-Length: 17 Content-Type: application/json Date: Sat, 06 May 2017 02:52:08 GMT {"hello":"World"}
なお、「JsObjectってMapでなくSeq渡してる…?」と思ったけどそういうものですね。
Ok(JsObject(Seq("hello" -> JsString("World"), "hello" -> JsString("Yahoo"))))
こうすると生成されるJSONは {"hello":"Yahoo"}
になる。
テスト直す
JSONデータが帰ってくるようになったので、テストが落ちているはずです。直しましょう。
"GoodController GET" should { "render the hello json data from a new instance of controller" in { val controller = new GoodController val hello = controller.hello().apply(FakeRequest()) status(hello) mustBe OK contentType(hello) mustBe Some("application/json") contentAsString(hello) must include ("""{"hello":"world"}""") } }
こんな感じにしときゃいいでしょう。よしよし。
実行版を作成する
distタスクを使います。
$ sbt dist
こうすると、target/universal/
以下に必要なjarなどを全部まとめたzipファイルが生成されます。こいつを展開して bin/(パッケージ名)-(バージョン)
というスクリプトを実行しましょう。
最初にプロジェクトを作成したばかりの時は、アプリケーションシークレットが設定されてないぞと怒られます。
[error] p.a.l.c.CryptoConfigParser - The application secret has not been set, and we are in prod mode. Your application is not secure. [error] p.a.l.c.CryptoConfigParser - To set the application secret, please read http://playframework.com/documentation/latest/ApplicationSecret
そうしたら、conf/application.conf
に適当に入れておきましょう。(運用前にちゃんと変える)
play.crypto.secret="hello_play_hogehoge"
デプロイはこのzipを配布、展開、ってことになるのかな。
ということで
設定ファイルやコードの書き換えに対するライブリロードもちゃんと走るし、そんな煩雑な記述が必要となるわけでもない。非同期IOなどのパターンもあらかじめ盛り込まれているし、トリッキーな印象はなく手堅く書けそうな印象でした。
ところでPlayFrameworkのバージョンは2.5系が最新ですが「Play2」と呼ぶのがお洒落なのだろうか。Playはググラビリティ低いのでPlay2と書いてるのかしらと邪推したりしつつ。