揭秘浏览器远程调试技术

334 阅读9分钟

调试技术的起源

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 的调试开关:

# for mac
sudo /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

Chrome Android 浏览器

对于调试 Android 上的 Chrome 或者 WebView 需要连接 USB 线。打开调试端口的方法如下:

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。

开始调试

通过以上的调试方式的接入以及调试端口的打开,这个时候在浏览器中输入:

http://127.0.0.1:9222/json

将会看到类似下面的内容

[
  {
    "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 接口控制目标浏览器

获取当前所有可调式页面信息 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 进行调试, 浏览器输入一下内容:

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 调试,有一条非常重要的消息,我蓝色选中的那条消息

{"id":6,"method":"Debugger.enable"}

然后选中要调试的 JavaScript 文件,然后设置一个断点,我们再来看看 WebSocket 消息:

devtool 像目标 Chrome 发送了 2 条消息

{
  "id": 23,
  "method": "Debugger.getScriptSource",
  "params": {
    "scriptId": "103"
  }
}



{
  "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 里面的一小段源码片段:

// 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 最正确的方式是使用 cs.chromium.org,比 SourceInsight 还要方便。

Debugger.enable

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 解析引擎解析到 <script> 标签之后,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 源码了:

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 以 <Key, Value> 的形式存放在一个 Map 里面,而这个 Key,就是这个 JavaScript 文件的 URL。看到这里,已经可以解释很多同学在调试 JavaScript 遇到的一个问题:

有些同学为了防止页面的 JavaScript 文件不更新,对于一些重要的 JavaScript 文件的 URL 添加访问时间戳,对于这些添加了访问时间戳的 JavaScript 文件进行设置断点然后刷新调试的时候,Chrome 会打印一个 warnning,告诉你断点丢失。

原因很简单,在调试的时候,V8 发现这个 breakpointMap 里面找不到对应的 breakpointObject,因为 URL 发生了变化,这个 brakpointObject 就丢失了,所以 V8 就找不到了,无法进行断点调试。

根据我们的正常思维,你可能会认为 V8 会将断点设置在 C++ 中,其实一开始我也是这么认为。随着对 V8 的探索,让我看到了我时曾相识的一些函数名:

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 的知识。

文章转载地址:fed.taobao.org/blog/2016/1…