1. 第22天的学习规划:为什么这个节点值得复盘
今天是我给自己定下的"百日进阶计划"第22天,按学习日记DAY22的记录来看,正好跨过了五分之一的分水岭。很多人会把学习计划做成前三天打鸡血、后两周随缘的节奏,但真正能拉开差距的,恰恰是第20天到第30天这段"平台期"——新鲜感消退,基础概念已经过了一遍,但又还没到能自如输出的程度。我刻意把这一天的主题定为"状态机与策略模式在真实业务中的落地",而不是继续堆新知识,就是想在这个节点把前面零散学的东西串成体系。
先说结论:第22天最适合做三件事——横向串联旧知识点、挑一个真实场景做深度实战、把踩过的坑固化成checklist。知识增长不是线性的,前21天我分别学了Java集合源码、MySQL索引原理、Redis缓存策略、Spring Bean生命周期等等,单看每一块都懂,但合在一起就发现根本不会用。这就像学了一堆食材的处理方法,却没正儿八经做过一道菜。所以DAY22的内容我不再开新坑,而是围绕"如何用策略模式重构一个多支付渠道订单系统"这个综合案例,把集合、设计模式、Spring依赖注入、单元测试全部揉进去。
这个思路适合谁?如果你也在做长期自学计划,尤其是走技术路线,但发现自己"学得快忘得更快",那这篇学习日记里梳理的方法论和实战过程,可以直接抄作业。哪怕你不写代码,里面关于"刻意练习"和"复盘机制"的部分,对学任何技能都有参考价值。
2. 状态机与策略模式:两个容易混淆的思路辨析
2.1 从需求出发倒推技术选型
DAY22的实战案例背景是我自己搭的一个简化版电商订单系统,支付环节对接了微信支付、支付宝和模拟余额三种渠道。最初的实现很粗暴,就是一个大service类,里面塞满if-else判断渠道类型,再根据订单状态status字段决定下一步动作。代码大概长这样:
public PayResult pay(Order order, String channel) { if ("wechat".equals(channel)) { // 微信支付逻辑 if (order.getStatus() == 1) { // 调微信API } else if (order.getStatus() == 2) { // 处理退款 } } else if ("alipay".equals(channel)) { // 支付宝支付逻辑 } else if ("balance".equals(channel)) { // 余额支付逻辑 } // 后面还有更多渠道和状态分支... }这种写法在渠道只有两三个、状态流转只有两三种的时候,还能勉强维护。但一旦加入"待支付、已支付、已退款、退款中、已关闭"五个状态,再叠加"支付成功回调、退款申请、超时关闭"等多个动作,if-else的组合爆炸会让方法体膨胀到几百行。更麻烦的是,每加一个新渠道,就要在所有涉及状态判断的地方各加一段分支,漏掉一处就是线上事故。
这时候我意识到,"按照渠道把逻辑切成多份"和"按照状态把流转理清楚"其实是两个维度的需求,正确做法是把它们拆开。渠道差异适合用策略模式解决,订单状态流转适合用状态机思路管理。两者并不冲突,策略模式管的是"同一个动作在不同渠道下怎么实现",状态机管的是"什么状态下允许执行什么动作、动作完成后迁移到什么状态"。
2.2 策略模式的核心价值:替换与隔离
策略模式不是新鲜东西,教科书里的UML图大家都会画,但真正落地时很多人搞不清它解决了什么问题。我用大白话解释:策略模式就是把"做什么"和"怎么做"分开。上层调用方只关心"我要支付这笔订单",至于微信怎么签名、支付宝怎么加密、余额怎么扣减,那是各自策略内部的事。
这样一来有三个直接好处。第一,新增渠道时,调用方代码零改动,只需要新增一个实现类并在配置里注册;第二,每个渠道的代码独立成类,微信支付的sign逻辑再复杂也不会污染支付宝的代码;第三,单元测试变得好写,我可以单独测微信策略,不用启动整个Spring容器。
2.3 状态机:不是非要引入框架
说到状态机,很多人第一反应是引入Spring StateMachine或者阿里的COLA状态机组件。但DAY22我做了一个刻意的选择:不引入任何状态机框架,用手写的方式实现核心逻辑。原因有二:一是这个订单状态流转并不算极其复杂,引入框架反而要学习框架的DSL语法,成本大于收益;二是手写一遍能让我彻底理解状态机的本质——它就是一张"状态 + 事件 -> 新状态"的映射表。
用更直白的方式说,状态机就是一张二维表:行是当前状态,列是触发事件,交叉点是下一个状态。如果交叉点是空的,就表示该状态下不允许这个事件发生。这张表在代码里最自然的映射就是HashMap嵌套,外层key是当前状态,内层key是事件,value是目标状态。
3. 订单支付场景的策略模式落地实操
3.1 定义策略接口与统一上下文
DAY22的第一步,我把之前的支付逻辑重构为策略模式。先定义一个顶层接口,让所有渠道实现它:
public interface PaymentStrategy { // 渠道标识,例如 "wechat"、"alipay"、"balance" String getChannel(); // 执行支付,context里封装订单信息和扩展参数 PayResult pay(PayContext context); // 渠道特有的回调验签处理 boolean verifyCallback(CallbackRequest request); }这个接口设计的细节值得说一下。第一,getChannel()是必须的,它用来在运行时定位具体策略,相当于给每个实现类贴了个标签;第二,pay方法的参数我特意封装成PayContext而不是直接传Order对象,因为后续很可能要加IP、设备号、优惠券ID等参数,直接传Order会导致方法签名频繁变动;第三,verifyCallback单独抽出来,因为支付回调的处理逻辑和主动支付差别很大,合并到一个方法里会显得臃肿。
PayContext我用一个简单的POJO承载,包含订单号、金额、用户ID和Map类型的扩展字段。有人会问,既然有扩展字段了,为什么不直接把Order也放进去?我的经验是,PayContext是"本次支付这个动作"的上下文,Order是"订单这个业务实体"的持久化对象,两者生命周期和关注点都不同,混在一起容易让策略实现类不知不觉就去操作订单的其他字段,破坏封装性。
3.2 三种渠道策略的具体实现
微信支付策略类的核心逻辑长这样,我抽了关键代码出来:
@Component public class WechatPaymentStrategy implements PaymentStrategy { @Override public String getChannel() { return "wechat"; } @Override public PayResult pay(PayContext context) { // 1. 构建微信统一下单请求参数 Map<String, String> params = new HashMap<>(); params.put("out_trade_no", context.getOrderNo()); params.put("total_fee", String.valueOf(context.getAmount().multiply(new BigDecimal(100)).intValue())); params.put("body", context.getSubject()); // 2. 生成签名(微信用MD5或HMAC-SHA256,这里省略具体实现) String sign = WechatSignUtil.sign(params, wechatApiKey); params.put("sign", sign); // 3. 调用微信API // 4. 解析返回结果并封装为统一PayResult return new PayResult(success, prepayId, rawResponse); } }这里有几个埋在细节里的坑。金额计算我用了BigDecimal,并且转成分(微信支付以分为单位)时通过multiply(new BigDecimal(100)).intValue(),避免了double运算的精度问题;签名参数放进Map里之后必须先按key排序再拼接成字符串,微信对这个顺序有严格要求,我第一天联调时就栽在这里;每次调微信接口前都要先查一遍订单当前状态,防止重复下单,这个检查放在策略内部还是外部,当时纠结了很久,最后决定放在策略内部,因为不同渠道对"重复支付"的容忍度不一样,余额支付只要幂等校验就够了,微信支付还需要考虑防重回调。
支付宝策略类的支付流程本质相同,区别在于签名算法用的是RSA2,参数格式是表单拼接,回调验签需要把异步通知的所有参数(除去sign字段)重新排序签名再比对。余额支付则完全在本地完成,先检查余额充足,再扣减账户余额并更新订单状态。三种策略放到一起对比,差异点一目了然,这正是策略模式最直观的价值。
3.3 策略工厂与Spring整合
有了策略实现类,剩下的关键问题是如何根据前端传来的字符串渠道类型找到对应的策略对象。最原始做法是写个工厂类,里面塞if-else判断来new对象,但那等于把原来的分支逻辑换了个地方写,没有任何进步。既然项目用了Spring,就应该利用Spring的容器管理能力。
我的方案是这样的:在Spring配置类里把所有PaymentStrategy的实现类注入到一个Map中,key就是getChannel()的返回值,value就是策略对象本身:
@Configuration public class PaymentStrategyConfig { private final Map<String, PaymentStrategy> strategyMap = new HashMap<>(); public PaymentStrategyConfig(List<PaymentStrategy> strategies) { for (PaymentStrategy strategy : strategies) { strategyMap.put(strategy.getChannel(), strategy); } } public PaymentStrategy getStrategy(String channel) { PaymentStrategy strategy = strategyMap.get(channel); if (strategy == null) { throw new IllegalArgumentException("不支持的支付渠道: " + channel); } return strategy; } }Spring会自动把容器里所有PaymentStrategy接口的实现类收集成一个List注入进来,我再遍历放到Map里。这样以后新增渠道,只要实现接口并标注@Component,策略自动注册,无需改动任何现有代码。我实测过,这个方案的扩展性非常好,唯一要注意的是getChannel()绝对不能返回null或重复,否则会出现策略覆盖,排查起来比较隐蔽。
4. 订单状态机的建模与实现细节
4.1 状态定义与状态转移表
策略模式解决的是渠道差异,接下来是把订单生命周期的流转规则理清楚。我先穷举了订单在这一阶段可能涉及的所有状态和事件:
| 当前状态 | 触发事件 | 目标状态 | 允许的操作 |
|---|---|---|---|
| 待支付 | 支付成功回调 | 已支付 | 修改库存、发送通知 |
| 待支付 | 用户取消 | 已关闭 | 释放预占库存 |
| 待支付 | 超时未付 | 已关闭 | 定时任务批量处理 |
| 已支付 | 申请退款 | 退款中 | 调用支付渠道退款接口 |
| 退款中 | 退款成功回调 | 已退款 | 记录退款流水 |
| 退款中 | 退款失败 | 已支付 | 还原订单状态并告警 |
| 已支付 | 发货 | 已完成 | 填写物流单号 |
这个表不只是拿来画图的,而是要直接作为代码里的配置存在。我把它翻译成了Java里的结构:
public enum OrderState { PENDING_PAYMENT, PAID, REFUNDING, REFUNDED, CLOSED, COMPLETED } public enum OrderEvent { PAY_SUCCESS, USER_CANCEL, TIMEOUT, REFUND_APPLY, REFUND_SUCCESS, REFUND_FAILURE, SHIP } // 状态机核心配置:State -> Event -> TargetState Map<OrderState, Map<OrderEvent, OrderState>> stateMachine = new HashMap<>();状态从PAID迁到REFUNDING时,我不仅更新了状态值,还额外做了一件事——把退款单号、退款原因、操作人ID这些业务字段一并写入订单扩展表。这其实是很多状态机实现容易忽略的点:状态迁移不是单纯的枚举值替换,它往往伴随一系列业务动作。所以我的建议是,不要在状态机引擎里直接写业务逻辑,而是在迁移成功的事件回调里做,保持状态流转规则的纯粹性。
4.2 状态迁移的执行与校验逻辑
状态机的执行入口我设计成一个统一的方法,接收当前状态、事件和业务上下文,返回目标状态:
public OrderState transition(OrderState currentState, OrderEvent event) { // 查表获取目标状态 Map<OrderEvent, OrderState> stateTransitions = STATE_TRANSITIONS.get(currentState); if (stateTransitions == null) { throw new IllegalStateException("非法状态: " + currentState); } OrderState targetState = stateTransitions.get(event); if (targetState == null) { throw new IllegalStateException("状态[" + currentState + "]不允许触发事件[" + event + "]"); } return targetState; }这个实现看着简单,但真正难的是要保证"校验—迁移—落库"的原子性。我遇到的第一个问题是并发场景下的状态覆盖:用户和支付回调同时操作一笔订单,用户点取消,回调说支付成功,两个请求同时读到待支付状态,各自执行自己的迁移逻辑,后写入的覆盖先写入的,订单状态就乱了。解决方案是给订单表的状态更新加上乐观锁:
UPDATE t_order SET order_status = #{newStatus}, version = version + 1 WHERE order_no = #{orderNo} AND order_status = #{oldStatus} AND version = #{version}如果更新的行数为0,说明当前状态已被其他请求修改,这时候要抛异常或触发重试机制。DAY22的实战里我专门写了一个并发测试类,用线程池同时模拟"取消订单"和"支付成功回调"两个操作,跑了几十轮,确认最终状态完全由数据库的行锁和乐观锁控制,才敢把它作为结论写进学习笔记。
4.3 表驱动配置与硬编码的取舍
状态机的映射表我用的是内存里的static final常量,没有存数据库。有人会建议,状态流转规则应该做成可配置的,放数据库里方便运营调整。DAY22我的判断是:订单状态流转是强业务规则,正常情况下不允许运营随意修改"已支付能不能直接改已完成"之类的问题,放数据库反而增加了出问题的可能性。真正需要动态配置的,比如支付渠道的开关、费率的阈值,那是另一类配置,不该和状态机混在一起。就好比交通信号灯的红绿切换顺序可以写死在程序里,但某个路口高峰期延长绿灯时长,这是参数调节,不是规则变更,两者要区分开。
当然,如果你做的是审批流引擎这类需求极多变的场景,状态机规则外置到数据库或配置文件是合理的。取舍的关键在于"规则变化的频率"和"规则变化由谁发起"。技术人最容易犯的错,就是看到一个模式好用就直接套,不管业务场景是否匹配。我前几天的学习笔记里就吐槽过自己,为了用设计模式而用设计模式,最后写出来的代码比原来的if-else还绕。
5. 实战中的典型报错与排查过程
5.1 策略Map注入为空的问题
第一次把策略配置类写好启动项目时,getStrategy("wechat")直接抛了空指针,原因是strategyMap是空的。排查思路:先确认WechatPaymentStrategy类上的@Component是否正确标注,再确认Spring的包扫描路径是否覆盖到了这个类。当时我的配置类是放在com.example.config包,而策略实现在com.example.payment.strategy包,主启动类在com.example,理论上都能扫到。但你猜真正的问题是什么?我在PaymentStrategyConfig的构造方法里直接使用List 注入,和@Configuration配合没问题,问题出在我同时在strategyMap的put操作里调用了getChannel(),而这个方法在构造阶段依赖的某个配置属性还没初始化完成。
解决方法也很简单,把初始化从构造方法改为@PostConstruct,或者改用ApplicationContextAware的afterPropertiesSet回调。这让我意识到,Spring Bean的初始化顺序是个总被忽视的坑,构造方法里能别做复杂操作就别做。我把这个point写进了DAY22的踩坑记录里,顺手复习了一遍Spring Bean的生命周期。
5.2 状态机迁移丢事件的问题
联调阶段发现一个诡异问题:订单从已支付申请退款后,状态变成了已退款,但并没有调用渠道的退款接口。追了很久发现是我在测试数据里手动把订单状态改成了退款中,而代码里"申请退款"这个事件执行时,会先检查当前状态是否为已支付,只有已支付才能发起退款。可我手改的状态是退款中,等于跳过了前置校验,直接进入了一个非法状态。状态机表里"退款中"状态下并没有定义"申请退款"这个事件,按逻辑应该抛异常才对,但实际却执行成功了。
问题根源在于我的service层调用状态机之前,还自己写了一段状态判断的代码,这段判断和状态机里表的规则不一致,出现了两套标准。后来我把所有状态变更的入口全部收敛到状态机的统一方法里,删掉了service层手动判断状态的代码,这类问题就再没出现过。这个教训很重要:状态机最忌讳的是一处规则多处实现,必须保证全系统只有状态机引擎这一个地方能改状态值,不然迟早会出线上事故。
5.3 金额精度与浮点运算的坑
写余额支付策略时,我一开始用的是double类型存金额,测试时发现余额从100块扣了33.33再充进66.67,最后余额变成了100.00000000000001。这就是经典的浮点数二进制表示误差。虽然线上订单表最终存库用的是Decimal类型,但代码里一旦用double走了一圈,误差就可能带进别的计算逻辑。我在DAY22的学习笔记里特别强调:涉及金额一律用BigDecimal,且所有运算走String参数的构造器或valueOf,不要直接new BigDecimal(double)。
还有一个小坑是BigDecimal的equals和compareTo的区别,equals会比较精度,所以new BigDecimal("1.0")不等于new BigDecimal("1.00"),而compareTo只比较数值,这两者搞混了会在金额比较时出现莫名其妙的false。当时测试用例里就因为这个踩了一脚。测试代码里金额断言一律用compareTo,再结合setScale统一小数位,这个问题就没再犯过。
6. 针对DAY22学习的复盘与后续优化方向
把整个实战完整跑通后,我做了一次系统性的复盘,发现虽然整体思路是对的,但代码的组织还有几处可以优化。比如策略工厂里我只是暂存了策略Map,后面应该结合Spring的@Qualifier做更灵活的命名注入;状态机的映射目前是硬编码在内存里,虽然不推荐放数据库,但可以考虑用枚举表驱动的方式进一步消除重复代码。
另外我发现策略模式和状态机的组合其实可以继续往深挖:支付的"发起支付"和"支付回调"本质上也是两个不同的事件,它们分别触发不同的状态迁移,但都依赖同一个渠道策略。如果后续要接新的聚合支付平台,比如PayPal或者国际信用卡渠道,策略实现类的增长会让工厂方法越来越长。到时候可以考虑把策略注册改为Spring的自动装配,通过自定义注解+BeanPostProcessor来收集策略,代码会更加优雅。
我在DAY22的回家路上还想到一个点子:给每个策略实现类加一个getRetryTimes()方法,表示该渠道在支付超时时的自动重试次数。这样不同的渠道可以根据自身接口特点设置不同的重试策略,微信可以重试3次,余额支付则完全不重试,因为它一旦扣款成功就不存在超时问题。这样把策略模式的使用场景又扩大了一层。
关于学习的方法论,DAY22给我的最大触动是"输出倒逼输入"。前21天我都是被动地看视频、做笔记、敲示例代码,看似很努力,其实都是舒适区里的重复。今天通过这个综合实战,我被迫去查了Spring源码里List注入的实现原理、BigDecimal的底层存储方式、数据库乐观锁的生效条件——这些都是以前知道一点但从没深究的东西。如果你也在做类似的学习计划,我强烈建议把"第22天"这样的时间点用来做一次综合实战或项目复盘,而不是继续一本正经地学新章节。只有当你需要用到那些分散的知识并把它们组装起来时,那些知识才真正从"知道"变成"掌握"。
我个人的习惯是每10天一个周期总结一次,DAY22正好踩在这个节奏上。下一步我打算把状态机这部分封装成一个小工具包,腾出更多精力研究订单超时取消的延迟队列实现,到时再继续更新学习日记。