在这里插入图片描述
在这里插入图片描述

实例:数据备份导出(Backup)|技术:backup/restore、JSON/CSV 导出、文件读写

一、备份与恢复的完整闭环

备份导出的数据流是一个闭环:

导出:数据库(backup_source) → 序列化(JSON/CSV) → 沙箱文件(backup_xxx.json) → 日志(backup_log)
恢复:备份内容(JSON字符串) → 清空数据源表 → 重新插入(事务) → 完成

本篇文章把闭环的两端——doBackup(完整备份)restoreJson(恢复导入)——讲透,包括文件删除的细节。

二、doBackup:一次完整备份的四步流水线

doBackup 把「查询 → 序列化 → 写文件 → 记日志」四步串成一次完整备份:

static async doBackup(context: common.Context, format: string): Promise<BackupRecord> {
  const store = await BackupDao.getStore(context);
  const now = Date.now();
  const fileName = `backup_${now}.${format}`;   // 唯一文件名:时间戳
  let content = '';
  if (format === 'json') {
    content = await BackupDao.exportJson(context);
  } else {
    content = await BackupDao.exportCsv(context);
  }
  // 写文件到沙箱
  const file = await BackupDao.writeFile(context, fileName, content);
  // 写备份日志
  const values: relationalStore.ValuesBucket = {
    file_name: fileName,
    size: file.size,
    source_table: BackupDao.SOURCE_TABLE,
    backup_time: now,
    type: format,
  };
  const id = await store.insert(BackupDao.TABLE, values);
  const record: BackupRecord = {
    id: id, fileName: fileName, size: file.size,
    sourceTable: BackupDao.SOURCE_TABLE, backupTime: now, type: format,
  };
  return record;
}

四步拆解

步骤 实现 产出
1. 生成文件名 backup_${Date.now()}.${format} 时间戳保证唯一
2. 序列化 exportJson / exportCsv 分支 JSON/CSV 文本
3. 写文件 writeFile(10-1 讲过) 沙箱文件 + 大小
4. 记日志 insert backup_log BackupRecord 实体

文件名用时间戳backup_1750000000000.json——毫秒时间戳保证「每次备份文件名唯一」,同名覆盖不会发生(除非同一毫秒点两次,几乎不可能)。唯一文件名是备份系统的基本要求——否则两次备份会互相覆盖。

返回 BackupRecord:完整返回「这次备份的元数据」,页面用它更新日志区(文件名 + 大小)和记录列表。

三、restoreJson:恢复导入的事务实现

恢复导入是最「危险」的操作——要清空现有数据再导入备份内容,中途失败必须回滚(否则数据源表半空):

static async restoreJson(context: common.Context, json: string): Promise<number> {
  const store = await BackupDao.getStore(context);
  const parsed: SourceJsonRow[] = JSON.parse(json);  // 反序列化
  let count = 0;
  try {
    await store.beginTransaction();
    // 1. 清空数据源表
    const delPred = new relationalStore.RdbPredicates(BackupDao.SOURCE_TABLE);
    await store.delete(delPred);
    // 2. 逐条重新插入
    for (const r of parsed) {
      const values: relationalStore.ValuesBucket = {
        name: r.name, category: r.category, amount: r.amount,
        note: r.note, created_time: r.createdTime,
      };
      await store.insert(BackupDao.SOURCE_TABLE, values);
      count++;
    }
    await store.commit();
  } catch (e) {
    try {
      await store.rollBack();
    } catch (e2) {
      // 忽略
    }
    hilog.error(DOMAIN, TAG, `恢复导入失败: ${e}`);
  }
  return count;
}

恢复三步

  1. 反序列化JSON.parse(json) 把 JSON 文本还原成对象数组——注意 SourceJsonRow 显式接口标注解析结果类型;
  2. 清空 + 重插:事务内先 DELETE 全表再循环 INSERT——「全量替换」语义,恢复后数据源表 = 备份时的快照;
  3. 事务保障:清空后任何一条插入失败 → rollBack → 数据源表恢复原状(清空操作也被回滚)——恢复导入必须事务,否则失败会造成数据丢失。

为什么先清空再插入而不是 UPDATE 覆盖? 备份是「快照」——恢复 = 让当前数据等于备份时数据。如果只更新相同 id 的行,备份后新增的行会残留(数据源表比快照多几行),快照语义被破坏。全量替换(清空 + 重插)才是恢复的正确语义

SourceJsonRow 接口:JSON 反序列化的对象需要显式类型——ArkTS 的 JSON.parse 返回 any,用接口标注后字段访问有类型保障。

恢复导入的页面演示:本实例的恢复导入其实是用「当前数据源」重新走一遍(exportJsonrestoreJson)——演示流程闭环,真实场景应从备份文件读内容恢复。页面代码里 doRestore 是「导出当前 → 恢复当前」,读者可改成「读文件 → 恢复」。

四、deleteBackup:数据库记录 + 沙箱文件双删

删除备份要同步清理「日志记录」和「沙箱文件」:

static async deleteBackup(context: common.Context, id: number, fileName: string): Promise<number> {
  const store = await BackupDao.getStore(context);
  // 1. 删数据库记录
  const predicates = new relationalStore.RdbPredicates(BackupDao.TABLE);
  predicates.equalTo('id', id);
  const r = await store.delete(predicates);
  // 2. 删沙箱文件
  try {
    const path = `${context.filesDir}/${fileName}`;
    const file = fileIo.openSync(path, fileIo.OpenMode.READ_WRITE);
    fileIo.closeSync(file);
    fileIo.unlinkSync(path);  // 删除文件
  } catch (e) {
    hilog.warn(DOMAIN, TAG, `删除文件失败(可能不存在): ${e}`);
  }
  return r;
}

文件删除的前置操作:先 openSynccloseSyncunlinkSync——为什么先打开?其实 unlinkSync(path) 可以直接删,这里先 open 是「检查文件存在」的间接方式(文件不存在时 openSync 抛异常进 catch)。更简洁的写法是直接 unlink + catch 吞掉「文件不存在」错误。

双删的顺序:先删数据库记录(业务索引),再删文件(内容)。即使文件删除失败(已 catch),数据库记录已清理,只是沙箱残留一个孤儿文件——主删除优先,文件删除容错

五、导出格式的对比与选择

维度 JSON CSV
可读性 结构化但专业 Excel 直接打开
恢复 JSON.parse 原生支持 需手工解析(无现成 API)
类型 保留数字/字符串类型 全是字符串
适用 程序间交换、恢复 人查看、导入表格工具

本实例的取舍:JSON 支持恢复(parse 原生),CSV 仅导出(给人看)。如果 CSV 也要恢复,需要写一个 CSV 解析器(按行 split + 按逗号 split)——读者可自行扩展。

六、技术要点对照表

技术点 实现方式 生产价值
完整备份 查询→序列化→写文件→记日志 四步流水线
唯一文件名 backup_时间戳.格式 防覆盖
恢复导入 清空 + 重插(事务) 快照语义
JSON 解析 JSON.parse + 接口标注 类型安全反序列化
双删 先删记录再删文件 无孤儿
unlink fileIo.unlinkSync 沙箱文件删除

七、常见问题 FAQ

Q1:恢复导入时 JSON.parse 失败(文件损坏)怎么办?
A:parse 抛异常会被 catch 捕获 → rollBack → 数据源表不变(清空也被回滚)。损坏的备份文件不会破坏现有数据——这正是事务的价值。页面应在 catch 分支提示「备份文件损坏」。

Q2:备份文件会占用多大空间?
A:12 条数据约 2KB。真实场景几千条也才几百 KB。沙箱空间充足(通常 GB 级),无需担心。可定期清理旧备份(保留最近 N 份,类似实例 7 的淘汰逻辑)。

Q3:沙箱文件能导出到用户可见位置吗(如相册/文档)?
A:可以,但需要权限与系统能力:通过 picker(文件选择器)或保存到公共目录(需用户授权)。本实例止步于沙箱(应用私有),生产环境按需求接入保存能力。

Q4:为什么恢复导入不用「读文件再恢复」而是「导出当前再恢复」?
A:演示闭环的简化——页面 doRestore 用当前数据源走「导出 → 恢复」,验证恢复逻辑正确性。真实场景应 fileIo.readSync 读取备份文件内容再 restoreJson。读者可自行把 doRestore 改成读文件版。

Q5:备份日志表会不会越来越大?
A:每次备份插一条,长期积累会膨胀。生产方案:保留最近 N 份 + 定时清理(DELETE WHERE id NOT IN (SELECT id ORDER BY backup_time DESC LIMIT N))——与实例 7 的淘汰同款。

Q6:CSV 里的中文会乱码吗?
A:文件以 UTF-8 写入(writeSync 默认 UTF-8),现代 Excel 打开 UTF-8 CSV 正常。老版本 Excel 可能需要 BOM 头(\uFEFF)——遇到乱码时在内容前加 BOM 即可。

八、文章小结

本篇文章讲解了备份导出的核心闭环:doBackup 四步流水线(查询→序列化→写文件→记日志)restoreJson 事务恢复(清空 + 重插的快照语义)deleteBackup 双删(记录 + 文件)。JSON/CSV 双格式满足「程序恢复 / 人查看」两种需求。备份系统的核心心法:写文件要有唯一名,恢复要有事务,删除要双清理

下一篇(10-4)展示 12 条数据源 + 4 条备份日志的种子数据,让终端一开屏就有「备份历史」可看。

九、备份核心逻辑实现:多表遍历与字符串拼接

前三节讲的是「备份的调度层」(doBackup 编排四步),本节深入序列化层——exportJson / exportCsv 内部如何读数据、拼字符串:

static async exportJson(context: common.Context): Promise<string> {
  const store = await BackupDao.getStore(context);
  const rows = await BackupDao.querySource(context);          // 1. 读数据源表
  const list: SourceJsonRow[] = rows.map(r => ({              // 2. 映射成导出结构
    name: r.name, category: r.category, amount: r.amount,
    note: r.note, createdTime: r.created_time,
  }));
  return JSON.stringify(list, null, 2);                       // 3. 缩进 2 空格序列化
}

多表遍历:本实例数据源只有一张 backup_source 表,真实场景往往要备份多张表——遍历表名数组逐表查询、拼接成「表名 → 行数组」的 JSON 对象:

const tables = ['backup_source', 'backup_log'];
let json = '{';
for (const t of tables) {
  const rows = await BackupDao.queryTable(context, t);
  json += `"${t}": ${JSON.stringify(rows)},`;   // 每表一段,逗号分隔
}
json = json.slice(0, -1) + '}';                 // 去掉末尾多余逗号

拼接细节:手写 JSON 最容易出 bug 的地方就是尾逗号——slice(0, -1) 去掉最后一个逗号是标准处理;字段值含 "\JSON.stringify(rows) 会帮你转义。生产建议:多表结构整体用 JSON.stringify({ source: rowsA, log: rowsB }) 一次性组装,避免手工拼串踩坑。CSV 同理,只是拼接更朴素:

static async exportCsv(context: common.Context): Promise<string> {
  const rows = await BackupDao.querySource(context);
  let csv = '名称,分类,金额,备注,创建时间\n';          // 表头
  for (const r of rows) {
    csv += `${r.name},${r.category},${r.amount},${r.note},${r.created_time}\n`;
  }
  return csv;
}

写文件:拼接出的字符串交给 10-1 讲过的 writeFile(fileIo.openSync + writeSync + closeSync)落盘到 context.filesDir,返回文件大小供日志记录——序列化层至此闭环。

十、备份记录表 CRUD:日志的增删查

backup_log 是「备份的台账」,四类操作对应四种 SQL:

操作 方法 SQL 本质
新增 doBackup 内 insert INSERT INTO backup_log (...) VALUES (...)
查询列表 queryAllRecords SELECT * FROM backup_log ORDER BY backup_time DESC
删除 deleteBackup DELETE FROM backup_log WHERE id = ?
清空 deleteAllRecords DELETE FROM backup_log(重置历史)

查询列表是页面「备份记录区」的数据来源——按时间倒序、最新在前:

static async queryAllRecords(context: common.Context): Promise<BackupRecord[]> {
  const store = await BackupDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(BackupDao.TABLE);
  predicates.orderByDesc('backup_time');                    // 最新备份排最前
  const result = await store.query(predicates);
  const records: BackupRecord[] = [];
  while (result.goToNextRow()) {                            // resultSet 逐行游标
    records.push({
      id: result.getLong(result.getColumnIndex('id')),
      fileName: result.getString(result.getColumnIndex('file_name')),
      size: result.getLong(result.getColumnIndex('size')),
      sourceTable: result.getString(result.getColumnIndex('source_table')),
      backupTime: result.getLong(result.getColumnIndex('backup_time')),
      type: result.getString(result.getColumnIndex('type')),
    });
  }
  result.close();                                           // 记得释放游标
  return records;
}

resultSet 使用要点goToNextRow() 移动游标并返回是否有下一行;getColumnIndex 把列名换成索引再取值(比按数字索引可读、抗字段顺序调整);用完必须 result.close()——不关闭会占用连接资源,多次查询后出现「too many open result sets」。

十一、导出文件读取与显示

备份文件写进沙箱后,怎么读回来给用户看?读取是对称操作:open → read → close:

static async readBackupFile(context: common.Context, fileName: string): Promise<string> {
  const path = `${context.filesDir}/${fileName}`;
  const file = fileIo.openSync(path, fileIo.OpenMode.READ_ONLY);
  const stat = fileIo.statSync(path);                       // 先拿文件大小
  const buf = new ArrayBuffer(stat.size);                   // 按大小分配缓冲区
  fileIo.readSync(file.fd, buf);
  fileIo.closeSync(file);
  return util.TextDecoder.create('utf-8').decodeToString(new Uint8Array(buf));
}

两个细节:① readSync 需要传入足够大的 ArrayBuffer,所以先 statSync 拿 size——缓冲区小于文件会读不全;② 读回的是二进制,用 TextDecoder.create('utf-8') 解码成字符串——与写入时 UTF-8 对称,中文不乱码。

显示方式:页面读取后把内容打进终端日志区——appendLog('备份内容: ' + content.slice(0, 200)),让用户「看得见备份了什么」;完整内容可弹窗展示或另存。先读文件再恢复的真实链路即:readBackupFile → restoreJson,把第五节 Q4 的「演示版 doRestore」升级为「读文件版」。

十二、恢复流程的 executeSql 批量

前面 restoreJson 用循环 insert 逐条写入——12 条数据无所谓,但万级数据时 N 次数据库 IO 偏慢。RDB 提供 executeSql 直接执行 SQL 字符串,可以拼一条 INSERT 一次执行

// 拼接批量 INSERT
let sql = `INSERT INTO backup_source (name, category, amount, note, created_time) VALUES `;
const rows: string[] = [];
for (const r of parsed) {
  const name = r.name.replaceAll("'", "''");                // 单引号转义
  rows.push(`('${name}', '${r.category}', ${r.amount}, '${r.note}', ${r.createdTime})`);
}
sql += rows.join(', ') + ';';
await store.executeSql(sql);                                // 一条 SQL 插入全部

executeSql 的代价:手拼 SQL 必须处理注入与转义——字段含单引号会直接破坏语法(O'Brien 变成三段),转义规则是 SQL 标准:''';数值字段(amount、createdTime)不要加引号,否则隐式转字符串。本实例保留循环 insert 的原因:清晰、安全、12 条数据性能无差异;executeSql 批量适合大数据量导入。

方案 速度 安全性 适用场景
循环 insert(事务内) 慢(N 次 IO) 高(参数化) 中小数据量、恢复导入
executeSql 批量 快(1 次 IO) 需自行转义 万级数据批量导入

两种方案都要包在事务里(beginTransaction → commit / rollBack)——批量拼接的 SQL 一旦语法错误,整批回滚,数据源表保持原状。

十三、FAQ(补充)

Q7:多表备份时,恢复怎么知道「哪段数据回哪张表」?
A:靠 JSON 里的结构信息。导出用 {表名: 行数组} 组织,恢复时 JSON.parse 后按 key 取数组,逐表走「清空 + 重插」。多表恢复的顺序要注意外键依赖——先恢复主表再恢复从表。

Q8:备份文件读取为空或乱码?
A:空文件——先 statSync 拿 size,size 为 0 直接返回空串并提示「文件为空」;乱码——确认写入与读取用同一编码(都走 UTF-8 + TextDecoder(‘utf-8’)),对称编解码是文件读写的基本纪律。

Q9:executeSql 批量导入失败会影响现有数据吗?
A:不会——批量拼接仍包在事务里,任一条失败整批 rollBack,现有数据原样保留。但要注意:拼 SQL 的转义 bug 是编译期发现不了的,批量越大越要先拿小样本验证生成的 SQL 语法正确。

Q10:resultSet 忘记 close 会怎样?
A:连接资源不释放,连续多次查询后抛出「result set 已满」类异常。查询方法结尾必须 close,异常分支也要 close(try/finally 包裹是标准写法)。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐