San DevTools 技术解析(中)

前言

我们已经分享了《San DevTools 技术解析》上篇,主要通过问题的方式,带着大家设计一款远程调试工具需要考虑的技术,并形成四大核心概念与模块,上篇中讲了其中两部分: Backend
、  Frontend
,今天重点来讲下剩余两部分:  Message Channel
和  DevTools Protocol
。这是非常有意思的话题。
为帮助大家联系之前的内容,先放张上期的架构图。


消息通道(Message Channel)

整体架构


上图是非常核心的消息通信与运行机制流程图,我们先说明下其中的几个概念:

  • Chrome Remote Protocol(CDP):包括协议定义、前端请求、后端服务三部分,具体在下边的 调试器协议
    (DevTools Protocol)会进行详细说明;

  • Inspector:中间独立审查 (Inspect) 页面,同时支持 San DevTools 和 Chrome DevTools;
  • ID Mapping:前后端都会向 Server 注册 ID,由 Server 通过 ID 匹配前后端,实现 Mapping 映射,创建 Channel 通信『房间』;

技术要点:

  • 支持 San DevTools 和 Chrome DevTools 两种调试模式
    ;同时支持多 Target 页面调试;

  • 基于 Node 的 HTTP 和 WebSocket Server 服务,起 中间代理
    作用,建立前后端的联接;

  • Target 需注入提供的 JS 文件,Inspector 自动发现 Target 调试页面;
  • Channel Manager 管理前后端的 ID Mapping,建立双端通信 Channel 信道,确保消息正确;
  • Bridge 实例分别负责桥接前后端与 WebSocket 的通信;
  • 插件模式下,Server 部分由插件的 Background 实现;

WebSocket 通信

简介

WebSocket 目前大多数浏览器都支持该协议。

WebSocket 协议标准将 ws
(WebSocket)和  wss
(WebSocket Secure)定义为两个新的 URI (统一资源标识符),分别对应明文和加密连接。
WebSocket 协议可以使用 Chrome DevTools 等浏览器开发工具进行查看。

服务端实现

服务端的实现依赖于开源的 ws

启动服务

WebSocket 是建立在 TCP 上的协议,通过 HTTP 协议的 101 状态码进行握手;HTTP 协议的 Header 增加 upgrade
的请求头,自动实现 HTTP 协议向 WebSocket 协议转换。关于握手协议,可以参考 WebSocket 维基百科 Protocol handshake。

握手成功后,需要手动调用 handleUpgrade
创建 Websocket 对象,建立连接。完整示例代码如下:

const http = require('http');
const WebSocket = require('ws');
const url = require('url');

const server = http.createServer();
const wss = new WebSocket.Server({noServer: true});

wss.on('connection', ws => {
    // ...
});

// 两次触发,分别为:/backend/:id 和 /frontend/:id
server.on('upgrade', (request, socket, head) => {
    const pathname = url.parse(request.url).pathname;
    const [_, role, id] = pathname.split('/');

    wss.handleUpgrade(request, socket, head, ws => {
        ws.role = role;
        ws.role = id;
        wss.emit('connection', ws, request);
    });
});

server.listen(8080);

通信信道

WebSocketServer
主要作用是提供 WebSocket 服务,frontend 与 backend 分别联接 WebSocket 服务,建立对应的 channel 信道,并通过 ChannelMultiplex 进行信道管理与连接。

// file: WebSocketServer.js
const WebSocket = require('ws');
class WebSocketServer {
    constructor() {
        this.channelManager = new ChannelMultiplex();

        // 核心,调用ws创建Server
        const ws = new WebSocket.Server({noServer: true});

        // 客户端连接后,创建backend与frontend信道
        this._wss.on('connection', ws => {
            const {id, role} = ws;
            switch (role) {
                case 'backend':
                    this.channelManager.createBackendChannel(id, ws);
                    break;
                case 'frontend':
                    this.channelManager.createFrontendChannel(id, ws, ws.backend);
                    break;
            }
        });
    }
}

ChannelMultiplex
主要负责管理 Channel 多信道通讯,分别按 frontend 和 backend 区分创建管理,创建 frontend 时,默认 backend 已经准备 OK,这时去连接 backend 的 Channel 信道。

// file: ChannelMultiplex.js
class ChannelMultiplex extends EventEmitter {
    constructor() {
        super();
        this._backendMap = new Map();
        this._frontendMap = new Map();
    }
    createBackendChannel(id, ws) {
        const channel = new Channel(id, ws);

        // 保存backend通道,frontend联接时,建立两者连接
        this._backendMap.set(id, {...});
    }

    createFrontendChannel(id, ws, targetId) {
        const backendId = targetId;
        // 获取backend通道,建立两者连接
        const backendChannel = this._backendMap.get(backendId);

        const channel = new Channel(id, ws);
        // 关键点,建立front与back的连接,具体可以看 Channel.js
        channel.connect(backendChannel.channel);

        this._frontendMap.set(mapId, {...});
    }
}

Channel
主要作用是实现 backend 和 frontend 的两个 ws 信道对象之间的通信问题,Channel 同时由 fontend 和 backend 实现,持有两个 ws 信道对象,实现从一个 ws 收到消息,再从另一个 ws 发送消息出去。

重点理解 this.emit('message')
和  connection.on('message')
两句话。注意在 backend 时,不会调用 connect 方法,内部不存在_connections。

// file: Channel.js
class Channel extends EventEmitter {
    constructor(id, ws) {
        this._id = id;
        this._ws = ws;
        this._connections = [];
        ws.on('message', message => {
            const channelMessage = `@${this._id}\n${message}`;

            this._connections.forEach(connection => {
                 // frontend -> backend
                connection.send(channelMessage);
            });
            // 通过派发事件,将数据发送出去,结合 this.connect 实现来看
            this.emit('message', message);
        });
    }
    send(message) {
        this._ws.send(message);
    }
    // connection表示backend对象,backend不会调用此方法
    connect(connection) {
        this._connections.push(connection);
        // backend -> frontend
        connection.on('message', message => {
            this.send(message);
        });
        connection.on('close', () => this.disconnect(connection));
    }
};

客户端实现

客户端不管是 frontend 还是 backend,其本质都是运行在浏览器环境,都可以调用浏览器的 window.WebSocket
创建客户端连接。

// file: WebSocket.js
class WebSocketMultiplex extends EventEmitter {
    constructor(sockUrl: string) {
        this._ws = new window.WebSocket(sockUrl);

        this._ws.onmessage = (event: any) => {
            this.emit('message', event);
        };
    }
    send(message: any) {
        this._ws.send(message);
    }
}

这里还有个比较重要概念 Bridge
,它是实现通信的桥梁,看下简化后的实现:

// Bridge.ts
interface Wall {
    listen: (fn: Function) => void;
    send: (data: any) => void;
}

class Bridge extends EventEmitter {
     constructor (wall) {
          this.wall = wall;
          wall.listen(message => {
               this.emit(message);
          });
     }
     send(data: any): void {
          this.wall.send(data);
     }
}

可以看出 Wall
的实现里必须包含两个方法:listen 和 send,分别用于接收和发送事件。接下来看下 Bridge 的使用,frontend 和 backend 的此处代码几乎一样:

// file: frontend.ts
const wss = new window.WebSocket(url);

wss.on('open', () => {
     new Bridge({
          listen(fn) {
               wss.on('message', fn);
          },
          send(data: any) {
               wss.send(data);
          }
     });
});

Extension 通信

浏览器插件开发,目前主要支持 Chrome
,接下来以 Chrome 插件为例进行介绍,  Firefox
通信基本类似,API 会不同。

Chrome Extension 简介


关键技术点:

  • Content Script 内容脚本是在网页上下文中运行的文件,能够访问网页上的 dom,但是内容脚本无法操作网页中的变量或函数,需借助 Inject Script 来操作;同时 Content Script 能够调用 chrome 提供的部分 api 与 Backgroud 建立长链接通信;
  • Content Script 与 Inject Script 之间的通信只能通过 window.postMessage 通信;
  • Background 负责与 Content Script 和 Panel 建立 connect 长链接的通信,通过TabID发送消息;
  • Background 类似于服务端,建立 Server 监听,由其他方主动发起连接;
  • Panel 不直接与 Content Script 通信;

说明:

  • 从感性认知上来看,Content Script 与 Inject Script 同在页面内,但实际上并不是;两者不可能存在服务端与客户端的关系,所以通信使用 PostMessage 短链接的形式交互;
  • Background 通过监听 onConnect 事件,实现类似服务端的功能,Content 与 Panel 主动发起联接;消息发送时要带着 TabID,明确 Tab 页面;

Chrome 插件中有三种通信模式:

  1. window.postMessage
    :短链接,适合在页面间通讯, content-script 和 inject-script;

  2. chrome.runtime.sendMessage
    和  chrome.tabs.sendMessage
    :短链接,适合 background、panel、popup 等通信

  3. chrome.runtime.connect
    和  chrome.tabs.connect
    :长链接,适合 background、panel、popup 等通信

在 Chrome 扩展程序中可以使用 chrome.runtime.connect
创建一个长链接,每个长链接对应会有一个  runtime.port
对象,可以利用该对象从长链接中接收 / 发送数据。

const port = chrome.runtime.connect({name: 'knockknock'});
port.postMessage({joke: 'Knock knock'});
port.onMessage.addListener(function (msg) {
    if (msg.question == "Who's there?") {
        port.postMessage({answer: 'Madame'});
    }
    else if (msg.question == 'Madame who?') {
        port.postMessage({answer: 'Madame... Bovary'});
    }
});

为了管理各个长链接,可以监听 runtime.onConnect
事件,当扩展程序中其他部分通过  chrome.runtime.connect
创建长链接的时候,会触发  runtime.onConnect
事件,从该事件中可以获得刚创建的长链接对应的  runtime.port
对象,可以利用该对象从长链接中接收 / 发送数据。

chrome.runtime.onConnect.addListener(function (port) {
    console.assert(port.name === 'knockknock');
    port.onMessage.addListener(function (msg) {
        if (msg.joke === 'Knock knock') {
            port.postMessage({question: "Who's there?"});
        } else if (msg.answer === 'Madame') {
            port.postMessage({question: 'Madame who?'});
        } else if (msg.answer === 'Madame... Bovary') {
            port.postMessage({question: "I don't get it."});
        }
    });
});

Extension 通信实例

DevTools 扩展程序的 Backend 的实现如下:

let bridge = new Bridge({
    listen(fn: Function) {
        window.addEventListener('message', (event) => {
            fn(event.data.payload);
        });
    },
    send(data: any) {
        window.postMessage(
            {
                source: 'san-devtools-bridge',
                payload: data
            },
            '*'
        );
    }
});

content_script 的实现如下,主要建立 background 与 backend 之间的数据传输通道:

const port = chrome.runtime.connect({
    name: 'content-script'
});
// 将 backend 发送的数据通过长链接透传给 background
window.addEventListener('message', (evt: MessageEvent) => {
    port.postMessage(evt.data.payload);
});
// 将 background 发送的数据通过 window.postmessage 透传给 backend
port.onMessage.addListener((message: any) => {
    window.postMessage(
        {
            source: 'san-devtools-content-script',
            payload: message
        },
        '*'
    );
});

background 管理链接:

let connections = [];
chrome.runtime.onConnect.addListener(function (port: chrome.runtime.Port) {
    let tabId: string;
    let memberName: string;
    if (+port.name + '' === port.name) {
        // 1. content_script 创建的长链接,存入对应 tabId 下的 backend
        // 2. backend 长链接中接收到的消息透传给 frontend
        tabId = port.name;
        connections[tabId].frontend = port;
        port.onMessage.addListener((message) => {
            connections[tabId].backend.postMessage(message);
        });
        port.onDisconnect.addListener(() => {...});
    }
    else if (port.sender && port.sender.tab) {
        // 1. devtools_page 创建的长链接,存入对应 tabId 下的 frontend
        // 2. frontend 长链接中接收到的消息透传给 backend
        tabId = port.sender.tab.id + '';
        connections[tabId].backend = port;
        port.onMessage.addListener((message) => {
            connections[tabId].frontend.postMessage(message);
        });
        port.onDisconnect.addListener(() => {...});
    }
})

devtools_page 创建的 panel 中会创建一个长链接以及 bridge 实例,bridge 实例通过该长链接从 background 发送 / 接收消息。

const port = chrome.runtime.connect({
    name: '' + tabId
});
const bridge = new Bridge({
    listen(fn: Function) {
        port.onMessage.addListener(message => {
            fn(message);
        });
    },
    send(data: any) {
        port.postMessage(data);
    }
});

调试器协议(DevTools Protocol)

简介

前后端接口交互有 RestFul、GraphQL API 接口风格定义,有接口规范文档约束字段定义等等接口协议方面的规则;具体业务前端后还会有接口文档。那调试器前后端要不要有协议方面的约束规则呢?显然是有必要的,这就是调试器协议主要解决的问题。
什么是调试器协议?调试器协议是通过定义调试工具 (frontend) 与被调试页面 (backend) 之间的交互协议,通过方法和事件提供双方的交互,包括相应的 JSON 数据格式的定义。其中交互协议包括被发送到页面的命令,和该页面生成的事件。

为了能够对协议的概念有个大概了解,我们简单演示下 Chrome DevTools
的协议。

San DevTools
调试工具同时提供  Chrome DevTools
和  San DevTools
两种模式的远程调试能力,同时对应的有两种通信协议,分别介绍。

San DevTools Protocol(SDP)

简介

类似于 Chrome 的协议, 我们定义支持了 San DevTools Protocol
交互协议,支持 San 应用的远程调试,制定规范约束前后端通信标准化。协议分为方法和事件两种类型,提供双方的交互,包括对应的 JSON 数据结构。

Domain 设计

我们参考了 Chrome DevTools 协议,同样划分了多个 Domain 域的设计。

  • HandShake:握手域,包括通信的建立,基础信息的同步等;
  • San:San 相关信息;
  • Component:组件信息,包括组件树,组件的高亮 / 取消高亮,组件 Data 数据等;
  • Store:数据中心,包括 Store 数据,Mutation 数据等;
  • History:组件生命周期历史 data 数据;
  • Message:组件消息通信;
  • Event:组件事件,包括自定义事件和 Dom 事件;

SDP Server

Hook 实现

Hook 主要用于监听
san.dev.js
 
调用 emit 触发的事件,是 backend 对  
san.dev.js
 
暴露的通信接口。执行 install 会在 san 应用的全局对象 target (比如 window) 上增加一个属性 __san_devtool__
,该属性值是一个 Hook 实例,Hook 实例上存储了  
san
 
实例,组件树数据,store 实例,所有挂载的组件实例等。

class Hook {
constructor() {
this.san = null;
this.data = {
totalCompNums: 0,
selectedComponentId: '',
treeData: []
};
this.storeMap = new Map();
this.componentMap = new Map();
this.listeners = new Map();
}
on(eventName, listener) {
let listeners = this.listeners.get(eventName);
listeners && listeners.push(listener);
}
emit(eventName, data) {
const listeners = this.listeners.get(eventName);
if (listeners && listeners.length > 0) {
listeners.map(listener => {
listener(data);
});
}
}
}
function install(target: any) {
const sanHook = new Hook();
sanHook.on('san', (san) => {
sanHook.san = san;
});
Object.defineProperty(target, '__san_devtool__', {
configurable: true,
get() {
sanHook._this = this;
return sanHook;
}
});
return sanHook;
}

Agent 实现

Agent 是 backend 中数据处理模块,数据来源为 HOOK 收集的数据或者通过 Bridge 接收到的数据,处理之后的数据会通过 Bridge 发送出去抑或存储在目标页面。
Agent 类主要接收两个参数,一个是 Hook 实例一个是 bridge 实例,在实例化过程中主要执行 Agent 实例的两个方法:

  1. addListener: 利用 bridge.on 监听 message channel 中传递过来的消息。
  2. setupHook: 利用 hook.on 监听 san.dev.js 中触发的消息。
// Agent.ts
export default class Agent {
    hook: DevToolsHook;
    bridge: Bridge;

    constructor(hook: DevToolsHook, bridge: Bridge) {
        this.hook = hook;
        this.bridge = bridge;
        this.setupHook();
        this.addListener();
    }
    addListener() {}
    setupHook() {}
}

更直观地,给出如下例子:

const SAN_COMPONENT_HOOK = [
    'comp-compiled',
    'comp-inited',
    'comp-created',
    'comp-attached',
    'comp-detached',
    'comp-disposed',
    'comp-updated'
];
export class ComponentAgent extends Agent {
    setupHook() {
        // 生命周期监听
        SAN_COMPONENT_HOOK.map(evtName => {
            this.hook.on(evtName, component => {
                switch (evtName) {
                  case 'comp-attached': {
                      this.hook.componentMap.set(String(component.id), component);
                      const data: ComponentTreeData = getComponentTree(this.hook, component);
                      addToTreeData(this.hook.data.treeData, data);

                      this.bridge.send('Component.setTreeData', JSON.stringify(this.hook.data));
                      break;
                  }
                    ...
                    default: break;
                }
            });
        });
    }
    addListener() {
        this.bridge.on('Component.getTreeData', () => {
            this.sendToFrontend('Component.setTreeData', JSON.stringify(this.hook.data));
        });
    }
}

export default function init(hook: DevToolsHook, bridge: Bridge) {
    return new ComponentAgent(hook, bridge);
}

例子中的 setupHook 利用 hook.on 监听了 san.dev.js
触发的  comp-*
事件,事件处理函数会将事件中拿到的组件实例 component 处理,将处理后的部分数据存储到 hook 实例的  data
属性之后,调用 bridge.send 将数据发送给 messag channel 。
例子中的 addListener 利用 bridge.on 监听了 message channel 传递过来的消息,消息处理函数则将 hook.data 上存储的数据发送给 message channel。
对于 Frontend 部分,没有 Hook 以及 Agent,数据流相对简单,只需要通过 bridge 实例监听 message channel 传递过来的数据,数据被处理之后通过响应式的 san 框架渲染到页面。通过 bridge.send 将页面中的数据发送给 message channel。

Chrome DevTools Protocol(CDP)

简介

Chrome 调试协议允许工具对 Chrome 和其他支持 WebSocket 的浏览器进行检测、检查、调试和分析。目前许多现有项目都使用该协议。Chrome 开发者工具使用这个协议,由它的团队维护 API。
协议分为多个域(domain),比如 DOM、Debugger、Network 等。每个域定义了它支持的许多命令和它生成的事件。命令和事件都是固定结构的序列化 JSON 对象。

CDP 实现

接下来我们看下协议如何在 San DevTools 落地实现的。

整体包括两部分: CDP Server
和  CDP Client
,San DevTools 中基于第三方库实现二者功能:liriliri/chobitsu 和 liriliri/chii。

CDP Server

Chrome 浏览器是实现 CDP 协议最完整的,San DevTools 中为实现通用浏览器的远程调试,基于第三方远程工具,在前端页面实现了部分 CDP 协议。
原理:基于 JS 的数据收集,模拟实现部分 CDP 协议。

CDP Client

基于 CDP 协议的客户端常见两种:

  • 基于浏览器环境的 WebSocket 或 Node 环境的 ws 库;
  • 基于 Node 的 chrome-remote-interface 库;

我们分别进行演示:

CDP Client 实例

首先演示基于浏览器环境的 WebSocket,打开新页面并获取 Dom 树的例子,示例中涉及三个协议方法:

  • Target.createTarget:创建新页面
  • Target.attachToTarget:attach 到指定的 Target
  • DOM.getDocument:获取 Dom 根节点

注:以下代码执行前,请先启动 Chrome 的 Debug 模式

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

(async () => {
    const ws = new WebSocket('ws://127.0.0.1:9222/devtools/browser/edd7902e-75c9-43dd-8ac7-17a1ae00accc');

    await new Promise(resolve => ws.onopen = resolve);

    const pageTarget = await SEND(ws, {
        id: 1,
        method: 'Target.createTarget',
        params: {
            url: 'http://www.baidu.com'
        }
    });

    const sessionId = (
        await SEND(ws, {
            id: 2,
            method: 'Target.attachToTarget',
            params: {
                targetId: pageTarget.result.targetId,
                flatten: true
            }
        })
    ).result.sessionId;

    const domTree = await SEND(ws, {
        sessionId,
        id: 4,
        method: 'DOM.getDocument',
        params: {}
    });
    console.log(domTree.result);
})();

function SEND(ws, command) {
    ws.send(JSON.stringify(command));
    return new Promise(resolve => {
        ws.onmessage = msg => {
            const response = JSON.parse(msg.data);
            if (response.id === command.id) {
                ws.onmessage = null;
                resolve(response);
            }
        };
    });
}

Node 调用协议实例

基于 Node 的 chrome-remote-interface 库。
示例同样演示打开新页面、获取 Dom 树,以及抓紧所有网络请求。

const CDP = require('chrome-remote-interface');

(async () => {
    let client;
    try {
        client = await CDP();
        // domains
        const {Network, Page, DOM} = client;
        // 事件监听
        Network.requestWillBeSent((params) => {
            console.log(params.request.url);
        });
        await Network.enable();
        await Page.enable();
        await Page.navigate({url: 'https://www.baidu.com'});
        await Page.loadEventFired();

        // Dom树获取
        const domTree = await DOM.getDocument();
        console.log(domTree);
    } catch (err) {
        console.error(err);
    } finally {
        if (client) {
            await client.close();
        }
    }
})();

最后
感谢你阅读到了这里,以上便是《San DevTools 技术解析 (中)》的全部内容。

San DevTools
除以上介绍的四大模块外,整个项目中还有很多非常有意思的技术,比如 DevTools 插件是怎么实现的;如何新增加一个 Panel 面板;插件开发是如何调试的;大型项目工程结构是怎么管理的等等。
期待下期《San DevTools 技术解析 (下)》再见!
EOF

作者:刘斌 焕宇
2020 年 12 月 29 日