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 伺服器
編輯 Gemfile
+ gem 'sidekiq'
執行 bundle
編輯 config/environments/development.rb
和 config/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 說有一個任務要執行而已。
那到底誰去執行這個非同步任務呢? 我們需要另外啟動 Sidekiq 進程,請再開一個新個 Terminal 視窗,執行
bundle exec sidekiq
只要有非同步任務進來,這個 Sidekiq 會去去執行它。
如果進 rails console 想要單純測試這個任務,可以不需要非同步,改用
.perform_now
方法就會馬上執行
26-3 Sidekiq 管理 UI
Sidekiq 有提供一個漂亮的管理 UI,可以欣賞有多少待執行的非同步任務、有多少執行成功和失敗(失敗 sidekiq 會重試),也可以刪除任務。
修改 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 上傳的檔案,預設是公開的。但是匯出和匯入的檔案,應該也必須要檢查有沒有權限才行。這部分的實作牽扯到我們使用哪種檔案伺服器:
- 存儲在本機的話,請參考 How To: Secure Upload
- 存儲在七牛雲,請參考 下載憑證
- 存儲在 AWS S3,請參考 Serving Private Content
在之後的進階部署課程中,會再進一步示範如何使用。
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
方法,就會立即寄出。