SaaS 后台的 DSL 改造(四):一套代码服务 N 个券商——DSL 继承与合并算法
前三篇讲了为什么改造、框架底座和 BFF 防御体系。这一篇是全系列的核心:券商之间的差异到底怎么表达,一份基线配置和一份差异配置怎么合并出每个券商的完整后台。第一篇里说的"90% 通用 DSL 生成,10% 扩展继承",落地就在这一篇。
照例,代码按真实实现精简过,结构是真的,细节不全。
1. 从一个最普通的定制说起
券商 A 提了需求:商品列表要多一列,展示他们自己渠道的字段。
分支时代的做法:切到券商 A 的分支,找到商品列表页面的代码,加列,发版,祈祷别碰坏别的。下次主干改了商品列表,cherry-pick 过来,解决冲突。
现在的做法:在券商 A 的差异 DSL 里加一段声明,构建,结束。商品列表的代码只有一份,所有人共用。
从"改代码"到"写声明",中间隔着的就是这一篇要讲的东西。
2. DSL 长什么样
先要知道差异声明写在哪、长什么样。一个后台页面的通用骨架其实就这几样:菜单、表格、搜索栏、表单、详情。DSL 就是把这些结构化地描述出来:
// 基线 model 的一个菜单项 精简过
{
key: 'productManagement',
name: '商品管理',
menuType: MenuType.MODULE,
moduleType: ModuleType.SCHEMA,
schemaConfig: {
api: PRODUCT_MAIN, // 页面数据走哪个 BFF 接口
tableConfig: {
headerButtons: [{ key: 'create', name: '新增', /* ... */ }],
rowButtons: [{ key: 'edit', name: '编辑', /* ... */ }],
},
searchConfig: { /* 搜索栏配置 */ },
componentConfig: { /* 新增/编辑表单、详情面板的配置 */ },
schema: {
type: 'object',
properties: {
id: { type: 'string', label: '商品ID', tableOption: { hidden: true } },
name: { type: 'string', label: '商品名称' },
price: { type: 'number', label: '价格' },
},
},
},
}关键点有两个。一是每个元素都有 key,菜单项有 key,按钮有 key,字段名本身就是 key——这是后面合并算法的命脉。二是 moduleType 决定这个页面由什么渲染:SCHEMA 是 DSL 生成的标准页面,另外还有 IFRAME、CUSTOM 这些逃生舱类型,第五篇讲。
3. 双层模型:基线写全量,券商只写差异
DSL 文件按目录组织,目录结构本身就是继承关系:
app/model/
└── business/
├── model.ts # 基线: 通用后台的完整描述
└── project/
├── brokerA.ts # 券商 A: 只写和基线不一样的部分
└── brokerB.ts # 券商 B: 同上基线 model.ts 是完整的全量配置,可以直接跑。券商的 project 文件是 Partial,只写差异。比如券商 A 要加一列、改个项目名,它的文件里就只有这两样:
// project/brokerA.ts 精简示意
export default {
key: 'brokerA',
name: '券商 A 管理后台',
headerMenu: [
{
key: 'productManagement', // 同 key: 和基线的这个菜单合并
schemaConfig: {
schema: {
properties: {
channel: { type: 'string', label: '渠道' }, // 新 key: 追加一列
},
},
},
},
],
} satisfies Partial<Dashboard>文件里没有任何 import,没有继承语法,"继承"完全由目录位置和 key 表达。写差异的人不需要知道基线的实现,只需要知道自己要改的那个 key 叫什么。
4. 合并算法:数组为什么不能直接深合并
基线和差异都是嵌套很深的对象,合并它们,第一个想法是上 Object.assign 或者随便哪个深合并工具。都不行,问题全出在数组上。
DSL 里最重要的结构都是数组:菜单是数组、按钮是数组。普通深合并对数组只有两种策略,两种都是错的:
整体覆盖。 券商 A 为了加一列,就得把基线 headerMenu 里那个菜单项连菜单带按钮带字段整个抄过来再改。差异文件迅速膨胀成全量文件的副本,基线之后任何修改都被覆盖失效——这就是分支地狱换了个马甲。
按索引合并。 券商 A 的 headerMenu[0] 和基线的 headerMenu[0] 合并。基线哪天调整菜单顺序,所有券商的差异全部错位,合出来的东西面目全非。顺序是表达不出来的,key 才行。
所以数组必须按 key 合并,这是整个算法的核心:
function extendModel(target, source) {
return mergeWith(target, source, (targetValue, sourceValue) => {
// 只特殊处理"两边都是数组"的情况 其余交给 mergeWith 默认行为
if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
const ret = []
// 基线有的: 差异里有同 key 就递归合并 没有就原样保留
targetValue.forEach((item) => {
const sourceItem = sourceValue.find((s) => s.key === item.key)
ret.push(sourceItem ? extendModel(item, sourceItem) : item)
})
// 差异新增的 key: 追加
sourceValue.forEach((item) => {
if (!targetValue.find((t) => t.key === item.key))
ret.push(item)
})
return ret
}
})
}三种情况,正好对应定制的三种意图:同 key 递归合并(我要改这个)、只进不退地保留(这个我没意见)、新 key 追加(我要加这个)。对象部分交给 mergeWith 的默认深合并。
一次完整的合并走查
光看代码不过瘾,拿商品管理这个菜单完整走一遍。基线:
// model.ts 里的一个菜单项
{
key: 'productManagement',
name: '商品管理',
schemaConfig: {
tableConfig: {
rowButtons: [
{ key: 'edit', name: '编辑' },
{ key: 'detail', name: '详情' },
],
},
schema: {
properties: {
name: { type: 'string', label: '商品名称' },
price: { type: 'number', label: '价格' },
},
},
},
}券商 A 的差异:编辑按钮要改名、要加个复制按钮、商品名称的文案要改、要加渠道列:
// project/brokerA.ts
{
key: 'productManagement', // 同 key -> 和这个菜单合并
schemaConfig: {
tableConfig: {
rowButtons: [
{ key: 'edit', name: '修改' }, // 同 key -> 改
{ key: 'copy', name: '复制' }, // 新 key -> 加
],
},
schema: {
properties: {
name: { label: '产品名' }, // 同字段 -> 只改 label
channel: { type: 'string', label: '渠道' }, // 新字段 -> 加
},
},
},
}合并结果:
{
key: 'productManagement',
name: '商品管理', // 差异没提 -> 基线原样保留
schemaConfig: {
tableConfig: {
rowButtons: [
{ key: 'edit', name: '修改' }, // 递归合并: key 保留 name 被覆盖
{ key: 'detail', name: '详情' }, // 差异没提 -> 保留
{ key: 'copy', name: '复制' }, // 新 key -> 追加在末尾
],
},
schema: {
properties: {
name: { type: 'string', label: '产品名' }, // 深合并: type 来自基线 label 来自差异
price: { type: 'number', label: '价格' }, // 差异没提 -> 保留
channel: { type: 'string', label: '渠道' }, // 新字段 -> 追加
},
},
},
}注意 name 字段那一行:type: 'string' 来自基线,label: '产品名' 来自差异,谁也不缺。这就是"差异只写要改的部分"的确切含义——对象按属性深合并,数组按 key 递归,未提及的一切原样继承。
还有一个容易翻车的地方:合并之前必须 cloneDeep 基线。
Object.entries(project).forEach(([projectKey, projectModel]) => {
project[projectKey] = extendModel(cloneDeep(model), projectModel)
})基线 model 是被所有券商共享的一份对象,直接在它上面合并,券商 A 的定制就会漏进券商 B——分支地狱幽灵再次还魂。这个 bug 在开发期真的咬过我们一口,症状是两家券商的配置互相串,最后查到是合并把基线改了。
要点:
- 数组按 key 合并:同 key 递归、基线保留、差异追加
- 索引合并不可用,因为顺序不是稳定标识,key 才是
- 合并前 cloneDeep 基线,否则券商之间互相污染
5. 运行时:怎么知道当前是哪家券商
合并是启动时一次性做完的,运行时的每个请求只需要回答一个问题:你是谁?
答案在上一篇提到的那道中间件里:前端请求带 proj_key header,injectProjectParams 把它挂到 ctx 上:
// /api/proj/ 开头的接口必须带券商标识
const { proj_key: projKey } = ctx.request.headers
if (!projKey) {
return Object.assign(ctx, getErrorResponse('参数错误', ResponseCode.PARAM_ERROR))
}
ctx.projKey = projKey之后的所有环节都拿这个标识说话。页面加载时,dashboard 接口按 projKey 从合并结果里取出这家券商的完整 DSL 下发;BFF 数据接口也拿得到它,同一份接口实现里可以按券商做差异化处理。
整条链路串起来:
flowchart LR
subgraph BUILD[启动时]
M[model.ts 基线] --> MG[extendModel 合并]
PA[brokerA.ts 差异] --> MG
PB[brokerB.ts 差异] --> MG
MG --> RA[券商 A 完整 DSL]
MG --> RB[券商 B 完整 DSL]
end
subgraph RUN[运行时]
REQ[请求 带 proj_key] --> INJ[injectProjectParams]
INJ --> API[dashboard / 数据接口]
RA --> API
RB --> API
end
6. 这个机制的边界
用了这么久,有两条边界是我们有意守住不越过的。
只支持加和改,不支持删。 差异 DSL 没法表达"基线里这个按钮我不要了"。不是做不了(约定一个 disabled: true 之类的语义就行),是不想开这个口子:一旦允许减法,读差异文件就必须同时在脑子里跑合并算法才能知道最终长什么样,差异文件就失去了"看到即所得"的直白。真遇到某家券商要的页面和基线差异太大,正确做法是把基线拆细,而不是在差异里写一堆删除。
key 是契约,改名等于删除加新增。 整个合并机制建立在 key 稳定的前提下。基线里一个菜单的 key 改了名,所有券商针对它的差异立刻全部变成"新增",旧 key 的配置原样保留——配置悄悄翻倍。所以 key 一旦发布就不动,要改展示名改 name,别动 key。
7. 改造的收益
第一篇说改造后工作量降了 80%, 我们来看看具体的对比:
| 分支时代 | DSL 时代 | |
|---|---|---|
| 通用功能改动 | 改主干 + 同步 N 个分支 + 解冲突 | 改基线,所有券商生效 |
| 券商定制 | 切分支改代码发版 | 写差异 DSL,一次构建 |
| 新券商接入 | copy 分支人肉改,按周算 | 写一份差异文件,按天算 |
| 差异的可见性 | 翻代码 diff | 一个文件看到即所得 |
最后一行是我们最核心的改造价值:差异从"藏在 N 份代码里的 diff"变成"一个可以review的配置文件"。code review 的时候,券商 A 这次要什么、会不会影响别人,一个文件全说清了。
小结
这一篇讲了核心机制:DSL 用 key 标识每个元素、目录结构表达继承、基线写全量而券商只写差异、数组按 key 合并(同 key 递归、基线保留、差异追加)、合并前必须 cloneDeep,以及"不支持删除"和"key 是契约"两条边界。下一篇讲这些 DSL 下发到前端之后,怎么被渲染成真实的表格、表单和详情页,以及那 10% 兜不住的手写部分怎么通过逃生舱接入。