SaaS 后台的 DSL 改造(一):分支地狱——我们为什么推翻重写
这个系列写的是我在公司里主导做的一次后台系统改造。我们是做券商业务的,公司有一套自己的 SaaS 系统,这个系列讲的是它的后台管理部分。这套后台要服务很多家接入的券商,每家都有自己的定制化要求,需求越积越多之后,原来的方案扛不住了,我们把它整个推翻重做了一遍。整个系列大概五篇,这一篇不讲实现,只讲我们为什么走到推翻这一步,以及新的方案是怎么长出来的。
这篇里不会出现真实的商户名,券商 A 券商 B 这样的叫法都是代称。数字是按实际情况约出来的,不是精确统计。
1. 业务背景:SaaS 的宿命是定制
先说清楚这套系统在我们业务里的位置,后面的问题都从这里长出来。
公司主业是券商业务,围绕它建了一套 SaaS 系统,我负责的是其中的后台管理部分。券商接入之后,用这套后台管自己的业务。
问题在于,没有两家券商要的东西是完全一样的。
券商 A 说我们的商品列表要多一列,展示我们自己渠道的字段。券商 B 说这个按钮不能叫这个名字,而且点了之后要先弹一个确认框。券商 C 有自己的主题色和品牌规范,整个后台要换成他们的样子。券商 D 更直接,他们有一个现成的内部系统,要求我们某个页面整块嵌他们的东西进去。
这些需求没有一个是过分的,甚至每一个单独看都很好做。难的是它们同时存在,而且持续增加。
2. 旧方案:一家商户一个分支
最早的解法非常直觉:从主干拉一个分支,这家券商的所有定制都在这个分支上改。主干保持通用逻辑,谁家有特殊需求谁家有分支。
两家三家客户的时候,这个方案几乎完美。定制改动隔离在各自分支里,互不影响,主干依然是干净的。
3. 地狱是从什么时候开始的
客户多起来之后,三个问题依次浮出来,一个比一个致命。
3.1 修一个 bug 要修 N 遍
主干上发现一个通用逻辑的 bug 修完不算完,要把这个修复同步到每一个商户分支。cherry-pick 十几次是常态,每次还可能因为各分支的差异产生冲突,要逐个人肉解决。
最可怕的不是麻烦,是会漏。十几个分支同步一圈,总有漏掉的,漏掉的那个商户就带着 bug 上线了。这种事故我们出过,排查的时候才发现,这个 bug 主干上个月就修了,只是没同步到出事的那家。
3.2 分支会漂移
时间长了之后,各分支和主干长得越来越不一样。有些差异是定制需求,合理。有些差异是某次同步冲突没解决好留下的,或者是某个分支上的人顺手改了点通用逻辑。没人说得清哪些差异是需求,哪些是事故。
到后期,想给所有客户统一上一个新功能,先要花大量时间研究每个分支现在到底长什么样,这个功能的代码往哪儿放。新功能的开发成本里,有一大半是这种考古成本。
3.3 接入新商户变成体力活
新签一家券商,标准动作是:找一个最接近的旧分支 copy 一份,然后人肉把它改成新券商的样子。这个"最接近"的判断本身就要靠老员工的经验。改的过程中要全程祈祷别漏掉旧券商留下的私货,我们就出现过新商户的页面上还挂着旧商户文案的事故。
整个接入周期按周算,而且其中真正的开发工作可能只有一两天,剩下全是复制、比对、确认。
4. 压垮骆驼的那个瞬间
转折点是一个普通的迭代。那次要给所有券商上一个功能优化,我评估完工作量发现:写这个功能本身大概两天,把它落到所有分支上并且验证,要两周。
也就是说,维护分支差异的成本已经是开发新功能成本的好几倍,而且这个倍数随着商户变多还在继续涨。再往后走,团队会完全陷在同步和排查里,没有余力做任何新东西。
那天晚上我盘了一下现状,结论很清楚:分支这个方案管的是"代码的差异",但我们真正的需求是管"配置的差异"。一家券商和另一家的区别,绝大多数时候不是代码逻辑的区别,而是这一列显不显示、这个按钮叫什么、这个表单多一个字段。这些东西用代码分支来表达,粒度太粗了,代价也太大了。
应该用一份描述来说清楚每家要什么,而不是用一份代码来固化每家是什么。
5. 新方案长什么样
想清楚这一点之后,方案的方向就明确了。最后落地的东西长这样:
flowchart TD A[model.ts 通用 DSL 基线] --> C[合并引擎] B1[券商 A 差异 DSL] --> C B2[券商 B 差异 DSL] --> C B3[券商 N 差异 DSL] --> C C --> D[每家券商的完整 DSL] D --> E[渲染引擎 自动生成页面] E --> F[构建出多份产物] F --> G[nginx 按券商分流] H[逃生舱: iframe / custom 页面 / 自定义插槽] -.兜住 10% 硬定制.-> E
拆开说是四层:
通用功能 DSL 化。 一个后台页面的构成其实是高度结构化的:菜单、表格、搜索栏、新增编辑表单、详情。这些东西用一份 JSON 风格的 DSL 就能描述完整,大约 90% 的功能都在这个范围内。页面不再手写,由渲染引擎读 DSL 自动生成。
定制走继承,不走分支。 每家券商不再是一份完整代码,而是一份只写差异的 DSL。券商 A 要加一列,就在它自己的 DSL 里声明加这一列,其余部分自动继承通用基线。所有券商跑的是同一份代码,差异只是数据。
留逃生舱。 总有一小部分定制是 DSL 描述不了的,大约 10%。比如券商 D 那种整块嵌入内部系统的需求。对这些我们提供三种口子:iframe 模块直接嵌外部页面、custom 模块接完全手写的页面、自定义插槽替换 DSL 生成页面里的局部。原则是 DSL 优先,兜不住的地方允许手写,但手写部分被严格关在这些口子里,不会污染主干。
多产物分发。 构建时按券商产出多份静态产物 nginx 根据域名把不同券商导到各自的产物。每家券商也可以有自己的主题,在产物层面彻底隔离。
6. 效果
改造完成后,最直观的变化是工作量的分布反过来了。
以前一个通用功能的成本是"开发一次 + 同步 N 次 + 排查漂移",现在是"改 DSL 一次,所有券商生效"。整体工作量降了大约 80% 新商户接入从按周算变成按天算:写一份差异 DSL 走一遍构建,就结束了。分支从十几个收敛回一个主干,再也没有同步和考古。
当然这套东西不是免费的,它有自己的代价和边界,比如 DSL 的表达能力上限、合并算法的复杂度、团队要学习新的开发范式。这些我会在最后一篇认真讨论,它不适合所有项目。
7. 系列预告
后面的文章按从内到外的顺序把整个实现拆开:
- 框架底座:为什么手写了一个 egg 风格的 Koa 加载器 DSL 和定制代码都需要一个可插拔的家
- BFF 防御体系:zod 声明式参数校验、签名、责任链,以及 Koa 中间件拿不到路由参数这个坑
- DSL 继承与合并算法:全系列的核心,差异 DSL 怎么和基线合并,数组按 key 合并的那些边界
- 渲染引擎与逃生舱:一份 DSL 怎么变成表格、表单、详情页,那 10% 的手写部分怎么接入
小结
这一篇没有代码,只讲了一件事:我们原来用分支管理券商差异,商户变多之后,同步成本、漂移成本、接入成本全面失控,维护开销超过了开发开销。改造的出发点不是技术升级,是算账算出来的。下一篇开始进代码,先讲承载这一切的框架底座。