BFF 层设计与实现
为什么需要分层
如果不考虑代码分层,一个接口可能会被写成下面这样:
js
router.get("/api/getData", (req, res) => {
// 判断前置条件
// 校验请求参数
// 执行业务逻辑
// 组装并返回响应数据
});
这种写法在业务规模较小时简单直接,但随着需求不断增加,参数校验、权限判断、业务处理和响应组装等逻辑会逐渐堆积在同一个接口中。
当接口数量和业务复杂度持续增长后,代码会变得越来越臃肿,不仅难以阅读,也不利于复用、测试和维护。
因此,Mint 对 BFF 应用进行了职责拆分,将不同类型的逻辑放入对应的目录中,再通过框架内核将这些模块加载并关联起来。
目录结构与职责划分
Mint 约定业务代码统一放在 app 目录下,整体结构如下:
text
根目录
├──
app
├── router # 路由定义
├── controller # 控制层,接收请求并组织响应
├── service # 服务层,承载业务逻辑
├── model # 数据及领域模型
├── extend # 扩展层,为 Koa 上下文扩展能力
├── router-params # 路由参数与校验规则
├── middleware # 路由级中间件,只负责定义,由业务按需注册
├── pre-middleware.js # 全局前置中间件
└── config # 应用配置
各层之间的职责如下:
router:声明请求路径、请求方法以及对应的控制器。controller:接收请求参数,调用服务层并组装响应结果。service:承载具体的业务逻辑,也可以在这里调用企业后端服务。model:管理数据模型。extend:为 Koa 应用或上下文扩展自定义属性和方法。router-params:描述路由参数及其校验规则。middleware:定义可复用的路由中间件,由业务根据需要注册。pre-middleware.js:注册需要在所有路由之前执行的全局中间件。config:管理不同运行环境下的应用配置。
通过目录约定,每一层只负责自身职责,从而降低模块之间的耦合度。
使用 Loader 关联各层模块
完成目录分层后,下一个问题是:框架如何发现这些文件,并让不同层之间能够互相访问?
Mint 的解决方案是实现一套基于约定的 Loader 机制。
执行 node index.js 启动 Koa 应用时,框架内核会依次运行多个 Loader。每个 Loader 负责扫描指定目录,将符合约定的文件加载到内存中,并挂载到 Koa 应用实例上。
例如:
js
app.config;
app.extend;
app.service;
app.middleware;
app.routerSchema;
app.controller;
业务代码可以通过这些统一入口访问不同层的能力,而不需要在每个文件中手动维护复杂的模块引用关系。
整体加载流程如下:
- 创建 Koa 应用实例;
- 初始化项目路径和运行环境;
- 加载应用配置;
- 加载上下文扩展;
- 加载 Service;
- 加载 Middleware;
- 加载路由参数配置;
- 加载 Controller;
- 注册框架及业务的全局前置中间件;
- 加载并注册 Router;
- 启动 HTTP 服务。
BFF服务内核自研
core/index.js 是整个 BFF 应用的启动入口,其实现如下:
js
const Koa = require("koa");
const path = require("path");
const env = require("./env");
const { sep } = path;
const middlewareLoader = require("./loader/middleware");
const routerLoader = require("./loader/router");
const routerSchema = require("./loader/router-schema");
const controllerLoader = require("./loader/controller");
const serviceLoader = require("./loader/service");
const configLoader = require("./loader/config");
const extendLoader = require("./loader/extend");
const mergeWith = require("lodash/mergeWith");
const defaultOptions = {
name: "m-service",
homePage: "",
noAuthDirectPath: "",
};
module.exports = {
/**
* 启动项目
* @param {object} options 项目配置
* @param {string} options.name 项目名称
*/
start(options = {}) {
const app = new Koa();
// 合并默认配置与业务配置
app.options = mergeWith(defaultOptions, options);
// 项目根目录
app.baseDir = process.cwd();
// 业务代码目录
app.businessPath = path.resolve(app.baseDir, `.${sep}app`);
// 初始化运行环境
app.$env = env(app);
console.log(`-- [start] env: ${app.$env.get()}`);
// 加载配置
configLoader(app);
console.log("-- [start] config loader done --");
// 加载上下文扩展
extendLoader(app);
console.log("-- [start] extend loader done --");
// 加载服务
serviceLoader(app);
console.log("-- [start] service loader done --");
// 加载中间件
middlewareLoader(app);
console.log("-- [start] middleware loader done --");
// 加载路由参数配置
routerSchema(app);
console.log("-- [start] router schema loader done --");
// 加载控制器
controllerLoader(app);
console.log("-- [start] controller loader done --");
// 注册框架内置的全局前置中间件
try {
require(path.resolve(__dirname, `..${sep}app${sep}pre-middleware.js`))(app);
console.log("-- [start] framework pre-middleware loader done --");
} catch {
console.error("-- [exception] framework pre-middleware load exception --");
}
// 注册业务侧的全局前置中间件
try {
require(path.resolve(app.businessPath, `.${sep}pre-middleware.js`))(app);
console.log("-- [start] business pre-middleware loader done --");
} catch {
console.error("-- [exception] business pre-middleware load exception --");
}
// 路由依赖其他模块,因此最后加载
routerLoader(app);
console.log("-- [start] router loader done --");
try {
const port = process.env.PORT || 8080;
const host = process.env.HOST || "0.0.0.0";
app.listen(port, host);
console.log(`${app.options.name} listening on ${host}:${port}`);
} catch (error) {
console.error(error);
}
return app;
},
};
这里需要注意 Loader 的执行顺序。 Loader 不只是负责扫描文件,也负责建立应用各层之间的依赖关系。
Loader 的实现原理
不同 Loader 的职责虽然不同,但实现思路基本一致:
- 确定需要扫描的目录;
- 决定是否支持递归扫描子目录;
- 使用
glob获取符合规则的文件; - 遍历文件列表;
- 根据相对路径生成模块名称;
- 创建对应的嵌套对象;
- 将模块挂载到 Koa 应用实例上。
下面以 Controller Loader 为例。
假设存在以下目录:
text
app/controller
└── custom-model
├── xxxx.js
└── custom-controller.js
加载完成后,Controller 会按照文件目录结构映射为嵌套对象:
js
app.controller.customModel.xxxx;
app.controller.customModel.customController;
对应的实现如下:
js
const path = require("path");
const glob = require("glob");
const { sep } = path;
/**
* 扫描 Controller 文件,并将其注册到 app.controller
*
* 文件结构:
* app/controller
* └── custom-model
* ├── xxxx.js
* └── custom-controller.js
*
* 注册结果:
* app.controller.customModel.xxxx
* app.controller.customModel.customController
*/
module.exports = (app) => {
// 框架内置的 Controller 目录
const frameworkControllerPath = path.join(__dirname, `..${sep}..${sep}app${sep}controller`);
// 递归获取目录下的所有 JavaScript 文件
const frameworkFileList = glob.sync(path.join(frameworkControllerPath, "**", "*.js"));
const controller = {};
const handleFile = (filePath, controllerPath) => {
let currentController = controller;
// 获取文件相对于 Controller 根目录的路径
let relativePath = path.relative(controllerPath, filePath);
// 将短横线或下划线命名转换为驼峰命名
relativePath = relativePath.replace(/[-_](\w)/gi, (_, letter) => letter.toUpperCase());
const parts = relativePath.split(sep);
let filename = parts.pop();
filename = path.parse(filename).name;
const names = [...parts, filename];
// 按照目录层级创建嵌套对象
for (let i = 0; i < names.length; i += 1) {
const name = names[i];
const isLast = i === names.length - 1;
if (isLast) {
const Controller = require(filePath)(app);
currentController[name] = new Controller();
} else {
if (!currentController[name]) {
currentController[name] = {};
}
currentController = currentController[name];
}
}
};
// 先加载框架能力,再加载业务能力
frameworkFileList.forEach((filePath) => {
handleFile(filePath, frameworkControllerPath);
});
// 将加载结果统一挂载到 Koa 应用实例
app.controller = controller;
};
这套 Loader 机制将物理目录结构映射成了 JavaScript 对象结构。
运行环境管理
除了业务模块,内核还提供了统一的运行环境判断能力。
运行环境通过启动命令中的 NODE_ENV 传入,env.js 将常用的环境判断封装成统一方法:
js
module.exports = () => {
return {
// 是否为开发环境
isDevelopment() {
return process.env.NODE_ENV === "development";
},
// 是否为测试环境
isTest() {
return process.env.NODE_ENV === "test";
},
// 是否为生产环境
isProduction() {
return process.env.NODE_ENV === "production";
},
// 获取当前运行环境,默认使用 development
get() {
return process.env.NODE_ENV ?? "development";
},
};
};
业务代码不需要在不同位置重复判断 process.env.NODE_ENV,只需要通过统一入口访问:
js
app.$env.isDevelopment();
app.$env.isTest();
app.$env.isProduction();
app.$env.get();
这也为后续按环境加载不同配置、启用不同中间件以及调整日志策略提供了基础能力。
本章小结
至此,Mint 的 BFF 层已经具备了几个基础能力:
- 通过目录约定划分 Router、Controller、Service 等模块;
- 通过 Loader 自动发现并加载业务文件;
- 将文件目录映射为可访问的嵌套对象;
- 将各层能力统一挂载到 Koa 应用实例;
- 支持框架内置能力与业务扩展能力共同加载;
- 提供统一的运行环境判断机制。
这套设计的核心是"约定优于配置"。业务开发者只需要按照目录规范编写代码,框架便可以自动完成模块发现、实例化、注册和关联,从而减少重复的工程配置工作。