Skip to main content

BFF 架构初探

什么是BFF 以及 node.js 在BFF 层能做的事情

BFF-服务于前端的后端,如果存在多端共用同一个后端,需要根据需求返回不同的数据,这里就需要使用中间代理层来处理,NODE使用js进行编码,前端开发人员可以快速上手。

Node模板渲染

在没有Node中间层之前前段项目部署 都是 打包完生成 dist 目录交给后端或者运维部署到 nginx 等上. 现在有了 Node 中间层后,可以通过 Node API接口吐出模版页面来。这里选择使用 koa-swig 模版引擎

npm install koa-swig koa koa-static --save

启动Node服务配置koa-swig

const Koa = require('koa');
const serve = require('koa-static');
var render = require('koa-swig');
const co = require('co');

const app = new Koa();
const { port, viewDir, memoryFlag, staticDir } = require('./config')

app.use(serve(staticDir))

app.context.render = co.wrap(render({
root: viewDir,
autoescape: true,
cache: memoryFlag, // disable, set to false
ext: 'html',
writeBody: false,
varControls: ['[[', ']]']
}));

app.listen(port, () => {

});

新建views目录,并新建index.html

编写路由并返回index.html页面

const Router = require('koa-router')
const router = new Router()

router.get('/', async (ctx) => {
ctx.body = await ctx.render('index')
})

启动Node服务, 成功访问我们刚才的页面。

提示

如果启动页面发现只有一个 {},则记住需要在 render前加上 await

Node真假路由混用

如上编写了后端路由,通常前端SPA页面也有前端路由以vie-cli打包后为例。

访问node渲染了前端SPA页面,页面在进行切换的时候没问题,但是刷新页面却发现报错,这是因为没有前端SPA的路由,因此这里需要使用到真假路由。

npm install koa2-connect-history-api-fallback

如上是koa的一个中间件,用于处理vue-router使用history模式返回index.html,让koa2支持SPA应用程序。

使用方法如下:

const { historyApiFallback } = require('koa2-connect-history-api-fallback');
// 可自行配置白名单
app.use(historyApiFallback({ whiteList: ['/api'] }));

Node错误处理

对于异常,可以分为已知异常未知异常

已知异常就是程序中能够知道的异常,如:客户端参数传递错误,服务端抛出的异常,客户端无权访问等这一类就是已知异常。

未知异常就是程序中不能预想的错误,最常见的服务器程序抛出状态码500的异常,如常见的程序运行错误,这种是未知错误。

中间件的异常

如下使用中间件抛出一个异常:

app.use((ctx, next) => {
throw Error('error');
ctx.msg += 'world';
next();
});

控制台得到UnhandledPromiseRejectionWarningError:error 错误

现在加上app.onerror来拦截错误

app.use((ctx, next) => {
throw Error('error');
ctx.msg += 'world';
next();
});

app.use(ctx => {
ctx.body = 'hello word';
});

app.onerror = (err) => {
console.log('捕获到了!', err.message);
}

再次运行发现onerror没有拦截到,查阅官网记载的错误处理方法Error Handling,发现违反了koa的设计,有如下两种方法处理这个问题:

  • 如果不想修改成async函数,那就在所有的next()前面加return即可
  • 如果是async函数,那所有的next前面加await即可

全局异常中间件

全局异常监听

编写捕获异常处理中间件catchError.js

const catchError = async (ctx, next) => {
try {
await next()
// 处理页面404 防止页面404过多 被搜索引擎降权
if (ctx.status === 404) {
ctx.body =
'<script type="text/javascript" src="//qzonestyle.gtimg.cn/qzone/hybrid/app/404/search_children.js" charset="utf-8"></script>';
}
} catch (error) {

}
}

module.exports = catchError

在app.js加载中间件

全局异常中间件监听、处理因此放在所有的中间件的最前面

const catchError = require('./middlewares/exception')
const app = new Koa()
app.use(catchError)
...
app.listen(8000)

通过如下代码测试捕获异常

const Koa = require('koa');
const app = new Koa();

const catchError = async (ctx, next) => {
try {
await next()
} catch (error) {
console.log(error);
}
}

app.use(catchError)

app.use((ctx, next) => {
console.log(a);
});
app.listen(8000);

定义异常的返回结果

如上代码中a是空值会报空指针异常,这类错误就是前面所说的未知错误。

在服务器接口开发中,一个异常的返回结果通常如下:

  • msg:异常信息
  • code:HTTP状态码
  • errorCode:自定义的异常状态码

因此这里定义HttpException

// 定义HttpException继承Error这个类
class HttpException extends Error {

constructor(msg = '服务器异常', errorCode = 500, code = 400) {
super()
/**
* 错误信息
*/
this.msg = msg
/**
* Http 状态码
*/
this.code = code
/**
* 自定义的异常状态码
*/
this.errorCode = errorCode

}
}

改写中间件,区分是已知异常还是未知异常

const { HttpException } = require("../core/httpException")

const catchError = async (ctx, next) => {
try {
await next()
} catch (error) {
const isHttpException = error instanceof HttpException

//判断是否是已知错误
if (isHttpException) {
ctx.body = {
msg: error.msg,
errorCode: error.errorCode,
request: `${ctx.method} ${ctx.path}`
}
ctx.status = error.code
} else {
ctx.body = {
msg: '服务器出现了未知异常',
errorCode: 999,
request: `${ctx.method} ${ctx.path}`
}
ctx.status = 500
}
}
}

启动服务访问 http://localhost:8000/发现抛出 {"msg":"服务器出现了未知异常","errorCode":999,"request":"GET /"},说明自定义的已知错误被拦截到了。

定义常见的异常状态

有了如上异常的基类,定义一些常见的异常如:参数校验失败

class ParameterExceptio extends HttpException {
constructor(msg, errorCode) {
super()
this.code = 400;
this.msg = msg || '参数错误';
this.errorCode = errorCode || 400;
}
}

成功返回

class Success extends HttpException {
constructor(msg, errorCode) {
super()
this.code = 200;
this.msg = msg || '成功';
this.errorCode = errorCode || 200;
}
}

权限认证

class AuthFaild extends HttpException {
constructor() {
super()
this.code = 401;
this.msg = '认证失败'
this.errorCode = 1004;
}
}

根据业务定义异常情况

开发环境异常查看

全局异常组件区分了已知异常和未知异常,但是返回了一样的错误对于开发环境调试不友好,需要在控制台抛出异常方便定位问题。

改写package.json启动命令

  "scripts": {
"start": "set NODE_ENV=dev&& nodemon --inspect app.js"
}

启动服务后根据命令参数判定是开发环境还是生产环境,只需要在开发环境中抛出错误,代码如下:

 const isHttpException = error instanceof HttpException
// 开发环境下输出 异常 error
if (process.env.NODE_ENV === 'dev' && !isHttpException) {
throw error
}
...

JS type module和systemjs

使用JavaScript模块依赖于import和export,最新的浏览器开始原生支持模块功能了,浏览器能过最优化加载模块,使他比其他库更有效率:使用库通常需要做额外的客户端处理。

首先需要把script属性type=module,来声明这个脚本是一个模块:

<script type="module">
// 1.支持module 支持nomodule
import("./js/data.js").then(_ => {
console.log(_.default);
})
</script>

浏览器中可用的JavaScript模块功能的最新部分是动态模块加载。这允许仅在需要时动态加载模块,而不必预先加载所有模块,如上:

如果浏览器不支持module,支持nomodule如下:

<script type='nomodule'>
...
</script>

这时候就需要babel进行打包编译

如果浏览器即不支持module也不支持nomodule这个时候需要用到systemjs

安装依赖

npm install @babel/cli @babel/core @babel/plugin-transform-modules-systemjs @babel/preset-env -D

配置.babelrc

{
"presets": [
"@babel/preset-env"
],
"plugins": [
"@babel/plugin-transform-modules-systemjs"
]
}

执行打包

    "build": "babel ./assets/scripts/data.js -o ./assets/scripts/data_bundle.js"

systemjs是一个通用模块加载器,支持AMD、Commonjs、ES6等各种格式的模块加载

在index.html引入systemjs

<script nomodule src="https://cdn.staticfile.org/systemjs/6.3.3/system.js"></script>
<script nomodule>
// 不支持module 不支持nomodule 下面
System.import("./scripts/data_bundle.js").then((_) => {
console.log(_.default)
});
</script>

如果支持module不支持nomodule那么如上代码会被执行两次,解决如下

<script>
(function () {
var check = document.createElement('script');
if (!('noModule' in check) && 'onbeforeload' in check) {
var support = false;
document.addEventListener('beforeload', function (e) {
if (e.target === check) {
support = true;
} else if (!e.target.hasAttribute('nomodule') || !support) {
return;
}
e.preventDefault();
}, true);

check.type = 'module';
check.src = '.';
document.head.appendChild(check);
check.remove();
}
}());
</script>

总结如下:

  • 支持module、支持nomodule
  • 支持module不支持nomodule代码会执行2次
  • 不支持module不支持nomodule

拦截后端接口并进行格式化

新建controllers目录,新建Book.js

const axios = require('axios');

class Book {
static async getData() {
const { data } = await axios.get('http://localhost:8080/book/list')
data.forEach(item => {
delete item.content
delete item.page
});
return data
}
}

module.exports = Book

如上访问本地java接口,对返回的数据进行了处理,删除了一些无用的数据

Node BFF层进行调用

router.get('/book/list', async (ctx) => {
const data = await Book.getData()
throw new Success(data)
})

请求流程:

  • 前端发送请求到Node BFF层
  • Node再次发送请求到JAVA获取到数据,并对数据进行返回

locale Dropdown

小插件

解决目录引入复杂问题

require('../../../../some/very/deep/module')

安装module-alias解决如上问题,用法如下:

const moduleAlias = require('module-alias');
moduleAlias.addAliases({
'@root': __dirname,
'@models': __dirname + '/models',
'@controllers': __dirname + '/controllers',
});

// 引用 root 下的某个文件
import module from '@root/some-module'