“加一个导出按钮”很容易被理解成复制数据库文件。可器材簿正在写入时,数据可能还关联着日志文件;一份能看到扩展名的文件,也不代表其中的表、版本和库存关系适合恢复。系列最后一篇从恢复决策倒推备份设计:先确认什么样的数据可以接管当前库,再安排文件生成、隔离预检、覆盖前保留和恢复后的重新查询。

器材簿备份与恢复工程封面

一、先限定恢复承诺

本篇使用前四篇的 API 14 工程和数据版本二,采用官方 RdbStore.backup() 与 restore()。范围是当前应用沙箱内的受控备份,不开放任意外部数据库导入,也不把这份备份称为云端同步。文件留在应用沙箱内,卸载清除应用数据时仍可能丢失,因此它解决的是当前数据误操作后的回退,不等于独立介质上的灾难恢复。

恢复意味着用过去的完整状态覆盖当前状态,不是把旧流水追加到新流水。备份之后发生的借出、归还和区域编辑都会被回退。界面必须在用户确认前说明这一点,不能只显示一个含义模糊的“导入”。如果产品希望保留新旧两份数据,应设计合并规则、身份冲突处理和审计记录,那是另一种功能。

我们允许恢复的候选数据必须满足三个条件:结构版本为二,必需字段可以查询,器材库存与未归还流水一致。版本判断避免把旧结构直接交给新代码;字段探测避免只相信版本数字;业务检查避免恢复一份结构合法、内容却已经失衡的账本。文件大小、后缀或修改时间都不能替代这些条件。

还有一个运行边界:本系列所有数据库操作都经过同一仓库队列,结果集在方法返回前关闭,恢复页打开期间没有其他进程写库。恢复需要暂停新业务操作,不能一边让用户继续借器材,一边把整个数据库替换成旧版本。多进程产品应另行设计维护协调机制,本篇的进程内队列不承担跨进程锁的角色。

评审恢复功能时,可以反向问三个问题:用户要回到哪一个时刻,哪些后续变化会被放弃,失败后还能保留哪一份状态。答案确定之后,按钮名称、确认文案和文件保留策略才有依据。单独讨论文件复制速度,无法回答这些产品与数据共同承担的问题,也容易让备份功能在真正需要时变得难以使用。

二、候选数据先进入隔离库

把下面方法加入 data/GearRepository.ets。沿用第一篇的导入,common、relationalStore 和 BusinessError 都已经存在。备份路径使用已有的 filesDir,不依赖手工拼接数据库内部目录。隔离库使用独立名称,不进入公共 GearRepository.open(),因此预检不会运行初始化种子或自动迁移,从而掩盖候选文件的真实状态。

private async scalar(store: relationalStore.RdbStore, sql: string): Promise<number> {
  const rs = await store.querySql(sql);
  try {
    if (!rs.goToNextRow()) { throw new Error('缺少校验结果'); }
    return rs.getLong(0);
  } finally { rs.close(); }
}

private async assertSnapshot(store: relationalStore.RdbStore): Promise<void> {
  const version = await this.scalar(store, 'SELECT VERSION FROM SCHEMA_META WHERE ID = 1');
  if (version !== 2) { throw new Error('备份数据版本不匹配'); }
  const fields = await store.querySql('SELECT ID, NAME, TOTAL, AVAILABLE, AREA FROM GEAR LIMIT 0');
  fields.close();
  const loanFields = await store.querySql('SELECT ID, GEAR_ID, BORROWER, REQUEST_KEY, ' +
    'CREATED_AT, RETURNED_AT FROM LOAN LIMIT 0');
  loanFields.close();
  const invalid = await this.scalar(store,
    'SELECT COUNT(*) FROM GEAR G WHERE G.TOTAL < 0 OR G.AVAILABLE < 0 ' +
    'OR G.AVAILABLE > G.TOTAL OR G.TOTAL - G.AVAILABLE <> ' +
    '(SELECT COUNT(*) FROM LOAN L WHERE L.GEAR_ID = G.ID AND L.RETURNED_AT = 0)');
  const orphan = await this.scalar(store,
    'SELECT COUNT(*) FROM LOAN L LEFT JOIN GEAR G ON G.ID = L.GEAR_ID WHERE G.ID IS NULL');
  if (invalid !== 0 || orphan !== 0) { throw new Error('备份中的库存与流水不一致'); }
}

private async preflight(context: common.UIAbilityContext, path: string): Promise<void> {
  const config: relationalStore.StoreConfig = {
    name: 'gear_probe.db', securityLevel: relationalStore.SecurityLevel.S1
  };
  const probe = await relationalStore.getRdbStore(context, config);
  try {
    await probe.restore(path);
    await this.assertSnapshot(probe);
  } finally {
    try { await probe.close(); } catch (closeError) {
      console.error(`预检连接清理异常:${(closeError as BusinessError).code}`);
    }
  }
}

createBackup(context: common.UIAbilityContext): Promise<void> {
  return this.serial(async () => {
    await this.assertSnapshot(this.store);
    const path = context.filesDir + '/gear_manual.db';
    await this.store.backup(path);
    await this.preflight(context, path);
  });
}

预检使用数据库接口真正打开候选内容,再执行结构与业务查询。它不会把“备份调用完成”直接等同于“恢复路径可用”。隔离库里的数据属于受控临时副本,使用后关闭连接;需要清理空间时,可以在连接关闭之后通过 deleteRdbStore() 删除该库,不能在连接仍被查询时直接删除底层文件。

gear_manual.db 是一个明确的手动备份槽位。按钮旁固定显示“再次创建会更新当前备份”,让覆盖含义在操作之前可见;要保留多个时间点,则改成有保留上限的版本化文件,并持久化对应清单。示例先保持单槽位,避免把文件选择、目录授权和备份版本管理混进恢复正确性这一个问题里。

这里检查的是本应用自己产生的备份,不能据此接收任意来源的数据库文件。外部文件还涉及来源、大小限制、权限、内容约束和恶意数据处理,预检条件需要更严格。把函数命名为预检,是为了明确它只证明当前检查项通过,不是对所有可能的异常作出保证。

校验条件应随着模型一起维护。以后增加单件资产编号、报废状态或者多人审批,只检查数量守恒就不再充分,候选数据还需要满足新的关联规则。备份格式也应保留可识别的版本,恢复代码只能接受自己明确理解的结构。向后兼容可以通过隔离库迁移实现,但不应在正式库上边猜边改。

DevEco Studio 中的候选备份预检代码

三、覆盖之前留一条明确退路

恢复先预检手动备份,再把当前库保存到另一个槽位 gear_before_restore.db。候选文件通过预检,不意味着后续写入不可能失败;覆盖前快照为恢复过程中出现的异常提供回退起点。两个路径必须不同,否则保存当前库时就会把真正想恢复的旧备份覆盖掉。

private blocked: boolean = false;

restoreBackup(context: common.UIAbilityContext): Promise<void> {
  return this.serial(async () => {
    const source = context.filesDir + '/gear_manual.db';
    const safety = context.filesDir + '/gear_before_restore.db';
    await this.preflight(context, source);
    await this.store.backup(safety);
    await this.preflight(context, safety);
    try {
      await this.store.restore(source);
      await this.assertSnapshot(this.store);
    } catch (error) {
      try {
        await this.store.restore(safety);
        await this.assertSnapshot(this.store);
      } catch (recoveryError) {
        this.blocked = true;
        console.error(`恢复回退异常:${(recoveryError as BusinessError).code}`);
      }
      throw error as Error;
    }
  });
}

同时用下面版本替换第一篇的 serial()。检查放在队列真正开始执行时,而不是调用方法时;这样恢复失败后已经排队的借用操作也会被阻止。异常仍然返回调用方,队尾本身恢复可等待状态。维护标记只存在于当前进程,重启后的业务一致性校验仍然必须执行,不能把重启当作修复。

private serial<T>(work: () => Promise<T>): Promise<T> {
  const next = this.tail.then(async (): Promise<T> => {
    if (this.blocked) { throw new Error('数据需要维护,已暂停操作'); }
    return work();
  });
  this.tail = next.then(() => {}, () => {});
  return next;
}

在第一篇 create() 中,依次等待 repository.initialize()、第三篇增加的 repository.migrateToV2(),再增加 await repository.assertSnapshot(store),最后返回仓库,让每次新进程打开时都检查业务关系。如果检查失败,仓库不对页面开放。这样即使进程内维护标记消失,也不会让存在库存差异的数据库继续接受借还。初始化阶段仍然不启用自动删库重建,保留问题数据是后续恢复的重要前提。

这段补偿处理不是跨文件原子事务。应用在恢复中途被终止、电量耗尽或存储设备异常时,仍然需要启动检查与恢复策略配合。更完整的产品可以持久化维护阶段、候选文件标识和恢复检查结果,让下次启动知道停在哪里;不要因为代码里有 catch 就把所有中断都称作可自动恢复。

如果数据库已经无法正常打开,普通业务仓库也就不应该继续初始化。那时需要独立的维护入口,在不执行种子写入的条件下选择已验证备份;本篇页面针对的是当前库可以打开的主动回退。把主动回退与损坏救援分开,能够避免一个“恢复”按钮同时承担互相冲突的初始化策略。

预检、覆盖前快照与失败回退的代码批注

四、恢复页要让覆盖动作可理解

下面替换 pages/Index.ets。页面先创建本地备份,再允许用户勾选覆盖确认并恢复这份备份;新进入页面时恢复按钮默认停用。这个最小入口覆盖当前操作会话,既有备份的管理列表可以随后扩展。恢复完成后回到器材列表时,应重新查询,同时丢弃第四篇保存的分页游标与上界。

import { common } from '@kit.AbilityKit';
import { GearRepository } from '../data/GearRepository';

@Entry
@Component
struct Index {
  @State status: string = '先创建一份本地备份';
  @State saved: boolean = false;
  @State confirmed: boolean = false;
  @State busy: boolean = false;

  async perform(restore: boolean): Promise<void> {
    if (this.busy || (restore && (!this.saved || !this.confirmed))) { return; }
    this.busy = true;
    if (!restore) { this.saved = false; }
    try {
      const context = getContext(this) as common.UIAbilityContext;
      const repository = await GearRepository.open(context);
      if (restore) {
        await repository.restoreBackup(context);
        this.status = '恢复完成,请重新进入器材列表';
      } else {
        await repository.createBackup(context);
        this.saved = true;
        this.status = '本地备份已就绪';
      }
    } catch (error) {
      this.status = '操作未完成,请检查备份与数据状态';
    } finally { this.busy = false; this.confirmed = false; }
  }

  build() {
    Column({ space: 20 }) {
      Text('备份与恢复').fontSize(28)
      Text('数据版本 2')
      Text('备份范围:器材库存与借还流水')
      Text('恢复策略:预检后替换')
      Text(this.status).fontColor('#475569')
      Button('创建本地备份').enabled(!this.busy).onClick(() => { this.perform(false); })
      Text('再次创建会更新当前备份').fontSize(14).fontColor('#64748B')
      Row({ space: 10 }) {
        Checkbox().select(this.confirmed)
          .onChange((value: boolean) => { this.confirmed = value; })
        Text('我知道恢复会覆盖当前数据').fontSize(14)
      }
      Button('恢复这份备份').enabled(this.saved && this.confirmed && !this.busy)
        .onClick(() => { this.perform(true); })
    }.padding(24).width('100%').height('100%').backgroundColor('#F5F7FA')
  }
}

界面中的完成提示要对应最后一个必要步骤。备份文件写完但预检失败,不能显示备份可用;恢复调用返回但业务校验失败,也不能显示恢复完成。这里把提示放在整条方法结束之后,减少用户把中间状态当成最终结果的机会。异常信息可以在开发日志中保留错误码,面向用户的提示则聚焦下一步操作。

恢复以后不复用旧数组,不继续旧游标,也不保留“最近一次借用编号”作为操作依据。整份数据库回到了过去,某些编号可能已经不存在,某些记录的归还状态也已经改变。重新进入业务页面是一种简单明确的状态重建方式,比逐个猜测哪些组件需要清空更容易维护。

五、用恢复演练结束这条工程主线

先在三类器材完整的状态创建备份,再借出一支麦克风,确认可借量变为三。执行恢复后重新查询,预期回到备份时的四支,备份之后产生的借用流水不再存在。这个结果不是丢数据的缺陷,而是完整状态覆盖的定义;用户确认文案必须与这个结果一致。

接着在测试环境准备一份版本不匹配或库存不平衡的候选文件,观察隔离预检应阻止它进入正式库。还应检查备份不存在、可用空间不足和恢复失败时的提示,确认不会为了消除错误而自动创建一份空库。回退过程失败时,后续借还应进入维护状态,而不是继续对不确定的数据做增减。

最后应把保留空间纳入正常运行观察。手动备份、覆盖前快照和隔离库可能同时占用存储,不能只估算正式数据库的大小。清理策略应优先删除确认不再需要的临时副本,保留仍可能用于恢复的文件;清理失败只报告空间问题,不应该连带删除当前库。功能做完之后还要能够解释每一份副本为何存在。

至此,器材簿的五篇主线形成一个完整边界:初始化决定何时可用,事务维护库存与流水,迁移维护结构承诺,游标维护浏览范围,备份恢复维护跨时间的状态切换。每个部分都服务于具体业务约束,也都保留了明确的适用范围。继续扩展扫码、多人协作或外部导出时,应沿着这些边界增加能力,而不是把存储操作重新塞回页面按钮。

官方参考:关系型数据库备份与恢复、API 14 backup、restore 与 close、备份路径与恢复说明。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐