看了许多企业级Java项目的源代码,发现许多Java程序员都在用Java这门面向对象语言行过程式开发之事,且对此乐此不疲,毫不自觉。本文并非比较过程式设计与面向对象设计之优劣,而是反对挂羊头卖狗肉,希望将Java开发拉回到面向对象的轨道上。
要做到这一点,只需规避Java开发三大怪即可。
第一怪隐私暴露,嫉妒他人心眼坏
Java语言规定了访问修饰符,目的在于隐藏无需公开的细节。其中,字段作为一个对象拥有的数据,往往需要隐藏起来,定义为私有字段乃是标准操作。如果外部调用者需要操作对象的数据,可以通过对外公开的get和set访问器进行读写。——但是,这并不意味着一个私有的字段一定需要对应公开的get和set。
定义Java类时,要从对象拟人化角度思考,结合业务场景,将对象拥有的数据视为一种“隐私”。既然是隐私,自然不能随便暴露。
隐私既是自身数据的保护,又能减少不必要的依赖。当我们在调用一个类的get或set访问器时,先问问自己:操作这些数据的行为究竟该交给调用者,而是交给拥有这些数据的对象?
以如下代码为例:
public class ComponentService { public boolean publish(ComponentReview review) { Component component = detailById(review.getId()); component.setStatus(ComStatus.PUBLISH_SUCESS.getCode()); component.setVersion(review.getVersion()); component.setPublishTime(new Date()); // ... return true; } }
- 就应该问问ComponentService,为何需要调用Component的这些set访问器呢?
如果将数据当做信息,则可推导出信息专家模式:信息的持有者即为操作该信息的专家。简单说来,它就是通常所说面向对象设计的第一原则——数据与行为应该封装在一起。
当一个对象调用另一个对象的get或set访问器时,产生的协作模式是将另一个对象当做数据的提供者。这并非不允许,如果当前业务场景就是要获得数据,这是合理的。
必须明确,良好的协作模式应该形成对象之间的行为协作。如果我们将上述代码这几个set访问器的调用转移到Component,情况就完全不同了:
public class Component { public void publish(String version) { setStatus(ComStatus.PUBLISH_SUCESS.getCode()); setVersion(version); setPublishTime(new Date()); } } public class ComponentService { public boolean publish(ComponentReview review) { Component component = detailById(review.getId()); component.publish(review.getVersion); // ... return true; } }
此时的ComponentService与Component之间就属于行为间的协作。
倘若对这一改进不以为然,则可以设想Component的发布逻辑存在多个调用者时,情况会怎么样?
为什么在我们的业务代码中总会出现Martin Fowler所说的“贫血模型”,原因就在于此。这一做法同时也是Martin Fowler在《重构》一书中定义的“特性依恋(feature envy)”坏味道,具体描述为“函数对某个类的兴趣高过对自己所处类的兴趣”。用上述代码阐述,就是ComponentService的publish方法对Component的兴趣更大,它嫉妒Component的特性,故而将其抢了过来,心眼实在太坏!
这一问题同样违背了迪米特法则。该法则要求:对象不要和陌生对象之间进行通信,也就是常说的“不要和陌生人说话”。如果一个对象B对于对象A而言,不符合以下条件:
- B是A定义方法的参数
- B是A的属性
- B的示例由A创建
则将对象B视为A的陌生对象。看一个老生常谈的例子:
public class Cashier { public void charge(Customer myCustomer, float payment) { Wallet theWallet = myCustomer.getWallet(); if (theWallet.getTotalMoney() >= payment) { theWallet.subtractMoney(payment); } else { //money not enough } } }
Customer是Cashier方法charge()的参数,所以它们并非陌生对象;但是,Wallet既非Cashier方法的参数,也不是它的属性,更不由它创建,因此,Wallet就是Cashier的陌生对象。
因此,当前的实现违背了迪米特法则的设计,它对Cashier与Customer二者都不讨好:
- 对于Customer:Cashier要操作顾客的钱包,侵犯了顾客的隐私,违背了隐私法则
- 对于Cashier:Cashier要操作顾客的钱包,增加了Cashier的负担,违背了最小知识法则
由于Java社区开始广泛使用lombok框架,使得get和set访问器的滥用变本加厉。许多领域类都被调用者剥削,使得它们只剩下了数据的定义,却失去了对自己隐私的掌控权。责任当然不在于lombok框架的设计者。事实上,lombok已经告诉调用者,@Data注解说明:只有将一个类视为数据类时,才应该如此使用。然则,一个领域类应该作为数据类吗?
第二怪懒用实例,静态方法人人爱
静态方法用起来很方便,因为无需实例化即可调用。它的致命缺点是不可扩展,调用者与静态方法之间是紧耦合的。静态方法是代码可测试性的最大障碍,虽然可以使用PowerMock模拟静态方法,但一旦出现这一形式,已经说明代码不具备良好的可测试性。
静态方法是过程式代码的集结地。
为何要使用静态方法?如果一个类的方法都为静态方法,则说明这个类并无状态,它仅仅是诸多行为的一个载体。Martin Fowler将这样的静态方法实现称之为“事务脚本(transacation script)”,以形容它们的实现就像脚本一样,按照规定的过程顺序依次执行。
由于定义静态方法的类自身没有数据,就需要从另外的对象获取数据,就使得事务脚本与贫血模型成为天生一对。
如果程序员建立了贫血模型,则领域行为必然分配给另外一个类,使得贫血对象以数据提供者的身份参与对象之间的协作;调用者需要的数据既然都来自另一个对象,它就没有持有状态的必要,定义为静态方法就成为必然的选择了。
一旦将一个领域行为定义为静态方法,程序员就不去考虑如何封装数据与行为,更不会思考这些行为逻辑应该分配给哪些类。程序员只会思考,要实现这些逻辑需要哪些数据,形成数据驱动的开发模式。
例如作为一家承运商,需要确认一个运输委托。因为与运输相关,就会很自然地定义一个ShipmentServices类,并在其下定义静态方法confirmShipment()。
编写该静态方法时,首先会根据该业务功能梳理执行步骤,如:
- 确定承运商是否为当前承运商
- 获得运输路线的起始地址
- 获得承运货物清单
- 计算重量
- 获得运输方式
- 获取与当前承运商有关的运输凭证
- 完成确认
一旦梳理好了这些步骤,自然而然就会考虑这些步骤需要哪些数据,这些数据又可以从哪些数据表获得。于是,就会诞生如下所示的代码:
public class ShipmentServices { public static void confirmShipment(ShipmentRouteSegment routeSegment) { if (!"承运商编号".equals(routeSegment.getCarrierPartyId()) { throw new DomainException("Facility Shipment Not Route Segment Carrier", locale); } Address origionalAddress = origionalAddressDao.queryBy(routeSegment.getShipmentId()); Address destAddress = destAddressDao.queryBy(routeSegment.getShipmentId()); // 验证起止地址 List<ShipmentPackage> shipmentPackages = shipmentPackage.queryBy(routeSegment.getShipmentId()); if (shipmentPackages == null) { throw new DomainException("Facility Shipment Package Not Found", locale); } if (shipmentPackages.size() != 1) { throw new DomainException("Facility Shipment Multiple Packages Not Supported", locale)); } boolean hasBillingWeight = false; BigDecimal billingWeight = routeSegment.getBillingWeight(); String billingWeightUomId = routeSegment.getBillingWeightUomId(); // 以下略 } }
这样的代码就是事务脚本的实现方式。
业务功能的各个步骤被映射到代码中,平铺直叙,没有封装,也没有合理的职责分配。ShipmentServices就好似专注于运输的上帝类,它无所不知,成为业务的主控对象。
主控对象是中心,它所操作的对象都是数据的提供者。如果逻辑需要复用,它会毫不吝啬地将这些逻辑封装为另一个静态公开方法,仿佛殷勤的店家,开门迎客,不停地吆喝着:来调用吧,又方便又快捷,尽管调用。
然而,依赖就会由此产生。主控对象就像一个超强的磁力球,凡是经过它的对象,都被它吸住,并由此产生越来越强的磁性,最终形成一个没有空隙的大磁球。
静态方法虽然人人都爱,但它的正确用法只能用于工具类,或者作为静态工厂。除此之外,一定要慎用!
第三怪接口泛滥,类上长头小妖怪
许多人错误地理解了“面向接口编程”,以为定义的每个类必得定义一个对应的接口,方才满足该原则的要求。许多Spring的案例也错误地演示了这一做法,诞生如下图所示的代码结构:
Martin Fowler将这样的接口称之为“header interface”,看如下的代码,是否有一种AccountTransactionServiceImpl类上长了一个AccountTransactionService接口头的荒谬感呢?
public interface AccountTransactionService { Account creditToAccount(String userNo, BigDecimal amount, String requestNo, String bankTrxNo, String trxType, String remark) throws BizException; Account debitToAccount(String userNo, BigDecimal amount, String requestNo, String bankTrxNo, String trxType, String remark) throws BizException; Account freezeAmount(String userNo, BigDecimal freezeAmount) throws BizException; Account unFreezeAmount(String userNo, BigDecimal amount, String requestNo, String trxType, String remark) throws BizException; Account unFreezeSettAmount(String userNo, BigDecimal amount) throws BizException; void settCollectSuccess(String accountNo, String collectDate, int riskDay, BigDecimal totalAmount) throws BizException; } public class AccountTransactionServiceImpl implements AccountTransactionService { public Account creditToAccount(String userNo, BigDecimal amount, String requestNo, String bankTrxNo, String trxType, String remark) throws BizException {} public Account debitToAccount(String userNo, BigDecimal amount, String requestNo, String bankTrxNo, String trxType, String remark) throws BizException {} public Account freezeAmount(String userNo, BigDecimal freezeAmount) throws BizException {} public Account unFreezeAmount(String userNo, BigDecimal amount, String requestNo, String trxType, String remark) throws BizException {} public Account unFreezeSettAmount(String userNo, BigDecimal amount) throws BizException {} public void settCollectSuccess(String accountNo, String collectDate, int riskDay, BigDecimal totalAmount) throws BizException {} }
这样的实现,不知是不是C语言头文件的遗传?
必须明确,面向接口编程原则所谓的“接口”,并非Java的interface类型,而是设计者定义的一种交互标准,以此可形成调用双方都需遵循的契约。实际上,每个类的公开方法定义都可认为是接口。
如果程序员为每个类都定义一个接口,说明他/她并没有真正理解抽象接口的含义。我在之前的文章《面向接口设计与角色接口》中解释过什么是接口:
- 接口代表一种能力,例如在Java JDK中定义了很多这种接口,如Runnable, Cloneable, Seriazable。
- 接口代表业务场景中与其他类型协作的角色,从语法特性看,就是对履行职责的角色的抽象。
定义一个Java接口的目的在于应对扩展,如果每个接口只有一个实现类,又何须抽象呢?
试想想一个相对复杂的业务系统,承担业务职责的类恐怕不少于数百个。如果每个类都长一个接口头,类型数量就会翻一倍。这些接口只有一个实现类,抽象的意义何在?除非要使用RPC协议,如Dubbo,需要抽象的接口和实现完全分离;否则,抽象接口的定义就是多余的。
或许有人会说,倘若以后真的出现了扩展,该怎么办?很简单,重构啊!
以上述代码为例,如果交易的credit行为需要支持本行和跨行操作,完全可以在当前类的基础上提取一个新的接口,即运用重构手法Extract Interface:
然后选择“Extract Interface”,挑选需要提取到接口中的方法即可。
如果原本的类名本身就比较抽象,更适合作为接口的名称,可选择“Rename original class and use interface where possible”选项。它会将当前类名当做接口类型的名称,然后要求你输入更为具体的类名。根据“use interface where possible”的语义,IDE会帮助你检测其他用到当前类的地方,将其改为使用抽象的接口类型。
编码实现时,不要做多余的抽象,这符合“简单设计”原则。拜托大家不要再给无需扩展的类装上一个小头,不仅奇怪,而且冗余,除非贵公司按照代码行的多少给你发奖金。