はじめてのSocket.io #2 チャット編「自分がemitした通信内容か判定する」

前回作成したチャットに機能追加を行います。今回はユーザー名の入力に対応しつつ、Socket.ioサーバから送られてきた内容が自分自身が送ったものかを判定する機能を追加していきます。

次のデモ動画の通り最初に名前を入力、自分自身が発言すると文字色が青色になります。


www.youtube.com

基本的な説明

原理

まずはいかにしてSocket.ioから送られてきた情報が自分の送信したものかを判定する処理について考えます。ここではシンプルにユーザー1人1人を判定するためにトークンと呼ばれる文字列を利用します。例えばアルパカさんは「AAA」、パンダさんは「BBB」といった具合にサーバ側で重複しない文字列を割り当てます。

もう少し具体的な処理に落として考えてみましょう。ブラウザがSocket.ioサーバに接続すると各ユーザーを識別する「トークン」が送られてくるので、これを適当な変数などに保存しておきます。

あとは発言する度にこのトークンをつけて送信し、Socket.ioサーバから返ってきたデータ内のトークンが自分のトークンと同じかどうかで判定するというわけです。

なおトークンは単なる文字列ですが、ユーザー間で重複しなければ何でもかまいません。

処理の流れ

省略している箇所もありますが、シーケンス図で書くと以下のような流れになります。

大きく3つのステップに分かれています。発言する度に名前を送信し合うのは効率が良いとは言えないわけですが、そこらへんは次回以降に解消する予定です。

では具体的なコードを見ていきましょう。

サンプルコード

前回のコードを修正しています。登場人物は以下の4名。

serve.js
Socket.ioサーバ。Node.js上で動かします。
public/index.html
ユーザーからの入力を受け取りSocket.ioサーバと通信を行います。いわゆるクライアントですね。今回はJavaScriptを別ファイルにしました。
public/app.js, util.js
index.htmlから呼び出される2つのJavaScript。実際の処理を担当しブラウザ上で動作します。

ソースはGitHub上からも参照できます。 github.com

前回と違う部分の背景色を変更しています。

serve.js

Node.jsで実行するためのJavaScriptです(Webブラウザ上で実行はしません)。

const crypto = require('crypto');
const app  = require("express")();
const http = require("http").createServer(app);
const io   = require("socket.io")(http);

// HTMLやJSなどを配置するディレクトリ
const DOCUMENT_ROOT = __dirname + "/public";

/**
 * "/"にアクセスがあったらindex.htmlを返却
 */
app.get("/", (req, res)=>{
  res.sendFile(DOCUMENT_ROOT + "/index.html");
});
app.get("/:file", (req, res)=>{
  res.sendFile(DOCUMENT_ROOT + "/" + req.params.file);
});

/**
 * [イベント] ユーザーが接続
 */
io.on("connection", (socket)=>{
  console.log("ユーザーが接続しました");

  //---------------------------------
  // ログイン
  //---------------------------------
  (()=>{
    // トークンを作成
    const token = makeToken(socket.id);

    // 本人にトークンを送付
    io.to(socket.id).emit("token", {token:token});
  })();

  //---------------------------------
  // 発言を全員に送信
  //---------------------------------
  socket.on("post", (msg)=>{
    io.emit("member-post", msg);
  });
});

/**
 * 3000番でサーバを起動する
 */
http.listen(3000, ()=>{
  console.log("listening on *:3000");
});

/**
 * トークンを作成する
 *
 * @param  {string} id - socket.id
 * @return {string}
 */
function makeToken(id){
  const str = "aqwsedrftgyhujiko" + id;
  return( crypto.createHash("sha1").update(str).digest('hex') );
}

public/index.html

ファイルの場所をpublicディレクトリ配下に変更しました。前回は発言フォームだけでしたが、今回はSocket.ioサーバへ接続するまでNow Loading状態であることを表示しつつ、名前入力フォームを追加しています。またJavaScriptは長くなってきたので別ファイルに移しました。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>SocketIOチャット</title>
  <style>
    #inputmyname{display:none}
    #chat{display:none}
    #msglist{list-style-type:none; padding-left:1px;}
    .msg-me{color:blue}
    .name {font-weight:bold}
  </style>
</head>
<body>

<h1>SocketIOチャット</h1>

<!-- STEP1. 接続中 -->
<section id="nowconnecting">
  ...サーバに接続中
</section>
<!-- /接続中 -->

<!-- STEP2. 名前を入力 -->
<section id="inputmyname">
  <p>あなたの名前を入力してください</p>
  <form id="frm-myname">
    <input type="text" id="txt-myname">
    <button>入室</button>
  </form>
</section>
<!-- /名前を入力 -->

<!-- STEP3. チャット開始 -->
<section id="chat">
  <!-- 発言フォーム -->
  <form id="frm-post">
    <span id="myname"></span>&nbsp;<input type="text" id="msg">
    <button>送信</button>
  </form>

  <!-- 発言ログ -->
  <ul id="msglist"></ul>
</section>
<!-- /チャット本体 -->

<script src="/socket.io/socket.io.js"></script>
<script src="util.js"></script>
<script src="app.js"></script>

</body>
</html>```

<h3>public/util.js</h3>

今回から新規で登場するファイルです。これはブラウザ上で実行するJavaScriptです。汎用的に利用する関数などを登録していく予定で、今回は<code>document.querySelector()</code>のラッパー関数を置いています。

<pre class="javascript line-numbers"><code>/**
 * [Wrapper] document.querySelector
 *
 * @param  {string} selector "#foo", ".bar"
 * @return {object}
 */
function $(selector){
  return( document.querySelector(selector) );
}

document.querySelector()をjQueryっぽい書き方に変更できます。例えば本来であれば以下のように書くところを

document.querySelector("#foo").addEventListener("click", ()=>{});

以下のように短縮できます。

$("#foo").addEventListener("click", ()=>{});

こういったシュガーシンタックス的な使い方は賛否ありますので、抵抗のある方は使わなくてももちろんかまいません。私が楽をしたいだけですw

public/app.js

ここが本題ですね。ユーザーとSocket.ioサーバの間を取り持ってくれます。

//自分自身の情報を入れる
const IAM = {
  token: null,  // トークン
  name: null    // 名前
};

//-------------------------------------
// STEP1. Socket.ioサーバへ接続
//-------------------------------------
const socket = io();

// 正常に接続したら
socket.on("connect", ()=>{
  // 表示を切り替える
  $("#nowconnecting").style.display = "none";   // 「接続中」を非表示
  $("#inputmyname").style.display = "block";    // 名前入力を表示
});

// トークンを発行されたら
socket.on("token", (data)=>{
  IAM.token = data.token;
});

//-------------------------------------
// STEP2. 名前の入力
//-------------------------------------
/**
 * [イベント] 名前入力フォームが送信された
 */
$("#frm-myname").addEventListener("submit", (e)=>{
  // 規定の送信処理をキャンセル(画面遷移しないなど)
  e.preventDefault();

  // 入力内容を取得する
  const myname = $("#txt-myname");
  if( myname.value === "" ){
    return(false);
  }

  // 名前をセット
  $("#myname").innerHTML = myname.value;
  IAM.name = myname.value;

  // 表示を切り替える
  $("#inputmyname").style.display = "none";   // 名前入力を非表示
  $("#chat").style.display = "block";         // チャットを表示
});


//-------------------------------------
// STEP3. チャット開始
//-------------------------------------
/**
 * [イベント] 発言フォームが送信された
 */
$("#frm-post").addEventListener("submit", (e)=>{
  // 規定の送信処理をキャンセル(画面遷移しないなど)
  e.preventDefault();

  // 入力内容を取得する
  const msg = $("#msg");
  if( msg.value === "" ){
    return(false);
  }

  // Socket.ioサーバへ送信
  socket.emit("post", {
    text: msg.value,
    token: IAM.token,
    name: IAM.name
  });

  // 発言フォームを空にする
  msg.value = "";
});

/**
 * [イベント] 誰かが発言した
 */
socket.on("member-post", (msg)=>{
  const is_me = (msg.token === IAM.token);
  addMessage(msg, is_me);
});


/**
 * 発言を表示する
 *
 * @param {object}  msg
 * @param {boolean} [is_me=false]
 * @return {void}
 */
function addMessage(msg, is_me=false){
  const list = $("#msglist");
  const li = document.createElement("li");

  //------------------------
  // 自分の発言
  //------------------------
  if( is_me ){
    li.innerHTML = `<span class="msg-me"><span class="name">${msg.name}</span>> ${msg.text}</span>`;
  }
  //------------------------
  // 自分以外の発言
  //------------------------
  else{
    li.innerHTML = `<span class="msg-member"><span class="name">${msg.name}</span>> ${msg.text}</span>`;
  }

  // リストの最初に追加
  list.insertBefore(li, list.firstChild);
}

実行する

実行や確認方法は前回と同じです。

サーバを起動

では早速動かしてみましょう。Terminalで先ほどのJavaScriptをnodeに渡すだけです。これでSocket.ioサーバが3000番のポートで起動します。

$ node serve.js
listening on *:3000

ブラウザから確認

ブラウザからhttp://localhost:3000へアクセスします。前回と異なりサーバへ接続中の文字が表示された後、名前を入力するフォームが表示されます。複数のタブまたはウィンドウを開いておいてくださいね。

名前を入力し「入室」ボタンを押すとチャット画面へ移動します。自分が発言した物は青色で表示されるのがわかると思います。無事に自分の通信かどうかの判定がうまくいっているようですね。

サーバを終了

サーバを落としたいときはCtrl+cキーで強制的に終了させます。

$ node serve.js
listening on *:3000
^C

解説

サーバ

トークンの作成

Socket.ioではsocket.idから各ユーザーのセッションIDを知ることができます。今回はこのIDに適当な文字列を足してSHA1によりハッシュ化した物をトークンとしています。ひとまず今回の目的を達成するだけであれば連番を順番に割り振ってもかまいません。

const crypto = require('crypto');

io.on("connection", (socket)=>{
  const token = makeToken(socket.id);
});

function makeToken(id){
  const str = "aqwsedrftgyhujiko" + id;
  return( crypto.createHash("sha1").update(str).digest('hex') );
}

cryptoは標準モジュールなので特別なインストールは必要ありません。

特定のユーザーにだけemitする

今回のような場合、トークンは接続してきた本人にだけ送信したいわけです。Socket.ioでは以下のようにemitする前にto(相手のID)を付けることで、そのIDのユーザーにだけemitすることが可能です。

io.to(socket.id).emit("token", {token:token});

クライアント

自分のトークンを保存する

Socket.ioサーバからトークンが発行されたら、以下の箇所で連想配列へトークンを代入しています。変数に入れているだけですのでブラウザを終了したり別のページに遷移するとリセットされてしまう点に注意が必要です。

//自分自身の情報を入れる
const IAM = {
  token: null,  // トークン
  name: null    // 名前
};

// トークンを発行されたら
socket.on("token", (data)=>{
  IAM.token = data.token;
});

もしブラウザ終了後も保存しておきたい場合は、WebStorageなどを利用します。

トークンを比較し自分の通信か判定する

チャットで発言する際に自分のトークンを一緒に渡し、チャットの発言を受信した際に自分のトークンと相手のトークンが同じであれば自分の発言と幹分ける処理を行っています。

// チャットで発言
$("#frm-post").addEventListener("submit", (e)=>{
  // Socket.ioサーバへ送信
  socket.emit("post", {
    text: msg.value,   // 発言
    token: IAM.token,  // 自分のトークン
    name: IAM.name     // 自分の名前
  });
});

// Socket.ioサーバから受信
socket.on("member-post", (msg)=>{
  // サーバから送られてきたmsg.tokenと自分のトークンIAM.tokenを比較
  const is_me = (msg.token === IAM.token);
  addMessage(msg, is_me);
});

別のアプローチ

トークンをクライアントで作成する

現在の処理ではサーバ側でトークンを作成していますが、クライアントで作成する方法も考えられます。

例えば以下のように乱数や現在時間(マイクロ秒)を組み合わせ、他のユーザーと重複する確率が限りなく低い文字列を作成します。念の為にサーバ側で簡単なチェックは行っても良いかもしれませんね。

function getToken(){
  return(
    Math.floor( Math.random() * 100000).toString() +
    new Date().getTime()
  );
}

なおクライアント側でトークンを作成する場合、Socket.ioのセッションIDの再利用はしない方が良いでしょう。セッションハイジャック(セッションを他者に乗っ取られるクラッキングの一種)の原因となります。

自分の発言は自分に送らない

Socket.ioには送信者以外にemitするbroadcastと呼ばれる機能があります。

io.on("connection", (socket)=>{
  socket.on("post", (msg)=>{
    // 発言者には送らない(=発信者以外に送る)
    socket.broadcast.emit("member-post", msg);
  });
});

この機能を利用し、以下のような処理でも同様の結果が実現できます。

  1. 自分の発言は直接ブラウザに描画する
  2. 同時に裏側でSocket.ioサーバへ送信し他のユーザーへ共有する

これだと(自分の発言は)サーバを介さずに描画が行われるため非常にスピーディに表示を行うことができます。Socket.ioサーバから送られてくるのは常に他のユーザーですのでトークンの作成も不要でしょう。

ただしデメリットもあり、例えばサーバ上で発言を加工するなどの処理が行なえません。またSocket.ioサーバへ送信する際に何らかの障害で送れなかった場合に巻き戻す処理が必要になります。

一長一短あるので場合によって使い分ける感じですね。

続き

blog.katsubemakito.net

参考ページ