透过独立变化原则审视错误处理

内容目录

最近我一直在思考错误处理——不是语法争论或"哪种语言做得更好"的辩论,而是更深层次的问题。是什么让某种方法在架构上优于其他方法?

有一个原则为这些决策提供了客观基础:独立变化原则(PIV)【Principle of Independent Variation】。该原则指出:独立变化的事物应该分离,共同变化的事物应该组合。看似简单,但它能告诉你哪种设计在技术上更优越——尽管具体场景可能仍需权衡。

让我通过将其应用于四种错误处理策略来阐明我的观点。

四种处理方式

返回码(C风格)

int read_file(const char* path, char** content) {
    if (!path) return -1;
    if (!exists(path)) return -2;
    return 0;
}

受检异常

public String readFile(String path) throws IOException, SecurityException {
    // 编译器强制你处理这些异常
}

非受检异常(Python、C#、Java RuntimeException)

def read_file(path):
    # 可能引发 FileNotFoundError、PermissionError...
    # 函数签名不会告知这些信息

Result单子(Rust、Haskell;可通过库在Java、C#、Python中使用)

fn read_file(path: &str) -> Result<String, FileError> {
    // 不处理Result就无法访问值
}

变化驱动因素是什么?

PIV首先要问:这段代码可能变化的独立原因有哪些?

对于错误处理,我认为有三类:

  1. 错误类型演变(出现新的失败模式,旧的被移除)
  2. 错误处理逻辑变化(不同的恢复策略)
  3. 业务逻辑变化(与错误无关)

这些变化确实是独立的。添加新错误类型不应涉及业务逻辑。改变网络故障的恢复方式不应影响存在的错误类型。

为什么错误处理策略必须独立变化

这并非空谈。考虑相同的业务操作——"获取用户资料"——及其在不同上下文中处理失败的不同方式:

恢复策略:

  • 退避重试 — 瞬时网络故障,再次尝试
  • 使用缓存/陈旧数据 — 可接受的降级
  • 返回默认值 — 必须继续运行
  • 快速失败 — 如果失败则无需继续

可观测性策略:

  • 记录日志并继续 — 非关键问题,仅记录
  • 通知值班人员 — 需要人工立即关注
  • 增加指标 — 为SLO仪表板跟踪
  • 追踪关联 — 附加到分布式追踪

传播策略:

  • 吸收 — 不让调用方知晓
  • 转换 — 转换为领域特定错误
  • 原样传播 — 让其向上冒泡
  • 熔断 — 停止调用故障服务

在微服务架构中,这点更加有趣。相同的下游故障可能需要:

  • 服务A:重试3次,然后返回缓存数据(面向用户,延迟敏感)
  • 服务B:立即失败,将消息加入死信队列(异步作业,正确性至关重要)
  • 服务C:记录警告,返回部分结果(聚合器,尽力而为)

业务逻辑——"获取用户资料"——是相同的。错误处理根据操作上下文、SLA和各服务的角色而变化。这些关注点因不同原因不同时间变化。PIV指出:它们必须是可分离的

四种方法对比

变化 返回码 受检异常 非受检异常 Result
添加错误类型 涉及所有调用方 更新调用栈上的每个throws子句 无影响 编译器对不完整匹配发出警告
移除错误类型 静默—常量闲置 编译错误(好!) 无影响 编译错误(好!)
更改处理策略 分散在各调用方 重构try-catch块 可集中处理 交换一个组合子
更改业务逻辑 与错误检查纠缠 埋在try块中 清晰—直接更改 清晰—直接更改

详细分析变化驱动因素

变化驱动因素1:错误类型演变

当你添加新错误类型时会发生什么?

受检异常创建耦合——throws子句具有传染性:

// 你向此方法添加DatabaseException...
void saveUser(User u) throws IOException, DatabaseException

// ...现在这个方法需要更新...
void processRegistration(Form f) throws IOException, DatabaseException

// ...还有这个...
void handleRequest(Request r) throws IOException, DatabaseException

// ...一直波及整个调用栈

我见过团队通过在每层捕获和包装来应对——这完全违背了初衷。或者更糟,到处声明throws Exception

返回码静默失败。你添加新常量-3,但现有调用方仍只检查-1和-2。新错误作为垃圾数据传播。

非受检异常完全隐藏变化。调用方直到运行时崩溃才知道新异常存在。

Result类型像非受检异常一样传播——?操作符向上传递错误而无需中间层签名更改。但与非受检异常不同,错误在类型中可见。当你添加新变体时,编译器标记每个决策点(你匹配的地方),而不是整个调用链。两全其美:轻量级传播,显式处理

变化驱动因素2:处理策略变化

这里差异最明显。Result类型提供组合子——在不展开Result的情况下转换它们的小函数。这让你可以在组合时插入不同策略。

相同业务逻辑,不同恢复策略:

// 策略1:快速失败
let user = fetch_user(id)?;

// 策略2:失败时返回缓存数据
let user = fetch_user(id)
    .or_else(|_| get_cached_user(id))?;

// 策略3:退避重试
let user = fetch_user(id)
    .or_else(|_| { sleep(100); fetch_user(id) })
    .or_else(|_| { sleep(500); fetch_user(id) })?;

// 策略4:默认值
let user = fetch_user(id)
    .unwrap_or_else(|_| User::anonymous());

相同业务逻辑,不同可观测性:

// 记录日志并继续
let user = fetch_user(id)
    .map_err(|e| { log::warn!("获取失败: {e}"); e })?;

// 增加指标
let user = fetch_user(id)
    .map_err(|e| { metrics::increment("user_fetch_error"); e })?;

// 通知值班人员(针对关键路径)
let user = fetch_user(id)
    .map_err(|e| { pagerduty::alert("用户获取失败"); e })?;

相同业务逻辑,不同传播:

// 吸收错误,返回部分结果
let profile = fetch_user(id).ok()
    .map(|u| Profile::from(u))
    .unwrap_or(Profile::empty());

// 转换为领域错误
let user = fetch_user(id)
    .map_err(|e| DomainError::UserUnavailable(e))?;

// 熔断模式
let user = circuit_breaker.call(|| fetch_user(id))?;

业务逻辑——fetch_user(id)——从不改变。处理策略围绕它组合,而不是与它交织。你可以在不触及核心操作的情况下交换策略。

现在用异常实现相同的四种策略:

// 策略1:快速失败
User user = fetchUser(id);  // 抛出,调用方处理

// 策略2:失败时返回缓存数据
User user;
try {
    user = fetchUser(id);
} catch (IOException e) {
    user = getCachedUser(id);
}

// 策略3:退避重试
User user;
int retries = 0;
while (true) {
    try {
        user = fetchUser(id);
        break;
    } catch (IOException e) {
        if (++retries >= 3) throw e;
        Thread.sleep(100 * retries);
    }
}

// 策略4:默认值
User user;
try {
    user = fetchUser(id);
} catch (IOException e) {
    user = User.anonymous();
}

每种策略都是不同的控制流结构。重试需要while循环。回退需要try-catch。默认值需要带赋值的try-catch。它们不能组合——你需要从头重建。

组合策略更糟(重试然后回退到缓存):

User user;
int retries = 0;
while (true) {
    try {
        user = fetchUser(id);
        break;
    } catch (IOException e) {
        retries++;
        if (retries >= 3) {
            try {
                user = getCachedUser(id);
                break;
            } catch (CacheException ce) {
                throw new UserFetchException("所有策略都失败", e);
            }
        }
        Thread.sleep(100 * retries);
    }
}

与Result比较:fetch_user(id).or_else(retry).or_else(retry).or_else(cache)fetchUser调用在两者中都没有被触及,但使用异常时它被埋在20行嵌套控制流中。

使用返回码(相同策略):

User* user = NULL;
int result, retries = 0;

while (retries < 3) {
    result = fetch_user(id, &user);
    if (result == 0) break;
    retries++;
    sleep_ms(100 * retries);
}

if (result != 0) {
    result = get_cached_user(id, &user);
}

if (result != 0) {
    log_error("所有策略都失败");
    return ERR_USER_FETCH;
}

同样情况:fetch_user未被触及,但埋在条件判断中。每个策略更改都意味着重写围绕它的if检查。如果你忘记某个检查,错误就会作为垃圾数据静默传播。

差异不仅仅是语法Result策略线性组合——你可以链式调用它们。异常策略层次嵌套——try-catch内部嵌套try-catch。返回码策略条件分散——到处都是if检查。

注意在任何方法中都不变的是:各个操作(fetchUsergetCachedUser)保持不变。问题在于组合方式。使用异常和返回码时,你组合操作的方式与处理它们失败的方式纠缠在一起。想添加第三个回退?需要重构整个代码块。想更改重试次数?编辑定义操作序列的相同代码。

使用Result时,组合和策略是独立的关注点。fetch_user(id).or_else(f).or_else(g)读起来像管道,每个组合子都是独立的。将.or_else(cache)换为.unwrap_or(default)而无需触及其他部分。

变化驱动因素3:业务逻辑变化

当你需要更改核心操作本身时会发生什么——比如fetch_user现在需要一个额外参数,或者你要用fetch_user_v2替换它?

返回码迫使你在错误检查条件语句中导航以找到实际调用。业务逻辑与错误处理纠缠:

// 实际操作在哪里?埋在这里:
while (retries < 3) {
    result = fetch_user(id, &user);  // <-- 在杂音中找到这个
    if (result == 0) break;
    retries++;
    sleep_ms(100 * retries);
}

受检异常将你的逻辑埋在try块中。低内聚——业务操作与其错误处理交织:

try {
    user = fetchUser(id);  // <-- 实际工作
    break;
} catch (IOException e) {
    // 10行恢复逻辑
}

非受检异常和Result类型都保持业务逻辑清晰。操作独立存在:

let user = fetch_user(id)?;  // 清晰显示发生了什么
user = fetch_user(id)  # 同样清晰

区别在于:使用Result时,你知道错误处理存在于链中的某个地方。使用非受检异常时,你相信某个地方的某人会处理它。

评分卡

每种方法在多大程度上允许你独立变化这三个关注点?

变化驱动因素 返回码 受检异常 受检异常 结果
错误类型演变 ❌ 静默失败 ❌ 传染性签名 ⚠️ 不可见 ✅ 穷尽匹配
处理策略变化 ❌ 重写条件判断 ❌ 重构try-catch ⚠️ 可集中但隐式 ✅ 交换组合子
业务逻辑变化 ❌ 与检查纠缠 ⚠️ 埋在try块中 ✅ 清晰 ✅ 清晰

应用PIV

Result类型胜出不是因为语法或时尚,而是因为它们尊重变化的实际运作方式。成功和失败是独立的关注点——它们来自不同的利益相关者,在不同的时间线上演变,响应不同的压力。PIV指出:分离它们

更深刻的见解是PIV基于业务现实。变化驱动因素不是技术抽象——它们是产品需求、合规更新、操作事件、扩展需求。业务不关心你的try-catch嵌套。它关心的是你能在下一次中断前添加熔断器,或者无需两周重构就能交换重试策略。

唯一不变的是变化本身。与这一现实抗争的软件会积累摩擦。拥抱它的软件——通过分离独立变化的事物——保持可塑性。

要自行应用PIV:

  1. 识别变化驱动因素。这段代码可能变化的独立原因是什么?不是抽象类别——真实的力量。谁要求这些变化?频率如何?在什么时间线上?

  2. 追踪耦合。对于每个驱动因素,还有什么必须改变?如果触及一个关注点会波及另一个,你就耦合了本应独立变化的事物。

  3. 检查内聚性。相关代码是否分散?如果共同变化的代码存在于五个不同文件中,你就碎片化了本应组合的内容。

  4. 比较设计。最小化跨关注点耦合同时保持每个关注点内聚的设计就是PIV推崇的。这不是观点——这是变更成本更低的设计。


【注】本文译自:Error Handling Through the Lens of the Principle of Independent Variation

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注