非同步處理

最后更新于:2022-04-01 02:31:48

> Nine people can’t make a baby in a month.  — Fred Brooks, The Mythical Man-Month 作者 通常一個HTTP request/response的工作時間理想上都要在 200ms 以內完成,要不然 web server 通常也會限制在 30 秒以內,不然就會出現 timeout 錯誤。一個運算時間太久的 request 除了讓使用者感受不佳之外,對於伺服器效能上的影響也很巨大。使用者可能等待不及重新reload,於是相同的任務又在重頭執行一遍。一個 request 長時間佔據了一個 rails process,也讓其他 reuqest 無法進行處理。 常見的非同步任務包括: * 寄出E-mail * 匯入大筆資料 * 匯出大筆資料 * 呼叫第三方服務 對於這種任務,非同步的處理就非常重要。非同步的意思是讓任務的處理在背景完成,而不在瀏覽器的HTTP request/response流程中完成,等完成之後再通知使用者即可。 Rails 4.2之後內建了一個統一的處理介面叫做ActiveJob,就像ActiveRecord透過不同的Adapter可以支援不同資料庫,ActiveJob也支援了非常多種不同的排程工具,最多人使用的有: * [delayed_job](https://github.com/collectiveidea/delayed_job) 使用關聯式資料庫,非常方便安裝使用。 * [sidekiq](http://sidekiq.org/) 使用高效能的[Redis](http://redis.io/): key-value store來儲存要執行的任務,並且善用多執行序來增加效能,號稱可以以一個process抵上20個delayed_job的processes。 我們來用sidekiq舉例,本機Mac需要安裝Redis: ~~~ brew install redis ~~~ 而在Ubuntu伺服器上可以透過`sudo apt-get install redis-server`進行安裝。 在Gemfile新增`gem 'sidekiq'`然後bundle 預設的ActiveJob Adapter是:inline,也就是沒有非同步。我們必須編輯config/environments/production.rb切換成改用`:sidekiq`如下: ~~~ # be sure to have the adapter gem in your Gemfile and follow the adapter specific # installation and deployment instructions config.active_job.queue_adapter = :sidekiq ~~~ 接著編輯config/application.rb加入一行設定讓Rails可以找到job檔案: ~~~ config.eager_load_paths += %W( #{config.root}/app/jobs ) ~~~ 接下來要建立一個Worker非常容易,執行`rails g job hard_worker`會產生app/jobs/hard_worker_job.rb這個檔案, ~~~ # app/jobs/hard_worker_job.rb class HardWorkerJob < ActiveJob::Base queue_as :default def perform(*args) # Do something later end end ~~~ 接著在需要非同步的地方使用以下程式,就會將工作排程進sidekiq: ~~~ HardWorkerJob.perform_later ~~~ 最後,我們需要啟動另外的sidekiq process來執行這些非同步的任務: ~~~ bundle exec sidekiq ~~~ sidekiq提供了一個Web UI介面讓我們可以觀察目前有哪些任務在執行。如果搭配Devise的話,需要在`Gemfile`加上`gem 'sinatra', '>= 1.3.0', :require => nil`,以及`routes.rb`加入: ~~~ require 'sidekiq/web' authenticate :user, lambda { |u| u.admin? } do mount Sidekiq::Web => '/sidekiq' end ~~~ ## Action Mailer 我們在「ActionMailer: E-mail發送」那一章介紹過`deliver_later`方法,如果我們有設定好ActiveJob,那Rails就會用非同步寄信。 ## GlobalID 因為非同步的工作是另一個process在執行,在從Rails這端指派工作的時候,設計的參數會避免將物件進行序列化(serialize)動作,以免另一個process無法順利deserialize回來,例如這中間剛好程式碼有變更,造成類別的定義不同,更別提從enqueue到真正執行之間會有時間差,資料內容可能改變了。因此參數最好是簡單的基本型態,例如字串、數字、陣列或雜湊等等。例如你想要傳遞一個使用者物件當作參數,我們不傳整個user物件,而是傳user id而已: ~~~ HardWorkerJob.perform_later(user.id) ~~~ 接著在worker那端設計成根據user id從資料庫再拉出來: ~~~ def perform(user_id) user = User.find(user_id) end ~~~ 事實上,由於這是非常常見的設計,Rails甚至自動會針對ActiveRecord物件進行轉換,例如你寫成 ~~~ HardWorkerJob.perform_later(user) ~~~ 那在Rails內部會自動幫你把user物件轉成一個GlobalID字串放進queue裡,讓以下的job可以直接運作: ~~~ def perform(user) # user 就是 activerecord 物件了,Rails 自動幫你 query 資料庫轉換回來 end ~~~ 不過如果你面對的不是ActiveRecord物件,就要自行注意了。 ## 固定排程 另一種執行非同步任務的方式,則是透過作業系統的[Cron](http://en.wikipedia.org/wiki/Cron)排程工具,你可以將需要執行的工作寫成一個rake指令,在主機上用crontab指令進行編輯。例如每天凌晨四點進行備份、每週一凌晨一點產生報表等等。 由於crontab的格式不是非常友善,我們可以透過[whenever](https://github.com/javan/whenever)這個Gem來編輯,這也可以搭配Capistrano做自動化部署,非常方便。 ## 參考資料 * [Active Job Basics](http://edgeguides.rubyonrails.org/active_job_basics.html) * [ActiveJob::QueueAdapters](http://api.rubyonrails.org/classes/ActiveJob/QueueAdapters.html) * [Distributed Ruby and Rails](http://www.slideshare.net/ihower/distributed-ruby-and-rails)
';