Nginx,Redis,MySQLを使ってほんの少し実践的なRails ActionCableと、iOS/Androidのサンプルアプリを作って全体像を学ぶ〜バックエンド編〜

# 前置き
「Rails ActionCableで双方向通信してみたい」「モバイルアプリでリアルタイム通信アプリ作りたい」と思いサンプルアプリを作ってみました。個々の詳細については既に解説してくださっている記事はありますので、大まかに環境構築やソースコードをご紹介します。以下の3部構成になっています。

* バックエンド(本記事)
* [iOS](https://qiita.com/items/4abea6237c00c384cdfc)
* [Android](https://qiita.com/items/2b26c038c6f3b631e87d)

# お遊びサンプルの紹介
以下のアニメーションGIFをご覧ください。各ユーザーのアクティブ状況を表示して、メッセージをやりとります。もっと砕けた表現をするならば、筆者の愛犬たちが寝起きして、鳴いたり、遠吠えしたり、唸ったりします。
![ios_demo.gif](https://qiita-image-store.s3.amazonaws.com/0/240974/d4f4e4c8-4053-4257-6644-c7c65614a752.gif)

このアプリでは大きく2つのActionCableの使い方があります。

* 同じルーム内の全ユーザーにブロードキャスト
    * ルームに入る  
現在ルーム内にいるユーザー(以下、アクティブユーザー)にルームに入ったことを通知し、アクティブユーザーを取得します。そして、各ユーザーのアクティブ状況を表示します。
    * 「ワンワン」ボタンと「ワオーン」ボタン  
文字入力で任意の文字が送れないだけで、チャットでいうところの「メッセージ」とほぼ同義です。ボタンに対応したメッセージを送信します。
    * ルームから出る  
iOSなら「キャンセル」、Androidなら「←」をタップしたり、アプリを閉じたりするとルームから出たことにします。ルームに入るときと同様にアクティブユーザーを取得して、各ユーザーのアクティブ状況を表示します。
* 自分にブロードキャスト
    * 「独り言」  
「独り言」ということで自分のみメッセージを受信します。

# 構成
[以前作成した開発環境](https://qiita.com/items/c068e499c43276c55a32)をベースにDockerで構築しています。この構成も[GitHub](https://github.com/devnokiyo/ActionCable-iOS-Android)に公開していますので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](https://github.com/devnokiyo/ActionCable-iOS-Android)に公開していますので、この記事では要点のみ紹介します。
## プロジェクトを作成する
Docker Composeを初期起動した時点で以下が実行されます。

```sh:docker/containers/rails/docker-entrypoint.sh
bundle exec rails new . -d mysql -f -T --api --skip-bundle
```
## ActionCableを設定する
1. 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
```
1. サブスクリプションアダプターにRedisを指定
開発環境のアダプターはデフォルトでasyncです。プロダクション環境ではRedisが推奨(async非推奨)されています。ここではRedisを利用することにします。

    ```yml:volumes/app/config/cable.yml
    default: &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'
```
1. Redisを設定
ActionCableでRedisはDB番号```0```を指定しているので、キャッシュの用途ではDB番号```1```を指定しています。

    ```ruby: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
```

その他、いくつか制約を加えてマイグレーションした結果以下のようになりました。ただし、追加した制約が無くても動作に影響はありません。

```ruby:volumes/app/db/schema.rb
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ユーザー以外は動作させることができません。
すみません:bow::bow::bow:

```ruby:volumes/app/db/seeds.rb
User.create(account: 'chiyo', name: '千代')
User.create(account: 'eru', name: 'エル')
User.create(account: 'otome', name: '乙女')
```

```sh:コンソール
$ bundle exec rails db:seed
```
## コネクションを設定する
公式とほとんど変わりません。アプリとWebAPIはステートレスにしたいのでユーザーの情報はCookieからではなくパラメーターから取得します。実際はOpenID Connectなどのアクセストークンの妥当性をチェックしてコネクションの可否を判断すると思います。


```ruby:ユーザー情報のやりとり
request.params[:account]
```

```ruby:volumes/app/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

    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
```

## チャンネルを作成する
```sh:コンソール
$ bundle exec rails g channel room
```

```ruby:volumes/app/app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
    :
    :
end
```
デフォルトで作成されているメソッドと、サンプルアプリ向けに作成したメソッドを紹介します。(パブリックメソッドのみ)

### subscribed
アプリ側からリクエストされたroomのパラメーターを取得します。サンプルアプリでは無条件にサブスクライブしますが、ユーザーとroomの検証を行って不正なアクセスだったら遮断するなどの処理があっても良いと思います。続いて```stream_for ```を指定します。この表現が正しいかわかりませんが、送信先のグルーピングを指定するイメージです。

* room全体  
<span>@</span>room(params[:room])を指定します。同じroomを指定(以下、同じルーム)しているアクティブユーザーにブロードキャストすることができます。
* 自身のみ  
ユーザーのアカウントとroomを連結して自身のみを指定します。一意になれば何でも良いと思います。ブロードキャストではあるものの実質的に自身のみが宛先になります。

room_inは簡易的にルーム毎のアクティブユーザーを追加するメソッドです。このメソッドは説明程度に動作すれば良いので作りは甘いです:bow:

```ruby
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とは反対にルーム毎のアクティブユーザーを削除します。このメソッドも同様に説明程度に動作すれば良いので作りは甘いです:bow:
また、ルームを出たことをアクティブユーザーにブロードキャストします。

```ruby
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で管理しています。

```ruby
def greeting
  # 全員に送ります。
  RoomChannel.broadcast_to(@room, roommate: roommate(key: @room), account: self.current_user.account, type: :in)
end
```
### mumbling
「独り言」ボタンで自分自身だけにブロードキャストします。
「自身だけに通知した処理をつけたい」という理由ありきで実装しました:sweat:

```ruby
def mumbling
  # 独り言です。
  RoomChannel.broadcast_to(@user, content: '(ﾟДﾟ;)', account: self.current_user.account, type: :mumbling)
end
```
### bark
「ワンワン」「ワオーン」ボタンで、その鳴き声を同じルームのアクティブユーザーに送ります。
多くのサンプルが公開されているチャットアプリだと、このメソッドが肝ですよね。

```ruby
def bark(data)
  # 全員に送ります。
  RoomChannel.broadcast_to( \
      @room, content: data["content"], account: self.current_user.account, type: :bark)
end
```

# 起動する
1. Docker Composeで起動

    ```sh
    $ 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
    ```
1. ブラウザでトップページのにアクセス
この環境では正規のドメインを取得していないので、とりあえずhostsに設定してください。
![image](https://qiita-image-store.s3.amazonaws.com/0/240974/d6ab44be-a3b8-10c1-5950-623193547025.png)

# 動作を確認したいけれど・・・
本記事ではアプリからのアクセスを前提としてAPIモードでプロジェクトを作成しましたのでWeb向けのCoffeeScriptなどがありません。ここのご紹介はiOS/Android編に譲りたいと思います。

# 終わりに
ActionCableのドキュメントは分かりやすいと思います。しかし、事例や情報量が少ない上にBeta版の頃の情報も混在しており何が正しいか分かりづらい印象でした。筆者は構築した経験はありませんが、この点は「Socket.IO強し」と言ったところでしょうね。今回の構成が少しでもご参考になれば幸いです。