原方链接:https://dev.yinxiang.com/media/pdf/edam-sync.pdf

译者前言

  1. 印象笔记是Evernote中国区业务独立之后的品牌名称,原Evernote现在国内被称为印象笔记国际版,实际两者相互独立,不共享服务器和账号信息。但从开发者角度来说,两者的API接口几乎一致,本文统一称为印象笔记,只在需要的场合使用Evernote称呼。
  2. 本文是译者在使用印象笔记SDK进行开发时因需要了解印象笔记同步方面的细节而开始接触了解的,有感于英文对中文开发者阅读理解造成不便,故而翻译成中文,希望能帮助到有需要的人。
  3. 本文不仅仅是对原文的翻译,其中还在关键处标注了个人的理解,旨在减轻后来人的上手难度。个人添加的注释标有“译注”二字,或是以括号的形式跟在标注对象后面,以便于和原文进行区别。
  4. 本文只描述了同步规范,并未包含实际代码,如需开发印象笔记同步类应用,可参考个人项目:evernote-sdk-cpp-quickstart
  5. 限于作者的个人水平,译文中难免会出现翻译出错或不当的地方,希望大家多多包涵。

EDAM概述

印象笔记数据接入与管理(Evernote Data Access and Management, EDAM,后文简称EDAM)是一个用于和印象笔记服务进行数据交换的协议。EDAM同步流程及数据结构使用Thrift IDL进行描述。这些Thrift IDL文件可用于生成和具体语言相关的数据结构和接口。生成的类可用于封装及解封装数据,以及使用抽象概念“协议(protocol)”和“载体(transport)”完成接口调用。

 

译注:

transport有运输机、交通工具的意思,这里指的是完成传输工作的载体,例如使用网络传输数据,那套接字就是载体,使用共享内存进行传输,那内存就是载体。需要注意的是,按照Thrift的定义和实现,如果使用HTTP进行传输,那载体是HTTP,而不是套接字。

 

Thrift运行时库包含具体的协议(protocol)与载体(transport)的代码实现,这些实现使得Thrift PRC能够以二进制封装并通过HTTP/或原始的TCP/IP套接字进行传输。

 

想了解构成EDAM的数据结构和远程调用接口,请访问:http://dev.evernote.com/documentation/reference/

 

印象笔记的Thrift IDL文件为以下三个目的服务:

  1. 数据中心的内部印象笔记服务组件查询UserStore或者NoteStore
  2. 全缓存客户端通过接口完成数据同步,同步以HTTP为载体。
  3. 非全缓存客户端直接使用API通过HTTP网关观察或修改用户状态。这包剪贴板类应用和集成印象笔记功能的应用。

 

基于以上这种多用途的特性,印象笔记的API被设计成有些函数只针对以上一两个场景,而有些则针对所有场景。例如,getSyncChunk函数只用于缓存客户端进行数据同步,而authenticategetResourceData则可以用于全部三种用途。

 

本文档只限于讨论同步场景,该场景下带缓存的客户端将和印象笔记服务进行同步。

EDAM同步概述

印象笔记同步方案的设计基于一组专门的需求:

  1. 同步遵循客户端-服务器模型,中心服务器是用户账户状态的最终仲裁者。
  2. 客户端有着多种本地存储机制,同步方案不能在底层假定数据表达方式:数据必须在逻辑层面进行传输,而不是块/记录层面。
  3. 印象笔记必须支持完全同步及增量同步,每次同步都发送整个笔记数据库的行为是不可取的。
  4. 同步必须在不可靠的网络环境下也能完成,且不能有太多的重传。即使首次同步,也应该假定有可能出现网络错误。
  5. 同步时不能锁住账户,使同步变成原子操作。同步方案必须能够包容同步期间其他客户端对数据的修改。

 

EDAM同步规范通过“基于状态重传(state based replication)”方案来实现这些目标,该方案将中心服务看作数据存储点,然后将同步决策推送到客户端。这种模型和邮件系统的模型类似,例如IMAPMS Exchange,它们都可以可靠且可扩展地实现类似需求。

 

在这个同步方案中,印象笔记服务并不独立追踪每个客户的同步状态,也不会维护一个细分的同步记录表以实现“基于日志重传”。相反,它只为每个用户存储一系列的元数据(笔记,标签等)。每个元数据都有一个“更新序列号(Update Sequence Number, USN)”,用于记录该数据的每次修改。系统可以使用这些序号来决定该数据是否是最新状态。

 

单个账户的USN1开始(对应账户创建的第一个对象),然后随着每次创建,修改,删除对象单调递增。服务器负责记录每个账户的更新次数,也就是最大的那个USN

 

任何时候,印象笔记同步服务都能够通过账户的USN来同步对象。同步时,客户端只能同步与上次同步记录相比拥有较新USN的对象。也就是说,只有服务端对象的USN比本地记录的要大时才可以同步成功。

 

这个目标在加入上面的需求3~5后情况会变得有些复杂。为了实现这些需求,同步协议必须允许客户端在同步期间不锁住服务的情况下能够请求较小的数据(对象)块。协议必须能够处理在发送数据块到客户端期间服务状态发生变化的情况,不论是由于网络错误引起的,还是由于数据大小/传输速度引起的。

 

前面提到过,服务端将所有记录同步和解决冲突的工作放到了客户端一侧来处理,以便于服务器实现可伸缩的“无状态(stateless)”管理。这意味客户端需要在每次同步时记录服务器的状态,然后在下次同步时利用这些信息获取新的同步。从更高层次来说,客户端需要完成以下几点:

  1. 获取自上次同步之后服务端更新/修改过的文件列表
  2. 同步服务端的修改至本地数据库
  3. 发送客户端未同步的更新到服务端
  4. 记录服务端的状态以便于下次更新

 

为了在客户端上区分与服务端保持一致的对象和新增/修改过的对象,客户端必须为每个已经修改过的对象设置一个“脏”标志。含有“脏”标志的对象构成了下次同步时必须推送到服务端的对象列表(当然必须在解决冲突之后)。

 

客户端应该支持任何时候都能够执行全同步操作,即使当前理论上只需要执行增量同步。

同步流程伪代码描述

以下的伪代码描述了客户端与服务端的同步流程。

服务端变量:

  • updateCount  - 账户当前最大的USN
  • fullSyncBefore  - 自上次完全同步以来,客户端仍可保持增量同步的截止日期。这个值代表了本地历史数据被清除的日期,或者有可能是服务器端一个严重问题将导致客户端USN失效的日期

译注:印象笔记中被永久删除的对象的GUID仍会保留一段时间才会被永久删除,由以上描述可推测fullSyncBefore代表这些GUID可能被永久删除的时间;如果客户端长时间不执行更新,那客户记录的USN将远落后于服务端,服务端有可能将该USN识别为无效USN

客户端变量:

  • lastUpdateCount  - 上次同步时客户端获取的服务端updateCount变量
  • lastSyncTime - 上次同步的时间(由服务端给出)

译注:

由以上的变量可大致推测印象笔记的同步策略:

  1. 客户端从未同步,则执行完全同步,同时服务端会生成一个fullSyncBefore,表示在fullSyncBefore代表的截止日期之前,客户端都可以执行增量同步。
  2. 未超出同步截止日期,则可执行增量同步。超出截止日期,则服务端强制执行完全同步,同时忽略本地的任何修改。
  3. 任何时候客户端都可以强制执行完全同步。

 

 

认证:

  1. 通过authenticate(username,pwd,key,secret) 执行认证,使用HTTPS协议。
    1. 收到authenticationToken字符串用于后续所有操作。
    2. 记录authenticationToken过期时间,当Token快过期时,需要调用refreshAuthentication()重新获取Token

 

同步状态:

  1. 客户端从未执行过同步时,执行完全同步。
  2. 通过getSyncState()获取服务端的updateCountfullSyncBefore值。
    1. 如果(fullSyncBefore > lastSyncTime),继续执行完全同步。
    2. 如果(updateCount = lastUpdateCount),已是最新,无需同步。跳到更新修改。
    3. 否则,执行增量同步(跳到增量同步)。

 

完全同步:

  1. getSyncChunk(, afterUSN=0, maxEntries)从服务端获取第一块数据对象。服务器返回最多maxEntries个对象(可以是任何类型)的元数据,从帐户内修改过的距今最久的对象开始。这些元数据包含了“小”对象的完整状态,所谓“小”对象是指例如标签、保存的搜索之类的对象,它们只包含笔记和资源的元数据,而不包含长度和MD5信息。长度和MD5需要后续再进行获取。作废(被永久删除的)对象只包含引用(GUID)。
    1. 如果数据块的chunkHighUSN被设置并且小于数据块的updateCount,缓存该数据块并且重复步骤4通过afterUSN=chunkHighUSN再次请求下一个数据块。(即使将两次请求有时间间隔操作也是安全的)。如果数据块的chunkHighUSN未被设置,则表明账户内已经没有更多的数据了,算法应在记录最后一个(空的)数据块的updateCount之后结束。
  2. 依次处理缓存的数据块以构建出服务端的状态:
    1. 建立同步块内的服务端标签列表(tags,通过GUID区分)。按顺序搜索数据块中的标签,依次添加到列表中,同时移除已经作废的标签。
      1. 如果某个标签存在于服务端但客户端中无此记录,则将该标签新增到客户端的数据库。如果客户端有相同标签相同但两者GUID不同,则:
        1. 如果客户端标签被设置“脏”标志,表示用户在服务端已经创建了这个标签的情况下,又在离线的客户端中也创建了这个标签,此时应按域进行合并或者报告冲突。
        2. 否则,将客户端中的标签进行重命名(例如添加后缀2
      2. 如果标签存只在于客户端,不在服务端上:
        1. 如果客户端标签无“脏”标志,或者之前已经被上传到服务端了,那么将该标签从客户端删除。
        2. 否则,该标签被认为是客户端新创建的标签,应同步到服务端。
  • 如果标签同时存在于客户端和服务端:
    1. 如果它们有相同的USN并且无“脏”标志,则无需同步;
    2. 如果有相同的USN,但有“脏”标志,那么将客户端的标签同步到服务端。
    3. 如果服务端的标签有更高的USN值并且客户端的标签无“脏”标志,则更新客户端标签的状态。(需按以上步骤解决命名冲突)
    4. 如果服务端的标签有更高的USN值并且客户端的标签有“脏”标志,则表示该标签在服务端和客户端都修改过了,此时应按域进行合并,或报告冲突。
  1. 重复以上算法更新保存的搜索。
  2. 重复以上算法更新所有的笔记本。如果客户端删除了笔记本,则对应所有的笔记和资源文件都会被删除。
  3. 重复以上算法更新所有的共享笔记本。共享笔记本是指向其他账户笔记本的链接,所以它们和本账户内的其他数据无任何直接关联。
  4. 重复以上算法更新所有的笔记。笔记的内容并不和同步块一起传送,所以如果有新笔记,或者现有笔记被更新(通过笔记元数据中的MD5和长度值判断),需要单独使用getNoteContent(…)进行获取。相同的操作也适用于资源文件和识别文本。笔记的标题无需唯一,所以不需要标题冲突时无需处理。
  1. 同步完成之后,客户端将服务端的updateCount变量记录到lastUpdateCount,同时将服务端的时间记录到lastSyncTime
  2. 执行更新修改。

增量同步:

  1. 执行步骤4以构建同步块,但afterUSNlastUpdateCount开始。
  2. 按顺序处理缓存的数据块,将数据对象从服务端新增/更新到客户端:
    1. 建立同步块内的服务端标签列表(tags,通过GUID区分)。按顺序搜索数据块中的标签,依次添加到列表中,同时移除已经作废的标签。
      1. 如果某个标签存在于服务端但客户端中无此记录,则将该标签新增到客户端的数据库。如果客户端有相同标签相同但两者GUID不同,则:
        1. 如果客户端标签被设置“脏”标志,表示用户在服务端已经创建了这个标签的情况下,又在离线的客户端中也创建了这个标签,此时应按域进行合并或者报告冲突。
        2. 否则,将客户端中的标签进行重命名(例如添加后缀2
      2. 如果标签同时存在于客户端和服务端:
        1. 如果客户端的对象没有“脏”标志,则更新客户端标签的状态。(需解决命名冲突)
        2. 如果客户端的对象有“脏”标志,则该对象在服务端和客户端都被修改了,此时应按域进行合并,或报告冲突。
      3. 重复以上算法更新资源文件。(特别是接收服务端已识别的资源识别和可选数据)
      4. 重复以上算法更新保存的搜索。
      5. 重复以上算法更新笔记本。
      6. 重复以上算法更新共享笔记本。
      7. 重复以上算法更新所有的笔记。笔记的内容并不和同步块一起传送,所以如果有新笔记,或者现有笔记被更新(通过笔记元数据中的MD5和长度值判断),需要单独使用getNoteContent(…)进行获取。相同的操作也适用于资源文件和识别文本。
    2. 按顺序处理缓存的数据块,根据服务端对客户端执行删除工作:
      1. 从数据块中提取作废笔记的GUID,依次删除。
      2. 对笔记本执行相同的操作。删除一个笔记本意思着对应的所有笔记和资源文件都被删除。
      3. 对保存的搜索执行相同的操作。
      4. 对标签执行相同的操作。
      5. 对共享笔记本执行相同的操作。
    3. 同步完成之后,客户端将服务端的updateCount变量记录到lastUpdateCount,同时将服务端的时间记录到lastSyncTime
    4. 继续执行更新修改。

更新修改

  1. 对每个设置了“脏”标志的标签:
    1. 如果该标签是新增的(即该标签无USN记录),则通过createTag(…)将其更新到服务端。如果服务端返回冲突(客户端在处理完最后本次同步的最后一个数据块时恰好另一个客户端更新了该标签——该情况不太容易出现),则客户端应在本地先处理冲突。如果服务端返回的GUID与请求的GUID不一致,则修改本地的GUID以适配服务端。
    2. 如果标签被修改了(即该标签有USN记录),则通过updateTag(…)将其更新到服务端,同样先需要解决冲突。
    3. 其他情况,检查服务端返回的USN
      1. 如果USN = lastUpdateCount + 1,则客户端与服务端仍保持同步。更新lastUpdateCount使之与服务端保持一致。
      2. 如果USN > lastUpdateCount + 1,则客户端与服务端仍未同步,需要在更新修改之后再执行一次增量同步。
    4. 对含有“脏”标志的保存的搜索执行相同的操作。
    5. 对含有“脏”标志的笔记本执行相同的操作。
    6. 对含有“脏”标志的笔记执行相同的操作。客户端应该传递笔记的所有数据(笔记内容,资源数据,识别文本)到服务端,使用createNote(…)函数,并且其中任何一部分更新之后调用NoteStore.updateNote(…)进行上传时,都应该传送所有数据。也就是说,笔记是按照一整条消息进行上传的,其组成部分不能单独上传。

完全同步流程图

增量同步流程图

共享笔记本同步

译注:

由于众所周知的原因的,国内版的印象笔记的关闭了共享笔记本功能,所以本章内容只对印象笔记国际版有效。

To be continued…

参考链接

《印象笔记开发者-数据模型》https://dev.yinxiang.com/doc/articles/data_structure.php

  • 无标签