实现了 iCalendar 解析和 RRULE 展开之后,下一步是让日历系统能和外部客户端同步。CalDAV 是标准的日历同步协议,这篇介绍如何实现 CalDAV 服务端。
CalDAV 协议概述
CalDAV (RFC 4791) 基于 WebDAV (RFC 4918),而 WebDAV 又基于 HTTP。简单说就是用 HTTP 方法(加几个扩展方法)操作日历数据:
- PROPFIND — 查询资源属性(类似 ls)
- REPORT — 按条件批量查询日历事件
- PUT — 创建或更新事件
- DELETE — 删除事件
- MKCALENDAR — 创建新日历集合
资源的 URL 结构通常是:
/calendars/{user}/ # 用户的日历根目录
/calendars/{user}/{calendar}/ # 具体某个日历
/calendars/{user}/{calendar}/{event-uid}.ics # 单个事件
每个事件以 .ics 文件形式存储,内容就是标准的 iCalendar 格式。
CTAG / ETAG 同步机制
CalDAV 同步的核心是两个标记:
- ETAG — 每个事件资源的版本标识。事件内容一变,ETAG 就变。客户端用它判断单个事件是否有更新
- CTAG (Calendar Tag) — 日历集合的版本标识。任何一个事件变化,CTAG 就变。客户端先查 CTAG,如果没变就不用深入查每个事件
典型的同步流程:
- 客户端 PROPFIND 获取日历的 CTAG
- 和本地缓存的 CTAG 比较,相同则跳过
- 不同则用 REPORT (calendar-multiget) 获取变更的事件
- 下载变更事件内容,更新本地
- 保存新的 CTAG
CTAG 的一种实现方式:用日历中最后修改时间的时间戳哈希值。ETAG 则可以用事件内容的 MD5。
PROPFIND 请求处理
PROPFIND 是 CalDAV 中最频繁的请求,客户端用它发现日历和查询属性。请求体是 XML:
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"
xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname/>
<d:resourcetype/>
<cs:getctag/>
<c:supported-calendar-component-set/>
</d:prop>
</d:propfind>
响应也是 XML(multistatus 格式),需要按请求的 prop 返回对应属性。处理 XML 命名空间是需要仔细处理的部分。
REPORT: calendar-query 和 calendar-multiget
客户端有两种方式批量获取事件:
calendar-query:按时间范围过滤。Apple Calendar 经常用这个,只拉最近几个月的事件:
<c:calendar-query xmlns:c="urn:ietf:params:xml:ns:caldav"
xmlns:d="DAV:">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT">
<c:time-range start="20220101T000000Z" end="20221231T235959Z"/>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>
calendar-multiget:按 URL 列表获取指定事件。客户端已经知道哪些事件的 ETAG 变了,直接拉内容:
<c:calendar-multiget xmlns:c="urn:ietf:params:xml:ns:caldav"
xmlns:d="DAV:">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<d:href>/calendars/user1/default/event1.ics</d:href>
<d:href>/calendars/user1/default/event2.ics</d:href>
</c:calendar-multiget>
与主流客户端的兼容性
这部分需要仔细处理。不同客户端对 CalDAV 的实现有微妙的差异:
Apple Calendar (macOS/iOS):
- 遵循标准较好的客户端之一
- 依赖 well-known URL 发现(
/.well-known/caldav),必须正确配置 301 重定向 - 对 PROPFIND 的 Depth 头处理很严格
- 会发送 calendar-query 带 time-range 过滤
Google Calendar:
- 主要用 Google 自己的 API,CalDAV 支持是通过适配层提供的
- 客户端行为上有些非标准的地方
- 对 VTIMEZONE 的处理比较宽松
Thunderbird (Lightning):
- 对 XML 响应格式很敏感,属性顺序不对可能解析失败
- 需要正确返回
calendar-home-set属性
实现中的注意事项
-
XML 命名空间处理。CalDAV 涉及至少三个命名空间(DAV:、caldav、calendarserver),客户端发送的 XML 前缀不固定,解析器需要正确处理命名空间而不是硬编码前缀
-
时区处理。DTSTART 可以是浮动时间(无时区)、UTC 时间(Z 后缀)、或带 TZID 参数。同步时必须正确转换,否则事件时间会错乱
-
大事件集合的性能。当日历有上千事件时,calendar-query 的时间范围过滤如果在内存里做,响应会很慢。建议在数据库层面做时间索引
-
锁和并发。多个客户端同时同步同一个日历时,PUT 操作需要用 If-Match 头做乐观锁,避免覆盖冲突
-
Apple 的 well-known 重定向。iOS 添加账号时先请求
/.well-known/caldav,如果重定向不正确就直接报错。需要确保 Nginx 或应用层正确配置了重定向
CalDAV 协议设计得很完整,但实现起来细节较多。建议先跑通 Apple Calendar 的同步(它最标准),然后再兼容其他客户端。