记录我第一次在 Apache ShenYu 中完整处理一个 Issue 的过程,以及读代码、修复问题时的一些收获。
这是我第一次比较完整地在 Apache ShenYu 这样规模的开源仓库里处理一个 Issue。这次遇到的是 Issue #6781:HTTP 同步收到空的 ProxySelector(代理选择器)列表后,Gateway 没有及时清理原有状态,可能留下已经不再需要的 TCP 服务。
刚看到空列表分支直接 return 时,我以为补一次 refresh() 调用就能解决。但顺着代码往下读,才发现事情没有这么简单:刷新请求经过 Subscriber 后,并没有继续传到真正负责关闭 TCP Server 的 Handler。 要修复这个问题,我还得弄清楚这几个模块是怎么配合的。
最后,修复通过 PR #6909 合并。这篇文章想记下从“一行调用”继续往下追的过程,也聊聊第一次在不熟悉的开源项目里改代码时,我学到了什么。
配置删空,TCP Server 为什么还在?h2
先看一下这次问题涉及的配置和运行状态。Apache ShenYu 是一个基于 Java 的 API 网关,支持多种协议代理和动态配置。
其中,TCP 插件可以通过 ProxySelector(代理选择器)配置监听端口、负载均衡策略及上游服务等信息。管理员在 Admin 中修改相关配置后,这些配置可以同步到 Gateway,并反映到实际运行的 TCP 代理服务中。
正常情况下,我们希望配置和运行状态保持一致。例如,Admin 中删除某个代理选择器后,Gateway 应当不再保留对应的 TCP 服务。
问题发生在配置同步返回空列表的时候。
假设最初存在两个代理选择器:
Admin: ProxySelector A ProxySelector B
Gateway: TCP Server A TCP Server B随后,管理员删除了全部代理选择器。下一次 HTTP 全量同步返回的 ProxySelector 数据变成空列表(下面只是简化示意,不是原始响应的完整格式):
{ "PROXY_SELECTOR": { "data": [] }}按照预期,Gateway 应当识别到这组配置已经为空,并清理原有运行状态。
但原来的 ProxySelectorRefresh 在检测到空列表后,只记录了一条日志,然后直接 return。同步入口知道配置已经为空,却没有把清理动作传递给真正保存运行时状态的组件。
看到这里,我意识到,空列表也有它的含义。如果这次全量同步是成功的,那么返回空列表就表示当前已经没有任何代理选择器,而不是“不需要处理”。
如果程序把空列表理解成“不需要处理”,运行状态就可能与 Admin 的配置出现偏差。
追踪 refresh():调用链在哪里断开?h2
明确问题后,我主要沿着这条调用链检查:
ProxySelectorRefresh ↓ProxySelectorDataSubscriber ↓CommonProxySelectorDataSubscriber ↓ProxySelectorDataHandler ↓TcpProxySelectorDataHandler ↓TcpBootstrapFactory ↓BootstrapServer这里涉及两个不同的层次。
第一个层次是数据同步。ProxySelectorRefresh 负责接收同步数据,并通知订阅者。
第二个层次是插件的运行时状态。具体插件的 Handler 负责管理对应的代理服务,而 TCP 插件通过 TcpBootstrapFactory 缓存实际的 BootstrapServer。
问题是,这两层之间的清理行为并没有完全连接起来。
第一处问题:空列表没有触发刷新h3
原本 ProxySelectorRefresh 的空列表分支会直接返回。修复这部分只需要在返回之前通知所有订阅者:
proxySelectorDataSubscribers.forEach( ProxySelectorDataSubscriber::refresh);但如果修改只停留在这里,问题仍然没有完全解决。
第二处问题:refresh 是空实现h3
继续查看 ProxySelectorDataSubscriber,我发现它虽然定义了 refresh(),但默认实现没有任何操作。而 CommonProxySelectorDataSubscriber 原本也没有把刷新请求传给实际负责资源管理的 Handler。
换句话说,即使 HTTP 层触发了刷新,插件内部也不会自动清除已有的 TCP 服务。
因此,真正需要解决的问题不只是“有没有调用 refresh()”,而是:
这个清理事件能否沿着完整的调用链,最终到达负责管理资源的组件?
追到这里,我才明白,不能只看入口有没有调用这个方法,还得继续看它最后做了什么。
修复设计:沿原有架构传递清理事件h2
确认调用链后,我没有选择让 HTTP 同步模块直接操作 TCP Server,而是继续使用 ShenYu 原有的 Subscriber 和 Handler 结构。
这样,数据同步模块只需要表达“当前配置需要清空”,至于如何清理,则交给对应的插件。
给 Handler 增加统一的刷新入口h3
首先,在 ProxySelectorDataHandler 接口中增加一个默认的 refresh() 方法。
这里使用 default 方法,是为了不让现有 Handler 都跟着改。需要清理资源的实现,比如 TCP Handler,再覆盖这个方法。
写到这里,我开始意识到,在已有项目里补一个接口方法,不能只看自己的代码能不能用,还要看看其他实现会不会受影响。当然,空默认实现不会自动清理资源,有状态的 Handler 仍然需要实现自己的清理逻辑。
由 CommonSubscriber 负责分发h3
接下来,修改 CommonProxySelectorDataSubscriber.refresh(),让它遍历 handlerMap,调用对应的 Handler:
handlerMap.values().forEach( ProxySelectorDataHandler::refresh);这样,清理逻辑就不再固定于 HTTP 同步模块,而是被放到了已有的插件处理结构中。
这样,HTTP 层不需要知道 TCP 服务该怎么关闭,只需要把刷新请求传下去,具体清理由插件负责。这次我主要处理的,还是 Issue 中的 HTTP 空快照场景。
缓存清理:移除引用不等于释放资源h2
接下来就是 TCP 插件的资源释放。
在 TcpBootstrapFactory 中,项目使用 ConcurrentHashMap 保存代理选择器与 TCP 服务实例之间的对应关系:
selectorName → BootstrapServer
tcp-proxy-a → BootstrapServer Atcp-proxy-b → BootstrapServer B最简单的做法似乎是直接调用 cache.clear()。但这里有一个问题:从 Map 中删除引用,并不等于真正停止 TCP 服务。
BootstrapServer 是运行中的服务实例,可能涉及监听端口以及其他网络资源。仅清空容器,并不会自动调用它的 shutdown()。
如果想确保 TCP 代理选择器被清理,就需要完成两个动作:
- 将实例从缓存中移除。
- 对移除的实例执行关闭操作。
因此,我在 TcpBootstrapFactory 中增加了 clearCache() 方法,统一处理这两件事。
为什么使用条件删除?h3
这里没有采用单纯的遍历再按 key 删除,而是使用类似下面的逻辑:
if (cache.remove(selectorName, bootstrapServer)) { // 关闭当前移除成功的实例}ConcurrentHashMap.remove(key, value) 的意义是:只有当前 key 对应的值仍然是这个实例时,才执行删除。
考虑一个并发场景:线程 A 正在执行全量清理;与此同时,线程 B 更新了某个代理选择器,把旧服务替换成新服务。
如果线程 A 直接按照遍历时拿到的 key 删除,就可能误删线程 B 刚刚更新的实例。条件删除可以避免这种特定情况:发现当前值已经改变时,线程 A 不再删除新值。
同时,只有成功移除缓存项的线程才继续关闭对应实例,也减少了并发清理时重复关闭同一对象的风险。
不过,条件删除保护的是当前这条缓存项,并没有把整个全量刷新变成原子操作。
如果一个 TCP Server 关闭失败呢?h3
假设当前存在三个服务:
Server AServer BServer C如果在关闭 Server B 时抛出异常,直接让异常中断整个遍历,就可能导致 Server C 没有机会执行清理。
因此,clearCache() 会在每个实例的 shutdown() 外捕获 RuntimeException,记录错误并继续处理其他实例。这样可以避免单个实例的关闭异常阻断其他实例的清理。
关闭失败的实例仍可能有资源没有释放。这次先记录异常,让其他服务继续清理,没有再把重试和资源恢复一起加进来。
单条删除:check-then-act 的竞态h2
除了全量清理,这次还修改了 TcpProxySelectorDataHandler.removeProxySelector()。
原来的实现会先调用 inCache() 判断某个选择器是否存在,再调用 removeCache() 获取实例并关闭。乍一看没有问题,但并发情况下,这两个操作不是原子的。
例如:
线程 A:检查 selector 是否存在 → true线程 B:清理并移除 selector线程 A:再次 removeCache() → null线程 A:调用 shutdown() → NullPointerException这里就是典型的 check-then-act 问题。
ConcurrentHashMap 能保证单次操作的线程安全,但无法自动保证两个独立操作之间的状态不变。
修复思路也比较直接:不再依赖之前的存在性判断,而是直接尝试移除,随后判断返回值是否为空。只有确实取得 BootstrapServer 实例时才调用 shutdown()。
这样既处理了并发移除导致的空指针风险,也让重复删除不存在的选择器成为安全的空操作。
这是修复过程中顺带发现的问题。改动不大,却让我意识到,不能因为用了 ConcurrentHashMap 就觉得这段逻辑一定安全。单次操作是线程安全的,但把“先判断、再删除”放在一起,中间还是可能被其他线程插进来。
回归测试:验证事件传递与关闭调用h2
这次 PR 增加了多层测试,而不是只检查 ProxySelectorRefresh 有没有调用订阅者。
| 测试位置 | 验证内容 |
|---|---|
ProxySelectorRefreshTest | 空列表触发刷新,非空列表仍执行订阅 |
CommonProxySelectorDataSubscriberTest | 刷新事件能传递到各 Handler |
TcpProxySelectorDataHandlerTest | 验证 shutdown() 调用、缓存移除和关闭异常隔离 |
HttpSyncDataServiceTest | HTTP 同步路径能触发 ProxySelector 刷新 |
其中,我觉得比较有意义的是关闭异常测试。
测试中放入两个模拟的 BootstrapServer,让其中一个在 shutdown() 时主动抛出异常,然后检查另一个是否仍然收到关闭调用,以及两个缓存项是否都已移除。
这样验证的不只是正常路径,还包括清理失败时的处理行为。
第一次参与之后,我学到了什么?h2
这次修复的代码改动不算多,但读代码和确定修改范围的过程,比我一开始想的复杂。最初觉得补一个 refresh() 就够了,后来才发现,要让这次调用真的起作用,还得跨过几个模块,一直追到缓存和 TCP Server。
对我来说,最大的收获不是多认识了几个类,而是开始找到阅读大型代码库的方法。刚接触 ShenYu 时,很容易觉得要先把整个项目弄懂,才有把握动手。但这次做下来,我发现可以先从一个具体问题开始:找到入口,顺着调用往下看,遇到模块边界时,再确认数据和操作有没有继续传下去。这样一点点缩小范围,比一开始就试图读完整个仓库更容易入手。
另一个感受是,修改已有代码和自己从头写项目不太一样。自己写的时候,很多接口和结构都能按自己的想法来;但在 ShenYu 里,改一个方法之前,还需要看看谁在调用它、哪些类实现了它。比如这次给 Handler 增加刷新入口,我不仅要考虑 TCP 插件怎么用,还要尽量不影响其他已有实现。这让我更具体地理解了“兼容性”为什么重要。
并发问题也是类似的。以前看到 ConcurrentHashMap,我更多关注的是这个容器本身是否线程安全。这次把两个线程的执行顺序拆开看,才发现判断和删除之间也会出问题。这个场景让我对“线程安全”有了更具体的认识,而不只是记住一个类的特点。
回头看,我还没有因此就熟悉整个 ShenYu,但至少更清楚该怎么面对一段不熟悉的代码了:不急着改,也不用等到理解所有模块才开始。先把问题相关的那条路径弄清楚,再确认自己的修改会影响哪里。对还在学习的我来说,这就是这次参与开源最实在的收获。