《Node.js设计模式》欢迎来到Node.js平台

本系列文章为《Node.js Design Patterns Second Edition》的原文翻译和读书笔记,在GitHub连载更新,同步翻译版连接javascript

欢迎关注个人专栏,以后的博文将在专栏同步:前端

Welcom to the Node.js Platform

Node.js 的发展

  • 技术自己的发展
  • 庞大的Node.js生态圈的发展
  • 官方组织的维护

Node.js的特色

小模块

package的形式尽量多的复用模块,原则上每一个模块的容量尽可能小而精。java

原则:react

  • "Small is beautiful" ---小而精
  • "Make each program do one thing well" ---单一职责原则

所以,一个Node.js应用由多个包搭建而成,包管理器(npm)的管理使得他们相互依赖而不起冲突。git

若是设计一个Node.js的模块,尽量作到如下三点:程序员

  • 易于理解和使用
  • 易于测试和维护
  • 考虑到对客户端(浏览器)的支持更友好

以及,Don't Repeat Yourself(DRY)复用性原则。github

以接口形式提供

每一个Node.js模块都是一个函数(类也是以构造函数的形式呈现),咱们只须要调用相关API便可,而不须要知道其它模块的实现。Node.js模块是为了使用它们而建立,不只仅是在拓展性上,更要考虑到维护性和可用性。web

简单且实用

“简单就是终极的复杂” ————达尔文算法

遵循KISS(Keep It Simple, Stupid)原则,即优秀的简洁的设计,可以更有效地传递信息。数据库

设计必须很简单,不管在实现仍是接口上,更重要的是实现比接口更简单,简单是重要的设计原则。

咱们作一个设计简单,功能完备,而不是完美的软件:

  • 实现起来须要更少的努力
  • 容许用更少的速度进行更快的运输资源
  • 具备伸缩性,更易于维护和理解
  • 促进社区贡献,容许软件自己的成长和改进

而对于Node.js而言,由于其支持JavaScript,简单和函数、闭包、对象等特性,可取代复杂的面向对象的类语法。如单例模式和装饰者模式,它们在面向对象的语言都须要很复杂的实现,而对于JavaScript则较为简单。

介绍Node.js 6 和 ES2015的新语法

let和const关键字

ES5以前,只有函数和全局做用域。

if (false) {
  var x = "hello";
}

console.log(x); // undefined复制代码

如今用let,建立词法做用域,则会报出一个错误Uncaught ReferenceError: x is not defined

if (false) {
  let x = "hello";
}

console.log(x);复制代码

在循环语句中使用let,也会报错Uncaught ReferenceError: i is not defined

for (let i = 0; i < 10; i++) {
  // do something here
}

console.log(i);复制代码

使用letconst关键字,可让代码更安全,若是意外的访问另外一个做用域的变量,更容易发现错误。

使用const关键字声明变量,变量不会被意外更改。

const x = 'This will never change';
x = '...';复制代码

这里会报出一个错误Uncaught TypeError: Assignment to constant variable.

可是对于对象属性的更改,const显得毫无办法:

const x = {};
x.name = 'John';复制代码

上述代码并不会报错

可是若是直接更改对象,仍是会抛出一个错误。

const x = {};
x = null;复制代码

实际运用中,咱们使用const引入模块,防止意外被更改:

const path = require('path');
let path = './some/path';复制代码

上述代码会报错,提醒咱们意外更改了模块。

若是须要建立不可变对象,只是简单的使用const是不够的,须要使用Object.freeze()deep-freeze

我看了一下源码,其实不多,就是递归使用Object.freeze()

module.exports = function deepFreeze (o) {
  Object.freeze(o);

  Object.getOwnPropertyNames(o).forEach(function (prop) {
    if (o.hasOwnProperty(prop)
    && o[prop] !== null
    && (typeof o[prop] === "object" || typeof o[prop] === "function")
    && !Object.isFrozen(o[prop])) {
      deepFreeze(o[prop]);
    }
  });

  return o;
};复制代码

箭头函数

箭头函数更易于理解,特别是在咱们定义回调的时候:

const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter(function(x) {
  return x % 2 === 0;
});复制代码

使用箭头函数语法,更简洁:

const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter(x => x % 2 === 0);复制代码

若是不止一个return语句则使用=> {}

const numbers = [2, 6, 7, 8, 1];
const even = numbers.filter((x) => {
  if (x % 2 === 0) {
    console.log(x + ' is even');
    return true;
  }
});复制代码

最重要是,箭头函数绑定了它的词法做用域,其this与父级代码块的this相同。

function DelayedGreeter(name) {
  this.name = name;
}

DelayedGreeter.prototype.greet = function() {
  setTimeout(function cb() {
    console.log('Hello' + this.name);
  }, 500);
}

const greeter = new DelayedGreeter('World');
greeter.greet(); // 'Hello'复制代码

要解决这个问题,使用箭头函数或bind

function DelayedGreeter(name) {
  this.name = name;
}

DelayedGreeter.prototype.greet = function() {
  setTimeout(function cb() {
    console.log('Hello' + this.name);
  }.bind(this), 500);
}

const greeter = new DelayedGreeter('World');
greeter.greet(); // 'HelloWorld'复制代码

或者箭头函数,与父级代码块做用域相同:

function DelayedGreeter(name) {
  this.name = name;
}

DelayedGreeter.prototype.greet = function() {
  setTimeout(() => console.log('Hello' + this.name), 500);
}

const greeter = new DelayedGreeter('World');
greeter.greet(); // 'HelloWorld'复制代码

类语法糖

class是原型继承的语法糖,对于来自传统的面向对象语言的全部开发人员(如JavaC#)来讲更熟悉,新语法并无改变JavaScript的运行特征,经过原型来完成更加方便和易读。

传统的经过构造器 + 原型的写法:

function Person(name, surname, age) {
  this.name = name;
  this.surname = surname;
  this.age = age;
}

Person.prototype.getFullName = function() {
  return this.name + '' + this.surname;
}

Person.older = function(person1, person2) {
  return (person1.age >= person2.age) ? person1 : person2;
}复制代码

使用class语法显得更加简洁、方便、易懂:

class Person {
  constructor(name, surname, age) {
    this.name = name;
    this.surname = surname;
    this.age = age;
  }

  getFullName() {
    return this.name + '' + this.surname;
  }

  static older(person1, person2) {
    return (person1.age >= person2.age) ? person1 : person2;
  }
}复制代码

可是上面的实现是能够互换的,可是,对于class语法来讲,最有意义的是extendssuper关键字。

class PersonWithMiddlename extends Person {
  constructor(name, middlename, surname, age) {
    super(name, surname, age);
    this.middlename = middlename;
  }

  getFullName() {
    return this.name + '' + this.middlename + '' + this.surname;
  }
}复制代码

这个例子是真正的面向对象的方式,咱们声明了一个但愿被继承的类,定义新的构造器,并可使用super关键字调用父构造器,并重写getFullName方法,使得其支持middlename

对象字面量的新语法

容许缺省值:

const x = 22;
const y = 17;
const obj = { x, y };复制代码

容许省略方法名

module.exports = {
  square(x) {
    return x * x;
  },
  cube(x) {
    return x * x * x;
  },
};复制代码

key的计算属性

const namespace = '-webkit-';
const style = {
  [namespace + 'box-sizing']: 'border-box',
  [namespace + 'box-shadow']: '10px 10px 5px #888',
};复制代码

新的定义getter和setter方式

const person = {
  name: 'George',
  surname: 'Boole',

  get fullname() {
    return this.name + ' ' + this.surname;
  },

  set fullname(fullname) {
    let parts = fullname.split(' ');
    this.name = parts[0];
    this.surname = parts[1];
  }
};

console.log(person.fullname); // "George Boole"
console.log(person.fullname = 'Alan Turing'); // "Alan Turing"
console.log(person.name); // "Alan"复制代码

这里,第二个console.log触发了set方法。

模板字符串

其它ES2015语法

reactor模式

reactor模式Node.js异步编程的核心模块,其核心概念是:单线程非阻塞I/O,经过下列例子能够看到reactor模式Node.js平台的体现。

I/O是缓慢的

在计算机的基本操做中,输入输出确定是最慢的。访问内存的速度是纳秒级(10e-9 s),同时访问磁盘上的数据或访问网络上的数据则更慢,是毫秒级(10e-3 s)。内存的传输速度通常认为是GB/s来计算,然而磁盘或网络的访问速度则比较慢,通常是MB/s。虽然对于CPU而言,I/O操做的资源消耗并不算大,可是在发送I/O请求和操做完成之间总会存在时间延迟。除此以外,咱们还必须考虑人为因素,一般状况下,应用程序的输入是人为产生的,例如:按钮的点击、即时聊天工具的信息发送。所以,输入输出的速度并不因网络和磁盘访问速率慢形成的,还有多方面的因素。

阻塞I/O

在一个阻塞I/O模型的进程中,I/O请求会阻塞以后代码块的运行。在I/O请求操做完成以前,线程会有一段不定长的时间浪费。(它多是毫秒级的,但甚至有多是分钟级的,如用户按着一个按键不放的状况)。如下例子就是一个阻塞I/O模型。

// 直到请求完成,数据可用,线程都是阻塞的
data = socket.read();
// 请求完成,数据可用
print(data);复制代码

咱们知道,阻塞I/O的服务器模型并不能在一个线程中处理多个链接,每次I/O都会阻塞其它链接的处理。出于这个缘由,对于每一个须要处理的并发链接,传统的web服务器的处理方式是新开一个新的进程或线程(或者从线程池中重用一个进程)。这样,当一个线程因 I/O操做被阻塞时,它并不会影响另外一个线程的可用性,由于他们是在彼此独立的线程中处理的。

经过下面这张图:

经过上面的图片咱们能够看到每一个线程都有一段时间处于空闲等待状态,等待从关联链接接收新数据。若是全部种类的I/O操做都会阻塞后续请求。例如,链接数据库和访问文件系统,如今咱们能很快知晓一个线程须要因等待I/O操做的结果等待许多时间。不幸的是,一个线程所持有的CPU资源并不廉价,它须要消耗内存、形成CPU上下文切换,所以,长期占有CPU而大部分时间并无使用的线程,在资源利用率上考虑,并非高效的选择。

非阻塞I/O

阻塞I/O以外,大部分现代的操做系统支持另一种访问资源的机制,即非阻塞I/O。在这种机制下,后续代码块不会等到I/O请求数据的返回以后再执行。若是当前时刻全部数据都不可用,函数会先返回预先定义的常量值(如undefined),代表当前时刻暂无数据可用。

例如,在Unix操做系统中,fcntl()函数操做一个已存在的文件描述符,改变其操做模式为非阻塞I/O(经过O_NONBLOCK状态字)。一旦资源是非阻塞模式,若是读取文件操做没有可读取的数据,或者若是写文件操做被阻塞,读操做或写操做返回-1EAGAIN错误。

非阻塞I/O最基本的模式是经过轮询获取数据,这也叫作忙-等模型。看下面这个例子,经过非阻塞I/O和轮询机制获取I/O的结果。

resources = [socketA, socketB, pipeA];
while(!resources.isEmpty()) {
  for (i = 0; i < resources.length; i++) {
    resource = resources[i];
    // 进行读操做
    let data = resource.read();
    if (data === NO_DATA_AVAILABLE) {
      // 此时尚未数据
      continue;
    }
    if (data === RESOURCE_CLOSED) {
      // 资源被释放,从队列中移除该连接
      resources.remove(i);
    } else {
      consumeData(data);
    }
  }
}复制代码

咱们能够看到,经过这个简单的技术,已经能够在一个线程中处理不一样的资源了,但依然不是高效的。事实上,在前面的例子中,用于迭代资源的循环只会消耗宝贵的CPU,而这些资源的浪费比起阻塞I/O反而更不可接受,轮询算法一般浪费大量CPU时间。

事件多路复用

对于获取非阻塞的资源而言,忙-等模型不是一个理想的技术。可是幸运的是,大多数现代的操做系统提供了一个原生的机制来处理并发,非阻塞资源(同步事件多路复用器)是一个有效的方法。这种机制被称做事件循环机制,这种事件收集和I/O队列源于发布-订阅模式。事件多路复用器收集资源的I/O事件而且把这些事件放入队列中,直到事件被处理时都是阻塞状态。看下面这个伪代码:

socketA, pipeB;
wachedList.add(socketA, FOR_READ);
wachedList.add(pipeB, FOR_READ);
while(events = demultiplexer.watch(wachedList)) {
  // 事件循环
  foreach(event in events) {
    // 这里并不会阻塞,而且总会有返回值(不论是不是确切的值)
    data = event.resource.read();
    if (data === RESOURCE_CLOSED) {
      // 资源已经被释放,从观察者队列移除
      demultiplexer.unwatch(event.resource);
    } else {
      // 成功拿到资源,放入缓冲池
      consumeData(data);
    }
  }
}复制代码

事件多路复用的三个步骤:

  • 资源被添加到一个数据结构中,为每一个资源关联一个特定的操做,在这个例子中是read
  • 事件通知器由一组被观察的资源组成,一旦事件即将触发,会调用同步的watch函数,并返回这个可被处理的事件。
  • 最后,处理事件多路复用器返回的每一个事件,此时,与系统资源相关联的事件将被读而且在整个操做中都是非阻塞的。直到全部事件都被处理完时,事件多路复用器会再次阻塞,而后重复这个步骤,以上就是event loop

上图能够很好的帮助咱们理解在一个单线程的应用程序中使用同步的时间多路复用器和非阻塞I/O实现并发。咱们可以看到,只使用一个线程并不会影响咱们处理多个I/O任务的性能。同时,咱们看到任务是在单个线程中随着时间的推移而展开的,而不是分散在多个线程中。咱们看到,在单线程中传播的任务相对于多线程中传播的任务反而节约了线程的整体空闲时间,而且更利于程序员编写代码。在这本书中,你能够看到咱们能够用更简单的并发策略,由于不须要考虑多线程的互斥和同步问题。

在下一章中,咱们有更多机会讨论Node.js的并发模型。

介绍reactor模式

如今来讲reactor模式,它经过一种特殊的算法设计的处理程序(在Node.js中是使用一个回调函数表示),一旦事件产生并在事件循环中被处理,那么相关handler将会被调用。

它的结构如图所示:

reactor模式的步骤为:

  • 应用程序经过提交请求到时间多路复用器产生一个新的I/O操做。应用程序指定handlerhandler 在操做完成后被调用。提交请求到事件多路复用器是非阻塞的,其调用因此会立马返回,将执行权返回给应用程序。
  • 当一组I/O操做完成,事件多路复用器会将这些新事件添加到事件循环队列中。
  • 此时,事件循环会迭代事件循环队列中的每一个事件。
  • 对于每一个事件,对应的handler被处理。
  • handler,是应用程序代码的一部分,handler执行结束后执行权会交回事件循环。可是,在handler 执行时可能请求新的异步操做,从而新的操做被添加到事件多路复用器。
  • 当事件循环队列的所有事件被处理完后,循环会在事件多路复用器再次阻塞直到有一个新的事件可处理触发下一次循环。

咱们如今能够定义Node.js的核心模式:

模式(反应器)阻塞处理I/O到在一组观察的资源有新的事件可处理,而后以分派每一个事件对应handler的方式反应。

OS的非阻塞I/O引擎

每一个操做系统对于事件多路复用器有其自身的接口,LinuxepollMac OSXkqueueWindowsIOCP API。除外,即便在相同的操做系统中,每一个I/O操做对于不一样的资源表现不同。例如,在Unix下,普通文件系统不支持非阻塞操做,因此,为了模拟非阻塞行为,须要使用在事件循环外用一个独立的线程。全部这些平台内和跨平台的不一致性须要在事件多路复用器的上层作抽象。这就是为何Node.js为了兼容全部主流平台而
编写C语言库libuv,目的就是为了使得Node.js兼容全部主流平台和规范化不一样类型资源的非阻塞行为。libuv今天做为Node.jsI/O引擎的底层。

相关文章
相关标签/搜索