背景
什么是「日歷」服務,相信大家都用過,或者看到過。就像非計算機時代,大家也會買個掛歷,然后把什么時候要做什么事用筆圈起來,然后每過一個月,一天,就撒一頁,這樣到了做標記處理事情的日子,我們就可以知道今天有個什么事情要做,比如媽媽的生日,同學聚會的日子等。當然現在互聯網應用時代我們會用更好的軟件應用管理好我們的日歷提醒事件,比如大家最常用的Google日歷,QQ日歷:

如上圖所示,就是Google的日歷產品,我添加了一個每月7號還貸的事件,這樣每個月的7號前,比如說6號上午9點,我就會收到一封Google的郵件,或者手機短信提示我明天要還房貸了,這樣我就會立即處理這個事情。現在大家應該對這樣類似的產品有個感性的認識了,在生活中也能給我們很多幫助,這樣可以在親人生日,還貸還款,朋友聚會,商務會議等很多場景中幫我們記憶事件活動提醒,不用自己每天記一堆的事情,而且還容易忘記。好了,說完這個產品的背景,現在我們想想應該怎樣從技術上設計,實現這個產品呢?
設計實現一個「日歷」服務產品,我覺得有兩個難點,一是「重復事件規則計算」,二是「到點事件准時提醒」。這篇文章主要講解第一點,「重復規則的設計和實現」,下一篇博客討論下怎樣保證准時到點提醒。
事件/活動的定義
首先,我們必須定義什么是「事件」或者稱「活動」。所謂事件/活動,在「日歷產品」中就是我們創建的提醒事件本身,如「每月7號還房貸」就是一個事件/活動。
/** * 日歷事件/活動. * * * @author tony.li.fly@gmail.com */ public class Event { /** * 事件標題 */ private String title; /** * 事件描述 */ private String description; /** * 事件發生地點 */ private String location; /** * 事件發生的開始時間 */ private Date startDate; /** * 事件發生的結束時間 */ private Date endDate; /** * 日歷類型: 1,公歷 2,農歷 */ private int calendarType; /** * 重復事件規則表達式 */ private String rule;
所以一個事件的基本的幾個屬性有:標題,描述,地點,事件開始時間,事件結束時間,還有后面要重點討論的重復規則表達式和日歷類型。
重復事件中「重復」關鍵字的一些元素
看下Google產品中設置重復事件的頁面:

上圖是設置一個重復事件的設置頁面,可以看到設置項還是挺多的,有「重復類型」:按日,周,月,年重復;「重復頻率」:每幾天,幾周....等發生一次;「重復日期」:按月重復時是「一月中的某天呢」,還是「一周中的某天呢」;「結束日期」:重復事件到什么時候結束呢。所以從Google產品功能和我們的描述可以知道想要准確描述一個「重復事件」,要具備如下幾個元素:
- 重復類型,你是按天,按周,按月,還是按年呢
- 重復頻率,即幾個周期發生一次,如我每兩個月去和朋友打一次球
- 一個周期內發生的日期,比如你按周重復,那你是每周幾發生呢,是周二,周四才發生,還是只周一才發生;如果是按月重復,是每月的第三個星期六發生,還是每月的23號發生呢;
- 結束日期,即重復多久后終止事件本身。如你每個月要還房貸,也是有個最終還完的那天,比如30年后。比如每個月參加某個培訓,只參加5次課堂培訓就完了。
現在問題來了,我們怎樣定義「重復規則」的數據結構呢?基於重復規則的復雜性和彈性可變性(之所以說彈性可變性,因為我們不能保證自己的產品不會有一些個性化的規則,比如支持農歷日歷,怎樣表示清明節這樣的日期),用字符串表達式定義,持久化存儲更為理想,就像正則表達式一樣,我們可以用一個字符串表達任何豐富的信息在里面。其實對於重復事件的描述,設計,我們可以遵守一定的業界標准。在RFC2445中有詳細的定義,這里我精簡總結下:
freq *( ; either UNTIL or COUNT may appear in a 'recur', ; but UNTIL and COUNT MUST NOT occur in the same 'recur' ( ";" "UNTIL" "=" enddate ) / ( ";" "COUNT" "=" 1*DIGIT ) / ; the rest of these keywords are optional, ; but MUST NOT occur more than once ( ";" "INTERVAL" "=" 1*DIGIT ) / ( ";" "BYDAY" "=" bywdaylist ) / ( ";" "BYMONTHDAY" "=" bymodaylist ) / ( ";" "BYYEARDAY" "=" byyrdaylist ) / ( ";" "BYWEEKNO" "=" bywknolist ) / ( ";" "BYMONTH" "=" bymolist ) )
上面是「重復規則表達式」的公式定義,詳細解釋如下:
- freq : 事件重復頻率,有效值:DAILY(按天),WEEKLY(按周),MONTHLY(按月),YEARLY(按年)
- UNTIL: 重復結束日期 格式:20130102T170000Z(2013-1-2 下午5點結束)
- COUNT: 重復多少次后結束,該字段與UNTIL兩者只有出現一次
- INTERVAL: 事件重復的間隔,如按天重復時,INTERVAL=2,則表明每2天重復一次,默認值 為1
- BYDAY: 表示一周的某一天,有效值:MO(周一),TU(周二),WE(周三),TH(周四),FR(周五),SA(周六),SU(周日) , 示例: BYDAY=MO,TH,SU 表示重復日期包括周一,周四,周日. 每個值前面可以用 ”+”, “-” 修飾,表示第幾個和倒數第幾個日子,如 BYDAY = 2MO 表示第2個星期一發生; BYDAY=MO,-1SU 表示每個星期一和最后一個星期日發生
- BYMONTHDAY: 表示一月的第幾天發生,有效值是 [1 ~ 31] 和 [-31 ~ -1] ,如: BYMONTHDAY=2,18 表示一月的第2天,第18天發生; BYMONTHDAY=-1 表示一月的最后一天
- BYYEARDAY: 表示一年的第幾天發生,有效值是 [1 ~ 366] 和 [-366 ~ -1], 如 BYYEARDAY=125 表示一年的第125年發生; BYYEARDAY=-1 表示一年的最后一天發生
- BYWEEKNO: 表示一年的第幾周發生, 有效值是 [1 ~ 53] 和 [-53 ~ -1], 如 BYWEEKNO=2,23 表示一年的第2周,第23周發生
- BYMONTH: 表示一年中的第幾個月發生, 有效值是 [1 ~ 12]
需要注意幾點:
- 如果各字段所設置的值是無效的,如 BYMONTHDAY=30 ,則會忽略該值
- 如果某條事件的重復規則表達式缺少一些必要字段,如 YEARLY;BYMONTH=1 ,表示按年重復,每年的1月某日發生,現在缺少”日”字段,則從該事件的”開始日期”中獲得
通過上面的公式定義,基本上可以表示出任何一個重復事件的定義,下面來做一些練習:
按天重復
目標 : 按天重復, 且每3天重復一次
表達式: DAILY;INTERVAL=3
目標 : 按天重復, 重復到今年結束
表達式: DAILY;UNTIL=20140101T000000Z
目標 : 按天重復, 重復20次
表達式: DAILY;COUNT=20
按周重復
目標 : 每周二,周四,周日重復,每隔2周發生一次
表達式: WEEKLY;INTERVAL=2;BYDAY=TU,TH,SU
按年重復
目標 : 每年的七月,八月兩月最后一天
表達式: YEARLY;BYMONTH=7,8;BYMONTHDAY=-1
父親節
目標 : 每年六月的第三個星期日
表達式: YEARLY;BYMONTH=6;BYDAY=3SU
除夕
目標 : 農歷每年的最后一天
表達式: YEARLY;BYMONTH=12;BYMONTHDAY=-1
感恩節
目標 : 每年11月的第四個星期四
表達式: YEARLY;BYMONTH=11;BYDAY=4TH
母親節
目標 : 每年5月的第二個星期日
表達式: YEARLY;BYMONTH=5;BYDAY=2SU
解析並計算重復規則表達式
什么叫重復規則的解析和計算?解析,就是把上面的重復規則表達式解析成我們程序內部結構化的對象;計算,就是我們能知道某個重復事件在將來的哪些時間點上會發生。
上圖所示,我們先把重復表達式字符串解析成程序內部的結構化數據實例,然后計算在整個將來的時間軸上該重復事件在什么時間點發生。
我們先定義一個接口 Rule ,它就是根據重復事件解析后的規則引擎實例接口,它應該具有如下方法:

- nextOccurDate: 根據傳入的時間計算出以該時間為起始值的下一次事件發生的時間
- includes(theDay): 判斷指定時間是否是該事件的發生時間點系列之一

上圖中,定義一個按月重復活動(每月15號發生)。執行nextOccurDate('2013-11-28')方法返回的結果就是以2013-11-28這個時間為起始值,計算下一次事件發生的時間點,即返回2013-12-15. 其實nextOccurDate和includes兩個方法表達的意思一樣的,只是從不同的兩個方面去定義。我們知道了任一時間點之后的事件發生時間,那么我們也就知道了指定的一個時間是否滿足事件發生的要求。現在我們要考慮的重點問題是:怎樣簡單高效的實現這兩個方法,保證計算的准確性和性能。在實現之前,我們有必要來認清這個計算中的難點在哪里:
- 多個周期重復。在按天,周,月,年重復事件中,可以設置任何的周期倍數,即 每N天/周/月/年 發生一次。
- 在按周重復時,一周內可以設置多天,如每周的星期三,四,五重復
- 按月重復時,不只是簡單的每月多少號,還有比較復雜的規則:每月的第幾個星期幾;每月的最后一天;
- 按年重復時,也有每年的第幾個月的第幾個星期幾重復等復雜規則
- 重復事件可以永不終止,可以發生到某一天終止,也可以發生多少次后終止
- 在重復事件的時間軸上,可以去除某幾次的發生時間點,如前面例子中的我不想要2013-10-15號發生,其它時間點不變
- ..........
我自己能想到的實現方法有兩種:「實時計算法」和「枚舉法」。下面來分別討論一下:
實時計算法
實時計算,可以理解成「無狀態」的實時求值。每次根據傳入的參數計算並返回。

每次我們計算時,都沒有任何上下文信息,只要知道開始時間和「重復規則配置」,實時根據公式計算出下一次的發生時間。我們分析下這個計算過程的可行性:根據事件最先發生的開始時間和當前傳入的時間值,我們知道兩者的時間差,然后根據「重復周期值:interval」可以知道下次發生的時間所在的周期區間。縮小在指定時間周期區間后,再根據具體的某天,某月,某年的信息,即可以算出最終的下次發生的時間點。所以從這里分析來看,好像理論上是可行的。但是有幾點障礙使我覺得這種計算方法不能很完美:
- 這個時間差和Interval的關系在「農歷計算」時我覺得沒有公式計算,主要是閏月的原因。當然有一種辦法,就是計算兩個農歷時間的差值時,一年一年的判斷累加。但是我覺得這種方法不完美。
- 「重復次數:Count」這個值基本上實時計算不出來。為什么這么說呢,因為有些比較特殊的「重復規則」會導致忽略一些時間點。如果每月31號重復時間,在小月的時候就不會發生。還有每月的第四個星期三,有時候一個月經常沒有第四個星期幾發生。
枚舉法
枚舉法,可以理解成「有狀態」的比較計算。每次調用都是根據傳入的值和「預存計算好的值」比較。

我們總是先把該重復事件所有要發生的時間線上的點都計算出來,並保存起來。以后每次調用計算方法時,只要根據傳入的參數值馬上知道它的上次和下次發生時間點。相比上面的「實時計算法」,它的優點顯而易見:簡單,快速,並且可以解決上面方法中無法處理的兩點。但是缺點你也想到了:那要多少空間存儲這些預計算的值? 但是任何產品,都有它的實際使用場景,我想任何人使用「日歷產品」的時候我們關注的時間區間都是以今天為中心兩邊延伸的時間區間,而且一般這個區間不會超過1年,或者2年吧。所以我們可以先計算出以今天為中心的前后各十年(這個看你估量設置)的時間區間上所有發生時間點。
代碼實現

當我們創建完一個活動事件后(Event),我們就可以通過該事件(Event)的「重復規則表達式字符串」,利用 RuleFactory 來創建Rule對象。有了Rule對象,我們就可以進行相應的計算求值了。我們知道 Rule 只是一個接口,我們返回接口這也符合設計的准則,對外屏蔽內部的具體實現,使調用者根本不用知道里面的計算實現方法。Rule的層級關系如下圖:

這張圖看起來類有點多,但是一點都不復雜,它的層次設計也是完全按照業務模型來設計的。簡要說明一下這幾個類:
- Rule 是最頂層接口,用戶直接操作的也只會是這個類型,這樣用戶就不用知道太多細節。
- AbstractRule 是對事件中和時間相關屬性的一些基本框架方法定義。
- OnceTimeRule 是一次性事件,即只發生一次非重復發生事件
- AbstractRecurRule 是所有重復事件的抽象類
- DailyRule , WeeklyRule , AbstractMonthlyRule , AbstractYearlyRule 分別代表按天,周,月,年重復事件規則。
- GregorianMonthlyRule,LunarMonthlyRule,GregorianYearlyRule, LunarYearlyRule 分別是農歷,公歷的按月,按年重復事件規則
- AbstractMutliCalendarRuleHelper,GregorianCalenarRuleHelper,LunarCalenarRuleHelper 是公歷,農歷規則計算中使用到的輔助類。
具體的代碼請參見git項目地址:https://github.com/hongfuli/simplecal ,參考代碼注意幾點:里面有兩個分支,master和redis這兩個分支對象於「實時計算」和「枚舉」兩種實現方式;代碼沒用maven管理,如果缺少什么jar包請上網下載;「枚舉」分支用的redis實現,請了解下redis的使用。
好了,關於規則的設計和討論我就寫到這里,最后還是真的希望大家留言把更好的設計告訴我,一起參與討論下。后面文章還會寫關於掃描提醒方面的東西。
