设计模式之美学习(二十二)

本文最后更新于:6 个月前

  • 为什么要分 MVC 三层来开发?
  • 为什么要针对每层定义不同的数据对象?

业务开发包括哪些工作?

平时做业务系统的设计与开发,无外乎有这样三方面的工作要做:接口设计、数据库设计和业务模型设计(也就是业务逻辑)。

数据库和接口的设计非常重要,一旦设计好并投入使用之后,这两部分都不能轻易改动。改动数据库表结构,需要涉及数据的迁移和适配;改动接口,需要推动接口的使用者作相应的代码修改。这两种情况,即便是微小的改动,执行起来都会非常麻烦。因此,我们在设计接口和数据库的时候,一定要多花点心思和时间,切不可过于随意。相反,业务逻辑代码侧重内部实现,不涉及被外部依赖的接口,也不包含持久化的数据,所以对改动的容忍性更大。

从代码实现角度来说,大部分业务系统的开发都可以分为 ControllerServiceRepository 三层。Controller 层负责接口暴露,Repository 层负责数据读写,Service 层负责核心业务逻辑,也就是业务模型。

除此之外,前面还提到两种开发模式,基于贫血模型的传统开发模式和基于充血模型的 DDD 开发模式。前者是一种面向过程的编程风格,后者是一种面向对象的编程风格。不管是 DDD 还是 OOP,高级开发模式的存在一般都是为了应对复杂系统,应对系统的复杂性。

为什么要分 MVC 三层开发?

刚刚提到,大部分业务系统的开发都可以分为三层:Contoller 层、Service 层、Repository 层。对于这种分层方式,我相信大部分人都很认同,甚至成为了一种开发习惯,但你有没有想过,为什么我们要分层开发?很多业务都比较简单,一层代码搞定所有的数据读取、业务逻辑、接口暴露不好吗?

对于这个问题,总结了以下几点原因。

1. 分层能起到代码复用的作用

同一个 Repository 可能会被多个 Service 来调用,同一个 Service 可能会被多个 Controller 调用。比如,UserService 中的 getUserById() 接口封装了通过 ID 获取用户信息的逻辑,这部分逻辑可能会被 UserControllerAdminController 等多个 Controller 使用。如果没有 Service 层,每个 Controller 都要重复实现这部分逻辑,显然会违反 DRY 原则。

2. 分层能起到隔离变化的作用

分层体现了一种抽象和封装的设计思想。比如,Repository 层封装了对数据库访问的操作,提供了抽象的数据访问接口。基于接口而非实现编程的设计思想,Service 层使用 Repository 层提供的接口,并不关心其底层依赖的是哪种具体的数据库。当我们需要替换数据库的时候,比如从 MySQLOracle,从 OracleRedis,只需要改动 Repository 层的代码,Service 层的代码完全不需要修改。

除此之外,ControllerServiceRepository 三层代码的稳定程度不同、引起变化的原因不同,所以分成三层来组织代码,能有效地隔离变化。比如,Repository 层基于数据库表,而数据库表改动的可能性很小,所以 Repository 层的代码最稳定,而 Controller 层提供适配给外部使用的接口,代码经常会变动。分层之后,Controller 层中代码的频繁改动并不会影响到稳定的 Repository 层。

3. 分层能起到隔离关注点的作用

Repository 层只关注数据的读写。Service 层只关注业务逻辑,不关注数据的来源。Controller 层只关注与外界打交道,数据校验、封装、格式转换,并不关心业务逻辑。三层之间的关注点不同,分层之后,职责分明,更加符合单一职责原则,代码的内聚性更好。

4. 分层能提高代码的可测试性

单元测试不依赖不可控的外部组件,比如数据库。分层之后,Repsitory 层的代码通过依赖注入的方式供 Service 层使用,当要测试包含核心业务逻辑的 Service 层代码的时候,我们可以用 mock 的数据源替代真实的数据库,注入到 Service 层代码中。

5. 分层能应对系统的复杂性

所有的代码都放到一个类中,那这个类的代码就会因为需求的迭代而无限膨胀。当一个类或一个函数的代码过多之后,可读性、可维护性就会变差。那我们就要想办法拆分。拆分有垂直和水平两个方向。水平方向基于业务来做拆分,就是模块化;垂直方向基于流程来做拆分,就是这里说的分层。

还是那句话,不管是分层、模块化,还是 OOPDDD,以及各种设计模式、原则和思想,都是为了应对复杂系统,应对系统的复杂性。对于简单系统来说,其实是发挥不了作用的,就是俗话说的“杀鸡焉用牛刀”。

BO、VO、Entity 存在的意义是什么?

针对 ControllerServiceRepository 三层,每层都会定义相应的数据对象,它们分别是 VOView Object)、BOBusiness Object)、Entity,例如 UserVoUserBoUserEntity。在实际的开发中,VOBOEntity 可能存在大量的重复字段,甚至三者包含的字段完全一样。在开发的过程中,经常需要重复定义三个几乎一样的类,显然是一种重复劳动。

相对于每层定义各自的数据对象来说,是不是定义一个公共的数据对象更好些呢?

实际上,更加推荐每层都定义各自的数据对象这种设计思路,主要有以下 3 个方面的原因。

  • VOBOEntity 并非完全一样。比如,我们可以在 UserEntityUserBo 中定义 Password 字段,但显然不能在 UserVo 中定义 Password 字段,否则就会将用户的密码暴露出去。

  • VOBOEntity 三个类虽然代码重复,但功能语义不重复,从职责上讲是不一样的。所以,也并不能算违背 DRY 原则。在前面讲到 DRY 原则的时候,针对这种情况,如果合并为同一个类,那也会存在后期因为需求的变化而需要再拆分的问题。

  • 为了尽量减少每层之间的耦合,把职责边界划分明确,每层都会维护自己的数据对象,层与层之间通过接口交互。数据从下一层传递到上一层的时候,将下一层的数据对象转化成上一层的数据对象,再继续处理。虽然这样的设计稍微有些繁琐,每层都需要定义各自的数据对象,需要做数据对象之间的转化,但是分层清晰。对于非常大的项目来说,结构清晰是第一位的!

既然 VOBOEntity 不能合并,那如何解决代码重复的问题呢?

继承可以解决代码重复问题。我们可以将公共的字段定义在父类中,让 VOBOEntity 都继承这个父类,各自只定义特有的字段。因为这里的继承层次很浅,也不复杂,所以使用继承并不会影响代码的可读性和可维护性。后期如果因为业务的需要,有些字段需要从父类移动到子类,或者从子类提取到父类,代码改起来也并不复杂。

前面在讲“多用组合,少用继承”设计思想的时候提到,组合也可以解决代码重复的问题,所以,这里我们还可以将公共的字段抽取到公共的类中,VOBOEntity 通过组合关系来复用这个类的代码。

代码重复问题解决了,那不同分层之间的数据对象该如何互相转化呢?

最简单的转化方式是手动复制。自己写代码在两个对象之间,一个字段一个字段的赋值。但这样的做法显然是没有技术含量的低级劳动。Java 中提供了多种数据对象转化工具,比如 BeanUtilsDozer 等,可以大大简化繁琐的对象转化工作。如果你是用其他编程语言来做开发,也可以借鉴 Java 这些工具类的设计思路,自己在项目中实现对象转化工具类。

VOBOEntity 都是基于贫血模型的,而且为了兼容框架或开发库(比如 MyBatisDozerBeanUtils),我们还需要定义每个字段的 set 方法。这些都违背 OOP 的封装特性,会导致数据被随意修改。那到底该怎么办好呢?

EntityVO 的生命周期是有限的,都仅限在本层范围内。而对应的 Repository 层和 Controller 层也都不包含太多业务逻辑,所以也不会有太多代码随意修改数据,即便设计成贫血、定义每个字段的 set 方法,相对来说也是安全的。

不过,Service 层包含比较多的业务逻辑代码,所以 BO 就存在被任意修改的风险了。但是,设计的问题本身就没有最优解,只有权衡。为了使用方便,我们只能做一些妥协,放弃 BO 的封装特性,由程序员自己来负责这些数据对象的不被错误使用。

重点回顾

1. 为什么要分 MVC 三层开发?

对于这个问题,总结了以下 5 点原因:

  • 分层能起到代码复用的作用
  • 分层能起到隔离变化的作用
  • 分层能起到隔离关注点的作用
  • 分层能提高代码的可测试性
  • 分层能应对系统的复杂性

2. BOVOEntity 存在的意义是什么?

从设计的角度来说,VOBOEntity 的设计思路并不违反 DRY 原则,为了分层清晰、减少耦合,多维护几个类的成本也并不是不能接受的。但是,如果你真的有代码洁癖,对于代码重复的问题,我们可以通过继承或者组合来解决。

如何进行数据对象之间的转化?最简单的方式就是手动复制。当然,你也可以使用 Java 中提供了数据对象转化工具,比如 BeanUtilsDozer 等,可以大大简化繁琐的对象转化工作。

尽管 VOBOEntity 的设计违背 OOP 的封装特性,有被随意修改的风险。但 EntityVO 的生命周期是有限的,都仅限在本层范围内,相对来说是安全的。Service 层包含比较多的业务逻辑代码,所以 BO 就存在被任意修改的风险了。为了使用方便,我们只能做一些妥协,放弃 BO 的封装特性,由程序员自己来负责这些数据对象的不被错误使用。

3. 总结用到的设计原则和思想

从表面上看,做业务开发可能并不是特别有技术挑战,但是实际上,如果你要做到知其然知其所以然,做到透彻理解、真的懂,并不是件容易的事情。深挖一下,你会发现这其中还是蕴含了很多设计原则、思想和模式的。

参考:实战一(下):如何实现一个遵从设计原则的积分兑换系统?