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

Play Frameworkの WS API でHTTP GETする

Scala Play

Playには、HTTP通信するためのライブラリが用意されてます。
play.api.libs.ws.WS というやつですね。

ドキュメントにもある通り、これを使えば時間がかかるであろうHTTP通信を非同期に行うことができます。
http://www.playframework-ja.org/documentation/2.0.4/ScalaWS

WS を使ってRSSを取得してみました。
使ったのはPlay2.1.1です。

GETリクエストする

以下のように、GETリクエストできます。結果はFutureで返ってきます。

val rss: Future[ws.Response] = WS.url("http://example1/atom.xml").get()

Futureが分かってる人はもうこれで終わりです。

でも、おれはFutureがよく分かってないので、1つずつ解きほぐしていく必要があります。

WS API は非同期でHTTP通信するため、get()メソッドを呼び出すとFutureオブジェクトをとりあえず返してきます。
この時、HTTP通信はまだ終わっていません。
HTTP通信が終わってなくても、とりあえずFutureオブジェクトが返ってくるので次の処理に進むことができます。

GETリクエストの結果を使う

じゃあ、RSSを取得して、それをそのまま返すようなActionを作ってみます。
先に言っておくと、下の例は良くないやり方です。

import play.api.libs.ws
import play.api.libs.ws.WS
import scala.concurrent._
import ExecutionContext.Implicits.global
import scala.concurrent.duration.Duration

object Application extends Controller {

  def index = Action {
    val rss: Future[ws.Response] = WS.url("http://example1/atom.xml").get()
    val response: ws.Response = Await.result(rss, Duration(1000, MILLISECONDS))
    Ok(response.body)
  } 

}

RSSを取得するためのHTTP GETに対するレスポンスを取得します。
つまり、Futureの中身にあるws.Responseオブジェクトを取得します。

それには、Await.restult()を使います。
HTTP通信が非同期で行われるため、Await.result()を呼び出した時には、まだHTTP通信が終わってない可能性があります。
上の例では、1000ミリ秒以内にHTTP通信が終わればレスポンスを取得でき、1000ミリ秒以内にHTTP通信が終わらない場合はTimeoutExceptionが発生します。

レスポンスが取得できたら、レスポンスボディをそのままOk()に指定しています。
(Ok()はSimpleResultオブジェクトを生成するためのメソッドですね)

ところで、

import ExecutionContext.Implicits.global

というような謎のimport文がありますが、これはFutureオブジェクトのメソッドがImplicitパラメータを受け取るようになっているのがあって、そのためです。
FutureオブジェクトのほとんどのメソッドはImplicitパラメータが必要なので、Futureを使うときはimportしておいた方がよさそうです。

AsyncResultを使う

さっきも言いましたが、Awaitでレスポンスを取得するのは良くないやり方です。
せっかく時間が掛かりそうなHTTP通信を非同期処理にして、さっさと次の処理移れるような仕組みなってるのに、結局Futureから結果を取得するために、1000ミリ秒待っているからです。
これじゃあ非同期にしてる意味がない。

そこで登場するのが、Controllerで定義されているAsync() 。
こいつは、Future[Result]を受け取ってAsyncResultを返します。
AsyncResultは、Okで生成したSimpleResultと同様に、Resultの子クラスなのでActionの結果に使えます。

Async()を利用するために、WS API で手に入れたFuture[ws.Response]からws.Responseを取り出して、Future[Result]に変換する必要があります。
これは、List("1", "2", "3").map{ s => s.toInt } というようにList[String]からList[Int]に変換するのと同じです。
Futureにもmapが定義されているので、以下のように書けます。

val rss: Future[ws.Response] = WS.url("http://example1/atom.xml").get()
rss map { response => Ok(response.body) }

これでFuture[Result]が手に入るので、Asyncへ渡すようにします。

def index = Action {
  Async {
    val rss: Future[ws.Response] = WS.url("http://example1/atom.xml").get()
    rss map { response => Ok(response.body) }
  }
}

例外をハンドリングする

適当なURLを指定した場合など、Futureからws.Responseを取得しようとしたときに、例外が発生します。
発生した例外をハンドリングしてみます。
Futureのrecover()メソッドを使うことで例外をハンドリングできます。

def index = Action {
  Async {
    val rss: Future[ws.Response] = WS.url("http://example1/atom.xml").get()
    rss map { response =>
      Ok(response.body)
    } recover {
      case e: java.net.ConnectException => Ok("RSSを取得できませんでした")
    }
  }
}

2つのRSSを取得する

2つのRSSを取得して両方のレスポンスを使って結果を返したい場合の話です。

val rss1 = WS.url("http://example1/atom.xml").get()
val rss2 = WS.url("http://example2/atom.xml").get()

2つのFutureオブジェクトを使えばいいのですが、下のように単純に入れ子にしてもダメですね。

def index = Action {
  Async {
    val rss1 = WS.url("http://example1/atom.xml").get()
    val rss2 = WS.url("http://example2/atom.xml").get()
    rss1 map { response1 =>
      rss2 map { response2 =>
        Ok(response.body)
      }
    } recover {
      case e: java.net.ConnectException => Ok("RSSを取得できませんでした")
    }
  }
}

mapの定義上、ws.Response => Result という関数にする必要がありますが、上記のように入れ子にしてしまうと、ws.Response => Future[Result] となってしまいます。
res1 map { ・・・ } の結果は、Future[Future[Result]]になってしまいます。
こんな時はflatMap。

これは、Listなどの操作といっしょです。

scala> List(1,2,3) map { x => List(1,2) map( x * _) }
res1: List[List[Int]] = List(List(1, 2), List(2, 4), List(3, 6))

scala> res1.flatten
res2: List[Int] = List(1, 2, 2, 4, 3, 6)

scala> List(1,2,3) flatMap { x => List(1,2) map( x * _) }
res3: List[Int] = List(1, 2, 2, 4, 3, 6)

じゃあ、さっきのmapを入れ子にしたやつを、flatMapで書き換えてみます。

def index = Action {
  Async {
    val rss1 = WS.url("http://example1/atom.xml").get()
    val rss2 = WS.url("http://example2/atom.xml").get()
    rss1 flatMap { response1 =>
      rss2 map { response2 =>
        Ok(response1.body + "\n\n" + response2.body)
      }
    } recover {
      case e: java.net.ConnectException => Ok("RSSを取得できませんでした")
    }
  }
}

これで2つのFutureを組み合わせることができました。

ちなみに、Futureはmap, flatMapを定義しているので以下のようにfor式で書くこともできます。
複数個のFutureを組み合わせて入れ子が深くなるならfor式の方が見やすいです。

def index = Action {
  Async {
    val rss1 = WS.url("http://example1/atom.xml").get()
    val rss2 = WS.url("http://example2/atom.xml").get()
    
    val f = for {
      response1 <- rss1
      response2 <- rss2
    } yield Ok(response1.body + "\n\n" + response2.body)

    f recover {
      case e: java.net.ConnectException => Ok("RSSを取得できませんでした")
    }
  }
}

これで2個以上のFutureでも扱えそうです。

n個のRSSを取得する

先程の例だと、取得するRSSの個数が2つで決まっていました。
Futureオブジェクトの個数が動的な場合、for式は使えなさそうです。

そこで、Futureに定義されてるFuture.sequenceを使います。
Future.sequenceにSeq[Future[ws.Response]]を渡すと、Future[Seq[ws.Response]に変換してくれます。

def index = Action {
  Async {
    val urls: Seq[String] = ・・・
    val fs: Seq[Future[ws.Response]] = urls.map(WS.url(_))
    val f = Future.seqence(fs) map { responses =>
      Ok(responses.map(_.body).mkString("\n\n")
    }
    f recover {
      case e: java.net.ConnectException => Ok("RSSを取得できませんでした")
    }
  }
}

n個のRSSをすべて取得したらmap内の処理を行います。
これでn個のRSSも扱えました。

ちなみに、Future.traverseを使っても書けます。

def index = Action {
  Async {
    val urls: Seq[String] = ・・・
    val fs: Future[Seq[ws.Response]] = Future.traverse(urls)( WS.url(_).get)
    val f = fs map { responses =>
      Ok(responses.map(_.body).mkString("\n\n")
    }
    f recover {
      case e: java.net.ConnectException => Ok("RSSを取得できませんでした")
    }
  }
}

おしまい

こんな感じでRSSを取得してみました。
Futureについては以下のサイトが分かりやすかったです。
http://docs.scala-lang.org/ja/overviews/core/futures.html