封裝定時任務框架的正確方式

凌霄光發表於2019-02-28

程式碼的執行路徑和入口

程式碼並不是從開始一直執行到結束,經歷順序、分支、迴圈的控制結構,經歷函式、類和物件等各種封裝就可以了,工具指令碼是這樣的,只執行一次,傳入引數或者修改配置,然後就會執行或計算出你想要的結果,客戶端和服務端的程式碼都是長期執行的,有阻塞、併發等概念,涉及到多執行緒甚至多程式。服務端的程式碼邏輯可能是在接收到請求之後再執行,或者某個時間自動執行,客戶端也是一樣,有的程式碼是使用者操作觸發了什麼事件才會執行,而有的是定時的自動執行的。

定時任務的週期與複雜度

定時執行的任務根據有沒有周期概念和週期的長短複雜度也不同,有的任務是週期性的以天為單位的定時執行,比如每天的資料和日誌備份,有的任務只執行一次或者只是很短的週期,比如我們雲課堂中紀律樹的成長,下課的提醒。很多後端應用的框架都會提供定時任務的功能,比如eggjs的schedule,比如java生態的quartz。這些是週期比較長的定時任務,因為伺服器是不會停止的。客戶端用到的定時任務一般週期都較短,框架或者執行平臺提供的也都比較簡單,比如瀏覽器自帶的setInterval、setTimeout,egret的Timer等。

我們封裝的定時任務框架TimeReminder

我們產品所面向的場景是教室中的一次上課過程,其中有一些需要定時任務的地方,比如下課的提醒,比如紀律樹的自動生長。這些任務可以單獨去定時,但是這樣太過散亂,不易於管理和維護,所以我們封裝了一個定時任務佇列的庫,叫做TimeRemider,功能是新增一個時間和該時間要執行的任務,到時間後會自動執行,這和直接用setInterval或者setTimout的區別有兩個:

  1. 把所有定時任務集中到了一起來管理
  2. 定時器部分通過子程式的方式來提升效能

這部分功能比較獨立,因此我們把他抽取成了一個單獨的專案,以一個node module的形式來被我們業務的專案所使用,通過版本號的更新來迭代升級。

TimeReminder中存在的問題

最近我在讀這部分的程式碼,對定時功能實現的方式、程式碼職責和結構的劃分、暴露出去的api還有配置方面都有一些自己的看法。

定時功能的實現方式

time-reminder的定時器是用setInterval來實現的,通過定時輪詢,每次輪詢取出任務佇列中最近的一個任務,判斷是否需要執行,如果需要執行,則通知主程式執行這個任務,如果任務過期則刪掉該任務。

封裝定時任務框架的正確方式

因為輪詢是有一定間隔的,所以這裡需要判斷當前時間是否在這個輪詢週期的時間段內。另外,這裡的offsetTime是和伺服器時間的差值,專案中請求都會在接收到響應之後根據伺服器的時間來更新這個offset,我覺得這裡只要在其中一個介面校準一次就好了,因為伺服器的時間也不會手動調整。

封裝定時任務框架的正確方式

這裡的實現方式是基於setInterval,所以才會有定時的輪詢和時間段的計算。其實這裡用setTimeout也可以,唯一有問題的是offset更新的問題,setInterval在下一個輪詢的計算時就能感知到更新,而se'tTimeout感知不到,需要在offset更新後,手動的去clear掉所有的timer,然後基於新offfset算出的時間重新setTimeout。兩種方式各有優缺點。

程式碼的職責和結構的劃分

定時任務涉及到定時器和任務佇列兩個方面,

封裝定時任務框架的正確方式

現在的版本中定時器是基於setInterval來實現的,考慮到效能,把他放到了子程式中去(我們是基於electron做的客戶端,可以使用node),而主程式負責任務佇列的維護和任務的執行。現在的目錄劃分很簡單:

封裝定時任務框架的正確方式

index.js是主程式的程式碼,主要是任務佇列的維護和定時器的啟動、停止等。core/adjust-timer.js是子程式的程式碼,裡面是定時器的輪詢和在檢測到有任務要執行時通知主程式執行的邏輯。

程式碼職責方面我覺得是有問題的,如上面的架構圖,我覺得定時器應該是純粹的,只有基於setInterval的根據設定的時間不斷輪詢,或者基於setTimeout的定時通知的邏輯,而不應該包含判斷任務佇列是否有要執行任務的邏輯。而現在這部分邏輯是直接寫在定時器裡的。這樣不但使得不能透明的從setInterval替換成setTimeout,也使得將來如果要適應更多平臺(不支援node的程式)的成本增加。

合理的架構應該是多層次多模組的,層與層之間單項依賴,模組與模組之間職責明確,基於抽象的約定來通訊,這樣才能做到可以靈活的替換實現方案,比如把setInterval替換成setTimeout,比如把程式的方式去掉。

封裝定時任務框架的正確方式

如圖,至少應該有2個層次,定時器和任務佇列是底層實現功能的部分,主程式和子程式的程式碼是node環境下的適配方式,然後再提供一個index.js暴露全域性api。

封裝定時任務框架的正確方式

封裝定時任務框架的正確方式

這樣的架構和對應的目錄結構是易於擴充套件和替換實現方案的,vue在3.0中把observer獨立成頂層資料夾,就是為了替換成proxy更方便,這裡也是一樣。

暴露出去的api

實現功能之後要暴露出一些api去,供外部使用,暴露出去的api對應著定時器和任務佇列,也有兩部分,一部分是新增、刪除、清空定時任務的,一部分是啟動、停止定時器以及修改計時offset的。

現在暴露出去的api如下:

addTimeListener
removeTimeListener
hasOwnId

clear
start
stop
getCurrentServiceTime
updateOffset
複製程式碼

8個api前3個是定時任務的,後5個是定時器相關的,但是從名字上不能明確的區分出各自的功能,我覺得如下的命名會更好一些;

addTimedTask
removeTimedTask
clearTimedTask

startTimer
stopTimer
setTimeOffset
複製程式碼

getCurrentServiceTime是獲取當前伺服器時間的,雖然在請求響應的時候設定到了這裡,但是這並不是定時任務的功能,不應該放到這裡面,可以在響應的時候再儲存一份到別的地方。

配置

定時任務中有很多可以配置的地方,比如擴充套件成多平臺之後的平臺選擇,比如定時器setInterval和setTimeout兩種實現的選擇,比如是否列印日誌等。

可以像eslint、babel等提供一個配置檔案放在專案下,支援json等配置方式,可以叫timerTaskQueue.config.js。

module.exports = {
   log: false,//是否列印日誌
   platform: 'node',//使用定時任務的平臺
   timer: 'interval'//定時器的實現方式
}
複製程式碼

甚至可以提供外掛擴充套件的機制或者一系列內建的功能供使用者自己去選擇。

其他的問題

程式碼中還有很多命名和實現的具體問題:

比如分了handlerList和taskList兩部分,本意是handler可以複用,但是卻沒有提供複用handler的合理機制,像提供handler的name序號產生器制等。

比如taskList中的task如果一個time有多個任務,會組織成如下的結構,我覺得這個也是沒有必要的,扁平化的放多份就可以,這樣組織還有維護成本。

{
   time: 2323232
    ids: [id1,id2]
}
複製程式碼

總結

請求、定時任務、事件都是程式碼觸發的方式,或者說執行的入口,定時任務根據週期的長短複雜度也不同,後端或者客戶端的框架都提供了定時任務的功能(eggjs、quartz、egret、web等)。

我們的專案為了集中管理定時任務,封裝了一個定時任務框架叫time-reminder,提供定時器和任務佇列兩方面的功能。因為是node平臺,考慮到效能使用了子程式的方式,並且定時器的實現是setInterval。我提出了一些重構的思路,包括程式碼架構和目錄結構的調整、支援配置、改進暴露出去的api,以及一些程式碼的細節問題。

真正做一個通用的東西,和做只能適應一種業務場景的東西是完全不一樣的,我們既然把他抽取了出來,就要使得它更加的通用,完善的差不多之後可以考慮開源,到時候一定要支援多平臺、支援配置、暴露的頂層api更加優化,甚至提供外掛功能。同時書寫文件、demo和測試用例。會繼續完善下去。

相關文章