ActionCableをつかって複数のひとが集まれるマップをつくっている

こんにちは。みんなでつくるダンジョンに複数のひとが集まれる仕組みがほしいな~とずっと思っていました。というわけで、前回記事の下準備 を経て実装してみています。

こんなかんじのことがやりたい

やりたいことはこんなかんじ。ほかのユーザーをじぶんの画面にも表示できるようにします。おおよそ0.5秒ごとに座標を送るようにしてみたのですが、予想どおり結構カクカクになる...。しかも、たまにデータが詰まったみたいな挙動をしていて、しばらくアバターの位置が反映されないことがありました。

(どうでもいいけど、連動しているよ感がある動画を撮るにはどうしたらいいのだろう~という気持ちになり、結果としてカメラ片手に動画を撮ったのですが、三脚とか持っていないのでなかなか大変だったのでした...)

ちょっとよくなった

動いているアバターは、別ブラウザから操作しています。座標を送る間隔を1.5秒間隔にしつつ、0.2秒間隔でサンプリングしたアバターの位置リストを渡すようにし、かつアバターをばねっぽく移動させるようになどしてみた結果、このくらいまではカクつきが改善されました。

ActionCableはどんなかんじでつかったの?

基本的にはRailsガイドにぜんぶかいてある!というかんじです。

railsguides.jp

上記記事では、JavaScriptファイルをアセットパイプラインに乗せるようにしていますが、みんなでつくるダンジョンのフロントエンドまわりはRails管理下にはなく、Webpackをつかってビルドしています。なので、 こんなかんじにクライアント側ライブラリを別途インストールをします。

npm install --save @rails/actioncable

あとは記事の通りにJS側、Ruby側のソースコードを用意すればOKです。(記事ではRailsapp/javascripts 配下にJavaScriptを置いていますが、ここには置かずに別途Webpackでビルドするかんじ)

基本的に、同じマップにいるひとたち間でのやりとりができればよいので、以下を満たすようにします。

  • stage_?? というチャンネルを用意する(??はマップID)
  • 同じマップにいるひとたちは stage_?? というチャンネルでアバターの位置やアニメーション情報を共有しあう
  • 誰かが送信した情報はマップにいるひとたちすべてに送られる

サーバ側のソースコードは概ねこんなかんじ。 (ここには書いていませんが、 app/channels/application_cable/connection.rbRailsガイドを参考に別途実装します)

# /path/to/app/channels/avatar_channel.rb

class AvatarChannel < ApplicationCable::Channel
  def subscribed
    stream_from "stage_#{params[:stage_id]}"
  end

  def avatar_state(data)
    ActionCable.server.broadcast("stage_#{params[:stage_id]}", data)
  end
end

クライアント側がチャンネルを購読し始めたときに呼ばれる subscribed で 「stage_?? でデータのやり取りをしますよ~」という宣言をします。 また、クライアント側から avatar_state を呼び出したとき、 stage_?? を購読しているみんなにデータをばらまきます( ActionCable.server.broadcast をつかって )。

avatar_state とは私が勝手に定義したメソッドですが、特に難しいことを気にせずに、クライアント側から任意のメソッドを呼び出すように通信できるというのが便利ですね。

リクエストヘッダを送れなさそうですが、Cookieは送信されるので、認証情報などを送りたいときはCookieをつかうとよさそうです。

クライアント側のソースコードはだいたいこんなかんじ。先ほどサーバ側で定義した avatar_state へデータを送りつけたり、 broadcast されたデータを受け取ったりします。

// /path/to/javascript/avatar-socket.js

import { createConsumer } from "@rails/actioncable";

export default class AvatarSocket {
  connect(mapId) {
    const cookieVal = '認証情報のクッキー’;
    document.cookie = cookieVal;
    this._consumer = createConsumer('wss://example.com/cable');
    this._channel = this._consumer.subscriptions.create({ channel: 'AvatarChannel', map_id: mapId }, {
      received(data) {
        // broadcastされたデータを受け取る
        console.log(data);
      },
      sendAvatarState(data) {
        this.perform("avatar_state", data);
      }
    });
  }

  send(params) {
    this._channel.sendAvatarState({...params});
  }
}

アバターの情報はこんなかんじに送ります。

import AvatarSocket from "/path/to/javascript/avatar-socket.js";

// アバターの情報を送る
// positions はアバターの0.2秒毎の位置情報(時系列順)
this._avatarSocket.send({
  avatar_id: 1,
  positions: [
    [10, 10],
    [10, 20]
  ]
});

ハマりどころ

なぜかbroadcastがうまくいかない~となっていました。原因は以下の通りでした。

  • config.reload_classes_only_on_change = false が設定されており、変更が読み込まれずうまく動かなかった(っぽい?)
  • わたしの環境では config/cable.yml で設定する async adapter だとbroadcastしたデータをうけとれず
    • (いろいろガチャガチャといじっていたので、もしかしたら async adapter でもうまくいくのかもですが...)
    • asyncではプロセス間のpub/subができないようなので、ワーカーの数が2以上だとうまくいかないのかも?
    • 本番環境相当では async adapter は非推奨なので、 redis adapter を利用するようにしました

心配ごと

本番環境で動かしたときに負荷はどうなるんだろう...というのが結構気になっています。みんなでつくるダンジョンのアクセス数的には全然大丈夫だろう、という気持ちでつくってはいるけど、動かしてみないとわからないので心配...。

ActionCableサーバはRailsアプリケーションサーバと同一でなくても良いらしいですが、趣味開発なのでほどほどのお金しかかけられないことを考えると、はじめのほうは様子を見つつ、機能を制限しながらお試し運用する感じになりそうです。

まとめ

ひきつづきこんな感じの機能を実装しています。お楽しみに~

ではでは。