Nodejs BFF之RPC

3,159 阅读7分钟

概述

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 主要包含以下部分:

  1. 接受client传递的消息
  2. 解析消息,调用对应的方法
  3. 返回结果

当启动一个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);

其中 combinelongString 就是该服务提供的方法。基于以上接口,我们设计 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(&#39;data&#39;, getOnDataFn(c, lengthObj, self));
  });

  this.server = server;
}
this.close = () =&gt; {
  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(&#39;connect&#39;, () =&gt; {})...
// 解析远端响应
connection.on(&#39;data&#39;, 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: &#39;parse data error.&#39; })
  }
})
// ...省略如error等其他事件




})
}

}) }

这样,我们就完成了 bff 层与 rpc 服务进行通信的功能。详细代码可以参见github repo

实现了与微服务的rpc通信之后,再进行接口聚合和编排就比较简单了,这里不再赘述。

参考