39
26

More than 3 years have passed since last update.

趣味プロジェクトで簡単に使える双方向通信技術を模索した話

Posted at

勉強を兼ねて、自分の技術スタックにマッチする範囲で利用できる双方向通信技術を一通り試し、それぞれの長所短所、向き不向きを把握しておきたくなり「いろんな技術でチャットサービスを何個か作ってみる」という活動をしておりました。

結果として、IaaS/ライブラリを変えながら4パターンでチャットサービスを構築し、各IaaS/ライブラリの特性がだいたい把握できたので記事にしたいと思います。

作ったチャットサービスはこんな感じ。
chat-capture03.gif

個人のサービスに導入することをメインに考えてるので、下記を重視しています。

  • 双方向通信部分の実装の難易度
  • 認証との連携
  • インフラの制約

逆に、下記要素は気にしていません。(ゲームとか作るわけじゃないので)

  • 接続数、負荷
  • コスト
  • リアルタイム性(遅延)

※タイトルにある「簡単に使える」というのは技術スタックによって個々人で異なると思いますので、ご参考までに。

なぜ双方向通信なのか?

簡単に言うと、ユーザー同士の操作をリアルタイムに反映するためです。

通常webサービスにおける通信は下記のように、client(ブラウザ)からリクエストをserver(サービス)に送り、そのレスポンスとしてhtmlやjsやcssが返るといった仕組みです。

qiita-chat01.png
※TCPコネクションまわりは省略

ではチャットサービスの例を考えてみましょう。
client1client2がチャットに参加していると仮定します。

単純なREST API等のリクエストのみの場合は下記のようなシーケンスになります。

qiita-chat02.png

①client1が新規にメッセージを投稿し、正常に投稿できた旨のレスポンスを受け取ります。
②client1は①で正常に投稿できたので、全メッセージを取得します。これで最新のメッセージ一覧がclient1の画面に表示されます。
③今度はclient2がメッセージを新たに投稿し、正常に投稿できた旨のレスポンスを受け取ります。
④client2は③で正常に投稿できたので、全メッセージを取得します。この結果には当然client1の①での投稿も含まれるため、全メッセージを見ることができます。

⑤ここで問題が発生します。
(普通のwebサービスの場合)serverからclientへはリクエストを送ることができません。
そのためclient2が新たにメッセージを投稿した瞬間に、それをclient1が知ることはできないのです。

このような課題を解決する一つの方法として、双方向通信技術があげられます。
(ポーリングやPub/Subなど他にも様々な解決方法がありますが、それはまた別のお話)

双方向通信でのclient-server間の通信の様子をシーケンス図で表すと下記のようになります。
qiita-chat03.png

コネクションを確立し、そのコネクション内でclient、serverの双方から任意のタイミングでメッセージを送信することが可能になります。
この技術により、サーバー側から任意のタイミングで確立されたコネクションを通し、クライアントにデータを送信できるようになります。
「双方から」、「任意のタイミングで」という点がキモになります。

チャットサービスをWebSocketで構築した場合の通信のフローを考えてみましょう。
qiita-chat04.png

①と②でclient1とclient2がそれぞれserverへコネクションを確立します。
この時点で、client1-server間、client2-server間で双方向に通信が可能となります。

③でclient1がHelloとメッセージを投稿します。
④でserverは接続している全てのコネクションに対し、client1からHelloというメッセージが投稿されたことを送信します。
この「接続しているコネクション全てに送信する」ことをbroadcastと呼びます。
この送信により、client1、clinet2ともにHelloという新規メッセージが投稿されたことを検知でき、画面に表示することができます。

⑤で今度はclient2がWorldとメッセージを投稿します。
⑥でserverがclient2がWorldというメッセージを投稿したことをbroadcastします。
これにより、各clientが新たに投稿されたメッセージを受信します。

このような仕組みでチャットサービス等でリアルタイムに別ユーザーの操作内容を伝えることができます。

チャットサービス

ということで、チャットサービスを4パターンほど実装してみました。

仕様は共通で、より現実的な課題も体験できるように下記の機能を実装しています。

  • 認証機能
  • チャット部屋作成/取得/削除
  • チャット機能

その他UI等の実装コストを極力減らすため、下記を共通で採用しています。

  • Nuxt.js(フロントエンドフレームワーク)
  • Vuetify.js(マテリアルデザインコンポーネントフレームワーク)
  • Auth0(認証サービス、Firebase以外で利用)

なお、チャット部分のコードを抜粋して掲載していますが、「実装イメージが湧いてくれれば幸い」程度のものになります。
(最終的な完成コードを載せると記事が長くなってしまうので)

socket.io

まずは最もオーソドックスな(?)Node.js用WebSocketライブラリのsocket.ioを利用した実装です。

リポジトリ

reireias/chat-socket.io

アーキテクチャ図

アーキテクチャはこのようになっています。
chat-architecture-socketio.png

  • Node.jsのサーバーライブラリであるExpress上でNuxt.jsを動かす
  • socket.ioをExpress上で動かす
  • Nuxt.js上でsocket.ioのclientを動かし、メッセージの送受信を行う
  • 認証はAuth0を使う
  • roomの管理等はredisを利用する(express-sessionと共通で利用して楽をしただけ)
  • Herokuにデプロイ(Expressを使う都合上、サーバーが必要であるため)

チャット部分コード抜粋

socket.ioの双方向通信は極めてシンプルです。
サーバー側/クライアント側共にイベント駆動になっているので、イベントの送信とイベント種類ごとにハンドラーを定義する形になっています。

サーバー側

// server/socket.js
const io = socketio(http)
const store = {}

// コネクション確立時のハンドラーを定義
io.on('connection', (socket) => {
  const userId = 'express-sessionを利用して取得する'

  // join-roomが送信された場合のハンドラー
  socket.on('join-room', (data) => {
    store[userId] = data
    // socket.ioの機能でroom管理が可能
    socket.join(data.roomId)
  })

  // clientからsend-messageイベントでメッセージが送られてきた際のハンドラー
  socket.on('send-message', (message) => {
    const roomId = store[userId].roomId
    // roomに接続している自分以外にイベントを送信
    socket.broadcast.to(roomId).emit('send-message', message)
    // 自分にも送信
    io.to(socket.id).emit('send-message', message)
    // 必要であればサーバー側でメッセージを永続化
  })
})

クライアント側

// pages/chat.vue ※一部コードは省略
import io from 'socket.io-client'

export default {
  created() {
    this.socket = io()
    // 現在のページに対応したroomへの参加イベントを送信
    this.socket.emit('join-room', {
      id: this.user.id,
      roomId: this.$route.query.roomId,
    })
    // serverから'send-message'イベントを受け取った場合のハンドラーを追加
    this.socket.on('send-message', this.recieveMessage)
  },
  methods: {
    // 新たにメッセージを受け取ったら、ローカル変数に追加し描画する
    recieveMessage(message) {
      this.messages.push(message)
    },
    // メッセージ送信ボタンが押された際のハンドラー
    onPost() {
      if (this.text) {
        const message = {
          text: this.text,
          roomId: this.$route.query.roomId,
        }
        // emitすることでserver側にイベントを送る
        this.socket.emit('send-message', message)
        this.text = null
      }
    },
  },
}

特徴

  • チャット部分のコードをシンプルに実装できる
  • 部屋ごとに送信するような機能をsocket.ioが持っている
  • サーバーが必要
  • 認証はAuth0 + Passport + express-sessionでそこまで難しくない
  • express-sessionを利用することで、WebSocket通信の認証も可能

API Gateway

次はAWSのAPI Gatewayを用いた実装方法です。
API GatewayではWebSocketがサポートされており、WebSocketのbodyの指定した属性に応じて起動するLambda関数を制御できます。
Serverless Frameworkのドキュメントが参考になります。

リポジトリ

reireias/chat-serverless

アーキテクチャ図

chat-architecture-serverless-framework.png

  • インフラ全体の管理にはServerless Frameworkを利用
  • WebSocketはAPI Gatewayを利用し、WebSocketのイベントに合わせて実行されるLambda関数を設定
  • フロント部分のコードをNuxt.jsでビルドし、API Gateway→S3というパスで配信
  • Nuxt.jsのSSR部分やバックエンド(DynamoDB)と接続するAPI部分はwebpackでビルドし、それぞれLambda関数にデプロイし、API Gatewayから呼び出す形式とする
  • 認証にはAPI GatewayのLambda Authorizerを利用し、Auth0で認証

チャット部分コード抜粋

サーバー側

// server/websocket.js

// roomへの参加イベントのハンドラー
// connectionIdとroomIdを紐付けてDynamoDBへ保存する
module.exports.joinHandler = async (event, _context, callback) => {
  const body = JSON.parse(event.body)
  const params = {
    TableName: CONNECTIONS_TABLE,
    Item: {
      roomId: body.roomId,
      id: event.requestContext.connectionId,
    },
  }
  await docClient.put(params).promise()
  callback(null, {
    statusCode: 200,
    body: 'joined',
  })
}

// メッセージ投稿イベントのハンドラー
// DynamoDBからroomに属するconnectionを全て取得し、それぞれに対してメッセージ投稿のイベントを送信する
module.exports.postHandler = async (event, _context, callback) => {
  const body = JSON.parse(event.body)
  const roomId = body.roomId
  const params = {
    TableName: CONNECTIONS_TABLE,
    ExpressionAttributeValues: {
      ':room': roomId,
    },
    ExpressionAttributeNames: {
      '#r': 'roomId',
    },
    KeyConditionExpression: '#r = :room',
  }
  const data = await docClient.query(params).promise()
  const domain = event.requestContext.domainName
  const stage = event.requestContext.stage
  const url = `https://${domain}/${stage}`
  const apigatewaymanagementapi = new AWS.ApiGatewayManagementApi({
    apiVersion: '2018-11-29',
    endpoint: url,
  })
  for (const item of data.Items) {
    const apiParams = {
      ConnectionId: item.id,
      Data: JSON.stringify({
        message: body.message,
        author: body.author,
        authorIcon: body.authorIcon,
      }),
    }
    try {
      await apigatewaymanagementapi.postToConnection(apiParams).promise()
    } catch (err) {
      // 切断済みのコネクションをDynamoDBから削除する
      if (err.statusCode === 410) {
        const params = {
          TableName: CONNECTIONS_TABLE,
          Key: {
            roomId,
            id: item.id,
          },
        }
        await docClient.delete(params).promise()
      }
    }
  }
  callback(null, {
    statusCode: 200,
    body: 'posted',
  })
}

クライアント側

// pages/chat/_id.vue
export default {
  mounted() {
    // API GatewayのWebSocket用エンドポイントを指定して接続
    // 標準APIのWebSocketクラスを利用する
    this.ws = new WebSocket('wss://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev')
    // イベント受信時のハンドラーを設定する
    this.ws.onopen = this.onOpen
    this.ws.onmessage = this.onMessage
  },
  methods: {
    // コネクション確立後にroomへの参加イベントをsever側に送信
    onOpen(event) {
      this.ws.send(
        JSON.stringify({
          action: 'join',
          roomId: this.$nuxt.$route.params.id,
        })
      )
    },
    // serverからメッセージ受信時は変数に追加し表示する
    onMessage(event) {
      const data = JSON.parse(event.data)
      this.messages.push({
        text: data.message,
        author: data.author,
        authorIcon: data.authorIcon,
      })
    },
    // メッセージ送信ボタンが押された際のハンドラー
    onPost() {
      this.ws.send(
        JSON.stringify({
          action: 'post',
          roomId: this.$nuxt.$route.params.id,
          message: this.text,
          author: this.user.sub,
          authorIcon: this.user.picture,
        })
      )
      this.text = ''
    },
  },
}

特徴

  • この4つのサンプルサービスの実装の中で最も難易度が高く時間がかかった
    • API GatewayのLambda Authorizer + Auth0による認証
    • API GatewayのWebSocketでLambda Authorizerを使う方法
    • DynamoDBを利用した自前でのWebSocketコネクション管理
    • Nuxt.jsをSSR on Lambdaで動かす部分
  • スケーラビリティはかなり高く、AWSとの連携も柔軟にできるので拡張性は最も高いといえる
  • すくなくとも趣味で軽く作る範囲ではないなという印象

Action Cable

続いてはRuby on RailsのAction Cableでの実装です。
Railsガイドでは「WebSocketとRailsのその他の部分をシームレスに統合するためのもの」と紹介されています。

リポジトリ

reireias/chat-action-cable

アーキテクチャ図

Railsの場合、アーキテクチャはとてもシンプルになります。

chat-architecture-action-cable.png

  • フロントエンドは既存の実装を使いまわすために、WebPacker + Vue.jsで実装
  • 認証はAuth0 + omniouthで実装
  • Herokuへデプロイ
    • DBはHerokuのPostgreSQLを利用

チャット部分コード抜粋

サーバー側

app/controllers/api/v1/messages_controller.rb
class Api::V1::MessagesController < Api::ApplicationController
  include Secured

  def create
    # ActionCableを通し、全clientへメッセージをbroadcastする
    ActionCable.server.broadcast "rooms:#{params[:room_id]}:messages", message
    render json: {}
  end

  private

  def message
    {
      text: params[:text],
      user: current_user
    }
  end
end

その他のファイルはRailsガイドのこのへんを参照

クライアント側

app/javascript/components/pages/rooms/Show.vue
import consumer from '../../../channels/consumer'

// ※もろもろ省略
  created() {
    // イベント受信時のハンドラーを定義
    consumer.subscriptions.create(
      { channel: 'ChatChannel', room_id: this.roomId },
      {
        received: (data) => {
          this.messages.push({
            text: data.text,
            author: data.user.uid,
            authorIcon: data.user.image,
          })
        }
      }
    )
  },
  methods: {
    // client→serverではWebSocketを利用せずに普通にAPIでメッセージをPOST
    async onPost() {
      if (this.text) {
        await axios.post(`/api/v1/rooms/${this.roomId}/messages`, {
          text: this.text,
        })
        this.text = null
      }
    },
  }

特徴

  • なんと言っても「Railsが使える」というのが最大の利点
  • 全てWebSocketで通信するというよりも、一部をWebSocketでbroadcast配信するような用途での利用が多く見られた
  • 「サーバー側の何かの通知をクライアントにリアルタイムに送る」のみであれば比較的簡単に実装が可能である

Firebase

最後はみんな大好き(?)Firebaseでの実装です。
FirebaseにはFirestoreという強力なNoSQLデータベースがあります。
このFirestoreの特徴の一つとして、更新をクライアント側からwatchできるというものがあるので、これを利用して実装します。
ちなみに、FirestoreのwatchはgRPCのBidirectional Streamingを利用して実装されています。

リポジトリ

reireias/chat-firebase

アーキテクチャ図

おそらくFirestore + Vue.jsの鉄板の構成になっていると思います。

chat-architecture-firebase.png

  • チャット部分はFirestoreのデータをvuexfireを利用してVuexのstoreに同期
  • メッセージ投稿はNuxt.jsからFirestoreのclientで登録
  • 認証はFirebase Authentication + firebaseui-webを利用
  • Nuxt.jsで静的サイトとしてgenerateしFirebase Hostingへデプロイ

チャット部分コード抜粋

サーバー側:なし

クライアント側

// store/index.js
import { firestoreAction } from 'vuexfire'
import firebase from '@/plugins/firebase'

export const actions = {
  // vuexfireを利用して、Firestore上のcollectionをstateへbindする
  bindMessages: firestoreAction(({ bindFirestoreRef }, payload) => {
    return bindFirestoreRef(
      'messages',
      db
        .collection('rooms')
        .doc(payload.id)
        .collection('messages')
        .orderBy('createdAt', 'asc')
    )
  }),
  // Firestoreにごく普通の方法でレコードを追加する
  addMessage(_, payload) {
    const message = {
      author: payload.uid,
      authorIcon: payload.authorIcon,
      text: payload.text,
      createdAt: firebase.firestore.FieldValue.serverTimestamp(),
    }
    db.collection('rooms')
      .doc(payload.roomId)
      .collection('messages')
      .add(message)
  },
}

// pages/chat.vue
import { mapActions, mapGetters } from 'vuex'
import moment from 'moment'

export default {
  data() {
    return {
      text: null,
    }
  },
  computed: {
    ...mapGetters(['user', 'messages']),
  },
  created() {
    // ページ描画時にFirestore上のmessagesをlocalのstoreにbindする
    // これにより、computedのmessagesでFirestore上のレコードが取得できる
    this.bindMessages({ id: this.$route.query.roomId })
  },
  methods: {
    onPost() {
      if (this.text) {
        // storeのaddMessageをmapActions経由で呼び出す
        this.addMessage({
          uid: this.user.uid,
          authorIcon: this.user.photoURL,
          text: this.text,
          roomId: this.$route.query.roomId,
        })
        this.text = null
      }
    },
    ...mapActions(['bindMessages', 'addMessage']),
  },
}

特徴

  • 認証、データストア、リアルタイムが組み合わさったFiresotreはかなり便利で洗練されているということを再認識した
    • とくに認証とデータ永続化がお手軽に実装できる点は他にはない強みである
    • 逆に永続化しないで良いデータの送信であればsocket.io等のほうがシンプルでインフラも柔軟である
  • Authentication(認証), Functions(FaaS)、Hosting(静的サイトホスティング)、Messaging(push通知)等一通りの機能がそろっているのでインフラの柔軟性という点でも十分である
    • Firebaseで不足している場合はGCPとも連携が容易なので問題ない
  • Firebase固有の知識がけっこう必要になる
    • Firestoreの設計や認証まわりのコード等

所感

どの実装方法も他と異なる点がいくつかあり、とても勉強になりました。
socket.ioが思っていた以上にシンプルだったことや、いくつかの方法において認証の連携がとても手間であること等様々な発見がありました。

Firebaseが実装難易度、インフラ管理コスト、認証/DBとの連携などかなりの面で優れていることを再認識できたので、今後の個人サービス開発は相変わらずFirebaseを利用していくことになりそうです。

39
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
26