PAYATHON 2026

.NET API 设计:通过异常向上传播来发现调用方问题并推动重构是否合理?

技术老齐

抛出异常合理,但要先明确 API 如何表达失败

B 向 A 传入违反约定的参数时,A 检查参数并抛出异常,是合理的 API 设计。例如,构造函数要求数量大于零,收到负数时抛出 ArgumentOutOfRangeException,既能阻止无效对象进入后续流程,也方便开发者定位错误的调用。

这些异常可以帮助发现遗留问题,进而补充测试、重构调用方。但异常类型和触发条件应由 API 契约决定,不能仅仅为了推动重构而抛出异常。

选择异常、false 还是 null,取决于调用方该如何理解和处理这个结果。

如何判断该抛异常,还是返回失败结果

“异常只用于异常情况”容易产生歧义。失败频率可以影响设计,却不能单独决定设计。判断时应先区分:调用违反了约定,还是得到了 API 允许出现的结果?

情况通常适合的表达方式原因
必填参数为 nullArgumentNullException调用方没有满足参数要求
参数超出约定范围ArgumentOutOfRangeException当前参数不符合契约
参数不满足其他约束ArgumentException需要指出具体参数问题
对象当前状态不允许操作InvalidOperationException参数可能有效,但调用时机不对
尝试解析外部输入,格式可能无效TryParse 返回 false无效输入属于预期结果
查询完成,但没有匹配对象可返回 null,并明确其含义“没有找到”可以是正常结果
业务操作被拒绝,且需要说明原因明确的结果类型或错误码调用方需要据此执行业务分支
文件读取、网络访问等操作失败通常通过异常报告,也可在上层转换需要保留失败原因,不能伪装成正常结果

同一个值在不同 API 中可以有不同含义。用户在输入框里填入负数,界面需要将其作为校验失败来处理;内部代码把这个负数直接传给只接受正数的实体,则违反了实体构造函数的约定。

输入边界的校验和实体自身的参数检查可以同时存在:前者给用户合适的反馈,后者维护对象不变式。

用构造函数阻止无效对象产生

假设业务规则要求订单数量大于零,可以这样定义类型:

using System;

public sealed class OrderQuantity
{
    public decimal Value { get; }

    public OrderQuantity(decimal value)
    {
        if (value <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(value),
                value,
                "订单数量必须大于零。");
        }

        Value = value;
    }
}

调用方传入负数时,构造过程会失败,不会正常返回违反规则的实例。异常中的参数名和实际值也能帮助定位问题。

构造函数无法通过返回 null 表示失败。如果创建失败是调用方经常需要处理的正常分支,可以另设 TryCreate 或返回结果类型的工厂方法,并写清楚失败条件。

参数检查应尽量放在修改状态或执行外部操作之前。抛出异常本身没有事务保证:已经完成的数据库写入,不会因后续抛出异常而自动撤销。

Parse 和 TryParse 表达不同的调用意图

调用 IPAddress.Parse,表示调用方要求将输入转换为地址。对于无法解析的非空字符串,它会抛出 FormatException:

using System.Net;

var address = IPAddress.Parse("It makes sense =]");

如果字符串来自用户输入,调用方本来就需要处理无效格式,使用 IPAddress.TryParse 更直接:

using System;
using System.Net;

string input = "It makes sense =]";

if (IPAddress.TryParse(input, out var address))
{
    Console.WriteLine(address);
}
else
{
    Console.WriteLine("请输入有效的 IP 地址。");
}

这里返回 false 就能表达“无法解析”,无需先抛出异常,再用 catch 处理普通分支。

Try... 方法也要明确失败的含义。例如,自定义 TryCreate 可以约定某些校验失败时返回 false,但不应捕获内部缺陷、数据库故障等所有异常,再一律转换为 false。否则,调用方就无法区分输入问题和系统故障。

根据处理能力决定在哪里捕获异常

A 抛出异常后,如果 B 既无法恢复,也无法补充有意义的信息,可以让异常继续向上传播,无需在每一层都写 try/catch。

通常应在能够采取具体措施的位置捕获异常,例如:

  • 界面层将已识别的输入问题转换为校验提示。
  • 请求处理边界结束当前请求,并记录必要的诊断信息。
  • 后台任务边界将当前任务标记为失败。是否重试,要看失败原因,以及操作是否允许重复执行。

仅仅为了消除报错而捕获异常,再返回 null 或默认值,会改变失败的含义。例如,查询数据库失败后返回 null,可能让上层误以为记录不存在。

捕获后如果仍需重新抛出当前异常,应使用 throw; 保留原始堆栈,避免使用 throw ex;。需要转换异常时,可以将原异常保留为 InnerException,方便追溯诊断信息。

应用边界仍需处理传播上来的异常,决定如何结束当前操作,以及如何向用户报告失败。

在遗留代码中逐步落实

给已有 API 增加参数检查,可能让过去能正常返回的调用开始抛出异常。这是可观察的行为变化,需要检查兼容性,不能仅凭检查“更严格”就认定改动安全。

可以按以下顺序推进:

  1. 写清楚 A 接受哪些值,以及哪些情况属于正常的业务失败。
  2. 为当前行为补充必要的测试,检查是否有调用方依赖默认值、自动修正或特殊输入。
  3. 在明确的边界加入检查,使用准确的异常类型和参数名。
  4. 根据异常定位调用方,区分代码缺陷、历史脏数据和正常的输入失败,分别处理。
  5. 为修复增加回归测试。如果无效输入经常出现,而且需要作为正常分支处理,再考虑增加 Try... 或结果类型 API。

TDD 和运行时参数校验各有职责:测试验证已覆盖的场景,参数校验检查实际发生的调用。已有测试无法保证所有输入和调用路径都符合约定;已经出现过的参数错误,则适合补成回归测试。

返回 null 本身没有问题,前提是它的含义明确、单一。如果调用方还需要知道失败原因,或者根本不应在该条件下继续执行,通常应使用异常或带错误信息的结果类型。

备注:内容仅供参考。