查看原文
其他

异常≠错误,正如Bug≠事故,详解业务开发中的异常处理

陈明龙 腾讯云开发者 2024-01-11



👉导读

软件开发中遇到异常才是正常,很少有人能写出完美的程序跑在任何机器上都不会报错。但极为正常的软件异常,却经常出自不同的原因,导致不同的结果。怎么样科学地认识异常、处理异常,是很多研发同学需要解决的问题。本文作者根据自己多年的工作经验,撰写了《异常思辨录》系列专栏,希望能体系化地帮助到大家。本文为系列第三篇,本篇文章将主要聚焦业务开发对异常处理的需求点和一些优秀的异常处理案例,欢迎阅读。

👉目录

1 业务开发对异常处理的需求点2 优秀的异常处理方案    2.1 异常的建模    2.2 异常的兜底
    2.3 其他人性化的思考



01



业务开发对异常处理的需求点

站在业务开发角度,编写一段异常处理的代码需要考虑诸多问题,而作为框架开发者,帮忙解决这些问题或给出明确的指导意见,是一名务实的框架开发者的责任。

《持续交付 2.0》中提到高效率交付的关键要求,我觉得是每一个务实的业务开发关注的自身业务的在使用中都会思考的。


   1.1 需求点  1:内存安全性


线程安全和协程安全永远是 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 来释放内存,而通过分析也可知道异常对象的内存是在栈上保存的,不存在异常抛出时协程切换导致异常对象被其他协程修改。

   1.2 需求点 2:关注点分离


对于一个负责任的程序员,时刻要保证自己的代码干干净净,用最小的代码量做最多的事情。

由于目前使用错误码的思想来对异常进行处理,所以对于复杂的业务逻辑,需要每次有返回错误码的时候都需要完成很多代码的编写:
  • 断言错误发生的时机
    • 如果是原发性逻辑错误,需要对逻辑进行判断
    • 如果是转发上层错误,需要对返回值进行判断
  • 设置需要返回的错误码
  • 打印日志,日志中需要包含可能的上下文信息
  • 做 Oss 上报,用于分钟级曲线监控,以便对该异常做全局监控或报警
  • (可选)做 mmdata 上报,用于同时上报场景信息(如发生的商户号、用户 Uin等),以便对该异常做分场景的上报,为 KA 商户等场景做特殊告警等
  • (可选)接入层模块中还会对最终用户的文案进行错误码转义或组装

由于对于金融系统的谨慎,所以在错误码的指导思想下,每一层带有错误码的都需要完成上述每一步操作,哪怕是一个简单的参数校验,都需要层层校验返回码,层层上报,层层打日志,这对于一线的业务研发简直就是反人类的做法,因为对于他们来说对于某一个他无权处理也无权关注的异常也也必须老老实实按照规范来做,否则到时候定位异常时,就无法通过层层的日志来欢迎最根源的异常场景。

同时这样的冗长的代码,对于代码审阅者而言也是极大的痛苦,根据 RAII 的思想和面向对象的分析与设计,代码审查时只应该关注对象自身能够处理的异常,而非所有的异常,其他组合的对象在发生异常后会被编译期优化的代码自动析构,关联的对象的则会减少关联引用。

开发者盼望着有一种异常的机制可以实现真的关注点分离:
  1. 在抛出异常时记录调用帧的信息,这样就在回溯时可以拿到完整的调用链路;
  2. 业务只需要关注自己能够处理的异常,对于无法处理的异常,交给上层来处理;
  3. 在抛出异常前可以对异常的错误码、监控、上报进行统一的处理;
  4. 错误信息或日志完全可以在捕获异常时进行处理,如果不能捕获,框架应该统一处理。

   1.3 需求点 3:框架兜底行为


对于一个合格的框架,应该是对异常友好的,而非交由操作系统来处理。

  • 开发环境:框架的行为应该是尽可能暴露问题,为开发者提供完整的错误上下文,使得开发者可以快速定位问题修复代码。如 Windows 下的 Debug 编译的 exe 文件可以显示友好的异常上下文,配合 pdb 文件可以直观的显示源代码中调用帧和异常发生的位置。
  • 生产环境:框架应该尽力兜底错误,恢复职能。保证自身框架的稳定性,不应影响其他并行的业务接口。

比较好的做法是在编译时确定兜底行为。若编译配置为生产环境,兜底报错写日志,立即恢复工作进程处理下个请求;开发配置,放过异常捕获,直接让进程异常终止。这样开发者在开发环境就可以直接使用 gdb 进行异常现场的恢复并调试。

目前普遍优秀的后端框架(如微信后端开发框架)都支持服务端调用的拦截器,如果需要设计一个异常,那么与之对应的,还需要准备一个服务器端拦截器,用于将该异常中的错误码转换为函数返回值,并自动填充错误信息到回包中。

   1.4 需求点 4:使用简单


一个优秀的方案,一定是能够让使用者感觉非常方便的,并且可以非常和其他的相关系统交互良好的。

使用 C++ 语言中的宏在此时可以发挥一些简单的优势避免业务开发这边写过于冗余的代码。
  • 使用宏可以将一部分代码判断直接变成字符串文本常量,用于形成错误提示的一部分。gtest 中 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

   1.5 需求点 5:面向运营和监控


优秀的方案在使用初期就应该需要面向运营和监控来设计,而不是充分得暴露扩展性给开发者,最终只抛出一句我们可以来共建啊打发对方,而是我们在设计之初就应该为监控而设计我们的组件。

这里一点我们可以吸取错误码的一些优势,错误码作为一种简单的数据类型,如果能被治理好,保证全局唯一,作为一个全局的面向运营和监控是一个非常好用的手段。
  • 如果这个错误码被运用到某个领域系统的业务逻辑中:因为此错误码关联住了系统和领域,那么当这个错误码发生次数出现异常时(例如和上一个工作日周期做比对),就可以非常快速了解到某个业务逻辑是不是出现了异常。
  • 如果这个错误码被运用到某个基础组件中:错误码被全局管控,可以知道某个机器的特性出现问题,比如某个机器的 KV server 磁盘读写失败次数升高。
  • 如果这个错误码被运用到某个边界系统中:边界系统网网会有比较完善的监控,那么就可以非常快速的知道,在某个业务下的某个边界系统出现问题,这时候为动态运营提供了强有力的切换决策理由。(比如某双路消息订阅系统,在分布式事件中心的压力太大时,事件中心的错误码上报增加,此时可以准备预案切换到某些流量到本地消息队列以缓解事件中心生产者端的压力)。
  • 错误码还可以被简单的集成到模块最终和调用链分析中:通过错误码管理系统可以为模块调用系统提供具体接口级别调用的异常控制聚合分析,对这样的特性异常进行配置告警,并针对这些告警推测可能出现的问题,制定 BCP。

传统的异常管理由于基于语言的特殊性,不具备有普适的通用型,故现有的系统的监控告警机制都依赖错误码,如果业务要做到自己业务的异常也面向监控而设计,那么我们异常组件也应该可以支持这样的能力。

所以在我们设计的系统中,错误码和控制码被设计成一种通用能力用于在抛出异常时提供给上层框架上报运营异常的能力。

当然这里也必须提醒一句,就算使用了抛异常来处理业务错误,也必须要做好错误码的管理,包括全局唯一性的分配、场景的描述,统计运营等才能构建好业务系统。

   1.6 需求点 6:方便调试


面对层层的 if return 出错了居然还有人忍受,一步步去看日志,一步步去跳转代码查看错误原因?

现在使用错误码最多让人诟病的就是 if return,看到一个错误码就懵逼了根本就不知道是哪个错误条件引起的。所以我们在设计之初就为方便调试做了诸多规划。

之前为了避免使用异常,早在 2020 年规划构建微信支付后端开发框架时,就使用了错误栈来记录每一层的源代码位置,使其可以通过一个调试界面拿到完整错误发生到捕获的全部的每一层的源代码信息,不过后来发现还是很多人对这样的一种记录方式感觉非常疑惑,往往在转发一个错误时并不需要记录任何转发层的错误信息。而且由于中途还可以修改错误码或控制信息,导致最终其他组件不得不在最顶部的错误码还是最底部的错误码进行选择。


上图是对应一个普通业务开发遇到的场景,对应编写 ProcessInBusiness 函数。
  1. 调用某组件开发者开发者的一个功能(可能是函数或对象),对应示例中调用 ProcessInComponent 函数;
  2. 编写自己的业务逻辑;
    1. 如果属于自己的业务逻辑,(比如查找某数据不存在,下一步可能是需要插入数据),那么进行逻辑处理,此时无论如何,都表示自己已经对 ProcessInComponent 处理完成了,按照异常处理流程,如果在自己的处理的业务逻辑中,此时应该引发一个新的错误,而不是对上次异常进行重新抛出;
    2. 如果不属于以自己的业务逻辑,自己的业务流程不能处理,则需要将这个错误码进行转发,并加入自己当前的代码位置以方便调试;
  3. 框架一般是将某些虚函数暴露给业务实现,或使用依赖注入的方式将业务处理的函数注入到框架中,此时框架一般都只是转发错误码,并记录转发的代码位置。

上述基于 OpenSSL 的错误码处理思想在一定程度上解决了在调试时追踪错误发生链路的问题。
  • 调试器可以拿到一个完整的错误链,每个错误链都是由代码中的代码显式上报的;
  • 虽然不是必须的,每次调用链都可以对其中的节点进行错误码转义、甚至是状态码、错误信息都可以添加记录,以保证完整链路中的上下文信息可以完整被捕获到;

但在实践中,由于业务开发的误用,导致出现一个非常蹩脚的结果。
  1. PushForward 和 SetFail 在语义上由非常大的区别,一个用于在错误信息中添加一个节点的记录,一个表示完全清空错误链信息;
  2. 某些开发在应该使用 SetFail 时错误使用了 PushForward 并转义了错误码,导致只是在错误链中增加了一行源代码记录信息(如上图中右下的 错误码 -2:❶ 基础组件报错 没有被清除);
  3. 最先被插入的错误信息的依然是组件开发者提供的错误码,因此最终框架把 错误码 -2:❶ 基础组件报错 作为错误的源头,把此组件的错误码作为错误信息返回给主调方,其实业务的想法应该是把 错误码 -1001:业务转义错误码 报告给主调方;
  4. 最后框架不得不作为妥协,将 错误码 -1001:❸ 框架转发错误码 中的错误码 -1001 和 错误码 -2:❶ 基础组件报错 中的错误信息 基础组件报错 这样一种畸形的结果报告给主调方,因为错误码的误解造成的危害远远比错误信息造成的误解要来的严重。

所以作为一个新的框架需要考虑的重点不再是功能的强大,而是要让各个业务方都能无忧无虑的在毫无压力的情况下正确使用,使用异常显然完成可以满足这样的新设计的需求。因为异常的处理核心就是不会让业务在在自己不熟悉的领域编写错误转发代码,同时,通过自定义的业务异常,可以拿到调用帧的数据,无感的获取调试信息。

   1.7 需求点 7:具备扩展性


虽然在某些情况下使用继承是合理的,但总的来说,组合提供了更好的封装性、代码复用性、灵活性和可维护性,因此通常被推荐使用。

由于 C++ 异常在设计时是可以继承的,很多开发者都认为是不是所有的业务异常都应该分配一个唯一的类的名字,然后再外层进行捕获。这样的思想可能来自于早期 Java 思想,Java 可以显式在每个函数中定义处那些异常是可抛出的,那么在调用方就可以非常清晰的列出,也就是说我在不知道对方代码实现的情况下,调用者可以知道抛出的异常的类型,并对其中的自己能够处理的类型进行处理。

但随着业务的发展和 Java 框架的成熟,在Java设计中,对每个业务都分配一个唯一的异常子类并不是必要的。一种常见的做法是使用一个全局异常处理类来处理所有异常。全局异常处理类使用了 @ControllerAdvice 或 @RestControllerAdvice 注解,这两个注解都是Spring MVC提供的,作用于控制层的一种切面通知,可以进行全局异常处理、全局数据绑定以及全局数据预处理。

我们可以自定义一个异常类(如GlobalException),这个异常类可以用于处理项目中的异常,并收集异常信息。这个全局的异常处理类(如GlobalExceptionHandler)内部使用了 @ExceptionHandler 注解去捕获异常,包括处理自定义异常。总的来说,虽然我们可以为每个业务创建一个唯一的异常子类,但在实践中,这可能会导致代码过于复杂和难以管理。更常见的做法是定义一些通用的异常类,如GlobalException,并通过全局的异常处理类来捕获和处理这些异常。

其实对所有业务异常都使用一个全局的业务是实际上是对异常建模之后去泛化的结果。所谓 去泛化 就是在最初设计的带有继承的类图中将像似的子类合并到同一个基类,使用属性来代替继承来实现模型表达的过程。

在去泛化之后,我们发现某些异常可能需要带有原始的异常信息,这些信息也许是结构化的,并非直接从错误信息可以获取的,如:
  • 框架 Xcgi 在解析 Json 数据包中可以提供哪些字段因为哪些规则导致数据解析失败;
  • 组件频率限制组件中可以提供频率出错的规则编号和违反条件;
  • 某分布式业务在使用幂等查重时,发现某个任何正在执行的前置条件未满足而提前终止时前置条件的值。

这些自定义信息则可以使用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> 获取抛出时异常对象中的特定数据。



02



优秀的异常处理方案

一个优秀的方案并不是一句话需求,我认为任何一刀切不要使用 C++ 异常或必须返回 int 这样的话术都是及其不负责任且低级的,所以我们需要提出一个对于业务错误的综合的方案,包括从最初设计异常模型开始,到最后上线成为一个业务开发真正可用的系统。

   2.1 异常的建模


我们可以通过通用的设计工具来设计一个通用异常的类图。


上述的类图使用太复杂了,面对技术需求,我们需要把其中的异常类进行去泛化,将某些子类的属性通过组合的方式压缩,收敛到通用的基类中。通过去泛化我们可以得到基于组合设计的通用异常类。


  • 错误码:错误码可以作为面向运营和监控的手段,也可以通过集中的管理平台用于集中化的管理和分配,满足 需求点 5 
  • 状态码:通过状态码,细化组件、框架、业务代码中的错误的具体的行为,也和 HTTP 状态码保持兼容性,解决 缺点 1
  • 错误信息:异常抛出方可以使用在异常抛出时自定义错误内容详情,解决 缺点 2
  • 调试信息:异常抛出方可以记录当前调用帧的指针地址和当前代码行,用于未来通过调试代码的二进制文件获取完整调用帧,解决 需求点 6
  • 资源池:异常抛出方或捕获者,只有需要用到附加数据时,才需要依赖资源池中的具体头文件,满足 需求点 7

有了上述异常的基类,分别在基础组件、业务代码、基础框架中就可以非常简单的使用抛出异常。


   2.2 异常的兜底


早期的异常处理语言还存在语言设计层面的自动恢复的功能。在一些编程环境中,特别是像 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) 可以提供一定的兜底措施,但如果确实是领域逻辑中会出现的异常,还是应该给出友好的错误提示,并提供可被验证的恢复方案。

对于一个运行在后台不间断的运行的服务时,不可避免的会遇到某些错误,这些错误根据分类可以进行不同程度的处理:

  1. Error 不能被捕获、可以声明、不可恢复。此类问题常见的场景是内存不足:
    1. 如果本身是 IO 进程工作进程多进程模型(绝大多数 svrkit 服务、mqworker),其实可以简单的直接让进程终止(即不处理 std::bad_alloc 这样的异常);
    2. 如果是多线程模型(所有的mqsvr),因为忽略错误依然无法让已使用的内存得到释放,故这里也没办法处理这样的错误,最好 做法是直接让进程异常终止,再由 CK 脚本重新拉起服务;
    3. 如果是通用的二进制工具,这是由于也是无法恢复的,直接 abort 也是一种兜底策略
  2. RuntimeException 应该被捕获、可以声明、可恢复的错误。C++11 之后绝大多数类型的基类是 std::runtime_error
    1. 对于生产环境,这些可恢复的错误应该被捕获,同时快速的记录上报大致的信息(如类型 ID 错误信息等),也可以为不同的接口分配专用的错误码,用于此类异常点的监控和运营。切记,此时的任务是尽快恢复服务,而非记录现场或开启交互式调试模式;
    2. 对于调试环境,职责是尽可能的让程序员找到出现异常问题的代码、上下文、调用帧,以便编写逻辑代码将运行时异常通过添加错误码、上下文信息转换为逻辑异常,例如将 mysqlpp::ConnectionFailed 捕获住,为当前场景添加合适的错误码、带上下文的错误描述等。而由于 C++ 的语言特性,一旦 catch 住异常后,再也没有办法可以获取异常发生时的上下文信息、包括调用帧、代码位置等信息,所以框架此时应该直接让操作系统接管,并生成 coredump 文件用于排查调试模式下的可能出现的运行时异常;
      1. std::bad_cast:使用 dynamic_cast 向下转换时失败引发的异常;
      2. std::bad_any_cast:使用 std::any_cast<T> 进行拆箱时引发的转换错误;
      3. std::bad_optional_access:使用 std::optional<T>::value() 获取没有值时引发的错误;
      4. google::protobuf::FatalException:可能由于使用了不正确的反射获取不匹配消息字段引发;
      5. boost::bad_lexical_cast:使用 boost::lexical_cast 进行类型转换引发的异常;
      6. fmt::format_error:使用 fmtlib 对目标对象进行格式化时,由于格式化串错误引发的异常;
      7. Json::LogicError:使用 JsonCpp 获取不到值时,或无法将 Json 类型进行转换时引发的异常(非常常见);
      8. mysqlpp::ConnectionFailed: 使用 MySQL++ 库连接 MySQL 客户端时无法连接上引发的异常;
    3. 对于大多数程序而言这些错误的发生并非是自身引起的,有可能是因为环境或调用异构系统时触发的异常,例如:
    4. 在我们编写业务代码时,应该随时保持警惕,对于这些异构系统的的异常,应该在第一时间捕获并转换为逻辑异常(对应 Java 中 Checked Exception 概念)。比如使用 MySQL++ 时,对于数据连接不上,应该将 mysqlpp::ConnectionFailed 及时捕获,并在专用系统中登记明确登记错误码,将这个运行时异常转化为逻辑异常(表示这个异常是我已经预期到的,可以被正确的处理的,异常的收敛的也是处理方式之一);
    5. 框架对于这样的异常,对于框架而言是可恢复的。但由于框架运行的环境和职责不同,所以对待这样的异常应该有所区分:
  3. Checked Exception 必须被捕获、必须声明、可恢复的错误。C++11 之后绝大多数的基类是 std::logic_error,本方案中的对应 UnifiedException。即对于不同框架制作一个适配层用于捕获业务异常,再将其转换为框架的能返回回去。
    1. Svrkit 在调用具体的业务函数时捕获 UnifiedException ,将其中的错误码转换为返回码、错误信息注入的回报的 error_message 中,其他的信息可以使用 RespCookie 返回;
    2. Xcgi/Xwi 支持拦截器,对于 Xcgi 可以将拦截到的异常转为 HTTP 状态码,其他字段转化为回包包体;Xwi 则可以无感的添加组件对所有的事件处理函数进行异常处理转换为 Xwi Context 中的错误状态;
    3. 作为框架已经拿到了业务开发的完整上下文了,所以作为框架,完全可以把这个异常集中捕获,根据里面所携带的信息进行集中化处理;
    4. 对于支持安装拦截器的框架(如标准 svrkit、Xcgi、Xwi 等),提供拦截器的库,将 UnifiedException 在执行工作函数时将异常捕获,并按照框架的需求返回
    5. 对于不支持拦截器特性的框架,只能业务方使用 UnifiedRpcController::SafeCall 函数先包一层,再进行到 MeshRet 的转换(WxMesh),或在每次调用时使用 try...catch... 手动进行异常处理。

   2.3 其他人性化的考虑


需求点 4 提到了一些对于业务开发的一些痛点,比如每次都要重写一遍断言表达式,比如很多 if 造成干扰。这一点其实对于一个务实的框架码农是非常容易完成的,我们提出的一些更更加人性化的考虑会重点放在调试和运营阶段。

比如最痛一点是在服务的开发过程中如果发现了一个业务异常,根本就没办法知道发生异常的调用帧,以前的做法是一层一层的打日志进行排查,Xwi 的做法是一层一层的增加错误栈用于调试。

未来人性化的考量可能会通过调试环境、生产环境来实现差异化的功能:
  • 调试环境:调试环境可以将调用帧信息直接显示在界面中,解析调用帧信息可能需要比较长的时间(差不多需要 1s)左右,计划是在调试编译条件下启用新的调试命令字,对返回的调用帧指针进行名称的转化;
  • 生产环境:生产环境将异常发生时调用帧信息输出在日志中,并提供统一的入口将帧指针转化为可读的名称,可以在日志系统中留下入口,将某一条错误日志定义到调用帧的每一帧的代码位置(注意:生产环境二进制和调试符号是分开存放的)。

本文为《异常思辨录》系列第三篇,第一篇:《降本增笑的P0事故背后,是开猿节流引发的代码异常吗?》
第二篇:《累了,代码异常!》

在下一篇文章中,我们将主要介绍一些上层的决策点,感兴趣的记得关注收藏,不错过后续文章更新。

-End-
原创作者|陈明龙

  


为什么要使用异常处理?异常处理对项目有什么好处?欢迎评论分享。我们将选取1则优质的评论,送出腾讯云开发者社区定制鼠标垫1个(见下图)。2024年1月10日中午12点开奖。


📢📢欢迎加入腾讯云开发者社群,社群专享券、大咖交流圈、第一手活动通知、限量鹅厂周边等你来~

(长按图片立即扫码)





继续滑动看下一个

异常≠错误,正如Bug≠事故,详解业务开发中的异常处理

陈明龙 腾讯云开发者

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存