Railsで非同期処理を実行し、ブラウザで実行結果を取得する

  • 14
    いいね
  • 0
    コメント

Livesense Advent Calendar 2016 21日目の記事です。

何を実現したいか

Webアプリケーションの開発では以下のような要件が求められることがあります。

  • CSVファイルをアップロードして、一括データインポートを行う
  • リアルタイムに複雑な計算をして値を算出し、表示する

このような処理時間が長くキャッシュも効かないような処理では、以下の問題が発生します。

  • レスポンスタイムが長くなりすぎて、Webサーバがタイムアウトしてしまう
  • Unicornなどprefork型のWebサーバを利用している場合、ワーカーの一つを長時間専有してしまう
  • レンダリングに時間がかかるので利用者が心配する or F5を連打される

このような問題を解決するために、時間のかかる処理はキューに積んで、非同期で実行する方法がよくとられます。
Railsには4.2からActiveJobという機能が導入されていて、処理をキューに積むための共通インターフェースが用意されています。
http://railsguides.jp/active_job_basics.html

キューイングバックエンドには、Sidekiq/Resque/DelayedJobなどが使えます。

処理をキューに積むだけで良ければここで終了なのですが、以下のような要件が追加されることがあります。

  • ブラウザには「実行中…」のような表示をさせておき、バックグラウンド処理が終了したらその旨を通知する
  • 計算が終わったら別ページへリダイレクトさせて計算結果を表示する

こうなってくると、バックグラウンド処理の状況をブラウザ側で知る必要があります。
今回解決したい問題はこれです。

環境・前提条件

  • Rails 4.x 系

DelayedJobを使った方法

https://github.com/collectiveidea/delayed_job
DelayedJobでは、ジョブをRDBMSに積みます。

[5] pry(main)> job = User.first.delay.delete
  User Load (22.8ms)  SELECT  `users`.* FROM `users`  ORDER BY `users`.`id` ASC LIMIT 1

[6] pry(main)> job
+----+----------+----------+---------------------------------------------------------------------+------------+------------------+-----------+-----------+-----------+-------+------------------+------------------+
| id | priority | attempts | handler                                                             | last_error | run_at           | locked_at | failed_at | locked_by | queue | created_at       | updated_at       |
+----+----------+----------+---------------------------------------------------------------------+------------+------------------+-----------+-----------+-----------+-------+------------------+------------------+
| 4  | 0        | 0        | --- !ruby/object:Delayed::PerformableMethod\nobject: !ruby/objec... |            | 2016/12/19 17:48 |           |           |           |       | 2016/12/19 17:48 | 2016/12/19 17:48 |
+----+----------+----------+---------------------------------------------------------------------+------------+------------------+-----------+-----------+-----------+-------+------------------+------------------+

キューに積んだとき、返り値に積んだジョブのインスタンス(ActiveRecordオブジェクト)が返却されます。
ということは、積まれたDelayedJobのIDを取得することができます。1

取得したIDをgonなどを使ってDOM経由でJavaScriptに渡します。
https://github.com/gazay/gon

ここでは会員の退会処理を非同期で行う処理を例にして説明します。
(このアプリケーションでは会員の退会は非常に時間のかかる処理なのです)

class UsersController < ApplicationController
  def destroy
    job = User.first.delay.delete
    gon.job_id = job.id
  end
end

JavaScriptではここで受け取ったIDを元に、処理が終了するかどうか定期的にサーバに問い合わせます。
ここでは例としてロングポーリングを用いましたが、状況によってはServer Sent Eventなどを使っても構いません。

$(function () {
  var intervalId = setInterval(checkPullContact, 5000);
  function checkoutDeleteUser() {
    $.ajax({
        type: 'GET',
        url: '/users/check_destory',
        data: { id: gon.job_id },
        dataType: 'json',
        success: function (value) {
          if (value.result == 'success') {
            clearInterval(intervalId);
            // 成功時の処理
          }
        }
    });
  }
});

Railsには処理が終了したかどうかを返却するエンドポイントを用意しておきます。

class UsersController < ApplicationController
  def check_destory
    return render json: { result: 'waiting' } if DelayedJob::Job.exists? params[:job_id]
    render json: { result: 'success' }
  end
end

これで、処理が終了したことをブラウザに伝えることができます。

さらに…

実行する処理の目的が「なんらかのレコードを生成すること」だった場合、ブラウザはジョブによって生成されたレコードを特定する必要があります。

ブラウザはジョブのIDを知っています。
ジョブが生成するレコードにジョブのIDを保存しておくことができれば、ブラウザはどのレコードが生成されたのかを特定できます。
しかしDelayedJobのジョブは、自分がどのレコードでキューに積まれたかを知る方法を持っていないのです。
よって、ジョブIDでは生成されたレコードを特定することはできません。

そこで、ジョブをキューに積むときにUUIDを生成して、ジョブとブラウザ両方に渡しておきます。

class UsersController < ApplicationController
  def destroy
    job_uuid = SecureRandom.uuid
    job = User.first.delay.delayed_delete(job_uuid)
    gon.job_uuid = job_uuid
  end
end

そして生成したレコードのカラムにUUIDを保存しておけば、処理終了後にどのレコードが生成されたかを特定することができます。

class User < ActioveRecord::Base
  def delayed_delete(job_uuid)
    transaction do
      user_delete_log = UserDeleteLog.create(user_id: self.id, job_uuid: job_uuid)
      self.delete
    end
  end
end

まとめ

ジョブのステータスや実行結果をブラウザから取得したいというニーズはそこそこありそうですが、あまり情報がなかったのでまとめてみました。
もっとスマートなやり方があればぜひ教えてください。


  1. ActiveJobでは出来ません。ActiveJobでは、返り値はキューに積むのが成功したかどうかを表すboolean値が返却されます