常见js宿主环境(二):node.js

1,570 阅读8分钟

本文是系列文章第二篇,介绍js的第二种宿主环境,即node.js,想了解本系列其他参考这里


1 概述

node.js是一个开源和跨平台的js运行时环境(runtime environment,也被称为host environment),和chrome一样 内置 v8 js引擎,另外的宿主api为js提供了服务端工作的能力。

本文会对node.js的实现原理和常见用法做简要介绍。

2 实现方式

node.js的实现方式主要是封装了c++/c项目v8和libuv的代码,并结合一些其他library为开发者提供了js接口,具体依赖的library包括

注意在源码中有两个主要目录,其中lib包含所有我们使用的js模块,src中是下面介绍的各种以c++/c为主的依赖实现。

2.1 v8

v8是一个跨平台的js引擎,由c++编写,实现了ecma规范,其他常见js引擎还包括

js通常被认为是一个解释型语言,但是现代js引擎为了加速运行会通过 just-in-time (JIT) 编译,虽然在编译js时花了一些事件,但是执行时会比单纯的解释性能更好。
js引擎会首先将js代码解析为ast,并进一步通过解释器解释为字节码(bytecode),字节码是一种通常与机器无关的中间代码,如果按传统方式便可以直接在js引擎运行。为了更快运行,字节码被发送到优化编译器,后者会将字节码优化为执行更快地机器码(machine code),如果编译优化出现错误会返回字节码,更多细节参考JavaScript engine fundamentals: Shapes and Inline Caches

2.2 libuv

由c编写,设计的初衷是为node.js提供跨平台的事件驱动非阻塞异步i/o模型,提供的功能包括

  • 支持epoll, kqueue, IOCP, event ports的全功能event loop
  • 异步TCP and UDP sockets
  • 异步dns解析
  • 异步文件和文件系统操作
  • 文件系统时间
  • 子进程
  • 线程池, 具体实现因系统而异,epoll on Linux, kqueue on OSX and other BSDs, event ports on SunOS and IOCP on Windows
  • 线程和同步原语

其中阻塞指的是另外的js代码需要等待非js操作完成才能执行,i/o指的是与磁盘和网络之间的交互。

2.2.1 Handles and requests

libuv提供了两个和event loop一起使用的抽象Handles(中文翻译为句柄) and requests。
其中handle表示long-lived对象,可以在激活时处理一些特定的操作,比如

  • 一个prepare handle会在每次event loop前都会获取它的回调
  • 一个tcp server handle在每次有新连接时都会获取它的连接回调

request表示short-lived操作,这些操作可以被一个handle执行,比如write request可以被handle或单独用来写数据。

2.2.2 i/o loop

i/o loop,或被称为event loop,是libuv的核心部分,用单线程来执行,这部分会结合node.js实际使用的event loop来理解。

浏览器中的event loop在html标准中被定义,我们在这篇文章有过讨论,这里简要重复一下,在浏览器中的event loop中存在一个microtask queue和一个或多个task(或被称为macroTask) queues,每个macro task完成后会检查microtask queue,将包含的microtask及其生成的microtask执行结束,就执行必要的渲染,然后执行下一个macrotask。

在node.js中的event loop有所不同,包含一个microtask queue和各种phase,每个phase执行的是macrotask,每个macrotask结束后执行microtask queue中的microtask,直到microtask queue为空(在node.js@11 之前是执行完每个phase后再执行microtask,详情查看New Changes to the Timers and Microtasks in Node v11.0.0)。

其中microtask在node.js中包括process.nextTick()和Promise相关回调.

具体每个event loop中的phase包括

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

具体为

  • timers 本阶段如果有到期的timer(包括setTimeout 和 setInterval)会执行其回调,否则结束本阶段。
  • Pending callbacks 执行上一轮loop被延迟到本次的回调,比如tcp错误
  • ide,prepare node.js内部使用
  • poll 轮询阶段,处理close回调、timer、setImmediate外所有异步i/o回调,主要是计算各个回调还有多久调用,如果可以调用了就被放在poll queue。
    • 如果没有timer到期
      • 如果poll queue非空,则会迭代这个queue同步执行,直到都被执行完或者到达系统相关的限制节点
      • 如果poll queue空的,则
        • 如果存在setImmediate()回调,则会到下一阶段执行该回调
        • 否则,event loop会等待回调被加入poll queue然后立刻执行
    • 一旦poll queue为空,就会检查timer,如果有timer到期,event loop会返回timer阶段执行会掉
  • check 执行setImmediate回调
  • Close callbacks 执行close回调

setImmediate() vs setTimeout()

两者比较类似,前者会在poll阶段结束后立刻执行,后者会在一个时间间隔后执行,两者执行的先后顺序和执行位置有关,如果在i/o循环之外,则执行顺序不确定

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

因为timer的最小时间为1(见timer文档),因此如果在1ms内执行了setImmediate就会先执行setImmediate,否则先执行setTimeout

When delay is larger than 2147483647 or less than 1, the delay will be set to 1. Non-integer delays are truncated to an integer.

如果在i/o循环内,则前者肯定会首先执行

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

其他参考

2.2.3 File I/O

对于文件i/o,libuv没有依赖特定平台的原语,用的是一个线程池的i/o操作,该线程池是全局的,可以执行以下操作,

  • file操作
  • dns方法
  • 用户特定的代码

2.3 llhttp

由c和ts编写,用来解析http,因为没有系统调用和分配,因此占用很小内存

2.4 c-ares

由c编写,用来处理异步的dns请求

2.5 OpenSSL

提供加密功能,用在tls and crypto模块

2.6 zlib

用来压缩和解压缩

3 常用模块

node.js借助上述底层依赖提供了丰富的模块和详实的api文档,下面我们挑选常用的几个进行介绍。

3.1 process

表示当前的进程,不需要require直接使用,可以从其获取一些信息,比如可以通过process.env.NODE_ENV获取环境变量。

3.2 http

使用时需要require('http')引入,可以用来做http server和client,一个简单的使用如

const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World');
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

是我们最常用的模块之一

3.3 events

大部分的node.js 核心api都是基于异步的事件驱动架构,其中包含一类对象(即emitter)用来触发事件来调用相关监听器。比如

const EventEmitter = require('events');

class MyEmitter extends EventEmitter {}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
});
myEmitter.emit('event');

和浏览器中的Event接口类似。

3.4 file

提供了和文件系统交互的方法

3.5 path

提供了处理文件和目的时路径相关的工具

3.6 module

这篇文章详细介绍了es module,在node.js中除此之外还有commonjs,两者的主要区别是前者对变量是live bindings,后者是浅拷贝(对原始类型复制值,对引用类型复制地址),因此前者对循环引用也能好的处理(如果出现循环引用,后者会只输出已经执行的部分,具体参考上面提到的链接)。

4 框架

node.js框架内容很多,基本功能是处理http连接。由于目前工作不涉及选型这一块,因此先丢两个参考链接

这里只讨论使用的最多的express.jskoa

4.1 express

express是一个内置了部分常用功能的框架,可以用来处理一些基本操作,比如路由、使用中间件、使用模板引擎和错误处理。

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

4.1.1 路由

路由决定了怎么响应一个client对特定endpoint(指一个uri和一个特定的http方法)的请求,每个路由可以有一个或多个handler方法,会在路由匹配到时被调用。用来响应各种http请求。

app.METHOD(PATH, HANDLER)

还可以使用express.Router 指定模块化路由

const express = require('express')
const router = express.Router()

// middleware that is specific to this router
router.use((req, res, next) => {
  console.log('Time: ', Date.now())
  next()
})
// define the home page route
router.get('/', (req, res) => {
  res.send('Birds home page')
})
// define the about route
router.get('/about', (req, res) => {
  res.send('About birds')
})

module.exports = router
const birds = require('./birds')

// ...

app.use('/birds', birds)

4.1.2 中间件

中间件(Middleware )指的是可以作为路由handler的函数,可以访问到req,res和next函数,其中req表示请求对象,res表示响应对象,通过next调用可以将控制权向下传递。本质就是在应用的req-res循环过程中执行的函数。

可以用来

  • 执行任何代码
  • 修改req,res对象
  • 结束req-res循环
  • 调用下一个中间价

4.1.3 模板引擎

模板引擎(template engine)用于将模板中的变量用实际数据表示,并将模板转换为html文件。

4.1.4 原理

express导出的是一个函数,并且挂载了多个静态方法。express源码入口文件如下

exports = module.exports = createApplication;
function createApplication() {
  var app = function(req, res, next) {
    app.handle(req, res, next);
  };
  app.init();
  return app;
}

/**
 * Expose the prototypes.
 */

exports.application = proto;
exports.request = req;
exports.response = res;

/**
 * Expose constructors.
 */

exports.Route = Route;
exports.Router = Router;

/**
 * Expose middleware
 */

exports.json = bodyParser.json
exports.query = require('./middleware/query');
exports.raw = bodyParser.raw
exports.static = require('serve-static');
exports.text = bodyParser.text
exports.urlencoded = bodyParser.urlencoded

具体实现细节参考How express.js works

4.2 koa

koa是有express团队出的另一个框架,和express的区别主要是引入了async函数和没有内置任何中间件,利用async和next可以实现真正意义上的中间件,即express等框架只是利用中间件将控制权向下传递,而koa可以向下传递后,然后可以再返回第一个中间件,类似于dom中的捕获和冒泡,这部分可以参考官方说明

源码分析可以参考十分钟带你看完 KOA 源码

5 进程管理工具

进程管理工具(Process managers)用来管理node.js应用,可以保证用来在crash时重启、查看运行时性能和资源使用情况以及集群控制等,比如pm2