Spring-RestTemplate之urlencode參數(shù)解析異常全程分析

對接外部的一個接口時,發(fā)現(xiàn)一個鬼畜的問題,一直提示缺少某個參數(shù),同樣的url,通過curl命令訪問ok,但是改成RestTemplate請求就不行;因為提供接口的是外部的,所以也無法從服務(wù)端著手定位問題,特此記錄下這個問題的定位以及解決過程

I. 問題復(fù)現(xiàn)

首先我們是通過get請求訪問服務(wù)端,參數(shù)直接拼接在url中;與我們常規(guī)的get請求有點不一樣的是其中一個參數(shù)要求url編碼之后傳過去。

因為不知道服務(wù)端的實現(xiàn),所以再事后定位到這個問題之后,反推了一個服務(wù)端可能實現(xiàn)方式

1. web服務(wù)模擬

模擬一個接口,要求必須傳入accessKey,且這個參數(shù)必須和我們定義的一樣(模擬身份標(biāo)志,用戶請求必須帶上自己的accessKey, 且必須合法)

@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請求,把參數(shù)放在url中的時候,很明顯這個accessKey需要編碼

2. 訪問驗證

在拼接訪問url時,首先對accessKey進行編碼,得到一個訪問的連接 http://localhost:39531/access?accessKey=ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D&name=yihuihui

下面看下瀏覽器 + curl + restTemplate三種訪問姿勢的返回結(jié)果

瀏覽器訪問結(jié)果:

瀏覽器訪問

curl訪問結(jié)果:

curl訪問

restTemplate訪問結(jié)果:

@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);
}
restTemplate訪問

看到上面的輸出,結(jié)果就很有意思了,同樣的url為啥前面的訪問沒啥問題,換到RestTemplate就不對了???

II. 問題定位分析

如果服務(wù)端的代碼也在我們的掌控中,可以通過debug服務(wù)端,查看請求參數(shù)來定位問題;但是這個問題出現(xiàn)時,服務(wù)端不在掌握中,這個時候就只能從客戶端出發(fā),來推測可能出現(xiàn)問題的原因了;

接下來記錄下我們定位這個問題的"盲人摸象"過程

1. 問題猜測

很容易懷疑問題出在url編碼后的參數(shù)上,直接傳這種編碼后的url參數(shù)會不會解析有問題,既然編碼之后不行,那就改成不編碼試一試

@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如下

test 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);


    //創(chuàng)建httpclient對象
    CloseableHttpClient httpClient = HttpClients.createDefault();
    //創(chuàng)建請求方法的實例, 并指定請求url
    HttpGet httpget = new HttpGet(url);
    //獲取http響應(yīng)狀態(tài)碼
    CloseableHttpResponse response = httpClient.execute(httpget);
    HttpEntity entity = response.getEntity();
    //接收響應(yīng)頭
    String content = EntityUtils.toString(entity, "utf-8");
    System.out.println(httpget.getURI());
    System.out.println(content);
    httpClient.close();
}

輸出結(jié)果如下,神器的一幕出現(xiàn)了,返回結(jié)果正常了

httpclient

到了這一步,基本上可以知道是RestTemplate的使用問題了,要么就是操作姿勢不對,要么就是RestTemplate有什么潛規(guī)則是我們不知道的

2. 問題定位

同樣的url,兩種不同的包返回結(jié)果不一樣,自然而然的就會想到對比下兩個的實現(xiàn)方式了,看看哪里不同;如果對兩個包的源碼不太熟悉的話,想一下子定位都問題,并不容易,對這兩個源碼,我也是不熟的,不過因為巧和,沒有深入到底層的實現(xiàn)就發(fā)現(xiàn)了疑是問題的關(guān)鍵點所在

首先看的RestTemplate的發(fā)起請求的邏輯,如下(下圖中有關(guān)鍵點,單獨看不太容易抓到)

image

接下來再去debug HttpClient的請求鏈路中,在創(chuàng)建HttpGet對象時,看到下面這一行代碼

image

單獨看上面兩個,好像發(fā)現(xiàn)不了什么問題;但是兩個對比著看,就發(fā)現(xiàn)一個有意思的地方了,在HttpTemplateexecute方法中,創(chuàng)建URI居然不是我們熟知的 URI.create(),接下來就來驗證下是不是這里的問題了;

測試方法也比較簡單,直接傳入URI對象參數(shù),看能否訪問成功

@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參數(shù)的構(gòu)建上了

image

3. 原因分析

前面定位到了出問題的環(huán)節(jié),在RestTemplate創(chuàng)建URI對象的地方,接下來我們深入源碼,看一下這段邏輯的神奇之處

通過單步執(zhí)行,下面截取關(guān)鍵鏈路的代碼,下面圈出的就是定位最終實現(xiàn)uri創(chuàng)建的具體對象org.springframework.web.util.DefaultUriBuilderFactory.DefaultUriBuilder

image

接下來重點放在具體實現(xiàn)方法中

// 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方法提供關(guān)鍵URI生成邏輯,根據(jù)最后的返回可以知道,生成URI依然是使用URI.create,所以出問題的地方就應(yīng)該是 uriComponents.encode() 實現(xiàn)url編碼的地方了,對應(yīng)的代碼如下

// 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;
}

記錄下參數(shù)編碼的前后對比,編碼前參數(shù)為 ASHJRK3LJFD%2BR32SADFLK%2BFASDJ%3D

image

編碼之后,參數(shù)變?yōu)?code>ASHJRK3LJFD%252BR32SADFLK%252BFASDJ%253D

image

對比下上面的區(qū)別,發(fā)現(xiàn)這個參數(shù)編碼,會將請求參數(shù)中的 % 編碼為 %25, 所以問題就清楚了,我傳進來本來就已經(jīng)是編碼之后的了,結(jié)果再編碼一次,相當(dāng)于修改了請求參數(shù)了

看到這里,自然而然就有一個想法,既然你會給我的參數(shù)進行編碼,那么為啥我傳入的非編碼的參數(shù)也不行呢?

接下來我們改一下請求的url參數(shù),再執(zhí)行一下上面的過程,看下編碼之后的參數(shù)長啥樣

image

從上圖很明顯可以看出,現(xiàn)編碼之后的和我們URLEncode的結(jié)果不一樣,加號沒有被編碼, 我們調(diào)用jdk的url解碼,發(fā)現(xiàn)將上面編碼后的內(nèi)容解碼出來,+號沒了

image

所以問題的原因也找到了,RestTemplate中首先url編碼解碼的邏輯和URLEncode/URLDecode不一致導(dǎo)致的

4. 關(guān)鍵代碼分析

最后一步,就是看下具體的url參數(shù)編碼的實現(xiàn)方法了,下面貼出源碼,并在關(guān)鍵地方給出說明

// 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 對應(yīng)的代碼為

// 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 對應(yīng)的相關(guān)代碼為

/**
 * Indicates whether the given character is in the {@code pchar} set.
 * @see <a >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 >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 >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 >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 >RFC 3986, appendix A</a>
 */
protected boolean isDigit(int c) {
    return (c >= '0' && c <= '9');
}

上面涉及的方法挺多,小結(jié)一下需要轉(zhuǎn)碼的字符為: =, &

下圖是維基百科中關(guān)于url參數(shù)編碼的說明,比如上例中的+號,按照維基百科的需要轉(zhuǎn)碼;但是在Spring中卻是不需要轉(zhuǎn)碼的

image

所以為啥Spring要這么干呢?網(wǎng)上搜索了一下,發(fā)現(xiàn)有人也遇到過這個問題,并提給了Spring的官方,對應(yīng)鏈接為

官方人員的解釋如下

根據(jù) RFC 3986 加號等符號的確實可以出現(xiàn)在參數(shù)中的,而且不需要編碼,有問題的在于服務(wù)端的解析沒有與時俱進

III. 小結(jié)

最后復(fù)盤一下這個問題,當(dāng)使用RestTemplate發(fā)起請求時,如果請求參數(shù)中有需要url編碼時,不希望出現(xiàn)問題的使用姿勢應(yīng)傳入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參數(shù)編碼,默認(rèn)只會針對 =& 進行處理;為了兼容我們一般的后端的url編解碼處理在需要編碼參數(shù)時,目前盡量不要使用Spring默認(rèn)的方式,不然接收到數(shù)據(jù)會和預(yù)期的不一致

IV. 其他

0. 項目

1. 一灰灰Blog

一灰灰的個人博客,記錄所有學(xué)習(xí)和工作中的博文,歡迎大家前去逛逛

2. 聲明

盡信書則不如,以上內(nèi)容,純屬一家之言,因個人能力有限,難免有疏漏和錯誤之處,如發(fā)現(xiàn)bug或者有更好的建議,歡迎批評指正,不吝感激

3. 掃描關(guān)注

一灰灰blog

QrCode

知識星球

goals
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容