• 日历设计之重复事件规则设计


    背景  

      什么是「日历」服务,相信大家都用过,或者看到过。就像非计算机时代,大家也会买个挂历,然后把什么时候要做什么事用笔圈起来,然后每过一个月,一天,就撒一页,这样到了做标记处理事情的日子,我们就可以知道今天有个什么事情要做,比如妈妈的生日,同学聚会的日子等。当然现在互联网应用时代我们会用更好的软件应用管理好我们的日历提醒事件,比如大家最常用的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. 其实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的使用。

    好了,关于规则的设计和讨论我就写到这里,最后还是真的希望大家留言把更好的设计告诉我,一起参与讨论下。后面文章还会写关于扫描提醒方面的东西。

  • 相关阅读:
    【转载】mysql dual 虚拟表
    Mysql中DATE_SUB函数
    mybatis中<include>标签的作用
    IDEA提升幸福感的小技巧之"快速从controller跳转到serviceImpl"
    MySQL一些常用的时间函数 current_timestamp()
    SpringCloud配置中心搭建与使用(本地存储配置)
    Linux内存管理 (7)VMA操作【转】
    linux下的调试工具ltrace与strace【转】
    (十四)Linux内存管理之page fault处理【转】
    Linux系统调用详解(实现机制分析)--linux内核剖析(六)【转】
  • 原文地址:https://www.cnblogs.com/jcli/p/calendar_recur_rule.html
Copyright © 2020-2023  润新知