SaaS 后台的 DSL 改造(二):框架底座——手写一个 egg 风格的 Koa 加载器

12 分钟阅读

上一篇讲了我们的困境:用分支管理券商差异,商户变多之后维护成本全面失控,最后决定推翻重来,用一份描述代替一份代码。

从这一篇开始进实现。整套系统最底下是一个自己写的 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 的函数

框架里所有可被加载的东西,遵守同一个协议:

typescript
// 一个模块默认导出一个函数 接收 app 挂载完成后的实例
export type PuzzlesModule<T = unknown> = (app: PuzzlesCoreApp) => T | Promise<T>

controller、service、middleware、extend、router 全都长这样。比如一个中间件:

typescript
export default ((app) => {
  return async (ctx, next) => {
    // ...中间件逻辑
    await next()
  }
}) as PuzzlesModule<Middleware>

一个 service:

typescript
export default function projectServiceModule(app: PuzzlesCoreApp) {
  return class ProjectService {
    async getModelList() { /* ... */ }
  }
}

这个协议的好处是依赖注入不用容器。模块被加载的那一刻就拿到完整的 app 实例,配置、日志、别的模块都在上面,想要什么自己取。没有装饰器,没有 metadata,一个普通函数走天下。

5. 分层实战:controller 和 service 长什么样

协议定了,看两个真实角色怎么落地。

controller 的写法是:模块函数返回一个类,类继承基类。基类同样由模块函数生成,把 app 实例、配置、curl(内部请求用的 superagent)和统一响应方法全部备好:

typescript
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,后端服务的调用、字段裁剪、多家后端差异的消化全在这层:

typescript
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 管后端的一切。而"统一出入参"落到代码里,就是基类的这两个方法:

typescript
// 所有接口同一个信封: 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 为例,核心逻辑:

typescript
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,启动时按固定顺序执行:

typescript
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 进框架类型:

typescript
// 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:让类型自己长出来

手动跑生成脚本总会忘,忘了就对着满屏红线排查半天,最后发现是类型没更新。所以又加了一个文件监听器,开发时常驻后台:

typescript
// scripts/startWatcher.ts
const watcher = spawn('npx', ['ts-node', 'scripts/fileWatcher.ts'], {
  detached: true,
  stdio: ['ignore', 'ignore', 'ignore'],
})
watcher.unref()  // 解除父进程对子进程的引用

detached 加 unref() 是让这个 watcher 进程脱离主服务的生命周期独立存活。监听的逻辑:

typescript
// 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 来得很快。

路由文件里是这样挂载的:

typescript
router.get(PRODUCT_MAIN, bussinessController.get)

bussinessController.get 是类实例上的一个方法,这样直接传引用,this 就丢了,方法内部访问 this.app 直接 undefined。传统解法是要么路由里包一层箭头函数,要么每个 controller 构造函数里手动 bind,都很丑,而且总会有人忘。

最后的解法是在 loader 实例化 controller 的时候,直接用 Proxy 把方法访问拦截掉,自动 bind:

typescript
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 接口的参数校验、签名和那个让我们绕了一圈的路由参数坑。

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