本文将回答几个常见的问题:
在function中出现错误,该何时throw error或通过callback回调,或使用eventEmitter触发?
Function的参数应该满足什么?是否需要校验参数的类型,或者满足其他限制如非null、非负、是否是
ip地址。
function不接受传递的参数值,该如何处理?应该throw 抛出异常还是把error传递给回调函数。
能以编程方式区分不同类型的错误吗?
如何提供更多关于错误的描述信息,让调用者更好的处理这个错误?
怎么处理异常?该用try/catch、domains还是其他方式?
本文分为几个部分
阅读背景:你需要了解的一些相关知识
运行错误VS程序错误:介绍两种基础的错误类型
函数开发的模式:在function中处理错误的一般原则
开发新function的具体建议:如对函数进行详细的注释文档
总结:概括全文内容
附录:Error对象的常用属性列表
阅读背景
阅读本文的前提
熟悉exception机制,如在javascript、java、Python、c++或其他相似语言,知道如何抛出
和捕获异常
熟悉node编程。知道node的异步操作和完成异步操作后的callback回调函数模式。
了解node中三种主要传递error的方式:
1.Throw一个error,即抛出异常
2.把error传递给回调函数,让回调函数去处理错误和异常操作的结果
3.触发EventEmitter的“error”事件
最后,应该掌握javascript(特别是node)中error和exception之间的区别。
error是一个Error的实例。Error可以被传递给其他函数或者抛出,当Error被抛出时,它就变
为一个异常。这里有一个Error作为异常的例子:
throw new Error('something bad happened');
除此之外,你也可以使用一个Error,不抛出。
callback(new Error('something bad happened'));
这在node中更为常见,因为大部分错误传递是异步的。实际上,唯一能够使用try/catch的情
况是JSON.parse和其他需要验证输入正确性的函数。在同步函数中需要使用catch捕获Error的情
况是非常罕见的,这与java、C++和其他语言处理异常情况是有很大区别的。
运行错误VS程序错误
所有的错误可分为两大类:
运行错误:表示在正确程序编码上的运行时问题,这不是程序的bug。通常是一些系统问题
(如内存溢出,打开过多文件)、系统配置、网络问题等,包括:
1.连接服务器失败
2.解析主机失败
3.无效的用户输入
4.请求超时
5.服务器返回500
6.Socket挂起
7.系统内存溢出
程序错误:程序编码中的bug,这种问题可以通过修改代码来避免。比如:
1.试图读取“undefined”属性
2.调用异步的函数时未定义回调函数
3.期望Object时传递了一个“String”
4.期望ip地址时传递了一个对象
人们使用“Error”的术语去概括运行和程序的错误,但是他们之间是有区别的。运行错误是
程序必须要处理的,只要处理了就不在是bug或者严重问题。“file not found”是一个运行错
误,但并不意味着程序是错的,只需要程序在查找文件时,若不存在则先创建文件。与运行错误
相比,程序错误是bug。也许是忘记校验输入有效性、错误的变量名或者其他。这个区别是很重要
的,运行错误作为程序正常运行的一部分,而程序错误则是bug。
有时,你会同时遇到运行和程序错误。如一个http server试图使用undefined变量后崩溃,
这是程序错误,是bug。在http server崩溃时,node发起客户端请求,会有ECONNRESET的错误,
将出现“socket hang-up”,对于客户端,这是运行错误。同理,若未处理“运行错误”本身也
是程序的错误。当程序试图连接服务器但出现ECONNREFUSED错误,而且未注册socket的“Error
”事件,使程序崩溃了就是程序错误。所以连接失败是运行错误,但未处理这个错误就是程序错
误了。理解运行错误和程序错误的区别,是搞清楚如何传递和处理错误的基础,在阅读下面时确
定你已经理解他们的区别。
运行错误的处理
就像性能和安全问题一样,错误处理在程序中并不是一开始就很完善。不可能用一部分程序
就处理所有的错误,也不可能通过提高部分性能来提高整体的性能。任何代码都可能会出错(打
开文件、连接服务器、启动子进程等等),必须事先考虑失败时会发生什么。包括考虑如何失败
的,该作出什么响应,因为错误的影响和响应都依赖具体的错误。
当内部把错误传递给外部调用者而未对错误进行有效的处理,最终在多个层次的stack中可能
处理同样的错误。通常,只有最外层的调用者才知道对应错误的响应是什么,是否需要重新执行
操作、或者把错误报告给用户。这并不是说把所有的错误都传递给最外层调用者,因为回调函数
本身不知道错误发生的上下文,操作的哪部分已完成,哪些是失败的。
对于所有错误,可以这样处理:
直接处理错误:有时,很明显必须去处理一个错误。假设你在打开log文件操作时遇到ENOENT
错误,也许是程序首次在该系统运行,你只需要先创建一个log文件就可以避免这个问题。还有一
个例子是操作一个持久化的连接(如数据库),遇到“socket hang-up”错误。这意味着远程服
务器或网络断开,然而这经常是瞬间的,只要重新连接就不在发生这个错误了。
把错误反馈给客户端:如果不知道该如何处理这个错误,最简单的就是终止所有正在执行的
操作,清空已经操作的结果,然后把错误反馈给客户端。当引起错误的因素不会很快发现改变,
如用户给你一个无效的json对象,重新试着转换它是无意义的,所以把这个错误反馈给用户是不
错的方式。
重试操作:如果错误来源于网络或远程服务,那么重新执行操作是不错的选择。如远程服务
返回503,也许几秒后重试下操作就不会了。如果要重试,应该在注释文档中标明重试的次数,重
试的间隔时间。同时记住要防止一直在重试某个操作。
记录错误:有时,除了记录错误的信息日志,做其他任何事情都是徒劳,即不处理错误。
程序错误的处理
如错误的变量名称引起的程序错误,是无法通过更多的代码(异常处理机制)来处理这种错
误。如果可以通过更多代码处理错误,只需在出错的代码处替换错误的代码。
有人建议在程序出错后进行恢复操作,即允许失败,但继续持有请求。这是不推荐的方式。
在原始代码中若没有考虑到程序错误,那么能确定这错误不影响其他请求吗?如果其他请求共用
某个状态(一个服务、socket、数据库连接池等),那么可能使其他请求也会失败。
一个典型例子是一个REST server,某个请求抛出ReferenceError。还会继续导致一系列的
难以追踪的bug。如:
1.一些共享的变量也许是null、undefined或无效的
2.数据库连接泄露,减少了可以并行处理的数量
3.数据库连接的事务未关闭
4.数据库连接处于认证状态,并用于后续连接
5.Socket未关闭
6.无效内存引用,导致内存泄露
处理程序错误最好的方式是立即使程序崩溃,同时程序可以进行自动重启操作。程序崩溃重
启带来的影响是连接的用户会临时中断,但显而易见,这不是我们所说的系统或网络故障,而是
程序中的bug。由于系统崩溃重启而导致用户连接经常断开,显然是代码错误引起,那么要重点处
理使系统崩溃的bug,而不能去避免系统崩溃。调试这些问题的最好方式是使用dump处理异常。在
GUN/LINUX和illumos-based系统中,不仅可以跟踪程序堆栈,也可以查看函数参数值和
javascript Object,甚至是闭包中的引用。
最后,请记住服务端的程序错误,在客户端看来是一个运行错误。客户端也必须处理程序崩
溃和网络异常的情况。这不仅仅停留在理论当中,在实际系统中也是经常会发生的。
函数开发的模式
上面讨论了如何处理错误,但当开发一个新函数时,如何把错误传递给外层调用者呢?
最重要的一件事是写好函数注释文档,包括函数的功能、参数、返回值、可能的错误。如果
不知道新函数会出现什么错误或不知道错误是什么意思,那么新函数很难被正确的使用。所以在
开发新函数时,必须明确告诉外层调用者可能发生什么错误,错误的处理方式等。
Throw、Callback、还是EventEmitter?这三种方式可以把错误传递给外层调用者
Throw 同步的传递错误,即和外层调用者在同一上下文中处理错误。如果调用者使用try/
catch,那么将捕获这个错误,否则程序崩溃。
Callback回调函数是异步传递错误的最常用的方式。当异步操作完成后调用Callback,一般
是callback(err, result),err和result中只有一个是非空的,这取决于操作是否成功。如成功
则callback(null, result),否则callback(err)。
EventEmitter 用于更复杂的情况,Callback将不在适用。由于function本身也是EventEmitter对
象,外层调用者可以监听所以调用函数的error事件。如下情况:
1.当一个复杂操作有多个错误类型或结果的情况。如假设一个操作请求查询多个数据库记录
,对每个结果到达后进行操作,而不是等所有结果都到达后才操作。对于每条记录,function可
以返回一个EventEmitter并触发row事件,最后所有结果完成后触发一个end事件,或发生错误时
触发error事件。
2.一个对象表示复杂的状态机,可能有很多异步的事件。如socket是一个EventEmitter对
象,能触发connect、end、timeout、drain和close事件。当socket发生这些事件时很可能触发
了error事件。有一点是只需关注error触发事件,不用管其他事件是否触发、其他事件是否同时
可见、触发顺序如何、socket最终是否关闭。
何时使用throw、Callback或event emitter?取决于两个方面
1.是运行错误还是程序错误
2.函数本身是同步还是异步
对于大部分异步函数的运行错误,使用Callback作为参数,然后把错误传递给Callback,这
是很好的方式,应用广泛,比如node的fs模块。
对于常见的同步函数的运行错误,如JSON.parse。应该同步的传递error,可以抛出或返回
。
对于一个函数,如果所有的运行错误都可以异步传递,那么应该使用异步传递。某种情况
下,也许可以马上判断请求会失败,但不是由程序错误引起。而且函数缓存了最近请求的结果,
该错误就在缓存中,即使你可以直接返回给调用者,你也应该异步的传递错误。
一般情况下,一个函数可以同步传递或异步传递 运行错误,但不应该同时出现两种方式。
即可以通过Callback或try/catch来处理错误,但不应该是同时使用。使用那种方式取决于函数
如何传递错误,并且应该在函数文档注释中标明。
在这忽略了程序错误。细想下,这些总是一种错误。他们通常可以使用类型校验在函数的开
端来识别。如外层调用,未传递Callback参数给被调用的函数,此时应该马上抛出这个错误。
建议在函数开始时就对参数进行类型校验。
虽然建议程序错误不应该被处理,但这个建议并不影响外层调用者要使用try/catch或
Callback(或Event emitter)去处理可能的错误。
这里概况下一些node核心库中的部分函数
函数 |
函数类型 |
错误 |
错误类型 |
如何传递 |
调用者处理 |
Fs.stat |
Asynchronous |
File not found |
Operational |
Callback |
Handle callback error |
JSON.parse |
Sysnchronous |
Bad user input |
Operational |
Throw |
Try/catch |
Fs.stat |
Asynchronous |
Null for filename |
Programmer |
Throw |
None(crash) |
无效输入是程序错误还是运行错误?
该如何判断哪些是程序错误,哪些又是运行错误?其实很简单,取决于函数需要那种类型的
参数并如何解析参数。假设传递的参数和函数注释文档的不一致,那么就是程序错误。如果输入
的参数符合定义但函数现在无法处理,那么就是运行错误。
必须判断该如何按照想要的去严格定义函数参数,这里有些建议。来个具体的例子,有个函
数connect,定义了一个ip address和Callback参数。假设用户传了一个明显无效的ip address
如‘bob’,此时,有以下处理方式:
注释文档标明函数只接受有效的IPv4地址的字符串,如果参数为‘bob’则抛出异常。强烈
推荐这种方式。
注释文档标明函数接受所有字符串。如果参数为‘bob’,触发异步的错误,提示不能连接到
ip 地址‘bob’。
这两种可作为运行错误和程序错误的参考,决定输入该作为程序错误还是运行错误。一般而
言,用户输入验证的条件是非常宽松的。如Date.parse接受多种输入,这也是问题的关键。因为
对于大多数函数,输入条件是比较严格的。函数越对输入越宽松,出错的可能也越多,所以推荐
使用更严格的输入校验。宽松的条件有时不仅没有使调用者更专心于需要的地方,反而浪费时间
去调试这个函数。此外,版本升级时可能会减少函数的校验限制,但引起更多猜测用户意图而导
致更多bug,在不破坏兼容性的条件下,很难修复这些bug。所以,如果一个值是无效的,应该在
注释文档中标明这是不允许的,一旦发现就抛出错误。只要不符合注释文档中注明条件,这种错
误就是程序的错误,而不是运行错误。这样就可以把bug造成的影响降至最低,同时提供了有用
的错误信息,便于调用者去调试。
Domains和process.on(‘uncaughtException’)是什么?
运行错误总是能通过明确的机制去处理:捕获异常、在回调函数中处理或通过EventEmitter
触发error事件等。Domains和process的‘uncaughtException’事件主要用来处理来自意料之外
的程序错误,这是不推荐的处理方式。
开发新函数的具体建议
1.在上面讨论了很多原则,现在我们来具体说说:
明确函数内容,做好注释文档,内容如下
函数需要什么参数
每个参数的类型
参数是否还要满足其他条件
若不满足上面的那么就是程序错误,应该立即抛出错误
其他也想在注释文档标明的:
调用者应该考虑哪些运行错误
如何处理这些运行错误(是被抛出、传递给回调还是通过Event Emitter触发)
返回值
2.对于所有错误使用Error对象或者其子类
所有的错误应该使用Error对象或者子类,并提供名称和Message属性,stack也可以。
3.通过Error的name属性,以程序的方式区分不同类型的错误
当判断是何种错误时,可以使用name属性。也可以重用内置在javascript的名称如
“RangeError”、TypeError。对于HTTP Error,通常使用RFC-given状态文本去命名错误,像
BadRequestError或ServiceUnavailableError。不必所有的错误都去创建一个新的名称。不必
细分InvalidHostnameError, InvalidIpAddressError还是InvalidDnsServerError这些无效值
引起的错误,只需一个InvalidArgumentError,说明什么错误就可以了。
4.包装Error对象,提供扩展属性,描述具体的错误细节
例如,一个参数是无效的,那么在Error对象里设置对应的属性,名称为参数的名称,值为
参数的值。若不能连接到服务器,那么可以使用remoteIp。如果出现系统错误,使用syscall
属性表示syscall失败,同时使用errno属性表示system errno。查看附录了解更多的属性名
称。
以下是常用的属性
Name:用来区分不同类型的Error
Message:具有可读的,足够完善的错误信息,以便调用者能够更好的理解它。假如传递一个
底层堆栈的错误信息,应该包装下这个错误,添加一些信息解释正在执行的操作等。
Stack:一般不能修改它。V8只有在明确读取stack属性时才会获取它。如果读取它的属性仅
仅是为了包装扩展它,那么这是增加额外的负担,即使调用者并不需要它。
5.如果传递底层错误给外层调用者,建议包装下错误对象
如,异步函数funa调用异步函数funb,然后funb触发Error,希望funa也触发同样的Error。
这种情况下,建议包装Error,而不是直接返回。包装后,扩展了底层错误对象,让其包含更多
有用的当前上下文信息。Verror模块提供了简单的方式去实现这个。比如,有个fetchconfig函
数,从远程数据库获取配置文件。当系统启动时调用这个函数,整个启动路径如下:
1.加载配置
1.1连接数据库
1.1.1解析数据库服务器的DNS主机
1.1.2建立TCP连接
1.1.3数据库权限验证
1.2数据库请求
1.3解析数据库响应
1.4加载配置
2.开始处理请求
假设运行时,在数据库连接时出错了。问题出在“建立TCP连接”失败,每个层级都会传递错
误给调用者。首次未包装Error时,将得到以下错误信息:
Myserver: Error: connect ECONNREFUSED
很明显,这对于调用者没什么帮助
再次,如果每个层级都包装这个错误对象,将得到更多的信息:
myserver: failed to start up: failed to load configuration: failed to connect to
database server: failed to connect to 127.0.0.1 port 1234: connect ECONNREFUSED
也许你想跳过一些层级的包装,获取更精简的信息:
myserver: failed to load configuration: connection refused from database at
127.0.0.1 port 1234.
如果要包装错误对象,这里有些注意事项:
保留原有错误对象的完整性,保证调用者可以直接从它获取一些信息
可以继续使用同样的name属性,也可以使用更有代表性的name属性。比如底层的错误对于node
只是纯粹的错误对象,而对于step 1(加载配置)是初始化错误InitializationError。
保持所有原始错误对象的属性。适当的扩展Message属性。浅复制所有的属性如syscall、
errno等。除了name、Message和stack,复制其他所有属性。不要对stack进行操作,因为即使只
是读取它都是要很大代价的。
总 结
1.学会区分(可预期的、无法避免的)运行错误和程序错误。
2.运行错误可以、应该被处理。程序错误不应该被处理或者即使可以恢复过来,但这会导致
这个问题更难调试。
3.函数应该同步或异步传递出错误对象,但不能同时使用两种方式。调用者可以使用
try/catch或Callback去处理错误,但不能同时使用。一般来说,使用throw抛出异常,调用者使
用try/catch是相当少见的。因为在node中对于同步函数出现运行错误是很少见的。(主要的例
子就是需要输入验证的函数,像JSON.parse)。
4.当开发新函数时,注释文档要清楚标明函数所接受的参数类型,限制条件等,列明可能发
生的运行错误(如解析主机失败,连接服务器失败等),这些错误会如何传递给调用者(同步使
用throw,或异步使用Callback或Event emitter)
5.未传递参数值或无效的参数值是程序错误,发生时应该总是throw这个错误。只要传递的参
数值不符合函数所期望的,那么就是属于程序错误。
6.错误传递时,使用标准的Error对象和属性。可以添加一些其他的有用信息属性。尽可能的
使用采用的属性名称如附录。
附录:Error对象常用属性
强烈建议和Error对象的属性保持一致。有用的信息应该尽可能的详细。
属性名称 |
用途 |
localHostname |
本地DNS主机名称 |
localIp |
本地ip地址 |
localPort |
本地端口 |
remoteHostname |
远程DNS主机名称 |
remoteIp |
远程ip地址 |
remotePort |
远程主机端口 |
path |
路径 |
srcpath |
原路径 |
Dstpath |
目标路径 |
hostname |
DNS主机名称 |
Ip |
Ip地址 |
propertyName |
属性名称 |
propertyValue |
属性值 |
syscall |
系统调用失败的名称 |
Errno |
Errno的值(如"ENOENT") |