Play Frameworkを使ったWebアプリケーション作成入門4日目

Webアプリケーション作成の4日目は、Web APIを作成してみましょう。

Web APIは、iPhoneやAndroidなどのネイティブアプリケーションから呼ばれたり、シングルページアプリケーションのJavaScriptから呼ばれたりなど、利用されるケースがどんどん増えてきています。

3日目まで作成したWebアプリケーションは表示用のHTMLをレスポンスとして返していましたが、今回はHTMLではなくJSONを返すようなサーバーに修正します。レスポンスをJSONに変更することでネイティブアプリケーションやJavaScriptで利用することができるようになります。

PlayにはJSONを扱うための便利な機能もあるので、そちらも見ていきましょう。

掲示板の内容をJSONで取得できるようにする

3日目までに作成していた掲示板アプリケーションをWeb API化して、投稿されたメッセージをJSONで取得できるようにしましょう。利用例として別のフロントサーバーからJavaScriptでWeb APIが利用されるとします。

サンプルコードは http://github.o-in.dwango.co.jp/Scala/textbook/tree/master/src/example_projects/web-app-4th-day-textboard にあります。

ルーティングの設定

掲示板のWeb APIは2つのURLを持ちます。メッセージの投稿と、投稿されたメッセージの取得です。

Web APIのURLはWebページのものとは違う方針で作成する必要があります。 WebページのURLはページの機能に合わせて決められると思いますが、Web APIのURLの場合は通常リソースに合わせて決められます。

掲示板の場合は「投稿されたメッセージ」がリソースになります。ここではこのリソースのURLを /posts としましょう。この /posts に対して、たとえばGETなら取得、POSTなら投稿、(今回はできませんが)DELETEなら削除のようになります。

というわけで routes ファイルは以下のようになります。

routes

GET    /posts   controllers.PostsController.get()
POST   /posts   controllers.PostsController.post()

PlayのJSONの作り方

今回のWeb APIのレスポンスはJSONになるのでレスポンス用にJSONを作成しなければなりませんが、PlayにはいくつかのJSONの作り方があります。

文字列からJSONを作る

一番わかりやすいのが文字列から作る方法かもしれません。

Json.parse にJSONの文字列を与えるとPlayのJSONオブジェクトを作成することができます。

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

scala> Json.parse("""{"name":"randy","age":26}""")
res0: play.api.libs.json.JsValue = {"name":"randy","age":26}

Playの Json のメソッドを使って組み立てる

Playの Json のメソッドを使ってJSONオブジェクトを作成することもできます。

scala> Json.obj("name" -> "randy", "age" -> 26)
res1: play.api.libs.json.JsObject = {"name":"randy","age":26}

Writesを使ってScalaのオブジェクトを変換する

Playの Writes オブジェクトをクラスに対して用意すると、そのクラスのオブジェクトをJSONに変換することができます。

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

scala> case class Person(name: String, age: Int)
defined class Person

scala> implicit val writes: Writes[Person] =
     |   new Writes[Person] {
     |     def writes(person: Person) =
     |       Json.obj("name" -> person.name, "age" -> person.age)
     |   }
writes: play.api.libs.json.Writes[Person] = $anon$1@2797ace6

scala> Json.toJson(Person("randy", 26))
res2: play.api.libs.json.JsValue = {"name":"randy","age":26}

作成した Writes オブジェクトは Json.toJson メソッドのimplicitパラメーターとして渡されます。

型クラスの復習

このように型に応じた実装をimplicitで取得する方法は、1週目でも学びましたが型クラスと呼ばれることがあります。この Writes(と読み込み用の Reads)はScalaの型クラスの代表的な利用例として挙げられることが多いです。ここでは少し実装を中断して、型クラスの復習のつもりで型クラスについて考えてみましょう。

型クラスのメリット

まず、型クラスを使うことの利点をいくつか挙げたいと思います。

元のデータ構造に変更の必要がない

上記の例では Person とは別に Writes[Person] を定義することでJSON変換を実装しており、元の Person には変更を加えていません。これは既存のコードに機能を加えたいが既存のコードを変更したくない場合には便利な性質です。

継承を用いるより型クラスを使ったほうが実装を分離できる

たとえばPlayの Writes ではなく、継承でJSON変換を実現する Writeable というインタフェースがあるとします。その場合、JSON変換は以下のようなコードになるでしょう。

import play.api.libs.json.JsValue

class Person(name: String, age: Int)

trait Writeable {
  def toJson: JsValue
}

case class WriteablePerson(name: String, age: Int) extends Person(name, age) with Writeable {
  def toJson: JsValue =
    Json.obj("name" -> name, "age" -> age)
}

この例は単純な例なのでわかりづらいかもしれませんが、実装が WriteablePerson という具象クラスに集中してしまっています。型クラスを使った例では Writes[Person] にJSONへの変換をおこなうコードがありました。型クラスを使ったほうが実装を分離できていると言えます。こういったインタフェースが増えていくとメソッドの衝突も発生するので、さらにこの問題は大きくなっていきます。

他にも、たとえばJavaの最上位クラスである Object には equals メソッドや toString メソッドがありますが、型クラスを使えば比較や文字列化の型クラスを用意すればいいので(Haskellにはそのための ShowEq という型クラスがあります)最上位クラスにこのようなメソッドを持つ必要がなく、最上位クラスをもっと最小限の機能を有するクラスにできたことでしょう。

型を使ったほうがコンパイル時に結果が判明する場合がある

型クラスを使ったほうがプログラミングのミスを防ぎやすい場合もあります。代表的な例は先ほども例に挙げた equals メソッドですが、 equals メソッドを使ったオブジェクトの比較の場合、equals メソッドはすべてのクラスを引数に取れるので実行時でなければオブジェクトが等しいかどうかわかりません。しかし、明らかにプログラミングのミスでまったく違う型同士を比較した場合でも、コンパイラがコンパイル時には判断できず実行時に問題が判明するので手間が増えたり、わかりにくいバグの原因になったりします。

しかし、型クラスを使ったオブジェクトの比較の場合、違う型の比較ではそもそもコンパイルエラーになります。このことをJavaとScalaのコードで表現すると以下のようになります

class Object {
   boolean equals(Object obj) // どのクラスでもコンパイルエラーにはならない
}
trait Equal[T] {
  def equal(a: T, b: T): Boolean // 二つの引数が同じ型でないとコンパイルエラー
}

実際ScalaではJavaの影響からオブジェクトの比較はJavaと同様におこなわれますが、型クラスの効用としてこのようなことがあるということも覚えておくとよいと思います。

Scalaで型クラスを使うことのデメリット

これまで述べてきたように型クラスには様々なメリットがあるのですが、実践的には型クラスが使われないことも多いので、その理由として型クラスのデメリットについても述べておきます。

型クラスインスタンスの実装の労力

型クラスがScala全体で採用されにくい理由の1つは、型クラスインスタンスの単調な実装をたくさんしなければならないことが挙げられます。

Haskellではderiving構文による型クラスインスタンスの自動導出機能があり、型クラスインスタンスの実装労力が軽減されているのですが、Scalaではそういう機能はありません。

型クラスと同じ機能を継承を用いて実装する場合、実装をスーパークラスから引き継ぐことができるので、労力が少なくて済むことが多いです。継承による実装の引き継ぎはスーパークラスとサブクラスの密結合を引き起こし、多用するとそれはそれで問題になるのですが、そのあたりは実装労力とのトレードオフということになるでしょう。

高階型プログラミングの難しさ

型クラスはHaskellやScalaなど極一部のプログラミング言語にしかない機能ですが、これには理由があります。型クラスを実現するためには implicit による型の同一性による多相(これをアドホック多相と呼びます)に加えて、実質的にプログラミング言語に高階型(カインドと呼ばれることもあります)の機能が必要になります。

型クラスは型に対する性質を扱う以上、型を取り型を返すような型(たとえば ListString のような型と合わせて List[String] のような型になるような型です)を扱う必要があり、プログラミング言語にもこのような機能が求められることになります。このような概念が難しい機能は極一部のプログラミング言語にしかないものになってしまいます。高階型はプログラミングするも難しいと言えると思います。

継承との相性の悪さ

継承がある言語の場合、継承によりたくさんのサブクラスが生まれますが、型クラスはそれに合わせて型クラスインスタンスをたくさん用意しなければならず、実装の労力はさらに増大します。

また高階型のプログラミングをする場合、継承があると型変数に変位指定を付けなければならないことがありますが、型変数の変位指定と高階型が組み合わさると、プログラミングがたいへん難解になります。よって、高階型プログラミングをすることが多い型クラスのプログラミングもまた難解になってしまいます。

こういった継承との相性の悪さも、型クラスを全面的に採用するのが難しい一因になっています。

ただ前述のように型クラスが有効である場面も多いですし、ここでは紹介しませんが高階型の型クラスを使うからこそできる表現もあるので、デメリットをなるべく避けつつピンポイントで型クラスを採用するというのが良いと思います。

Playのマクロを使って Writes を作成する

長々と型クラスについて述べてしまいましたが、実装のほうに戻ることにしましょう。

先ほど、型クラスのデメリットとして型クラスインスタンスの実装がめんどくさいということを述べましたが、Playの Writes にはこの問題を軽減する仕組みが入っています。

上のほうでおこなった Writes の実装を見直してみると "name" という名前で Person クラスの name をシリアライズするというのはいかにも自明な実装と言えると思います。

implicit val writes: Writes[Person] =
  new Writes[Person] {
    def writes(person: Person) =
      Json.obj("name" -> person.name, "age" -> person.age)
    }

こういうクラスのフィールド名とJSONのフィールド名が同じであるような自明な実装をおこなう場合は、Playで提供しているマクロを使って以下のように簡潔に書くことができます。

implicit val writes: Writes[Person] = Json.writes[Person]

今回はこの方法を使ってJSON変換コードを作っていきたいと思います。

app/controllers/Post.scala にJSON変換コードを追加する

レスポンスでJSONを返すために以下の Writes のコードを Post のコンパニオンオブジェクトに追加します。

  implicit val writes: Writes[Post] = Json.writes[Post]

全体のコードは以下のようになります。

package controllers

import java.time.OffsetDateTime
import play.api.libs.json.Json
import play.api.libs.json.Writes

case class Post(id: Long, body: String, date: OffsetDateTime)

object Post {
  implicit val writes: Writes[Post] = Json.writes[Post]

  def apply(body: String, date: OffsetDateTime): Post =
    Post(0, body, date)
}

1週目でも触れましたが、implicitの探索にはコンパニオンオブジェクトが含まれるので、明示的にimportしなくても Writes[Post] を使うことができるようになりました。

このようにコンパニオンオブジェクトに型クラスインスタンスを置く方法はよく使われるので覚えておいてください。

レスポンスの形式

最初に述べたようにレスポンスはJSONで返すわけですが、ただ値を返すだけでなく、エラー時のエラーメッセージなどの追加の情報を返したくなる場合があります。エラーをHTTPのレスポンスヘッダーで返すべきかJSONで返すべきかは諸説ありますが、JavaScriptからの取り扱いやすさ、データ構造の柔軟性、JSONスキーマの普及によるバリデーションなどを考えると、現在はJSONで返すのがよいと思われます。

そこで、レスポンスの形式の形式を以下のようにしたいと思います。

  • meta
    • レスポンスのデータではなく、レスポンスのステータスコードやエラー時のエラーメッセージを入れる。
  • data
    • レスポンスの値を入れる。

この形式をコードにすると以下のようになります。

package controllers

import play.api.libs.json.Json
import play.api.libs.json.JsValue
import play.api.libs.json.Writes

case class Meta(status: Int, errorMessage: Option[String] = None)

object Meta {
  implicit val writes: Writes[Meta] = Json.writes[Meta]
}

case class Response(meta: Meta, data: Option[JsValue] = None)

object Response {
  implicit def writes: Writes[Response] = Json.writes[Response]
}

この Response クラスを使うとたとえば以下のようなJSONが生成されます。

{
  "meta": {
    "status": 200
  },
  "data": {
    "posts": [
      {
        "id": 1,
        "body": "abc",
        "date": "2017-05-21T18:51:06.734+09:00"
      }
    ]
  }
}

コントローラー

この Response を使ってコントローラーを書き換えると以下のようになります。

package controllers

import java.time.OffsetDateTime
import javax.inject.Inject
import play.api.mvc.Action
import play.api.mvc.Controller
import play.api.data.Form
import play.api.data.Forms._
import play.api.i18n.I18nSupport
import play.api.i18n.Messages
import play.api.i18n.MessagesApi
import play.api.libs.json.Json

case class PostRequest(body: String)

class PostsController @Inject()(val messagesApi: MessagesApi) extends Controller with I18nSupport {

  private[this] val form = Form(
    mapping(
      "post" -> text(minLength = 1, maxLength = 10)
    )(PostRequest.apply)(PostRequest.unapply))

  def get = Action { implicit request =>
    Ok(Json.toJson(Response(Meta(200), Some(Json.obj("posts" -> Json.toJson(PostRepository.findAll))))))
  }

  def post = Action { implicit request =>
    form.bindFromRequest.fold(
      error => {
        val errorMessage = Messages(error.errors("post")(0).message)
        BadRequest(Json.toJson(Response(Meta(400, Some(errorMessage)))))
      },
      postRequest => {
        val post = Post(postRequest.body, OffsetDateTime.now)
        PostRepository.add(post)
        Ok(Json.toJson(Response(Meta(200))))
      }
    )
  }
}

各クラスに Writes の型クラスインスタンスが定義されているので Json.toJsonResponsePost のオブジェクトをJSONに変換しています。

CORS対応を追加する

以上でだいたいWeb APIとしての実装は終わりましたが、このままでは別のサーバーにあるJavaScriptからこのAPIを使うことができません。

ブラウザでのJavaScriptの通信には同一生成元ポリシーというものがあり、JavaScriptからサーバーへの通信はJavaScriptを返したサーバーとしかおこなうことができないという制限があります。

この制限を緩和するためにCORS(Cross-Origin Resource Sharing)という仕組みがあります。 CORSの基本的な動作はWeb APIのレスポンスに Access-Control-Allow-Origin ヘッダーを追加し、そのヘッダの値に許可するサイトを記述するというものです。今回の場合、フロントサーバー(ここではフロントサーバーのURLを http://localhost:3000 とします)からのアクセスを許したいので Access-Control-Allow-Origin: http://localhost:3000 というヘッダーになります。最低限この設定によりフロントサーバーからWeb APIへJavaScriptで通信をおこなうことができるようになります。

CORSには他にも Access-Control-Allow-Methods ヘッダーで使えるHTTPのメソッドを制限したり、HTTPのカスタムヘッダーを使いたい場合はプリフライトと呼ばれる OPTION リクエストにも対応しなければなりません。そのすべてに対応するのはたいへんなので、今回はPlayに用意されているCORSのための仕組みを使うことにします。

build.sbt

PlayのCORS対応はフィルターという仕組みが使われています。フィルターはすべてのリクエストやレスポンスに対する処理など横断的な処理を追加したい場合に便利な機能です。フィルターは build.sbtfilters というライブラリを追加する必要があるので追加します。

lazy val root = (project in file(".")).enablePlugins(PlayScala)

scalaVersion := "2.11.11"

val scalikejdbcVersion = "3.0.0"

libraryDependencies ++= Seq(
  jdbc,
  evolutions,
  filters,
  "org.scalikejdbc"        %% "scalikejdbc"                  % scalikejdbcVersion,
  "org.scalikejdbc"        %% "scalikejdbc-config"           % scalikejdbcVersion,
  "org.scalikejdbc"        %% "scalikejdbc-play-initializer" % "2.5.1",
  "org.scalatestplus.play" %% "scalatestplus-play"           % "2.0.0" % Test
)

app/Filters.scala

フィルターを使うためにはパッケージのトップレベルで HttpFilters を継承したクラスを作成すればよいです。またPlayが提供するCORS用のフィルターの CORSFilter を依存性の注入で受け取るようにします。

import javax.inject.Inject

import play.api.http.DefaultHttpFilters
import play.filters.cors.CORSFilter

class Filters @Inject() (corsFilter: CORSFilter) extends DefaultHttpFilters(corsFilter)

conf/application.conf

最後にPlayが提供するCORS用のフィルター用の設定を application.conf に追加すれば今回の実装は終わりです。

今回は http://localhost:3000 からのアクセスを許したいので以下の設定を加えます。

play.filters.cors.allowedOrigins = ["http://localhost:3000"]

application.conf 全体では以下のようになります。

play.i18n.langs = [ "en", "ja" ]

db.default.driver=org.h2.Driver
db.default.url="jdbc:h2:file:./textboarddb;MODE=MYSQL"

play.modules.enabled += "scalikejdbc.PlayModule"

play.filters.cors.allowedOrigins = ["http://localhost:3000"]

動作確認

軽くCORS対応の動作確認をしてみましょう。

$ curl -v http://localhost:9000/posts -H "Origin: http://localhost:3000"
*   Trying ::1...
* Connected to localhost (::1) port 9000 (#0)
> GET /posts HTTP/1.1
> Host: localhost:9000
> User-Agent: curl/7.43.0
> Accept: */*
> Origin: http://localhost:3000
>
< HTTP/1.1 200 OK
< Vary: Origin
< Access-Control-Allow-Origin: http://localhost:3000
< Access-Control-Allow-Credentials: true
< Content-Length: 43
< Content-Type: application/json
< Date: Sun, 21 May 2017 07:27:29 GMT
<
* Connection #0 to host localhost left intact
{"meta":{"status":200},"data":{"posts":[]}}

Origin をリクエストヘッダーに追加すると Access-Control-Allow-Origin が返ってくることが確認できました。

テスト

アプリケーションがWebページを返すものからWeb APIに変更になったので、テストも変更する必要があります。 3日目のテストではSeleniumを使ったブラウザのテストでしたが、今回はPlayが提供しているWSというHTTPクライアントを使ったテストになります。

テスト用の場合 WsTestClient.withClient でWSのインスタンスを取得できるので、URLを指定し getpost のメソッドを呼ぶとHTTPリクエストをすることができます。

たとえば何も投稿してない掲示板へのテストは以下のようになります。

  "GET /posts" should {
    "何も投稿しない場合は空の配列を返す" in {
      WsTestClient.withClient { ws =>
        val response = Await.result(ws.url(s"http://localhost:$port/posts").get(), Duration.Inf)
        assert(response.status === 200)
        assert(response.json === Json.parse("""{"meta":{"status":200},"data":{"posts":[]}}"""))
      }
    }
  }

WSからのレスポンスは Future で返ってくるので Await.result で結果を取り出します。レスポンスの json メソッドを使うとレスポンスがJSONである場合にPlayのJSONオブジェクトを取得できるので内容を確認しています。

JSONの一部の値だけ取り出したい場合は \ "フィールド名" というメソッドを使うことができます。

たとえば空のメッセージを送ってしまった場合のエラーを取り出したい場合は以下のようになります。

   "空のメッセージは投稿できない" in {
      WsTestClient.withClient { ws =>
        val body = ""
        val response = Await.result(ws.url(s"http://localhost:$port/posts").post(Map("post" -> Seq(body))), Duration.Inf)
        assert(response.status === 400)
        assert((response.json \ "meta" \ "status").as[Int] === 400)
        assert((response.json \ "meta" \ "errorMessage").as[String] === "Please enter a message.")
      }
    }

レスポンスは以下のようなJSONになっていますが、

{
  "meta": {
    "status": 400,
    "errorMessage": "Please enter a message."
  }
}

(response.json \ "meta" \ "errorMessage").as[String] でエラーメッセージを取り出すことができます。

テスト全体では以下のようになります。他のテストケースも実装は同様なので説明は省略します。

import org.scalatestplus.play.PlaySpec
import org.scalatestplus.play.guice.GuiceOneServerPerSuite
import play.api.inject.guice.GuiceApplicationBuilder
import play.api.libs.json.Json
import play.api.libs.json.JsValue
import play.api.test.WsTestClient
import scala.concurrent.Await
import scala.concurrent.duration.Duration

class PostsSpec extends PlaySpec with GuiceOneServerPerSuite {

  override def fakeApplication() =
    new GuiceApplicationBuilder()
      .configure(
        "db.default.driver" -> "org.h2.Driver",
        "db.default.url" -> "jdbc:h2:mem:test;MODE=MYSQL")
      .build()

  "GET /posts" should {
    "何も投稿しない場合は空の配列を返す" in {
      WsTestClient.withClient { ws =>
        val response = Await.result(ws.url(s"http://localhost:$port/posts").get(), Duration.Inf)
        assert(response.status === 200)
        assert(response.json === Json.parse("""{"meta":{"status":200},"data":{"posts":[]}}"""))
      }
    }
  }

  "POST /posts" should {
    "投稿したものが返される" in {
      WsTestClient.withClient { ws =>
        val body = "test post"
        val postResponse = Await.result(ws.url(s"http://localhost:$port/posts").post(Map("post" -> Seq(body))), Duration.Inf)
        assert(postResponse.status === 200)
        val getResponse = Await.result(ws.url(s"http://localhost:$port/posts").get(), Duration.Inf)
        assert(getResponse.status === 200)
        assert((getResponse.json \ "meta" \ "status").as[Int] === 200)
        val posts = (getResponse.json \ "data" \ "posts").as[Array[JsValue]]
        assert(posts.length === 1)
        assert((posts(0) \ "body").as[String] === body)
      }
    }

    "空のメッセージは投稿できない" in {
      WsTestClient.withClient { ws =>
        val body = ""
        val response = Await.result(ws.url(s"http://localhost:$port/posts").post(Map("post" -> Seq(body))), Duration.Inf)
        assert(response.status === 400)
        assert((response.json \ "meta" \ "status").as[Int] === 400)
        assert((response.json \ "meta" \ "errorMessage").as[String] === "Please enter a message.")
      }
    }

    "長すぎるメッセージは投稿できない" in {
      WsTestClient.withClient { ws =>
        val body = "too long messages"
        val response = Await.result(ws.url(s"http://localhost:$port/posts").post(Map("post" -> Seq(body))), Duration.Inf)
        assert(response.status === 400)
        assert((response.json \ "meta" \ "status").as[Int] === 400)
        assert((response.json \ "meta" \ "errorMessage").as[String] === "The message is too long.")
      }
    }
  }
}

演習問題:予定表アプリケーションをWeb API化する

3日目に作成した予定表アプリケーションをWeb APIとして使えるようにしてみましょう。

results matching ""

    No results matching ""