呼叫鏈系列三:解讀UAVStack中的呼叫鏈技術

iEven發表於2018-11-09

本專題前幾篇文章主要從架構層面介紹瞭如何實現分散式呼叫追蹤系統。這篇文章我們不談架構,就其中的一項關鍵技術實現進行深入探討:如何從超文字傳輸協議(HTTP)中獲取request和response的body和header。


在Java中,HTTP協議的請求/響應模型是由Servlet規範+Servlet容器(如Tomcat)實現的。換句話說,在類Tomcat容器中,一次完整的HTTP請求都是通過實現Servlet規範完成的;Spring、Jesery 等技術棧也是在Servlet規範基礎上封裝的。因此我們可以藉助底層的Servlet規範來獲取Java技術棧中HTTP的body和header,即通過攔截使用者自定義實現的HttpServlet類中的HttpServletRequest和HttpServletResponse,獲取HTTP的body和header。

通過閱讀前幾篇文章大家知道,呼叫鏈模型和架構都是依託UAVStack的中介軟體增強框架技術實現的。在這篇文章中,我會向大傢俱體介紹如何從零開始捕獲body和header。

攔截http請求

想要在儘可能少改動程式碼的前提下從請求中提取body和header,必須對進入容器的請求進行統一攔截,否則就需要在所有HttpServlet實現類中嵌入程式碼。這裡要再次感謝Servlet規範制定者為我們提供的filter機制。

根據Servlet規範,filter是一個可重用的程式碼段,可以轉換HTTP requests、responses和header資訊的內容。過濾器一般不會為一個request建立一個響應,而是會修改或適配一個request和response。filter主要提供四種攔截方式:

  • REQUEST:直接訪問目標資源時執行過濾器。包括:在位址列中直接訪問、表單提交、超連結、重定向,只要在位址列中可以看到目標資源的路徑,就是REQUEST;

  • FORWARD:轉發訪問執行過濾器。包括RequestDispatcher#forward()方法、< jsp:forward>標籤都是轉發訪問;

  • INCLUDE:包含訪問執行過濾器。包括RequestDispatcher#include()方法、< jsp:include>標籤都是包含訪問;

  • ERROR:當目標資源在web.xml中配置為< error-page>中時,並且真的出現了異常,轉發到目標資源時,會執行過濾器。

這裡我們只需使用REQUEST模式。配置filter以後,我們就可以從filter的doFilter方法中獲取到HttpServletRequest和HttpServletResponse(後文簡稱request和response)了。

獲取header

上文中我們已經通過filter機制獲取了request和response。開啟對應原始碼實現我們可以發現如下API:

1
規範中已經為我們提供API直接獲取header,通過組合使用getHeaderNames()和getHeader(String name)方法我們可以輕鬆獲取到request和response中的header。

獲取body

request和response獲取body的方式大體相同。此處我們先以request為例,後文會對不同之處進行適配。

從request的API中可以發現,body在Java中是以ServletInputStream形式儲存的,並且ServletInputStream是繼承的InputStream。若直接讀取,使用者獲取到的body將為空(因為InputStream只能被讀取一次,除非把指標回執)。這裡我們就需要藉助Servlet的wrapper機制了。 Servlet中的wrapper 這裡簡單介紹一下requestWrapper和responseWrapper。wrapper是一種裝飾模式,在Servlet規範中通過繼承HttpServletResponseWrapper和HttpServletRequestWrapper實現,相當於為request和response進行了一次套殼,類似於Java中的代理,這樣所有操作request和response的動作都會經過我們的自定義wrapper,使重複獲取request和response中的body成為可能。

編寫自己的wrapper

我們以request為例,解釋如何編寫自定義wrapper。開啟servlet-api原始碼可見HttpServletRequestWrapper繼承了ServletRequestWrapper並且實現了HttpServletRequest介面。

2
ServletRequestWrapper已經幫我們實現了大部分的方法。

3
我們只需要將關心的幾個方法覆寫即可,如:getInputStream和getReader等。

4
當使用者嘗試呼叫getReader或getInputStream時,我們將之替換為自己的流,並且額外提供一個getContent()方法,將提前從StringBuilder或byte[]中讀取到的body內容進行提取。

編寫完自定義wrapper以後,我們就可以將其放入我們上文定義好的filter中,並將原request進行包裝替換,進而將使用者的request都變成我們的requestWrapper。

優化提取邏輯

上文的方法相當於是將包含body的inputStream提前進行一次讀取,將其儲存在中間byte[]或StringBuilder當中,當使用者在呼叫getInputStream時,將byte[]或StringBuilder轉成inputStream返給使用者。如果使用者根本不關心本次http請求的body,即使用者根本沒有使用此次請求的body,那我們將其提前讀取出來相當於做了一次無用功(浪費了寶貴的CPU時間和記憶體資源)。如何保證只有在使用者使用時才讀取inputStream,並且當使用者或後續邏輯多次獲取body時都只讀一次是我們優化的目標。

答案還是繼續從原始碼中尋找。既然我們的資料在inputStream中,那我們可以跟進原始碼,看看inputStream是如何被讀取到的。在Servlet規範中,inputStream被封裝成了ServletInputStream,而ServletInputStream又提供了一個readLine方法。仔細觀察可以發現,他們都是呼叫了inputStream中的read方法,如下圖:

5
既然read方法是統一入口,是否只需要自定義實現一個ServletInputStream並覆寫其中的read()方法就能修改所有讀取方式了呢?答案是肯定的。只要在使用者呼叫read方法時,悄悄複製一份我們關心的內容,就能保證只有在使用者使用body時才讀取inputStream。

下一個問題就是如何保證在使用者多次呼叫read時只讀取一次inputStream。這裡需要藉助一個AtomicBoolean標誌:當已經進行了一次完整讀取後,將其置為true;否則為false。最終效果如下:

6

舉一反三

這裡我們使用Servlet規範中的filter和wrapper機制來獲取進入我們容器(Tomcat)中所有Http請求的body和header。這個能力在實際生產中還能進一步擴充,如:傳輸某些敏感資料時,在Client端進行加密,然後在Server端統一解密,並格式化Client端上送的資料格式等。


讀完本文,大家應該能夠在不影響原始碼的前提下,通過簡單程式碼獲取進入容器的所有Http請求的body和header。不過對於特殊技術棧,還需要進行適配。如果專案中使用了Jersey且使用application/x-www-form-urlencoded形式傳遞引數等資訊,而服務端沒有使用@FormParam註解來獲取引數,那麼獲取body以後使用者將無法獲取引數。但至少我們已經驗證了這條路是可行的,所以已經成功了一半。希望這份技術分享能夠在工作中幫到大家。

本月呼叫鏈專題的推送就到此為止啦,如果對呼叫鏈技術仍有疑問,歡迎後臺留言,我們會盡快回復大家~下月我們將開啟新的專題,敬請期待~~~

官方網站

開源地址

UAVStack已在Github上開放原始碼,並提供了安裝部署、架構說明和使用者指南等雙語文件,歡迎訪問-給星-拉取~~~

掃一掃下方二維碼,關注一個不會讓你失望的公眾號

7

相關文章