简

固定 query 參數和工具輸入合併後,為什麼整個請求地址變了?從丟失的問號、查錯位置的重複判斷,到回調中的 URI 驗證,記錄 ShenYu PR #7448 的修復與收穫。

少了一個問號,參數成了路徑:Apache ShenYu MCP 請求修復
9 分鐘
1812 字

給 MCP 工具配置一個 /users?fixed=true 的地址,再讓工具輸入補上 page=1,看起來只是把兩個 query 參數放到一起。但 ShenYu 原來的構造結果是 /users&page=1&fixed=true。

參數一個都沒少,位置卻全錯了。沒有 ?,後面的內容就不再是 query,而是路徑的一部分。

Issue #7442 記錄了這個問題。我在 PR #7448 中修復它時,還一起處理了重複參數判斷:固定參數已經存在時,動態輸入不應再添加同名字段;但路徑或參數值中出現了相似文字,也不該讓動態參數被誤刪。

這次修改集中在 RequestConfigHelper。我需要確認的不只是地址字符串變得好看了,而是回調拿到它之後,仍然能把路徑和參數分別讀對。

模板有問號,不代表拆出來的路徑還有問號h2

buildPath() 會先拆開 URL 模板。對於 /users?fixed=true,拆出的 basePath 是 /users,existingQuery 是 fixed=true。動態參數則單獨累積到 queryBuilder 中。

到這裏都沒有問題。錯誤發生在重新拼接的時候。

原來的 buildFinalPath() 根據 hasExistingQuery 選擇分隔符:模板有 query,就用 & 添加動態參數;沒有才用 ?。

但這個標記描述的是原模板,不是當前正在拼接的 basePath。問號已經在拆分時被拿掉了,不能因為原來有,就假定現在也有。

階段內容
原模板/users?fixed=true
拆出的路徑/users
生成的動態參數page=1
原來的拼接結果/users&page=1&fixed=true
修復後的拼接結果/users?page=1&fixed=true

我把添加動態 query 的第一處分隔符固定為 ?。後面如果還有固定 query,再用 & 接上;如果沒有動態參數,就讓固定 query 自己帶上第一個 ?。

這樣,分隔符取決於實際正在構造的內容,而不是之前那個已經被拆開的模板。

判斷參數是否重複,得去 query 裏找h2

補上問號之後,還需要檢查固定參數和動態參數同名時怎麼辦。

這次保留固定參數優先的規則。例如模板已經寫了 /users?page=10,工具輸入又給出 page=1,最終仍然使用 /users?page=10,不額外追加一個 page=1。

原代碼雖然有重複判斷,但它檢查的是 modifiedBasePath.contains(key + "=")。

此時 modifiedBasePath 已經不含 query。它既看不到固定的 page=10,又可能因為路徑裏恰好包含 page=,誤以為參數已經存在。

所以,我把 existingQuery 傳進 processArguments(),讓判斷回到固定 query 上。同時,不直接搜索任意位置的 page=,而是檢查它是否出現在開頭,或者緊跟在 & 後面。

這個邊界很重要。homepage=10 不是 page=10;note=page=10 裏的 page=10 則屬於參數值,也不是一個名叫 page 的字段。

迴歸用例把這兩種干擾放在同一個模板裏:

/users?homepage=10&note=page=10

再傳入動態 page=1,結果應該保留原來的兩個字段,並正常加入 page。如果只用不分位置的子串搜索,就會把真正需要添加的參數跳過去。

這裏採用的是對現有 query 文本的字段邊界檢查,沒有另做一套通用 URL 解析器。固定 query 的原文仍然保留,已有的參數優先級也不變。

編碼前,先分清原始值和已編碼內容h2

合併參數時,不能因為兩邊都是字符串,就把它們當成同一種輸入。工具傳入的是待編碼的值,模板裏保留的則是已有的 query 文本,其中可能已經包含編碼結果。

例如,動態輸入 hello world & 50% 中的 & 是值的一部分,不是參數分隔符,需要編碼成 %26。但固定 query 中如果已經有 %26,再把它當成原始值編碼,百分號就會變成 %25,得到 %2526。看起來只是多處理了一遍,傳給下游的含義卻變了。

因此,這次合併只對動態值沿用 UriUtils.encodeQueryParam(),固定 query 保留原文。迴歸用例把原始輸入和已編碼的固定值放在一起,檢查的就是這兩種狀態沒有被混為一談。

這裏要先確認的不是“該調用哪個編碼方法”,而是“這段內容之前已經經過了什麼處理”。分清這個前提,才能判斷哪一層需要編碼、哪一層只負責拼接,避免在修復一個問題時又改變了原本正常的參數含義。

字符串包含參數,還不夠證明請求構造正確h2

這個 Bug 有一個很容易誤判的地方:錯誤地址裏也能找到 page=1 和 fixed=true,甚至連 & 都在。如果測試只檢查這些子串,仍然可能放過它。

因此,helper 的新增用例直接比較完整結果,覆蓋固定與動態參數合併、固定同名參數優先、字段邊界,以及編碼保留。

但 helper 返回正確字符串,還需要和實際使用它的地方接起來看。

ShenyuToolCallback 會根據配置調用 buildPath(),再用結果構造內部 HTTP 請求,並更新 ShenyuContext。我補了一條回調測試,用 MockServerWebExchange 和真實的回調構造方法檢查結果,而不是隻停在 helper 的返回值。

這條用例從一個 /mcp 請求出發,模板設為 /users?fixed=true,輸入提供 page=1。構造後分別確認:

  • 請求 URI 是 http://gateway.example:9195/users?page=1&fixed=true。
  • 請求的 query 參數能分別讀出 page=1 和 fixed=true。
  • 上下文的 path 和 realUrl 都是 /users,不混入 query。

這些斷言檢查的是回調構造出的請求及上下文,沒有實際向後端發起網絡請求。它們把這次需要修復的邊界接了起來:helper 負責拼接,回調負責使用,最終兩邊對 path 和 query 的理解要一致。

維護者在 Review 中確認,修復保留了固定參數,能加入動態參數,也沒有覆蓋已有的同名字段。對方在 Java 17 下針對當前提交運行目標測試後批准了 PR,隨後合併。

看起來相似的字符串,可能已經不是同一種數據h2

這次我意識到,拆開再拼回去,並不是原樣搬運。urlTemplate、basePath 和 existingQuery 雖然都是字符串,表示的內容卻已經不同。沿用原模板的判斷條件之前,需要先確認它對當前這段內容是否還成立。

重複參數的判斷也是如此。我需要找的是一個 query 字段,不是任意位置的一段相同文字。先分清查找對象,再決定在哪兒查、按什麼邊界查,比往 contains() 裏補幾個條件更清楚。

測試上,我也更理解了為什麼要檢查解析後的含義。參數出現在字符串裏,不代表它就是參數;只有請求能把它讀進 query,而路徑仍然保持 /users,才能説明這次拼接達到了預期。

這次的改動不大,但從 helper 一直看到回調以後,修復依據就具體了:保留固定配置的含義,讓動態輸入在正確的位置加入,再確認使用方讀到的還是同一回事。