jQueryのSortableとem-websocketを組み合わせてみた
jQueryのSortableとem-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:なければ推測が難しいグループ名を生成し、データベースに保存。グループに所属するユーザのコネクションが全て切れたら削除。