記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

青石路發表於2023-11-27

開心一刻

  今天中午,侄子在沙發上玩手機,他妹妹屁顛屁顛的跑到他面前

  小侄女:哥哥,給我一塊錢

  侄子:叫媽給你

  小侄女朝著侄子,毫不猶豫的叫到:媽!

  侄子:不是,叫媽媽給你

  小侄女繼續朝他叫到:媽媽

  侄子受不了,從兜裡掏出一塊錢說道:我就只有這一塊錢了,拿去拿去

  小侄女最後還不忘感謝到:謝謝媽媽!

  侄子徹底奔潰了,我在一旁笑出了鵝叫聲

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

需求背景

  需求很簡單,就是以 HTTP 的方式下載 OSS 上的檔案,類似如下

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

  分兩步

  1、獲取檔案的下載地址( HTTP 地址 )

  2、根據下載地址下載檔案

  第 1 步不是本文的重點,略過,我們只需要實現第 2 步,是不是很簡單?

問題復現

  目前,系統跟其他系統的 HTTP 對接都是用的 RestTemplate 

  那毫無疑問,也用 RestTemplate 來下載 OSS 檔案

  測試程式碼非常簡單,如下

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義
package com.qsl;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;

/**
 * @description: RestTemplate 測試
 * @author: 部落格園@青石路
 * @date: 2023/11/26 15:31
 */
@RunWith(SpringRunner.class)
@SpringBootTest
public class RestTemplateTest {

    @Resource
    private RestTemplate restTemplate;

    @Test
    public void testOss() {
        String ossUrl = "https://qsl-yzb-test.oss-cn-wuhan-lr.aliyuncs.com/company_compare_t.sql?Expires=1700987277&OSSAccessKeyId=TMP.3Kf7vKYWL9RHkroENy7hUyrqAhHBC8YpBCnqXAstCyH3K1j6fkZujtL47V1mFkG5e5hmnLD2dVn4ZJGeD2yDh3GAAQc1k8&Signature=O2qiPYvfZyPmeouwzkXcNqC4Oy0%3D";
        ResponseEntity<byte[]> responseEntity = restTemplate.getForEntity(ossUrl, byte[].class);
        System.out.println(responseEntity.getStatusCode());
    }
}
View Code

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

  我們看下執行結果,發現報異常了

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義
org.springframework.web.client.HttpClientErrorException$Forbidden: 403 Forbidden: [<?xml version="1.0" encoding="UTF-8"?>
<Error>
  <Code>AccessDenied</Code>
  <Message>Request has expired.</Message>
  <RequestId>65630E3B05EC713334EDD93D</RequestId>
  <HostId>qsl-yzb-test.oss-cn-wuh... (443 bytes)]

    at org.springframework.web.client.HttpClientErrorException.create(HttpClientErrorException.java:109)
    at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:170)
    at org.springframework.web.client.DefaultResponseErrorHandler.handleError(DefaultResponseErrorHandler.java:112)
    at org.springframework.web.client.ResponseErrorHandler.handleError(ResponseErrorHandler.java:63)
    at org.springframework.web.client.RestTemplate.handleResponse(RestTemplate.java:785)
    at org.springframework.web.client.RestTemplate.doExecute(RestTemplate.java:743)
    at org.springframework.web.client.RestTemplate.execute(RestTemplate.java:677)
    at org.springframework.web.client.RestTemplate.getForEntity(RestTemplate.java:345)
    at com.qsl.RestTemplateTest.testOss(RestTemplateTest.java:27)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
    at org.springframework.test.context.junit4.statements.RunBeforeTestExecutionCallbacks.evaluate(RunBeforeTestExecutionCallbacks.java:74)
    at org.springframework.test.context.junit4.statements.RunAfterTestExecutionCallbacks.evaluate(RunAfterTestExecutionCallbacks.java:84)
    at org.springframework.test.context.junit4.statements.RunBeforeTestMethodCallbacks.evaluate(RunBeforeTestMethodCallbacks.java:75)
    at org.springframework.test.context.junit4.statements.RunAfterTestMethodCallbacks.evaluate(RunAfterTestMethodCallbacks.java:86)
    at org.springframework.test.context.junit4.statements.SpringRepeat.evaluate(SpringRepeat.java:84)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:251)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.runChild(SpringJUnit4ClassRunner.java:97)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
    at org.springframework.test.context.junit4.statements.RunBeforeTestClassCallbacks.evaluate(RunBeforeTestClassCallbacks.java:61)
    at org.springframework.test.context.junit4.statements.RunAfterTestClassCallbacks.evaluate(RunAfterTestClassCallbacks.java:70)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
    at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.run(SpringJUnit4ClassRunner.java:190)
    at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
    at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
    at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
    at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230)
    at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58)
View Code

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

  直接從瀏覽器下載是正常的,用程式碼走 RestTemplate 方式下載則失敗,提示 403 Forbidden 

  是不是有點懵?

問題排查

  系統中已經用 RestTemplate 對接了很多 HTTP 介面,全部都沒問題

  這不就是一個很簡單的 HTTP 請求嗎,簡單的不能再簡單了,怎麼會失敗了?

  直接把我整不會了,不知道從何下手去排查了

  第一時間想到了阿里雲 OSS 售後,聯絡到人工客服,反饋了問題

  客服響應倒是很及時,但卻遲遲沒有找到問題原因

  然後我又將求助目光轉向了部門內同事

  有個同事提到:你開啟 debug 日誌,看看 RestTemplate 請求地址或引數是不是有什麼問題

  我內心其實是拒絕的, HTTP 地址都是現成的,都不用拼接, GET 方式的引數也是直接在 URL 中,能有什麼問題?

  但我的手卻很誠實,默默的開啟了 debug 日誌(在配置檔案中加上: debug: true )

  執行結果依舊失敗,但是多了三行 debug 日誌

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

   RestTemplate 的請求 URL 已經列印出來了,我們來和原始的 URL 對比一下,看看是不是有區別

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

   不比不知道,一比嚇一跳,這特喵的 RestTemplate 是做了手腳呀!對 % 進行了轉義處理,處理成 %25 了

  至於為什麼需要對 GET 方式的 URL 的特殊字元進行轉義,我就不做過多解釋了(網上資料很多!),舉個例子你們就明白了

     http://localhost:8080/hello?name=青石路 的引數 name 的值是 青石路 ,這個大家都認可吧?

    如果 name 的值是 青石路&路石青 ,這個 URL 應該是怎樣的?

    有人可能會有疑問了:你這說的是 &,跟 % 有什麼關係?

    你是黑子,來搞我的吧?

    求求你別搞我,我很菜的!

     RFC 3986編碼規範 指明瞭:百分號本身用作對不安全字元進行編碼時使用的特殊字元,因此本身需要編碼

    例如: %20 表示空格, %2B 表示 +,等等

問題處理

  問題已經找到了,那麼該如何處理了?

  拋開上面的問題,處理這種 URL 轉義的問題,方式有很多

  1、改成 POST 請求方式

    比較推薦這種方式,奈何這種方式不適用本案例

  2、使用 HttpClient jar

    因為同事用的這種方式實現與本案例一樣的下載,沒有轉義問題

    但為了統一,仍想保留統一的 RestTemplate 方式,即沒有采用這種方式

  3、 RestTemplate 的 URI 方式

    本案例最終採用這種的方式

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

    透過 debug 日誌是能夠看到, RestTemplate 請求的地址是沒有進行轉義的(這裡不展示了,大家自行去測試!)

    至於 String 和 URI 的差別,大家去 debug 跟下原始碼就清楚了,底層的實現差別還是很大的哦

  當然還有其他的方式,但是需要結合系統當前的情況,找出最合適的那種方式

總結

  1、別自以為是,該試還得試

  2、 debug 日誌是除錯的好東西,記得用、用、用!

  3、多學多總結,多和同事分享溝通,有問題了才好請教他們

記一次 RestTemplate 請求失敗問題的排查 → RestTemplate 預設會對特殊字元進行轉義

 

相關文章