5

域模块剖析

可用性问题

隐式行为

开发人员可以创建新域,然后只需运行domain.enter(),然后,它充当将来抛出者无法观察到的任何异常的万能捕捉器,允许模块作者拦截不同模块中不相关代码的异常,防止代码的发起者知道自己的异常。

以下是一个间接链接模块如何影响另一个模块的示例:

// 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'处理程序,域会自动路由错误,对此没有可选的插入机制,而是自动跨整个异步链传播。这看起来似乎很有用,但是一旦异步调用深度为两个或更多模块,其中一个不包含错误处理程序,域的创建者将突然捕获意外异常,并且抛出者的异常将被作者忽视。

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

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)手动删除连接也不会阻止连接的错误被自动拦截。

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

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);
});

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

API差距

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

错误传播

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

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包

博弈
2.5k 声望1.5k 粉丝

态度决定一切