V8: 模板(1)

balus

2022-02-17

Preface

当我们在 web server 中做 v8 扩展开发时,我们在做些什么?

可以和 OpenResty 中的 Lua 类比,其中很大一部分工作是将 web server 的原生(native)能力暴露在 JS 中供用户使用。在 Lua 中写 C/C++ API 我们只需要遵守它所要求的函数签名以及参数传递机制写函数并将其注入全局环境就足够了,比如:

static int ngx_readfile(lua_State *L) {
    int nargs = lua_gettop(L);
    if (nargs != 1) {
        return luaL_error("expecting one argument but seen %d", nargs);
    }

    ngx_str_t data;

    /* ... open file and read its content to text ... */

    lua_pushlstring((const char *)data.data, data.len);
    return 1;
}

lua_pushcfunction(L, ngx_readfile);
lua_setglobal(L, "readfile");

这样我们就可以在 Lua 中使用 readfile() 直接执行 ngx_readfile 函数;事实上,Lua 标准库中所有的 API 也是这样暴露出去的。

V8 中的做法也大同小异,只是通过模板做了封装;这里所说的模板并不是 C++ 中的 Template,而是 V8 中的概念,但既然二者都是一个名字,那必然是有相似之处的;v8 embed doc 中对模板的解释是这样的:

A template is a blueprint for JavaScript functions and objects in a context. You can use a template to wrap C++ functions and data structures within JavaScript objects so that they can be manipulated by JavaScript scripts.

这里用了 blueprint 这个词,即蓝图、模型,或者可以用模具来表示,通过它,我们可以源源不断地创建对象(即实例化)。

FunctionTemplate V.S ObjectTemplate

V8 中模板主要分为两类:FunctionTemplate 以及 ObjectTemplate,简单来说前者是用来包裹 C++ 函数的,后者是用来包裹 C++ 对象的,但是由于 JS 中函数与对象之间存在着千丝万缕的关系,导致二者实际的关系要复杂的多。

FunctionTemplate

FunctionTemplate 用于将 C++ 函数包裹,从而暴露给 JS 使用。举一个简单的例子,看看如何在 JS 中调用自己写的 C++ 函数:

static void ngx_readfile(const v8::FunctionCallbackInfo<v8::Value>& args) {
    ...
}

v8::Local<v8::ObjectTemplate> global = v8::ObjectTemplate::New(isolate);
global->Set(isolate, "readfile", v8::FunctionTemplate::New(isolate, ngx_readfile));

v8::Local<v8::Context> context = v8::Context::New(isolate, nullptr, global);
v8::Context::Scope ctx_scope(context);

/* ... load and execute js script */

以上代码主要说明了如何将 ngx_readfile 这个函数注册进 JS 环境;和 Lua 一样,提供给 JS 使用的 API 也需要满足一定的函数签名:

using FunctionCallback = void (*)(const FunctionCallbackInfo<Value>& info);

Lua 中 Lua 和 C/C++ 之间的参数/返回值传递都是通过栈(lua_State)来完成的,而 v8 中扮演该角色的则是 FunctionCallbackInfo,该类型有几个常见的方法:

比如下面是 ngx_readfile 函数的具体实现:

static void ngx_readfile(const v8::FunctionCallbackInfo<v8::Value>& args) {
  if (args.Length() != 1) {
    args.GetIsolate()->ThrowError("Bad parameters");
    return;
  }
  v8::String::Utf8Value file(args.GetIsolate(), args[0]);
  if (*file == NULL) {
    args.GetIsolate()->ThrowError("Error loading file");
    return;
  }
  v8::Local<v8::String> source;
  if (!ReadFile(args.GetIsolate(), *file).ToLocal(&source)) {
    args.GetIsolate()->ThrowError("Error loading file");
    return;
  }

  args.GetReturnValue().Set(source);
}

以上覆盖了了写 API 所需要的所有流程:请求参数获取、异常处理、数据返回…;

写好了函数之后,需要将其暴露在 JS 中,这样用户才可以调用;我们知道 V8 中 v8::Context 是 JS 脚本的执行环境,所以 API 势必是和它相关联的——这一点在其创建函数中已有体现:

Local<Context> v8::Context::New(
    v8::Isolate* external_isolate, v8::ExtensionConfiguration* extensions,
    v8::MaybeLocal<ObjectTemplate> global_template,
    v8::MaybeLocal<Value> global_object,
    DeserializeInternalFieldsCallback internal_fields_deserializer,
    v8::MicrotaskQueue* microtask_queue) {
    /* ... */
}

这个函数参数很多,但是我们目前只需要关注两个:external_isolate 以及 global_template;前者自不必言,创建 context 所需要的资源都需要从其中获取;而 global_template 参数,则是用于指定该 context 的全局环境,我们只需要将 API 放入该全局环境即可,此时不能直接使用 C/C++ 函数,而是首先需要将其包裹成一个 FunctionTemplate

A function template is the blueprint for a single function. You create a JavaScript instance of the template by calling the template’s GetFunction method from within the context in which you wish to instantiate the JavaScript function. You can also associate a C++ callback with a function template which is called when the JavaScript function instance is invoked.

V8 doc 中提到了 FunctionTemplate 的两种用途:在 JS 中执行 C++ 定义的函数,以及在 C++ 中执行 JS 定义的函数,二者体现了 JS 和 C++ 的互操作性;此处属于第一种用途。

我们可以使用 FunctionTemplate 类提供的静态方法 New 来创建该类型的对象:

Local<FunctionTemplate> FunctionTemplate::New(
    Isolate* isolate, FunctionCallback callback, v8::Local<Value> data,
    v8::Local<Signature> signature, int length, ConstructorBehavior behavior,
    SideEffectType side_effect_type, const CFunction* c_function,
    uint16_t instance_type, uint16_t allowed_receiver_instance_type_range_start,
    uint16_t allowed_receiver_instance_type_range_end) {
    /* ... */
}

该函数参数也很多,但是目前只需要了解前 3 个:

最终我们可以在 JS 中这样使用该接口:

let text = read("hello.txt")
console.log(text)

C++ 执行 JS 函数

前面已经提到我们不仅仅可以通过 FunctionTemplate 关联一个 C++ 函数供 JS 代码执行,还可以通过 FunctionTemplate 获取 JS 中定义的函数并执行。

v8 中的 process.cc 这个 demo 就提供了一个这样的例子:用户提供一个 JS 脚本,该脚本需要定义一个名为 process 的全局函数,该函数接受一个 request 对象并进行处理,此外有 3 个全局对象可供使用:options 用来控制,output 用来存储返回值,log 是一个用于打印的函数;下面是官方提供的例子:

function Process(request) {
  if (options.verbose) {
    log("Processing " + request.host + request.path +
        " from " + request.referrer + "@" + request.userAgent);
  }
  if (!output[request.host]) {
    output[request.host] = 1;
  } else {
    output[request.host]++
  }
}

程序首先执行该脚本,获取 Process 函数然后在 C++ 中执行:

/* ... load and execute js script ... */

v8::Local<Value> process_val =
    context->Global()
        ->Get(context, v8::String::NewFromUtf8Literal(isolate_, "Process"))
        .ToLocalChecked();

/* ... construct request objects */

const int argc = 1;
Local<Value> argv[argc] = { request_obj };

Local<Function> process_fun = process_val.As<Function>();
Local<Value> result =
    process_fun->Call(context, context->Global(), argc, argv)
        .ToLocalChecked();

执行完该 JS 脚本之后,Process 函数就被注入至全局环境中,此时可以通过 global 对象从中取出该函数,最终得到 JS 函数在 C++ 中的表示形式,即 v8::Function;组装好函数参数调用其 Call() 方法即可。

ObjectTemplate

虽然上面基本讲的都是 FunctionTemplate,但是其实我们在 global_object 中已经接触过 ObjectTemplate 了;文档中是这样解释它的:

Each function template has an associated object template. This is used to configure objects created with this function as their constructor.

并没有直接解释 ObjectTemplate 是什么,而是告诉我们每个 FunctionTemplate 都关联着一个 ObjectTemplate,当该函数被用作构造函数创建对象时,创建的对象将通过其关联的 ObjectTemplate 进行实例化。

JS 中的函数与对象

上面这段话听起来很绕,想要理解它,首先得理解 JS 中函数与对象的关系。

In JavaScript, functions are first-class objects, because they can have properties and methods just like any other object. What distinguishes them from other objects is that functions can be called. In brief, they are Function objects.

也就是说,在 JS 中,函数也是一种对象,它也可以有属性和方法,不同之处只是它是可以被调用的,也就是说,函数是一种函数对象

JS 中对象就是一系列属性的集合,属性包含一个名字和一个值;有 3 种方法用于创建对象:

通常我们定义函数是这样的:

function multipy(x, y) {
    return x * y
}

这样似乎看不出函数是对象,但是我们可以换一种方式:

var multiply = new Function('x', 'y', 'return x * y');

这样就可以很清晰地看出这点了:和创建对象一样的创建方式,只不过创建的是一个 Function 对象,甚至还可以和普通对象一样动态地为函数对象增加属性:

multiply.hello = "world"

在最后一种使用 new 构造函数的方法中,可以看到这个构造函数其实并没有什么特殊的,只是它配合 new 关键字一同使用,如果不写 new,这就是一个普通函数,它返回 undefined。但是如果写了 new,它就变成了一个构造函数,它绑定的 this指向新创建的对象,并默认返回 this

在构造函数中我们用到了 this 关键字,用来指代对象自己,在函数体中我们利用 this 初始化了该对象的一些属性,说明函数的确关联着一个对象类型定义,也就是上面所说的每一个 FunctionTemplate 都关联着一个 ObjectTemplate

InstanceTemplate V.S PrototypeTemplate

A FunctionTemplate can have properties, these properties are added to the function object when it is created.

A FunctionTemplate has a corresponding instance template which is used to create object instances when the function is used as a constructor. Properties added to the instance template are added to each object instance.

A FunctionTemplate can have a prototype template. The prototype template is used to create the prototype object of the function.

我们已经知道每个 FunctionTemplate 都关联着一个 ObjectTemplate,当 FunctionTemplate 表示的函数被用作构造函数时,该 ObjectTemplate 将会被用来创建对象。那么我们可以在 ObjectTemplate 中预设一些内容,这样当 JS 中对象被创建时,这些字段也就有了默认值。

我们可以通过 FunctionTemplateInstanceTemplate 来获取其关联的对象类型定义的 ObjectTemplate

static void dummy_constructor(const v8::FunctionCallbackInfo<v8::Value>& args) {
  static int ncalls = 0;

  if (args.IsConstructCall()) {
    printf("construct the %d-th object\n", ++ncalls);
  } else {
    printf("non-constructor call\n");
  }
}

v8::Local<v8::FunctionTemplate> ftpl = v8::FunctionTemplate::New(isolate, dummy_constructor);
ftpl->SetClassName(v8::String::NewFromUtf8Literal(isolate, "Dummy"));
global->Set(v8::String::NewFromUtf8Literal(isolate, "Dummy"), ftpl);

v8::Local<v8::ObjectTemplate> itpl = ftpl->InstanceTemplate();
itpl->Set(isolate, "greet", v8::String::NewFromUtf8Literal("Hello, v8!"));

/* ... load and execute js script ... */

以上代码在 JS 的执行环境中注册了一个名为 Dummy 的函数,并在其关联的 ObjectTemplate 中注册了一个名为 greet,值为 Hello, v8! 的属性;每次以构造函数的形式调用 Dummy 函数时,都会打印所创建对象的序号至标准输出,且该对象默认带有前面注册的 greet 属性。

在如上 context 中执行下面的 JS 脚本:

var d1 = new Dummy()
console.log(d1.greet)
Dummy()
var d2 = new Dummy()
var d3 = new Dummy()

得到的输出:

construct the 1-th object
Hello, v8!
non-constructor call
construct the 2-th object
construct the 3-th object
JS 原型

事实上,FunctionTemplate 关联着两个 ObjectTemplate:除了 InstanceTemplate 之外还有一个 PrototypeTemplate;二者有什么区别呢?想要解答这个问题,首先得了解 JS 中原型的概念。

When it comes to inheritance, JavaScript only has one construct: objects. Each object has a private property which holds a link to another object called its prototype. That prototype object has a prototype of its own, and so on until an object is reached with null as its prototype. By definition, null has no prototype, and acts as the final link in this prototype chain.

在 JS 中,几乎每个对象都有另一个与之关联的对象,这个对象被称为原型(prototype),原型对象通过对象的 __proto__ 属性引用;目前可以简单地将原型对象视为 C++ 继承体系中的父类,所有原始对象都会从其原型对象中继承属性,所以说 JS 是基于原型的继承(prototype-based inheritance),而 C++/Java 等语言则是基于类的继承(class-based inheritance)。

由于原型其实也是一个对象,所以原型对象也有自己的原型;从原始对象找不到的属性会尝试从原型对象中找,通过原型对象一直往上,就形成了一条原型链:

let user = {
    showAccess: true
}

let premiumUser = {
    __proto__: user,
    ads: false
}

let familyPremium {
    __proto__: premiumUser,
    multipleDevices: true
}

console.log(familyPremium.showAccess)   // "true"

在前面提到的 JS 对象的三种创建方法中:通过对象字面量创建的对象都有相同的原型对象,在 JS 中可以通过 Object.prototype 引用它;通过构造函数创建的对象会使用构造函数的 prototype 属性(别忘了函数也是一种对象)作为它们的原型。几乎所有对象都有原型,但是只有函数对象有 prototype 属性,正是这些有 prototype 属性的对象为所有其他对象定义了原型。

对应到 v8 中,PrototypeTemplate 就是该函数的 prototype 属性;还是接着前一段 Dummy 的 C++ 代码继续,往它的原型对象中添加一个属性:

v8::Local<v8::FunctionTemplate> ptpl = ftpl->PrototypeTemplate();
ptpl->Set(isolate, "name", v8::String::NewFromUtf8Literal(isolate, "DummyObject"));

在如上 context 中执行一下 JS 代码:

var d1 = new Dummy()
console.log(d1.name)

得到如下输出:

construct the 1-th object
DummyObject

的确从 Dummy 对象中读取到了其原型中的属性。

Reference