Node.js Design Patterns--1.Node.js Design Fundamentals

原创
2017/01/14 21:46
阅读数 190

Node.js哲学

小核心(small core)

Node.js的内核设计基于一些小原则, 其中之一就是由小的函数集合组成, 其余部分为由模块组成的生态圈, 而核心则起到连接不同模块进行自由的组合.

小模块(small modules)

Node.js中, 尽量设计小的模块, 这是基于Unix的哲学:

1. 小即美.

2. 使每个模块只做一件事.

Node.js使用npm解决了"依赖地狱"的问题, 保证每个安装的包具有完整可分离的依赖包, 而不会产生依赖冲突.

具有复用性的模块应该具有以下特性(符合DRY原则: Don't Repeat Yourself):

1. 易于理解和使用.

2. 简单测试和管理.

3. 浏览器中可共享.

小接触层(small surface area)

Node.js中, 定义模块的通用原则是导出一小块主要功能, 其它的实现细节应该隐藏起来. 这样方便调用者分清模块的主次, 并且更易于进行模块的组合使用.

模块是用来使用的, 而非用来扩展, 这样它可易于复用, 实现简单, 操作便利, 以及增加它的可用性.

简单和实用(simplicity and pragmatism)

遵循KISS(keep it simple, stupid)原则.

介绍Node.js 6和ES2015

let: 所定义的变量具有块级作用域.

const: 定义一个常量.

"=>"函数: 简化函数的编写. 如果函数只有一行, 可忽略return.

const numbers = [2, 5, 6, 7, 9];
const even = numbers.filter(function(x) {
	return x % 2 === 0;
});

使用"=>"函数可简化为:

const numbers = [2, 5, 6, 7, 9];
const even = numbers.filter(x => x % 2 === 0);

"=>"函数同时保留着它的词法作用域, 所以this指针会指向此函数的对象. 

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

DelayedGreeter.prototype.greet = function() {
	// Hello undefined
	setTimeout(function cb() {
		console.log('Hello ' + this.name);
	}, 500);
	// Hello world!
	setTimeout(() => console.log('Hello ' + this.name), 1000);
};

const greeter = new DelayedGreeter('world!');
greeter.greet();

class的引入则简化了继承.

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甚至可以使用extend进行继承:

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

对象字面量的赋值功能被增强:

const x = 22;
const y = 17;
const obj = {x, y};
// {x: 22, y: 17}
console.log(obj);

之前我们导出一个module, 需要如此编写代码:

module.exports = {
  square: function(x) {
    return x * x;
  },
  cube: function(x) {
    return x * x * x;
  }
};

而现在可简化为:

module.exports = {
  square(x) {
    return x * x;
  },
  cube(x) {
    return x * x * x;
  }
};

Map数据结构的出现, 简化了"字典"型数据结构的编写:

const m = new Map();
m.set('a', 1);
m.set('b', 2);
//2
console.log(m.size);
// true
console.log(m.has('a'));
// 1
console.log(m.get('a'));
m.delete('b');
// undefined
console.log(m.get('b'));

// a:1
for (const entry of m) {
	console.log(entry[0] + ':' + entry[1]);
}

Set简化了集合的操作:

const s = new Set([0, 1, 2, 3]);
s.add(3);
// 4
console.log(s.size);
s.delete(0);
// false
console.log(s.has(0));
// 1 2 3
for (const entry of s) {
	console.log(entry);
}

模板字符串

我们可以使用"``"来实现模板字符串, 模板字符串中使用${expression}来求解表达式.

 

reactor模式

I/O是缓慢的

I/O操作是缓慢的, 接触RAM的速度为纳秒级别, 接触磁盘或者网络的速度为微秒级别.

阻塞I/O

在传统的I/O操作中, 在当前线程中I/O将阻塞直到操作完成.

// 线程阻塞直到数据有效
data = socket.read();
// 数据有效
print(data);

如果一个Web服务器是基于阻塞I/O的, 那它在单线程情况下不支持多个连接. 传统的解决方案就是新建一个连接, 则fork出一个子线程来处理.

线程耗费的资源是昂贵的, 并且每个线程的空闲时间也是及其浪费的(例如数据库操作时候的阻塞). 所以在Web中使用多线程来处理多连接, 不是完美的解决方案.

非阻塞I/O

非阻塞模式下, 系统不需要等待数据读写完毕而是马上返回. 调用的函数会立即返回一个变量, 可表示暂时没有数据, 正在读写, 或者读写完毕.

一种非阻塞I/O模式是将执行一次循环, 等待循环中数据全部操作完毕, 而非顺序等待每个元素操作执行完毕:

resources = [socketA, socketB, pipeA];
while (!resources.isEmpty()) {
  for (i = 0; i < resources.length; i++) {
    resource = resources[i];
    var data = resource.read();
    if (data === NO_DATA_AVAILABLE) {
      // 当前没有任何的有效数据
      continue;
    }
    if (data === RESOURCE_CLOSED) {
      // 数据读取完毕, 将resource从列表中删除
      resource.remove(i);
    } else {
      // 读取到一些数据, 处理它
      consumeData(data);
    }
  }
}

考虑一个实际的Node.js例子, 我们使用net模块创建一个服务器, 客户端连接到服务器后, 输入数据, 服务器原样返回. 客户端全部关闭后, 服务器也关闭.

服务器代码test.js:

const net = require('net');
const server = net.createServer((c) => {
  console.log('client connected');
  c.on('end', () => {
    console.log('client disconnected');
    server.unref();
  });
  c.write('hello\r\n');
  c.pipe(c);
});

server.on('error', (err) => {
  throw err;
});

server.listen(8000, () => {
  console.log('server bound');
});

服务器运行:

leicj@leicj:~/test$ node test.js
server bound
client connected
client connected
client disconnected
client disconnected
leicj@leicj:~/test$ 

客户端1:

leicj@leicj:~/test$ telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
nihao
nihao
^]

telnet> quit
Connection closed.
leicj@leicj:~/test$ 

客户端2:

leicj@leicj:~/test$ telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
what
what
^]

telnet> quit
Connection closed.
leicj@leicj:~/test$ 

事件多路(event demultiplexing)

同步事件多路操作: 将所有的I/O操作排列好, 循环监测事件直到新的事件可操作时候进行阻塞, 直到操作完成后继续循环监测.

socketA, pipeB;
watchedList.add(socketA, FOR_READ);    // [1]
watchedList.add(pipeB, FOR_READ);
while (events = dumultplexer.watch(watchedList)) {    // [2]
  // event loop
  foreach(event in events) {    // [3]
    // read将永远不会阻塞并且总是返回数据
    data = event.resource.read();
    if (data === RESOURCE_CLOSED)
      demultiplexer.unwatch(event.resource);
    else
      consumeData(data);
  }
}

1. resource添加到具体的数据结构中, 关联一个特定的操作, 如read

2. 监测resources直到具体的操作有效(例如可读)后进行阻塞操作, 直到操作完毕后继续监测.

3. 针对每个resource操作完毕后, 重新阻塞.

这种情况下, 可在单线程下执行多个I/O操作。

 

reactor模式

reactor模式是特殊版的event demultiplexing, 它主要观点在于: 每个I/O操作都有一个关联的handler, 在event loop中会被产生和调用.

1. Application通过提交给Event Demultiplexer生成一个新的I/O操作.

2. 当一系列的I/O操作完成以后, Event Demultiplexer将新的事件push到Event Queue中.

3. Event Loop会顺序循环Event Queue, 确定哪个Event将被触发.

4. 针对每个Event, 相应的handler会被调用.

5. 当handler执行完毕后, 会将控制权返回给Event Loop中(5a). 然而, 也可能handler中会产生新的异步操作, 则执行(5b).

6. 当所有的Event Queue都执行完毕后, loop会被阻塞, Event Demultiplexer会执行下一次的循环.

回调模式

持续传递类型(The continuation-passing style)

在JavaScript中, 将一个函数作为参数进行传递, 在另一个函数执行完毕后进行调用, 则称为回调函数.

同步性回调

考虑以下函数:

function add(a, b) {
  return a + b;
}

假设将return当做一个函数(实际上return为一个操作符), 则可修改成:

function (a, b, cb) {
  cb(a + b);
}

测试代码如下:

function add(a, b, cb) {
    cb(a + b);
}
console.log('before');
add(1, 2, result => console.log('Result: ' + result));
console.log('after');

输出:

leicj@leicj:~/test$ node test.js
before
Result: 3
after

异步性回调

我们使用setTimeout来模拟异步性回调:

function addAsync(a, b, cb) {
    setTimeout(() => cb(a + b), 100);
}
console.log('before');
addAsync(1, 2, result => console.log('Result: ' + result));
console.log('after');

输出:

leicj@leicj:~/test$ node test.js
before
after
Result: 3

类似异步, 实为同步的回调

例如, map函数:

> [1, 5, 7].map((item) => item - 1);
[ 0, 4, 6 ]

 

同步还是异步?

针对一个封装的函数, 最危险的一个行为是在某些条件下为同步, 而在某些条件下为异步:

var fs = require('fs');
var cache = {};
function inconsistentRead(filename, cb) {
  if (cache[filename]) {
    cb(cache[filename]);
  } else {
    fs.readFile(filename, 'utf8', (err, data) => {
      cache[filename] = data;
      cb(data);
    });
  }
}

这种情况下, 会造成一些意外的情况, 考虑如下的代码:

const fs = require('fs');
let cache = {};
function inconsistentRead(filename, cb) {
    if (cache[filename]) cb(cache[filename]);
    else {
        fs.readFile(filename, 'utf8', (err, data) => {
            cache[filename] = data;
            cb(data);
        });
    }
}

function createFileReader(filename) {
    let listeners = [];
    inconsistentRead(filename, value => {
        listeners.forEach(listener => listener(value));
    });
    return {
        onDataReady: listener => listeners.push(listener)
    };
}

let reader1 = createFileReader('data.txt');
reader1.onDataReady(data => {
    console.log('First call data: ' + data);
    let reader2 = createFileReader('data.txt');
    reader2.onDataReady(data => {
        console.log('Second call data: ' + data);
    });
});

如果data.txt的内容为: some data, 则实际输出为:

leicj@leicj:~/test$ node test.js
First call data: some data

我们认真查看createFileReader函数, 里面inconsistentRead函数只有可能其为异步调用情况下, 回调函数才会被调用. 而第一次读取data.txt时候为异步, 第二次读取data.txt时候为同步,  则inconsistentRead函数在第二次永远不会被调用.

一种改进的方法为: 将函数修改为同步API:

function inconsistentRead(filename, cb) {
    if (cache[filename]) cb(cache[filename]);
    else cache[filename] = fs.readFileSync(filename, 'utf8');
}

或者修改为异步API:

function inconsistentRead(filename, cb) {
    if (cache[filename]) {
        process.nextTick(() => {
            cb(cache[filename]);
        });
    } else {
        fs.readFile(filename, 'utf8', (err, data) => {
            cache[filename] = data;
            cb(data);
        });
    }
}

 

Node.js回调规则

回调函数作为最后一个参数

fs.readFile(filename, [options], callback)

回调函数中, Error作为第一个参数

fs.readFile('foo.txt', 'utf8', function(err, data) {
  if (err) handleError(err);
  else processData(data);
});

传播错误

一个典型的错误处理方式如下:

var fs = require('fs');
function readJSON(filename, cb) {
  fs.readFile(filename, 'utf8', (err, data) => {
    let parsed;
    if (err) return cb(err);
    try {
      parsed = JSON.parse(data);
    } catch (err) {
      return cb(err);
    }
    cb(null, parsed);
  });
}

正常情况下, 我们不能使用try/catch来捕获异步的异常. 在Node.js中, 一种使用on('error')来捕获, 一种使用process.on('uncaughtException')来捕获.

模块系统和模式

The revealing module pattern

var module = (() => {
  const privateFoo = () => {...};
  const privateVar = [];

  const export = {
    publicFoo: () => {...},
    publicBar: () => {...}
  };

  return export;
})();

 

Node.js模块

以下代码用于模拟Node.js的模块调用:

function loadModule(filename, module, require) {
  var wrappedSrc =
    '(function(module, exports, require) {' +
      fs.readFileSync(filename, 'utf8') +
    '})(module, module.exports, require);';
  eval(wrappedSrc);
}

var require = function(moduleName) {
  console.log('Require invoked for module:' + moduleName);
  var id = require.resolve(moduleName);  // [1]
  if (require.cache[id]) {  // [2]
    return require.cache[id].exports;
  }
  // module metadata
  var module = {  // [3]
    exports: {},
    id: id
  };
  // Update the cache
  require.cache[id] = module;  // [4]

  // load the module
  loadModule(id, module, require);  // [5]

  // return exported variables
  return module.exports;  // [6]
};
require.cache = {};
require.resolve = function(moduleName) {
  /* resolve a full module id from the moduleName */
}

1. 通过模块名称, 使用require.resolve来解析, 得到模块的完整路径.

2. 判断id是否已经存在于缓存之中(即是否已加载).

3. 如果未加载, 则定义一个module包含exports和id.

4. 将此变量缓存起来.

5. 读取文件id的内容, 并与之前的module关联起来.

6. 导出其相应的模块.

备注: 这仅仅只是一个例子, [5]的操作会将文件的加载和module关联起来。

定义一个模块

// load another dependency
var dependency = require('./anotherModule');

// a private function
function log() {
  console.log('Well done ' + dependency.username);
}

// the API to be exported for public use
module.exports.run = function() {
  log();
};

模块内的所有变量都是私有的, 除非调用module.exports将变量暴露出去. 而使用require进行模块的调用.

虽然模块内的变量都是私有的, 但Node.js支持特殊的变量global, 任何赋值给global的都是全局变量.

exports和module.exports的区别

exports和module.exports本质上并没有什么区别, 如下的exports代码:

exports.hello = function() {
  console.log('hello');
}

exports.world = function() {
  console.log('world');
}

等价于:

module.exports = {
  hello: function() {
    console.log('hello');
  },
  world: function() {
    console.log('world');
  }
};

require是同步的

加载一个模块是同步操作, 以下代码是错误的:

setTimeout(function() {
  module.exports = function() {...};
}, 100);

resolving算法

File modules: 如果模块名称以"/"开头, 则为绝对路径. 如果以"./"开头, 则为相对路径.

Core modules: 如果模块不以"/"和"./"开头, 则从Node.js的核心模块进行寻找.

Package modules: 如果核心模块没有找到, 则从当前目录的node_modules进行查找, 找不到则向父目录继续查找...

针对目录或者文件, 则算法优先匹配:

<moduleName>.js

<moduleName>/index.js

<moduleName>/package.json中指定的文件.

一个具体的例子, 考虑如下的目录结构:

1. 在/myApp/foo.js中require('depA'), 则加载/myApp/node_modules/depA/index.js

2. 在/myApp/node_modules/depB/bar中require('depA'), 则加载/myApp/node_modules/depB/node_modules/depA/index.js

模块缓存

每个模块在第一次require时候会被加载和计算, 之后多次的加载均从缓存中读取. 

缓存的作用有两个:1是避免模块加载中的循环依赖. 2是保证同一个模块被多次加载, 则输出一样.

Module a.js:

exports.loaded = false;
var b = require('./b');
module.exports = {
  bWasLoaded: b.loaded,
  loaded: true
};

Module b.js:

exports.loaded = false;
var a = require('./a');
module.exports = {
  aWasLoaded: a.loaded,
  loaded: true
};

test.js:

var a = require('./a');
var b = require('./b');
console.log(a);
console.log(b);

输出:

leicj@leicj:~/test$ node test.js
{ bWasLoaded: true, loaded: true }
{ aWasLoaded: false, loaded: true }

 

观察者模型

观察者模型: 定义一个观察对象, 用于监测一个对象集合.

观察者模型不同于回调模型的一点在于: 它可以观察多个对象, 而每个对象被触发后所执行的动作可能不同, 而回调函数只有一个。

EventEmitter

var EventEmitter = require('events').EventEmitter;

class MyEmitter extends EventEmitter {}

var myEmitter = new MyEmitter();

myEmitter.on('ok', () => {
  console.log('ok');
});

myEmitter.on('error', (err) => {
  console.log(err.message);
});

myEmitter.emit('ok');
myEmitter.emit('error', new Error('err.....'));

一个典型的EventEmitter的实现如上, 可视化如下:

EventEmitter所给予的基本操作如下:

on(event, listener): 给特定一个事件注册一个监听处理函数.

once(event, listener): 给特定一个事件注册一个监听处理函数, 当此事件触发后移除处理函数.

emit(event, [arg1], [...]): 触发一个事件, 并传递特定的参数.

removeListener(event, listener): 移除特定时间的监听处理函数.

 

对于错误来说, 一般都是注册一个error事件, 然后emit('error', new Error());

 

如果我们想继承EventEmitter, 则可以如下编写代码:

const EventEmitter = require('events').EventEmitter;
const fs = require('fs');

class FindPattern extends EventEmitter {
  constructor(regex) {
    super();
    this.regex = regex;
    this.files = [];
  }

  addFile(file) {
    this.files.push(file);
    return this;
  }

  find() {
    this.files.forEach(file => {
      fs.readFile(file, 'utf8', (err, content) => {
        if (err) return this.emit('error', err);
        this.emit('fileread', file);

        let match = null;
        if (match = content.match(this.regex)) {
          match.forEach(elem => this.emit('found', file, elem));
        }
      });
    });
    return this;
  }
}

const findPatternObject = new FindPattern(/hello \w+/);
findPatternObject.addFile('fileA.txt')
  .addFile('fileB.txt')
  .find()
  .on('found', (file, match) => console.log(`Matched "${match} in file ${file}`))
  .on('error', err => console.log(`Error emitted ${err.message}`));

 

而对于EventEmitter来说, 它是同步注册的, 即遵循: 先注册, 后触发原则. 所以下例代码并无输出:

const EventEmitter = require('events').EventEmitter;
class MyEmitter extends EventEmitter {}

const myEmitter = new MyEmitter();

myEmitter.emit('ok');

myEmitter.on('ok', () => {
    console.log('....');
});

 

而使用EventEmitter还是callbacks的一个原则是: EventEmitter用于处理"当某件事发生时候要做什么", 而callbacks用于处理:"当某件事处理完毕时候一定要做什么".

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部