読者です 読者をやめる 読者になる 読者になる

ux00ff

ビールとプログラミングと

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

サーバが立ち上がるので、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-Typeapplication/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と書いてるのかしらと邪推したりしつつ。