tsy77 / blog

78 stars 2 forks source link

如何知根知底使用Node.js C++ Addon #4

Open tsy77 opened 6 years ago

tsy77 commented 6 years ago

最初的想法是去写一个node C++ addon,但在了解如何去写node addon的过程中,发现想要知根知底的去使用node addon,需要对node架构、V8、libuv、模块加载等要点都有一个了解。所以本文的目的就是梳理如何清楚的使用node addon?

本文将从以下几点进行阐述:

1.node 架构
2.V8
3.libuv
4.深入node源码了解其模块加载
5.addon

node架构

node的架构相信大家都不陌生,以模块加载为角度架构图如下:

V8 engine是Google开发的javascript引擎,是一个独立运行的虚拟机,node以第三方依赖的形式引入V8(与libuv等依赖放在deps目录下)。除了作为Javascript运行引擎外,V8提供了嵌入API,为编译和执行JS脚本, 访问 C++ 方法和数据结构, 错误处理, 开启安全检查等提供了函数接口,承担着是node中js与C++桥接的重要作用。

libuv是专门为node开发的库,提供跨平台的异步I/O能力。其基于异步的、事件驱动模型,提供一个event-loop,还有基于I/O和其它事件通知的回调函数。

Builtin modules是node提供的C++模块。

Native module是node提供的js模块,其被使用者直接调用,并且有些Native module会借助下层的Builtin module。在native模块中使用builtin模块,利用的是node提供的process.binding方法(后面模块加载会有介绍)。

Addon是一个用C++写的node的动态链接库,使用者可以直接使用require()方法进行加载,当然前提是addon已经编译好。Addon主要用来扩展node的底层能力,具体应用可能是计算密集型的模块(C++的运行性能高,可以利用libuv异步和事件循环的能力,同时可以使用多进程、多线程)。

V8

V8提供了对外的使用API,可以参考V8嵌入指南

下面主要对其中主要概念进行简要梳理

Isolate是一个独立的V8实例,也可以说一个独立虚拟机,其中可以包含一个或多个线程,但同一时间,只有一个线程是执行状态。

Context代表一个执行上下文(执行环境),它使得可以在一个 V8 实例中运行相互隔离且无关的 JavaScript 代码. 你必须为你将要执行的 JavaScript 代码显式的指定一个 context。Context支持嵌套。

Handle是一个指向堆内存的指针,在V8中JavaScript的值和对象也都存放在堆中,Handle提供了一个JS对象在堆内存中的地址的引用。有人会有疑问我们直接操作JS变量指针不可以嘛?由于V8的GC策略,可能会对堆中的JS变量移动其内存位置,Handle的出现可以跟踪相应变量的地址。

Handle Scope是一个Handle的容器,为了解决一个个释放handle过于繁琐,将一些handle接入handle scope中,方便统一管理(释放等)。

下图主要是为了大家理解Isolate、Context、Handle Scope、Handle的大小关系,在细节上不够准确。

libuv

libuv是一个跨平台的异步I/O库。其架构图如下:

上图的左侧是网络相关的I/O,使用的都是各个平台比较有效率的多路I/O模型,Linux上的epoll,OSX和BSD类OS上的kqueue,SunOS上的event ports以及Windows上的IOCP机制。

右侧File类型的I/O,基于线程池的方式来实现异步的请求和处理。

具体讲解可参考libuv 教程

node 模块加载

node模块可分为Native Module、Builtin Module、Constants。

Native Module在下载node源码并编译后,会在out/Release/obj/gen目录下node_natives.h。该文件由 js2c.py 生成,其会将node源代码中的lib目录下所有js文件以及src目录下的node.js文件中每一个字符转换成对应的ASCII码,并存放在相应的数组里面。

Builtin模块会被main()之前加载到modlist_builtin中,当使用时,从链表中将模块取出即可。在每个builtin模块中,都会通过宏NODE_BUILTIN_MODULE_CONTEXT_AWARE预编译阶段将其转为函数_register_ ## modname,函数会调用node_module_register方法将其加载进modlist_builtin。tcp_wrap中宏定义如下:

NODE_BUILTIN_MODULE_CONTEXT_AWARE(tcp_wrap, node::TCPWrap::Initialize)

模块加载

我们加载node C++ addon时,可以直接使用process.binding方法。其实process.binding是node require()的基础,所以后面也介绍了require的实现

process.binding()

process.binding()做了什么呢?
static void GetBinding(const FunctionCallbackInfo<Value>& args) {
  Environment* env = Environment::GetCurrent(args);

  CHECK(args[0]->IsString());

  Local<String> module = args[0].As<String>();
  node::Utf8Value module_v(env->isolate(), module);

  node_module* mod = get_builtin_module(*module_v);
  Local<Object> exports;
  if (mod != nullptr) {
    exports = InitModule(env, mod, module);
  } else if (!strcmp(*module_v, "constants")) {
    exports = Object::New(env->isolate());
    CHECK(exports->SetPrototype(env->context(),
                                Null(env->isolate())).FromJust());
    DefineConstants(env->isolate(), exports);
  } else if (!strcmp(*module_v, "natives")) {
    exports = Object::New(env->isolate());
    DefineJavaScript(env, exports);
  } else {
    return ThrowIfNoSuchModule(env, *module_v);
  }

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

static Local<Object> InitModule(Environment* env,
                                 node_module* mod,
                                 Local<String> module) {
  Local<Object> exports = Object::New(env->isolate());
  // Internal bindings don't have a "module" object, only exports.
  CHECK_EQ(mod->nm_register_func, nullptr);
  CHECK_NE(mod->nm_context_register_func, nullptr);
  Local<Value> unused = Undefined(env->isolate());
  mod->nm_context_register_func(exports,
                                unused,
                                env->context(),
                                mod->nm_priv);
  return exports;
}

process.binding()主要做了对不同类型的模块做了不同的处理:

1.Builtin模块,直接从modlist_builtin获取
2.constants模块,通过 constants 导出。
3.Native模块,从node_natives.h中获取

require

首先node中require()方法做了什么呢?
// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function(id) {
  if (typeof id !== 'string') {
    throw new ERR_INVALID_ARG_TYPE('id', 'string', id);
  }
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id,
                                    'must be a non-empty string');
  }
  return Module._load(id, this, /* isMain */ false);
};
代码前面时一些path校验,那么Module._load做了什么呢?
// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call `NativeModule.require()` with the
//    filename and return the result.
// 3. Otherwise, create a new module for the file and save it to the cache.
//    Then have it load  the file contents before returning its exports
//    object.
Module._load = function(request, parent, isMain) {
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id);
  }

  if (experimentalModules && isMain) {
    asyncESM.loaderPromise.then((loader) => {
      return loader.import(getURLFromFilePath(request).pathname);
    })
    .catch((e) => {
      decorateErrorStack(e);
      console.error(e);
      process.exit(1);
    });
    return;
  }

  var filename = Module._resolveFilename(request, parent, isMain);

  var cachedModule = Module._cache[filename];
  // 如果在缓存
  if (cachedModule) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }

  // 原生模块
  if (NativeModule.nonInternalExists(filename)) {
    debug('load native module %s', request);
    return NativeModule.require(filename);
  }

  // 创建新module
  // Don't call updateChildren(), Module constructor already does.
  var module = new Module(filename, parent);

  if (isMain) {
    process.mainModule = module;
    module.id = '.';
  }

  Module._cache[filename] = module;

  tryModuleLoad(module, filename);

  return module.exports;
};

Module._load()主要做了三件事:

1.缓存模块直接从缓存取
2.原生模块调用`NativeModule.require`
3.否则,创建新模块,加入缓存
我们再深度遍历代码到NativeModule.require
NativeModule.require = function(id) {
    if (id === loaderId) {
      return loaderExports;
    }

    const cached = NativeModule.getCached(id);
    // 判断是否缓存
    if (cached && (cached.loaded || cached.loading)) {
      return cached.exports;
    }

    if (!NativeModule.exists(id)) {
      // Model the error off the internal/errors.js model, but
      // do not use that module given that it could actually be
      // the one causing the error if there's a bug in Node.js
      // eslint-disable-next-line no-restricted-syntax
      const err = new Error(`No such built-in module: ${id}`);
      err.code = 'ERR_UNKNOWN_BUILTIN_MODULE';
      err.name = 'Error [ERR_UNKNOWN_BUILTIN_MODULE]';
      throw err;
    }

    moduleLoadList.push(`NativeModule ${id}`);

    const nativeModule = new NativeModule(id);

    nativeModule.cache();
    nativeModule.compile();

    return nativeModule.exports;
 };

NativeModule.require主要做了两件事:

1.缓存模块直接从缓存取
2.否则,加入到moduleLoadList(bootstrapInternalLoaders中的私有变量)数组中,创建新的 NativeModule 对象,缓存,最后`nativeModule.compile()`。
nativeModule.compile()做了什么呢?
NativeModule.getSource = function(id) {
  return NativeModule._source[id];
};

NativeModule.wrap = function(script) {
  return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

NativeModule.wrapper = ['(function (exports, require, module, __filename, __dirname) {','\n});' ];

NativeModule.prototype.compile = function() {
    let source = NativeModule.getSource(this.id);
    source = NativeModule.wrap(source);

    this.loading = true;

    try {
      const script = new ContextifyScript(source, this.filename);
      // Arguments: timeout, displayErrors, breakOnSigint
      const fn = script.runInThisContext(-1, true, false);
      const requireFn = this.id.startsWith('internal/deps/') ?
        NativeModule.requireForDeps :
        NativeModule.require;
      fn(this.exports, requireFn, this, process);

      this.loaded = true;
    } finally {
      this.loading = false;
    }
};

nativeModule.compile()就是将源码wrap起来,使用script.runInThisContext去运行。

script.runInThisContext做了什么呢?
const {
  ContextifyScript,
  kParsingContext,
  makeContext,
  isContext: _isContext,
} = process.binding('contextify');

class Script extends ContextifyScript {...}

function createScript(code, options) {
  return new Script(code, options);
}

function runInThisContext(code, options) {
  if (typeof options === 'string') {
    options = { filename: options };
  }
  return createScript(code, options).runInThisContext(options);
}

Contextify中的runInThisText如何实现的呢?

static void RunInThisContext(const FunctionCallbackInfo<Value>& args) {
    Environment* env = Environment::GetCurrent(args);

    CHECK_EQ(args.Length(), 3);

    CHECK(args[0]->IsNumber());
    int64_t timeout = args[0]->IntegerValue(env->context()).FromJust();

    CHECK(args[1]->IsBoolean());
    bool display_errors = args[1]->IsTrue();

    CHECK(args[2]->IsBoolean());
    bool break_on_sigint = args[2]->IsTrue();

    // Do the eval within this context
    EvalMachine(env, timeout, display_errors, break_on_sigint, args);
}

Node Addon

加载

上面讲解了node的模块加载,那么Node Addon为什么能够正确加载呢?

原因在于每个Node Addon模块入口中,需要#include <node.h>node.h包含者一些宏定义,其中有:

#define NODE_MODULE(modname, regfunc)                                 \
  NODE_MODULE_X(modname, regfunc, NULL, 0)  // NOLINT (readability/null_usage)

 #define NODE_MODULE_X(modname, regfunc, priv, flags)                  \
  extern "C" {                                                        \
    static node::node_module _module =                                \
    {                                                                 \
      NODE_MODULE_VERSION,                                            \
      flags,                                                          \
      NULL,  /* NOLINT (readability/null_usage) */                    \
      __FILE__,                                                       \
      (node::addon_register_func) (regfunc),                          \
      NULL,  /* NOLINT (readability/null_usage) */                    \
      NODE_STRINGIFY(modname),                                        \
      priv,                                                           \
      NULL   /* NOLINT (readability/null_usage) */                    \
    };                                                                \
    NODE_C_CTOR(_register_ ## modname) {                              \
      node_module_register(&_module);                                 \
    }                                                                 \
  }

我们又看到了我们熟悉的node_module_register方法,我们再写一个addon时调用的NODE_MODULE方法,实际上就是将该模块编译后的结果加入到modlist_builtin,我们调用process.binding()就可以将其加载。

NAN

为什么要有NAN呢?

原因在于随着Node.js和V8的版本迭代,其底层API可能会发生变化,我们写的这些原生模块又依赖了变化了的API的话,包就作废了。除非包的维护者去支持新版的API,不过这样依赖,老版Node.js下就又无法编译通过新版的包了。

为了解决这种尴尬的局面,NAN出现了,其在nan.h中定义了许多判断宏,会判断当前node版本,我们使用nan.h宏定义中的方法,编译器会将其展开成不同的结果。

Node v8.0之后,官方推出了N-API,它与NAN的区别在于,NAN在适配不同版本时,需要每次重新编译,而N-API将其底层接口抽象,所有版本都适用一套API即可。