Frontend开疆拓土 — 打造专属于你的BFF方案

BFF网关是什么?

BFF 即 Backend For Frontend,翻译过来就是服务于前端的后端。这个概念最早在Pattern: Backends For Frontends中提出,它不是一种技术,而是一种逻辑分层:在后端普遍采用微服务的技术背景下,作为适配层能够更好地为前端服务,而传统业务后端只需要关注自己的微服务即可。

BFF 网关拆解图:

1.png

如图所示,我们把用户体验适配和 API 网关聚合层合称为广义的 BFF 层,在 BFF 层的上游是各种后端业务微服务,在 BFF 下游就是各端应用。从职责上看,BFF 层向下给端提供 HTTP 接口,向上通过调用 HTTP 或 RPC 获取数据进行加工,最终完成整个 BFF 层的闭环。

对比传统的架构,我们可以得出 BFF 层设计的优势:

降低沟通成本,领域模型与页面数据更好地解耦(可各自独立运行).

提供更好的用户体验,比如可以做到多端应用适配,根据不同端,提供更精简的数据.

但是 BFF 层需要谁来开发呢?这就引出了 BFF 的一些痛点:

需要解决分工问题,作为衔接前与后的环节,需要界定前后端职责,明确开发归属;

链路复杂,引入 BFF 层之后,流程变得更加繁琐.

资源浪费,BFF 层会带来一定额外资源的占用,需要有较好的弹性伸缩扩容机制。

通过分析 BFF 层的优缺点,我们可以明确打造一个 BFF 网关需要考虑的问题。而对于前端开发者来说,使用 Node.js 实现一个 BFF 网关则是一个非常好的选择。

BFF的来龙去脉:

关于BFF的前世今生

打造 BFF 网关需要考虑的问题

数据处理

这里的数据处理,主要包括了:

  • 数据聚合和裁剪

  • 序列化格式转换

  • 协议转换

  • Node.js 调用 RPC

在微服务体系结构中,各个微服务的数据实体可能并不统一和规范,如果没有 BFF 层的统一处理,在端上进行不同数据格式的聚合会是一件非常痛苦的事情。因此,数据裁剪和聚合对于 BFF 网关来说就变得尤为重要了。

同时,不同端可能也会需要不同的数据序列化格式。比如,某个微服务使用 JSON,而某个客户只能使用 XML,那么 JSON 转换为 XML 的工作,也应当合理地在 BFF 网关层实现。

再比如微服务架构一般允许多语言协议传输,比如客户端需要通过 HTTP REST 进行所有的通信,而某个微服务内部使用了 gRPC 或 GraphQL,其中的语言协议转换,也需要在 BFF 网关层解决。

还需要你了解的是,在传统开发模式中,前端通过 Node.js 实现 BFF 的模式:前端请求 BFF 提供的接口,BFF 直接通过 HTTP Client 或者 cURL 方式透传给微服务——这种模式下BFF不做任何逻辑处理且有其优势,但我们可以更进一步。Node.js 是一个 Proxy,so, 我们可以思考如何让 Node.js 调用 RPC,以最大限度地发挥 BFF 层能力。

流量处理

这里的流量处理主要是指请求分发能力、代理能力和可用性保障。

请求分发能力、代理能力

在 BFF 层网关中,我们需要执行一些代理操作,比如将请求路由到特定服务。在 Node.js 中,我们可以使用http-proxy来简单代理特定服务。

我们需要考虑网关层如何维护分发路由这个关键问题。简单来说,我们可以 hard coding 写在代码里,同时也可以实现网关层的服务发现。比如,在 URL 规范化的基础上,网关层进行请求匹配时,可以只根据 URL 内容对应不同的 namespace 进而对应到不同的微服务。当然也可以使用中心化配置,通过配置来维护网关层路由分发。

可用性保障
  • 除此之外,网关层也要考虑条件路由,即对具有特定内容(或者一定流量比例)的请求进行筛选并分发到特定实例组上,这种条件路由能力是实现灰度发布、蓝绿发布、AB Test 等功能的基础。

  • BFF 网关直面用户,因此这一层也需要有良好的限速、安全隔离、熔断降级、负载均衡和缓存能力。

关于这些内容,我们会在后面的代码实操环节中进一步体现。

安全问题

鉴于 BFF 层承上启下的位置,BFF 要考虑数据流向的安全性,需要完成必要的校验逻辑。其原则是:

  • BFF 层不需要完成全部的校验逻辑,部分业务校验应该留在微服务中完成;

  • BFF 需要完成必要的检查,比如请求头检查和必要的数据消毒;

  • 合理使用 Content-Security-Policy;

  • 使用 HTTPS/HSTS;

  • 设置监控报警以及调用链追踪能力。

同时,在使用 Node.js 做 BFF 层时,需要开发者时刻注意依赖包的安全性,可以考虑在 CI/CD 环节使用nsp、npm audit等工具进行安全审计。

权限与校验设计

在上面提到的安全问题中,一个关键的设计就是 BFF 层的用户权限校验。这里我们单独展开说明。

对于大多数微服务基础架构来说,需要将身份验证和权限校验等共享逻辑放入网关层,这样不仅能够帮助后端开发者缩小服务的体积,也能让后端开发者更专注于自身领域。

在网关中,一般我们需要支持基于 cookie 或 token 的身份验证。关于身份验证的话题这里我们不详细展开,值得一提的是,需要开发者关注 SSO 单点登录的设计。

关于权限问题,一般主流采用 ACL 或 RBAC 的方式,这就需要开发者系统学习权限设计知识。简单来说,ACL 即访问控制列表,它的核心在于用户直接和权限挂钩。RBAC 的核心是用户只和角色关联,而角色对应了权限,这样设计的优势在于:对用户而言,只需分配角色即可以实现权限管理,而某角色可以拥有各种各样的权限并可以继承。

ACL 和 RBAC 相比,缺点在于由于用户和权限直接挂钩,导致在授予时的复杂性;虽然可以利用组(角色)来简化这个复杂性,但 RBAC 仍然会导致系统不好理解,而且在判断用户是否有该权限时比较困难,一定程度上影响了效率。

总之,设计一个良好的 BFF 网关,要求开发者具有较强的综合能力。下面,我们就来实现一个精简的网关系统,该网关只保留了最核心的能力,以性能为重要目标,同时支持能力扩展。

实现一个core-gataway

如何设计一个扩展性良好的 BFF 层,以灵活支持上述需要考量的问题呢?我们来看几个关键的思路。

插件化:一个良好的 BFF 层设计可以内置或可插拔多种插件,比如 Logger 等,也可以接受第三方插件。

中间件化:SSO、限流、熔断等策略可以通过中间件形式实现,类似插件,中间件也可以进行定制和扩展。

在我看来,插件和中间件都能够帮助我们在解耦合的基础上,实现能力扩展。插件和中间件区别在于:

插件作用于软件的某一个或者多个生命周期hooks中,通过“装饰”软件改造软件的运行结果;主要执行过程是插件注册和调用。

中间件作用于类库本身,主要执行过程是中间件的注册、编排和调用。

下面我们就实现一个 BFF 网关。

首先安装以下必要依赖:

fast-proxy:支持 HTTP、HTTPS、HTTP2 三种协议,可以高性能完成请求的转发、代理。

@polka/send-type:处理 HTTP 响应的工具函数。

http-cache-middleware:是一个高性能的 HTTP 缓存中间件。

restana:一个极简的 REST 风格的 Node.js 框架。

我们的设计主要从

  • 基本反向代理

  • 中间件

  • 缓存

  • Hooks

几个方向展开。

基本反向代理

设计使用方式如下代码:

const gateway = require("./my-gateway")
const server = gateway({
  routes: [{
    prefix: '/service',
    target: 'http://127.0.0.1:3000'
  }]
})
server.start(8080)

网关暴露出gateway方法进行请求反向代理。如上代码,我们将 prefix 为/service的请求反向代理到http://127.0.0.1:3000地址。我们来看看gateway核心函数的实现(全部加了详细注释):

// 一个简易的高性能 Node.js 框架
const restana = require("restana")



// 使用 http-cache-middleware 作为缓存中间件
const cache = require("http-cache-middleware")()

// 默认支持的方法,包括 ['get', 'delete', 'put', 'patch', 'post', 'head', 'options', 'trace']
const DEFAULT_METHODS = require("restana/libs/methods").filter(
  (method) => method !== "all"
)

// 一个简易的 HTTP 响应库
const send = require("@polka/send-type")

// 支持 HTTP 代理
const PROXY_TYPES = ["http"]

// 默认的代理 handler
// 注意: url单独提取出来了, 方便使用; 还有proxyOpts
const defaultProxyHandler = (req, res, url, proxy, proxyOpts) =>
  proxy(req, res, url, proxyOpts)

// 代理方法
const proxyFactory = require("./lib/proxy-factory")

const gateway = (opts) => {
  // 第一步,初始化选项
  // 初始化中间件和路径正则匹配范围
  opts = Object.assign(
    {
      middlewares: [cache],
      pathRegex: "/*",
      timeout: 30 * 1000,
    },
    opts
  )

  // 第二步,生成server实例,并注册中间件
  // 运行开发者传一个 server 实例, 默认则使用 restana server
  const server = opts.server || restana()

  // 注册中间件
  opts.middlewares.forEach((middleware) => {
    server.use(middleware)
  })

  // 第三步,建议接口测试 && 代理信息整合输出
  // 一个简易的接口 `/services.json, 该接口罗列出网关代理的所有请求和相应信息
  const services = opts.routes.map((route) => ({
    prefix: route.prefix,
    docs: route.docs,
  }))
  server.get("/services.json", (req, res) => {
    send(res, 200, services)
  })

  // 路由处理
  opts.routes.forEach((route) => {
    // prefixRewrite 参数兼容
    if (undefined === route.prefixRewrite) {
      route.prefixRewrite = ""
    }

    // proxyType 参数校验
    const { proxyType = "http" } = route
    if (!PROXY_TYPES.includes(proxyType)) {
      throw new Error(
        "Unsupported proxy type, expecting one of " + PROXY_TYPES.toString()
      )
    }

    // hooks兼容
    // 加载默认的 Hooks
    const { onRequestNoOp, onResponse } = require("./lib/default-hooks")[
      proxyType
    ]
    // 加载自定义的 Hooks,允许开发者拦截并响应自己的 Hooks
    route.hooks = route.hooks || {}
    route.hooks.onRequest = route.hooks.onRequest || onRequestNoOp
    route.hooks.onResponse = route.hooks.onResponse || onResponse

    // 加载中间件,允许开发者自己传入自定义中间件
    route.middlewares = route.middlewares || []
    // 支持正则形式的 route path, 注意pathRegex的层次扩展性
    route.pathRegex =
      undefined === route.pathRegex ? opts.pathRegex : String(route.pathRegex)
    // 使用 proxyFactory 创建一个 proxy 实例
    const proxy = proxyFactory({ opts, route, proxyType })
    // 允许开发者自定义proxyHandler逻辑, 并且有默认的defaultProxyHandler兜底
    // 允许开发者自定义传入一个 proxyHandler,否则使用默认的 defaultProxyHandler
    const proxyHandler = route.proxyHandler || defaultProxyHandler
    // 设置超时时间
    route.timeout = route.timeout || opts.timeout
    const methods = route.methods || DEFAULT_METHODS

    const args = [
      // path
      route.prefix + route.pathRegex,
      // route middlewares
      ...route.middlewares,
      // 相关 handler 函数
      handler(route, proxy, proxyHandler),
    ]

    // 根据methods遍历挂载route对应的server
    methods.forEach((method) => {
      method = method.toLowerCase()
      if (server[method]) {
        server[method].apply(server, args)
      }
    })
  })
  return server
}

const handler = (route, proxy, proxyHandler) => async (req, res, next) => {
  try {
    // 支持 urlRewrite 配置
    req.url = route.urlRewrite
      ? route.urlRewrite(req)
      : req.url.replace(route.prefix, route.prefixRewrite)

    const shouldAbortProxy = await route.hooks.onRequest(req, res) //
    // 如果 onRequest hooks 返回一个 falsy 值, 则执行 proxyHandler, 否则停止代理
    // proxyOpts设计成了如下的hooks+request+queryString, 通过proxy/代理器发挥一定的作用
    if (!shouldAbortProxy) {
      const proxyOpts = Object.assign(
        {
          request: {
            timeout: req.timeout || route.timeout,
          },
          queryString: req.query,
        },
        route.hooks
      )
      proxyHandler(req, res, req.url, proxy, proxyOpts)
      res.send(`a common responese ${JSON.stringify(req.query)}`)
    }
  } catch (err) {
    return next(err)
  }
}
module.exports = gateway

这里我们对proxyFactory函数进行简单梳理:

// 一个高性能的请求代理库,同时支持HTTP\HTTPS\HTTP2三种协议
const fastProxy = require('fast-proxy')



module.exports = ({ proxyType, opts, route }) => {
  let proxy = fastProxy({
      base: opts.targetOverride || route.target,
      http2: !!route.http2,
      ...(route.fastProxy)
    }).proxy
  return proxy
}
 

如上代码所示,我们使用了fast-proxy库,并支持开发者以fastProxy字段进行对fast-proxy库的配置。具体配置信息你可以参考fast-proxy库,这里我们不再展开。

其实通过以上代码分析,我们已经把大体流程梳理了一遍。但是上述代码只实现了基础的代理功能,只是网关的一部分能力。接下来,我们从网关扩展层面,继续了解网关的设计和实现。

中间件

中间件思想已经渗透到前端编程理念中,中间件能够帮助我们在解耦合的基础上,实现能力扩展。相关代码如下:

const rateLimit = require('express-rate-limit')
const requestIp = require('request-ip')
gateway({
  // 定义一个全局中间件
  middlewares: [
    // 记录访问 IP
    (req, res, next) => {
      req.ip = requestIp.getClientIp(req)
      return next()
    },
    // 使用 RateLimit 模块
    rateLimit({
      // 1 分钟窗口期
      windowMs: 1 * 60 * 1000, // 1 minutes
      // 在窗口期内,同一个 IP 只允许访问 60 次
      max: 60,
      handler: (req, res) => res.send('Too many requests, please try again later.', 429)
    })
  ],
  // downstream 服务代理
  routes: [{
    prefix: '/public',
    target: 'http://localhost:3000'
  }, {
    // ...
  }]
})

上面代码中,我们实现了两个中间件。第一个中间通过request-ip这个库获取访问的真实 IP 地址,并将 IP 值挂载在 req 对象上。第二个中间件通过express-rate-limit进行“在窗口期内,同一个 IP 只允许访问 60 次”的限流策略。因为express-rate-limit库默认使用req.ip作为keyGenerator,所以我们的第一个中间件将 IP 记录在了req.ip上面。

这是一个简单的运用中间件实现限流的案例,开发者可以通过自己动手实现,或依赖其他库实现相关策略。

缓存策略

缓存能够有效提升网关对于请求的处理能力和吞吐量。我们的网关设计支持了多种缓存方案,如下代码是一个使用 Node 内存缓存的案例:

// 使用 http-cache-middleware 作为缓存中间件
const cache = require('http-cache-middleware')()
// enable http cache middleware
const gateway = require('fast-gateway')
const server = gateway({
  middlewares: [cache],
  routes: [...]
})

如果不担心缓存数据的丢失,即缓存数据不需要持久化,且只有一个网关实例,使用内存缓存(如上)是一个很好的选择。

当然,也支持使用 Redis 进行缓存,如下代码:

// 初始化 Redis
const CacheManager = require('cache-manager')
const redisStore = require('cache-manager-ioredis')
const redisCache = CacheManager.caching({
  store: redisStore,
  db: 0,
  host: 'localhost',
  port: 6379,
  ttl: 30
})
// 缓存中间件
const cache = require('http-cache-middleware')({
  stores: [redisCache]
})
const gateway = require('fast-gateway')
const server = gateway({
  middlewares: [cache],
  routes: [...]
})

在网关的设计中,我们依赖了http-cache-middleware库作为缓存,参考其源码,我们可以看到缓存使用了req.method + req.url + cacheAppendKey作为缓存的 key,cacheAppendKey出自req对象,因此开发者可以通过设置req.cacheAppendKey = (req) => req.user.id的方式,自定义缓存 key。

当然,我们可以对某个接口 Endpoint 禁用缓存,这也是通过中间件实现的:

routes: [{
  prefix: '/users',
  target: 'http://localhost:3000',
  middlewares: [(req, res, next) => {
    req.cacheDisabled = true
    return next()
  }]
}]

Hooks 设计

有了中间件还不够,我们还可以以 Hooks 的方式,允许开发者介入网关处理流程。比如以下代码同时也是我们的入口文件index.js:

const rateLimit = require("express-rate-limit")
const requestIp = require("request-ip")



const { fgMultipleHooks: {onRequestHooks, onResponseHooks} } = require("fg-multiple-hooks")
const hook1 = async (req, res) => {
  console.log("hook1 with logic 1 called")
  // 返回 falsy 值,不会阻断请求处理流程
  return false
}
const hook2 = async (req, res) => {
  console.log("hook2 with logic 2 called")
  const shouldAbort = false
  if (shouldAbort) {
    res.send("handle a rejected request here")
  }
  // 返回 true,则终端处理流程
  return shouldAbort
}
const PORT = 8080

const gateway = require("./my-gateway")
const server = gateway({
  // 定义一个全局中间件
  middlewares: [
    // 记录访问 IP
    (req, res, next) => {
      req.ip = requestIp.getClientIp(req)
      return next()
    },
    // 使用 RateLimit 模块
    rateLimit({
      // 1 分钟窗口期
      windowMs: 1 * 60 * 1000, // 1 minutes
      // 在窗口期内,同一个 IP 只允许访问 60 次
      max: 60,
      handler: (req, res) =>
        res.send("Too many requests, please try again later.", 429),
    }),
  ],
  // downstream 服务代理
  routes: [
    {
      prefix: "/service",
      target: "http://127.0.0.1:3000",
      docs: "just a test example",
      hooks: {
        // 使用多个 Hooks 函数,处理 onRequest
        onRequest: (req, res) => onRequestHooks(req, res, hook1, hook2),
        // rewriteHeaders(handlers) {
        //   // 可以在这里设置 response header
        //   return headers
        // },
        // 可以使用多个 Hooks 函数,处理 onResponse => onResponseHooks, 此处不做演示
        onResponse: (req, res, stream) => {
          // do some logic
        },
      },
      middlewares: [],
      pathRegex: "/*",
      proxyHandler: null,
      timeout: 5000,
      prefixRewrite: "", 
    },
  ],
})
server.start(PORT).then((server) => {
  console.log(`API Gateway listening on ${PORT} port!`)
})

做个简单测试:

node index.js

在浏览器中输入http://localhost:8080/service/test?age=18&name=如花,结果如下:

2.png

完整代码在我的github仓库

总结:

我们详细介绍了 BFF 网关的优缺点、打造 BFF 网关需要考虑的问题。然后我们实现一个精简的网关系统,并结合源码和设计层面对其实现进行了解析。总之,设计一个良好的 BFF 网关,要求开发者具有较强的综合能力。

作为前端开发者,向 BFF 进军是一个有趣且必要的发展方向。

另外,Serverless 是一种无服务器架构,它的弹性伸缩、按需使用、无运维等特性都是未来的发展方向。而 Serverless 结合 BFF 网关设计理念,业界也推出了 SFF(Serverless For Frontend)的概念。事实上,知名大V狼叔在2022大前端总结和2023就业分析中也特别提到了未来的前端发展趋势之一是包含了SFF在内的低代码全栈,大家有兴趣的可以参看以下,运筹于帷幄之中,决胜于千里之外。

其实,这些概念万变不离其宗,掌握了 BFF 网关,能够设计一个高可用的网关层,会让你在技术上收获颇多,同时也能在业务上有所精进。

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYGyyeio' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片