简介
本篇文章来探索Soul网关Admin中的HTTP长轮询数据同步流程
概览
运行示例参考:Soul网关源码解析(十四)HTTP数据同步-Bootstrap端
文章分析切入点为上面第十四篇中分析得到的两个请求接口:
- /configs/listener : 数据变化监听接口
- /configs/fetch : 数据获取接口
首先对获取接口进行分析,得到其直接从本地缓存中得到数据后返回
再来分析数据变化监听端口,见识了一种拉实现推的新操作,请求会在Admin进行等待,超时或者数据有变化后才会返回,非常有意思
最后想找出事件发布到更新的流程,踩了点坑,不是很顺利,但感受到了HTTP长轮询数据同步和Websocket、Zookeeper、Nacos有些区别,实现更加复杂一些
源码Debug
寻找切入点
找到第十四篇中HTTP同步需要使用的两个接口位置,代码大致如下:
@RequestMapping("/configs")
public class ConfigController {
@Resource
private HttpLongPollingDataChangedListener longPollingListener;
// 数据获取
@GetMapping("/fetch")
public SoulAdminResult fetchConfigs(@NotNull final String[] groupKeys) {
Map<String, ConfigData<?>> result = Maps.newHashMap();
for (String groupKey : groupKeys) {
ConfigData<?> data = longPollingListener.fetchConfig(ConfigGroupEnum.valueOf(groupKey));
result.put(groupKey, data);
}
return SoulAdminResult.success(SoulResultMessage.SUCCESS, result);
}
// 数据变化监听
@PostMapping(value = "/listener")
public void listener(final HttpServletRequest request, final HttpServletResponse response) {
longPollingListener.doLongPolling(request, response);
}
}
数据获取解析:/configs/fetch
首先来看下数据获取接口,我们看到其直接获取各个类型的data后返回
@RequestMapping("/configs")
public class ConfigController {
@GetMapping("/fetch")
public SoulAdminResult fetchConfigs(@NotNull final String[] groupKeys) {
Map<String, ConfigData<?>> result = Maps.newHashMap();
for (String groupKey : groupKeys) {
ConfigData<?> data = longPollingListener.fetchConfig(ConfigGroupEnum.valueOf(groupKey));
result.put(groupKey, data);
}
return SoulAdminResult.success(SoulResultMessage.SUCCESS, result);
}
}
我们接着来看下 fetchConfig这个函数,看到是根据类型从本地Cache中获取数据返回
public abstract class AbstractDataChangedListener implements DataChangedListener, InitializingBean {
protected static final ConcurrentMap<String, ConfigDataCache> CACHE = new ConcurrentHashMap<>();
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);
}
}
}
我们去看一下Cache,发现更新函数只能更新 md5 和 时间,json是初始化传入的。这里暂时放过
public class ConfigDataCache {
protected synchronized void update(final String md5, final long lastModifyTime) {
this.md5 = md5;
this.lastModifyTime = lastModifyTime;
}
}
通过上面的分析,我们知道了fetch接口是直接返回全量时间的,也就是只要调用这个接口,五种类型的数据都会发送过去,数据量还挺大的
/configs/listener
接下来看看监听接口,一看就比较奇怪,没有返回值
@RequestMapping("/configs")
public class ConfigController {
@PostMapping(value = "/listener")
public void listener(final HttpServletRequest request, final HttpServletResponse response) {
longPollingListener.doLongPolling(request, response);
}
}
抱着好奇心继续看doLongPolling函数
从下面的代码可以看出,它显示看看各个数据类型是否有数据变化。如果有,则生成响应后直接返回(神奇的操作,response直接自己调用返回);
如果没有发送变化,会开启一个线程进行等待,具体内容下面在看
public class HttpLongPollingDataChangedListener extends AbstractDataChangedListener {
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));
}
// Bootstrap端传过来的应该是它最新的MD5,如果其中有一个类型MD5是新的,那就好添加到group中,如果group不为空,则直接返回
private List<ConfigGroupEnum> compareChangedGroup(final HttpServletRequest request) {
List<ConfigGroupEnum> changedGroup = new ArrayList<>(ConfigGroupEnum.values().length);
for (ConfigGroupEnum group : ConfigGroupEnum.values()) {
// md5,lastModifyTime
String[] params = StringUtils.split(request.getParameter(group.name()), ',');
if (params == null || params.length != 2) {
throw new SoulException("group param invalid:" + request.getParameter(group.name()));
}
String clientMd5 = params[0];
long clientModifyTime = NumberUtils.toLong(params[1]);
ConfigDataCache serverCache = CACHE.get(group.name());
// do check.
if (this.checkCacheDelayAndUpdate(serverCache, clientMd5, clientModifyTime)) {
changedGroup.add(group);
}
}
return changedGroup;
}
private void generateResponse(final HttpServletResponse response, final List<ConfigGroupEnum> changedGroups) {
try {
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-cache,no-store");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
// 学到了新的技能,response直接自己能调用
response.getWriter().println(GsonUtils.getInstance().toJson(SoulAdminResult.success(SoulResultMessage.SUCCESS, changedGroups)));
} catch (IOException ex) {
log.error("Sending response failed.", ex);
}
}
}
我们来看下那个线程任务
看着好像是启动了一个定时的任务,时间到了就返回数据,这里细节不太清楚,先放过
public class HttpLongPollingDataChangedListener extends AbstractDataChangedListener {
class LongPollingClient implements Runnable {
@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);
}
void sendResponse(final List<ConfigGroupEnum> changedGroups) {
// cancel scheduler
if (null != asyncTimeoutFuture) {
asyncTimeoutFuture.cancel(false);
}
generateResponse((HttpServletResponse) asyncContext.getResponse(), changedGroups);
asyncContext.complete();
}
}
}
通过的上面的分析,我们知道了一个数据变化监听的大致流程:
- 判断是否有数据变更:
- 有变更:直接返回
- 没有变更:启动任务等待
它有个asyncContext,如果超时了会返回空吗?
事件发布:Cache更新
在上面的探索中,没有和事件变化发布结合起来,下面我们来探索下从Controllers接口-->事件发布-->HTTP监听处理的流程
首先我们在上面的本地缓存更新函数中打上断点,查看其调用栈,但非常的可惜,根本没有触发......非常的奇怪
public class ConfigDataCache {
protected synchronized void update(final String md5, final long lastModifyTime) {
this.md5 = md5;
this.lastModifyTime = lastModifyTime;
}
}
我们查看日志,找到我们刚才在后台页面中更新的事件日志
o.d.s.a.l.AbstractDataChangedListener : update config cache[PLUGIN], old: {group='PLUGIN', md5='5e40806d61fedeaf4f20be6a61c43991', lastModifyTime=1611713809914}, updated: {group='PLUGIN', md5='9166fd41ff37951bd871025178ebfac3', lastModifyTime=1611713823241}
根据日志,我们定位到相应的类和函数。需要注意的是,我们直接重启也能进入断点
从下面的代码中可以看出,已经收到了变化的数据,然后更新本地缓存
public abstract class AbstractDataChangedListener implements DataChangedListener, InitializingBean {
protected <T> void updateCache(final ConfigGroupEnum group, final List<T> data) {
String json = GsonUtils.getInstance().toJson(data);
ConfigDataCache newVal = new ConfigDataCache(group.name(), json, Md5Utils.md5(json), System.currentTimeMillis());
// 更新本地缓存
ConfigDataCache oldVal = CACHE.put(newVal.getGroup(), newVal);
log.info("update config cache[{}], old: {}, updated: {}", group, oldVal, newVal);
}
我们跟踪其调用栈,触发了下面代码中的Cache更新函数
然后来到一个奇怪的afterPropertiesSet
public class HttpLongPollingDataChangedListener extends AbstractDataChangedListener {
protected void updatePluginCache() {
this.updateCache(ConfigGroupEnum.APP_AUTH, appAuthService.listAll());
}
// Admin重启后就触发,根据下面的内容,可以看出这就是一个初始化的函数,得到数据刷新本地缓存
public final void afterPropertiesSet() {
updateAppAuthCache();
updatePluginCache();
updateRuleCache();
updateSelectorCache();
updateMetaDataCache();
afterInitialize();
}
// 这个线程启动了一个任务,定时周期执行指定的任务
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的下面函数中打上断点,然后在后台改变插件状态
public class HttpLongPollingDataChangedListener extends AbstractDataChangedListener {
@Override
protected void afterPluginChanged(final List<PluginData> changed, final DataEventTypeEnum eventType) {
scheduler.execute(new DataChangeTask(ConfigGroupEnum.PLUGIN));
}
}
execute后面再看,跟踪调用栈来到下面的代码
可以看到是先更新缓存,然后再执行后面afterPluginChanged的execute,调用栈后面就是熟悉的:DataChangedEventDispatcher::onApplicationEvent、PluginServiceImpl::createOrUpdate、PluginController::updatePlugin,这里就不再赘述了
public abstract class AbstractDataChangedListener implements DataChangedListener, InitializingBean {
@Override
public void onPluginChanged(final List<PluginData> changed, final DataEventTypeEnum eventType) {
if (CollectionUtils.isEmpty(changed)) {
return;
}
this.updatePluginCache();
this.afterPluginChanged(changed, eventType);
}
protected void updatePluginCache() {
this.updateCache(ConfigGroupEnum.PLUGIN, pluginService.listAll());
}
protected <T> void updateCache(final ConfigGroupEnum group, final List<T> data) {
String json = GsonUtils.getInstance().toJson(data);
ConfigDataCache newVal = new ConfigDataCache(group.name(), json, Md5Utils.md5(json), System.currentTimeMillis());
ConfigDataCache oldVal = CACHE.put(newVal.getGroup(), newVal);
log.info("update config cache[{}], old: {}, updated: {}", group, oldVal, newVal);
}
}
我们来看一下execute函数的具体细节:
public class HttpLongPollingDataChangedListener extends AbstractDataChangedListener {
class DataChangeTask implements Runnable {
@Override
public void run() {
// 遍历所有的client,然后发送变化的groupKey(数据类型)
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);
}
}
}
}
在上面的线程中启动任务,循环遍历client列表,进行响应的发送,感觉和前面的定时监听任务有关,我们在回过头看看那段代码:
public class HttpLongPollingDataChangedListener extends AbstractDataChangedListener {
class LongPollingClient implements Runnable {
@Override
public void run() {
this.asyncTimeoutFuture = scheduler.schedule(() -> {
// 先将client从列表中移除
clients.remove(LongPollingClient.this);
List<ConfigGroupEnum> changedGroups = compareChangedGroup((HttpServletRequest) asyncContext.getRequest());
sendResponse(changedGroups);
}, timeoutTime, TimeUnit.MILLISECONDS);
// 超时后将其加入列表中?
clients.add(this);
}
void sendResponse(final List<ConfigGroupEnum> changedGroups) {
// cancel scheduler
if (null != asyncTimeoutFuture) {
asyncTimeoutFuture.cancel(false);
}
generateResponse((HttpServletResponse) asyncContext.getResponse(), changedGroups);
asyncContext.complete();
}
}
}
猜测流程是这样的:
-
1.如果客户端监听请求过来
- 首先开一个超时任务,判断数据是否有变化,如果有变化,则进行响应的发送
- 将client加入列表,用于后面的数据变化时,能进行推送(移除应该是为了避免重复)
-
2.事件进行发布,遍历列表中的client,返回变化响应给客户端
总结
比较其他三个同步方式,HTTP的是很复杂,用了一种自己以前没有见过的方式实现的,感觉又学到了新东西
Admin的HTTP长轮询的数据同步流程如下:
- 1.初始化
- 进行本地缓存更新
- 开启一个定时周期任务,定时刷新本地缓存
- 2.数据更新处理流程
- controllers :接口调用
- Service :服务调用
- 事件发布
- 数据更新:首先更新本地缓存;启动一个任务返回监听结果给等待中的客户端(通过监听端口得到变化,然后请求数据)
此外还有一个可能可以提PR的点,下面的函数似乎是没有用的(而且也没有搜索到那里使用,初始化和更新数据都没有能触发),在更新缓存的过程中基本都是new一个新的。也不排除其他地方有,但目前是没有找到,如果能确认的话,可以提一个PR进行删除
public class ConfigDataCache {
protected synchronized void update(final String md5, final long lastModifyTime) {
this.md5 = md5;
this.lastModifyTime = lastModifyTime;
}
}