前言
最近在学习全栈开发的思想,然后看了哲玄的课程做了一个项目实战,这个练手项目叫 Elpis,借着这个实战项目从而了解全栈的开发思想,框架设计方案,以及 node 的应用
为什么要实现 BFF 层?
BFF层可以作为前后端中转站,前端页面发送请求到 BFF 层,经过 BFF 层 再到数据层:
这么做的好处是:
能够解决多端展示问题:我们有两个需求一个是PC,另一个是移动端。此时这两个需求逻辑相似,可以调用一个接口去实现,但接口的数据格式又不太一样,这个时候我们可以用 BFF 层作为中间件去解决。
能够将多个业务进行整合:例如:实现一个功能需求可能触发多个服务:用户服务、朋友关系服务、热门数据服务。这些服务请求可以不放在展示层作处理,可以先请求 BFF 层,然后 BFF 请求多个服务,这样能减轻展示层的复杂性。
这样的 BFF 层的具体实现分析:
为了实现这个 BFF 层设计了一个内核引擎:Elpis-core 这么一个 node 服务,由它将 服务文件通过解析器将这些服务运行加载出来。
将页面配置的相关代码逻辑,通过 elpis-core 自动解析然后挂载到一个 Koa 的实例上然后运行,elpis-core 的实现用到了 Koa 和 Koa-router 路由解析,使用 Koa 提供的API能够更方便的开发 node 服务。
核心实现相关代码:
// elpis-core/index.js
const Koa = require('koa');
const path = require('path');
const glob = require('glob');
const { sep } = path; // 兼容不同操作系统上的斜杠
const env = require('./env');
// 导入对应模块Loader
const loaderPath = path.resolve(process.cwd(), `.${sep}elpis-core${sep}loader`);
// 读取 loader 目录下的所有 js 文件
const loaderModules = glob.sync(path.resolve(loaderPath, `.${sep}**${sep}**.js`));
module.exports = {
/**
* 启动项目
* @param options 项目配置
*/
start(options = {}) {
// 创建 Koa 实例
const app = new Koa();
// 应用配置
app.options = options;
// 基础路径
app.baseDir = process.cwd();
// 业务文件路径
app.businessPath = path.resolve(app.baseDir, `.${sep}app`)
// 初始化环境配置
app.env = env();
console.log(`-- [start] env: ${app.env.get()} --`);
// 注册全局中间件
try {
require(`${app.businessPath}${sep}middleware.js`)(app);
console.log(`-- [start] load global middleware done --`);
} catch (e) {
console.log(`-- [exception] there is on global middleware file --`);
}
// 加载所有 loader 模块到内存
loaderModules.forEach(file => {
// 调用对应 loader 模块,传入 app 实例
require(path.resolve(file))(app);
});
// 启动服务
try {
const port = process.env.PORT || 8080;
const host = process.env.IP || '0.0.0.0';
app.listen(port, host);
console.log(`Server runnin on port: ${port}`)
} catch (e) {
console.error(e);
};
}
}
// elpis-core/loader/router.js
const KoaRouter = require('koa-router');
const glob = require('glob');
const path = require('path');
const { sep } = path;
/**
* roter loader
* @param {object} app koa 实例
* 解析所有 app/router/ 下所有 js 文件,加载到 KoaRouter 下
*/
module.exports = (app) => {
// 找到路由文件路径
const routerPath = path.resolve(app.businessPath, `.${sep}router`);
// 实例化 KoaRouter
const router = new KoaRouter();
// 注册所有路由
const fileList = glob.sync(path.resolve(routerPath, `.${sep}**${sep}**.js`));
fileList.forEach(file => {
require(path.resolve(file))(app, router);
});
// 路由兜底
router.get('*', async (ctx, next) => {
ctx.status = 302;
ctx.redirect(`${app?.options?.homePage ?? '/'}`);
});
// 路由注册到 app 上
app.use(router.routes());
app.use(router.allowedMethods());
};
这里会引入另一个概念 “洋葱圈模型”
利用注册的中间件进行请求的捕获和校验:
// middleware.js
const path = require('path');
const { sep } = path;
module.exports = (app) => {
// 配置静态根目录
const koaStatic = require('koa-static');
app.use(koaStatic(path.resolve(process.cwd(), `.${sep}app${sep}public`)));
// 模版渲染引擎
const koaNunjucks = require('koa-nunjucks-2');
app.use(koaNunjucks({
ext: 'tpl',
path: path.resolve(process.cwd(), `.${sep}app${sep}public`),
nunjucksConfig: {
noCache: true,
// 去掉多余行
trimBlocks: true
}
}));
// 引入 cxt.body 解析中间件
const bodyParser = require('koa-bodyparser');
app.use(bodyParser({
formList: '1000mb',
enableTypes: ['from', 'json', 'text']
}));
// 异常捕获中间件
app.use(app.middlewares.errorHanlder);
// 签名合法性校验
app.use(app.middlewares.apiSignVerify);
// API 参数的合法性校验
// app.use(app.middlewares.apiParamsVerify);
};
接口 API 的校验采用了 ajv + json-schema 进行校验
// app/middleware/api-params-verify.js
const Ajv = require('ajv');
const ajv = new Ajv();
/**
* API 参数校验
*/
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;
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}] params: ${JSON.stringify(params)}`);
app.logger.info(`[${method} ${path}] headers: ${JSON.stringify(headers)}`);
const schema = app.routerSchema[path]?.[method.toLowerCase()];
if (!schema) {
return await next();
}
let valid = true;
// jav 校验器
let validate;
// 校验 headers
if (valid && headers && schema.headers) {
schema.headers.$schema = $schema;
validate = ajv.compile(schema.headers);
valid = validate(headers);
}
// 校验 body
if (valid && body && schema.body) {
schema.body.$schema = $schema;
validate = ajv.compile(schema.body);
valid = validate(body);
}
// 校验 query
if (valid && query && schema.query) {
schema.query.$schema = $schema;
validate = ajv.compile(schema.query);
valid = validate(query);
}
// 校验 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();
};
}
对于接口时效校验采用了 “前端+后端” 的双重校验,约束好密钥key,通过 md5 加密,在发送请求时,前端传入和后端存的“密钥key”比较一致性,如果不一致,就返回 445 请求。
const md5 = require("md5");
/**
* API 签名合法性校验
*/
module.exports = (app) => {
return async (ctx, next) => {
// 只对 API 请求做签名校验
if (ctx.path.indexOf('/api') < 0) {
return await next();
};
const { path, method } = ctx;
const { headers } = ctx.request;
const { s_sign: sSign, s_t: st } = headers;
const signKey = 'f5b39c88-c0db-4df9-ba38-6d79bf898806';
const signature = md5(`${signKey}_${st}`);
app.logger.info(`[${method} ${path}] signature: ${signature}`);
if (!sSign || !st || signature !== sSign.toLowerCase() || Date.now() - st > 600 * 1000) {
ctx.status = 200;
ctx.body = {
success: false,
message: 'signature not correct or api timeout !!',
code: 445
}
return;
};
await next();
};
};
关于日志工具使用了 log4js
// app/extend/logger.js
const log4js = require('log4js');
/**
* 日志工具
* 外部调用 app.logger.info app.logger.error
*/
module.exports = (app) => {
let logger;
if (app.env.isLocal()) {
// 打印在控制台即可
logger = console;
} else {
// 把日志输出并落地到磁盘(日志落盘)
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;
};
总结:
本次学习主要是了解 elpis-core 内核的实现方式,以及封装思路: 定义每一个块业务逻辑的 loader 将业务逻辑加载进来,再经过 层层 “洋葱圈”模型的中间件(接口校验、接口参数校验,页面校验...),再到每个 controller (获取日志信息、读写 mysql...)业务处理后,再响应请求结果。