Soul网关第8天:使用http长轮训同步数据到网关

375 阅读5分钟

一、准备工作

  • 1、打开soul-admin即控制台(后文用控制台代替)和 soul-bootstrap即网关服务(后文用网关服务代替)配置中的 http 数据同步配置

  • 启动控制台和网关服务

二、http长轮训方式同步数据

1、通过控制台日志入手

学习一个成熟的项目我们可以从它的官方文档、单元测试入手,也可以从它的日志入手。长轮训,猜测是客户端有定时任务在不断的轮训服务端数据,即网关一直在轮训控制台数据,不断的更新本地缓存。

在启动网关服务的时候,可以看到一行网关日志

...HttpSyncDataService: request configs: [http://localhost:9095/configs/fetch?groupKeys=APP_AUTH&groupKeys=PLUGIN&groupKeys=RULE&groupKeys=SELECTOR&groupKeys=META_DATA]

通过请求 http://localhost:9095/configs/fetch?groupKeys=APP_AUTH&groupKeys=PLUGIN&groupKeys=RULE&groupKeys=SELECTOR&groupKeys=META_DATA 可以拿到全部配置。之后半天了,网关服务的日志也没再更新,这不科学,应该还有轮训请求,说明网关服务日志打印不全。

再来看一下控制台日志

不难看出来,每隔5分钟更新一下各类数据,让我们从日志中出现的类 HttpLongPollingDataChangedListener 入手

先看 被 afterPropertiesSet 方法调用的 HttpLongPollingDataChangedListener#afterInitialize 方法,也就是打印日志的来源方法。

InitializingBean接口为bean提供了属性初始化后的处理方法,它只包括afterPropertiesSet方法,凡是继承该接口的类,在bean的属性初始化后都会执行该方法

    @Override
    protected void afterInitialize() {
        long syncInterval = httpSyncProperties.getRefreshInterval().toMillis();
        // Periodically check the data for changes and update the cache
        scheduler.scheduleWithFixedDelay(() -> {
            log.info("http sync strategy refresh config start.");
            try {
                this.refreshLocalCache();
                log.info("http sync strategy refresh config success.");
            } catch (Exception e) {
                log.error("http sync strategy refresh config error!", e);
            }
        }, syncInterval, syncInterval, TimeUnit.MILLISECONDS);
        log.info("http sync strategy refresh interval: {}ms", syncInterval);
    }

    private void refreshLocalCache() {
        this.updateAppAuthCache();
        this.updatePluginCache();
        this.updateRuleCache();
        this.updateSelectorCache();
        this.updateMetaDataCache();
    }

可以看出来在初始化 HttpLongPollingDataChangedListener bean 的时候,创建了一个定时执行的线程,查库并写到本地缓存,然后呢?这只是写到控制台的 JVM 内存里,并没有发送给网关服务。

2、控制台端接收http请求

那些我们就看看有哪些方法 get 了这个缓存 Map 的数据,其中又一个 Controller 接口 /configs/listener。我们看一些Server层的执行方法 HttpLongPollingDataChangedListener#doLongPolling 有很多知识点。

    public void doLongPolling(final HttpServletRequest request, final HttpServletResponse response) {

        // compare group md5
        List<ConfigGroupEnum> changedGroup = compareChangedGroup(request);
        String clientIp = getRemoteIp(request);

        // response immediately.
        if (CollectionUtils.isNotEmpty(changedGroup)) {
            this.generateResponse(response, changedGroup);
            log.info("send response with the changed group, ip={}, group={}", clientIp, changedGroup);
            return;
        }

        // listen for configuration changed.
        final AsyncContext asyncContext = request.startAsync();

        // AsyncContext.settimeout() does not timeout properly, so you have to control it yourself
        asyncContext.setTimeout(0L);

        // block client's thread.
        scheduler.execute(new LongPollingClient(asyncContext, clientIp, HttpConstants.SERVER_MAX_HOLD_TIMEOUT));
    }

首先发现执行逻辑是丢到线程池执行,而线程池的初始化是

	this.scheduler = new ScheduledThreadPoolExecutor(1, SoulThreadFactory.create("long-polling", true));

其中 HttpLongPollingDataChangedListener#compareChangedGroup 调用 checkCacheDelayAndUpdate 方法,在checkCacheDelayAndUpdate 方法中还有锁,获取锁成功后会查库更新控制台本地缓存。

也就是有缓存更新异步返回查询结果,告诉网关服务有数据更新。

单个线程,那这异步的意义是什么呢?接着看线程 LongPollingClient 的 run 方法来揭晓答案

单线程有很多好处:当处理逻辑很快(一般是无IO操作),单线程的TPS也相当可以;处理逻辑可以串行,无锁、代码结构简单。

        @Override
        public void run() {
            this.asyncTimeoutFuture = scheduler.schedule(() -> {
                clients.remove(LongPollingClient.this);
                List<ConfigGroupEnum> changedGroups = compareChangedGroup((HttpServletRequest) asyncContext.getRequest());
                sendResponse(changedGroups);
            }, timeoutTime, TimeUnit.MILLISECONDS);
            clients.add(this);
        }

总感觉这里怪怪的 clients.remove(LongPollingClient.this) 和 clients.add(this) 相当于并发执行,到底是先删除在增加,还是先增加在删除,二意性吧。 上面代码的 clients 的初始化是一个有界阻塞队列

        this.clients = new ArrayBlockingQueue<>(1024);

LongPollingClient#run方法是将请求处理丢到线程后,再将 LongPollingClient 对象再丢到 clients 队列中,这是什么操作,像是把请求阻塞中,然后异步返回结果。

这里应该是用到了Tomcat 异步 Servlet 技术,Soul网关有太多的知识点的实践,太棒了。

有点吃力看起来,以后画一下这本门的线程模型图或者时序图,挺有意思的。我们从 clients 中的元素出列队代码线程DataChangeTask#run看起来,执行它的方法就是我们前两天看到的监听事件的执行方法。

        @Override
        public void run() {
            for (Iterator<LongPollingClient> iter = clients.iterator(); iter.hasNext();) {
                LongPollingClient client = iter.next();
                iter.remove();
                client.sendResponse(Collections.singletonList(groupKey));
                log.info("send response with the changed group,ip={}, group={}, changeTime={}", client.ip, groupKey, changeTime);
            }
        }

也就是当有事件到来时,更新控制台本地缓存,响应网关服务的请求,告诉有请求进来(在阻塞队列里)网关有数据更新了。

3、网关服务端发送http请求

看控制台逻辑看晕了,以后要画画图补上,下面我们根据控制台接口 /configers/listener 接口找到网关服务调用方法 HttpSyncDataService#doLongPolling

    private void doLongPolling(final String server) {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>(8);
        for (ConfigGroupEnum group : ConfigGroupEnum.values()) {
            ConfigData<?> cacheConfig = factory.cacheConfigData(group);
            String value = String.join(",", cacheConfig.getMd5(), String.valueOf(cacheConfig.getLastModifyTime()));
            params.put(group.name(), Lists.newArrayList(value));
        }
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        HttpEntity httpEntity = new HttpEntity(params, headers);
        String listenerUrl = server + "/configs/listener";
        log.debug("request listener configs: [{}]", listenerUrl);
        JsonArray groupJson = null;
        try {
            String json = this.httpClient.postForEntity(listenerUrl, httpEntity, String.class).getBody();
            log.debug("listener result: [{}]", json);
            groupJson = GSON.fromJson(json, JsonObject.class).getAsJsonArray("data");
        } catch (RestClientException e) {
            String message = String.format("listener configs fail, server:[%s], %s", server, e.getMessage());
            throw new SoulException(message, e);
        }
        if (groupJson != null) {
            // fetch group configuration async.
            ConfigGroupEnum[] changedGroups = GSON.fromJson(groupJson, ConfigGroupEnum[].class);
            if (ArrayUtils.isNotEmpty(changedGroups)) {
                log.info("Group config changed: {}", Arrays.toString(changedGroups));
                this.doFetchGroupConfig(server, changedGroups);
            }
        }
    }

注意看这一行这个方法 this.doFetchGroupConfig(server, changedGroups),这说明 /configers/listener 接口只是告诉网关服务,数据有没有更新和哪些类型数据更新了,然后网关服务再 /configs/fetch 接口更新数据。

    private void doFetchGroupConfig(final String server, final ConfigGroupEnum... groups) {
        StringBuilder params = new StringBuilder();
        for (ConfigGroupEnum groupKey : groups) {
            params.append("groupKeys").append("=").append(groupKey.name()).append("&");
        }
        String url = server + "/configs/fetch?" + StringUtils.removeEnd(params.toString(), "&");
        log.info("request configs: [{}]", url);
        String json = null;
        try {
            json = this.httpClient.getForObject(url, String.class);
        } catch (RestClientException e) {
            String message = String.format("fetch config fail from server[%s], %s", url, e.getMessage());
            log.warn(message);
            throw new SoulException(message, e);
        }
        // update local cache
        boolean updated = this.updateCacheWithJson(json);
        if (updated) {
            log.info("get latest configs: [{}]", json);
            return;
        }
        // not updated. it is likely that the current config server has not been updated yet. wait a moment.
        log.info("The config of the server[{}] has not been updated or is out of date. Wait for 30s to listen for changes again.", server);
        ThreadUtils.sleep(TimeUnit.SECONDS, 30);
    }

/configs/fetch 接口眼熟吧,也就是网关服务刚启动时,调用的接口来获取控制台数据。然后我们再反过头去看控制台这个接口的代码

    public ConfigData<?> fetchConfig(final ConfigGroupEnum groupKey) {
        ConfigDataCache config = CACHE.get(groupKey.name());
        switch (groupKey) {
            case APP_AUTH:
                List<AppAuthData> appAuthList = GsonUtils.getGson().fromJson(config.getJson(), new TypeToken<List<AppAuthData>>() {
                }.getType());
                return new ConfigData<>(config.getMd5(), config.getLastModifyTime(), appAuthList);
            case PLUGIN:
                List<PluginData> pluginList = GsonUtils.getGson().fromJson(config.getJson(), new TypeToken<List<PluginData>>() {
                }.getType());
                return new ConfigData<>(config.getMd5(), config.getLastModifyTime(), pluginList);
            case RULE:
                List<RuleData> ruleList = GsonUtils.getGson().fromJson(config.getJson(), new TypeToken<List<RuleData>>() {
                }.getType());
                return new ConfigData<>(config.getMd5(), config.getLastModifyTime(), ruleList);
            case SELECTOR:
                List<SelectorData> selectorList = GsonUtils.getGson().fromJson(config.getJson(), new TypeToken<List<SelectorData>>() {
                }.getType());
                return new ConfigData<>(config.getMd5(), config.getLastModifyTime(), selectorList);
            case META_DATA:
                List<MetaData> metaList = GsonUtils.getGson().fromJson(config.getJson(), new TypeToken<List<MetaData>>() {
                }.getType());
                return new ConfigData<>(config.getMd5(), config.getLastModifyTime(), metaList);
            default:
                throw new IllegalStateException("Unexpected groupKey: " + groupKey);
        }
    }

ConfigDataCache config = CACHE.get(groupKey.name()); 这不就是从控制台缓存 Map 中查数据吗。至于网关服务拿到数据怎么更新到网关本地缓存,昨天已经讲了,顺着今天网关服务调用/configs/fetch接口拿到数据往下看,应该不难。

今天逻辑有点服务,线程和锁有点巧妙,以后要多点时间画画图