貓哥網絡編程系列:HTTP PEM 萬能調試法


注:本文內容較長且細節較多,建議先收藏再閱讀,原文將在 Github 上維護與更新

在 HTTP 接口開發與調試過程中,我們經常遇到以下類似的問題:

  • 為什么本地環境接口可以調用成功,但放到手機上就跑不起來?
  • 這個接口很復雜,內部調用了好幾個其他接口,如何定位問題究竟出在哪一步?
  • 后端開發還沒有把接口提供好,前端開發任務無法推進……

「貓哥網絡編程系列」最核心的任務便是向各位分享一個我從多年的前后端項目中總結而來的「萬能」HTTP 調試法,掌握並從網絡編程原理上理解它,能讓我們順利定位並解決所有 HTTP 接口問題。由於該方法主要涉及到的知識點包括 HTTP 代理(Proxy)、編輯(Edit)與數據模擬(Mock),因此我稱之為「HTTP PEM 調試法」。

接下來,我們就針對前面提出的幾個問題,詳細講解下 PEM 調試法的思路。

如何調試線上 App 中的 H5 頁面?

「HTTP PEM 調試法」之 Proxy

在上一期《貓哥網絡編程系列:詳解 BAT 面試題》中,我們有介紹到 Windows 下的 Fiddler 和 Mac 下的 Charles 這兩款 HTTP 抓包工具,其實它們就是兩個 HTTP 代理服務器(HTTP Proxy Server)。由於 HTTP 是一種符合 REST 架構風格(Representational State Transfer)的協議,具有無狀態(Stateless)與統一接口(Uniform Interface)的架構約束,因此其代理機制的實現十分的簡單。

打個比方,我們可以把 Proxy Server 理解成一個快遞中轉站,當一個包裹經過中轉站時,包裹的信息(發件人、收件人與包裹里的貨物)通常不會做任何的改動,直接發往下一個中轉站或顧客手中。但中轉站完全有能力修改快遞單信息、拆箱檢查貨物,甚至是私吞或調換貨物。

當我們需要快速定位「線上產品的接口問題」時,如果沒有源碼、數據、依賴服務和足夠的時間去搭建一個測試環境,則通常會使用 HTTP 代理服務器來進行快速抓包調試。

Fiddler 默認只允許本地 IP(127.0.0.1)使用代理服務,通過設置「Tools -> Connections -> Allow remote computers to connect」可以開啟其他 IP(通常是同一局域網內的其他設備)使用代理服務。

Fiddler 開啟 Remote Proxy

Charles 默認開放代理服務,但陌生設備首次連接時需要授權確認,通過以下配置可以設置成無需授權。

授權所有設備使用 Charles 代理服務

以上兩款軟件默認的代理端口均是 8888 ,軟件開啟之后,我們可以在對應的平台終端下通過ipconfig(Windows) 或 ifconfig(Mac)命令查詢本機的局域網 IP,還可以使用 telnet 命令檢查代理通道是否可用。(注:Win7 下如何開啟 telnet 命令請參考百度經驗。)

以下是 Windows 下 CMD 終端的使用截圖,Mac 系統下請類比參考。

CMD 下 ipconfig 與 telnet

接下來,我們將手機的 Wi-Fi 代理設置為上述的 IP 與 端口號,以下是 iOS 的設置截圖( Android 系統通常是長按已連接的 Wi-Fi ,在彈出的高級設置菜單中配置代理服務器)。

iOS 下設置 HTTP 代理

至此,手機上任意應用發起的 HTTP 請求都將會被代理服務器(本例中的 Fiddler/Charles 軟件)監聽到。

「HTTP PEM 調試法」之 Edit

通過代理服務器監聽到 HTTP 請求之后,我們可以通過瀏覽報文的詳細信息,定位出可能的接口問題。Fiddler 與 Charles 都具有同樣強大的 HTTP 編輯(Edit)、重發(Replay/Repeat)、斷點(Breakpoints)功能。Charles 的基礎與高級用法請參考《Charles 從入門到精通》,Fiddler 教程可以參考 OSChina 專題《HTTP調試代理 Fiddler》,以下介紹 Fiddler 的部分常見用法。

Fiddler Edit 與 AutoResponder

抓到手機 HTTP 請求之后,通過編輯(Unlock For Editing)和重發(Replay)操作可以不斷地調試接口的響應是否符合預期。

Fiddler 手動修改調試請求

通過設置自動響應規則(AutoResponder Rules)可以將響應頭設置成常見狀態碼的返回,或將響應體映射成本地文件,通過外部編輯器修改文件內容進行調試。其中,若設置響應為 *bpu 或 *bpafter 可以在請求前與響應前的事件觸發時進行斷點調試,十分方便。

Fiddler 將請求映射本地文件

需要注意的是,在 Fiddler 中使用 Replay 功能重發請求時,請求由 Fiddler 代理重新發起而非手機,因此手機 App 中的 H5 不會有任何變化。只有重新刷新 App 的 H5 頁面,配合 HTTP 斷點調試(Breakpoints )的方式才可以讓修改后的 HTTP 響應體在 App中生效。這里介紹另外一種配合 Weinre 的調試用法。

Weinre 基本用法

Weinre 屬於知名 Hybrid 框架 Cordova 中的一款 Web App 遠程調試工具。通過在頁面中注入一段 JS 腳本,可以在 PC 和手機端的 H5 頁面之間建立一個 Socket 雙向數據傳輸通道。原理上可以理解為,當我們在 PC 端的后台進行 debug 時,相關的操作被序列化成一組 JSON 字符串,數據經由通道傳輸給手機端中的 H5 頁面,頁面在接收到這些數據之后反序列化成相應的 JS 腳本操作,在其 window 上下文中執行,並將執行的結果回傳給通道,PC 端的 Chrome 通過監聽通道獲取到相應的數據在 debug 后台中展現出來。

以下介紹 Weinre 的基本用法:

  1. 通過 npm 全局安裝 weinre: npm install -g weinre
  2. 在本地 8081 端口上啟動 weinre 服務:weinre --boundHost 0.0.0.0 --httpPort 8081 。通常在 Node.js 的服務中綁定 IP 為 0.0.0.0 而非 127.0.0.1(本地 IP),意味着可以讓任意來源的 IP 訪問該服務
  3. 通過上文介紹的 ipconfig(Mac 為 ifconfig)命令獲取本機 IP 后,在本機 Chrome 瀏覽器中訪問 Weinre 管理后台:http://10.2.69.47:8081 (本例中我的 IP 為 10.2.69.47,請注意將其替換成自己的局域網 IP)
  4. 在管理后台我們能看到相關使用說明,要求將以下腳本插入需要調試的 H5 頁面中:<script src="http://10.2.69.47:8081/target/target-script-min.js#anonymous"></script>
  5. 將以上腳本插入進 H5 頁面后,我們在 PC 端 Chrome 中,通過http://10.2.69.47:8081/client/#anonymous 后台點擊進入相應的客戶端調試界面

問題是,我們「如何將 Weinre Script 自動注入到手機的 H5 頁面中」?

HTTP Script 注入

想必用過中國電信寬帶的同學都有過這樣的體驗:在剛開始瀏覽網頁時,會自動跳出一些「寬帶升級優惠」、「寬帶繳費提醒」之類的頁面。這種耍流氓的方式便是寬帶運營商在 HTTP 代理層面的 Script 注入行為。前面已經提到 HTTP 協議是一種 REST 風格的架構,並且他的頭部與主體報文為字符串文本流(對比二機制、十六進制數據流),在不使用 HTTPS 的情況下,很容易被中間路由或代理網關進行消息篡改。

通過 Fiddler Script 特性,我們可以自動對經過 Fiddler 的 HTTP 流量進行二次修改,注入任意內容(Mac 用戶若已了解相關知識點,請直接跳至下方的 Charles 截圖)。

打開 Fiddler 菜單「Rules -> Customize Rules… 」,如果是首次開啟會要求先下載安裝 Fiddler ScriptEditor。打開 Fiddler ScriptEditor 之后,找到以下代碼塊(或使用菜單「Go -> to OnBeforeResponse」):

static function OnBeforeResponse(oSession: Session) {
    if (m_Hide304s && oSession.responseCode == 304) {
        oSession["ui-hide"] = "true";
    }
}

Fiddler Script 使用的編程語言是 JScript.NET(JavaScript 和 C# 的混合語法,類似 TypeScript),OnBeforeResponse 是 HTTP Response 響應前的事件函數,我們只需要在這里判斷「如果開啟了 Weinre Debug 功能,那么就在所有的 HTML 響應體中注入 Weinre Script」,以下是我修改的示例代碼,覆蓋以上代碼塊即可。

public static RulesOption("Enable Weinre Script")
var m_EnableWeinreScript: boolean = true;

public static var g_weinreScriptString: String = '<script src="http://127.0.0.1:8080/target/target-script-min.js#anonymous"></script>';

public static ToolsAction("Config Weinre Script")
function ConfigWeinreScript(){ 
    g_weinreScriptString = FiddlerObject.prompt("Text beblow will inject into HTML pages when 'Enable Weinre Script' rule is Enabled.", g_weinreScriptString , "Please Input the Weinre Script");
}

static function OnBeforeResponse(oSession: Session) {
    if (m_Hide304s && oSession.responseCode == 304) {
        oSession["ui-hide"] = "true";
    }
    if (m_EnableWeinreScript && oSession.oResponse.headers.ExistsAndContains("Content-Type","text/html")){
        oSession.utilDecodeResponse();

        if(oSession.utilFindInResponse("</html>", false)>-1){
            oSession["ui-backcolor"] = "#5E30B5";
            oSession["ui-color"] = "white";
            oSession.utilReplaceRegexInResponse("<\/html>", g_weinreScriptString + '</html>');
        }
    }
}

修改保存后重啟 Fiddler(或使用菜單「Tools -> Reset Script」)以生效規則,接下來運行「Tools」菜單中新出現的「Config Weinre Script」,將 127.0.0.1:8080 替換成自己本機的局域網 IP 與 weinre 服務端口號,同時開啟菜單「Rules -> Enable Weinre Script」。至此,所有 HTML 頁面將會被自動注入 Weinre Script,之后我們就可以在 weinre 后台中開始調試相關頁面。以下是參考截圖:

Fiddler 中的 HTTP Script 注入

可以看到 HTTP 響應體中已經被動態注入 Weinre Script。

在 Mac Charles 下的 Script 注入配置更加容易,只需利用其 「Rewrite」功能進行簡單的配置即可,參看下圖:

Charles Rewrite 配置 HTTP Script 注入

通過 Fiddler/Charles 代理工具將 JS 腳本注入成功后,我們便可以通過前文提到的 weinre 后台開始 debug 相應的頁面,以下是在 iPhone 模擬器中調試新浪微博界面的截圖:

Weinre 后台 debug Webview H5 頁面

使用該方法可以調試 Android 和 iOS 中「任意 App 的 H5 頁面」,但由於主要使用了 weinre 服務,其原理決定了該方法無法像真正的 Chrome DevTools 一樣支持 JS 斷點調試、Profiles 性能分析等功能,具有一定的局限性。在實際 Web App 開發過程中,推薦使用以下工具進行調試 :

由此可見,「HTTP PEM 調試法」是一個通用的 HTTP 接口調試方案,可以用來快速定位線上接口問題,對於開發人員來說掌握其背后的 HTTP 協議及其代理機制的原理更加重要,接下來我們聊聊常見的 HTTP 接口開發協作方法與 Mock 思路。

我的開發任務沒法推進,因為某某的接口還沒提供給我。

「HTTP PEM 調試法」之 Mock

希望新手程序員在看完這一章節之后,不要再向你的項目組和上級反饋這樣的說法,因為 HTTP Mock(接口數據模擬)是一項網絡編程的基礎技能,從實際項目經驗來看,大部分基於 HTTP 接口的任務都可以並行開發。

最簡 HTTP API

不同崗位(例如前端開發與后台開發)或不同業務(例如訂單系統與賬戶系統)的開發人員開始並行開發任務之前,首先要做的應該是對耦合和相互依賴的任務進行邊界划分與規則約定。具體到某個 HTTP API 接口的約定上,至少應該明確以下信息:

  1. 是否按照 RESTful API 的約定來設計接口
  2. 接口的路徑、提交方法、參數、編碼類型(Enctype/Content-Type)
  3. 接口返回的錯誤碼(code)、消息說明(message)、業務數據(data)

針對以上三條信息,我設想的「最簡」 HTTP API 包含以下幾條原則,供各位參考:

1、不使用 RESTful API 來設計接口

RESTful API 實際上是利用 HTTP 協議的語義(提交類型、返回碼、Hypermedia Link)來將所有接口操作抽象化為一系列資源對象。這要求 API 的設計者與調用者都具備深厚的 HTTP 協議功底、語義化與抽象化能力。

  • RESTful 作為一個 Buzzword(流行詞),其含義已經被曲解。HTTP 協議和 REST 架構的設計者 Roy Fielding 很反感這一點,還專門開了博客以正視聽。大多數人只將 HTTP 當做一種傳輸協議來使用(既成事實),並不能真正理解 REST 架構風格;
  • RESTful API 將所有請求抽象化為資源名詞(Resources)的做法爭議很大。這種做法總會讓我回想起上個世紀用 FrontPage 做網頁的經歷,「設置一個超鏈接,從某個資源跳到另外一個資源」。在經過 Web 2.0 浪潮,進入移動互聯網時代后,這種 API 設計容易給人帶來困惑。例如「登錄、注冊」這樣的「動詞」如何抽象成「名詞」(還好有 Github API 可以參考 )。而刻意的使用 「HTTP CRUD」(POST/GET/PUT/DELETE Method)操作「資源化」之后的接口,並未帶來更多實質上的收益;
  • HTTP 狀態碼的分層思路在 RESTful API 模式下被破壞了。HTTP 1.0 中定義的常見狀態碼已經足夠網絡中間組件(代理、網關、路由)使用,HTTP 1.1 中加入的很多狀態碼缺乏實際場景(例如 306 狀態碼的廢棄),它們增加了中間組件以及瀏覽器對規范理解與實現的要求。盡可能的將狀態碼交給相應的接口邏輯層而非 HTTP 協議層,能夠將問題簡化;
  • 對比以英文為母語的國外開發者而言,國內開發者對語義化的認知難度更高,例如 RESTful 建議資源命名用復數形式,那收貨地址單詞 address 的復數形式是什么?address or addresses ?address-list or address-lists?(沒過英語八級的同學已經哭暈在廁所 T_T)
  • 每個人對 RESTful API 的理解都不同,在 HTTP 協議層面做擴展與實現,不如交給接口設計者與調用者自己來約定數據結構(或者參考 JSON-RPC 規范)。把 HTTP 只當做傳輸協議來使用的好處是,當后端服務間的接口需要直接基於 TCP 傳輸層來做性能優化時,可以十分方便的切換成 Socket 的實現(之前在騰訊做微博相關項目時,微博開放平台對外只提供 HTTP 的 Open API,但對內可以提供更高頻率與頻次調用的原生 Socket 協議)。

2、只使用 GET/POST Method

由於 HTTP 1.0 尤其是 HTML 的規范與應用已經深入人心。大部分開發者能夠很自然的這樣理解:「GET」 表示「讀」操作,「POST」 表示「寫」操作。這樣既可以保證中間組件與瀏覽器很好的利用 GET 的緩存機制,又能降低接口設計的復雜度。HTTP 之父 Roy Fielding 也說過「It is okay to use POST」:

Some people think that REST suggests not to use POST for updates. Search my dissertation and you won’t find any mention of CRUD or POST. (很多人認為 RESTful 建議不要使用 POST 用於提交更新,去翻一翻我的論文,壓根就沒提到過 POST 和其他「增查改刪」方面的內容。)

但使用 POST 方法時尤其要注意:「使用統一的 Content-Type」。這是一個容易被新手忽略的細節,也是接口設計中經常出錯的點。在上一期的《貓哥網絡編程系列:詳解 BAT 面試題》中有問到:

一個 POST 請求的 Content-Type 有多少種,傳輸的數據格式有何區別?

以下舉例一些常見類型的 HTTP POST Request 報文,請注意其中的 Content-Type 與 Body 的對應關系(已手動刪除無關 HTTP Header)

POST /test.php HTTP/1.1
Host: 127.0.0.1:8080
Content-Length: 54
Content-Type: application/json

{"weixin_id":"imgXQB","weixin_name":"貓哥學前班"}
POST /test.php HTTP/1.1
Host: 127.0.0.1:8080
Content-Length: 74
Content-Type: application/x-www-form-urlencoded

weixin_id=imgXQB&weixin_name=%E7%8C%AB%E5%93%A5%E5%AD%A6%E5%89%8D%E7%8F%AD
POST /test.php HTTP/1.1
Host: 127.0.0.1:8080
Content-Length: 259
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryl60ti7CVoBj2kxfX

------WebKitFormBoundaryl60ti7CVoBj2kxfX
Content-Disposition: form-data; name="weixin_id"

imgXQB
------WebKitFormBoundaryl60ti7CVoBj2kxfX
Content-Disposition: form-data; name="weixin_name"

貓哥學前班
------WebKitFormBoundaryl60ti7CVoBj2kxfX--

只有客戶端 POST 請求體的消息格式與其請求頭聲明的 Content-Type 一致時,服務端才能正確的接收與響應。因為許多后端的 Web 應用框架會遵照 HTTP 協議的內容協商原則(Content Negotiation)對響應體進行預處理,以提升開發體驗。例如,Python 的 Flask 框架 封裝了request.jsonrequest.formrequest.data 等一系列屬性用於存放不同類型的來源數據。

3、接口 URI 與參數命名風格的一致性

  • API URI 應該全小寫。屏蔽掉 Linux/Windows 操作系統對文件名大小寫敏感度不一致的問題;
  • URI 命名上應該使用連字符「-」來間隔,而不是使用下划線「_」或駝峰式。這是出於視覺美觀度和英文語義方面的考慮,英文域名規范規定可以使用連字符,但不能使用下划線,API 路徑應該和 Domain 命名風格一致;
  • URI 使用「動詞+名詞」或者「名詞+動詞」均可,但選定一種之后應該保持一致。接口風格的一致性,可以降低使用者的理解成本,好的 API 命名風格能讓人「以一知萬」,能從一個 API 猜測出所有其他 API 的命名形式;
  • 參數命名上應該使用下划線「_」而非連接符「-」。這點主要是從數據庫字段設計的統一性和后台應用程序框架的易用性來考慮;
  • 不同接口的相同參數命名應保持統一,並考慮擴展要求。例如,收集用戶信息的參數可以統一叫「ua」,為了便於擴展可以約定將客戶端分辨率、瀏覽器型號等信息使用「||」字符串連接,如ua=1280x768||chrome,當需要添加操作系統字段時,客戶端只需按規則追加信息到原來的參數上,如ua=1280x768||chrome||windows。該條原則還有許多其他的方法來實現,不再一一舉例。

4、返回數據結構的一致性

基本的返回體結構,可參考以下示例代碼。

{
    "code": "0",
    "message": "success",
    "data": {
        "id" : "1",
        "list" : []
    }
}

寥寥的幾行代碼飽含了幾部深刻的血淚史:

  • 出於一致性的考慮,code 表示返回碼(也可以理解成錯誤碼),成功時返回 "0" ,出錯時按預設的錯誤碼規則返回(微信的返回碼規范設計的並不好,因為沒有內建的規律和語義);
  • 同上,可以理解 message 與 data 的設計。需要注意的是 data 只具有 Object 一種類型。無數據的時候返回一個空對象 {}(而非 null),有多條數據的時候將 Array 類型數據放在其內部的 list 之類的屬性中;
  • 所有原始數據類型建議統一使用字符串類型,包括布爾值用 "0" 和 "1"。原因是前后端對浮點數運算精度不一致,會導致商品價格的計算與展示出錯;iOS/Android 客戶端對 JSON null、布爾類型轉換的不一致會導致頻繁的 App Crash。

當然,也有許多其他的方案可以解決上面提到的問題,但出於「最簡」的原則,這樣約定的理解成本最低。

最簡 Mock Server

有了最簡 API 的約定之后,實現最簡 Mock Server 就相對簡單多了。

1、編寫返回的模擬數據

首先,我們按照 API 接口約定來新建一些模擬數據文件。例如新建一個 「mock-data.json」 的文件,將以上返回體數據保存其中。

2、運行 php 內置服務器

在命令行模式下運行 php 命令,Mac 用戶直接打開終端即可,Windows 用戶需要先安裝 XAMPP 套件,並將 php.exe 所在的目錄配置到系統環境變量中,再使用 CMD 運行以下命令:

php -S 0.0.0.0:8080 mock-data.json

開啟之后訪問任意 API 地址(http://127.0.0.1:8080/any-api-uri-you-want/)均會返回 mock-data.json 的數據響應體。通過將 8080 端口換成 80 端口(Mac 需要使用 sudo 權限),再設置類似 127.0.0.1 www.example.com 的 HOST 配置,便可以模擬 API 的 Domain Host(http://www.example.com/any-api-uri-you-want/)形式。

當然,也可以自己編寫一個 index.php 的入口文件來實現一個基於 URL Path 規則的簡單 Rewrite 功能,用來同時支持多個 API 的數據模擬。

使用 Fiddler/Charles 的 Map Local 功能

Fiddler/Charles 的 Map Local(本地映射)不光是用於 HTTP Edit,同樣可以用於 HTTP Mock,當一個 404 請求(還未真正實現的 API)被代理服務器捕獲后,可以設置映射到本地自定義的 mock-data.json 模擬數據文件,從而被模擬成一個正常的 200 請求。

自動化 Mock System 構想

迄今為止,我還未發現一個理想中的 Mock API 開源系統,如有哪位同學有見到過請在 Github 上留言周知,以下是我對最理想 Mock System 的構想:

  1. API 錄入后台。包含一個按項目(一般是 Domain)維度進行 API 管理的后台。可以在后台上錄入「請求 URI、參數、多種業務數據響應體、全局錯誤碼、API 錯誤碼」等接口信息;
  2. API 接口文檔。能夠基於 API 后台數據,生成在線的 API 文檔平台;
  3. Postman 導入/導出。能夠基於 API 數據導出生成 Postman Collections,以便導入 Postman 中進行 API 調試;
  4. Mock Server。能夠基於 API 數據快速搭建類似 MockServer 的本地服務,或提供遠程模擬接口服務。

「HTTP PEM」系統分析利器

這個接口很復雜,內部調用了好幾個其他接口,如何定位問題究竟出在哪一步?

對於新人來說,最快的成長方式是不斷地在新項目中實踐,從頭到尾參與到項目的每個系統細節的設計與討論。如果能參與到重點、大型項目中,甚至幸運地得到大牛的親自指導,成長速度將會突飛猛進。

但更多的情況是,新人作為離職程序員的補充力量來接手一個老項目甚至是爛攤子。面對一個復雜的陌生系統,吐槽與抱怨無濟於事。這時,如果能使用「HTTP PEM 調試法」,從接口設計與調用的角度來剖析、理解整個系統的設計,就能快速上手業務。例如,PHP 程序員可以在項目代碼中所有的 curl 調用點,將「CURLOPT_PROXY」設置成 Fiddler/Charles 的代理服務,然后一步步調試,從接口字段上理解數據庫設計和 Controller 背后的業務邏輯。

最后,歡迎各位給我留言分享更多關於「HTTP PEM」和其他調試方法的經驗與體會。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM