头图
本文是 2021 年 12 月 26 日,第三十五届 - 前端早早聊【前端搞 Node.js】专场,来自涂鸦的大前端基础建设团队 —— 龙野的分享。感谢 AI 的发展,借助 GPT 的能力,最近我们终于可以非常高效地将各位讲师的精彩分享文本化后,分享给大家。(完整版含演示请看录播视频和 PPT):https://www.zaozao.run/video/c35

正文如下

大家好,我是涂鸦智能的龙野,目前在团队中主要负责前端网关和 Node 这个语言基础方向的一些建设。今天我分享的主题是《如何用 Node 建设企业级应用网关》。

背景

前端网关的背景可以追溯到几年前,当微服务刚刚出现时,我们在 Java 技术体系中有一张架构图。

整个微服务大致是这样的,外部请求从用户端进来后,先经过负载均衡器,再到达一个外部网关,最后被转发到集群内部的各种服务,不论是 Java 还是其他语言的服务。在集群内部可能存在许多各式各样的服务,服务之间的上下层划分并不是非常明确。因此,外部网关到内部服务之间的边界并不是特别清晰。

当某个服务出现问题时,外部网关路由层代理到内部时,我们需要定位或排查问题时可能会比较复杂。而且,如果这个服务出现问题后,需要迅速发布一个修复版本,由于各个公司的发布系统稳定性依赖于整个流程,可能需要较长的时间。

所以,我们希望能够在外部网关到内部服务之间架设一个业务方向的网关,这就是现在 BFF 网关产生的背景。类似于现在的前置 BFF 网关,它是挂在外部网关之后,并且挂在内部服务集群的业务之前的一中间的代理层。这层代理层也就是聚合服务,即 BFF 网关最初产生的背景,它的目的是想要将不同的上游服务的数据聚合后反馈给用户。

在这个基础服务层中,我们可以扩展其功能。

  1. 当上游的某个服务出现问题时,可以在 BFF 网关中进行兜底操作,避免请求到达出现问题的服务。
  2. BFF 网关可以统计针对某个业务方向的流量,同时对恶意请求进行过滤。
  3. 引入了 BFF 网关,用于在常用的服务中进行封装和泛化,以处理不同的调用方法。业务方只需在网关中配置一个通用的接口,并使用通用的配置方式,即可请求不同的服务。
  4. BFF 网关还具有聚合接口数据的功能,以减少前端浏览器发起的请求数量,提高页面的流畅度。
  5. BFF 网关可以通过配置实现多接口的聚合,使前端浏览器只需通过一次请求即可获得多个接口的数据。
  6. 我们封装了一些公共能力,通过网关插件,允许对业务有更深了解的人直接编写网关插件以解决业务问题,降低业务开发接入的复杂度。
  7. 除此之外,使用这种网关还有一些其他附加价值。
    a. 例如,具有数据伪造功能,网关可以通过配置直接提供假数据,在云端或服务端暂时无法提供实际数据环境下进行联调;
    b. 自带服务发现能力,用户只需知道网关地址,即可请求网关,无需关心网关内部的路由和数据来源;
    c. 缓存能力,如果在特殊场景下这些服务出现了问题,我们可以返回上一次正常响应的页面。

今天要讲的内容一共分为以下几个部分,后面我们将按照这个顺序进行简要讲解。

网关基本架构

我们简单聊一下 BFF 网关的架构。

作为内部用户,对于网关,我希望它能实现请求到达网关后代理到特定的服务,从而完成我要做的事情。但是业务的网关需求较为复杂,不仅仅是简单的代理。如果只是简单的代理,那么直接部署一个 Nginx 不就好了吗?

我们希望做的是最初的设想,即具备动态化配置 Nginx 的能力。默认情况下,Nginx 的规则是写在 Nginx 的 conf 文件中的,但是如果要动态修改这些规则怎么办呢?了解过 Nginx 配置的同学应该知道,修改完后执行 reload 命令就可以使新的配置生效。

那么作为一个开发人员而不是运维人员,我应该如何修改这个 Nginx 配置呢?社区中有很多类似的实践,其中一个典型的例子就是将 Nginx 的配置写入到 Consul 中,Consul 会以文件的形式将配置下发到 Nginx 特定的目录。这样,Nginx 可以通过执行 Nginx 的 reload 命令来加载新的配置。我们最初设计网关时的想法实际上就是这样的。我们希望用户能够自定义他们的规则,并将这些规则下发到线上的各种环境中。因此,我们先开发了一个网关后台系统,内部用户可以在后台系统中配置代理规则。然后,我们对这些规则进行了抽象,引入了项目的概念,项目下面包含了路由,这些路由函数的逻辑由我们自己来定义。用户可以将这些配置和数据写入线上特定环境的持久化存储中。

在这种情况下,我们已经解决了内部链路的问题。当外部请求进入 BFF 网关时,它应该执行怎样的代理逻辑呢?

直接从 MySQL 中读取数据显然不合适,因为网关通常会处理大量的流量,而依赖于一个 IO 型的数据库会导致性能问题。因此,我们进行了发布操作,将数据从 MySQL 直接发送到线上的 Redis 中,因为 Redis 的读写速度非常高,可以支持足够的业务请求。因此,网关的所有依赖配置都是从 Redis 中读取的,这样可以确保在高并发的情况下,网关仍然能够处理足够的业务请求。

对于正常的外部请求,我们这里有一个简单的流程图。

在这种情况下,外部请求首先经过你的 DNS 解析,DNS 解析会将其转发到特定的 LB(负载均衡)。根据这个 LB,请求将被代理到特定的 Nginx 和 Ingress。

一旦请求被代理到 Nginx,Nginx 将根据配置规则进行处理。在这个配置中,你可以决定将这种请求代理到集群内部的网关服务,或者是其他服务,因为有些业务方可能希望将请求代理到自己的服务,而不需要经过网关服务。这种情况通常发生在前端业务或者与前端相关的业务中。如果我是一个云端业务,我肯定不会特意走网关。一旦你的业务请求到达网关,网关将在运行时执行特定的逻辑,匹配到之前存储在 Redis 中的配置,并根据规则进行处理。规则可以包括直接代理到像 Nginx 这样的代理服务,或者请求云端服务,以及从请求中获取哪些数据等。这些规则可以通过使用 Node.js 编写代码的方式由用户自定义,这也是我们网关的一个重要目标,即赋予前端云端服务端开发的能力。

接下来,我们将简单地看一下接下来要关注的几个要点。

  1. 用户在后台可以进行配置项目的管理,包括路由的配置和插件相关的配置。而这些配置的管理流程是怎样的?
  2. 在运行时,我们需要解析路由代码和插件代码,运行时的执行流程是怎样的?
  3. 插件的管理和正常代码的管理可能存在差异,需要关注运行时如何处理插件代码。
  4. 为了提供更好的用户体验,目前我们使用 JSON 作为用户编写路由函数的替代方式。我们需要关注如何将 JSON 解析为 TS 代码的逻辑。
  5. Docker 出现后的好处之一是隔离性,即多个实例运行在虚拟机中环境是独立的。在网关场景下,我们也需要关注这一点,甚至在未来的其他场景下,都需要关注代码的动态执行能力。如果涉及到动态执行代码,需要特别关注安全性和资源使用情况。

后台管理系统

在我们公司的网关后台管理页面中,可以看到项目的域名配置。这个域名会被解析到 Nginx,然后再到网关。域名的解析方式可以使用泛解析,例如 *.xxx.com 的方式。默认情况下,请求会解析到 fast 网关,也就是我们的 BFF 网关。

这些路由像你写代码的时候,Controller 层的一个一个的方法,它把这些方法写到运行时里面,运行时就是 BFF 网关,运行时会解析请求,然后根据配置进行代理。我们简单地新建一条路由,试一试这种配置方式。

路由的规则是默认的,用户需要查看网关相关的文档,才能知道这些东西应该如何配置。比如说在这个地方配置一条 GET 请求的路由,我正常访问的时候,就可以响应一个 "hello"。

至于这里面的这些代码运行时,我们也提供了一些依赖服务供给用户使用,包括一些链路相关的数据,还有一些全局变量和一些工具类的函数。这样用户在写这些路由的时候相对来说会比较方便。

接下来,我们简单看一下项目的列表页面。

需要提一下,由于我们公司是一个全球化的业务场景,为了让用户在使用时不需要访问多个系统,以降低使用成本,我们开发了一个功能,让用户可以在一个系统上将数据配置分发到各个国家的数据库或 Redis 中。这种方式在安全性方面需要考虑一些因素,需要与公司的安全部门商讨,考虑各国家的政策,以确定数据是否能够传输到不同地区,具体根据各公司的情况而定。

除了之前提到的列表模式之外,我们还为用户提供了一个基于 Web 的 IDE 编辑模式。

这种编辑模式类似于使用 Web IDE 进行编辑,编写路由、网关和配置信息。通过这种方式,用户可以在不同的区域内同步数据,并且系统会生成 diff 操作,方便用户了解修改的内容。

目前我们主要使用微软提供的 monaco-editor 的 npm 包来实现,该包专门用于实现类似于 VS Code 在线编辑器的功能。

关于后台系统的核心功能主要有以下几点。

路由的增加、删除、修改和查询是我们的主要关注点。另外我们还提到了一个配置发布的流程,尽管在我们的实际使用中没有提到,但为了提升用户的使用体验,我们在日常开发和预发布环境中采用了一些技术手段来简化发布流程,以减少繁琐的操作。

此外我们还支持路由的引用功能,允许其他业务方引用我们的路由信息,从而避免重复维护代码的问题。这一功能可以提高开发效率,同时也需要与其他业务方进行充分的沟通和协调,以确保数据的一致性和安全性。

当涉及到企业级应用时,除了之前提到的一些功能外,还需要考虑权限管理。用户在操作某种数据时需要具备相应的权限,并且这些权限需要有记录。另外,还需要一些插件、日志链路以及接口的统计等能力,来保障网关的健壮性。

运行时要如何设计

有了后台系统,我们就可以把这个数据写到 MySQL 数据库,也可以把这个数据从 MySQL 这种持久化数据库里面去写到 Redis 里。那么在运行时怎么从 Redis 里面去读这个数据,并且响应给用户呢?

我们借鉴了 Koa 框架的核心思想,即通过在请求或响应阶段的任何一层,附加一个类似于 AOP 的思想,在任何一层进行拦截和处理。

使用这个框架时,一个常见问题是如何管理上下文数据。这个框架未能提供足够的建议,导致许多用户在上下文中挂载大量不明数据。这会导致后续用户不知道上下文中到底挂载了哪些数据。

为了解决这个问题,我们要求所有的数据只能挂载到 context.state 对象上,并对这个对象的类型进行约束。这样我可以通过这个 context 对象了解所有后续请求的信息。

另外,Koa 框架的请求,进来就是全部进来了,出去就是全部出去了,没有某个请求进来时可以停止执行的功能。因此,基于 Koa 框架,我们封装了 Cube 和 CubeFlow 这两个概念,其中 Cube 的概念实际上就是 Koa 的中间件,只是在这个中间件里面我们引入了 enable 或者 disable 的概念。这样,在请求执行到这个中间件时,我们可以直接进行 next 跳转。通过这样的方式,我们将多个 Cube 进行了串联,形成了一条 flow,这个就是请求的执行链路。

在图片左边我们还可以看到,main.ts 是请求进入的执行入口,在这个入口里面主要做了两件事情,启动运行时的监听端口,启动另外一个实例。这主要用于监听另外一个端口以暴露一些指标,例如当前网关服务的健壮性和运行时的历史记录统计信息等,供其他服务进行采集和告警。

我们简单来看一下这个目录。适配器是一个用于处理路由规则的组件。这种适配器主要指的是路由规则的处理方式,我们默认情况下将路由规则写入 Redis 中,但是后面我们可能会对其进行扩展。因 Redis 不适合存储较大的数据,在直接读取较大数据时可能会导致阻塞。对于一些私有化部署的场景,可能没有 Redis 这样的需求。那么我们就提供一种基于文件配置的方式,预先将客户的一些路由规则写入文件中,然后从文件中读取。

很多人在项目中可能使用的是本地文件存储的方式,这种方式确实简便,但也带来了一个很大的隐患。如果将一些账号、密码等数据写入文件中,一旦文件泄露,对公司来说将是一个很大的安全问题。尤其是很多人喜欢将代码存放在类似 GitHub 这样的地方,相关的密钥的泄露影响会非常严重。此外,还包括一些工具函数的暴露,以及关于日志的处理。

我们为了便于后续的日志查找,需要对日志进行标准化处理,包括为日志添加等级和类型字段。这样,我们可以通过这些字段来索引和查找当前请求经过的所有日志的类型。我们还使用了日志链路和指标工具来观测网关的健壮性和风险,以保证网关的可观测性。

在链路方面,目前最流行且主要使用的工具有 SkyWalking 和 Jaeger。Jaeger是一款用 Golang 编写的工具,符合云原生架构的标准,具有良好的扩展性。我们同时在前端和云端都使用了这两套工具。

经过标准化处理后,我们目前在日志链路中采用了 B3 数据传输规范。虽然它的官方提供了不同的部署模式,但不论哪种部署模式,由于缺乏前置队列,在 span 数量过多时,可能会导致数据丢失。因此日志链路虽然方便,但并不完全可靠。如果需要更可靠的链路追踪,还需依赖日志记录。

在常用的中间件中,由于暴露了 HTTP 服务,需要注意处理请求中的 body 数据,否则无法解析 POST 请求的 body。网关还有一个核心功能,即关于登录 session 的保存和 cookie 的处理。在后面的基础中间件中,还涉及到 CSRF 安全中间件,这将在后续详细讨论。

现在,让我们来看一下请求进入代理流程的具体步骤。

对于熟悉 Node.js 并且有前端插件编写经验的人来说,可能会对这一过程更加了解。 通过 http-proxy 将上游请求代理到指定的下游服务,核心代码如上所示,官方文档中还包含其他用法,其实都大同小异。

在封装时,我们需要关注运行时的几个重要点。例如,如果监听失败、用户自动取消、代理超时以及上游异常应该如何处理?应该返回什么样的信息给用户?

我们主要做了以下处理,当请求到来时,我们首先判断这是否为代理请求还是 Ajax 接口请求。如果是代理请求失败,我们会直接通过模板引擎进渲染一个失败页。如果是接口请求,我们会直接返回错误信息,并携带网关指定的错误码。这些错误码会在文档中进行记录,以便用户在看到错误时能够了解发生了什么问题。

关于运行时的代码执行方式,我们需要查看下面的图表。

用户配置的数据会被写入 router 对象,并在运行时同步到 Redis 中。当请求到来时,我会先从 Redis 中获取这个数据。获取到的数据实际上是一个字符串。然后我们将 JS 字符串丢到 Node 提供的 vm 模块中,然后在 vm 内部运行的代码中,考虑到用户可能使用我们提供的 context 或者外部依赖 RPC 的情况,需要先将这些能力替换为特定的函数,这样 vm 才能正确识别这些数据。

当然,需要额外提及的是官方的 vm 模块存在一些安全性问题。动态执行代码有很多方式,比如 function 等,但为什么在最佳实践中,这些方式基本上都没有被使用呢?最大的问题就是,vm 直接将一些敏感的运行环境数据暴露给了用户,用户可以在虚拟机内部进行修改,从而涉及到了较大的安全性问题。因此,社区现在基本上都在使用 vm2 来替代官方的 vm 模块,处理一些动态执行的场景。

目前,我们使用最普遍且隔离性最好的工具就是 Docker。Docker 在线程级别或进程级别,对不同的服务和资源进行了隔离。刚刚提到的 vm2,其安全性和隔离性方面还不够。因此,如果我们想从线程层面对隔离进行更加严格的控制,我们可以考虑在 Docker 基础上进行封装,或者自行基于 vm 进行更上一层的线程级别封装。

需要额外提到的是,Docker 在容器隔离方面的安全性可能还不够。尤其是在涉及底层 Linux 文件描述符等配置操作时,容器可能会泄露一些数据到外部。社区目前也在致力于开发更加安全的容器隔离技术,建议大家密切关注相关的最新进展。如果你从事与阿里云或其他云服务商相关的产品开发,更需要关注容器隔离技术的安全性。

接下来,简单地使用 JSON 来描述这段代码。

与之前的流程类似,首先从配置中提取这些数据,提取到了这些数据之后,我们会对一些前置的变量和工具进行初始化,并在运行时,当解析到需要执行的路由时,进行编译。这个编译的过程实际上就是将 JSON 对象提取出来,然后进行替换的工作。由于这样的替换涉及到了无限的层级配置,我们不方便在代码中进行适配。因此,我们做了一个妥协,只允许接受 JSON 的第一层数据。

在代码中,核心的部分其实是 router.run(),实际上执行的是类似于以下的步骤。

首先创建一个虚拟机,然后将代码或者插件放入其中。最后,通过运行虚拟机实例来运行需要的脚本,并获取代码的返回值。

Drone 是一个基于 Docker 的、更方便隔离的工具。它的上层可以使用 vm2 来封装,但具体使用的方式取决于团队的需求和能力。作者写的内容很不错,有兴趣的读者可以在 GitHub 上了解更多。

插件的加载和管理

业务方认为在后台配置数据和工具很麻烦,我们为他们提供了一份文档,告诉他们如何进行插件开发,让他们可以根据自己的能力自行编写插件,减少沟通成本。平台允许用户新增插件,默认使用内网的 GitLab 进行管理。代码保存方式可能需要变更,不必保存在仓库,可以直接存储在 MySQL 数据库或对象存储中。

目前的插件源码放在 GitLab 上进行管理。后续规划中,我们计划使用文件存储的方式,直接将对象存储为文件。这是因为类似于 vm2 这样的工具可以直接加载文件并执行其中的代码。关于数据同步功能,大家可以自行查看。

插件开发的模版如下图所示,需要先定义输入参数,并暴露一个函数作为插件的执行逻辑。业务方自己实现具体的业务逻辑,可以通过函数请求业务依赖的服务,也可以根据登录相关的数据来修改上下文中的内容。插件是作用在全局的整个项目中,配置某个插件后,每个路由都会默认使用该插件。如果特定路由不使用插件,则插件需提供路由白名单。

在插件的使用上,有以下建议:

  1. 项目级和路由级配置,最终运行时在路由级识别最终插件代码,并在链路上体现。
  2. 一般配项目级插件即可,特殊路由通过白名单跳过插件逻辑,或配置路由级插件。
  3. 不建议过度使用插件,就好比不建议 Koa 中间件过多,导致请求上下文过于复杂。
  4. 使用插件前需阅读插件文档,明确插件用途再来使用;如有疑问可联系插件开发者。
  5. 网关插件有版本功能,插件版本号需要遵循 semver 规范。

网关的安全性加固

最后要提到的一个功能点是网关的安全性,这在之前的分享中也提到过,安全性是一个不可忽视的问题。网关的安全性可能会导致公司陷入法律诉讼等严重问题,因此需要引起足够的重视。

网关上可以实现一些在安全性方面的增强功能。例如,如果我经常暴露这种接口,那么我的接口是否支持防止请求的重放攻击呢?

防重攻击

在接口防重攻击方面,有很多种方案可供选择。

我们这边主要是通过左边四个点的认证来确定请求是否合法。只要请求能够通过这个认证,我们就认为该接口具备了防重攻击的能力。不过,实际的防重攻击力度应该达到什么级别,这需要根据各个公司的安全委员会的要求来确定。具体而言,每个请求都必须能够相互区别,并且可以拒绝重复的请求,防止请求内容被篡改,同时容许容错。

在我们的场景中,我们发现大多数业务方在使用接口时,先从网关加载到前端的页面,然后在页面上发起 Ajax 请求。在这种情况下,当文档加载完成时,网关会返回一个 nonce 作为响应。后续的请求中,限制前端必须使用自己的框架去发起 HTTP 请求,框架会在发起请求时进行封装,将当前时间戳和 nonce 一同携带过去。当请求到达网关时,网关会解析参数,并判断当前时间差是否在容错范围内。然后,网关会在 Redis 检查请求中携带的 nonce 字段是否合法。

防 XSS 攻击

关于常见的防 Web 安全的方法,其中涉及到的一个问题是 XSS 攻击。

这种攻击主要分为两种类型,一种是反射型 XSS 攻击。这种攻击方式是用户在提交表单数据中携带一些恶意脚本,当后端存储了这些数据后,就会出现存储型 XSS 攻击。当后面用户的请求从后端取出这些数据时,前端的脚本会在浏览器中执行,从而导致网站被攻击。

可以通过配置两种常见的包来配置 XSS 防护参数,一个是 XSS 包,另外一个是 Hamlet 包。在前端的场景中,一般会封装一个函数来使用这些包。在安全防护上,主要是增加安全攻击的难度,而不是完全避免。

防 CSRF

CSRF 这个跨站脚本伪造的问题和 XSS 攻击可能是当前 Web 领域中最常见且危害较大的两个问题。

与之前提到的防重攻击方案类似,网关在渲染时会返回一个 token 值,然后前端框架在发起请求时会携带这个 token 值。通常情况下,前端会把 token 放在 cookie 里与登录态一起使用。

另外,网关主要对 post、put 和 delete 等可能对数据库数据造成影响的接口做一些合法性校验,对 get 等获取请求大多数情况下直接放行。

内存级别和 IP 级别的限流

最后,安全性还涉及到内存级别和 IP 级别的限流。

当你的流量请求较大时,你服务的稳定性或响应时间可能会受到一定影响。如果你的网关服务多个业务方,类似之前提到的默认情况,多个业务方通过你的网关代理请求,那么如果某个业务方发起恶意攻击,一分钟内发送成千上万个请求,导致你的网关负载能力增加,QPS 上升,从而导致每个响应的延迟增加到四五百毫秒甚至一两秒,其他业务方也会受到影响。

因此,首先我们需要对你的网关在特定配置下进行压力测试,了解你的 QPS 大约能够支持多少,响应延迟、内存、CPU 等资源是否在可控范围内。可以在代码中添加一个限流插件来进行限流。一个简单的限流插件可以基于 map 来实现,即请求进来时进行计数,超过限定值时进行限制,然后请求出去时将计数减少。如果您想要更加健壮的限流方案,可以考虑社区上的一些限流 npm 包。我们现在在网关上实现 Mesh 化,当某个服务出现问题,不会影响其他服务。同时,在这种场景下,业务方到网关之间的网络带宽和延迟也可以得到性能优化。

其他功能

这个后台系统的功能在很多系统中都很常见,主要包括安全性合规、数据加密存储、日志输出时避免敏感信息泄露以及配置中心接入。

结束语

最后需要关注的是,我们正在开发一个业务服务的 BFF 网关,这将帮助解决一些痛点。随着业务的不断变化,我们还需要对这个 BFF 网关进行优化,因为业务需求千变万化。例如,刚才提到的管理方式的匹配化、后续的可观测性提升,提供低代码化或可视化的编排能力以提升用户体验等。此外,在线配置路由等功能时,需要提供在线和线下的单元测试,并对路由信息的相关参数进行完善的校验,这些都是我们需要尝试实现的一些目标。

最后

以上就是我的全部分享内容。最后,推荐一本讲经济的书《未来二十年,经济大趋势》。


前端早早聊
4 声望1 粉丝