Action Cable＋ReactでWebSocketのアプリケーションを0から作る

RailsのAction CableとReactの組み合わせでWebSocketのアプリケーションを0から作ってローカルで動作させました。

この記事では下記のチャットアプリの作った手順を紹介します。
下記の動画では、上下のブラウザでwebsocketでチャットしています。

![ezgif-1-7afa5d7957.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83424/43644227-8722-339f-9a7b-c99478de698f.gif)

# 主要ライブラリなどのバージョン

フロントエンドはReact、バックエンドはRailsを使っています。
主要なライブラリのバージョンは下記の通り。

## バックエンド

| ライブラリ | バージョン |
|:-----------|:-----------:|
| Ruby       | 3.1.2       |
| Rails      | 7.0.4       |
| Redis      | 7.0.5       |
| Redis(Gem) | 5.0.5       |

全ソースを確認したい場合、GitHubに完成したときのタグ[0.0.1]を残しているのでご覧ください。

https://github.com/ham0215/subscription-server/releases/tag/0.0.1

## フロントエンド

| ライブラリ | バージョン |
|:-----------|:-----------:|
| React      | 18.2.0      |
| Vite       | 3.2.2       |
| actioncable| 5.2.8       |

※ 2022/11/14追記 `actioncable`の最新版は `@rails/actioncable`のようです。

全ソースを確認したい場合、GitHubに完成したときのタグ[0.0.1]を残しているのでご覧ください。

https://github.com/ham0215/subscription-client/releases/tag/0.0.1

# バックエンド

まずはAction Cableのサーバーサイドを構築します。

## Rails New

バックエンドはDockerで動かすため、事前にRails(api)とMySQL(db)とRedis(redis)が動作するコンテナを作ります(括弧内は今回設定したコンテナ名)。
Docker環境の構築は本題からずれるので割愛します。

コンテナができたら早速Rails Newします。今回は下記のコマンドを使いました。使わないサービスはOFFにしています。

```console
docker compose run api rails new . --skip-git --skip-action-mailer --skip-action-mailbox --database=mysql --skip-action-text --skip-active-storage --skip-asset-pipeline --skip-javascript --skip-hotwire --skip-jbuilder --skip-test --skip-system-test --skip-bootsnap --api --force
```

## ローカルをhttpsで動くようにする

今はhttpsで動かさないことはないと思うので、ローカルでもhttpsで動くようにします。
下記の記事通りやればサクッとできました。httpsで開発環境に接続できれば動作確認OKです。

https://qiita.com/A__Matsuda/items/775bd86ad31b07a153c7

## Action Cableの設定

各種設定を行います。

https://railsguides.jp/action_cable_overview.html#%E8%A8%AD%E5%AE%9A

### サブスクリプションアダプタ

サブスクリプションを管理するアダプタを設定します。
デフォルトではdevelopmentに`async`が設定されていますが、プロダクション環境に近づけるためにredisを使用します。

```yml:config/cable.yml
development:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://redis/1" } %>
  channel_prefix: app_development
```

### 許可されたリクエスト送信元

後にReactで作るクライアントのドメインを追加します。

```ruby:config/environments/development.rb
config.action_cable.allowed_request_origins = [ "https://localhost:5173" ]
```

### マウントパス

ActionCableにアクセスするためのパスを設定します。

```ruby:config/application.rb
config.action_cable.mount_path = "/cable"
```

urlも設定(こちらは設定しなくても良いかも？)

```ruby:config/environments/development.rb
config.action_cable.url = "wss://localhost:3020/cable"
```

## Userモデル作成

チャットするユーザーを管理するためUserモデルを作成します。

```console
docker compose exec api rails g model User
```

今回はidと名前しか使わないので、nameカラムを追加しました。

```ruby:db/migrate/20221102162025_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end
```

テストデータを作成するため、seedで雑に100ユーザー作るようにしたので実行しておきます。

```ruby:db/seeds.rb
User.insert_all((1..100).map { { name: "ham#{_1}" } })
```

```console
docker conpose exec api rails db:seed  
```

## Connectionクラスを実装

クライアントからアクセスされてきた時に認可するConnectionクラスを実装します。

https://railsguides.jp/action_cable_overview.html#%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E5%81%B4%E3%81%AE%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88-%E3%82%B3%E3%83%8D%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3

本来はcookieなどを利用して、接続してきたクライアントに対応したユーザーを特定して認可するところですが、今回は動作確認したいだけなので、先ほどseedで作った100ユーザーをランダムで1名返すようにしました。

```ruby:app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user!
    end

    private

    def find_verified_user!
      # 本来はcookieなどを使い接続してきたクラウアントのユーザーを検索するが
      # 今回は動作確認したいだけなのでランダムでユーザーを1人返す
      User.all.sample
    end
  end
end
```

## Channelクラスを実装

クライアントとのやりとりを行うチャネルクラスを実装します。

https://railsguides.jp/action_cable_overview.html#%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E5%81%B4%E3%81%AE%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88-%E3%83%81%E3%83%A3%E3%83%8D%E3%83%AB

* サブスクライブしたら`subscribed`メソッドが呼び出され、`chat`をサブスクライブします。
* クライアントからメッセージをつけて`chat`メソッドを呼び出すと、メッセージをブロードキャストします。

```ruby:app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from 'chat'
  end

  def unsubscribed
  end

  def chat(data)
    ActionCable.server.broadcast('chat', { sender: current_user.name, body: data['body'] })
  end
end
```

## テスト

今回はテストを書かなかったですが、Railsガイドにテストのやり方も記載されていたのでリンクを載せておきます。

https://railsguides.jp/testing.html#action-cable%E3%82%92%E3%83%86%E3%82%B9%E3%83%88%E3%81%99%E3%82%8B

# フロントエンド

次にフロントエンドを構築します。

## Vite

Reactの環境をゼロから作るため、Viteを使いました。
初めて使いましたが、`npm create vite@latest`を実行するだけでサクッと構築できてめちゃくちゃ楽でした！

https://vitejs.dev/guide/

## ローカルをhttpsで動くようにする

クライアントもhttpsで動くようにします。
下記の記事を参考に設定しました。httpsで開発環境に接続できれば動作確認OKです。

https://dev.classmethod.jp/articles/vite-https-localhost/

## actioncable

Railsから提供されているactioncableを追加します。
Typescriptを使っているので、@types/actioncableも追加します。

https://www.npmjs.com/package/actioncable

## クライアントの実装

最後にクライアントを実装します。
Action Cableに関わる部分にはコメントを入れています。

```tsx:src/Cable/index.tsx
import { useState, useMemo, useEffect } from 'react';
import ActionCable from 'actioncable';

type Message = {
  sender: string;
  body: string;
};

export default function Cable() {
  const [receivedMessage, setReceivedMessage] = useState<Message>();
  const [text, setText] = useState('');
  const [input, setInput] = useState('');
  const [subscription, setSubscription] = useState<ActionCable.Channel>();
  // Action Cableに接続
  const cable = useMemo(() => ActionCable.createConsumer('wss://localhost:3020/cable'), []);

  useEffect(() => {
    // ChatChannelをサブスクライブ
    // receivedにメッセージを受信した時のメソッドを設定します。
    // 今回はreceivedMessageにメッセージをセットします。
    const sub = cable.subscriptions.create({ channel: "ChatChannel" }, {
      received: (msg) => setReceivedMessage(msg)
    });
    setSubscription(sub);
  }, [cable]);

  const handleSend = () => {
    // inputをサーバーに送信
    subscription?.perform('chat', { body: input });
    setInput('');
  };

  useEffect(() => {
    if (!receivedMessage) return;

    const { sender, body } = receivedMessage;
    setText(text.concat("\n", `${sender}: ${body}`));
  }, [receivedMessage]);

  useEffect(() => {
    const history = document.getElementById('history');
    history?.scrollTo(0, history.scrollHeight);
  }, [text]);

  const onChangeInput = (e) => {
    setInput(e.currentTarget.value);
  };

  return (
    <div>
      <div>
        <textarea id="history" readOnly style={{ width: "500px", height: "200px" }} value={text} />
      </div>
      <div>
        <input
          type="text"
          onKeyDown={(e) => {
            if (e.key === 'Enter') {
              e.preventDefault();
              handleSend();
            }
          }}
          style={{ width: "400px", marginRight: "10px" }}
          onChange={onChangeInput}
          value={input}
        />
        <button onClick={handleSend} disabled={input === ''}>
          send
        </button>
      </div>
    </div >
  );
}
```

# 動作確認

一通り実装は終わったので最後に動作確認です。

## クライアント

ChromeのDevToolを使い、コンソールログや通信状況を確認します。
通信状況はDevTool > Netwookで表示できます。WSでフィルターすると見やすいです。

## サーバー

Dockerのログを`docker compose logs -f`で確認したり、Railsのログ`log/development.log`などを確認します。
うまく動かない場合はログを追加したりdebuggerで止めるなどして検証しましょう。

今回はRedisを使っているので、Redisのログも確認します。
Redisサーバーにアクセスして`redis-cli`を実行し、`monitor`コマンドで確認できます。

```
 % x redis bash
root@8cfed9bcac8e:/data# redis-cli 
127.0.0.1:6379> monitor
OK

1667539510.712099 [1 192.168.208.4:37452] "publish" "app_development:chat" "{\"body\":\"sent: 87\"}"
```