I. Concepts
Essentially, Node.js extensions are C++ dynamically-linked shared objects:
Addons are dynamically-linked shared objects written in C++.
Equivalent to a door from JS to the C/C++ world:
Addons provide an interface between JavaScript and C/C++ libraries.
These C++ extensions (xxx.node files) can also be directly required and used like JS modules, because [Node module loading mechanism](/articles/node 模块加载机制/) provides native support.
P.S. The so-called dynamically-linked library is a library that can be dynamically loaded at runtime (.so files, or .dll files under Windows):
A shared library(.so) is a library that is linked but not embedded in the final executable, so will be loaded when the executable is launched and need to be present in the system where the executable is deployed.
In contrast is static libraries (.a files), linked into the executable at compile time, no need to load from outside:
A static library(.a) is a library that can be linked directly into the final executable produced by the linker,it is contained in it and there is no need to have the library into the system where the executable will be deployed.
II. Implementation Methods
In Node.js, there are 3 ways to write a C++ extension:
-
Direct manual writing: Write directly based on C++ APIs provided by Node, V8, libuv, but need to manually handle compatibility issues of these APIs under different Node versions (especially V8 API changes frequently)
-
Based on nan: Native Abstractions for Node.js, a layer of abstraction added to shield C++ API differences between different Node/V8 versions, expecting to consolidate the handling of lower-level API compatibility issues into this layer
-
Based on N-API (recommended method): Native extension support API provided by Node.js, completely independent from the underlying JS runtime (V8), guaranteeing ABI remains unchanged across Node versions, therefore can run on different Node versions without recompilation
P.S. Actually, with this independent abstraction layer of N-API, C++ extensions can also cross JavaScript engines, cross Electron and other runtimes, see The Future of Native Modules in Node.js for details.
Among them, N-API is the preferred method, only consider other methods if N-API can't handle it:
Unless there is a need for direct access to functionality which is not exposed by N-API, use N-API.
Running directly across Node versions (without recompilation) is undoubtedly a decisive advantage, but only specially provided N-API guarantees ABI stability. That is, only using N-API (without mixing lower-level Node, V8, libuv APIs at the same time) can guarantee C++ extensions can run directly under different Node versions, see Implications of ABI Stability for details.
Without using N-API, manually writing one is somewhat complex, involving knowledge of several layers:
-
V8: JavaScript engine that Node.js depends on, object creation, function calling mechanisms are all provided by V8, specific C++ APIs see header file node/deps/v8/include/v8.h
-
libuv: Event loop, Worker threads and all platform-related asynchronous behaviors are provided by libuv, and provides cross-platform abstraction for file systems, sockets, timers, system events, etc. C++ extensions can use libuv to implement various operations in non-blocking way, thereby avoiding I/O or other time-consuming tasks blocking the event loop
-
Node internal libraries: Node.js itself also exposes some C++ APIs, such as
node::ObjectWrapclass -
Node dependency libraries: Some static link libraries that Node.js depends on can also be used in C++ extensions, such as OpenSSL (more dependency libraries, see node/deps/)
P.S. For more information about Node.js source code dependencies, operation mechanisms, see Node.js Architecture Overview
III. Hello World
For clarity, here we use the most original way, manually write a simplest C++ extension:
// hoho.cc
// See https://github.com/nodejs/node/blob/master/src/node.h
#include <node.h>
// See https://github.com/nodejs/node/blob/master/deps/v8/include/v8.h
using namespace v8;
namespace demo {
void Method(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
args.GetReturnValue().Set(String::NewFromUtf8(
isolate, "hoho, there.", NewStringType::kNormal).ToLocalChecked());
}
void Initialize(Local<Object> exports) {
NODE_SET_METHOD(exports, "hoho", Method);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
}
Notice the two key lines:
// Implement initialization method
void Initialize(Local<Object> exports) { /* ... */ }
// Register initialization logic corresponding to module name
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
C++ extensions expose the initialization method (Initialize) through the NODE_MODULE macro provided by Node.js, where NODE_GYP_MODULE_NAME is a macro, which will be expanded to the module name passed by node-gyp command during preprocessing stage before compilation.
P.S. Macro expansion can be understood as string replacement, see Macros for details.
Compile and Run
Before compiling C++ source code, first need a compilation configuration:
{
"targets": [
{
"target_name": "hoho",
"sources": [ "hoho.cc" ]
}
]
}
Configuration file is named binding.gyp, placed in project root directory (similar to package.json), for node-gyp compilation use.
P.S. binding.gyp specific format and field meanings see Input Format Reference
First need to install node-gyp command:
npm install -g node-gyp
P.S. Of course, can also npm install node-gyp to install it to current project, and call through npx node-gyp
Then through node-gyp configure command, generate configuration files needed for current platform build process (generates Makefile under Unix systems, vcxproj files under Windows), for example (Mac OSX):
$ node-gyp configure
gyp info it worked if it ends with ok
...
gyp info ok
# Generated files located in build directory
$ tree build/
build/
├── Makefile
├── binding.Makefile
├── config.gypi
├── gyp-mac-tool
└── hoho.target.mk
Compile to get .node binary file:
$ node-gyp build
gyp info it worked if it ends with ok
gyp info using node-gyp @6.1.0
gyp info using node @10.18.0 | darwin | x64
gyp info spawn make
gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
CXX(target) Release/obj.target/hoho/hoho.o
SOLINK_MODULE(target) Release/hoho.node
gyp info ok
Compilation product located at Release/hoho.node, try it out:
// index.js
// Omit suffix, automatically find hoho.node and load, initialize
const hoho = require('./build/Release/hoho');
console.log(hoho.hoho());
Run result:
$ node index.js
hoho, there.
Above example directly used C++ APIs provided by Node, V8, may have cross-version compatibility issues (may report compilation errors after a few versions), and need to recompile under different Node environments, otherwise will produce runtime errors:
$ node -v
v10.18.0
# Switch to 8.17.0
$ n 8.17.0
# Execute directly without recompiling
$ node index.js
module.js:682
return process.dlopen(module, path._makeLong(filename));
^
Error: The module '/path/to/hoho/build/Release/hoho.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 64. This version of Node.js requires
NODE_MODULE_VERSION 57. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).
Must recompile, execute:
$ node-gyp rebuild
gyp info it worked if it ends with ok
...
gyp info ok
$ node index.js
hoho, there.
So, is there a once-and-for-all way?
Yes. N-API
IV. N-API
Don't directly use APIs exposed by lower-level C/C++ modules like Node, V8, etc., all switch to N-API:
// hoho-anywhere.cc
#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, "hoho, anywhere.", 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, "hoho", fn);
if (status != napi_ok) return nullptr;
return exports;
}
NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
}
Only import one header file node_api.h, value types etc. no longer directly use v8::String
Modify compilation configuration binding.gyp:
{
"targets": [
{
"target_name": "hoho",
"sources": [ "hoho-anywhere.cc" ]
}
]
}
Compile and run:
$ node-gyp rebuild
$ node index.js
hoho, anywhere.
# Switch Node version
$ n 8.17.0
# No need to compile, can run directly!
$ node index.js
hoho, anywhere.
P.S. For more complex usage, and more information about N-API, see N-API
P.S. Additionally, N-API provides C interfaces, for C++ environments, can use node-addon-api
V. Application Scenarios
In some scenarios, using C++ extensions to implement is especially suitable:
-
Compute-intensive modules, C++ execution performance is generally higher than JS
-
Low-cost encapsulation of existing C++ libraries into Node.js extensions, for Node ecosystem use
-
Native capabilities provided by Node.js cannot meet needs, such as fsevents
-
JS language has inherent deficiencies in some aspects (such as numerical precision, bit operations, etc.), can be supplemented through C++
P.S. Note, runtime initialization of C++ templates itself has some overhead, in performance-critical scenarios need to consider this factor, and C++ is not always faster than JS (such as some regex matching scenarios)
No comments yet. Be the first to share your thoughts.