koheitakahashiのブログ

2020.07.01にプログラマーとして生を受けた私が学んだことや、日常について徒然に書いていきます。

ActionCableとVue.jsを使ってリアルタイム分報機能を実装しました(リリースはまだです)

はじめに

私が2020年6月まで参加させていただいたフィヨルドブートキャンプでは、スクラム開発を学ぶプラクティスとして、実際にスクラム開発の手法に則ってアプリを開発するというプラクティスがあります。

フィヨルドブートキャンプ受講生が日頃学習に使っているeラーニングアプリを実際にスクラム開発の手法に則って開発していきます。

スクラム開発のプラクティスについて詳しく知りたい方は、以前書いた拙文がありますので、そちらをご覧ください。

docs.koheitakahashi.com

そのプラクティスの中で、私が最後に任されたissueが以下の分報機能の実装でした。

github.com

そして、以下が、私が作成したPRです。

github.com

PRの内容としては、ActionCableとVue.jsを使用して、上記のリアルタイムでCRUDができる分報機能を実装したというものになります。

大きな機能のため、私は最小限の機能の実装し、大枠を作ってその他必要な機能の追加は他の受講生の方がやってくださっているという状態です(本当は私が必要な機能を全て実装できれば良かったのですが…)。

正直なところ、このissueに着手し始めた当初は、「Vue.jsナニモワカラン」「ActionCableナニソレワカラン」という感じで四苦八苦していました。

日本語・英語で検索しても、ActionCableの具体的な実践例が載っている記事をあまり見ませんでした。

そのため、この記事では私が書いたAction CableとVue.jsのコードを解説します。
設計が悪い・書き方が悪いというところは多々あると思いますが、あくまで一例として捉えていただき、ActionCableの実践例として少しでも参考になれば幸いです。

また、本記事で以下に解説するコードの例は私がPRを出してレビューが通った時点のコードになります。
そのため、上記ブランチの最新版とはコードが異なることをご了承ください。

この記事について

対象としている読者

この記事は以下のような方々を対象として書きました。

  • ActionCableの概要は掴めたけど、具体的に、どのように書いていけば良いのか分からない。
  • ActionCableを使用したコードの例を知りたい。

本記事が上記のような方々にとって、少しでも参考になれば幸いです。

実装した機能について

前提

この分報機能はFJORD BOOT CAMPのeラーニングアプリの中で使われる機能の一部として実装しました。 このeラーニングアプリは以下のような機能を備えております。

  • アプリ内にメンターの方々が作成したプラクティス(カリキュラム)があり、それを受講生が見て、提出物を提出できる。
  • 受講生が毎日の学習の記録を綴った日報を作成できる。
  • 提出物や日報に対してメンターの方や受講生の方がコメントをつけられる…など。

そのため、今回の分報機能は受講生がリアルタイムに学習の様子を呟いたりできるという、ブートキャンプ内のTwitterのような用途で使われることを想定して実装しました。

概要

ユーザーが分報の投稿・更新・編集・削除をリアルタイムでできる機能を実装しました。

別々のユーザーがログインしている時、あるユーザーが分報を投稿・編集・削除すると、リアルタイム(ページのリロードせず)にそれが反映されているのが確認できます。 Image from Gyazo

フレームワークなどのバージョン

  • Ruby 2.6.5
  • Ruby on Rails 6.0.3.1
  • Node.js 12.2.0
  • Vue.js 2.6.10

書いたコードの解説

全体的な処理の流れ

ユーザーが分報ページを訪れて、過去の分報一覧が表示されるまでの流れは以下のようになります。

  1. ユーザーが分報ページを開いた時に、Vueコンポーネントがcreateされます。
  2. Vueコンポーネントがcreateされたタイミングで、websocketコネクションを確立しようとします。
  3. ユーザーがログインユーザーであれば、websocketコネクションを確立し、subscribeを開始します。
  4. subscribeされたら、Rails側からVue.js側に過去の分報一覧がbroadcastされます。
  5. Vue.js側はbroadcastされた分報一覧を受け取り、それを描画します。

上記のような流れにより、ユーザーは分報ページを訪れた時に過去の分報一覧が見れます。

Image from Gyazo

また、分報をCRUDするときの処理の流れは以下のようになります。

  1. Vueコンポーネントがユーザーの操作を受け取ります。
  2. Vueコンポーネントは、その操作に応じて、app/channels/timelines_channel.rbで定義されているCRUDメソッドを呼び出して実行します(CRUDメソッド実行後は、そのデータをbroadcastするという処理を行います)。
  3. broadcastされたデータをVue.js側が受け取って、後述するデータと一緒にbroadcastされるeventという文字列を見て、そのイベントに対応した処理を行います。

Image from Gyazo

コードを解説

上記で全体的な構成と処理の流れを説明しました。
これを踏まえて、各ファイルに記述されたコードを見ていきたいと思います。

Rails側

app/channels/application_cable/connection.rb

websocketコネクションを管理します。
今回は、以下のように記述しました。

# app/channels/application_cable/connection.rb

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    # このように書くことで、確立されるwebsocketコネクションの識別子がcurrent_userになります。
    # さらに、後述するchannelの中でcurrent_userオブジェクトを使えるようになります。
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

      # websocketコネクションの確立を求めてきているuser_idがログインしているユーザーIDと一致する場合は、コネクションを確立。そうでなければ、コネクションを拒否します。
      def find_verified_user
        if verified_user = User.find_by(id: session_data["user_id"])
          verified_user
        else
          reject_unauthorized_connection
        end
      end

      def session_data
        cookies.encrypted[Rails.application.config.session_options[:key]]
      end
  end
end

app/channels/timelines_channel.rb

ActionCableを使用したwebsocket通信における、controllerの役割を担うのがこのchannelになります。
そのため以下のようなことを記述しています。

  • クライアントがsubscribeしたときの処理。
  • 分報のCRUDと、CRUD後にデータをbroadcastする。

加えて、ここに定義されたメソッドは後述するVueコンポーネント側で呼び出すことができます。

# app/channels/timelines_channel.rb
class TimelinesChannel < ApplicationCable::Channel
  before_subscribe :set_host_for_disk_storage

  # ここでchannelがsubscribeされたときの処理を記述することができます。
  # [以下、書いたコードに込めた自分の意図]
  # ここでは、timelines_channleがsubsrcibeされた時に、過去の分報データをbroadcastするように処理を書いています。
  # broadcastメソッドではなく、transmitメソッドを使っているのは、subscribedの中でbroadcastメソッドを使用すると、処理の実行タイミングの関係で、subscriberが特定されない内にbroadcastすることになってしまい、エラーが発生しました。
  # そのため、subscriberが特定されていない状態でもbroadcastが実行できるtransmitメソッドを使用しています。
  # また、websocket通信はHPPTのようにステータスコードのように一般化されているものがないはずです。そのため、`event`という文字列を送ることで、broadcastに成功したとき、失敗したときがVue.js側で明確に分かるようになります。
  def subscribed
    stream_from "timelines_channel"
    if !subscription_rejected?
      transmit({ event: "subscribe", current_user: decorated(current_user).format_to_channel, timelines: formatted_timelines })
    else
      transmit({ event: "failed_to_subscribe" })
    end
  end

  def create_timeline(data)
    # [以下、書いたコードに込めた自分の意図]
    # 分報をcreateする処理と、createした分報をbroadcastする処理を記述しています。
    set_host_for_disk_storage
    @timeline = Timeline.new(permitted(data))
    @timeline.user_id = current_user.id
    if @timeline.save
      broadcast_to_timelines_channel("create_timeline", @timeline)
    else
      broadcast_to_timelines_channel("failed_to_create_timeline", nil)
    end
  end

  def update_timeline(data)
    set_host_for_disk_storage
    @timeline = Timeline.find_by(id: data["id"])
    if @timeline.update(permitted(data))
      broadcast_to_timelines_channel("update_timeline", @timeline)
    else
      broadcast_to_timelines_channel("failed_to_update_timeline", nil)
    end
  end

  def delete_timeline(data)
    set_host_for_disk_storage
    @timeline = Timeline.find_by(id: data["id"])
    if @timeline.destroy
      broadcast_to_timelines_channel("delete_timeline", @timeline)
    else
      broadcast_to_timelines_channel("failed_to_delete_timeline", nil)
    end
  end

  private

    def permitted(data)
      params = ActionController::Parameters.new(data)
      params.require(:timeline).permit(:description)
    end

    def broadcast_to_timelines_channel(event, timeline)
      ActionCable.server.broadcast "timelines_channel", { event: event, timeline: decorated(timeline).format_to_channel }
    end

    def decorated(obj)
      ActiveDecorator::Decorator.instance.decorate(obj)
    end

    def formatted_timelines
      Timeline.order(created_at: :asc).map { |timeline| decorated(timeline).format_to_channel }
    end

    # local・test環境でActiveStorage::Current.hostが定義されずユーザーアイコンのservice_urlを取得することができなかったため、以下のように定義しています。
    def set_host_for_disk_storage
      if %i(local test).include? Rails.application.config.active_storage.service
        ActiveStorage::Current.host = Rails.application.config.action_cable.url.gsub(/cable/, "")
      end
    end
end

Vue.js側

Vue.js側はchannelへのsubscribeや、channel内のデータを取得して処理するということを管理するための、timelines-channel.vueというコンポーネントを作成ました。
加えて、その子コンポーネントで、1つ1つの分報に対する処理を管理するtimeline.vueというコンポーネントを作成しました。

また、エントリーポイントとなるtimeline.jsを作成しました。


app/javascript/channels/timelines.js

このtimeline.jsでは以下のようなことを行っています。

  • js-timelinesとうい要素が存在しなければconsumer(subscribeする前のクライアントのこと)を定義します。
  • timelines-channel.vuejs-timelinesという要素にマウントします。
// app/javascript/channels/timelines.js
// JavaScriptがActionCableと協調できるようにするライブラリです。
import ActionCable from 'actioncable'
import Vue from 'vue'
import Timelines from './timelines-channel.vue'

document.addEventListener('DOMContentLoaded', () => {
  const timelines = document.getElementById('js-timelines')
  if (timelines) {
    const cable = ActionCable.createConsumer()
    Vue.prototype.$cable = cable

    new Vue({
      render: h => h(Timelines)
    }).$mount('#js-timelines')
  }
})

app/javascript/channels/timelines-channel.vue

timelines-channel.vueでは以下のようなことを行っています。

  • このcreatedのタイミングで、TimelinesChannelにsubscribeします。
  • TimelinesChannelにデータがbroadcastされたのを検知して、データと一緒にbroadcastされたeventという文字列を見て、それに応じた処理を行います。
  • app/channels/timelines_channel.rbで定義されたCRUDメソッドをイベントに応じて実行します。
// app/javascript/channels/timelines-channel.vue
<template lang="pug">
  .thread-timeline-container
    .thread-timeline-form.a-card
      .thread-timeline__author
        img.thread-timeline__author-icon.a-user-icon(:src="currentUser.avatar_url" :title="currentUser.icon_title")
      .thread-timeline-form__form.a-card
        .thread-timeline-form__markdown-parent.js-markdown-parent
          markdown-textarea(v-model="description" id="js-new-timeline" class="a-text-input js-warning-form thread-timeline-form__textarea js-markdown" name="new_timeline[description]")
        .thread-timeline-form__actions
          .thread-timeline-form__action
            button(v-on:click="createTimeline" :disabled="!validation || buttonDisabled")
              | 投稿する
    .thread-timelines
      timeline(v-for="(timeline, index) in timelines"
        :key="timeline.id"
        :timeline="timeline"
        :currentUser="currentUser"
        @update="updateTimeline"
        @delete="deleteTimeline")
</template>
<script>
  import Timeline from './timeline.vue'
  import MarkdownTextarea from '../markdown-textarea'

  export default {
    components: {
      'timeline': Timeline,
      'markdown-textarea': MarkdownTextarea
    },
    data: () => {
      return {
        currentUser: {},
        description: '',
        timelines: [],
        timelinesChannel: null,
        buttonDisabled: false
      }
    },
    created () {
      this.timelinesChannel = this.$cable.subscriptions.create("TimelinesChannel", {
        connected: () => {
          console.log('connected successfully');
        },

        // 以下がTimelinesChannelにbroadcastされたデータを受け取って処理をする部分になります。
        received: (data) => {
          switch (data.event) {
            case 'subscribe':
              this.currentUser = data.current_user
              this.timelines = []
              data.timelines.forEach((timeline) => {
                this.timelines.unshift(timeline)
              });
              break
            case 'failed_to_subscribe':
              console.warn('Failed to subscribe');
              break
            case 'create_timeline':
              this.timelines.unshift(data.timeline);
              this.description = '';
              break
            case 'failed_to_create_timeline':
              console.warn('Failed to create timeline');
              break
            case 'update_timeline':
              this.timelines.forEach((timeline) => {
                if (timeline.id === data.timeline.id) {
                  timeline.description = data.timeline.description
                }
              });
              break
            case 'failed_to_update_timeline':
              console.warn('Failed to update timeline');
              break
            case 'delete_timeline':
              this.timelines.forEach((timeline, i) => {
                if (timeline.id === data.timeline.id) {
                  this.timelines.splice(i, 1)
                }
              });
              break
            case 'failed_to_delete_timeline':
              console.warn('Failed to delete timeline');
              break
          }
        }
      })
    },
    methods: {
      createTimeline: function () {
        if (this.description.length < 1) { return null }
        this.buttonDisabled = true;
        let params = {
          'timeline': { 'description': this.description },
        }
        this.timelinesChannel.perform('create_timeline', params);
        this.buttonDisabled = false;
      },
      updateTimeline: function (data) {
        this.timelinesChannel.perform('update_timeline', data);
      },
      deleteTimeline: function (data) {
        this.timelinesChannel.perform('delete_timeline', data);
      }
    },
    computed: {
      validation: function () {
        return this.description.length > 0
      }
    }
  }
</script>

app/javascript/channels/timeline.vue

最後にtimelines-channel.vueの子コンポーネントである、timeline.vueについて説明します。
ここでは以下のようなことを行っています。

  • 親コンポーネントからtimelineのデータを受け取って、それを描画します。
  • timelineの編集・削除します。
// app/javascript/channels/timeline.vue
<template lang="pug">
  .thread-timeline.a-card
    .thread-timeline__author
      a.thread-timeline__author-link(:href="timeline.user.path" itempro="url")
        img.thread-timeline__author-icon.a-user-icon(:src="timeline.user.avatar_url" :title="timeline.user.icon_title"  v-bind:class="userRole")
    .thread-timeline__body.a-card(v-if="!editing")
      header.thread-timeline__body-header
        a.thread-timeline__title-link(:href="timeline.user.path" itempro="url")
          | {{ timeline.user.login_name }}
        time.thread-timeline__created-at(:datetime="createdAt" pubdate="pubdate")
          | {{ createdAt }}
        .card-header-actions(v-if="this.timeline.user.id === this.currentUser.id")
          ul.card-header-actions__items
            li.card-header-actions__item
              button.card-header-actions__action.a-button(@click="editing = !editing")
                i.fas.fa-pen
                  | 編集
            li.card-header-actions__item
              button.card-header-actions__action.a-button(@click="deleteTimeline")
                i.fas.fa-trash-alt
                  | 削除
      .thread-timeline__description.js-target-blank.is-long-text(v-if="!editing" v-html="markdownDescription")
    .thread-timeline-form__form.a-card(v-show="editing")
      markdown-textarea(v-model="description" :class="classTimelineId" class="a-text-input js-warning-form thread-timeline-form__textarea js-timeline-markdown" name="timeline[description]")
      button(v-on:click="updateTimeline" v-bind:disabled="!validation")
        | 保存する
      button(v-on:click="cancel")
        | キャンセル
</template>
<script>
  import MarkdownTextarea from '../markdown-textarea'
  import MarkdownIt from 'markdown-it'
  import MarkdownItEmoji from 'markdown-it-emoji'
  import moment from 'moment'

  export default {
    props: ['timeline', 'currentUser'],
    components: {
      'markdown-textarea': MarkdownTextarea
    },
    data: () => {
      return {
        description: '',
        editing: false,
      }
    },
    created: function() {
      this.description = this.timeline.description;
    },
    mounted: function() {
      $('textarea').textareaAutoSize();
    },
    methods: {
      editTimeline: function() {
        this.editing = true;
      },
      cancel: function() {
        this.description = this.timeline.description;
        this.editing = false;
      },
      updateTimeline: function () {
        if (this.description.length < 1) { return null}
        let params = {
          'id' : this.timeline.id,
          'timeline' : { 'description': this.description }
        }
        this.$emit('update', params);
        this.editing = false;
      },
      deleteTimeline: function () {
        let params = {
          'id' : this.timeline.id
        }
        if (window.confirm('削除してよろしいですか?')) {
          this.$emit('delete', params);
        }
      }
    },
    computed: {
      markdownDescription: function() {
        const md = new MarkdownIt({
          html: true,
          breaks: true,
          linkify: true,
          langPrefix: 'language-'
        });
        md.use(MarkdownItEmoji)
        return md.render(this.timeline.description);
      },
      classTimelineId: function() {
        return `timeline-id-${this.timeline.id}`
      },
      userRole: function(){
        return `is-${this.timeline.user.role}`
      },
      createdAt: function() {
        return moment(this.timeline.updated_at).format('YYYY年MM月DD日(dd) HH:mm')
      },
      validation: function () {
        return this.description.length > 0
      }
    }
  }
</script>

まとめ

上記に各ファイルの解説をさせていただきました。

改めてまとめるならば、この分報機能のポイントは以下だと思います。

  • ActionCableを使用したwebsocket通信におけるcontrollerの役割はchannelが担うものと考えて、app/channels/timelines_channel.rbにCRUDメソッドの定義をしています。
  • データをbroadcastする際にeventという文字列を渡して、Vue.js側でデータのbroadcastが成功したのか失敗したのか、CRUDのいずれかなどを明示することで処理を分けて記述しやすくしました。
  • ActionCableの特徴として、channelに定義されたメソッドをフロントエンド側で呼ぶことができます。

テストなどの解説は割愛させていただきましたが、そちらについてはコードの方を参照していただければと思います。

最後に

私自身、設計や書き方がこれで良いのかという思いは拭きれないため、これはあくまでActionCableとVue.jsを用いた機能実装の一例と捉えていただければと思います。

また、issueを担当することになった当初は、「Vue.jsの書き方が分からない…」「そもそもActionCableとは…」と分かないことだらけという状態でした。

しかし、1つ1つ頭を悩ませながら理解して、そしてできる方々に教えていただきながら少しずつ実装を進めていくことで、なんとかPRを出すところまで進めることができました。
教えてくださった方々ありがとうございました。

私の実装では必要な機能を全て備えることができなかったため、まだリリースには至っていませんが、フィヨルドブートキャンプで後からこの分報機能の機能拡張を担当する方の参考にもなればという意味も込めて本記事を書きました。

私は上記のようにAction Cableを理解して実装しましたが、理解や記述が誤っているところなどがあれば教えてくださると大変有り難いです。

参考にさせていただいた資料