6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

PlayFramework 2.5でActorによるWebSocketを使ってみた

Last updated at Posted at 2017-03-12

はじめに

PlayFrameworkでのWebScoketですが、2.5からAkkaStreamベースになったためWebSocket.usingでIterateeEnumeratorを指定するやり方はdeprecatedになったみたい。

Play Framework Official Document - WebSockets

ただ上のチュートリアル読んでも「PlayやAkkaの機能が良く分からん」どころかScalaの仕様すらだいぶ忘れてて、結構躓いたので理解を整理する意味でもメモがてら記載。

TL;DR

論よりコードという事で、とりあえずWebSocketによるechoの完成形を。

WebSocket用のコントローラ。

WebSocketController.scala
@Singleton
class WebSocketController @Inject()(implicit system: ActorSystem, materializer: Materializer) {
  
  object MyWebSocketActor {
    def props(out: ActorRef) = Props(new MyWebSocketActor(out))
  }

  class MyWebSocketActor(out: ActorRef) extends Actor {
    override def preStart() {
      println("open")
    }

    override def postStop() {
      println("close")
    }

    override def receive = {
      case request: JsValue =>
        val response = handleMessage(request)
        out ! response
    }

    def handleMessage(event: Event): JsValue = {
      event match {
        case event: EventA => Json.toJson(event.data.toInt + 2)
        case event: EventB => Json.toJson(event)
      }
    }
  }

  def socket = WebSocket.accept[JsValue, JsValue] { request =>
    ActorFlow.actorRef(out => MyWebSocketActor.props(out))
  }

}

メッセージとして使ったJSONをラッピングしたモデル

Eevents.scala
abstract class Event()

case class EventA(data: String, eventType: String = "a") extends Event

case class EventB(data: String, eventType: String = "b") extends Event

object EventA {
  implicit val format = Json.format[EventA]
}

object EventB {
  implicit val format = Json.format[EventB]
}

object Event {
  implicit def json2object(value: JsValue): Event = {
    (value \ "eventType").as[String] match {
      case "a" => value.as[EventA]
      case "b" => value.as[EventB]
    }
  }

  implicit def object2json(event: Event): JsValue = {
    event match {
      case event: EventA => Json.toJson(event)
      case event: EventB => Json.toJson(event)
    }
  }
}

クライアントになるHTML。

ws.scala.html
@main("echo") {
<div>
    <script language="JavaScript">
    ws = new WebSocket('ws://localhost:9000/socket');

    ws.onopen = function () {
        console.log("Connecting is success!!");
    };

    ws.onmessage = function (me) {
        var msg = "recievedData: " + me.data;

        console.log(msg);
        var view = document.getElementById("message");
        view.innerHTML += "<br />" + msg;
    };

    function send(){
        console.log("send");
        ws.send(document.getElementById("input").value);
    }
    </script>
    <div id="cats">
        <h2>Hello WebSocket</h2>
        <input id="input" type="text" value='{"data":"1", "eventType":"a"}' /> <button onclick="send()">Send</button>
        <div id="message"></div>
    </div>
</div>
}

ルーティング

routes
GET     /ws                         controllers.HomeController.ws
GET     /socket                     controllers.WebSocketController.socket

コントローラとアクション

まずはコントローラの作成。ActorSystemをインジェクションする必要があるみたい。
アクションにはWebSocket.acceptを使う。実際のシステム開発ではより高機能なacceptOrResultとかを使うんだと思う。

WebSocketController.scala
@Singleton
class WebSocketController @Inject()(implicit system: ActorSystem, materializer: Materializer) {
  
  ...

  def socket = WebSocket.accept[JsValue, JsValue] { request =>
    ActorFlow.actorRef(out => MyWebSocketActor.props(out))
  }

}

acceptにはGenericsで受け取る型を渡す。JsValueはJSONをPlayFrameworkで取り扱うための型。Stringにすれば文字列で返ってくるけど実用では使う事はない気がする。

アクションの中では、ActorFlowを使って今回作成するMyWebSocketActorを初期化している。outはクライアントからのリクエスト。Actorの実装に関しては後述。

JsValueとcase classの変換

Scalaっぽいところとして、JSONとcase classの相互変換が出来る。

まずは、以下のようなモデルを作る。

abstract class Event()

case class EventA(data: String, eventType: String = "a") extends Event

case class EventB(data: String, eventType: String = "b") extends Event

これは、下記のJSONと対応する。

{"data":"1", "eventType":"a"}

続いて、JSON.formatを定義してJsValueとcase classの変換ルールを定義する。

object EventA {
  implicit val format = Json.format[EventA]
}

object EventB {
  implicit val format = Json.format[EventB]
}

今回は複雑な変換ルールは使わずにプリミティブ型を同名で割り当てるだけだから上記の通りシンプル。

複雑な変換がしたければformatではなく個別にWrites/Readsを指定する。「Play Framework で JSON パースを簡単に」を参考にすれば良さげ。

続いて、implicit defによる暗黙の型変換を行えるようにする。

上のEventA,Bに定義したformatもそうだけど、implicit def/valは検索対象がimportとか含めて名前解決がされるところ + コンパニオンオブジェクトの中っぽいので、影響範囲を狭めて可読性を高める意味でもオブジェクトの中に書くのがベター。

object Event {
  implicit def json2object(value: JsValue): Event = {
    (value \ "eventType").as[String] match {
      case "a" => value.as[EventA]
      case "b" => value.as[EventB]
    }
  }

  implicit def object2json(event: Event): JsValue = {
    event match {
      case event: EventA => Json.toJson(event)
      case event: EventB => Json.toJson(event)
    }
  }
}

json2objectはJsValueをeventTypeをベースに判定して、任意のイベントに変換すしている。ちなみに、JsValue#as[T]でEventA,Bが返るのは上でJson.formatの指定をしているから。

object2jsonはEventを受け取って型でパターンマッチをしてJSONに変換。Json.toJsonもJson.formatを指定しているから変換できるみたい。

今回見たいにtoJsonしか結局しないならパターンマッチは実は不要かも?

アクターとreceive

やっとメイン処理のActor周り。

Play 2.5ではWebSocketのコネクション毎に1アクターが作成されるみたいで、ライフサイクルもopne/endに一致する見たいなので管理がとても楽。

まずは、初期化用にコンパニオンオブジェクトにpropsを宣言。この辺は公式のチュートリアルまんま書いてるので未だあまり理解できてないので要Actorの勉強。

  object MyWebSocketActor {
    def props(out: ActorRef) = Props(new MyWebSocketActor(out))
  }

続いてメインとなるActorを宣言。

  class MyWebSocketActor(out: ActorRef) extends Actor {
    override def preStart() {
      println("open")
    }

    override def postStop() {
      println("close")
    }

    override def receive = {
      case request: JsValue =>
        val response = handleMessage(request)
        out ! response
    }

    def handleMessage(event: Event): JsValue = {
      event match {
        case event: EventA => Json.toJson(event.data.toInt + 2)
        case event: EventB => Json.toJson(event)
      }
    }
  }

大事なのはreceive。ここでクライアントからの処理を受け取る。

とはいえreceiveの中に色々書くよりはメソッドで切り出したほうが可読性が良いのでhandleMessage(Event):JsValueとして切り出しています。implicit defを定義しておいたおかげでかなりすっきり書けますね。

クライアント側へのメッセージ送信はActorお馴染みの!です。このあたり普通のActorの送信として扱えばいいみたい。

WebSocketのopne/closeはAkkaのライフサイクルにマッピングされてるので、結果としてpreStartpostStopをoverrideすれば良いみたい。実際にブラウザを開いたり閉じたりしたら、イベントが発生してました。

クライアントにメッセージをPushをする

さて、上の例はクライアントからのレスポンスをトリガーにメッセージを送ってるので実質的にはリクエスト/レスポンス方式と変わらない。

WebSocketなので、サーバからの任意のタイミングでのPush通信がしたくなりますよね。なりますがチュートリアル見ても良く分からない...

一応、下記の通りの手順で出来たのだけど、適切かどうかは結構疑問が残る。この辺、詳しい人いたら情報プリーズです。

まずは、ActorのユニークキーであるActorPathをアプリケーションデータと紐づけて、CacheAPIで保存。

def handleMessage(event: Event): JsValue = {
    event match {
        case event: EventA => {
            val actorId = self.path.parent.toSerializationFormat
            cache.set("actor-id_" + event.data, actorId)
            Json.toJson(event)
        }
        case event: EventB => Json.toJson(event)
    }
}

つづいて、任意の場所でCacheからアクターパスをとりだして、アクターを取得してメッセージを送信。

val actorId = cache.get[String]("actor-id_1").get
val client = system.actorSelection(actorId)
client ! Json.toJson(EventB("MessageB"))

これで一応送信はできる。CacheAPIを経由するのがとてもイケて無い気がするが他に良い手が思いつかない。うーん。
AkkaStreamのPub/Sub周りの仕様で出来るっぽいことを書いてあるサイトもあったので、要調査かも。

まとめ

Hello Worldを書くだけならIterateeとEnumeratorが断然楽だけど、実際のアプリを組むうえではActorの流儀で統一的に扱えるのは有利かも。

特に、JavaEEとかで組むとスレッドと1:1にするわけにもいかないし、そういう点ではWebSocketと軽量プロセスの相性は良さそうだなー。

なお、この記事は分からないなりに試行錯誤で書いてる部分がるので、おかしなところがあったら指摘をいただけると助かります。

それでは、Happy Hacking!

参考

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?