jQueryのSortableとem-websocketを組み合わせてみた

jQuerySortableem-websocketを組み合わせ、同じページをブラウザで閲覧中のユーザが、共同してリストの順番などを変更できないかRuby on Railsで試してみました。

また誰かが操作中は、警告アイコンを表示するようにしました。

用意

Gemfileにem-websocketを追加し、「bundle install」でインストールします。

gem 'em-websocket'
gem 'jquery-rails'

また、今回はprototype.jsではなくjQueryなので、jquery-railsも追加し、アプリの作成は「rails new APP_NAME -J」とprototype.jsを作らず、インストール後に

rails generate jquery:install --ui

prototype.jsからjQueryに入れ替えました。

WebSocketサーバ側

em-websocketのサンプル「echo.rb」をベースに、サーバ側を作りました。起動時、「--cache」を付けると、サーバ側でSortableの状態を保存するようになります。実際は、アプリのデータベースなどに状態保存し、AJAXで問い合わせることになると思いますが、今回はお試しということで用意しました。

#!/usr/bin/env ruby
require 'rubygems'
require 'em-websocket'

connections = Array.new
prevMsgs = Array.new

EventMachine::WebSocket.start(:host => "0.0.0.0", :port => 8080, :debug => true) do |ws|
  ws.onopen { 
    connections.push(ws) unless connections.index(ws)
    prevMsgs.each {|prevMsg| ws.send(prevMsg)} if ARGV[0] == '--cache'
  }

  ws.onmessage {|msg|
    msgs = msg.split(':')

    if msgs[0] != '' && ARGV[0] == '--cache'
      prevMsgs.delete_if{|prevMsg| msg == prevMsg} 
      prevMsgs.push(msg)
    end 

    connections.each {|con| con.send(msg) unless con == ws} 
  }

  ws.onclose {
    connections.delete_if{|con| con == ws} 
  }
end

JavaScriptなどの読み込み

APP_ROOT/config/application.rbで「= javascript_include_tag :defaults」で展開されるJavaScriptの一覧を設定。

config.action_view.javascript_expansions[:defaults] = %w(jquery.min jquery-ui.min swfobject FABridge web_socket rails)

ビューのレイアウト

左右に二つのテーブルを用意し、それぞれli要素で項目を作りました。em-websocketでは、li要素のid属性がやりとりされます。
APP_ROOT/app/views/home/index.html.haml

#content
  %ul#table1
    %li#no_1.ui-state-default No.1
    %li#no_2.ui-state-default No.2
    %li#no_3.ui-state-default No.3
    %li#no_4.ui-state-default No.4
    %li#no_5.ui-state-default No.5
  %ul#table2
    %li#no_6.ui-state-default No.6
    %li#no_7.ui-state-default No.7

クライアント側のJavaScript

APP_ROOT/public/javascripts/application.js

$(function() {
  // WebSocketのクライアント
  var ws = new WebSocket("ws://localhost:8080/");
  // WebSocketサーバからメッセージを受信
  ws.onmessage = function(event) { 
    var data = event.data.split(':');
    if(data[0]) {
      // 他の人が操作したSortableの状態を反映する
      var ids = data[1].split(',');
      for(var i = 0; i < ids.length; i++){
        $("#" + ids[i]).appendTo(data[0]);
      }   
    } else {
      // 他の人が...
      var alertIcon = $("#" + data[1] + " > span.ui-icon-alert");
      if(alertIcon.length == 0) {
      // 操作中なのでアイコンを追加
        $('<span class="ui-icon ui-icon-alert"></span>').appendTo($("#" + data[1]));
      } else {
      // 操作終了したのでアイコンを削除
        $(alertIcon).remove();
      }   
    }   
  };  

  // Sortableの用意
  $("#table1, #table2").sortable({
    connectWith: 'ul',
    placeholder: 'ui-state-highlight',
    containment: $("#content")
  }).disableSelection();

  // 操作開始を他のユーザに通知
  $("#table1, #table2").bind('sortstart', function(event, ui) {
    ws.send(":" + ui.item.attr('id'));
  }); 

  // 操作完了を他のユーザに通知
  $("#table1, #table2").bind('sortstop', function(event, ui) {
    ws.send(":" + ui.item.attr('id'));
  }); 

  // 更新内容を他のユーザに通知
  $("#table1, #table2").bind('sortupdate', function(event, ui) {
    var id = ui.item.parent().attr('id');
    ws.send("#" + id + ":" + $("#" + id).sortable('toArray').join(','));
  }); 
});

起動

Railsは、普通に「rails s」などで起動し、WebSocketサーバは

lib/echo.rb --cache

で動きます。WebSocketサーバはデバッグモードで起動するので、以下のような通信内容が標準出力されるでしょう。

[[:send, ":no_2"]]

[[:receive_data, "\000#table1:no_1,no_2,no_7,no_3,no_6\377"]]

[[:message, "\000#table1:no_1,no_2,no_7,no_3,no_6\377"]]

[[:send, "#table1:no_1,no_2,no_7,no_3,no_6"]]

[[:receive_data, "\000#table1:no_1,no_2,no_7,no_3,no_6\377\000:no_2\377"]]

[[:message, "\000#table1:no_1,no_2,no_7,no_3,no_6\377\000:no_2\377"]]

[[:send, "#table1:no_1,no_2,no_7,no_3,no_6"]]

[[:send, ":no_2"]]

もう少し実用的に

今回はデータベースからSortableの項目を取得したり、並び順序を保存したりしていません。
また、WebSocketサーバ側で複数グループの共同作業者に対応させないと実用性は低いでしょう。実装はしていないのですが、ユーザがログインした後、グループ名を取得*1し、それをWebSocketサーバにわたして、コネクションをグループ毎にすればよいのかなと思っています。

余談

それにしても、hamlは楽だ。

*1:なければ推測が難しいグループ名を生成し、データベースに保存。グループに所属するユーザのコネクションが全て切れたら削除。