随着 Node.js 的出现和不断发展,其他新的 JS 运行时也穷出不断,Deno、Just、Bun等等。本文简单介绍一下如何写一个 JS 运行时,相比操作系统、编译器来说,写一个 JS 运行时理论上并不是一个难的事情,但是写一个优秀且功能齐全的运行时并不是一个容易的事情。
JS 引擎
写一个 JS 运行时,首先就必须需要一个 JS 引擎来处理 JS,大部分的 JS 运行时都是基于 V8的,当然你也可以使用其他的 JS 引擎。所以首先需要选择一个 JS 引擎,然后下载代码,编译成功。有了 JS 引擎,就可以通过它提供的一些 API 实现一个可以执行 JS 代码的软件。
int main(int argc, char* argv[]) {
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);
Local<Object> globalInstance = context->Global();
globalInstance->Set(context, String::NewFromUtf8Literal(isolate, "No",
NewStringType::kNormal), No);
// 设置全局属性global指向全局对象
globalInstance->Set(context, String::NewFromUtf8Literal(isolate,
"global",
NewStringType::kNormal), globalInstance).Check();
{
// 打开文件
int fd = open(argv[1], 0, O_RDONLY);
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();
// 编译
Local<Script> script = Script::Compile(context, source).ToLocalChecked();
// 解析完应该没用了,释放内存
free(ptr);
// 执行
Local<Value> result = script->Run(context).ToLocalChecked();
}
}
// Dispose the isolate and tear down V8.
isolate->Dispose();
v8::V8::Dispose();
v8::V8::ShutdownPlatform();
delete create_params.array_buffer_allocator;
return 0;
}
拓展功能
有了 JS 引擎,我们只能使用 JS 语言本身提供的一些能力,可以做的事情不多,比如网络、文件、进程能力都没有。但是幸运的是,JS 引擎提供了拓展能力,我们可以使用 JS 引擎提供的 API 拓展网络、文件这些功能。在之前代码的基础上增加以下代码。
Local<ObjectTemplate> global = ObjectTemplate::New(isolate);
Local<Context> context = Context::New(isolate, nullptr, global);
Context::Scope context_scope(context);
// 所有拓展功能挂到这个对象中
Local<Object> No = Object::New(isolate);
No::Console::Init(isolate, No);
Local<Object> globalInstance = context->Global();
// 再把这个对象挂载到全局变量
globalInstance->Set(context, String::NewFromUtf8Literal(isolate, "No",
NewStringType::kNormal), No);
void No::Console::log(V8_ARGS) {
V8_ISOLATE
String::Utf8Value str(isolate, args[0]);
Log(*str);
}
void No::Console::Init(Isolate* isolate, Local<Object> target) {
Local<ObjectTemplate> console = ObjectTemplate::New(isolate);
setMethod(isolate, console, "log", No::Console::log);
setObjectValue(isolate, target, "console", console->NewInstance(isolate->GetCurrentContext()).ToLocalChecked());
}
以上代码在 JS 的全局变量上挂载了一个变量 No,然后在 No 变量上挂载我们需要拓展的功能,比如上面的 console.log。这样我们就可以直接在 JS 里使用 console.log 了。
事件循环
有了之前的基础后,接下来我们就需要实现一个事件循环,因为有些拓展功能的 API,是同步执行的,但是有些是不能同步执行的,比如文件、网络。所以我们需要一个事件循环来处理异步的任务。事件循环本质上是一个生产者 / 消费者模型,在这个模型中,最重要的是当没有任务消费的时候,如何处理。通常使用的是阻塞 / 唤醒的机制,通常是使用事件驱动模块实现这种机制。如果我们只支持 Linux,那么就可以选择 epoll,如何是 Mac,那么就可以选择 kqueue,基本上,大多数操作系统都提供了这种机制,如果我们支持多操作系统,那么就需要封装好各个操作系统提供的 API,当然如果为了方便,我们可以直接使用 Libuv。如果你只想支持比较新版本的 Linux,可以使用真正的异步 IO 框架 io_uring。
void No::io_uring::RunIOUring(struct io_uring_info *io_uring_data) {
struct io_uring* ring = &io_uring_data->ring;
struct io_uring_cqe* cqe;
struct request* req;
while(io_uring_data->stop != 1 && io_uring_data->pending != 0) {
// 提交请求给内核
int count = io_uring_submit_and_wait(ring, 1);
// 处理每一个完成的请求
while (1) {
io_uring_peek_cqe(ring, &cqe);
if (cqe == NULL)
break;
--io_uring_data->pending;
// 拿到请求上下文
req = (struct request*) (uintptr_t) cqe->user_data;
req->res = cqe->res;
io_uring_cq_advance(ring, 1);
// 执行回调
if (req->cb != nullptr) {
req->cb((void *)req);
}
}
}
}
模块加载器
有了上面的基础后,基本上实现了一个 JS 运行时了。可以在 JS 里使用到各种各样的拓展功能,比如建立 TCP 连接,读写文件。但是还有一个重要的部分需要实现,那就是模块加载器,内置的功能可以通过挂载到全局变量的方式来实现,这样用户就不需要通过模块加载器的方式来使用拓展功能,但是用户的 JS,还是需要一个模块加载器。实现模块加载器之后,架子就搭建得差不多了。剩下的事情就是取决于需要支持什么功能。
void No::Loader::Compile(V8_ARGS) {
V8_ISOLATE
V8_CONTEXT
String::Utf8Value filename(isolate, args[0].As<String>());
int fd = open(*filename, 0 , O_RDONLY);
std::string content;
char buffer[4096];
while (1)
{
memset(buffer, 0, 4096);
int ret = read(fd, buffer, 4096);
if (ret == -1) {
return args.GetReturnValue().Set(newStringToLcal(isolate, "read file error"));
}
if (ret == 0) {
break;
}
content.append(buffer, ret);
}
close(fd);
ScriptCompiler::Source script_source(newStringToLcal(isolate, content.c_str()));
Local<String> params[] = {
newStringToLcal(isolate, "require"),
newStringToLcal(isolate, "exports"),
newStringToLcal(isolate, "module"),
};
MaybeLocal<Function> fun =
ScriptCompiler::CompileFunctionInContext(context, &script_source, 3, params, 0, nullptr);
if (fun.IsEmpty()) {
args.GetReturnValue().Set(Undefined(isolate));
} else {
args.GetReturnValue().Set(fun.ToLocalChecked());
}
}