概述
BFF的产生
BFF的产生与两个因素紧密相关:一是后台微服务化和领域化;二是前端应用的跨多端成为潮流。前者使得后台向领域内聚方向发展,后者使得同一个应用在不同端呈现出不可避免的差异。两个方向的发展,就导致了在后台服务与前台应用之间,出现了明显的Gap。
假设后台接口彻底领域化,那么前端应用中的一些逻辑,就需要在前端代码中获取和组装多个后台接口,然后进行编排;这对于前端应用来说就太重了,且这些逻辑很难维护。
假设将这些面向UI组装数据的逻辑放到了后台,那么又阻碍了后台的领域化。
所以就产生了BFF层,来填补的后台与多变前台之间的Gap。
BFF的功能定位
从以上可以看出,BFF层应该具备的核心功能是:
- 接口聚合和编排
除了这个核心功能之外,BFF层通常也会提供:
- 鉴权(通常是访问第三方服务进行)
- 参数校验与剪裁
在不同的企业应用架构中,BFF层承担的功能可能会略有差异,比如 鉴权 可能在网关层承载掉。但大体不会超出以上的功能范围。
接口聚合和编排
当请求走到BFF层,流量此时已经走到了企业的服务后台。它要进行聚合和编排的接口,这时往往以RPC的形式提供。那么在BFF层首先要完成的就是RPC接口的调用。
RPC是内部服务调用的通用协议,具体实现会有多种,比如 dubbo、gRPC。
不失一般性,我们首先定义一个RPC协议的实现,然后用代码实现它。
实现RPC协议,需要实现:
- 通信协议。我们这里使用tcp
- 数据协议。需要设计消息体格式,关键点是如何判断消息长度、编解码
消息体格式
我们将消息体拟定为以下格式:
长度+'/n'+payload
其中长度是 payload 的字节长度。我们采用最简单的序列化/反序列化作为编解码方案,采用 { data: {}, error: {}}的格式来表示解码消息,则编解码函数为:
export function encode(payload) {
return JSON.stringify(payload)
}
export function decode(payload) {
try {
return JSON.parse(payload)
} catch(err) {
log.error('decode error: ', err)
return { error: err }
}
}
rpc通信时的buffer生成方法:
export function buildDataBuffer(payload) {
const encoded = encode(payload)
// 消息的字节长度 +'/n'+payload
return Buffer.byteLength(encoded) + '\n' + encoded;
}
确定消息体格式后,我们有了长度判断和编解码规则,就可以确定从接收到的buffer数据中解析出payload的逻辑:
/**
* 将 buffer data 转为携带 payload 的信息
* @param {*} data 通过 rpc 传达的数据
* @param {*} lengthObj 用来储存 buffer data, 结构如下
{
bufferBytes: undefined, // 即已经收到的 buffer data
getLength: true, // 标识是否需要解析出字节长度字段
length: -1 // 解析出来的 字节长度 , 据此以对 buffer data 截断进行解析
}
当消息体过大时,会多次触发 on('data') ,所以需要用它作为容器存储 buffer data,等其长度 >= payload.length 后再解析
在 connection.on('data') 之外初始化此容器,以确保多次触发的 data 事件能通过它实现存储
*/
export function parseRpcResponse(data, lengthObj) {
// 取出 buffer 消息
if (lengthObj.bufferBytes && lengthObj.bufferBytes.length > 0) {
// 如果长度大于0,则已经接收过数据,将新的 data 拼接在其后
const tmpBuff = Buffer.alloc(lengthObj.bufferBytes.length + data.length);
lengthObj.bufferBytes.copy(tmpBuff, 0);
data.copy(tmpBuff, lengthObj.bufferBytes.length);
lengthObj.bufferBytes = tmpBuff;
} else {
lengthObj.bufferBytes = data;
}
var [fnDatas, finished] = parseBufferDatas.call(lengthObj);
return [fnDatas, finished]
}
export function parseBufferDatas() {
var datas = [];
// 标识消息体是否解析完
let finished = false;
var i = -1;
// 对根据 length + '/n' + payload 中的 length 截取的 payload 进行 parse
var parseBufferData = function () {
if (this.getLength === true) {
// 寻找 \n 前的长度标识
i = getNewlineIndex(this.bufferBytes);
if (i > -1) {
this.length = Number(this.bufferBytes.slice(0, i).toString());
this.getLength = false;
this.bufferBytes = clearBuffer(this.bufferBytes, i + 1);
}
}
// 等数据长度达到 length 时,再开始解析
if (this.bufferBytes && this.bufferBytes.length >= this.length) {
const dataStr = this.bufferBytes.slice(0, this.length).toString();
// 处理完第一个data后,需要查看是否还有未解析完的数据继续解析
this.getLength = true;
let parsedData
try {
parsedData = decode(dataStr)
}
catch (e) {
log.e('ERROR PARSE: ', e, dataStr);
return;
}
datas.push(parsedData);
// 将剩余部分放入缓存对象
this.bufferBytes = clearBuffer(this.bufferBytes, this.length);
// 继续处理下面的指令
if (this.bufferBytes && this.bufferBytes.length > 0) {
parseBufferData.call(this);
} else {
// 解析完了数据,设置为 true
finished = true
}
}
};
parseBufferData.call(this);
return [datas, finished];
}
parseBufferData.call(this);
return [datas, finished];
}
在 rpc 通信中,客户端要描述调用的方法及参数,设计其数据格式如下:
function buildClientRequestPayload(fnName, args) {
var id = idGenerator();
const payload = { id: id, fn: fnName, args }
return buildDataBuffer(payload)
}
rpc 通信的服务端收到消息后,调用对应方法,然后将结果以如下形式返回:
/**
* @param {*} oPayload 格式如下
* {
* reqData: { id: 'x', fn: 'fnname', args: [] } 请求
* data?: 方法执行结果
* msg?: 错误描述
* error?: 错误信息
* }
*/
function buildResponse(oPayload) {
const responsePayload = { id: oPayload.reqData.id, ...oPayload }
delete responsePayload.reqData
return buildDataBuffer(responsePayload)
}
RPC Server
Rpc server 主要包含以下部分:
- 接受client传递的消息
- 解析消息,调用对应的方法
- 返回结果
当启动一个rpc server时,需要描述当前server上的方法,我们设计如下的rpc server实例描述:
import RPCServer from '../rpc/server.js';
const port = 5665;
const rpc = new RPCServer({
combine: function (a, b) {
return a + b
},
longString: function () {
// 构造一个 2m 的string
let s = '1'
const n2m = (2 << 20)
for (let i = 0; i < n2m; i++) {
s += '1'
}
s += 'over.'
return s
}
});
rpc.listen(port);
rpc.listen(port);
其中 combine 、longString 就是该服务提供的方法。基于以上接口,我们设计 RPCServer 如下:
class RPCServer {
constructor(services, logger) {
this.services = services;
this.listen = (port) => {
this.getServer();
this.server.listen(port, () => {
console.log(`server running on port ${port}`)
});
}
this.getServer = () => {
const self = this;
const server = net.createServer(function (c) {
// 用来存储要进行解析的数据
const lengthObj = {
bufferBytes: undefined,
getLength: true,
length: -1
};
c.on('data', getOnDataFn(c, lengthObj, self));
});
this.server = server;
}
this.close = () => {
this.server.close();
}
}
}
function getOnDataFn(connection, lengthObj, rpcInstance) {
return function (data) {
const [fnDatas, finished] = parseRpcResponse(data, lengthObj)
if (finished) {
fnDatas.forEach(fData => fnExecution(fData, connection, rpcInstance));
}
};
}
function fnExecution(reqData, c, rpcInstance) {
if (!rpcInstance.services[reqData.fn]) {
c.write(buildResponse({ reqData, msg: '未找到对应的方法', error: { code: 'UNKNOWN_COMMAND' } }))
return
}
const args = reqData.args;
try {
const fn = rpcInstance.services[reqData.fn]
const argList = Array.isArray(args) ? args : [args]
const data = fn.apply({}, argList)
c.write(buildResponse({ reqData, data }))
}
catch (error) {
c.write(buildResponse({ reqData, error, msg: '执行方法错误' }));
}
};
const args = reqData.args;
try {
const fn = rpcInstance.services[reqData.fn]
const argList = Array.isArray(args) ? args : [args]
const data = fn.apply({}, argList)
c.write(buildResponse({ reqData, data }))
}
catch (error) {
c.write(buildResponse({ reqData, error, msg: '执行方法错误' }));
}
};
parseRpcResponse 即前述数据解析方法。
BFF 层中的请求管理
我们用 express 启动一个服务作为bff层
import express from 'express';
import bodyParser from 'body-parser';
import RpcClient from '../client.js';
const rpcClient = new RpcClient()
const app = express()
app.use(bodyParser.json());
app.post('/', async (req, res) => {
const { fn, args } = req.body
const response = await rpcClient.proxy({
host: 'localhost',
port: 5665,
fn,
args,
})
if (response.data) {
res.send(response.data)
return
}
res.send(response)
})
app.listen(3000, () => {
console.info('listening on port 3000.')
})
app.listen(3000, () => {
console.info('listening on port 3000.')
})
RpcClient即我们需要的作为客户端的类,对于远端的调用使用其 proxy 方法进行。
RPC Client
我们来实现BFF 中使用的 client,在消息体格式中已经确定它需要提供 proxy 方法,用来发起请求和处理响应。其中要做的事情主要是:
请求与 server 的连接
const connection = net.createConnection(port, host);
发送方法描述信息
connection.on('connect', function () {
// 连接上后发送消息, buildClientRequestPayload见前
connection.write(buildClientRequestPayload(fn, args));
})
解析响应返回数据
RpcClient.prototype.proxy = function proxy({
host,
port,
fn,
args,
} = {}) {
return new Promise((resolve, reject) => {
// 建立连接
const connection = net.createConnection(port, host);
let success = false
// 远端数据的容器,与 server 端类似的逻辑
const lengthObj = {
bufferBytes: undefined,
getLength: true,
length: -1
}
// connection.on('connect', () => {})...
// 解析远端响应
connection.on('data', function (data) {
try {
// 数据超出 socket 的一次消息长度后,将会分多次收到,这里会在一次通信中反复触发;所以用 finished 来标识此次通信是否结束,只有在结束后才返回
// finished的判断标准是所有消息的长度,大于消息头部传来的长度字段值
const [result, finished] = parseRpcResponse(data, lengthObj)
if (finished) {
success = true
connection.end()
resolve(getDataFromRpcResponse(result))
}
} catch (err) {
resolve({ error: err, msg: 'parse data error.' })
}
})
// ...省略如error等其他事件
})
}
})
}
这样,我们就完成了 bff 层与 rpc 服务进行通信的功能。详细代码可以参见github repo
实现了与微服务的rpc通信之后,再进行接口聚合和编排就比较简单了,这里不再赘述。