错误处理最佳实践
本节讲解 Move 合约中的错误处理策略。良好的错误处理不仅能帮助调试,还能向用户提供有意义的反馈。我们将介绍错误码设计、分类策略和三条核心规则。
Move 中的错误机制
当执行遇到 abort 时,交易失败并返回中止码(abort code)。Move VM 会返回中止交易的模块名称和中止码。但这种行为对调用者来说不够透明,特别是当一个函数包含多个可能中止的调用时。
问题场景
module book::module_a;
use book::module_b;
public fun do_something() {
let field_1 = module_b::get_field(1); // 可能以 abort code 0 中止
/* ... 大量逻辑 ... */
let field_2 = module_b::get_field(2); // 可能以 abort code 0 中止
/* ... 更多逻辑 ... */
let field_3 = module_b::get_field(3); // 可能以 abort code 0 中止
}
如果调用者收到 abort code 0,无法确定是哪个调用失败了。
三条核心规则
规则一:处理所有可能的场景
在调用可能中止的函数之前,先用安全的检查函数验证:
module book::module_a;
use book::module_b;
const ENoField: u64 = 0;
public fun do_something() {
assert!(module_b::has_field(1), ENoField);
let field_1 = module_b::get_field(1);
/* ... */
assert!(module_b::has_field(2), ENoField);
let field_2 = module_b::get_field(2);
/* ... */
assert!(module_b::has_field(3), ENoField);
let field_3 = module_b::get_field(3);
}
通过在每次调用前添加自定义检查,开发者掌握了错误处理的控制权。
规则二:使用不同的错误码
为每个失败场景分配唯一的错误码:
module book::module_a;
use book::module_b;
const ENoFieldA: u64 = 0;
const ENoFieldB: u64 = 1;
const ENoFieldC: u64 = 2;
public fun do_something() {
assert!(module_b::has_field(1), ENoFieldA);
let field_1 = module_b::get_field(1);
/* ... */
assert!(module_b::has_field(2), ENoFieldB);
let field_2 = module_b::get_field(2);
/* ... */
assert!(module_b::has_field(3), ENoFieldC);
let field_3 = module_b::get_field(3);
}
现在调用者可以精确定位问题:abort code 0 表示 “字段 1 不存在”,1 表示 “字段 2 不存在”,依此类推。
规则三:返回 bool 而非 assert
不要暴露一个公共的 assert 函数,而是提供返回 bool 的检查函数:
// 不推荐:暴露断言函数
module book::some_app_assert;
const ENotAuthorized: u64 = 0;
public fun do_a() {
assert_is_authorized();
// ...
}
/// 不要这样做
public fun assert_is_authorized() {
assert!(/* 某个条件 */ true, ENotAuthorized);
}
// 推荐:暴露布尔函数
module book::some_app;
const ENotAuthorized: u64 = 0;
public fun do_a() {
assert!(is_authorized(), ENotAuthorized);
// ...
}
public fun do_b() {
assert!(is_authorized(), ENotAuthorized);
// ...
}
/// 返回 bool,让调用者决定如何处理
public fun is_authorized(): bool {
/* 某个条件 */ true
}
// 内部使用的断言函数仍然可以存在
fun assert_is_authorized() {
assert!(is_authorized(), ENotAuthorized);
}
错误码设计规范
命名约定
错误常量使用 EPascalCase 前缀:
// 正确:EPascalCase
const ENotAuthorized: u64 = 0;
const EInsufficientBalance: u64 = 1;
const EObjectNotFound: u64 = 2;
// 错误:ALL_CAPS 用于普通常量
const NOT_AUTHORIZED: u64 = 0; // 不推荐
分类编号策略
按模块功能分组分配错误码:
module my_protocol::marketplace;
// 权限错误:0-9
const ENotOwner: u64 = 0;
const ENotAdmin: u64 = 1;
const ENotApproved: u64 = 2;
// 输入验证错误:10-19
const EInvalidPrice: u64 = 10;
const EInvalidQuantity: u64 = 11;
const EInvalidName: u64 = 12;
// 状态错误:20-29
const EAlreadyListed: u64 = 20;
const ENotListed: u64 = 21;
const EAlreadySold: u64 = 22;
// 余额错误:30-39
const EInsufficientBalance: u64 = 30;
const EInsufficientPayment: u64 = 31;
// 版本/系统错误:100+
const EInvalidPackageVersion: u64 = 100;
const EDeprecated: u64 = 101;
前端错误码映射
const ERROR_MESSAGES: Record<number, string> = {
0: '您没有权限执行此操作',
1: '需要管理员权限',
10: '价格无效,请输入正数',
11: '数量无效',
20: '该物品已上架',
21: '该物品未上架',
30: '余额不足',
100: '合约版本不兼容,请刷新页面',
};
function getErrorMessage(abortCode: number): string {
return ERROR_MESSAGES[abortCode] ?? `未知错误 (代码: ${abortCode})`;
}
高级模式
错误上下文包装
当需要区分同一模块中不同位置的相同类型错误时:
const ETransferFailed_SenderCheck: u64 = 40;
const ETransferFailed_ReceiverCheck: u64 = 41;
const ETransferFailed_AmountCheck: u64 = 42;
public fun transfer(
from: &mut Account,
to: &mut Account,
amount: u64,
) {
assert!(from.is_active(), ETransferFailed_SenderCheck);
assert!(to.is_active(), ETransferFailed_ReceiverCheck);
assert!(from.balance >= amount, ETransferFailed_AmountCheck);
// ...
}
优雅降级
对于非关键操作,考虑返回结果而非中止:
/// 尝试装备武器,返回操作结果
public fun try_equip_weapon(
hero: &mut Hero,
weapon: Weapon,
): (bool, Option<Weapon>) {
if (hero.weapon.is_some()) {
// 已有武器,返回失败和未使用的武器
(false, option::some(weapon))
} else {
hero.weapon.fill(weapon);
(true, option::none())
}
}
测试错误处理
#[test, expected_failure(abort_code = ENotAuthorized)]
fun unauthorized_access_fails() {
let ctx = &mut tx_context::dummy();
// 设置无权限场景
unauthorized_action(ctx);
abort 0xFF // 如果执行到这里说明测试失败
}
#[test]
fun error_returns_correct_code() {
// 验证 is_authorized 返回正确的布尔值
assert!(!is_authorized_for(@0x0));
assert!(is_authorized_for(@0x1));
}
小结
- 遵循三条核心规则:处理所有场景、使用不同错误码、返回 bool 而非 assert
- 错误常量使用
EPascalCase命名约定 - 按功能分组分配错误码,便于定位和维护
- 在前端维护错误码到用户友好消息的映射
- 提供
is_*检查函数让调用者在中止前验证条件 - 对非关键操作考虑优雅降级(返回结果而非中止)
- 使用
#[expected_failure(abort_code = ...)]测试错误路径