socket.ioのv3で知らなかったこと
サーバーサイド
初期化
httpサーバーと共存方式、websocketサーバーだけのスタンドアロン方式、expressを使った方法 koaを使った方法、tsを用いる方法などちゃんとドキュメントに書かれてあった。
イベント
socket.emit
のほかに単純にsocket.send()
をするだけで、message
イベントとして送信される。
// 送り手
socket.send('hello')
// 受け手
socket.on('message', data => console.log(data)); // hello
socket id
コネクションが確率後に作成されるランダム文字列。
ルーム
client.join('roomname') とすると、roomに入ったことになる
io.on("connection", (socket) => {
console.log(socket.rooms); // Set { <socket.id> }
socket.join("room1");
console.log(socket.rooms); // Set { <socket.id>, "room1" }
});
socket.roomsに現在のコネクションで入室しているroomが表示されるが0番目はsocket.idになる。これはコネクションが確立してある限り消えない。room idを取得したい場合は1番目もものを取得すべし。
ミドルウェア
socket.io もミドルウェア機能がつかえる。nodeと同様にuseを利用する。next()で次のミドルウェアにパスする。next('string')でエラーとなり、socket.on('connection_error')イベントが発生する。
io.use((socket, next) => {
next();
});
io.use((socket, next) => {
next(new Error("thou shall not pass"));
});
io.use((socket, next) => {
// not executed, since the previous middleware has returned an error
next();
});
socketに変数をぶちこめる
socketインスタンスにプロパティをセットしてあとから再利用できる。
// in a middleware
io.use(async (socket, next) => {
try {
const user = await fetchUser(socket);
socket.user = user;
} catch (e) {
next(new Error("unknown user"));
}
});
io.on("connection", (socket) => {
console.log(socket.user);
// in a listener
socket.on("set username", (username) => {
socket.username = username;
});
});
クライアントサイド
初期化
クライアント側はサーバーのwebsocketが同じドメインであれば
const socket = io();
でsocketを取得できる。ことなるドメインであればこう。
const socket = io('https://server-domain.com');
ねーむすぺーす
ネームスペースはendpointをスラッシュで分けるだけ
// same origin version
const socket = io('/admin');
// cross origin version
const socket = io('https://server-domain.com/admin');
クレデンシャル
tokenも渡せる
クライアントサイド
// plain object
const socket = io({
auth: {
token: "abc"
}
});
// or with a function
const socket = io({
auth: (cb) => {
cb({
token: "abc"
});
}
});
サーバーサイド
io.use((socket, next) => {
const token = socket.handshake.auth.token;
// ...
});
expressのミドルウェアとsocket.ioのミドルウェアの統合
ミドルウェアの形が少し違うので小さなラッパーが必要
express sessionとの統合
const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);
const session = require("express-session");
io.use(wrap(session({ secret: "cats" })));
io.on("connection", (socket) => {
const session = socket.request.session;
});
Passportとの統合
const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);
const session = require("express-session");
io.use(wrap(session({ secret: "cats" })));
io.on("connection", (socket) => {
const session = socket.request.session;
});
複数のサーバーでどのようにwebsocketを扱うのか
2つのことに注意すべし。
- HTTPロングポーリングが有効になっているならば、スティッキーセッションを有効にする
- redisのアダプターを利用する
スティッキーセッションを有効にする
スティッキーセッションとは、ロードバランサーにおいて、同じ人からのアクセスはすべて同じサーバーに転送するという設定。セッションを固定する。
ロングポーリングの設定はsocket.ioではデフォルトで有効になっており、websocket通信が使えない環境のユーザーはロングポーリングでアクセスしている。もし、途中でロードバランサーにより通信サーバーが変わってしまえば、コネクション先を見失いエラーになる。
Error during WebSocket handshake: Unexpected response code: 400
もしこのような挙動(websocketを使えない環境でロングポーリングをする)をしたくない場合は次のように設定をする。
const client = io('https://io.yourhost.com', {
// WARNING: in that case, there is no fallback to long-polling
transports: [ 'websocket' ] // or [ 'websocket', 'polling' ], which is the same thing
})
このような設定をした場合、websocketのコネクションが確立できなかった場合の再接続処理(フォールバック)はされない。生のwebsocketを使うかrobust-websocketのようなライブラリを使う。
スティッキーセッションをするには2つの方法がある。
- クッキーを使ったルーティング
- 送信元アドレスによるルーティング
Nginxを使う設定やApacheを使う設定例などが公式サイトにのっている
redisのアダプターを利用する
redisのpub/sub機能を利用する方法。こちらはNode.jsデザインパターンに詳しい内容がのっている。
ただしこのパターンは、クライアント側が落ちていたときに、メッセージをキューにためておくわけではない。そこまでしたいならばRabitMQなどのサービスが必要になる。
CORS
v3 から corsの設定を明示する必要があるよ
const io = require("socket.io")(httpServer, {
cors: {
origin: "https://example.com",
methods: ["GET", "POST"]
}
});
// server-side
const io = require("socket.io")(httpServer, {
cors: {
origin: "https://example.com",
methods: ["GET", "POST"],
allowedHeaders: ["my-custom-header"],
credentials: true
}
});
// client-side
const io = require("socket.io-client");
const socket = io("https://api.example.com", {
withCredentials: true,
extraHeaders: {
"my-custom-header": "abcd"
}
});
アンチパターン
クライアントサイドでは、connectイベントの中にイベント受信を書くの良くないらしい。新しいハンドラーがリコネクションのたびに設定されてしまうとのこと。
(サーバーサイドではconnectionの引数(clientとかsocketとか名前つけることが多い)を使わなければ送信できないのでそもそもネストになる。)
// BAD
socket.on("connect", () => {
socket.on("data", () => { /* ... */ });
});
// GOOD
socket.on("connect", () => {
// ...
});
socket.on("data", () => { /* ... */ });
connect_errorをうけたとき
disconnectイベントはリトライ処理をやってくれるが、connect_errorイベントの場合はリトライ処理をやってくれない。手動で書くこと。ちなみにconnect_errorイベントはサーバー側のsocket.ioミドルウェアでnext(err)されたときに発生する。
// either by directly modifying the `auth` attribute
socket.on("connect_error", () => {
socket.auth.token = "abcd";
socket.connect();
});
// or if the `auth` attribute is a function
const socket = io({
auth: (cb) => {
cb(localStorage.getItem("token"));
}
});
socket.on("connect_error", () => {
setTimeout(() => {
socket.connect();
}, 1000);
});
最近のwebsocketはバイナリを扱えるよ
昔のサンプルコードはわざわざJSON.stringify
して文字列になおして送信していたが、いまはそのままjsonを渡すことが可能。むしろJSON.stringifyは無駄なオーバーヘッドが発生するぶん非推薦。
// server-side
io.on("connection", (socket) => {
socket.emit("hello", 1, "2", { 3: '4', 5: Buffer.from([6]) });
});
// client-side
socket.on("hello", (arg1, arg2, arg3) => {
console.log(arg1); // 1
console.log(arg2); // "2"
console.log(arg3); // { 3: '4', 5: ArrayBuffer (1) [ 6 ] }
});
// BAD
socket.emit("hello", JSON.stringify({ name: "John" }));
// GOOD
socket.emit("hello", { name: "John" });
ただしMap
とSet
はシリアライズされないので、手動出する必要あり。
const serializedMap = [...myMap.entries()];
const serializedSet = [...mySet.keys()];
従来のリクエストレスポンス方式も使えるよ
HTTPリクエストみたいに、リクエストしたらレスポンスをコールバックで処理するのも可能。例えばメッセージを送信する前にローカルストレージに保存しておいて、送信成功時にローカルストレージから消去するしたいときは、これを利用するとよい。
socket.ioではこの機能のことをacknowledgements
と呼ぶよ。
// server-side
io.on("connection", (socket) => {
socket.on("update item", (arg1, arg2, callback) => {
console.log(arg1); // 1
console.log(arg2); // { name: "updated" }
callback({
status: "ok"
});
});
});
// client-side
socket.emit("update item", "1", { name: "updated" }, (response) => {
console.log(response.status); // ok
});
タイムアウトはサポートされていないけど、実装できるよ
const withTimeout = (onSuccess, onTimeout, timeout) => {
let called = false;
const timer = setTimeout(() => {
if (called) return;
called = true;
onTimeout();
}, timeout);
return (...args) => {
if (called) return;
called = true;
clearTimeout(timer);
onSuccess.apply(this, args);
}
}
socket.emit("hello", 1, 2, withTimeout(() => {
console.log("success!");
}, () => {
console.log("timeout!");
}, 1000));
予約語があるよ
以下の名前はすでに使用されているためイベント名として利用できない
- connect
- connect_error
- disconnect
- disconnecting
- newListener
- removeListener
// BAD, will throw an error
socket.emit("disconnecting");
disconnectの挙動
自動的にすべてのroomからleaveする。disconnect時の全room idを取得したいのであればdisconnecting
イベントを利用する。disconnectされたあとは、接続リトライ処理が(デフォルトの設定では)試みられる。
io.on('connection', socket => {
socket.on('disconnecting', () => {
console.log(socket.rooms); // the Set contains at least the socket ID
});
socket.on('disconnect', () => {
// socket.rooms.size === 0
});
});
emitとboradcastの違い
送信者含めて全員に配信
io.on("connection", (socket) => {
io.emit("hello", "world");
});
送信者を除く全員に配信
// server-side
io.on("connection", (socket) => {
socket.broadcast.emit("hello", "world");
});
複数のサーバーがある場合
redisアダプターを使えばサーバーをまたいで送信できます。
ローカルフラグを立てれば自身の属するサーバーだけメッセージを送信できます。
io.on("connection", (socket) => {
io.local.emit("hello", "world");
});
on以外のリスナー
ワンタイムリスナー
socket.once("details", (...args) => {
// ...
});]
すべてのイベントをキャッチ
socket.onAny((eventName, ...args) => {
// ...
});
すべてのイベントをキャッチ。かつ一番最初に実行されるよ
socket.prependAny((eventName, ...args) => {
// ...
});
リスナーを解除
const listener = (...args) => {
console.log(args);
}
socket.on("details", listener);
// and then later...
socket.off("details", listener);
複数のリスナーを削除
// for a specific event
socket.removeAllListeners("details");
// for all events
socket.removeAllListeners();
any系のリスナーを削除
const listener = (eventName, ...args) => {
console.log(eventName, args);
}
socket.onAny(listener);
// and then later...
socket.offAny(listener);
// or all listeners
socket.offAny();
validation
データの有効性チェックはsocket.ioの範囲外だから外部ライブラリを使ってね。
- joi
- ajv
- validatorjs
とかあるよ。
const Joi = require("joi");
const userSchema = Joi.object({
username: Joi.string().max(30).required(),
email: Joi.string().email().required()
});
io.on("connection", (socket) => {
socket.on("create user", (payload, callback) => {
if (typeof callback !== "function") {
// not an acknowledgement
return socket.disconnect();
}
const { error, value } = userSchema.validate(payload);
if (error) {
return callback({
status: "KO",
error
});
}
// do something with the value, and then
callback({
status: "OK"
});
});
});
エラーハンドリング
ビルトインでエラーハンドリング機構がないのでtry-catch
使ってね。
io.on("connection", (socket) => {
socket.on("list items", async (callback) => {
try {
const items = await findItems();
callback({
status: "OK",
items
});
} catch (e) {
callback({
status: "NOK"
});
}
});
});
サーバーサイドでは、状況によってはEventEmitter.captureRejections = true
は有効かもしれない。リンク
require("events").captureRejections = true;
io.on("connection", (socket) => {
socket.on("list products", async () => {
const products = await findProducts();
socket.emit("products", products);
});
socket[Symbol.for('nodejs.rejection')] = (err) => {
socket.emit("error", err);
};
});
揮発性のイベント(Volatile events)
socket.volatile.emit("hello", "might or might not be received");
volatileフラグをつけて送信すると、受け手が受け取ったかどうかは無視する。実はsocket.ioのデフォルトの動作では、受け手と再びコネクションするまでバッファされている。volatileフラグをつけるとバッファせず、せっせと新しいイベントを送信する。
これが役に立つのは、ゲームなどでキャラクターのいち情報など最新の情報がやくにたつときなど。
以下は例。
// server-side
io.on("connection", (socket) => {
console.log("connect");
socket.on("ping", (count) => {
console.log(count);
});
});
// client-side
let count = 0;
setInterval(() => {
socket.volatile.emit("ping", ++count);
}, 1000);
サーバーを起動させる。
connect
1
2
3
4
# サーバーをリスタートすると、クライアントは自動的に再接続する。
9
10
11
かりにvolatileフラグを付けていなかった場合(デフォルトの挙動)
connect
1
2
3
4
# サーバーをリスタートすると、クライアントは自動的に再接続をして、バッファされたイベントを送信する。
5
6
7
8
9
10
11