Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save asufana/9680175 to your computer and use it in GitHub Desktop.
Save asufana/9680175 to your computer and use it in GitHub Desktop.
WebSocket でリアルタイム更新インターフェース

WebSocket でリアルタイム更新インターフェース

チケット管理の Pivotal Tracker のように、誰かが内容を更新すると即時に反映されるインターフェースを、WebSocket/PlayFramework を使って実装してみる。具体的にはこんな動作になる。

<iframe src="http://player.vimeo.com/video/37656566?byline=0&portrait=0" width="500" height="563" frameborder="0" webkitAllowFullScreen mozallowfullscreen allowFullScreen></iframe> [デモ動画](http://player.vimeo.com/video/37656566?byline=0&portrait=0)

Safari と Chrome のブラウザ間で更新がリアルタイムに反映する。iPhone の Safari でも問題なく動作する。

クライアントサイドは <a href="http://extjs.co.jp/" target~"_blank">ExtJS4 の ドラッグ&ドロップ・グリッドサンプル をそのまま利用。サーバサイドは WebSocket に対応した Java フレームワーク PlayFramework を利用する。クライアントとサーバ間は JSON でデータ授受する。

このチケット管理インターフェースの実装を簡単に説明する。ソースは https://github.com/asufana/PlayWebSocketGridDemo


サーバサイド

以下のルートを用意した。

GET     /        Application.index               # 初期画面(HTML/JS返却)
GET     /init    Application.init                # 初期データ取得(XHR->JSON返却)
WS      /ws      Notifications.WebSocket.connect # ストーリー順序の更新通知(WS->JSON返却)
POST    /order   Application.changeOrder         # ストーリー順序の変更
POST    /story   Application.addStory            # ストーリーの追加

初期画面取得時に JS ファイルを取得し、ExtJS で画面を構成する。ExtJS の初期化時に /init から初期データを XHR で取得する。またこの初期化時に /ws にて WebSocket コネクションも確立する。

あとはストーリー順序の変更と追加をそれぞれ POST で受けられるようにし、その変更を WS を通じて通知するという仕組みだ。

処理構成

ストーリー追加時、および順序更新時には、新しいストーリー順序一覧 JSON を WebSocket 経由で通知し、それに従い画面を更新する。複数の WebSocket 接続に対して、同時に通知を行う仕組みは mumoshu: Play framework + WebSocket + Growl で遊ぶ を参考にさせて頂いた。

詳しくはソースを見てもらうとして、以下のような構成となっている。

モデル

モデルとして、ストーリーを保持する Story エンティティ、ストーリー順序を保持する StoryOrder エンティティを用意する。またエンティティメンバのアクセス修飾子は(PlayFramework思想に逆い)private なため、表示データ保持用として DTO を用意した。エンティティは DDD 風に記述しているが、これは最近の個人的な趣味である。

Story エンティティ

ストーリー内容を保持する。

@javax.persistence.Entity
public class Story extends Models implements Entity<Story> {

    //タイトル
    @Column(nullable=false)
    private String title;

    //着手フラグ
    @Column(nullable=false)
    private boolean isCurrent;

    //以下省略

StoryOrder エンティティ

ストーリー順序を保持する。

@javax.persistence.Entity
public class StoryOrder extends Models implements Entity<StoryOrder> {

    //着手中リスト
    @OneToMany
    @JoinColumn(name="currenOrder_fk")
    private static List<Story> currentOrder = new ArrayList<Story>();

    //バックログリスト
    @OneToMany
    @JoinColumn(name="backlogOrder_fk")
    private static List<Story> backlogOrder = new ArrayList<Story>();

    //以下省略

コントローラ

Application コントローラ

初期画面 HTML/JS の返却とデータ授受を行う。ストーリー順序変更と追加時には、Notifire を介して Notification インスタンス内容を WebSocket で通知する。

public class Application extends Controller {

    public static void index() {
        render();
    }

    //初期化データ返却
    public static void init() {
        renderJSON(StoryOrderDTOAssembler.toDTO(StoryOrder.getInstance()));
    }

    //ストーリー順序変更
    public static void changeOrder(final String currentOrderIds, final String backlogOrderIds) {
        StoryOrder.getInstance().changeOrder(
                currentOrderIds != null ? currentOrderIds.replace(" ", "").split(",") : null,
                backlogOrderIds != null ? backlogOrderIds.replace(" ", "").split(",") : null);
        notifyUpdated();
    }

    //ストーリー追加
    public static void addStory(String title) {
        Story story = new Story(title, false).save();
        StoryOrder.getInstance().addStory(story);
        notifyUpdated();
        redirect("/");
    }

    //WebSocket変更通知
    private static void notifyUpdated() {
        StoryOrderDTO dto = StoryOrderDTOAssembler.toDTO(StoryOrder.getInstance());
        Notifier.notify(new Notification(Order, dto));
    }
}

Notifications コントローラ

WebSocket 接続と通知を行う。

public class Notifications extends Controller {

    public static class WebSocket extends WebSocketController {
        public static void connect() {
            Logger.info("Connected.");
            F.EventStream<Notification> eventStream = NotificationStream.stream();
            while(inbound.isOpen()) {
                F.Either<Http.WebSocketEvent,Notification> receivedEvent = await(F.Promise.waitEither(inbound.nextEvent(), eventStream.nextEvent()));

                // WebSocketから受け取ったメッセージを他のWebSocketコネクションにブロードキャストする。今回は利用していない。
                for (String message : TextFrame.match(receivedEvent._1)) {
                    Notifier.notify(new Notification(Order, message));
                }

                // 他のWebSocket接続から受け取ったメッセージを、現在のWebSocket接続先に送る。
                for (Notification notification : ClassOf(Notification.class).match(receivedEvent._2)) {
                    outbound.send(new Gson().toJson(notification));
                }

                for (Http.WebSocketClose close : SocketClosed.match(receivedEvent._1)) {
                    Logger.info("Disconnect.");
                    disconnect();
                }
            }
        }
    }
}

Notifications コントローラが行なっているのは、WebSocket 経由で JSON 化した Notification インスタンスを渡し、クライアントサイドでイベントが発火させるというものである。Web アプリケーション内容に依存しない汎用的な機能である。イベント毎に通知データを異なるものにしたい場合には、Notification インスタンス内容を変えれば良い。


クライアントサイド

ExtJS のサンプルをほぼそのまま流用している。

grid.js にグリッドパネルの構造と振る舞いを、server.js にサーバとのデータ授受を定義している。JavaScript は、ほとんど手探りなので苦労した。怪しいところがあれば絶賛ご指摘願いたい。

grid.js

ExtJS グリッドパネルを構成する。

Ext.gridpanel = function(){
    var currentGridStore = Ext.create('Ext.data.Store', { model: 'DataObject' });
    var backlogGridStore = Ext.create('Ext.data.Store', { model: 'DataObject' });

    //省略

    //初期データ登録
    Ext.server.init(currentGridStore, backlogGridStore);

    //更新検知
    Ext.server.watch(currentGridStore, backlogGridStore);
};

Ext.onReady(function(){
    Ext.gridpanel();
});

server.js

サーバとのデータ授受を行う。

Ext.server = function(){
    return {
        //初期データ
        init : function(currentGridStore, backlogGridStore){
            Ext.Ajax.request({
                url: '/init',
                success: function (result, request) {
                    var obj = JSON.parse(result.responseText);
                    currentGridStore.loadData(obj.currentOrder);
                    backlogGridStore.loadData(obj.backlogOrder);
                },
            });
        },
        //更新検知
        watch : function(currentGridStore, backlogGridStore){
            var ws = new WebSocket("ws://" + location.host + "/ws");
            ws.onmessage = function(event) {
                var obj = JSON.parse(event.data);
                if(obj.type === "Order"){
                    currentGridStore.loadData(obj.value.currentOrder);
                    backlogGridStore.loadData(obj.value.backlogOrder);
                }
            };
        },
        //更新
        update : function(currentOrderIds, backlogOrderIds){
            Ext.Ajax.request({
                url: '/order',
                method: 'POST',
                params: {
                    currentOrderIds: currentOrderIds,
                    backlogOrderIds: backlogOrderIds,
                }
            });
        },
    };
}();

サンプル実行

以下コマンドからサンプル実行が可能。事前に PlayFramework のインストールが必要。また Chrome でテストする場合には、ソースからのコンパイルが必要(後述)。

$ git clone git@github.com:asufana/PlayWebSocketGridDemo.git
$ play test PlayWebSocketGridDemo
Access to http://localhost:9000/

2012年2月現在、Play1.2.4 と Chrome17 は WebSocketプロトコル不一致のため正常に動作しない。Chrome を利用する場合には、Play をソースからコンパイルする。

$ git clone git://github.com/playframework/play.git
$ cd play/framework
$ ant jar
$ ../play

業務アプリケーションでは、特定のロール・職務を持ったユーザが同一のインターフェースから同一の業務を行う場面が多く存在する。たとえばワークフローでの申請・承認を行おうとしている操作の裏で、同じ職務の別の担当者がそのワークフローを完了しているなど、である。

リアルタイム更新を提供することは、リッチなインターフェースという見た目だけでなく、リロード操作の不要・複数人業務での操作の連携など、オペレーションの効率化に効果があると思う。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment