深入浅出 Nodejs( 三 ):Nodejs 核心模块机制

作者:郭泽豪

导语

本篇教程关于Nodejs的核心模块机制,具体讲Nodejs核心模块的原理、C/C++扩展模块的原理、包、模块调用栈以及NPM。

本章的重点内容:

  • JavaScript核心模块的编译过程
  • C/C++核心模块的编译过程
  • C/C++扩展模块的编写、编译、加载过程
  • 模块调用栈
  • NPM

一、Nodejs核心模块的原理

前面提及,Node的核心模块在编译成可执行文件的过程中被编译进了二进制文件。核心模块其实分为C/C++编写的和JavaScript编写的两部分,其中C/C++文件存在在Node项目的src目录下,JavaScript文件存放在lib目录下。

1.1 JavaScript核心模块的编译过程

在编译所有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 = {};

1.2 C/C++核心模块的编译过程

在核心模块中,有些模块全部由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!’

1.3 C/C++扩展模块

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模块调用扩展模块。

三、包与NPM

Node组织了自身的核心模块,也使得第三方文件模块可以有序地编写和使用。但是在第三方模块中,模块与模块仍然是散列在各地的,相互之间不能直接引用。而在模块之外,包和NPM则是将模块联系起来的一种机制。

CommonJs的包规范的定义其实十分简单,它由包结构和包描述文件两个部分组成,前者是用于组织包中的各种文件,后者则用于描述包的相关信息,以供外部读取分析。

3.1 包结构

包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。完全符合CommonJs规范的包目录应该包含以下这些文件。

  • Package.json:包描述文件
  • Bin: 用于存放可执行二进制文件的目录
  • Lib:用于存放JavaScript代码的目录
  • Doc: 用于存放文档的目录
  • Test:用于存放单元测试用例的代码

可以看到,CommonJs包规范从文档、测试等方面都做过考虑。当一个包完成向外公布时,用户看到单元测试和文档的时候,会给他们一种踏实可靠的感觉。

3.2 包描述文件和NPM

包描述文件用于表达非代码相关的信息,它是一个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》

原创声明,本文系作者授权云+社区-专栏发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏自动化测试实战

HTML第二课——css

2337
来自专栏小李刀刀的专栏

Laravel 4 小技巧两则

用 Laravel 作为 PHP 开发框架很久了,但是有些官方文档中没有覆盖到的地方,每隔一段时间又会忘记。最近做了一点简单的整理,顺便记录下来备忘。 1. R...

3465
来自专栏社区的朋友们

深入浅出 Nodejs( 二 ):Nodejs 文件模块机制

本篇教程关于Nodejs的文件模块机制,具体讲CommonJs规范以及Nodejs文件模块的实现原理。

2421
来自专栏柠檬先生

Sass 基础(八)

@import       Sass 支持所有css 的@规则,以及一些Sass 专属的规则,也被称为“指令(directive)”.这些规则在Sass 中具...

1809
来自专栏hotqin888的专栏

HydroCMS规范、图集查询系统设计

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/hotqin888/article/det...

642
来自专栏大内老A

编写T4模板进行代码生成无法避免的两个话题:"Assembly Locking"&"Debug"

在这之前,我写了一系列关于代码生成和T4相关的文章,而我现在也试图将T4引入我们自己的开发框架。在实践中遇到了一些问题,也解决了不少问题。如果你也在进行T4相关...

1767
来自专栏练小习的专栏

条件注释

下面是条件注释的语法 gt /Greater than/大于/<!--[if gt IE 5.5]> gte /Greater than or equal t...

17010
来自专栏finleyMa

Chrome 功能总结

原文:https://developers.google.com/web/updates/2017/08/devtools-release-notes#awai...

792
来自专栏喔家ArchiSelf

一文贯通python文件读取

不论是数据分析还是机器学习,乃至于高大上的AI,数据源的获取是所有过程的入口。 数据源的存在形式多为数据库或者文件,如果把数据看做一种特殊格式的文件的话,即所有...

572
来自专栏tkokof 的技术,小趣及杂念

HGE系列之七 管中窥豹(图形界面)

这次的HGE源码之旅,让我们来看看HGE的图形用户界面(GUI)的实现,话说电脑技术发展至今,当年轰动一时的图形用户界面,而今早已司空见惯,想来不得不感叹一下...

701

扫码关注云+社区