こんにちは。SI部の志賀です。

PlayframeworkからwebSocketを使ったコンテンツを紹介します。
Playframeworkのインストールについてはいろいろと情報があるため説明を省略します。

WebSocketとは

WebSocketは通信規格の1つで、AjaxやComet等に代わる事を目標としています。WebSocketを利用することで、サーバとクライアント間の双方向通信が、より効率的になります。HTML5でも採用されています。

特徴
リアルタイムにデータ送受信が可能。
(Webチャットなどに利用されている。)

webSocketを使ったコンテンツ概要

WebSocketを利用し、複数のユーザーでリアルタイムに同じ星を動かすというものです。
ブラウザを複数起動して、星を動かすとリアルタイムに星が連動して動くことが確認できます。

star

アプリケーションの作成

今回のアプリケーションのメインであるControllerは、app/controllersにあるApplication.javaを以下の内容に書き換えまましょう。

Application.java

package controllers;

import models.WebSocketActor;
import com.fasterxml.jackson.databind.JsonNode;
import play.*;
import play.mvc.*;
import views.html.*;

/**
 * testWebSocket
 */
public class Application extends Controller {

    /**
     * @param username ユーザー名
     * @return Result
     */
    public static Result draggable(String username) {
        session("username", username);
        return ok(index.render("WebSocket Sample", username));
    }

    /**
     * WebSocketのアクセス先
     *
     * @return WebSocketのJSONノード
     */
    public static WebSocket<JsonNode> ws() {
        final String username = session("username");
        return new WebSocket<JsonNode>() {
            @Override
            public void onReady(final WebSocket.In<JsonNode> in, final WebSocket.Out<JsonNode> out) {
                try {
                    WebSocketActor.join(username, in, out);
                } catch (Exception e) {
                    Logger.error("Can't connect WebSocket");
                    e.printStackTrace();
                }
            }
        };
    }
}

次に、app/にeventフォルダを作成し、Message.javaを作成しまいましょう。

Message.java

package events;

import com.fasterxml.jackson.databind.JsonNode;
import play.mvc.WebSocket;
import static play.libs.F.*;
import java.io.Serializable;

/**
 * メッセージオブジェクト
 */
public class Message implements Serializable {

    /**
     * ユーザ名
     */
    final String username;

    /**
     * X座標
     */
    final String x;

    /**
     * Y座標
     */
    final String y;

    /**
     * イベントの種類
     */
    final WebSocketEvent event;

    /**
     * ユーザのチャンネル
     */
    final WebSocket.Out<JsonNode> channel;

    /**
     * コンストラクタ
     *
     * @param username
     * @param x
     * @param y
     * @param e
     * @param o
     */
    public Message(String username, String x, String y, WebSocketEvent e, WebSocket.Out<JsonNode> o) {
        this.username = username;
        this.x = x;
        this.y = y;
        this.event = e;
        this.channel = o;
    }

    public WebSocketEvent getEventType() {

        return event;
    }

    public String getUsername() {
        return username;
    }

    public String getX() {
        return x;
    }

    public String getY() {
        return y;
    }

    public WebSocket.Out<JsonNode> getChannel() {
        return channel;
    }

    /**
     * メッセージのオブジェクト判定
     *
     * @param object
     * @return
     */
    public static Option<Message> getEvent(Object object){
        if(object instanceof Message){
            return Some((Message) object);
        }
        return new None<Message>();
    }

    /**
     * WebSocket用の各種イベントタイプ
     * JOIN      : 送信先一覧に追加
     * MESSAGE   : メッセージを送信先一覧に送信
     * QUIT      : 送信先一覧から削除
     */
    public enum WebSocketEvent {

        JOIN    { public String event() { return "JOIN";    } },
        MESSAGE { public String event() { return "MESSAGE"; } },
        QUIT    { public String event() { return "QUIT";    } };

        abstract public String event();

    }
}

次に、app/modelsにWebSocketActor.javaを作成しまいましょう。

WebSocketActor.java

package models;

import akka.actor.ActorRef;
import akka.actor.Props;
import akka.actor.UntypedActor;
import events.*;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import play.libs.Akka;
import play.libs.Json;
import play.mvc.WebSocket;
import play.Logger;
import scala.concurrent.Await;
import scala.concurrent.duration.Duration;
import static play.libs.F.*;
import static java.util.concurrent.TimeUnit.*;
import static akka.pattern.Patterns.ask;

/**
 * WebSocketアクター
 */
public class WebSocketActor extends UntypedActor {

    /**
     * アクターのインスタンス
     */
    private final static ActorRef ref = Akka.system().actorOf(new Props(WebSocketActor.class));

    /**
     * 送信先メンバー一覧
     */
    Map<String, WebSocket.Out<JsonNode>> members = new HashMap<String, WebSocket.Out<JsonNode>>();

    /**
     * 送信先に追加されるメソッド
     * @param username ユーザー名
     * @param in WebSocketの受信
     * @param out WebSocketの送信
     * @throws Exception
     */
    public static void join(final String username, WebSocket.In<JsonNode> in, WebSocket.Out<JsonNode> out) throws Exception {

        // 初回アクセス時にJOINイベント
        Boolean result = (Boolean) Await.result(ask(ref, new Message(username, "", "", Message.WebSocketEvent.JOIN, out), 1000), Duration.create(1, SECONDS));

        if(result) {
            // WebSocketを通じてJSONがあれば、MESSAGEイベント
            in.onMessage(new Callback<JsonNode>() {
                public void invoke(JsonNode event) {
                    ref.tell(new Message(username, event.get("x").asText(), event.get("y").asText(), Message.WebSocketEvent.MESSAGE, null), ref);
                }
            });
            // WebSocketがクローズしたときに、QUITイベント
            in.onClose(new Callback0() {
                public void invoke() {
                    ref.tell(new Message(username, "", "", Message.WebSocketEvent.QUIT, null), ref);
                }
            });
        } else {
            // エラー
            ObjectNode error = Json.newObject();
            error.put("error", result);
            out.write(error);
        }
    }

    /**
     * イベント発生時に実行するメソッド
     * @param message イベントオブジェクト
     * @throws Exception
     */
    @Override
    public void onReceive(Object message) throws Exception {

        // イベントかどうか判定
        Option<Message> event = Message.getEvent(message);
        if(event.isDefined()){
            Message m = event.get();
            switch (m.getEventType()) {
                // 送信先メンバーに追加
                case JOIN:
                    members.put(m.getUsername(), m.getChannel());
                    getSender().tell(true, ref);
                    break;
                // 全員にメッセージ送信
                case MESSAGE:
                    notifyAll(m.getUsername(), m.getX(), m.getY(), members);
                    break;
                // 送信先メンバーから除外
                case QUIT:
                    members.remove(m.getUsername());
                    break;
                default:
                    unhandled(message);
                    break;
            }
        }

    }

    /**
     * @param username 送信主
     * @param x x座標
     * @param y y座標
     * @param members 送信先一覧
     */
    public static void notifyAll(String username, String x, String y, Map<String, WebSocket.Out<JsonNode>> members) {
        // ユーザー名と座標の指定
        ObjectNode event = Json.newObject();
        event.put("username", username);
        event.put("x", x);
        event.put("y", y);
        Logger.info(event.toString());
        // メンバーへ送信
        for(WebSocket.Out<JsonNode> channel: members.values()) {
            channel.write(event);
        }
    }

}

index.scala.html

@(message: String, username: String)

@main(message) {

    <div class="draggable">
        <div class="text">@(username)さん</div>
        <div id="star-five"></div>
    </div>

}

古いブラウザ等にも対応できるように、web_socket.js、swfobject.jsを読み込んで置きましょう。

main.scala.html

@(title: String)(content: Html)

<!DOCTYPE html>
<html>
    <head>
        <title>@title</title>
        @*<script src="@routes.Assets.at("javascripts/jquery-1.9.0.min.js")" type="text/javascript"></script>*@
        <script type='text/javascript' src='@routes.WebJarAssets.at(WebJarAssets.locate("jquery.min.js"))'></script>
        <script src="@routes.Assets.at("javascripts/jquery-ui-1.10.2.custom.min.js")" type="text/javascript"></script>
        <script src="@routes.Assets.at("javascripts/wssocket.js")" type="text/javascript"></script>
        <script src="@routes.Assets.at("javascripts/swfobject.js")" type="text/javascript"></script>
        <script src="@routes.Assets.at("javascripts/web_socket.js")" type="text/javascript" ></script>
        <link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")">
    </head>
    <body>
        @content
        <script type="text/javascript">
            var WS_LOCATION = "@routes.Application.ws.webSocketURL(false)";
            var USERNAME = "@session.get("username")";
            var WEB_SOCKET_SWF_LOCATION = "@routes.Assets.at("javascripts/WebSocketMain.swf")";
        </script>
    </body>
</html>

wssocket.js

$(document).ready(function() {

    /*
     * WebSocket
     */
    var target = ".draggable";
    var WS = window['MozWebSocket'] ? MozWebSocket : WebSocket
    var wsSocket = new WS(WS_LOCATION);
    wsSocket.onmessage = function(event) {
        var data = JSON.parse(event.data);
        if(data.error) {
            wsSocket.close();
        }
        if(data.x != undefined && data.y != undefined && data.username != USERNAME) {
            $(target).css("top", data.x);
            $(target).css("left", data.y);
        }
    };

    var sendMessage = function(x, y) {
        wsSocket.send(JSON.stringify(
            {
                x: x,
                y: y
            }
        ));
    };

    $(target).draggable({
        drag: function() {
            sendMessage($(this).css("top"), $(this).css("left"));
        }
    });

});

main.css

html, body {
background-color: #000000;
}

.text {
color: #ffffff;
font-size: 100%;
}

/*星のスタイル*/
#star-five {
margin: 50px 0;
position: relative;
display: block;
color: #FFFF00;
width: 0px;
height: 0px;
border-right: 100px solid transparent;
border-bottom: 70px solid #FFFF00;
border-left: 100px solid transparent;
-moz-transform: rotate(35deg);
-webkit-transform: rotate(35deg);
-ms-transform: rotate(35deg); -o-transform: rotate(35deg);
}

#star-five:before {
border-bottom: 80px solid #FFFF00;
border-left: 30px solid transparent;
border-right: 30px solid transparent;
position: absolute; height: 0; width: 0;
top: -45px; left: -65px; display: block;
content: ''; -webkit-transform: rotate(-35deg);
-moz-transform: rotate(-35deg);
-ms-transform: rotate(-35deg);
-o-transform: rotate(-35deg);
}

#star-five:after { position: absolute;
display: block; color: #FFFF00; top: 3px;
 left: -105px; width: 0px; height: 0px;
 border-right: 100px solid transparent;
 border-bottom: 70px solid #FFFF00;
 border-left: 100px solid transparent;
 -webkit-transform: rotate(-70deg);
 -moz-transform: rotate(-70deg);
 -ms-transform: rotate(-70deg);
 -o-transform: rotate(-70deg);
 content: '';
}

最後に、conf/routesファイルにControllerのメソッドとリクエストパスとのルーティングを追記します。

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

# Home page
GET     /star/:username             controllers.Application.draggable(username)
GET     /ws                         controllers.Application.ws()

# Map static resources from the /public folder to the /assets URL path
GET     /assets/*file               controllers.Assets.at(path="/public", file)
GET     /webjars/*file              controllers.WebJarAssets.at(file)

アプリケーションの実行

$ activator run

でPlayframeworkを起動したあと、
http://localhost:9000/star/ユーザー名(好きな名前)で画面が表示されます。

さいごに

今回はwebSocketを使った簡単なコンテンツを作成しましたが、webSocketを使い簡単なチャットアプリなども面白そうです。