JQ Blog

Rails5のActionCable

はじめに

Action Cableとは

RailsにWebSocketを組み込む機能で、クライアントサイドからサーバサイドまでフルスタックな機能が提供されるということ。

大きな流れ

ビューのEvent発生(ex: onclick) -> coffeescriptのfunction呼び出し -> channelのメソッド呼び出し -> 必要な処理をする(ex: モデルのcreate,update,deployなど)

チャットサンプルを作ってみる

簡単に説明をすると、Rooms_controllerを通してチャットルームを作り、Messageモデルを通してチャットルームの中のメッセージを管理する。 その中でチャットルームでリアルタイムの通信を行ってみる。

Roomsコントローラの作成

1
rails g controller rooms index show

Messageモデルの作成

1
2
$ rails g model message content:text
$ rails db:migrate

Viewの作成

Roomsコントローラを編集し、Messageの配列をインスタンス変数に設定する。

1
2
3
4
5
6
7
8
9
10
class RoomsController < ApplicationController
  def index
    @rooms = Room.all.order(:id)
  end

  def show
    @room = Room.find(params[:id])
    @messages = @room.messages.order(:id)
  end
end

app/views/messages/_message.html.slimを作成する。

1
2
3
.message class="message_#{message.id}"
  = "#{message.body}"
  i.fa.fa-times.remove-icon data-message-id="#{message.id}"

app/views/rooms/show.html.slimを編集して、Messageの一覧を表示できるようにする。 renderはどんな形でも構わないがここには簡単にメッセージのrenderだけでしてみる。

1
2
3
4
5
6
7
8
9
h1 Chat Room
#myRoom data-room-id="#{@room.id}"
  = render @messages

div style="text-align: center; margin-top: 50px;"
  form
    label
      |say something:
      input type="text" data-behavior="room_input"

チャンネルの作成

speakというアクションを持つ、Roomチャンネルを作成する。 rails g channel room speakというコマンドを実行すると、以下の通り2つのファイルが作成される。

1
2
3
$ rails g channel room speak
    create  app/channels/room_channel.rb
    create  app/assets/javascripts/channels/room.coffee

app/channels/room_channel.rbはサーバーサイドの処理を受け持つチャンネルである。

1
2
3
4
5
6
7
8
9
10
11
12
13
# Be sure to restart your server when you modify this file. Action Cable runs in an EventMachine loop that does not support auto reloading.
class RoomChannel < ApplicationCable::Channel
  def subscribed
    # stream_from "some_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def speak
  end
end

app/assets/javascripts/channels/room.coffeeはクライアントサイドの処理を受け持つチャンネルである。

1
2
3
4
5
6
7
8
9
10
11
12
App.room = App.cable.subscriptions.create "RoomChannel",
  connected: ->
    # Called when the subscription is ready for use on the server

  disconnected: ->
    # Called when the subscription has been terminated by the server

  received: (data) ->
    # Called when there's incoming data on the websocket for this channel

  speak: ->
    @perform 'speak'

routesにmountする

Action Cableを有効にするため、mount ActionCable.server => '/cable'をroutesに追加。

1
2
3
4
5
6
Rails.application.routes.draw do
  # (省略)

  # Serve websocket cable requests in-process
  mount ActionCable.server => '/cable'
end

アクションの設定

app/assets/javascripts/channels/room.coffeeで、クライアントサイドのspeakアクションを定義する。 ここではサーバーサイドのspeakアクションを呼びだし、messageをパラメータとして渡す。

1
2
3
4
5
App.room = App.cable.subscriptions.create "RoomChannel",
  # (省略)

  speak: (message) ->
    @perform 'speak', message: message

次に、app/channels/room_channel.rbを編集して、サーバーサイドのspeakアクションを定義する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class RoomChannel < ApplicationCable::Channel
  def subscribed
    # stream_from "some_channel"
    stream_from "room_channel"
  end

  # (省略)

  def speak(data)
    if room = Room.find(params[:id])
      room.messages.create! body: data['message']
    end
  end
end

speakのメソッドが呼ばれるとモデルを生成する。モデルが生成されたらjobを通して画面を更新するようにする。

フォームを使ったデータの送信

さっきapp/views/rooms/show.html.slimのところに

1
2
3
4
5
div style="text-align: center; margin-top: 50px;"
  form
    label
      |say something:
      input type="text" data-behavior="room_input"

を書いておいたからこのフォームを使ってデータを送信する。 app/assets/javascripts/channels/room.coffee

1
2
3
4
5
6
7
8
9
10
11
12
$(document).on 'keypress', '[data-behavior~=room_input]', (event) ->
  if event.keyCode is 13 # return = send
    if !(event.target.value == '')
       App.room.speak event.target.value
       event.target.value = ''
       event.preventDefault()
  else
    event.preventDefault()
$(document).on 'click', '.remove-icon', (event) ->
  id = $(event.target).data('message-id')
  App.room.remove id
  event.target.value = ''

データの保存の後、ブロードキャスト処理

次に、Messageモデルのコールバックを定義し、データが作成されたら非同期でブロードキャスト処理を実行するようにします。トランザクションをコミットしたあとでブロードキャストしないと、他のクライアントからデータが見えない恐れがあるのでafter_createではなくafter_create_commitを使う。

1
2
3
4
5
6
class Message < ApplicationRecord
  belongs_to :room, class_name: "Room", foreign_key: :room_id

  after_create_commit { MessageBroadcastJob.perform_later self }
  after_destroy_commit { MessageBroadcastJob.perform_later self.id }
end

続いて、非同期でブロードキャストするためのMessageBroadcastジョブを作成する。

1
$ rails g job message_broadcast

ApplicationController.renderer.renderメソッドを使うと、コントローラ以外の場所でビューをレンダリングできます。なのでこのジョブにはパーシャルビューのHTMLを返す。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MessageBroadcastJob < ApplicationJob
  queue_as :default

  def perform(message)
    if message.is_a?(ActiveRecord::Base)
      ActionCable.server.broadcast 'room_channel', message: render_message(message)
    else
      ActionCable.server.broadcast 'room_channel', id: message
    end
  end

  def render_message(message)
    ApplicationController.renderer.render(partial: 'messages/message', locals: {message: message})
  end
end

サーバーからデータを受け取ったらブラウザ内の表示を書き換える

ジョブから返されたパーシャルビューはapp/assets/javascripts/channels/room.coffeereceivedメソッドに渡されるのでreceivedメソッドを編集する。 今回はメッセージの削除も行うのでメッセージを受け取った場合と削除ボタンを押した場合を分岐して処理する。

1
2
3
4
5
6
7
8
9
10
# (省略)

received: (data) ->
  # Called when there's incoming data on the websocket for this channel
  if data['message'] != undefined
    $('#myRoom').append data['message']
  else
    $('.message_' + data['id']).remove()

# (省略)

ログの内容

ビューでhello, rails!とメッセージを入力すると下記のログが出る。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
RoomChannel#speak({"message"=>"hello, rails!"})
  Room Load (0.4ms)  SELECT  "rooms".* FROM "rooms" WHERE "rooms"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
   (0.1ms)  BEGIN
  CACHE (0.0ms)  SELECT  "rooms".* FROM "rooms" WHERE "rooms"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
  SQL (0.9ms)  INSERT INTO "messages" ("body", "created_at", "updated_at", "room_id", "commenter_type", "commenter_id") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"  [["body", "hello, rails!"], ["created_at", 2017-03-02 10:04:50 UTC], ["updated_at", 2017-03-02 10:04:50 UTC], ["room_id", 2], ["commenter_type", "User"], ["commenter_id", 4]]
   (1.2ms)  COMMIT
[ActiveJob] Enqueued MessageBroadcastJob (Job ID: f1d4d926-add7-40d9-a0c7-08d96e413680) to Async(default) with arguments: #<GlobalID:0x007feee520cf20 @uri=#<URI::GID gid://practice/Message/335>>
  Message Load (0.3ms)  SELECT  "messages".* FROM "messages" WHERE "messages"."id" = $1 LIMIT $2  [["id", 335], ["LIMIT", 1]]
[ActiveJob] [MessageBroadcastJob] [f1d4d926-add7-40d9-a0c7-08d96e413680] Performing MessageBroadcastJob from Async(default) with arguments: #<GlobalID:0x007feee5214ae0 @uri=#<URI::GID gid://practice/Message/335>>
[ActiveJob] [MessageBroadcastJob] [f1d4d926-add7-40d9-a0c7-08d96e413680]   User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
[ActiveJob] [MessageBroadcastJob] [f1d4d926-add7-40d9-a0c7-08d96e413680]   Rendered u/messages/_message.html.slim (4.7ms)
[ActiveJob] [MessageBroadcastJob] [f1d4d926-add7-40d9-a0c7-08d96e413680] [ActionCable] Broadcasting to room_channel: {:message=>"<div class=\"message message_335\"><div class=\"message-p\">user4@example.com : hello, rails!<i class=\"fa fa-times remove-icon\" data-message-id=\"335\"></i></div></div>"}
[ActiveJob] [MessageBroadcastJob] [f1d4d926-add7-40d9-a0c7-08d96e413680] Performed MessageBroadcastJob from Async(default) in 15.06ms
RoomChannel transmitting {"message"=>"<div class=\"message message_335\"><div class=\"message-p\">user4@example.com : hello, rails!<i class=\"fa fa-times remove-icon\" data-message-id=\"335\"></i></div></div>"} (via streamed from room_channel)

そして、削除ボタンを押したら下記のログが出る。

1
2
3
4
5
6
7
8
9
10
11
RoomChannel#remove({"id"=>335})
  Room Load (0.2ms)  SELECT  "rooms".* FROM "rooms" WHERE "rooms"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
  Message Load (0.4ms)  SELECT  "messages".* FROM "messages" WHERE "messages"."room_id" = $1 AND "messages"."id" = $2 LIMIT $3  [["room_id", 2], ["id", 335], ["LIMIT", 1]]
   (0.1ms)  BEGIN
  SQL (0.3ms)  DELETE FROM "messages" WHERE "messages"."id" = $1  [["id", 335]]
   (1.2ms)  COMMIT
[ActiveJob] [MessageBroadcastJob] [694e3186-c328-4327-8c2c-686de737f7ee] Performing MessageBroadcastJob from Async(default) with arguments: 335
[ActiveJob] Enqueued MessageBroadcastJob (Job ID: 694e3186-c328-4327-8c2c-686de737f7ee) to Async(default) with arguments: 335
[ActiveJob] [MessageBroadcastJob] [694e3186-c328-4327-8c2c-686de737f7ee] [ActionCable] Broadcasting to room_channel: {:id=>335}
[ActiveJob] [MessageBroadcastJob] [694e3186-c328-4327-8c2c-686de737f7ee] Performed MessageBroadcastJob from Async(default) in 0.6ms
RoomChannel transmitting {"id"=>335} (via streamed from room_channel)

参考

Rails 5 + ActionCableで作る!シンプルなチャットアプリ(DHH氏のデモ動画より)