チケット管理の 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 風に記述しているが、これは最近の個人的な趣味である。
ストーリー内容を保持する。
@javax.persistence.Entity public class Story extends Models implements Entity<Story> { //タイトル @Column(nullable=false) private String title; //着手フラグ @Column(nullable=false) private boolean isCurrent; //以下省略
ストーリー順序を保持する。
@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>(); //以下省略
初期画面 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)); } }
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 は、ほとんど手探りなので苦労した。怪しいところがあれば絶賛ご指摘願いたい。
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(); });
サーバとのデータ授受を行う。
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
業務アプリケーションでは、特定のロール・職務を持ったユーザが同一のインターフェースから同一の業務を行う場面が多く存在する。たとえばワークフローでの申請・承認を行おうとしている操作の裏で、同じ職務の別の担当者がそのワークフローを完了しているなど、である。
リアルタイム更新を提供することは、リッチなインターフェースという見た目だけでなく、リロード操作の不要・複数人業務での操作の連携など、オペレーションの効率化に効果があると思う。