JSON 导出与恢复导入:ArkTS 文件读写实现鸿蒙数据备份


实例:数据备份导出(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;
}
恢复三步:
- 反序列化:
JSON.parse(json)把 JSON 文本还原成对象数组——注意SourceJsonRow显式接口标注解析结果类型; - 清空 + 重插:事务内先 DELETE 全表再循环 INSERT——「全量替换」语义,恢复后数据源表 = 备份时的快照;
- 事务保障:清空后任何一条插入失败 → rollBack → 数据源表恢复原状(清空操作也被回滚)——恢复导入必须事务,否则失败会造成数据丢失。
为什么先清空再插入而不是 UPDATE 覆盖? 备份是「快照」——恢复 = 让当前数据等于备份时数据。如果只更新相同 id 的行,备份后新增的行会残留(数据源表比快照多几行),快照语义被破坏。全量替换(清空 + 重插)才是恢复的正确语义。
SourceJsonRow 接口:JSON 反序列化的对象需要显式类型——ArkTS 的 JSON.parse 返回 any,用接口标注后字段访问有类型保障。
恢复导入的页面演示:本实例的恢复导入其实是用「当前数据源」重新走一遍(exportJson 再 restoreJson)——演示流程闭环,真实场景应从备份文件读内容恢复。页面代码里 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;
}
文件删除的前置操作:先 openSync 再 closeSync 再 unlinkSync——为什么先打开?其实 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 包裹是标准写法)。
更多推荐




所有评论(0)