我写的 180 行 if-else 被 review 打回了
上周做了个订单状态流转的需求,我吭哧吭哧写完,自我感觉良好地提了 MR。师傅留了一条评论:"状态机别这么写,用枚举。"
先看看我交上去的东西:
@Service
public class OrderStateService {
public void transfer(Order order, String targetStatus) {
Integer current = order.getStatus();
if ("WAIT_PAY".equals(targetStatus)) {
if (current != 0) {
throw new BizException("当前状态不能转为待支付");
}
order.setStatus(0);
} else if ("PAID".equals(targetStatus)) {
if (current != 0 && current != 1) {
throw new BizException("当前状态不能转为已支付");
}
order.setStatus(1);
// 支付后要发券
couponService.grant(order.getUserId());
} else if ("SHIPPED".equals(targetStatus)) {
if (current != 1) {
throw new BizException("当前状态不能转为已发货");
}
order.setStatus(2);
// 发货后要发短信
smsService.send(order.getUserPhone(), "您的订单已发货");
} else if ("RECEIVED".equals(targetStatus)) {
if (current != 2) {
throw new BizException("当前状态不能转为已收货");
}
order.setStatus(3);
} else if ("CANCELLED".equals(targetStatus)) {
if (current == 2 || current == 3 || current == 4) {
throw new BizException("当前状态不能取消");
}
order.setStatus(4);
// 取消要退款
refundService.refund(order);
// 取消要回库存
inventoryService.rollback(order.getSkuId(), order.getQuantity());
} else if ("REFUNDED".equals(targetStatus)) {
...
}
// 后面还有 60 行
}
}
问题挺明显的:
- 状态用 Integer 数字表示,代码里全是 0、1、2、3,读的人得去翻文档才知道 1 是什么意思。
- 每次加一个状态,要改这个方法、改前端下拉框、改字典表,漏一处就是线上 bug。
- 状态能不能流转的规则、流转后要做什么动作,全都耦合在一起,改一个动作可能影响别的分支。
- 没法测试——想测"CANCELLED 状态下调用 refund"这个场景,得先构造出整个 if 链。
第一步:把状态定义成枚举
public enum OrderStatus {
WAIT_PAY(0, "待支付"),
PAID(1, "已支付"),
SHIPPED(2, "已发货"),
RECEIVED(3, "已收货"),
CANCELLED(4, "已取消"),
REFUNDED(5, "已退款");
private final int code;
private final String desc;
OrderStatus(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() { return code; }
public String getDesc() { return desc; }
public static OrderStatus of(int code) {
for (OrderStatus s : values()) {
if (s.code == code) {
return s;
}
}
throw new IllegalArgumentException("未知的订单状态码: " + code);
}
}
枚举比常量类好在哪?它是类型。transfer(Order, OrderStatus) 这样的签名,编译器会替你挡掉传错参数的情况,而 Integer 不行——我以前就因为把 userId 传进了 status 参数,线上跑出一批状态为 10086 的订单。
另外推荐用 enum 存数据库。MyBatis 3.4 自带了 EnumTypeHandler(存名称)和 EnumOrdinalHandler(存序号),我个人偏好存名称,可读性好,改顺序也不会错乱:
<resultMap id="orderMap" type="Order">
<result column="status" property="status"
typeHandler="org.apache.ibatis.type.EnumTypeHandler"/>
</resultMap>
第二步:把流转规则放进枚举
这是改完让我最舒服的一步——让每个状态自己声明"我能转成什么":
public enum OrderStatus {
WAIT_PAY(0, "待支付") {
@Override
public boolean canTransferTo(OrderStatus target) {
return target == PAID || target == CANCELLED;
}
},
PAID(1, "已支付") {
@Override
public boolean canTransferTo(OrderStatus target) {
return target == SHIPPED || target == REFUNDED;
}
},
SHIPPED(2, "已发货") {
@Override
public boolean canTransferTo(OrderStatus target) {
return target == RECEIVED;
}
},
RECEIVED(3, "已收货") {
@Override
public boolean canTransferTo(OrderStatus target) {
return target == REFUNDED;
}
},
CANCELLED(4, "已取消") {
@Override
public boolean canTransferTo(OrderStatus target) {
return false; // 终态
}
},
REFUNDED(5, "已退款") {
@Override
public boolean canTransferTo(OrderStatus target) {
return false; // 终态
}
};
public abstract boolean canTransferTo(OrderStatus target);
}
这种写法叫策略枚举(《Effective Java》第 30 条)。每个枚举常量用匿名内部类的方式实现抽象方法,规则就写在离它最近的地方。现在想知道"待支付能转成什么",直接跳到 WAIT_PAY 那几行看,不用再通读 180 行。
Service 层立刻清爽了:
@Transactional(rollbackFor = Exception.class)
public void transfer(Order order, OrderStatus target) {
OrderStatus current = order.getStatus();
if (!current.canTransferTo(target)) {
throw new BizException(String.format(
"订单状态不允许从[%s]流转到[%s]", current.getDesc(), target.getDesc()));
}
order.setStatus(target);
orderMapper.updateStatus(order.getId(), target);
}
从 180 行到 10 行,而且错误信息里能直接打出中文状态名,排查问题时不用再对数字。
第三步:流转后的动作怎么办
状态变了之后要发券、发短信、退款、回滚库存,这些副作用不能直接写在枚举里——枚举里注入 Spring Bean 很别扭(枚举的构造早于 Spring 容器,字段注入不了)。
我用了事件的方式,枚举只声明"我要发什么事件",具体动作交给监听器:
public enum OrderStatus {
PAID(1, "已支付") {
@Override
public boolean canTransferTo(OrderStatus target) {
return target == SHIPPED || target == REFUNDED;
}
@Override
public Class<? extends OrderEvent> onEnter() {
return OrderPaidEvent.class;
}
},
// ...
/** 进入该状态时触发的事件,null 表示不触发 */
public Class<? extends OrderEvent> onEnter() {
return null;
}
}
Service 里发布事件:
@Transactional(rollbackFor = Exception.class)
public void transfer(Order order, OrderStatus target) {
OrderStatus current = order.getStatus();
if (!current.canTransferTo(target)) {
throw new BizException("不允许的状态流转");
}
order.setStatus(target);
orderMapper.updateStatus(order.getId(), target);
Class<? extends OrderEvent> eventType = target.onEnter();
if (eventType != null) {
applicationContext.publishEvent(buildEvent(eventType, order));
}
}
每个动作一个监听器,职责单一,好测好改:
@Component
public class CouponGrantListener {
@Autowired
private CouponService couponService;
@EventListener
@Async
public void onOrderPaid(OrderPaidEvent event) {
couponService.grant(event.getOrder().getUserId());
}
}
注意 @EventListener 默认是同步的,事件处理会阻塞主流程。发券、发短信这种我用 @Async 异步掉了(记得在启动类上加 @EnableAsync),但退款和回库存必须保持同步,不然事务回滚了钱却退了。
另一种思路:用 Map 代替枚举的策略方法
我同事用的是另一种写法,适合规则比较简单的场景——直接在枚举构造时传入允许的下一个状态列表:
public enum OrderStatus {
WAIT_PAY(0, "待支付", Arrays.asList(PAID, CANCELLED)),
PAID(1, "已支付", Arrays.asList(SHIPPED, REFUNDED)),
SHIPPED(2, "已发货", Arrays.asList(RECEIVED)),
RECEIVED(3, "已收货", Arrays.asList(REFUNDED)),
CANCELLED(4, "已取消", Collections.emptyList()),
REFUNDED(5, "已退款", Collections.emptyList());
private final List<OrderStatus> allowedNext;
OrderStatus(int code, String desc, List<OrderStatus> allowedNext) {
this.code = code;
this.desc = desc;
this.allowedNext = allowedNext;
}
public boolean canTransferTo(OrderStatus target) {
return allowedNext.contains(target);
}
}
这个写法更紧凑,一眼能看全所有流转关系。缺点是规则复杂时(比如"已支付超过 7 天才能申请退款"这种带条件的)就不够用了,还是抽象方法灵活。我两个都用过,一般先上 Map 版,规则变复杂了再改策略枚举。
注意一个坑:枚举的静态字段之间不能有前向引用。WAIT_PAY(0, "待支付", Arrays.asList(PAID, ...)) 里引用了后面定义的 PAID,这在某些情况下会拿到 null。上面的写法能跑是因为 Arrays.asList 传的是对象引用,JVM 允许这种循环引用(编译器不报错,运行时也能工作),但别在构造器里访问对方的字段值,容易踩坑。
效果
重构前后的对比:
| 指标 | 重构前 | 重构后 |
|---|---|---|
| OrderStateService 行数 | 213 | 32 |
| 新增一个状态要改的地方 | 5 处 | 1 处(枚举里加一行) |
| 状态规则单元测试 | 0 | 36(覆盖 6×6 种流转组合) |
最关键的是那 36 个测试——枚举组合是有限的,可以穷举验证所有流转是否合法。这种"把所有情况都测一遍"的踏实感,是写 if-else 时从来没有过的。
上周新增了个"部分退款"状态,我在枚举里加了一行,改了两个流转规则,5 分钟搞定。要是以前,光是读懂自己那 180 行 if-else 就得半小时。