Socket.io
Express
websocket
vue.js
nuxt.js

Nuxt.js (Vue.js) + Express + Socket.IO でリアルタイムWeb (チャット) を体験する

追記

2018/06/26

作成したアプリを Heroku にデプロイする手順を追加しました。
また、これに伴いクライアントサイドの実装にも変更があり、デプロイ前にDockerfileを修正する必要があるため追記しました。

2018/06/27

pugでテンプレートを記述したものを「おまけ」として追記しました。

はじめに

「Node.js + Socket.IO」の記事はよく見かけるのですが、「Nuxt.js + Socket.IO」の記事はなかなか見当たらなかったため書いてみました。

Vue.js 製アプリケーションフレームワーク Nuxt.js で簡単なチャットアプリ作ってみます。

完成イメージはこちらです。

スクリーンショット 2018-06-25 0.32.44.png

チャットでは画面をリロードせずとも内容が更新されるように、リアルタイム通信の技術がしばしば利用されます。
このリアルタイム双方向通信の技術には WebSocket や WebRTC といったものがありますが、今回は WebSocket の方を利用してリアルタイムWebを実現してみたいと思います。

WebSocket と WebRTC の違いをざっくり見てみると、WebSocket はクライアントサーバ方式のリアルタイム通信、 WebRTC は P2P 方式のリアルタイム通信となります。
通信量の多い音声通話 / ビデオ通話 / 動画配信などではサーバに負荷のかからない WebRTC が採用されるケースが多いですが、テキストチャットのような少ない通信量のリアルタイム通信には WebSocket で (ある程度までは) 十分かと思われます。

今回この WebSocket を実装していくにあたり、リアルタイムWeb技術を簡単に扱うことができる Node.js 製のライブラリ Socket.IO を使用します。

Socket.IO 公式サイト

ちなみにではありますが、この Socket.IO には WebRTC を扱うための Socket.IO P2P というものもあるので、機会があればこちらにも挑戦してみようと思っています。

Socket.IO P2P

前置きが長くなりましたが、これより実装の方に入っていきます。
なお、実行環境は Docker for Mac を利用して準備します。

DockerでNode.js実行環境を作成する

まずはプロジェクトのディレクトリを作成して、 Dockerfile, docker-compose.yml を作成します。

# ドキュメントディレクトリ配下にプロジェクトディレクトリを作成する
$ cd ~/Documents
$ mkdir nuxt-socket-tutorial
$ cd nuxt-socket-tutorial

# docker-compose.yml を作成する
$ touch docker-compose.yml

# Nuxt.js アプリ用のディレクトリを作成し、その中にDockerfileを作成する
$ mkdir nuxt
$ touch nuxt/Dockerfile

docker-compose.yml, Dockerfile に以下の内容を記述します。

docker-compose.yml
version: '3'
services:
  nuxt:
    build: ./nuxt
    command: yarn run dev
    volumes:
      - ./nuxt:/nuxt
    ports:
      - "3000:3000"
      - "9229:9229"
nuxt/Dockerfile
FROM node

RUN mkdir -p /nuxt
COPY . /nuxt
WORKDIR /nuxt

RUN npm uninstall -g yarn && \
    npm install -g yarn && \
    chmod u+x /usr/local/bin/yarn && \    
    yarn global add nuxt && \
    yarn global add vue-cli create-nuxt-app

ENV HOST 0.0.0.0
EXPOSE 3000
CMD ["yarn", "start"]

記述が終わったらビルドを実行して docker image を作成します。

# docker-compose.yml のあるディレクトリに移動する
$ cd ~/Documents/nuxt-socket-tutorial

# ビルドして docker image を作成する
$ docker-compose build nuxt

Nuxt.js プロジェクトを作成する

create-nuxt-app コマンドを利用して Nuxt.js アプリケーションを作成します。
コマンドを実行すると対話形式でオプションを指定していきますが、今回重要となるのは次の4点です。

  • サーバーのフレームワークに express を利用する
  • CSSフレームワークに bulma を利用する
  • レンダリングモードとして Universal モード (サーバーサイドレンダリング) を利用する
  • node.js のパッケージマネージャーとして yarn を利用する

この他は特に指定はありません。

# nuxt コンテナを起動して create-nuxt-app コマンドを実行する
$ docker-compose run nuxt create-nuxt-app

Creating network "nuxt-socket-tutorial_default" with the default driver
> Generating Nuxt.js project in /nuxt
? Project name nuxt
? Project description My incredible Nuxt.js project
? Use a custom server framework express
? Use a custom UI framework bulma
? Choose rendering mode Universal
? Use axios module yes
? Use eslint no
? Author name
? Choose a package manager yarn

コマンドが実行し終わるとプロジェクトが作成されるので、ファイルを少々編集します。

ホストOSからDockerコンテナにアクセスできるようにします。

nuxt/server/index.js
- const host = process.env.HOST || '127.0.0.1'
+ const host = process.env.HOST || '0.0.0.0'

今回は特に使用することはありませんが、Node.js のデバッガー起動用のオプションを追加します。

nuxt/packege.json
  "scripts": {
-   "dev": "cross-env NODE_ENV=development nodemon server/index.js --watch server",
+   "dev": "cross-env NODE_ENV=development nodemon -L --inspect-brk=0.0.0.0 server/index.js --watch server",

ファイルの編集が完了したらコンテナを起動します。

$ docker-compose up

コンパイルが終了してコンソール上に Server listening on http://0.0.0.0:3000 と表示されたら、ブラウザで localhost:3000 にアクセスします。

スクリーンショット 2018-06-25 0.09.38.png

サーバーサイドの実装をする (socket.io)

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

# コンテナが起動中の場合は Ctrl + C で停止させる
# コンテナを破棄する
$ docker-compose down

# コンテナを一時的に起動して、コマンドを実行する
$ docker-compose run nuxt yarn add socket.io

server/index.js にコードを追加していきます。
socket.io のメソッドについては公式のドキュメントやドットインストールのレッスンが参考になりました。また、Webサーバーとして Express を使用しますので、必要があればこちらのドキュメントも参考にしてください。

nuxt/server/index.js
  const express = require('express')
  const { Nuxt, Builder } = require('nuxt')
  const app = express()
  const host = process.env.HOST || '0.0.0.0'
  const port = process.env.PORT || 3000

  app.set('port', port)

  // Import and Set Nuxt.js options
  let config = require('../nuxt.config.js')
  config.dev = !(process.env.NODE_ENV === 'production')

  async function start() {
    // Init Nuxt.js
    const nuxt = new Nuxt(config)

    // Build only in dev mode
    if (config.dev) {
      const builder = new Builder(nuxt)
      await builder.build()
    }

    // Give nuxt middleware to express
    app.use(nuxt.render)

    // Listen the server
-   app.listen(port, host)
+   let server = app.listen(port, host)
    console.log('Server listening on http://' + host + ':' + port) // eslint-disable-line no-console

+    // WebSocketを起動する
+    socketStart(server)
+    console.log('Socket.IO starts')
  }

+  let messageQueue = []
+
+  function socketStart(server) {
+    // Websocketサーバーインスタンスを生成する
+    const io = require('socket.io').listen(server)
+
+    // クライアントからサーバーに接続があった場合のイベントを作成する
+    io.on('connection', socket => {
+      // 接続されたクライアントのidをコンソールに表示する
+      console.log('id: ' + socket.id + ' is connected')
+
+      // サーバー側で保持しているメッセージをクライアント側に送信する
+      if (messageQueue.length > 0) {  
+        messageQueue.forEach(message => {
+          socket.emit('new-message', message)
+        })
+      }
+
+      // クライアントから送信があった場合のイベントを作成する
+      socket.on('send-message', message => {
+        console.log(message)
+
+        // サーバーで保持している変数にメッセージを格納する
+        messageQueue.push(message)
+        // 送信を行ったクライアント以外のクライアントに対してメッセージを送信する
+        socket.broadcast.emit('new-message', message)
+
+        // サーバー側で保持しているメッセージが10を超えたら古いものから削除する
+        if (messageQueue.length > 10) {
+          messageQueue = messageQueue.slice(-10)
+        }
+      })
+    })
+ }

  start()

クライアントサイドの実装をする (socket.io-client)

まずは必要なパッケージをインストールします。
UIコンポーネントに buefy を使用するのでここで一緒にインストールします。

$ docker-compose run nuxt yarn add socket.io-client nuxt-buefy

nuxt.config.js を編集して module に nuxt-buefy を追加します。
また、socket.io-client を build.vendor に指定します。(2018/06/26追記)

nuxt/nuxt.config.js
  modules: [
    // Doc: https://github.com/nuxt-community/axios-module#usage
    '@nuxtjs/axios',
    // Doc:https://github.com/nuxt-community/modules/tree/master/packages/bulma
-   '@nuxtjs/bulma'
+   '@nuxtjs/bulma',
+   'nuxt-buefy',
  ],


  build: {
+   vendor: [
+     'socket.io-client'
+   ],


  }

pages ディレクトリ配下に socket.vue ファイルを作成してコードを記述していきます。
必要であれば適宜 Vue.js, Nuxt.js, Bulma, Buefy, Socket.IO のドキュメントを参照してください。

$ touch nuxt/pages/socket.vue
nuxt/pages/socket.vue
<template>
  <section class="section">
    <div id="wrapper" class="container">
      <article class="media">
        <div class="media-content">
          <div class="field is-grouped">
            <p class="control is-expanded">
              <input class="input" type="text" v-model="message" @keyup.enter="sendMessage" placeholder="message">
            </p>
            <p class="control">
              <button class="button is-info" @click="sendMessage">
                Send
              </button>
            </p>
          </div>
        </div>
      </article>
      <article class="media" v-for="(message, index) in reverseMessages" :key="index">
        <figure class="media-left">
          <p class="image is-64x64">
            <img src="https://bulma.io/images/placeholders/128x128.png">
          </p>
        </figure>
        <div class="media-content">
          <div class="content">
            <p>
              <strong>id: {{ message.user }}</strong>
              <br>
              {{ message.text }}
              <br>
              <small><a>Like</a> · <a>Reply</a> · {{ message.date }}</small>
            </p>
          </div>
        </div>
      </article>
      <b-loading :is-full-page="false" :active.sync="isLoading" :can-cancel="false"></b-loading>
    </div>
  </section>
</template>

<script>
import io from 'socket.io-client'

export default {
  data() {
    return {
      message: '',
      messages: [],
      socket: '',
      isLoading: true
    }
  },
  computed: {
    // 配列の後ろ(新しいもの)から順に表示させたいので反転させる
    reverseMessages: function() {
      return this.messages.slice().reverse()
    },
  },
  mounted() {
    // VueインスタンスがDOMにマウントされたらSocketインスタンスを生成する
    this.socket = io()

    // サーバー側で保持しているメッセージを受信する
    this.socket.on('new-message', message => {
        this.messages.push( message || {} )
      }
    })

    // コンポーネントがマウントされてから1秒間はローディングする
    setTimeout(() => {
      this.isLoading = false
    }, 1000)
  methods: {
    sendMessage() {
      // スペースのみの場合は即時リターンをする
      if (!this.message.trim()) {
        return
      }

      let now = new Date()  // 現在時刻(世界標準時)を取得
      now.setTime(now.getTime() + 1000 * 60 * 60 * 9) // 日本時間に変換
      now = now.toJSON().split('T')[1].slice(0, 5)  // 時刻のみを取得

      // メッセージオブジェクトを作る
      let message = {
        user: this.socket.id,
        date: now,
        text: this.message.trim(),
      }

      // 自身(Vueインスタンス)のデータオブジェクトにメッセージを追加する
      this.messages.push(message)
      // サーバー側にメッセージを送信する
      this.socket.emit('send-message', message)
      // input要素を空にする
      this.message = ''
    }
  }
}
</script>

<style scoped>
#wrapper
{
  max-width: 600px;
}
</style>

2018/06/26修正あり

上記の socket.vue のコードを 2018/06/26 に修正しております。
具体的な箇所としては、次の2点です。

  • Socketインスタンスの生成をdataオブジェクト宣言時ではなく、DOMマウント時に行うようにする
  • Socketのサーバーとの接続をVueインスタンス生成時ではなく、DOMマウント時に行うようにする

このような修正を行った理由は、Socketインスタンスを生成するメソッド io() のデフォルトの引数が、Javascript の window.location となっているためです。
Vueインスタンスが window オブジェクトにアクセスできるようになるのは DOM にマウントされた後になります。
こうすることで、ローカル環境からHeroku等の本番環境に変わった場合でも同様にリアルタイム通信に対応することができます。

ローカルで動作を確認する

さて、以上で実装が終わったのでブラウザで動かしてみます。
Dockerコンテナを起動します。

$ docker-compose up

コンパイルが完了してサーバーがListen状態になったらブラウザで localhost:3000/socketにアクセスします。
画面をリロードせずとも更新されるかどうかを確かめるために2画面で開きます。

スクリーンショット 2018-06-25 1.57.06.png

スクリーンショット 2018-06-25 1.57.27.png

スクリーンショット 2018-06-25 1.57.38.png

スクリーンショット 2018-06-25 1.58.28.png

スクリーンショット 2018-06-25 1.58.39.png

画面をリロードすることなく送信したメッセージが別のクライアントの画面にも反映されました。

Herokuにデプロイする

Heroku Container Registry を利用して、作成した Docker イメージを本番環境で動かします。

事前に Heroku のアカウント登録を済ませておいてください。
https://signup.heroku.com/login

本番環境へのデプロイにあたり、Dockerfileを修正します。

nuxt/Dockerfile
  FROM node

  RUN mkdir -p /nuxt
  COPY . /nuxt
  WORKDIR /nuxt

  RUN npm uninstall -g yarn && \
      npm install -g yarn && \
      chmod u+x /usr/local/bin/yarn && \    
      yarn global add nuxt && \
-     yarn global add vue-cli create-nuxt-app
+     yarn global add vue-cli create-nuxt-app && \
+     yarn install && yarn build

  ENV HOST 0.0.0.0
  EXPOSE 3000
  CMD ["yarn", "start"]

次にターミナルで作業していきます。

# heroku-cliのインストール
$ brew install heroku/brew/heroku

# herokuにログイン
$ heroku login

# コンテナ用のプラグインを追加
$ heroku plugins:install heroku-container-registry

# コンテナにログイン
$ heroku container:login

# アプリの作成(作成されたアプリ名称を控えておく)
$ heroku create

# Nuxtプロジェクトのディレクトリ (Dockerfileのあるディレクトリ) に移動
$ cd nuxt/

# コンテナをデプロイ
$ heroku container:push web -a {your_heroku_app_name}

# コンテナのリリース
$ heroku container:release web -a {your_heroku_app_name}

# ブラウザで確認
$ heroku open -a {your_heroku_app_name}

スクリーンショット 2018-06-26 0.26.15.png
スクリーンショット 2018-06-26 0.26.39.png

おわりに

Nuxt.js と Socket.IO を使用することで簡単に双方向リアルタイム通信を実装することができました。
今回はDBとの接続は行いませんでしたが、クライアントからのデータ送信時に登録処理を、クライアントのサーバー接続時にデータ取得処理をすることでデータの永続化も可能です。

今回は WebSocket を使用したので、次は WebRTC にふれてみようと思います。

おまけ

Python ライクに HTML が記述できるテンプレートエンジン Pug を使用してクライアントサイドを書き換えてみました。
HTML と比べて記述量が減り、構造も把握しやすくなるためオススメです。Pug はいいぞ。

書き方は次のサイトを参考にしました。

Webpack の loader をインストールしてコードを記述していきます。

$ docker-compose run nuxt yarn add -D pug pug-loader

$ touch nuxt/pages/socket_pug.vue
nuxt/pages/socket_pug.vue
<template lang="pug">
  section.section
    #wrapper.container
      article.media
        .media-content
          .field.is-grouped
            p.control.is-expanded
              input.input(type="text" v-model="message" @keyup.enter="sendMessage" placeholder="message")
            p.control
              button.button.is-info(@click="sendMessage") Pug

      article.media(v-for="(message, index) in reverseMessages" :key="index")
        figure.media-left
          p.image.is-64x64
            img(src="https://bulma.io/images/placeholders/128x128.png")
        .media-content
          .content
            p 
              strong id: {{ message.user }}
              br
              | {{ message.text }}
              br
              small 
                a Like
                |  · 
                a Reply
                |  · {{ message.date }}
      b-loading(:is-full-page="false" :active.sync="isLoading" :can-cancel="false")
</template>

script以下は同じ

(比較のため再掲)

nuxt/pages/socket.vue
<template>
  <section class="section">
    <div id="wrapper" class="container">
      <article class="media">
        <div class="media-content">
          <div class="field is-grouped">
            <p class="control is-expanded">
              <input class="input" type="text" v-model="message" @keyup.enter="sendMessage" placeholder="message">
            </p>
            <p class="control">
              <button class="button is-info" @click="sendMessage">
                Send
              </button>
            </p>
          </div>
        </div>
      </article>

      <article class="media" v-for="(message, index) in reverseMessages" :key="index">
        <figure class="media-left">
          <p class="image is-64x64">
            <img src="https://bulma.io/images/placeholders/128x128.png">
          </p>
        </figure>
        <div class="media-content">
          <div class="content">
            <p>
              <strong>id: {{ message.user }}</strong>
              <br>
              {{ message.text }}
              <br>
              <small><a>Like</a> · <a>Reply</a> · {{ message.date }}</small>
            </p>
          </div>
        </div>
      </article>
      <b-loading :is-full-page="false" :active.sync="isLoading" :can-cancel="false"></b-loading>
    </div>
  </section>
</template>

おわり。