深入理解异步事件驱动编程(三)

介绍

介绍同深入理解异步事件驱动编程(一)

并发和错误

Node 社区的成员每天都在开发新的包和项目。由于 Node 的事件性质,回调渗透到这些代码库中。我们已经考虑了事件可能如何通过回调排队、分发和处理的关键方式。让我们花点时间概述最佳实践,特别是关于设计回调和处理错误的约定,并讨论在设计复杂的事件和回调链时一些有用的模式。特别是,让我们看看在本书中会看到的新 Promise、Generator 和 async/await 模式,以及现代 Node 代码的其他示例。

并发管理

自从项目开始以来,简化控制流一直是 Node 社区关注的问题。事实上,这种潜在的批评是Ryan Dahl在向 JavaScript 开发者社区介绍 Node 时讨论的第一个预期批评之一。

由于延迟代码执行通常需要在回调中嵌套回调,因此 Node 程序有时会开始类似于侧向金字塔,也被称为"末日金字塔"。你见过吧:深度嵌套的代码,4 层或 5 层甚至更深,到处都是花括号。除了语法上的烦恼,你也可以想象在这样的调用堆栈中跟踪错误可能会很困难------如果第三层的回调抛出异常,谁负责处理这个错误?第二层吗?即使第二层正在读取文件,第三层正在查询数据库?这有意义吗?很难理解异步程序流的含义。

回调

幸运的是,Node 的创建者们早早就就如何构造回调达成了理智的共识。遵循这一传统是很重要的。偏离会带来意外,有时是非常糟糕的意外,总的来说,这样做会自动使 API 变得笨拙,而其他开发人员会迅速厌倦。

一个要么通过执行callback返回函数结果,要么处理callback接收到的参数,要么在 API 中设计callback的签名。无论考虑的是哪种情况,都应该遵循与该情况相关的惯例。

传递给callback函数的第一个参数是任何错误消息,最好是以错误对象的形式。如果不需要报告错误,这个位置应该包含一个空值。

当将callback传递给函数时,它应该被分配到函数签名的最后一个位置。API 应该一贯地按照这种方式设计。

在错误和callback之间可能存在任意数量的参数。

创建错误对象:new Error("Argument must be a String!")

Promises

就像一些政客一样,Node 核心在支持 Promises 之前反对它们。Mikeal Rogers 在讨论为什么 Promises 从最初的 Node 核心中被移除时,提出了一个强有力的论点,即将功能开发留给社区会导致更强大的核心产品。你可以在这里查看这个讨论:web.archive.org/posts/broken-promises.html

从那时起,Promises 已经获得了非常庞大的追随者,Node 核心也做出了改变。Promises 本质上是标准回调模式的替代品,而标准回调模式在 Node 中随处可见。曾经,你可能会这样写:

javascript 复制代码
API.getUser(loginInfo, function(err, user) {
    API.getProfile(user, function(err, profile) {
        // ...and so on
    }
});

如果 API 改为"Promisified"(回想一下前一章中的util.promisify?),你对前面的异步控制流的描述将使用 Promise 链来描述:

ini 复制代码
let promiseProfile = API.getUser(loginInfo)
.then(user => API.getProfile(user))
.then(profile => {
    // do something with #profile
})
.catch(err => console.log(err))

这至少是一个更紧凑的语法,读起来更容易一些,操作的链条更长;然而,这里有更多有价值的东西。

promiseProfile引用了一个 Promise 对象。Promises 只执行一次,达到错误状态(未完成)或完成状态,你可以通过then提取最后的不可变值,就像我们之前对 profile 所做的那样。当然,Promises 可以被分配给一个变量,并且该变量可以传递给尽可能多的消费者,甚至在解决之前。由于then只有在有值可用时才会被调用,无论何时,Promises 都被称为未来状态的承诺。

也许最重要的是,与回调不同,Promises 能够管理许多异步操作中的错误。如果你回头看一下本节开头的示例回调代码,你会看到每个回调中都有err参数,反映了 Node 的核心错误优先回调风格。每个错误对象都必须单独处理,因此前面的代码实际上会开始看起来更像这样:

javascript 复制代码
API.getUser(loginInfo, function(err, user) {
  if(err) {
    throw err;
  }
  API.getProfile(user, function(err, profile) {
    if(err) {
      throw err;
    }
    // ...and so on
  }
});

观察每个错误条件必须单独处理。在实践中,开发人员希望对这段代码进行"手动"包装,比如使用try...catch块,以某种方式捕获这个逻辑单元中的所有错误并以集中的方式进行管理。

使用 Promises,你可以免费获得这些。任何catch语句都会捕获链中之前的任何then抛出的错误。这使得创建一个通用的错误处理程序变得轻而易举。更重要的是,Promises 允许执行链在错误发生后继续。你可以将以下内容添加到前面的 Promise 链中:

javascript 复制代码
.catch(err => console.log(err))
.then(() => // this happens no matter what happened previously)

通过 Promises,你可以在更少的空间中组合相当复杂的异步逻辑流,缩进有限,错误处理更容易处理,值是不可变的且可交换的。

Promise 对象的另一个非常有用的特性是,这些未来解析的状态可以作为一个块来管理。例如,想象一下,为了满足对用户配置文件的查询,你需要进行三次数据库调用。与其总是串行地链式调用这些调用,你可以使用Promise.all

ini 复制代码
const db = {
  getFullName: Promise.resolve('Jack Spratt'),
  getAddress: Promise.resolve('10 Clean Street'),
  getFavorites: Promise.resolve('Lean'),
};

Promise.all([
  db.getFullName() 
  db.getAddress() 
  db.getFavorites() 
])
.then(results => {
  // results = ['Jack Spratt', '10 Clean Stree', 'Lean']
})
.catch(err => {...})

在这里,所有三个 Promise 将被同时触发,并且将并行运行。并行运行调用当然比串行运行更有效率。此外,Promise.all保证最终的 thenable 接收到一个按照调用者位置同步结果位置排序的结果数组。

你最好熟悉一下完整的 Promise API,你可以在 MDN 上阅读:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

尽管 Promises 现在是原生的,但仍然存在一个"用户空间"模块,bluebird,它继续提供一个引人注目的替代 Promises 实现,具有附加功能,通常执行速度更快。你可以在这里阅读更多关于 bluebird 的信息:bluebirdjs.com/docs/api-reference.html

async/await

与其用一个专门的数据结构来包装满足条件,比如一个带有许多函数块和括号和特殊上下文的 Promise,为什么不简单地让异步表达式既能实现异步执行,又能实现程序的进一步执行(同步)直到解决?

await操作符用于等待一个 Promise。它只在async函数内部执行。async/await并发建模语法自 Node 8.x 以来就可用。这里演示了async/await被用来复制之前的Promise.all的例子:

javascript 复制代码
const db = {
  getFullName: Promise.resolve('Jack Spratt'),
  getAddress: Promise.resolve('10 Clean Street'),
  getFavorites: Promise.resolve('Lean'),
}

async function profile() {
  let fullName = await db.getFullName() // Jack Spratt
  let address = await db.getAddress() // 10 Clean Street
  let favorites = await db.getFavorites() // Lean

  return {fullName, address, favorites};
}

profile().then(res => console.log(res) // results = ['Jack Spratt', '10 Clean Street', 'Lean'

不错,对吧?你会注意到profile()返回了一个 Promise。一个async函数总是返回一个 Promise,尽管我们在这里看到,函数本身可以返回任何它想要的东西。

Promises 和async/await像老朋友一样合作。这里有一个递归目录遍历器,演示了这种合作:

ini 复制代码
const {join} = require('path');
const {promisify} = require('util');
const fs = require('fs');
const readdir = promisify(fs.readdir);
const stat = promisify(fs.stat);

async function $readDir (dir, acc = []) {
  await Promise.all((await readdir(dir)).map(async file => {
    file = join(dir, file);
    return (await stat(file)).isDirectory() && acc.push(file) && $readDir(file, acc);
  }));
  return acc;
}

$readDir(`./dummy_filesystem`).then(dirInfo => console.log(dirInfo));

// [ 'dummy_filesystem/folderA',
// 'dummy_filesystem/folderB',
// 'dummy_filesystem/folderA/folderA-C' ]

这个递归目录遍历器的代码非常简洁,只比上面的设置代码稍长一点。由于await期望一个 Promise,而Promise.all将返回一个 Promise,所以通过readDir Promise 返回的每个文件运行,然后将每个文件映射到另一个等待的 Promise,该 Promise 将处理任何递归进入子目录,根据需要更新累加器。这样阅读,Promise.all((await readdir(dir)).map的结构读起来不像一个基本的循环结构,其中深层异步递归以一种简单易懂的过程化、同步的方式进行建模。

一个纯 Promise 的替代版本可能看起来像这样,假设与async/await版本相同的依赖关系:

ini 复制代码
function $readDir(dir, acc=[]) {
  return readdir(dir).then(files => Promise.all(files.map(file => {
    file = join(dir, file);
    return stat(file).then(fobj => {
      if (fobj.isDirectory()) {
        acc.push(file);
        return $readDir(file, acc);
      }
    });
  }))).then(() => acc);
};

这两个版本都比回调函数更清晰。async/await版本确实兼顾了两者的优点,并创建了一个简洁的表示,类似于同步代码,可能更容易理解和推理。

使用async/await进行错误处理也很容易,因为它不需要任何特殊的新语法。对于 Promises 和catch,同步代码错误存在一个小问题。Promises 捕获发生在then块中的错误。例如,如果你的代码调用的第三方库抛出异常,那么该代码不会被 Promise 包装,而且该错误*不会被catch*捕获。

使用async/await,你可以使用熟悉的try...catch语句:

javascript 复制代码
async function makeError() {
    try {
        console.log(await thisDoesntExist());
    } catch (error) {
        console.error(error);
    }
}

makeError();

这避免了所有特殊错误捕获结构的问题。这种原生的、非常可靠的方法将捕获try块中任何地方抛出的任何东西,无论执行是同步还是异步。

生成器和迭代器

生成器是可以暂停和恢复的函数执行上下文。当你调用一个普通函数时,它可能会return一个值;函数完全执行,然后终止。生成器函数将产生一个值然后停止,但是生成器的函数上下文不会被销毁(就像普通函数一样)。你可以在以后的时间点重新进入生成器并获取更多的结果。

一个例子可能会有所帮助:

lua 复制代码
function* threeThings() {
    yield 'one';
    yield 'two';
    yield 'three';
}

let tt = threeThings();

console.log(tt); // {} 
console.log(tt.next()); // { value: 'one', done: false }
console.log(tt.next()); // { value: 'two', done: false }
console.log(tt.next()); // { value: 'three', done: false }
console.log(tt.next()); // { value: undefined, done: true }

通过在生成器上标记一个星号(*)来声明生成器。在第一次调用threeThings时,我们不会得到一个结果,而是得到一个生成器对象。

生成器符合新的 JavaScript 迭代协议(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#iterator),对于我们的目的来说,这意味着生成器对象公开了一个next方法,该方法用于从生成器中提取尽可能多的值。这种能力来自于生成器实现了 JavaScript 迭代协议。那么,什么是迭代器?

正如developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators所说,

"当对象知道如何一次从集合中访问一个项,并跟踪其在该序列中的当前位置时,它就是一个迭代器。在 JavaScript 中,迭代器是提供了一个 next()方法的对象,该方法返回序列中的下一个项。此方法返回一个具有两个属性的对象:done 和 value。"

我们可以仅使用迭代器来复制生成器示例:

lua 复制代码
function demoIterator(array) {
  let idx = 0;
  return {
    next: () => {
      return idx < array.length ? {
        value: array[idx++],
        done: false
      } : { done: true };
    }
  };
}
let it = demoIterator(['one', 'two', 'three']);
console.log(it); // { next: [Function: next] }
console.log(it.next()); // { value: 'one', done: false }
console.log(it.next()); // { value: 'two', done: false }
console.log(it.next()); // { value: 'three', done: false }
console.log(it.next()); // { done: true }

你会注意到,结果与生成器示例几乎相同,但在第一个结果中有一个重要的区别:迭代器只是一个具有 next 方法的对象。它必须完成维护自己的内部状态的所有工作(在先前的示例中跟踪idx)。生成器是迭代器的工厂;此外,它们完成了维护和产生自己的状态的所有工作。

从迭代器继承,生成器产生具有两个属性的对象:

  • done :一个布尔值。如果为 true,则生成器表示它没有剩余的内容可以yield。如果你把生成器想象成流(这不是一个坏的类比),那么你可能会将这种模式与流结束时Readable.read()返回 null 的模式进行比较(或者如果你愿意,也可以将其与Readable在完成时推送 null 的方式进行比较)。
  • value :最后一个yield的值。如果done为 true,则应该忽略。

生成器被设计用于迭代上下文,与循环类似,提供了函数执行上下文的强大优势。你可能已经写过类似这样的代码:

scss 复制代码
function getArraySomehow() {
  // slice into a copy; don't send original
  return ['one','two','buckle','my','shoe'].slice(0); 
}

let state = getArraySomehow();
for(let x=0; x < state.length; x++) {
    console.log(state[x].toUpperCase());
}

这是可以的,但也有缺点,比如需要创建对外部数据提供程序的本地引用,并在此块或函数终止时维护该引用。我们应该将state设置为全局变量吗?它应该是不可变的吗?例如,如果底层数据发生变化,例如向数组添加了一个新元素,我们如何确保state被更新,因为它与我们应用程序的真实状态是断开的?如果有什么意外地覆盖了state会怎么样?数据观察和绑定库存在,设计理论存在,框架存在,可以正确地封装数据源并将不可变版本注入执行上下文;但如果有更好的方法呢?

生成器可以包含和管理自己的数据,并且即使发生变化也可以yield正确的答案。我们可以使用生成器实现先前的代码:

ini 复制代码
function* liveData(state) {
    let state = ['one','two','buckle','my','shoe'];
    let current;

    while(current = state.shift()) {
        yield current;
    }
}

let list = liveData([]);
let item;
while (item = list.next()) {
    if(!item.value) {
        break;
    }
    console.log('generated:', item.value);
}

生成器方法处理所有发送回值的"样板",并自然地封装了状态。但在这里似乎没有显著的优势。这是因为我们正在使用生成器执行顺序和立即运行的迭代。生成器实际上是用于承诺一系列值的情况,只有在请求时才生成单个值,随着时间的推移。我们真正想要创建的不是一次性按顺序处理数组,而是创建一个连续的通信过程链,每个过程"tick"都计算一个结果,并能看到先前过程的结果。

考虑以下情况:

javascript 复制代码
function* range(start=1, end=2) {
    do {
        yield start;
    } while(++start <= end)
}

for (let num of range(1, 3)) {
    console.log(num);
}
// 1
// 2
// 3

您可以向生成器传递参数。我们通过传递范围边界来创建一个range状态机,进一步调用该机器将导致内部状态改变,并将当前状态表示返回给调用者。虽然为了演示目的,我们使用了遍历迭代器(因此生成器)的for...of方法,但这种顺序处理(会阻塞主线程直到完成)可以被异步化

生成器的运行/暂停(而不是运行/停止)设计意味着我们可以将迭代看作不是遍历列表,而是捕获一组随时间变化的过渡事件。这个想法对于响应式编程en.wikipedia.org/wiki/Reactive_programming)是核心的。让我们通过另一个例子来思考一下生成器的这种特殊优势。

对于这些类型的数据结构,还有许多其他操作。这样想可能会有所帮助:生成器对未来值的序列就像 Promises 对单个未来值一样。Promises 和生成器都可以在生成时传递(即使有些最终值仍在解析中,或者尚未排队等待解析),一个通过next()接口获取值,另一个通过then()接口获取值。

错误和异常

一般来说,在编程中,术语错误异常经常可以互换使用。在 Node 环境中,这两个概念并不相同。错误和异常是不同的。此外,在 Node 中,错误和异常的定义并不一定与其他语言和开发环境中类似的定义相一致。

在 Node 程序中,错误 条件通常是应该被捕获和处理的非致命条件,最明显地体现在典型的 Node 回调模式所显示的错误作为第一个参数 约定中。异常是一个严重的错误(系统错误),一个明智的环境不应该忽视或尝试处理。

在 Node 中会遇到四种常见的错误上下文,并且应该有可预测的响应:

  • 同步上下文 :这通常发生在函数的上下文中,检测到错误的调用签名或其他非致命错误。函数应该简单地返回一个错误对象;new Error(...),或者其他一致的指示函数调用失败的指示器。
  • 异步上下文 :当期望通过触发callback函数来响应时,执行上下文应该传递一个Error对象,并将适当的消息作为该callback的第一个参数。
  • 事件上下文 :引用 Node 文档:"当EventEmitter实例遇到错误时,典型的操作是触发一个错误事件。错误事件在 node 中被视为特殊情况。如果没有监听器,那么默认操作是打印堆栈跟踪并退出程序。"在预期的情况下使用事件。
  • Promise 上下文 :Promise 抛出或以其他方式被拒绝,并且此错误在.catch块中被捕获。重要提示:您应该始终使用真正的Error对象拒绝 Promises。 Petka Antonov ,流行的 Bluebird Promises 实现的作者,讨论了为什么:github.com/petkaantonov/bluebird/blob/master/docs/docs/warning-explanations.md

显然,这些情况是在控制的方式下捕获错误,而不是在整个应用程序不稳定之前。在不过分陷入防御性编码的情况下,应该努力检查输入和其他来源的错误,并妥善处理它们。

始终返回正确的Error对象的另一个好处是可以访问该对象的堆栈属性。错误堆栈显示错误的来源,函数链中的每个链接以及导致错误的函数。典型的Error.stack跟踪看起来像这样:

javascript 复制代码
> console.log(new Error("My Error Message").stack);
 Error: My Error Message
     at Object.<anonymous> (/js/errorstack.js:1:75)
     at Module._compile (module.js:449:26)
     at Object.Module._extensions..js (module.js:467:10)
     ...

同样,堆栈始终可以通过console.trace方法获得:

javascript 复制代码
> console.trace("The Stack Head")
 Trace: The Stack Head
     at Object.<anonymous> (/js/stackhead.js:1:71)
     at Module._compile (module.js:449:26)
     at Object.Module._extensions..js (module.js:467:10)
     ...

应该清楚这些信息如何帮助调试,有助于确保我们应用程序的逻辑流是正确的。

正常的堆栈跟踪在十几个级别后会截断。如果更长的堆栈跟踪对您有用,请尝试Matt Inslerlongjohngithub.com/mattinsler/longjohn

此外,运行并检查您的捆绑包中的js/stacktrace.js文件,以获取有关在报告错误或测试结果时如何使用堆栈信息的一些想法。

异常处理是不同的。异常是意外或致命错误,已经使应用程序不稳定。这些应该小心处理;处于异常状态的系统是不稳定的,未来状态不确定,并且应该优雅地关闭和重新启动。这是明智的做法。

通常,异常在try/catch块中捕获:

csharp 复制代码
try {
  something.that = wontWork;
} catch (thrownError) {
  // do something with the exception we just caught
} 

在代码库中使用try/catch块并尝试预期所有错误可能变得难以管理和笨拙。此外,如果发生您没有预料到的异常,未捕获的异常会怎么样?您如何从上次中断的地方继续?

Node 没有标准内置的方法来处理未捕获的关键异常。这是平台的一个弱点。未捕获的异常将继续通过执行堆栈冒泡,直到它到达事件循环,在那里,就像在机器齿轮中的扳手一样,它将使整个进程崩溃。我们最好的办法是将uncaughtException处理程序附加到进程本身:

javascript 复制代码
process.on('uncaughtException', (err) => {
  console.log('Caught exception: ' + err);
 });

setTimeout(() => {
  console.log("The exception was caught and this can run.");
}, 1000);

throwAnUncaughtException();

// > Caught exception: ReferenceError: throwAnUncaughtException is not defined
// > The exception was caught and this can run.

虽然我们异常代码后面的内容都不会执行,但超时仍然会触发,因为进程设法捕获了异常,自救了。然而,这是处理异常的一种非常笨拙的方式。domain模块旨在修复 Node 设计中的这个漏洞,但它已经被弃用。正确处理和报告错误仍然是 Node 平台的一个真正弱点。核心团队正在努力解决这个问题:nodejs.org/en/docs/guides/domain-postmortem/

最近,引入了类似的机制来捕获无法控制的 Promise,当您未将 catch 处理程序附加到 Promise 链时会发生这种情况:

javascript 复制代码
process.on('unhandledRejection', (reason, Prom) => {
  console.log(`Unhandled Rejection: ${p} reason: ${reason}`);
});

unhandledRejection处理程序在 Promise 被拒绝并且在事件循环的一个回合内未附加错误处理程序时触发。

考虑事项

任何开发人员都在经常做出具有深远影响的决定。很难预测从新代码或新设计理论中产生的所有可能后果。因此,保持代码的简单形式并迫使自己始终遵循其他 Node 开发人员的常见做法可能是有用的。以下是一些您可能会发现有用的准则:

  • 通常,尽量追求浅层代码。这种重构在非事件驱动的环境中并不常见。通过定期重新评估入口和出口点以及共享函数来提醒自己。
  • 考虑使用不同的、可组合的微服务来构建你的系统,我们将在第九章中讨论,微服务
  • 在可能的情况下,为callback重新进入提供一个公共上下文。闭包在 JavaScript 中是非常强大的工具,通过扩展,在 Node 中也是如此,只要封闭的回调的上下文帧长度不过大。
  • 给你的函数命名。除了在深度递归结构中非常有用之外,当堆栈跟踪包含不同的函数名称时,调试代码会更容易,而不是匿名函数。
  • 认真考虑优先级。给定结果到达或callback执行的顺序实际上是否重要?更重要的是,它是否与 I/O 操作有关?如果是,考虑使用nextTicksetImmediate
  • 考虑使用有限状态机来管理你的事件。状态机在 JavaScript 代码库中非常少见。当callback重新进入程序流时,它很可能改变了应用程序的状态,而异步调用本身的发出很可能表明状态即将改变。

使用文件事件构建 Twitter 动态

让我们应用所学知识。目标是创建一个服务器,客户端可以连接并从 Twitter 接收更新。我们首先创建一个进程来查询 Twitter 是否有带有#nodejs标签的消息,并将找到的消息以 140 字节的块写入到tweets.txt文件中。然后,我们将创建一个网络服务器,将这些消息广播给单个客户端。这些广播将由tweets.txt文件上的写事件触发。每当发生写操作时,都会从上次已知的客户端读取指针异步读取 140 字节的块。这将一直持续到文件末尾,同时进行广播。最后,我们将创建一个简单的client.html页面,用于请求、接收和显示这些消息。

虽然这个例子显然是刻意安排的,但它展示了:

  • 监听文件系统的更改,并响应这些事件
  • 使用数据流事件来读写文件
  • 响应网络事件
  • 使用超时进行轮询状态
  • 使用 Node 服务器本身作为网络事件广播器

为了处理服务器广播,我们将使用服务器发送事件SSE)协议,这是 HTML5 的一部分,正在标准化的新协议。

首先,我们将创建一个 Node 服务器,监听文件的更改,并将任何新内容广播给客户端。打开编辑器,创建一个名为server.js的文件:

ini 复制代码
let fs = require("fs");
let http = require('http');

let theUser = null;
let userPos = 0;
let tweetFile = "tweets.txt";

我们将接受一个单个用户连接,其指针将是theUseruserPos将存储此客户端在tweetFile中上次读取的位置:

javascript 复制代码
http.createServer((request, response) => {
  response.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Access-Control-Allow-Origin': '*'
  });

  theUser = response;

  response.write(':' + Array(2049).join(' ') + '\n');
  response.write('retry: 2000\n');

  response.socket.on('close', () => {
    theUser = null;
  });
}).listen(8080);

创建一个监听端口8080的 HTTP 服务器,它将监听并处理单个连接,存储response参数,表示连接服务器和客户端的管道。response参数实现了可写流接口,允许我们向客户端写入消息:

ini 复制代码
let sendNext = function(fd) {
  let buffer = Buffer.alloc(140);
  fs.read(fd, buffer, 0, 140, userPos * 140, (err, num) => {
    if (!err && num > 0 && theUser) {
      ++userPos;
      theUser.write(`data: ${buffer.toString('utf-8', 0, num)}\n\n`);
      return process.nextTick(() => {
        sendNext(fd);
      });
    }
  });
};

我们创建一个函数来向客户端发送消息。我们将从绑定到我们的tweets.txt文件的可读流中拉取 140 字节的缓冲区,每次读取时将我们的文件位置计数器加一。我们将这个缓冲区写入到将我们的服务器与客户端绑定的可写流中。完成后,我们使用nextTick排队重复调用相同的函数,重复直到出现错误、不再接收数据或客户端断开连接:

scss 复制代码
function start() {
  fs.open(tweetFile, 'r', (err, fd) => {
    if (err) {
      return setTimeout(start, 1000);
    }
    fs.watch(tweetFile, (event, filename) => {
      if (event === "change") {
        sendNext(fd);
      }
    });
  });
};

start();

最后,我们通过打开tweets.txt文件并监视任何更改来启动这个过程,每当写入新的推文时调用sendNext。当我们启动服务器时,可能还没有存在要读取的文件,因此我们使用setTimeout进行轮询,直到存在一个文件。

现在我们有一个服务器在寻找文件更改以进行广播,我们需要生成数据。我们首先通过npm 为 Node 安装TWiT Twitter 包。

然后我们创建一个进程,其唯一工作是向文件写入新数据:

javascript 复制代码
const fs = require("fs");
const Twit = require('twit');

let twit = new Twit({
  consumer_key: 'your key',
  consumer_secret: 'your secret',
  access_token: 'your token',
  access_token_secret: 'your secret token'
});

要使用这个示例,您需要一个 Twitter 开发者帐户。或者,还有一个选项,可以更改相关代码,简单地将随机的 140 字节字符串写入tweets.txt: require("crypto").randomBytes(70).toString('hex'):

ini 复制代码
let tweetFile = "tweets.txt";
let writeStream = fs.createWriteStream(tweetFile, {
  flags: "a" // indicate that we want to (a)ppend to the file
});

这将建立一个流指针,指向我们的服务器将要监视的同一个文件。

我们将写入这个文件:

ini 复制代码
let cleanBuffer = function(len) {
  let buf = Buffer.alloc(len);
  buf.fill('\0');
  return buf;
};

因为 Twitter 消息永远不会超过 140 字节,所以我们可以通过始终写入 140 字节的块来简化读/写操作,即使其中一些空间是空的。一旦我们收到更新,我们将创建一个消息数量 x 140 字节宽的缓冲区,并将这些 140 字节的块写入该缓冲区:

ini 复制代码
let check = function() {
  twit.get('search/tweets', {
    q: '#nodejs since:2013-01-01'
  }, (err, reply) => {
    let buffer = cleanBuffer(reply.statuses.length * 140);
    reply.statuses.forEach((obj, idx) => {
      buffer.write(obj.text, idx*140, 140);
    });
    writeStream.write(buffer);
  })
  setTimeout(check, 10000);
};

check();

现在我们创建一个函数,每 10 秒被要求检查是否包含#nodejs标签的消息。Twitter 返回一个消息对象数组。我们感兴趣的是消息的#text属性。计算表示这些新消息所需的字节数(140 x 消息数量),获取一个干净的缓冲区,并用 140 字节的块填充它,直到所有消息都被写入。最后,这些数据被写入我们的tweets.txt文件,导致发生变化事件,我们的服务器得到通知。

最后一部分是客户端页面本身。这是一个相当简单的页面,它的操作方式应该对读者来说很熟悉。需要注意的是使用 SSE 监听本地主机上端口8080。当从服务器接收到新的推文时,应该清楚地看到一个列表元素被添加到无序列表容器#list中:

xml 复制代码
<!DOCTYPE html>
<html>
<head>
    <title></title>
</head>

<script>

window.onload = () => {
  let list = document.getElementById("list");
  let evtSource = new EventSource("http://localhost:8080/events");

  evtSource.onmessage = (e) => {
    let newElement = document.createElement("li");
    newElement.innerHTML = e.data;
    list.appendChild(newElement);
  }
}

</script>
<body>

<ul id="list"></ul>

</body>
</html>

要了解更多关于 SSE 的信息,请参阅第六章,创建实时应用程序

或者您可以访问:developer.mozilla.org/en-US/docs/Web/API/Server-sent_events

总结

使用事件进行编程并不总是容易的。控制和上下文切换,定义范式,通常会使新手对事件系统感到困惑。这种看似鲁莽的失控和由此产生的复杂性驱使许多开发人员远离这些想法。

通过研究架构问题的演变,Node 现在正试图解决网络应用程序的问题------在扩展和代码组织方面,一般数据和复杂性量级方面,状态意识方面,以及明确定义的数据和过程边界方面。我们学会了如何智能地管理这些事件队列。我们看到了不同的事件源如何可预测地堆叠以供事件循环处理,以及远期事件如何使用闭包和智能回调排序进入和重新进入上下文。我们还了解了新的 Promise、Generator 和 async/await 结构,旨在帮助管理并发。

相关推荐
橘右溪11 小时前
Node.js种os模块详解
node.js
HelloRevit11 小时前
npm install 版本过高引发错误,请添加 --legacy-peer-deps
前端·npm·node.js
Bl_a_ck13 小时前
npm、nvm、nrm
前端·vue.js·npm·node.js·vue
zhu_zhu_xia13 小时前
npm包管理工具理解
前端·npm·node.js
eason_fan14 小时前
解决nvm安装指定版本node失败的方法
前端·node.js
全栈派森15 小时前
Web认证宇宙漫游指南
python·node.js
夏虫不与冰语15 小时前
nvm切换node版本后,解决npm找不到的问题
node.js
冰墩墩115 小时前
使用nvm install XXX 下载node版本时网络不好导致npm下载失败解决方案
前端·npm·node.js
techdashen16 小时前
性能比拼: Node.js vs Go
开发语言·golang·node.js
Mintopia17 小时前
Node.js 对前端技术有利的知识点
前端·javascript·node.js