👉导读
软件开发中遇到异常才是正常,很少有人能写出完美的程序跑在任何机器上都不会报错。但极为正常的软件异常,却经常出自不同的原因,导致不同的结果。怎么样科学地认识异常、处理异常,是很多研发同学需要解决的问题。本文作者根据自己多年的工作经验,撰写了《异常思辨录》系列专栏,希望能体系化地帮助到大家。本文为系列第二篇,本篇文章将主要聚焦面向对象的分析设计和框架设计,欢迎阅读。
👉目录
1 重谈面向对象的分析与设计
1.1 属性、方法、事件
1.2 资源获取即初始化
1.3 符合面向对象的异常计思路
1.4 小结
2 框架设计者的思考
2.1 思辨地看待使用错误码
面向对象的分析与设计的原理包括以下 5 种概念。
如果需要对面向对象的分析中异常处理的深入分析还需要对一些重要的术语进行解释和分析。
在面向对象的分析与设计中,属性、方法和事件是对象的三个基本构成部分,它们描述了对象的特性和行为。
不少开发者将属性和类的字段这两个术语化为等号,其实这个是不正确的。比如:一个汽车的生产日期和车龄,生产日期修改时,车龄也会随时变化,实现上就很有可能只用一个字段来存储。
class Car {
public:
void set_finish_time(std::chrono::system_clock::time_point t) { finish_time_ = t; }
std::chrono::system_clock::time_point finish_time() const { return finish_time_; }
int year() const {
std::time_t now_c = std::chrono::system_clock::to_time_t(finish_time_);
std::tm now_tm = *std::localtime(&now_c);
return now_tm.tm_year + 1900;
}
private:
std::chrono::system_clock::time_point finish_time_;
}
上述 year
这个属性是通过计算出来的,是只读属性, finish_time
是读写属性。当然你也可以在增加一个 lunar_finish_time
这个读写属性用于通过设置农历的方式来设置,但最终都只会反应到 finish_time_
这个字段上。
C++ 中缺乏对属性和方法的区分,属性和方法只都是通过成员函数来实现的,C++ 中对于属性的修改通常是通过与之对应的 Getter/Setter 来实现的。但对于 C++ 的影响后的语言,更多的是将属性和方法分开,如 VB.NET 中 Property Get/Set
C# 中的 get/set
,JavaScript 中的 get/set
关键字,Delphi 中 property...read...write
等。
Class Car
Private _finish_time As DateTime
Public Property FinishTime() As DateTime
Get
Return _finish_time
End Get
Set(ByVal value As DateTime)
_finish_time = value
End Set
End Property
Public ReadOnly Property Year() As Integer
Get
Return _finish_time.Year()
End Get
End Property
End Class
通常情况下,使用属性时的自然语义是:
object.WritableProperty = newProperty
,设置一个可写属性之后, object
这个类的实例实际上发生了变化,可能会引发起多个字段的改变,但这个字段不一定对应到这个对象的某个特定的字段中。myProperty = object.ReadableProperty
,获取一个可读属性之后,object
实际上不应该有任何的变化,故在 C++ 中一般可读属性都会标记为 const
。而方法重要的代表这个对象的某种能力,或某种职责。比如汽车有速度的属性,当启动、加速、刹车就对应了这个对象的三种操作,而这些操作会引起属性的变化。
class Car {
public:
void Start() { set_speed(1); }
void SpeedUp() { set_speed(speed() + 1); }
void SlowDown() { set_speed(speed() - 1); }
void set_speed(int v) { speed_ = v; }
int speed() const { return speed_; }
private:
int speed_ = 0;
}
这里来简单用一个表格归纳一下属性和方法的区别。
属性 | 方法 | |
---|---|---|
引起实例状态的改变 | 获取属性值不会,设置属性会 | 绝大多数情况下会 |
名称约定 | 一般情况下使用名词 | 一般情况下使用动词 |
C++ 中大小写约定 | C++ 使用 snake_case | C++ 使用 PascalCase |
Java 中起名约定 | 使用 getXxx setXxx 进行区分 | 一般情况下使用动词 camelCase |
既然属性的修改和方法都有可能引起最终对象状态的变化,那么是不是有一种办法可以监听这种改变的。一种比较复杂的做法(不推荐)是将所有这种变化通过虚函数定义起来,并在变化前后都使用虚函数来触发。子类需要重写这个虚函数,从而实现对这些改变前或改变后的状态做出改变。例如:
class Car {
public:
void Start() { set_speed(1); }
void SpeedUp() { set_speed(speed() + 1); }
void SlowDown() { set_speed(speed() - 1); }
void set_speed(int v) {
OnBeforeSpeedChange(speed_, v);
speed_ = v;
OnAfterSpeedChange(speed_);
}
int speed() const { return speed_; }
virtual void OnBeforeSpeedChange(int old_speed, int &speed) const {}
virtual void OnAfterSpeedChange(int speed) const {}
private:
int speed_ = 0;
}
但这样一种分析与设计中不太符合对于考察对象 Car
的设计。因为对于一个车辆本身而言,在速度改变前、在速度改变后并不一定属于车辆或车辆这个子类的职责。
很多现代语言都对这样的一种事件驱动的场景做出了语言层面的扩展。比如 VB.NET 中的委托用于定义一个事件的签名(类型),再定义事件这个对象。那么当属性改变时,目标对象就可以直接发起这个委托的事件。
' 定义委托(函数签名)来保证某个对象可以以这样的委托来触发事件
Delegate Sub BeforeSpeedChange(ByRef sender As Object, ByVal oldSpeed As Integer, ByRef speed As Integer)
Delegate Sub AfterSpeedChange(ByRef sender As Object, ByVal speed As Integer)
Class Car
Private _speed As Integer
' 定义一些事件用来表示“车”这个对象有可能在某些状态变化时触发这些时间,并按照委托定义的签名来触发
Public Event OnBeforeSpeedChange As BeforeSpeedChange
Public Event OnAfterSpeedChange As AfterSpeedChange
Public Property Speed() As Integer
Get
Return _speed
End Get
Set(value As Integer)
' 触发速度改变前事件,注意这里的 value 是按引用传递的,
' 即事件处理函数可以修改这个 value
RaiseEvent OnBeforeSpeedChange(Me, _speed, value)
_speed = value
' 触发速度改变后事件,注意这里的 _seeed 是按值传递的,
' 即事件处理函数不可以修改这个 speed
RaiseEvent OnAfterSpeedChange(Me, _speed)
End Set
End Property
Public Sub Start()
Speed = 1
End Sub
Public Sub SpeedUp()
Speed += 1
End Sub
Public Sub SlowDoup()
Speed -= 1
End Sub
End Class
Module ModuleExample
Sub Main()
Dim car As New Car
AddHandler car.OnBeforeSpeedChange,
Sub(ByRef sender As Object, ByVal oldSpeed As Integer, ByRef speed As Integer)
Console.WriteLine("Car change from {0} to {1}", oldSpeed, speed)
End Sub
AddHandler car.OnAfterSpeedChange,
Sub(ByRef sender As Object, ByVal speed As Integer)
Console.WriteLine("Car change {0}", speed)
End Sub
car.Start()
End Sub
End Module
上述代码中 Car
对象和事件处理函数就完全解耦了,通过 lambda 表达式中的 sender
拆箱之后获得被执行对象的发起者。
假如我们使用面向对象的分析与设计来分析 Car
SpeedLimiter
—— 一个汽车限速装置的,这样系统的设计。我们应该是怎么设计的呢?
Car
中定义属性 Speed
用于定义当前行驶过程的速度;Car
中使用 SpeedUp
方法用于加速操作;BeforeSpeedChange
AfterSpeedChange
用于签名当速度改变前后进行的事件;Car
在属性 Speed
中触发委托调用;SpeedLimiter
中实现 BeforeSpeedChange
用于超速的拦截和告警;Car
SpeedLimiter
;speedLimiter
的 BeforeSpeedChange
方法注册到 car
的事件中。那么,Car
SpeedLimiter
通过定义的委托就实现了触发→调用这一机制的解耦。如果使用传统的虚函数来驱动,那么就必须为每个需要限速的 car/bike/motocycle 之类的全部编写一把限速逻辑。
这样通用的设计,目前只有 .NET(VB.NET、C#、C++/CLI) 在语言层面实现了这一设计。
资源获取即初始化(RAII)是一种在几种面向对象、静态类型的编程语言中使用的编程习惯,用于描述特定的语言行为。在 RAII 中,资源的持有是类的不变式,并与对象生命周期绑定。资源分配(或获取)在对象创建(特别是初始化)时由构造函数完成,而资源释放(解除)在对象销毁(特别是最后处理)时由析构函数完成。换句话说,资源获取必须成功才能使初始化成功。因此,资源保证在初始化完成和最后处理开始之间被持有(持有资源是类的不变式),并且只有当对象存在时才被持有。因此,如果没有对象泄漏,就不会有资源泄漏。
RAII 最初起源于 C++,与之最为关联,但也在 D、Ada、Vala 和 Rust 等语言中有所应用。该技术主要由 Bjarne Stroustrup 和 Andrew Koenig 在 1984-89 年间为 C++ 的异常安全资源管理开发,术语本身由 Stroustrup 首次提出。
RAII 作为一种资源管理技术的优点在于它提供了封装、异常安全性(对于栈资源)和局部性(它允许获取和释放逻辑被写在彼此旁边)。封装是因为资源管理逻辑在类中定义一次,而不是在每个调用站点。对于栈资源(在同一范围内被获取和释放的资源),通过将资源绑定到栈变量(在给定范围内声明的局部变量)的生命周期,提供了异常安全性:如果抛出一个异常,并且有适当的异常处理机制,当退出当前范围时,唯一将被执行的代码是在该范围内声明的对象的析构函数。
RAII 由于在析构函数中自动释放获取的资源,无论正在使用的特定机制是什么,都有一个运行时保证析构函数会在对象实例消失之前被调用。因此,它应该始终被使用。与此同时,当对象消失时,它的成员也会消失,每一个成员在退出时都会执行它的析构函数。所以,如果这些成员对象实现得正确,就没有必要做任何事情。
#include <fstream>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <string>
void WriteToFile(const std::string& message) {
// 一个全局的对象用于让读取文件互斥的进行
static std::mutex mutex;
// 在访问文件之前锁定互斥体,这样就保证了下述的代码将在在多线程访问时不会穿梭执行
std::lock_guard<std::mutex> lock(mutex);
// 打开文件
std::ofstream file("example.txt");
if (!file.is_open()) {
// 这里抛出异常会跳出 WriteToFile 函数区域,RAII 特性保证 file/lock 都会被析构
throw std::runtime_error("unable to open file");
}
// 输出数据到文件中
file << message << std::endl;
// 当 file 离开作用域是会被析构,析构时会自动关闭打开的文件,无论是不是因为异常而离开作用域
// 当 lock 离开作用域时会自动解锁互斥体(通过 lock_gard 的析构函数),无论是不是因为异常或正常返回
}
如果我们按照领域服务的逻辑 UML 来编写代码,使用 RAII 思想来编写将做的非常自然。
比如在修改值对象属性这一步骤出错了(例如车速太快,翻车了)。那么正常人类对于这样不是有特殊的逻辑的设计的程序,会根据 RAII 的思想将会执行以下步骤:
CarSpeed
)由于设置一个异常的值(例如:car.speed().set_speed(100000);
),从而引发异常;car
组合的 car_speed
也不再有效,那么 car
也将被自然的析构;car
被析构掉,所以也不能继续执行持久化领域对象到仓储及其后续的步骤;这一切的做法都可以将 UML 序列图最大程度的映射到实现代码中,因为异常思想就是包含在面向对象的分析与设计中的。在设计序列图时,只需要关注当前领域能够处理的异常才是最佳的实践。比如上图中,如果业务流程能够处理修改值对象属性异常,那么就可以拦截到值对象对象的异常,此时领域对象还没有修改,也不会消亡,从而进行额外的操作。
某业务一直都有使用领域设计驱动和面向对象的分析与设计两种思想来分析业务。但由于某些遗留的思想,很多开发者选择使用返回错误码这样一种 C 语言时代的思考方式来编写业务代码。给个简单的例子就能看看写出来的代码有多丑陋了。
class AmountDomain {
public:
int Plus(const AmountDomain &other) const;
int Minus(const AmountDomain &other) const;
int Set(int amout);
int IsValid() const;
const std::string &last_error() const { return last_error_; }
private:
int amount_ = 0;
std::string currency_ = "RMB";
std::string last_error_;
};
上面这个简单值类型的类看到声明就感觉血压升高了。
last_error
属性用于表示最后一个操作的错误信息,即这个对象为什么会坏掉,由于要保存最后一次坏掉的状态,这个对象依然不能被析构int IsValid() const
成员方法用来判断这个对象是不是坏掉,并且判断是不是坏掉的方法还有可能返回错误operator+/-
都有可能出现错误,所以必须要返回一个错误码来表示操作结果这还是一个简单的值对象,对于某些业务币值、币种、银行类型、支付方式等值对象、甚至是知识域或其他领域对象都要用这样丑陋的方式来设计,想想看真正的业务代码中有意义的信息能有多少?
因为所有的操作都是不可信的(方法的签名已经完全背离了面向对象的分析与设计),所以每次都必须写成统一的范式。
if (auto ret = obj.Operate(arg...); ret) {
Log("日志日志日志");
Oss("报报报");
return ret;
}
虽然聪明的人觉得可以用宏简化这些符号,但每一层的上报和日志监控考验着代码编写者巨大的耐心和毅力,也挑战者代码审阅者爆裂的心态,最终让代码工作者迷失在无尽的上报、日志、监控上。
然而如果要对控制信息进行升级(比如级联返回的不再只有错误码,还有控制码,调用帧,错误上下文)怎么办呢,只能再搞个类似 errno
的错误对象来全局存储,这样一搞某些函数返回只有错误码,有些函数又写了全局变量 errno
,一个本来明明很好理解的错误信息被硬生生的割裂到两个地方放置。
更严重的是,定义一个返回码,你压根就没有能力约束主调方是不是真正判断了返回码(如果非要有人说 [[nodiscard]]
那我也没办法,毕竟这也是习惯的问题)。正如更高级的语言中所描述的最佳实践一样:
Exceptions ensure that failures don't go unnoticed because the calling code didn't check a return code. 你应该抛出一个异常,而不是返回一个错误码。因为引发一个异常,对于那些没有检查返回码而继续的人,也不会走到后面的正确的逻辑。
框架设计者应该意识到,异常不是某种语言的特性,而是一种思考的范式。这种范式是一种面向对象的设计的核心思想的延伸——我这个领域对象只能处理我领域内的事物,领域内的事物包括了属性、方法、事件,也包括了面向对象的任何一种在执行代码时出现的逻辑错误——异常:
面向对象的分析与设计中,异常控制是在课本中较少提到的,为了解决对象在属性修改、方法调用、事件驱动时导致的状态的改变,异常设计也被广泛地运用到面向对象的分析与设计中——即当分析对象失效之后,应该处理的业务逻辑是如何的。
如果作为一名框架的作者,首先不应该是避免使用某些语言的特性,而应该思考如果使用方使用了这些特性会造成那些问题,如何规范的使用这样的特性。
由于某些历史原因,一些框架使用非 0 的返回码作为错误码为每个函数作为标准函数签名,在使用依赖注入的思想重写微信后端框架调用一文中有了非常明确的表述。由于大多数人接触到的一个后端服务都是这么一种蹩脚的设计,所以越来越多的开发者在编写自己的库的时候,都把这样一种蹩脚的设计奉为最佳实践。
我们来回顾一下函数直接返回错误码的优劣,然后来逐条分析其中的优劣。
错误码可以帮助开发者明确地了解函数执行的状态。它们可以提供有关失败原因的详细信息,使得调试和错误处理更为容易。
然而,标准 C 语言中的函数库中会将返回值定义一个枚举,并在一个固定的枚举文件中,在这个头文件中或手册中,会详细罗列所有的错误码的定义,出现的位置以及解决方案。
但在一个超大规模的系统中,整理并且保证这个错误码不被冲突将是一个巨大的工程。除非建立一个庞大的系统来保证分配的错误码在整个系统唯一切定义明确(微信支付确实有在这个方向的努力),但依然无法保证外部依赖(如 kv、统一加解密)也接入到这个系统中,在实际的业务代码编写过程中,充斥着大量的 -1、系统错误、-16 之类的依靠口对口交流的错误码,甚至某些系统还搞出了 -7009 这样模棱两可的错误码。
对于接入方可能表面上看起来一个错误码就可以搞定的事情,但对提供方是一个巨大的挑战,他不仅要对自己的内部系统负责,还需要设计一个清晰可用的唯一的错误码,稍稍有点懈怠就有可能随便将一个错误码用一个毫无意义的魔数来代替,甚至是直接用 __LINE__
这样的宏来解决(简直是可恶至极)。
所谓真正的正确的的做法是返回一个明确的错误枚举,类似 CURLcode
或 std::error_code
才是真正实践了明确性这样一个特点。
在某些旧的或跨平台的系统中,异常处理可能没有得到很好的支持,而错误码则可以在这些系统中使用。
然而对于某些特定的系统,已经统一了编译环境、甚至是线上机器的容器环境都已经统一,例如某业务系统。
所以在特定环境下使用异常已经不存在任何兼容性问题(开篇的话语已经说的很明白:在特定领域有特定的业务目标)——即构建稳定可靠的支持实际业务开发的系统——再此目标性下,对于实时系统、跨平台甚至是交叉编译的需求可以说压根就不是设计的重点。
在特定的商用系统中,为业务而生的系统中,根本不需要为了所谓的兼容性而选择错误码。
作为可以开源协同的业务无关的库,也建议使用错误枚举或标准异常子类来报告错误。
错误码通常比异常处理具有更好的性能,因为它们不需要额外的 CPU 和内存开销来处理异常。这在性能关键的系统中尤其重要。
其实在真正的业务系统中,异常所带来了性能损耗往往不是最重要的,安全性和可控才是上层最大的决策点(这也是为什么 C# Java 等工业级系统已经将使用语言异常来解决业务错误写入最佳实践)。如果是对性能要求特别高的,往往就属于计算密集型的场景,相信很多人才可以直接用 C/ASM 甚至是专用的语言来编写一个中间代码,再使用 C 语言头文件导出给业务方使用。
我们可以写一个 简单的程序用于验证使用异常导致会导致有多少的性能消耗。这个程序模拟了 10W 次调用,每次调用分别有 0.95 0.85 0.99 0.9 的概率失败,最多嵌套 4 层调用。在 GCC 7.5.0 C++17 使用 O2 优化的开发机的性能测试如下。
因此拿性能那么一丁点的可忽略不计的优势来说构造庞大的领域知识逻辑的业务系统来说,可以说是舍本逐末。
错误码需要开发者在每次调用函数后都检查返回值,这增加了额外的编码工作,并可能导致错误的忽视。相比之下,异常可以自动传播,无需手动检查。
正是由于无法自动传播,导致一旦需要增加异常对象信息,错误码就无所适从了。某些时候框架设计这不仅要设计一种跨函数级别的异常信息的传播,还需要实现跨 RPC 的异常信息的传播。由于在 10 年前的设计中并没有把异常中的 控制信息(如:正常返回、最终异常、服务器忙、可重试),进行传播,导致业务在编写代码时,只转义了错误码,并没有正确传递控制码,上层在发现错误码后,依然对某个已经确定无法提供服务的服务器发送请求导致请求持续失败。
因为使用错误码时,我们一般写的代码是这样的。
// lib_component 代码提供方 LIB
int foo_in_lib() {
if (/* xxx */) return LIB_ERROR;
// ....
return 0;
}
// exe_business 代码提供方 BIZ
int foo_in_exe() {
if (foo_in_lib()) { return MY_TRANSLATED_ERROR; }
return 0;
}
// lib_framework 代码提供方 INFRA
int foo_in_framework() {
auto ret = foo_in_exe();
if (ret == LIB_ERROR) {
// 重试或换机重试之类
}
return ret;
}
由于 BIZ 根本就没意识到 LIB_ERROR 会被 INFRA 理解为换机重试,所以 BIZ 会直接转义一个自己能够理解的,上报可以监控到的错误,可以被运营的错误码。但 INFRA 收到 MY_TRANSLATED_ERROR 后因为并不带有换机重试的语义所以丧失了 LIB_ERROR 传播的语义。
虽然我们可以把所有的返回码全部修改为一个全新的对象例如某框架的 MeshRet 其中包含了控制信息,但现在错误码的陋习已经深深的印刻在每个看起来不那么专业的伪 C++ 程序员心中,就算要推广 MeshRet 需要将所有的返回 int 的函数全部修改为 MeshRet 其工作量也非一般。
由于上述复盘进而推演出的错误码解决方案也漏洞百出。
int ret = secure::SafeKeyEncrypt(kMyProductId, kMyRuleId, input, output);
if (ret != 0) {
int error_type = SafeKey_GetErrorType(ret, kSafeKeyCryptCmd_Encrypt);
if (error_type == kSafeKeyErrorType_System) {
//通用加解密服务瞬时过载,需要换机重试,错误码务必传回至最顶层接口返回码
return COMM_ERR_SAFE_KEY_AGENT_SYS_ERR;
} else {
//其他逻辑失败,按需处理
}
}
因为还是使用错误码这样一种方案,增加一个 SafeKey_GetErrorType
的转义逻辑,按照正常人的想法,肯定是对于某些特定的错误码,返回 kSafeKeyErrorType_System
即判断需返回换机重试,因为绝大多数加解密在正常的情况下异常都是应该是确定的,但研读过 SafeKey_GetErrorType
代码就发现,这个函数只是将几个极少数的错误码视为非系统错误,其他全部要求你换机重试。
结果是业务方几乎不会在所有 MMNewDataTicket_CommEncrypt
时去判断,而是在所有函数的总入口加这样一段逻辑。最终将所有的业务逻辑错误全部被转义成换机重试!
int ServiceDispatch() {
int ret = dispatch.CallMethod();
if (ret!=0) {
// 但查看代码之后实际的逻辑是 !!只有某些特殊的错误码会被透传,其他不认识的全部转义的换机重试!!
int error_type = SafeKey_GetErrorType(ret, kSafeKeyCryptCmd_Encrypt);
if (error_type == kSafeKeyErrorType_System) {
// 通用加解密服务瞬时过载,需要换机重试,错误码务必传回至 svrkit 接口返回码
return COMM_ERR_SAFE_KEY_AGENT_SYS_ERR;
}
}
return ret;
}
而正是由于统一加解密和业务系统所有的错误码都没有被统一登记和维护,导致易用性的缺失。通常我们现在编写业务逻辑可能都是这样一种调用链路。而正是一种这样额外的编码工作导致错误码在大型复杂的系统中易用性极差。
使用错误码可能会使代码变得难以理解和维护。特别是在嵌套函数调用的情况下,错误码的传播和处理可能变得非常复杂。
通常在一个简单的开发框架中会存在多种开发者角色。
由于角色的分工,这三种角色基本上都不对各自非角色的代码过多了解。这样体现在我们开发中的一种固定的行为模式:
我们现在就存在一种这样的难题,组件开发者返回了一个错误,并希望框架开发者收到这个错误的时候能够换机重试。但由于需要通过错误码来传播这一错误,使得业务开发者也不得不理解框架开发者的和组件开发者中定义的换机重试的约定,这使得这一部分的代码存在于所有的复杂的领域逻辑中,所有的领域逻辑都必须加上一层判断,但作为业务开发者而言,如果中间某一步错了,直接将领域对象析构这一 finally 的逻辑执行了就行了。这样编写出来的代码才具有很高的可读性,因为他所有的逻辑判断都是真实应对 UML 顺序图中的。不夹杂任何因为非业务逻辑带来的额外代码,最大能力的提升代码可读性。
不同的开发者可能会为相同的错误使用不同的错误码,这可能导致一致性问题。
通常情况下,如果没有经过大规的模的培训,没有经过专用系统的配合,要想在一个庞大复杂的系统中,无可无可分配到一个全局唯一的错误码,几乎是一个不可能的事情。
相信在某些祖传的程序代码中都看看到一些魔数错误码,仔细研究就会发现很多问题。
-1
表示系统错误、网络错误,但真的是这样吗?-1
表示系统错误,但其实根本就是逻辑错误,只是当时偷懒并没有找一个可以可以合理存放错误码。为了解决上述痛点,微信支付团队特地耗费巨资构建了一套错误码管理运营系统来尝试通过一个统一的系统来系统的解决异常时使用错误码的一些问题。
mmsomebizslowread
mmsomebizslowwrite
mmsomebiz4openapi
但在业务分析时,这些模块还没有被划分开(业务分析只对业务进行分析,不对实现进行干涉),如果需要对错误码所针对的分支异常进行运营,理论上来说应该在业务分析时,某些错误码就应该被指定下来,赋予相应的场景和对应的描述,但目前系统中不存在这样的一种申请操作。我认为在对错误码建模时,就应该考虑子域和错误码是 1 对多的组合关系,而模块和错误码是多对多的关联关系,但错误码又耦合了运营的职能,如果错误码和模块是多对多的关系,又不能从全局唯一的错误码的监控中了解某个模块的健康状况。try...catch...
类似——我能处理我能处理的,否则就交给上一级能够处理的来完成。可惜是的,上述规则目前也只能停留在规范的层面,如果真正要保证正确的遵守规则,还需要靠领域内专家的代码审查来保证。
如果函数的返回值被用于表示错误码,那么它就不能用于返回函数结果。这可能会导致需要使用输出参数(out parameters)或者修改状态,从而进一步降低代码的可读性和易用性。
错误码强制将返回值修改为错误码或一个特定的枚举,这样使得类似 jQuery 的链式调用成为奢望。同时也让 C++ 运算符重载特性失效。
// 某个金额
class Amount {
public:
Amount(int money_fen = 0) : money_fen_(money_fen) {
// 这里我们业务规定金额不能为负数
if (money_fen_ < 0) {
throw std::logic_error("Money must greater than zero.");
}
}
// 支持运算符重载对金额进行比较
bool operator<(const Amount& other) const { return money_fen_ < other.money_fen_; }
bool operator<=(const Amount& other) const { return money_fen_ <= other.money_fen_; }
bool operator>(const Amount& other) const { return money_fen_ > other.money_fen_; }
bool operator>=(const Amount& other) const { return money_fen_ >= other.money_fen_; }
bool operator==(const Amount& other) const { return money_fen_ == other.money_fen_; }
bool operator!=(const Amount& other) const { return money_fen_ != other.money_fen_; }
// 注意这里的 +/- 运算符是而可能会抛出异常的,因为有可能隐式转换到一个非法的 Amount 实例
Amount operator+(const Amount& other) const { return money_fen_ + other.money_fen_; }
Amount operator-(const Amount& other) const { return money_fen_ - other.money_fen_; }
// 重写转换成数字操作字
operator int() const { return money_fen_; }
private:
int money_fen_ = 0;
};
// 用户账户
class Account {
public:
const Amount& amount() const { return amount_; }
Amount& amount() { return amount_; }
void set_amount(const Amount& v) { amount_ = v; }
void set_amount(Amount&& v) { amount_ = std::move(v); }
private:
Amount amount_ = 0;
};
int main(int argc, const char* argv[]) {
Account a;
a.set_amount(100);
Amount bill(200);
a.amount() = a.amount() - bill;
return 0;
}
如果不使用错误码,我们可以非常自然写出 a.amount() = a.amount() - bill;
这样的语句。而这样的语句是由业务逻辑分析而来的,而非程序员空想的。
Amount
这个值类型决定的,而不是领域 Account
决定的。Acount a
将在析构时销毁,a
中的金额不会变成负数,因为不存在一个 Amount
对象中 money_fen_
是负数。同时,业务开发者也非常容易根据之前设计好的分析序列图来编写代码,其他在分析序列图中并没有出现的判断或分支,也完全不会(也不应该)在代码中体现。还是拿上述代码的例子,如果使用错误码:
// 正确的使用错误码的示例代码
// 注意由于使用了错误码,所以 C++ 中的运算符重载也不能使用了,只能使用 Minus 来代替
int main(int argc, const char* argv[]) {
Accout a;
if (int ret = a.set_amount(100); ret) return ERR_INVALID_AMOUNT;
Amount bill;
if (int ret = bill.set_money_fen(200); ret) return ERR_INVALID_AMOUNT;
Amount remain = a.amount();
if (int ret = remain.Minus(bill); ret) return ERR_INSUFFICIENT_AMOUNT;
Account tmp_a = a;
if (int ret = tmp_a.set_amount(std::move(remain)); ret) return ERR_SET_AMOUNT;
a = std::move(tmp_a);
return 0;
}
// 然而更多的开发者可能会这么来完成
int main(int argc, const char* argv[]) {
Accout a;
if (int ret = a.set_amount(100); ret) return ERR_INVALID_AMOUNT;
Amount bill;
if (int ret = bill.set_money_fen(200); ret) return ERR_INVALID_AMOUNT;
// 这里将实际上分析中的资金金额的业务逻辑规则前置到流程服务了
if (a.amount().money_fen() < bill.money_fen()) {
return ERR_INVALID_AMOUNT;
}
a.amount().Minus(bill.money_fen());
}
main
中来做 amout 和 account 的判断,如果发现 amout 大于 acount 中的值,就会返回一个错误码。false
由于使用了错误码,禁用了异常,导致编写出来的代码大量耦合了错误码的检查逻辑,而由于不能使用异常,也就不能使用 RAII 特性来编写代码,这样看起来业务代码不仅不自然,还会因为程序员为了避免编写冗余代码不按照分析设计的结果来实现代码。
在 C++ 17 之前大量的函数并没有标标记 [[nodiscard]]
,导致编写代码时对于一些自认为不重要的代码缺少对错误码的检查和传播,此编写代码会造成严重的问题。
因为是最新的 C++17 添加的关键字 [[nodiscard]]
,所以并不是有很多开发者会留意到这个情况。其实应该对于所有的对象写修改状态的操作都必须添加 [[nodiscard]]
。但目前很多代码还是使用 C++11 编译的,并不支持 [[nodiscard]]
属性标记。
可怕的是代码的审阅者,不能通过主调方的函数使用的情况来审视是否应该增加对返回值的判断。
int foo(const SomeRequest &req) {
SomeDomain d(req.domain_po());
CHECK_RET(d.IsValid());
CHECK_RET(d.StartTransaction());
d.EnsureDone();
if (auto ret = d.FinishTransaction(); ret) {
d.RollbackTransaction();
return ret;
}
return 0;
}
上述代码中 EnsureDone
RollbackTransaction
是没有进行检查的,但细心的代码审阅者会询问,为什么这带个函数不检查返回值?而其他的代码却检查了返回值。
更可怕的是在未来的维护中,如果我觉得 EnsureDone
可能发生错误,为了保持兼容性,我不得不将返回值从 void
修改为 int
,放弃增加 [[nodiscard]]
,以前调用方的代码就可能因为改动而产生异常。如果我们保持使用异常的思维,认为所有调用(包括构造函数、设置属性、调用方法都有可能发生异常),那么就能简单的避免强制检查带来的问题。
void foo(const SomeRequest &req) {
// 由于使用了异常,也就不需要使用 IsValid 进行检查,因为构造即初始化,不正常的对象就不能构造出来
SomeDomain d(req.domian_po());
// 开始事务处理,如果开始失败,那么 SomeDomain 会被自动析构,保证资源的释放
d.StartTransaction();
// 在正式开始前,定义一个 defer 清理函数用于非正常终止时回滚操作
// 目前 std::scope_exit 还只是存在 TSv3 阶段 https://en.cppreference.com/w/cpp/experimental/scope_exit
// 暂时使用 BOOST_SCOPE_EXIT 代替
// 或使用 polyfill https://github.com/offa/scope-guard
bool need_rollback = true;
BOOST_SCOPE_EXIT(&need_rollback) {
// 注意 RollbackTransaction 应该必须保证最终成功并 nothrow
if (need_rollback) d.RollbackTransaction();
}
BOOST_SCOPE_EXIT_END
// 安全的调用其他方法或设置其他属性,因为失败时会执行 defer 中的代码块进行资源保证
d.EnsureDone();
d.FinishTransaction();
// 最后阻止 defer 块中的回滚操作
need_rollback = false;
}
本文为《异常思辨录》系列第二篇,第一篇:《降本增笑的P0事故背后,是开猿节流引发的代码异常吗?》
在下一篇文章中,我们将主要介绍业务开发对异常处理的需求点和一些优秀的异常处理案例,感兴趣的记得关注收藏,不错过后续文章更新。
-End-
原创作者|陈明龙