Java开发三大怪

简介: Java开发三大怪

看了许多企业级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的特性,故而将其抢了过来,心眼实在太坏!

image.png

这一问题同样违背了迪米特法则。该法则要求:对象不要和陌生对象之间进行通信,也就是常说的“不要和陌生人说话”。如果一个对象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的陌生对象。image.png

因此,当前的实现违背了迪米特法则的设计,它对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的案例也错误地演示了这一做法,诞生如下图所示的代码结构:

image.png

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:

image.png

然后选择“Extract Interface”,挑选需要提取到接口中的方法即可。

image.png

如果原本的类名本身就比较抽象,更适合作为接口的名称,可选择“Rename original class and use interface where possible”选项。它会将当前类名当做接口类型的名称,然后要求你输入更为具体的类名。根据“use interface where possible”的语义,IDE会帮助你检测其他用到当前类的地方,将其改为使用抽象的接口类型。

编码实现时,不要做多余的抽象,这符合“简单设计”原则。拜托大家不要再给无需扩展的类装上一个小头,不仅奇怪,而且冗余,除非贵公司按照代码行的多少给你发奖金。

相关文章
|
6天前
|
存储 Java 数据库连接
使用Java开发桌面应用程序
使用Java开发桌面应用程序
15 0
|
6天前
|
Java API 开发工具
java与Android开发入门指南
java与Android开发入门指南
14 0
|
6天前
|
分布式计算 负载均衡 Java
构建高可用性Java应用:介绍分布式系统设计与开发
构建高可用性Java应用:介绍分布式系统设计与开发
11 0
|
6天前
|
前端开发 安全 Java
使用Spring框架加速Java开发
使用Spring框架加速Java开发
52 0
|
6天前
|
前端开发 JavaScript Java
Java与Web开发的结合:JSP与Servlet
Java与Web开发的结合:JSP与Servlet
10 0
|
6天前
|
设计模式 算法 Java
设计模式在Java开发中的应用
设计模式在Java开发中的应用
18 0
|
6天前
|
监控 Java Maven
揭秘Java Agent技术:解锁Java工具开发的新境界
作为JDK提供的关键机制,Java Agent技术不仅为Java工具的开发者提供了一个强大的框架,还为性能监控、故障诊断和动态代码修改等领域带来了革命性的变革。本文旨在全面解析Java Agent技术的应用场景以及实现方式,特别是静态加载模式和动态加载模式这两种关键模式。
34 0
|
7天前
|
存储 Java 开发者
探索Java开发中触发空指针异常的场景
作为一名后端开发者在Java编程的世界中,想必大家对空指针并不陌生,空指针异常是一种常见而又令人头疼的问题,它可能会在我们最不经意的时候突然出现,给我们的代码带来困扰,甚至导致系统的不稳定性,而且最可怕的是有时候不能及时定位到它的具体位置。针对这个问题,我们需要深入了解触发空指针异常的代码场景,并寻找有效的方法来识别和处理这些异常情况,而且我觉得空指针异常是每个Java开发者都可能面临的挑战,但只要我们深入了解它的触发场景,并采取适当的预防和处理措施,我们就能够更好地应对这个问题。那么本文就来分享一下实际开发中一些常见的触发空指针异常的代码场景,并分享如何有效地识别和处理这些异常情况。
21 1
探索Java开发中触发空指针异常的场景
|
7天前
|
传感器 人工智能 前端开发
JAVA语言VUE2+Spring boot+MySQL开发的智慧校园系统源码(电子班牌可人脸识别)Saas 模式
智慧校园电子班牌,坐落于班级的门口,适合于各类型学校的场景应用,班级学校日常内容更新可由班级自行管理,也可由学校统一管理。让我们一起看看,电子班牌有哪些功能呢?
98 4
JAVA语言VUE2+Spring boot+MySQL开发的智慧校园系统源码(电子班牌可人脸识别)Saas 模式
|
15天前
|
Java 索引
Java String应用与开发
Java String应用与开发
22 0