Teable 的 v2 Core application 层架构:应用服务、投影与跨表副作用编排
【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable
导读
Teable 在packages/v2/core中落地了一套清晰的分层架构:命令(Command)负责入口,领域模型(Domain)承载业务规则,而介于两者之间的application 层(packages/v2/core/src/application)专门负责"编排"——协调仓储、跨聚合工作流、事务边界与事件发布。本文以该目录下的 ARCHITECTURE.md 为核心骨架,深入剖析应用服务的职责边界、projections/与services/两个子目录的组成,并结合TableUpdateFlow、FieldCreationSideEffectService、RecordCreationService、DeleteByRangeApplicationService等核心实现,说明 Teable 如何在保持领域纯净的同时完成复杂的跨表副作用、实时投影与撤销/重做。读完本文,你将理解 v2 Core 分层设计的约束动机,并能据此定位、扩展或复用 application 层的编排服务。
一、application 层在整个 v2 Core 中的位置
在深入本目录之前,先明确它的上级语境。根据 packages/v2/core/src/ARCHITECTURE.md,v2 Core 的源码根目录分为五大部分:
| 目录 | 职责 |
|---|---|
commands/ | 应用命令与处理器(写侧入口) |
queries/ | 应用查询与处理器(读侧入口) |
application/ | 编排领域行为、协调端口的应用服务 |
domain/ | 领域模型(聚合、值对象、规格、事件) |
ports/ | 端口及默认/内存实现、映射器 |
同时该文档明确了三条分层规则:
- 命令处理器可以编排端口与应用服务,但不得调用其他命令处理器,也不得通过
ICommandBus重新分发命令; - 共享的写行为应下沉到
application/services/中,由多个处理器复用; - 仓储专属的持久化后工作(如 schema 刷新、回填重放、仓储起源的 action-trigger 收集)必须留在仓储的
create/update/delete方法内部,应用流程只能消费返回的聚合及其领域事件。
这三条规则直接解释了为什么需要application/这一层:它是"共享编排逻辑"的收容所,避免命令处理器之间互相调用、避免领域模型被基础设施污染。
二、application 层职责:编排而非实现规则
application/ARCHITECTURE.md 用三句话概括了本层定位:
- 职责一:提供"编排领域行为、协调端口"的应用服务;
- 职责二:拥有跨聚合工作流、事务与事件发布边界;
- 职责三(边界红线):不得包含领域规则——领域规则必须委托给领域服务 / 实体 / 访问器(visitor)实现。
理解这条边界的关键在于区分两种代码:
- 领域规则:例如"双向关联字段(link field)删除后必须同步删除对称字段""新建字段时关联表需要产生何种副作用""字段类型转换的合法性校验"。这些逻辑写在
domain/table下的 visitor 与 spec 中,例如FieldCreationSideEffectVisitor、FieldDeletionSideEffectVisitor。 - 编排逻辑:例如"先收集副作用 → 逐表在事务中应用变更 → 汇总事件 → 发布事件"。这些正是 application 服务要承担的。
以 FieldCreationSideEffectService.ts 为例,其类注释直接写明:"Application service: coordinates repositories and update flow for cross-table side effects. Domain logic lives in visitors/specs; this class only orchestrates persistence and events."(应用服务协调仓储与更新流程以完成跨表副作用,领域逻辑位于 visitor/spec 中,本类仅编排持久化与事件)。这是整层设计哲学的最直观注脚。
为什么需要这个"编排层"?
从源码可以归纳出三个动机:
- 跨聚合一致性:字段创建/删除的副作用会落在"另一张表"(foreign table)上,需要同时更新多张表的元数据,天然属于跨聚合工作流,领域聚合本身无法单独完成;
- 事务与事件边界的统一:变更、持久化、事件发布必须被编排到同一个事务作用域,并保证"提交后发布"的时序;
- 多处理器复用:同一个工作流(如单条记录创建、按范围删除)会被多个命令处理器调用,抽成共享服务可避免重复实现。
三、子目录导航:projections 与 services
application 目录下有两个子目录,职责边界非常清晰:
| 子目录 | 职责 |
|---|---|
| projections/ | 投影(Projection)类型与基于 EventHandler 的处理器装饰器别名 |
| services/ | 应用服务实现(仅编排) |
顶层还直接放置了若干编排类(如DeleteByRangeApplicationService、TableUpdateFlow、RecordCreationService、RecordWritePluginRunner等)与对应的测试(.spec.ts),以及本架构说明文件。
3.1 projections/:把领域事件绑定到派生效果
projections/ARCHITECTURE.md 明确了投影的职责:
- 定义"把领域事件绑定到派生效果"的投影类型;
- 提供投影事件绑定的装饰器别名;
- 投影必须是 EventHandler,处理器内部不得做事件类型分支(keep projections as EventHandlers, no event type branching inside handlers)。
其核心文件是 Projection.ts:
import type { IDomainEvent } from '../../domain/shared/DomainEvent'; import type { EventType, IEventHandler } from '../../ports/EventHandler'; import { EventHandler } from '../../ports/EventHandler'; export type IProjection<TEvent extends IDomainEvent = IDomainEvent> = IEventHandler<TEvent>; export const ProjectionHandler = <TEvent extends IDomainEvent>(event: EventType<TEvent>) => EventHandler(event, { role: 'projection' });可以看到IProjection只是IEventHandler的别名,ProjectionHandler则是EventHandler的薄封装,额外标记了role: 'projection'——这让投影在事件总线上拥有独立的语义身份。投影处理器通过装饰器绑定到特定领域事件类型,例如:
@ProjectionHandler(FieldCreated) @injectable() export class FieldCreatedRealtimeProjection implements IEventHandler<FieldCreated> { ... }RealtimeProjection.ts是一个标记类型,用于标注"面向实时引擎(realtime engine)"的投影。目录内现有的实时投影包括:
- TableCreatedRealtimeProjection.ts —— 建表时发布表快照;
- FieldCreatedRealtimeProjection.ts —— 建字段时发布字段快照(同时通过
RealtimeTableSnapshotCache复用表快照,并用realtimeEngine.ensure保证表文档与字段文档存在); - FieldDeletedRealtimeProjection.ts —— 删字段时删除字段快照;
- ViewColumnMetaUpdatedRealtimeProjection.ts —— 字段增删导致视图列元数据变化时更新快照。
投影只负责"事件 → 派生数据"的单向推导,不会回写领域模型,这是它区别于应用服务的关键。以FieldCreatedRealtimeProjection.handle为例,它加载表快照、确保表文档存在、再从快照中取出字段 DTO 写入实时引擎,全程只读消费事件与仓储数据。
3.2 services/:事务化编排的容器
services/ARCHITECTURE.md 给出了更细的职责描述:
- 实现"协调仓储、schema 更新与事件发布"的应用服务;
- 围绕领域变更与 spec 提供事务化编排;
- 领域逻辑保留在领域模型/visitor 中;本层只负责接线端口,并为跨表校验提供预加载数据。
同时该文档列出了一批典型的服务及其定位,下面逐一结合源码展开。
四、核心应用服务源码级剖析
4.1 TableUpdateFlow:事务化表更新的统一流水线
TableUpdateFlow.ts 是 application 层最基础的"基座"服务,字段创建/删除副作用服务都依赖它。它的职责正如文档所述:"shared table update workflow (mutate + persist + publish)",即变更 → 持久化 → 发布三步走。
其execute方法接收四个要素:执行上下文、更新目标(可直接传Table聚合,也可传tableId或TableUpdateCommand让流程自行解析)、变更函数((table) => Result<TableUpdateResult, DomainError>)、以及可选选项(publishEvents与prepare/afterPersist钩子)。
整个流程的关键步骤(对应 TableUpdateFlow.ts):
- 解析目标表:若目标只含
tableId,则通过TableRepository.findOne加载聚合; - 执行领域变更:调用调用方传入的
mutate(table),得到更新后的表与变更 spec(mutateSpec),并拉取表聚合上累积的领域事件(pullDomainEvents); - 判断是否需要物理 schema 修复:
mayRequirePhysicalSchemaRepair检查mutateSpec是否包含会改变物理结构的 spec——TableAddFieldSpec、TableRemoveFieldSpec、TableDuplicateFieldSpec、TableUpdateFieldDbFieldNameSpec、TableUpdateFieldConstraintsSpec、UpdateLinkConfigSpec、UpdateLinkRelationshipSpec、RemoveSymmetricLinkFieldSpec,以及"发生类型转换"的TableUpdateFieldTypeSpec。若需要,则先行beginTableSchemaOperation(因为在"元数据/数据分库"场景下,DDL 可能先于元数据事务提交被看到,需要 schema 操作生命周期管理); - 双事务阶段:外层
withTransaction(..., { scope: 'meta' })中先执行prepare钩子,然后在数据阶段({ scope: 'data' })内调用tableSchemaRepository.update落物理 schema、执行afterPersist钩子,最后flushTableUpdateTransactionScope; - 失败恢复:事务失败时,根据是否已持久化元数据,选择
failRecoverableTableSchemaOperation(可恢复失败)或completeTableSchemaOperation(记录不可修复失败),并保留原始错误返回; - 提交后收尾:通过
registerAfterCommit(context, finalizeReady)注册提交后完成 schema 操作;若外层事务回滚,则registerAfterRollback记录失败状态,且"不使表不可用"; - 事件版本回填与发布:
attachPersistedEventVersions将仓储返回的字段/视图版本变化(fieldVersionChanges/viewVersionChanges)回填到FieldUpdated、FieldOptionsAdded、ViewColumnMetaUpdated事件上(按字段/视图 ID 排队、逐事件消费);随后经eventBus.publishMany发布领域事件与持久化后事件(postPersistEvents)。
这个服务是理解"Teable 如何在分库(meta/data split)场景下保证 DDL 与元数据一致性"的最佳入口:schema 操作生命周期 + 双层事务 + 提交后收尾三者配合,正是ARCHITECTURE.md所说"跨聚合工作流、事务与事件发布边界"的具体实现。
4.2 FieldCreationSideEffectService:建字段的跨表副作用
当用户新建一个关联字段(link 字段)时,往往会触发对端表的结构变化。FieldCreationSideEffectService负责"validate cross-table field dependencies (via visitors) and apply side effects after field creation"。
其流程(对应 FieldCreationSideEffectService.ts):
- 维护一个
tableState映射(foreign table id → Table 聚合),并把当前表也放入其中; - 调用领域侧的
FieldCreationSideEffectVisitor.collect收集副作用 spec——这是领域规则所在,应用服务不参与判断; preview阶段:逐条在内存中对目标表执行mutateSpec.mutate(foreignTable),产出"预览后的表状态",用于调用方预先校验(如数据安全限制检查);execute阶段:逐条副作用调用tableUpdateFlow.execute以事务方式落库,并以publishEvents: false抑制逐表事件发布,最后统一返回{ events, tableState }交由调用方决定何时发布。
一个关键细节:execute中tableUpdateFlow.execute的 mutate 回调会把 spec 包成TableUpdateResult.create(updated, sideEffect.mutateSpec),即"变更结果与变更 spec 一起交给流程",从而让TableUpdateFlow能够判断是否触发物理 schema 修复。
4.3 FieldDeletionSideEffectService:删字段的对称清理
删除字段比创建更危险:对于 link 字段,必须同步删除对端表的对称字段;在字段被软删除后,还要保证"孤儿 link/lookup 字段仍然可删"。FieldDeletionSideEffectService的实现与创建流程对称(对应 FieldDeletionSideEffectService.ts):
FieldDeletionSideEffectVisitor.collect收集删除副作用;- 逐副作用在事务中应用,同样以
publishEvents: false聚合事件; - 若副作用 spec 是
TableRemoveFieldSpec,则记录appliedDeletions(被删字段、变更前表、变更后表),供调用方做撤销/重做快照或后续元数据清理。
注意删除流程没有 preview 阶段,且输入只包含table、fields、foreignTables三项,这是因为删除副作用的"可预览性"需求低于创建(创建需要先经过数据安全限制等检查)。
4.4 ForeignTableLoaderService:一次加载、集中校验
跨表副作用的前提是"先把对端表加载出来"。ForeignTableLoaderService实现了 "load foreign tables once and validate missing references"(对应 ForeignTableLoaderService.ts):
load(context, { baseId, references, allowMissing }):按baseId对引用分组(无 baseId 的归入__any__),用TableAggregate.specs(baseId).byIds(...)批量查询,最后校验缺失引用——默认情况下任一 foreign table 缺失即返回notFound错误并携带missingForeignTableIds明细;allowMissing: true是专门给"删字段流程"用的:当对端表已被软删除或彻底移除后,孤儿 link/lookup 字段必须仍可删除,此时缺失引用会被跳过而不是失败(该选项在源码注释中有明确说明);loadForLinkTitleFill:针对typecast场景,通过内部MissingLinkTitleForeignTableCollector(一个ICellValueSpecVisitor)从单元格值 spec 中收集"需要按标题解析链接目标"的引用,再去加载对应表,用于记录写入时填充 link 标题。
此外还提供了NullForeignTableLoaderService(空实现,用于不依赖外表的默认场景),这种"接口 + 可替换实现"的组合体现了依赖注入端口(port)风格。
4.5 RecordCreationService:单条记录创建的共享工作流
RecordCreationService是"shared single-record creation workflow reused by multiple handlers",即被"创建单条记录 / 表单提交(submit)/ 复制记录(duplicate)"三类入口复用的统一创建流程(对应 RecordCreationService.ts)。其编排链路非常完整,几乎串联了 application 层的所有关键服务:
FieldKeyResolverService.resolveFieldKeys:把输入按fieldKeyType(字段 ID 或字段名)解析为字段 ID → 值;recordWritePluginRunner.prepare(...).guard():执行记录写插件的 prepare/guard 阶段(详见下文);recordWriteSideEffectService.execute:处理写记录的跨表副作用(例如 select 选项的按需创建),得到"可用于创建的表状态";recordWriteUndoRedoPlanService.captureSelectOptionSideEffects:为 select 选项副作用捕获撤销/重做计划;- 领域调用
tableForCreate.createRecord(resolvedFieldValues, { typecast, source })生成记录与 mutate spec;若 spec 需要解析(如按标题写链接),则recordMutationSpecResolver.resolveAndReplace替换为可落库的 spec; - 事务内:先(如有必要)执行
tableUpdateFlow.execute落表变更,再pluginExecution.beforePersist,随后tableRecordRepository.insert插入记录(typecast 时携带fillLinkTitles与fillLinkTitleForeignTables); - 事务外:
recordChangedValueDecoratorService装饰变更字段值、合并计算字段变化(computedChanges)后构造RecordCreated事件,eventBus.publishMany发布; requireStoredRecordSnapshot校验持久化快照,undoRedoStackService.appendRecordCreate追加撤销/重做栈(包含 select 选项副作用的 undo/redo 命令);- 最后
pluginExecution.afterCommit(),并返回{ record, events, fieldKeyMapping, computedChanges }。
其中fieldKeyMapping的意义在于:当请求以字段名写入时,响应需要用字段名回指字段 ID,方便调用方做键映射。这个服务完整展示了"应用服务 = 编排多个端口/子服务 + 领域模型只出规则与事件"的范式。
4.6 RecordWritePluginRunner:记录写插件的四阶段编排
RecordWritePluginRunner.ts 实现了文档所说的 "run typed record-write plugins across prepare/guard/beforePersist/afterCommit phases"。从源码可见其编排细节:
- 插件阶段:
supports → prepare → scope → guard → beforePersist → afterCommit,其中supports决定插件是否参与本次操作; - enforce 分组:
enforceOrder将pre/post(默认居中)插件分入三组,保证执行顺序确定(createEnforceGroups); - 上下文处理:
sanitizeRecordWritePluginContext通过table.clone(tableMapper)对传入的表做克隆,避免插件污染主聚合;withTransactionBoundContext在事务内阶段把isTransactionBound置为 true; - 可观测性:每个阶段都会创建带插件名与阶段名的 trace span(
createRecordWritePluginTraceAttributes,含TeableSpanAttributes.OPERATION、PLUGIN_PHASE等属性)。
它支撑了 Teable 的"表数据安全限制"(TableDataSafetyLimitRecordWritePlugin)与"表字段数限制"(TableFieldLimitFieldOperationPlugin)等能力,这些插件同样位于services/下,印证了"应用服务/插件编排"在该层的密度。
4.7 DeleteByRangeApplicationService:按范围删除的两种模式
DeleteByRangeApplicationService是顶层文档点名的服务之一:"share range-based delete planning, persistence, undo/redo, and progress streaming for delete-by-range handlers"(共享基于范围的删除规划、持久化、撤销/重做与进度流)。它支持直接模式(direct)与流式模式(stream):
- 直接模式
delete()(对应 DeleteByRangeApplicationService.ts):prepareDeletePlan(按 range 类型解析目标记录、合并视图的 filter/sort/group、替换current user标签、构建删除快照)→ 准备并 guard 插件 → 单事务deleteManyInSingleTransaction→finalizeDeletePlan(含撤销/重做栈追加)。 - 流式模式
createStream():面向超大范围删除,返回AsyncIterable<DeleteByRangeStreamEvent>,事件类型为progress | done | error(DeleteByRangeStreamProgressEvent携带phase: 'preparing' | 'deleting'、batchIndex、totalCount、deletedCount、batchDeletedCount)。内部把删除计划切成多个DeleteStreamChunkPlan(默认查询页大小常量DEFAULT_DELETE_QUERY_PAGE_SIZE = 500,流式事件缓冲上限MAX_DELETE_STREAM_BUFFERED_EVENTS = 64),逐 chunk 执行并把进度推入AsyncIterableQueue,供 HTTP 层以 SSE/流式响应回传进度。
该服务的所有步骤都被runInSpan包裹并打上teable.DeleteByRangeApplicationService.*与teable.delete_mode(direct/stream)、teable.range_type(columns/rows/cells)等 span 属性,体现了 Teable 在编排层内置的可观测性标准。
4.8 其他值得关注的服务
顶层文档还提到两个服务,这里一并说明:
- TableUpdateFlow 之外的 TableQueryService:提供跨 CommandHandler/QueryHandler 复用的通用查表操作(
getById、getByIdInBase、exists),DeleteByRangeApplicationService 的prepareDeletePlan第一步就是tableQueryService.getById(context, command.tableId); - TableDeletionSideEffectService:删除表之前,向其他表显式派发
OnTeableTableDeleted反应(reaction),包括 link-to-text 转换与依赖元数据清理(见 services/ARCHITECTURE.md)。
此外services/目录还包含FieldUpdateSideEffectService、FieldCrossTableUpdateSideEffectService、LinkFieldUpdateSideEffectService、TableCreationService、SchemaOperationRunnerService、DuplicateRecordsApplicationService、RecordBatchCreationService、RecordBulkUpdateService、RecordReorderService、UndoRedoStackService、TableSchemaOperationLifecycleService、TableSchemaOperationRepairHandler、TableUpdateTransactionScope等,共同构成完整的编排能力矩阵。每个服务都配有同名.spec.ts测试,例如 TableUpdateFlow.spec.ts、FieldCreationSideEffectService 相关测试 与 DeleteByRangeApplicationService 目录内对应测试,是阅读实现行为的最佳辅助材料。
五、数据流串联:一次"建字段"请求如何穿越 application 层
把上述服务串起来,可以还原一个典型场景——用户在视图中新建一个 link 字段,请求在 v2 Core 中的编排路径大致为:
- 命令处理器接收
CreateFieldCommand,通过ForeignTableLoaderService.load预加载对端表(缺失引用立即报错); - 调用
FieldCreationSideEffectService.preview在内存中预览跨表副作用(供数据安全限制等检查); - 通过
FieldCreationSideEffectService.execute逐表执行TableUpdateFlow,以事务落库,产出聚合后的领域事件; - 命令处理器按需发布事件;
- 事件总线把
FieldCreated、FieldUpdated等事件分发给投影处理器(如FieldCreatedRealtimeProjection),投影从快照缓存取数、写入实时引擎,前端实时刷新。
整个过程体现了 ARCHITECTURE.md 的三层约定:规则在领域(visitor/spec),编排在应用服务(services/),派生在投影(projections/)。
六、给开发者的扩展与维护建议
6.1 新增一个应用服务时的自查清单
对照 ARCHITECTURE.md 与 services/ARCHITECTURE.md,新增服务时应确认:
- 服务内没有领域规则:任何"该不该做、做成什么样"的判断都应委托给 domain 下的 visitor/spec,应用服务只负责"按什么顺序、在什么事务里、何时发布事件";
- 不调用其他命令处理器:共享逻辑一律下沉为服务并在本层复用(对应 src/ARCHITECTURE.md 的分层规则);
- 持久化后工作留给仓储:schema 刷新、回填重放等不应写在应用服务里;
- 携带可观测性:参照
@TraceSpan()或runInSpan的习惯,为关键步骤打 span; - 配套
.spec.ts:每个服务都有对应测试是本目录的既定惯例。
6.2 维护本架构文档的约定
本目录及所有子目录的ARCHITECTURE.md首行都有一个统一声明:"Declaration: If the folder I belong to changes, please update me, especially core domain concepts."——即目录结构或核心概念变化时,必须同步更新本文件,并在抽象概念处补充示例或示例文件路径。这也是整个 v2 core 仓库的文档纪律:架构注释与代码同源演进。
结语
Teable v2 Core 的 application 层用最朴素的方式回答了"编排逻辑放哪里"的问题:领域模型保持纯净,命令处理器保持轻量,所有跨聚合的协调、事务边界与事件发布统一收敛到 application/services 与 application/projections 中。无论是TableUpdateFlow对分库场景下 DDL 一致性的精细管理,还是DeleteByRangeApplicationService对超大规模删除的流式进度推进,亦或是RecordCreationService对插件、副作用、撤销/重做、事件发布的串联,都展示了一个生产级电子表格引擎在"编排层"应当具备的工程水准。以此为参照,你可以轻松定位任何写路径的编排入口,并遵循同一套约束继续扩展。
【免费下载链接】teable✨ AI Spreadsheet for Business项目地址: https://gitcode.com/GitHub_Trending/te/teable
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考