如何实现 JS 运行时的 Inspector 能力

JavaScript/前端
409
0
0
2023-01-05

前言:无论什么语言,调试能力都是非常重要的,像 C、C++ 等语言,我们可以使用现成的工具去调试。JS 也不例外,我们可以通过浏览器来实现对 JS 的调试,但是 JS 运行时就不太一样了,因为 JS 运行时通常独立于浏览器运行,所以无法直接使用浏览器提供的能力,这时候就需要自己实现了。当然 JS 运行时不需要完全实现调试的功能,核心的能力都是由 V8 提供,JS 运行时只需要按照 V8 的规范实现一个 Inspector 代理就行。本文介绍以 V8 为基础,实现一个简单的 JS 运行时(严格来说不算,本文只是用它来代替一个描述),并基于这个 JS 运行时实现调试 JS 的能力。

浏览器或者其他工具通常提供了 Inspector 客户端,所以这部分我们不需要重新实现,而 V8 内部本身已经实现了调试具体的实现,我们只需要实现这个调试代理就行,这个代理的主要功能就是透传客户端和服务器之间通信的数据,通信的数据是基于 V8 提供的调试协议,具体可以参考 https://chromedevtools.github.io/devtools-protocol/v8/。下面来看一下具体的实现。

int main(int argc, char* argv[]) {

  std::thread t;
  setvbuf(stdout, nullptr, _IONBF, 0);
  setvbuf(stderr, nullptr, _IONBF, 0);
  v8::V8::InitializeICUDefaultLocation(argv[0]);
  v8::V8::InitializeExternalStartupData(argv[0]);
  std::unique_ptr<Platform> platform = platform::NewDefaultPlatform();
  v8::V8::InitializePlatform(platform.get());
  v8::V8::Initialize();
  Isolate::CreateParams create_params;
  create_params.array_buffer_allocator = ArrayBuffer::Allocator::NewDefaultAllocator();
  Isolate* isolate = Isolate::New(create_params);
  Isolate::Scope isolate_scope(isolate);
  HandleScope handle_scope(isolate);
  Local<ObjectTemplate> global = ObjectTemplate::New(isolate);
  Local<Context> context = Context::New(isolate, nullptr, global);
  Context::Scope context_scope(context);
  // 创建一个和 V8 通信的客户端
  std::unique_ptr<V8InspectorClientImpl> client = std::make_unique<V8InspectorClientImpl>(platform, context);
  // 打开文件 
  int fd = open(argv[1], 0, O_RDONLY);
  if (fd == -1) {
    std::cout<<"file not found";
    return errno;
  }
  // 新建一个线程用于透传调试数据
  t = std::thread(worker, client.get());
  // 执行 JS 代码
  {
    struct stat info;
    // 取得文件信息 
    fstat(fd, &info);
    // 分配内存保存文件内容 
    char *ptr = (char *)malloc(info.st_size + 1);
    // ptr[info.st_size] = '\0'; 
    read(fd, (void *)ptr, info.st_size);
    // 要执行的js代码
    Local<String> source = String::NewFromUtf8(isolate, ptr,
                        NewStringType::kNormal,
                        info.st_size).ToLocalChecked();
    ScriptOrigin origin(String::NewFromUtf8(isolate, "V8-Inspector", NewStringType::kNormal, strlen("V8-Inspector")).ToLocalChecked());

    // 编译
    Local<Script> script = Script::Compile(context, source, &origin).ToLocalChecked();
    // 解析完应该没用了,释放内存 
    free(ptr);
    // 执行
    Local<Value> result = script->Run(context).ToLocalChecked();

  }

  t.join();
  // Dispose the isolate and tear down V8.
  isolate->Dispose();
  v8::V8::Dispose();
  v8::V8::ShutdownPlatform();
  delete create_params.array_buffer_allocator;
  return 0;
}

上面的代码大部分是使用 V8 执行 JS 的通用例子。主要关注的地方是创建了一个 V8InspectorClientImpl 对象和新建了一个线程(为什么需要新建线程在之前的文章已经分析过,就不再介绍)。在介绍 V8InspectorClientImpl 之前,先看看子线程的实现。

void worker(V8InspectorClientImpl* client) {
    struct sockaddr_in server_addr;
    int connfd;
    struct sockaddr_in clent_addr; 
    socklen_t len = sizeof(clent_addr);
    size_t count;
    int server_fd = socket(AF_INET, SOCK_DGRAM, 0);

    if (server_fd < 0) { 
        perror("create socket error"); 
        goto EXIT;
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family       = AF_INET;
    server_addr.sin_port         = htons(8888);
    server_addr.sin_addr.s_addr  = htonl(INADDR_ANY);

    if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { 
        perror("bind address error"); 
        goto EXIT;
    }
    char buf[BUF_LEN];
    while(1)
    {
        struct sockaddr_in server; 
        socklen_t server_len = sizeof(server);
        memset(&server, 0, sizeof(server));
        server.sin_family       = AF_INET;
        server.sin_port         = htons(5555);
        server.sin_addr.s_addr  = htonl(INADDR_ANY);

        memset(buf, 0, BUF_LEN);
        count = recvfrom(server_fd, buf, BUF_LEN, 0, (struct sockaddr*)&clent_addr, &len);
        if(count == -1)
        {
            continue;
        }
        int client_port = htons(clent_addr.sin_port);
        // From V8 inspector 
        if (client_port == 6666) {
            sendto(server_fd, buf, count, 0, (struct sockaddr*)&server, server_len); 
        } else { 
            // From Inspector client, such as Chrome Dev Tools
            client->onMessage(buf, count);
        }
    }

    close(server_fd);
EXIT:
    return;
}

子线程的逻辑也很简单,就是启动一个 UDP server 来传递调试客户端和服务器之间的数据。理论上来说,我们可以使用 TCP、UDP 甚至 Unix 域来实现数据的通信,因为重点是有一个数据通道完成数据透传到能力,具体的用什么协议去实现这个通道并不重要。但是因为调试客户端是基于 websocket 协议通信的,所以我们需要有一个 websocket 的服务器,为了实现的简单,这个 websocket 服务器我们使用 JS 实现,然后 JS 里再通过 UDP 把数据传递给子线程,子线程再传递给 V8,反过来, V8 的数据也是通过同样的方式传给客户端。架构如下。

img

接下来看一下 V8 Inspector 部分的实现。

V8InspectorClientImpl::V8InspectorClientImpl(const std::unique_ptr<v8::Platform> &platform, const v8::Local<v8::Context> &context) {
    platform_ = platform.get();
    context_ = context;
    isolate_ = context_->GetIsolate();
    // V8 Inspector 通过 channel 返回数据给客户端
    channel_.reset(new V8InspectorChannelImp(isolate_));
    inspector_ = v8_inspector::V8Inspector::create(isolate_, this);
    // 通过 session 发送数据给 V8 Inspector
    session_ = inspector_->connect(kContextGroupId, channel_.get(), v8_inspector::StringView());
    std::string contextName = "NoInspector";
    v8_inspector::V8ContextInfo v8info(context, kContextGroupId, convertToStringView(contextName));
    inspector_->contextCreated(v8info);
    terminated_ = true;
    run_nested_loop_ = false;
}

V8InspectorClientImpl 是一个负责和 V8 Inspector 通信的对象,它主要封装了 channel 和 session 对象,这两个对象是具体和 V8 通信的。介绍完整体和基础的数据结构后,接下来看看细节。刚才介绍中说到当收到客户端数据时,子线程会调用 onMessage 通知 Inspector。

void V8InspectorClientImpl::onMessage(char *buf, size_t count) {
    std::lock_guard<std::mutex> guard(mutex_);
    requests_.push_back(std::string(buf, 0, count));
    if (requests_.size() == 1) {
        isolate_->RequestInterrupt([](v8::Isolate* isolate, void* data) {
            V8InspectorClientImpl *client = static_cast<V8InspectorClientImpl *>(data);
            client->dispatchProtocolMessage();
        }, this); 
    }
    condition_variable_.notify_one();
}

onMessage 首先把数据插入主线程的任务队列,然后通过 RequestInterrupt 注册一个任务,等待 V8 处理这个 RequestInterrupt 任务时,就会通过 dispatchProtocolMessage 处理 requests_ 里面的任务。这里其实是一个非常关键的地方,在不同的 JS 运行时中,这个通知的方式不一样,比如在 Node.js 里,Node.js 除了调用 RequestInterrupt 还会通过线程间通信机制 async 通知主线程,因为这时候主线程可能阻塞在事件驱动模块中,也可能正在执行 JS,所以需要两种方式通知主线程,保证客户端的数据可以被处理。在本文这个简单的 JS 运行时中,目前只会在一个 while 循环中不断执行 JS,所以这里通过 RequestInterrupt 就可以了。V8 会在执行 JS 的时候,处理 RequestInterrupt 的任务,接下来看一下 dispatchProtocolMessage。

void V8InspectorClientImpl::dispatchProtocolMessage() {
    std::vector<std::string>::iterator it;
    std::vector<std::string> queues;
    {
        std::lock_guard<std::mutex> guard(mutex_);
        requests_.swap(queues);
    }
    for(it = queues.begin(); it != queues.end(); it++)
    {
        // 传给 V8 Inspector
        session_->dispatchProtocolMessage(convertToStringView(*it));
    }
}

dispatchProtocolMessage 很简单,通过 session 把数据传给 V8。V8 处理完后会通过 channel 通知客户端。

V8InspectorChannelImp::V8InspectorChannelImp(v8::Isolate *isolate) {
    isolate_ = isolate;
    client_fd_ = socket(AF_INET, SOCK_DGRAM, 0);
    // 用于和子线程通信的 UDP client socket 
    struct sockaddr_in client_addr;
    memset(&client_addr, 0, sizeof(client_addr));
    client_addr.sin_family       = AF_INET;
    client_addr.sin_port         = htons(6666);
    client_addr.sin_addr.s_addr  = htonl(INADDR_ANY);
    if (bind(client_fd_, (struct sockaddr*)&client_addr, sizeof(client_addr)) < 0) { 
        perror("bind address error"); 
    }
    // 子线程监听的端口 
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr_.sin_family       = AF_INET;
    server_addr_.sin_port         = htons(8888);
    server_addr_.sin_addr.s_addr  = htonl(INADDR_ANY);
}

void V8InspectorChannelImp::sendResponse(int callId, std::unique_ptr<v8_inspector::StringBuffer> message) {
    const std::string response = convertToString(isolate_, message->string());
    send(response.c_str(), response.length());
}

// 发送给子线程
void V8InspectorChannelImp::send(const char* buf, int size) {
    sendto(client_fd_, buf, size, 0, (struct sockaddr*)&server_addr_, sizeof(server_addr_)); 
}

V8 处理完数据后或者有新的事件触发时会通过 channel 通知 V8 使用者, V8 使用者接着通过 UDP 把数据透传给子线程,子线程再发送到客户端。这里也是实现的一个关键的地方,根据前面的分析,子线程是不断地阻塞在 recvfrom 等待数据的,所以唯一能唤醒子线程的就是给它发送数据,这里是为了实现上的简单。在 Node.js 里,子线程会跑一个事件循环,子线程除了可以在收到数据时被唤醒,还可以通过线程间通信机制 async 去唤醒。所以子线程收到数据时会根据发送发的端口进行不同的处理,如果是来自客户端,则转发给 V8 Inspector,如果数据是来自 V8 Inspector,则转发给客户端。

int client_port = htons(clent_addr.sin_port);
 // From V8 inspector 
 if (client_port == 6666) {
     sendto(server_fd, buf, count, 0, (struct sockaddr*)&server, server_len); 
 } else { 
     // From Inspector client, such as Chrome Dev Tools
     client->onMessage(buf, count);
 }

至此,大概的流程就介绍完了,但是还有一个非常关键的地方,那就是断点调试。刚才介绍的场景没有断点的场景,比如我们的代码正在正常地运行,然后通过客户端发送获取 CPU Profile 的请求。断点的实现在之前的文章里已经介绍过了,所以就不多介绍了,直接看代码。

void V8InspectorClientImpl::runMessageLoopOnPause(int contextGroupId) {
    if (run_nested_loop_) {
        return;
    }
    std::cout<<"runMessageLoopOnPause"<<std::endl;
    terminated_ = false;
    run_nested_loop_ = true;
    while (!terminated_) {
        {
            std::unique_lock<std::mutex> lock(mutex_);
            condition_variable_.wait(lock);
        }
        dispatchProtocolMessage();
    }
    terminated_ = true;
    run_nested_loop_ = false;
}

当 V8 执行到一个断点时,就会执行 runMessageLoopOnPause 进入停住的状态。这里是通过条件变量来实现停住的状态,也就是阻塞线程。当客户端点击继续执行时,就会在刚才分析的 onMessage 中唤醒线程,接着通过 dispatchProtocolMessage 通知 V8 继续执行。从而实现断点的功能。实现了和 V8 Inspector 通信部分后,再看一下 JS 层。

const { WebSocketServer } = require('ws');
const dgram = require('dgram');

const udpClient = dgram.createSocket('udp4');
udpClient.bind(5555);

const wsServer = new WebSocketServer({
    port: 9229,
});

wsServer.on('connection', (socket) => {
  udpClient.on('message', (data) => {
    socket.send(data.toString());
  });
  socket.on('message', (data) => {
    udpClient.send(data, 8888);
  });
});

JS 层主要是符合透传客户端和 V8 Inspector 的数据。最终实现的功能如下。

img

通过 Chrome Dev Tools 就可以对我们的 JS 运行时进行调试。

时间关系,就介绍到这里,目前只是实现一个简单可用的版本来体验一下 V8 Inspector 的实现。