5.9 KiB
5.9 KiB
错误处理分层设计 Skill
目标
设计错误处理时,底层通用设施应尽可能开放,不替调用者决定错误处理方式;高层业务接口应收束语义,避免暴露过多错误处理范式,降低使用成本和实现复杂度。
核心原则
1. 底层通用函数:提供错误处理范式,不限制调用者
底层函数、宏、工具类、适配层应尽可能保留完整错误信息,并支持不同调用风格,例如:
- 返回错误码
- 返回
expected<T, E> - 抛出异常
- 回调式错误处理
- 日志记录后继续传播
- 捕获异常并转换为统一错误对象
底层的职责不是判断哪种方式“最好”,而是提供足够通用的错误表达能力,使不同调用者可以根据自己的上下文选择合适方式。
底层设计应优先关注:
- 错误信息完整
- 错误来源明确
- 可组合
- 可转换
- 不丢失上下文
- 不强制调用者使用某一种错误处理模型
示例原则:
// 底层可以提供统一的错误对象
struct error_info {
int code {};
std::string message;
std::string file;
int line {};
std::string function;
};
// 底层提供构造错误的统一入口
#define MAKE_ERROR(code, msg) \
error_info{code, msg, __FILE__, __LINE__, __func__}
底层可以围绕这个统一错误对象,再提供不同适配形式:
// expected 风格
expected<T, error_info>
// 异常风格
throw project_exception(error_info)
// 错误码风格
return error_info
// 回调风格
on_error(error_info)
2. 高层函数:根据语义选择一种主要错误模型
高层函数不应该为了“灵活”而同时暴露多种错误处理接口。
错误示例:
foo();
foo_expected();
foo_nothrow();
foo_with_error_code();
除非确实有强需求,否则这种接口会增加维护成本,也会让调用者不知道应该使用哪个。
高层函数应根据语义选择一种主要错误模型:
简单高层函数
简单操作可以直接使用异常。
适合场景:
- 调用失败通常不是正常业务分支
- 调用者一般无法局部恢复
- 失败代表流程应中断
- 接口希望保持简洁
User load_user(UserId id); // 找不到、数据库异常等直接抛异常
复杂高层函数
复杂业务流程可以使用 expected<T, E> 表示预期内的失败结果。
适合场景:
- 失败是业务的一部分
- 调用者需要根据失败类型分支处理
- 成功和失败都是接口语义的一部分
- 不希望用异常表达正常业务分支
expected<Order, CreateOrderError> create_order(CreateOrderRequest req);
例如:
- 参数校验失败
- 库存不足
- 权限不足
- 用户状态不允许
- 资源不存在
这些属于“预期的失败结果”,适合用 expected。
3. 异常表示错误,但不等于错误处理
异常是一种错误传播机制,不是完整的错误处理策略。
异常适合表达:
- 不可继续的错误
- 非预期错误
- 系统错误
- 编程错误
- 资源错误
- 调用者当前层级无法处理的问题
但异常本身不负责:
- 是否重试
- 是否降级
- 是否返回默认值
- 是否记录日志
- 是否转换成 HTTP 响应
- 是否转换成 UI 错误提示
这些应由更高层的边界处理逻辑完成。
4. expected 表示预期结果,异常表示非预期错误
推荐约定:
expected<T, E>:用于表达业务上可预期、调用者应当处理的失败
exception:用于表达非预期、当前层级无法合理处理的错误
例如:
expected<User, LoginError> login(name, password);
登录失败是业务结果,所以适合 expected。
Config load_config(path);
配置文件损坏、磁盘读取失败、解析器内部异常等,可以直接抛异常,因为这通常不是普通业务分支。
5. 边界层负责错误收束
在模块边界、线程边界、HTTP 边界、RPC 边界、插件边界,应捕获并转换错误。
例如 HTTP 层:
try {
auto result = service.do_work(req);
if (!result) {
return make_business_error_response(result.error());
}
return make_success_response(*result);
}
catch (const project_exception& e) {
return make_internal_error_response(e.info());
}
catch (const std::exception& e) {
return make_unknown_error_response(e.what());
}
catch (...) {
return make_unknown_error_response("unknown exception");
}
边界层的职责是:
- 捕获异常
- 统一日志
- 统一响应格式
- 避免异常逃逸到框架之外
- 把内部错误转换成外部协议错误
6. 不要在每一层都处理错误
中间层如果没有恢复能力,不应该假装处理错误。
错误示例:
try {
return lower_call();
}
catch (...) {
log_error();
throw;
}
如果只是重复日志、重复包装、重复捕获,可能导致:
- 日志重复
- 栈信息污染
- 错误语义变模糊
- 维护成本增加
中间层只有在以下情况才应该处理错误:
- 能恢复
- 能补充关键上下文
- 能转换错误语义
- 能降级
- 能重试
- 到达了模块边界
7. 推荐分层模型
底层工具层:
提供统一错误对象、宏、异常类型、expected 适配、错误码适配
基础库层:
尽量保留错误上下文,不强制业务语义
领域服务层:
根据业务语义选择 expected 或 exception
接口边界层:
捕获异常,处理 expected,转换成 HTTP/RPC/UI/日志格式
8. 总结规则
底层:
越底层,越通用,越不替调用者做决定。
高层:
越高层,越具体,越应该收束错误语义。
简单高层函数:
可以直接异常。
复杂业务函数:
优先 expected 表达预期失败。
异常:
表示错误传播,不等于错误处理。
边界层:
负责最终捕获、记录、转换、响应。