原文地址:https://www.usblog.cc/blog/post/justzhl/揭秘瀏覽器遠程調試技術
調試技術的起源
1947 年 9 月 9 日,一名美國的科學家格蕾絲.霍普和她的同伴在對 Mark II 計算機進行研究的時候發現,一只飛蛾粘在一個繼電器上,導致計算機無法正常工作,當他們把飛蛾移除之后,計算機又恢復了正常運轉。於是他們將這只飛蛾貼在了他們當時記錄的日志上,對這件事情進行了詳細的記錄,並在日志最后寫了這樣一句話:First actual case of bug being found。這是他們發現的第一個真正意義上的 bug,這也是人類計算機軟件歷史上,發現的第一個 bug,而他們找到飛蛾的方法和過程,就是 debugging 調試技術。
從格蕾絲調試第一個 bug 到現在,69 年的時間里,在計算機領域,硬件、軟件各種調試技術都在不斷的發展和演進。那么對於日新月異的前端來說,調試技術也尤其顯得重要。淘寶前端團隊也正在使用一些創新的技術和手段來解決無線頁面調試的問題。今天先跟大家分享下瀏覽器遠程調試技術,本文將用 Chrome/Webview 來作為案例。
調試原理
調試方式與權限管理
目前常規瀏覽器調試目標分為兩種:Chrome PC 瀏覽器和 Chrome Mobile(Android 4.4 以后,Android WebView 其實就是 Chromium WebView)。
Chrome PC 瀏覽器
對於調試 Chrome PC 瀏覽器,可能大家經常使用的是用鼠標右鍵或者快捷方式(mac:option + command + J),喚起 Chrome 的控制台,來對當前頁面進行調試。其實還有另外一種方法,就是使用一個 Chrome 瀏覽器調試另一個 Chrome 瀏覽器。Chrome 啟動的時候,默認是關閉了調試端口的,如果要對一個目標 Chrome PC 瀏覽器進行調試,那么啟動的時候,可以通過傳遞參數來開啟 Chrome 的調試開關:
1
2
|
# for mac
sudo /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
|
Chrome Android 瀏覽器
對於調試 Android 上的 Chrome 或者 WebView 需要連接 USB 線。打開調試端口的方法如下:
1
|
adb forward tcp:9222 localabstract:chrome_devtools_remote
|
跟 Chrome PC 瀏覽器不同的是,對於 Chrome Android 瀏覽器,由於數據傳輸是通過 USB 線而不是 WIFI,實際上 Chrome Android 創建的一個 chrome_devtools_remote 這個 path 的 domain socket。所以,上面一條命令則是通過 Android 的 adb 將 PC 的端口 9222 通過 USB 線與 chrome_devtools_remote 這個 domain socket 建立了一個端口映射。
權限管理
Google 為了限制調試端口的接入范圍,對於 Chrome PC 瀏覽器,調試端口只接受來自 127.0.0.1
或者 localhost
的數據請求,所以,你無法通過你的本地機器 IP 來調試 Chrome。對於 Android Chrome/WebView,調試端口只接受來自於 shell
這個用戶數據請求,也就是說只能通過 USB 進行調試,而不能通過 WIFI。
開始調試
通過以上的調試方式的接入以及調試端口的打開,這個時候在瀏覽器中輸入:
1
|
http://127.0.0.1:9222/json
|
將會看到類似下面的內容:
1
2
3
4
5
6
7
8
9
10
11
|
[
{
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/ebdace60-d482-4340-b622-a6198e7aad6e",
"id": "ebdace60-d482-4340-b622-a6198e7aad6e",
"title": "揭秘瀏覽器遠程調試技術.mdown—/Users/harlen/Documents",
"type": "page",
"url": "http://127.0.0.1:51004/view/61",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/ebdace60-d482-4340-b622-a6198e7aad6e"
}
]
|
其中,最重要的 2 個參數分別是 id 和 webSocketDebuggerUrl。Chrome 會為每個頁面分配一個唯一的 id,作為該頁面的唯一標識符。幾乎對目標瀏覽器的所有操作都是需要帶上這個 id。
Chrome 提供了以下這些 http 接口控制目標瀏覽器
1
2
3
4
5
6
7
8
9
10
11
|
# 獲取當前所有可調式頁面信息
http://127.0.0.1:9222/json
# 獲取調試目標 WebView/blink 的版本號
http://127.0.0.1:9222/json/version
# 創建新的 tab,並加載 url
http://127.0.0.1:9222/json/new?url
# 關閉 id 對應的 tab
http://127.0.0.1:9222/json/close/id
|
webSocketDebuggerUrl 則在調試該頁面需要用到的一個 WebSocket 連接。chrome 的 devtool 的所有調試功能,都是基於 Remote Debugging Protocol 使用 WebSocket 來進行數據傳輸的。那么這個 WebSocket,就是上面我們從 http://127.0.0.1:9222/json
獲取的 webSocketDebuggerUrl
,每一個頁面都有自己不同的 webSocketDebuggerUrl
。這個 webSocketDebuggerUrl
是通過 url 的 query 參數傳遞給 chrome devtool 的。
chrome 的 devtool 可以從 Chrome 瀏覽器中進行提取 devtool 源碼或者從 blink 源碼中獲取。在部署好自己的 chrome devtool 代碼之后,下面既可以開始對 Chrome 進行調試, 瀏覽器輸入一下內容:
1
|
http://path_to_your_devtool/devtool.html?ws=127.0.0.1:9222/devtools/page/ebdace60-d482-4340-b622-a6198e7aad6e
|
其中 ws 這個參數的值就是上面出現的 webSocketDebuggerUrl。Chrome 的 devtool 會使用這個 url 創建 WebSocket 對該頁面進行調試。
如何實現 JavaScript 調試
在進入 Chrome 的 devtool 之后,我們可以調出控制台,來查看 devtool 的 WebSocket 數據。這個里面有很多數據,我這里只講跟 JavaScript 調試相關的。
圖中,對於 JavaScript 調試,有一條非常重要的消息,我藍色選中的那條消息:
1
|
{"id":6,"method":"Debugger.enable"}
|
然后選中要調試的 JavaScript 文件,然后設置一個斷點,我們再來看看 WebSocket 消息:
devtool 像目標 Chrome 發送了 2 條消息
1
2
3
4
5
6
7
|
{
"id": 23,
"method": "Debugger.getScriptSource",
"params": {
"scriptId": "103"
}
}
|
1
2
3
4
5
6
7
8
9
10
|
{
"id": 24,
"method": "Debugger.setBreakpointByUrl",
"params": {
"lineNumber": 2,
"url": "https://g.alicdn.com/alilog/wlog/0.2.10/??aplus_wap.js,spm_wap.js,spmact_wap.js",
"columnNumber": 0,
"condition": ""
}
}
|
那么收到這幾條消息之后,V8 做了些什么呢?
我們先來簡單的看下 V8 里面的一小段源碼片段:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// V8 Debugger.cpp
DispatcherImpl(FrontendChannel* frontendChannel, Backend* backend) :DispatcherBase(frontendChannel), m_backend(backend) {
m_dispatchMap["Debugger.enable"] = &DispatcherImpl::enable;
m_dispatchMap["Debugger.disable"] = &DispatcherImpl::disable;
m_dispatchMap["Debugger.setBreakpointsActive"] = &DispatcherImpl::setBreakpointsActive;
m_dispatchMap["Debugger.setSkipAllPauses"] = &DispatcherImpl::setSkipAllPauses;
m_dispatchMap["Debugger.setBreakpointByUrl"] = &DispatcherImpl::setBreakpointByUrl;
m_dispatchMap["Debugger.setBreakpoint"] = &DispatcherImpl::setBreakpoint;
m_dispatchMap["Debugger.removeBreakpoint"] = &DispatcherImpl::removeBreakpoint;
m_dispatchMap["Debugger.continueToLocation"] = &DispatcherImpl::continueToLocation;
m_dispatchMap["Debugger.stepOver"] = &DispatcherImpl::stepOver;
m_dispatchMap["Debugger.stepInto"] = &DispatcherImpl::stepInto;
m_dispatchMap["Debugger.stepOut"] = &DispatcherImpl::stepOut;
m_dispatchMap["Debugger.pause"] = &DispatcherImpl::pause;
m_dispatchMap["Debugger.resume"] = &DispatcherImpl::resume;
m_dispatchMap["Debugger.searchInContent"] = &DispatcherImpl::searchInContent;
m_dispatchMap["Debugger.setScriptSource"] = &DispatcherImpl::setScriptSource;
m_dispatchMap["Debugger.restartFrame"] = &DispatcherImpl::restartFrame;
m_dispatchMap["Debugger.getScriptSource"] = &DispatcherImpl::getScriptSource;
m_dispatchMap["Debugger.setPauseOnExceptions"] = &DispatcherImpl::setPauseOnExceptions;
m_dispatchMap["Debugger.evaluateOnCallFrame"] = &DispatcherImpl::evaluateOnCallFrame;
m_dispatchMap["Debugger.setVariableValue"] = &DispatcherImpl::setVariableValue;
m_dispatchMap["Debugger.setAsyncCallStackDepth"] = &DispatcherImpl::setAsyncCallStackDepth;
m_dispatchMap["Debugger.setBlackboxPatterns"] = &DispatcherImpl::setBlackboxPatterns;
m_dispatchMap["Debugger.setBlackboxedRanges"] = &DispatcherImpl::setBlackboxedRanges;
}
|
你會發現,V8 有 m_dispatchMap
這樣一個 Map。專門用來處理所有 JavaScript 調試相關的處理。
其中就有本文即將重點講述的:
- Debuggger.enable
- Debugger.getScriptSource
- setBreakpointByUrl
這些都需要在 V8 的源碼中找到答案。順便給大家推薦一個查看 Chromium/V8 最正確的方式是使用 https://cs.chromium.org,比 SourceInsight 還要方便。
Debugger.enable
1
2
3
4
5
6
7
8
9
|
void V8Debugger::enable() {
if (m_enableCount++) return;
DCHECK(!enabled());
v8::HandleScope scope(m_isolate);
v8::Debug::SetDebugEventListener(m_isolate, &V8Debugger::v8DebugEventCallback,
v8::External::New(m_isolate, this));
m_debuggerContext.Reset(m_isolate, v8::Debug::GetDebugContext(m_isolate));
compileDebuggerScript();
}
|
這個接口的名稱叫 Debugger.enable
,但是收到這條消息,V8 其實就干了兩件事情事情:
- SetDebugEventListener:
給 JavaScript 調試安裝監聽器,並設置v8DebugEventCallback
這個回調函數。JavaScript 所有的調試事件,都會被這個監聽器捕獲,包括:JavaScript 異常停止,斷點停止,單步調試等等。 - compileDebuggerScript:
編譯 V8 內置的 JavaScript 文件 debugger-script.js。由於這文件比較長,我這里就不貼出來了,感興趣的同學點擊這個鏈接進行查看源碼。debugger-script.js
主要是定義了一些針對 JavaScript 斷點進行操作的函數,例如設置斷點、查找斷點以及單步調試相關的函數。那么這個debugger-script.js
文件,被 V8 進行編譯之后,保存在 global 對象上,等待對 JavaScript 進行調試的時候,被調用。Debugger.getScriptSource
在 Chrome 解析引擎解析到
標簽之后,Chrome 將會把 script 標簽對應的 JavaScript 源碼扔給 V8 編譯執行。同時,V8 將會對所有的 JavaScript 源碼片段進行編號並保存。所以,當 chrome devtool 需要獲取要調試的 JavaScript 文件的時候,只需要通過
Debugger.getScriptSource
,給 V8 傳遞一個 scriptId,V8 將會把 JavaScript 源碼返回。我們再回頭看看這個圖中的消息:
上面 id 為 23 的scriptSource
就是 V8 返回的 JavaScript 源碼,如此以來,我們就可以在 devtool 中看到我們要調試的 JavaScript 源碼了。
Debugger.setBreakpointByUrl
所有准備工作都做好了,現在就可以開始設置斷點了。從上面的幾個圖中,已經可以很清楚的看到,Debugger.setBreakpointByUrl
給目標 Chrome 傳遞了一個 JavaScript 的 url 和斷點的行號。
首先,V8 會去找,是否已經存在了該 URL 對應的 JavaScript 源碼了:
1
2
3
4
5
6
7
8
9
|
for (const auto& script : m_scripts) {
if (!matches(m_inspector, script.second->sourceURL(), url, isRegex))
continue;
std::unique_ptr<protocol::Debugger::Location> location = resolveBreakpoint(
breakpointId, script.first, breakpoint, UserBreakpointSource);
if (location) (*locations)->addItem(std::move(location));
}
*outBreakpointId = breakpointId;
|
V8 給所有的斷點,創建一個 breakpointObject。並將這些 braekpointObject 以 的形式存放在一個 Map 里面,而這個 Key,就是這個 JavaScript 文件的 URL。看到這里,已經可以解釋很多同學在調試 JavaScript 遇到的一個問題:,>
有些同學為了防止頁面的 JavaScript 文件不更新,對於一些重要的 JavaScript 文件的 URL 添加訪問時間戳,對於這些添加了訪問時間戳的 JavaScript 文件進行設置斷點然后刷新調試的時候,Chrome 會打印一個 warnning,告訴你斷點丟失。
原因很簡單,在調試的時候,V8 發現這個 breakpointMap 里面找不到對應的 breakpointObject,因為 URL 發生了變化,這個 brakpointObject 就丟失了,所以 V8 就找不到了,無法進行斷點調試。
根據我們的正常思維,你可能會認為 V8 會將斷點設置在 C++ 中,其實一開始我也是這么認為。隨着對 V8 的探索,讓我看到了我時曾相識的一些函數名:
1
2
3
4
5
6
7
|
v8::Local<v8::Function> setBreakpointFunction = v8::Local<v8::Function>::Cast(
m_debuggerScript.Get(m_isolate)
->Get(context, toV8StringInternalized(m_isolate, "setBreakpoint"))
.ToLocalChecked());
v8::Local<v8::Value> breakpointId =
v8::Debug::Call(debuggerContext(), setBreakpointFunction, info)
.ToLocalChecked();
|
其中,m_debuggerScript
,就是我前面提到的 debugger-script.js
。隨着對 V8 Debugger 的進一步探索,我發現,V8 實際上對這個對這個 breakpointObject 設置了 2 次。一次是通過在 C++ 中調用 m_debuggerScript 的 setBreakpoint 設置到 JavaScript 的 context 里面,也就是上面這段 C++ 邏輯做的事情。另一次是,m_debuggerScript
反過來將斷點信息設置到了 V8 的 C++ Runtime 中,為要調試的 JavaScript 的某一行設置一個 JavaScript 的回調函數。
斷點命中
由於 V8 對 JavaScript 是及時編譯執行的,沒有生成 bytecode,而是直接生成的 machine code 執行的,所以這個斷點回調函數也會被設置到這個 machine code 里面。
最終觸發斷點事件,也是 V8 的 C++ Runtime。當用戶刷新或者直接執行 JavaScript 的邏輯的時候,實際上是 V8 C++ Runtime 在運行 JavaScript 片段產生的 machine code,這個 machine code 已經包含了斷點回調函數了。一旦這個 machine code 里面的回調函數被觸發,接着就會觸發之前 Debugger.enable 設置的調試事件監聽器 DebugEventListener 的回調函數。並返回一條消息給 Chrome 的 devtool,告訴 Chrome devtool,當前 JavaScript 被 pause 的行號。到此為止,一個斷點就被命中了。
關於 JavaScript 斷點命中,其實是一個很復雜的過程。后面有時間的話,會專門講講 JavaScript 斷點命中的詳細邏輯。
總結
瀏覽器的調試,最終都落腳到引擎:渲染引擎和 JavaScipt 引擎。那么對於 JavaScript 調試來說,難點就在於 V8 如何給 JavaScript 某一行進行標記然后進行斷點,這需要有一點 V8 的知識。