日历系统开发(四):重复事件的RRULE规则实现

重复事件是日历系统里最复杂的部分,没有之一。这篇介绍如何实现 RFC 5545 RRULE 规则解析和实例展开。

为什么 RRULE 这么难

一个看似简单的需求"每周一开会",背后的规则表达和实例计算其实很复杂。RFC 5545 定义了一套完整的重复规则语法 RRULE,能描述几乎任何重复模式:

  • RRULE:FREQ=WEEKLY;BYDAY=MO — 每周一
  • RRULE:FREQ=MONTHLY;BYDAY=2FR — 每月第二个周五
  • RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU — 每年三月最后一个周日
  • RRULE:FREQ=DAILY;INTERVAL=3;COUNT=10 — 每 3 天一次,共 10 次

组合起来,可能出现的情况非常多,而且有各种边界条件。

RRULE 核心属性

实现中需要支持的属性:

属性 含义 示例
FREQ 频率,必填 DAILY/WEEKLY/MONTHLY/YEARLY
INTERVAL 间隔,默认 1 INTERVAL=2(每隔 2 个周期)
BYDAY 按星期几 MO,WE,FR 或 2MO(第 2 个周一)
BYMONTH 按月份 1,6(一月和六月)
BYMONTHDAY 按月中第几天 15,-1(15 号和最后一天)
BYHOUR/BYMINUTE 按时分 较少用
UNTIL 结束时间 20231231T235959Z
COUNT 总次数 COUNT=10
WKST 周起始日 默认 MO

UNTIL 和 COUNT 互斥,只能有一个。如果都没有,理论上无限重复(实际展开时需要设一个上限)。

Java 实现:RRULE 解析器

将 RRULE 字符串解析成结构化的 RecurrenceRule 对象:

public class RecurrenceRule {
    private Frequency freq;        // DAILY, WEEKLY, MONTHLY, YEARLY
    private int interval = 1;
    private List<DayOfWeek> byDay;
    private List<Integer> byDayPos;  // 对应 BYDAY 中的位置前缀,如 2MO 中的 2
    private List<Integer> byMonth;
    private List<Integer> byMonthDay;
    private LocalDateTime until;
    private Integer count;
    private DayOfWeek weekStart = DayOfWeek.MONDAY;

    public static RecurrenceRule parse(String rruleStr) {
        // 去掉前缀 "RRULE:"
        String raw = rruleStr.startsWith("RRULE:") 
            ? rruleStr.substring(6) : rruleStr;
        
        RecurrenceRule rule = new RecurrenceRule();
        String[] parts = raw.split(";");
        
        for (String part : parts) {
            String[] kv = part.split("=", 2);
            String key = kv[0].toUpperCase();
            String val = kv[1];
            
            switch (key) {
                case "FREQ":
                    rule.freq = Frequency.valueOf(val);
                    break;
                case "INTERVAL":
                    rule.interval = Integer.parseInt(val);
                    break;
                case "COUNT":
                    rule.count = Integer.parseInt(val);
                    break;
                case "UNTIL":
                    rule.until = parseDateTime(val);
                    break;
                case "BYDAY":
                    parseByday(rule, val);
                    break;
                case "BYMONTH":
                    rule.byMonth = parseIntList(val);
                    break;
                case "BYMONTHDAY":
                    rule.byMonthDay = parseIntList(val);
                    break;
                case "WKST":
                    rule.weekStart = parseDayOfWeek(val);
                    break;
            }
        }
        return rule;
    }
    
    private static void parseByday(RecurrenceRule rule, String val) {
        rule.byDay = new ArrayList<>();
        rule.byDayPos = new ArrayList<>();
        for (String dayStr : val.split(",")) {
            dayStr = dayStr.trim();
            // 解析类似 "2MO", "-1FR" 
            Matcher m = Pattern.compile("(-?\d+)?(MO|TU|WE|TH|FR|SA|SU)")
                .matcher(dayStr);
            if (m.matches()) {
                String posStr = m.group(1);
                rule.byDayPos.add(posStr != null ? Integer.parseInt(posStr) : 0);
                rule.byDay.add(parseDayOfWeek(m.group(2)));
            }
        }
    }
}

实例展开算法

解析完规则之后,核心工作是根据 RRULE 展开出所有实际发生的时间点。常用做法是一个迭代生成器:

public class RecurrenceExpander {
    
    public List<LocalDateTime> expand(
        LocalDateTime dtStart, 
        RecurrenceRule rule,
        LocalDateTime rangeStart,
        LocalDateTime rangeEnd
    ) {
        List<LocalDateTime> instances = new ArrayList<>();
        LocalDateTime cursor = dtStart;
        int generated = 0;
        int maxIterations = 10000; // 安全上限
        
        for (int i = 0; i < maxIterations; i++) {
            // 生成当前周期内的候选日期
            List<LocalDateTime> candidates = generateCandidates(cursor, rule, dtStart);
            
            for (LocalDateTime candidate : candidates) {
                if (candidate.isBefore(dtStart)) continue;
                if (rule.getUntil() != null && candidate.isAfter(rule.getUntil())) {
                    return instances;
                }
                if (candidate.isAfter(rangeEnd)) {
                    return instances;
                }
                
                if (!candidate.isBefore(rangeStart)) {
                    instances.add(candidate);
                }
                
                generated++;
                if (rule.getCount() != null && generated >= rule.getCount()) {
                    return instances;
                }
            }
            
            // 推进到下一个周期
            cursor = advanceCursor(cursor, rule);
        }
        
        return instances;
    }
    
    private List<LocalDateTime> generateCandidates(
        LocalDateTime cursor, RecurrenceRule rule, LocalDateTime dtStart
    ) {
        List<LocalDateTime> candidates = new ArrayList<>();
        
        switch (rule.getFreq()) {
            case DAILY:
                candidates.add(cursor.withHour(dtStart.getHour())
                    .withMinute(dtStart.getMinute()));
                break;
            case WEEKLY:
                if (rule.getByDay() != null && !rule.getByDay().isEmpty()) {
                    // 找到本周内匹配 BYDAY 的所有日期
                    LocalDateTime weekStart = cursor.with(
                        TemporalAdjusters.previousOrSame(rule.getWeekStart()));
                    for (int d = 0; d < 7; d++) {
                        LocalDateTime day = weekStart.plusDays(d);
                        if (rule.getByDay().contains(day.getDayOfWeek())) {
                            candidates.add(day.withHour(dtStart.getHour())
                                .withMinute(dtStart.getMinute()));
                        }
                    }
                } else {
                    candidates.add(cursor);
                }
                break;
            case MONTHLY:
                expandMonthly(cursor, rule, dtStart, candidates);
                break;
            case YEARLY:
                expandYearly(cursor, rule, dtStart, candidates);
                break;
        }
        
        Collections.sort(candidates);
        return candidates;
    }
}

MONTHLY 的展开是最复杂的,因为要处理"每月第二个周五"、"每月最后一天"这类情况,还有 2 月份只有 28/29 天等边界:

private void expandMonthly(LocalDateTime cursor, RecurrenceRule rule,
                           LocalDateTime dtStart, List<LocalDateTime> candidates) {
    if (rule.getByDay() != null && !rule.getByDay().isEmpty()) {
        // BYDAY 带位置:如 2FR = 每月第 2 个周五
        for (int i = 0; i < rule.getByDay().size(); i++) {
            DayOfWeek dow = rule.getByDay().get(i);
            int pos = rule.getByDayPos().get(i);
            
            LocalDateTime result;
            if (pos > 0) {
                result = cursor.withDayOfMonth(1)
                    .with(TemporalAdjusters.dayOfWeekInMonth(pos, dow));
            } else if (pos < 0) {
                result = cursor.with(TemporalAdjusters.lastInMonth(dow));
                for (int j = -1; j > pos; j--) {
                    result = result.minusWeeks(1);
                }
            } else {
                // pos == 0 表示该月所有该星期几
                LocalDateTime d = cursor.withDayOfMonth(1)
                    .with(TemporalAdjusters.firstInMonth(dow));
                while (d.getMonth() == cursor.getMonth()) {
                    candidates.add(d.withHour(dtStart.getHour())
                        .withMinute(dtStart.getMinute()));
                    d = d.plusWeeks(1);
                }
                continue;
            }
            
            if (result.getMonth() == cursor.getMonth()) {
                candidates.add(result.withHour(dtStart.getHour())
                    .withMinute(dtStart.getMinute()));
            }
        }
    } else if (rule.getByMonthDay() != null) {
        for (int day : rule.getByMonthDay()) {
            int actualDay = day > 0 ? day 
                : cursor.toLocalDate().lengthOfMonth() + day + 1;
            if (actualDay >= 1 && actualDay <= cursor.toLocalDate().lengthOfMonth()) {
                candidates.add(cursor.withDayOfMonth(actualDay)
                    .withHour(dtStart.getHour()).withMinute(dtStart.getMinute()));
            }
        }
    } else {
        // 默认按 DTSTART 的日期
        int day = dtStart.getDayOfMonth();
        if (day <= cursor.toLocalDate().lengthOfMonth()) {
            candidates.add(cursor.withDayOfMonth(day)
                .withHour(dtStart.getHour()).withMinute(dtStart.getMinute()));
        }
    }
}

异常日期 (EXDATE) 处理

重复事件中某一次需要取消怎么办?RFC 5545 用 EXDATE 属性标记被排除的日期:

DTSTART:20220101T090000
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
EXDATE:20220107T090000,20220114T090000

在展开实例后做一次过滤就行:

public List<LocalDateTime> expandWithExclusions(
    LocalDateTime dtStart,
    RecurrenceRule rule,
    Set<LocalDateTime> exDates,
    LocalDateTime rangeStart,
    LocalDateTime rangeEnd
) {
    List<LocalDateTime> all = expand(dtStart, rule, rangeStart, rangeEnd);
    return all.stream()
        .filter(dt -> !exDates.contains(dt))
        .collect(Collectors.toList());
}

看起来简单,但实际中 EXDATE 可以带时区、可以是 DATE 类型(不含时间),需要在比较时做归一化处理。

实现 RRULE 解析和展开需要大量单元测试来覆盖各种组合和边界情况。建议编写上百个测试用例,确保各种 RRULE 组合都能正确处理。下一篇会讲 CalDAV 协议同步,那是另一个复杂度较高的部分。