對接外部的一個接口時,發現一個鬼畜的問題,一直提示缺乏某個參數,一樣的url,經過curl命令訪問ok,可是改爲RestTemplate請求就不行;由於提供接口的是外部的,因此也沒法從服務端着手定位問題,特此記錄下這個問題的定位以及解決過程java
<!-- more -->git
首先咱們是經過get請求訪問服務端,參數直接拼接在url中;與咱們常規的get請求有點不同的是其中一個參數要求url編碼以後傳過去。github
由於不知道服務端的實現,因此再過後定位到這個問題以後,反推了一個服務端可能實現方式web
模擬一個接口,要求必須傳入accessKey,且這個參數必須和咱們定義的同樣(模擬身份標誌,用戶請求必須帶上本身的accessKey, 且必須合法)spring
@RestController public class HelloRest { public final String ALLOW_KEY = "ASHJRK3LJFD+R32SADFLK+FASDJ="; @GetMapping(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須要編碼後端
在拼接訪問url時,首先對accessKey進行編碼,獲得一個訪問的鏈接 http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui
瀏覽器
下面看下瀏覽器 + curl + restTemplate三種訪問姿式的返回結果app
瀏覽器訪問結果:curl
curl訪問結果:ide
restTemplate訪問結果:
@Test 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就不對了???
若是服務端的代碼也在咱們的掌控中,能夠經過debug服務端,查看請求參數來定位問題;可是這個問題出現時,服務端不在掌握中,這個時候就只能從客戶端出發,來推測可能出現問題的緣由了;
接下來記錄下咱們定位這個問題的"盲人摸象"過程
很容易懷疑問題出在url編碼後的參數上,直接傳這種編碼後的url參數會不會解析有問題,既然編碼以後不行,那就改爲不編碼試一試
@Test 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訪問,看下能不能正常訪問
@Test 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(); //建立請求方法的實例, 並指定請求url HttpGet 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有什麼潛規則是咱們不知道的
一樣的url,兩種不一樣的包返回結果不同,天然而然的就會想到對比下兩個的實現方式了,看看哪裏不一樣;若是對兩個包的源碼不太熟悉的話,想一會兒定位都問題,並不容易,對這兩個源碼,我也是不熟的,不過由於巧和,沒有深刻到底層的實現就發現了疑是問題的關鍵點所在
首先看的RestTemplate的發起請求的邏輯,以下(下圖中有關鍵點,單獨看不太容易抓到)
接下來再去debug HttpClient的請求鏈路中,在建立HttpGet
對象時,看到下面這一行代碼
單獨看上面兩個,好像發現不了什麼問題;可是兩個對比着看,就發現一個有意思的地方了,在HttpTemplate
的execute
方法中,建立URI竟然不是咱們熟知的 URI.create()
,接下來就來驗證下是否是這裏的問題了;
測試方法也比較簡單,直接傳入URI對象參數,看可否訪問成功
@Test 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參數的構建上了
前面定位到了出問題的環節,在RestTemplate建立URI對象的地方,接下來咱們深刻源碼,看一下這段邏輯的神奇之處
經過單步執行,下面截取關鍵鏈路的代碼,下面圈出的就是定位最終實現uri建立的具體對象org.springframework.web.util.DefaultUriBuilderFactory.DefaultUriBuilder
接下來重點放在具體實現方法中
// org.springframework.web.util.DefaultUriBuilderFactory.DefaultUriBuilder#build(java.lang.Object...) @Override 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()); } @Override 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 @Override 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
不一致致使的
最後一步,就是看下具體的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_PARAM if (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 { @Override 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的官方,對應連接爲
官方人員的解釋以下
根據 RFC 3986 加號等符號的確實能夠出如今參數中的,並且不須要編碼,有問題的在於服務端的解析沒有與時俱進
最後覆盤一下這個問題,當使用RestTemplate
發起請求時,若是請求參數中有須要url編碼時,不但願出現問題的使用姿式應傳入URI對象而不是字符串,以下面兩種方式
@Override @Nullable public <T> T execute(URI url, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback, @Nullable ResponseExtractor<T> responseExtractor) throws RestClientException { return doExecute(url, method, requestCallback, responseExtractor); } @Override @Nullable 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默認的方式,否則接收到數據會和預期的不一致
一灰灰blog