devひよこのあしあと

いつでもひよっこな気持ちで学びと挑戦を

Rails5の新機能ActionCableを使ったスライド共有システムを作ってみた

社内LT大会で発表するネタとして、Rails5の新機能である ActionCable を使って某動画共有サイト風のスライド共有システム「nslides」を構築してみました。

まずは下記を参照ください。

ニコニコRails5 〜Rails5の新機能 ActionCable 使ってみたよ!〜 公開終了しました (2017/02/27)

f:id:devchick:20160313225406p:plain

ソースコードおよびシステム構築のためのプロビジョニング設定などはgithubにて公開しています。

https://github.com/devchick/nslides

ActionCableとは

ActionCableとはRailsアプリの中でWebSocketを簡単に利用できるようにするフレームワークです。 WebSocketを利用すると、クライアント側からサーバー側へいちいちHTTPリクエストでポーリングしたりすることなく、必要なタイミングでサーバー・クライアントが互いに通信することができます。これにより、サーバー側から通知情報をプッシュすることなどができるようになります。

ActionCableのサンプルとしてRails作者のDHHさんがシンプルなチャットシステムの作り方動画をこちらで公開しています。また、この動画の日本語解説が@jnchitoさんによってこちらで公開されています。

nslidesでは、コメントの配信と発表者のページ遷移に追従させるためにActionCableを利用しています。

ぶっちゃけてしまうと、「WebSocketって何? なんか便利らしいけどよーわからん。」程度にしかWebSocketを知りませんでしたがActionCableを使うことでそれなりに動作するモノが作れてしまったので、これからWebSocketの敷居が下がってくるのではないかと期待します。

Cable実装(サーバー側)

さて、ActionCableを使って実装している部分をピックアップして紹介します。詳細はリポジトリを参照ください。

Rails5においてActionCable関係のフォルダ構成は下記のようになっており、nslidesシステム用に作成したファイルはslide_channel.rbになります。

RAILS_ROOT
 └ app
   └ channels
     ├ application_cable
     │ ├ channel.rb
     │ └ connection.rb
     └ slide_channel.rb

SlideChannelはApplicationCable::Channelを継承したクラスで、クライアントからの接続要求の受け付けとアクションの受け付けを担わせました。ポイントは、ユーザーがいま閲覧しているスライドについての情報をやり取りしたいのでスライドIDごとにチャンネルを分けるようにしています。

なお、ApplicationCable::ChannelはActionCable::Channel::Baseを継承したクラスで、モデルでいうApplicationModelに相当する共通機能を定義しておく親クラスになります。

# RAILS_ROOT/app/channels/slide_channel.rb

class SlideChannel < ApplicationCable::Channel
  # 接続要求を受け付ける
  def subscribed
    # スライドIDごとにチャンネルを分ける
    stream_from "slide_channel_#{params[:slide_id]}"
  end

  # ユーザー定義のアクションメソッド
  def add_comment(data)
    data = data.with_indifferent_access
    comment = slide.comments.create(data[:comment])
    # 他の閲覧者への配信はバックグラウンドジョブに任せる
    CommentBroadcastJob.perform_later(comment, params)
  end

  private

  def slide
    @slide ||= Slide.find(params[:slide_id])
  end
end

上記で受信したコメント情報を接続されているソケット全部に対してメッセージ送信する処理は、下記のようにActiveJobを使って非同期に実施しています。

# RAILS_ROOT/app/jobs/comment_broadcast_job.rb

class CommentBroadcastJob < ApplicationJob
  queue_as :default

  def perform(comment, params)
    # スライドIDごとのチャンネルに対してメッセージ送信
    ActionCable.server.broadcast "slide_channel_#{params[:slide_id]}", comment: comment
  end
end

Cable実装(クライアント側)

クライアント側のActionCableに関する実装は下記のようにしました。フレームワークとして用いているReact.jsの方言がありますが、要はビューがレンダリングされたときにチャンネルにケーブル接続し、ケーブルを通してサーバー側からの通知を受ける仕組みとサーバー側に通知する仕組みを作っています。

// RAILS_ROOT/app/assets/javascript/components/views/slide_show.js.jsx

var SlideShow = React.createClass({
    componentDidMount() {
        // ビューのレンダリングが終わったら呼び出されるコールバックでチャンネルにケーブルを接続する
        this.subscriptChannel();
    },

    // ActionCableチャンネルの購読
    subscriptChannel() {
        App.slide = App.cable.subscriptions.create(
            // チャンネルの種類とスライドのIDを指定。これがサーバー側にparamsとしてセットされる。
            { channel: "SlideChannel", slide_id: this.state.slide.id },
            {
                // ActionCableが受信したときのコールバック
                received(data) {
                    // 受信したデータを解析して状態を更新する
                    var next_state = this.mergeData(data);
                    this.setState(next_state);
                },

                addComment(comment) {
                    // ケーブルを通してコメントを通知。サーバー側のadd_commentメソッドが呼び出される
                    this.perform('add_comment', { comment: comment });
                }
            }
        );
        App.slide.received = App.slide.received.bind(this);
    }
}

あとは、スライド下部に配置しているボタンが押下されたときにApp.slide.addComment()をコールしてやることで、コメント文字列がサーバー側に通知され、その後前述のロジックによりサーバー側から全クライアントへコメント文字列が配信されてきます。

var ActionBox = React.createClass({
    render() {
        return (
            <div className="action_box">
              <form>
                <Input type="textarea" placeholder="コメント" rows={1} />
                <ButtonInput type="submit" onClick={ this.addComment } value="コメントする" bsStyle="warning" />
              </form>
            </div>
        );
    },

    addComment(event) {
        event.preventDefault();
        var comment = {
            page_id:       this.props.current_page.id,
            body:          event.target.form[0].value,
            recorded_time: this.props.getElapsedTime()
        };
        if (comment.body) App.slide.addComment(comment); // ← ケーブルを通してコメントを通知
        if (event.target.form) event.target.form[0].value = "";
    }
}

地雷踏み

今回はRails 5.0.0.beta2というRCにも満たないベータ版を利用しています。そのためなのか開発していて少しハマったことがありました。

rails sでpumaをdevelopment状態で起動した状態でJSなどのソースコードを修正したあとブラウザをリロードすると修正後のコードがキチンと適用されます。ところが、ActionCableを接続している状態で同じことをすると下記のようなエラーが発生し、pumaを再起動しないと動作しない状態となってしまいました。

ActionController::RoutingError - uninitialized constant CommentsController:
  actionpack (5.0.0.beta2) lib/action_dispatch/routing/route_set.rb:46:in `rescue in controller'
  actionpack (5.0.0.beta2) lib/action_dispatch/routing/route_set.rb:44:in `controller'
  actionpack (5.0.0.beta2) lib/action_dispatch/routing/route_set.rb:30:in `serve'
  actionpack (5.0.0.beta2) lib/action_dispatch/journey/router.rb:42:in `block in serve'
  actionpack (5.0.0.beta2) lib/action_dispatch/journey/router.rb:29:in `serve'
  actionpack (5.0.0.beta2) lib/action_dispatch/routing/route_set.rb:724:in `call'
    ...

RC1もしくは正式リリースでは動作するようになってると嬉しいなー。

システム構成

LT大会の枠が余ってるので何か発表して〜って主催者に依頼されたとき、Rails5の最新版が5.0.0.beta2だったのでこれを利用しています。この記事執筆時は5.0.0.beta3がリリースされており、更に近々5.0.0 RC1がリリースされることが示唆されています (3/1って書いてあるけどまだ出てないよな…)。RC1へのバージョンアップについてなどは機会があればまた別の記事で。

バックエンドをRails5で構成し、フロント部分にはReact.jsを今回は用いました。私自身はビューやJS、CSSあたりがちょっと苦手なもので、流行りのJSフレームワークに乗っかって楽をしたかったためです。初めてReact.jsに触れてみましたがなかなかすっきりと構成できて良い感じの印象です。Flux?とかいうのはまだ勉強足りてなくて導入してません。

インフラ周りはAWSを利用しています。AWSアカウント開設から1年間はいろいろなサービスの無料枠がありますので、それらをフル活用して構成しました。なので、今回のサービス開設にあたって掛かった費用はいまのところ、ドメイン取得のための88円のみ (ありがとうムームードメインさん)。アクセス数が増えてきた時にAWS無料枠の中だけで収まるかな〜。

今後の展開

  • HTTP/2による高速化
  • AWS Lambdaを使ってPDFファイル分解
  • ユーザー認証機能
  • チーム内共有機能
  • かっこいいデザイン
  • 社員総会でこのシステムを使う!!
    • 社長による来期の方針発表中とかにコメント送ってみたいwww

参考

  • nslidesの元ネタ