Chrome DevTools 远程调试协议分析及实战和selenium自动化程序上面的结合和应用

Chrome DevTools 远程调试协议分析及实战

winty~~

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

阅读量7.3k

 收藏 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 为交互中使用到的实体。

例如:

 
  1. # https://chromedevtools.github.io/devtools-protocol/1-3/Log

  2. Log Domain 

  3. Provides access to log entries.

  4. Methods

  5. Log.clear

  6. Log.disable

  7. Log.enable

  8. Log.startViolationsReport

  9. Log.stopViolationsReport

  10. Events

  11. Log.entryAdded

  12. Types

  13. LogEntry

  14. 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 目录,可以看到如下结构:

 
  1. # tree -L 1

  2. .

  3. ├── accessibility

  4. ├── accessibility_test_runner

  5. │   ├── AccessibilityPaneTestRunner.js

  6. │   └── module.json

  7. ├── animation

  8. ├── application_test_runner

  9. ├── axe_core_test_runner

  10. ...

  11. ├── input

  12. ├── inspector.html

  13. ├── inspector.js

  14. ├── inspector.json

  15. ├── network

  16. ├── network_test_runner

  17. ├── node_app.html

  18. ├── node_app.js

  19. ├── node_app.json

  20. ├── worker_app.html

  21. ├── worker_app.js

  22. └── 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 为例,我们来看配置文件的语义:

 
  1. // devtools_frontend/front_end/devtools_app.json

  2. {

  3.   "modules" : [

  4.     { "name": "emulation", "type": "autostart" },

  5.     { "name": "inspector_main", "type": "autostart" },

  6.     { "name": "mobile_throttling", "type": "autostart" },

  7.     ...

  8.     { "name": "timeline" },

  9.     { "name": "timeline_model" },

  10.     { "name": "web_audio" },

  11.     { "name": "media" }

  12.   ],

  13.   "extends": "shell",

  14.   "has_html": true

  15. }

  • modules 表示此应用包含的模块,每个模块都对应 front_end 目录下的一个目录。

  • extends 表示此应用是否继承自另外一个应用,devtools_app 继承自 shell 应用,我们可以在 front_end 目录下看到 shell.js、shell.json。

  • has_html 表示此应用有 html 界面,即同名的 devtools_app.json。

我们再来看一下模块,所有的模块都平级放在 front_end 目录下,不存在嵌套,每个模块都有一个 module.json 文件,表示此模块的配置。

 
  1. {

  2.     "extensions": [

  3.         {

  4.             "type": "view",

  5.             "location": "drawer-view"

  6.         }

  7.     ],

  8.     "dependencies": [

  9.         "elements"

  10.     ],

  11.     "scripts": [],

  12.     "modules": [

  13.         "animation.js",

  14.         "animation-legacy.js",

  15.         "AnimationUI.js"

  16.     ],

  17.     "resources": [

  18.         "animationScreenshotPopover.css",

  19.         "animationTimeline.css"

  20.     ]

  21. }

  • extensions 表示此模块的自定义属性。

  • dependencies 表示此模块依赖的模块。

  • modules 表示此模块包括的 js 文件。

  • resources 表示此模块包括的静态资源,主要是 css。

之所以有这些配置,是因为,front_end 有自己的一套模块加载逻辑,和通常的 node 应用和前端应用都不一样。

初始化

front_end 各个应用初始化的过程类似,基本如下:

  • 从对应的 json 文件中加载配置,并根据配置加载需要的模块

 
  1. // devtools-frontend/front_end/RuntimeInstantiator.js

  2. export async function startApplication(appName) {

  3.   console.timeStamp('Root.Runtime.startApplication');

  4.   const allDescriptorsByName = {};

  5.   for (let i = 0; i < Root.allDescriptors.length; ++i) {

  6.     const d = Root.allDescriptors[i];

  7.     allDescriptorsByName[d['name']] = d;

  8.   }

  9.   if (!Root.applicationDescriptor) {

  10.     // 加载应用配置 <appName>.json

  11.     let data = await RootModule.Runtime.loadResourcePromise(appName + '.json');

  12.     Root.applicationDescriptor = JSON.parse(data);

  13.     let descriptor = Root.applicationDescriptor;

  14.     while (descriptor.extends) {

  15.       // 加载父级配置直到没有父级

  16.       data = await RootModule.Runtime.loadResourcePromise(descriptor.extends + '.json');

  17.       descriptor = JSON.parse(data);

  18.       Root.applicationDescriptor.modules = descriptor.modules.concat(Root.applicationDescriptor.modules);

  19.     }

  20.   }

  21.   const configuration = Root.applicationDescriptor.modules;

  22.   const moduleJSONPromises = [];

  23.   const coreModuleNames = [];

  24.   for (let i = 0; i < configuration.length; ++i) {

  25.     const descriptor = configuration[i];

  26.     const name = descriptor['name'];

  27.     const moduleJSON = allDescriptorsByName[name];

  28.     // 根据每个模块的 module.json 加载模块

  29.     if (moduleJSON) { 

  30.       moduleJSONPromises.push(Promise.resolve(moduleJSON));

  31.     } else {

  32.       moduleJSONPromises.push(

  33.           RootModule.Runtime.loadResourcePromise(name + '/module.json').then(JSON.parse.bind(JSON)));

  34.     }

  35.   }

  36.     // ...

  37. }

  • 实例化模块

虽然 js 代码都是通过 import 来引用依赖,但是 front_end 并非使用 import 来加载模块,而是自己写了一个模块加载逻辑,先请求模块文件,然后在根据依赖关系把代码 eval。

 
  1. // devtools-frontend/front_end/root/Runtime.js

  2. function evaluateScript(sourceURL, scriptSource) {

  3.     loadedScripts[sourceURL] = true;

  4.     if (!scriptSource) {

  5.       // Do not reject, as this is normal in the hosted mode.

  6.       console.error('Empty response arrived for script \'' + sourceURL + '\'');

  7.       return;

  8.     }

  9.     self.eval(scriptSource + '\n//# sourceURL=' + sourceURL);

  10. }

  • 模块加载完成后,才是真正的初始化

作为调试器前端,socket 通信是不可或缺的,初始化的主要工作就是对调试器后端建立 socket 连接,准备好调试协议。

对于页面应用来说,还需要初始化 UI,front_end 未使用任何渲染框架,全部都是原生 DOM 操作。

 
  1. // devtools-frontend/front_end/main/MainImpl.js

  2. 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 通信地址。例如:

 
  1. [{

  2.    "description": "",

  3.    "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/8ED9DABCE2A6BD36952657AEBAA0DE02",

  4.    "faviconUrl": "https://github.githubassets.com/favicon.ico",

  5.    "id": "8ED9DABCE2A6BD36952657AEBAA0DE02",

  6.    "title": "GitHub - Unitech/pm2: Node.js Production Process Manager with a built-in Load Balancer.",

  7.    "type": "page",

  8.    "url": "https://github.com/Unitech/pm2",

  9.    "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/8ED9DABCE2A6BD36952657AEBAA0DE02"

  10. }]

  • /json/new  创建新的 tab 页

  • /json/activate/:id 根据 id 激活 tab 页

  • /json/close/:id 根据 id 关闭 tab 页

  • /json/version 获取浏览器/协议/v8/webkit 版本,例如:

 
  1. {

  2.    "Browser": "Chrome/80.0.3987.149",

  3.    "Protocol-Version": "1.3",

  4.    "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",

  5.    "V8-Version": "8.0.426.27",

  6.    "WebKit-Version": "537.36 (@5f4eb224680e5d7dca88504586e9fd951840cac6)",

  7.    "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/ad007235-aa36-4465-beb1-70864067ea49"

  8. }

注意:这些接口都不能跨域,可以通过服务器访问,或者直接在浏览器中打开,但是不能使用 ajax 访问。

连接

获取到 webSocketDebuggerUrl 后,我们就可以用此连接来调试页面。front_end 下的 devtool、inspector 等应用均可使用。

观察 初始化 socket 链接的代码可以得知,我们需要把 webSocketDebuggerUrl 以 url 参数的形式传给应用,参数名为 ws。

 
  1. // devtools-frontend/front_end/sdk/Connections.js

  2. export function _createMainConnection(websocketConnectionLost) {

  3.   const wsParam = Root.Runtime.queryParam('ws');

  4.   const wssParam = Root.Runtime.queryParam('wss');

  5.   if (wsParam || wssParam) {

  6.     const ws = wsParam ? `ws://${wsParam}` : `wss://${wssParam}`;

  7.     return new WebSocketConnection(ws, websocketConnectionLost); 

  8.   }

  9.   if (Host.InspectorFrontendHost.InspectorFrontendHostInstance.isHostedMode()) {

  10.     return new StubConnection();

  11.   }

  12.   return new MainConnection();

  13. }

我们在 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 服务代码示例:

 
  1. // server.js

  2. var WebSocketServer = require('websocket').server;

  3. var http = require('http');

  4. var server = http.createServer(function(request, response) {

  5.     response.writeHead(404);

  6.     response.end();

  7. });

  8. server.listen(3232, function() {

  9.     console.log((new Date()) + ' Server is listening on port 3232');

  10. });

  11. wsServer = new WebSocketServer({

  12.     httpServer: server

  13. });

  14. var frontendConnection;

  15. var debugConnection;

  16. wsServer.on('request', async function(request) {

  17.     var requestedProtocols = request.requestedProtocols;

  18.     if(requestedProtocols.indexOf("frontend") != -1){  // 处理来自调试器前端的请求

  19.         frontendConnection = request.accept('frontend', request.origin);

  20.         frontendConnection.on('message', function(message) {

  21.             if (message.type === 'utf8') {

  22.                 // 把调试器前端的请求直接转发给被调试页面

  23.                 if(debugConnection){

  24.                     debugConnection.sendUTF(message.utf8Data)

  25.                 }else{

  26.                     frontendConnection.sendUTF(JSON.stringify({msg:'调试器后端未准备好,先打开被调试的页面'}))

  27.                 }  

  28.             }

  29.         })

  30.         frontendConnection.on('close', function(reasonCode, description) {

  31.             console.log('frontendConnection disconnected.');

  32.         });

  33.     }

  34.     if(requestedProtocols.indexOf("remote-debug") != -1){ // 处理来自被调试页面的请求

  35.         debugConnection = request.accept('remote-debug', request.origin);

  36.         debugConnection.on('message', function(message) {

  37.             if (message.type === 'utf8') {

  38.                 var feed = JSON.parse(message.utf8Data);

  39.                 if(feed.type == "remote_debug_page"){   // 确认连接

  40.                     debugConnection.sendUTF(JSON.stringify({"type":"start_debug"}));

  41.                 }else if(feed.type == "start_debug_ready"){

  42.                     // 被调试页面已连接好

  43.                 } else{

  44.                     // 把被调试页面的数据全部转发给调试器前端

  45.                     if(frontendConnection){

  46.                         frontendConnection.sendUTF(message.utf8Data)

  47.                     }else{

  48.                         console.log('无法转发给frontend,没有建立连接')

  49.                     }

  50.                 }                

  51.             }

  52.         });

  53.         debugConnection.on('close', function(reasonCode, description) {

  54.             console.log((new Date()) + ' Peer remote' + debugConnection.remoteAddress + ' disconnected.');

  55.         });

  56.     }

  57. });

laucher.js 代码示例:

 
  1. var host = "localhost:3232"

  2. var ws = new WebSocket(`ws://${host}`,'remote-debug');       

  3. var search = location.search.slice(1);

  4. var urlParams = {};

  5. search.split('&').forEach(s=>{

  6.     var pair = s.split('=');

  7.     if(pair.length == 2){

  8.         urlParams[pair[0]] = pair[1]

  9.     }

  10. })

  11. ws.onopen = function() {

  12.     ws.send(JSON.stringify({type:"remote_debug_page",url:location.href}))

  13. };

  14. ws.onmessage = function (evt)  { 

  15.     var feed = JSON.parse(received_msg);

  16.     if(feed.type == "start_debug") {

  17.         // 连接到 webSocketDebuggerUrl

  18.         var debugWS = new WebSocket(`ws://${urlParams.ws}`);  

  19.         debugWS.onopen = function() {  

  20.             ws.send(JSON.stringify({type:"start_debug_ready"})); // 确认可以开始调试

  21.             ws.onmessage = function (evt) { // 转发到 debugger

  22.                 debugWS.send(evt.data);

  23.             }

  24.             ws.onclose = function (evt) {

  25.                 debugWS.close()

  26.             }

  27.         }

  28.         debugWS.onmessage = function (evt)  { 

  29.             ws.send(evt.data); // 转发到 server

  30.         }

  31.         debugWS.onclose = function() { 

  32.             ws.send(JSON.stringify({type:"remote_page_lost",url:location.href}))

  33.         };

  34.     }

  35. };

  36. ws.onclose = function() { 

  37.     console.log("连接已关闭..."); 

  38. };

回放

使用 inspector 时我们可以发现,只要开启了 Page.enable 和 Network.enable,就可以一直接收到调试器后端推送的页面快照和网络请求数据。

我们可以略微改造一下 server.js 的代码,把所有收到的推送数据打时间戳后保存到一个文件,持久化存储起来。

 
  1. if (message.type === 'utf8') {

  2.     var feed = JSON.parse(message.utf8Data);

  3.     if(feed.type == "remote_debug_page"){  

  4.         debugConnection.sendUTF(JSON.stringify({"type":"start_debug"}));

  5.     }else if(feed.type == "start_debug_ready"){

  6.         writeStream = fs.createWriteStream(saveFilePath,{flags:'as',encoding: 'utf8'});

  7.     } else{

  8.         // 全部转发给 frontendConnection

  9.         if(frontendConnection){

  10.             frontendConnection.sendUTF(message.utf8Data)

  11.         }else{

  12.             console.log('无法转发给frontend,没有建立连接')

  13.         }

  14.         // 保存数据到文件

  15.         if(feed.method)writeStream.write(message.utf8Data+'\n') 

  16.     }                

  17. }

然后我们给 websocket 服务增加一个协议类型,和 inspector 建立连接后,读取文件中保存的数据,按照时间戳上的时间间隔推送数据。

这样就实现了回放功能,把之前调试时的现场重现一遍。

 
  1. if(requestedProtocols.indexOf("feedback") != -1){

  2.     feedbackConnection = request.accept('feedback', request.origin);

  3.     feedbackConnection.on('message', function(message) {

  4.         // 忽略来的消息

  5.     })

  6.     const fileStream = fs.createReadStream(saveFilePath);

  7.     const rl = readline.createInterface({

  8.         input: fileStream,

  9.         crlfDelay: Infinity

  10.     });

  11.     for await (const line of rl) {  // 逐行读取数据

  12.         feedbackConnection.sendUTF(line)

  13.         rl.pause();

  14.         setTimeout(_=>{rl.resume()},1000)

  15.     }

  16.     feedbackConnection.on('close', function(reasonCode, description) {

  17.         console.log('feedbackConnection disconnected.');

  18.     });

  19. }

甚至可以更进一步,创建一个 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值