Node.js 指南(域模块剖析)

域模块剖析

可用性问题

隐式行为

开发人员能够建立新域,而后只需运行domain.enter(),而后,它充当未来抛出者没法观察到的任何异常的万能捕捉器,容许模块做者拦截不一样模块中不相关代码的异常,防止代码的发起者知道本身的异常。node

如下是一个间接连接模块如何影响另外一个模块的示例:segmentfault

// module a.js
const b = require('./b');
const c = require('./c');


// module b.js
const d = require('domain').create();
d.on('error', () => { /* silence everything */ });
d.enter();


// module c.js
const dep = require('some-dep');
dep.method();  // Uh-oh! This method doesn't actually exist.

因为模块b进入域但从不退出,任何未捕获的异常都将被吞噬,不让模块c知道它为何没有运行整个脚本,留下可能部分填充的module.exports。这样作与监听'uncaughtException'不一样,由于后者明确意味着全局捕获错误,另外一个问题是在任何'uncaughtException'处理程序以前处理域,并阻止它们运行。服务器

另外一个问题是,若是事件发射器上没有设置'error'处理程序,域会自动路由错误,对此没有可选的插入机制,而是自动跨整个异步链传播。这看起来彷佛颇有用,可是一旦异步调用深度为两个或更多模块,其中一个不包含错误处理程序,域的建立者将忽然捕获意外异常,而且抛出者的异常将被做者忽视。less

如下是一个简单的示例,说明缺乏'error'处理程序如何容许活动域拦截错误:dom

const domain = require('domain');
const net = require('net');
const d = domain.create();
d.on('error', (err) => console.error(err.message));

d.run(() => net.createServer((c) => {
  c.end();
  c.write('bye');
}).listen(8000));

即便经过d.remove(c)手动删除链接也不会阻止链接的错误被自动拦截。异步

困扰错误路由和异常处理的失败是错误被冒出的不一致,如下是嵌套域如何根据它们什么时候发生以及不会使异常冒出的示例:socket

const domain = require('domain');
const net = require('net');
const d = domain.create();
d.on('error', () => console.error('d intercepted an error'));

d.run(() => {
  const server = net.createServer((c) => {
    const e = domain.create();  // No 'error' handler being set.
    e.run(() => {
      // This will not be caught by d's error handler.
      setImmediate(() => {
        throw new Error('thrown from setImmediate');
      });
      // Though this one will bubble to d's error handler.
      throw new Error('immediately thrown');
    });
  }).listen(8080);
});

能够预期嵌套域始终保持嵌套,并始终将异常传播到域堆栈中,或者异常永远不会自动冒出,不幸的是,这两种状况都会发生,致使可能使人困惑的行为甚至可能难以调试时序冲突。async

API差距

虽然基于使用EventEmitter的 API可使用bind(),而errback风格的回调可使用intercept(),可是隐式绑定到活动域的替代API必须在run()内部执行。这意味着若是模块做者想要使用替代那些提到的机制来支持域,则他们必须本身手动实现域支持,而不是可以利用现有的隐式机制。函数

错误传播

若是可能的话,跨嵌套域传播错误并非直截了当的,现有文档显示了若是请求处理程序中存在错误,如何close() http服务器的简单示例,它没有解释的是若是请求处理程序为另外一个异步请求建立另外一个域实例,如何关闭服务器,使用如下做为错误传播失败的简单示例:post

const d1 = domain.create();
d1.foo = true;  // custom member to make more visible in console
d1.on('error', (er) => { /* handle error */ });

d1.run(() => setTimeout(() => {
  const d2 = domain.create();
  d2.bar = 43;
  d2.on('error', (er) => console.error(er.message, domain._stack));
  d2.run(() => {
    setTimeout(() => {
      setTimeout(() => {
        throw new Error('outer');
      });
      throw new Error('inner');
    });
  });
}));

即便在域实例用于本地存储的状况下,也能够访问资源,仍然没法让错误继续从d2传播回d1。快速检查可能告诉咱们,简单地从d2的域'error'处理程序抛出将容许d1而后捕获异常并执行其本身的错误处理程序,虽然状况并不是如此,检查domain._stack后,你会看到堆栈只包含d2

这可能被认为是API的失败,但即便它确实以这种方式运行,仍然存在传递异​​步执行中的分支失败的事实的问题,而且该分支中的全部进一步操做必须中止。在http请求处理程序的示例中,若是咱们触发多个异步请求,而后每一个异步请求将write()的数据发送回客户端,则尝试将write()发送到关闭的句柄会产生更多错误,

异常资源清理

如下脚本包含在给定链接或其任何依赖项中发生异常的状况下在小资源依赖关系树中正确清理的更复杂示例,将脚本分解为基本操做:

'use strict';

const domain = require('domain');
const EE = require('events');
const fs = require('fs');
const net = require('net');
const util = require('util');
const print = process._rawDebug;

const pipeList = [];
const FILENAME = '/tmp/tmp.tmp';
const PIPENAME = '/tmp/node-domain-example-';
const FILESIZE = 1024;
let uid = 0;

// Setting up temporary resources
const buf = Buffer.alloc(FILESIZE);
for (let i = 0; i < buf.length; i++)
  buf[i] = ((Math.random() * 1e3) % 78) + 48;  // Basic ASCII
fs.writeFileSync(FILENAME, buf);

function ConnectionResource(c) {
  EE.call(this);
  this._connection = c;
  this._alive = true;
  this._domain = domain.create();
  this._id = Math.random().toString(32).substr(2).substr(0, 8) + (++uid);

  this._domain.add(c);
  this._domain.on('error', () => {
    this._alive = false;
  });
}
util.inherits(ConnectionResource, EE);

ConnectionResource.prototype.end = function end(chunk) {
  this._alive = false;
  this._connection.end(chunk);
  this.emit('end');
};

ConnectionResource.prototype.isAlive = function isAlive() {
  return this._alive;
};

ConnectionResource.prototype.id = function id() {
  return this._id;
};

ConnectionResource.prototype.write = function write(chunk) {
  this.emit('data', chunk);
  return this._connection.write(chunk);
};

// Example begin
net.createServer((c) => {
  const cr = new ConnectionResource(c);

  const d1 = domain.create();
  fs.open(FILENAME, 'r', d1.intercept((fd) => {
    streamInParts(fd, cr, 0);
  }));

  pipeData(cr);

  c.on('close', () => cr.end());
}).listen(8080);

function streamInParts(fd, cr, pos) {
  const d2 = domain.create();
  const alive = true;
  d2.on('error', (er) => {
    print('d2 error:', er.message);
    cr.end();
  });
  fs.read(fd, Buffer.alloc(10), 0, 10, pos, d2.intercept((bRead, buf) => {
    if (!cr.isAlive()) {
      return fs.close(fd);
    }
    if (cr._connection.bytesWritten < FILESIZE) {
      // Documentation says callback is optional, but doesn't mention that if
      // the write fails an exception will be thrown.
      const goodtogo = cr.write(buf);
      if (goodtogo) {
        setTimeout(() => streamInParts(fd, cr, pos + bRead), 1000);
      } else {
        cr._connection.once('drain', () => streamInParts(fd, cr, pos + bRead));
      }
      return;
    }
    cr.end(buf);
    fs.close(fd);
  }));
}

function pipeData(cr) {
  const pname = PIPENAME + cr.id();
  const ps = net.createServer();
  const d3 = domain.create();
  const connectionList = [];
  d3.on('error', (er) => {
    print('d3 error:', er.message);
    cr.end();
  });
  d3.add(ps);
  ps.on('connection', (conn) => {
    connectionList.push(conn);
    conn.on('data', () => {});  // don't care about incoming data.
    conn.on('close', () => {
      connectionList.splice(connectionList.indexOf(conn), 1);
    });
  });
  cr.on('data', (chunk) => {
    for (let i = 0; i < connectionList.length; i++) {
      connectionList[i].write(chunk);
    }
  });
  cr.on('end', () => {
    for (let i = 0; i < connectionList.length; i++) {
      connectionList[i].end();
    }
    ps.close();
  });
  pipeList.push(pname);
  ps.listen(pname);
}

process.on('SIGINT', () => process.exit());
process.on('exit', () => {
  try {
    for (let i = 0; i < pipeList.length; i++) {
      fs.unlinkSync(pipeList[i]);
    }
    fs.unlinkSync(FILENAME);
  } catch (e) { }
});
  • 当新链接发生时,同时:

    • 在文件系统上打开一个文件
    • 打开管道到独惟一的socket
  • 异步读取文件的块
  • 将块写入TCP链接和任何监听sockets
  • 若是这些资源中的任何一个发生错误,请通知全部其余附加资源,他们须要清理和关闭它们

正如咱们从这个例子中能够看到的,当出现故障时,必须采起更多措施来正确清理资源,而不是经过域API严格完成,全部域提供的都是异常聚合机制。即便在域中传播数据的潜在有用能力也容易被抵消,在本例中,经过将须要的资源做为函数参数传递。

尽管存在乎外的异常,但应用领域的一个问题仍然是可以继续执行(与文档所述相反)的简单性,这个例子证实了这个想法背后的谬论。

随着应用程序自己的复杂性增长,尝试对意外异常进行适当的资源清理会变得更加复杂,此示例仅具备3个基本资源,而且全部资源都具备明确的依赖路径,若是应用程序使用共享资源或资源重用之类的东西,那么清理能力和正确测试清理工做的能力就会大大增长。

最后,就处理错误而言,域不只仅是一个美化的'uncaughtException'处理程序,除了第三方更隐式和不可观察的行为。

资源传播

域的另外一个用例是使用它来沿异步数据路径传播数据,一个问题在于,当堆栈中有多个域时(若是异步堆栈与其余模块一块儿工做,则必须假定),什么时候指望正确的域是模糊的。此外,可以依赖域进行错误处理同时还能够检索必要的数据之间存在冲突。

下面是一个使用域沿着异步堆栈传播数据失败的示例:

const domain = require('domain');
const net = require('net');

const server = net.createServer((c) => {
  // Use a domain to propagate data across events within the
  // connection so that we don't have to pass arguments
  // everywhere.
  const d = domain.create();
  d.data = { connection: c };
  d.add(c);
  // Mock class that does some useless async data transformation
  // for demonstration purposes.
  const ds = new DataStream(dataTransformed);
  c.on('data', (chunk) => ds.data(chunk));
}).listen(8080, () => console.log('listening on 8080'));

function dataTransformed(chunk) {
  // FAIL! Because the DataStream instance also created a
  // domain we have now lost the active domain we had
  // hoped to use.
  domain.active.data.connection.write(chunk);
}

function DataStream(cb) {
  this.cb = cb;
  // DataStream wants to use domains for data propagation too!
  // Unfortunately this will conflict with any domain that
  // already exists.
  this.domain = domain.create();
  this.domain.data = { inst: this };
}

DataStream.prototype.data = function data(chunk) {
  // This code is self contained, but pretend it's a complex
  // operation that crosses at least one other module. So
  // passing along "this", etc., is not easy.
  this.domain.run(() => {
    // Simulate an async operation that does the data transform.
    setImmediate(() => {
      for (let i = 0; i < chunk.length; i++)
        chunk[i] = ((chunk[i] + Math.random() * 100) % 96) + 33;
      // Grab the instance from the active domain and use that
      // to call the user's callback.
      const self = domain.active.data.inst;
      self.cb(chunk);
    });
  });
};

以上显示,很难有多个异步API尝试使用域来传播数据,能够经过在DataStream构造函数中分配parent: domain.active来修复此示例,而后在调用用户的回调以前经过domain.active = domain.active.data.parent恢复它。另外,'connection'回调中的DataStream实例化必须在d.run()中运行,而不是简单地使用d.add(c),不然将没有活动域。

简而言之,为此祈祷有机会使用,须要严格遵照一套难以执行或测试的准则。

性能问题

使用域的重要威胁是开销,使用node的内置http基准测试http_simple.js,没有域,它能够处理超过22,000个请求/秒。若是它在NODE_USE_DOMAINS=1下运行,那么该数字会降低到低于17,000个请求/秒,在这种状况下,只有一个全局域。若是咱们编辑基准测试,那么http请求回调会建立一个新的域实例,性能会进一步降低到15,000个请求/秒。

虽然这可能不会影响仅服务于每秒几百甚至一千个请求的服务器,但开销量与异步请求的数量成正比,所以,若是单个链接须要链接到其余几个服务,则全部这些服务都会致使将最终产品交付给客户端的整体延迟。

使用AsyncWrap并跟踪在上述基准测试中调用init/pre/post/destroy的次数,咱们发现全部被调用事件的总和超过每秒170,000次,这意味着即便为每种调用增长1微秒的开销,任何类型的设置或拆除都会致使17%的性能损失。

固然,这是针对基准测试的优化方案,但我相信这演示了域等机制尽量廉价运行的必要性。

展望将来

域模块自2014年12月以来一直被软弃用,但还没有被删除,由于node目前没有提供替代功能,在撰写本文时,正在进行构建AsyncWrap API的工做以及为TC39准备区域的提议,在这种状况下,有适当的功能来替换域,它将经历彻底弃用周期并最终从核心中删除。


上一篇:流中的背压

下一篇:如何发布N-API包

相关文章
相关标签/搜索