手把手教你从零构建高性能Spring WebFlux网关

手把手教你从零构建高性能Spring WebFlux网关

一、前言

最近在github上看了soul网关的设计,突然就来了兴趣准备自己从零开始写一个高性能的网关。折腾了大概三周时间,我的网关ship-gate核心功能基本都已完成,写这篇文章是记录我是如何从零开始手写一个网关的。

二、设计

2.1技术选型

网关是所有请求的入口,所以要求有很高的吞吐量,为了实现这点可以使用请求异步化来解决。目前一般有以下两种方案:

Tomcat/Jetty+NIO+Servlet3

Servlet3已经支持异步,这种方案使用比较多,京东,有赞和Zuul,都用的是这种方案。

Netty+NIO

Netty为高并发而生,目前唯品会的网关使用这个策略,在唯品会的技术文章中在相同的情况下Netty是每秒30w+的吞吐量,Tomcat是13w+,可以看出是有一定的差距的,但是Netty需要自己处理HTTP协议,这一块比较麻烦。

后面发现Soul网关是基于Spring WebFlux(底层Netty)的,不用太关心HTTP协议的处理,于是决定也用Spring WebFlux。

网关的第二个特点是具备可扩展性,比如Netflix Zuul有preFilters,postFilters等在不同的阶段方便处理不同的业务,基于责任链模式将请求进行链式处理即可实现。

在微服务架构下,服务都会进行多实例部署来保证高可用,请求到达网关时,网关需要根据URL找到所有可用的实例,这时就需要服务注册和发现功能,即注册中心。

现在流行的注册中心有Apache的Zookeeper和阿里的Nacos两种(consul有点小众),因为之前写RPC框架时已经用过了Zookeeper,所以这次就选择了Nacos。

前端部分考虑部署方便性,没有采用流行的前后端分离的方式(其实是不会vue😂),而是使用layui+thymeleaf的方式。

2.2需求清单

首先要明确目标,即开发一个具备哪些特性的网关,总结下后如下:

自定义路由规则

可基于version的路由规则设置,路由对象包括DEFAUL,HEADER和QUERY三种,匹配方式包括=、regex、like三种。

跨语言

HTTP协议天生跨语言

高性能

Netty本身就是一款高性能的通信框架,同时server将一些路由规则等数据缓存到JVM内存避免请求admin服务。

高可用

支持集群模式防止单节点故障,无状态。

灰度发布

灰度发布(又名金丝雀发布)是指在黑与白之间,能够平滑过渡的一种发布方式。在其上可以进行A/B testing,即让一部分用户继续用产品特性A,一部分用户开始用产品特性B,如果用户对B没有什么反对意见,那么逐步扩大范围,把所有用户都迁移到B上面来。通过特性一可以实现。

接口鉴权

基于责任链模式,用户开发自己的鉴权插件即可。

负载均衡

支持多种负载均衡算法,如随机,轮询,加权轮询等。利用SPI机制可以根据配置进行动态加载。

2.3架构设计

在参考了一些优秀的网关Zuul,Spring Cloud Gateway,Soul后,将项目划分为以下几个模块。

名称

描述

ship-admin

后台管理界面,配置路由规则等

ship-server

网关服务端,核心功能模块

ship-client-spring-boot-starter

网关客户端,自动注册服务信息到注册中心

ship-common

一些公共的代码,如pojo,常量等。

它们之间的关系如图:

注意:这张图与实际实现有点出入,Nacos push到本地缓存的那个环节没有实现,目前只有ship-server定时轮询pull的过程。同时,ServiceA在项目启动和下线时主动发送HTTP请求通知ship-admin。用户如果在admin后台修改了实例权重,服务状态(启用/禁用)等,ship-admin定时同步更新数据到注册中心。

2.4表结构设计

三、编码

3.1 ship-client-spring-boot-starter

首先创建一个spring-boot-starter命名为ship-client-spring-boot-starter,不知道如何自定义starter的可以看我以前写的《开发自己的starter》。

其核心类 AutoRegisterListener 就是在项目启动时做了两件事:

1.将服务信息注册到Nacos注册中心

2.通知ship-admin服务上线了并注册下线hook。

代码如下:

/**

* Created by 2YSP on 2020/12/21

*/

public class AutoRegisterListener implements ApplicationListener {

private final static Logger LOGGER = LoggerFactory.getLogger(AutoRegisterListener.class);

private volatile AtomicBoolean registered = new AtomicBoolean(false);

private final ClientConfigProperties properties;

@NacosInjected

private NamingService namingService;

@Autowired

private RequestMappingHandlerMapping handlerMapping;

private final ExecutorService pool;

/**

* url list to ignore

*/

private static List ignoreUrlList = new LinkedList<>();

static {

ignoreUrlList.add("/error");

}

public AutoRegisterListener(ClientConfigProperties properties) {

if (!check(properties)) {

LOGGER.error("client config port,contextPath,appName adminUrl and version can't be empty!");

throw new ShipException("client config port,contextPath,appName adminUrl and version can't be empty!");

}

this.properties = properties;

pool = new ThreadPoolExecutor(1, 4, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());

}

/**

* check the ClientConfigProperties

*

* @param properties

* @return

*/

private boolean check(ClientConfigProperties properties) {

if (properties.getPort() == null || properties.getContextPath() == null

|| properties.getVersion() == null || properties.getAppName() == null

|| properties.getAdminUrl() == null) {

return false;

}

return true;

}

@Override

public void onApplicationEvent(ContextRefreshedEvent event) {

if (!registered.compareAndSet(false, true)) {

return;

}

doRegister();

registerShutDownHook();

}

/**

* send unregister request to admin when jvm shutdown

*/

private void registerShutDownHook() {

final String url = "http://" + properties.getAdminUrl() + AdminConstants.UNREGISTER_PATH;

final UnregisterAppDTO unregisterAppDTO = new UnregisterAppDTO();

unregisterAppDTO.setAppName(properties.getAppName());

unregisterAppDTO.setVersion(properties.getVersion());

unregisterAppDTO.setIp(IpUtil.getLocalIpAddress());

unregisterAppDTO.setPort(properties.getPort());

Runtime.getRuntime().addShutdownHook(new Thread(() -> {

OkhttpTool.doPost(url, unregisterAppDTO);

LOGGER.info("[{}:{}] unregister from ship-admin success!", unregisterAppDTO.getAppName(), unregisterAppDTO.getVersion());

}));

}

/**

* register all interface info to register center

*/

private void doRegister() {

Instance instance = new Instance();

instance.setIp(IpUtil.getLocalIpAddress());

instance.setPort(properties.getPort());

instance.setEphemeral(true);

Map metadataMap = new HashMap<>();

metadataMap.put("version", properties.getVersion());

metadataMap.put("appName", properties.getAppName());

instance.setMetadata(metadataMap);

try {

namingService.registerInstance(properties.getAppName(), NacosConstants.APP_GROUP_NAME, instance);

} catch (NacosException e) {

LOGGER.error("register to nacos fail", e);

throw new ShipException(e.getErrCode(), e.getErrMsg());

}

LOGGER.info("register interface info to nacos success!");

// send register request to ship-admin

String url = "http://" + properties.getAdminUrl() + AdminConstants.REGISTER_PATH;

RegisterAppDTO registerAppDTO =

相关推荐

100平方中央空调多少钱?中央空调安装费用是多少?
365永久激活怎么做到的

100平方中央空调多少钱?中央空调安装费用是多少?

📅 06-30 👁️ 1074
薇娅、李佳琦的双11直播间,真的是“全网最低价”吗?
安装衣柜教程、安装衣柜教程图解
365天电影观看免费

安装衣柜教程、安装衣柜教程图解

📅 07-03 👁️ 7676
78厘米腰围是多少尺(78cm腰围是多少尺码)
365天电影观看免费

78厘米腰围是多少尺(78cm腰围是多少尺码)

📅 07-02 👁️ 7756
台式电脑备用电源
365bet在线体育

台式电脑备用电源

📅 07-09 👁️ 9612
17载生涯终谢幕!35岁夺冠功臣马图伊迪宣布退役
365bet在线体育

17载生涯终谢幕!35岁夺冠功臣马图伊迪宣布退役

📅 07-01 👁️ 1475