前言:无论什么语言,调试能力都是非常重要的,像 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 的实现。