SaaS 后台的 DSL 改造(二):框架底座——手写一个 egg 风格的 Koa 加载器
上一篇讲了我们的困境:用分支管理券商差异,商户变多之后维护成本全面失控,最后决定推翻重来,用一份描述代替一份代码。
从这一篇开始进实现。整套系统最底下是一个自己写的 Koa 框架,内部叫 puzzles-core,它是所有东西的地基。这一篇就讲这个地基:BFF 层是怎么被逼出来的,为什么我们不用现成框架,加载器是怎么实现的,以及运行时挂上去的东西怎么让 TypeScript 认识。
我的目的是把设计决策讲清楚,不是把每一行都贴出来,所以下面的代码都按真实实现精简过,变量名和结构是真的,细节不全。
1. 为什么需要框架层
推翻分支方案之后,我面对的是一个空目录和一堆待安置的东西。
盘了一下,新系统里有这些角色:BFF 接口、参数校验和签名(安全问题下一篇专门讲)、DSL 的存储和合并、渲染引擎要消费的接口、还有每家券商都可能有的零星定制接口。这些都不是前端页面,是服务端代码,它们需要一个统一的组织方式。
这里面的核心是 BFF 层,它是这次改造里新长出来的东西,值得单独说清楚它的来历。
旧方案里这些代码散在各个分支的路由文件里,没有分层,没有约定,想往哪写往哪写。这也是分支漂移那么快的原因之一:结构本身没有约束力,每个人都按自己的习惯放代码。
所以新系统的第一块砖不是 DSL,是框架。定好代码住在哪、怎么被找到、怎么被组装,后面的东西才有地方放。
2. BFF 层是被不规范的后端接口逼出来的
旧方案里没有 BFF 这个概念,前端页面直接调后端服务的接口。这在页面少的时候没问题,页面和客户多起来之后,后端接口的不规范开始原封不动地传导到前端代码里。
不规范体现在几个地方。同类型的接口参数风格不统一:都是查列表,这个接口的分页参数是一种叫法,换个接口又是另一种,排序、筛选各有各的习惯。返回结构不统一:有的接口把数据包在公共结构里,有的直接裸返回,错误码也是各定义各的,前端要在代码里为不同接口写不同的解包和判错逻辑。还有些接口把后端内部的字段原样吐出来,内部的数据结构、不该给前端看的实现细节,全暴露在响应里。
前端的应对方式是每个页面各自适配,适配代码散落一地,复制粘贴越来越多。换一家后端服务的实现,前端要跟着改一片。
BFF 层就是在这个背景下立起来的,它干三件事:
统一出入参。 所有经过 BFF 的同类型接口,请求参数和返回结构收敛成一套规范。前端面对的不是"后端接口",而是"BFF 接口",解包、判错、分页这些逻辑只写一遍。后端各家服务的差异在 BFF 内部消化,不传出去。
聚合。 一个页面需要的数据往往散在后端多个接口里,以前前端要自己发好几个请求再拼。现在页面只调一个 BFF 接口,后端那几次调用、数据的拼装裁剪,都在 BFF 里完成。前端代码里再也没有请求编排。
隐藏实现细节。 BFF 是后端和前端之间的过滤器:后端返回的字段在 BFF 里过一遍,该裁的裁掉,该改名的改名,内部服务的地址、数据结构这些实现细节到 BFF 为止。后端重构、换实现,只要 BFF 的对外结构不变,前端完全无感。
flowchart LR
FE[前端页面] -->|统一的一套接口| BFF
subgraph BFF[BFF 层]
C[controller<br/>对外接口] --> S[service<br/>聚合 裁剪 消化差异]
end
S -->|多次调用| BE1[后端服务 A]
S -->|多次调用| BE2[后端服务 B]
S -->|多次调用| BE3[后端服务 C]
前端只跟左边那一个箭头打交道,右边的混乱全部被关在 BFF 里。
想清楚 BFF 要干这些事之后,框架的需求就具体了:要有地方写 controller 和 service,要有中间件链做统一的校验和签名,要有规范管住每个人写接口的方式。这就是下一节要讲的取舍。
3. 为什么是约定式,为什么不直接用 egg
组织服务端代码大体两条路。
一条是手动注册:写一个路由文件,里面一行行 router.get('/xxx', someController.someMethod),再 import 一堆东西。这条路的问题是,注册处本身成了一个新的维护点:加了文件要记得注册,删了文件要记得注销,多人协作时注册文件还是冲突高发区。
另一条是约定式:目录结构就是注册表。你把文件放进 controller/ 目录,它就自动成为 app.controller 上的一员,不需要任何注册动作。egg.js 是这个路线的代表。
那为什么不直接用 egg?两个原因。
一是 egg 附带的功能太多了,而我们实际要的只是一个简单的 BFF。egg 是一整套企业级框架:多进程模型、插件体系、定时任务、完整的生命周期。这些能力我们一个都用不上,但引入它们就要为它们付理解成本和维护成本,对于一个只做接口聚合转发的 BFF 来说太重了。
二是改造成本。我们要做的事情里有不少自己的东西:DSL 的 model 解析、project 合并、按我们习惯定制的加载规则。这些用 egg 实现,意味着要一点点翻 egg 的文档,研究它的插件机制和生命周期,再想办法把我们的逻辑塞进它预留的扩展点里,塞不进去的地方还得对 egg 本身做改造。这条路走下来,花的功夫大概率比从头写一个还多,而且写出来的东西还是绕的。
所以结论很自然:借鉴 egg 的思路,自己从头设计一个简化版。我们需要的其实就是加载器、配置、日志这几样。自己的框架,每一行都知道为什么存在,后面 DSL 机制要往加载器里长的时候,也不存在任何隔阂。
4. PuzzlesModule:一切皆 app 的函数
框架里所有可被加载的东西,遵守同一个协议:
// 一个模块默认导出一个函数 接收 app 挂载完成后的实例
export type PuzzlesModule<T = unknown> = (app: PuzzlesCoreApp) => T | Promise<T>controller、service、middleware、extend、router 全都长这样。比如一个中间件:
export default ((app) => {
return async (ctx, next) => {
// ...中间件逻辑
await next()
}
}) as PuzzlesModule<Middleware>一个 service:
export default function projectServiceModule(app: PuzzlesCoreApp) {
return class ProjectService {
async getModelList() { /* ... */ }
}
}这个协议的好处是依赖注入不用容器。模块被加载的那一刻就拿到完整的 app 实例,配置、日志、别的模块都在上面,想要什么自己取。没有装饰器,没有 metadata,一个普通函数走天下。
5. 分层实战:controller 和 service 长什么样
协议定了,看两个真实角色怎么落地。
controller 的写法是:模块函数返回一个类,类继承基类。基类同样由模块函数生成,把 app 实例、配置、curl(内部请求用的 superagent)和统一响应方法全部备好:
export default function bussinessControllerModule(app: PuzzlesCoreApp) {
const BaseController = BaseControllerModule(app)
return class BussinessController extends BaseController {
async getList(ctx: Context) {
const { bussiness } = app.service // loader 挂好的 service 树 直接用
const { name, page, pageSize } = ctx.request.query
const res = await bussiness.getList({ page: +page, pageSize: +pageSize, name })
this.success(ctx, res) // 基类的统一响应方法
}
}
}service 是同一个模式,基类提供 curl,后端服务的调用、字段裁剪、多家后端差异的消化全在这层:
return class BussinessService extends BaseService {
async getList({ page, pageSize, name }) {
const res = await this.curl
.get(`${backendHost}/product/list`)
.query({ page, pageSize, name })
// 裁剪 改名 拼装 都在这 后端长什么样不传出去
return res.body
}
}controller 和 service 的职责切分,就是第 2 节 BFF 三件事的直接落地:controller 只管取参数、调 service、返回响应;service 管后端的一切。而"统一出入参"落到代码里,就是基类的这两个方法:
// 所有接口同一个信封: code + data + message + metadata
this.success(ctx, res) // { status: 200, body: { code: ResponseCode.SUCCESS, data: res } }
this.error(ctx, ResponseCode.CUSTOM_ERROR, '获取商品详情失败')前端面对的永远是同一个结构,判错只看 code。前端"解包判错只写一遍"能成立,靠的就是这一层, 想返回别的结构都没那么容易。约束长在基类里,比写在规范文档里有效得多。
要点:
- 模块函数返回类,类继承基类,基类备好 app、config、curl、统一响应
- controller 搬运参数,service 消化后端,响应走统一信封
- 规范靠基类固化,不靠文档
6. loader:目录结构就是注册表
加载器的输入是一个目录,输出是挂在 app 上的一棵树。以 controller loader 为例,核心逻辑:
export default (async (app) => {
const controllerDir = path.resolve(app.options.businessDir, './controller')
const files = glob.sync(`${controllerDir}/**/*.ts`)
const controller = {}
for (const file of files) {
// 从文件路径里截出模块名 比如 controller/order/refund.ts -> order/refund
const name = extractModuleName(file, 'controller/')
const names = name.split(sep)
// 按路径段拼嵌套结构 挂载到对应位置
let cursor = controller
for (let i = 0; i < names.length; i++) {
if (i === names.length - 1) {
const module = (await import(file)).default
cursor[names[i]] = new (await module(app))() // 拿到类 实例化
}
else {
cursor[names[i]] ??= {}
cursor = cursor[names[i]]
}
}
}
app.controller = controller
})效果是:app/controller/order/refund.ts 这个文件,自动变成 app.controller.order.refund 可以直接用。新增一个接口就是新增一个文件,删一个接口就是删一个文件,注册这个动作从开发流程里消失了。
middleware、service、extend、routerSchema、config 用的都是同一套思路,一共七个 loader,启动时按固定顺序执行:
await configLoader(app) // 先要有配置 后面都要读
await middlewareLoader(app)
await controllerLoader(app)
await serviceLoader(app)
await extendLoader(app)
await routerSchemaLoader(app)
await routerLoader(app) // 路由最后 它依赖前面所有的树整个过程画出来是这样:
flowchart TD
subgraph DIRS[目录 约定即注册]
D1[app/config/]
D2[app/middleware/]
D3[app/controller/]
D4[app/service/]
D5[app/extend/]
D6[app/routerSchema/]
D7[app/router/]
end
subgraph LOADERS[七个 loader 按序执行]
L[config -> middleware -> controller<br/>-> service -> extend -> routerSchema -> router]
end
subgraph APP[app 实例]
T1[app.config]
T2[app.middlewares]
T3[app.controller]
T4[app.service]
T5[app.extend]
T6[app.routerSchema]
T7[路由表]
end
D1 --> L --> T1
D2 --> L --> T2
D3 --> L --> T3
D4 --> L --> T4
D5 --> L --> T5
D6 --> L --> T6
D7 --> L --> T7
顺序不是随便排的。config 必须第一,因为所有模块初始化时都可能读配置;router 必须最后,因为路由文件里写的是 app.controller.bussiness.getList 这样的引用,前面的树没建好,路由就挂了。
要点:
- 目录结构即命名空间,加文件即注册,删文件即注销
- 模块协议统一为
(app) => ...,依赖注入不需要容器 - loader 顺序是依赖关系的体现:config 最前,router 最后
7. 运行时挂上去的东西,TS 不认识
约定式框架有一个经典的代价:app.controller.bussiness 是运行时扫目录挂上去的,TypeScript 编译期根本不知道 app 上有这个属性。没有类型提示,没有跳转,写错一个字母到运行时才炸。
解法是把树的形状生成为 .d.ts 声明文件,通过模块补充声明 merge 进框架类型:
// scripts/genServiceTypes.ts 生成的 types/service.d.ts 长这样
import '@puzzles/core'
declare module '@puzzles/core' {
interface ServiceTree {
project: InstanceType<ReturnType<typeof import('app/service/project').default>>
bussiness: InstanceType<ReturnType<typeof import('app/service/bussiness').default>>
}
}生成逻辑本身很朴素:和 loader 一样扫目录、按路径拼树,只不过拼出来的叶子不是实例,是一串类型表达式字符串,最后写进文件。InstanceType<ReturnType<typeof import(...)>> 这一长串的意思是:这个 service 文件默认导出一个模块函数,函数的返回是类,类的实例就是这个属性的类型。类型直接引用源文件,源文件的实现改了,类型跟着变,不用重新生成。
controller、middleware、extend 各有一个对应的生成脚本,启动命令里先跑一遍 gen-types 再启动服务。
8. 常驻 watcher:让类型自己长出来
手动跑生成脚本总会忘,忘了就对着满屏红线排查半天,最后发现是类型没更新。所以又加了一个文件监听器,开发时常驻后台:
// scripts/startWatcher.ts
const watcher = spawn('npx', ['ts-node', 'scripts/fileWatcher.ts'], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
})
watcher.unref() // 解除父进程对子进程的引用detached 加 unref() 是让这个 watcher 进程脱离主服务的生命周期独立存活。监听的逻辑:
// scripts/fileWatcher.ts
chokidar.watch(['app/controller/', 'app/service/', 'app/middleware/', 'app/extend/'])
.on('add', path => execCommand(path)) // 新增文件 -> 重新生成对应类型的 d.ts
.on('unlink', path => execCommand(path)) // 删除文件 -> 同上注意这里只监听了 add 和 unlink,没有监听 change。这不是偷懒:类型树的形状只取决于"有哪些文件",文件内容怎么改都不影响 import('app/service/x') 指向哪。内容变了 TS 自己重新推导就行,形状变了才需要重新生成。少监听一类事件,就少一些无意义的进程调用。
效果是从这之后,类型问题在团队里基本绝迹:新建文件,一秒后编辑器里 app.service.xxx 就有完整提示。
要点:
- 类型树的形状取决于文件清单,不取决于文件内容,所以只监听增删
- 生成的声明引用源文件本身,实现变化自动传导,不用重新生成
detached + unref让 watcher 脱离主进程独立存活
9. 踩坑:controller 方法的 this 丢了
加载器写完之后,第一个真实 bug 来得很快。
路由文件里是这样挂载的:
router.get(PRODUCT_MAIN, bussinessController.get)bussinessController.get 是类实例上的一个方法,这样直接传引用,this 就丢了,方法内部访问 this.app 直接 undefined。传统解法是要么路由里包一层箭头函数,要么每个 controller 构造函数里手动 bind,都很丑,而且总会有人忘。
最后的解法是在 loader 实例化 controller 的时候,直接用 Proxy 把方法访问拦截掉,自动 bind:
const instance = new ControllerClass()
tempController[name] = new Proxy(instance, {
get(target, prop) {
const result = Reflect.get(target, prop, instance)
// 是函数就返回 bind 过的版本 使用方完全无感
return isFunction(result) ? result.bind(instance) : result
},
})从那之后 router.get(path, controller.someMethod) 就是安全的,写路由的人不需要知道这件事的存在。框架的职责就是把这种坑在自己这层填平,而不是增加使用者的心智负担。
10. 回头看这个底座
整个 puzzles-core 讲完了:一个统一模块协议、七个加载器、一套类型生成管线、一个常驻 watcher。它没有多少"高级"技术,全是把已知模式(egg 的约定式、TS 的模块补充声明、Proxy)按我们的场景拼起来。
到这里,BFF 有了家:controller 写对外接口,service 消化后端差异,中间件链管统一校验,这些都住在框架划好的房间里。
但它给后面的东西提供了两个关键性质。第一是可插拔:BFF 的校验中间件、签名中间件、DSL 的 model 解析,都是往对应目录丢一个文件的事,框架本身一行不用改。第二是有约束:代码只能住在框架划好的房间里,目录结构本身就是架构文档,这就从源头上掐死了旧方案那种"想往哪写往哪写"的漂移。
小结
这一篇讲了框架底座:旧代码没有分层没有规范、后端接口的不规范逼出了 BFF 层(统一出入参、聚合、隐藏实现细节)、为什么不直接用 egg、PuzzlesModule 协议、controller/service 分层与统一响应信封、loader 的目录即注册表、运行时挂载的类型问题怎么靠生成 d.ts 解决,以及 this 丢失那个坑。下一篇讲跑在这个底座上的第一道防线:BFF 接口的参数校验、签名和那个让我们绕了一圈的路由参数坑。