【听见课堂 HarmonyOS NEXT 实战系列 11】先定义接口再选数据库:ClassroomRepository 的可替换数据层

很多 ArkUI 项目在第一版里会直接从页面调用 RelationalStore:按钮点击后拼 RdbPredicates,列表刷新时执行 SQL,删除时顺手清理几张表。功能少时看似高效,等到要增加内存降级、测试替身、迁移或多页面刷新,页面就会同时承担展示、业务规则和数据一致性。

听见课堂没有让页面认识 RdbStore。它先定义 ClassroomRepository,再提供 RelationalStore 和内存两套实现;ClassroomService 只依赖接口,ArkUI 只依赖 Service。本文结合当前源码,拆解接口粒度、状态约束、事务边界和替换步骤。

ClassroomRepository 可替换数据层

一、先看真实依赖方向

项目的数据调用链是:

ArkUI Page
  -> ClassroomService
  -> ClassroomRepository
     -> RelationalClassroomRepository
     -> InMemoryClassroomRepository

这条链最重要的性质是单向依赖。页面不知道 SQL,Service 不知道表名,Repository 不处理页面提示。具体数据库实现可以变化,但候选任务必须人工确认、已确认任务才能完成等业务语义保持稳定。

二、Repository 接口暴露的不是 CRUD 表名

当前 ClassroomRepository 只有一个领域接口:

export interface ClassroomRepository {
  getTodayCourse(): Promise<CourseSummary>;
  getTranscript(): Promise<Array<TranscriptSegment>>;
  replaceTranscript(courseId: string, transcript: Array<TranscriptSegment>): Promise<boolean>;
  getScanNotes(): Promise<Array<ScanNote>>;
  getTasks(): Promise<Array<TaskItem>>;
  updateCourse(course: CourseSummary): Promise<boolean>;
  deleteCourse(courseId: string): Promise<boolean>;
  clearAllData(): Promise<boolean>;
  updateScanText(scanId: string, text: string, source?: string): Promise<boolean>;
  updateTask(taskId: string, title: string, dueText: string, dueAtMillis: number): Promise<boolean>;
  confirmTask(taskId: string): Promise<boolean>;
  unconfirmTask(taskId: string): Promise<boolean>;
  setTaskCompleted(taskId: string, completed: boolean): Promise<boolean>;
  toggleTask(taskId: string): Promise<boolean>;
}

它没有暴露 querySql()、表名或 ValuesBucket,而是使用课程、字幕、扫描、任务这些领域语言。调用方关注“替换某课程字幕”而不是“删除后批量插入 transcript_segments”。

三、读操作和写操作要分开理解

接口可以分为三类:

1. 快照读取

getTodayCourse()getTranscript()getScanNotes()getTasks() 返回当前 canonical data。Service 再据此构造复盘、任务中心、历史搜索等页面快照。

2. 领域更新

updateScanText()updateTask()confirmTask() 等方法表达一个明确状态变化。它们避免页面直接更新某个字段,同时为两套 Repository 保留相同契约。

3. 组合与破坏性操作

replaceTranscript()deleteCourse()clearAllData() 可能影响多条或多表记录,需要原子性和明确失败语义。事务属于具体 Repository,而二次确认和用户提示属于页面与 Service。

这种分类能阻止接口退化成“万能 execute”。如果 Repository 只提供 execute(sql),上层仍会泄漏数据库细节,替换实现没有实际价值。

四、为什么 Service 只持有接口

ClassroomService 的构造函数接收 ClassroomRepository

export class ClassroomService {
  private repository: ClassroomRepository;
  private storageMode: string = '内存降级';

  constructor(repository: ClassroomRepository) {
    this.repository = repository;
  }

  useRepository(repository: ClassroomRepository, storageMode: string): void {
    this.repository = repository;
    this.storageMode = storageMode;
  }
}

Service 可以在应用初始化成功后切换到 RelationalStore,也可以在初始化失败时继续使用内存实现。页面调用 getTaskCenterSnapshot() 时,无需判断当前数据来自数据库还是内存。

Repository、Service 与数据源边界

五、业务规则应该放在 Service 还是 Repository

一个实用判断是:规则是否与数据源无关。

例如任务标题和截止时间不能为空,这是所有实现都要遵守的输入规则,放在 Service:

async updateTask(taskId: string, title: string, dueText: string): Promise<boolean> {
  const normalizedTitle: string = title.trim();
  const normalizedDueText: string = dueText.trim();
  if (normalizedTitle.length === 0 || normalizedDueText.length === 0) {
    return false;
  }
  const dueAtMillis: number = TaskDateResolver.resolveDueText(normalizedDueText);
  return this.repository.updateTask(taskId, normalizedTitle, normalizedDueText, dueAtMillis);
}

而“更新哪张表、如何构造 predicates、影响了几行”与数据源相关,属于 Repository。两层都可以做防御:当前两套 Repository 都会拒绝编辑已确认任务,从而保证即使调用方绕过部分页面流程,也不会静默改写稳定事实。

六、事务为什么必须留在具体实现

替换课堂字幕不是单条 UPDATE,而是删除课程旧字幕后按顺序插入新字幕。如果中途失败,旧数据和新数据不能各留一半。

RelationalStore 实现使用事务:

store.beginTransaction();
try {
  await this.deleteWhere(TABLE_TRANSCRIPT, 'course_id', courseId);
  for (let index: number = 0; index < transcript.length; index++) {
    await store.insert(TABLE_TRANSCRIPT, {
      id: transcript[index].id,
      course_id: courseId,
      sort_order: index + 1
    });
  }
  store.commit();
  return true;
} catch (error) {
  store.rollBack();
  throw new Error('Failed to replace classroom transcript.');
}

内存实现不需要数据库事务,但必须保持同样的对外语义:参数无效返回 false,成功后整组替换,不能先修改一半再返回失败。

七、接口让内存降级真正可替换

InMemoryClassroomRepository implements ClassroomRepository 是可替换性的直接证明。它用数组保存课程、字幕、扫描和任务,却仍然支持确认、撤销确认、完成、撤销完成、删除课程和清空全部数据。

初始化逻辑只在组合根选择实现:

export async function initializeClassroomData(context: common.UIAbilityContext): Promise<boolean> {
  try {
    const repository = new RelationalClassroomRepository();
    await repository.initialize(context);
    classroomService.useRepository(repository, 'RelationalStore');
    return true;
  } catch (error) {
    classroomService.useRepository(fallbackRepository, '内存降级');
    return false;
  }
}

如果页面直接 import RelationalClassroomRepository,这个切换就会失效。可替换性不是“有接口文件”即可,而是上层不得绕过接口。

八、页面为什么仍要知道“存储模式”

页面不需要知道数据库 API,但用户需要知道数据是否持久化。听见课堂通过 getStorageMode() 将技术状态映射为可理解文案:

  • RelationalStore -> “本机存储”;
  • 内存降级 -> “临时存储”;
  • 初始化中 -> “正在准备”。

这是接口隔离和产品透明度的平衡。隐藏 SQL 细节,不等于隐藏数据可靠性。若降级后仍显示“字幕、板书和任务会永久保留”,就会形成错误承诺。

九、怎样用同一接口做确定性测试

不引入真实数据库也可以测试 Service 规则。创建一个实现 ClassroomRepository 的测试替身,预置少量任务,然后验证:

  1. getCandidateTasks() 只返回 confirmed=false
  2. getConfirmedTasks() 只返回已确认记录;
  3. 未确认任务不能标记完成;
  4. 撤销确认会同时清除 completed
  5. 清空后所有聚合快照进入空态。

这种测试不证明 RelationalStore SQL 正确,却能快速证明业务规则。数据库事务、迁移和 ResultSet 释放仍需要单独的 Repository 验证。

十、新增一种数据源需要哪些步骤

假设未来要增加加密文件或远端只读备份,不应该先改页面。建议按以下顺序:

  1. 判断现有 ClassroomRepository 是否已经表达所需领域动作;
  2. 若必须扩展接口,先定义返回值、错误和状态语义;
  3. 为现有 RelationalStore 与内存实现补齐新方法;
  4. 编写新 Repository,不在其中复制 Service 业务规则;
  5. 仅在组合根选择实现或组合多个数据源;
  6. 运行两套实现的契约测试;
  7. 页面只增加必要的存储状态或恢复提示。

如果扩展接口导致几十个页面一起修改,通常说明页面已经越过 Service 边界。

十一、当前接口的真实限制

可替换不等于已经支持所有场景。当前项目以单个当前课程为主:getScanNotes()getTasks() 没有课程参数,Repository 查询也按全表 sort_order 返回。它适合现阶段演示闭环,但若要支持多课程并发、分页历史或大数据量查询,需要演进为:

  • courseId 查询字幕、扫描和任务;
  • 为常用筛选增加索引;
  • 返回分页或游标,而不是一次加载全部;
  • 明确多课程删除和跨课程事务;
  • 将错误从单一 boolean 扩展为可区分的结果类型。

文章不能因为用了 Repository 就宣称“天然支持多课程”。接口只证明了边界,能力仍以当前实现为准。

十二、常见反模式

反模式 1:页面直接拼 SQL

后果是页面测试困难、迁移逻辑分散、内存降级失效。修复方式是把数据动作收口到 Repository,把业务动作收口到 Service。

反模式 2:Repository 返回数据库对象

如果接口返回 ResultSetValuesBucket,上层仍被 ArkData 绑定。应该在 Repository 内完成 DTO/领域模型映射并关闭 ResultSet。

反模式 3:两套实现语义不一致

例如内存实现允许编辑已确认任务,而数据库实现拒绝;页面在不同模式下就会表现不同。需要共享契约测试锁定行为。

反模式 4:捕获所有错误后仍返回成功

降级可以保证应用可用,但不能把持久化失败伪装成保存成功。应显示“临时存储”,并让用户理解重启后数据可能丢失。

十三、验收清单

  • 页面没有 import ArkData 或具体 Repository;
  • Service 只依赖 ClassroomRepository
  • 接口使用领域动作,不暴露 SQL;
  • 两套实现对无效参数、确认状态和删除语义一致;
  • 多记录写入使用事务或等价原子替换;
  • ResultSet 在 finally 中关闭;
  • 写入后页面重新读取 canonical data;
  • 降级模式对用户可见;
  • 当前单课程限制已明确,不夸大扩展能力。

十四、总结

先定义 Repository 接口,价值不在于“多一层”,而在于建立稳定的领域边界。听见课堂让 Service 面向课程、字幕、扫描和任务编程,把 SQL、事务和 ResultSet 留在 RelationalStore 实现,并用内存实现验证可替换性。

下一篇将继续讨论最容易被忽略的问题:当 RelationalStore 初始化失败时,如何使用 InMemoryClassroomRepository 保住最小可演示闭环,同时明确告诉用户数据只是临时保存。

Logo

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

更多推荐