目录
1.概述
1.1 什么是面向对象编程和面向对象编程语言?
面向对象编程的英文缩写为OOP,面向对象编程语言的英文缩写为OOPL。这两个概念可以用下面两句话来概括:
- 面向对象编程是一种编程范式或编程风格。它以类或对象为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石。
- 面向对象编程语言是支持类或对象的语法机制,能方便地实现面向对象编程四大特性(抽象、封装、继承、多态)的编程语言。
一般来讲,面向对象编程都是通过使用面向对象编程语言来实现的,但是,不使用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即使使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也可能是面向过程编程风格的。一般来讲,面向对象软件开发要经历三个阶段:面向对象分析(OOA)、面向对象设计(OOD)、面向对象编程(OOP)。
1.2 什么是面向过程编程与面向过程编程语言?
类比面向对象编程与面向对象编程语言的定义,对于面向过程编程和面向过程编程语言这两个概念,给出如下定义:
- 面向过程编程也是一种编程范式或编程风格,它也过程(可以理解为方法、函数、操作)作为组织代码的基本单位,以数据(可以理解为成员变量、属性)与方法相分离为主要特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。
- 面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如多态、继承、封装),仅支持面向过程编程。
总结来看,面向过程和面向对象最基本的区别是代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构,方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定在一起,定义在类中。
面向对象编程相比面向过程编程有如下优势:
- OOP更加能够应对大规模复杂程序的开发。
- OOP风格的代码更易复用,易扩展,易维护。
- OOP语言更加人性化,更高级,更智能。
2.哪些代码设计看似是面向对象,实际是面向过程的?
在用面向对象编程语言进行软件开发的时候,我们有时候会写出面向过程风格的代码。有些是有意为之,并无不妥,有些是无意为之,会影响到代码的质量。下面通过三个例子来看一下,什么样的代码看似是面向对象风格,实际是面向过程风格的。
2.1 滥用getter和setter方法
在我们的项目开发中,经常看到有同事定义完类的属性之后,就顺手把这些属性的getter和setter方法都定义上。他们的理由一般是,为了以后可能会用到,现在事先定义好,而且即便用不到这些getter、setter方法,定义上它们也无伤大雅。实际上,这种做法违反了面向对象编程的封装性,相当于将面向对象编程风格退化成了面向过程编程风格。比如下面的代码:
public class ShoppingCart {
private int itemsCount;
private double totalPrice;
private List<ShoppingCartItem> items = new ArrayList<>();
public int getItemsCount() {
return this.itemsCount;
}
public void setItemsCount(int itemsCount) {
this.itemsCount = itemsCount;
}
public double getTotalPrice() {
return this.totalPrice;
}
public void setTotalPrice(double totalPrice) {
this.totalPrice = totalPrice;
}
public List<ShoppingCartItem> getItems() {
return this.items;
}
public void addItem(ShoppingCartItem item) {
items.add(item);
itemsCount++;
totalPrice += item.getPrice();
}
// ...省略其他方法...
}
在这段代码中,ShoppingCart是一个简化后的购物车类。itemsCount和totalPrice,虽然将它们定义成private私有属性,但是提供了public的getter、setter方法,这就跟将这两个属性定义成public公有属性,没有什么两样了。外部可以通过setter方法随意地修改这两个属性的值,这就会导致其跟items属性的值不一致。而面向对象封装的定义是:通过访问权限控制,隐藏内部数据,外部仅能通过类提供的有限的接口访问、修改内部数据。所以,暴露不应该暴露的setter方法,明显违反了面向对象的封装特性,代码就退化成了面向过程编程风格了。
接着,我们再来看一下items这个属性,它的getter方法,返回的是一个List集合容器。外部调用者在拿到这个容器之后,是可以操作容器内部数据的,也就是说,是可以修改items中的数据。例如:
ShoppingCart cart = new ShoppCart();
...
cart.getItems().clear(); // 清空购物车
在我们看来,清空购物车这样的功能需求看起来是合情合理的,上面的代码没有什么不妥。但是,这样的代码写法,会导致itemsCount和totalPrice、items三者数据不一致。我们不应该将清空购物车的业务逻辑暴露给上层代码。正确的写法应该是,ShoppingCart类中定义一个clear()方法,将清空购物车的业务逻辑封装在里面,透明地给调用者使用。clear()方法的具体实现代码如下:
public class ShoppingCart {
// ...省略其他代码...
public void clear() {
items.clear();
itemsCount = 0;
totalPrice = 0.0;
}
}
你可能还会说,我有一个需求是要查看购物车中都买了啥,此时ShoppingCart类不得不提供items属性的getter方法了,那该怎么办呢?我们可以通过Java提供的Collections.unmodifiableList()方法,让getter方法返回一个不可被修改的UnmodifiableList集合容器,而这个容器重写了List容器中跟修改数据相关的方法,比如add(),clear()等方法。一旦我们调用了这些修改数据的方法,代码就会抛出UnsupportedOperationException异常,这样就避免了容器中的数据被修改。代码如下:
public class ShoppingCart {
// ...省略其他代码...
public List<ShoppingCartItem> getItems() {
return Collections.unmodifiableList(this.items);
}
}
public class UnmodifiableList<E> extends UnmodifiableCollection<E>
implements List<E> {
public boolean add(E e) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
// ...省略其他代码...
}
ShoppingCart cart = new ShoppingCart();
List<ShoppingCartItem> items = cart.getItems();
items.clear();//抛出UnsupportedOperationException异常
总结来看,在设计实现类的时候,除非真的需要,否则,尽量不要给属性定义setter()方法。除此之外,尽管getter方法相对setter方法要安全一些,但是如果返回的是集合容器,也要防止集合内部数据被修改的危险。
2.2 滥用全局变量和全局方法
在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。Constants类和Utils类是我们最常用的全局变量和全局方法。首先,让我们看一段Constants类的代码:
public class Constants {
public static final String MYSQL_ADDR_KEY = "mysql_addr";
public static final String MYSQL_DB_NAME_KEY = "db_name";
public static final String MYSQL_USERNAME_KEY = "mysql_username";
public static final String MYSQL_PASSWORD_KEY = "mysql_password";
public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234";
public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
public static final int REDIS_DEFAULT_MAX_IDLE = 50;
public static final int REDIS_DEFAULT_MIN_IDLE = 20;
public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:";
// ...省略更多的常量定义...
}
这段代码中,我们把程序中所有用到的常量都集中到Constants类中。实际上,定义一个如此大而全的Constants类,并不是一个很好的设计思路,原因如下:
- 影响代码的可维护性:如果参与项目的开发人员很多,在开发过程中,可能都要涉及修改这个类,这个类就会变得越来越大,查找修改某个常量就变得比较费时,还会增加提交代码冲突的概率。
- 增加代码的编译时间:当Constants类中包含很多常量定义时,依赖这个类的代码就会很多。那每次修改Constants类,都会导致依赖它的类文件重新编译,因此会浪费很多不必要的编译时间。在我们的开发中,每次运行单元测试,都会触发一次编译的过程,这个编译时间就有可能会影响到我们的开发效率。
那应该如何改进Constants类的设计呢?有两种思路可以借鉴:
- 将Constants类拆解为功能单一的多个类,比如跟MySQL配置相关的,就放置到MySqlConstants类中。
- 尽量减少使用Constants,如果某个类用到了常量,我们就把这个常量定义到这个类中。比如,RedisConfig类用到了Redis配置相关的常量,那我们就直接将这些常量定义到RedisConfig中,这样也会提高类设计的内聚性和代码的复用性。
讲完了Constants类,我们再讨论一下Utils类。实际上,只包含静态方法不包含任何属性的Utils类,是彻彻底底的面向过程的编程风格。但是,Utils类存在的目的是解决代码复用的问题,它在软件开发中还是很有价值的。所以,这里并不是说完全不能用Utils类,而是说要避免滥用,不要不假思索地随意去定义Utils类。
2.3 定义数据和方法分离的类
在后端开发中,代码结构一般分为Controller层、Service层、Respository层。相应地,我们又会定义VO、BO、Entity数据模型。一般来说,VO、BO、Entity中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的Controller类、Service类、Respository类中。这就是典型的面向过程的编程风格。
实际上,这种开发模式叫做基于贫血模型的MVC三层架构开发模式,也是我们常见的一种Web项目的开发模式。虽然这种开发模式已经成为标准的Web项目开发模式,但它却违反了面向对象编程风格,是一种彻彻底底的面向过程的编程风格,因此有些人称为反模式。特别是在领域驱动设计(DDD)盛行之后,这种基于贫血模型的传统开发模式就更加被人诟病了。而基于充血模型的DDD开发模式越来越被人提倡。
2.3.1 贫血模型
目前几乎所有的业务后端系统,都是基于贫血模型的。例如下面的代码:
// Controller+VO(View Object) //
public class UserController {
private UserService userService; //通过构造函数或者IOC框架注入
public UserVo getUserById(Long userId) {
UserBo userBo = userService.getUserById(userId);
UserVo userVo = [...convert userBo to userVo...];
return userVo;
}
}
public class UserVo {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
// Service+BO(Business Object) //
public class UserService {
private UserRepository userRepository; //通过构造函数或者IOC框架注入
public UserBo getUserById(Long userId) {
UserEntity userEntity = userRepository.getUserById(userId);
UserBo userBo = [...convert userEntity to userBo...];
return userBo;
}
}
public class UserBo {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
// Repository+Entity //
public class UserRepository {
public UserEntity getUserById(Long userId) { //... }
}
public class UserEntity {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}
我们平时开发Web后端项目的时候,基本上都是这么组织代码的。其中,UserEntity和UserRepository组成了数据访问层,UserBo和UserService组成了业务逻辑层,UserVo和UserController在这里属于接口层。从代码中,可以看到,UserBo是一个纯粹的数据结构,只包含数据,不包含任何业务逻辑。像UserBO这种只包含数据,不包含业务逻辑的类,就叫做贫血模型。
2.3.2 充血模型
在贫血模型中,数据和业务逻辑被分割到不同的类中。充血模型正好相反,数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足对象的封装特性,是典型的面向对象编程风格。
在基于充血模型的DDD开发模式中,Service层包含Service类和Domain类两部分,Domain就相当于贫血模型中的BO。不过,Domain与BO的区别在于它是基于充血模型开发的,即包含数据,也包含业务逻辑,相应的Service类会变的非常单薄。此时的Service类主要是有下面这几个职责:
- Service类负责与Repository交流,获取数据库中的数据并转化成领域模型,然后由领域模型来完成业务逻辑,最后将数据存回数据库。
- Service类负责一些非功能性及与第三方系统交互的工作,比如幂等,事务,记录日志,调用其他接口等。
尽管Service层被改造成了充血模型,但是Controller层和Repository层还是贫血模型,是否有必要进行充血模型建模呢?答案是没必要的,Controller层主要负责接口的暴露,Repository层主要负责与数据库打交道,这两层的业务逻辑并不多,比较简单,没必要做充血模型。
那我们在什么时候应该考虑基于充血模型的DDD开发模式,什么时候考虑基于贫血模型的传统开发模式?
如果我们开发的系统业务比较简单,简单到就是基于SQL的CRUD操作,根本不需要精心设计充血模型,此时我们应该考虑使用贫血模型。如果我们的业务非常复杂,比如包含各种利息计算模型,还款模型等复杂业务的金融系统,此时我们应该考虑充血模型。