ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

策略模式+Spring注册表:如何优雅消除500行if-else

策略模式+Spring注册表:如何优雅消除500行if-else 如果你也接手过一个整天被if-else支配的模块你一定懂那种头皮发麻的感觉每次加需求都像在雷区里走路改一行代码要反复确认走的是哪个分支测试用例排到下周都排不完。我上个月就经历了这么一回接手的支付回调模块里一个方法整整500行全是if-else从订单创建到退款成功、从拼团活动到优惠券核销凡是你能想到的状态变更都堆在同一个方法里。当时我一个人加班到深夜看着屏幕上一排排花括号脑子里只有一个念头这玩意真的还有救吗后来我用两周时间把它重写完了。整个核心分发逻辑最后浓缩下来就十行左右的核心代码其他逻辑全部拆到了各自的处理器里。同事review完说的那句话我现在还记得这代码终于能看了。这篇我不打算讲什么高深架构就把我踩过的坑、怎么选的方案、每一步怎么落地的原原本本复盘一遍。如果你们项目里也有这种if-else重灾区希望能给你一点动手的思路。1. 那段让人想当场离职的500行if-else长什么样先别急着看解决方案我们得先搞清楚为什么if-else会变成500行我在重构前把那段代码从头到尾读了三遍总结下来它的罪恶不是单纯的代码量大而是所有分支的决策、执行、异常处理全都耦合在一起就像是把一整套城市交通系统全塞进了一个路口。那段代码的主要任务是根据消息类型做分发。消息类型大概有订单创建、订单支付、订单关闭、物流发货、用户注册、用户注销、优惠券发放、优惠券过期、活动开始提醒这几种。每种类型进来之后都要走校验参数→查库拿上下文→按业务规则处理→记录处理日志→返回结果这套逻辑。但由于没有分层这些步骤全被一个巨型方法包着每个分支里又嵌套了三到五层内部if用来处理各种边界情况。比如订单支付那里它会先判断订单存不存在再判断订单状态是不是待支付再判断支付金额对不对还要判断用户有没有被风控命中。每一步都是if任何一个判断不通过就抛异常或者直接return。你想想光是订单支付这一个分支可能就占了几十行。这种写法最可怕的地方在哪不是它写着累而是改起来非常容易出事。我复盘了一下之前线上出现过的一个P0事故就是因为某位同事在订单关闭这个分支里加了新的判断结果他用的变量名跟前面订单支付分支里的重了Java又没有块级作用域的强约束意识直接覆盖了状态导致一批待支付订单被误关。这种bug当时排查了两个通宵最后发现就是if-else太长上下文太难追踪了。还有一个隐性成本测试代码根本写不下去。你想给这个方法写单元测试得先构造一个能直接传入的Object然后祈祷它内部每个if条件都能命中你想要的路径。如果其中某个判断依赖前置状态你得把这个状态一路准备好。一套流程跑下来光写测试就花了三天这还只是一个分支的覆盖率。其实做了几年开发的人多多少少都见过这种上帝方法。大家讨厌它不是讨厌判断本身而是讨厌它违反了单一职责、开闭原则而且可读性几乎为零。所以在动手重构之前我先做了一件事把每个分支的业务逻辑从头到尾梳理清楚画了一份消息类型→处理动作→异常分支→扩展点的对应表。有了这份表后面的拆解才有了依据不然凭感觉乱拆拆完更容易出问题。2. 为什么我选了策略模式注册表而不是查表法或责任链网上讲干掉if-else的文章很多方案也五花八门。最常见的有三种卫语句提前返回、查表法Map映射、策略模式。我当时没直接照抄别人的方案而是结合手上的具体场景做了取舍。先说查表法。它的思路是直接建立一个HashMapkey是消息类型value是执行逻辑。很多文章里写的10行代码干掉if-else就是这么干的确实简单粗暴。但对我的场景来说它有个致命问题每种消息类型的业务逻辑至少二三十行如果全塞进lambda表达式里Map虽然变短了但Map里的每个value又会变成几百行的lambda等于换个地方堆垃圾可读性并没有真正提升。而且lambda里没法直接用Spring注入的service除非在注册阶段就手动拉取很麻烦。再说责任链。这个模式适合多个处理器按顺序尝试谁匹配谁执行的场景。比如风控引擎里各种规则的串联。但我的场景里消息类型之间是互斥的——一条消息只可能属于一个类型不存在上一个处理器不处理就传给下一个的需求硬套责任链反而增加了调用链的复杂度。所以最终我选了策略模式Spring自动收集注册表的组合。每个消息类型对应一个独立的Handler类这些类实现同一个接口注册工作交给Spring容器自动完成。核心分发器只保留一个Map和一个get方法。这样做有几个好处第一每个Handler独立成类符合单一职责。动订单支付的逻辑你只需要打开OrderPaidHandler不需要在500行里上下翻找。第二新增消息类型不用改分发器。只需要新建一个Handler类并加上Spring注解容器启动时会自动把它注册进Map。这正好满足开闭原则——对扩展开放对修改关闭。第三测试难度直线下降。每个Handler可以单独写单测不需要为了一条路径把整个方法上下文都准备齐全。当然这个方案不是没有成本。它最大的成本在于类数量会增加。一个模块如果有二十种消息类型就会多二十个类文件。有些团队会觉得类太多反而臃肿这就要自己掂量了。我当时的判断标准很简单如果每个分支的业务逻辑超过十行而且将来大概率还会继续加类型那策略模式就值如果每个分支就一两行一个Map搞定绝不拆类。3. 十行核心代码背后的设计接口、注册、分发到底怎么配合标题说10行代码干掉了500行if-else我得先坦白一下这里的10行指的是核心分发逻辑的体量不是整个模块的全部代码。要是整个模块真的只有10行那业务逻辑去哪了肯定不能凭空消失。真正优雅的设计不是消灭逻辑而是把逻辑拆到该待的地方让主干代码变得极短、极其稳定。我先说一下这套结构的三层设计你就明白10行是怎么来的了。第一层是处理器接口。它约定了一个Handler必须具备的能力我这里的Handler主要包含两个信息支持哪种消息类型、具体怎么处理。public interface MessageHandler { // 返回该处理器支持的消息类型 String type(); // 核心处理方法 void handle(Message message); }第二层是具体处理器。比如订单支付处理器Component public class OrderPaidHandler implements MessageHandler { Override public String type() { return order.paid; } Override public void handle(Message message) { // 校验参数 // 查询订单 // 修改状态 // 记录日志 } }第三层是分发器。它的任务就两个字注册和分发。Spring在启动时会把所有的MessageHandler实现类收集到一个List里分发器拿到这个List后遍历组装成Map。分发的核心代码就是Component public class MessageDispatcher { private final MapString, MessageHandler handlerMap new HashMap(); public MessageDispatcher(ListMessageHandler handlers) { for (MessageHandler handler : handlers) { handlerMap.put(handler.type(), handler); } } public void dispatch(String type, Message message) { MessageHandler handler handlerMap.get(type); if (handler null) { throw new IllegalArgumentException(不支持的消息类型: type); } handler.handle(message); } }你去数一下真正干活的逻辑一个for循环负责组装Map一个get从Map里拿处理器一个if判断有没有一个handle调用。核心真的就是十行级别。原来的500行去哪了都进到各个Handler的业务方法里了。但这些Handler再也不是堆在一起让人窒息的大杂烩而是各自独立、互不干扰的单元。有些同学可能会问为什么要把注册逻辑放在构造方法里而不是直接用PostConstruct因为Spring的构造器注入天然保证List里所有的bean都已经实例化完成不需要等生命周期回调更安全。这也是我踩过一次坑之后学到的——用PostConstruct去注册如果某个Handler的构造器里有循环依赖可能启动阶段就炸了排查起来很头痛。还有一个细节上面的代码里handlerMap.get(type)如果返回null我选择的是抛异常。为什么不用默认处理器因为在我看来出现未知消息类型说明有上游传了脏数据这是需要立刻暴露的问题不能被默认逻辑掩盖。这个决策逻辑也属于我在重构前梳理出来的规则之一。4. 从500行到10行一次订单状态消息处理的完整还原光讲概念还是飘我把这次的完整还原过程拆成七步每一步都交代清楚意图和落地方式你们可以直接照着这个思路套用在自己项目里。4.1 梳理类型清单和处理动作动手之前我把原来500行里涉及的所有消息类型列了一份清单。这份清单不是简单罗列字符串而是标出了每个类型对应的处理入口、内部分支、异常情况和扩展点。比如order.created这个类型它内部有15个分支包括判断商品库存、扣减库存、设置超时自动关单等等。整理完这份清单我对整个模块的复杂度有了精确的掌握。这一步是我觉得最该花时间的环节。很多人一上来就写接口、拆类拆到一半发现某个分支依赖其他分支的局部变量被迫把参数传来传去最后拆得四不像。先做业务梳理后面就是执行层面的事。4.2 定义Handler的顶层抽象根据清单我抽象了统一的处理器接口。当时我犹豫过一个问题接口里要不要加一个boolean supports(String type)方法让每个Handler自己判断能不能处理后来否了。因为我的场景里类型是严格互斥的不需要模糊匹配直接用type()返回精确类型更清晰。如果你面临的是这个处理器既能处理A也能处理B的场景那supports()方法更合适。这里没有标准答案只有场景适配。4.3 按类型拆分Handler类这一步就是机械劳动了把原来每个分支里的代码原封不动搬进对应的Handler里。我特别强调原封不动这四个字是因为重构最忌讳顺手改业务逻辑。你这次的目标是换结构不是动行为。如果结构换完行为也跟着变了出了问题你根本分不清是结构问题还是逻辑问题。每个Handler内部可以有自己的私有方法做子步骤拆分但对外只暴露handle这一个入口。搬的过程中如果发现原来代码里有属性透传的情况——比如外层方法先查了个订单对象然后if里的代码直接用这个对象——这时候不要硬拆可以把查询动作下沉到Handler内部让Handler自己搞定前置依赖。4.4 构建Dispatch分发器分发器代码就是第三层设计里贴的那样。这里有一个当时让我犹豫过的点handlerMap到底用HashMap还是ConcurrentHashMap。我后来选了HashMap因为Map在容器启动阶段就构建完毕之后只读不写不存在并发修改的问题。但如果你有运行时动态注册处理器的需求比如通过配置中心热加载某个规则那就得换ConcurrentHashMap。分发器的对外API我做了简化只暴露dispatch(type, message)。原来的调用方方法签名基本不用变只是把内部500行的if-else换成了这一行调用。外部感知不到重构这很重要。4.5 用Spring自动收集替代手动new这一块是让10行代码成立的关键。如果没有Spring的自动收集你就得在分发器里手动new一堆Handler那注册那部分代码又会变成几十行。所以我把所有Handler都标注了Component然后在分发器的构造参数里声明ListMessageHandler handlersSpring会自动把所有实现类注入进来。这里有个小坑刚入门的同事容易踩Spring注入List时它的顺序是不保证的。如果业务上需要按顺序处理可以在接口上定义getOrder()方法或者在Handler上用Order注解控制。我的场景里没有顺序依赖所以没有处理这个问题。4.6 补一个启动时的类型冲突校验Map的key如果出现重复后注册的Handler会覆盖先注册的这个问题在启动时不会暴露一直要等运行时调到了才发现。我为了把风险提前拦截在注册循环里加了一个判断MessageHandler old handlerMap.put(handler.type(), handler); if (old ! null) { throw new IllegalStateException(重复的消息类型: handler.type()); }别看这是三行不起眼的代码它把类型冲突从运行时错误变成了启动时错误。改配置的人当天就能发现问题不用等线上被用户投诉了才排查。4.7 写完跑一次全量回归结构拆完了不等于重构结束了。我把原来500行方法的单元测试、集成测试全部跑了一遍重点看几个地方各消息类型的处理结果是否有变化、异常路径是否抛出同样的异常信息、日志打印顺序是否一致。跑完这轮测试心里那块石头才算真正落地。5. 重构之后加一个新消息类型到底有多轻松这部分我想直接展示一下重构带来的日用品体验变化因为代码结构这种东西光看当下是不够的要看你接下来三个月怎么维护。以前加一个新消息类型我要经历这么一串心理活动打开那个500行的大方法找到最后一个if-else复制一个分支改类型判断把业务逻辑塞进去再担心有没有跟之前的变量重名。如果测试要覆盖新分支还得在看不懂完整上下文的情况下构造前置条件。整个过程至少个把小时而且每次都提心吊胆。重构之后加一个新类型只需要三步第一步新建一个类实现MessageHandler接口加上Component注解。 第二步写type()返回新消息的类型字符串。 第三步在handle()里边写这个类型的专属业务逻辑。不用改分发器一行代码不用动任何老Handler更不用担心变量覆盖。为了验证这套流程是真的爽我在重构完的第二周专门模拟加了一个退款成功提醒的类型从新建类到单测通过全程大概二十分钟其中大部分时间花在业务规则确认上。这里我觉得可以套用开闭原则的说法系统对扩展是开放的对修改是关闭的。老代码一个字不用动新功能照样加得稳稳的。这种体验一旦习惯了再让你回去改那种巨型if-else你会有种由俭入奢易由奢入俭难的抗拒感。不过我也得泼盆冷水这套方案不是万能解药。如果你的消息类型总共就两三个而且业务逻辑加起来不到二十行硬拆成六七个类那反而是过度设计类爆炸比if-else更让人头疼。我见过有人把根据状态返回一个状态名称这种查表就能解决的事情强行搞了六个策略类加一个工厂类beancount都快赶上功能复杂度了。什么时候该拆什么时候不该拆我心里有一条经验线一个分支的代码超过十行且分支数量超过五个且未来大概率会继续增加分支这三个条件至少满足两个的时候策略模式才值得上。如果不满足老老实实用Map卫语句成本更低。6. 想删掉if-else前必须先想清楚的三个问题很多文章只会告诉你if-else很low快用策略模式但不会告诉你哪些场景用了策略模式反而更糟糕。这里我把这次实践中总结的三个关键问题列出来你们在动手前先拿自己项目过一遍。6.1 这些判断条件是类型还是取值范围策略模式和Map注册表天然适合类型互斥的场景订单状态、消息类型、事件类型、渠道类型。但如果你的判断是基于数值范围的比如金额小于100走A逻辑100到500走B逻辑大于500走C逻辑策略模式就很别扭因为你没法用固定的字符串当key。硬要套的话你得在每个Handler里都写一个范围判断然后用supports()轮询本质上就是换汤不换药if-else还是那个if-else。碰到范围判断我更推荐查表法配合区间定义或者直接保留if-else——只要分支不多、条件清晰if-else本身不是罪。6.2 分支之间有没有共享状态和顺序依赖我最开始梳理业务清单时发现原来的500行里有一类隐式顺序依赖某几个分支的执行结果会影响外层方法的局部变量而外层方法后来又依赖这个变量决定是否执行另一个分支。这种情况如果不加处理就拆Handler每个Handler拿到的都是孤立上下文根本复现不了原逻辑。我的做法是在消息对象里封装一个上下文对象Handler之间不直接共享变量而是通过上下文传递必要的数据。这样做其实把隐式依赖变成了显式依赖虽然设计上多了一层但比拆完逻辑对不上要好得多。6.3 新增类型的频率有多高如果你这个模块一年到头都不会加一个新类型那策略模式带来的收益其实是打折的。因为策略模式真正的红利在未来的扩展体验而不是当下的代码好看。当下代码就算有500行if-else只要它稳定不变化、没有bug你其实没有动力去动它。重构有风险收益要算清楚。我自己动手前也问过自己这个模块接下来半年要不要继续加消息类型答案是肯定的——产品那边已经排了好几个新通知场景。这才让我下定了决心。7. 同事在Code Review上对我说了什么重构完成、测试跑通、合入主干之后我把代码发到群里的Code Review频道当时已经做好被挑刺的准备。没想到同事的反馈比我想象中顺利整体就一个争议点。有位后端同事建议我把handlerMap.get(type)换成handlers.stream().filter(...).findFirst()理由是代码更函数式。我直接表示了不同意见get是O(1)的哈希查询stream是O(n)的遍历虽然消息类型不会多到影响性能但既然map已经建出来了为什么不用函数式风格不是目的可读性和性能才是。还有一位同事问了个很实际的问题如果某个Handler处理失败了事务怎么处理这确实是拆分之后需要重新考虑的问题。原来500行里所有分支是共享同一个事务边界的方法入口开启事务方法结束提交。拆到不同Handler后每个Handler自己决定事务边界稳定性反而更好了——一个类型处理失败不会影响其他类型的事务状态。我后来在分发器入口加了一个事务模板让整条分发还是在一个事务里避免Handler内部再开事务导致嵌套传播的复杂性。Code Review通过后另一位同事说了句让我挺有成就感的话这个新架构让我第一次觉得加消息类型不用先给自己做半小时心理建设了。8. 重构之后这一个月我实际体会到的三个变化代码合入差不多一个月了这段时间里产品又加了两个新的消息类型我特意观察了一下这套设计的实际体验跟你分享三个变化。第一个变化是排查问题的时间变短了。以前线上出问题我要在那个巨型方法里从头到尾捋一遍经常要翻到两百行才能定位到具体分支。现在看报错日志里的类名就知道是哪个Handler出了问题直接打开那个类看定位速度感觉快了一倍都不止。第二个变化是新人对模块的上手成本明显降低。有个刚入职的同事第一次接触这个模块他自己看了一遍Handler列表就大概理解了整个结构——哦每个类型一个类想看哪个业务直接找对应类就行。这在以前是不可能的500行if-else让新人至少得摸爬滚打一周才能动手改需求。第三个变化是我终于敢理直气壮地写单元测试了。每个Handler都是独立类直接new出来就能测mock掉依赖的service即可。测试的覆盖率也直观了二十个类型就有二十个测试类哪个类型没测到一目了然。以前呢一个500行方法想测全二十个分支你除了绝望没有别的感受。9. 最后再分享两个重构过程中的小技巧第一个技巧重构前先拍照。我说的拍照是把原来的方法签名、异常抛出情况、日志格式、返回值约定全部记下来。最好用git建一个分支所有改动都留在上面不要混着其他需求一起提交。一旦发现重构后行为和原版有差异可以随时比对。我这轮重构总共提交了六次每次提交都只做一件事搬哪几个类型、新增哪个测试。这样回滚和定位问题都特别清晰。第二个技巧先跑行为对比测试再删老代码。我重构完后没有立刻把老方法删掉而是写了一个临时对比测试同一个消息类型分别喂给老方法和新分发器比对处理结果和数据库记录是否一致。确认了两天数据完全一致才把老代码彻底清掉。这个过程多花了点时间但换来的是重构上线当晚睡了个安稳觉。老实说干掉500行if-else本身算不上什么技术奇观但这个过程让我明白了一件事代码烂不烂不在于它用了什么语句而在于它有没有清晰的结构边界。if-else不是原罪把一堆职责强行揉在一起才是原罪。策略模式也不是银弹它不过是逼着你在动手写代码之前先想清楚每个分支到底属于谁、边界在哪里。如果你的项目里也有一坨看得脑壳疼的if-else我建议你先别急着抄代码按这个思路梳理一遍自己的业务拆出来之后你也会觉得原来代码可以这么清爽。
返回列表