はじめに
こんばんは。弊社の直近の仕事で行なっていたwebsocket_railsで開発していたユーザーチャットの機能をActionCableにリプレースする話でも執筆しようと思います。
なぜActionCableにリプレースするのか
単純に、もうそろそろ限界かなと思った次第です。
WebSocketとは
通常、クライアントがWebサーバと通信を行う際にはHTTPの手順に基づいて通信を行いますが、HTTPはステートレス(ステート: 状態という事をさす事が多い?)なプロトコルなため、どれだけリクエストを送っても過去のステートを保持しないため、サーバから同じレスポンスが返ってくる仕様です。勿論、現在のWebアプリケーションでは過去のステートを保持しなければならない事情の事の方が多いため、Cookie情報を付加したりして、過去のステートをサーバに渡す事もしています。
また、一度HTTP通信が完了すると、その通信は閉じられるため、基本的には片方向の通信しかできません(TCPのコネクションとは別の模様、HTTP1.1からそれまでTCPのコネクション閉じていたものが、閉じないようになった)。
そのため、XMLHTTPRequestやHTTPロングポーリングなどの複数のHTTP通信を介して双方向通信をしているように見せかける方法はあるのですが、如何せん実装は複雑になる傾向があります。
そこで、HTTPの通信を拡張させてTCPのようにハンドシェイクを行い、HTTP上で仮想的な通信経路(コネクション)を実現し、その中で双方向通信ができるようにしようと提案されたのがWebSocket
です。
Railsは5.0以降においてActionCable
と呼ばれるJSによるクライアントサイドとRailsによるサーバサイド両方のWebSocketフレームワークを提供できる機能が標準化されており、Rails5.0にアップグレードして以降利用したいツールでした(なお、Rails5.0以前はwebsocket-railsが主流だった?)。
今回は、websocket-railsで開発していたチャット機能をActionCableにリプレースします。
続いて、ActionCableの概要を書きます。
ActionCableの概要
参考文献があるのですが、ActionCableはどうも用語が多すぎて複雑です。パッと読んだ理解と下手なポンチ絵なので、間違いがあれば指摘をお願いします。
このポンチ絵は、
https://railsguides.jp/action_cable_overview.html#%E7%94%A8%E8%AA%9E%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6
とhttps://railsguides.jp/action_cable_overview.html#pub-sub%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6を参考に書きました。
要は、コネクションを確立させるためにチャネル(データを伝送する経路に)クライアントが加入すると、クライアントはサブスクライバ
という名前になり、パブリッシャ(情報を伝送する者)から受け取ったデータをブロードキャストでサブスクライバに伝達させます。
この時、特定の個人宛にデータを送信するわけではないので、ブロードキャストを行うことにより、パブリッシャも後にサブスクライバとなり、データを受信することができます。
中々、用語の整理が複雑ですが、基本的な所を抑えてしまえば、後はJSとRailsのコードでWebSocketの通信を行える事ができるので、次に具体的なコードにいってみましょう。
ActionCableをRailsサーバと一緒に実行する
routes.rbに以下のように/websocket
でWebSocketリクエストがListenできるようにします。
mount ActionCable.server => "/websocket"
クライアントがWebSocketリクエストを送る
まずはActionCableサーバにWebSocketリクエストを送ります。createConsumer
メソッドにより、WebSocketのリクエストを送ります。createConsumerの引数には直接サーバホスト名を書く事もできますが、今回はパス名のみを指定します。
//= require action_cable
//= require_self
(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer("/websocket");
}).call(this);
ActionCableのサーバサイドの概要
ユーザーチャット用に利用するチャネルを作成します。チャネルの初回作成時には、親チャネルとなるapp/channels/application_cable/channel.rb
やapplication_cable/channel.rb
などなどが作成されます。
% bundle exec rails g channel chat
初めに、サーバサイド側のコードを見てみましょう。
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_for room
end
def speak(data)
message_datas = data["message"]
message = message_datas["message"]
account_class_name = message_datas["account_class_name"]
account_id = message_datas["account_id"]
chat = Chat.create!(chat_room_id: room.id, sender_type: account_class_name,
sender_id: account_id, message: message)
ChatChannel.broadcast_to(room, chat: chat)
end
def remove(data)
chat = Chat.find_by(id: data["message_id"])
chat.destroy!
ChatChannel.broadcast_to(room, delete_id: data["message_id"])
end
def update(data)
message_datas = data["message"]
chat = Chat.find_by(id: message_datas["message_id"])
chat.update(message: message_datas["message"])
ChatChannel.broadcast_to(room, edit_id: chat.id, edit_message: chat.message)
end
private
def room
(中略)
end
def my_account
(中略)
end
end
この内のdef subscribed
のコードを見てみましょう。
subscribedはコンシューマがChatChannelにサブスクライブされたときに呼び出されるコードになります。
stream_for
により、今後ユーザーチャットでブロードキャストをする際に何をトリガーとしてブロードキャストをするのが指定しています。
今回は、中身が1対1のチャットということもあり、ChatRoomのIDを入れて検索をかけたオブジェクトをルーティングのトリガーとしています。
次に、speakメソッドはクライアントがメッセージを送信した時に呼び出されるメソッドとしてここでは定義しています。受け取ったパラメーターからDBにChatデータを残しつつ、他のクライアントへブロードキャストします。この時、特定の個人を指定していないのがポイントです。
同じように、update, removeメソッドはクライアントがメッセージを更新・削除したときに呼び出されるメソッドとして定義しています。
続いて、クライアントコードを見てみましょう。
ActionCableのクライアントコードの概要
//= require ../channels/chat_channel.js
document.addEventListener("DOMContentLoaded", () => {
if ($(".chat_rooms").length) {
let chat_json = JSON.parse(*****);
let account_class_name = *****
let account_id = *****
const reply_button = *****;
reply_button.addEventListener("click", () => {
let message = {};
message["message"] = document.getElementById("chat_message").value;
message["account_class_name"] = account_class_name;
message["account_id"]= account_id;
App.room.speak(message);
});
App.room = App.cable.subscriptions.create({ channel: "ChatChannel", room_id: *****, room_type: *****, account_class_name: account_class_name, account_id: account_id}, {
connected: function() {
this.appendChats(chat_json);
},
appendChats(data) {
const html = this.createChats(data);
const element = document.getElementById("chat-area");
element.insertAdjacentHTML("beforeend", html);
},
updateChat(data) {
let message = *****;
message.innerText = data["edit_message"];
},
removeChat(message_id) {
let remove_chat = *****;
remove_chat.remove();
},
createChat(data) {
(中略)
return chat_line;
},
createChats(data) {
let chat_area = [];
let chats = []
chats.push(`<div class="chat-message-container" style="max-height: 651px;">`);
chats.push(`<div id="chat-list">`)
(中略)
return chats.join("");
},
speak: function(message) {
return this.perform("speak", {
message: message
});
},
update: function(message) {
return this.perform("update", {
message: message
});
},
remove: function(message_id) {
return this.perform("remove", {
message_id: message_id
});
},
received: function(chat) {
if(chat["chat"] != undefined) {
this.appendChat(chat["chat"]);
} else if(chat["edit_id"] != undefined) {
this.updateChat(chat);
} else if(chat["delete_id"] != undefined) {
this.removeChat(chat["delete_id"]);
}
}
});
}
});
大分、マスキングしましたが、基本的には各々が書いたHTMLの操作に合わせてJSのメソッドを用意するような書き方で良いと思います。
App.room = App.cable.subscriptions.create({ channel: "ChatChannel", room_id: result["room_id"], room_type: result["room_type"], account_class_name: account_class_name, account_id: account_id}, {
指定のチャネルにサブスクリプションを作成することで、コンシューマがサブスクライバとして振る舞うことができます。
これ以降にも違う形でActionCableを使うのであれば、子チャネル用のサブスクライブメソッドを作ればいいわけです。
また、サブスクライバとなったクライアントからはサーバのパブリックメソッドが見えるため、公開されたパブリックメソッドをperform
メソッドで呼びだすことができます。
speak: function(message) {
return this.perform("speak", {
message: message
});
},
update: function(message) {
return this.perform("update", {
message: message
});
},
remove: function(message_id) {
return this.perform("remove", {
message_id: message_id
});
各々のメソッドに合わせた、パラメータを付加してサーバのパブリックメソッドを呼び出します。
唯一、receivedはブロードキャストの通信を受け取った後に実行されるコールバックです。
received: function(chat) {
if(chat["chat"] != undefined) {
this.appendChat(chat["chat"]);
} else if(chat["edit_id"] != undefined) {
this.updateChat(chat);
} else if(chat["delete_id"] != undefined) {
this.removeChat(chat["delete_id"]);
}
}
情報の伝達方法がブロードキャストのみとなっているので、サーバから受け取るブロードキャストの中身を各メソッド用にカスタマイズすることとしました。
これによって、ブロードキャストによって送られてきたデータの中身を確認することによって、クライアントの振る舞いを変えることができます。
後は各Webアプリケーションの実装に合わせて、HTMLを動的に追加・更新・削除させて見栄えを変えてみてください。
NginxとProductionの設定
後は、Webサーバとproduction.rbの設定を行います。
config.action_cable.allowed_request_origins = ['https://*****']
ActionCableは、指定されていない送信元からのリクエストを受け付けていないため、配列の形で許可する送信元ホスト名を渡します。
server {
listen 80;
server_name *****;
root /var/www/******/public;
(中略)
location /websocket {
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://puma/websocket;
}
最後にNginxの設定です。NginxはロードバランサーからのアクセスをRailsサーバにわたすリバースプロキシの設定を行っているため、/websocket
のパスに対して、HTTPのプロトコルをWebSocketにスイッチングできるように設定をします。
これにより、RailsとActionCableの機能を一緒のPumaプロセスで動かすことができました。
まとめ
以上で、チャット機能をwebsocket_railsからActionCableにリプレースすることができました。
ActionCableに登場する人物の用語に関しては本当になんとかならなかったのかとちょっと疑問に思っています。
また、本当はwebsocket_railsからどうコードを移植したのか見せたかったのですが、実は修正前はHTMLの中で直接、<script>
タグを埋め込んでその中でフォームを構成しており、非常に直しにくい印象を受けてしまい、「これは全捨ての方が良くないか?」という事でコード全捨てしてしまいました。
今だと、websocket_railsでWebSocketプロトコルによる機能を作るメリットもないと考えているので、リプレースを考えていらっしゃる人は一旦コード全捨てのアプローチを取った方がいいかもしれません。
それでは、また。