理解nodejs插件的加载原理并使用n-api编写你的第一个nodejs插件
发布于 4 年前 作者 theanarkh 4227 次浏览 来自 分享

nodejs拓展本质是一个动态链接库,写完编译后,生成一个.node文件。我们在nodejs里直接require使用,nodejs会为我们处理这一切。下面我们按照文档写一个拓展并通过nodejs14源码了解他的原理(ubuntu18.4)。     首先建立一个test.cc文件

// hello.cc using N-API
#include <node_api.h>

namespace demo {

napi_value Method(napi_env env, napi_callback_info args) {
  napi_value greeting;
  napi_status status;

  status = napi_create_string_utf8(env, "world", NAPI_AUTO_LENGTH, &greeting);
  if (status != napi_ok) return nullptr;
  return greeting;
}

napi_value init(napi_env env, napi_value exports) {
  napi_status status;
  napi_value fn;

  status = napi_create_function(env, nullptr, 0, Method, nullptr, &fn);
  if (status != napi_ok) return nullptr;

  status = napi_set_named_property(env, exports, "hello", fn);
  if (status != napi_ok) return nullptr;
  return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, init)

}  // namespace demo

我们不需要具体了解代码的意思,但是从代码中我们大致知道他做了什么事情。剩下的就是阅读n-api的api文档就可以。接着我们新建一个binding.gyp文件。gyp文件是node-gyp的配置文件。node-gyp可以帮助我们针对不同平台生产不同的编译配置文件。比如linux下的makefile。

{
  "targets": [
    {
      "target_name": "test",
      "sources": [ "./test.cc" ]
    }
  ]
}

语法和makefile有点像,就是定义我们编译后的目前文件名,依赖哪些源文件。然后我们安装node-gyp。

npm install node-gyp -g

nodejs源码中也有一个node-gyp,他是帮助npm安装拓展模块时,就地编译用的。我们安装的node-gyp是帮助我们生成配置文件并编译用的,具体可以参考nodejs文档。一切准备就绪。我们开始编译。直接执行

 node-gyp rebuild

在路径./build/Release/下生成了test.node文件。这就是我们的拓展模块。我们编写测试程序。

var addon = require("./build/Release/test");
console.log(addon.hello());

执行

nodejs app.js

我们看到输出world。我们已经学会了如何编写一个nodejs的拓展模块。剩下的就是阅读n-api文档,根据自己的需求编写不同的模块。     写完了一个拓展模块,当然要去分析他的机制。一切的源头在于require函数。但是我们不必从这开始分析,我们只需要从加载.node模块的源码开始。

Module._extensions['.node'] = function(module, filename) {
  // ...
  return process.dlopen(module, path.toNamespacedPath(filename));
};

直接调了process.dlopen,该函数在node.js里定义。

const rawMethods = internalBinding('process_methods');
process.dlopen = rawMethods.dlopen;

找到process_methods模块对应的是node_process_methods.cc。

  env->SetMethod(target, "dlopen", binding::DLOpen);

之前说过,node的拓展模块其实是动态链接库,那么我们先看看一个动态链接库我们是如何使用的。以下是示例代码。

#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
int main(){
	// 打开一个动态链接库,拿到一个handler
    handler = dlopen('xxx.so',RTLD_LAZY);
    // 取出动态链接库里的函数add
    add = dlsym(handler,"add");
    // 执行
    printf("%d",add (1,1));
    dlclose(handler);
    return 0;
}

了解动态链接库的使用,我们继续分析刚才看到的DLOpen函数。

void DLOpen(const FunctionCallbackInfo<Value>& args) {

  int32_t flags = DLib::kDefaultFlags;

  node::Utf8Value filename(env->isolate(), args[1]);  // Cast
  env->TryLoadAddon(*filename, flags, [&](DLib* dlib) {
  
    const bool is_opened = dlib->Open();

    node_module* mp = thread_local_modpending;
    thread_local_modpending = nullptr;
	// 省略部分代码
    if (mp->nm_context_register_func != nullptr) {
      mp->nm_context_register_func(exports, module, context, mp->nm_priv);
    } else if (mp->nm_register_func != nullptr) {
      mp->nm_register_func(exports, module, mp->nm_priv);
    } 
    return true;
  });
}

我们看到重点是TryLoadAddon函数,该函数的逻辑就是执行他的第三个参数。我们发现第三个参数是一个函数,入参是DLib对象。所以我们先看看这个类。

class DLib {
 public:
  static const int kDefaultFlags = RTLD_LAZY;
  DLib(const char* filename, int flags);

  bool Open();
  void Close();
  const std::string filename_;
  const int flags_;
  std::string errmsg_;
  void* handle_;
  uv_lib_t lib_;
};

再看一下实现。

bool DLib::Open() {
  handle_ = dlopen(filename_.c_str(), flags_);
  if (handle_ != nullptr) return true;
  errmsg_ = dlerror();
  return false;
}

DLib就是对动态链接库的一个封装,他封装了动态链接库的文件名和操作。TryLoadAddon函数首先根据require传入的文件名,构造一个DLib,然后执行

const bool is_opened = dlib->Open();

Open函数打开了一个动态链接库,这时候我们要先了解一下打开一个动态链接库究竟发生了什么。首先我们看一个napi动态链接库的定义。我们回来文章开头的测试代码test.cc。最后一句是

NAPI_MODULE(NODE_GYP_MODULE_NAME, init)

这是个宏定义。

#define NAPI_MODULE(modname, regfunc)                                 \
  NAPI_MODULE_X(modname, regfunc, NULL, 0)

继续展开

#define NAPI_MODULE_X(modname, regfunc, priv, flags)                  \
    static napi_module _module =                                      \
    {                                                                 \
      NAPI_MODULE_VERSION,                                            \
      flags,                                                          \
      __FILE__,                                                       \
      regfunc,                                                        \
      #modname,                                                       \
      priv,                                                           \
      {0},                                                            \
    };                                                                \
    static void _register_modname(void) __attribute__((constructor)); \
  	static void _register_modname(void) 	 {                        \
      napi_module_register(&_module);                                 \
    }                                                               

所以一个node扩展就是定义了一个napi_module 模块和一个_register_modname(modname是我们定义的)函数。我们貌似定义了两个函数,其实一个带__attribute__((constructor))。attribute((constructor))是代表该函数会先执行的意思,具体可以查阅文档。看到这里我们知道,当我们打开一个动态链接库的时候,会执行_register_modname函数,该函数执行的是

napi_module_register(&_module);                                 

我们继续展开。

// Registers a NAPI module.
void napi_module_register(napi_module* mod) {
  node::node_module* nm = new node::node_module {
    -1,
    mod->nm_flags | NM_F_DELETEME,
    nullptr,
    mod->nm_filename,
    nullptr,
    napi_module_register_cb,
    mod->nm_modname,
    mod,  // priv
    nullptr,
  };
  node::node_module_register(nm);
}

nodejs把napi模块转成node_module。最后调用node_module_register。

extern "C" void node_module_register(void* m) {
  struct node_module* mp = reinterpret_cast<struct node_module*>(m);

  if (mp->nm_flags & NM_F_INTERNAL) {
    mp->nm_link = modlist_internal;
    modlist_internal = mp;
  } else if (!node_is_initialized) {
    mp->nm_flags = NM_F_LINKED;
    mp->nm_link = modlist_linked;
    modlist_linked = mp;
  } else {
    thread_local_modpending = mp;
  }
}

napi模块不是NM_F_INTERNAL模块,node_is_initialized是在nodejs初始化时设置的变量,这时候已经是true。所以注册napi模块时,会执行thread_local_modpending = mp。thread_local_modpending 类似一个全局变量,保存当前加载的模块。分析到这,我们回到DLOpen函数。

node_module* mp = thread_local_modpending;
thread_local_modpending = nullptr;

这时候我们就知道刚才那个变量thread_local_modpending的作用了。node_module* mp = thread_local_modpending后我们拿到了我们刚才定义的napi模块的信息。接着执行node_module的函数nm_register_func。

if (mp->nm_context_register_func != nullptr) {
  mp->nm_context_register_func(exports, module, context, mp->nm_priv);
 } else if (mp->nm_register_func != nullptr) {
   mp->nm_register_func(exports, module, mp->nm_priv);
 } 

从刚才的node_module定义中我们看到函数是napi_module_register_cb。

static void napi_module_register_cb(v8::Local<v8::Object> exports,
                                    v8::Local<v8::Value> module,
                                    v8::Local<v8::Context> context,
                                    void* priv) {
  napi_module_register_by_symbol(exports, module, context,
      static_cast<napi_module*>(priv)->nm_register_func);
}

该函数调用napi_module_register_by_symbol函数,并传入napi_module的nm_register_func函数,即我们test.cc代码里定义的函数。

void napi_module_register_by_symbol(v8::Local<v8::Object> exports,
                                    v8::Local<v8::Value> module,
                                    v8::Local<v8::Context> context,
                                    napi_addon_register_func init) {
 
  // Create a new napi_env for this specific module.
  napi_env env = v8impl::NewEnv(context);

  napi_value _exports;
  env->CallIntoModuleThrow([&](napi_env env) {
    _exports = init(env, v8impl::JsValueFromV8LocalValue(exports));
  });

  if (_exports != nullptr &&
      _exports != v8impl::JsValueFromV8LocalValue(exports)) {
    napi_value _module = v8impl::JsValueFromV8LocalValue(module);
    napi_set_named_property(env, _module, "exports", _exports);
  }
}

init就是我们在test.cc里定义的函数。入参是env和exports,可以对比我们定义的函数的入参。最后我们修改exports变量。即设置导出的内容。最后在js里,我们就拿到了c++层定义的内容。

回到顶部