高性能 Java 应用层网关设计实践
前言
上文我们简单阐述了一下接入层网关的实现原理
不少人对 Java 网关的实现也比较感兴趣,所以这篇文章我们来简单谈谈 Java 应用网关设计,本文将会从以下几个方面来阐述 Java 应用层网关的设计
Java 应用层网关的必要性
核心网关技术选型
嵌入式网关 设计
Java 应用层网关的必要性
我们的 Java 网关分为应用层网关和业务嵌入式网关两部分,架构图如下
在这里插入图片描述
Java 网关分为核心网关和业务嵌入式网关服务两部分,主要工作原理如下
接入层流量首先进入 Java 核心网关,经过一系列的 pipeline 处理(风控,路由协议转换、流控、降级等操作)后发起泛化调用再打入业务层网关
业务层网关也会经过一系列的 pipeline(接口校验,验签,session 校验等)进入最终的业务逻辑,然后再调用相关 dubbo 服务最终完成本次 Java 请求的响应。
核心网关与嵌入式业务网关的功能如下
在这里插入图片描述
其中嵌入式网关是以 jar 包的形式集成到业务的工程里的,具体为啥要这样设计,后文会详述。
首先来看 Java 网关为啥要分成核心网关和嵌入式业务网关两部分,直接从接入层打到业务网关不是更省事吗,何必多此一举再加一层核心网关,多加一层不是多了一个损耗吗。
这里有三个原因
核心网关主要起着风控,鉴权、路由协议转换、流控、降级,打点统计(请求报错等)等作用,这些功能对每一个层请求来说都是通用的,统一将这些功能抽离放在核心网关实现更合理。
当然了,可以统一把第一点所述的这些功能放在接入层实现,但这样会让接入层显得很臃肿,另外第一点中有一个很重要的功能,路由协议转换(将 http 转成 dubbo),由于我们的接入层用的是 OpenResty,它是不支持这种协议转换的,除非基于 OpenResty 做二次开发,这样费时费力,也无必要,这样看来抽出一个 Java 核心网关来担任第一点所述的功能是更合理的,计算机界不有一句话么:任何问题,在计算机界都可以通过加入一个中间层来解决。加一个 Java 核心网关符合单一职责,分层的设计理念。
加入一个核心网关,确实多了一层,也多了一个损耗,不过核心网关并不处理具体的逻辑,它主要起着流量转发的作用,而且在下文我们可以看到,它采用了 webflux 这种反应式编程框架,带来的损耗比起引入它带来的优势可以忽略不计。
接下来我们简单谈谈核心网关和业务网关的设计思路。
核心网关技术选型
同步阻塞 VS 异步非阻塞
上节介绍可知 Java 核心网关承担着所有的流量入口,本身会调用大量的业务接口(打到业务网关里),所以 IO 操作会很频繁,在技术选型上是有要求的, 首先来看看传统的 Spring MVC(servlet 3.0之前)
很明显它是同步阻塞的, 一个请求需要对应一个 Servlet Thread 来处理,当有 DB,网络 IO 时,此线程会阻塞,可想而知用这种方案线程很快会占满,导致系统不可用。
显然我们应该采用异步非阻塞的编程模型,它是如何工作的呢,如下图示
工作原理如下
只有一个 request 线程负责 accept 所有的请求,每个请求都有一个 Event handler 和回调,request 线程接收到 request 请求后,首先会为此请求在 Event Loop 中注册一个回调函数,紧接着马上把这个请求丢给线程池中的某个线程处理,然后此 request 线程立马返回,马上就可以处理另外的请求了。
线程池中的线程处理完请求的 Event Handler(DB,网络IO等逻辑) 后,会去调用之前注册好的回调函数返回请求结果
从以上的工作原理可以看出,负责处理请求的 request 线程只需求一个,线程数大大减少!更少的线程意味着更高的内存利用,也意味着线程间的切换开销大大减少!所以显然应该使用这种编程模型。
打个简单的比方,相信大家都有去酒店就餐的经历,对于酒店来说,怎么才能最大化地提高接客效率呢
一种方式是对每一个客人,都安排一位接待员,这名接待员负责客人的接待,入座,上菜等所有流程,显然如果这样安排的话有多少位客人就等安排多少位接待员。
第二种方式是只安排一位接待员,这名接待员在接待客人入座后,立刻回到门口迎接客人,剩下的交给上菜服务员(线程池工作),这样的话接待员的人数就大大减少了,能极大地提升效率。
最终我们选择了 Spring WebFlux 这种反应式(Reactive),基于事件驱动的异步非阻塞框架。
反应式编程与 Spring WebFlux 简介
反应式编程简介
反应式编程 (reactive programming) 是一种基于数据流 (data stream) 和 变化传递 (propagation of change) 的 声明式 (declarative) 的编程范式。它是一种编程思想,能够基于数据流中的事件(变化)进行相关反应处理,举个简单的例子:在 a = b + c 这个语句中,要得到 a 的值,如果用传统的编程模型,每次 b 或 c 变化后都需要重新计算以获得 a,而在反应式编程中,我们把 b,c 当作数据流,a 会对 b,c 作出的变化实时响应。
反应式编程有以下几个特点
1、事件驱动
在事件驱动的程序中,组件之间通过松藕合的生产者(也称被订阅者,即 Publisher)和订阅者模式(Subscriber)来实现,这些事件是以异步和非阻塞的方式来接收和发送的,基于事件驱动的编程有啥好处呢,简单地说它是依靠推模式而不是拉模式来动作的,也就是说只有生产者有消息(变化)时才会通知消费者作出响应,也就意味着消费者不需要轮询也不需要等待数据。
2、实时响应
以我们的网关为例, request 线程接收请求后,快速返回存储结果的上下文,把具体执行交给线程池里的线程(可以认为是后台线程),处理完成后,异步地将调用结果封装到结果的上下文中,可以看到此过程是完全异步的,也就是说实时响应必须通过异步编程实现,在 Java 8 中,发起调用后可以快速返回 CompletableFuture 对象。
3、弹性机制
事件驱动的松散耦合提供了组件在失败下可以抓获完全隔离的上下文场景,作为消息封装,发送到其他组件时,在具体编程时可以检查错误比如是否接受到,接受的命令是否可执行等等,并决定如何应对。
反应式编程主要工作流程如下
被订阅者主动推送数据给订阅者,在异步或完成时触发另外的两个方法
被订阅者发生异常,会触发 onError
所有的推送完成无异常,最终会执行 onSuccess 方法
还有一个问题,如果 Publisher 发送消息过快超过 Subscriber 的处理速度了怎么办,所以就得提一下背压(BackPressure)的概念了,知乎网友扔物线对此概念解释我认为非常到位:
backpressure 是源自工程学中的概念:在管道运输中,气流或液流由于管道突然变细、急弯等原因导致由某处出现了下游向上游的逆向压力,这种情况称为「backpressure」,相应的在反应式编程中,在数据流从上游生产者向下游消费者传输的过程中,上游生产速度大于下游消费速度,导致下游的 Buffer 溢出,这种现象就叫做 Backpressure 出现,这里的重点在于「Buffer 溢出」,为什么需要 buffer, 因为 Publisher 生产速度大于 Subscriber 的消费速度,所以需要 Buffer, 因为外部条件限制,显然 Buffer 是有上限的,如果生产速度超过 buffer, 则 backpressure 产生,超过 buffer 的话,唯一的选择就是丢掉新事件。
这就好比,比如你的 server 只能承受 5000~6000 的请求,如果你把 buffer 设置为 5000,则一旦请求数超过 5000,则背压产生,超过的请求数丢弃,这样保证了机器不会被源源不断的 Publisher 生产事件压垮,有效提升了网关的可用性。
Spring WebFlux 简介
为了更好地促进反应式编程的应用,在 Java 平台上,Netflix(开发了 RxJava)、TypeSafe(开发了 Scala、Akka)、Pivatol(开发了 Spring、Reactor)共同制定了一个被称为 Reactive Streams 项目(规范),用于制定反应式编程相关的规范以及接口。
Reactor 基于 Reactive Stream 定制了一套反应式编程框架,而 WebFlux 则是以 Reactor 为基础实现了 Web 领域的反应式编程框架,由于反应式编程的异步非阻塞特性,所以 WebFlux 运行于 Netty , Undertow 等支持异步编程模型的 server 之上,当然也可运行于支持 Servlet 3.1 的 Server 容器上(Servlet 3.1 开始支持异步)
如图示,左侧是传统的 Spring MVC 结构, 右侧是 webflux 组件。
为了让大家更好利用 webflux 编程,Spring 贴心地兼容了 @Controller 等 Spring MVC 的注解在 webflux 的使用,能让使用者更好地过渡到 webflux 编程中来,不过在底层实现中,与 Spring MVC 的实现的请求 InputStream 和响应 OutputStream 不同,webflux 实现了一套反应式的请求(ServerHttpRequest) 和响应(ServerHttpResponse),这两个类将请求体与响应体以 Flux(Flux 下文会简单介绍下)的形式暴露出来,同时 webflux 底层也实现了基于 Flux的 JSON,XML 的序列化和反序列化,HTML 实图的渲染,Server 发送事件等。
通过介绍可以看到 webflux 实现了从请求到响应,到渲染,事件发送等一整套反应式事件的支持,是的,要最大程度地发挥 webflux 的性能,中间所有的事件都应该以 Mono 或 Flux 响应式事件流的形式存在!
WebFlux 的底层实现其实是基于 Reactor 实现的,在 Reactor 的核心类中,以下两个类代表了发布者
Mono: 代表 0 到 1 个元素的发布者
Flux:代表 0 到 N 个元素的发布者
这玩意怎么用呢,如下图示
@RequestMapping("/demo")
@RestController
public class DemoController {
@RequestMapping(value = "/foobar")
public Mono foobar() {
return Mono.just(new Foobar());
}
}
本来是要返回 foobar 对象的,结果最终以 Mono(或 Flux)的形式存在,这样就构建了响应式编程中的生产者(Publisher),再调用 subscribe 即可完成对生产者的监听消费。
在我们的网关设计中,当收到请求后,使用了 Mono 来充当发布者,如果中间出现了问题,会调用 onError, 最终成功后会调用 onSuccess,以下是网关实现采用的总体框架。
图中 Mono.empty 代表创建一个不包含任何元素,只发布消息的队列。发送消息后,会在线程池里处理网关的 slot ,最后处理成功后会调用 onSuccess 方法,处理失败则会调用 onError。下一节我们来看看这些网关 slot 是如何处理的。
网关的责任链设计
不管是核心网关还是嵌入式网关我们都采用了责任链模式来实现网关的核心处理流程,将每个处理逻辑看成一个slot,每个 slot 按照预先设定的顺序先后执行,与开源kong,zuul等类似,我们也采用了PRPE模式(Pre、Routing、Post、Error)
Pre 阶段:
initParamsSlot 初始化组装请求上下文参数
sentinelSlot 流控组件引入 ,做集群限流、降级、熔断使用
riskSlot 风控处理
Route 阶段:
dubboSlot 通过 dubbo 泛化调用转换成 dubbo 协议进行远程调用
POST Slots: 后置处理
APMMonitorSlot APM 监控处理,请求出错等打点监控
采用这样的设计方式,各个 slot 各司其职,也有较好的可扩展性,如果还想加什么 slot,定义好此 slot 功能,指定好其在调用链中的位置即可。
需要注意的是有些 Slot 的请求结果依赖于前面 Slot 的执行结果,这种情况下需要对前面的执行事件用 Mono 的形式封装起来,这样这些 slot 就构成了一个个的响应式事件流,保证了这些 Slot 都是异步执行的,不会阻塞主线程。
另外注意高亮的 dubboSlot 阶段,在 dubbo 2.7 之前 dubbo 底层返回 Future(会一直占用一个线程轮询结果),对异步编程不友好,2.7 之后返回了 CompleteFuture,与 webflux 的异步编程模型完美结合(发起调用嵌入式网关后立马返回,等调用完成后才执行,是真正的异步)。
嵌入式网关设计
首先我们要明白为啥会有嵌入式网关的需求,主要有三个原因
目前有 H5, 小程序,app 端,各端的 session 存储不一样,需要根据请求的各端来查找 session 对应的 uid,这个操作显然应该在网关层面来做,放在嵌入式网关来实现更合理
每个请求进入业务层之后,我们需要对其时间戳,app 签名,小程序签名等进行校验,这些校验对每个端的请求都是必要的,所以显然应该在网关来做
有些业务需要在执行业务前后做一些扩展,比如执行前后需要打点分析等,对扩展的实现网关也应该支持
那么嵌入式网关如何实现呢,业务服务是以 dubbo 服务的形式存在的,而在 dubbo 中有一个 Filter 机制,是专门为服务提供方和服务消费方调用过程进行拦截设计的,每次远程方法执行,该拦截都会被执行。这样就为开发者提供了非常方便的扩展性,所以嵌入式网关的主要设计思路就是自定义 dubbo 的 filter,然后在此 filter 中执行相关的扩展逻辑即可,伪代码如下:
这样通过自定义 filter 的方式我们解决了扩展性的问题,注意我们使用了Activate注解,这样 dubbo 就会把注释的Filter 作为 dubbo 原生的 Filter 自动加载,而不需要显示的配置 provider 或者 consumer 的 filter,也就避免了对代码的侵入性。
这里的业务逻辑执行前后的扩展也是通过责任链的模式来执行一个个的的 slot, 我们先定义好时间戳校验,签名校验,Session转id等 slot, 然后在 xml 中指定这些 slot 的执行顺序
每个业务都有一个 gateway.xml 文件,可以在此文件中配置 H5, app, 小程序需要执行的 slot。
以对 app 请求配置需要执行的前置 slot 和后置处理 slot 为例 ,伪代码如下
这样只要在启动函数中引入(ImportResource)需要支持的 gateway 的 xml 文件,配置的 bean 就能生效,然后在 filter 中会分别取 bizChannel(请求必传,代表是业务哪一端标识,如 biz_h5, biz_app, biz_小程序)对应的 slotBizList 即可执行业务逻辑前后的扩展。
通过这样的方式就有效地指定了业务逻辑执行前后需要执行的 slot,每个业务如果想在业务逻辑执行前后进行扩展,只要定义好自己的 slot 逻辑,在 xml 文件中指定此 slot 的位置即可生效。
嵌入式网关按以上思路实现后,就通过 jar 包分发到各个业务系统。好处是:稳定性提升,每个业务集成一个稳定版本的网关 Jar,某一个业务系统做网关 Jar 升级时,其他业务系统都不受干扰