Rails 6: ActionCableとVue.jsで非同期処理を行うサンプル

環境: Rails 6.0、Vue.js 2.6、ソース: https://github.com/kazubon/cable60

ActionCable、ActiveJob、およびVue.jsを使って、次のような非同期処理を行う画面を作ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308595/059a47fc-87bf-93ac-00c7-9126b58d1200.png)

## 必要なライブラリ

GemfileにRedisとSidekiqを追加して、bundle install してください。

```Gemfile
gem 'redis', '~> 4.0'
gem 'sidekiq'
```

Redisをインストールしていない場合は、インストールして起動しておきます。

```zsh
% brew install redis
% brew services start redis
```

## ActionCableの準備

cable.ymlで、development環境の設定を async から redis に変えます。サンプルでは、ActiveJobのジョブの中でActionCableのブロードキャストを行いますが、async だと効かないからです。

```config/cable.yml
development:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: cable60_development
```

コントローラでは、ActionCable用の接続の識別子となるランダム文字列をクッキーに入れます。このサンプルでは、「1ユーザー - 1識別子 - 1ストリーム」とします。1対多の送信は行いません。

```app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_cable_code

  private
  # Action Cable用ユーザー識別
  def set_cable_code
    cookies.signed[:cable_code] ||= SecureRandom.hex
  end
end
```

connection.rbではクッキーから識別子 cable_code を取り出します。

```app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :cable_code

    def connect
      if cookies.signed[:cable_code]
        self.cable_code = cookies.signed[:cable_code]
      end
    end
  end
end
```

`bin/rails g channel user` で user_channel.rb を作っておき、識別子 cable_code を stream_from にそのまま渡します。

```app/channels/user_channel.rb
class UserChannel < ApplicationCable::Channel
  def subscribed
    stream_from cable_code if cable_code
  end

  def unsubscribed
  end
end
```

## ActiveJobの準備

config/environments下のdevelopment.rbとproduction.rbを修正し、ActiveJobではSidekiqを使うことを指定します。

```config/environments/development.rb
  config.active_job.queue_adapter     = :sidekiq
```

コントローラでは、「開始」ボタンで呼び出す update アクションを書いておきます。SampleJobというジョブにクッキーの識別子を渡して非同期処理をさせます。

```app/controllers/samples_controller.rb
class SamplesController < ApplicationController
  def show
  end

  def update
    SampleJob.perform_later(cookies.signed[:cable_code])
    render json: {}
  end
end
```

`bin/rails g job sample` でSampleJobを作っておいて、performメソッドにサンプル用の処理を書きます。20回スリープしながら現在のパーセンテージを進めるだけのものです。

ActionCableのブロードキャストを使い、識別子 cable_code に対してハッシュ（JavaScriptのオブジェクト）を送信します： `{ type: 処理の種類に付けた名前, progress: パーセンテージ, processing: 処理中かどうか }` 。


```app/jobs/sample_job.rb
class SampleJob < ApplicationJob
  queue_as :default

  def perform(cable_code)
    20.times do |idx|
      sleep 0.2
      ActionCable.server.broadcast(cable_code,
        type: 'sample', progress: (idx + 1) * (100 / 20), processing: true)
    end
    ActionCable.server.broadcast(cable_code,
      type: 'sample', progress: 100, processing: false)
  end
end
```

## JavaScriptでデータを受け取り、進行状況を表示する

JavaScript側では、ActionCableからデータを受け取るためのオブジェクトを作ります。`Vue.observable` を使うことで、sampleプロパティを変更したらVueのテンプレートに反映するようにします。

ほかに非同期処理を扱う画面が増えたら、fooとかbarとかプロパティを増やすことを想定しています。

```app/javascript/channels/cable_data.js
import Vue from 'vue';

export default Vue.observable({
  sample: { },
  // foo: { },
  // bar: { },
})
```

UserChannelに対応するuser_channel.jsを修正します。receivedでデータを受け取ったら、ActionCable用のオブジェクトcableDataのプロパティにそのまま入れます。

オブジェクトのtypeプロパティの値がcableDataの各プロパティの名前に対応していることにします。

```app/javascript/channels/user_channel.js
import consumer from "./consumer"
import cableData from "./cable_data";

consumer.subscriptions.create("UserChannel", {
  connected() {
  },

  disconnected() {
  },

  received(data) {
    switch(data.type) {
      case 'sample':
        cableData.sample = data;
        break;
      // case 'foo':
      //   cableData.foo = data;
      //   break;
      // case 'bar':
      //   cableData.bar = data;
      //   break;
    }
  }
});
```

非同期処理の進行状況を表示するVueコンポーネントです。ActionCable用のオブジェクト cableData.sampleのprogressとprocessingの値を画面に反映させます。

なお、ここではBootstrapの[Progress](https://getbootstrap.com/docs/4.5/components/progress/)を使っています。

```app/javascript/sample.vue
<template>
  <div>
    <div class="form-group row">
      <div class="progress">
        <div class="progress-bar" role="progressbar" :aria-valuenow="progress"
          aria-valuemin="0" aria-valuemax="100" :style="`width: ${progress}%`"></div>
     </div>
    </div>
    <div class="form-group row">
      <button type="button" class="btn btn-primary" @click="startProcess"
        :disabled="processing">開始</button>
    </div>
  </div>
</template>

<script>
import Axios from 'axios'
import cableData from "./channels/cable_data"

export default {
  data() {
    return {
    };
  },
  computed: {
    progress() {
      return cableData.sample.progress || 0;
    },
    processing() {
      return cableData.sample.processing;
    }
  },
  methods: {
    startProcess() {
      cableData.sample = { progress: 0, processing: true };
      Axios.patch('/sample');
    }
  }
}
</script>

<style scoped>
.progress {
  width: 100%;
}
</style>
```

サンプルのVueコンポーネントをマウントするpacks下のJavaScriptです。`require("channels")` ががあることを確認しましょう。

```app/javascript/packs/application.js
import 'bootstrap';
import '../stylesheets/application';

require("@rails/ujs").start()
require("turbolinks").start()
// require("@rails/activestorage").start()
require("channels")

import Vue from 'vue';
import TurbolinksAdapter from 'vue-turbolinks';

import Sample from '../sample.vue';
import '../axios_config';

Vue.use(TurbolinksAdapter);

document.addEventListener('turbolinks:load', () => {
  if(document.getElementById('sample')) {
    new Vue(Sample).$mount('#sample');
  }
});
```

`bin/rails s` でサーバーを起動し、別のターミナルで `bundle exec sidekiq` を起動すれば、非同期処理の動作を確認できます。

## Vue.observable を使わない場合

上記のcable_data.jsで、Vue.observableを使わずにJavaScriptのオブジェクトをそのままエクスポートしても、ActionCableのデータを扱えます。

```app/javascript/channels/cable_data.js
export default {
  sample: { }
}
```

この場合は、Vueコンポーネントでdataを使ってオブジェクトを渡せば、sampleプロパティの変更が反映されます（リアクティブになります）。

```app/javascript/sample.vue
<script>
import Axios from 'axios'
import cableData from "./channels/cable_data"

export default {
  data() {
    return {
      cableData: cableData
    };
  },
  computed: {
    progress() {
      return this.cableData.sample.progress || 0;
    },
    processing() {
      return this.cableData.sample.processing;
    }
  },
  methods: {
    startProcess() {
      this.cableData.sample = { progress: 0, processing: true };
      Axios.patch('/sample');
    }
  }
}
</script>
```