SaaS 后台的 DSL 改造(四):一套代码服务 N 个券商——DSL 继承与合并算法

8 分钟阅读

前三篇讲了为什么改造、框架底座和 BFF 防御体系。这一篇是全系列的核心:券商之间的差异到底怎么表达,一份基线配置和一份差异配置怎么合并出每个券商的完整后台。第一篇里说的"90% 通用 DSL 生成,10% 扩展继承",落地就在这一篇。

照例,代码按真实实现精简过,结构是真的,细节不全。

1. 从一个最普通的定制说起

券商 A 提了需求:商品列表要多一列,展示他们自己渠道的字段。

分支时代的做法:切到券商 A 的分支,找到商品列表页面的代码,加列,发版,祈祷别碰坏别的。下次主干改了商品列表,cherry-pick 过来,解决冲突。

现在的做法:在券商 A 的差异 DSL 里加一段声明,构建,结束。商品列表的代码只有一份,所有人共用。

从"改代码"到"写声明",中间隔着的就是这一篇要讲的东西。

2. DSL 长什么样

先要知道差异声明写在哪、长什么样。一个后台页面的通用骨架其实就这几样:菜单、表格、搜索栏、表单、详情。DSL 就是把这些结构化地描述出来:

typescript
// 基线 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 文件按目录组织,目录结构本身就是继承关系:

text
app/model/
└── business/
    ├── model.ts          # 基线: 通用后台的完整描述
    └── project/
        ├── brokerA.ts    # 券商 A: 只写和基线不一样的部分
        └── brokerB.ts    # 券商 B: 同上

基线 model.ts 是完整的全量配置,可以直接跑。券商的 project 文件是 Partial,只写差异。比如券商 A 要加一列、改个项目名,它的文件里就只有这两样:

typescript
// 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 合并,这是整个算法的核心:

typescript
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 的默认深合并。

一次完整的合并走查

光看代码不过瘾,拿商品管理这个菜单完整走一遍。基线:

typescript
// 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 的差异:编辑按钮要改名、要加个复制按钮、商品名称的文案要改、要加渠道列:

typescript
// 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: '渠道' },  // 新字段 -> 加
      },
    },
  },
}

合并结果:

typescript
{
  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 基线。

typescript
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 上:

typescript
// /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% 兜不住的手写部分怎么通过逃生舱接入。

系列 · SaaS 后台的 DSL 改造 · 第 4 篇 / 共 5 篇
01SaaS 后台的 DSL 改造(一):分支地狱——我们为什么推翻重写02SaaS 后台的 DSL 改造(二):框架底座——手写一个 egg 风格的 Koa 加载器03SaaS 后台的 DSL 改造(三):BFF 防御体系——zod 声明式校验、签名与责任链
04SaaS 后台的 DSL 改造(四):一套代码服务 N 个券商——DSL 继承与合并算法本篇
05SaaS 后台的 DSL 改造(五):渲染引擎——一份 DSL 生成整个后台
评论