?导读
软件开发中遇到异常才是正常,很少有人能写出完美的程序跑在任何机器上都不会报错。但极为正常的软件异常,却经常出自不同的原因,导致不同的结果。怎么样科学地认识异常、处理异常,是很多研发同学需要解决的问题。本文作者根据自己多年的工作经验,撰写了《异常思辨录》系列专栏,希望能体系化地帮助到大家。本文为系列第三篇,本篇文章将主要聚焦业务开发对异常处理的需求点和一些优秀的异常处理案例,欢迎阅读。
?目录
1 业务开发对异常处理的需求点
2 优秀的异常处理方案
2.1 异常的建模
2.2 异常的兜底
2.3 其他人性化的思考
站在业务开发角度,编写一段异常处理的代码需要考虑诸多问题,而作为框架开发者,帮忙解决这些问题或给出明确的指导意见,是一名务实的框架开发者的责任。
《持续交付 2.0》中提到高效率交付的关键要求,我觉得是每一个务实的业务开发关注的自身业务的在使用中都会思考的。
线程安全和协程安全永远是 C++ 开发者关心的话题,好的异常处理方案应该首先摆在前面的就是内存数据的安全性。
首先我们需要明确何种问题是异常处理内存安全性的问题,即抛出的异常和捕获的异常应该是同一个异常对象,如下述代码:
static std::atomic<int> thread_counter;
void foo() {
thread_local int tid = ++thread_counter;
try {
throw MyException("Data from thread", tid);
} catch (const MyException &ex) {
assert(ex.tid == tid);
}
}
如果在多个线程中使用 foo
,C++ 11 已经能够保证 throw
的异常和捕获住的异常是同一个对象,不会出现线程读写冲突,因为每个 std::current_exception()
都是线程变量而非全局变量。C++11 还有更高级的用法,使用 std::current_exception()
和 std::rethrow_exception()
,可以将一个线程的异常获取保存下来并在另外的线程抛出。
如果使用 C++20 的 coroutine 来实现协程的业务代码,完全不用担心异常的内存安全性问题。因为编译器已经将你的 return_value
和 return_void 函数包装在了 try/catch
中,所以你不需要再这么做。
如果 svrkit-like 框架,其协程切换是通过 hook 系统调用来进行协程切换的。根据 colib 的源代码,对于非网络操作、IO 读写根本就不会触发协程切换。即在你抛出异常时,也是编译器使用 __cxa_allocate_exception
分配异常对象的内存,在 catch 之后使用 __cxa_free_exception
来释放内存,而通过分析也可知道异常对象的内存是在栈上保存的,不存在异常抛出时协程切换导致异常对象被其他协程修改。
对于一个负责任的程序员,时刻要保证自己的代码干干净净,用最小的代码量做最多的事情。
由于目前使用错误码的思想来对异常进行处理,所以对于复杂的业务逻辑,需要每次有返回错误码的时候都需要完成很多代码的编写:
由于对于金融系统的谨慎,所以在错误码的指导思想下,每一层带有错误码的都需要完成上述每一步操作,哪怕是一个简单的参数校验,都需要层层校验返回码,层层上报,层层打日志,这对于一线的业务研发简直就是反人类的做法,因为对于他们来说对于某一个他无权处理也无权关注的异常也也必须老老实实按照规范来做,否则到时候定位异常时,就无法通过层层的日志来欢迎最根源的异常场景。
同时这样的冗长的代码,对于代码审阅者而言也是极大的痛苦,根据 RAII 的思想和面向对象的分析与设计,代码审查时只应该关注对象自身能够处理的异常,而非所有的异常,其他组合的对象在发生异常后会被编译期优化的代码自动析构,关联的对象的则会减少关联引用。
开发者盼望着有一种异常的机制可以实现真的关注点分离:
对于一个合格的框架,应该是对异常友好的,而非交由操作系统来处理。
比较好的做法是在编译时确定兜底行为。若编译配置为生产环境,兜底报错写日志,立即恢复工作进程处理下个请求;开发配置,放过异常捕获,直接让进程异常终止。这样开发者在开发环境就可以直接使用 gdb 进行异常现场的恢复并调试。
目前普遍优秀的后端框架(如微信后端开发框架)都支持服务端调用的拦截器,如果需要设计一个异常,那么与之对应的,还需要准备一个服务器端拦截器,用于将该异常中的错误码转换为函数返回值,并自动填充错误信息到回包中。
一个优秀的方案,一定是能够让使用者感觉非常方便的,并且可以非常和其他的相关系统交互良好的。
使用 C++ 语言中的宏在此时可以发挥一些简单的优势避免业务开发这边写过于冗余的代码。
EXPECT_EQ
和 Google Protobuf 中 CHECK_EQ
都用了此类技术。同时基于 C++ 类的 ADL 特性,可以将不同类型的数值或对象通过统一的方式展现出来,ADL 是由编译器在查找函数调用时自动进行的。ADL是一种名称查找规则,它会根据函数参数的类型在特定的命名空间中查找函数。例如下列代码:
static constexpr int fake_result = 1;
std::string test;
UCLI_ASSURE_GT(fake_result, 9) // ASSURE_XX 之类的宏用于保证某个条件一定能实现,否则就触发异常
<< 503 // int 或 枚举类型会翻译成错误码
<< UnifiedControlCode::UCC_UNCERTAIN_BUSY // UnifiedControlCode 会被视为设置控制码
<< "The server is busy, try later" // 字符串类型会被视为附加的错误信息
<< DoReport<OssCheckPoint<123, 1>>() // DoReport 指令用立即上报一个和框架相关的数值(框架相关)
<< WithReport<tags::InvalidTradeNo>() // WithReport 指令用于对 InvalidTradeNo 标记进行上报(框架无关)
<< WithRes<MyString>("My Additional Text") // WithRes 指令用于抛出异常时附加其他数据
<< [&]() { test = "Oh god!"; }; // 可接受一个 Callable 用于执行附加的操作
如果有其他的对象可以集成使用 operator <<
这样的运算符,也只需要配合示例特化 UnifiedExceptionApplier
即可。
既然是考虑用到了异常,就应该提供一种方案可以让某段代码安全的运行,如果这段代码抛出了特定的异常,可以通过特定的对象获取这个异常的上下文信息。例如:
UnifiedRpcController controller;
int a = controller.SafeCall([]() {
UCLI_ASSURE_EQ(101, 102) << 500 // 错误码
<< UnifiedControlCode::UCC_UNCERTAIN_RETRY // 表示是可重试的错误
<< "Server down!" // 错误文本
<< WithRes<int>(123456); // 其他附加资源
return 100; // 如果成功就会走到这一步
});
a; // 0 出现了错误,直接返回默认值
controller.IsOk(); // false 控制信息记录错误
controller.IsDone(); // true 控制信息表示代码块完成
controller.IsUncertainRetry(); // true 控制信息表示是结果不确定需要重试
controller.error_code(); // 500 控制信息包含的错误码
controller.ErrorText(); // "Check failed: (101) == (102): Server down!" 断言文本+错误提示
controller.Options<int>() // 123456 其他附加资源
上述代码可以安全执行一段可能会抛出异常到代码,并将异常信息记录到 controller
中,这样业务方也就没有心智负担的调用其领域服务的逻辑了,当然也可以直接使用 try..catch..
来处理异常。
既然 UnifiedRpcController
已经包含了异常的所需的错误码、控制码、错误信息等,那么也应该有一个方法可以让一些含有异常信息的对象转换为异常抛出。例如:
UnifiedRpcController contorller;
contorller.SetResult(UnifiedControlCode::UCC_UNCERTAIN_BUSY, 504, "Server busy");
UCLI_ASSURE_OK(contorller); // 将抛出一个异常,错误信息控制码错误码都来源于 controller
优秀的方案在使用初期就应该需要面向运营和监控来设计,而不是充分得暴露扩展性给开发者,最终只抛出一句我们可以来共建啊打发对方,而是我们在设计之初就应该为监控而设计我们的组件。
这里一点我们可以吸取错误码的一些优势,错误码作为一种简单的数据类型,如果能被治理好,保证全局唯一,作为一个全局的面向运营和监控是一个非常好用的手段。
传统的异常管理由于基于语言的特殊性,不具备有普适的通用型,故现有的系统的监控告警机制都依赖错误码,如果业务要做到自己业务的异常也面向监控而设计,那么我们异常组件也应该可以支持这样的能力。
所以在我们设计的系统中,错误码和控制码被设计成一种通用能力用于在抛出异常时提供给上层框架上报运营异常的能力。
当然这里也必须提醒一句,就算使用了抛异常来处理业务错误,也必须要做好错误码的管理,包括全局唯一性的分配、场景的描述,统计运营等才能构建好业务系统。
面对层层的 if return
出错了居然还有人忍受,一步步去看日志,一步步去跳转代码查看错误原因?
现在使用错误码最多让人诟病的就是 if return
,看到一个错误码就懵逼了根本就不知道是哪个错误条件引起的。所以我们在设计之初就为方便调试做了诸多规划。
之前为了避免使用异常,早在 2020 年规划构建微信支付后端开发框架时,就使用了错误栈来记录每一层的源代码位置,使其可以通过一个调试界面拿到完整错误发生到捕获的全部的每一层的源代码信息,不过后来发现还是很多人对这样的一种记录方式感觉非常疑惑,往往在转发一个错误时并不需要记录任何转发层的错误信息。而且由于中途还可以修改错误码或控制信息,导致最终其他组件不得不在最顶部的错误码还是最底部的错误码进行选择。
上图是对应一个普通业务开发遇到的场景,对应编写 ProcessInBusiness
函数。
ProcessInComponent
函数;ProcessInComponent
处理完成了,按照异常处理流程,如果在自己的处理的业务逻辑中,此时应该引发一个新的错误,而不是对上次异常进行重新抛出;上述基于 OpenSSL 的错误码处理思想在一定程度上解决了在调试时追踪错误发生链路的问题。
但在实践中,由于业务开发的误用,导致出现一个非常蹩脚的结果。
PushForward
和 SetFail
在语义上由非常大的区别,一个用于在错误信息中添加一个节点的记录,一个表示完全清空错误链信息;SetFail
时错误使用了 PushForward
并转义了错误码,导致只是在错误链中增加了一行源代码记录信息(如上图中右下的 错误码 -2:? 基础组件报错 没有被清除);所以作为一个新的框架需要考虑的重点不再是功能的强大,而是要让各个业务方都能无忧无虑的在毫无压力的情况下正确使用,使用异常显然完成可以满足这样的新设计的需求。因为异常的处理核心就是不会让业务在在自己不熟悉的领域编写错误转发代码,同时,通过自定义的业务异常,可以拿到调用帧的数据,无感的获取调试信息。
虽然在某些情况下使用继承是合理的,但总的来说,组合提供了更好的封装性、代码复用性、灵活性和可维护性,因此通常被推荐使用。
由于 C++ 异常在设计时是可以继承的,很多开发者都认为是不是所有的业务异常都应该分配一个唯一的类的名字,然后再外层进行捕获。这样的思想可能来自于早期 Java 思想,Java 可以显式在每个函数中定义处那些异常是可抛出的,那么在调用方就可以非常清晰的列出,也就是说我在不知道对方代码实现的情况下,调用者可以知道抛出的异常的类型,并对其中的自己能够处理的类型进行处理。
但随着业务的发展和 Java 框架的成熟,在Java设计中,对每个业务都分配一个唯一的异常子类并不是必要的。一种常见的做法是使用一个全局异常处理类来处理所有异常。全局异常处理类使用了 @ControllerAdvice
或 @RestControllerAdvice
注解,这两个注解都是Spring MVC提供的,作用于控制层的一种切面通知,可以进行全局异常处理、全局数据绑定以及全局数据预处理。
我们可以自定义一个异常类(如GlobalException
),这个异常类可以用于处理项目中的异常,并收集异常信息。这个全局的异常处理类(如GlobalExceptionHandler
)内部使用了 @ExceptionHandler
注解去捕获异常,包括处理自定义异常。总的来说,虽然我们可以为每个业务创建一个唯一的异常子类,但在实践中,这可能会导致代码过于复杂和难以管理。更常见的做法是定义一些通用的异常类,如GlobalException
,并通过全局的异常处理类来捕获和处理这些异常。
其实对所有业务异常都使用一个全局的业务是实际上是对异常建模之后去泛化的结果。所谓 去泛化 就是在最初设计的带有继承的类图中将像似的子类合并到同一个基类,使用属性来代替继承来实现模型表达的过程。
在去泛化之后,我们发现某些异常可能需要带有原始的异常信息,这些信息也许是结构化的,并非直接从错误信息可以获取的,如:
这些自定义信息则可以使用C++ 类型擦除的方式存储到异常对象中,从而使得只有关注此异常信息的代码才需要这个异常对象的定义。例如如下代码:
struct MyString : public string {
using string::string;
std::string ToString() const { return *this; }
};
try {
static constexpr int fake_result = 1;
UCLI_ASSURE_GT(fake_result, 9)
<< 503 << UnifiedControlCode::UCC_UNCERTAIN_BUSY
<< "The server is busy, try later"
<< WithRes<MyString>("My Additional Text");
} catch (const UnifiedException& ex) {
ex.Res<MyString>();
}
可以设计一个 WithRes<T>
的模板函数,将某些特定的数据类型在抛出之前放置到异常对象中;当需要关注此异常数据的使用方捕获住异常后,使用 Res<T>
获取抛出时异常对象中的特定数据。
一个优秀的方案并不是一句话需求,我认为任何一刀切不要使用 C++ 异常或必须返回 int 这样的话术都是及其不负责任且低级的,所以我们需要提出一个对于业务错误的综合的方案,包括从最初设计异常模型开始,到最后上线成为一个业务开发真正可用的系统。
我们可以通过通用的设计工具来设计一个通用异常的类图。
上述的类图使用太复杂了,面对技术需求,我们需要把其中的异常类进行去泛化,将某些子类的属性通过组合的方式压缩,收敛到通用的基类中。通过去泛化我们可以得到基于组合设计的通用异常类。
有了上述异常的基类,分别在基础组件、业务代码、基础框架中就可以非常简单的使用抛出异常。
早期的异常处理语言还存在语言设计层面的自动恢复的功能。在一些编程环境中,特别是像 Visual Basic for Applications (VBA)——继承于老式的 Visual Basic——这样的环境,提供了一种方式可以在出现错误时让程序自动“恢复”,即跳过错误并继续执行后面的代码1。然而,需要注意的是,On Error Resume Next
并不是在所有情况下都是最佳的错误处理方式。因为它仅仅是忽略错误,而不是解决错误。如果错误涉及到的是关键任务或者数据,这种做法可能会导致程序在后续运行中出现更严重的问题。因此,应该谨慎使用 On Error Resume Next
,并确保在使用它时能够在适当的地方处理或记录错误。
随着时代的发展,越来越多的程序员发现,应该给应用程序自动恢复的异常的能力。“自动异常处理”是一个计算术语,指的是计算机化的错误处理。运行时系统(如 Java 编程语言或.NET 框架的运行时引擎)本身就支持异常或错误的自动处理模式。在这些环境中,软件错误不会导致操作系统或运行时引擎崩溃,而是生成异常。这些运行时引擎的最近进展使得专门的运行时引擎附加产品能够提供独立于源代码的自动异常处理,并为每个感兴趣的异常提供根源信息。
在发生异常时,运行时引擎会调用一个附加到运行时引擎(例如,Java 虚拟机(JVM))的错误拦截工具。基于异常的性质,例如其类型以及发生异常的类和方法,以及基于用户偏好,可以选择处理或忽略异常。
这样的异常一般出现在 GUI 应用程序中,因为最终用户非常有可能重试一下自己刚刚的操作,或简单的重启应用程序并重做刚才的操作。因为 GUI 程序中有大量的 UI 交互,开发人员非常难把所有的异常状态都捕获住(特别那些多线程的程序),所以这时候保留异常的现场就变得尤其重要。
但对于一名务实的代码工作者,像这样底层的、兜底的错误不应该被最终用户所看到,虽然框架(或某些插件 Delphi
中的 madExcept
) 可以提供一定的兜底措施,但如果确实是领域逻辑中会出现的异常,还是应该给出友好的错误提示,并提供可被验证的恢复方案。
对于一个运行在后台不间断的运行的服务时,不可避免的会遇到某些错误,这些错误根据分类可以进行不同程度的处理:
std::bad_alloc
这样的异常);abort
也是一种兜底策略std::runtime_error
mysqlpp::ConnectionFailed
捕获住,为当前场景添加合适的错误码、带上下文的错误描述等。而由于 C++ 的语言特性,一旦 catch 住异常后,再也没有办法可以获取异常发生时的上下文信息、包括调用帧、代码位置等信息,所以框架此时应该直接让操作系统接管,并生成 coredump 文件用于排查调试模式下的可能出现的运行时异常;std::bad_cast
:使用 dynamic_cast
向下转换时失败引发的异常;std::bad_any_cast
:使用 std::any_cast<T>
进行拆箱时引发的转换错误;std::bad_optional_access
:使用 std::optional<T>::value()
获取没有值时引发的错误;google::protobuf::FatalException
:可能由于使用了不正确的反射获取不匹配消息字段引发;boost::bad_lexical_cast
:使用 boost::lexical_cast
进行类型转换引发的异常;fmt::format_error
:使用 fmtlib
对目标对象进行格式化时,由于格式化串错误引发的异常;Json::LogicError
:使用 JsonCpp
获取不到值时,或无法将 Json 类型进行转换时引发的异常(非常常见);mysqlpp::ConnectionFailed
: 使用 MySQL++ 库连接 MySQL 客户端时无法连接上引发的异常;mysqlpp::ConnectionFailed
及时捕获,并在专用系统中登记明确登记错误码,将这个运行时异常转化为逻辑异常(表示这个异常是我已经预期到的,可以被正确的处理的,异常的收敛的也是处理方式之一);std::logic_error
,本方案中的对应 UnifiedException
。即对于不同框架制作一个适配层用于捕获业务异常,再将其转换为框架的能返回回去。UnifiedException
,将其中的错误码转换为返回码、错误信息注入的回报的 error_message
中,其他的信息可以使用 RespCookie 返回;UnifiedException
在执行工作函数时将异常捕获,并按照框架的需求返回UnifiedRpcController::SafeCall
函数先包一层,再进行到 MeshRet
的转换(WxMesh),或在每次调用时使用 try...catch...
手动进行异常处理。需求点 4 中提到了一些对于业务开发的一些痛点,比如每次都要重写一遍断言表达式,比如很多 if
造成干扰。这一点其实对于一个务实的框架码农是非常容易完成的,我们提出的一些更更加人性化的考虑会重点放在调试和运营阶段。
比如最痛的一点是在服务的开发过程中如果发现了一个业务异常,根本就没办法知道发生异常的调用帧,以前的做法是一层一层的打日志进行排查,Xwi 的做法是一层一层的增加错误栈用于调试。
未来人性化的考量可能会通过调试环境、生产环境来实现差异化的功能:
本文为《异常思辨录》系列第三篇,第一篇:《降本增笑的P0事故背后,是开猿节流引发的代码异常吗?》
在下一篇文章中,我们将主要介绍一些上层的决策点,感兴趣的记得关注收藏,不错过后续文章更新。
-End-