由x-www-form-urlencoded引發的介面對接失敗

扣釘日記 發表於 2023-03-18

原創:扣釘日記(微信公眾號ID:codelogs),歡迎分享,非公眾號轉載保留此宣告。

問題發生

這周正在寫程式碼,突然,旁邊小哥問我個問題...

  • 小哥:我這有個介面,自己呼叫沒有問題,但別人呼叫就不行,這種問題該如何排查?
  • 我:抓下包看看呢...
  • 小哥:是這樣使用tcpdump嗎?
  • 我:是的

待小哥抓到包後,使用wireshark開啟,並找到了相應的請求,類似如下:
fail

然後我讓小哥將這個請求,使用curl發一個同樣的請求,看能不能復現這個錯誤,如下:

$ curl -X POST localhost:80/api \
      -H 'Content-Type: application/x-www-form-urlencoded' \
      -d 'eyJvcmRlcl9pZCI6MTIzNDU2Nzg5MDIxNDN9Cg=='

命令執行之後,重現了呼叫方一樣的介面報錯。

然後抓包小哥自己的正確請求是這樣的:
ok

這裡很容易發現,別人調不通介面,小哥能調通,原因是別人的請求體裡面缺失data=這一段😒

先不管為什麼缺這個會報錯,這裡展示了一個實用技巧,對於http介面來說,排查這種介面呼叫差異問題,最直接高效的方法,就是對比正確呼叫與錯誤呼叫的資料包!

問題解決

那麼接下來,就是研究為什麼報錯了,看看服務端的處理程式碼,大概如下:

public JsonObject parseRequest(HttpServletRequest request, Charset charset) throws IOException {
      String base64Str = request.getParameter("data");
      if (base64Str == null) {
            try (InputStream is = request.getInputStream()) {
                  base64Str = StreamUtils.copyToString(is, charset);
            }
      }
      byte[] jsonBytes = Base64.getDecoder().decode(base64Str);
      return new Gson().toJsonTree(new String(jsonBytes, charset)).getAsJsonObject();
}

這個邏輯很簡單,如下:

  1. 先從data引數中取資料。
  2. 若沒有再從請求體中拿。
  3. 然後base64解碼。
  4. 最後轉json物件。

我們介面基本都這樣,使用base64將資料包了一層,許多年過去了,具體原因不詳,不深究😂

從上面處理邏輯看,按道理小哥的呼叫方式與別人的呼叫方式都是支援的,理論上來說,小哥的呼叫方式會命中request.getParameter,而別人的呼叫方式會命中request.getInputStream(),那為啥別人的呼叫方式不行?

小哥又除錯了下上述服務端程式碼,發現使用別人的呼叫方式時,從request.getInputStream()中讀不到資料😥

我在小哥旁邊,提示將ContentType改成text/plain試試,curl命令改成這樣:

$ curl -X POST localhost:80/api \
      -H 'Content-Type: text/plain' \
      -d 'eyJvcmRlcl9pZCI6MTIzNDU2Nzg5MDIxNDN9Cg=='

執行這條命令後,介面返回了正確結果😁

那為什麼會這樣呢😰😰😰

ContentType指的是什麼?

首先來看看ContentType指的是什麼,看2個例子

  1. 如果ContentType是application/x-www-form-urlencoded時,請求可能是這樣的:
    x-www-form-urlencoded
  2. 如果ContentType是application/json時,請求可能是這樣的:
    json
  3. 如果ContentType是application/xml時,請求可能是這樣的:
    xml

不難發現,ContentType這個請求頭的作用是,指定請求體的資料格式。比如application/x-www-form-urlencoded表示請求體是key=value格式,application/json表示請求體是json格式,application/xml表示是xml格式,而text/plain表示請求體是純文字。

那為什麼將ContentType從application/x-www-form-urlencoded變成text/plain,報錯的呼叫就能跑通了?

application/x-www-form-urlencoded有何不同?

application/x-www-form-urlencoded是個歷史非常悠久的ContentType了,它透過key=value的形式來組織表單資料,當然key和value還需要做urlencode編碼。

而正是因為它如此悠久,所以被採納在了web伺服器的實現標準中,幾乎所有的web伺服器,當發現ContentType是application/x-www-form-urlencoded時,會自動按key=value&key2=value2的格式來解析請求體資料,解析完成後,我們就可以透過request.getParameter()來獲取對應key的值了。

比如Tomcat的實現在org.apache.catalina.connector.Request#parseParameters,如下:
image_2023-03-17_20230317225055
解析key=value格式資料如下:
image_2023-03-17_20230317225236

但是,這裡有一個重要的細節!

當ContentType是application/x-www-form-urlencoded時,由於Tomcat提前將請求體的資料流讀了一遍,所以後面再透過request.getInputStream()就讀不到請求體資料了。

如下,從request.getInputStream()中獲取到的流,pos遊標已經走到了lim結束位置了。
image_2023-03-17_20230317230241

而將ContentType改為text/plain後,Tomcat不會解析請求體,所以就不會讀資料流,自然後面我們透過request.getInputStream()就又能讀到資料了,故又可以調通了!

解決問題

解決這個問題很簡單,如下:

  1. 讓呼叫方在請求體里加上data=,以符合application/x-www-form-urlencoded的key=value規範。
  2. 讓呼叫方將ContentType修改為text/plain,因為呼叫方的請求資料就是base64純文字而已,我們讓呼叫方選擇了這個方案。

如果呼叫方有很多,難以確定呼叫方的規範情況,那其實還有一種方案,透過request.getParameterMap()實現,程式碼有點hack(常規場景不推薦),如下:
image_2023-03-17_20230317231546
這是因為,在application/x-www-form-urlencoded中,key=value格式,value為空時,可以傳key=,也可以省略掉等號傳key,所以我們取第一個key值就拿到了請求體資料。