javascript
Spring-RestTemplate之urlencode参数解析异常全程分析
對接外部的一個接口時,發現一個鬼畜的問題,一直提示缺少某個參數,同樣的url,通過curl命令訪問ok,但是改成RestTemplate請求就不行;因為提供接口的是外部的,所以也無法從服務端著手定位問題,特此記錄下這個問題的定位以及解決過程
I. 問題復現
首先我們是通過get請求訪問服務端,參數直接拼接在url中;與我們常規的get請求有點不一樣的是其中一個參數要求url編碼之后傳過去。
因為不知道服務端的實現,所以再事后定位到這個問題之后,反推了一個服務端可能實現方式
1. web服務模擬
模擬一個接口,要求必須傳入accessKey,且這個參數必須和我們定義的一樣(模擬身份標志,用戶請求必須帶上自己的accessKey, 且必須合法)
public class HelloRest {public final String ALLOW_KEY = "ASHJRK3LJFD+R32SADFLK+FASDJ=";(path = "access")public String access(String accessKey, String name) {System.out.println(accessKey + "|" + name) ;if (ALLOW_KEY.equals(accessKey)) {return "true";} else {return "false";}} } 復制代碼這個接口只支持get請求,把參數放在url中的時候,很明顯這個accessKey需要編碼
2. 訪問驗證
在拼接訪問url時,首先對accessKey進行編碼,得到一個訪問的連接 http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui
下面看下瀏覽器 + curl + restTemplate三種訪問姿勢的返回結果
瀏覽器訪問結果:
curl訪問結果:
restTemplate訪問結果:
public void testUrlEncode() {String url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui";RestTemplate restTemplate = new RestTemplate();String ans = restTemplate.getForObject(url, String.class);System.out.println(ans); } 復制代碼看到上面的輸出,結果就很有意思了,同樣的url為啥前面的訪問沒啥問題,換到RestTemplate就不對了???
II. 問題定位分析
如果服務端的代碼也在我們的掌控中,可以通過debug服務端,查看請求參數來定位問題;但是這個問題出現時,服務端不在掌握中,這個時候就只能從客戶端出發,來推測可能出現問題的原因了;
接下來記錄下我們定位這個問題的"盲人摸象"過程
1. 問題猜測
很容易懷疑問題出在url編碼后的參數上,直接傳這種編碼后的url參數會不會解析有問題,既然編碼之后不行,那就改成不編碼試一試
public void testUrlEncode() {String url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui";RestTemplate restTemplate = new RestTemplate();String ans = restTemplate.getForObject(url, String.class);System.out.println(ans);url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD+R32SADFLK+FASDJ=&name=yihuihui";ans = restTemplate.getForObject(url, String.class);System.out.println(ans); } 復制代碼毫無疑問,訪問依然失敗,模擬case如下
傳編碼后的不行,傳編碼之前的也不行,這就蛋疼了;接下來怎么辦?換個http包試一試
接下來改用HttpClient訪問,看下能不能正常訪問
public void testUrlEncode() throws IOException {String url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui";RestTemplate restTemplate = new RestTemplate();String ans = restTemplate.getForObject(url, String.class);System.out.println(ans);//創建httpclient對象CloseableHttpClient httpClient = HttpClients.createDefault();//創建請求方法的實例, 并指定請求urlHttpGet httpget = new HttpGet(url);//獲取http響應狀態碼CloseableHttpResponse response = httpClient.execute(httpget);HttpEntity entity = response.getEntity();//接收響應頭String content = EntityUtils.toString(entity, "utf-8");System.out.println(httpget.getURI());System.out.println(content);httpClient.close(); } 復制代碼輸出結果如下,神器的一幕出現了,返回結果正常了
到了這一步,基本上可以知道是RestTemplate的使用問題了,要么就是操作姿勢不對,要么就是RestTemplate有什么潛規則是我們不知道的
2. 問題定位
同樣的url,兩種不同的包返回結果不一樣,自然而然的就會想到對比下兩個的實現方式了,看看哪里不同;如果對兩個包的源碼不太熟悉的話,想一下子定位都問題,并不容易,對這兩個源碼,我也是不熟的,不過因為巧和,沒有深入到底層的實現就發現了疑是問題的關鍵點所在
首先看的RestTemplate的發起請求的邏輯,如下(下圖中有關鍵點,單獨看不太容易抓到)
接下來再去debug HttpClient的請求鏈路中,在創建HttpGet對象時,看到下面這一行代碼
單獨看上面兩個,好像發現不了什么問題;但是兩個對比著看,就發現一個有意思的地方了,在HttpTemplate的execute方法中,創建URI居然不是我們熟知的 URI.create(),接下來就來驗證下是不是這里的問題了;
測試方法也比較簡單,直接傳入URI對象參數,看能否訪問成功
public void testUrlEncode() throws IOException {String url = "http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui";RestTemplate restTemplate = new RestTemplate();String ans = restTemplate.getForObject(url, String.class);System.out.println(ans);ans = restTemplate.getForObject(URI.create(url), String.class);System.out.println(ans); } 復制代碼從截圖也可以看出,返回true表示成功了,因此我們可以圈定問題的范圍,就在RestTemplate中url參數的構建上了
3. 原因分析
前面定位到了出問題的環節,在RestTemplate創建URI對象的地方,接下來我們深入源碼,看一下這段邏輯的神奇之處
通過單步執行,下面截取關鍵鏈路的代碼,下面圈出的就是定位最終實現uri創建的具體對象org.springframework.web.util.DefaultUriBuilderFactory.DefaultUriBuilder
接下來重點放在具體實現方法中
// org.springframework.web.util.DefaultUriBuilderFactory.DefaultUriBuilder#build(java.lang.Object...) public URI build(Map<String, ?> uriVars) {if (!defaultUriVariables.isEmpty()) {Map<String, Object> map = new HashMap<>();map.putAll(defaultUriVariables);map.putAll(uriVars);uriVars = map;}if (encodingMode.equals(EncodingMode.VALUES_ONLY)) {uriVars = UriUtils.encodeUriVariables(uriVars);}UriComponents uriComponents = this.uriComponentsBuilder.build().expand(uriVars);if (encodingMode.equals(EncodingMode.URI_COMPONENT)) {uriComponents = uriComponents.encode();}return URI.create(uriComponents.toString()); } public URI build(Object... uriVars) {if (ObjectUtils.isEmpty(uriVars) && !defaultUriVariables.isEmpty()) {return build(Collections.emptyMap());}if (encodingMode.equals(EncodingMode.VALUES_ONLY)) {uriVars = UriUtils.encodeUriVariables(uriVars);}UriComponents uriComponents = this.uriComponentsBuilder.build().expand(uriVars);if (encodingMode.equals(EncodingMode.URI_COMPONENT)) {uriComponents = uriComponents.encode();}return URI.create(uriComponents.toString()); } 復制代碼兩個builder方法提供關鍵URI生成邏輯,根據最后的返回可以知道,生成URI依然是使用URI.create,所以出問題的地方就應該是 uriComponents.encode() 實現url編碼的地方了,對應的代碼如下
// org.springframework.web.util.HierarchicalUriComponents#encode public HierarchicalUriComponents encode(Charset charset) {if (this.encoded) {return this;}String scheme = getScheme();String fragment = getFragment();String schemeTo = (scheme != null ? encodeUriComponent(scheme, charset, Type.SCHEME) : null);String fragmentTo = (fragment != null ? encodeUriComponent(fragment, charset, Type.FRAGMENT) : null);String userInfoTo = (this.userInfo != null ? encodeUriComponent(this.userInfo, charset, Type.USER_INFO) : null);String hostTo = (this.host != null ? encodeUriComponent(this.host, charset, getHostType()) : null);PathComponent pathTo = this.path.encode(charset);MultiValueMap<String, String> paramsTo = encodeQueryParams(charset);return new HierarchicalUriComponents(schemeTo, fragmentTo, userInfoTo, hostTo, this.port, pathTo, paramsTo, true, false); }// org.springframework.web.util.HierarchicalUriComponents#encodeQueryParams private MultiValueMap<String, String> encodeQueryParams(Charset charset) {int size = this.queryParams.size();MultiValueMap<String, String> result = new LinkedMultiValueMap<>(size);this.queryParams.forEach((key, values) -> {String name = encodeUriComponent(key, charset, Type.QUERY_PARAM);List<String> encodedValues = new ArrayList<>(values.size());for (String value : values) {encodedValues.add(encodeUriComponent(value, charset, Type.QUERY_PARAM));}result.put(name, encodedValues);});return result; } 復制代碼記錄下參數編碼的前后對比,編碼前參數為 ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D
編碼之后,參數變為ASHJRK3LJFD%252BR32SADFLK%252BFASDJ%253D
對比下上面的區別,發現這個參數編碼,會將請求參數中的 % 編碼為 %25, 所以問題就清楚了,我傳進來本來就已經是編碼之后的了,結果再編碼一次,相當于修改了請求參數了
看到這里,自然而然就有一個想法,既然你會給我的參數進行編碼,那么為啥我傳入的非編碼的參數也不行呢?
接下來我們改一下請求的url參數,再執行一下上面的過程,看下編碼之后的參數長啥樣
從上圖很明顯可以看出,現編碼之后的和我們URLEncode的結果不一樣,加號沒有被編碼, 我們調用jdk的url解碼,發現將上面編碼后的內容解碼出來,+號沒了
所以問題的原因也找到了,RestTemplate中首先url編碼解碼的邏輯和URLEncode/URLDecode不一致導致的
4. 關鍵代碼分析
最后一步,就是看下具體的url參數編碼的實現方法了,下面貼出源碼,并在關鍵地方給出說明
// org.springframework.web.util.HierarchicalUriComponents#encodeUriComponent(java.lang.String, java.nio.charset.Charset, org.springframework.web.util.HierarchicalUriComponents.Type) static String encodeUriComponent(String source, Charset charset, Type type) {if (!StringUtils.hasLength(source)) {return source;}Assert.notNull(charset, "Charset must not be null");Assert.notNull(type, "Type must not be null");byte[] bytes = source.getBytes(charset);ByteArrayOutputStream bos = new ByteArrayOutputStream(bytes.length);boolean changed = false;for (byte b : bytes) {if (b < 0) {b += 256;}// 注意這一行,我們的type實際上為 org.springframework.web.util.HierarchicalUriComponents.Type#QUERY_PARAMif (type.isAllowed(b)) {bos.write(b);}else {bos.write('%');char hex1 = Character.toUpperCase(Character.forDigit((b >> 4) & 0xF, 16));char hex2 = Character.toUpperCase(Character.forDigit(b & 0xF, 16));bos.write(hex1);bos.write(hex2);changed = true;}}return (changed ? new String(bos.toByteArray(), charset) : source); } 復制代碼if/else 這一段邏輯需要撈出來好好看一下,這里決定了什么字符會進行編碼;其中 type.isAllowed 對應的代碼為
// org.springframework.web.util.HierarchicalUriComponents.Type#QUERY_PARAM QUERY_PARAM {public boolean isAllowed(int c) {if ('=' == c || '&' == c) {return false;}else {return isPchar(c) || '/' == c || '?' == c;}} },// isPchar 對應的相關代碼為/*** Indicates whether the given character is in the {@code pchar} set.* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>*/ protected boolean isPchar(int c) {return (isUnreserved(c) || isSubDelimiter(c) || ':' == c || '@' == c); }/*** Indicates whether the given character is in the {@code unreserved} set.* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>*/ protected boolean isUnreserved(int c) {return (isAlpha(c) || isDigit(c) || '-' == c || '.' == c || '_' == c || '~' == c); }/*** Indicates whether the given character is in the {@code sub-delims} set.* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>*/ protected boolean isSubDelimiter(int c) {return ('!' == c || '$' == c || '&' == c || '\'' == c || '(' == c || ')' == c || '*' == c || '+' == c ||',' == c || ';' == c || '=' == c); }/*** Indicates whether the given character is in the {@code ALPHA} set.* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>*/ protected boolean isAlpha(int c) {return (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'); }/*** Indicates whether the given character is in the {@code DIGIT} set.* @see <a href="http://www.ietf.org/rfc/rfc3986.txt">RFC 3986, appendix A</a>*/ protected boolean isDigit(int c) {return (c >= '0' && c <= '9'); } 復制代碼上面涉及的方法挺多,小結一下需要轉碼的字符為: =, &
下圖是維基百科中關于url參數編碼的說明,比如上例中的+號,按照維基百科的需要轉碼;但是在Spring中卻是不需要轉碼的
所以為啥Spring要這么干呢?網上搜索了一下,發現有人也遇到過這個問題,并提給了Spring的官方,對應鏈接為
- HierarchicalUriComponents.encodeUriComponent() method can not encode Pchar
官方人員的解釋如下
根據 RFC 3986 加號等符號的確實可以出現在參數中的,而且不需要編碼,有問題的在于服務端的解析沒有與時俱進
III. 小結
最后復盤一下這個問題,當使用RestTemplate發起請求時,如果請求參數中有需要url編碼時,不希望出現問題的使用姿勢應傳入URI對象而不是字符串,如下面兩種方式
public <T> T execute(URI url, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback,@Nullable ResponseExtractor<T> responseExtractor) throws RestClientException {return doExecute(url, method, requestCallback, responseExtractor); } public <T> T getForObject(URI url, Class<T> responseType) throws RestClientException {RequestCallback requestCallback = acceptHeaderRequestCallback(responseType);HttpMessageConverterExtractor<T> responseExtractor =new HttpMessageConverterExtractor<>(responseType, getMessageConverters(), logger);return execute(url, HttpMethod.GET, requestCallback, responseExtractor); } 復制代碼注意Spring的url參數編碼,默認只會針對 = 和 & 進行處理;為了兼容我們一般的后端的url編解碼處理在需要編碼參數時,目前盡量不要使用Spring默認的方式,不然接收到數據會和預期的不一致
其他
- 源碼工程:spring-boot-demo
- 一灰灰Blog-Spring專題博客 spring.hhui.top
一灰灰blog
總結
以上是生活随笔為你收集整理的Spring-RestTemplate之urlencode参数解析异常全程分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: c语言数组与指针浅析
- 下一篇: iptables实现访问A的请求重定向到