【寻迹校园 HarmonyOS NEXT 实战 15】Repository 双数据源:RelationalStore 与内存回退如何共用接口

这是“寻迹校园 HarmonyOS NEXT 实战”系列第 15 篇。本文结合 ReportRepository.ets,拆解有 UIAbilityContext 时使用 RelationalStore、无 Context 时使用静态内存数据的双数据源策略,以及统一接口、种子数据、对象克隆和证据边界。

Repository 双数据源统一接口原创封面图

上图为本文原创生成的架构插画,不是应用截图。同一组 list/find/insert/update/delete 业务接口,可以路由到真实本地数据库或内存回退;调用方不需要在页面里写两套业务逻辑。

一、为什么项目会同时存在两种数据源

“寻迹校园”的正式运行需要 RelationalStore:记录要在应用重启后保留,需要查询、排序、状态更新和增量迁移。

但部分纯业务测试、无平台执行环境或初始化前路径拿不到 UIAbilityContext。如果 Repository 完全依赖 Context,Service 很难在这些环境验证匹配、状态机和错误映射。

项目因此采用两条路径:

  • 有 Context:打开加密 S2 RelationalStore,读写 item_report
  • 无 Context:使用静态 fallbackReports,返回确定性种子数据和内存变更。

这是一种可测试性设计,不是把内存数组当成正式数据库替代品。

二、统一接口让页面与 Service 不感知数据源

Repository 提供的主要动作保持一致:

list(context?)
findById(reportId, context?)
insert(report, privateFeature, context?)
update(report, privateFeature, context?)
deleteById(reportId, context?)
updateStatus(reportId, status, context?)

Service 负责业务规则,只选择是否传入 Context,不需要维护 listFromDb()listFromMemory() 两套返回模型。页面更不应该直接判断数据库是否可用。

统一的不只是方法名,还包括:

  • 返回 ItemReport 业务模型;
  • ID、状态和 ReportType 的语义;
  • 排序与查找结果;
  • 写入后再次读取的可观察行为;
  • 不向调用方暴露 ResultSet 或内部数组引用。

三、有 Context 时才进入 RelationalStore

ensureStore() 使用真实 UIAbilityContext 获取 RdbStore,创建表并执行缺列检查。list() 则通过谓词按创建时间倒序:

const predicates = new relationalStore.RdbPredicates(TABLE_REPORT);
predicates.orderByDesc('created_at');
const resultSet = await (await this.ensureStore(context)).query(predicates);

数据库路径负责:

  • S2 与加密配置;
  • 表结构与增量迁移;
  • ValuesBucket 映射;
  • 条件查询、排序和状态更新;
  • 应用重启后的持久化;
  • ResultSet 生命周期。

这些能力都依赖真实平台环境,不能由内存测试替代。

四、无 Context 时返回内存回退

没有 Context 时,list() 直接从静态数据创建返回数组:

return ReportRepository.fallbackReports.map(
  (item: StoredReport) => this.cloneReport(item.report)
);

回退数据包括几条固定校园失物记录,能够让首页、匹配规则和认领流程在无数据库环境下获得确定输入。

固定种子比随机数据更适合回归:同一个用例每次看到相同 ID、类型、类别、地点和日期,失败更容易复现。若测试需要修改,结束后应重建进程或显式重置,避免静态数组在不同用例间泄漏状态。

五、为什么读取结果必须 clone

ItemReport 是可变对象。如果 Repository 直接把内部对象引用返回给页面,调用方执行 report.status = ReportStatus.RESOLVED 就可能绕过正式写入动作。

就可能绕过 updateStatus(),直接修改内存权威数据。数据库路径中,修改查询结果也不会自动更新数据库,两种数据源语义随即分裂。

项目用 cloneReport() 重建对象:

return new ItemReport(
  report.id, report.reportType, report.title, report.category,
  report.area, report.timeLabel, report.description, report.status,
  report.createdAt, report.isUserCreated, report.eventDate, report.imageUris
);

这样,读取结果是快照;任何正式变更仍要经过 Repository 方法。克隆隔离让内存回退更接近数据库“读出—修改—显式写回”的行为。

六、insert 为什么同时更新 fallback 与数据库

当前 insert() 先更新静态回退,再在有 Context 时写数据库:

ReportRepository.fallbackReports = [stored].concat(
  ReportRepository.fallbackReports.filter(
    (item: StoredReport) => item.report.id !== report.id));

if (context) {
  await store.insert(TABLE_REPORT, bucket,
    relationalStore.ConflictResolution.ON_CONFLICT_REPLACE);
}

它保证当前进程后续无 Context 查询仍能看到新记录,但也带来需要关注的顺序风险:如果数据库写入失败,fallback 已经发生变化。

Service 不能因此把内存结果当成持久化成功。生产路径应以数据库写入结果为准;如果要求两者严格一致,可以调整顺序或在失败时补偿回退状态。文章记录真实实现与风险,不把它描述成跨数据源事务。

七、数据库读取异常时为什么会退到 fallback

list()findById() 在数据库查询异常时返回内存数据,能让演示页面继续展示基础内容。这属于可用性降级,但也容易产生误判:

  • 页面有数据,不代表 RelationalStore 正常;
  • 新写入记录可能只存在某一数据源;
  • 用户重启后可能看到不同结果;
  • 错误被完全吞掉时,维护者难以定位根因。

更完善的实现应让 Service 或诊断层知道当前数据来源,例如内部记录受控状态或返回可观察错误分类;用户页面可以显示“当前使用本地示例数据”,而不是静默伪装成真实持久化结果。

八、初始化时如何把种子写入空数据库

initialize(context) 先统计表内数量。只有 COUNT(*) 为 0 时,才遍历 fallbackReports,把每条 StoredReport 映射为 ValuesBucket 并以 ON_CONFLICT_REPLACE 写入数据库。

这避免每次启动重复插入。稳定 ID 与 ON_CONFLICT_REPLACE 也降低重复风险。

但“表为空”不等于“首次安装”。用户主动删除所有记录后再次启动,种子可能重新出现。若产品不希望这样,应使用独立初始化标记或数据库版本状态,而不是仅依赖行数。

RelationalStore 与内存回退证明边界原创对比图

上图明确区分两类证据:内存回退可以验证接口和业务语义;数据库 Schema、加密、事务、设备 I/O 与冷启动持久化,必须走真实 RelationalStore 路径。

九、私密字段为什么不直接放进 ItemReport

Repository 内部使用 StoredReport 同时保存公开 ItemReportprivateFeature。普通 list() 只返回公开模型,私密特征通过单独的 findPrivateFeature() 在认领审核场景读取。

这个接口分离降低了公开列表、匹配页或 Agent 输入误取私密信息的概率。无论数据来自内存还是数据库,访问边界都应保持一致。

不过“单独方法”不是完整鉴权。未来接入账号和后端后,还需要在服务端验证身份、记录访问审计,并限制私密字段出现在日志、缓存和错误上报中。

十、两种数据源的行为必须对齐

双数据源最容易出现的 bug 不是编译错误,而是语义不一致:

行为 RelationalStore 内存回退 对齐要求
list 排序 created_at 倒序 当前数组顺序 种子和写入顺序应一致
findById 谓词匹配 数组查找 ID 归一化相同
insert 冲突替换 过滤旧项后头插 同 ID 最终只有一条
update 条件更新 替换对应索引 不存在时策略明确
delete 谓词删除 过滤数组 再查询都不可见
status 更新单列 修改内部状态 返回克隆,禁止旁路修改

为每个 Repository 接口建立一组契约测试,并分别运行内存实现与真实数据库实现,才能发现行为漂移。

十一、内存回退测试能证明什么

它适合验证:

  • Service 调用 Repository 的顺序;
  • ID、状态和类型转换;
  • 匹配与筛选规则;
  • 错误结果是否阻止后续动作;
  • 读取对象是否克隆隔离;
  • 写入、更新、删除的业务语义;
  • 固定种子下的确定性输出。

这些测试速度快、无需设备,可以作为日常回归基础。

十二、内存回退测试不能证明什么

它不能证明:

  • CREATE TABLEALTER TABLE 在目标系统有效;
  • ValuesBucket 列名与类型完全正确;
  • ResultSet 游标和关闭逻辑没有问题;
  • S2、加密与实际文件权限满足要求;
  • SQL 条件、索引和排序性能;
  • 磁盘不足、数据库损坏和并发写入行为;
  • 进程重启后的数据仍存在;
  • 图片文件 URI 在真实设备可读取。

因此报告中应写“无 Context 契约测试 passed”,而不是“数据库持久化 passed”。证明层级必须和实际执行环境一致。

十三、失败回滚与数据权威

只要正式应用有 Context,RelationalStore 就应该是权威数据源。fallback 可以支撑无平台测试和受控降级,但不应在数据库写入失败后向用户返回成功。

一次写操作至少要明确:

  1. 业务校验是否通过;
  2. 图片物化是否完成;
  3. 数据库写入是否完成;
  4. fallback 是否需要同步或回滚;
  5. 失败后页面保留什么;
  6. 哪个结果会触发 dataRevision

只有权威写入成功才发跨页面刷新信号。否则其他页面可能重查到回退数据,看似更新,重启后却消失。

十四、什么时候应该拆成两个实现类

当前项目把两条分支放在同一个 Repository 中,代码量有限、共享映射逻辑较多,改动成本较小。

当出现以下情况时,建议提取明确接口与两个实现:

  • fallback 规则持续增加;
  • 数据库与内存排序、分页差异扩大;
  • 需要依赖注入和自动契约测试;
  • 引入远端 Repository、缓存或离线同步;
  • 页面需要明确显示当前数据来源;
  • 异常策略不能再用简单回退表达。

拆分后仍应共享 ItemReport、错误模型和方法契约,Service 通过构造或工厂选择实现,而不是在每个业务方法里写 if (context)

十五、验证矩阵与维护建议

建议把验证分为三层:

1. 无 Context 契约层

运行固定种子下的 list/find/insert/update/delete/status 用例,检查克隆隔离和 Service 结果。

2. 有 Context 数据层

在真机或模拟器上验证首次初始化、旧库迁移、条件查询、重启持久化、异常路径与图片引用。

3. 页面业务层

完成发布—首页出现—编辑—撤回—删除链路,确认所有页面都从权威数据重新读取,而不是手工补状态。

三层结果分别记录 passedfailednot run。任何一层通过都不能自动替代另外两层。

十六、本文小结

ReportRepository 用同一套业务接口承接 RelationalStore 与内存回退:有 Context 时读写真实本地数据库,无 Context 时使用固定种子与克隆对象支撑契约测试。

双数据源的关键不是“随时有数据可显示”,而是保持方法语义一致、明确权威来源,并诚实区分证明边界。内存测试可以证明 Service 行为,却不能证明 Schema、加密、事务和真实设备 I/O。

系列导航:第 15 篇 / 共 50 篇。上一篇:《RelationalStore 安全增量迁移》;下一篇:《把 Repository 错误映射成 OperationResult》。

Logo

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

更多推荐