blog icon indicating copy to clipboard operation
blog copied to clipboard

离线数据同步的具体实现思路

Open lmk123 opened this issue 4 years ago • 0 comments

我最近要做一个离线数据同步的的功能,这次把我的实现过程记录下来。

场景

一个待办事项 APP,可以离线使用、支持多个端,要求是多个端的数据都是同步的。

大致思路

  • 用户登录时,从服务器下载全部数据到客户端。
  • 之后用户在客户端增删改数据时,把每种操作都记录下来;等到上传本地变更的时候,收集所有改动并发送到服务器,服务器将这些改动应用到数据库的同时,再用另一张表记录这些变更;
  • 更新数据时,本地客户端将上次更新数据的时间(至少也是第一次完全下载数据的时间)发送给服务器,服务器返回此时间节点后的所有变更给客户端。

按照这个思路,我们就可以把示例代码写出来了:

async function sync() {
  // 读取上次同步数据的时间
  let lastSyncTime: number | undefined = getLastSyncTime()
  // 用户登录之后,这个时间肯定是没有的,所以此时需要从服务器下载完整数据
  if (!lastSyncTime) {
    const { data, serverTime } = await getDataFromServer()
    // 将数据保存到客户端的数据库
    saveToLoaclDB(data)
    // 设置上次同步数据的时间
    lastSyncTime = serverTime
    setLastSyncTime(serverTime)
  }
  // 收集客户端数据库的变更
  const localChanges = await getLoaclDBChanges()
  // 将变更和上次检查更新的时间都发送给服务器,服务器会返回变更日志和服务器时间
  const { remoteDBChanges, serverTime } = await getRemoteDBChanges(localChanges, lastSyncTime)
  // 保存 serverTime 作为下次检查更新的时间点
  setLastSyncTime(serverTime)
  // remoteDBChanges 其实就是由客户端数据库变更组成的数组,在使用之前,我们需要合并这些变更。
  // 比如用户可能在另一个端新增了一条数据但随后又删除了它,那么在当前客户端的数据库中其实啥都不用做
  const allChanges = remoteDBChanges.reduce(
    (result, cur, index, array) => {
      // 合并新增列表
      cur.added.forEach((_id) => {
        if (!result.added.includes(_id)) {
          result.added.push(_id)
        }
      })
      // 合并删除列表
      cur.deleted.forEach((_id) => {
        // 先检查这个被删除的 id 是否在新增列表中,
        // 在的话说明在这一次更新当中,用户新增了一条记录又删除了,
        // 本地数据库无需做重复的操作
        const i = cur.added.indexOf(_id)
        if (i >= 0) {
          cur.added.splice(i, 1)
        }
        // 不在才合并进删除列表中
        else if (!result.deleted.includes(_id)) {
          result.deleted.push(_id)
        }
      })
      return result
    },
    { add: [], del: [] }
  )
  // 在客户端数据库中添加服务器数据库新增的数据
  const newTodos = await getDataFromServer(allChanges.add)
  saveToLocalDB(newTodos)
  
  // 在客户端数据库中删除服务器数据库删除的数据
  deleteLocalDB(allChanges.del)
}

接下来,我们再来一一实现这段代码中的各个功能函数。

读取 / 设置上次更新时间 getLastSyncTime / setLastSyncTime

这两个方法由客户端提供的能力来实现。比如在网页端,我们可以把这个时间用 localStorage 来保存:

function getLastSyncTime(): number | undefined {
  return Number(localStorage.getItem('lastSyncTime'))
}
function setLastSyncTime(time: number) {
  localStorage.setItem('lastSyncTime', time)
}

保存 / 删除客户端数据 saveToLocalDB / deleteLocalDB

这两个方法同样由客户端提供的能力来实现。比如在网页端,我们可以用 IndexedDB 来保存数据。下面的示例代码使用了 Dexie.js 来操作 IndexedDB:

function saveToLocalDB(todos: ITodoItem[]) {
  db.todos.bulkAdd(todos)
}
function deleteLoaclDB(_ids: ITodoItem['_id'][]) {
  db.todos.where('_id').anyOf(_ids).delete()
}

ITodoItem 的结构会在后面介绍 getLocalDBChanges 的实现时讲到。

从服务器获取数据 getDataFromServer

服务器端需要有一个能获取数据的接口,用于在用户登录的时候将服务器的所有数据下载到客户端或者在更新时下载特定 id 的数据,代码示例如下:

// _ids 是由服务器 id 组成的数组,如果不传,则下载全部数据
async function getDataFromServer(_ids?: ITodoItem['_id'][]): Promise<{ data: ITodoItem[], serverTime: number }> {
  return (await axios.get('/Todo', { params: { _ids } })).data
}

收集客户端数据库的变更 getLocalDBChanges

在实现这个方法时,就要涉及到数据的结构了。假设我们用如下的结构表示一条待办数据:

interface ITodoItem {
  id: number // 在客户端数据库中的唯一 id
  title: string // 标题
  createAt: number // 客户端创建这条待办事项的时间戳
}

但为了能收集到客户端数据库的变更,我们还需要添加一些字段。

同步状态字段 syncStatus

当用户在客户端新增了一条待办事项时,我们需要一个字段用于表示这条数据还没有同步到服务器端。我们可以加一个 syncStatus 字段,0 表示未同步,1 表示已同步,用 JSON 表现形式如下:

{
  "id": 0,
  "title": "今晚打老虎",
  "createAt": 1607604397839,
  "syncStatus": 0
}

服务器数据库 id 字段

如果一条数据已经上传到了服务器,我们还需要记录下这条数据在服务器数据库中的 id。当然,也可以由客户端生成数据的 id 并保存到服务端,这样客户端和服务器端的 id 就是一致的,但这里我们暂时假设服务端对每条同步的数据都另外生成了 id,为了跟客户端数据的 id 做区分,我们用 _id 表示服务器数据库的 id。

所以如果一条代办事项数据已经保存在服务器中了,我们再添加一个 _id 字段保存这条待办事项在服务器数据库中的 id,用 JSON 的表现形式是这样的:

{
  "id": 1,
  "_id": "服务器 id 0",
  "title": "赏花赏月赏秋香",
  "createAt": 1607604346956,
  "syncStatus": 1
}

标记为已删除的字段

当我们在客户端删除已经保存在服务器数据库中的数据时,我们只能将这条数据标记为已删除,不能真的删除它,这样当我们收集客户端数据库变更的时候就能收集到用户的删除操作。我们再新增一个 deleted 字段用于将一条已经同步到数据库中的数据标记为已删除:

{
  "id": 2,
  "_id": "服务器 id 1",
  "title": "但愿人长久",
  "createAt": 1607604334965,
  "syncStatus": 0,
  "deleted": true
}

小结

最后,ITodoItem 变成了这样:

interface ITodoItem {
  id: number // 在客户端数据库中的唯一 id
  title: string // 标题
  createAt: number // 客户端创建这条待办事项的时间戳
  syncStatus: number // 0 表示未同步,1 表示已同步
  _id?: string // 服务器数据库中此条数据的 id,有这个字段则说明这条数据已经同步到服务器数据库了
  deleted: boolean // 如果是 true,则表示这条数据被标记为了删除
}

同时,我们在操作客户端数据库时需要注意以下几点:

  • 查询数据时,需要从结果集中剔除掉 deletedtrue 的数据;
  • 新增数据时,需要将 syncStatus 设置为 0
  • 删除数据时,如果这条数据有 _id,则将 syncStatus 设置为 0deleted 设为 true;如果没有 _id,可以直接删除。

修改数据的情况暂时不在这篇文章的讨论范围之内,但看完之后,加上修改数据的同步也不是难事。

具体实现

有了前面添加的这些字段,我们就可以很容易收集到客户端数据库的变更了,示例代码如下:

async function getLoaclChanges(): Promise<{ add: ITodoItem[], del: ITodoItem['_id'][] }> {
  const todos = await db.where('syncStatus').equal(0) // 从本地数据库中查询所有 syncStatus 为 0 的待办事项
  const add = [] // 用于保存新增的数据的数组
  const del = [] // 用于保存删除的数据的数组
  
  todos.forEach(todo => {
    if (todo._id) {
      // 如果数据有 _id 且被标记为已删除,则将这条数据的服务器 id 保存下来
      if (todo.deleted) del.push(todo._id)
    } else {
      // 如果这条数据没有 _id,则表示这是客户端新增的数据
      add.push(todo)
    }
  })

  return { add, del }
}

上传客户端数据库变更并获取服务器数据库的变更日志 getRemoteDBChanges

服务器需要提供一个接口,接收客户端提交过来的数据库变更和此客户端上次检查更新的时间,服务器则返回这段时间内服务器数据库发生的所有变更和服务器时间。

示例代码如下:

async function getRemoteDBChanges(localChanges, lastSyncTime): Promise<{
  remoteDBchanges: { add: ITodoItem['_id'][], del: ITodoItem['_id'][] }[]
  serverTime: number
}> {
  return (await axios.put('/todo/sync', { localChanges, lastSyncTime })).data
}

总结

到了这里,整个实现思路就完成了,但还有一些细节需要完善:

  • 随着对服务器数据库的操作逐渐变多,记录操作日志的数据表会越来越大,但其实旧的操作日志是可以删除的,但什么时间点之前的操作日志可以安全的删除,我还没有想到。
  • 修改数据的同步没有实现,不过按照文章中的思路实现一个也不难。在多个端同时修改一条数据可能还会产生冲突,不过这可能是另外一篇文章要介绍的事情了。
  • 同一个端登录多个账号的情况需要考虑进去。比如在同一台手机上,未登录的情况下,用户产生了一些数据;然后用户 A 登录了,产生了一些数据,此时将账号切换为用户 B 之后又产生了一些数据……

抛砖引玉,欢迎大家给出不同的看法。

lmk123 avatar Dec 10 '20 14:12 lmk123