简

從 nnamespace 這個小錯誤出發,我發現僅僅去掉一個 + 1,還會留下參數丟失和誤截地址的問題。記錄這次使用 Dubbo URL 模型修復 Registry 地址重寫,以及圍繞相鄰輸入補充測試的過程。

一個多出來的 n,為什麼不能只刪掉:Apache ShenYu Registry 地址重寫修復
15 分鐘
3021 字

這次問題,最先讓我注意到的是一個多出來的 n。

在 Apache ShenYu 的 Dubbo 插件中,請求可以帶上 namespace,用於覆蓋當前 Reference 的 Registry 地址參數。Issue #6516 裏,原本的 namespace=old 在替換後卻變成了 nnamespace=new:

原地址:zookeeper://127.0.0.1:2181?namespace=old
请求: namespace: new
结果: zookeeper://127.0.0.1:2181?nnamespace=new

找到代碼以後,我的第一反應也是:是不是把 substring 裏那個 + 1 去掉就好了?看起來只是多保留了一個字符,修復應該很直接。

但再往旁邊看幾個輸入,我發現事情沒有這麼簡單。地址裏多一個參數,或者主機名裏恰好出現 namespace,這段邏輯仍然可能改壞整個地址。多出來的 n 只是最容易看見的結果,真正的問題是:代碼在用字符串的位置猜 URL 的結構。

最後這次修復合併為 PR #7425,只改了兩個文件。相比改動大小,我更想記下的是:自己怎麼從“刪掉一個字符”,走到了重新理解這段代碼到底要做什麼。

修掉一個字符,不等於修掉同一種錯誤h2

問題出在 ApacheDubboConfigCache 的 changeRegistryAddressNamespace(...)。原來的實現分成兩條路徑:地址裏沒有 namespace,就拼上 ?namespace=...;已經有了,就找到它的位置,截掉後半段,再拼一個新值。

把關鍵部分單獨拿出來,是這樣的:

// 原实现的关键逻辑,省略 RegistryConfig 的创建和绑定
if (!address.contains(Constants.NAMESPACE)) {
newAddress = address + "?" + Constants.NAMESPACE + "=" + namespace;
} else {
newAddress = address.substring(
0, address.indexOf(Constants.NAMESPACE) + 1)
+ Constants.NAMESPACE + "=" + namespace;
}

這裏的 Constants.NAMESPACE 就是 "namespace"。substring 的結束位置不包含在結果裏,但這裏加了 1,於是多保留了開頭的 n。後面又拼上完整的 namespace=new,就變成了 nnamespace=new。

把 + 1 去掉,確實能讓 Issue 裏的這個例子恢復正常。可如果地址是下面這樣呢?

zookeeper://127.0.0.1:2181?namespace=old&group=g&timeout=5000

我們只想換掉 namespace,group 和 timeout 都應該留下。但原來的操作是“從 namespace 的位置截到結尾”,這兩個參數會一起被丟掉。即使不再多出那個 n,這件事也沒有改變。

圖 1 · 只想換一個參數,卻可能把後面的配置一起截掉
字符串截取與參數修改的對比 原地址的參數是 namespace=old、group=g、timeout=5000。原實現從 namespace 的位置截斷,生成 nnamespace=new 並丟失其餘參數;使用 Dubbo URL 模型設置 namespace 後,group 和 timeout 仍然保留。 原 Registry 地址 namespace=old · group=g · timeout=5000 原來的字符串邏輯 修復後的 URL 模型 找到位置,截掉後半段 substring + 拼接新值 解析地址,只設置 namespace addParameter → toFullString nnamespace=new group / timeout 一起丟失 namespace=new group=g · timeout=5000 保留
窄屏可在圖內左右滑動查看。

這時我開始覺得,不應該只盯着那個字符。需要繼續問的是:這段代碼還默認了哪些條件?

出現了同一個單詞,不代表找到了同一個參數h2

另一個分支也有問題。如果原地址已經有 group=g,只是沒有 namespace,直接追加 ?namespace=new 就會得到:

zookeeper://127.0.0.1:2181?group=g?namespace=new

第二個 query 參數應該由 & 分隔,而不是再加一個 ?。不過,就算為它補一個“有沒有問號”的判斷,仍然沒有解決更根本的問題:contains("namespace") 判斷的是整個字符串,不是 query 的參數名。

namespace 出現在哪裏為什麼不能按原邏輯截取
主機名:my-namespace-svc它屬於 host,不是參數名
路徑:/namespace它屬於 path,不能當成 query 來替換
參數值:group=namespace值裏出現這個詞,不代表存在同名 key

這些都是合法輸入可能具有的形態,卻會被同一個 contains 命中。接着用第一個 indexOf 截取,甚至可能從主機名或路徑中間截斷地址。

到這裏,繼續補字符串規則就不太合適了。問題不是“namespace 在第幾位”,而是“query 裏有沒有一個叫 namespace 的參數,以及怎麼設置它”。

用已有的 URL 模型,直接表達要改什麼h2

Dubbo 本身已經有 org.apache.dubbo.common.URL。最終我沒有再手工判斷分隔符和字符串位置,而是複用這個模型:

URL.valueOf(currentRegistryConfig.getAddress())
.addParameter(Constants.NAMESPACE, namespace)
.toFullString()

這三步分別是解析原地址、設置 namespace 參數、重新序列化。已有這個參數就替換它,沒有就新增;主機名、路徑和其他參數不再跟着某個字符串位置一起被截走。

對我來説,最明顯的變化其實是代碼的表達方式。原來的代碼在解釋“怎麼找到它、截到哪裏、再拼什麼”,現在只需要説明“我要修改這個參數”。其他部分交給項目本身已經使用的 URL 模型處理,也不需要引入新的依賴。

這裏用的是 Dubbo 的 URL 模型,不是 java.net.URL。我希望保持項目原有的地址解析語義,而不是為了這次修復另寫一套 URL 規則。

測試要覆蓋相鄰輸入,也要比較真正的語義h2

如果測試只寫 Issue 裏的那個地址,去掉 + 1 和換成 URL 模型,都可能讓它通過。可這兩種修復解決的問題範圍並不一樣。

所以我把前面發現的相鄰輸入一起整理成了八組參數化場景:

輸入形態重點確認
只有 namespace=old舊值被替換,沒有多餘的 n
完全沒有 query 參數能新增 namespace
有 group=g,沒有 namespace新增參數,同時保留 group
namespace 在最前,後面還有參數後面的 group、timeout 不丟失
namespace 在中間前後參數都保留
host 包含 namespace主機名不被誤截
path 和參數值包含 namespace不把它們誤認成參數名
有用户名、密碼及 group=g%26x地址組件和已有編碼參數值保留

測試裏還有一個我覺得很值得記下來的細節:沒有直接比較最終 URL 字符串。

對於這裏的 Registry 地址,下面兩個 query 表達的是同一組參數:

?namespace=new&group=g
?group=g&namespace=new

重新序列化後,參數順序未必和我手寫的 expected 一樣。如果只比較字符串,測試可能因為順序變化而失敗,但地址本身並沒有錯。

所以斷言會把 expected 和 actual 都重新解析成 Dubbo URL,分別比較 protocol、host、port、path、username、password 和 parameters。像下面這兩項,就是完整斷言的一部分:

URL expected = URL.valueOf(expectedAddress);
URL actual = URL.valueOf(actualAddress);
assertEquals(expected.getHost(), actual.getHost());
assertEquals(expected.getParameters(), actual.getParameters());

我需要確認的是 namespace 已更新、其餘地址信息還在,而不是要求參數必須按某一種固定順序打印。這讓我對“測試到底應該比較什麼”有了更具體的理解。

兩條入口都要接住,原始配置也不能被改動h2

ApacheDubboConfigCache 不只有普通的 Reference 構建路徑,還有根據 RuleData 和 DubboUpstream 構建 Reference 的路徑。兩邊在 namespace 非空時,都會調用同一個地址重寫方法。

除了八組參數化測試,我也補了一條 upstream 路徑的迴歸測試,確認經過另一條入口,namespace 能更新,group 和 timeout 仍然保留。

還有一個邊界需要守住:namespace 來自當前請求,不應該因此修改原始 Registry 配置。實現仍然創建新的 RegistryConfig,將重寫後的地址綁定到當前 Reference,而不是直接改傳入的配置對象。

圖 2 · 兩條入口共用重寫邏輯,結果只綁定到當前 Reference
Reference 構建與 Registry 配置隔離 普通 Reference 從當前 RegistryConfig 讀取地址,upstream Reference 從 DubboUpstream 構造臨時配置。在 namespace 非空時,兩條入口調用共同的 changeRegistryAddressNamespace,解析地址、設置參數並序列化。結果寫入新的 RegistryConfig 後綁定當前 Reference,原 RegistryConfig 與 DubboUpstream 保持不變。 普通 Reference 讀取當前 RegistryConfig Upstream Reference 從 DubboUpstream 構造臨時配置 namespace 非空 namespace 非空 changeRegistryAddressNamespace 解析原地址 → 設置 namespace → 重新序列化 URL 寫入新的 RegistryConfig 保留 register=false 綁定到當前 Reference
原 RegistryConfig 的地址、DubboUpstream 的 registry 字段都保持原值。窄屏可在圖內左右滑動查看。

測試不只檢查新地址,也檢查 reference.getRegistry() 不是原配置對象、原地址仍然不變;upstream 用例同樣檢查 DubboUpstream.getRegistry() 沒有被修改。這樣一次請求的覆蓋值,才不會順手變成後續請求共用的配置。

小修復的價值,是讓我看見原先沒問過的問題h2

前面幾個 ShenYu PR,我花了不少時間在同步鏈路、併發和緩存生命週期上。這次沒有那麼多模塊,核心改動也很短。剛看到 Issue 時,我確實覺得它可能只是一個很快就能修好的字符串錯誤。

但把那些輸入放在一起以後,我意識到:代碼能處理眼前這個例子,不代表我已經理解了它應該接受什麼樣的輸入。地址多一個參數,或者同一個詞換個位置,就足以把原來沒寫出來的假設暴露出來。

以後再遇到類似問題,我會先問自己:我是在修改數據的某個字段,還是隻是在修改它打印出來的樣子? 如果項目已經有對應的模型,先讀懂並複用它,往往比自己繼續補分隔符和截取規則更合適。

測試也是一樣。我以前很容易把“expected 和 actual 一樣”當作目標,這次才更具體地想清楚:什麼應該一樣,什麼只是表示方式不同。namespace 的值、其他參數和原配置的狀態需要保證,query 參數的打印順序則不是這裏要守住的東西。

這個 PR 沒有很大的 diff,但它讓我多了一點面對小 Bug 時的耐心。先別急着把最顯眼的那個字符刪掉;再往旁邊看一步,可能才會發現真正該修的地方。