Ali end face: how to design a high-performance gateway?

I. Introduction

I recently saw the design of the soul gateway on github, and suddenly I became interested in writing a high-performance gateway from scratch. After two weeks of development, the core functions of my gateway ship-gate have basically been completed. The biggest flaw is that the front-end skills are too poor and there is no management background.

2. Design

2.1 Technical selection

The gateway is the entry point for all requests, so it requires high throughput. In order to achieve this, request asynchrony can be used to solve it. Currently there are generally two options:

  • Tomcat/Jetty+NIO+Servlet3

Servlet3 already supports asynchronous, this kind of scheme is used more, Jingdong, Youzan and Zuul all use this kind of scheme.

  • Netty+NIO

Netty is born for high concurrency. At present, the gateway of Vipshop uses this strategy. In the technical article of Vipshop, under the same conditions, Netty has a throughput of 30w+ per second, and Tomcat is 13w+. It can be seen that there are certain There is a gap, but Netty needs to handle the HTTP protocol by itself, which is more troublesome.

Later, it was found that the Soul gateway is based on Spring WebFlux (the underlying Netty), and you don't need to care too much about the processing of the HTTP protocol, so I decided to use Spring WebFlux as well.

The second feature of gateways is scalability. For example, Netflix Zuul has preFilters, postFilters, etc. to process different services at different stages, which can be achieved by chaining requests based on the chain of responsibility model.

Under the microservice architecture, services are deployed in multiple instances to ensure high availability. When a request arrives at the gateway, the gateway needs to find all available instances according to the URL. At this time, the service registration and discovery function is required, that is, the registry.

There are two popular registries now, Apache's Zookeeper and Ali's Nacos (consul is a bit niche). Because Zookeeper has been used when writing RPC frameworks before, I chose Nacos this time.

2.2 List of requirements

First of all, it is necessary to clarify the goal, that is, to develop a gateway with which features. The summary is as follows:

  • Custom routing rules can be set based on version routing rules, routing objects include DEFAUL, HEADER and QUERY, and matching methods include =, regex, and like.
  • Cross-language HTTP protocol is inherently cross-language
  • High-performance Netty itself is a high-performance communication framework. At the same time, the server caches some routing rules and other data in the JVM memory to avoid requesting admin services.
  • High availability supports cluster mode to prevent single node failure, stateless.
  • Grayscale Publishing Grayscale Publishing (also known as Canary Publishing) refers to a publishing method that can smoothly transition between black and white. A/B testing can be carried out on it, that is, let some users continue to use product feature A, and some users start to use product feature B. If users have no objection to B, then gradually expand the scope and migrate all users to B. Come. This can be achieved through feature one.
  • Interface authentication is based on the chain of responsibility model, and users can develop their own authentication plug-ins.
  • Load balancing supports multiple load balancing algorithms, such as random, round-robin, weighted round-robin, etc. Using the SPI mechanism, it can be dynamically loaded according to the configuration.

2.3 Architecture Design

After referring to some excellent gateways Zuul, Spring Cloud Gateway, Soul, the project is divided into the following modules.

The relationship between them is shown in the figure:

Note: This picture is a bit different from the actual implementation. The link of Nacos push to the local cache has not been implemented. Currently, only the ship-sever regularly polls the pull process. The process for ship-admin to obtain registration service information from Nacos has also been changed to notify ship-admin of an HTTP request when ServiceA starts.

2.4 Table structure design

3. Coding

3.1 ship-client-spring-boot-starter

First create a spring-boot-starter named ship-client-spring-boot-starter, if you don't know how to customize the starter, you can read the "Developing Your Own Starter" that I wrote before.

Its core class  AutoRegisterListener  does two things when the project starts:

1. Register the service information to the Nacos registration center

2. Notify the ship-admin service is online and register the offline hook.

code show as below:

/**
 * Created by 2YSP on 2020/12/21
 */
public class AutoRegisterListener implements ApplicationListener<ContextRefreshedEvent> {
 
    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<String> 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<String, String> 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 = buildRegisterAppDTO(instance);
        OkhttpTool.doPost(url, registerAppDTO);
        LOGGER.info("register to ship-admin success!");
    }
 
 
    private RegisterAppDTO buildRegisterAppDTO(Instance instance) {
        RegisterAppDTO registerAppDTO = new RegisterAppDTO();
        registerAppDTO.setAppName(properties.getAppName());
        registerAppDTO.setContextPath(properties.getContextPath());
        registerAppDTO.setIp(instance.getIp());
        registerAppDTO.setPort(instance.getPort());
        registerAppDTO.setVersion(properties.getVersion());
        return registerAppDTO;
    }
}

3.2 ship-server

The ship-sever project mainly includes two parts,

1. The main process of requesting dynamic routing

2. The local cache data is synchronized with ship-admin and nacos. This part will be discussed later in 3.3.

The principle of ship-server implementing dynamic routing is to use WebFilter to intercept the request, and then hand over the request to the plugin chain for chain processing.

PluginFilter parses out the appName according to the URL, and then assembles the enabled plugins into a plugin chain.

PluginChain inherits AbstractShipPlugin and holds all plugins to be executed.

/**
 * @Author: Ship
 * @Description:
 * @Date: Created in 2020/12/25
 */
public class PluginChain extends AbstractShipPlugin {
    /**
     * the pos point to current plugin
     */
    private int pos;
    /**
     * the plugins of chain
     */
    private List<ShipPlugin> plugins;
 
    private final String appName;
 
    public PluginChain(ServerConfigProperties properties, String appName) {
        super(properties);
        this.appName = appName;
    }
 
    /**
     * add enabled plugin to chain
     *
     * @param shipPlugin
     */
    public void addPlugin(ShipPlugin shipPlugin) {
        if (plugins == null) {
            plugins = new ArrayList<>();
        }
        if (!PluginCache.isEnabled(appName, shipPlugin.name())) {
            return;
        }
        plugins.add(shipPlugin);
        // order by the plugin's order
        plugins.sort(Comparator.comparing(ShipPlugin::order));
    }
 
    @Override
    public Integer order() {
        return null;
    }
 
    @Override
    public String name() {
        return null;
    }
 
    @Override
    public Mono<Void> execute(ServerWebExchange exchange, PluginChain pluginChain) {
        if (pos == plugins.size()) {
            return exchange.getResponse().setComplete();
        }
        return pluginChain.plugins.get(pos++).execute(exchange, pluginChain);
    }
 
    public String getAppName() {
        return appName;
    }
 
}

AbstractShipPlugin implements the ShipPlugin interface and holds the ServerConfigProperties configuration object.

The ShipPlugin interface defines three methods order(), name() and execute() that all plugins must implement.

DynamicRoutePlugin inherits the abstract class AbstractShipPlugin and contains the main business logic of dynamic routing.

/**
 * @Author: Ship
 * @Description:
 * @Date: Created in 2020/12/25
 */
public class DynamicRoutePlugin extends AbstractShipPlugin {
 
    private final static Logger LOGGER = LoggerFactory.getLogger(DynamicRoutePlugin.class);
 
    private static WebClient webClient;
 
    private static final Gson gson = new GsonBuilder().create();
 
    static {
        HttpClient httpClient = HttpClient.create()
                .tcpConfiguration(client ->
                        client.doOnConnected(conn ->
                                conn.addHandlerLast(new ReadTimeoutHandler(3))
                                        .addHandlerLast(new WriteTimeoutHandler(3)))
                                .option(ChannelOption.TCP_NODELAY, true)
                );
        webClient = WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
 
    public DynamicRoutePlugin(ServerConfigProperties properties) {
        super(properties);
    }
 
    @Override
    public Integer order() {
        return ShipPluginEnum.DYNAMIC_ROUTE.getOrder();
    }
 
    @Override
    public String name() {
        return ShipPluginEnum.DYNAMIC_ROUTE.getName();
    }
 
    @Override
    public Mono<Void> execute(ServerWebExchange exchange, PluginChain pluginChain) {
        String appName = pluginChain.getAppName();
        ServiceInstance serviceInstance = chooseInstance(appName, exchange.getRequest());
//        LOGGER.info("selected instance is [{}]", gson.toJson(serviceInstance));
        // request service
        String url = buildUrl(exchange, serviceInstance);
        return forward(exchange, url);
    }
 
    /**
     * forward request to backend service
     *
     * @param exchange
     * @param url
     * @return
     */
    private Mono<Void> forward(ServerWebExchange exchange, String url) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        HttpMethod method = request.getMethod();
 
        WebClient.RequestBodySpec requestBodySpec = webClient.method(method).uri(url).headers((headers) -> {
            headers.addAll(request.getHeaders());
        });
 
        WebClient.RequestHeadersSpec<?> reqHeadersSpec;
        if (requireHttpBody(method)) {
            reqHeadersSpec = requestBodySpec.body(BodyInserters.fromDataBuffers(request.getBody()));
        } else {
            reqHeadersSpec = requestBodySpec;
        }
        // nio->callback->nio
        return reqHeadersSpec.exchange().timeout(Duration.ofMillis(properties.getTimeOutMillis()))
                .onErrorResume(ex -> {
                    return Mono.defer(() -> {
                        String errorResultJson = "";
                        if (ex instanceof TimeoutException) {
                            errorResultJson = "{\"code\":5001,\"message\":\"network timeout\"}";
                        } else {
                            errorResultJson = "{\"code\":5000,\"message\":\"system error\"}";
                        }
                        return ShipResponseUtil.doResponse(exchange, errorResultJson);
                    }).then(Mono.empty());
                }).flatMap(backendResponse -> {
                    response.setStatusCode(backendResponse.statusCode());
                    response.getHeaders().putAll(backendResponse.headers().asHttpHeaders());
                    return response.writeWith(backendResponse.bodyToFlux(DataBuffer.class));
                });
    }
 
    /**
     * weather the http method need http body
     *
     * @param method
     * @return
     */
    private boolean requireHttpBody(HttpMethod method) {
        if (method.equals(HttpMethod.POST) || method.equals(HttpMethod.PUT) || method.equals(HttpMethod.PATCH)) {
            return true;
        }
        return false;
    }
 
    private String buildUrl(ServerWebExchange exchange, ServiceInstance serviceInstance) {
        ServerHttpRequest request = exchange.getRequest();
        String query = request.getURI().getQuery();
        String path = request.getPath().value().replaceFirst("/" + serviceInstance.getAppName(), "");
        String url = "http://" + serviceInstance.getIp() + ":" + serviceInstance.getPort() + path;
        if (!StringUtils.isEmpty(query)) {
            url = url + "?" + query;
        }
        return url;
    }
 
 
    /**
     * choose an ServiceInstance according to route rule config and load balancing algorithm
     *
     * @param appName
     * @param request
     * @return
     */
    private ServiceInstance chooseInstance(String appName, ServerHttpRequest request) {
        List<ServiceInstance> serviceInstances = ServiceCache.getAllInstances(appName);
        if (CollectionUtils.isEmpty(serviceInstances)) {
            LOGGER.error("service instance of {} not find", appName);
            throw new ShipException(ShipExceptionEnum.SERVICE_NOT_FIND);
        }
        String version = matchAppVersion(appName, request);
        if (StringUtils.isEmpty(version)) {
            throw new ShipException("match app version error");
        }
        // filter serviceInstances by version
        List<ServiceInstance> instances = serviceInstances.stream().filter(i -> i.getVersion().equals(version)).collect(Collectors.toList());
        //Select an instance based on the load balancing algorithm
        LoadBalance loadBalance = LoadBalanceFactory.getInstance(properties.getLoadBalance(), appName, version);
        ServiceInstance serviceInstance = loadBalance.chooseOne(instances);
        return serviceInstance;
    }
 
 
    private String matchAppVersion(String appName, ServerHttpRequest request) {
        List<AppRuleDTO> rules = RouteRuleCache.getRules(appName);
        rules.sort(Comparator.comparing(AppRuleDTO::getPriority).reversed());
        for (AppRuleDTO rule : rules) {
            if (match(rule, request)) {
                return rule.getVersion();
            }
        }
        return null;
    }
 
 
    private boolean match(AppRuleDTO rule, ServerHttpRequest request) {
        String matchObject = rule.getMatchObject();
        String matchKey = rule.getMatchKey();
        String matchRule = rule.getMatchRule();
        Byte matchMethod = rule.getMatchMethod();
        if (MatchObjectEnum.DEFAULT.getCode().equals(matchObject)) {
            return true;
        } else if (MatchObjectEnum.QUERY.getCode().equals(matchObject)) {
            String param = request.getQueryParams().getFirst(matchKey);
            if (!StringUtils.isEmpty(param)) {
                return StringTools.match(param, matchMethod, matchRule);
            }
        } else if (MatchObjectEnum.HEADER.getCode().equals(matchObject)) {
            HttpHeaders headers = request.getHeaders();
            String headerValue = headers.getFirst(matchKey);
            if (!StringUtils.isEmpty(headerValue)) {
                return StringTools.match(headerValue, matchMethod, matchRule);
            }
        }
        return false;
    }
 
}

3.3 Data synchronization

app data synchronization

When the background service (such as the order service) starts, only the service name, version, ip address and port number are registered to Nacos, but there is no instance weight and enabled plugin information. What should I do?

Generally, online instance weights and plug-in lists are configured on the management interface, and then take effect dynamically. Therefore, ship-admin needs to regularly update the instance weight and plug-in information to the registry.

NacosSyncListener corresponding to code ship-admin

/**
 * @Author: Ship
 * @Description:
 * @Date: Created in 2020/12/30
 */
@Configuration
public class NacosSyncListener implements ApplicationListener<ContextRefreshedEvent> {
 
    private static final Logger LOGGER = LoggerFactory.getLogger(NacosSyncListener.class);
 
    private static ScheduledThreadPoolExecutor scheduledPool = new ScheduledThreadPoolExecutor(1,
            new ShipThreadFactory("nacos-sync", true).create());
 
    @NacosInjected
    private NamingService namingService;
 
    @Value("${nacos.discovery.server-addr}")
    private String baseUrl;
 
    @Resource
    private AppService appService;
 
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        if (event.getApplicationContext().getParent() != null) {
            return;
        }
        String url = "http://" + baseUrl + NacosConstants.INSTANCE_UPDATE_PATH;
        scheduledPool.scheduleWithFixedDelay(new NacosSyncTask(namingService, url, appService), 0, 30L, TimeUnit.SECONDS);
    }
 
    class NacosSyncTask implements Runnable {
 
        private NamingService namingService;
 
        private String url;
 
        private AppService appService;
 
        private Gson gson = new GsonBuilder().create();
 
        public NacosSyncTask(NamingService namingService, String url, AppService appService) {
            this.namingService = namingService;
            this.url = url;
            this.appService = appService;
        }
 
        /**
         * Regular update weight,enabled plugins to nacos instance
         */
        @Override
        public void run() {
            try {
                // get all app names
                ListView<String> services = namingService.getServicesOfServer(1, Integer.MAX_VALUE, NacosConstants.APP_GROUP_NAME);
                if (CollectionUtils.isEmpty(services.getData())) {
                    return;
                }
                List<String> appNames = services.getData();
                List<AppInfoDTO> appInfos = appService.getAppInfos(appNames);
                for (AppInfoDTO appInfo : appInfos) {
                    if (CollectionUtils.isEmpty(appInfo.getInstances())) {
                        continue;
                    }
                    for (ServiceInstance instance : appInfo.getInstances()) {
                        Map<String, Object> queryMap = buildQueryMap(appInfo, instance);
                        String resp = OkhttpTool.doPut(url, queryMap, "");
                        LOGGER.debug("response :{}", resp);
                    }
                }
 
            } catch (Exception e) {
                LOGGER.error("nacos sync task error", e);
            }
        }
 
        private Map<String, Object> buildQueryMap(AppInfoDTO appInfo, ServiceInstance instance) {
            Map<String, Object> map = new HashMap<>();
            map.put("serviceName", appInfo.getAppName());
            map.put("groupName", NacosConstants.APP_GROUP_NAME);
            map.put("ip", instance.getIp());
            map.put("port", instance.getPort());
            map.put("weight", instance.getWeight().doubleValue());
            NacosMetadata metadata = new NacosMetadata();
            metadata.setAppName(appInfo.getAppName());
            metadata.setVersion(instance.getVersion());
            metadata.setPlugins(String.join(",", appInfo.getEnabledPlugins()));
            map.put("metadata", StringTools.urlEncode(gson.toJson(metadata)));
            map.put("ephemeral", true);
            return map;
        }
    }
}

The ship-server periodically pulls the app data from Nacos and updates it to the local Map cache.

/**
 * @Author: Ship
 * @Description: sync data to local cache
 * @Date: Created in 2020/12/25
 */
@Configuration
public class DataSyncTaskListener implements ApplicationListener<ContextRefreshedEvent> {
 
    private static ScheduledThreadPoolExecutor scheduledPool = new ScheduledThreadPoolExecutor(1,
            new ShipThreadFactory("service-sync", true).create());
 
    @NacosInjected
    private NamingService namingService;
 
    @Autowired
    private ServerConfigProperties properties;
 
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        if (event.getApplicationContext().getParent() != null) {
            return;
        }
        scheduledPool.scheduleWithFixedDelay(new DataSyncTask(namingService)
                , 0L, properties.getCacheRefreshInterval(), TimeUnit.SECONDS);
        WebsocketSyncCacheServer websocketSyncCacheServer = new WebsocketSyncCacheServer(properties.getWebSocketPort());
        websocketSyncCacheServer.start();
    }
 
 
    class DataSyncTask implements Runnable {
 
        private NamingService namingService;
 
        public DataSyncTask(NamingService namingService) {
            this.namingService = namingService;
        }
 
        @Override
        public void run() {
            try {
                // get all app names
                ListView<String> services = namingService.getServicesOfServer(1, Integer.MAX_VALUE, NacosConstants.APP_GROUP_NAME);
                if (CollectionUtils.isEmpty(services.getData())) {
                    return;
                }
                List<String> appNames = services.getData();
                // get all instances
                for (String appName : appNames) {
                    List<Instance> instanceList = namingService.getAllInstances(appName, NacosConstants.APP_GROUP_NAME);
                    if (CollectionUtils.isEmpty(instanceList)) {
                        continue;
                    }
                    ServiceCache.add(appName, buildServiceInstances(instanceList));
                    List<String> pluginNames = getEnabledPlugins(instanceList);
                    PluginCache.add(appName, pluginNames);
                }
                ServiceCache.removeExpired(appNames);
                PluginCache.removeExpired(appNames);
 
            } catch (NacosException e) {
                e.printStackTrace();
            }
        }
 
        private List<String> getEnabledPlugins(List<Instance> instanceList) {
            Instance instance = instanceList.get(0);
            Map<String, String> metadata = instance.getMetadata();
            // plugins: DynamicRoute,Auth
            String plugins = metadata.getOrDefault("plugins", ShipPluginEnum.DYNAMIC_ROUTE.getName());
            return Arrays.stream(plugins.split(",")).collect(Collectors.toList());
        }
 
        private List<ServiceInstance> buildServiceInstances(List<Instance> instanceList) {
            List<ServiceInstance> list = new LinkedList<>();
            instanceList.forEach(instance -> {
                Map<String, String> metadata = instance.getMetadata();
                ServiceInstance serviceInstance = new ServiceInstance();
                serviceInstance.setAppName(metadata.get("appName"));
                serviceInstance.setIp(instance.getIp());
                serviceInstance.setPort(instance.getPort());
                serviceInstance.setVersion(metadata.get("version"));
                serviceInstance.setWeight((int) instance.getWeight());
                list.add(serviceInstance);
            });
            return list;
        }
    }
}

Routing rule data synchronization

At the same time, if the user updates the routing rules in the management background, the ship-admin needs to push the rule data to the ship-server. Here we refer to the method of the soul gateway and use websocket to perform full synchronization after the first connection is established. After that, the routing rules are changed. Incremental synchronization only.

Server WebsocketSyncCacheServer:

/**
 * @Author: Ship
 * @Description:
 * @Date: Created in 2020/12/28
 */
public class WebsocketSyncCacheServer extends WebSocketServer {
 
    private final static Logger LOGGER = LoggerFactory.getLogger(WebsocketSyncCacheServer.class);
 
    private Gson gson = new GsonBuilder().create();
 
    private MessageHandler messageHandler;
 
    public WebsocketSyncCacheServer(Integer port) {
        super(new InetSocketAddress(port));
        this.messageHandler = new MessageHandler();
    }
 
 
    @Override
    public void onOpen(WebSocket webSocket, ClientHandshake clientHandshake) {
        LOGGER.info("server is open");
    }
 
    @Override
    public void onClose(WebSocket webSocket, int i, String s, boolean b) {
        LOGGER.info("websocket server close...");
    }
 
    @Override
    public void onMessage(WebSocket webSocket, String message) {
        LOGGER.info("websocket server receive message:\n[{}]", message);
        this.messageHandler.handler(message);
    }
 
    @Override
    public void onError(WebSocket webSocket, Exception e) {
 
    }
 
    @Override
    public void onStart() {
        LOGGER.info("websocket server start...");
    }
 
 
    class MessageHandler {
 
        public void handler(String message) {
            RouteRuleOperationDTO operationDTO = gson.fromJson(message, RouteRuleOperationDTO.class);
            if (CollectionUtils.isEmpty(operationDTO.getRuleList())) {
                return;
            }
            Map<String, List<AppRuleDTO>> map = operationDTO.getRuleList()
                    .stream().collect(Collectors.groupingBy(AppRuleDTO::getAppName));
            if (OperationTypeEnum.INSERT.getCode().equals(operationDTO.getOperationType())
                    || OperationTypeEnum.UPDATE.getCode().equals(operationDTO.getOperationType())) {
                RouteRuleCache.add(map);
            } else if (OperationTypeEnum.DELETE.getCode().equals(operationDTO.getOperationType())) {
                RouteRuleCache.remove(map);
            }
        }
    }
}
 

Client WebsocketSyncCacheClient:

/**
 * @Author: Ship
 * @Description:
 * @Date: Created in 2020/12/28
 */
@Component
public class WebsocketSyncCacheClient {
 
    private final static Logger LOGGER = LoggerFactory.getLogger(WebsocketSyncCacheClient.class);
 
    private WebSocketClient client;
 
    private RuleService ruleService;
 
    private Gson gson = new GsonBuilder().create();
 
    public WebsocketSyncCacheClient(@Value("${ship.server-web-socket-url}") String serverWebSocketUrl,
                                    RuleService ruleService) {
        if (StringUtils.isEmpty(serverWebSocketUrl)) {
            throw new ShipException(ShipExceptionEnum.CONFIG_ERROR);
        }
        this.ruleService = ruleService;
        ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1,
                new ShipThreadFactory("websocket-connect", true).create());
        try {
            client = new WebSocketClient(new URI(serverWebSocketUrl)) {
                @Override
                public void onOpen(ServerHandshake serverHandshake) {
                    LOGGER.info("client is open");
                    List<AppRuleDTO> list = ruleService.getEnabledRule();
                    String msg = gson.toJson(new RouteRuleOperationDTO(OperationTypeEnum.INSERT, list));
                    send(msg);
                }
 
                @Override
                public void onMessage(String s) {
                }
 
                @Override
                public void onClose(int i, String s, boolean b) {
                }
 
                @Override
                public void onError(Exception e) {
                    LOGGER.error("websocket client error", e);
                }
            };
 
            client.connectBlocking();
            //使用调度线程池进行断线重连,30秒进行一次
            executor.scheduleAtFixedRate(() -> {
                if (client != null && client.isClosed()) {
                    try {
                        client.reconnectBlocking();
                    } catch (InterruptedException e) {
                        LOGGER.error("reconnect server fail", e);
                    }
                }
            }, 10, 30, TimeUnit.SECONDS);
 
        } catch (Exception e) {
            LOGGER.error("websocket sync cache exception", e);
            throw new ShipException(e.getMessage());
        }
    }
 
    public <T> void send(T t) {
        while (!client.getReadyState().equals(ReadyState.OPEN)) {
            LOGGER.debug("connecting ...please wait");
        }
        client.send(gson.toJson(t));
    }
}

4. Test

4.1 Dynamic Routing Test

Start nacos locally, sh startup.sh -m standalone

start ship-admin

Start two instances of ship-example locally.

Example 1 configuration:

Example 2 configuration:

  1. Add a routing rule configuration in the database, which indicates that when name=ship in the http header, the request is routed to the node of gray_1.0 version.

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

6. Use Postman to request
http://localhost:9000/order/user/add, POST method, set name=ship in the header, and you can see that only instance 1 has logs displayed.

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

4.2 Performance stress test

Stress test environment:

MacBook Pro 13-inch

Processor 2.3 GHz quad-core Intel Core i7

Memory 16 GB 3733 MHz LPDDR4X

One backend node

Pressure measurement tool: wrk

Pressure test results: 20 threads, 500 connections, and a throughput of about 9,400 requests per second.

V. Summary

​ A journey of a thousand miles begins with a single step. At first, I thought it would be difficult to write a gateway, but when you actually start to act, you will find that it is not that difficult, so it is important to take the first step. I also encountered a lot of problems in the process. I also raised two issues for the two open source projects soul and nacos on github. Later, I found out that it was my own problem, which was embarrassing.

Guess you like

Origin blog.csdn.net/m0_63437643/article/details/123794162