这份文档主要聚焦于介绍在Elpis项目中,BFF层级的实现和心得体会
从设计图上我们不难看出,BFF层的主要功能聚焦在于分发路由、配置路由规则、提供路由中间件、做业务处理以及服务处理等等功能。因此为了实现这些功能,首先我们应该能想到的是这些功能点他们一定是需要被分割和管理在不同的文件里(按照NodeJS的理念,就是分布在不同的Module中)。因为你总不可能把这些所有的功能写在一个文件里进行管理吧,短期来看这么做是可以的,但是如果长期看来这种模式下所写的代码会随着版本迭代变得日益臃肿,直到最后难以维护。
那么为了读取这些Module并使其能够运行在我们的内存中,我们自然而然需要设计这么一个解析器引擎,它所做的事情,就是按照我们所定义的规则去到项目对应的目录中,读取并加载相关的文件和模块。这个引擎/解析器我们就将其称之为elpis-core。同时,最好每一个模块有一个独立的解析器,这样方便统一管理和读取这个模块与之关联的相关所有模块
我们所定义的规则如下:
- 所有的业务相关联文件必须存放在app目录下
- app/middleware 存放中间件模块
- app/router-schema 存放路由分发规则
- app/controller 存放控制器模块
- app/service 存放服务模块
- app/config 存放我们BFF层所需要的所有的配置
- app/extend 存放我们拓展的一些服务功能等
- elpis-core单独存放在另一个目录,与app目录位于同一层级
- elpis-core目录下存放着每个业务模块的解析器
遵守此规则,我们就自然而然的画出了下面这个业务模型图,左侧是我们的业务功能模块(文件),它经历我们Elpis-core这个解析器读取和解析后,运行在我们的程序中
所以我们的关键代码如下
- 主程序(用于读取和启动各大elpis-core loader)
/**
* 配置调整
*/
const app = new Koa(); //初始化Koa实例
app.options = options;
app.baseDir = process.cwd(); //基础路径
app.businessPath = path.join(app.baseDir, 'app'); //业务代码路径
app.env = env(); //环境变量
console.log('elpis-core running in environment: ' + app.env.get());
//调用各个loader
configLoader(app);
console.log('load config loader success');
extendLoader(app);
console.log('load extend loader success');
serviceLoader(app);
console.log('load service loader success');
controllerLoader(app);
console.log('load controller loader success');
middlewareLoader(app);
console.log('load middleware success');
routerSchemaLoader(app);
console.log('load router schema loader success');
try {
require(path.resolve(app.businessPath, 'middleware.js'))(app)
console.log('load global middleware done');
}
catch (err) {
console.error('[exception] there is no global middleware file.')
}
routerLoader(app);
console.log('load router loader success');
/**
* try catch 捕获,并启动监听
*/
try {
const port = process.env.PORT || 8080;
const host = process.env.HOST || '0.0.0.0';
app.listen(port, host);
console.log(`Server running at http://${host}:${port}/`);
}
catch (err) {
console.error(err);
}
- loader 实现(以middlewareLoader为例)
const path = require('path');
const glob = require('glob');
const { sep } = path;
/**
* middleware loader
* 加载所有middleware,可通过'app.middleware.${目录}.${文件}'访问
* 例子:
* app/middleware
* |
* | -- custom-module
* |
* | -- custom-middleware.js
* => app.middlewares.customModule.customMiddleware //通过这种方式访问对应的module
* @param {object} app Koa 实例
*/
module.exports = (app) => {
//读取app/middleware/**/*.js下的文件
const middlewarePath = path.resolve(app.businessPath, 'middleware');
const fileList = glob.sync(path.resolve(middlewarePath, `.${sep}/**${sep}*.js`));
//遍历所有文件目录,把内容加载到app.middlewares对象上
const middlewares = {};
fileList.forEach((filePath) => {
const filePathArr = filePath.split(`${sep}`);
const middlewareNameArr = filePathArr.filter((item, index) => index > filePathArr.indexOf('middleware')); //过滤出middleware/xxx/xxx后续的文件名称和目录名称
middlewareNameArr.forEach((name, index, thisArg) => {
name = name.replace(/[_-][a-z]/ig, (s) => s.charAt(1).toUpperCase());//返回匹配到的-_号的后面一个字母,并将这个字母转换为大写并返回。寓意在于将短横线命名改为驼峰命名
name = name.replace('.js', ''); //去掉.js后缀
thisArg[index] = name;
})
//根据切分好的驼峰文件名称,将middleware对象进行层层嵌套,最终把文件内容加载到对应的层级上
//例如app/middleware/custom-module/custom-module-entry.js -> app.middlewares.customModule.customModuleEntry
let tempMiddlewares = middlewares;
for (let i = 0; i < middlewareNameArr.length; i++) {
if (i === middlewareNameArr.length - 1) {
tempMiddlewares[middlewareNameArr[i]] = require(filePath)(app);
return;
}
if (!tempMiddlewares[middlewareNameArr[i]]) {
tempMiddlewares[middlewareNameArr[i]] = {};
}
tempMiddlewares = tempMiddlewares[middlewareNameArr[i]];
}
})
app.middlewares = middlewares; //把加载好的middleware对象挂载到app.middlewares上
}
不难看出,loader所做的事情主要就是利用nodejs读取文件的能力去到定义好的对应目录下方加载所有的模块,并将这些个模块挂载到app实例上。其他的loader虽有一些细节上的实现差异(例如文件名命名等)但是主逻辑之间相差不大。这其实也是如今市面上后端框架的主要功能和思想
这里补充一些细节点:
- 为了让这些所读取的模块方便其他模块所使用,因此这里选择将模块都挂载到了app实例上。app实例是一个Koa实例,它会贯穿自请求产生到响应结束的全周期
- loader的加载顺序是有可优化空间的,因为各个loader之间他们有些相互存在依赖,有些又不存在,因此按照依赖关系做出了如下优化:
- configLoader可以优先加载。因为它没有依赖其他模块,并且加载进来的config文件可以方便后续的loader调用,因此放在首位加载。
- extendLoader,因为它会依赖于config文件
- serviceLoader,因为它依赖config/extend
- controllerLoader,因为它依赖于service/config
- middlewareLoader,依赖于config
- routerSchemaLoader,无依赖但是需要紧邻routerLoader
- middleware.js,进行全局中间件注册
- routerLoader,进行路由注册(依赖以上全部Loader中的内容,包括已注册的中间件,因此放在最后执行)
当解析引擎实现完成后,我们就可以考虑实现业务逻辑和中间件本身了 首先是接入层,这一层决定了请求进入我们服务器后会流转到哪些函数,需要用到哪些模块等等,包含router、routerSchema以及middleware路由中间件。
- routerSchema(路由模式)模块,它的作用是定义某个接口的路由规范,包含需要什么参数,请求方式等等。在这里为了方便管理和配置所有的routerSchema(路由模式),引入了json-schema,它允许通过JSON的方式配置接口路由,例如下面的JSON描述了POST /api/project/list这个接口,请求参数为'proj_key',其值是一个string。
{
'/api/project/list': {
post: {
body: {
type: 'object',
properties: {
proj_key: {
type: 'string'
}
},
required: ['proj_key']
},
}
}
}
2.router模块;它的作用是帮助我们映射接口到对应的controller处(即映射到业务层的逻辑处理函数处),例如上面提到的project/list对应的就是projectController这个业务层控制器
app/router/project.js
module.exports = (app, router) => {
const { project: projectController } = app.controller;
router.post('/api/project/list', projectController.getList.bind(projectController))
}
- middleware模块:它的作用是提供一些路由中间件,进而帮助我们更好的管理和处理请求,比如统一的错误管理中间件。在请求找不到页面的情况下将用户重定向至配置的路由处
/**
* 运行时异常错误处理中间件,兜底所有异常
* @param {object} app Koa实例对象
*/
module.exports = app => {
return async (ctx, next) => {
try {
await next();
}
catch (err) {
//异常处理
const { status, message, detail } = err;
//打印到日志
app.logger.info(JSON.stringify(err));
app.logger.error('[-- exception --]:', err);
app.logger.error('[-- exception --]:', status, message, detail);
//处理页面请求错误,例如页面请求中常遇到的找不到对应的页面,就会报错'template not found',此时重定向
if (message && message.indexOf('template not found') > -1) {
//进行重定向,注意这里需要设置为303临时重定向
//如果使用了301的话,未来用户浏览器没清缓存的情况下会被一直重定向到对应页面。这是我们不希望看见的,因为某个页面模板未来是有可能会上线的
ctx.status = 303;
ctx.redirect(app.options?.homePage ?? '/');
return;
}
//出错以后对响应体进行修改,响应给前端,确保响应码为200,这样前端看到的是网络是通畅的。
//但是在响应体里面我们将约束好的code设置为50000,以告诉前端是服务器内部业务逻辑错误
const resBody = {
success: false,
code: 50000,
message: '网络异常,请稍后重试'
}
ctx.status = 200;
ctx.body = resBody;
}
}
}
类似的可供发挥的路由中间件还有很多,因为一个请求从接入服务器到响应给前端这个过程能做的事情其实有很多,这些事情大多围绕性能、安全、健壮性等方面考虑。例如出于性能考量,我可以在请求接入的时候就验证其参数的正确性,如果参数不正确就没必要继续后续的业务逻辑处理了,进而提升服务器性能。同时也可以出于接口的安全性考量,为接口设计一个时效性的签名,这个签名是由前后端统一对称加密的,目的就是为了防止短期且大量的恶意请求搬空数据库。例如一个list列表接口它的入参分别是pageSize和pageIndex,如果在没有做签名之前,那么用户可以依靠pageSize=1000,pageIndex++这种大量的请求进而将库里面的数据全部挖空。
其次是业务层,主要由controller,extend、config这三个模块组成(其他模块暂未实现,先写到这儿)
- controller:业务逻辑处理模块。我们日常所接触到的MVC架构中的Controller就基本封装在这个模块,这个模块所做的事情其实主要就是围绕从数据库取数据 -> 处理数据 -> 响应给前端这三件事所展开,也就是我们常说的后端接口业务逻辑所在的层级。还是拿project/list这个接口作为例子
app/controller/project.js
const BaseController = require('./base.js')(app);
return class ProjectController extends BaseController {
/**
* 获取项目列表
* @param {object} ctx 请求上下文
*/
async getList(ctx) {
const { project: projectService } = app.service;
const projectList = await projectService.getList();
this.success(ctx, projectList);
}
}
app/controller/base.js
/**
* controller基类,所有controller都继承自这个类
* 统一收拢controller相关的公共方法
* @param {*} app
* @returns
*/
module.exports = app => class BaseController {
constructor() {
this.app = app;
this.config = app.config;
this.service = app.service;
}
/**
* API处理请求成功时的统一返回结构
* @param {*} ctx 请求上下文
* @param {*} data 请求参数
* @param {*} metadata 附加数据
*/
success(ctx, data = {}, metadata = {}) {
ctx.status = 200;
ctx.body = {
success: true,
data,
metadata,
}
}
/**
* API处理请求失败时的统一返回结构
* @param {*} ctx 请求上下文
* @param {*} message 错误信息
* @param {*} code 错误代码
*/
fail(ctx, message = '', code = 500) {
ctx.body = {
success: false,
message,
code,
}
}
}
补充一些细节点
- 之所以采用面向对象的设计模式设计这些Controller,是因为他们之间具有大量的可共享方法可以使用。例如例子中请求成功和请求失败的的响应体的编写就是一个公共方法。
- 如果使用函数形式编写,一个考量点是代码不够优雅,二是可维护性和可拓展性不如面向对象的这种写法高,想想未来如果controller一多,你要新增一个controller,你是愿意直接继承基类派生出一个新的controller子类容易,还是到处去找一些公共方法,并给他import进来方便?另外如果你需要新增一个公共方法,你是觉得修改基类更容易还是到处进这些模块引入一个新方法容易?
- extend:extend是后端的功能性拓展模块。其中以日志系统最为重要。日志系统决定了我们未来线上问题的复盘依据。在这个项目中,我们所使用的是log4js。这里主要是根据环境不同(开发/测试/生产环境)决定使用的日志系统,开发环境直接控制台打印,测试环境和生产环境才永久化存储
const log4js = require('log4js')
/**
* 日志工具
* 外部调用 app.logger.log logger.error
* @param {*} app
* @returns
*/
module.exports = app => {
let logger;
if (app.env.isLocal()) {
//本地环境下,日志对象为我们的console,也就是说打印在控制台即可
return logger = console;
}
//否则将日志永久化存储(落地到磁盘)
log4js.configure({
appenders: {
console: {
type: 'console'
},
//日志文件切分
dateFile: {
type: 'dateFile',
filename: './logs/application.log',
pattern: '.yyyy-MM-dd', //以年月日的方式切分日志文件
},
},
categories: {
default: {
appenders: ['console', 'dateFile'],
level: 'trace',
}
}
});
logger = log4js.getLogger();
return logger;
}
细节补充:
- 设计日志系统的时候,日志名称的切分很重要;因为服务器一旦上线,日志的内容量非常庞大;如果不正确切分日志的文件命名,那么很难检索到真正所需要的记录,即便能检索到性能也非常低。因此这里的建议是根据实际需求/时间/日期维度来进行区分。
- 另外日志的记录详尽程度也要做好考量,如果日志记录的太过详细,虽然有助于复盘检索,但是由于日志体量庞大,硬盘空间会很容易被撑满。相反如果记录的粒度太过于粗,那不利于复盘。通常来说记录到error粒度即可
- config:即配置模块,区分不同的环境我们所需要的配置也不同。这些配置影响到日志系统,接口响应等各方面,因此也是一个很重要的模块。在这个项目中,我们只区分三种环境:测试环境、开发环境以及生产环境。而且这个配置我们在config loader中进行实现即可
config loader:
const path = require('path');
const { sep } = path
/**
* config loader
* 配置加载器
* 配置区分 本地/生产/测试环境, 通过env环境读取不同文件配置 env.config
* 通过env.config 覆盖 default.config并加载到app.config中
* 目录下对应的config配置
* 默认配置 config/config.default.js
* 本地配置 config/config.local.js
* 生产配置 config/config.prod.js
* 测试配置 config/config.beta.js
* @param {Object} app - Koa应用实例
*/
module.exports = (app) => {
//找到config目录
const configPath = path.resolve(app.baseDir, `.${sep}config`);
//获取default-config
let defaultConfig = {};
//健壮性考量,如果默认配置文件不存在会报错,此时给他一个默认值
try {
defaultConfig = require(path.resolve(configPath, `.${sep}config.default.js`));
}
catch (err) {
console.error('[exception] there is no default.config file');
}
//获取env.config
let envConfig = {};
try {
if (app.env.isLocal()) {
envConfig = require(path.resolve(configPath, `.${sep}config.local.js`));
} else if (app.env.isProduction()) {
envConfig = require(path.resolve(configPath, `.${sep}config.prod.js`));
} else if (app.env.isBeta()) {
envConfig = require(path.resolve(configPath, `.${sep}config.beta.js`));
}
if (typeof envConfig !== 'object') {
throw Error('config file is not an object')
}
}
catch (err) {
if (err.code === 'MODULE_NOT_FOUND') {
console.warn(`[warning] env config file not found, use default config 'config.default.js' instead`);
} else {
console.error(`[error] env config file has an error: ${err.message}`);
throw err;
}
}
//覆盖并加载config配置
app.config = Object.assign({}, defaultConfig, envConfig);
}
env.js
module.exports = () => {
return {
isLocal() {
return process.env._ENV === 'local';
},
isBeta() {
return process.env._ENV === 'beta';
},
isProduction() {
return process.env._ENV === 'production';
},
get() {
return process.env._ENV ?? 'local';
}
}
}
重点在于:
- 设立一个默认配置,这个默认配置作为兜底配置,是当各个环境配置不存在的时候一个兜底。有了这个兜底配置才能保证程序正常运行。
最后就是Service层 在BFF设计中,Service层往往封装的就是读取数据库的原子化操作。这里还是以project service为例
app/service/project.js
module.exports = app => {
const BaseService = require('./base')(app);
return class ProjectService extends BaseService {
async getList() {
...读取数据库等操作
return 接口所需要数据 -> 例如[{
name: 'project 1',
desc: 'project1 description',
}, {
name: 'project 2',
desc: 'project2 description',
},
{
name: 'project 3',
desc: 'project3 description',
}]
}
}
}
app/service/base.js
const superagent = require('superagent');
module.exports = app => class BaseService {
/**
* service基类,所有service都继承自这个类
* 统一收拢service相关的公共方法
*/
constructor() {
this.app = app;
this.config = app.config;
this.superagent = superagent;
}
}
细节补充:
- 有人在这里可能会问为什么需要Service层而不能将这些操作封装到Controller层?有必要区分这两层吗?毕竟干的事情都是围绕业务逻辑而执行的。
- 首先业务逻辑主要封装在Service层,Controller层主要作为请求和数据库之间的衔接层,他的主要作用其实就是承上启下,包括接受请求,接受Service层处理好的业务数据后,包装请求数据发送给前端。
- 在这个场景下,如果把Service层的业务逻辑揉到Controller层会发生什么?未来如果请求协议发生了改变,例如不再使用HTTP协议,改用Websocket等其他协议进行通信,那么Controller代码需要改,顺带着也影响了内部的业务代码,其实这部分业务代码是本来不用受影响的。
- 不利于复用。如果不分层的情况下,要想复用同一份业务逻辑唯一的做法就是复制粘贴一份新的Controller类。时间一长整个系统会充斥各种各样相同的Controller对象。然而分层以后,我们相关Controller需要引入对应的Service类就可以了。
- 不利于维护。如果不分层的话,想象一下你复制了N多份相同的Controller,某天业务逻辑一改,你也需要修改这N份Controller。
总结: BFF层级(后端)其实主要由解析引擎以及业务模块两大板块组成
- 解析引擎的主要作用是将各个业务模块聚合在一起并保证其在程序内存中正常运行。这也是大部分后端框架所做的事情,也是这些框架设计背后的思想。例如Eggjs、Nextjs、Nestjs等等
- 业务模块主要分为三个层级,一个是路由层级,包含路由模式(router-schema)、路由定义(router)以及中间件(middleware)组成。第二个层级是业务层,它主要由控制器(controller)、拓展功能(extend)以及config(配置器)组成。控制器主要负责分发、响应和处理从路由层过来的请求,拓展功能可以有很多,其中以日志系统为重。配置器主要为程序运行时提供配置,决定了接口的响应、处理方式甚至是日志系统的记录模式。第三个层级是Service层,它主要负责读取数据库,处理业务逻辑等操作。这个层级的职责在实际开发中可能会经常和Controller层做类比,但是它却是一个有必要存在的层级,因为它的存在可以极大程度上提升后端代码的维护性和拓展性和复用性。
- 从一个请求接入到响应的过程中,服务器其实能做很多事情。这些事情包含性能方面、安全性方面、健壮性方面的建设和考量这些都是通过路由中间件来完成的。文章中以错误捕获举例
- 路由中间件甚至还能提供一些额外功能的拓展,例如SSR服务端渲染(Koa-nunjucks),以及parse http请求体(Koa-bodyparser)。