我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
归档 2016 年 10 月

我的 JavaScript 学习之路

源自我在知乎对 你们的 JavaScript 学习开发之路是怎样的? 的回答,补充了一些对时下热点问题的看法。

JavaScript 和方言

三年前,我当时作为服务器端工程师,从未系统地了解过 JavaScript,只是在少数需要改前端页面时才写过几句 JavaScript,正是因为这种不了解,写起来觉得很烦,也不喜欢 JavaScript.

但因为工作需要开始学习 Node.js,项目是用 CoffeeScript 写的,显然这又是一个我没见过的东西,但时间很紧,只看了一天 CoffeeScript 就开始写代码了,的确 CoffeeScript 的宽容度要比 JavaScript 好很多,语法很直观,也避开了 JavaScript 的一些坑。

写了大半年 Node.js 之后我喜欢上了 Node.js 和 CoffeeScript,但我依然不太喜欢 JavaScript,但毕竟代码最后是要被编译到 JavaScript 的,所以我还是认真地读完了「JavaScript 语言精粹」和「JavaScript 权威指南」。权威指南虽然很厚,但其实通篇都是平铺直叙的介绍,读起来并不是很累,小节之间关联也比较少,很适合碎片时间阅读。什么,你说权威指南太重了?不过我看的是电子版。

后来继续写了两年 Node.js,此时我 JavaScript 的编码经验依然为零 —— 我全部的代码都是 CoffeeScript。经过这么长时间,我对 JavaScript 的了解也非常深入了,所以对 JavaScript 也就没什么抗拒的情绪了 —— 所谓抗拒有时候只是自己不够了解

再后来换了一家公司,开始维护一些 JavaScript 的项目,在我写了两年多 Node.js 之后终于开始真正地写 JavaScript 了,虽然我已经读了很多书、写了大量编译到 JavaScript 的代码,但真正写起来还是遇到了很多麻烦 —— 这些在 CoffeeScript 中并不是问题:定义类、判等(两个等号和三个等号)、非空判断(a?.b?.c)、满屏的中括号和花括号。一些人喜欢拿 JavaScript 的设计缺陷说事,但哪个语言没点历史问题呢?如果一个缺陷是任何有基本 JavaScript 经验的人都可以绕开的,或者可以通过自动化的工具发现和修复的,那我觉得它可能不算一个问题,而仅仅是设计不完美而已。

经过原生 JavaScript 的折腾,我不像以前那么固执地用 CoffeeScript 来写所有的代码了,也开始尝试 TypeScript、Babel 之类其他的预编译语言,无数的预编译方言是 JavaScript 的闪光点之一 ,切换一下语言也会变化一下思路。

JavaScript 的特性

前端面试有一个被问烂了的问题就是「你如何理解 JavaScript 的闭包」,这的确是一个很难回答的问题。从本质上来看,闭包赋予了一个函数基于词法作用域去读取外层作用域的变量的能力 —— 即使外层作用域本来已经要被销毁了。从结果上来看,闭包赋予了函数拥有「内部状态」的能力,在实践上,我更多地会用闭包来实现轻量级的面向对象范式:通过一个函数来创建对象,这个对象中的方法可以访问到闭包中的一些内部状态。

所以其实我很少去和原型链打交道,个人感觉用闭包来实现面向对象要比用原型链简单、直观一些,尤其在 ES2015 之前并没有一个标准的定义类的方法。对于原型链我的理解就是对象可以有个「默认的属性来源」,当读取一个对象上不存在的属性时,就会到原型上查找,如果找不到就继续到原型的原型上查找,原型赋予了对象之间共享数据的能力。

很多 JavaScript 程序员吐槽异步回调的繁琐,但如果你了解过其他语言的多线程编程,会发现 JavaScript 是非常美好的 —— 同一时间只有一个线程在执行 JavaScript 代码,而且事件循环是以函数为单位的,在函数内你完全不需要考虑线程间同步的问题(在一些语言中多线程同时读写变量都是未定义行为),而且 JavaScript 中的异步任务并不会对应到操作系统中的线程,即使有大量并发任务也不会引入线程切换的开销,这也是大家说 Node.js 适合高并发场景的原因。从概念上来说事件模型肯定是要比手动同步的多线程要先进的,事件模型不可避免地会引入大量的回调,但它将运行时的不确定的线程安全问题转换到了编写代码时的一些「小麻烦」。而且社区中已经有很多方案去解决这些小麻烦,可以看到 Promise 和 async/await 其实只是表现层面的语法糖,并没有触及到核心的事件循环机
制,所以说异步回调的繁琐可能并不是一个不可解决的问题。

我觉得我在 JavaScript 学习上所走的最大的弯路就是比较晚才开始了解和使用 Promise。相比于编写 Callback 风格的异步代码,使用 Promise 意味着一种思路上的转变,虽然 Promise 的原理简单,但在具体的使用场景上还是需要自己做很多尝试的,例如具有分支的异步逻辑、循环地处理数据、逐级传递异常等。在使用 Promise 的过程中,也让我对「异常」有了更加深入的认识,异常是现代语言所提供的非常强大的流程控制机制,让本来唯一一条通常的、正确的执行路径变得可以从任何一处中断,并进入一个所谓的异常处理流程。

在基于命名空间的语言中,同一依赖的多版本并存问题一直是一个大坑,因为同一个库的多个版本拥有着相同的命名空间,不可避免地会出现冲突。而 JavaScript 是没有命名空间的,取而代之的是基于文件系统的模块机制,这给构建出复杂的依赖关系提供了可能,也让 JavaScript 的社区变得更加活跃,在 JavaScript 中我几乎从未操心过依赖的版本,只要安装自己需要的版本即可。

前端的 JavaScript

在熟悉了 JavaScript 之后,感觉其实前端并没有那么神秘了,大家都是 JavaScript,只不过调的 API 不同嘛,在架构上也无非是若干年前桌面编程已经踩过的那些坑。在此推荐一本「JavaScript Web Applications」,让我很快地对前端框架的实现有了一个概览性的了解。

最近几年前端的工具和框架更新极快,我觉得一方面是因为 JavaScript 语言本身在进化,所以构建工具方面变化很快了;另一方面的确前端,也就是 GUI 应用需要管理的状态远比服务器端要复杂 —— 来自服务器的状态和 GUI 上元素的状态需要相互地同步,而状态的变化也同时来自于服务器端和用户的操作。其实这是若干年前桌面应用已经走过的路,但因为之前浏览器端能力的限制,直到最近几年才涌现出这些新的尝试。

而我的选择是 React,作为服务器端工程师里前端水平尚可的人,我后来也用 React 写了一些内部站点(管理员后台之类的)。React 的渲染过程和后端的数据 API 很像,将数据和状态统一地存储于一处,每当数据变化时就进行一次完整的重新渲染,渲染过程是一个无状态也无副作用的纯函数,不需要在两次渲染之间维护状态,有一种函数风格的美感。

Node.js 错误处理实践

这篇文章由我九月末在 Node Patry 杭州 进行的一次技术分享整理而来。

今天我想介绍的是 Node.js 开发中一个很小,但又很重要的话题 —— 错误处理。作为一名软件工程师,我想我们应该都会认可「错误是无法避免的」,因此我们必须积极地去对待这些错误,才能写出健壮的代码。

首先,我想先介绍一下我们理想的错误处理是什么样的:

  • 出现错误时能将任务中断在一个合适的位置
  • 能记录错误的摘要、调用栈及其他上下文
  • 通过这些记录能够快速地发现和解决问题

通常一个大的任务是由很多小的步骤构成的,很多时候当一个步骤中发生了错误,你并不能放任它在这里中断。我又要举那个经典的例子了:两个人,A 向 B 转账,当从 A 的账户扣完钱要加到 B 的账户上的时候发生了错误,这时显然你不能让整个任务中断在这里,否则就会出现数据的不一致 —— A 的账户被扣了钱但没有被加到其他任何账户上。因此我们需要通过错误处理精心地控制错误中断的位置,在必要的情况下回滚数据,确保数据的一致性。

我们的程序也需要在出现错误的情况下能够显示(或记录)一个错误的摘要、调用栈,以及其他的上下文。调用栈通常语言本身会提供,但很多时候仅有调用栈是不足以定位问题的,所以我们还需要去记录那些可能与这个错误有关的「上下文」,比如当时某几个关键的变量的值。对于一个服务器端项目,如果我们决定不向用户展示错误的详情,可能还需要为用户提供一个唯一的错误编号,以便用户事后反馈的时候我们可以根据编号还原当时的现场。

我也要举一些反面的例子,我们不希望错误处理是这样的:

  • 出现错误后程序崩溃退出
  • 出现错误后 HTTP 请求无响应
  • 出现错误后数据被修改了「一半」,出现不一致
  • 出现错误后没有记录日志或重复记录
  • 在日志中打印了错误但没有提供调用栈和上下文

在 Node.js 程序中经常会出现 HTTP 请求无响应的情况 —— 既没有收到成功响应,也没有收到失败响应,一直在保持着连接。这通常是由于回调函数中的代码抛出了一个异常但没有被正确地捕捉,导致对这个请求的处理流程被意外地中断了,后面我们会介绍如何写出健壮的异步代码。

有的数据库会提供回滚事务的功能,但有的时候我们在使用非事务的数据库,或者在操作临时文件或其他的外部资源,这时就需要我们自己在出现错误的时候来回滚数据了。

出现错误有没有记录或记录不全也是一个非常糟糕的情况,这会让你无从定位错误,你不得不去排查代码的每一个路径,通过用日志打点的方式排查错误发生在哪里;或者虽然打印了错误但没有调用栈或上下文,这也会给你定位错误带来一些不便;再或者一旦一个错误发生了,整个程序里所有相关的函数调用都在打印这个错误,造成日志被错误刷屏,虽然不是大问题,但也会让人感到烦躁。

层次化架构

前辈们说过「计算机领域内的任何问题都可以通过添加一个抽象层的方法来解决」,当我们需要维护一个规模较大的项目时,通常会选择一种层次化的架构:

其实我们通常提到的 MVC 就算是一种层次化架构,但我在这里展示了一个更为通用的架构:左侧的 Dispatcher 指程序的入口点,对于 Web 后端来说可能是解析 HTTP 请求,分发到对应处理函数的部分;对于 GUI 程序来说可能是接受用户输入的部分;对于命令行程序来说可能是解析命令行参数的部分。

在来自外部的任务被 Dispatcher 转换为内部表示之后,会进入到业务逻辑层,这部分是一个程序最复杂也是最核心的部分,它的内部可能还会被划分为若干个小的模块和层次。

最后,我觉得无论是什么程序,最后都要去操作数据,这就是图中的 Date Access 层。对于服务器端程序来说可能会直接连接到一个数据库;对于客户端程序来说可能会使用一个 HTTP Client 去操作远程的数据。

那么如果在这样一个复杂的层次化架构中,某个环节发生了错误怎么办?我们很可能会面临一个问题:我们在某一个层级可能没有足够的信息去决定如何处理这个错误。例如在 Data Access 层,一个数据库查询发生了错误,在 Data Access 这一层我们并不知道这个失败的查询对于更上层的业务逻辑意味着什么,而仅仅知道这个查询失败了。

所以我们需要有一种机制,将错误从底层不断地向上层传递,直到错误到达某个层级有足够的信息去决定如何处理这个错误。例如一个数据库查询的失败,根据不同的业务逻辑,可能会采取忽略、重试、中断整个任务这些完全不同的处理方式。

异常

好在 JavaScript 为我们提供了异常(Exception)这样一个特性。异常是现代的高级语言所提供的一种非常强大的流程控制机制,我说它是流程控制机制,是说它和 if-else、for、while 其实是差不多的,都是用来进行流程控制的。异常让原本唯一的、正确的执行路径变得可以从任何一处中断,并进入一个所谓的「异常处理流程」。

try {
  step1();
} catch (err) {
  console.error(err.stack);
}

function step1() {
  // ...
  step2()
  // ...
}

function step2() {
  if ( ... )
    throw new Error('some error');
}

在前面的例子中,我们定义了 step1 和 step2 两个函数,step1 调用了 step2,而 step2 中有可能抛出一个异常。我们仅需将对 step1 的调用放在一个 try 的语句块里,便可在后面的 catch 块中捕捉到 step2 抛出的异常,而不需要在 step1 和 step2 中进行任何处理 —— 即使它们再调用了其他函数。

这是因为异常会随着调用栈逆向地回溯,然后被第一个 catch 块捕捉到。这恰好符合我们前面提到的需求:在某个较底层(调用层次较深)的函数中我们没有足够的信息去处理这个错误,我们便不必在代码中特别地处理这个错误,因为异常会沿着调用栈回溯,直到某个层次有信息去处理这个异常,我们再去 catch, 一旦一个异常被 catch 了,便不会再继续回溯了(除非你再次 throw),这时我们称这个异常被处理了。

P.S. 下文中我们会同时使用「错误」和「异常」这两个词,它们之间的差别比较微妙:错误通常用来表示广义的不正确、不符合预期的情况;异常则具体指 JavaScript 或其他很多语言中提供的一种流程控制机制,以及在这个机制中被传递的异常对象。

也有很多语言是没有异常的支持的,例如 C 和 Golang, 让我们来想象一下没有异常的 JavaScript 会是什么样子:

var err = step1();
if (err) console.error(err);

function step1() {
  // ...
  var err = step2();
  if (err) return 'step1: ' + err;
  // ...
}

function step2() {
  if ( ... )
    return 'step2: some error';
}

如果没有异常,每个函数都必须提供一种方式,告诉它的调用者是否有错误发生,在这里我们选择通过返回值的方式来表示错误,即如果返回空代表执行成功,返回了非空值则表示发生了一个错误。可以看到在每一次函数调用时,我们都需要去检查返回值来确定是否发生了错误,如果有错误发生了,就要提前中断这个函数的执行,将同样的错误返回。如果 step1 或 step2 中再去调用其他的函数,也需要检查每个函数的返回值 —— 这是一项非常机械化的工作,即使我们不去处理错误也必须手动检查,并在有错误时提前结束。

语言内建的异常还提供了另外一项非常有用的功能,那就是调用栈:

Error: some error
    at step2 (~/exception.js:14:9)
    at step1 (~/exception.js:9:3)
    at <anonymous> (~/exception.js:2:3)

这是前面的例子中打印出的调用栈,调用栈中越靠上的部分越接近异常实际产生的位置,而下面的调用栈则会帮助我们的还原程序执行的路径。调用栈是 JavaScript 引擎为我们提供的功能,如果没有异常的话,恐怕就需要我们自己来维护调用栈了。

抛出一个异常

我在这里把异常粗略地分为两类:

  • 预期的异常:参数不合法、前提条件不满足
  • 非预期的异常:JavaScript 引擎的运行时异常

预期的异常通常是我们在代码中主动抛出的,目的是为了向调用者报告一种错误,希望外部的逻辑能够感知到这个错误,在某些情况下也可能是希望外部的逻辑能够给用户展示一个错误提示。

非预期的异常通常说明我们的程序有错误或者考虑不周到,比如语法错误、运行时的类型错误。或者也可能是来自依赖的库的错误,在实践中我们通常会把来自依赖库中的错误,捕捉后再次以特定的格式抛出,将其简单地「转化」为预期的异常。

那么,如果我们要主动抛出一个异常,应该怎样做呢:

  • 总是抛出一个继承自 Error 的对象
  • 慎用自定义的异常类型
  • 可以直接向异常上附加属性来提供上下文

首先你应该总是抛出一个继承自 JavaScript 内建的 Error 类型的对象,而不要抛出 String 或普通的 Object, 因为只有语言内建的 Error 对象上才会有调用栈,抛出其他类型的对象将可能会导致调用栈无法正确地被记录。同时也要慎重地使用自定义的异常类型,因为目前 JavaScript 中和调用栈有关的 API(如 Error.captureStackTrace)还不在标准中,各个引擎的实现也不同,你很难写出一个在所有引擎都可用的自定义异常类型。因此如果你的代码可能会同时运行在 Node.js 和浏览器中,或者你在编写一个开源项目,那么建议你不要使用自定义的异常类型;如果你的代码不是开源的,运行环境也非常确定,则可以考虑使用引擎提供的私有 API 来自定义异常类型。

另外这里的建议不仅适用于传递给 throw 关键字的异常对象,也适用于传递给 callback 函数的第一个参数。

前面我们几次提到「上下文」的这个概念,所谓上下文就是说和这个错误有关的一些信息,这个「有关」可能是非常主观的,即你觉得那些有助于你定位错误的信息。借助于 JavaScript 灵活的类型机制,我们可以向任意对象上附加任意的属性,异常对象也不例外:

var err = new Error('Permission denied');
err.statusCode = 403;
throw err;

var err = new Error('Error while downloading');
err.url = url;
err.responseCode = res.statusCode;
throw err;

前面一个例子中,当一个请求无权限访问数据时,我们在抛出的异常对象上添加了一个 statusCode = 403 的属性,这个属性将会提示最终处理这个错误的 HTTP 层代码,给客户端发送 409 的错误响应;后面一个例子是在下载一个文件时发生了错误,当出现了这样的情况,显然我们最感兴趣的会是下载的地址是什么、服务器发回了怎样的响应,所以我们选择将这两个信息附加到异常对象上,供外层的逻辑读取。

异步任务

目前为止我们提到的都是 JavaScript 语言内建的异常特性,但因为语言内建的异常是基于调用栈的,所以它只能在「同步」的代码中使用。当我们刚刚入门 Node.js 时经常会搞不清这一点:「异步」任务是通过所谓的「事件队列」来实现的,每当引擎从事件队列中取出一个回调函数来执行时,实际上这个函数是在调用栈的最顶层执行的,如果它抛出了一个异常,也是无法沿着调用栈回溯到这个异步任务的创建者的。

所以你无法在异步代码中直接使用 try … catch 来捕捉异常,因此接下来我们会介绍如何在异步的代码中使用类似异常的机制来处理错误,在这里我粗略地将 Node.js 中常见的异步流程控制机制分为下面三大类:

  • Node.js style callback
  • Promise(co、async/await)
  • EventEmitter(Stream)

首先是影响了几乎所有 Node.js 程序员的 Node.js style callback:

function copyFileContent(from, to, callback) {
  fs.readFile(from, (err, buffer) => {
    if (err) {
      callback(err);
    } else {
      try {
        fs.writeFile(to, buffer, callback);
      } catch (err) {
        callback(err);
      }
    }
  });
}

try {
  copyFileContent(from, to, (err) => {
    if (err) {
      console.error(err);
    } else {
      console.log('success');
    }
  });
} catch (err) {
  console.error(err);
}

我们在这里以一个 copyFileContent 函数为例,它从第一个参数所代表的源文件中读取内容,然后写入到第二个参数所代表的目标文件。

首先需要注意的是在每次回调中,我们都需要去检查 err 的值,如果发现 err 有值就代表发生了错误,那么需要提前结束,并以同样的错误调用 callback 来将错误传递给调用者。

然后在回调中的代码也必须要包裹在 try … catch 中来捕捉同步的异常,如果捕捉到了同步的异常,那么也需要通过 callback 将错误传递给调用者。这里是一个比较大的坑,很多人会忘记,但按照 Node.js style callback 的风格,一个函数既有可能同步地抛出一个异常,也有可能异步地通过 callback 报告一个错误,Node.js 标准库中的很多函数也是如此。

在使用这个 copyFileContent 时,我们也需要同时去捕捉同步抛出的异常和异步返回的错误,实际上这样导致了错误情况下的逻辑分散到了两处,实在让人有些难以接受。

我们来总结一下使用 Node.js style callback 时琐碎的细节:

  • 需要同时处理同步的异常和异步的回调
  • 在每次回调中需要检查 err 的值
  • 回调中的代码也需要捕捉同步异常

最后确保:无论成功或失败,要么 callback 被调用,要么同步地抛出一个异常。

我们需要在每个回调中检查 err 的值,如果有值就立刻调用 callback 并不再执行接下来的逻辑,确保错误被传递给调用者。仔细想想,这个做法和我们一开始展示的「没有异常的 JavaScript」是多么地相似!我们必须手动地去完成错误的传递工作,而且中间有很多容易被遗漏的琐碎细节。这也是为什么后来 Promise 得到了大家的认可,逐步取代了 Node.js style callback.

那么为什么 Node.js 会选择 callback style 而不是 Promise 作为标准库的接口呢?很大程度上是因为在 Node.js 刚刚发布时,Promise 还未进入 ECMAScript 的标准,Node.js 认为标准库应该提供最简单、最基本的接口,而使用 Promise 意味着 Node.js 还需要在标准库中内建一个 Promise 的实现,引入了额外的复杂度。如果用户希望使用 Promise 风格的标准库,大可以自己封装一个或选择第三方的封装,而标准库本身依然提供着最「简单」的接口。

所以接下来我们来看 Promise:

function copyFileContent(from, to) {
  return fs.readFile(from).then( (buffer) => {
    return fs.writeFile(to, buffer);
  });
}

Promise.try( () => {
  return copyFileContent(from, to);
}).then( () => {
  console.log('success');
}).catch( (err) => {
  console.error(err);
});

Promise 可以说是对同步任务和异步任务的一种一致的抽象,算是 Node.js 中异步流程控制的未来趋势,今天我们不过多介绍 Promise, 而是着重来看它对于错误处理的影响。

Pormise 的版本相比于前面的 Node.js style callback 要短了许多,主要是我们不需要在 copyFileContent 中处理错误了,而只需要去考虑正常的流程。fs.readFilefs.writeFile 和 copyFileContent 的返回值都是一个 Promise, 它会帮助我们传递错误,在 Promise 上调用 .then 相当于绑定一个成功分支的回调函数,而 .catch 相当于绑定一个失败分支的错误处理函数,实际上我们的代码已经非常类似于语言内建的异常机制了。

我也要介绍一些在使用 Promise 过程中的最佳实践,首先是要尽量避免手动创建 Promise:

function copyFileContent(from, to) {
  return new Promise( (resolve, reject) => {
    fs.readFile(from, (err, buffer) => {
      if (err) {
        reject(err);
      } else {
        try {
          fs.writeFile(to, buffer, resolve);
        } catch (err) {
          reject(err);
        }
      }
    });
  });
}

Promise 也有一个构造函数,通常用于将一段 Node.js style callback 风格的逻辑封装为 Promise, 在其中你需要手动在成功或失败的情况下调用 resolve 或 reject, 也需要手动处理 Node.js style callback 中各种琐碎的细节,十分容易出现疏漏。

取而代之的是,我们应该使用类似于 bluebird 提供的 Promise.promisify 这样的函数自动地帮我们完成转换,Promise.promisify 接受一个 Node.js style callback 风格的函数然后帮助我们自动转换到返回 Promise 的函数,虽然其内部和我们前面手动转换的例子差不多,但使用它可以避免直接面对复杂的转换逻辑,减少犯错的可能性:

function copyFileContent(from, to) {
  return Promise.promisify(fs.readFile)(from).then( (buffer) => {
    return Promise.promisify(fs.writeFile)(to, buffer);
  });
}

至于 co/generator 和 async/await,我觉得它们和 Promise 并没有什么本质上的区别,而且它们最后也提供了和 Promise 相兼容的 API, 因此接下来我们只是简单地一笔带过。

generator 提供了一种中断函数的执行而后再继续的能力,这种能力让它可以被用作异步流程控制:

var copyFileContent = co.wrap(function*(from, to) {
  return yield fs.writeFile(to, yield fs.readFile(from));
});

co(function*() {
  try {
    console.log(yield copyFileContent(from, to));
  } catch (err) {
    console.error(err);
  }
});

而 async/await 则是基于 generator 的进一步优化,使代码更加简洁而且具有语义:

async function copyFileContent(from, to) {
  return await fs.writeFile(to, await fs.readFile(from));
}

try {
  console.log(await copyFileContent(from, to));
} catch (err) {
  console.error(err);
}

通常来说一个返回 Promise 的函数也有可能同步地抛出一个异常,这也是为什么前面的代码我用了一个 Promise.try,如果你的代码已经在一个 Promise 的 .then 里,那你就不必去加 Promise.try 了,甚至也不需要为每一个 Promise 添加 .catch,而是让它自动地向上层抛出,这和 Node.js style callback 有着本质的区别,下面我们来展示一个更复杂一些的例子:

Promise.try( () => {
  return copyFileContent(a, b);
}).then( () => {
  return copyFileContent(b, c);
}).then( () => {
  return copyFileContent(c, d);
}).then( () => {
  console.log('success');
}).catch( (err) => {
  console.error(err);
});

这里我们将文件复制了三次,在主逻辑中我们依然没有去关心错误的情况,因为任何一个步骤发生了错误,都会到达最后的 catch 块中,这就是所谓的 Pormise chain, 但需要注意的是记得要在每个「返回 Promise 的函数」中添加 return, 否则调用者没有办法感知到你返回了一个 Promise.

语言内建的同步异常提供了调用栈,那么通过 Promise 传递的异步异常的调用栈会是什么样子的呢:

Error: EACCES: permission denied, open 'to'
    at Error (native)

就像我们前面提到的那样,我们只能看到来自 JavaScript 引起的调用,而看不到这个异步任务的创建者。但实际上很多 Promise 的实现提供了一个「记录异步调用栈」的功能,当开启了这个选项之后:

Error: EACCES: permission denied, open 'to'
    at Error (native)
From previous event:
    at ~/test.js:15:15
    at FSReqWrap.readFileAfterClose(fs.js:380:3)
From previous event:
    at copyFileContent (~/test.js:14:28)
    at ~/test.js:20:10

我们便可以看到创建这个异步任务的行号和更多的调用栈了,虽然这个选项对性能有一定影响,但我仍然建议开启这个选项,它将会很大程度上加快你定位线上错误的速度。

那么 Node.js style callback 是否有能力记录这样的异步调用栈呢?我的答案是不能,因为在 Node.js style callback 中,我们是直接在使用调用者传递进来的 callback, 中间没有任何的胶合代码允许我们插入记录调用栈的逻辑,除非手动在每一次调用时去添加调用栈,这样便会对业务代码产生侵入式的影响。而在 Promise 中,所有异步任务的回调都被包裹在一个 .then 中,异步调用都是间接地通过 Promise 完成的,这给了 Promise 实现记录异步调用栈的机会,而不会影响到业务代码。

当大家在争论 Promise 和 asycn/await 谁才是未来的时候,可能忘记了 Node.js 还有个 events 模块,提供了基于事件的异步流程控制机制。如果说 Node.js style callback 和 Promise 都是一个操作对应一个结果的话,那么 EventEmitter 则提供了一个操作(甚至没有操作)对应多个结果的异步模型:

var redisClient = redis.createClient();

redisClient.on('error', (err) => {
  console.error(err);
});

EventEmitter 提供了一种基于事件的通知机制,每个事件的含义其实是由使用者自己定义的,但它对于 error 事件却有一些特殊处理:如果发生了 error 事件,但却没有任何一个监听器监听 error 事件,EventEmiter 就会把这个错误直接抛出 —— 通常会导致程序崩溃退出。

标准库里的很多组件和一些第三方库都会使用 EventEmitter, 尤其是例如数据库这类的长链接,我们要确保监听了它们的 error 事件 —— 哪怕是打印到日志中。其实这里也比较坑,因为当我们在使用第三方库的时候,除非文档上写了,否则我们可能并不知道它在哪里用到了 EventEmitter(有的库可能有多个地方都用到了)。

Node.js 中的 Stream 也是基于 EventEmitter 的:

try {
  var source = fs.createReadStream(from);
  var target = fs.createWriteStream(to);

  source.on('error', (err) => {
    console.error(err);
  }).pipe(target).on('error', (err) => {
    console.error(err);
  });
} catch (err) {
  console.error(err);
}

在上面的例子中,我创建了一个读文件的流和一个写文件的流,并将读文件的流 .pipe 到写文件的流,实现一个复制文件内容的功能。我们一开始看到 pipe 这个函数,可能会以为它会将前面的流的错误一起传递给后面的流,然后仅需在最后加一个 error 事件的处理器即可。但其实不然,我们需要去为每一个流去监听 error 事件。

如果有异常没有捕捉到怎么样?如果有一个异常一直被传递到最顶层调用栈还没有被捕捉,那么就会导致进程的崩溃退出,不过我们还有两个终极捕捉手段:

process.on('uncaughtException', (err) => {
  console.error(err);
});

process.on('unhandledRejection', (reason, p) => {
  console.error(reason, p);
});

uncaughtException 事件可以捕捉到那些已经被抛出到最顶层调用栈的异常,一旦添加了这个监听器,这些异常便不再会导致进程退出。实际上有一些比较激进的人士认为程序一旦出现事先没有预料到的错误,就应该立刻崩溃,以免造成进一步的不可控状态,也为了提起开发人员足够的重视。但我从比较务实的角度建议还是不要这样做,尤其是服务器端程序,一个进程崩溃重启可能需要一分钟左右的时间,这段时间会造成服务的处理能力下降,也会造成一部分连接没有被正确地处理完成,这个后果很可能是更加严重的。

当我们应当将在这个事件中捕捉到的错误视作非常严重的错误,因为在此时已经丢失了和这个错误有关的全部上下文,必然无法妥善地处理这个错误,唯一能做的就是打印一条日志。

unhandledRejection 事件可以捕捉到那些被 reject 但没有被添加 .catch 回调的 Promise, 通常是因为你忘记为一个返回 Promise 的函数添加 return。因为 Promise 本来就是对异步错误的一种封装,所以实际使用中偶尔也会出现 Promise 先被 reject, 而后再用 .catch 添加错误处理的情况,所以这个事件实际上偶尔会有误报。

传递异常

前面我们按照 Node.js 中不同的异步流程控制方法介绍了如何捕捉和传递异常,接下来我还要介绍一些传递异常的过程中的一些最佳实践:

  • 注意 Promise / callback chain 不要从中间断开
  • 只处理已知的、必须在这里处理的异常,其他异常继续向外抛出
  • 不要轻易地丢弃一个异常
  • 传递的过程中可以向 err 对象上添加属性,补充上下文

相信在调试过程中最让人恼火的事情就是明明有错误发生,但错误却没有正确地传递回来,通常是因为这个错误在代码中被「不小心」地处理掉了,因此我们应该在进行错误处理时去注意保障外层代码的「知情权」,仅去处理必须在此处处理的异常,应该严格地去判断异常的类型和 message,确保只处理预期的异常,而其他大部分的异常都要继续向外层抛出:

function writeLogs(logs) {
  return fs.writeFile('out/logs', logs).catch( (err) => {
    if (err.code === 'ENOENT') {
      return fs.mkdir('out').then( () => {
        return fs.writeFile('out/logs', logs);
      });
    } else {
      throw err;
    }
  });
}

这里我们实现了向 out/logs 这个文件写入日志的函数,如果 out 这个目录不存在则会产生一个 ENOENT 的错误,我们非常确信这个错误应该通过创建 out 这个目录来解决,所以我们决定去捕捉 fs.writeFile 的错误,然后严格地去判断 err.code,如果不是 ENOENT 还要继续抛出。其实打印日志也算是处理异常的一种,如果没有必要在此处打印日志(例如你还会继续抛出这个错误),那么就不要轻易打印日志,否则就会出现我们前面提到的,程序中发生了一个错误,到处都在打印日志。

既然我们不应该轻易地处理异常,那么显然也不应该轻易地丢弃一个异常:

copyFileContent('a', 'b').catch( err => {
  // ignored
});

的确有时我们需要忽略一个错误,但即使要忽略错误,也应该去判断异常的类型,确保它的确是我们想要忽略的那种错误。

前面我们提到了上下文对定位错误的重要性,有的时候我们可以捕捉异常,向上面附加一些上下文然后继续抛出:

function mysqlQuery(sql, placeholders) {
  return mysqlClient.exec(sql, placeholders).catch( (err) => {
    err.sql = sql;
    throw err;
  });
}

这里的例子是一个用来进行数据库查询的工具函数,当一个数据库查询失败了,我们最感兴趣的可能是这个查询是什么,因此在这里我们捕捉了查询失败时的异常,将 SQL 语句作为属性附加到异常上,然后继续抛出。

还有的时候我们捕捉异常是为了回滚数据:

function mysqlTransaction(transaction) {
  return mysqlPool.getConnection( (connection) => {
    return connection.beginTransaction().then( () => {
      return transaction(connection).then( (result) => {
        return connection.commit().then( () => {
          return result;
        });
      }).catch( (err) => {
        return connection.rollback().then( () => {
          throw err;
        });
      });
    });
  });
}

这里的例子是一个用来进行数据库事务操作的工具函数,我们先从连接池得到一个连接、开始一个事务,然后执行要在事务中进行的操作。如果操作执行完成,我们提交这个事务,如果执行失败,我们捕捉异常,然后将事务回滚,最后将异常继续向外层抛出 —— 因为作为一个工具函数我们并不知道这个事务的失败对于业务逻辑意味着什么。

在程序的边界处理异常

前面我们讲了那么多都在提醒大家不要轻易地处理异常,而是让异常沿着调用栈向外层传递,在传递的过程中可能有一部分异常被忽略或以重试的方式被处理了,但还有一些「无法恢复」的异常被传递到了程序的「边界」,这些异常可能是预期的(无法成功执行的任务)或者非预期的(程序错误),所谓程序的边界可能是:

  • Routers(对于 Web-backend 而言)
  • UI Layer(对于 Web/Desktop App 而言)
  • Command Dispatcher(对于 CLI Tools 而言)

我们需要在程序的边界来处理这些错误,例如:

  • 展示错误摘要
  • 发送响应、断开 HTTP 连接(Web-backend)
  • 退出程序(CLI Tools)
  • 记录日志

正因为这些错误最后被汇总到了一处,我们可以以一种统一的、健壮的方式去处理这些错误,例如在一个 Express 程序中,我们会有这样的代码:

app.get('/', (req, res, next) => {
  copyFileContent(req.query.from, req.query.to).then( () => {
    res.send();
  }).catch(next);
});

app.use((err, req, res, next) => {
  err.userId = req.user.id;
  err.url = req.originalUrl;
  logger.error(err);
  res.status(err.statusCode || 500).send(err.message);
});

Express 是没有对 Promise 提供支持的,因此 Express 的中间件可以算是 Promise 代码的边界,我们需要手动地将异常传递给 Express 的 next, 以便进入到 Express 的错误处理流程。

Express 提供了一种错误处理中间件,在这里我们依然保留着有关 HTTP 连接的上下文,一个比较好的实践是在这里将 HTTP 连接所关联的用户、请求的 URL 等信息作为上下文附加到错误对象上,然后将错误记录到日志系统中,最后向客户端发送一个错误摘要。

这里只是一个简单的例子,在实际项目中这个错误处理中间件可能会很长很复杂,有很多内部的约定(例如 err.statusCode)来决定如何处理这个错误,正是因为错误被汇总到了这里,我们才有能力进行统一的处理。

最后,我向大家推荐一个叫 Sentry 的开源软件,它提供了各个语言的 SDK, 仅需简单的配置就可以将错误发送到 Sentry 提供的一个 Web 服务上面(实际上我们的项目中就会在 Express 的错误处理中间件向 Sentry 发送错误)。Sentry 提供了一个 Web 的 Dashboard, 会将同类错误聚合在一起,显示每个错误在过去一段时间发生的次数、影响的用户数量。你还可以在向 Sentry 发送错误时提供额外的 Tag, Sentry 可以根据 Tag 进行统计和分析。Sentry 还可以通过添加规则的方式配置 Webhook 和邮件报警。

小结

我们来对今天的技术分享做一个简单的小结:

  • 在层次化的架构中,很多时候在当前的层级没有足够的信息去决定如何处理错误,因此我们需要使用异常来将错误沿着调用栈逆向抛出,直到某个层级有足够的信息来处理这个错误。
  • 在异步的场景下我们应该使用 Promise 或相兼容的流程控制工具来模拟异常机制。
  • 传递异常时可以回滚数据或向其补充上下文,但如非必要,需要继续向外抛出。
  • 让所有无法被恢复的错误传递到程序的「边界」处,统一处理。
1

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2023-12-20.