日历系统开发(五):CalDAV协议与同步

实现了 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,如果没变就不用深入查每个事件

典型的同步流程:

  1. 客户端 PROPFIND 获取日历的 CTAG
  2. 和本地缓存的 CTAG 比较,相同则跳过
  3. 不同则用 REPORT (calendar-multiget) 获取变更的事件
  4. 下载变更事件内容,更新本地
  5. 保存新的 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 属性

实现中的注意事项

  1. XML 命名空间处理。CalDAV 涉及至少三个命名空间(DAV:、caldav、calendarserver),客户端发送的 XML 前缀不固定,解析器需要正确处理命名空间而不是硬编码前缀

  2. 时区处理。DTSTART 可以是浮动时间(无时区)、UTC 时间(Z 后缀)、或带 TZID 参数。同步时必须正确转换,否则事件时间会错乱

  3. 大事件集合的性能。当日历有上千事件时,calendar-query 的时间范围过滤如果在内存里做,响应会很慢。建议在数据库层面做时间索引

  4. 锁和并发。多个客户端同时同步同一个日历时,PUT 操作需要用 If-Match 头做乐观锁,避免覆盖冲突

  5. Apple 的 well-known 重定向。iOS 添加账号时先请求 /.well-known/caldav,如果重定向不正确就直接报错。需要确保 Nginx 或应用层正确配置了重定向

CalDAV 协议设计得很完整,但实现起来细节较多。建议先跑通 Apple Calendar 的同步(它最标准),然后再兼容其他客户端。