ActionCableで通信するホワイトボードをherokuにデプロイする

9/26/2019

現在参画している案件では、業務委託含めエンジニア全員がローテーションで2週に1回ペースで技術系発表会(通称:エンジニア会)を行っています。
業務時間外で作成した資料なので、本ブログにも残しておくことにします。


公開URL

https://sticky-whiteboard.herokuapp.com/
(非公開中)

github

https://github.com/kazukifujii/whiteboard


概要

テキストエリアに何らかの項目を入力して送信をすると付箋が作成される。ブラウザを開いている人全員にリアルタイム送信される。

whiteboard

作成意図

・node.jsを触っていてwebsocketに興味が出てきた。

・javascriptとjqueryをあまり触ってこなかったことで開発時スムーズに行かないことが多い。(特にデバッグ)

・Railsの知らない技術がまだまだたくさんある

・Railsの知らない機能を触りつつ必然的にjavascriptも書くのでいい題材そう。

ActionCable概要

フロントのWebSocket - バックエンドのRails を統合するフレームワーク。
WebSocketは、Web上の双方向の通信をHTTP等よりも軽量で行うことが出来る。

Pub/Subモデルで送信側(Publisher)が個別の受信者(Subscriber)を指定せずbroadcastでメッセージを送信する。

これにより低コストでリアルタイムな通信が可能となる。
詳しくはRailsガイドを参照。

https://railsguides.jp/action_cable_overview.html


HTTPとの違い

HTTPはブラウザから何らかのリクエストをしてサーバからレスポンスが返る一問一答型のプロトコル。

基本的にpush通知のようなことは出来ないが、ブラウザからサーバに定期的な問い合わせを行うポーリングという手法を使うとHTTPでも実装可能。
当然負荷は高い。

その他以下のような仕組みがある。

ロングポーリング

基本ポーリングと同じだが、問い合わせを受けたサーバは新着情報がなければレスポンスを保留して、新着情報が発生すれば即座にレスポンスを返す方法。

SSE(Server-Sent Events)

基本ロングポーリングと同じだが、情報が発生するたびにレスポンスは返さず、長さ不定のレスポンスをだらだら返し続ける(情報が発生するたびにレスポンスの続きが来る)方式。

HTTPではレスポンスボディの長さを不定にできるというルールがあるので、これにより下り情報が発生するたびにリクエスト・レスポンスが必要なロングポーリングのオーバーヘッドが解消される。

WebSocket

HTTPのオーバーヘッド無しに双方向通信するための規格。
HTTP(S)リクエストにUpgradeヘッダを付けることですぐにHTTPの枠外に飛び出し、一問一答というルールにとらわれず最短2バイトのヘッダを付けてやりとりする。


plantuml

plantuml


app/channels/room_channel.rb

class RoomChannel < ApplicationCable::Channel
  def subscribed
    stream_from "room_channel"
  end

  def unsubscribed
  end

  def speak(data)
    StickyNote.create! content: data['sticky_note']
  end

  def remove(data)
    StickyNote.destroy data['sticky_note']
  end
end

app/assets/javascripts/channels/room.coffee

app/assets/javascripts/channels/room.coffee
App.room = App.cable.subscriptions.create "RoomChannel",

  connected: () ->

  disconnected: () ->

  received: (data) ->
    if data['sticky_note'] != undefined
      sticky_note = $(data['sticky_note'])
      sticky_note.attr('id', 'note')
      $('#sticky_notes_index').append(sticky_note)
    else
      # !isNaNで動いてほしい。。
      if isNaN(data['sticky_note'])
        $('.sticky_note_' + data['id']).remove()

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

  remove: (sticky_note) ->
    @perform 'remove', sticky_note: sticky_note

  # 送信クリック - 付箋作成
  $(document).on 'click', '[data-behavior~=room_speak]', (e) ->
    App.room.speak $('.text').val();
    $('.text').val('');
    e.preventDefault()

  # 削除クリック - 付箋削除
  $(document).on 'click', '.remove-icon', (e) ->
    if !confirm('本当に削除しますか?')
      return false;
    else
      id = $(e.target).data('sticky_note-id')
      App.room.remove id
      e.preventDefault()

  # 付箋の色変更
  $ ->
    $('.note').children('.color-button').click ->
      main_color = $(this).data('main-color')
      sub_color = $(this).data('sub-color')
      shadow_color = $(this).data('shadow-color')
      shadow = $(this).data('shadow')
      $(this).parents('.note').css 'background', "linear-gradient(to right, #{shadow} 0%, #{shadow_color} 0.5%, #{sub_color} 13%, #{main_color} 16%)"

  # 付箋ホールド - ドラッグ
  $ ->
    $('.note').draggable()

  # 付箋ダブルクリック - 編集
  # $ ->
  #  $('.note').dblclick ->
  #    $(this).wrapInner('<textarea class="text" name="text" cols="23" rows="8"></textarea>').find('textarea').focus().select().blur ->
  #      $(this).parent().html $(this).val()
app/jobs/sticky_note_broadcast_job.rb
class StickyNoteBroadcastJob < ApplicationJob
  queue_as :default

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

  private

    def render_sticky_note(sticky_note)
      ApplicationController.renderer.render partial: 'sticky_notes/sticky_note', locals: { sticky_note: sticky_note }
    end
end

詰まりポイント

特定の受信者に対してメッセージを送ることは出来ない

roomは並列化出来るので、例えばdeviseでユーザー機能を作って部屋ごとに異なるユーザーが入室していて、そのroomの入室者だけにbroadcastするみたいなことは可能。
roomに対してのメッセージは全体送信になる。

サーバからデータを受け取るJSの関数が一つしかない

サーバーからデータを受け取ってブラウザにリアルタイムで返す際にJS側で received を使うが 、 received は固定かつ一箇所しか使えなかったので、付箋データの作成と削除処理の共存に苦労した。

現状 receivedで受け取ったデータ型に合わせて if/elseで 作成するか削除するかの処理を分けている。
(無理やり感がある上にbroadcastしたい処理を追加していくとreceivedが肥大化しそう)

herokuのエラー

There was an exception - Gem::LoadError(Error loading the 'redis' Action Cable pubsub adapter. Missing a gem it depends on? redis is not part of the bundle. Add it to your Gemfile.)

普通にdeployするとredisのエラーを吐かれる。
サイト自体は表示されるがリアルタイム通信が出来ない。

対処法

production.rb

config.action_cable.allowed_request_origins = [ /http:\/\/.*/ ]

config/cable.yml

production:
  # adapter: redis
  # url: redis://localhost:6379/1
  # channel_prefix: ac_test_production
  adapter: async

上記で一旦直るがherokuは無料でredisを使えるので、redisを使うように設定した方が無難。
本番でのasync設定は非推奨。

フリープランもクレジットカード登録しなければいけなかったので今回はスキップ。

8.1.1.1 Asyncアダプタ
asyncアダプタはdevelopment環境やtest環境での利用を意図したものであり、>production環境で使うべきではありません。

https://railsguides.jp/action_cable_overview.html#利用できるアダプタ設定


今後やりたい

・付箋の位置を追従したい

・色情報を保存したい

・ダブルクリックで後からコンテンツの内容を編集できるようにしたい

・色変更の●が増えていくと集合体恐怖症が発症しそうなのでなんとかしたい

・株価のチャート、ルーム分けされたチャットなども作ってみたい

©️ 2020 ふじい Dev-Remo-Work.