Chrome DevTools 远程调试协议分析及实战和selenium自动化程序上面的结合和应用
甚至可以更进一步,创建一个 websocket 服务作为调试器前端,模拟 inspector 发送请求的逻辑并保存推送数据到文件,这样就实现了一个录制服务器,可以随时录制调试现场,然后在需要的时候播放,因为记录了时间戳,pause、seek、resume、stop 都可以实现。我们先来看一下它是怎么工作的。我们可以用 front_end 来实现远程调试页面,例如:用户在自己的 PC、APP 上操作
Chrome DevTools 远程调试协议分析及实战


于 2020-04-24 08:45:00 发布


收藏 24

点赞数 3
Chrome DevTools 可以说是前端开发最常用的工具,无论是普通页面、移动端 webview、小程序、甚至 node 应用,都可以用它来调试。
Chrome DevTools 提供的功能非常丰富,包含 DOM、debugger、网络、性能等许多能力。
为什么 Chrome DevTools 能够适用这么多场景?如何把 Chrome DevTools 移植到新的应用场景?Chrome DevTools 提供的功能我们能不能拆解出模块单独使用?今天我们来尝试探索这些问题。
Chrome DevTools 组成
Chrome DevTools 包括四个部分:
-
调试器协议:devtools-protocol[1],基于 json rpc 2.0。
-
调试器后端:实现了调试协议的可调试实体,例如 chrome、node.js。
-
调试器前端:通常指内嵌在 chrome 中的调试面板,通过调试器协议和调试器后端交互,除此之外还有 Puppeteer[2],ndb[3] 等。
-
消息通道:前后端通信方式,例如 websocket、usb、adb 等,本质都是 socket 通信。

Chrome DevTools
我们可以看到,Chrome DevTools 的核心是调试器协议。
Chrome DevTools Protocol
协议按域「Domain」划分能力,每个域下有 Method、Event 和 Types。
Method 对应 socket 通信的请求/响应模式,Events 对应 socket 通信的发布/订阅模式,Types 为交互中使用到的实体。
例如:
-
# https://chromedevtools.github.io/devtools-protocol/1-3/Log -
Log Domain -
Provides access to log entries. -
Methods -
Log.clear -
Log.disable -
Log.enable -
Log.startViolationsReport -
Log.stopViolationsReport -
Events -
Log.entryAdded -
Types -
LogEntry -
ViolationSetting
一个调试器后端,应当实现对 Method 的响应,并在适当的时候发布 Event。
一个调试器前端,应当使用 Method 请求需要的数据,订阅需要的 Event。
browser_protocol & js_protocol
协议分为 browser_protocol[4] 和 js_protocol[5] 两种。
browser_protocol 是浏览器后端使用,js_protocol 是 node 后端使用。除此之外,还有对应的 Typescript 类型定义[6]。
js_protocol 只有以下四个域「Console、Schema 已废弃」:
-
Debugger
-
Profiler
-
Runtime 「js Runtime」
-
HeapProfiler
能力比 browser_protocol 少很多,这是因为页面有相对固定的工作模式,node 应用却千差万别。
browser_protocol 主要有以下几个域:
-
DOM
-
DOMDebugger
-
Emulation 「环境模拟」
-
Network
-
Page
-
Performance
-
Profiler
涉及了页面开发的方方面面。
Chrome DevTools Frontend
devtools-frontend 即调试器前端,我们平常使用的调试面板,其源码可以从 ChromeDevTools/devtools-frontend[7] 获得。我们先来看一下它是怎么工作的。
项目结构
从 ChromeDevTools/devtools-frontend[8] 下载源码后,我们进入 front_end 目录,可以看到如下结构:
-
# tree -L 1 -
. -
├── accessibility -
├── accessibility_test_runner -
│ ├── AccessibilityPaneTestRunner.js -
│ └── module.json -
├── animation -
├── application_test_runner -
├── axe_core_test_runner -
... -
├── input -
├── inspector.html -
├── inspector.js -
├── inspector.json -
├── network -
├── network_test_runner -
├── node_app.html -
├── node_app.js -
├── node_app.json -
├── worker_app.html -
├── worker_app.js -
└── worker_app.json

front_end 目录下的每一个 json 文件会有一个同名的 js 文件,有的还会有一个同名的 html 文件。
它们都代表一个应用,如 inspector.json 是其配置文件。如果此应用有界面,则带有 html,可以在浏览器中打开 html 运行应用。
我们可以看到熟悉的应用,inspector、node、devtools、ndb 等等。
devtools_app 即我们常用的调试面板,如图所示:
devtools
inspector 在 devtools_app 基础上增加了页面快照,可以实时看到页面的变化,并且可以在页面快照上交互,如图所示:
inspector
以 devtools_app 为例,我们来看配置文件的语义:
-
// devtools_frontend/front_end/devtools_app.json -
{ -
"modules" : [ -
{ "name": "emulation", "type": "autostart" }, -
{ "name": "inspector_main", "type": "autostart" }, -
{ "name": "mobile_throttling", "type": "autostart" }, -
... -
{ "name": "timeline" }, -
{ "name": "timeline_model" }, -
{ "name": "web_audio" }, -
{ "name": "media" } -
], -
"extends": "shell", -
"has_html": true -
}
-
modules 表示此应用包含的模块,每个模块都对应 front_end 目录下的一个目录。
-
extends 表示此应用是否继承自另外一个应用,devtools_app 继承自 shell 应用,我们可以在 front_end 目录下看到 shell.js、shell.json。
-
has_html 表示此应用有 html 界面,即同名的 devtools_app.json。
我们再来看一下模块,所有的模块都平级放在 front_end 目录下,不存在嵌套,每个模块都有一个 module.json 文件,表示此模块的配置。
-
{ -
"extensions": [ -
{ -
"type": "view", -
"location": "drawer-view" -
} -
], -
"dependencies": [ -
"elements" -
], -
"scripts": [], -
"modules": [ -
"animation.js", -
"animation-legacy.js", -
"AnimationUI.js" -
], -
"resources": [ -
"animationScreenshotPopover.css", -
"animationTimeline.css" -
] -
}

-
extensions 表示此模块的自定义属性。
-
dependencies 表示此模块依赖的模块。
-
modules 表示此模块包括的 js 文件。
-
resources 表示此模块包括的静态资源,主要是 css。
之所以有这些配置,是因为,front_end 有自己的一套模块加载逻辑,和通常的 node 应用和前端应用都不一样。
初始化
front_end 各个应用初始化的过程类似,基本如下:
-
从对应的 json 文件中加载配置,并根据配置加载需要的模块
-
// devtools-frontend/front_end/RuntimeInstantiator.js -
export async function startApplication(appName) { -
console.timeStamp('Root.Runtime.startApplication'); -
const allDescriptorsByName = {}; -
for (let i = 0; i < Root.allDescriptors.length; ++i) { -
const d = Root.allDescriptors[i]; -
allDescriptorsByName[d['name']] = d; -
} -
if (!Root.applicationDescriptor) { -
// 加载应用配置 <appName>.json -
let data = await RootModule.Runtime.loadResourcePromise(appName + '.json'); -
Root.applicationDescriptor = JSON.parse(data); -
let descriptor = Root.applicationDescriptor; -
while (descriptor.extends) { -
// 加载父级配置直到没有父级 -
data = await RootModule.Runtime.loadResourcePromise(descriptor.extends + '.json'); -
descriptor = JSON.parse(data); -
Root.applicationDescriptor.modules = descriptor.modules.concat(Root.applicationDescriptor.modules); -
} -
} -
const configuration = Root.applicationDescriptor.modules; -
const moduleJSONPromises = []; -
const coreModuleNames = []; -
for (let i = 0; i < configuration.length; ++i) { -
const descriptor = configuration[i]; -
const name = descriptor['name']; -
const moduleJSON = allDescriptorsByName[name]; -
// 根据每个模块的 module.json 加载模块 -
if (moduleJSON) { -
moduleJSONPromises.push(Promise.resolve(moduleJSON)); -
} else { -
moduleJSONPromises.push( -
RootModule.Runtime.loadResourcePromise(name + '/module.json').then(JSON.parse.bind(JSON))); -
} -
} -
// ... -
}

-
实例化模块
虽然 js 代码都是通过 import 来引用依赖,但是 front_end 并非使用 import 来加载模块,而是自己写了一个模块加载逻辑,先请求模块文件,然后在根据依赖关系把代码 eval。
-
// devtools-frontend/front_end/root/Runtime.js -
function evaluateScript(sourceURL, scriptSource) { -
loadedScripts[sourceURL] = true; -
if (!scriptSource) { -
// Do not reject, as this is normal in the hosted mode. -
console.error('Empty response arrived for script \'' + sourceURL + '\''); -
return; -
} -
self.eval(scriptSource + '\n//# sourceURL=' + sourceURL); -
}
-
模块加载完成后,才是真正的初始化
作为调试器前端,socket 通信是不可或缺的,初始化的主要工作就是对调试器后端建立 socket 连接,准备好调试协议。
对于页面应用来说,还需要初始化 UI,front_end 未使用任何渲染框架,全部都是原生 DOM 操作。
-
// devtools-frontend/front_end/main/MainImpl.js -
new MainImpl(); // 初始化SDK(协议),初始化socket连接,初始化通信
应用
远程调试
我们可以用 front_end 来实现远程调试页面,例如:用户在自己的 PC、APP 上操作页面,开发人员在另外一台电脑上观察页面、网络、控制台里发生的变化,甚至通过协议控制页面。
开启调试端口
不同后端打开调试端口的方式不同,以 chrome 为例:
chrome 和内嵌的调试面板使用 Embedder channel 通信,这个消息通道不能被用来做远程调试,远程调试我们需要使用 websocket channel。
使用 websocket channel 我们还需要打开 chrome 的远程调试端口,以命令行参数 remote-debugging-port 打开 chrome。
[path]/chrome.exe --remote-debugging-port=9222
或者使用脚本 devtools-frontend/scripts/hosted_mode/launch_chrome.js。
调试端口打开后,chrome 会启动一个内置的 http 服务,我们可以从中获取 chrome 的基本信息,其中最重要的是各个 tab 页的 websocket 通信地址。
chrome 提供的 http 接口如下,访问方式全部为 GET:
-
/json/protocol 获取当前 chrome 支持的协议,协议为 json 格式。
-
/json/list 获取可调试的目标列表,一般每个 tab 就是一个可调试目标,可调试目标的 webSocketDebuggerUrl 属性就是我们需要的 websocket 通信地址。例如:
-
[{ -
"description": "", -
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/8ED9DABCE2A6BD36952657AEBAA0DE02", -
"faviconUrl": "https://github.githubassets.com/favicon.ico", -
"id": "8ED9DABCE2A6BD36952657AEBAA0DE02", -
"title": "GitHub - Unitech/pm2: Node.js Production Process Manager with a built-in Load Balancer.", -
"type": "page", -
"url": "https://github.com/Unitech/pm2", -
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/8ED9DABCE2A6BD36952657AEBAA0DE02" -
}]
-
/json/new 创建新的 tab 页
-
/json/activate/:id 根据 id 激活 tab 页
-
/json/close/:id 根据 id 关闭 tab 页
-
/json/version 获取浏览器/协议/v8/webkit 版本,例如:
-
{ -
"Browser": "Chrome/80.0.3987.149", -
"Protocol-Version": "1.3", -
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36", -
"V8-Version": "8.0.426.27", -
"WebKit-Version": "537.36 (@5f4eb224680e5d7dca88504586e9fd951840cac6)", -
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/ad007235-aa36-4465-beb1-70864067ea49" -
}
注意:这些接口都不能跨域,可以通过服务器访问,或者直接在浏览器中打开,但是不能使用 ajax 访问。
连接
获取到 webSocketDebuggerUrl 后,我们就可以用此连接来调试页面。front_end 下的 devtool、inspector 等应用均可使用。
观察 初始化 socket 链接的代码可以得知,我们需要把 webSocketDebuggerUrl 以 url 参数的形式传给应用,参数名为 ws。
-
// devtools-frontend/front_end/sdk/Connections.js -
export function _createMainConnection(websocketConnectionLost) { -
const wsParam = Root.Runtime.queryParam('ws'); -
const wssParam = Root.Runtime.queryParam('wss'); -
if (wsParam || wssParam) { -
const ws = wsParam ? `ws://${wsParam}` : `wss://${wssParam}`; -
return new WebSocketConnection(ws, websocketConnectionLost); -
} -
if (Host.InspectorFrontendHost.InspectorFrontendHostInstance.isHostedMode()) { -
return new StubConnection(); -
} -
return new MainConnection(); -
}
我们在 front_end 目录下启动静态服务器。
serve -p 8002
然后访问 http://localhost:8002/inspector?ws=localhost:9222/devtools/page/8ED9DABCE2A6BD36952657AEBAA0DE02
我们可以看到页面上的一切变化都会出现在 inspector 的界面中。
跨域
如果前端和后端都在同一网段,我们使用以上方式就可以进行调试了,但是如果前后端在不同的内网内,我们如何实现远程调试?
只要我们有一台放在公网的服务器就可以调试。
前端和后端都在各自的内网内,因此相互之间肯定无法直接访问。但是它们都可以访问公网的服务器,并且,websocket 是可以跨域的。
因此我们可以通过两次转发,让不同内网的前端和后端交互,具体步骤如下:
-
创建一个转发用的 websocket 服务,放在公网。
-
我们在被调试的页面中增加一个自定义的 launcher.js,对公网的 websocket 服务建立连接,把页面的基本信息传递给服务器,同时通过 json/list 接口找出自身的 webSocketDebuggerUrl 建立连接。
注意:因为 json/list 是 http 接口,无法跨域,这一步必须手动获取,然后把 webSocketDebuggerUrl 放在 url 参数上传给 launcher.js
手动获取 webSocketDebuggerUrl
-
把 front_end 页面 url 的 ws 参数改为公网的 websocket 服务。
这样,我们的 socket 链路上有了四个节点,分别是:
-
front_end(调试器前端)
-
公网服务器(server)
-
laucher.js
-
debugger(调试器后端)
server 和 laucher 完全作为转发器,转发两边传来的信息,即可实现 front_end 到 debugger 的交互。
注意:如果 front_end 请求了 Network.enable, 就不能把 laucher.js 所在的页面作为调试页面,因为 laucher.js 收到 debugger 传来的数据会触发 Network.webSocketFrameReceived 推送,这个推送本身又会触发 Network.webSocketFrameReceived ,造成无限循环。处理方式有两种,一是拦截掉 Network.enable 请求,这样会取消掉所有的 Network 的推送。二是不把 laucher.js 所在的页面作为调试页面,仅作数据中转用。
远程调试
websocket 服务代码示例:
-
// server.js -
var WebSocketServer = require('websocket').server; -
var http = require('http'); -
var server = http.createServer(function(request, response) { -
response.writeHead(404); -
response.end(); -
}); -
server.listen(3232, function() { -
console.log((new Date()) + ' Server is listening on port 3232'); -
}); -
wsServer = new WebSocketServer({ -
httpServer: server -
}); -
var frontendConnection; -
var debugConnection; -
wsServer.on('request', async function(request) { -
var requestedProtocols = request.requestedProtocols; -
if(requestedProtocols.indexOf("frontend") != -1){ // 处理来自调试器前端的请求 -
frontendConnection = request.accept('frontend', request.origin); -
frontendConnection.on('message', function(message) { -
if (message.type === 'utf8') { -
// 把调试器前端的请求直接转发给被调试页面 -
if(debugConnection){ -
debugConnection.sendUTF(message.utf8Data) -
}else{ -
frontendConnection.sendUTF(JSON.stringify({msg:'调试器后端未准备好,先打开被调试的页面'})) -
} -
} -
}) -
frontendConnection.on('close', function(reasonCode, description) { -
console.log('frontendConnection disconnected.'); -
}); -
} -
if(requestedProtocols.indexOf("remote-debug") != -1){ // 处理来自被调试页面的请求 -
debugConnection = request.accept('remote-debug', request.origin); -
debugConnection.on('message', function(message) { -
if (message.type === 'utf8') { -
var feed = JSON.parse(message.utf8Data); -
if(feed.type == "remote_debug_page"){ // 确认连接 -
debugConnection.sendUTF(JSON.stringify({"type":"start_debug"})); -
}else if(feed.type == "start_debug_ready"){ -
// 被调试页面已连接好 -
} else{ -
// 把被调试页面的数据全部转发给调试器前端 -
if(frontendConnection){ -
frontendConnection.sendUTF(message.utf8Data) -
}else{ -
console.log('无法转发给frontend,没有建立连接') -
} -
} -
} -
}); -
debugConnection.on('close', function(reasonCode, description) { -
console.log((new Date()) + ' Peer remote' + debugConnection.remoteAddress + ' disconnected.'); -
}); -
} -
});

laucher.js 代码示例:
-
var host = "localhost:3232" -
var ws = new WebSocket(`ws://${host}`,'remote-debug'); -
var search = location.search.slice(1); -
var urlParams = {}; -
search.split('&').forEach(s=>{ -
var pair = s.split('='); -
if(pair.length == 2){ -
urlParams[pair[0]] = pair[1] -
} -
}) -
ws.onopen = function() { -
ws.send(JSON.stringify({type:"remote_debug_page",url:location.href})) -
}; -
ws.onmessage = function (evt) { -
var feed = JSON.parse(received_msg); -
if(feed.type == "start_debug") { -
// 连接到 webSocketDebuggerUrl -
var debugWS = new WebSocket(`ws://${urlParams.ws}`); -
debugWS.onopen = function() { -
ws.send(JSON.stringify({type:"start_debug_ready"})); // 确认可以开始调试 -
ws.onmessage = function (evt) { // 转发到 debugger -
debugWS.send(evt.data); -
} -
ws.onclose = function (evt) { -
debugWS.close() -
} -
} -
debugWS.onmessage = function (evt) { -
ws.send(evt.data); // 转发到 server -
} -
debugWS.onclose = function() { -
ws.send(JSON.stringify({type:"remote_page_lost",url:location.href})) -
}; -
} -
}; -
ws.onclose = function() { -
console.log("连接已关闭..."); -
};

回放
使用 inspector 时我们可以发现,只要开启了 Page.enable 和 Network.enable,就可以一直接收到调试器后端推送的页面快照和网络请求数据。
我们可以略微改造一下 server.js 的代码,把所有收到的推送数据打时间戳后保存到一个文件,持久化存储起来。
-
if (message.type === 'utf8') { -
var feed = JSON.parse(message.utf8Data); -
if(feed.type == "remote_debug_page"){ -
debugConnection.sendUTF(JSON.stringify({"type":"start_debug"})); -
}else if(feed.type == "start_debug_ready"){ -
writeStream = fs.createWriteStream(saveFilePath,{flags:'as',encoding: 'utf8'}); -
} else{ -
// 全部转发给 frontendConnection -
if(frontendConnection){ -
frontendConnection.sendUTF(message.utf8Data) -
}else{ -
console.log('无法转发给frontend,没有建立连接') -
} -
// 保存数据到文件 -
if(feed.method)writeStream.write(message.utf8Data+'\n') -
} -
}

然后我们给 websocket 服务增加一个协议类型,和 inspector 建立连接后,读取文件中保存的数据,按照时间戳上的时间间隔推送数据。
这样就实现了回放功能,把之前调试时的现场重现一遍。
-
if(requestedProtocols.indexOf("feedback") != -1){ -
feedbackConnection = request.accept('feedback', request.origin); -
feedbackConnection.on('message', function(message) { -
// 忽略来的消息 -
}) -
const fileStream = fs.createReadStream(saveFilePath); -
const rl = readline.createInterface({ -
input: fileStream, -
crlfDelay: Infinity -
}); -
for await (const line of rl) { // 逐行读取数据 -
feedbackConnection.sendUTF(line) -
rl.pause(); -
setTimeout(_=>{rl.resume()},1000) -
} -
feedbackConnection.on('close', function(reasonCode, description) { -
console.log('feedbackConnection disconnected.'); -
}); -
}

甚至可以更进一步,创建一个 websocket 服务作为调试器前端,模拟 inspector 发送请求的逻辑并保存推送数据到文件,这样就实现了一个录制服务器,可以随时录制调试现场,然后在需要的时候播放,因为记录了时间戳,pause、seek、resume、stop 都可以实现。
devtools-frontend 的调用方式
一般来说,我们习惯用 require/import 的方式调用模块,devtools-frontend 虽然也是个 npm 包 ,chrome-devtools-frontend[9],但是却不方便用 require/import 的方式直接引用。
主要是因为之前所述的 front_end 应用有自己的一套模块加载逻辑,应用的 js、json 配置文件必须在同一个目录下,模块也必须在同一个目录下,否则就会出现路径错误。
如果仅使用 front_end 的某个模块,还可以用 require/import 来引用。
如果想创建一个新的应用,最好是把整个 front_end 复制过来修改。
Chrome DevTools Extensions
如果想在 chrome 内嵌的调试面板中增加自定义的能力,可以用 chrome 插件的方式实现,例如vue-devtools[10]。
参考资料
ChromeDevTools/awesome-chrome-devtools[11]
ChromeDevTools/devtools-protocol[12]
参考资料
[1]
devtools-protocol: https://github.com/chromedevtools/devtools-protocol
[2]
Puppeteer: https://github.com/GoogleChrome/puppeteer/
[3]
ndb: https://github.com/GoogleChromeLabs/ndb
[4]
browser_protocol: https://github.com/ChromeDevTools/devtools-protocol/blob/master/json/browser_protocol.json
[5]
js_protocol: https://github.com/ChromeDevTools/devtools-protocol/blob/master/json/js_protocol.json
[6]
Typescript 类型定义: https://github.com/ChromeDevTools/devtools-protocol/tree/master/types
[7]
ChromeDevTools/devtools-frontend: https://github.com/ChromeDevTools/devtools-frontend
[8]
ChromeDevTools/devtools-frontend: https://github.com/ChromeDevTools/devtools-frontend
[9]
chrome-devtools-frontend: https://www.npmjs.com/package/chrome-devtools-frontend
[10]
vue-devtools: https://github.com/vuejs/vue-devtools
[11]
ChromeDevTools/awesome-chrome-devtools: https://github.com/ChromeDevTools/awesome-chrome-devtools
[12]
ChromeDevTools/devtools-protocol: https://github.com/chromedevtools/devtools-protocol
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)