阿里终面:如何设计一个高性能网关?

开发 后端
最近在github上看了soul网关的设计,突然就来了兴趣准备自己从零开始写一个高性能的网关。

 一、前言

最近在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。

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-sever定时轮询pull的过程。ship-admin从Nacos获取注册服务信息的过程,也改成了ServiceA启动时主动发生HTTP请求通知ship-admin。

2.4 表结构设计

三、编码

3.1 ship-client-spring-boot-starter

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

更多 Spring Boot 教程推荐看这个:

https://github.com/javastacks/spring-boot-best-practice

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

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

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

代码如下: 

  1. /**  
  2.  * Created by 2YSP on 2020/12/21  
  3.  */  
  4. public class AutoRegisterListener implements ApplicationListener<ContextRefreshedEvent> {  
  5.     private final static Logger LOGGER = LoggerFactory.getLogger(AutoRegisterListener.class);  
  6.     private volatile AtomicBoolean registered = new AtomicBoolean(false);  
  7.     private final ClientConfigProperties properties;  
  8.     @NacosInjected  
  9.     private NamingService namingService;  
  10.     @Autowired  
  11.     private RequestMappingHandlerMapping handlerMapping;  
  12.     private final ExecutorService pool;  
  13.     /**  
  14.      * url list to ignore  
  15.      */  
  16.     private static List<String> ignoreUrlList = new LinkedList<>(); 
  17.     static {  
  18.         ignoreUrlList.add("/error");  
  19.     }   
  20.     public AutoRegisterListener(ClientConfigProperties properties) {  
  21.         if (!check(properties)) {  
  22.             LOGGER.error("client config port,contextPath,appName adminUrl and version can't be empty!");  
  23.             throw new ShipException("client config port,contextPath,appName adminUrl and version can't be empty!");  
  24.         }  
  25.         this.properties = properties;  
  26.         pool = new ThreadPoolExecutor(1, 4, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());  
  27.     }  
  28.     /**  
  29.      * check the ClientConfigProperties  
  30.      *  
  31.      * @param properties  
  32.      * @return  
  33.      */  
  34.     private boolean check(ClientConfigProperties properties) {  
  35.         if (properties.getPort() == null || properties.getContextPath() == null  
  36.                 || properties.getVersion() == null || properties.getAppName() == null  
  37.                 || properties.getAdminUrl() == null) {  
  38.             return false; 
  39.         }  
  40.         return true;  
  41.     }   
  42.     @Override  
  43.     public void onApplicationEvent(ContextRefreshedEvent event) {  
  44.         if (!registered.compareAndSet(false, true)) {  
  45.             return; 
  46.         }  
  47.         doRegister();  
  48.         registerShutDownHook();  
  49.     }   
  50.     /**  
  51.      * send unregister request to admin when jvm shutdown  
  52.      */  
  53.     private void registerShutDownHook() {  
  54.         final String url = "http://" + properties.getAdminUrl() + AdminConstants.UNREGISTER_PATH;  
  55.         final UnregisterAppDTO unregisterAppDTO = new UnregisterAppDTO();  
  56.         unregisterAppDTO.setAppName(properties.getAppName());  
  57.         unregisterAppDTO.setVersion(properties.getVersion());  
  58.         unregisterAppDTO.setIp(IpUtil.getLocalIpAddress());  
  59.         unregisterAppDTO.setPort(properties.getPort());  
  60.         Runtime.getRuntime().addShutdownHook(new Thread(() -> {  
  61.             OkhttpTool.doPost(url, unregisterAppDTO);  
  62.             LOGGER.info("[{}:{}] unregister from ship-admin success!", unregisterAppDTO.getAppName(), unregisterAppDTO.getVersion());  
  63.         }));  
  64.     }  
  65.     /**  
  66.      * register all interface info to register center  
  67.      */  
  68.     private void doRegister() {  
  69.         Instance instance = new Instance();  
  70.         instance.setIp(IpUtil.getLocalIpAddress());  
  71.         instance.setPort(properties.getPort());  
  72.         instance.setEphemeral(true);  
  73.         Map<String, String> metadataMap = new HashMap<>();  
  74.         metadataMap.put("version", properties.getVersion());  
  75.         metadataMap.put("appName", properties.getAppName());  
  76.         instance.setMetadata(metadataMap);  
  77.         try {  
  78.             namingService.registerInstance(properties.getAppName(), NacosConstants.APP_GROUP_NAME, instance);  
  79.         } catch (NacosException e) {  
  80.             LOGGER.error("register to nacos fail", e);  
  81.             throw new ShipException(e.getErrCode(), e.getErrMsg());  
  82.         }  
  83.         LOGGER.info("register interface info to nacos success!");  
  84.         // send register request to ship-admin  
  85.         String url = "http://" + properties.getAdminUrl() + AdminConstants.REGISTER_PATH;  
  86.         RegisterAppDTO registerAppDTO = buildRegisterAppDTO(instance);  
  87.         OkhttpTool.doPost(url, registerAppDTO);  
  88.         LOGGER.info("register to ship-admin success!");  
  89.     }  
  90.     private RegisterAppDTO buildRegisterAppDTO(Instance instance) {  
  91.         RegisterAppDTO registerAppDTO = new RegisterAppDTO();  
  92.         registerAppDTO.setAppName(properties.getAppName());  
  93.         registerAppDTO.setContextPath(properties.getContextPath());  
  94.         registerAppDTO.setIp(instance.getIp());  
  95.         registerAppDTO.setPort(instance.getPort());  
  96.         registerAppDTO.setVersion(properties.getVersion());  
  97.         return registerAppDTO;  
  98.     }  

3.2 ship-server

ship-sever项目主要包括了两个部分内容:

1.请求动态路由的主流程

2.本地缓存数据和ship-admin及nacos同步,这部分在后面3.3再讲。

ship-server实现动态路由的原理是利用WebFilter拦截请求,然后将请求教给plugin chain去链式处理。

PluginFilter根据URL解析出appName,然后将启用的plugin组装成plugin chain。

最新 Java 核心技术教程,都在这了! 

  1. public class PluginFilter implements WebFilter {   
  2.     private ServerConfigProperties properties;   
  3.     public PluginFilter(ServerConfigProperties properties) {  
  4.         this.properties = properties;  
  5.     }   
  6.     @Override  
  7.     public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {  
  8.         String appName = parseAppName(exchange);  
  9.         if (CollectionUtils.isEmpty(ServiceCache.getAllInstances(appName))) {  
  10.             throw new ShipException(ShipExceptionEnum.SERVICE_NOT_FIND);  
  11.         } 
  12.         PluginChain pluginChain = new PluginChain(properties, appName);  
  13.         pluginChain.addPlugin(new DynamicRoutePlugin(properties));  
  14.         pluginChain.addPlugin(new AuthPlugin(properties));  
  15.         return pluginChain.execute(exchange, pluginChain);  
  16.     }  
  17.     private String parseAppName(ServerWebExchange exchange) {  
  18.         RequestPath path = exchange.getRequest().getPath(); 
  19.         String appName = path.value().split("/")[1];  
  20.         return appName;  
  21.     }  

PluginChain继承了AbstractShipPlugin并持有所有要执行的插件。 

  1. /**  
  2.  * @Author: Ship  
  3.  * @Description:  
  4.  * @Date: Created in 2020/12/25  
  5.  */  
  6. public class PluginChain extends AbstractShipPlugin {  
  7.     /**  
  8.      * the pos point to current plugin  
  9.      */  
  10.     private int pos;  
  11.     /**  
  12.      * the plugins of chain  
  13.      */  
  14.     private List<ShipPlugin> plugins;  
  15.     private final String appName;  
  16.     public PluginChain(ServerConfigProperties properties, String appName) {  
  17.         super(properties);  
  18.         this.appName = appName;  
  19.     }  
  20.     /**  
  21.      * add enabled plugin to chain  
  22.      *  
  23.      * @param shipPlugin  
  24.      */  
  25.     public void addPlugin(ShipPlugin shipPlugin) {  
  26.         if (plugins == null) {  
  27.             plugins = new ArrayList<>();  
  28.         }  
  29.         if (!PluginCache.isEnabled(appName, shipPlugin.name())) {  
  30.             return;  
  31.         }  
  32.         plugins.add(shipPlugin);  
  33.         // order by the plugin's order 
  34.         plugins.sort(Comparator.comparing(ShipPlugin::order));  
  35.     } 
  36.     @Override  
  37.     public Integer order() { 
  38.         return null;  
  39.     }  
  40.     @Override  
  41.     public String name() {  
  42.         return null;  
  43.     }  
  44.     @Override  
  45.     public Mono<Void> execute(ServerWebExchange exchange, PluginChain pluginChain) {  
  46.         if (pos == plugins.size()) {  
  47.             return exchange.getResponse().setComplete();  
  48.         }  
  49.         return pluginChain.plugins.get(pos++).execute(exchange, pluginChain);  
  50.     }  
  51.     public String getAppName() {  
  52.         return appName;  
  53.     }  

AbstractShipPlugin实现了ShipPlugin接口,并持有ServerConfigProperties配置对象。 

  1. public abstract class AbstractShipPlugin implements ShipPlugin {   
  2.     protected ServerConfigProperties properties;   
  3.     public AbstractShipPlugin(ServerConfigProperties properties) {  
  4.         this.properties = properties;  
  5.     }  

ShipPlugin接口定义了所有插件必须实现的三个方法order(),name()和execute()。 

  1. public interface ShipPlugin {  
  2.     /**  
  3.      * lower values have higher priority  
  4.      *  
  5.      * @return 
  6.      */  
  7.     Integer order();  
  8.     /**  
  9.      * return current plugin name  
  10.      *  
  11.      * @return  
  12.      */ 
  13.     String name();  
  14.     Mono<Void> execute(ServerWebExchange exchange,PluginChain pluginChain);  

DynamicRoutePlugin继承了抽象类AbstractShipPlugin,包含了动态路由的主要业务逻辑。 

  1. /**  
  2.  * @Author: Ship  
  3.  * @Description:  
  4.  * @Date: Created in 2020/12/25  
  5.  */  
  6. public class DynamicRoutePlugin extends AbstractShipPlugin {  
  7.     private final static Logger LOGGER = LoggerFactory.getLogger(DynamicRoutePlugin.class);  
  8.     private static WebClient webClient;  
  9.     private static final Gson gson = new GsonBuilder().create();  
  10.     static {  
  11.         HttpClient httpClient = HttpClient.create()  
  12.                 .tcpConfiguration(client ->  
  13.                         client.doOnConnected(conn ->  
  14.                                 conn.addHandlerLast(new ReadTimeoutHandler(3))  
  15.                                         .addHandlerLast(new WriteTimeoutHandler(3)))  
  16.                                 .option(ChannelOption.TCP_NODELAY, true)  
  17.                 );  
  18.         webClient = WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient))  
  19.                 .build();  
  20.     }  
  21.     public DynamicRoutePlugin(ServerConfigProperties properties) {  
  22.         super(properties);  
  23.     }  
  24.     @Override  
  25.     public Integer order() {  
  26.         return ShipPluginEnum.DYNAMIC_ROUTE.getOrder();  
  27.     }  
  28.     @Override  
  29.     public String name() {  
  30.         return ShipPluginEnum.DYNAMIC_ROUTE.getName();  
  31.     }  
  32.     @Override  
  33.     public Mono<Void> execute(ServerWebExchange exchange, PluginChain pluginChain) {  
  34.         String appName = pluginChain.getAppName();  
  35.         ServiceInstance serviceInstance = chooseInstance(appName, exchange.getRequest());  
  36. //        LOGGER.info("selected instance is [{}]", gson.toJson(serviceInstance));  
  37.         // request service  
  38.         String url = buildUrl(exchange, serviceInstance);  
  39.         return forward(exchange, url);  
  40.     }  
  41.     /**  
  42.      * forward request to backend service  
  43.      *  
  44.      * @param exchange  
  45.      * @param url  
  46.      * @return  
  47.      */  
  48.     private Mono<Void> forward(ServerWebExchange exchange, String url) {  
  49.         ServerHttpRequest request = exchange.getRequest();  
  50.         ServerHttpResponse response = exchange.getResponse();  
  51.         HttpMethod method = request.getMethod();  
  52.         WebClient.RequestBodySpec requestBodySpec = webClient.method(method).uri(url).headers((headers) -> {  
  53.             headers.addAll(request.getHeaders());  
  54.         });  
  55.         WebClient.RequestHeadersSpec<?> reqHeadersSpec;  
  56.         if (requireHttpBody(method)) {  
  57.             reqHeadersSpec = requestBodySpec.body(BodyInserters.fromDataBuffers(request.getBody()));  
  58.         } else {  
  59.             reqHeadersSpec = requestBodySpec 
  60.         }  
  61.         // nio->callback->nio  
  62.         return reqHeadersSpec.exchange().timeout(Duration.ofMillis(properties.getTimeOutMillis()))  
  63.                 .onErrorResume(ex -> { 
  64.                     return Mono.defer(() -> {  
  65.                         String errorResultJson = "" 
  66.                         if (ex instanceof TimeoutException) {  
  67.                             errorResultJson = "{\"code\":5001,\"message\":\"network timeout\"}";  
  68.                         } else {  
  69.                             errorResultJson = "{\"code\":5000,\"message\":\"system error\"}";  
  70.                         } 
  71.                         return ShipResponseUtil.doResponse(exchange, errorResultJson);  
  72.                     }).then(Mono.empty());  
  73.                 }).flatMap(backendResponse -> {  
  74.                     response.setStatusCode(backendResponse.statusCode());  
  75.                     response.getHeaders().putAll(backendResponse.headers().asHttpHeaders());  
  76.                     return response.writeWith(backendResponse.bodyToFlux(DataBuffer.class));  
  77.                 });  
  78.     }  
  79.     /**  
  80.      * weather the http method need http body  
  81.      *  
  82.      * @param method  
  83.      * @return  
  84.      */  
  85.     private boolean requireHttpBody(HttpMethod method) {  
  86.         if (method.equals(HttpMethod.POST) || method.equals(HttpMethod.PUT) || method.equals(HttpMethod.PATCH)) {  
  87.             return true;  
  88.         }  
  89.         return false;  
  90.     }  
  91.     private String buildUrl(ServerWebExchange exchange, ServiceInstance serviceInstance) {  
  92.         ServerHttpRequest request = exchange.getRequest();  
  93.         String query = request.getURI().getQuery();  
  94.         String path = request.getPath().value().replaceFirst("/" + serviceInstance.getAppName(), "");  
  95.         String url = "http://" + serviceInstance.getIp() + ":" + serviceInstance.getPort() + path;  
  96.         if (!StringUtils.isEmpty(query)) {  
  97.             urlurl = url + "?" + query;  
  98.         }  
  99.         return url;  
  100.     } 
  101.     /**  
  102.      * choose an ServiceInstance according to route rule config and load balancing algorithm  
  103.      *  
  104.      * @param appName  
  105.      * @param request  
  106.      * @return  
  107.      */  
  108.     private ServiceInstance chooseInstance(String appName, ServerHttpRequest request) {  
  109.         List<ServiceInstance> serviceInstances = ServiceCache.getAllInstances(appName);  
  110.         if (CollectionUtils.isEmpty(serviceInstances)) {  
  111.             LOGGER.error("service instance of {} not find", appName);  
  112.             throw new ShipException(ShipExceptionEnum.SERVICE_NOT_FIND);  
  113.         }  
  114.         String version = matchAppVersion(appName, request);  
  115.         if (StringUtils.isEmpty(version)) {  
  116.             throw new ShipException("match app version error");  
  117.         }  
  118.         // filter serviceInstances by version  
  119.         List<ServiceInstance> instances = serviceInstances.stream().filter(i -> i.getVersion().equals(version)).collect(Collectors.toList());  
  120.         //Select an instance based on the load balancing algorithm  
  121.         LoadBalance loadBalance = LoadBalanceFactory.getInstance(properties.getLoadBalance(), appName, version);  
  122.         ServiceInstance serviceInstance = loadBalance.chooseOne(instances);  
  123.         return serviceInstance;  
  124.     }  
  125.     private String matchAppVersion(String appName, ServerHttpRequest request) {  
  126.         List<AppRuleDTO> rules = RouteRuleCache.getRules(appName);  
  127.         rules.sort(Comparator.comparing(AppRuleDTO::getPriority).reversed());  
  128.         for (AppRuleDTO rule : rules) {  
  129.             if (match(rule, request)) {  
  130.                 return rule.getVersion();  
  131.             }  
  132.         }  
  133.         return null;  
  134.     }  
  135.     private boolean match(AppRuleDTO rule, ServerHttpRequest request) {  
  136.         String matchObject = rule.getMatchObject();  
  137.         String matchKey = rule.getMatchKey();  
  138.         String matchRule = rule.getMatchRule();  
  139.         Byte matchMethod = rule.getMatchMethod(); 
  140.         if (MatchObjectEnum.DEFAULT.getCode().equals(matchObject)) {  
  141.             return true;  
  142.         } else if (MatchObjectEnum.QUERY.getCode().equals(matchObject)) {  
  143.             String param = request.getQueryParams().getFirst(matchKey);  
  144.             if (!StringUtils.isEmpty(param)) {  
  145.                 return StringTools.match(param, matchMethod, matchRule);  
  146.             }  
  147.         } else if (MatchObjectEnum.HEADER.getCode().equals(matchObject)) {  
  148.             HttpHeaders headers = request.getHeaders();  
  149.             String headerValue = headers.getFirst(matchKey);  
  150.             if (!StringUtils.isEmpty(headerValue)) {  
  151.                 return StringTools.match(headerValue, matchMethod, matchRule);  
  152.             }  
  153.         }  
  154.         return false;  
  155.     }  

3.3 数据同步

app数据同步

后台服务(如订单服务)启动时,只将服务名,版本,ip地址和端口号注册到了Nacos,并没有实例的权重和启用的插件信息怎么办?

一般在线的实例权重和插件列表都是在管理界面配置,然后动态生效的,所以需要ship-admin定时更新实例的权重和插件信息到注册中心。

对应代码ship-admin的NacosSyncListener 

  1. /**  
  2.  * @Author: Ship  
  3.  * @Description:  
  4.  * @Date: Created in 2020/12/30  
  5.  */  
  6. @Configuration  
  7. public class NacosSyncListener implements ApplicationListener<ContextRefreshedEvent> {   
  8.     private static final Logger LOGGER = LoggerFactory.getLogger(NacosSyncListener.class);   
  9.     private static ScheduledThreadPoolExecutor scheduledPool = new ScheduledThreadPoolExecutor(1,  
  10.             new ShipThreadFactory("nacos-sync", true).create());   
  11.     @NacosInjected  
  12.     private NamingService namingService;   
  13.     @Value("${nacos.discovery.server-addr}")  
  14.     private String baseUrl;   
  15.     @Resource  
  16.     private AppService appService;   
  17.     @Override  
  18.     public void onApplicationEvent(ContextRefreshedEvent event) {  
  19.         if (event.getApplicationContext().getParent() != null) {  
  20.             return;  
  21.         }  
  22.         String url = "http://" + baseUrl + NacosConstants.INSTANCE_UPDATE_PATH;  
  23.         scheduledPool.scheduleWithFixedDelay(new NacosSyncTask(namingService, url, appService), 0, 30L, TimeUnit.SECONDS);  
  24.     }   
  25.     class NacosSyncTask implements Runnable {  
  26.          private NamingService namingService;   
  27.         private String url;   
  28.         private AppService appService;   
  29.         private Gson gson = new GsonBuilder().create();   
  30.         public NacosSyncTask(NamingService namingService, String url, AppService appService) {  
  31.             this.namingService = namingService;  
  32.             this.url = url;  
  33.             this.appService = appService;  
  34.         }  
  35.         /**  
  36.          * Regular update weight,enabled plugins to nacos instance  
  37.          */  
  38.         @Override  
  39.         public void run() {  
  40.             try {  
  41.                 // get all app names  
  42.                 ListView<String> services = namingService.getServicesOfServer(1, Integer.MAX_VALUE, NacosConstants.APP_GROUP_NAME);  
  43.                 if (CollectionUtils.isEmpty(services.getData())) {  
  44.                     return;  
  45.                 }  
  46.                 List<String> appNames = services.getData();  
  47.                 List<AppInfoDTO> appInfos = appService.getAppInfos(appNames);  
  48.                 for (AppInfoDTO appInfo : appInfos) {  
  49.                     if (CollectionUtils.isEmpty(appInfo.getInstances())) {  
  50.                         continue;  
  51.                     }  
  52.                     for (ServiceInstance instance : appInfo.getInstances()) {  
  53.                         Map<String, Object> queryMap = buildQueryMap(appInfo, instance);  
  54.                         String resp = OkhttpTool.doPut(url, queryMap, "");  
  55.                         LOGGER.debug("response :{}", resp);  
  56.                     }  
  57.                 }  
  58.             } catch (Exception e) {  
  59.                 LOGGER.error("nacos sync task error", e);  
  60.             }  
  61.         }  
  62.         private Map<String, Object> buildQueryMap(AppInfoDTO appInfo, ServiceInstance instance) {  
  63.             Map<String, Object> map = new HashMap<>();  
  64.             map.put("serviceName", appInfo.getAppName());  
  65.             map.put("groupName", NacosConstants.APP_GROUP_NAME);  
  66.             map.put("ip", instance.getIp());  
  67.             map.put("port", instance.getPort());  
  68.             map.put("weight", instance.getWeight().doubleValue());  
  69.             NacosMetadata metadata = new NacosMetadata();  
  70.             metadata.setAppName(appInfo.getAppName());  
  71.             metadata.setVersion(instance.getVersion());  
  72.             metadata.setPlugins(String.join(",", appInfo.getEnabledPlugins()));  
  73.             map.put("metadata", StringTools.urlEncode(gson.toJson(metadata)));  
  74.             map.put("ephemeral", true);  
  75.             return map;  
  76.         }  
  77.     }  

ship-server再定时从Nacos拉取app数据更新到本地Map缓存。 

  1. /**  
  2.  * @Author: Ship 
  3.  * @Description: sync data to local cache  
  4.  * @Date: Created in 2020/12/25  
  5.  */  
  6. @Configuration  
  7. public class DataSyncTaskListener implements ApplicationListener<ContextRefreshedEvent> {  
  8.     private static ScheduledThreadPoolExecutor scheduledPool = new ScheduledThreadPoolExecutor(1,  
  9.             new ShipThreadFactory("service-sync", true).create());  
  10.     @NacosInjected  
  11.     private NamingService namingService;  
  12.     @Autowired  
  13.     private ServerConfigProperties properties;  
  14.     @Override  
  15.     public void onApplicationEvent(ContextRefreshedEvent event) {  
  16.         if (event.getApplicationContext().getParent() != null) {  
  17.             return;  
  18.         }  
  19.         scheduledPool.scheduleWithFixedDelay(new DataSyncTask(namingService)  
  20.                 , 0L, properties.getCacheRefreshInterval(), TimeUnit.SECONDS);  
  21.         WebsocketSyncCacheServer websocketSyncCacheServer = new WebsocketSyncCacheServer(properties.getWebSocketPort());  
  22.         websocketSyncCacheServer.start();  
  23.     }  
  24.     class DataSyncTask implements Runnable {  
  25.         private NamingService namingService;  
  26.         public DataSyncTask(NamingService namingService) {  
  27.             this.namingService = namingService;  
  28.         }  
  29.         @Override  
  30.         public void run() {  
  31.             try {  
  32.                 // get all app names  
  33.                 ListView<String> services = namingService.getServicesOfServer(1, Integer.MAX_VALUE, NacosConstants.APP_GROUP_NAME);  
  34.                 if (CollectionUtils.isEmpty(services.getData())) {  
  35.                     return;  
  36.                 }  
  37.                 List<String> appNames = services.getData();  
  38.                 // get all instances  
  39.                 for (String appName : appNames) {  
  40.                     List<Instance> instanceList = namingService.getAllInstances(appName, NacosConstants.APP_GROUP_NAME);  
  41.                     if (CollectionUtils.isEmpty(instanceList)) {  
  42.                         continue;  
  43.                     }  
  44.                     ServiceCache.add(appName, buildServiceInstances(instanceList));  
  45.                     List<String> pluginNames = getEnabledPlugins(instanceList);  
  46.                     PluginCache.add(appName, pluginNames);  
  47.                 }  
  48.                 ServiceCache.removeExpired(appNames);  
  49.                 PluginCache.removeExpired(appNames);  
  50.             } catch (NacosException e) {  
  51.                 e.printStackTrace();  
  52.             }  
  53.         }  
  54.         private List<String> getEnabledPlugins(List<Instance> instanceList) {  
  55.             Instance instance = instanceList.get(0);  
  56.             Map<String, String> metadata = instance.getMetadata();  
  57.             // plugins: DynamicRoute,Auth  
  58.             String plugins = metadata.getOrDefault("plugins", ShipPluginEnum.DYNAMIC_ROUTE.getName());  
  59.             return Arrays.stream(plugins.split(",")).collect(Collectors.toList());  
  60.         }  
  61.         private List<ServiceInstance> buildServiceInstances(List<Instance> instanceList) {  
  62.             List<ServiceInstance> list = new LinkedList<>();  
  63.             instanceList.forEach(instance -> {  
  64.                 Map<String, String> metadata = instance.getMetadata();  
  65.                 ServiceInstance serviceInstance = new ServiceInstance();  
  66.                 serviceInstance.setAppName(metadata.get("appName"));  
  67.                 serviceInstance.setIp(instance.getIp());  
  68.                 serviceInstance.setPort(instance.getPort());  
  69.                 serviceInstance.setVersion(metadata.get("version")); 
  70.                 serviceInstance.setWeight((int) instance.getWeight());  
  71.                 list.add(serviceInstance);  
  72.             });  
  73.             return list;  
  74.         }  
  75.     }  

路由规则数据同步

同时,如果用户在管理后台更新了路由规则,ship-admin需要推送规则数据到ship-server,这里参考了soul网关的做法利用websocket在第一次建立连接后进行全量同步,此后路由规则发生变更就只作增量同步。

最新 Java 核心技术教程,都在这了!

服务端WebsocketSyncCacheServer: 

  1. /**  
  2.  * @Author: Ship  
  3.  * @Description:  
  4.  * @Date: Created in 2020/12/28 
  5.  */  
  6. public class WebsocketSyncCacheServer extends WebSocketServer {  
  7.     private final static Logger LOGGER = LoggerFactory.getLogger(WebsocketSyncCacheServer.class);  
  8.     private Gson gson = new GsonBuilder().create();  
  9.     private MessageHandler messageHandler;  
  10.     public WebsocketSyncCacheServer(Integer port) {  
  11.         super(new InetSocketAddress(port));  
  12.         this.messageHandler = new MessageHandler();  
  13.     }  
  14.     @Override  
  15.     public void onOpen(WebSocket webSocket, ClientHandshake clientHandshake) {  
  16.         LOGGER.info("server is open"); 
  17.     }  
  18.     @Override  
  19.     public void onClose(WebSocket webSocket, int i, String s, boolean b) {  
  20.         LOGGER.info("websocket server close...");  
  21.     }  
  22.     @Override  
  23.     public void onMessage(WebSocket webSocket, String message) {  
  24.         LOGGER.info("websocket server receive message:\n[{}]", message);  
  25.         this.messageHandler.handler(message);  
  26.     }  
  27.     @Override  
  28.     public void onError(WebSocket webSocket, Exception e) {  
  29.     }  
  30.     @Override  
  31.     public void onStart() {  
  32.         LOGGER.info("websocket server start...");  
  33.     }  
  34.     class MessageHandler { 
  35.         public void handler(String message) {  
  36.             RouteRuleOperationDTO operationDTO = gson.fromJson(message, RouteRuleOperationDTO.class);  
  37.             if (CollectionUtils.isEmpty(operationDTO.getRuleList())) {  
  38.                 return;  
  39.             }  
  40.             Map<String, List<AppRuleDTO>> map = operationDTO.getRuleList()  
  41.                     .stream().collect(Collectors.groupingBy(AppRuleDTO::getAppName));  
  42.             if (OperationTypeEnum.INSERT.getCode().equals(operationDTO.getOperationType())  
  43.                     || OperationTypeEnum.UPDATE.getCode().equals(operationDTO.getOperationType())) {  
  44.                 RouteRuleCache.add(map);  
  45.             } else if (OperationTypeEnum.DELETE.getCode().equals(operationDTO.getOperationType())) {  
  46.                 RouteRuleCache.remove(map); 
  47.             }  
  48.         }  
  49.     }  

客户端WebsocketSyncCacheClient: 

  1. /**  
  2.  * @Author: Ship  
  3.  * @Description:  
  4.  * @Date: Created in 2020/12/28  
  5.  */  
  6. @Component  
  7. public class WebsocketSyncCacheClient {  
  8.     private final static Logger LOGGER = LoggerFactory.getLogger(WebsocketSyncCacheClient.class);  
  9.     private WebSocketClient client;  
  10.     private RuleService ruleService;  
  11.     private Gson gson = new GsonBuilder().create();  
  12.     public WebsocketSyncCacheClient(@Value("${ship.server-web-socket-url}") String serverWebSocketUrl,  
  13.                                     RuleService ruleService) {  
  14.         if (StringUtils.isEmpty(serverWebSocketUrl)) {  
  15.             throw new ShipException(ShipExceptionEnum.CONFIG_ERROR);  
  16.         }  
  17.         this.ruleService = ruleService;  
  18.         ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1,  
  19.                 new ShipThreadFactory("websocket-connect", true).create());  
  20.         try {  
  21.             client = new WebSocketClient(new URI(serverWebSocketUrl)) {  
  22.                 @Override  
  23.                 public void onOpen(ServerHandshake serverHandshake) {  
  24.                     LOGGER.info("client is open");  
  25.                     List<AppRuleDTO> list = ruleService.getEnabledRule();  
  26.                     String msg = gson.toJson(new RouteRuleOperationDTO(OperationTypeEnum.INSERT, list));  
  27.                     send(msg);  
  28.                 }  
  29.                 @Override  
  30.                 public void onMessage(String s) {  
  31.                 }  
  32.                 @Override  
  33.                 public void onClose(int i, String s, boolean b) {  
  34.                 }  
  35.                 @Override  
  36.                 public void onError(Exception e) {  
  37.                     LOGGER.error("websocket client error", e);  
  38.                 }  
  39.             };  
  40.             client.connectBlocking();  
  41.             //使用调度线程池进行断线重连,30秒进行一次  
  42.             executor.scheduleAtFixedRate(() -> {  
  43.                 if (client != null && client.isClosed()) {  
  44.                     try {  
  45.                         client.reconnectBlocking();  
  46.                     } catch (InterruptedException e) {  
  47.                         LOGGER.error("reconnect server fail", e);  
  48.                     }  
  49.                 }  
  50.             }, 10, 30, TimeUnit.SECONDS);  
  51.         } catch (Exception e) {  
  52.             LOGGER.error("websocket sync cache exception", e);  
  53.             throw new ShipException(e.getMessage());  
  54.         } 
  55.     }  
  56.     public <T> void send(T t) {  
  57.         while (!client.getReadyState().equals(ReadyState.OPEN)) {  
  58.             LOGGER.debug("connecting ...please wait");  
  59.         }  
  60.         client.send(gson.toJson(t));  
  61.     }  

四、测试

4.1 动态路由测试

1)本地启动nacos ,sh startup.sh -m standalone

2)启动ship-admin

3)本地启动两个ship-example实例。

实例1配置: 

  1. ship:  
  2.  http:  
  3.    app-name: order  
  4.    version: gray_1.0  
  5.    context-path: /order  
  6.    port: 8081  
  7.    admin-url: 127.0.0.1:9001  
  8. server:  
  9.  port: 8081  
  10. nacos:  
  11.  discovery:  
  12.    server-addr: 127.0.0.1:8848 

实例2配置: 

  1. ship:  
  2.   http:  
  3.     app-name: order  
  4.     version: prod_1.0  
  5.     context-path: /order  
  6.     port: 8082  
  7.     admin-url: 127.0.0.1:9001 
  8. server:  
  9.   port: 8082  
  10. nacos:  
  11.   discovery:  
  12.     server-addr: 127.0.0.1:8848 

4)在数据库添加路由规则配置,该规则表示当http header 中的name=ship时请求路由到gray_1.0版本的节点。

启动ship-server,看到以下日志时则可以进行测试了。 

  1. 2021-01-02 19:57:09.159  INFO 30413 --- [SocketWorker-29] cn.sp.sync.WebsocketSyncCacheServer      : websocket server receive message:  
  2.   [{"operationType":"INSERT","ruleList":[{"id":1,"appId":5,"appName":"order","version":"gray_1.0","matchObject":"HEADER","matchKey":"name","matchMethod":1,"matchRule":"ship","priority":50}]}] 

用Postman请求http://localhost:9000/order/user/add,POST方式,header设置name=ship,可以看到只有实例1有日志显示。 

  1. ==========add user,version:gray_1.0 

4.2 性能压测

压测环境:

MacBook Pro 13英寸

处理器 2.3 GHz 四核Intel Core i7

内存 16 GB 3733 MHz LPDDR4X

后端节点个数一个

压测工具:wrk

压测结果:20个线程,500个连接数,吞吐量大概每秒9400个请求。

五、总结

千里之行始于足下,开始以为写一个网关会很难,但当你实际开始行动时就会发现其实没那么难,所以迈出第一步很重要。过程中也遇到了很多问题,还在github上给soul和nacos这两个开源项目提了两个issue,后来发现是自己的问题,尴尬😅。

本文代码已全部上传到 github:https://github.com/2YSP/ship-gate,最后,希望此文对你有所帮助。 

 

责任编辑:庞桂玉 来源: Java技术栈
相关推荐

2023-07-26 13:29:43

高性能短链系统

2020-07-16 08:06:53

网关高性能

2019-10-31 13:58:32

阿里电商系统

2018-11-01 13:23:02

网关APIHTTP

2019-06-27 09:50:49

高性能秒杀系统

2022-01-24 08:19:19

业务CRUD场景

2018-11-26 08:06:24

API网关亿级

2021-03-24 09:23:45

代码阿里应用分层

2020-08-17 08:18:51

Java

2020-12-04 06:35:20

TCPUDP阿里

2021-02-04 10:22:32

前端开发技术

2019-11-26 09:42:36

代码开发API

2019-11-27 15:19:44

系统缓存架构

2021-07-19 09:27:42

SSD内存Linux

2021-06-25 10:45:43

Netty 分布式框架 IO 框架

2021-06-24 10:27:48

分布式架构系统

2021-10-27 11:29:32

框架Web开发

2021-08-30 09:30:29

Kafka高性能设计

2021-03-10 09:52:38

开发技能架构

2011-09-14 10:08:07

Beanstalkd
点赞
收藏

51CTO技术栈公众号