Socket.io + Express を試してみる

WebSocketとは?

双方向通信を可能にするプロトコルの一つです。リアルタイムで通信を行いたいチャットなどに利用されます。

普通のhttp/https通信では、クライエントからリクエストが送られる度にコネクションが張られ、サーバーからレスポンスが返ると、コネクションは切断されます。 つまり、通信の度にコネクションを張り直す必要があり、クライエントからのリクエストなしにサーバーからレスポンスを送信することができません。

これを解決するために、従来はロングポーリング(comet)という技術が使われていました。 一定の間隔でブラウザからリクエストを送り、サーバーは新着情報がなければレスポンスを一定時間保留にします。その間に新着があればそれを返し、なければ何もなかったというレスポンスを返します。サーバー側の保留なしの単純なポーリングよりリアルタイム性が高いのが特徴です。これによって擬似的に、リアルタイム通信が可能になりますが、以下の欠点があります。

  • ヘッダーが冗長でオーバーヘッドが大きい
  • タイムアウトがある
  • httpコネクションを占有してしまう

一方でWebsocketは、最小2byte, 最大でも14byteと軽量かつ、HTTPをアップグレーデして、TCP上で双方向通信を実現します。 クライエントからの最初のリクエストのヘッダーは次のようになります。

GET / HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: xxxxxxxxxxx==
Sec-WebSocket-Version: 13

サーバーからのレスポンスは次のようになります。

HTTP/1.1 101 OK
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: yyyyyyyyyy=

なんとなく直感で理解できるのではないでしょうか。
Sec-Websocket-KeySec-Websocket-Acceptはそれぞれ対応しており、クライエントが自分のリクエストに対するレスポンスだと認識できるようになっています。 ちなみに、101は、Upgradeリクエストヘッダーが送られてきた場合に返却するSwitchinig Protocols レスポンスです。 この時点で、Websocketコネクションが確立されました。

Socket.ioとは?

手軽にwebsocketを実現するjavascriptライブラリで、フロント側と、node.jsで実装されたサーバー側から作られています。 WebSocketが繋がらなくなった時に、ロングポーリングポーリングなどの代替手段を提供してくれる他、到達確認や認可、namespaceなどを提供してくれます。

チャットを作ってみる

まずexpresssocket.ioをインストールします。

yarn add express socket.io

次のようにapp.jsを作成します。

// app.js

const express = require('express');
const app = express();
const http = require('http').createServer(app);

const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('<h1>Hello world</h1>');
});

http.listen(PORT, () => {
  console.log(`Server listening. Port: ${PORT}`);
});

expressに関する記述のみですが、動作確認をしておきましょう。

node app.js

ブラウザで、localhost:3000にアクセスします。Hello worldと表示されるはずです。

次に表示部分をhtmlファイルに分けていきます。

// app.js

app.use(express.static(`${__dirname}/public`));

app.get('/', (req, res) => {
  res.send(`${__dirname}/index.html`);
});

まず、app.jsでファイルを読み込みます。静的ファイルを分けたいので、staticを使っています。

<!-- public/index.html -->

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Socket.io</title>
    <link rel="stylesheet" href="index.css">
  </head>
  <body>
    <div class="chat-field">
      <div class="chat-area">
        <ul id="messages"></ul>
      </div>
      <form class="chat-form" action="">
        <input id="m" />
        <button class="disabled">Send</button>
      </form>
    </div>

    <script src="/socket.io/socket.io.js"></script>
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
    <script type="text/javascript" src="index.js"></script>

  </body>
</html>
/* public/index.css */

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font: 13px Helvetica, Arial;
}

.chat-field {
  margin: 10em auto;
  width: 40%;
  height: 50em;
  border-radius: 5px;
  box-shadow: 5px 5px 25px 0 rgba(0, 0, 0, .5);
  text-align: center;
}

.chat-area {
  margin: 5em auto;
  width: 90%;
  height: 40em;
  overflow: scroll;
}

.chat-area li {
  margin: 0.5em;
  padding: 0.7em;
  background-color: rgb(223, 223, 223);
  border-radius: 8px;
  list-style-type: none;
}

.chat-form input {
  margin-right: 0.3em;
  padding: 0.6em;
  width: 70%;
  border: 2px solid blueviolet;
  border-radius: 5px;
}

.chat-form button {
  padding: 0.6em;
  border: 2px solid blueviolet;
  border-radius: 5px;
  background-color: blueviolet;
  color: white;
}

次にindex.jsを書いていきます。アクセス時に、websocketを張り、送信ボタンを押すとそれを通じて通信を行い、レスポンスを表示する仕様です。

// public/index.js

$(() => {
  $('input').change(function() {
    let text = $(this).val();
    if (text === null) {
      $('button').prop('disabled', true);
    } else {
      $('button').prop('disabled', false);
    };
  });

  const socket = io();
  $('form').submit(e => {
    e.preventDefault();

    socket.emit('post', $('#m').val());
    $('#m').val('');
    $('button').prop('disabled', true);

    return false;
  });

  socket.on('msg', res => {
    $('#messages').append($('<li>').text(res));
  });
});

socket.ioインスタンスに対して、emitで送信、onで受信を行っています。第一引数は、送信側と受信側で同じキーを指定します。 ここまでで一度動作確認をしておきましょう。次のような画面になっていれば成功です。 f:id:shyu_61:20201220135226p:plain

続いてサーバー側にsocket.io部分を書いていきます。

// app.js

const io = require('socket.io')(http);

io.on('connection', socket => {
  console.log('user connected');

  socket.on('post', post => {
    io.emit('msg', post);
  });
});

onで受信し、postをキーに持つメッセージを受け取ります。そして、受け取ったものをそのまま返却していますが、これはconnectionが張られている全てのユーザーに送信されます。動作確認してみてください。

これで簡単なチャットは作成できましたが、自分と相手のメッセージくらい区別できるようにしておきましょう。 具体的には、接続時にユーザー一意のトークンを発行し、ユーザーがリクエストの際にそのトークンを含めることで実装します。

トークン生成にはcryptoを使用し、socket.idでユーザーごとに割り振られたidを取得できるのでこれを元に作成します。

// app.js

const crypto = require('crypto');

io.on('connection', socket => {
  console.log('user connected');

  (() => {
    const token = makeToken(socket.id);
    io.to(socket.id).emit('token', { token: token });
  })();

  socket.on('post', post => {
    io.emit('msg', post);
  });
});

const makeToken = id => {
  return crypto.createHash('sha1').update(id).digest('hex');
};

全てのユーザーに送信者のトークンを返却してるのが相当イケてないですが今回はよしとしてください(笑)

最後にクライエント側で識別を行って、ラインのように左右に出し分けたいと思います。

// index.js

const IAM = { token: null };
socket.on('token', data => {
  IAM.token = data.token;
});

$('form').submit(e => {
  e.preventDefault();

  socket.emit('post', {
    msg: $('#m').val(),
    token: IAM.token,
  });

  $('#m').val('');
  $('button').prop('disabled', true);

  return false;
});

socket.on('msg', res => {
  if (res.token === IAM.token) {
    $('#messages').append($('<li class="mine">').text(res.msg));
  } else {
    $('#messages').append($('<li class="others">').text(res.msg));
  }
});

まず、tokenを受け取り、IAMにセットしておきます。(tokenはconnectionが張られた際に、サーバーから送信されるものです。) メッセージの送信時に、トークンを含めるように変更し、受け取り時はIAMと比較して自分が送信したものかどうか判定します。class属性を変えることで表示を分けます。

最後にindex.cssを変更します。

/* index.css */

.chat-area li {
  padding: 0.7em;
  background-color: rgb(223, 223, 223);
  border-radius: 8px;
  list-style-type: none;
}

.mine {
  margin: 0.5em;
  width: 50%;
}

.others {
  margin: 0.5em 0.5em 0.5em auto;
  width: 50%;
}

これで完成です。ブラウザで複数タブを開いて、チャットをやりとりしてみてください。 最終コードはgithubにあげてます。 f:id:shyu_61:20201220135633p:plain

参考にさせて頂きましたm

WebSocketについて調べてみた。 - Qiita
socket.io が提供してくれているものは何か - from scratch
はじめてのSocket.io #2 チャット編「自分がemitした通信内容か判定する」