最初做Node的目的是什么?

为什么是JavaScript?

  • 事件驱动
  • 非阻塞I / O

Ryan Dahl也曾评估过使用C、Lua、Haskell、Ruby等语言作为备选实现,得出以下结论:

  • C的开发门槛高,可以预见不会有太多的开发者能将它用于业务开发
  • Ryan Dahl觉得自己还不足够玩转Haskell,所以舍弃它
  • Lua自身已经含有很多阻塞 I / O 库,为其构建非阻塞 I / O 库不能改变开发者使用习惯
  • Ruby的虚拟机性能不佳

JavaScript的优势:

  • 开发门槛低
  • 在后端领域没有历史包袱
  • 第二次浏览器大战渐渐分出高下,Chrome浏览器的JavaScript引擎V8摘得性能第一的桂冠

Node给JavaScript带来的意义

  • 浏览器通过事件驱动来服务界面上的交互
  • Node通过事件驱动来服务 I / O
  • 随心所欲地访问本地文件
  • 搭建WebSocket服务端
  • 连接数据库,进行业务研发
  • 像Web Worker一样玩转多进程

Node的特点

异步I / O

  • 以读取文件为例

var fs = require('fs');

fs.readFile('/path', function (err, file) {
 console.log('读取文件完成')
});

console.log('发起读取文件'); 

Node中,绝大多数操作都以异步的方式进行调用,Ryan Dahl排除万难,在底层构建了很多异步I / O的API,从文件读取到网络请求等。使开发者很已从语言层面很自然地进行并行I / O操作,在每个调用之间无需等待之前的I / O调用结束,在编程模型上可以极大提升效率

事件与回调函数

事件

随着Web2.0的到来,JavaScript在前端担任了更多的职责,时间也得到了广泛的应用。将前端浏览器中广泛应用且成熟的事件与回到函数引入后端,配合异步I / O ,可以很好地将事件发生的时间点暴露给业务逻辑。

  • 服务端例子
var http = require('http');
var querystring = require('querystring');

// 侦听服务器的request事件
http.createServer(function (req, res) {
 var postData = '';
 req.setEncoding('utf8');
 // 侦听请求的data事件
 req.on('data', function (trunk) {
 postData += trunk;
 });

 // 侦听请求的end事件
 req.on('end', function () {
 res.end(postData);
 });
}).listen(8080);

console.log('服务器启动完成'); 
  • 前端例子
request({
 url: '/url',
 method: 'POST',
 data: {},
 success: function (data) {
 // success事件
 }
}); 
  • 事件发布/订阅模式
  • Promise、async / await
  • 流程控制库

回调函数

  • Node除了异步和事件外,回调函数也是一大特色
  • 纵观下来,回调函数也是最好的接收异步调用返回数据的方式

    • 但是这种编程方式对于很多习惯同步思路编程的人来说,也许是十分不习惯的
    • 代码的编写顺序与执行顺序并无关系,这对他们可能造成阅读上的障碍
  • 在流程控制方面,因为穿插了异步方法和回调函数,与常规的同步方式相比变得不那么一目了然了

    • 转变为异步编程思维后,通过对业务的划分和对事件的提炼,在流程控制方面处理业务的复杂度是与同步方式实际上是一致的

单线程

  • 单线程的缺点

    • 无法利用多核CPU
    • 错误会引起整个应用退出,健壮性较差
    • 大量计算占用CPU导致无法继续调用异步I / O
  • 后续也推出了child_process和cluster模块较好地缓解了以上缺点

跨平台

  • libuv

Node模块机制 - CommonJS

愿景

出发点

对于JavaScript自身而言,它的规范依然是薄弱的,还有以下缺陷:

  • 没有模块系统
  • 标准库较少

    • ECMAScript仅定义了部分核心库
    • 对于文件系统 I / O流等常见需求没有标准API
  • 没有标准接口

    • 在JavaScript中,几乎没有定义过如Web服务器或者数据库之类的标准统一接口
  • 缺乏包管理系统

    • 导致JavaScript应用中基本没有自动加载和安装以来的能力

CommonJS的提出,主要是为了弥补当前JavaScript没有标准的缺陷,以达到像Python、Ruby和Java具备开发大型应用的基础能力,而不是停留在小脚本程序的阶段,希望可以利用JavaScript开发:

  • 服务端JavaScript程序
  • 命令行工具
  • 桌面图形界面应用程序
  • 混合应用

CommonJS规范涵盖了:

  • 模块
  • 二进制
  • Buffer
  • 字符集编码
  • I / O流
  • 进程环境
  • 文件系统
  • 套接字
  • 单元测试
  • Web服务器网关接口
  • 包管理

模块规范

  • 模块定义
// math.js
exports.add = function(a, b){
    return a + b;
}
  • 模块引用
const math = require('./math');

const res = math.add(1, 1);
console.log(res);
// 2
  • 模块标识

模块标识就是传递给require方法的参数,可以是:

  • 如何小驼峰命名的字符串
  • 以./ 、../ 开头的相对路径 or 绝对路径
  • 可以没有文件名后缀.js

  • 意义:

模块实现

  • 路径分析
  • 文件定位
  • 编译执行

Node中模块分为两类:

  • 核心模块
  • 用户编写的文件模块

优先从缓存加载

  • 浏览器仅缓存文件
  • Node缓存的是编译和执行之后的对象

路径分析和文件定位

标识符分析(路径)

前面说到过,require方法接受一个参数作为标识符,分为以下几类:

  • 核心模块
  • 路径形式的文件模块
  • 自定义模块

先介绍一下模块路径这个概念,也是定位文件模块时制定的查找策略,具体表现为一个路径组成的数组

  • console.log(module.path)
  • 你可以得到一个路径数组

['/home/bytedance/reasearch/node_modules',

'/home/bytedance/node_modules',

'home/node_module', /node_modules']

可以看出规则如下:

  • 当前文件目录下的node_modules目录
  • 父目录下的node_modules目录
  • 父目录的父目录下的node_modules目录
  • 沿路径向上逐级递归,直到根目录下的node_modules目录

文件定位

  • 文件扩展名分析
  • 目录分析和包

模块编译

function Module(id, parent) {
 this.id = id;
 this.exports = {};
 this.parent = parent;
 if (parent && parent.children) {
     parent.children.push(this);
 }
 this.filename = null;
 this.loaded = false;
 this.children = [];
} 
  • js文件

    • 通过fs模块同步读取文件后编译执行
  • node文件

    • 这是用C/C++编写的扩展文件,通过dlopen方法加在最后编译生成的文件
  • json文件

    • 通过fs模块同步读取文件后,JSON.parse解析返回的结果
  • 其他

    • 都被当作js文件载入

包与NPM

包结构

  • 符合CommonJS规范的包目录应该包含如下文件

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

包描述文件

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

  • name 包名
  • description 包简介
  • version 版本号
  • keywords 关键词数组,用于做npm搜索
  • maintainers 包维护者列表
  • contributors 贡献者列表
  • bugs 一个可以反馈bug的网页地址 / 邮件地址
  • licenses 许可证列表
  • repositories 托管源代码的位置列表
  • dependencies 使用当前包所需要依赖的包
  • homepage 当前包的网站地址
  • os 操作系统支持列表

    • aix、freebsd、linux、macos、solaris、vxworks、windows
  • cpu CPU架构的支持列表

    • arm、mips、ppc、sparc、x86、x86_64
  • builtin 标志当前包是否是内建在底层系统的标准组件
  • implements 实现规范的列表
  • scripts 脚本说明对象

NPM 常用功能

  1. 查看帮助
  • 查看版本 npm -v
  • 查看命令 npm
  1. 安装依赖包
npm install {packageName}
  • 全局安装模式
npm install {packageName} -g
  • 从本地安装
npm install <tarball file>
npm install <tarball url>
npm install folder>
  • 从非官方源安装
npm install --registry={urlResource}
npm config set registry {urlResource}
  1. NPM 钩子命令
"scripts":{
    "preinstall": "preinstall.js",
    "install": "install.js",
    "uninstall": "uninstall.js",
    "test": "test.js",
}
  • Install

    • 在以上字段执行npm install <package>时,preinstall指向的脚本会被加载执行,然后install指向的脚本会被执行
  • Uninstall

    • 执行npm uninstall <package>时,uninstall指向的脚本也许会做一些清理工作
  • Test

    • 执行npm test将会运行test指向的脚本,一个优秀的包应当包含测试用例,并在package.json文件正配置好运行测试的命令,方便用户运行测试用例,以便检验包是否稳定可靠

局域 NPM

  • 背景

企业的限制在于,一方面需要享受到模块开发带来的低耦合和项目组织上的好处,另一方面却要考虑模块保密性的问题。所以,通过NPM共享和发布存在潜在的风险。

  • 解决方案

为了同时能够享受到NPM上众多的包,同时对自己的包进行保密和限制,现有的解决方案就是企业搭建自己的NPM仓库,NPM无论是它的服务端和客户端都是开源的。

  • 作用

    • 私有的可重用模块可以打包到局域NPM仓库中,这样可以保持更新的中心化,不至于让各个小项目维护相同功能的模块
    • 杜绝通过复制粘贴实现代码共享的行为

异步I / O

为什么需要异步 I / O ?

  • 用户体验
  • 资源分配
  1. 多线程并行完成

多线程的代价在于创建线程和执行线程上下文切换的开销较大。

在复杂的业务中经常面临锁、状态同步等问题。但是多线程在多核CPU上能够有效提升CPU利用率

  1. 单线程串行依次执行

单线程顺序执行任务比较符合编程人员按顺序思考的思维方式,依然是主流的编程方式

串行执行的缺点在于性能,任意一个略慢的任务都会导致后续执行代码被阻塞

在计算机资源中,通常I / O与CPU计算是可以并行的,同步编程模型导致的问题是,I / O的进行会让后续任务等待,这造成资源不能更好地被利用

  1. Node在两者之间给出了它的答案

异步I / O现状

异步I / O与非阻塞I / O

  • 由于完整的I / O没有完成,立即返回的并不是业务层期望的数据而仅仅是当前调用的状态
  • 为了获取完整的数据,应用程序需要重复调用I / O操作来确认是否完成,称之为“轮询”。

主要的轮询技术

  • read

  • select

  • poll

  • epoll

理想的非阻塞异步I / O

完美的异步I / O应该是应用程序发起非阻塞调用,无需通过遍历或者时间唤醒等方式轮询

可以直接处理下一个任务,只需在I / O完成后通过信号或回调将数据传递给应用程序即可

Linux下存在原生提供的一种异步I / O方式(AIO)就是通过信号或者回调来传递数据的

缺点:

  • 仅Linux下有
  • 仅支持I / O中的O_DIRECT方式读取,导致无法利用系统缓存

现实的异步I / O

  • libeio实质上是采用线程池与阻塞I / O模拟异步I / O
  • Node最初在*nix平台下采用libeio配合libev实现异步I / O,后通过自行实现线程池完成
  • Windows下的IOCP

    • 调用异步方法,等待I / O完成之后的通知,执行回调,用户无需考虑轮询
    • 内部其实仍是线程池的原理,不同之处在于这些线程池由系统内核接手管理
    • 与Node异步调用模型十分近似
  • 由于Windows平台和*nix平台的差异,Node提供了libuv作为抽象封装层,做兼容性判断

    • 保证上层Node与下层的自定义线程池和IOCP各自独立
  • 我们时常提到Node是单线程的

    • 这里的单线程仅仅只是JavaScript执行在单线程中罢了
    • 无论是*nix还是Windows平台,内部完成I / O任务的另有线程池

Node的异步I / O

事件循环

Node进程启动时,会创建一个类似while(true)的循环

每次循环体的过程称之为Tick,每个Tick的过程就是查看是否有事件待处理

如果有就取出事件及其相关的回调函数,并执行它们

观察者

  • 浏览器采用了类似的机制

    • 事件可能来自用户的点击或者加载某些文件时产生,而这些产生的事件都有对应的观察者
  • Node中事件主要来源于网络请求、文件I / O等

    • 这些时间对应的观察者有文件I / O观察者、网络I / O观察者等,将事件进行了分类
  • 事件循环是一个典型的生产者 / 消费者模型

    • 异步I / O、网络请求等则是事件的生产者
    • 这些事件被传递到对应的观察者,事件循环则从观察者那取出事件并处理

小结

由于我们知道JavaScipt是单线程的,所以按尝试很容易理解它不能充分利用多核CPU

事实上在Node中,除了JavaScript是单线程外,Node自身其实是多喜爱昵称的,只是I / O线程使用的CPU较少

另一个需要注意的点是,除了用户代码无法并行执行以外,所有的I / O是可以并行执行的

事件驱动与高性能服务器

下面为几种经典的服务器模型:

  • 同步式

    • 一次只能处理一个请求,并且其余请求都处于等待状态
  • 进程 / 请求

    • 这样可以处理多个请求,但是它不具备扩展性,因为系统资源只有那么多
  • 线程 / 请求

    • 尽管线程比进程要清凉,但是由于每个线程都占用一定内存,当大并发请求到来时,内存将会很快用光,导致服务器缓慢
    • 比进程 / 请求要好,但对于大型站点而言依然不够
  • 总结

    • 线程 / 请求的方式目前还被Apache所采用
    • Node通过事件驱动的方式处理请求,无需为每一个请求创建额外的线程,可以省掉创建线程和销毁线程的开销
    • 同时操作系统在调度任务时因为线程较少,上下文的代价很低

      • 这使得服务器能够有条不紊地处理请求,即使在大量连接的情况下,也不受上下文切换开销的影响,这也是Node高性能的一个原因

写在最后

本文介绍了Node被创造的目的、语言选型、特点、模块机制、包管理机制以及异步I / O等相关知识,希望能给你带来对Node有一个新的认识。最近一直也在计划学习Node和服务端相关知识,感兴趣的同学可以一起学习和交流~

03-05 17:08