重复事件是日历系统里最复杂的部分,没有之一。这篇介绍如何实现 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 协议同步,那是另一个复杂度较高的部分。