作者:郭泽豪
本篇教程关于Nodejs的核心模块机制,具体讲Nodejs核心模块的原理、C/C++扩展模块的原理、包、模块调用栈以及NPM。
本章的重点内容:
前面提及,Node的核心模块在编译成可执行文件的过程中被编译进了二进制文件。核心模块其实分为C/C++编写的和JavaScript编写的两部分,其中C/C++文件存在在Node项目的src目录下,JavaScript文件存放在lib目录下。
在编译所有C/C++文件之前,编译程序需要将所有的JavaScript文件编译成C/C++代码,此时是否直接将其编译成可执行文件?其实不是。
(1)转存为C/C++代码
Node采用了V8附带的js2c.py工具,将全部内置的JavaScript代码(即src/node.js和lib/*.js)转换成C++里的数组,生成node_natives.h头文件,相关代码如下:
namespace node{
const char node_native[] = {47, 47, ..};
const char dgram_native[] = {47, 47, ..};
const char console_native[] = {47, 47, ..};
const char buffer_native[] = {47, 47, ..};
const char querystring_native[] = {47, 47, ..};
const char punycode_native[] = {47, 42, ..};
...
struct _native {
const char* name;
const char* source;
size_t source_len;
};
static const struct _native natives[] = {
{"node", node_native, sizeof(node_native) -1};
{"dgram", node_native, sizeof(node_native) -1};
...
};
}
在这个过程中,JavaScript代码以字符串的形式存储在node命名空间中,是不可直接执行的。在启动Node进程时,JavaScript代码直接加载进内存中。在加载的过程中,JavaScript核心模块通过标识符分析直接定位到内存中,找到JavaScript代码后编译执行,比普通的文件模块从磁盘查找快很多。
(2)编译JavaScript核心模块
lib目录下的所有模块文件也没有定义require、module、exports这些变量。在引入JavaScript核心模块的过程中,也经历头尾包装的过程,然后执行和导出exports对象。与文件模块有区别的地方在于:获取源代码的方式(核心模块从内存加载),以及缓存执行结果的位置。
JavaScript核心模块对象的定义如下面的代码所示,源文件可以通过process.binding(‘natives’)取出,编译执行成功生成的模块对象缓存在NativeModule._cache对象上,文件模块则缓存在Module._cache对象上。
function NativeModule(id){
this.filename = id + ".js";
this.id = id;
this.exports = {};
this.loaded = false;
}
NativeModule._source = process.binding('natives');
NativeModule._cache = {};
在核心模块中,有些模块全部由C/C++编写,也有些模块则由C/C++完成核心部分,其他部分则由JavaScript实现包装或向外导出,以满足性能需求。后者这种C++模块主内完成核心,JavaScript主外实现封装的模式是Node能够提高性能的常见方式。通常,脚本的开发速度快于静态语言,但性能却弱于静态语言。而Node的这种复合模式可以在开发速度和性能之间找到平衡点。这里我们将那么纯由C/C++编写的部分模块统一称为内建模块,因为它们通常不会被用户直接调用。
(1)内建模块的内部结构
在Node中,内建模块的内部结构如下:
struct node_module_struct{
int version;
void *dso_handle;
const char *filename;
void (*register_func) (v8:Handle<v8::Object> target);
const char *modname;
}
每一个内建模块在定义之后,都通过NODE_MODULE宏将模块定义到node的命名空间中,模块的具体初始化方法挂载为结构的register_func成员。
#define NODE_MODULE(modname, regfunc)
extern "c" {
NODE_MODULE_EXPORT node::node_module_struct modname ## _module =
{
NODE_STANDARD_MODULE_STUFF,
regfunc,
NODE_STRINGIFY(modname)
};
}
node_extension.h文件将散列的内建模块统一放进一个叫node_module_list的数组中,Node也提供了get_buildin_module()方法从node_module_list数组中取出这些模块。
内建模块的优势在于,首先它们本身有C/C++编写,其性能优于JavaScript脚本语言;其次在进行文件编译时,它们被编译进二进制文件。一旦Node开始执行,它们被直接加载进内存中,无须做标识符定位、文件定位、编译等过程,直接可执行。
(2)内建模块的导出
在Node的所有模块类型中,存在着一种依赖层级关系,即文件模块依赖于JavaScript核心模块,JavaScript核心模块依赖于内建模块。通常不推荐文件模块直接调用内建模块。如需调用,直接调用核心模块即可,因为其实核心模块中基本封装了内建模块。那么内建模块是如何将内部变量和方法导出,以供外部JavaScript核心模块调用的呢?Node在启动时,会生成一个全局变量process,并提供Binding()方法来协助加载内建模块。Binding()的实现代码在src/node.cc中,具体如下所示。
static Handle<Value> Binding(const Argument& args){
HandleScope scope;
Local<String> module = args[0]->ToString();//获取模块标识符
String::Utf8Value module_v(module);
if(binding_cache.isEmpty()){
binding_cache = Persistent<Object>::New(Object::New());
}
Local<Object> exports;
if(binding_cache->Has(module)){
exports = binding_cache->Get(module).ToObject();
return scope.Close(exports);
}
char buf[1024];
snprintf(buf, 1024, "Binding %s","*module_v");
uint32_t l = module_load_list->Length();
module_load_list->Set(l, String::New(buf));
//从node_module_list中取出标识符对应的node_module_struct对象
if((modp = get_buildin_module(*module_v)) != NULL){
exports = Object::New();
//调用node_module_strut对象的register_func()函数填充exports,返回给调用方
modp->register_func(exports);//
binding_cache->Set(module, exports);
}else if(!strcmp(*module_v, "constants")){
exports = Object::New();
DefineConstants(exports);
binding_cache->Set(module, exports);
#ifdef __POSIX__
}else if(!strcmp(*module_v, "io_watcher")){
exports = Object::New();
IOWatcher::Initialize(exports);
binding_cache->Set(module, exports);
#endif
}else if(!strcmp(*module_v, "natives")){
exports = Object::New();
DefineJavaScript(exports);
binding_cache->Set(module, exports);
}else{
return ThrowException(Exception::Error(String::New("No such module")));
}
return scope.close(exports);
}
在加载内建模块时,我们先创建一个exports空对象,然后调用get_buildin_module方法取出内建模块对象,通过执行register_func()填充exprots对象,最后将exports对象按模块名缓存,并返回调用方。
(3)核心模块的引入流程
从图1所示的os原生模块的引入流程可以看出,为了符合CommonJs模块规范,从JavaScript到C/C++的过程相当复杂,get_buildin_module(‘node_os’)中的node_os是通过宏命令NODE_MODULE(node_os, reg_func)注册到node命名空间,同时node_extension.h将内建模块放在node_module_list数组中,get_buidin_module实际上是根据参数从node_module_list取出内建模块。但是对于用户而言,require()十分简洁、友好。
图1 os原生模块的引入流程
(4)编写核心模块
核心模块被编译进二进制文件需要遵循一定规则。作为Node的使用者,尽管几乎没有机会参与核心模块的开发,但是了解如何开发核心模块有助于我们更加深入地了解Node。下面我们以C/C++模块为例演示如何编写内建模块。为了便于理解,我们先编写一个极其简单的JavaScript版本的原型,这个方法返回一个Hello World!字符串。
exports.sayHello = function(){
return “Hello world!”;
};
编写内建模块通常分两步完成,编写头文件和编写C/C++文件。
(1)将以下代码保存在node_hello.h,存放到Node的src目录下:
#ifndef NODE_HEELO_H_
#define NODE_HELLO_H_
#include<v8.h>
namespace node{
v8::Handle<v8::Value> SayHello(const v8::Argument& args);
}
#endif
(2)编写node_hello.cc,并存储在src目录下:
#include<node.h>
#include<node_hello.h>
#include<v8.h>
namespace node{
using namespace v8;
Handle<Value> SayHello(const Argument& args){
HandleScope scope;
return scope.Close(String::New("Hello world!"));
}
void Init_Hello(Handle<Object> target){
target->Set(String::NewSymbol("sayHello"), FunctionTemplate::New(SayHello)->GetFunction());
}
}
NODE_MODULE(node_hello, node::Init_Hello);
以上两步完成了内建模块的编写,但是真正让Node认为它是内建模块,还需要更改src/node_extensions.h,在NODE_EXT_LIST_END前添加NODE_EXT_LIST_ITEM(node_hello),以将node_hello模块添加到node_module_list数组中。
其次,还需要让编写的两份代码编译进执行文件,同时需要更改Node的项目生成文件node.gyp,并在’target_name’:’node’节点的sources中添加上新编写的两个文件。然后编译整个Node项目。编译和安装后,直接在命令行运行以下代码,将会得到期望的效果。
$node
> var hello = process.binding(‘hello’);
Undefined
> hello.sayHello();
‘Hello world!’
C/C++扩展模块对于前端工程师来说或许比较生疏和晦涩,但是如果了解它,那么在出现性能瓶颈时将会带来极大的帮助。
C/C++扩展模块属于文件模块的一类。前面讲述文件模块的编译部分时提到,C/C++模块通过预先编译成.node文件,然后调用process.dlopen()方法加载执行。在这里,我们将分析整个C/C++扩展模块的编写、编译、加载、导出的过程。
在开始编写扩展模块之前,需要强调的一点是,Node的原生模块一定程度上是跨平台的,其前提条件是源代码可以支持在window和nix上编译,其中windows需要通过Visual C++的编译器编译为动态链接库文件(.dll),nix通过g++/gcc等编译器编译成动态链接共享对象文件(.so)。这里有一个让人疑惑的地方,那就是引用加载时却是.node文件。其实.node的扩展名只是为了看起来更自然一点,不会因为平台差异产生不同的感觉。实际上,在windows下它是一个.dll文件,在*nix下则是一个.so文件。为了实现跨平台,dlopen()方法在内部实现时区分了平台,分别用加载.so和.dll的方式。下图是扩展模块在不同平台编译和加载的详细过程。
图2 扩展模块不同平台上的编译和加载过程
1.3.1 C/C++扩展模块的编写
在介绍C/C++内建模块时,其实已经介绍了C/C++模块的编写方式。普通的扩展模块与内建模块的区别在于无须将源代码编译进Node,而是通过dlopen()方法动态加载。所以在编写普通扩展模块时,无须将源代码写入node命名空间,也不需要提供头文件。下面将通过一个例子来介绍C/C++扩展模块的编写。
它的JavaScript原型代码与前面的例子一样:
exports.sayHello = function(){
return “Hello world!”;
};
新建hello目录作为自己的项目位置,编写hello.cc并将其存储在src目录下,相关代码如下:
#include<node.h>
#include<node_hello.h>
#include<v8.h>
namespace node{
using namespace v8;
Handle<Value> SayHello(const Argument& args){
HandleScope scope;
return scope.Close(String::New("Hello world!"));
}
void Init_Hello(Handle<Object> target){
target->Set(String::NewSymbol("sayHello"), FunctionTemplate::New(SayHello)->GetFunction());
}
}
NODE_MODULE(node_hello, node::Init_Hello);
C/C++扩展模块与内建模块的套路一样,将方法挂载在target对象上,然后通过NODE_MODULE声明即可。
由于不像编写内建模块那样将对象声明到node_module_list链表中,所以无法被认作一个原生模块,只能通过dlopen()来动态加载,然后导出给JavaScript调用。
1.3.2 C/C++扩展模块的编译
在GYP工具的帮助下,C/C++扩展模块的编译很容易,无须为每个平台编写不同的项目编译文件。写好.gyp项目文件是除编码外的头等大事,然而无须担心此事太难,因为.gyp项目文件是足够简单的。Node-gyp约定.gyp文件为binding.gyp,其内容如下:
{
'targets':[
{
'target_name' : 'hello',
'sources' : [
'hello.cc'
],
'conditions' : [
['OS == "win',
{
'libraries' : ['-lnode.lib']
}
]
]
}
]
}
然后调用:node-gyp configure,会在当前目录创建build目录,并生成系统相关的项目文件。在*nix平台下,build目录中会出现Makefile文件,在windows下会生成vcxproj文件。继续执行如下代码:node-gyp build,编译过程会根据平台不同,分别通过make或vcbuild进行编译。编译完成后,hello.node文件会生成在build/Release目录下。
1.3.3 C/C++扩展模块的加载
得到hello.node结果文件后,如何调用扩展模块其实前面已经提及。require()方法通过解析标识符、路径分析、文件定位,然后加载执行即可。下面的代码引入前面编译得到的.node文件,并调用执行其中的方法:
var hello = require('./build/Release/hello.node');
Console.log(hello.sayHello());
以上代码保存为hello.js,调用node hello.js命令即可得到如下输出结果:Hello world!
对于以.node为扩展名的文件,Node将会调用process.dlopen()方法来加载文件:Module._extension[‘.node’] = process.dlopen;
如图3,require()方法在引入.node文件的过程中,实际上经历了4个层面的调用。
加载.node文件实际上经历了两个步骤,第一个步骤是调用uv_dlopen()方法去打开动态链接库,第二个步骤是调用uv_dlsm()方法找到动态链接库通过NODE_MODULE宏定义的register_func方法地址。这两个过程都是通过libuv库进行封装的;在*nix平台实际上调用的是dlfcn.h头文件定义的dlopen()和dlsym()两个方法;在windows平台则通过LoadLibraryExW()和GetProcAddress()这两个方法实现的,它们分别加载.so和.dll文件(实际为.node文件)。
图3 require()引入node文件的过程
由于编写模块时通过NODE_MODULE将模块定义为node_module_struct结构,所以在获取函数地址之后,将它映射为node_module_struct结构几乎是无缝对接的。接下来的过程就是将传入的exports对象作为实参运行,将C++中定义的方法挂载在exports对象上,然后调用者就可以轻松调用了。
结束文件模块、核心模块、内建模块、C/C++扩展模块等阐述之后,有必要明确一下各种模块之间的调用关系,如图。
图4 模块之间的调用关系
C/C++内建模块属于最底层的模块,它属于核心模块,主要提供API给JavaScript核心模块和第三方JavaScript文件模块调用。JavaScript核心模块主要扮演的职责有两类:一类是作为C/C++内建模块的封装层和桥接层,供文件模块调用;一类是纯粹的功能模块,它不需要跟底层打交道,但是又十分重要。文件模块通过由第三方编写,包括普通JavaScript模块和C/C++扩展模块,主要调用方向是普通JavaScript模块调用扩展模块。
Node组织了自身的核心模块,也使得第三方文件模块可以有序地编写和使用。但是在第三方模块中,模块与模块仍然是散列在各地的,相互之间不能直接引用。而在模块之外,包和NPM则是将模块联系起来的一种机制。
CommonJs的包规范的定义其实十分简单,它由包结构和包描述文件两个部分组成,前者是用于组织包中的各种文件,后者则用于描述包的相关信息,以供外部读取分析。
包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。完全符合CommonJs规范的包目录应该包含以下这些文件。
可以看到,CommonJs包规范从文档、测试等方面都做过考虑。当一个包完成向外公布时,用户看到单元测试和文档的时候,会给他们一种踏实可靠的感觉。
包描述文件用于表达非代码相关的信息,它是一个JSON格式的文件——package.json,位于包的根目录下,是包的重要组成部分。而NPM所有行为都跟包描述文件的字段息息相关。下面是express框架的package.json文件。
{
"name": "express",
"description": "Fast, unopinionated, minimalist web framework",
"version": "4.13.3",
"author": {
"name": "TJ Holowaychuk",
"email": "tj@vision-media.ca"
},
"contributors": [
{
"name": "Aaron Heckmann",
"email": "aaron.heckmann+github@gmail.com"
},
{
"name": "Ciaran Jessup",
"email": "ciaranj@gmail.com"
},
{
"name": "Douglas Christopher Wilson",
"email": "doug@somethingdoug.com"
},
{
"name": "Guillermo Rauch",
"email": "rauchg@gmail.com"
},
{
"name": "Jonathan Ong",
"email": "me@jongleberry.com"
},
{
"name": "Roman Shtylman",
"email": "shtylman+expressjs@gmail.com"
},
{
"name": "Young Jae Sim",
"email": "hanul@hanul.me"
}
],
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/strongloop/express.git"
},
"homepage": "http://expressjs.com/",
"keywords": [
"express",
"framework",
"sinatra",
"web",
"rest",
"restful",
"router",
"app",
"api"
],
"dependencies": {
"accepts": "~1.2.12",
"array-flatten": "1.1.1",
"content-disposition": "0.5.0",
"content-type": "~1.0.1",
"cookie": "0.1.3",
"cookie-signature": "1.0.6",
"debug": "~2.2.0",
"depd": "~1.0.1",
"escape-html": "1.0.2",
"etag": "~1.7.0",
"finalhandler": "0.4.0",
"fresh": "0.3.0",
"merge-descriptors": "1.0.0",
"methods": "~1.1.1",
"on-finished": "~2.3.0",
"parseurl": "~1.3.0",
"path-to-regexp": "0.1.7",
"proxy-addr": "~1.0.8",
"qs": "4.0.0",
"range-parser": "~1.0.2",
"send": "0.13.0",
"serve-static": "~1.10.0",
"type-is": "~1.6.6",
"utils-merge": "1.0.0",
"vary": "~1.0.1"
},
"devDependencies": {
"after": "0.8.1",
"ejs": "2.3.3",
"istanbul": "0.3.17",
"marked": "0.3.5",
"mocha": "2.2.5",
"should": "7.0.2",
"supertest": "1.0.1",
"body-parser": "~1.13.3",
"connect-redis": "~2.4.1",
"cookie-parser": "~1.3.5",
"cookie-session": "~1.2.0",
"express-session": "~1.11.3",
"jade": "~1.11.0",
"method-override": "~2.3.5",
"morgan": "~1.6.1",
"multiparty": "~4.1.2",
"vhost": "~3.0.1"
},
"engines": {
"node": ">= 0.10.0"
},
"files": [
"LICENSE",
"History.md",
"Readme.md",
"index.js",
"lib/"
],
"scripts":{"test":"mocha --require test/support/env --reporter spec --bail --check-leaks test/ test/acceptance/","test-ci":"istanbul cover node_modules/mocha/bin/_mocha --report lcovonly -- --require test/support/env --reporter spec --check-leaks test/ test/acceptance/","test-cov":"istanbul cover node_modules/mocha/bin/_mocha -- --require test/support/env --reporter dot --check-leaks test/ test/acceptance/","test-tap":"mocha --require test/support/env --reporter tap --check-leaks test/ test/acceptance/"},"gitHead":"ef7ad681b245fba023843ce94f6bcb8e275bbb8e","bugs":{"url":"https://github.com/strongloop/express/issues"},"_id":"express@4.13.3","_shasum":"ddb2f1fb4502bf33598d2b032b037960ca6c80a3","_from":"express@*","_npmVersion":"1.4.28","_npmUser":{"name":"dougwilson","email":"doug@somethingdoug.com"},"maintainers":[{"name":"tjholowaychuk","email":"tj@vision-media.ca"},{"name":"jongleberry","email":"jonathanrichardong@gmail.com"},{"name":"dougwilson","email":"doug@somethingdoug.com"},{"name":"rfeng","email":"enjoyjava@gmail.com"},{"name":"aredridel","email":"aredridel@dinhe.net"},{"name":"strongloop","email":"callback@strongloop.com"},{"name":"defunctzombie","email":"shtylman@gmail.com"}],"dist":{"shasum":"ddb2f1fb4502bf33598d2b032b037960ca6c80a3","tarball":"http://registry.npmjs.org/express/-/express-4.13.3.tgz"},"directories":{},"_resolved":"https://registry.npmjs.org/express/-/express-4.13.3.tgz","readme":"ERROR: No README data found!"}
作者:MIG无线合作开发部实习生marcozhguo
电子邮箱:446882229@qq.com
参考资料:《深入浅出Nodejs》
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。