我们已经讨论了如何处理异常,那么当你在编写新的函数的时候,怎么才能向调用者传递错误呢?
最最重要的一点是为你的函数写好文档,包括它接受的参数(附上类型和其它约束),返回值,可能发生的错误,以及这些错误意味着什么。 **如果你不知道会导致什么错误或者不了解错误的含义,那你的应用程序正常工作就是一个巧合。** 所以,当你编写新的函数的时候,一定要告诉调用者可能发生哪些错误和错误的含义。
### Throw, Callback 还是 EventEmitter
函数有三种基本的传递错误的模式。
* `throw`以同步的方式传递异常--也就是在函数被调用处的相同的上下文。如果调用者(或者调用者的调用者)用了`try/catch`,则异常可以捕获。如果所有的调用者都没有用,那么程序通常情况下会崩溃(异常也可能会被`domains`或者进程级的`uncaughtException`捕捉到,详见下文)。
* Callback 是最基础的异步传递事件的一种方式。用户传进来一个函数(callback),之后当某个异步操作完成后调用这个 callback。通常 callback 会以`callback(err,result)`的形式被调用,这种情况下, err和 result必然有一个是非空的,取决于操作是成功还是失败。
* 更复杂的情形是,函数没有用 Callback 而是返回一个 EventEmitter 对象,调用者需要监听这个对象的 error事件。这种方式在两种情况下很有用。
* 当你在做一个可能会产生多个错误或多个结果的复杂操作的时候。比如,有一个请求一边从数据库取数据一边把数据发送回客户端,而不是等待所有的结果一起到达。在这个例子里,没有用 callback,而是返回了一个 EventEmitter,每个结果会触发一个`row` 事件,当所有结果发送完毕后会触发`end`事件,出现错误时会触发一个`error`事件。
* 用在那些具有复杂状态机的对象上,这些对象往往伴随着大量的异步事件。例如,一个套接字是一个EventEmitter,它可能会触发“connect“,”end“,”timeout“,”drain“,”close“事件。这样,很自然地可以把”error“作为另外一种可以被触发的事件。在这种情况下,清楚知道”error“还有其它事件何时被触发很重要,同时被触发的还有什么事件(例如”close“),触发的顺序,还有套接字是否在结束的时候处于关闭状态。
在大多数情况下,我们会把 callback 和 event emitter 归到同一个“异步错误传递”篮子里。如果你有传递异步错误的需要,你通常只要用其中的一种而不是同时使用。
那么,什么时候用`throw`,什么时候用callback,什么时候又用 EventEmitter 呢?这取决于两件事:
* 这是操作失败还是程序员的失误?
* 这个函数本身是同步的还是异步的。
直到目前,最常见的例子是在异步函数里发生了操作失败。在大多数情况下,你需要写一个以回调函数作为参数的函数,然后你会把异常传递给这个回调函数。这种方式工作的很好,并且被广泛使用。例子可参照 NodeJS 的`fs`模块。如果你的场景比上面这个还复杂,那么你可能就得换用 EventEmitter 了,不过你也还是在用异步方式传递这个错误。
其次常见的一个例子是像`JSON.parse`这样的函数同步产生了一个异常。对这些函数而言,如果遇到操作失败(比如无效输入),你得用同步的方式传递它。你可以抛出(更加常见)或者返回它。
对于给定的函数,如果有一个异步传递的异常,那么所有的异常都应该被异步传递。可能有这样的情况,请求一到来你就知道它会失败,并且知道不是因为程序员的失误。可能的情形是你缓存了返回给最近请求的错误。虽然你知道请求一定失败,但是你还是应该用异步的方式传递它。
通用的准则就是 **你即可以同步传递错误(抛出),也可以异步传递错误(通过传给一个回调函数或者触发EventEmitter的 `error`事件),但是不用同时使用**。以这种方式,用户处理异常的时候可以选择用回调函数还是用`try/catch`,但是不需要两种都用。具体用哪一个取决于异常是怎么传递的,这点得在文档里说明清楚。
差点忘了程序员的失误。回忆一下,它们其实是Bug。在函数开头通过检查参数的类型(或是其它约束)就可以被立即发现。一个退化的例子是,某人调用了一个异步的函数,但是没有传回调函数。你应该立刻把这个错抛出,因为程序已经出错而在这个点上最好的调试的机会就是得到一个堆栈信息,如果有内核信息就更好了。
因为程序员的失误永远不应该被处理,上面提到的调用者只能用`try/catch`或者回调函数(或者 EventEmitter)其中一种处理异常的准则并没有因为这条意见而改变。如果你想知道更多,请见上面的 (不要)处理程序员的失误。
下表以 NodeJS 核心模块的常见函数为例,做了一个总结,大致按照每种问题出现的频率来排列:
| 函数 | 类型 | 错误 | 错误类型 | 传递方式 | 调用者 |
| --- | --- | --- | --- | --- | --- |
| `fs.stat` | 异步 | file not found | 操作失败 | callback | handle |
| `JSON.parse` | 同步 | bad user input | 操作失败 | throw | `try/catch` |
| `fs.stat` | 异步 | null for filename | 失误 | throw | none (crash) |
异步函数里出现操作错误的例子(第一行)是最常见的。在同步函数里发生操作失败(第二行)比较少见,除非是验证用户输入。程序员失误(第三行)除非是在开发环境下,否则永远都不应该出现。
_吐槽:程序员失误还是操作失败?_
你怎么知道是程序员的失误还是操作失败呢?很简单,你自己来定义并且记在文档里,包括允许什么类型的函数,怎样打断它的执行。如果你得到的异常不是文档里能接受的,那就是一个程序员失误。如果在文档里写明接受但是暂时处理不了的,那就是一个操作失败。
你得用你的判断力去决定你想做到多严格,但是我们会给你一定的意见。具体一些,想象有个函数叫做“connect”,它接受一个IP地址和一个回调函数作为参数,这个回调函数会在成功或者失败的时候被调用。现在假设用户传进来一个明显不是IP地址的参数,比如`“bob”`,这个时候你有几种选择:
* 在文档里写清楚只接受有效的IPV4的地址,当用户传进来`“bob”`的时候抛出一个异常。强烈推荐这种做法。
* 在文档里写上接受任何string类型的参数。如果用户传的是`“bob”`,触发一个异步错误指明无法连接到`“bob”`这个IP地址。
这两种方式和我们上面提到的关于操作失败和程序员失误的指导原则是一致的。你决定了这样的输入算是程序员的失误还是操作失败。通常,用户输入的校验是很松的,为了证明这点,可以看`Date.parse`这个例子,它接受很多类型的输入。但是对于大多数其它函数,我们强烈建议你偏向更严格而不是更松。你的程序越是猜测用户的本意(使用隐式的转换,无论是JavaScript语言本身这么做还是有意为之),就越是容易猜错。本意是想让开发者在使用的时候不用更加具体,结果却耗费了人家好几个小时在Debug上。再说了,如果你觉得这是个好主意,你也可以在未来的版本里让函数不那么严格,但是如果你发现由于猜测用户的意图导致了很多恼人的bug,要修复它的时候想保持兼容性就不大可能了。
所以如果一个值怎么都不可能是有效的(本该是string却得到一个`undefined`,本该是string类型的IP但明显不是),你应该在文档里写明是这不允许的并且立刻抛出一个异常。只要你在文档里写的清清楚楚,那这就是一个程序员的失误而不是操作失败。立即抛出可以把Bug带来的损失降到最小,并且保存了开发者可以用来调试这个问题的信息(例如,调用堆栈,如果用内核文件还可以得到参数和内存分布)。
那么 `domains` 和 `process.on('uncaughtException')` 呢?
操作失败总是可以被显示的机制所处理的:捕获一个异常,在回调里处理错误,或者处理EventEmitter的“error”事件等等。`Domains`以及进程级别的`‘uncaughtException’`主要是用来从未料到的程序错误恢复的。由于上面我们所讨论的原因,这两种方式都不鼓励。