美文网首页
Node的特点和模块机制

Node的特点和模块机制

作者: Upcccz | 来源:发表于2019-10-16 20:50 被阅读0次

    异步I/O

    在Node中,我们可以从语言层面很自然的进行一并I/O操作,每个调用之间无须等待之前的I/O调用结束,在编程模型上可以极大提升效率。例如:读取本地两个文件的耗时取决于最慢的那个文件读取的耗时,而不是两个任务的耗时之和。

    事件与回调函数

    事件的编程方式具有轻量级、松耦合、只关注事务点等优势,但是在多个异步任务的场景下,事件与事件之间各自独立,如何协作是一个问题。

    单线程

    Node保持了JavaScript在浏览器中单线程的特点。而且在Node中,JavaScript与其余线程是无法共享任何状态的。单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。

    单线程的弱点具体有以下3方面

    • 无法利用多核CPU
    • 错误会引起整个应用退出,应用发健壮性值得考验。
    • 大量计算占用CPU导致无法继续调用异步I/O

    在Node中,长时间的CPU占用也会导致后续的异步I/O发不出调用,已完成的异步I/O的回调函数也会得不到及时执行。

    Node采用了与Web Workers相同的思路来解决单线程中大计算量的问题:child_process,子进程的出现,意味着Node可以从容地应对单线程在健壮性和无法利用多核CPU方面的问题,通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息来传递结果,这可以很好地保持应用模型的简单和低依赖。通过Master-Worker的管理方式,也可以很好地管理各个工作进程,以达到更高的健壮性。

    跨平台

    兼容Windows和*nix平台主要得益于Node在架构层面的改动,它在操作系统与Node上层模块系统之间构建了一层平台层架构,即libuv。目前,libuv已经成为许多系统实现跨平台的组件,通过良好的架构,Node的第三方C++模块也可以借助libuv实现跨平台,除了没有保持更新的C++模块外。

    Node的应用场景

    擅长擅长I/O密集型的应用场景,也能够应付CPU密集型应用。能与遗留系统和平共处,适合用于开发分布式应用。

    I/O密集型

    Node擅长I/O密集型的应用场景,Node面向网络且擅长并行I/O,能够有效地组织起更多的硬件资源,从而提供更多好的服务。I/O密集的优势主要在于Node利用时间循环的处理能力,而不是启动每一个线程为每一个请求服务,资源占用极少。

    是否不擅长CPU密集型业务

    CPU密集型应用给Node带来的挑战主要是:由于JavaScript单线程的原因,如果长时间的运行的计算(比如大循环),将会导致CPU时间片不能释放,使得后续I/O无法发起。

    但是适当调整和分解大型运算任务为多个小任务,使得运算能够适时释放,不阻塞I/O调用的发起,这样既可同时享受到并行异步I/O的好处,又能充分利用CPU。

    CommonJS规范

    CommonJS对模块的定义十分简单,主要分为模块引用、模块定义和模块标识3个部分。

    模块引用

    var math = require('math');
    // 这个方法接受模块标识。以此引入一个模块的API到当前上下文中
    

    模块定义

    exports对象用于导出当前模块的方法或者变量,并且它是唯一导出的出口,在模块中,还存在一个module对象,他代表模块自身,而exports是module的属性。在Node中,一个文件就是一个模块,将方法挂载在exports对象上作为属性即可定义导出的方式。

    // math.js
    
    exports.add = function () {
      var sum = 0,
        i = 0,
        args = arguments,
        l = args.length;
      while(i < l) {
        sum += args[i++]
      }
      return sum;
    }
    
    // app.js
    var math = require('./math.js');
    exports.increment = function(val) {
      return math.add(val, 1);
    }
    

    模块标识

    模块标识其实就是传递require()方法的参数,它必须是符合小驼峰命名的字符串,或者以. 、..开头的相对路径,或者绝对路径。它可以没有文件名后缀.js。

    Node的模块实现

    Node中的实现并非完全按照规范实现,在Node中引入模块需要经历如下3个步骤,路径分析、文件定位、编译执行。

    在Node中,模块分为两类,一类是Node提供的模块,称为核心模块;另一类是用户编写的模块,称为文件模块。核心模块在Node源代码的编译过程中,编译进了二进制执行文件,所有在引入核心模块时,文件定位和编译执行两个步骤可以省略,而且在路径分析中优先判断,所有的核心模块的加载速度是最快的。而文件模块则是在运行时动态加载,需要完整的三个步骤。

    不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先方式,这是第一优先级,但是核心模块的缓存检查依然先于文件模块的缓存检查。

    路径分析和文件定位

    路径分析:在Node实现中,是基于标识符进行模块查找的,标识符主要分为核心模块(fs,http,path等)、.或..的相对路径文件模块、以 / 开头的绝对路径文件模块、非路径形式的文件模块(自定义模块)。

    核心模块的优先级仅次于缓存加载,加载速度也最快,如果试图加载一个与核心模块标识符相同的自定义模块是不会成功的。

    路径形式的文件模块,在分析时都会转化为真实路径,并将编译执行后的结果放到缓存中,便于二次加载。因为在已经明确了文件位置,在查找过程中可以节约大量时间。

    自定义模块可能是一个文件或者包的形式,这种模块的查找最费时。会从当前目录沿着路径向上逐级递归,直到根目录下的node_modules目录直到找到为止,可以看出,当前文件的路径的路径越深,查找耗时就会越多,这就是自定义模块速度是最慢的原因。

    文件定位:commonJS模块规范允许在标识符中不包含文件扩展名,这种情况下,Node.js会按照.js .json .node的次序补充扩展名,依次查找。在查找的过程中,会调用fs模块同步阻塞地判断文件是否存在,所以如果是.node 和 .json文件应该带上扩展名。

    require()分析文件扩展名很可能找不到对应文件,但是却得到一个目录,此时Node会将目录当做一个包来处理。首先会在当前目录下查找package.json文件,解析其中的main属性指定的文件名进行定位,如果main 属性指定的文件名错误,或者没有package.json文件,Node会将index当做默认文件名,然后依次查找index.js index.json index.node。

    模块编译

    每一个编译成功的模块都会将其文件路径作为索引存在Module._cache对象上,以提高二次引入的性能。根据不同的文件扩展名会调用不同的读取方式,这些读取方法存储在Module._extensions上并赋值给require()的extensions属性,包含对应的用来编译.js .json .node的三个函数,如果不是这三种文件都会被当做.js文件载入。.node文件是用C/C++编写的扩展文件,通过dlopen()加载最后通过.node对应的函数编译。

    • js文件的编译

    会对js文件进行包装,(function (exports, require, module, __filename, __dirname) { /* 内容 */})。所以有些模块中相关的API有的是全局的,有的却是以形参方式传入的,在执行之后module的exports属性会返回给调用者,exports属性上的任何属性和方法都可以被外部调用到,但是模块中其余的变量和属性则不可直接被调用。

    exports对象是通过形参的方式传入的,直接赋值形参会改变形参的引用,但是并不能改变作用域外的值,不再引用到module.exports。而导出的永远是module.exports对象。

    • .node文件的编译

    .node的模块文件并不需要编译,因为它是编写C/C++模块之后编译生成的,所以这里只有加载和执行的过程,在执行的过程中,模块的exports对象与.node模块产生联系,然后返回给调用者。

    • json文件的编译

    Node利用fs模块同步读取JSON文件的内容之后,调用JSON.parse()方法得到对象,然后将他赋给module的exports对象,以供外部使用。如果你定义了一个JSON文件作为配置,那么直接调用require()引入即可,还可以享受模块缓存的便利。

    核心模块

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

    JavaScript核心模块的编译过程

    JS文件会先转为C/C++代码,将所有的JS代码转换为C++里的数组,JS代码以字符串的形式存储在node命名空间中,是不可直接执行的,在启动Node的进程时,JS代码直接加载进内存中。JS核心模块会在标识分析后直接定位到内存中,所以查找速度比普通文件模块要快。

    与普通文件模块一样,JS核心模块文件也米诶有require、module、exports这些变量,也需要经历头尾包装,与文件模块有区别的地方在于:获取源代码的方式(核心模块是从内存中加载的)以及缓存执行结果的位置。

    JS核心模块的源文件通过process.binding('natives')取出,编译成功的模块缓存到NativeModule._cache对象上,普通文件模块则缓存到Module._cache对象上。

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

    在核心模块中,有些模块全部由C/C++编写,有些模块则是由C/C++主内完成核心部分,JavaScript主外实现封装,这样的模式是Node提高性能的常见方式。那些由纯C/C++编写的部分统一被称为内建模块。因为它们都不被用户直接调用。

    在Node中常用的buffer,crypto,evals,fs,os等模块都只是部分通过C/C++编写。

    内建模块统一放进了一个叫node_module_list的数组中,Node提供了get_builtin_module()方法从node_module_list 数组中取出这些模块。

    内建模块的优势在于:性能好,Node执行的时候直接就加载进内存中,执行快。

    在Node的模块类型中存在着一种依赖关系,文件模块节能依赖核心模块,核心模块可能依赖内建模块。但是,不推荐文件模块直接调用内建模块,因为在核心模块中基本都封建了内建模块,当然,内建模块也需要被导出以供外部的JavaScript核心模块来调用。

    Node在启动时,会生成一个全局变量process,并提供binding()方法协助加载内建模块(JS核心模块的源文件被转换为C/C++数组存储后也是通过binding()方法取出的)。在加载内建模块时,我们先创建一个exports空对象,然后调用get_builtin_module()取出内建模块对象,通过执行register_func()填充exports对象,最后将exports对象按模块名缓存,并返回给调用方完成导出。

    模块调用栈

    C/C++内建模块属于最底层的模块,它属于核心模块,主要提供API给JavaScript核心模块和第三方JavaScript文件模块调用

    JavaScript核心模块主要扮演的职责有两类:一类是作为C/C++内建模块的封装层和桥阶层,供文件模块调用;一类是纯粹的功能模块,它不需要和底层打交道,但是又十分重要。

    文件模块通常由第三方编写,包括普通JavaScript模块和C/C++扩展模块,主要调用方向为普通JavaScript模块调用拓展模块。

    包与NPM

    包实际上是一个存档文件,即一个目录直接打包为.zip或tar.gz格式的文件,安装后解压还原为目录。

    完全符号CommonJS规范的包目录应该包含如下这些文件。

    • package.json:包描述文件
    • bin:用于存放二进制文件的目录
    • lib:用于存放JavaScript代码的目录
    • doc:用于存放文档的目录
    • test:用于存放单元测试用例的代码

    包描述文件与NPM

    CommonJS为package.json文件定义了如下一些必需的字段

    • name:包名,需要有小写的字母和数字组成,可以包含.、_ 和 -,但不允许出现空格
    • description: 包简介
    • version: 版本号
    • keywords: 关键字
    • maintainers: 包维护者列表,每个维护者者由name、email和web这3个属性组成。
    • contributors:贡献者列表,他的格式与维护者列表相同。
    • bugs:一个可以反馈bug的网页地址或邮件地址
    • licenses:当前包所使用的许可证列表,格式:"licenses": [{ "type": "GPLv2", "url": "http://www.example.com/licenses/gpl.html", }]
    • repositories:托管源代码的位置列表
    • dependencies:使用当前包所需要依赖的包列表。NPM会通过这个属性帮助自动加载依赖包
    • homepage:当前包的网站地址
    • os:操作系统的支持列表
    • cpu:cpu架构的支持列表,有效的架构名称有arm、mips、ppc、sparc、x86和x86_64。和os一样,如果列表为空,则不对CPU架构做任何假设。
    • engine:支持的JavaScript引擎列表
    • builtin:标志当前包是否是内建在底层系统的标准组件
    • directories:包目录说明
    • implements:实现规范的列表,标志当前包实现了CommonJS的哪些规范
    • scripts:脚本说明对象,它主要被包管理器用来安装、编译、测试和卸载包

    在包描述文件的规范中,NPM实际需要的字段要有name、version、description、keywords、 repositories、author、bin、main、scripts、engines、dependencies、devDependencies。

    与包规范的区别在于多了 author、bin、main、devDependencies这4个字段

    • author:包作者
    • bin:一些包作者希望包可以作为命令行工具使用,配置好bin字段后,通过npm install package_name -g命令可以将脚本添加到执行路径中,之后可以在命令行中直接执行,-g命令安装的模块包称为全局模式。
    • main:模块引入方法require()在引入包的时候,会优先检查这个字段,并将其作为包中其余模块的入口。如果不存在这个字段,require()方法会查找包目录下的index.js、index.node、index.json文件作为默认入口。
    • devDependencies:一些模块只在开发时需要依赖,配置这个属性,可以提示包的后续开发者安装依赖包。

    NPM常用功能

    安装依赖包是NPM最常见的用法,它的执行语句是npm install express。执行该命令后,NPM会在当前目录下创建node_modules目录,然后在node_modules目录下创建express目录,接着将包解压到这个目录下。安装好依赖包后,直接在代码中调用require('express');即可引入该包。require()方法在做路径分析的时候会通过模块路径查找到express所在的位置。模块引入和包的安装着两个步骤是相辅相承的。

    如果包中含有命令行工具,那么需要执行npm install packageName -g命令进行全局模式安装。它并不意味着这可以在任何地方通过require()来引用到它,实际上-g是将一个包安装为全局可用的可执行命令。

    如果Node可执行文件的位置是/usr/local/bin/node,那么模块目录就是/usr/local/lib/node_modules,最后,通过软链接的方式将bin字段配置的可执行文件链接到Node的可执行目录下。

    从非官方源安装

    • npm install packageName --registry=http://xxxx 安装某个包是使用镜像源
    • npm config set registry http://xxx 修改官方的默认源,改为某镜像源

    NPM钩子命令

    package.json中scripts字段的提出就是让包在安装或者卸载等过程中提供钩子机制,示例如下:

    "scripts": {
      "preinstall": "preinstall.js",
      "install": "install.js"
      "uninstall": "uninstall.js"
      "test": "test.js"
    }
    // 执行npm install <packageName> 时,preinstall指向的脚本将会被加载执行
    // 然后install 指向的脚本执行
    // 当执行 npm uninstall <packageName>卸载包时,uninstall执行的脚本会被执行
    // test执行的脚本一般包含测试用例,方便用户运行测试用例,以便检验包是否稳定可靠
    

    发布包

    编写完模块后,

    • npm init 生成package.json文件
    • npm adduser 注册包仓库账号
    • npm publish 上传包。

    管理包权限

    • npm own ls <packageName> 查看包的所有者
    • npm owner add <user> <packageName> 添加包的所有者
    • npm owner rm <user> <packageName> 删除包的所有者

    分析包

    在使用NPM的过程中,或许你不能确认当前目录下能否通过require()顺利引入想要的包,这里可以使用npm ls分析包,这个命令可以为你分析出当前路径下能够通过模块路径找到的所有包,并生成依赖树。

    NPM潜在问题

    潜在的问题在于,在NPM平台上,每个人都可以分享包到平台上,基于开发人员水平不一, 上面的包的质量也良莠不齐。另一个问题则是,Node代码可以运行在服务器端,需要考虑安全问题。

    对于包的使用者􏳟言,包质量和安全问题需要作为是否采纳模块的一个判断条件。

    尽管NPM没有硬性的方式去评判一个包的质量和好坏,好在开源社区也有它内在的健康发展机制,那就是口碑效应,其中NPM首页上的依赖榜可以说明模块的质量和可靠性。第二个可以考查质量的地方是GitHub,NPM中大多的包都是通过GitHub托管的,模块项目的观察者数量和分支数量也能从侧面反映这个模块的可靠性和流行度。第三个可以考量包质量的地方在于包中的测试用例和文档的状况,一个没有单元测试的包基本上是无法被信任的, 没有文档的包,使用者使用时内心也是不踏实的。

    前后端共用模块

    纵观Node的模块引入过程,几乎全都是同步,尽管与Node强调异步的行为有些相反,但它是合理的。但是如果前端模块也采用同步的方式来引入,那将会在用户的体验上造成很大的问题,UI在初始化过程中需要花费很多的时间来等待脚本加载完成。

    AMD、CMD规范

    CommonJS规范加载模块是同步的,只有加载完成,才能执行后面的操作。AMD规范是非同步加载模块,允许指定回调函数。

    由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范

    对于依赖的模块,CMD是延迟执行,AMD是提前执行

    CMD推崇依赖就近,AMD推崇依赖前置

    //CMD
    define(function(require, exports, module) {
       let a = require('./a'); 
       a.doSomething();
       ···
       let b = require('./b'); // 依赖可以就近书写   
       b.doSomething();
       ... 
    })
    
    // AMD 默认推荐的是
    define(['./a', './b'], function(a, b) {
      // 依赖必须一开始就写好    
      a.doSomething()   
      ...
      b.doSomething()   
      ...
    })
    

    CMD与AMD的主要区别在于定义模块和依赖引入两部分。

    相关文章

      网友评论

          本文标题:Node的特点和模块机制

          本文链接:https://www.haomeiwen.com/subject/kdrcuctx.html