Teable v2 应用服务层架构解析:领域编排、跨表副作用与事务发布机制
【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable
导读
本文围绕 Teable 仓库中 packages/v2/core/src/application/services/ARCHITECTURE.md 这份架构说明展开,深入讲解 v2 核心包中application/services 层的职责划分与八个核心应用服务的实现细节。你将掌握:应用服务如何在"领域模型 / Visitor / Spec"与"仓储、事件总线、事务单元"之间充当编排者,字段创建/删除如何触发跨表副作用,批量记录更新与行序重排如何协调持久化、事件与撤销/重做(undo/redo)。文章同时提供源码级佐证,便于后续阅读与二次开发。
一、层职责:应用服务只做编排,不掺领域逻辑
ARCHITECTURE.md 对该层给出了三条明确的职责定义:
- 实现应用服务,协调仓储(repositories)、Schema 更新与事件发布;
- 围绕领域变更与 Spec 提供事务性编排(transactional orchestration);
- 领域逻辑保留在领域模型/Visitor 中,本层只负责"连接端口(ports)"并为跨表校验提供预加载数据。
用一句话概括:领域模型决定"改什么",应用服务决定"怎么改、何时提交、改完通知谁"。这一点在源码中有直接体现——每个服务类的注释几乎都重复着同一句约定:
"Domain logic lives in visitors/specs; this class only orchestrates persistence and events."
例如 FieldCreationSideEffectService.ts 的类注释即为该约定的样板。这种分层保证:Schema 语义(如"删除链接字段要同步删掉对端的对称链接字段")只存在于领域 Visitor/Spec 中,应用服务可以被替换、复用而不会污染业务规则。
二、八个核心应用服务全景
ARCHITECTURE.md 在 Files 一节列出了八个服务文件及其角色。下表完整继承原文档并补充了各自的关键产出:
| 服务文件 | 角色 | 核心职责 |
|---|---|---|
FieldCreationSideEffectService.ts | 应用服务 | 通过 Visitor 校验跨表字段依赖,并在字段创建后施加副作用 |
FieldDeletionSideEffectService.ts | 应用服务 | 字段删除后施加跨表副作用(如移除对称链接字段) |
ForeignTableLoaderService.ts | 应用服务 | 一次性加载外部表并校验缺失引用 |
TableDeletionSideEffectService.ts | 应用服务 | 删除表之前,在其它表中派发显式的OnTeableTableDeleted反应,包括链接转文本与依赖元数据清理 |
RecordBulkUpdateService.ts | 应用服务 | 准备并执行"选择器(selector)"或"显式记录"批量更新,由领域模型构建 Spec/结果,服务协调仓储持久化、事件与 undo/redo |
RecordReorderService.ts | 应用服务 | 应用批量行序更新,为需要原生 v2 重排流程的调用方构建重排事件与 undo/redo 负载 |
TableQueryService.ts | 应用服务 | 在 CommandHandler 与 QueryHandler 间共享的通用表查询操作(getById、getByIdInBase、exists) |
TableUpdateFlow.ts | 应用服务 | 共享的表更新工作流(mutate + persist + publish) |
下面逐一深入各服务的源码实现。
三、ForeignTableLoaderService:跨表引用的"一次加载 + 缺失校验"
链接(link)、查找(lookup)等字段天然会引用其它表,若每个字段各自去查库,一次命令会产生 N 次查询。ForeignTableLoaderService的职责就是每个命令只加载一次外部表,并统一校验引用是否完整。
其输入类型定义如下(见 ForeignTableLoaderService.ts):
export type ForeignTableLoaderInput = { baseId?: BaseId; references: ReadonlyArray<LinkForeignTableReference>; /** * When true, missing foreign tables are skipped instead of failing. * Used by delete-field flows so orphan link/lookup fields remain deletable * after their foreign table was soft-deleted or permanently removed. */ allowMissing?: boolean; };实现要点:
- 按 Base 分组批量查询:
load()先把引用按baseId分组,再对每组通过TableAggregate.specs(baseId).byIds(...)构造 Spec、调用tableRepository.find()一次性取回,避免逐表查询; - 缺失引用校验:查询结束后比对实际返回的表集合,若存在
missingForeignTableIds且未开启allowMissing,则返回domainError.notFound,错误详情中携带缺失表 ID 列表; - allowMissing 的用途:删除字段流程中,外部表可能已被软删除或永久移除,此时允许"孤儿"链接/查找字段仍然可删除,避免删除操作被缺失的表卡死;
- 链接标题回填:
loadForLinkTitleFill()通过MissingLinkTitleForeignTableCollector(一个ICellValueSpecVisitor实现)遍历SetLinkValueSpec,收集需要标题解析(needsTitleResolution())的外部表引用,再统一加载——它同时做了按foreignTableId的去重。
对应的测试 ForeignTableLoaderService.spec.ts 覆盖了"无引用返回空"、"加载被引用表"、"从引用自带 baseId 加载外部表"等场景,并使用MemoryTableRepository作为内存仓储,可直接作为理解该服务行为的示例。
四、字段创建/删除的跨表副作用服务
4.1 FieldCreationSideEffectService:创建即同步
创建一个 link 字段通常需要在关联表创建对称字段、或为 rollup/lookup 建立依赖关系。该服务把"有哪些副作用"的判断完全交给领域 Visitor:
preview():在内存中构建foreignTableState(外部表 + 当前表),调用 FieldCreationSideEffectVisitor 的collect()收集副作用,随后对每个sideEffect.mutateSpec在状态快照上执行mutate()做预览,返回更新后的表集合——不落库、不产生持久事件,用于调用方在真正提交前观察影响;execute():在真实执行路径中,对每个副作用调用tableUpdateFlow.execute(context, { table: foreignTable }, mutateFn, { publishEvents: false }),将更新后的表写回状态,并收集领域事件。注意publishEvents: false——事件先聚合不发布,由上层命令在事务边界统一决定何时发布。
FieldCreationSideEffectVisitor是标准的IFieldVisitor实现:对于SingleLineTextField、NumberField、FormulaField等绝大多数类型直接返回ok([])(无副作用),只有 link 相关字段类型会产出{ foreignTable, mutateSpec }副作用描述。
4.2 FieldDeletionSideEffectService:删除即清理
删除字段的跨表副作用(例如删除 link 字段后同步移除对端对称链接字段)由 FieldDeletionSideEffectService.ts 负责:
- 同样先经
FieldDeletionSideEffectVisitor.collect()收集副作用; - 对每个副作用走
tableUpdateFlow.execute(..., { publishEvents: false })持久化更新并聚合事件; - 特殊之处:当
mutateSpec instanceof TableRemoveFieldSpec时,会把该副作用记录到appliedDeletions,携带deletedField、previousTable(更新前快照)与table(更新后),供上层在事务提交后做后续清理或审计。
五、TableDeletionSideEffectService:删表前的"群发通知"
删除一张表时,其它表里可能残留指向它的 link/lookup 字段,以及相关的元数据。TableDeletionSideEffectService的职责是在被删表真正删除之前,让其它表做出反应,反应逻辑由领域侧定义的OnTeableTableDeleted协议承载(见 TableDeletionSideEffectService.ts)。
执行流程(execute()):
- 加载候选表:用
TableAggregate.specs().byIncomingReferenceToTable(deletedTable.id())查出所有"引用被删表"的其它表(排除自身); - 优先级排序:
prioritizeReactingFieldIds()对每个候选表按reactionPriority排序字段——直接引用被删表的 link 字段优先(优先级 0),引用被删表但非 link 的字段其次(优先级 1),其余字段最后(优先级 2),确保删除依赖先被解除; - 收集反应:
collectDeletionReactions()对排序后的字段逐一检查是否implementsOnTeableTableDeleted,调用field.value.onTableDeleted(deletedTable, deletionContext);带afterPersist钩子的反应单独隔离执行,纯 Spec 类的反应被归入batchableSpecs; - 分批执行:隔离反应逐个走
tableUpdateFlow.execute()并携带afterPersist钩子;可批处理反应则用composeAndSpecs()组合成单个 Spec 一次执行——尽量减少表更新事务次数; - 链接转文本:通过
createDeletionContext()提供的createFieldUpdateAfterPersistHook,把"字段更新副作用"(FieldUpdateSideEffectService)接入 afterPersist 阶段,实现链接字段在被删表场景下向文本的转换等清理动作。
该服务的整体设计意图在类注释中写得很清楚:数据变更仍然通过显式的表删除钩子与表 Spec 流出,以便适配器(adapter)把工作留在 SQL 层完成。
六、TableUpdateFlow:共享的"mutate + persist + publish"工作流
TableUpdateFlow是本层最核心的编排器,字段创建/删除副作用、表更新命令全部复用它。其execute()完整路径如下(见 TableUpdateFlow.ts):
- 解析目标表:
resolveTable()支持直接传Table实例,或通过baseId + tableId走仓储查询;表不存在时返回table.not_found错误; - 领域变更:调用调用方传入的
mutate(table)得到TableUpdateResult,并pullDomainEvents()收集宿主表产生的领域事件; - 判断是否需要物理 Schema 修复:
mayRequirePhysicalSchemaRepair()会递归展平mutateSpec,若包含TableAddFieldSpec、TableRemoveFieldSpec、TableUpdateFieldDbFieldNameSpec、UpdateLinkConfigSpec、RemoveSymmetricLinkFieldSpec等,或为类型转换(TableUpdateFieldTypeSpec.isTypeConversion()),则先调用beginTableSchemaOperation登记待执行的 Schema 操作——这是为了处理元数据/数据分库部署下 DDL 先于元数据事务提交的一致性窗口; - 双层事务:外层
unitOfWork.withTransaction(..., { scope: 'meta' })提交元数据(tableRepository.updateOne),内层{ scope: 'data' }提交物理 Schema(tableSchemaRepository.update),并把更新后的最新表写入事务作用域(recordLatestTableInTransactionScope); - 钩子机制:
prepare钩子在元数据持久化前执行,afterPersist钩子在数据阶段落库后执行——表删除服务的"链接转文本"正是挂在这里; - 失败兜底:事务失败时按"元数据是否已落库"分别走
failRecoverableTableSchemaOperation(可恢复)或completeTableSchemaOperation(不可恢复),并保留原始错误; - 提交后收尾:通过
registerAfterCommit/registerAfterRollback在父事务提交后再将 Schema 操作标记为 ready,复用外层数据事务时避免"提前标记完成导致表不可用"; - 事件版本回填:
attachPersistedEventVersions()用仓储返回的fieldVersionChanges/viewVersionChanges为FieldUpdated、FieldOptionsAdded、ViewColumnMetaUpdated事件补全oldVersion/newVersion,保证事件携带真实的版本号供投影(projection)消费; - 发布:默认
publishEvents: true时经eventBus.publishMany发布事件;注释明确"投影自行拉取数据",事件本身不携带投影所需的全量快照。
七、记录维度:批量更新与行序重排
7.1 RecordBulkUpdateService:选择器批量更新与显式记录更新
RecordBulkUpdateService是记录更新的主入口,支持两种更新变体(record.update.variant追踪属性可区分):
- selector 变体(按筛选条件):输入提供
fieldValues与filter(或recordIds),先经FieldKeyResolverService.resolveFieldKeys把字段键解析为字段 ID,再buildRecordConditionSpec构建条件 Spec;随后在事务内用合成记录 ID(rec+ 16 个 0)在tableForWrite.updateRecord上构建mutateSpec,若 Spec 需要解析(needsResolution())则调用RecordMutationSpecResolverService.resolveAndReplace;最后tableRecordRepository.updateMany按条件批量落库; - explicit 变体(按记录列表):输入提供
records: IRecordBulkUpdateItem[](每条含recordId与fieldValues)。流程更重:先解析字段键 → 插件准备与守卫 → 加载当前记录 → 授权过滤(缺失记录、被插件过滤的记录会被统计剔除)→ 事务内updateRecordsStream以EXPLICIT_UPDATE_MAX_BATCH_SIZE = 1000为批大小流式生成更新批次,逐批解析、持久化并生成RecordUpdateDTO变更数据; - 事件与撤销/重做:为每个实际变更生成
RecordsBatchUpdated事件(source: 'user');同时把oldValue构造成UpdateRecords撤销命令、newValue构造成重做命令,与行序命令、副作用撤销计划一起通过undoRedoStackService.appendEntry入栈——注意撤销/重做命令的入栈顺序是相反的(redo 先执行副作用再执行更新,undo 则相反); - 可观测性:批量更新全程埋点(
BulkUpdateBatchTraceCollector),对单条记录 trace 采样上限为 3 条(GENERATE_UPDATE_BATCH_RECORD_TRACE_SAMPLE_LIMIT),并在 span 上记录批次耗时、字段赋值总数、最大单记录字段数等指标,用于定位大批量更新瓶颈。
7.2 RecordReorderService:原生 v2 重排
RecordReorderService提供与RecordInsertOrder(viewId + anchorId + position)配套的行序重排:
- 通过
recordOrderCalculator.calculateOrders()计算每条记录的下一组 order 值(批量大小常量UPDATE_BATCH_SIZE = 500); - 对每条记录构造
SetRowOrderValueSpec(viewId, nextOrder),用updateManyStream分批持久化; - 只对 order 实际变化的记录生成
RecordReordered事件(携带ordersByRecordId与previousOrdersByRecordId); - 撤销/重做命令统一为
ApplyRecordOrders,undo 写回 previousOrder、redo 写回 nextOrder。
八、TableQueryService:Handler 间的共享查询约定
TableQueryService是跨 CommandHandler 与 QueryHandler 复用的"查表服务",其文档注释对何时使用/何时不使用给出了明确约定(见 TableQueryService.ts):
- 适用:Handler 在执行操作前需要按 ID 取表、多个 Handler 共享"查询 + not-found 检查"模式、需要一致的表缺失错误信息;
- 不适用:复杂查询(应直接用仓储 + 自定义 Spec)、已在使用
TableUpdateFlow(它自带resolveTable)、涉及领域逻辑(应放领域服务或聚合内)。
提供的操作:
getById(context, tableId):按 ID 查询活动表,统一返回table.not_found错误(错误信息含表 ID);getByIdInBase(context, baseId, tableId):限定 Base 范围查询,作为额外的授权/作用域检查,错误信息同时含表 ID 与 Base ID;getDeletedByIdInBase(...):以state: 'deleted'查询已删除表,供回收站/恢复流程使用;exists(context, tableId):存在性检查(当前实现仍加载完整聚合,注释提示后续可用 count 查询优化)。
文档注释中还给出了 Handler 内的典型用法:
// In a CommandHandler: const table = yield* await this.tableQueryService.getById(context, command.tableId); const record = yield* table.createRecord(command.fieldValues); // With baseId constraint: const table = yield* await this.tableQueryService.getByIdInBase( context, command.baseId, command.tableId );九、设计原则总结
从 ARCHITECTURE.md 与源码可以提炼出该应用服务层的四条设计原则:
- Visitor 收集副作用、Spec 描述变更:跨表副作用的"是什么"由
FieldCreationSideEffectVisitor、FieldDeletionSideEffectVisitor等 Visitor 决定,"怎么改"由TableAddFieldSpec、TableRemoveFieldSpec、SetRowOrderValueSpec等 Spec 描述,应用服务从不直接拼装业务规则; - 事务范围显式声明:通过
unitOfWork.withTransaction的scope: 'meta' | 'data'区分元数据与数据两阶段提交,配合TableUpdateTransactionScope、TableSchemaOperationLifecycleService处理分库部署下的 DDL 一致性; - 事件先聚合、后发布:副作用服务统一使用
publishEvents: false,由上层在事务边界决定发布时机,避免中间态事件泄漏; - 端口驱动、依赖注入:所有服务通过
@injectable()+v2CoreTokens.*注入tableRepository、eventBus、unitOfWork等端口,测试可用MemoryTableRepository等内存实现替换(见 ForeignTableLoaderService.spec.ts)。
十、进一步阅读
- 层职责声明:packages/v2/core/src/application/services/ARCHITECTURE.md
- 字段创建/删除副作用:
FieldCreationSideEffectService.ts、FieldDeletionSideEffectService.ts及对应 Visitor FieldCreationSideEffectVisitor.ts - 表更新核心工作流:TableUpdateFlow.ts
- 记录批量更新/重排:
RecordBulkUpdateService.ts、RecordReorderService.ts - 表查询约定:TableQueryService.ts
- 单元测试样例:
ForeignTableLoaderService.spec.ts、TableUpdateFlow.spec.ts、TableQueryService.spec.ts(同目录下)
综上,application/services 层是 Teable v2 中连接"领域模型"与"基础设施端口"的枢纽:它用 Visitor/Spec 保持领域纯净,用统一的事务流与事件发布保证跨表操作的一致性,再用清晰的查询与撤销/重做约定支撑 CommandHandler/QueryHandler 的复用——理解这一层,就抓住了整个 v2 核心包的命令处理骨架。
【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考