クソ雑魚エンジニアのメモ帳

学んだことを書くところ

リアルタイムチャットをServerSentEvents/php/vueで実装

背景

  • チャット機能が必要になりそうな予感
  • 前にgo言語でwebsocketのチャット作ったことはあるけどなんも覚えてない
  • vuejs使えば、リッチに画面に表示させることも苦じゃないはずだからやってみよう
  • リアルタイム通信の手法って結構あったようなきがするから改めてちゃんと調べてみよう

実装結果デモ▼

chat.gif

手法調査

結論からいうと、server sent eventsを使うことに決めました

前提としてチャット機能とは以下のとおりと考えています▼ AさんとBさんがチャットの画面を開いているとして、Aさんがチャットでメッセージを送信すれば、Bさんの画面に画面のリロードなしにAさんのメッセージが表示されること

ここで、サーバーとクライアントのリアルタイム通信の手法がまとめてある以下のサイトが非常に勉強になりました▼

http://kimulla.hatenablog.com/entry/2016/01/17/%E3%83%AA%E3%82%A2%E3%83%AB%E3%82%BF%E3%82%A4%E3%83%A0%E3%81%AAweb%E3%82%A2%E3%83%97%E3%83%AA%E3%82%92%E5%AE%9F%E7%8F%BE%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95%28%E3%83%9D%E3%83%BC%E3%83%AA%E3%83%B3

チャット機能を作る手法は大きく分けて3つあります

  • longpolling
    • 聞き慣れない言葉だけど、要するにクライアントがajaxなどで一定時間ごとにサーバーにリクエスト送るだけ
    • プロトコルhttps/http
    • 毎回コネクション作ったりhttpsならhandshakeなりしないといけない
    • サーバーからはリクエストは投げれない
  • server sent events
    • サーバーからのみ、データの変更があった場合のみリクエストが届く
    • クライアントからはリクエストを投げれない
    • プロトコルは、http/httpsを使っている
    • コネクションは最初の一度のみで良い
  • websocket
    • リアルタイム通信の大本命的存在(多分)
    • ws/wssという独自プロトコルを使っているため、オーバーヘッドがめちゃ少ない
    • クライアントから・サーバーサイドからどちらでもリクエストを送信できる

有名どころなリアルタイム通信はどの手法を使っているか確認してみました

  • github(issue画面)
    • websocket
  • gitlab(issue画面)
    • longpolling
  • chatwork
    • server sent events
  • slack
    • websocket

こうみると割とバラバラ。ユーザーからするとgitlabのissue画面のリアルタイム性がslackとかgithubとかに劣るようにはあまり思いませんので、正直あんまり変わらないんだろうなあと思いました

実装のしやすさは longpolling > server sent events >>> websocket(独自プロトコルなので)

また、通信の効率性(オーバーヘッドの小ささ)は websocket > server sent events >>>> longpolling(毎回ちゃんとコネクションするので)

まとめると,以下のことから、可もなく不可もなくという理由でserver sent eventsを実装することに決めました

観点 long polling server sent events websocket
プロトコル http/https http/https ws/wss
サーバーからリクエストを送れる x o o
クライアントからリクエストを送れる o x o
採用実績
通信の効率性 xx o
開発しやすさ o xx

サーバーサイド側(php(phalcon))

以下を参考に、コントローラーに作成

https://developer.mozilla.org/ja/docs/Server-sent_events/Using_server-sent_events

 public function sse()
    {
        $this->response->setHeader("Content-Type", "text/event-stream");
        $this->response->setHeader('X-Accel-Buffering', 'no');
        $this->response->setHeader('Cache-Control', 'no-cache');
        $this->response->send();

        // Remove one level of output buffering
        ob_get_clean();

        $latestId = $this->request->getHeader('Last-Event-ID') ?: 0;
        for ($i = 0;$i<10;$i++) {

            $chats = Chats::find([
                'conditions' => 'id > :latestId:',
                'bind' => ['latestId' => $latestId]
            ]);

            // データが新たに発生した場合
            if (count($chats)) {
                foreach ($chats as $chat) {
                    $latestId = $chat->id;
                    // メッセージ書き出し
                    echo "event: phalcon-message\n";
                    echo 'data: ' . json_encode($chat) . "\n";
                    echo "id: " . $latestId;
                    echo "\n\n";
                }

                // 送信
                ob_flush();
                flush();
            }

            sleep(2);
        }
    }

とりあえず解説つけると、

最初の以下の部分で、ヘッダーにserver sent eventsの設定を記述し、すぐにsendします

text/event-streamを設定しているので、sendしてもresponseが途切れないのがミソですね

        $this->response->setHeader("Content-Type", "text/event-stream");
        $this->response->setHeader('X-Accel-Buffering', 'no');
        $this->response->setHeader('Cache-Control', 'no-cache');
        $this->response->send();

streamでresponseしつづける出力内容をここでリセットしています

        // Remove one level of output buffering
        ob_get_clean();

以下の部分が非常に大事。server sent eventsの特徴の一つに、以下の点がある

注意すべき点として、EventSourceはサーバーから接続が切断された場合再接続を試みるという点があります。ただし、404が返ってきたとか、サーバーに繋がらないとか、そういうときは再接続はしません。一度はつながったのに切れてしまった場合には、再接続を試みるのです。なお、上のreadyStateの2 (CLOSED)というのは、もう再接続もできず終了してしまったという状態を指します。 接続が切れて再接続したとき、一番最後に受信したイベントのIDがLast-Event-IDというHTTPヘッダとなってサーバーへ送られます。これにより、サーバー側はこのクライアントがイベントをどこまで受信したのか知ることができます。サーバー側をうまく作れば、再接続時は続きからイベントを配信するというようなことも可能でしょう。 出典:https://uhyohyo.net/javascript/13_2.html

要するに、今の実装では、2 x 10秒が経過すると、自動で接続が切れますが、自動的にそのあともクライアント側から接続を試みます。そのときに、最後に取得したチャット内容をサーバーサイドが把握していないと、再接続のしょっぱなに不必要(すでに送られている)なチャット内容をクライアント側に返してしまうということが起こってしまいます。

そのために、最後にクライアントに送ったidをLast-Event-IDを探しに行きます、存在しなかった場合はid=0としてまるっとチャット全部をクライアントに返します。ではどこでidをつけているのかは後述

        $latestId = $this->request->getHeader('Last-Event-ID') ?: 0;

また、以下のforループの意図は以下のとおりです

  • 2秒ごとにデータが更新されていないかチェック
  • それを10回繰り返す
        for ($i = 0;$i<10;$i++) {
          // クライアントにメッセージを送る処理
          // 省略

          sleep(2);
        }

最後に、ループ処理の中のこの部分ですが、簡単にいうと以下のとおりです

  • 新しいデータがないかDBから検索
  • データがあった場合、発生したデータを全てクライアントに返却
  • データがなかった場合はなにもしない
           $chats = Chats::find([
                'conditions' => 'id > :latestId:',
                'bind' => ['latestId' => $latestId]
            ]);

            // 新規で発生した場合
            if (count($chats)) {
                foreach ($chats as $chat) {
                    $latestId = $chat->id;
                    // Send the 'update' event
                    echo "event: phalcon-message\n";
                    echo 'data: ' . json_encode($chat) . "\n";
                    echo "id: " . $latestId;
                    echo "\n\n";
                }

                ob_flush();
                flush();
            }

ちょっと一部サンプルに頼ったので深い理解はできていませんが、echoでバッファに出力したあと、flush()で出力内容をクライアントにまとめて送信しているという認識で合ってるんですかね??詳しい方いらっしゃいましたら教えてください、

また、先ほど言っていた、メッセージにidをつける方法なのですが、以下の部分で設定しています、これをクライアントが受け取りつづけ、もし接続が切れてクライアント側が自動で再接続するときには、最後に受け取ったidを自動でLast-Event-IDとしてヘッダーにくっつけてサーバーに送ってくれるということですね、合理的で素敵です

echo "id: " . $latestId;

クライアント側(vue.js)

クライアント側は非常に簡単で、これで終わりです、

    setupStream () {
      let es = new EventSource('/api/v1/test/sse')
      es.addEventListener('phalcon-message', event => {
        let res = JSON.parse(event.data)
        this.storedDatas.push(res)
        this.id = res.id
      }, false)

      es.addEventListener('error', event => {
        if (event.readyState === EventSource.CLOSED) {
          console.log('Event was closed')
        }
      }, false)
    },

EventSourceインスタンスに対して、リスナをつけるだけです

サーバーからメッセージが送られた時は、先ほどのeventに設定した名前でイベントが発生します。 今回のサンプルの場合では、phalcon-messageというイベントが発生します

echo "event: phalcon-message\n";

また、 this.storedDatasとかthis.idとかってなんだよ!!!って方は、vueのコンポーネント丸ごと貼ったので参考にしてください▼

<template>
  <div>
    <div class="columns">
      <div class="column is-1"></div>
      <div class="column is-4">
        <b-input v-model="param" maxlength="200" rows="10" type="textarea"></b-input>
        <button type="button"  class="button is-primary" @click="setupStream">チャット開始</button>
        <button type="button"  class="button is-primary" @click="send">発言</button>
        <p>{{ id }}</p>
        <p>{{ storedDatas }}</p>
      </div>
      <div class="column is-2"></div>
      <div class="column is-4">
        <div v-for="data in storedDatas" :key="data.id" class="columns">
          <div class="column is-12">
            <span class="help">{{ data.id }}</span>
            {# TODO 要改行対応 #}
            <div class="button is-primary is-rounded is-large">{{ data.content }}</div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'chat',
  data () {
    return {
      storedDatas: [],
      // デフォルトで入力されているメッセージ
      param: 'default_message',
      // クライアントが持つ最新のチャットid
      id: 0
    }
  },
  methods: {
    setupStream () {
      let es = new EventSource('/api/v1/test/sse')
      es.addEventListener('phalcon-message', event => {
        let res = JSON.parse(event.data)
        this.storedDatas.push(res)
        this.id = res.id
      }, false)

      es.addEventListener('error', event => {
        if (event.readyState === EventSource.CLOSED) {
          console.log('Event was closed')
        }
      }, false)
    },
    send () {
     // axiosでポストしている
     // この処理でDBにデータが加わる
      this.$store.dispatch('get', {
        _path: '/api/v1/test',
        content: this.param
      })
    }
  }
}
</script>

ちなみに、上のvueファイルでは、bulma,buefy,vue-routerを使っています。

デモ

chat.gif

所感

  • server sent eventsのデメリットとしてあげられることの多かったクライアントからリクエストを送信できない点ですが、上のvueファイルにあるようにsseとは別のリクエストで送信すると問題なく登録できたので、あまり不自由には感じませんでした。
  • vueでいい感じにチャットのコンポーネントがなかった
    • なので今回は丸いボタンに文字列詰め込んでそれっぽくした
    • framework7のmessageコンポーネントがうらやましい
  • websocketでなくても実用に耐えれそうな気がした