BFF层主要工作: 根据架构图、Koa 洋葱圈模型,收到前端请求后,首先进行路由参数校验,校验通过后路由分发到业务层(中间可能经过一系列的中间件),在业务层可能是做 SSR 页面渲染、环境配置(生产/测试等环境对应的服务不同)、能力扩展等(如日志记录能力),然后到 Service 层,在这里进行调取日志、读写 Mysql、调用外部服务等工作,最后将响应结果返回给前端。
一、elpis-core 设计
该部分主要是实现 BFF 层,每个人都有自己的开发风格和设计思路,所以 BFF 层的实现也因人而异,但是在团队中这样对其他成员有一定的理解成本,不利于维护,为解决这个问题,借鉴 egg.js, 遵循「约定大于配置」,定义好项目结构以及每个部分具体做什么,让成员快速理解和上手,专注于具体业务,然后核心实现解析器,通过解析器将所有文件转化为运行时内存对象,需要时直接从 app 对象获取。
根据前面的架构设计,项目结构如下:
markdown
|- app
|- middleware 中间件
|- extend 扩展
|- router 路由分发
|- router-schema 路由参数校验
|- controller 业务处理
|- service 针对数据层的原子性操作
|- middleware.js 全局中间件
|- config 项目在不同运行环境下的自定义配置
对应的解析器如下:
markdown
|- elpis-core
|- loader 解析器
|- middleware.js
|- extend.js
|- router.js
|- router-schema.js
|- controller.js
|- service.js
|- config.js
|- env.js 环境配置
|- index.js 服务启动
二、elpis-core 实现
index.js
bash
## 引入所有loader
const xxxLoader = require("./loader/xx.js");
## 1. 实例化 koa 项目
const app = new Koa();
## 2. 将 config、env 等基础信息挂载到 app 上
app.config = config; # 外部传入
app.baseDir = process.cwd();
app.env = env();
## 3. 引入所有 loader
xxxLoader(app)
## 全局中间件
## 4. 注册路由
routerLoader(app)
## 5. 启动服务
const port = process.env.PORT;
const host = process.env.IP;
app.listen(port, host);
configLoader
读取当前环境下项目 config 中对应的配置文件,然后将配置覆盖到 app.config 中
js
// config/config.[env].js
// env: production、default、beta
module.exports = {};
// elpis-core/loader/config.js
// configLoader
module.exports = (app) => {
const env = app.env.xx();
const config = require(`config/${env}.js`);
app.config = Object.assign({}, config);
}
extendLoader
项目扩展(如:引入 log4js 进行日志打印)
读取 extend 文件下的内容(一个文件视为一个扩展),将扩展直接挂在 app 上,通过 app.xxx 调用
js
// app/extend/*.js 扩展实现,如日志打印
module.exports = (app) => {
let logger;
// 具体逻辑 开发环境打印到控制台,生产环境打印到日志文件中
if(app.env.isLocal()) {
logger = console.log
} else {
logger = log4js.getLogger();
}
return logger;
}
// elpis-core/loader/extend.js
// extendLoader
module.exports = (app) => {
const extendPath = path.resolve(app.businessPath, `.${sep}extend`);
const fileList = glob.sync(path.resolve(extendPath, `.${sep}**${sep}**.js`));
fileList.forEach(file => {
// 文件名转化为驼峰式
const name = path.resolve(file).substring(...);
// app 上已有的 name 过滤掉
app[name] = require(path.resolve(file))(app);
})
}
serviceLoader
数据服务相关
读取 service 文件夹下的内容,将每个 service 挂在 app.service 上,通过 app.service.customModule.customService 访问
js
// app/service/*.js
module.exports = (app) => {
return class xxxService {
constructor() {
this.app = app;
this.config = config;
}
async function xxx1(ctx) {}
async function xxx2(ctx) {}
}
}
// elpis-core/loader/service.js
// serviceLoader
module.exports = (app) => {
let services = {};
const servicePath = path.resolve(app.businessPath, `.${sep}service`);
const fileList = glob.sync(path.resolve(servicePath, `.${sep}**${sep}**.js`));
fileList.forEach(file => {
let temp = services;
// 截取路径 app/service/custom-module/custom-service.js => custom-module/custom-service
// 转化为驼峰式 customModule/customService
const name = path.resolve(file).substring(...);
const names = name.split('/');
for(let i = 0; i < names.length; i++) {
if(i == names.length - 1) {
const ServiceModule = require(path.resolve(file))(app);
temp[names[i]] = new ServiceModule()
} else {
if(!temp[names[i]]) {
temp[names[i]] = {}
}
temp = temp[names[i]]
}
}
})
app.service = services;
}
controllerLoader
业务处理
同 Service, 将 controller 挂在 app.controller 上,通过 app.controller.customModule.customController 访问
js
// app/controller/*.js
module.exports = (app) => {
return class xxxController {
constructor() {
this.app = app;
this.config = config;
this.service = app.service;
}
async function method1(ctx) {}
}
}
// elpis-core/loader/controller.js
// controllerLoader
module.exports = (app) => {
let controllers = {};
const controllerPath = path.resolve(app.businessPath, `.${sep}controller`);
const fileList = glob.sync(path.resolve(controllerPath, `.${sep}**${sep}**.js`));
fileList.forEach(file => {
let temp = controllers;
// 截取路径 app/controller/custom-module/custom-controller.js => custom-module/custom-controller
// 转化为驼峰式 customModule/customController
const name = path.resolve(file).substring(...);
const names = name.split('/');
for(let i = 0; i < names.length; i++) {
if(i == names.length - 1) {
const ControllerModule = require(path.resolve(file))(app);
temp[names[i]] = new ControllerModule()
} else {
if(!temp[names[i]]) {
temp[names[i]] = {}
}
temp = temp[names[i]]
}
}
})
app.controller = controllers;
}
middlewareLoader
中间件(如:errorHandler 统一处理错误、apiParamsVerify api请求参数校验)
读取 middleware 文件夹下所有内容,通过 app.middlewares.customModule.customMiddleware 访问
js
// app/middleware/*.js
module.exports = (app) => {
// ctx: 请求上下文
return async (ctx, next) => {
// ...
};
};
// elpis-core/loader/middleware.js
// middlewareLoader
// 加载所有 middleware
moudle.exports = (app) => {
const middlewares = {};
const middlewarePath = path.resolve(app.businessPath, `.${sep}middleware`);
const fileList = glob.sync(path.resolve(middlewarePath, `.${sep}**${sep}**.js`));
fileList.forEach(file => {
let temp = middlewares;
// 截取路径 app/middleware/custom-module/custom-middleware.js => custom-module/custom-middleware
// 转化为驼峰式 customModule/customMiddleware
const name = path.resolve(file).substring(...);
const names = name.split('/');
for(let i = 0; i < names.length; i++) {
if(i == names.length - 1) {
temp[names[i]] = require(path.resolve(file))(app)
} else {
if(!temp[names[i]]) {
temp[names[i]] = {}
}
temp = temp[names[i]]
}
}
})
}
app.middlewares = middlewares;
routerLoader
路由注册
js
// app/router/*.js
module.exports = (app, router) => {
const { xxController } = app.controller;
router.get([路由], xxController[方法].bind(xxController));
};
// elpis-core/loader/router.js
// routerLoader
module.exports = (app) => {
// 实例化路由
const router = new KoaRouter();
const routerPath = path.resolve(app.businessPath, `.${sep}router`);
const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`));
fileList.forEach(file => {
// 读取所有 router 文件,完成注册所有路由
require(path.resolve(file))(app, router);
})
// 路由兜底
router.get("*", async (ctx, next) => {});
// 路由注册
app.use(router.routes());
app.use(router.allowedMethods());
}
routerSchemaLoader
路由参数格式定义
js
// app/router-schema/*.js
module.exports = {
// 遵循 RESTFUL API 规范、`json-schema`格式
"/api/xxx": {
get: {}, // 查询/获取
post: {}, // 添加
put: {}, // 修改
delete: {}, // 删除
},
};
// elpis-core/loader/router-schema.js
// routerSchemaLoader
// 最终结果
// app.routerSchema = {
// `${api1}`: ${jsonSchema},
// `${api2}`: ${jsonSchema},
// }
module.exports = (app) => {
let routerSchema = {};
const routerSchemaPath = path.resolve(app.businessPath, `.${sep}router-schema`);
const fileList = glob.sync(path.resolve(routerSchemaPath, `.${sep}**${sep}**.js`));
fileList.forEach(file => {
routerSchema = {
...routerSchema,
...require(path.resolve(file)), // 解构获取所有 api
};
})
app.routerSchema = routerSchema;
}
三、elpis-core 应用
接收请求并渲染页面
CSR:客户端发送一条请求后,服务端返回空 HTML + JS 文件,然后浏览器下载执行 JS,调接口请求数据填充 HTML
SSR:客户端发送一条请求后,服务端动态生成完整页面(组装页面模版 + 数据)返回给前端,然后浏览器直接渲染
这里需要做的是 SSR:解析请求参数、生成对应页面字符串并赋值给 ctx.body、将结果返回给客户端
这些事情可以通过 koa-nunjucks-2 模版引擎中间件来做,内部的render 方法会挂在 ctx 上,render 会渲染模版页面并将页面模版字符串赋值给ctx.body
js
// router/view.js
module.exports = (app, router) => {
const {view: viewController} = app.controller;
router.get(`/app/:page`, viewController.renderPage.bind(viewController))
}
// controller/view.js
module.exports = (app) => {
return class ViewController {
async function renderPage(ctx){
// output/entry.${ctx.params.page} 文件名
await ctx.render(`output/entry.${ctx.params.page}`, {
// 传递的参数,可挂在 window 对象上
name: app.options?.name,
env: app.env.get(),
options: JSON.stringify(app.options),
})
}
}
}
// app/middleware.js 全局中间件
module.exports = (app) => {
// 配置静态根目录
const root = path.resolve(process.cwd(), "./app/public");
app.use(require("koa-static")(root));
// 模版渲染引擎:渲染页面 + 赋值 `ctx.body`,koa 接收到 ctx.body 后,将内容返回给了前端
const koaNunjucks = require("koa-nunjucks-2");
app.use(
koaNunjucks({
ext: "tpl", // 文件后缀
path: root, // 文件存放的目录
nunjucksConfig: {
noCache: true,
trimBlocks: true, // 开启后,文件中的空白字符会被移除
},
}),
);
}
// public/output/entry.pageX.tpl 文件
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1>Page1</h1>
</body>
</html>
数据请求
在 BFF 层做参数约束以及校验工作,保证了前后端都用一套规则,避免出 bug
js
// 假设某页面中发起 get 请求
const res = await axios.get('api/project/list', {
query: {
proj_Key: xx,
}
});
// router/project.js 路由分发
router.get('/api/project/list',
app.middlewares.apiParamsVerify,// 这里可以传递多个中间件
projectController.getList.bind(projectController))
// controller/project.js
async function getList(ctx) {
const {project: projectService} = app.service;
const res = projectService.getList(ctx.xxx);
// 这里可以对 res 进行组装裁剪,然后返回给前端
ctx.body({
success: true,
data: res,
})
}
// service/project.js
async function getList(params) {
// 进行数据库操作等
const res = sql.xxx;
return res;
}
// router-schema/project.js 参数约束
module.exports = {
'api/project/list': {
get: {
query: {
type: 'object'
properties: {
proj_Key: {
type: "string",
}
},
required: ['proj_Key']
}
}
}
}
// middleware/api-params-verify.js 参数校验中间件 利用 ajv
onst Ajv = require("ajv");
const ajv = new Ajv();
/**
* API 参数校验
* @param {object} app
* @returns
*/
module.exports = (app) => {
const $schema = "http://json-schema.org/draft-07/schema#";
return async (ctx, next) => {
// 只对 API 请求做签名校验
if (ctx.path.indexOf("/api") < 0) {
return await next();
}
// 获取请求参数
const { body, query, headers } = ctx.request;
// 在路由注册的时候app.middlewares.apiParamsVerify中间件如果没有传进去,这里是获取不到 params 参数的
const { params, path, method } = ctx;
app.logger.info(`[${method} ${path}] body: ${JSON.stringify(body)}`);
app.logger.info(`[${method} ${path}] query: ${JSON.stringify(query)}`);
app.logger.info(`[${method} ${path}] headers: ${JSON.stringify(headers)}`);
app.logger.info(`[${method} ${path}] params: ${JSON.stringify(params)}`);
const schema = app.routerSchema[path]?.[method.toLowerCase()];
if (!schema) {
return await next();
}
let valid = true;
// ajv 校验器
let validate;
// 校验 headers
if (valid && headers && schema.headers) {
schema.headers.$schema = $schema;
validate = ajv.compile(schema.headers);
valid = validate(headers);
}
// 校验 query
if (valid && query && schema.query) {
schema.query.$schema = $schema;
validate = ajv.compile(schema.query);
valid = validate(query);
}
// 校验 body
if (valid && body && schema.body) {
schema.body.$schema = $schema;
validate = ajv.compile(schema.body);
valid = validate(body);
}
// 校验 params
if (valid && params && schema.params) {
schema.params.$schema = $schema;
validate = ajv.compile(schema.params);
valid = validate(params);
}
if (!valid) {
ctx.status = 200;
ctx.body = {
success: false,
message: `request validate fail ${ajv.errorsText(validate.errors)}`,
code: 442,
};
return;
}
await next();
};
};
// app/middleware.js 全局中间件
// 引入 ctx.body 解析中间件
const bodyParser = require("koa-bodyparser");
app.use(
bodyParser({
formLimit: "1000mb",
enableTypes: ["form", "json", "text"],
}),
);
// 引入 API 参数校验
app.use(app.middlewares.apiParamsVerify);