LoginSignup
3
8

More than 1 year has passed since last update.

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

Last updated at Posted at 2019-03-17

前置き

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

お遊びサンプルの紹介

以下のアニメーションGIFをご覧ください。各ユーザーのアクティブ状況を表示して、メッセージをやりとります。もっと砕けた表現をするならば、筆者の愛犬たちが寝起きして、鳴いたり、遠吠えしたり、唸ったりします。
ios_demo.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を初期起動した時点で以下が実行されます。

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_protectiontrueに設定して送信元の制限を緩めます。この設定をしないと以下のエラーが発生します。
    筆者は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を利用することにします。

    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を指定しています。

    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

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

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:

volumes/app/db/seeds.rb
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]
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

チャンネルを作成する

コンソール
$ bundle exec rails g channel room
volumes/app/app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
    :
    :
end

デフォルトで作成されているメソッドと、サンプルアプリ向けに作成したメソッドを紹介します。(パブリックメソッドのみ)

subscribed

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

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

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

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:
また、ルームを出たことをアクティブユーザーにブロードキャストします。

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

「独り言」ボタンで自分自身だけにブロードキャストします。
「自身だけに通知した処理をつけたい」という理由ありきで実装しました:sweat:

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

起動する

  1. 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
    
  2. ブラウザでトップページのにアクセス
    この環境では正規のドメインを取得していないので、とりあえずhostsに設定してください。
    image

動作を確認したいけれど・・・

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

終わりに

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

3
8
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
3
8