spring cron表示式原始碼分析

2022-10-01 18:00:45

spring cron表示式原始碼分析

在springboot中,我們一般是通過如下的做法新增一個定時任務

上面的new CronTrigger("0 * * * * *")中的引數0 * * * * *就是cron表示式了。

這裡主要是對cron表示式的原始碼進行分析,其他內容不再展開了。

這能看到會建立一個CronTrigger物件,這個物件它主要就是用來包裝解析後的cron表示式,獲取任務下次執行的時間。

CronTrigger構造方法中會呼叫到this.expression = CronExpression.parse(expression);將我們傳入的cron字串解析成為CronExpression物件。

CronExpression主要有一個next方法,它會根據當前cron表示式解析出來的物件,以及傳入的時間,返回一個時間值,也就是下次任務執行的時間。

這裡的入參需要實現Temporal介面。這是在JDK8引入的一套全新的時間、日期。

能引入新的,至少說明之前的Date等等之類的時間處理是不能滿足各方面需要的。

下面看看它的主要實現

這裡我們一般常用的可能就是Instant,LocalDateTime,ZonedDateTime了。

從上面就可以看到cron表示式的處理,主要是分為兩步:1、將cron表示式字串解析為CronExpression物件;2、根據傳入的時間計算下次任務的執行時間。

在分析原始碼之前,我們簡單看幾個java中的類

  1. ValueRange主要用來表示時間、日期欄位的有效範圍。當然它也可以不用來表示時間、日期。下面我們簡單看下它的使用。

    它主要有4個欄位,4個屬性值從上到下是不小於的關係。

        private final long minSmallest;   //最小的最小值
        private final long minLargest;    //較大的最小值
        private final long maxSmallest;   //較小的最大值
        private final long maxLargest;    //最大的最大值
    
		//定義1個(1-10)的範圍指定minSmallest和minLargest都是1,maxSmallest和maxLargest都是10
    ValueRange valueRange=ValueRange.of(1,10); 
    //判斷5是不是在上面定義的minSmallest和maxLargest(1-10)的範圍內,在的話返回true
		boolean validValue = valueRange.isValidValue(5);   
		System.out.println(validValue);
  1. ChronoField是一個列舉類,就是用來表示時間、日期的欄位。

    下面我們簡單看它的幾個範例

    //用納秒來表示表,1秒==1000000000納秒,所以它的範圍是0-999999999
    NANO_OF_SECOND("NanoOfSecond", NANOS, SECONDS, ValueRange.of(0, 999_999_999)),
    //用納秒來表示一天,1天==86400秒,再轉成納秒就是86400L*1000000000
    NANO_OF_DAY("NanoOfDay", NANOS, DAYS, ValueRange.of(0, 86400L * 1000_000_000L - 1)),
    ......其他基本類似,就不繼續說了
  1. ChronoUnit也是一個列舉類,表示一個時間單元。有一個addTo方法表示給時間加上一個對應的時間單元。

    		//下面的程式碼就是給當前時間加上1天
    		ZonedDateTime now = ZonedDateTime.now();
    		ZonedDateTime zonedDateTime = ChronoUnit.DAYS.addTo(now, 1);
    

我們先看第一步:

1、將cron表示式字串解析為CronExpression物件

我們傳入的表示式用空格分成6個部分,每個部分代表的含義如下:

CronField類中有一個內部列舉類Type,它就是用來表示cron表示式中的欄位(

在cron表示式中沒有納秒欄位,其他都跟cron表示式是一一對應的

先看下它的構造方法

從上面也可以看到這個列舉類有兩個欄位,第一個是表示當前時間、日期的欄位,後面是一個用來表示小於它的時間、日期欄位的陣列。

程式碼的如下圖

主要程式碼就是上面框出來的:

  1. 將我們傳入的cron字串分割成陣列。

  2. 分別解析每個部分,建立CronExpression物件。

    解析每個部分都呼叫的是CronField.parsexxx 這樣的靜態方法。所有的解析基本是一致的,分別建立BitsCronField物件。所以我們就只看CronField.parseSeconds方法。

    • CronField.parseSeconds原始碼分析

      這個方法會調到BitsCronField.parseSeconds(value);方法,繼續呼叫到BitsCronField的靜態方法 parseField(value, Type.SECOND),下面我們主要看看這個方法的程式碼。

    上面就是這個方法的全部程式碼了,從上面我標註的地方就能看到一個cron欄位可以包含的其他符號,分別是/-這3種額外的符號。

    1、在標號1的地方首先把欄位用號拆分成陣列,後面在for迴圈中對每個部分進行處理。

    2、在for迴圈中,首先判斷是否包含/,如果不包含,就呼叫parseRange返回一個ValueRange


    2.1、 下面我們先看下不包含/if分支

    parseRange方法比較簡單,這裡簡單說下:

    如果當前的rangeStr==*,那就返回type對應的預設ValueRange

    這裡的type就是前面看到的CronField的內部列舉類Type

    如果rangeStr不包含-,那就表示一個固定的值,用ValueRange.of(result, result)返回;

    如果rangeStr包含-,那就表示一個範圍-前面的表示最小值、-之後的表示最大值。組裝成一個ValueRange返回。


    parseRange返回之後,再呼叫result.setBits(range)方法。

    我們先看看BitsCronField這個類,它有一個屬性private static final long MASK = 0xFFFFFFFFFFFFFFFFL;表示掩碼,還有一個屬性private long bits;我們最終計算出來的執行時間都會體現在這個欄位上。

    由於對於cron表示式中的6個部分,最大的也就是比如表示分鐘、秒鐘的一共60個。由於long是64位元,所以這是按照bit來設定對應的可用的值。

    舉個例子比如cron表示式計算出來秒的部分是第50秒執行,那就會將對應的bits欄位的第50位設定為1

    下面我們看看setBits方法

    如果傳入的ValueRange只表示一個值,那就把對應的bit位置1;

    否則就將將最小值與最大值之間的bit位置1。

    這裡使用|是由於外面我們可能會是,分割的多個欄位,會出現多次賦值,要確保本次賦值不會將之前賦值1的bit位清空。

    這裡需要注意的是右移是一個負數,這是由於MASKlong型別,也就是64位元,所以右移負數其實也就是移動(64-(range.getMaximum() + 1))位。後面的+1主要是由於我們最小值是從0開始的。

    舉個例子,如果範圍的最小值是0,最大值是1。如果沒有+1,最大值掩碼就會右移64-1=63,最終只有第0位是1,這明顯就是錯誤的。

    2.2、下面我們看下包含/分支的部分

  • 首先也還是拆分/前後,前面的作為ValueRange,後面的作為delta

    這裡需要注意的是如果/前面不包含-,那/前面的只是作為最小值,最大值還是用type對應的最大值。

  • 標註2的地方就是設定對應的bit位了,這裡主要是按照delta的增量在最小值和最大值之間分別設定對應bit位。


上面就是cron表示式中一個欄位的解析了,建立一個BitsCronField物件,設定對應的bits屬性對應的bit位為1,下面我們簡單看看各種設定。

下面我們看看cron表示式秒欄位的各種情況

* 表示將bitsbit位從0-59位都設定成1.

2,6,8表示將bitsbit位第2、6、8位元都設定成1.

2/20,8表示將bitsbit位第2、22、42、8位元都設定成1.

在解析完cron表示式的每個部分之後,就會建立一個CronExpression物件,這類會新增一個CronField.zeroNanos()欄位,用來表示納秒欄位,同時將bits設定為0,表示我們的定時任務希望在納秒為0的時刻執行。


上面已經建立好了CronExpression物件,下面我們看看如果計算下次執行時間。

這裡就是根據傳入的時間去計算下次任務的執行時間了 。

首先給入參時間加上1納秒,這個主要是避免在1個時間點任務執行多次。

舉個例子:

比如我們的定時任務很快,在0納秒後就返回了 ,由於我們的定時任務設定了只在0納秒執行,那這時候計算出來的下次執行任務時間和上一次任務是同一時間,就又會去執行一遍定時任務。

這裡也能看到我們的定時任務最快也是每秒執行一次。加1納秒就是為了確保當前任務和下次任務不會在同一秒執行。

下次任務執行時間是在本次任務執行完就就算出來的

在這裡能看到,最多會嘗試MAX_ATTEMPTS次,檢視計算出來的時間不再變化,那這個時間就是我們計算出來的下次任務執行的時間。如果嘗試MAX_ATTEMPTS次每次的時間都和上次的不一樣,那就返回null

nextOrSameInternal方法中會分別對每個field進行處理。這些field有額外新增的納秒(設定了bits=0,表示在0納秒執行),其他6個就分別是cron表示式對應的部分,分別是秒、分、小時、日、月、星期

下面我們看下對單個欄位的處理。

在標號1的地方首先獲取傳入的時間對應當前時間單位的值。

比如現在傳入的是2022-10-01 12:00:15,對應秒的單位的值就是15.


在標號2的位置會根據當前時間單位的值去計算下次值

具體的做法就是用將全F左移current位,與對應欄位的bits做與運算。然後返回最低位為1bit位的索引。就表示下次任務可以執行的對應的時間欄位的值。

當前有可能與運算後結果是0。那就沒有1的bit位。這時就會返回-1。在下面標註3的地方就會對這種場景進行處理。


在標註3的地方主要是對時間已經過去的情況進行處理。比如我們cron表示式計劃在秒數為00的時刻進行執行,由於現在已經是15秒了。那隻能在它的上一級時間單位(分鐘)+1,同時將本時間單位置為0。

在這裡已經對我們的時間進行了+1處理,所以時間值和傳入的值已經有變化了 ,這時在外層就會進入下次迴圈。


在標註4的地方會重新計算對應時間單位最早執行的最小值。


能走到標註5的地方說明對應下次任務執行時間對應時間單位的值已經有變化了,在這裡主要也還是調整時間,將時間調整成符合下次執行任務的時間。主要的程式碼是elapseUntil方法。

下面是elapseUntil方法的程式碼。

從圖上看,主要分3種情況:

  • 下次執行時間在合法範圍內,那就直接講欄位的值進行設定。

    這裡需要注意的是這個範圍不一定是固定的。如日,在1月範圍有效範圍就是在1-31。2月就是在1-28或1-29。

  • 如果時間不在有效範圍內,那就在當前的時間單位上加上一個差值。

    這個加操作會使上一級時間單位變化。如當前時間單位是日,執行加操作,可能會使月單位的值也有所變化。

  • 如果下次執行時間小於當前時間單位的值,那就只能進行加。

    比如當前是1月15號,下次任務是10號執行。那就只有給日時間單位加(下次任務時間10+最大值31-當前值15+1-最小值1)=26

    將時間變成下個月對應的時間單位進行重新計算(將時間變成2月10號)。


在標註6的地方,這裡是由於已經將當前的時間單位進行至少+1的調整。那這時就需要將它對應的所有下一級時間單位統一調整成最小值,以便下次重新計算。


上面就是整個下次任務執行時間的計算了 。

總結下,就是設定下次任務執行的納秒單位為0,分別在秒,分,小時,日,月,星期單位上進行計算。至少時間不再調整。

上面只是分析了cron表示式的解析處理,關於cron表示式的各種寫法並沒有列出。不過相信大家根據原始碼反推cron表示式的各種寫法,應該是個簡單事情了。