node.js + socket.io はリアルタイムwebアプリを作るのにとても良い組み合わせだと思っています。
しかし、基本的にsocket.ioのconnectionはhttpのsessionと関連が無いので、例えばそのconnectionがログインしているユーザのものかその他のユーザのものか区別がつかない。以前はSessionWebSocketを使ってそれを判別するようにしていたのだけど、残念なことに残念なのでコレはもう使いたくない。。
そこでclientから最初にcookieを送信してもらって、そこからclientのsessionを識別する、という方法がある、と以前@masahirohさんに教えていただきました。 *1
session共有方法
クライアント側では下記のように接続時にcookieを送信するようにしておき、
var socket = new io.Socket(); socket.on('connect', function() { socket.send({ cookie: document.cookie }); }); socket.connect();
サーバ側ではsession storeをsocket.ioと共有できるようにしておく。例えばexpressを使っている場合は
var express = require('express'), app = express.createServer(), config = { cookie_secret: "****" }; var store = new (require('connect').session.MemoryStore)(); app.use(express.cookieParser()); app.use(express.session({ store: store, secret: config.cookie_secret, cookie: { httpOnly: false } }));
のようなかんじで設定。デフォルトでhttpOnlyはtrueになっているけどこれをfalseにしておかないとclientでcookieが取得出来ないので。。
この例ではデフォルトのMemoryStoreを使っているけど、connect-redisやconnect-mongodbのように別のものに置き換えて使うことも可能*2。
socket.ioサーバでは、このstoreを使ってcookieからsession情報を引けるようにする。
module.exports = function(http, store) { var socket = require('socket.io').listen(http, { log: false }); socket.on('connection', function(client) { client.on('message', function(msg) { var cookie = msg.cookie; if (cookie) { // 送られてきたcookieからsession keyを取得し var parseCookie = require('connect').utils.parseCookie, sid = parseCookie(cookie)['connect.sid']; // storeからsession情報を引いて store.get(sid, function(err, session) { // とりあえずここではclientにsessionデータを送ってやる client.send({ data: session.data }); }); } }); }); return socket; };
こうしておくことで、httpサーバもsocket.ioサーバも同じsession storeを見るので、session情報を共有することができるようになる。
テストを書く
で、これもできればテストを書きたい。と思ってそもそもsocket.ioのテストってどうするんだろうとsocket.ioパッケージについているtestを調べてみたら、node-websocket-clientを使ってclientからの接続を作ってtestを書いているようだった。それを参考に、上記のhttp sessionを共有するsocket.ioサーバのテストを書いてみた。
require('../test_helper'); var port = 15873; var path = require('path'); var Cookie = require('connect').session.Cookie; var utils = (function() { var socketio_dir = path.dirname(require.resolve('socket.io')), utils_path = path.join(socketio_dir, 'lib', 'socket.io', 'utils'); return require(utils_path); })(); function client() { var dir = path.dirname(require.resolve('socket.io')), websocket_path = path.join( dir, 'support', 'node-websocket-client', 'lib', 'websocket' ), WebSocket = require(websocket_path).WebSocket, url = 'ws://localhost:' + port + '/socket.io/websocket'; return new WebSocket(url); } var http = require('http').createServer(), store = new (require('connect').session.MemoryStore)(), server; QUnit.module('socket.io & session', { setup: function() { http.listen(port); server = require('../lib/socket.io')(http, store); }, teardown: function() { http.close(); } }); QUnit.test('connect, message, disconnect', function() { var cookie = new Cookie(); QUnit.stop(); store.set('hoge', { data: 'fuga', cookie: cookie }, function() { assert.ok(true, 'session set'); QUnit.start(); }); QUnit.stop(); var c = client(http); c.onmessage = function(ev) { if (! c._first) { // 1回目はsessionIdが送られてくる? c._first = true; return; } var rawmsg = utils.decode(ev.data)[0], frame = rawmsg.substr(0, 3), msg; switch (frame) { case '~h~': return c.send(utils.encode(rawmsg)); // echo case '~j~': msg = JSON.parse(rawmsg.substr(3)); break; } // dataを受け取ったらcloseする assert.deepEqual(msg, { data: 'fuga' }, 'message'); c.close(); }; c.onopen = function() { assert.ok(true, 'connect'); c.send(utils.encode({ cookie: cookie.serialize('connect.sid', 'hoge') })); }; server.on('clientDisconnect', function() { assert.ok(true, 'disconnect'); QUnit.start(); }); }); QUnit.start();
qunit-tapを使ってnode.jsのテストをproveで行う - すぎゃーんメモで用意したtest_helperスクリプトを使ったQUnit+qunit-tapのテストになっています。使用portは空いてるものを見つけるようにした方が良いと思うけどここでは決め打ちで。
setup時にhttpサーバを適当に上げて、MemoryStoreと一緒にモジュール化したsocket.ioサーバに渡して立ち上げる。最初にsessionデータをcookieと一緒に保存し、websocket-clientでクライアント側からの接続を作って非同期でconnect -> cookie送信 -> message受信 -> 取得したsessionの情報を確認 -> disconnectまでの流れ。disconnectまで終わったらテスト終了。
$ prove --ext=.js --exec=node -v t/socket.io.js t/socket.io.js .. # module: socket.io & session # test: connect, message, disconnect ok 1 - session set ok 2 - connect ok 3 - message ok 4 - disconnect 1..4 ok All tests successful. Files=1, Tests=4, 1 wallclock secs ( 0.02 usr 0.00 sys + 0.14 cusr 0.02 csys = 0.18 CPU) Result: PASS
websocket-clientはnpmパッケージあるけどsocket.ioにも同梱されているのでそこから持ってくることができる。ブラウザ用に書くクライアント側のsocket.ioとちょっと違うのでインタフェースを合わせた下記のようなラッパーを使用した方がスッキリすると思う。
https://github.com/masahiroh/Socket.io-node-client
何にせよ、このように
- socket.ioサーバをhttpサーバ&session storeを渡して動かすようにするモジュールとして切り出す
- test時にはsetupで立ち上げてteadownで落とす
- cookieも作ってMemoryStoreにsessionを入れておき、testの中で検証できるようにする
- websocket-clientでクライアント側からの接続をエミュレート
というようにやればtestは書けるはず。非同期のやりとりはゴチャゴチャするかもしれないけど…