
1. 为什么说“忘记”而不是“不会”从一次Code Review说起1.1 一次Code ReviewJava代码写成存储过程上周做代码评审看到一个Service类的瞬间我有点恍惚。类名叫OrderService里面塞了整整几十个if-else从参数校验、优惠计算、库存判断到状态更新全部以“流水账”形式铺开。中间还穿插着大量OrderDTO字段的setter调用以及一个叫OrderUtil的静态工具类。代码大概是这种感觉public String createOrder(OrderDTO dto) { if (dto.getAmount() 0) { throw new IllegalArgumentException(金额非法); } if (dto.getUserId() null) { throw new IllegalArgumentException(用户不能为空); } if (dto.getOrderType() 1) { dto.setDiscount(dto.getAmount() * 0.1); } else if (dto.getOrderType() 2) { dto.setDiscount(dto.getAmount() * 0.2); } else { dto.setDiscount(0d); } // 继续几十行状态判断与字段搬运... orderMapper.insert(dtoToPo(dto)); return 创建成功; }这不是段子是真实项目里的常规操作。表面上它能跑但你要是追问“订单在什么状态可以取消”“优惠规则以后会不会变”“一个订单到底该不该知道自己能不能取消”代码里完全没有答案。实体类基本只有getter/setter所有业务规则都被“外包”给了Service和Util。我开玩笑说这哪是Java这是用Java语法写的存储过程。对方苦笑“最近赶上线面向对象那套先放一放能跑就行。”这句回答我琢磨了很久因为“能跑就行”背后藏着一个更普遍的现象Java开发者偶尔会彻底忘记面向对象编程。1.2 面试能答出封装继承多态代码却看不到对象最让我觉得有意思的是这位同学面试时肯定能流畅背出面向对象三大特性封装、继承、多态。但看他的日常代码你会以为他写的是C语言加了一层class语法糖。那问题出在哪儿我觉得不是“会不会”而是“信不信”。很多人对OOP的理解停留在语言机制层面比如知道private是封装知道extends是继承知道重写是运行时多态。但这些知识没有转化成身体记忆没有变成编码时的一种本能反应。一旦代码里出现DTO、Mapper、Controller这些框架层他们下意识就会回到数据处理的老路把对象当容器把方法当函数把业务当一串if-else。这不是技术能力问题而是思维惯性的问题。如果你平时主要靠刷题、背面试题、照着开源项目模板写业务你很难真正体会“对象自己管理自己的状态”是什么样的设计体验。这个“偶尔忘记”并不是偶然它在某种开发环境里几乎是必然。2. 把OOP挤走的不是大脑是项目环境里的这三样东西2.1 Spring Boot MyBatis项目里的脚本化惯性现在做Java后端最常见的组合就是Spring Boot MyBatis。你看一个典型业务流程的代码路径Controller接收DTOService调用MapperMapper执行SQL然后把POJO映射成Java对象返回。这套链路用起来非常顺但它有一个隐藏副作用你其实在“搬运数据”。我见过不少开源多商户跨境商城类的项目源码Service层往往是这个画风public void savePayment(PaymentDTO dto) { PaymentPO po new PaymentPO(); po.setOrderId(dto.getOrderId()); po.setAmount(dto.getAmount()); po.setStatus(1); paymentMapper.insert(po); OrderPO order orderMapper.selectById(dto.getOrderId()); order.setPayStatus(2); if (order.getAmount().compareTo(dto.getAmount()) ! 0) { throw new BusinessException(金额不一致); } // 后面还可以继续写30行... }你会发现开发者不是在设计对象而是在写“数据库操作的脚本”。对象只是数据库表结构的投影没有行为、没有约束、没有组合关系。MyBatis强化了“表驱动一切”的印象而Spring Boot又让模板化代码变得太容易复制。你甚至不需要知道对象为什么存在只要顺着Controller - Service - Mapper的惯性写下去项目就能跑。这种环境待久了你不需要刻意忘记OOP它自然会被“工程模板”挤走。因为你每一次写新功能身边人都这么写模板也这么定评审也看不出问题。于是“实体就是表Service就是脚本”就成了团队里的默认事实。2.2 算法题和库函数练出来的数据处理脑还有一个容易被忽略的来源刷题。不管是蓝桥杯还是各种算法题库大家训练的都是“输入数据 - 处理数据 - 输出结果”的思维模式。我现在看到很多关于Java的搜索词都是“常用库函数algorithm java”“java排序”“冒泡排序java”这类说明大量开发者是从算法题开始认识Java的。这类训练当然有价值但它带来的副作用是你会习惯性把程序理解为“对数据的操作序列”。比如排序、遍历、过滤、聚合这些动作天然是过程式的。类在这种处理里只是装数据的口袋你真正关心的是算法本身。一旦把这种思维带到业务代码里就很容易出现下面的情况先从数据库把订单列表查出来放到ListOrder里然后写一个循环判断状态再用另一个循环计算金额最后用一个静态方法汇总结果。整个流程里Order对象没有任何发言权它只是被动的数据结构。算法思维训练的是“怎么处理数据”OOP思维训练的是“谁能对自己负责”。两者不矛盾但在Java开发者的职业早期前者很容易占据上风。因为算法题有明确的输入输出能快速获得反馈而面向对象的建模没有标准答案甚至短期看不出收益。于是等到写业务系统时那个“数据处理脑”就自动接管了。2.3 性能优化当借口static和重复循环很顺手第三种挤压OOP的力量更隐蔽它藏在“性能优化”这四个字后面。我听过很多类似的理由“这个方法写成static可以减少对象分配”“对象太多影响GC”“在这段循环里创建对象太浪费”。听起来很有道理但大多数时候真实瓶颈根本不在Java堆里而在数据库查询、网络IO、缓存设计上。更现实的是你为了“优化”把业务逻辑写进静态方法结果代码很难测试也很难替换。举个例子有人会把订单金额计算写成一个静态方法public class PriceCalculator { public static BigDecimal calculate(OrderDTO dto, UserDTO user, CouponDTO coupon) { // 各种if-else和字段getter return money; } }表面上看这避免了每次调用都创建一个Calculator对象挺“省钱”。但你想想如果优惠规则变复杂了呢如果优惠分成满减、折扣、限时立减多种类型呢你就要在这个静态方法里继续堆flag或者让调用方自己去判断再传参。这时候对象协作的优势完全失效每次改需求你都要去翻那个“函数”的内部改一大片逻辑。我只在明确验证过热点之后才会接受这类的“性能优化”。业务代码里过早的static化往往不是优化而是给后续维护挖坑。把性能当借口写过一段过程式代码后你会发现自己早就忘记了OOP答应过你的那件事让变化点尽量局部化让新增需求有清晰的位置可以安放。3. 忘记OOP的真正原因我按出现频率排了一个序3.1 失血模型成为团队默认我参与过的Java项目里最常见的状态是“失血模型”实体类只负责映射数据库字段最多加几个方便JSON序列化的注解业务逻辑全部放在Service层。为什么会这样因为这是绝大多数框架教程和开源项目展示的标准姿势CRUD就是围绕数据表的增删改查领域对象没有任何存在的必要。“失血”不是说代码错而是业务行为不在它应该在的地方。你打开一个Order类看到的是订单号、金额、状态、时间你想知道“取消订单要满足什么条件”得去OrderService里找你还想知道“订单创建之后能不能改金额”又得去另一个Service里翻。时间长了对象就退化成只有getter/setter的数据包Service越来越膨胀。最可怕的是后来者会模仿。新加入团队的开发看到老代码全是贫血模型就会以为这就是项目规范。没人会主动在实体上写业务方法因为那看起来“不像这个项目的风格”。于是“忘掉OOP”从个人行为变成了团队惯性你不是偶尔忘记你是在维护一种默认状态。3.2 工具类被当成了逻辑垃圾场你大概率见过这种类public final class OrderUtil { public static boolean canCancel(OrderPO order) { if (order.getStatus() 1) { return true; } if (order.getStatus() 2 order.getPayTime() ! null) { return true; } return false; } }叫OrderUtil里面放的是订单业务规则。这就是典型的“逻辑垃圾场”。工具类本身没有错像字符串空判断、日期格式化、集合转换这些无状态的纯函数放在工具类里很合理。但一旦工具类开始接收业务对象、判断业务状态、计算业务金额它就变成了一个“没有对象外壳的Service方法”。为什么会这样因为写工具类非常省事不需要思考对象职责不需要理解聚合边界只要从A类和B类都能调用同一个static方法就行。这种“复用”看起来很香其实是把业务逻辑从实体的保护伞下偷了出来暴露给所有调用方。等你要改“取消条件”的时候你会发现有多处代码都在直接引用这个Util或者更糟有人为了避开这个Util自己重新写了一套判断逻辑。判断一个工具类是否越界有一个很简单的标准把static去掉看看它像不像某个实体实例的方法。比如canCancel(OrderPO order)去掉static后几乎就是order.canCancel()。这种情况下逻辑就应该往对象里搬而不是继续堆在Util里。3.3 八股文带来“我懂OOP”的幻觉说到Java开发者的学习路径很难绕开“面试八股”。接口和抽象类区别是什么重载和重写的区别是什么String为什么是不可变的这些题目本身没什么问题但它们容易让人产生一种感觉我已经掌握了面向对象编程。现实是你能说清楚final和immutable的区别不代表你在设计领域模型时知道怎么划分边界你能背出SOLID原则的每一个词不代表你能把一段涨了500行的Service拆成若干行为清晰的对象。面试题考的是语言特性和设计原则的名词解释而实际开发考验的是你在具体业务里分配职责的能力。这种幻觉最直接的结果是很多人把“会用interface”等同于“面向对象设计”。于是代码里出现了一堆只有一个实现的接口或者为了“解耦”强行加了一层泛型工厂。表面看设计模式用了很多看本质还是过程式思维。对象只是一层壳壳里面依旧是各种getter/setter加上if-else。真正的OOP不是背出来的而是“攒”出来的。你需要反复经历过用对象建模带来的可维护性提升经历过需求变更时只改一个类的轻松感才能内化成直觉。只靠背题永远停留在“名词知道实战不会”的状态。3.4 工期和低频维护下的主动选择还有一类情况我觉得要说句公道话某些场景下过程式写法是合理的主动选择。比如一次性数据迁移脚本、定时任务、算法验证demo、只跑一次的报告导出这些地方强行引入领域模型反而笨重。你要的本来就是“从头到尾按顺序执行”用对象封装只会碍手碍脚。问题在于很多项目原本是长生命周期业务系统却被开发者当成了“一次性脚本”来写。我自己也干过这种事为了赶版本上线把一个本该由领域对象自我管理的状态逻辑全部写在了Service的一个个循环里。上线那几天确实快但后来每次改需求都要小心翼翼地在那么一大段过程式代码里定位改动一处还得检查另外几处有没有跟不上的状态。所以我的态度是忘记OOP不可怕可怕的是你不知道自己正在做出取舍。你是在“主动选择”过程式写法还是“被动忘记”了面向对象这两者的差别决定了你后续面对技术债时的态度。前者会明确告诉团队“这块是临时逻辑后续要重构”后者只会一边加班改bug一边感慨项目怎么这么难维护。4. 把OOP捡回来的五个小动作附可落地示例4.1 写工具类之前先问一句“这个行为应该属于谁”我给自己定了条规矩凡是形如xxxUtil.isValid(xxx)、xxxUtil.calculate(xxx)这类静态方法写之前先停下来想一想这个行为的“主人”是谁。比如你有这样一个判断public static boolean isValidOrderNo(String orderNo) { return orderNo ! null orderNo.matches(^ORD\\d{12}$); }更好的位置其实是OrderNo值对象内部public class OrderNo { private final String value; public OrderNo(String value) { if (value null || !value.matches(^ORD\\d{12}$)) { throw new IllegalArgumentException(订单号格式不正确); } this.value value; } }这样订单号从诞生那一刻就保证是合法的而不是让每个使用点自己去调用工具类校验。类似的“现在该不该做”、字符串是否包含数字字母这一类判断只要它和某个业务概念强相关就应该先进对象里去问“你满足条件吗”而不是问工具类“你帮我看看这个对象满不满足条件”。4.2 状态变化让对象自己管Service不要直接setStatus我经常用来示范“OOP忘记前后差异”的例子是订单取消逻辑。贫血风格的代码如下public void cancel(Long orderId) { Order order orderMapper.selectById(orderId); if (order.getStatus() ! OrderStatus.PAID) { throw new IllegalStateException(只有已支付订单可以取消); } order.setStatus(OrderStatus.CANCELED); orderMapper.update(order); }看起来没什么大问题但一旦订单状态流转复杂起来Service里的“状态非法判断”会越来越多。你可以试着把判断交给订单自己public class Order { private Long id; private OrderStatus status; // 构造器、getter 省略 public void cancel() { if (this.status ! OrderStatus.PAID) { throw new IllegalStateException(只有已支付订单可以取消); } this.status OrderStatus.CANCELED; } }Service变成这样public void cancel(Long orderId) { Order order orderMapper.selectById(orderId); order.cancel(); orderMapper.update(order); }这个小小的变化带来的好处是以后如果新增状态“待支付”取消规则跟着变你只需要改Order.cancel()这一处。所有调用cancel的地方自动获得新规则。这就是OOP最朴实的好处——把变化收敛到该变化的地方。4.3 多态替换大if-else但别让设计模式喧宾夺主如果再往下走一步你会发现很多大if-else其实可以用多态来拆。比如不同促销类型的折扣计算public BigDecimal calcDiscount(OrderDTO dto) { if (FULL_REDUCTION.equals(dto.getPromotionType())) { // 满减计算 } else if (DISCOUNT.equals(dto.getPromotionType())) { // 折扣计算 } else if (CASH_COUPON.equals(dto.getPromotionType())) { // 代金券计算 } // 以后每新增一个类型这里就多一段分支 }用多态的方式是定义一个接口然后让每种促销策略自己实现计算public interface PromotionStrategy { BigDecimal calcDiscount(OrderDTO dto); } public class FullReductionStrategy implements PromotionStrategy { Override public BigDecimal calcDiscount(OrderDTO dto) { ... } }但我必须说一句不要见着if-else就想着套策略模式。分支只有两个、扩展频率极低的时候强行抽象只会让复杂度抬高。我见过最尴尬的代码是一个只判断“是否是VIP”的场景硬生生塞了三个接口、五个实现类。这种“为了面向对象而对象”的做法和忘记OOP一样让人难受。我的判断标准是如果这个分支大概率会持续扩展而且每个分支内部逻辑差异明显那可以考虑多态如果只是两三个固定选项保持简单反而更好。4.4 持久化放外层业务表达放内层很多Java开发者一用ORM就习惯性让实体继承基类、加满注解结果实体被框架绑死。这也不能全怪框架是你把“持久化模型”和“领域模型”混为一谈。更务实的做法是核心业务对象里不放MyBatis注解让它保持纯净持久化通过仓储接口隔离。你可以在业务对象层面定义自己的行为然后把数据库映射交给另一层public class Order { // 不依赖任何 ORM 注解 public void cancel() { ... } public void confirm() { ... } }Service只依赖仓储接口public interface OrderRepository { Order findById(Long id); void save(Order order); }MyBatis或Spring Data的实现类去负责POJO和SQL的转换事务边界放在Service外层。这样即便你还在用MyBatis业务对象依然能拥有自己的行为。换句话说OOP和持久化框架并不冲突只需要给它们划清边界。4.5 把“OOP是否退化”写进Code Review清单一个人容易忘那就让机制帮大家记着。我建议团队在Code Review清单里加上几条硬指标新增业务规则是在对象内部方法里还是散落在Service/Util里是否出现大量连续setter调用后跟着一段业务判断业务状态流转是否由对象自己控制工具类里有没有接收业务对象并做状态判断的静态方法这不需要什么高大上的工具。只要评审人在review时多问一句“这个判断为什么放在这里”很多退化都能在早期被发现。慢慢你会发现OOP不是谁的天赋而是一种需要团队共同维护的设计习惯。5. 常见问题与排查实录什么信号说明你正在忘记OOP5.1 需求一改到处飞不知道逻辑放哪我经常收到类似疑问“改一个活动规则为什么好几个Service都要动”这往往是OOP后退的典型症状。需求本身可能只涉及“订单金额怎么算”但因为金额计算逻辑被复制成了多个静态方法散落在OrderUtil、PayService、PromotionService里你只能挨个排查。遇到这种情况我的排查过程一般是先搜关键业务名词在所有代码里的出现位置看看逻辑到底有几个“家”然后找出改动频率最高的那个逻辑把它收拢到唯一一个业务对象内部以后新增需求只在这个对象里扩展。症状可能原因优先检查项改进方向一个需求改了5个文件业务逻辑散落在多个类搜索核心业务名词的出现位置收敛到唯一领域对象Service里有大量setter实体不管理自己的状态看实体里有没有业务方法给实体补充行为方法工具类方法超过30行工具类变成了逻辑垃圾场看方法参数是否有业务对象把方法移入对象内部新需求加一个if-else缺少多态抽象看分支是否高频扩展必要时引入策略模式5.2 全是贫血模型的项目怎么安全迈出第一步我也清楚不是说回归OOP就能立刻回归。老项目里实体全是贫血模型几百个Service互相调用你总不能一夜之间全重写。务实的做法是挑一个业务价值最大、改动最频繁的聚合根下手。比如电商系统里的订单它状态多、约束多、最容易出问题。先给Order增加cancel()、confirm()这样的行为方法再在Service里把原来的setStatus调用替换为新方法。每一步都结合单测跑一遍确保行为没变。我试过在这个思路下重构一个模块只花了两天时间就把原来几十处订单状态判断收敛到了实体内部。效果立刻体现后来修改取消规则真的只改了一个地方。要先从容易出错的点开始不要贪多求全。5.3 性能与OOP打架时我的取舍方式有人会担心让对象自己管理状态如果状态字段被多个线程共享性能是不是会下降这其实是另一个维度的问题。并发安全由锁和事务控制而不是由“去掉对象方法”来解决。你完全可以在对象方法内部加同步控制或使用不可变对象。真到了性能瓶颈我会遵循“先量后改”原则用Profiler找到热点确认是哪段循环、哪个对象分配耗时间再有针对性地优化。常见情况是前10%的优化就解决了90%的问题业务建模上的OOP结构极少是瓶颈本身。换个角度说如果一段代码真的被调用百万次static方法可能比实例方法快一点但它带来的维护成本你也要买单。我的取舍是核心业务逻辑优先保证结构清晰性能优化作为后续手段在局部进行而不是一开始就用“性能”当借口把对象拆掉。6. 最后想说的OOP是一种视野不是考点我见过太多Java开发者包括我自己早年的样子写了好几年class却始终在用C语言的方式思考。后来真正对OOP有感觉不是通过哪本书而是因为一段被改吐了的历史代码。当一次需求变更需要改七个地方我才意识到面向对象不是语法是一种让变化变得可预测的能力。我个人建议你下次写代码时多问自己一句如果这个业务规则要变我要改几个文件答案是“一个”那你的对象基本走对了路答案是“五六个”大概率你又在用数据搬运的思路写业务。OOP从来不需要你写得多花哨它只需要你承认业务行为天然有归属你要做的是把它放回该放的地方。偶尔忘记没关系关键是忘记之后要能察觉。等你开始在Code Review里主动说“这个判断应该让实体自己回答”的时候你就真的把面向对象捡回来了。