Link Search Menu Expand Document

26. 非同步處理任務

26-1 前言和安裝 sidekiq

所謂的非同步處理任務,就是在用戶送出操作後,Rails 不會立即執行這個任務,而是交由另一個進程(process)在背景進行處理執行。

使用的場景是:當任務要執行很久的時候,例如匯出大筆數據、匯入大筆數據等,當數據量上萬筆的時候,可能會需要好幾分鐘才能執行完成。這時候如果不做非同步處理,用戶的瀏覽器就會像卡住一樣,需要等伺服器完成任務才有回應。

等待時間太久除了讓用戶感受不佳之外,對於伺服器效能上的影響也很巨大。用戶可能等不及又重新整理一次,於是相同的任務又在重頭執行一遍。而一個 HTTP Request 如果長時間執行,也會讓 Rails 伺服器無法服務其他用戶。

因此,這類型的任務我們會改成非同步處理,第一時間用戶會先看到「操作正在執行中,請稍候再回來」或是「完成後會 E-mail 通知您」的訊息。

常用的非同步處理有兩套 gem:

  • delayed_job: 使用關聯式資料庫,容易安裝使用
  • Sidekiq: 使用高效能的 Redis 資料庫,執行效率極好,業界較常用

這兩套 gem 的作用都是讓 Rails 可以存下要執行的非同步處理任務,然後啟動進程(process)在背景進行處理執行。

這一章將用 Sidekiq 舉例,本機 Mac 需要安裝 Redis 資料庫:

執行 brew install redis

另開一個 Termonal 視窗,執行 redis-server /usr/local/etc/redis.conf 就會跑起來 Redis 伺服器

image

編輯 Gemfile


+  gem 'sidekiq'

執行 bundle

編輯 config/environments/development.rbconfig/environments/production.rb 告訴 Rails 要用 sidekiq 來做非同步處理

+ config.active_job.queue_adapter = :sidekiq

新增 config/sidekiq.yml

---
:queues:
  - default
  - mailers

重啟伺服器

26-2 非同步匯入

就拿上一章的數據匯入來改吧,本來的匯入有 1000 筆其實就要跑一陣子了,這是個標準情況需要改成非同步處理。

執行 rails g job import_worker 這會產生一種非同步處理任務,叫做 ImportWorkerJob

  class ImportWorkerJob < ApplicationJob
    queue_as :default

-   def perform(*args)
-     # Do something later
-   end

+   def perform(import_id)
+     import = RegistrationImport.find(import_id)
+     import.process!
+   end

  end

這個非同步處理要怎麽調用呢?接著修改 app/controllers/admin/registration_imports_controller.rb,改成非同步處理:

  def create
    @import = @event.registration_imports.new(registration_import_params)
    @import.status = "pending"
    @import.user = current_user

    if @import.save
-     @import.process!
+     ImportWorkerJob.perform_later(@import.id)

-     flash[:notice] = "匯入完成"
+     flash[:notice] = "匯入已在背景執行,請稍候再來看結果"
    end

    redirect_to admin_event_registration_imports_path(@event)
  end

本來是直接調用 @import.process!,現在改成調用 ImportWorkerJob.perform_later(@import.id) 就會變成非同步。

你可以回到 25-3 實作的數據匯入,上傳一個檔案看看,你會發現伺服器立即就回傳了「匯入已在背景執行,請稍候再來看結果」訊息。之所以這麽快,是因為它沒有執行匯入,它只是跟 Sidekiq 說有一個任務要執行而已。

image

那到底誰去執行這個非同步任務呢? 我們需要另外啟動 Sidekiq 進程,請再開一個新個 Terminal 視窗,執行

bundle exec sidekiq

image

只要有非同步任務進來,這個 Sidekiq 會去去執行它。

如果進 rails console 想要單純測試這個任務,可以不需要非同步,改用 .perform_now 方法就會馬上執行

26-3 Sidekiq 管理 UI

Sidekiq 有提供一個漂亮的管理 UI,可以欣賞有多少待執行的非同步任務、有多少執行成功和失敗(失敗 sidekiq 會重試),也可以刪除任務。

image

修改 config/routes.rb

  Rails.application.routes.draw do

+   require 'sidekiq/web'
+   authenticate :user, lambda { |u| u.is_admin? } do
+     mount Sidekiq::Web => '/sidekiq'
+   end

其中 authenticate 方法會檢查權限,利用我們之前寫的 User#is_admin? 方法

瀏覽器瀏覽 http://localhost:3000/sidekiq

26-4 非同步匯出

數據匯出也是一個常用非同步來處理的任務,流程和匯入其實 87 分像:

  • 建立 RegistraionExport model,這個 model 會紀錄是那個 user 做匯出、是哪個 event 要匯出,以及存儲最後匯出的檔案
  • 建立一個 RegistrationExports controller,這個 controller 讓用戶可以新增匯出紀錄,以及瀏覽匯出紀錄
  • 建立 ExportWorkerJob,這個非同步任務會執行匯出操作,並將匯出的檔案放到 RegistraionExport model 上
  • 非同步任務最後完成時,可以寄 E-mail 通知用戶匯出的檔案已經準備好了

這裡就不示範實做了。

最後,匯出和匯入的功能要完整實做的話,還需要考慮檔案存儲的位置。我們用 carrierwave 上傳的檔案,預設是公開的。但是匯出和匯入的檔案,應該也必須要檢查有沒有權限才行。這部分的實作牽扯到我們使用哪種檔案伺服器:

在之後的進階部署課程中,會再進一步示範如何使用。

26-5 報名 15 分鐘內沒完成自動取消

非同步處理可以設定延遲時間,例如我們來實作一個小功能:如果報名 15 分鐘內沒有完成,系統會自動取消

執行 rails g job check_registration

編輯 app/jobs/check_registration_job.rb

  class CheckRegistrationJob < ApplicationJob
    queue_as :default

-   def perform(*args)
+   def perform(registration_id)
+     registration = Registration.find(registration_id)
+
+     unless registration.status == "confirmed"
+       registration.status = "cancalled"
+       registration.save!
+     end
+
    end
  end

編輯 app/models/registration.rb


-  STATUS = ["pending", "confirmed"]
+  STATUS = ["pending", "confirmed", "cancalled"]

編輯 config/locales/zh-CN.yml

   registration:
     status:
       pending: 報名尚未完成
       confirmed: 報名成功
+      cancalled: 報名已取消

編輯 app/controllers/registrations_controller.rb,在新建報名之後,增加這個非同步任務,並用 set 方法指定延遲時間。另外,重構了 before_action :set_pending_registration 檢查如果是 cancalled 狀態就不能繼續填寫報名資料。

  class RegistrationsController < ApplicationController

    before_action :find_event
+   before_action :set_pending_registration, :only => [:step1, :step1_update, :step2, :step2_update, :step3, :step3_update]

    def create
      @registration = @event.registrations.new(registration_params)
      @registration.ticket = @event.tickets.find( params[:registration][:ticket_id] )
      @registration.status = "pending"
      @registration.user = current_user
      @registration.current_step = 1

      if @registration.save
+        CheckRegistrationJob.set( wait: 15.minutes ).perform_later(@registration.id)

    # 略...

    def step1
-     @registration = @event.registrations.find_by_uuid(params[:id])
    end

    def step1_update
-     @registration = @event.registrations.find_by_uuid(params[:id])

      # 略

    def step2
-     @registration = @event.registrations.find_by_uuid(params[:id])
    end

    def step2_update
-     @registration = @event.registrations.find_by_uuid(params[:id])

      # 略

    def step3
-     @registration = @event.registrations.find_by_uuid(params[:id])
    end

    def step3_update
-     @registration = @event.registrations.find_by_uuid(params[:id])

    # 略

    protected

+   def set_pending_registration
+     @registration = @event.registrations.find_by_uuid(params[:id])
+
+     if @registration.status == "cancalled"
+       flash[:alert] = "請重新報名"
+       redirect_to event_path(@event)
+     end
+   end

    # 略

可以暫時改成用 1.minute 進行測試

這樣就完成了。

26-6 非同步 E-mail 寄信

Rails 的 E-mail 寄信功能,其實也用了非同步處理任務,因為寄信會調用第三方的寄信 SMTP 伺服器,執行速度上比較慢,因此也適合用非同步處理來做。

回想在 24-2 寄送報名完成的 E-mail 是如何寄送的:

NotificationMailer.confirmed_registration(@registration).deliver_later

這裡的 deliver_later 方法,就會用這一章設定的非同步處理機制去寄信。

如果不要用非同步處理,例如我們在 rails console 測試的時候:

NotificationMailer.confirmed_registration( Registration.by_status("confirmed").last ).deliver_now

這裡的 deliver_now 方法,就會立即寄出。


Copyright © 2010-2021 Wen-Tien Chang All Rights Reserved.