前置き
「Rails ActionCableで双方向通信してみたい」「モバイルアプリでリアルタイム通信アプリ作りたい」と思いサンプルアプリを作ってみました。個々の詳細については既に解説してくださっている記事はありますので、大まかに環境構築やソースコードをご紹介します。以下の3部構成になっています。
お遊びサンプルの紹介
以下のアニメーションGIFをご覧ください。各ユーザーのアクティブ状況を表示して、メッセージをやりとります。もっと砕けた表現をするならば、筆者の愛犬たちが寝起きして、鳴いたり、遠吠えしたり、唸ったりします。
このアプリでは大きく2つのActionCableの使い方があります。
- 同じルーム内の全ユーザーにブロードキャスト
- ルームに入る
現在ルーム内にいるユーザー(以下、アクティブユーザー)にルームに入ったことを通知し、アクティブユーザーを取得します。そして、各ユーザーのアクティブ状況を表示します。 - 「ワンワン」ボタンと「ワオーン」ボタン
文字入力で任意の文字が送れないだけで、チャットでいうところの「メッセージ」とほぼ同義です。ボタンに対応したメッセージを送信します。 - ルームから出る
iOSなら「キャンセル」、Androidなら「←」をタップしたり、アプリを閉じたりするとルームから出たことにします。ルームに入るときと同様にアクティブユーザーを取得して、各ユーザーのアクティブ状況を表示します。
- ルームに入る
- 自分にブロードキャスト
- 「独り言」
「独り言」ということで自分のみメッセージを受信します。
- 「独り言」
構成
以前作成した開発環境をベースにDockerで構築しています。この構成もGitHubに公開していますのでDockerfileやDocker Composeと併せてご覧ください。ここではサービス毎に主な設定をピックアップします。
MySQL
ActionCableへ接続する際にユーザー情報を取得する処理のために利用します。特筆することはありませんが、強いて言えばデフォルトの文字コードをutf8mb4にしています。
Nginx
- ポート (nginx.conf / docker-compose.yml)
自己署名証明書(所謂オレオレ証明書)ではSSLハンドシェイクの関係でうまく通信出来ませんでしたので、この環境では平文のWebSocket通信(ws://)を行うため80番ポートを許可します。なお、筆者環境では独自ドメインとLet' EncryptのSSL証明書でも動作確認しています。その場合は443番ポードで想定通り暗号化されたWebSocketの通信(wss://)ができることを確認しています。 - WebSocket (nginx.conf)
ActionCableのエンドポイントである「location /cable」はhttp(https)ではなくWebSocket(ws/wss)として通信できるようにします。
Rails
- WebAPIモード
WebのViewやフロントエンドは不要なのでWebAPIとしてプロジェクトを構築します。 - MySQLのデフォルト文字コード: utf8mb4
このサンプルアプリでは動作上の意味はありません。折角なので導入しただけです。
Railsは本記事の主題なので後ほど別途説明します。
Redis
- サブスクリプションアダプター
Railsのサブスクリプションアダプターには「Async」ではなく「Redis」を使います。 - キャッシュ
簡易的なデータストアに使います。
RailsでWebAPIを構築する
前述のとおり本環境はGitHubに公開していますので、この記事では要点のみ紹介します。
プロジェクトを作成する
Docker Composeを初期起動した時点で以下が実行されます。
bundle exec rails new . -d mysql -f -T --api --skip-bundle
ActionCableを設定する
-
originを許可
Nginxの設定でも触れましたとおり自己署名証明書ではSSLハンドシェイクの関係でうまく通信出来ませんでした。この環境では平文のWebSocket通信(ws://)も許可します。正規のSSL証明書がインポートされていればwss://のみ指定すれば問題ありません。
Androidエミュレーターで動作させるためにdisable_request_forgery_protection
をtrue
に設定して送信元の制限を緩めます。この設定をしないと以下のエラーが発生します。
筆者はiOS版を開発してからAndroid版を開発しています。iOSシミュレーターでは発生しなかったのでRails側のログ調査を怠っており調査に時間が掛かってしまいました。log/development.log
Request origin not allowed:
Failed to upgrade to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket)
```ruby:volumes/app/config/environments/development.rb
config.action_cable.allowed_request_origins = [ /wss?:\/\/.*/, /ws?:\/\/.*/ ]
config.action_cable.disable_request_forgery_protection = true
-
サブスクリプションアダプターにRedisを指定
開発環境のアダプターはデフォルトでasyncです。プロダクション環境ではRedisが推奨(async非推奨)されています。ここではRedisを利用することにします。volumes/app/config/cable.ymldefault: &default adapter: redis url: <%= ENV.fetch("REDIS_URL") { "redis://cache:6379/0" } %> channel_prefix: app_production development: <<: *default
## Redisを設定する
ActionCableとは別にキャッシュの用途でもRedisを使うことにします。
1. Redis Gemをインストール
Gemfileのコメントアウトを外します。
```ruby:volumes/app/Gemfile
gem 'redis', '~> 4.0'
-
Redisを設定
ActionCableでRedisはDB番号0
を指定しているので、キャッシュの用途ではDB番号1
を指定しています。volumes/app/config/initializers/Redis.rb
REDIS = Redis.new(host: ENV.fetch("REDIS_HOST") { "cache" }, port: ENV.fetch("REDIS_PORT") { "6379" }, db: ENV.fetch("REDIS_DB") { "0" })
:warning::warning:2019/3/24 更新:warning::warning:
当初はRailsのキャッシュストア(Rails.cache)を経由してRedisを使用していました。直接Redisを利用したほうが可読性が高いと判断し、本記事とGitHubに公開しているソースコードを更新しました。
## モデルとマイグレーション
ほんの少し実践的にユーザーの情報がRDBに格納されていることを想定してモデルを作成します。
```sh:コンソール
$ bundle exec rails g model user account:string name:string
$ bundle exec rails db:migrate
その他、いくつか制約を加えてマイグレーションした結果以下のようになりました。ただし、追加した制約が無くても動作に影響はありません。
ActiveRecord::Schema.define(version: 2019_02_23_052441) do
create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC", force: :cascade do |t|
t.string "account", null: false
t.string "name", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account"], name: "index_users_on_account", unique: true
end
end
サンプルデータ(初期データ)を投入する
ActionCable以外の部分の説明が増えることを防ぐため、アプリ側のソースコードを簡略化しました。そのため、以下の3ユーザー以外は動作させることができません。
すみません
User.create(account: 'chiyo', name: '千代')
User.create(account: 'eru', name: 'エル')
User.create(account: 'otome', name: '乙女')
$ bundle exec rails db:seed
コネクションを設定する
公式とほとんど変わりません。アプリとWebAPIはステートレスにしたいのでユーザーの情報はCookieからではなくパラメーターから取得します。実際はOpenID Connectなどのアクセストークンの妥当性をチェックしてコネクションの可否を判断すると思います。
request.params[:account]
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
def disconnect
end
private
def find_verified_user
if verified_user = User.find_by(account: request.params[:account])
verified_user
else
reject_unauthorized_connection
end
end
end
end
チャンネルを作成する
$ bundle exec rails g channel room
class RoomChannel < ApplicationCable::Channel
:
:
end
デフォルトで作成されているメソッドと、サンプルアプリ向けに作成したメソッドを紹介します。(パブリックメソッドのみ)
subscribed
アプリ側からリクエストされたroomのパラメーターを取得します。サンプルアプリでは無条件にサブスクライブしますが、ユーザーとroomの検証を行って不正なアクセスだったら遮断するなどの処理があっても良いと思います。続いてstream_for
を指定します。この表現が正しいかわかりませんが、送信先のグルーピングを指定するイメージです。
- room全体
@room(params[:room])を指定します。同じroomを指定(以下、同じルーム)しているアクティブユーザーにブロードキャストすることができます。 - 自身のみ
ユーザーのアカウントとroomを連結して自身のみを指定します。一意になれば何でも良いと思います。ブロードキャストではあるものの実質的に自身のみが宛先になります。
room_inは簡易的にルーム毎のアクティブユーザーを追加するメソッドです。このメソッドは説明程度に動作すれば良いので作りは甘いです
def subscribed
@room = params[:room]
@user = self.current_user.id.to_s + @room
stream_for @room
stream_for @user
room_in(key: @room, account: self.current_user.account)
end
unsubscribed
暗黙的、明示的に関わらずサブスクライブを解除したとき、room_inとは反対にルーム毎のアクティブユーザーを削除します。このメソッドも同様に説明程度に動作すれば良いので作りは甘いです
また、ルームを出たことをアクティブユーザーにブロードキャストします。
def unsubscribed
room_out(key: @room, account: self.current_user.account)
# 全員に送ります。
RoomChannel.broadcast_to(@room, account: self.current_user.account, type: :out)
end
greeting
各ユーザーはサブスクライブしたあと、アクティブユーザーに「ルームに入った」ことを通知します。このとき、roommate(※)を利用してアクティブユーザーのリストを送信します。このリストでアプリ側で表示している各ユーザーのアクティブ状況を更新しています。
※ roommateはルーム毎にアクティブユーザーを取得するメソッドです。前述のroom_in/room_outで管理しています。
def greeting
# 全員に送ります。
RoomChannel.broadcast_to(@room, roommate: roommate(key: @room), account: self.current_user.account, type: :in)
end
mumbling
「独り言」ボタンで自分自身だけにブロードキャストします。
「自身だけに通知した処理をつけたい」という理由ありきで実装しました
def mumbling
# 独り言です。
RoomChannel.broadcast_to(@user, content: '(゚Д゚;)', account: self.current_user.account, type: :mumbling)
end
bark
「ワンワン」「ワオーン」ボタンで、その鳴き声を同じルームのアクティブユーザーに送ります。
多くのサンプルが公開されているチャットアプリだと、このメソッドが肝ですよね。
def bark(data)
# 全員に送ります。
RoomChannel.broadcast_to( \
@room, content: data["content"], account: self.current_user.account, type: :bark)
end
起動する
-
Docker Composeで起動
$ cd docker $ docker-compose up : cache_1 | 1:M 09 Mar 2019 09:05:57.692 * Ready to accept connections : db_1 | Version: '5.7.25' socket: '/var/run/mysqld/mysqld.sock' port: : 3306 MySQL Community Server (GPL) : app_1 | Use Ctrl-C to stop
-
ブラウザでトップページのにアクセス
この環境では正規のドメインを取得していないので、とりあえずhostsに設定してください。
動作を確認したいけれど・・・
本記事ではアプリからのアクセスを前提としてAPIモードでプロジェクトを作成しましたのでWeb向けのCoffeeScriptなどがありません。ここのご紹介はiOS/Android編に譲りたいと思います。
終わりに
ActionCableのドキュメントは分かりやすいと思います。しかし、事例や情報量が少ない上にBeta版の頃の情報も混在しており何が正しいか分かりづらい印象でした。筆者は構築した経験はありませんが、この点は「Socket.IO強し」と言ったところでしょうね。今回の構成が少しでもご参考になれば幸いです。