前言:无论什么语言,调试能力都是非常重要的,像 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 的数据也是通过同样的方式传给客户端。架构如下。
接下来看一下 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 的数据。最终实现的功能如下。
通过 Chrome Dev Tools 就可以对我们的 JS 运行时进行调试。
时间关系,就介绍到这里,目前只是实现一个简单可用的版本来体验一下 V8 Inspector 的实现。