[转载]日历设计之重复事件规则设计

转载自http://www.cnblogs.com/jcli/p/calendar_recur_rule.html

背景  

  什么是「日历」服务,相信你们都用过,或者看到过。就像非计算机时代,你们 也会买个挂历,而后把何时要作什么事用笔圈起来,而后每过一个月,一天,就撒一页,这样到了作标记处理事情的日子,咱们就能够知道今天有个什么事情要 作,好比妈妈的生日,同窗聚会的日子等。固然如今互联网应用时代咱们会用更好的软件应用管理好咱们的日历提醒事件,好比你们最经常使用的Google日 历,QQ日历:html

 

  如上图所示,就是Google的日历产品,我添加了一个每个月7号还贷的事件,这样每月的7号前,好比说6号上午9点,我就会收到一封 Google的邮件,或者手机短信提示我明天要还房贷了,这样我就会当即处理这个事情。如今你们应该对这样相似的产品有个感性的认识了,在生活中也能给我 们不少帮助,这样能够在亲人生日,还贷还款,朋友聚会,商务会议等不少场景中帮咱们记忆事件活动提醒,不用本身天天记一堆的事情,并且还容易忘记。好了, 说完这个产品的背景,如今咱们想一想应该怎样从技术上设计,实现这个产品呢?git

  设计实现一个「日历」服务产品,我以为有两个难点,一是「重复事件规则计算」,二是「到点事件准时提醒」。这篇文章主要讲解第一点,「重复规则的设计和实现」,下一篇博客讨论下怎样保证准时到点提醒。github

事件/活动的定义

  首先,咱们必须定义什么是「事件」或者称「活动」。所谓事件/活动,在「日历产品」中就是咱们建立的提醒事件自己,如「每个月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;
复制代码

 

  因此一个事件的基本的几个属性有:标题,描述,地点,事件开始时间,事件结束时间,还有后面要重点讨论的重复规则表达式日历类型。 redis

重复事件中「重复」关键字的一些元素

看下Google产品中设置重复事件的页面:算法

  

 

 

 

 

 

 

 

 

 

 

上图是设置一个重复事件的设置页面,能够看到设置项仍是挺多的,有「重复类型」:按日,周,月,年重复;「重复频率」:每几天,几周....等发生 一次;「重复日期」:按月重复时是「一月中的某天呢」,仍是「一周中的某天呢」;「结束日期」:重复事件到何时结束呢。因此从Google产品功能和 咱们的描述能够知道想要准确描述一个「重复事件」,要具有以下几个元素:数据结构

  • 重复类型,你是按天,按周,按月,仍是按年呢
  • 重复频率,即几个周期发生一次,如我每两个月去和朋友打一次球
  • 一个周期内发生的日期,好比你按周重复,那你是每周几发生呢,是周二,周四才发生,仍是只周一才发生;若是是按月重复,是每个月的第三个星期六发生,仍是每个月的23号发生呢;
  • 结束日期,即重复多久后终止事件自己。如你每月要还房贷,也是有个最终还完的那天,好比30年后。好比每月参加某个培训,只参加5次课堂培训就完了。

如今问题来了,咱们怎样定义「重复规则」的数据结构呢?基于重复规则的复杂性和弹性可变性(之因此说弹性可变性,由于咱们不能保证本身的产品不会有一些个性化的规则,好比支持农历日历,怎样表示清明节这样的日期),用字符串表达式定义,持久化存储更为理想,就像正则表达式同样,咱们能够用一个字符串表达任何丰富的信息在里面。其实对于重复事件的描述,设计,咱们能够遵照必定的业界标准。在RFC2445中有详细的定义,这里我精简总结下:app

复制代码
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]

须要注意几点:maven

  • 若是各字段所设置的值是无效的,如 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. 其实nextOccurDateincludes两 个方法表达的意思同样的,只是从不一样的两个方面去定义。咱们知道了任一时间点以后的事件发生时间,那么咱们也就知道了指定的一个时间是否知足事件发生的要 求。如今咱们要考虑的重点问题是:怎样简单高效的实现这两个方法,保证计算的准确性和性能。在实现以前,咱们有必要来认清这个计算中的难点在哪里:

  1. 多个周期重复。在按天,周,月,年重复事件中,能够设置任何的周期倍数,即 每N天/周/月/年 发生一次。
  2. 在按周重复时,一周内能够设置多天,如每周的星期三,四,五重复
  3. 按月重复时,不仅是简单的每个月多少号,还有比较复杂的规则:每个月的第几个星期几;每个月的最后一天;
  4. 按年重复时,也有每一年的第几个月的第几个星期几重复等复杂规则
  5. 重复事件能够永不终止,能够发生到某一天终止,也能够发生多少次后终止
  6. 在重复事件的时间轴上,能够去除某几回的发生时间点,如前面例子中的我不想要2013-10-15号发生,其它时间点不变
  7. ..........

我本身能想到的实现方法有两种:「实时计算法」和「枚举法」。下面来分别讨论一下:

实时计算法

 实时计算,能够理解成「无状态」的实时求值。每次根据传入的参数计算并返回。

 每次咱们计算时,都没有任何上下文信息,只要知道开始时间和「重复规则配置」,实时根据公式计算出下一次的发生时间。咱们分析下这个计算过程的可 行性:根据事件最早发生的开始时间和当前传入的时间值,咱们知道二者的时间差,而后根据「重复周期值:interval」能够知道下次发生的时间所在的周 期区间。缩小在指定时间周期区间后,再根据具体的某天,某月,某年的信息,便可以算出最终的下次发生的时间点。因此从这里分析来看,好像理论上是可行的。可是有几点障碍使我以为这种计算方法不能很完美:

  1. 这个时间差和Interval的关系在「农历计算」时我以为没有公式计算,主要是闰月的缘由。固然有一种办法,就是计算两个农历时间的差值时,一年一年的判断累加。可是我以为这种方法不完美
  2. 「重复次数: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的使用。

好了,关于规则的设计和讨论我就写到这里,最后仍是真的但愿你们留言把更好的设计告诉我,一块儿参与讨论下。后面文章还会写关于扫描提醒方面的东西。

 

 

原创文章,转载请注明出处,谢谢!

相关文章
相关标签/搜索