基于spring webflux的高性能http api网关
api网关作为反向代理,对外部系统提供统一的服务访问入口,对请求进行鉴权限流等访问控制处理,通过后将请求路由转发给后端服务。在高并发和潜在的高延迟场景下,api网关要实现高性能高吞吐量的一个基本要求是全链路异步,不要阻塞线程。spring cloud zuul网关采用同步阻塞模式不符合要求。
先前基于servlet,采用异步servlet filter + 异步servlet+servlet异步io+异步httpclient做过全链路异步http/rest api网关,要强调的是使用异步servlet后只是流程上异步,并不意味着io这块自动就是异步,还要使用servlet异步io才能真正达成网络io的高吞吐量。异步httpclient当时用的是基于netty的async-http-client库。
spring终于在版本5中提供反应式编程支持,比较完美地支持异步非阻塞编程,先前的spring系包括spring mvc大多是同步阻塞的编程模式,使用thread-per-request处理模型。即使在spring mvc controller方法上加@Async注解或返回DeferredResult、Callable类型的结果,其实仍只是把方法的同步调用封装成执行任务放到线程池的任务队列中,还是thread-per-request模型。靠线程池实现的伪异步,为何说它是伪异步,因为在出现高并发请求时,需要增大线程池的线程数(可能要几十个或几百个线程),由于仍是同步阻塞任务,加上线程调度开销,这种伪异步实际上不可能实现理想的高并发,这个问题根源是Java先前大多提供的是同步阻塞库或规范,也没有golang类似的官方支持的协程,例如还没有异步的JDBC api,不得已只能这样。不过这些都在改善提高中,例如openJDK中的Loom项目就计划实现协程功能,而阿里巴巴的Wisp协程技术已经在阿里生产环境中使用,这两个(Loom和Wisp)未来可能会合并成一个项目。异步JDBC在两年之内应该可以发布。真正的异步非阻塞任务处理,不需要过多的线程就能实现理想的高并发,线程数过多反而有副作用,通常线程数不大于cpu核数的两倍,一般就是cpu核数。
在node.js非阻塞异步编程和vertx的冲击下,spring5对反应式编程的支持可以说和先前走在前列的vertx并驾齐驱了,让spring粉丝们松了口气。spring5基于reactor3提供webflux模块来支持web反应式编程,反应式提倡异步非阻塞,背压这里就不说了。异步servlet是在原来的servlet同步模型上打补丁来实现的,和spring5全新设计的webflux不可同日而语。
基于webflux的网关主要组件是webfilter过滤器(webflux中的webfilter和servlet filter是两码事,虽然概念类似,但其底层缺省的运行时是基于netty,不是tomcat,虽然也可适配tomcat或jetty这些传统容器。webflux不用servlet那一套编程模型,servlet中的HttpServletRequest、HttpServletResponse这些类不能在webflux中使用),多个过滤器分别实现认证授权、限流、请求路由转发、正常响应回写(把后端服务的响应回写给调用网关的前端系统)等。如果调用后端服务时要做协议转换,例如前端请求是http(s),后端服务是rpc,那就要做协议转换,这块可以参考spring mvc和spring webflux的控制器方法参数解析器和返回值处理器,特殊情况下可以提供配置界面来设置两个协议数据之间的属性映射关系。协议转换组件最好能做成插件的形式。
异步io包括webflux适用的场景是高并发高延迟的场景,在并发度小或后端服务延迟(后端服务耗时)小于10ms左右的情况下,采用异步io因为线程的调度开销,性能和吞吐量反而不如阻塞io。
参考架构
这里的前端并不是通常说的ui前端,而是指角色,这些系统需要接入网关来调用后端的服务,他们是服务消费者,在网关前面。
代码仓库
代码在github demo-spring-webflux-api-gateway仓库上,有两个分支:master和dev。
使用技术
反应式&异步非阻塞io:springboot2+spring reactor 3+spring5 webflux包括webclient、webfilter、controller。
这个是从生产项目中抽出基本的架子作为演示,跑起来是没问题的,使用maven构建。
服务路由
后端服务的url前缀(域名和http schema)是固定的,在application.properties文件中由backend.service.url.prefix属性来配置,例如backend.service.url.prefix=http://127.0.0.1:8080 。
假设前端应用(服务消费者)用如下url调用网关(网关端口为9988)
http://api.gateway.demo:9988/orders/1234
如果请求过滤通过,网关最后会将请求路由转发到http://127.0.0.1:8080/orders/1234,这个url提供订单服务。
在实际的生产环境中,一般是后端有多个服务,部署在多个不同的服务域名上,特别是采用微服务架构后,网关需要从服务注册中心获取后端服务的endpoint信息(如果后端服务是http,主要是url前缀部分,例如http schema(http、https)+服务域名或ip+端口),而不是这种演示情况下使用固定前缀的方式。如果以网关为中心,注册中心可以是网关的一个模块或子系统,也可是独立的系统。
在有多个后端服务域名的情况下,为了方便网关进行路由,可以在前端请求中携带路由提示信息。这个路由提示信息的粒度可大可小:可以是细粒度的服务名,它对应一个url模式+http方法(考虑restful风格),根据服务名可以映射到服务所在的endpoint;也可是粗粒度的服务名,这个粗粒度的服务名其实是一个应用名(app name)或逻辑域,这个应用上可有多个细粒度的服务,应用名再映射到真实的endpoint上。服务名可放在http请求头中或在url中靠前的位置,例如http://api.gateway.demo:9988/order-center/orders/1234 这个url中的order-center就是粗粒度的服务名(应用名),网关可根据这个服务名从注册中心找到对应的后端服务域名和http schema等url endpoint信息。
如果众多请求url模式+http方法不冲突,能确保网关根据请求信息(主要是请求url和http方法)找到对应的路由endpoint地址,也可以不加服务名。
相关类介绍
1)main class为com.demo.http.api.gateway.main.RestApiGateWayApplication。
在IDE中直接运行这个类即可跑起来,或mvn package打包,再java -jar运行jar包。
2)过滤器类位于com.demo.http.api.gateway.access.filter包中,已经有两个过滤器:RequestAuthFilter和RateLimitFilter,分别用于鉴权和限流。过滤器类上有Order注解,决定该过滤器在过滤器链中的调用先后顺序,order数值小的先调用。作为演示,鉴权只是看请求中有没有appKey的http头,限流是放过所有请求不进行限流。
3)控制器类:转发请求到后端服务是由com.demo.http.api.gateway.access.controller.ApiProxyController控制器来实现的,也可用过滤器来实现,只是生产项目当时写的顺手用控制器来做了,没改过来。对后端http服务的调用使用webflux模块提供的WebClient,它支持异步和同步两种调用方式。使用反应式编程,对http client不推荐使用spring先前的RestTemplate和AsyncRestTemplate。其他几个控制器用来生成异常情况下的响应数据发给前端,这些也可不需要,而是直接在过滤器中(过滤器验证未通过或后端服务网络异常)生成异常数据并发给前端,在代码中有直接的,也有转发到控制器的。
前端请求中除了要有appKey http头,大多还需要携带类似token头和请求数据签名,如何获取token和对token进行验证这里不介绍。
4)服务类:com.demo.http.api.gateway.service.AppInfoProvider 根据appKey来获取调用方的应用信息(应用密钥),演示情况下没有调用AppInfoProvider的方法,只是把AppInfoProvider实例注入到RequestAuthFilter类中。
5)过滤器父类
dev分支中的过滤器均继承父类com.demo.http.api.gateway.access.filter.base.AbstractGatewayWebFilter,这个类采用模板方法设计模式。
子类只要实现两个模板方法doFilter和doDenyResponse。doFilter实现过滤器的业务逻辑,有两种过滤结果:布尔值类型TRUE和FALSE(代码中其实是布尔类型的反应式版本Mono<Boolean>)。TRUE表示过滤通过(pass),请求控制流会转到过滤器链中的下个filter或controller; 如果是FALSE,未通过(deny),会调用doDenyResponse方法,在这个方法中一般是生成deny响应数据发给前端系统,结束该请求的处理。这两个方法在dev分支的过滤器中有实现,特别是RequestAuthFilter。
该类中还有个skipProcess方法,filter可调用这个方法指示后面的filter跳过对该请求的处理,也就是不执行doFilter。
6)服务层和dao层
com.demo.http.api.gateway.service.AppInfoProvider提供方法用来根据appKey(应用名)获取该应用的密钥等app信息,app信息使用了spring caffeine缓存。com.demo.http.api.gateway.dao.mapper.AppInfoMapper是app信息的dao类,使用myibatis。
7)工具类
com.demo.http.api.gateway.util.WebfluxForwardingUtil,提供forward方法来让filter在需要的时候forward请求到网关内部的其他目标url。controller中如何forward在先前博文中有介绍。
配置文件
application.properties内容如下:
#spring.profiles.active=dev
#网关服务端口
server.port=9988
#网关url前缀,目前没有使用
gateway.url.prefix=/api
#调用后端服务的超时时间,单位毫秒。生产项目应该是根据具体的服务取对应的超时配置
backend.service.timeout.inmillis=10000
#后端服务endpoint,生产项目通常应该从注册中心获取服务和它对应的endpoint关系
backend.service.url.prefix=http://127.0.0.1:8080
#数据库配置,作为演示项目,没有调用数据库,不需要配置下面的属性
datasource.mysql.jdbcUrl=jdbc:mysql://127.0.0.1:3308/gateway?useUnicode=true&characterEncoding=utf-8
datasource.mysql.user=
datasource.mysql.password=
datasource.mysql.driverClass=com.mysql.jdbc.Driver
datasource.mysql.driverClass.type=com.mchange.v2.c3p0.ComboPooledDataSource
datasource.mysql.testWhileIdle = true
datasource.mysql.preferredTestQuery=SELECT 1
datasource.mysql.testConnectionOnCheckin = true
datasource.mysql.idleConnectionTestPeriod = 400
datasource.mysql.maxIdleTime = 500
后记
编写高性能api网关也可采用协程,当有阻塞时,阻塞在协程上而不是阻塞在线程上。和线程相比,协程是很轻量级的资源,在一个线程中可有几千个协程,协程的调度在用户级,调度开销也比线程小得多,所以采用协程也能实现网络io或io密集型系统的高吞吐量。反应式的组合链式调用风格可极大地减轻回调地狱问题(callback hell),而协程提供同步风格的代码编写体验,同步风格符合开发人员的心智模型和习惯,提高了编码效率,同时同步风格完全消除了回调地狱,显著改善代码的可维护性。
webflux中的reactor netty反应式运行时包括webclient目前还不支持http2,今年下半年应该就可以了。http2在一个http连接上能真正并发多个请求,这样在高并发情况下连接池中只要不多的连接资源,也避免了高并发时等待空闲连接的窘境或新建连接的时间。
上一篇: 数学思想方法揭秘-2(原创)
下一篇: 数学思想方法揭秘-3(原创)