Administrator
发布于 2018-05-16 / 129 阅读
3

用枚举干掉满屏 if-else:一次订单状态机重构

我写的 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 行数21332
新增一个状态要改的地方5 处1 处(枚举里加一行)
状态规则单元测试036(覆盖 6×6 种流转组合)

最关键的是那 36 个测试——枚举组合是有限的,可以穷举验证所有流转是否合法。这种"把所有情况都测一遍"的踏实感,是写 if-else 时从来没有过的。

上周新增了个"部分退款"状态,我在枚举里加了一行,改了两个流转规则,5 分钟搞定。要是以前,光是读懂自己那 180 行 if-else 就得半小时。

参考