.NET API 设计:通过异常向上传播来发现调用方问题并推动重构是否合理?
抛出异常合理,但要先明确 API 如何表达失败
B 向 A 传入违反约定的参数时,A 检查参数并抛出异常,是合理的 API 设计。例如,构造函数要求数量大于零,收到负数时抛出 ArgumentOutOfRangeException,既能阻止无效对象进入后续流程,也方便开发者定位错误的调用。
这些异常可以帮助发现遗留问题,进而补充测试、重构调用方。但异常类型和触发条件应由 API 契约决定,不能仅仅为了推动重构而抛出异常。
选择异常、false 还是 null,取决于调用方该如何理解和处理这个结果。
如何判断该抛异常,还是返回失败结果
“异常只用于异常情况”容易产生歧义。失败频率可以影响设计,却不能单独决定设计。判断时应先区分:调用违反了约定,还是得到了 API 允许出现的结果?
| 情况 | 通常适合的表达方式 | 原因 |
|---|---|---|
必填参数为 null | ArgumentNullException | 调用方没有满足参数要求 |
| 参数超出约定范围 | 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 增加参数检查,可能让过去能正常返回的调用开始抛出异常。这是可观察的行为变化,需要检查兼容性,不能仅凭检查“更严格”就认定改动安全。
可以按以下顺序推进:
- 写清楚 A 接受哪些值,以及哪些情况属于正常的业务失败。
- 为当前行为补充必要的测试,检查是否有调用方依赖默认值、自动修正或特殊输入。
- 在明确的边界加入检查,使用准确的异常类型和参数名。
- 根据异常定位调用方,区分代码缺陷、历史脏数据和正常的输入失败,分别处理。
- 为修复增加回归测试。如果无效输入经常出现,而且需要作为正常分支处理,再考虑增加
Try...或结果类型 API。
TDD 和运行时参数校验各有职责:测试验证已覆盖的场景,参数校验检查实际发生的调用。已有测试无法保证所有输入和调用路径都符合约定;已经出现过的参数错误,则适合补成回归测试。
返回 null 本身没有问题,前提是它的含义明确、单一。如果调用方还需要知道失败原因,或者根本不应在该条件下继续执行,通常应使用异常或带错误信息的结果类型。
备注:内容仅供参考。
