2012年8月18日土曜日

Playing with Play!

ここ何回か紹介してたWebSocketのサンプル、今度はScala (Playframework) + Dartに移植したので公開する。Playframeworkを使ってる関係でファイルが多いのでGithubのリポジトリにアップしてある。

わざわざGoで書いたのになんで今度はPlayframeworkに移植したかというと、ローカルで遊ぶんじゃなくてどこかのサーバーでプログラムを動かしたいと思ったから。で色々調べたらHerokuが良さそうだ!ってんでHerokuにGo版をアップしたら、実はHerokuがWebSocketにまだ対応してないことが発覚。しょうがないから他の候補を探したらdotCloudというサービスを発見して、でもそこはGoには対応してないからじゃあ今度はScala(というかPlayframework)で書きましょう、という流れになった。

Scalaとの戦い

今回はじめてScalaに触ったけど、これが想像以上に苦戦した。苦戦した点としては以下。

  • 型推論のおかげで(せいで?)色んなところが省略できてしまう。戻り値があるのにreturnは書いても書かなくてもいいとか、引数の無い関数はカッコが不要とか、そういった諸々のルールのおかげで何が型で何が関数かすら最初はわかりづらかった。
  • 記号の羅列、たとえば">>>"とか"?"とかも関数として定義できるから、importしたライブラリのリファレンスをちゃんと探さないとプログラムの意味がわからない。今回のPlayframeworkで言えば"a ? b"という記述の意味するところがわからなくて(結局はAkkaのaskという関数コールを意味してた)、その上検索もしづらいからかなり解読に苦労した。
  • いきなりPlayframeworkのコードはハードル高かった。フレームワーク自体が色々高度なことをやってるから、Scalaはじめたばっかりの人間が見ても厳しいものがある。最初はobject定義とclass定義の違いすらわからなくて、本当はobject定義の方のリファレンス見なきゃいけないのにずっとclassの方の定義をみてて、関数の定義のってねーなーどういうこと? とかやってた。

Application.scala

サーバー側で行う各種の処理を記述してあるのがこのApplication.scalaで、ここで定義された関数をroutesファイルでURLと結びつけることでウェブサイトを構築していく。

indexはルートのURLにアクセスしてきたクライアントに対してUUIDを生成して、そのUUIDを埋めこんだhtml+scriptを返すということをやっている。また、connectはWebSocketでアクセスしてきたクライアントに対してIterateeとEnumeratorの組を生成して返す、ということをやっている。

package controllers

import play.api._
import play.api.mvc._
import play.api.data._
import play.api.libs.json._
import play.api.data.Forms._
import models.Room
import models.Remark

object Application extends Controller {

  private val initialString = """
      _,,,,._                  、-r
   ,.','" ̄`,ゝ _,,,_   _,,,_   _,,,,__,. | |  _,,,,,_
  { {   ,___ ,'r⌒!゙! ,'r⌒!゙! ,.'r⌒!.!"| l ,.'r_,,.>〉
  ゝヽ、 ~]| ゞ_,.'ノ ゞ_,.'ノ ゞ__,.'ノ | l {,ヽ、__,.
   `ー-‐'"   ~    ~  〃 `゙,ヽ ̄` `゙'''"
                 ゙=、_,.〃
  """
 
  def index = Action { implicit request =>
    val uuid = java.util.UUID.randomUUID.toString
    Logger.info("Generated uuid: "+uuid)
    Ok(views.html.index("Remark Presenter", uuid, initialString))
  }

  def connect(userID: String) = WebSocket.async[JsValue] { request  =>
    Logger.info("userID from client: "+userID)
    Room.join(userID) 
  }
}

Room.scala

クライアントとやりとりを行うアクターを定義している。基本的にやっていることは単純で、クライアントをリストに追加して誰かが発言するたびにその内容を全員に伝播する、というもの。

package models

import akka.actor._
import akka.util.duration._

import play.api._
import play.api.libs.json._
import play.api.libs.iteratee._
import play.api.libs.concurrent._

import akka.util.Timeout
import akka.pattern.ask

import play.api.Play.current

object Room {

  lazy val default = Akka.system.actorOf(Props[Room])

  implicit val timeout = Timeout(1 second)

  def join(userID:String):Promise[(Iteratee[JsValue,_],Enumerator[JsValue])] = {
    (default ? Join(userID)).asPromise.map {
      
      case Connected(enumerator) => {
      
        // Create an Iteratee to consume the feed
        val iteratee = Iteratee.foreach[JsValue] { event =>
          val content = (event \ "Remark").as[String]
          val duration = (event \ "Duration").as[String]
          val path = (event \ "Path").as[String]
          val doRotate = (event \ "Rotate").as[Boolean]
          val remark = Remark(content, duration, path, doRotate)
          default ! Talk(userID, remark)
        }.mapDone { _ =>
          default ! Quit(userID)
        }

        (iteratee,enumerator)
      }
        
      case CannotConnect(error) => {
      
        // Connection error

        // A finished Iteratee sending EOF
        val iteratee = Done[JsValue,Unit]((),Input.EOF)

        // Send an error and close the socket
        val enumerator =  Enumerator[JsValue](JsObject(Seq("error" -> JsString(error)))).andThen(Enumerator.enumInput(Input.EOF))
        
        (iteratee,enumerator)
      }
    }
  }
}

case class Remark(content: String, duration: String, path: String, rotate: Boolean)
case class Join(userID: String)
case class Quit(userID: String)
case class Talk(userID: String, remark: Remark)

case class NotifyJoin(userID: String)
case class Connected(enumerator:Enumerator[JsValue])
case class CannotConnect(msg: String)

class Room extends Actor {
 private var clients = Map.empty[String, PushEnumerator[JsValue]]

 def receive = {
  case Join(userID) => {
      // Create an Enumerator to write to this socket
      val channel =  Enumerator.imperative[JsValue]( onStart = self ! NotifyJoin(userID))
      if(clients.contains(userID)) {
        sender ! CannotConnect("This userID is already used")
      } else {
        clients = clients + (userID -> channel)
        
        sender ! Connected(channel)
      }
  }

    case Quit(userID) => {
      clients = clients - userID
      Logger.info(userID + " leaved")
      //notifyAll("quit", userID, "has leaved the room")
    }

  case Talk(userID, remark) => {
      Logger.info(userID + ":" +remark.toString)
   // send to all clients
      val msg = Json.toJson(
          Map(
          "Remark" -> Json.toJson(remark.content),
          "Duration" -> Json.toJson(remark.duration),
          "Path" -> Json.toJson(remark.path),
          "Rotate" -> Json.toJson(remark.rotate)
        )
      ) 
      //Logger.info(msg.toString);
      clients.foreach { 
        case (_, channel) => channel.push(msg)
      }
   }
    
    case NotifyJoin(userID) => {
      Logger.info(userID + " joined")
      // notifyAll("join", userID, "has entered the room")
    }
  }


  // def notifyAll(kind: String, user: String, text: String) {
  //   val msg = JsObject(
  //     Seq(
  //       "kind" -> JsString(kind),
  //       "user" -> JsString(user),
  //       "message" -> JsString(text),
  //       "clients" -> JsArray(
  //         clients.keySet.toList.map(JsString)
  //       )
  //     )
  //   )
    
  //   clients.foreach { 
  //     case (_, channel) => channel.push(msg)
  //   }
  // }
}

routes

Application.scalaで記述されている関数と実際のURLを紐づけているのがこのroutesファイル。一番下の行はpublicディレクトリの下の各種CSS・スクリプトとURLを対応付けしている。

# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET     /                           controllers.Application.index
GET     /:userID                    controllers.Application.connect(userID: String)
GET     /assets/*file               controllers.Assets.at(path="/public", file)

main.scala.html

クライアント側で実行されるスクリプトのmain部分が含まれたテンプレート。Application.scalaで生成されたuserIDをもとにWebSocketへ接続を行うといった処理がDart側で行なわている。

@(title: String, userID: String)(content: Html)(implicit request: RequestHeader)

<!doctype html>
    <head>
        <title>@title</title>
        <link rel="stylesheet" type="text/css" href="@routes.Assets.at("stylesheets/bootstrap.min.css")">
        <link rel="stylesheet" type="text/css" href="@routes.Assets.at("stylesheets/client.css")">
        <link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")">
        <script src="http://dart.googlecode.com/svn/branches/bleeding_edge/dart/client/dart.js"></script> 
    </head>
    <body>
    <script type="application/dart">
    #import("@routes.Assets.at("scripts/App.dart")");
    #import("@routes.Assets.at("scripts/SVGRemarkDisplayer.dart")");

    void main() {
        // initialize displayers
        final int MAX_NUMBER_OF_REMARKS = 15;
        var displayer = new SVGRemarkDisplayer();
        displayer.initialize("#stage", MAX_NUMBER_OF_REMARKS);

        PanelLayoutParameter param = new PanelLayoutParameter("#inputPanel", "#displayArea", "#remarkPanel", "#hideInputPanel", "#hideRemarkPanel");

        var url = "@routes.Application.connect(userID).webSocketURL()";
        print(url);

        App app = new App();
        app.initialize(url, displayer, param);
    }
    </script>
        @content
    </body>
</html>

サーバー動作

上記のキモとなるロジックの部分とその他フレームワーク的に必要なものを用意してPlayframeworkに実行させ、Dartiumでlocalhost:9000にアクセスすると実行されているのを確認することができる。下は実際にPlayframworkを実行させたときのログ。

$ play
[info] Loading project definition from /home/masato/desk/programs/xclamm-j/project
[info] Set current project to xclamm-j (in build file:/home/masato/desk/programs/xclamm-j/)
       _            _ 
 _ __ | | __ _ _  _| |
| '_ \| |/ _' | || |_|
|  __/|_|\____|\__ (_)
|_|            |__/ 
             
play! 2.0.3, http://www.playframework.org

> Type "help play" or "license" for more information.
> Type "exit" or use Ctrl+D to leave this console.

[xclamm-j] $ run

--- (Running the application from SBT, auto-reloading is enabled) ---

[info] play - Listening for HTTP on port 9000...

(Server started, use Ctrl+D to stop and go back to the console...)

[info] play - database [default] connected at jdbc:h2:mem:play
[info] play - Application started (Dev)
[info] application - Generated uuid: a447101a-df11-45c5-8c64-97f0d1a96eb0
[info] application - userID from client: a447101a-df11-45c5-8c64-97f0d1a96eb0
[info] play - Starting application default Akka system.
[info] application - a447101a-df11-45c5-8c64-97f0d1a96eb0 joined