從 nnamespace 這個小錯誤出發,我發現僅僅去掉一個 + 1,還會留下參數丟失和誤截地址的問題。記錄這次使用 Dubbo URL 模型修復 Registry 地址重寫,以及圍繞相鄰輸入補充測試的過程。
這次問題,最先讓我注意到的是一個多出來的 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,這件事也沒有改變。
這時我開始覺得,不應該只盯着那個字符。需要繼續問的是:這段代碼還默認了哪些條件?
出現了同一個單詞,不代表找到了同一個參數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,而不是直接改傳入的配置對象。
測試不只檢查新地址,也檢查 reference.getRegistry() 不是原配置對象、原地址仍然不變;upstream 用例同樣檢查 DubboUpstream.getRegistry() 沒有被修改。這樣一次請求的覆蓋值,才不會順手變成後續請求共用的配置。
小修復的價值,是讓我看見原先沒問過的問題h2
前面幾個 ShenYu PR,我花了不少時間在同步鏈路、併發和緩存生命週期上。這次沒有那麼多模塊,核心改動也很短。剛看到 Issue 時,我確實覺得它可能只是一個很快就能修好的字符串錯誤。
但把那些輸入放在一起以後,我意識到:代碼能處理眼前這個例子,不代表我已經理解了它應該接受什麼樣的輸入。地址多一個參數,或者同一個詞換個位置,就足以把原來沒寫出來的假設暴露出來。
以後再遇到類似問題,我會先問自己:我是在修改數據的某個字段,還是隻是在修改它打印出來的樣子? 如果項目已經有對應的模型,先讀懂並複用它,往往比自己繼續補分隔符和截取規則更合適。
測試也是一樣。我以前很容易把“expected 和 actual 一樣”當作目標,這次才更具體地想清楚:什麼應該一樣,什麼只是表示方式不同。namespace 的值、其他參數和原配置的狀態需要保證,query 參數的打印順序則不是這裏要守住的東西。
這個 PR 沒有很大的 diff,但它讓我多了一點面對小 Bug 時的耐心。先別急着把最顯眼的那個字符刪掉;再往旁邊看一步,可能才會發現真正該修的地方。