Closed16

socket.ioのv3で知らなかったこと

ハトすけハトすけ

サーバーサイド

初期化

httpサーバーと共存方式、websocketサーバーだけのスタンドアロン方式、expressを使った方法 koaを使った方法、tsを用いる方法などちゃんとドキュメントに書かれてあった。
https://socket.io/docs/v3/server-initialization/

イベント

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つのことに注意すべし。

  1. HTTPロングポーリングが有効になっているならば、スティッキーセッションを有効にする
  2. 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を使う設定例などが公式サイトにのっている
https://socket.io/docs/v3/using-multiple-nodes/

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" });

ただしMapSetはシリアライズされないので、手動出する必要あり。

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
このスクラップは2020/12/12にクローズされました