一、前言
最近在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
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.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 =