大多数文档平台在谈论 "可扩展性 "时,就像航空公司谈论 "腿部空间 "一样--技术上是存在的,但实际上却令人失望。我们希望 Rasepi 的架构既具有真正的可扩展性,又不会变得不可预测,因此我们建立了三个环环相扣的系统:其中,插件负责功能,动作保护负责控制,管道负责确定性执行。
本篇文章将介绍每个系统在我们的实际代码库中是如何工作的。
插件系统:模块化设计
Rasepi 中的每个插件都实现了 IPluginModule_--一个单一的接口,它声明了插件是什么、需要哪些服务、暴露哪些路由:
codeblock_0
PluginManifest 是纯数据。它描述插件而不执行任何操作:
codeblock_1
请注意 UiContributions--该字典将前端扩展点映射到组件名称,因此 Vue 前端知道每个插件贡献了哪些 UI 组件(工具栏按钮、侧边栏面板、设置页面)。
每个插件注册一行
启动时,我们通过流畅的 API 注册插件:
codeblock_2
每次调用都会实例化模块,将其存储在注册表中,并调用 RegisterServices() 来连接其依赖关系。应用程序构建完成后,只需一行即可映射所有插件路由:
代码块_3_
在引擎盖下,每个插件都会在 /api/plugins/{pluginId}/ 处获得一个范围路由组,并自动应用授权。
真实例子:工作流插件
下面是一个真实插件的样子--工作流程与审批模块:
codeblock_4
核心平台从不直接引用 WorkflowService或 WorkflowPublishGuard。它是通过 DI 容器发现它们的。这就是零耦合的关键--核心应用程序从不接触插件代码。
操作防护:控制层
插件添加功能。操作保护器决定是否允许继续执行该功能或任何核心操作。它们是同步验证器,可在执行前拦截操作。
行动保护评估流程](/zh/blog/img/action-guard-flow.svg)
该接口特意做到了最小:
codeblock_5
当 _ActionName_为 _null_时,防护程序会在每个操作中运行。当设置为 "Entry.Publish"_时,它只拦截特定的操作。
上下文和结果合约
每个 guard 都会收到一个类型化上下文,其中包含操作名称、租户、用户、实体和一个属性包:
codeblock_6
每个监控程序都会返回一个可预测的结果--允许、拒绝或允许-带修改:
代码块 7
Modifications字段非常重要--防护程序可以批准一个操作,但重写部分内容(例如,在发布前编辑机密)。
###规范操作名称
我们将所有可拦截的操作都定义为字符串常量,因此防护程序的目标操作不会产生任何歧义:
codeblock_8
真实例子:阻止未经批准的发布
工作流插件注册了一个拦截 _Entry.Publish的保护程序:
代码块_9__
核心平台对审批工作流一无所知。
操作管道:一切汇聚之处
ActionPipeline 是所有受保护操作的单一执行路径。它会确定哪些防护措施适用,对其进行评估,并阻止或执行操作。
代码块_10_
EvaluateAsync_方法负责执行繁重的工作:
代码块_11_
这里有三个重要的设计决定:
1.按租户解析 - TenantPluginResolver会检查每个租户安装并启用了哪些插件。禁用插件的防护程序永远不会运行。
2.2. 全部必须通过 - 如果任何防护措施被拒绝,该操作就会被阻止。这是故意采取的安全立场。
3.** 防护错误无法打开** - 如果防护出现异常,则会记录并视为 Allow()_。这可以防止损坏的插件锁定整个平台。
每租户插件解决方案
解析器会查询 TenantPluginInstallations 表(通过 EF 全局查询过滤器自动扩展到当前租户):
代码块_12_
事件驱动的副作用
操作是同步的。副作用则不是。操作完成后,服务会发布一个域事件:
codeblock_13
Worker 将事件路由到多个系统:
- 活动跟踪** - 记录谁在何时做了什么。
- 翻译计费** - 跟踪每个提供商的成本
- 插件事件处理程序** - 任何插件都可以订阅域事件
插件事件处理程序实现 IPluginEventHandler:
代码块_14__
Worker 只调用租户已启用插件的处理程序。这意味着插件 A 的副作用永远不会泄漏到只安装了插件 B 的租户中。
块级翻译引擎
这是该架构最明显的优势所在。
块级翻译:仅对已更改的块进行重译](/zh/blog/img/block-translation.svg)
传统平台翻译整个文档。我们翻译的是单个块--段落、标题、列表项。当用户编辑 50 块文档中的一个段落时,只有该段需要重新翻译。这就是我们节省 94% 成本的原因所在。
如何从 TipTap JSON 创建块
当用户保存文档时,TipTap 编辑器会发送这样的 JSON:
codeblock_15
BlockTranslationService_会解析该 JSON 并创建单独的EntryBlock_记录:
代码块_16_
SHA256 哈希算法用于过期检测
内容散列是过期检测的核心。我们使用 SHA256 对区块内容(去掉 blockId 和 deleted 等元数据属性后)进行散列:
代码块_17_
当源代码块发生变化时,它的哈希值也会发生变化。然后,系统会将每个翻译块的 SourceContentHash与当前源散列进行比较,不匹配的部分会被标记为 Stale:
代码块_18_
结构适应
翻译人员可以改变不同语言的代码块类型。英语的项目列表可能会变成德语的编号列表--这是一种文化偏好。系统会对此进行跟踪:
codeblock_19
翻译提供者作为插件
外部翻译服务(DeepL、Google Translate 等)通过 _ITranslationProviderPlugin_插入:
代码块_20_
批处理方法接收内容块 ID 词典,翻译所有内容块,并返回翻译结果和计费字符数。由于我们只发送陈旧的块,而不是整个文档,因此成本保持在最低水平。
租户隔离:无形的安全网
上述每个系统都在严格的租户隔离内运行。
TenantContextMiddleware 会根据每次请求中的 JWT 解析租户,并验证成员身份:
代码块_21__
Entity Framework 全局查询过滤器可确保即使开发人员忘记按租户进行过滤,数据库层也会自动进行过滤:
代码块_22___
结果:db.Hubs.ToListAsync() 总是只返回当前租户的集线器。数据泄露需要主动绕过查询过滤器--这在我们的代码库中是禁止的。
##全貌
当用户点击 "发布 "条目时,会发生以下情况:
1.输入请求 - 身份验证验证 JWT,TenantContextMiddleware_解析并验证租户
2.控制器调用管道 - IActionPipeline.ExecuteAsync("Entry.Publish", context, action)
3.管道解析防护 - 查询租户启用了哪些插件,选择适用的防护
4.4. 防护装置评估 - 工作流防护装置检查审批,保留防护装置检查策略,规则防护装置验证内容
5.全部通过?执行操作 - 发布条目
6.事件触发* - _CODEBLOCK_54__事件被触发
7.后台工作进程 - 记录活动、更新翻译账单、调用插件事件处理程序
8.检查块翻译 - 识别过时的块以进行重新翻译
各层各司其职。没有任何一层会影响到另一层。这就是架构。
我们建立这个平台并不是因为可扩展性是一种时尚。我们之所以构建它,是因为一个不能适应每个团队工作流程的文档平台最终会被一个能适应的平台所取代,而一个没有护栏的平台最终会破坏一些重要的东西。