2016/12/19

Websocket in Scala Play


Websocket 就是在網頁 http protocol 之下,再增加的一層封裝協定,目的是讓 client 建立長時間的 socket 連線,在不重新建立連線的狀況下,我們可以傳送更多即時的資訊。


根據 WebSockets 的官方文件說明,scala play 2.5版已經調整為使用 Akka Streams 及 actors 來處理 WebSockets。


以最基本的 String 資料格式為例,跟一般的 http Action 一樣,websocket 也是以 Controller 為入口點。


WebSocketController 有兩個部分,index 是回傳一個簡單的 websocket 網頁 client,而 socket 就是主要處理 websocket 的部分。


// WebSocketController.scala
import play.api.mvc._
import play.api.libs.streams._

class WebSocketController @Inject()(implicit system: ActorSystem, materializer: Materializer) extends Controller {

  def index = Action { implicit request =>
    Ok(views.html.ws())
  }

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

ActorFlow.actorRef(...) 可以被替代為 Akka Streams Flow[In, Out, _],但還是用 actors 撰寫會比較直接。


在 WebSocketController.scala 的下面,我們直接將處理 websocket 的 MyWebSocketActor 寫在下面,收到字串就會直接回傳 echo String: 加上原本收到的字串。


import akka.actor._

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

class MyWebSocketActor(out: ActorRef) extends Actor {
  def receive = {
    case msg: String =>
      Logger.debug(s"echo String: ${msg}")
      out ! s"echo String: ${msg}"
  }
}

在 routes 加上兩個 service uri


GET         /ws                                         controllers.WebSocketController.socket
GET         /wstest                                     controllers.WebSocketController.index

如果不要自己撰寫 websocket client 網頁,可以直接用 Websocket Echo Test 測試,把 Location: wss://echo.websocket.org 改為 ws://localhost:9000/ws,Use secure WebSocket (TLS) 取消勾選。點 Connect 以後,就可以對 ws server 發送文字訊息。


以下是自己撰寫 websocket 網頁的 sample: ws.scala.html


<!DOCTYPE html>
<meta charset="utf-8" />
<title>WebSocket Test</title>
<script language="javascript" type="text/javascript">
    //var wsUri = "ws://localhost:9000/ws";
    //var output;

    function init()
    {
        //output = document.getElementById("output");
        //testWebSocket();
    }

    function testWebSocket()
    {
        var wsUri= document.getElementById('wsUri').value;
        websocket = new WebSocket(wsUri);
        //websocket.binaryType = 'arraybuffer';

        websocket.onopen = function(evt) { onOpen(evt) };
        websocket.onclose = function(evt) { onClose(evt) };
        websocket.onmessage = function(evt) { onMessage(evt) };
        websocket.onerror = function(evt) { onError(evt) };
    }

    function closeWebSocket() {
        websocket.close();
    }

    function onOpen(evt)
    {
        writeToScreen("CONNECTED");
        //doSend("WebSocket rocks");
    }

    function onClose(evt)
    {
        writeToScreen("DISCONNECTED");
    }

    function onMessage(evt)
    {
        console.log(evt);

        if ( evt.data instanceof ArrayBuffer ) {
            console.log( evt.data );
        } else {
            writeToScreen('<span style="color: blue;">RESPONSE: ' + evt.data+'</span>');
        }
        //websocket.close();
    }

    function onError(evt)
    {
        writeToScreen('<span style="color: red;">ERROR:</span> ' + evt.data);
    }

//    function doSend(message)
//    {
//        writeToScreen("SENT: " + message);
//        websocket.send(message);
//    }

    function doSendInput()
    {
        var message= document.getElementById('sendMessage').value;

        writeToScreen("SENT: " + message);
        websocket.send( message );
        //websocket.send( JSON.stringify(JSON.parse(message)) );
        //var byteArray = new Uint8Array(message);
        //websocket.send( byteArray.buffer );
    }

    function writeToScreen(message)
    {
        var pre = document.createElement("p");
        pre.style.wordWrap = "break-word";
        pre.innerHTML = message;

        output = document.getElementById("output");
        output.appendChild(pre);
    }

    function clearScreen()
    {
        var nodes = document.getElementById("output");
        while (nodes.hasChildNodes()) {
            nodes.removeChild(nodes.childNodes[0]);
        }
    }

    window.addEventListener("load", init, false);

    </script>

<h2>WebSocket Test</h2>

<input id="wsUri" size="35" value="ws://localhost:9000/ws"></input>

<button id="close" onclick="javascipt:testWebSocket()">connect</button>
<button id="close" onclick="javascipt:closeWebSocket()">close</button>

<br/><br/>

<input id="sendMessage" size="35" value="測試 WebSocket!!!"></input>
<button id="send" onclick="javascipt:doSendInput()">Send</button>
<button id="clear" onclick="javascipt:clearScreen()">clear</button>
<div id="output"></div>

啟動 server 瀏覽網頁 http://localhost:9000/wstest 就可以測試 websocket



如果要將資料的格式換成 json,就稍微調整一下程式。


//WebSocketController.scala 把 String 換成 JsValue

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

// 接收的資料改為 JsValue,並直接回傳原始的 json
class MyWebSocketActor(out: ActorRef) extends Actor {
  def receive = {
    case msg: JsValue =>
      Logger.debug(s"echo JsValue: ${msg}")
      out ! msg
  }
}

routes 修改為 socketJs method


GET         /ws                                         controllers.WebSocketController.socketJs
GET         /wstest                                     controllers.WebSocketController.index

ws.scala.html 中發送資料的部分,以 JSON.parse 及 JSON.stringify 確認發送的資料是 json。


    function doSendInput()
    {
        var message= document.getElementById('sendMessage').value;

        writeToScreen("SENT: " + message);
        //websocket.send( message );
        websocket.send( JSON.stringify(JSON.parse(message)) );
    }

測試的字串要改為 json



References


Binary websockets with Play 2.0 and Scala (and a bit op JavaCV/OpenCV)


Handling data streams reactively


Akka Streams integration in Play Framework 2.5