株式会社LAMILA テックブログ

"VideoStep"を開発する株式会社LAMILAのテックブログです。

TypeScript + Rails API 構成の SPA に WebSocket 導入する方法をざっくり解説

はじめに

株式会社LAMILAで開発を担当しています、kuraoと言います。

今回WebSocketなる概念に触れる機会があったので、TypeScript + Rails 構成の SPA にWebSocketを導入する手順について書いていきたいと思います。

WebSocketとは

こちらがメッセージを送るとメッセージが相手の画面上に瞬時に反映される。
そういったチャット機能を作りたくなる場合があると思いますが、そんな時に使えるのがWebSocketと言う通信方法です。
 
Web上で行う通信の1つにHTTP通信が使われているのはご存知かと思います。
HTTP通信は、クライアントからサーバーにリクエストを送ることは出来ますが、サーバーから自発的にリクエストを返す事が出来ないため、こちらからメッセージを送ったとしても相手の手元で瞬時に反映させるという事ができません。
 
Websocketは、必要に応じてサーバーからクライアントに対して通信を行える、双方向通信と呼ばれる通信方法を実現するシステムなので、リアルタイムのチャット機能を作るにはとても便利です。

導入に至った経緯

弊社で開発するサービスVideoStepには動画を編集する機能が存在します。
動画ファイルの編集処理自体はRailsサーバーで同期的に行っていましたが、諸々の理由から非同期的に行わせる必要が出てきました。
しかし非同期処理に変える事で、新たに以下の問題に直面することになります。
 
  • 処理が完了しても、処理後の動画ファイルの情報をクライアント上で更新出来ない
  • 失敗したかどうかも分からないため、失敗した事をユーザーに知らせる事ができない
 
上記を解決するためにWebSocketを導入することにしました。

実装要件

ここでは単に、クライアントとサーバーをWebSocketを用いて接続して、チャンネルを購読している間はサーバーからクライアントへメッセージを返すと言うだけの機構を作成します。

前提

  • Rails 6.1.3(APIモード)
  • Redisを使用している
  • 認可については考えない

Rails側の実装

RailsではWebSocketとの接続を可能にするために、ActionCableを用います。
ActionCableでは聞き馴染みがないような用語が存在するので、その都度調べてみると良いかと思います。
 

ActionCable周りの諸々の設定

ここではクライアントからRailsのActionCableへと接続できるよう設定を行います。
 
  • config/environments/development.rb
    • ActionCableにあらゆるオリジンからの通信を許可するため、以下のコメントアウトを解除します。
config.action_cable.disable_request_forgery_protection = true
 
  • config/environments/production.rb
    • development同様disable_request_forgery_protectionをtrueにします。
    • それに加えて、通信を許可するオリジンをallowed_request_originsに配列型式で追加します。
config.action_cable.allowed_request_origins = ['https://your-staging-domain', 'https://your-production-domain']
config.action_cable.disable_request_forgery_protection = true
 
  • config/cable.yml
    • redisを使用する場合は、adapterフィールドにredisを指定します。
development:
adapter: redis
url: redis://redis:6379/1
 
test:
adapter: test
 
production:
adapter: redis
url: redis://production-redis/1 # 本番環境で設定しているreidsサーバーを記述
channel_prefix: your_app_production
 
  • conifg/routes.rb
    • /cableにサーバーをマウントするよう設定します。このパスがWebSocketのコネクションを開始するためのルートパスになります。
Rails.application.routes.draw do
  mount ActionCable.server => '/cable'
  ...
end
 
  • default.conf
    • こちらは本番環境で必要な設定になります
upstream unicorn {
  server 127.0.0.1:3000;
}
 
server {
  listen 80 default_server;
  server_name api.videostep.io;
  root /usr/share/nginx/html;
  try_files $uri/index.html $uri @unicorn;
 
  proxy_connect_timeout 3600;
  proxy_read_timeout 3600;
  proxy_send_timeout 3600;
 
  client_max_body_size 5000m;
  error_page 404 /404.html;
  error_page 505 502 503 504 /500.html;
 
  location @unicorn {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_pass http://unicorn;
    proxy_redirect off;
  }
 
  // 追加
  location /cable {
    proxy_pass http://unicorn/cable;
    proxy_http_version 1.1;
    proxy_set_header Upgrade websocket;
    proxy_set_header Connection Upgrade;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    proxy_redirect off;
 }
}

コネクションの確立

  • app/channels/application_cableディレクトリ配下のconnection.rbファイルを使用します。
    • WebSocketでクライアントとサーバーを通信するために、始めにコネクションの確立を行います。
    • ここでは、現在時間を数値化した値をコネクショントークンに格納しています。
    • このコネクショントークンを用いることで、サーバーからクライアントへメッセージを送る事ができるようになります。
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :connection_token
 
def connect
self.connection_token = Time.current.to_i
end
end
end

チャンネルの作成

接続の準備ができたら、次はチャンネルの設定を行います。使い方的にはコントローラと似ているので理解しやすいと思います。
チャンネルでは、購読や購読解除などのを処理を行います。
チャンネルを購読している間は、サーバーからクライアントへメッセージを送る事(ブロードキャスト)ができ、購読解除をすると、それ以降そのチャンネルからのメッセージを閉じることになります。
 
  • チャンネル名は仮にChatChannelとします。
  • app/channelsディレクトリ配下にchat_channel.rbファイルを作成します。
  • ChatChannelと言うチャンネルを購読開始するためのアクションを定義します。
    • subscribedと言うアクションで行います。
    • strem_from "xxxxxx" と言う形にすることで、"xxxxxx" と言う名前で購読を開始します。
    • クライアントからはChatのIDを送信するつもりなので、そのIDを用いて購読名を決定します。
    • コントローラ同様、クライアントから送信されるパラメータはparamsで取得できます。
  • ChatChannelを購読している間、メッセージをブロードキャストしてあげるアクションを定義します。
    • <購読しているchannel名>の部分には、subscribedでstream_fromしたのと同一のチャンネル名を指定します。
    • broadcast_messageと言うアクションで行います。
    • ActionCable.server.broadcast <購読しているchannel名>, key1: value1, key2: value2 とする事で、特定のチャンネルを購読している間、このアクションを呼び出す事でクライアント側へ、メッセージを送る事ができます。
    • ここではチャンネルを購読しているクライアント側へ「ブロードキャストしています」と言うメッセージを送る処理を書きました。
class ChatChannel < ApplicationCable::Channel
 
  def subscribed
    stream_from "chat_channel_#{params[:chat_id]}"
  end
 
  def broadcast_message
    channel = "chat_channel_#{params[:chat_id]}"
 
    ActionCable.server.broadcast channel, message: 'ブロードキャストしています。'
  end
end
 
Rails側の設定は以上なので、次にクライアント側の実装をしていきます。

クライアント側の実装

ここで実装する項目は以下です。
  • チャンネルの購読を行いたいと思っているページに遷移したと同時にサーバーとコネクションを確立する
  • コネクション確立後、購読したいチャンネルの購読を開始する。ここでは上記で実装したChannelChatを購読させます。
  • チャンネルの購読が開始されたら他のページに移動するまでは、サーバーからブロードキャストされるメッセージを受け取り続ける。

actioncableパッケージを追加する

npm install actioncable

WebSocket通信用のコンポーネント作成

先に全てのコードを以下に示します。
import { useContext } from 'react'
import { useEffect, useState } from 'react'
 
let ActionCable = null as any
if (typeof window !== 'undefined') {
  ActionCable = require('actioncable')
}
 
export const ChatConnection = () => {
  const channelRef = useRef<any>(null)
  const cableRef = useRef<any>(null)
  const [message, setMessage] = useState<string>('')
 
  useEffect(() => {
    const initWebSockt = async () => {
      const wsEndpoint = "ws://localhost:3001/cable"
      cableRef.current = ActionCable?.createConsumer(wsEndpoint)
 
      channelRef.current = cableRef.current.subscriptions.create({ channel: 'ChatChannel', chat_id: "hoge" }, {
        connected: () => {
          console.log('コネクト成功')
        },
        received: (data: any) => {
          setMessage(data.message)
        },
        broadcastMessage: (chatId: string) => {
          return channelRef.current?.perform('broadcast_message', { chat_id: chatId          })
      },
     })
    }
 
    initWebSockt()
    channelRef.current.broadcastMessage("hoge")
 
    return () => {
      channelRef.current.unsubscribe()
      cableRef.current.disconnect()
    }
  }, [])
 
  return (
    <p>{message}</p>
  )
}
上記のコードについて順番に説明していきます。
 
let ActionCable = null as any
if (typeof window !== 'undefined') {
  ActionCable = require('actioncable')
}
まずはここですが、追加したactioncableをrequireしています。
このActionCableを用いてサーバーとのコネクションを確立します。
 
const wsEndpoint = "ws://localhost:3001/cable"
localhost:3001の部分はRails側のhostを指します。状況によって書き換えてください。
 
channelRef.current = cableRef.current.subscriptions.create({ channel: 'ChatChannel', chat_id: "hoge" }, {
  connected: () => {
    console.log('コネクト成功')
  },
  received: (data: any) => {
    setMessage(data.message)
  },
  broadcastMessage: (chatId: string) => {
    return channelRef.current?.perform('broadcast_message', { chat_id: chatId })
  },
})
cableRef.current.subscriptions.create(引数1, 引数2)
  • 引数1
    • 購読するチャンネルとパラメータをオブジェクト形式で渡しています。
    • 2番目のキーバリュー部分がRails側でparamsとして取得できる部分になるので、params[:chat_id]として取得できるようになります。
  • 引数2
    • 呼び出すときはchannelRef.current.broadcastMessage(chatId)です。
    • Rails側のbroadcast_messageから「ブロードキャストしています」と言うメッセージが返ってきますが受け取る場合はreceivedで受け取ります。
    • connectedとreceivedはデフォルトで存在するコールバックで、connectedでコネクションを確立し、確立されるとreceivedによってRailsで定義した何かしらのアクションがブロードキャストされます。
    • 3つ目のbroadcastMessageは直接アクションを呼ぶためのコールバックです。
 
channelRef.current.broadcastMessage("hoge")
ページレンダリング時、これを実行します。
 
return () => {
  channelRef.current.unsubscribe()
  cableRef.current.disconnect()
}
ページを離れる際にチャンネルとコネクションを切断しておきます。

終わりに

今回はWebSocketの導入手順を大まかにですが解説させて頂きました。

正直理解不足な部分がまだまだあるので、少しずつ穴を埋めていけたらと思っています。

 

そして弊社では一緒に働いてくださるエンジニアを募集しております!

少しでも興味がありましたら、Wantedlyから気軽にご応募頂ければと思います!

www.wantedly.com

 

 

参考