《WebKit 技术内幕》学习之十四(1):调式机制

第14章 调试机制

        支持调试HTML、CSS和JavaScript代码是浏览器或者渲染引擎需要提供的一项非常重要的功能,这里包括两种调试类型:其一是功能,其二是性能。功能调试能够帮助HTML开发者使用单步调试等技术来查找代码中的问题,性能调试能够采集JavaScript代码、网络等性能瓶颈。当然,这只是对于HTML开发者来说的。因为对于性能而言,问题可能存在于HTML代码,也可能是浏览器本身的问题。为此,Chromium的工程师开发出另外一套机制——“Tracing”技术,它能够收集Chromium内部代码的工作方式和性能瓶颈,以帮助定位Chromium本身的问题。

1 Web Inspector

1.1 基本原理

在之前的多个章节中,Chromium的开发者工具被用来帮助了解渲染引擎和浏览器背后的原理,这一工具实际上是基于WebKit的Web Inspector技术开发出来的,它的功能很丰富,这里将和大家一起了解背后的机制。图14-1是Chromium浏览器的开发者工具调试网页的界面示意图。

                                图14-1 使用开发者工具调试网页的用户界面

         图14-1主要包括上下两个部分,上半部分表示需要被调试的网页,下半部分表示调试器的界面,该界面是由WebKit提供的,这也就是说,使用了WebKit的内核就可以看到类似的界面,不同点在于Chromium使用了多进程架构。根据WebKit中的定义,上面的部分称为后端(Backend),下面的部分称为前端(Frontend),这一叫法会一直贯穿整个章节。

        图14-1还有一个显著的特点,那就是调试器的界面本身也是使用HTML、CSS和JavaScript技术来编写的,听起来很酷吧?后面会详细介绍调试器的工作方式。

Chromium开发者工具提供了众多的功能,主要包括以下几种。

  • 元素审查(EIements) :该功能能够帮助开发者查看每一个DOM元素,如图中查看“body”元素,同样可以查看它的样式信息。
  • 资源(Resources) :该功能能够帮助开发者查看各种资源信息,如内部存储、Cookie、离线缓存等。
  • 网络(Network) :该功能能够帮助开发者了解和诊断网络功能和性能,这个在第4章中曾经使用过。
  • JavaScript代码(Sources) :就是调试JavaScript代码,同其他语言的调试器一样,它能够设置断点、单步调试JavaScript语句等。
  • 时间序列(TimeIine) :该功能能够按照时间次序来收集网页消耗的内存、绘制的帧数和生成各种事件,帮助开发者分析网页性能。
  • 性能收集器(ProfiIes) :它能够收集JavaScript代码使用CPU的情况、JavaScript堆栈、CSS选择器等信息,以帮助开发者分析网页的运行行为。
  • 诊断器(Audits) :这是帮助开发者分析网页可能存在的问题或者可以改善的地方。
  • 控制台(ConsoIe):该控制台可以输入JavaScript语句,由JavaScript引擎计算出结果。插件“PageSpeed” :笔者自行安装的帮助分析网页性能问题的工具,它能够帮助全方位分析各种可能的优化点,它不是Chromium浏览器的默认功能,而是需要开发者自己去Chrome Web Store或其他地方下载。

1.2 协议

        调试机制的前端和后端通过使用一定格式的数据来进行通信,这些数据使用JSON格式来表示。具体到如何理解数据的内容,那就是Web Inspector使用的特殊调试协议,该协议定义了如何理解双方发送的数据内容。在WebKit中,协议被定义在Inspector.json文件中(Blink则是protocol.json文件),而遵照该协议传输的数据同样使用JSON格式,下面将详细分析该协议。

        图14-2是定义Web Inspector的前端和后端交互信息的协议,如上面所说,该协议使用的是一个JSON格式的文档。从全局来看,协议中主要包括两个属性,一个是“version”,用来表示协议的版本号,Web Inspector有多个版本,需要注意的是版本的兼容性问题,图14-2中显示的是版本1.0。另一个是“domains”,它定义了多个协议细节,并包含了多个“domain”,一个“domain”通常是一类功能,如“memory”、“CSS”等。下面再来了解“domain”的定义。

                                图14-2 Web Inspector的协议定义

        一个“domain”包括6个属性,前三个比较简单,容易理解,后面三个比较复杂,较难理解。下面着重介绍后面三个属性。

  • 第一个是“types”,它有点像预先定义的类型,这些类型表示一些特定的数据,在后面的定义中可以声明使用这些类型来表示一定的数据结构,例如图中定义“id”为“StyleSheetId”,它表示的是一个字符串。在第二个属性“commands”中可以看到对它们的引用。
  • 第二个属性“commands”定义“domain”中包含的所有命令,这些命令类似于远程过程调用,表示前端和后端之间发送请求并响应的方式。如图中的“toggleProperty”就是一个命令的定义,可以看到它定义了参数的名称和类型,对于第一个参数它引用了“CSSStyleId”,这就是之前定义在“types”中的一个类型(图中没有写出来)。该命令还包括一个或者多个返回值,跟参数类似的定义。
  • 最后一个属性是“events”,它是用来描述事件的,同样可以包含一个或者多个事件,主要是向对方发送当前的一些状态信息,与命令不同的是,它没有也不需要返回值。图中所示是一个表示样式表变化的事件。

        上面介绍的都是一些抽象的定义,下面通过一个具体的例子说明一下,估计读者就能理解透彻了。图14-3是用户单击取消一个CSS属性(也就是使它不再生效)的时候,WebKit背后发生的各种函数调用和时间派发的过程,实际上是一系列的JSON格式的数据,而这些数据当然是遵守上面协议中的具体定义的。

                图14-3 Web Inspector取消CSS属性值涉及的前后端信息交换

        当用户在单击取消一个CSS属性值的时候,在WebKit内部其实已经发生了多个消息的传递,这其中包含命令和事件,也包括返回值。首先最为直接的就是数据是使用JSON格式表示的,然后对每条消息,笔者都加入了标注表示是前端到后端或者后端到前端来方便理解,后面使用“{}”括起来的部分就是实际传输的数据。

        第一个数据就是从前端到后端的命令,其含义是将“id”为“9”的样式表中的属性索引值为0的属性禁用,根据图14-2中的定义,该命令需要返回值,为了标记返回值,在发送JSON数据的时候,附加了一个“id”为“1532”的标记,这样,当后端发送返回值的时候,前端就能够知道这是哪个请求的回复,而不会出现理解错误的情况。所以,对于不需要返回值的命令或者本身就没有返回值的事件而言,就不需要这样的“id”标记。例如,第二和第三条就是从后端到前端的数据,就是一个DOM属性改变的事件,其中并不包含这样的标记信息。

        第四条就是第一条命令的返回值,如前所述,包含了返回数据和第一条命令的标记。后面基本上是因为样式变化带来的请求,原理同上面所说的非常类似,由此过程可以看出,用户一个简单的操作,需要带来前后端大量的操作,其中这些命令主要是前端发送给后端,而事件主要是后端告诉前端当前的一些状态信息。

1.3 WebKit内部机制

        介绍了Web Inspector的协议和基本工作方式之后,下面有必要深入到WebKit和Chromium代码中来理解它们内部的工作机制,本节主要介绍WebKit中的基础设施,包括前后端的支持情况。前面介绍了前后端只是通过消息传递来完成调试功能的,不依赖于其他框架,所以这一节将重点介绍架构中是如何发送、接收消息及其支撑的架构,首先看前端调试器。

调试器界面本身也是使用Web技术来实现的,前面介绍的所有功能都是使用最新HTML5技术来完成的,目前有两个接口需要具体WebKit移植的实现,第一是发送消息到后端的接口,第二是从对方接收消息后,将消息派发给调试器。图14-4是WebInspector前端的主要结构和基础设施。

                                图14-4 WebInspector前端的主要结构

        最上层的是Inspector.html,也就是读者看到的调试器主界面,完全采用HTML5技术。因为调试器包含众多的功能,所以它实际上使用了各种功能的JavaScript代码。在其典型的三个JavaScript文件中,首先是InspectorFrontendAPI,它是前端的公共接口,被上层的调试器包含的JavaScript代码使用,同时该类也包括公共的派发消息的接口;然后是inspector.js,它是一个总的入口,包括所有主要对象的创建;而InspectorBackend.js是一个背后的具体实现类,它能够提供接口来将消息发送到被调试的网页,也就是后端。

        调试器主要需要两个能力,一是发送消息给前端,二是接收后端的消息。这两个接口在WebInspector框架中被定义,图14-4中左边就是发送消息给前端的过程。Web Inspector使用一个称为InspectorFrontendHost的类作为接口,当然它本身没有具体实现。在一般的情况下(如为远程调试,则稍有不同,后面会介绍),InspectorFrontendHost是一个使用C++编写的接口类,它通过V8的绑定机制来实现,最后会调用到InspectorFrontendHost,之后就依赖于具体移植的实现了。另外一个接口就是定义了派发消息的JavaScript接口,也就是InspectorFrontendAPI.js定义的派发消息的两个接口,在Web Inspector中,一个默认的实现是InspectorClient类中有一个静态方法,该方法使用ScriptController类,将通过C++代码获得的消息传入JavaScript代码,这样整个前端依赖的两个本地接口就得到了完美地实现,不仅如此,该结构还能很好地满足之后的远程调试的需求,是非常棒的结构。

                前端介绍完之后就是后端,如图14-5描述的后端所需要的主要类和它们的关系。同前端不一样的是,后端的主要功能都是使用C++代码来完成的,其中最重要的类是InspectorController,它控制着后端的所有动作及其和被调试网页之间的联系。InspectorClientroller类包含一个InspectorClient对象,该对象负责实现基础功能,如情况缓存、高亮等。同时,它包含一个主要的对外接口,那就是dispatchMessageFromFrontend类,它由WebKit移植将前端的消息传递给后端的时候被调用,这些消息都是由InspectorBackendDispatcherImpl这个自动生成类处理的,这个类能够处理所有的请求消息,并解析这些消息,然后转换成相应的C++对象和函数的调用。可是怎么做到这一点的呢?很简单,每个“domain”都会有相应的称为CommandHandler的类,如图中CSSCommandHandler类。每个类的对象都会注册到InspectorBackendDispatcherImpl对象中,根据图14-3所述的消息,该对象很容易知道调用的“domain”、命令或者事件等。InspectorBackendDispatcherImpl类也能够同V8等JavaScript引擎交互,典型的应用就是审查(Inspect)一个元素,用户单击一个元素的时候(可以从后端的被调试网页中单击),JavaScript引擎接收到事件,然后处理并调用该类来处理。本身CommandHandler类包含一些接口,以图中CSSCommandHandler类为例,它的具体实现类是InspectorCSSAgent,借助于一些其他设施类,它能够知道被调试网页有关CSS方面的信息,如借用InspectorStyleSheet类。

                                图14-5 WebInspector后端的主要结构

        图中的InspectorBaseAgent是支持所有功能的子类,由于Web Inspector需要众多功能,如前面介绍的CSS、内存、性能等,所有这些功能都是基于该类实现的,这些类的对象使用一个注册类来管理,如图中的InspectorAgentRegistry类。

        以与CSS相关的Agent为例,该类被调用后能够做正确的处理并按需返回相应的结果。但是,这里不进行消息的编码,而是使用一个InspectorFrontend自动生成类来帮助这些C++对象和数据转换成JSON格式的数据。

        另外一方面就是后端发送消息到前端,WebKit定义了一个抽象接口就是InspectorFrontendChannel类。顾名思义,它就是一个传输通道,所有后端到前端的消息都是从它传出的,消息本身不做任何转换,只是传输数据。InspectorFrontend是一个自动生成的类,这里是一个模拟前端的工具类,由于它是一个根据协议自动生成的类,后端调用协议中定义的方法和事件,而该类提供这些接口并将调用转变成JSON格式,包括命令的名称、参数等信息。转换后的JSON字符串通过通道传出,从而完成了消息的发送过程。

        通过上面的介绍,相信读者已经推测出前后端之间的通信框架,因为WebKit的特殊性,WebCore只是提供框架,具体实现交由移植来完成。图14-6是基本的通信框架。

图14-6 WebInspector的前后端通信框架示意图

        图中左边是前端,右边是后端,通信框架主要定义前后端的一些用来双向通信的基础类和提供的接口。这些接口需依赖实际的通信机制才能完成,设想一下如果前后端都工作在一个进程中,那么非常简单,只需将消息传递到另一线程中即可,不需要复杂的机制。不过,这里它只是定义了抽象接口,而没有定义通信方面的具体规定,这为跨进程的调试机制和远程调试提供了可能。

1.4 Chromium开发者工具

        Chromium开发者工具(通常见到的称谓是DevTools)是基于Web Inspector机制的一套跨进程的调试工具,具体用法笔者稍后会介绍,这里先来理解它是如何基于Web Inspector来实现的。

        因为Chromium的多进程架构,后端中被调试的网页是一个Renderer进程,前端的网页同样也是一个Renderer进程。根据前面Web Inspector的架构,Chromium所要做的是将前后端的通信机制连接起来。由于Chromium架构的特殊性,消息的传递实际上经过了一个中转站,那就是Browser进程,也就是说这两个Renderer进程不是直接通信的,而是将消息传递给Browser进程,由它再派发给相应的Renderer进程。

        图14-7描述了Chromium支持多进程调试的整个架构,上半部分是前端和后端两个Renderer进程,而下半部分是Browser进程,整个架构可以说非常简洁明确。首先来看前端的接收和发送消息是如何被支持的。首先是Chromium对前端发送消息到后端的支持。WebKit中的基类InspectorFrontHost类其实是调用InspectorFrontendClient类来发送消息的,而WebKit的Chromium移植做了一个具体的实现类Inspector-FrontendClientImpl类,该类会调用WebDevToolsFrontendImpl类。最后的跨进程通信类是content::DevToolsClient,该类是Chromium项目中用于开发者工具的进程间通信类。然后是Chromium对前端接收来自后端消息的支持。当WebDevToolsFrontendImpl类接收到content::DevToolsClient传递过来消息的时候,它直接通过V8提供的机制调用InspectorFrontendAPI.js的dispatchMessage方法。经过这一过程,Chromium已经将WebInspector的两个用于传递消息的接口实现了。

                                图14-7 Chromium DevTools多进程架构

        接下来是Browser进程,每个前端进程都有一个相应的DevTools-FrontendHost对象。当前端的一个消息到达时,如何找到其相应的后端呢?答案是DevToolsManagerImpl类,它管理了所有的“前后端对”,实际上包含了两个哈希表,第一个是从前端到后端的映射,第二个是相反方向的映射。有了这两个哈希表,一切就很简单了,Browser进程只是将前端的消息根据映射关系找到后端并传递给它,相反方向也是一样的,这就是图14-7中描述的过程。

        最后是后端进程的工作过程。content::DevToolsAgent也是负责同Browser进程交互消息的具体实现类,包括接收和发送。在这之上主要是WebKit的Chromium移植提供的接口,其具体的实现是通过WebDevToolsAgentImpl类,它会将接收的消息传递给InspectorController,至此,Chromium连接上WebKit中后端接收消息的处理机制。而对于发送消息,WebDevToolsAgentImpl是InspectorFrontendChannel的子类,会实现sendMessageToFrontend接口。这样,双向过程完整地得到了支持。

1.5 远程调试

        何谓远程调试(Remote Debugging)?刚刚上面介绍的都是在同一个浏览器中的进程,虽然可能在不同的进程中。远程调试是指前端和后端在不同的浏览器实例中,但这两个实例可能在同一个环境中,也可能在不同的环境中。例如,两台机器,甚至网络上的两个设备。

        根据前面描述的Web Inspector机制,本身Web Inspector机制没有定义通信的方式,而且前后端只是通过JSON消息和一定的协议来交互的,所以从理论上来讲,远程调试也只是需要建立一定的通信方式就能够支持远程调试,好消息是现在已经得到实现了。

        Web Inspector没有提供或者规定远程调试的方式,在Chromium中,远程调试得到了比较好的支持,其具体的做法如下。

  1. 首先在后端所在的浏览器中需要建立一个HTTP服务器,在桌面系统上,建立和打开TCP监听一个端口,如9222。然后在另外一个Chromium浏览器实例中输入“http://localhost:9222”就可以看到被调试的网页。目前,这种方式并不支持网络上的不同机器,可能是实现者考虑到安全性问题。如果调试Android平台上的Chrome浏览器中的网页,首先需要将Android设备通过USB连接上开发机器,这时候用户可以在Android上Chrome浏览器的设置中打开远程调试开关,这一操作实际上创建了一个Unix Domain Socket。此时,开发者在Linux系统中只要打开Chrome浏览器(必须大于版本33)并在地址栏输入“chrome://respect”就能够看到需要调试的网页了。
  2. 建立连接之后,通过HTTP协议将被调试网页的HTML、CSS和JS等资源文件从被调试网页所在的浏览器传输到前端调试器所在的网页中。
  3. 当开始调试时,前端调试器会尝试使用Web Socket建立前端和后端传输消息的通道。这是一种基于Web的新技术,能够建立类似于套接字的数据传输通道。

        图14-8描述了使用WebSocket技术来传输调试消息的远程调试机制。根据前面介绍的WebKit中前端和后端传输消息的机制,主要是InspectorFrontendHost(前端使用)和InspectorFrontendChannel(后端使用)这两个类,它们具体的实现由子类来完成,在远程调试中,通信的基础设施是由HTML5的WebSocket技术来支撑的,那么Chromium中如何使用该技术呢?道理很简单,就是将InspectorFrontendHost接口同时暴露到JavaScript中,当本地代码需要发送消息的时候,通过调用InspectorFrontendHost类的本地接口,而这个本地接口已经同JavaScript中的实际发送接口连接起来,这样本地代码中的消息就能够使用WebSocket技术来发送了,示例代码14-1是Chromium中连接本地代码和Javascript代码发送消息的部分代码节选,这个代码是前端的代码。而对于接收消息,同样比较简单,Chromium将WebSocket的onMessage事件同后端的消息派发函数连接起来,确实轻而易举。对于后端而言,它使用C++代码来完成WebSocket的连接,原理是类似的。

图14-8 使用WebSocket技术的远程调试

示例代码14-1 使用WebSocket建立连接的远程调试机制

    WebInspector.loaded = function() {
      // 首先构建ws,这个是WebSocket的URL,下面是创建WebSocket对象
      if (ws) {
      WebInspector.socket = new WebSocket(ws);
      // 接收对方过来的消息,直接交给相应的模块去处理
      WebInspector.socket.onmessage=function(message) {
          InspectorBackend.dispatch(message.data);   }
        WebInspector.socket.onerror = function(error) { console.error(error); }
        WebInspector.socket.onopen = function() {
          // 当连接打开后,就将InspectorFrontendHost的发送消息
          InspectorFrontendHost.sendMessageToBackend =
                  WebInspector.socket.send.bind(WebInspector.socket);
          WebInspector.doLoadedDone();
         }
         …
        }
    }

        Weinre是一个支持远程调试功能的开源项目,它除了能够支持WebInspector协议,还能够支持Firebug(Firefox的调试工具)的协议,其原理也是类似的,有兴趣的读者可以自行查阅相关技术文档。

1.6 Chromium Tracing机制

        Chromium开发者工具能够帮助Web开发者理解网页运行过程中的行为并帮助分析一些性能问题。但是,如果出现问题,特别是绘制网页的时候,开发者非常希望了解为什么Chromium会使用如此多的时间,笔者相信Chromium开发者工具很难回答这样的问题。同时,对于Chromium的开发者来说,如果需要分析Chromium自身问题,就需要相应的工具来帮助分析,在Chromium中,chrome://tracing这一诊断工具能够满足上面的要求。

        这是一个基于事件收集的分析工具,它能够帮助诊断一些WebKit和Chromium内部代码在绘制网页过程中存在的问题,其中最主要的还是同图形相关的操作。我们在第7章中的4.3节中使用过该工具来分析渲染过程。

        这一机制的实现采用的思想非常简单,Tracing机制在Chromium代码中插入相应的跟踪代码,然后计算开始和结束之间的时间差,虽然简单,但是非常有用,典型的例子如下所示。

    TRACE_EVENT_BEGIN0("MY_SUBSYSTEM", "SomethingCostly")
    doSomethingCostly()
    TRACE_EVENT_END0("MY_SUBSYSTEM", "SomethingCostly")

        Tracing机制在某个动作执行前加入“开始事件”代码,然后在动作结束后加入“结束事件”代码,机制中的TRACE_EVENT宏自动计算获得该动作执行的时间。当然,一般典型的例子是在函数或者一段代码开始的时候加入TRACE_EVENT0,在该函数退出时候,该事件自动记录下结束的时间,这是使用对象的自动析构机制来完成的。这样Tracing机制就能够计算出该函数运行所需要的时间,而不再需要额外插入结束代码,如示例代码14-2所示的三个记录点。

        示例代码14-2首先在函数入口处创建Tracing对象并记录时间点,在该函数退出时,对象析构前就能够自动记录整个函数执行的总时间。然后在第一个“if”语句中,又加入了一个记录点记录了这种条件下的时间消耗,后面的Tracing对象也是同样的原理。

示例代码14-2 Chromium合成器中的ThreadProxy类代码片段

    bool ThreadProxy::CompositeAndReadback(void* pixels, gfx::Rect rect)  {
      TRACE_EVENT0("cc", "ThreadProxy::CompositeAndReadback");
      DCHECK(IsMainThread());
      DCHECK(layer_tree_host_);
    
      if (defer_commits_) {
        TRACE_EVENT0("cc", "CompositeAndReadback_DeferCommit");
        return false;
      }
    
      if (!layer_tree_host_->InitializeOutputSurfaceIfNeeded()) {
        TRACE_EVENT0("cc", "CompositeAndReadback_EarlyOut_LR_Uninitialized");
        return false;
      }
      …
    }

        在第7章中,我们已经介绍过如何使用该诊断工具来收集数据,这里不再重复。回顾图7-15中chrome://tracing收集的一段时间内的跟踪事件集合,开发者可以看到各个进程内的时间分布情况,它同时也显示了各个线程的函数调用情况,从中能够发现热点区域,也就是耗时特别长的函数。该工具还能够支持保存这些数据,这样可以很方便地传播这些数据,并获得他人的分析帮助。

最近更新

  1. TCP协议是安全的吗?

    2024-01-26 10:42:04       16 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-01-26 10:42:04       16 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-01-26 10:42:04       15 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-01-26 10:42:04       18 阅读

热门阅读

  1. Objective-C中的nil和null的区别

    2024-01-26 10:42:04       30 阅读
  2. 关于 mapper.xml 中 sql使用 in 执行无效的原因

    2024-01-26 10:42:04       34 阅读
  3. C语言数据类型

    2024-01-26 10:42:04       32 阅读
  4. 解决SpringBoot 测试类无法自动注入@Autowired的问题

    2024-01-26 10:42:04       32 阅读