はじめに
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.coffee
のreceived
メソッドに渡されるので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.4 ms ) SELECT "rooms" . * FROM "rooms" WHERE "rooms" . "id" = $1 LIMIT $2 [[ "id" , 2 ], [ "LIMIT" , 1 ]]
User Load ( 0.3 ms ) SELECT "users" . * FROM "users" WHERE "users" . "id" = $1 LIMIT $2 [[ "id" , 4 ], [ "LIMIT" , 1 ]]
( 0.1 ms ) BEGIN
CACHE ( 0.0 ms ) SELECT "rooms" . * FROM "rooms" WHERE "rooms" . "id" = $1 LIMIT $2 [[ "id" , 2 ], [ "LIMIT" , 1 ]]
SQL ( 0.9 ms ) 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.2 ms ) COMMIT
[ ActiveJob ] Enqueued MessageBroadcastJob ( Job ID: f1d4d926 - add7 - 40 d9 - a0c7 - 08 d96e413680 ) to Async ( default ) with arguments: #<GlobalID:0x007feee520cf20 @uri=#<URI::GID gid://practice/Message/335>>
Message Load ( 0.3 ms ) SELECT "messages" . * FROM "messages" WHERE "messages" . "id" = $1 LIMIT $2 [[ "id" , 335 ], [ "LIMIT" , 1 ]]
[ ActiveJob ] [ MessageBroadcastJob ] [ f1d4d926 - add7 - 40 d9 - a0c7 - 08 d96e413680 ] Performing MessageBroadcastJob from Async ( default ) with arguments: #<GlobalID:0x007feee5214ae0 @uri=#<URI::GID gid://practice/Message/335>>
[ ActiveJob ] [ MessageBroadcastJob ] [ f1d4d926 - add7 - 40 d9 - a0c7 - 08 d96e413680 ] User Load ( 0.3 ms ) SELECT "users" . * FROM "users" WHERE "users" . "id" = $1 LIMIT $2 [[ "id" , 4 ], [ "LIMIT" , 1 ]]
[ ActiveJob ] [ MessageBroadcastJob ] [ f1d4d926 - add7 - 40 d9 - a0c7 - 08 d96e413680 ] Rendered u / messages / _message . html . slim ( 4.7 ms )
[ ActiveJob ] [ MessageBroadcastJob ] [ f1d4d926 - add7 - 40 d9 - a0c7 - 08 d96e413680 ] [ 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 - 40 d9 - a0c7 - 08 d96e413680 ] Performed MessageBroadcastJob from Async ( default ) in 15.06 ms
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.2 ms ) SELECT "rooms" . * FROM "rooms" WHERE "rooms" . "id" = $1 LIMIT $2 [[ "id" , 2 ], [ "LIMIT" , 1 ]]
Message Load ( 0.4 ms ) SELECT "messages" . * FROM "messages" WHERE "messages" . "room_id" = $1 AND "messages" . "id" = $2 LIMIT $3 [[ "room_id" , 2 ], [ "id" , 335 ], [ "LIMIT" , 1 ]]
( 0.1 ms ) BEGIN
SQL ( 0.3 ms ) DELETE FROM "messages" WHERE "messages" . "id" = $1 [[ "id" , 335 ]]
( 1.2 ms ) COMMIT
[ ActiveJob ] [ MessageBroadcastJob ] [ 694 e3186 - c328 - 4327 - 8 c2c - 686 de737f7ee ] Performing MessageBroadcastJob from Async ( default ) with arguments: 335
[ ActiveJob ] Enqueued MessageBroadcastJob ( Job ID: 694 e3186 - c328 - 4327 - 8 c2c - 686 de737f7ee ) to Async ( default ) with arguments: 335
[ ActiveJob ] [ MessageBroadcastJob ] [ 694 e3186 - c328 - 4327 - 8 c2c - 686 de737f7ee ] [ ActionCable ] Broadcasting to room_channel: { : id => 335 }
[ ActiveJob ] [ MessageBroadcastJob ] [ 694 e3186 - c328 - 4327 - 8 c2c - 686 de737f7ee ] Performed MessageBroadcastJob from Async ( default ) in 0.6 ms
RoomChannel transmitting { "id" => 335 } ( via streamed from room_channel )
参考
Rails 5 + ActionCableで作る!シンプルなチャットアプリ(DHH氏のデモ動画より)