免责声明:我不知道任何关于错误处理的理论,但是,当我探索各种语言和编程范式以及玩弄编程语言设计(并讨论他们)。因此,以下是我迄今为止的经验总结;有客观的论据。
注意:这应该涵盖所有问题,但我什至没有尝试按顺序解决它们,而是更喜欢结构化的演示文稿。为了清楚起见,在每个部分的末尾,我对它回答的问题给出了一个简洁的答案。
简介
作为前提,我想指出,无论讨论什么,在设计库(或可重用代码)时都必须牢记一些参数。
作者无法理解这个库将如何被使用,因此应该避免使集成变得更加困难的策略。最明显的缺陷是依赖全局共享状态;线程本地共享状态也可能是与协程/绿色线程交互的噩梦。使用这样的协程和线程也强调了同步最好留给用户,在单线程代码中这意味着没有(最佳性能),而在协程和绿色线程中,用户最适合实现(或使用现有的专用同步机制的实现。
话虽如此,当库仅供内部使用时,全局或线程局部变量可能很方便;如果使用,则应明确记录为技术限制。
记录
有很多方法可以记录消息:
- 带有额外信息,例如时间戳、进程 ID、线程 ID、服务器名称/IP...
- 通过同步调用或使用异步机制(和溢出处理机制)
- 在文件、数据库、分布式数据库、专用日志服务器...
作为库的作者,日志应该集成到客户端基础架构中(或关闭)。最好允许客户端提供钩子以便自己处理日志,我的建议是:
- 提供 2 个钩子:一个决定是否记录,一个用于实际记录(消息被格式化,后一个钩子仅在客户端决定记录时调用)
- 在消息的顶部提供:严重性(又名级别)、文件名、行和函数名(如果是开源的)或 逻辑模块(如果有多个)
- 默认情况下,写入
stdout 和stderr(取决于严重性),直到客户端明确表示不记录
我会注意到,按照介绍中描述的指南,同步留给客户端。
关于是否记录错误:不要记录(作为错误)您已经通过 API 报告的内容;但是,您仍然可以以较低的严重性记录详细信息。客户端可以在处理错误时决定是否报告,例如如果这只是一个推测调用,则选择不报告。
注意:某些信息不应进入日志,而其他一些信息最好进行混淆处理。例如,不应记录密码,最好对信用卡或护照/社会安全号码进行混淆(至少部分混淆)。在为此类敏感信息设计的库中,这可以在记录期间完成;否则应用程序应该处理这个问题。
日志记录是否应该只在应用程序代码中完成?还是可以从库代码中进行一些日志记录。
应用程序代码应该决定策略。库是否记录取决于它是否需要。
出错后继续?
在我们真正谈论报告错误之前,我们应该问的第一个问题是是否应该报告错误(以进行处理),或者事情是否严重到中止当前流程显然是最佳策略。
这当然是一个棘手的话题。一般来说,我会建议设计这样一个选项,如果需要,可以进行清除/重置。如果在某些情况下无法做到这一点,那么这些情况应该会导致流程中止。
注意:在某些系统上,可以获得进程的内存转储。如果应用程序处理敏感数据(密码、信用卡、护照等),最好在生产中停用它(但可以在开发过程中使用)。
注意:如果有一个调试开关,可以将部分错误报告调用转换为带有内存转储的中止,以帮助开发期间的调试,这可能会很有趣。
报告错误
错误的发生意味着函数/接口的契约不能被履行。这有几个后果:
- 应该警告客户端,这就是应该报告错误的原因
- 任何部分正确的数据都不应在野外逃逸
后一点将在稍后处理;现在让我们专注于报告错误。客户永远不能意外忽略此报告。这就是为什么使用错误代码是如此可恶的原因(在可以忽略返回值的语言中):
ErrorStatus_t doit(Input const* input, Output* output);
我知道有两种方案需要对客户端部分进行明确的操作:
- 例外情况
- 结果类型(
optional<T>、either<T, U>、...)
前者是众所周知的,后者在函数式语言中被大量使用,并在 C++11 中以std::future<T> 为幌子引入,尽管存在其他实现。
我建议在可能的情况下更喜欢后者,因为它更容易理解,但在没有预期结果时恢复到异常。对比:
Option<Value&> find(Key const&);
void updateName(Client::Id id, Client::Name name);
在updateName等“只写”操作的情况下,客户端对结果没有用处。它可以被引入,但很容易忘记检查。
当结果类型不切实际或不足以传达详细信息时,也会发生恢复异常:
Option<Value&> compute(RepositoryInterface&, Details...);
在这种外部定义的回调的情况下,有几乎无限的潜在故障列表。实现可以使用网络、数据库、文件系统……在这种情况下,为了准确报告错误:
- 当接口不足以(或不切实际)传达错误的完整细节时,外部定义的回调应该通过异常报告错误。
- 基于此abstract 回调的函数应该对这些异常透明(让它们通过,未经修改)
目标是让这个异常冒泡到决定接口实现的层(至少),因为只有在这个层,才有机会正确解释抛出的异常。
注意:外部定义的回调并不强制使用异常,我们应该期待它可能会使用一些。
使用错误
为了使用错误报告,客户端需要足够的信息来做出决定。应该首选结构化信息,例如错误代码或异常类型(用于自动操作),并且可以以非结构化方式(供人类调查)提供附加信息(消息、堆栈等)。
最好有一个函数清楚地记录所有可能的故障模式:它们何时发生以及如何报告。但是,特别是在执行任意代码的情况下,客户端应该准备好处理未知代码/异常。
当然,结果类型是一个值得注意的例外:boost::variant<Output, Error0, Error1, ...> 提供了经过编译器检查的已知故障模式的详尽列表……当然,返回此类型的函数仍可能抛出异常。
如何决定是记录错误还是将其作为错误消息显示给用户?
当订单无法完成时,应始终警告用户,但应显示用户友好(可理解)的消息。如果可能,还应提供建议或解决方法。详情供调查小组使用。
从错误中恢复?
最后但同样重要的是,关于错误的真正可怕的部分是:恢复。
这是数据库(真实的)非常擅长的:类似事务的语义。如果发生任何意外情况,事务就会中止,就像什么都没发生一样。
在现实世界中,事情并不简单。脑海中浮现出取消发送电子邮件的简单示例:为时已晚。可能存在协议,具体取决于您的应用程序域,但这不在讨论范围内。不过,第一步是恢复正常的in-memory状态的能力;这在大多数语言中远非简单(而 STM 目前只能做这么多)。
首先是挑战的说明:
void update(Client& client, Client::Name name, Client::Address address) {
client.update(std::move(name));
client.update(std::move(address)); // Throws
}
现在,地址更新失败后,我只剩下更新了一半的client。我能做什么?
- 尝试撤消发生的所有更新几乎是不可能的(撤消可能会失败)
- 在执行任何单个更新之前复制状态是一种性能消耗(假设我们甚至可以以可靠的方式将其交换回来)
在任何情况下,所需的簿记都会导致错误蔓延。
最糟糕的是:对于损坏的程度无法做出安全的假设(除了client 现在被搞砸了)。或者至少,没有假设会持续时间(和代码更改)。
通常,获胜的唯一方法就是不玩。
一种可能的解决方案:交易
在可能的情况下,关键思想是定义宏函数,这些函数要么失败,要么产生预期的结果。这些是我们的交易。而且它们的形式是不变的:
Either<Output, Error> doit(Input const&);
// or
Output doit(Input const&); // throw in case of error
事务不会修改任何外部状态,因此如果它无法产生结果:
- 外部世界没有改变(没有什么可以回滚)
- 没有观察到的部分结果
任何不是事务的函数都应该被认为已经损坏了它所触及的任何东西,因此处理非事务函数错误的唯一理智方法是让它冒泡直到到达事务层。任何事先处理错误的尝试最终都注定要失败。
如何决定错误是应该在本地处理还是传播到更高级别的代码?
如果出现异常,您通常应该在哪里捕获它们?在低级或高级代码中?
只要安全这样做并且这样做有价值,就与他们打交道。最值得注意的是,捕获一个错误是可以的,检查它是否可以在本地处理,然后要么处理它,要么传递它。
你是应该通过所有代码层争取统一的错误处理策略,还是尝试开发一个可以适应各种错误处理策略的系统(以便能够处理来自 3rd 方库的错误) .
我之前没有解决这个问题,但是我相信很明显,我强调的方法已经是双重的,因为它包含结果类型和异常。因此,处理 3rd 方库应该是小菜一碟,尽管出于其他原因我建议无论如何都要包装它们(第 3 方代码更好地隔离在一个负责阻抗适应的面向业务的接口之外)。