CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:设计原则/24 | 实战一(下):如何实现一个遵从设计原则的积分兑换系统?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

16 KiB
Raw Blame History

上一节课中,我们讲了积分系统的需求分析和系统设计。今天,我们来讲它的代码实现。

上一节课中,我们把积分赚取和消费的渠道和规则的管理维护工作,划分到了上层系统中,所以,积分系统的功能变得非常简单。相应地,代码实现也比较简单。如果你有一定的项目开发经验,那实现这样一个系统,对你来说并不是件难事。

所以我们今天讲解的重点并不是教你如何来实现积分系统的每个功能、每个接口更不是教你如何编写SQL语句来增删改查数据而是给你展示一些更普适的开发思想。比如为什么要分MVC三层来开发为什么要针对每层定义不同的数据对象最后我还会总结这其中都蕴含哪些设计原则和思想让你知其然知其所以然做到真正地透彻理解。

话不多说,让我们正式开始今天的学习吧!

业务开发包括哪些工作?

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

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

针对积分系统,我们先来看,如何设计数据库。

数据库的设计比较简单。实际上,我们只需要一张记录积分流水明细的表就可以了。表中记录积分的赚取和消费流水。用户积分的各种统计数据,比如总积分、总可用积分等,都可以通过这张表来计算得到。

接下来,我们再来看,如何设计积分系统的接口。

接口设计要符合单一职责原则粒度越小通用性就越好。但是接口粒度太小也会带来一些问题。比如一个功能的实现要调用多个小接口一方面如果接口调用走网络特别是公网多次远程接口调用会影响性能另一方面本该在一个接口中完成的原子操作现在分拆成多个小接口来完成就可能会涉及分布式事务的数据一致性问题一个接口执行成功了但另一个接口执行失败了。所以为了兼顾易用性和性能我们可以借鉴facade外观设计模式在职责单一的细粒度接口之上再封装一层粗粒度的接口给外部使用。

对于积分系统来说,我们需要设计如下这样几个接口。

最后,我们来看业务模型的设计。

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

除此之外前面我们还提到两种开发模式基于贫血模型的传统开发模式和基于充血模型的DDD开发模式。前者是一种面向过程的编程风格后者是一种面向对象的编程风格。不管是DDD还是OOP高级开发模式的存在一般都是为了应对复杂系统应对系统的复杂性。对于我们要开发的积分系统来说因为业务相对比较简单所以选择简单的基于贫血模型的传统开发模式就足够了。

从开发的角度来说,我们可以把积分系统作为一个独立的项目,来独立开发,也可以跟其他业务代码(比如营销系统)放到同一个项目中进行开发。从运维的角度来说,我们可以将它跟其他业务一块部署,也可以作为一个微服务独立部署。具体选择哪种开发和部署方式,我们可以参考公司当前的技术架构来决定。

实际上,积分系统业务比较简单,代码量也不多,我更倾向于将它跟营销系统放到一个项目中开发部署。只要我们做好代码的模块化和解耦,让积分相关的业务代码跟其他业务代码之间边界清晰,没有太多耦合,后期如果需要将它拆分成独立的项目来开发部署,那也并不困难。

相信这样一个简单的业务功能的开发,对你来说并没有太大难度。所以,具体的代码实现我就不在专栏中给出了。感兴趣的话,你可以自己实现一下。接下来的内容,才是我们这一节的重点。

为什么要分MVC三层开发

我们刚刚提到,大部分业务系统的开发都可以分为三层:Contoller层、Service层、Repository层。对于这种分层方式我相信大部分人都很认同甚至成为了一种开发习惯但你有没有想过为什么我们要分层开发很多业务都比较简单一层代码搞定所有的数据读取、业务逻辑、接口暴露不好吗你可以把它作为一道面试题试着自己思考下然后再看我下面的讲解。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

在前面的章节中我们提到针对Controller、Service、Repository三层每层都会定义相应的数据对象它们分别是VOView Object、BOBusiness Object、Entity例如UserVo、UserBo、UserEntity。在实际的开发中VO、BO、Entity可能存在大量的重复字段甚至三者包含的字段完全一样。在开发的过程中我们经常需要重复定义三个几乎一样的类显然是一种重复劳动。

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

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

  • VO、BO、Entity并非完全一样。比如我们可以在UserEntity、UserBo中定义Password字段但显然不能在UserVo中定义Password字段否则就会将用户的密码暴露出去。
  • VO、BO、Entity三个类虽然代码重复但功能语义不重复从职责上讲是不一样的。所以也并不能算违背DRY原则。在前面讲到DRY原则的时候针对这种情况如果合并为同一个类那也会存在后期因为需求的变化而需要再拆分的问题。
  • 为了尽量减少每层之间的耦合,把职责边界划分明确,每层都会维护自己的数据对象,层与层之间通过接口交互。数据从下一层传递到上一层的时候,将下一层的数据对象转化成上一层的数据对象,再继续处理。虽然这样的设计稍微有些繁琐,每层都需要定义各自的数据对象,需要做数据对象之间的转化,但是分层清晰。对于非常大的项目来说,结构清晰是第一位的!

既然VO、BO、Entity不能合并那如何解决代码重复的问题呢

从设计的角度来说VO、BO、Entity的设计思路并不违反DRY原则为了分层清晰、减少耦合多维护几个类的成本也并不是不能接受的。但是如果你真的有代码洁癖对于代码重复的问题我们也有一些办法来解决。

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

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

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

当下一层的数据通过接口调用传递到上一层之后我们需要将它转化成上一层对应的数据对象类型。比如Service层从Repository层获取的Entity之后将其转化成BO再继续业务逻辑的处理。所以整个开发的过程会涉及“Entity到BO”和“BO到VO”这两种转化。

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

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

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

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

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

前面我们提到很多人做业务开发总感觉就是CRUD翻译代码根本用不到设计原则、思想和模式。实际上只是你没有发现而已。现在我就给你罗列一下今天讲解的内容中都用到了哪些设计原则、思想和模式。

实际上,这两节课中还蕴含了很多其他的设计思想、原则、模式,你可以像我一样试着去总结一下,放在留言区说一说。

重点回顾

今天的内容到此就讲完了。我们一块来总结回顾一下,你需要掌握的重点内容。

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

对于这个问题我总结了以下5点原因。

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

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

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

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

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

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

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

课堂讨论

  1. 上节课中我们讲到下层系统不要包含太多上层系统的业务信息。但在今天的数据库设计中积分明细表中credit_transaction中包含event_idchannel_id这些跟上层业务相关的字段那这样的设计是否合理呢
  2. 我们经常说修改和查询不要耦合在一个接口中要分成两个接口来做。赚取积分和消费积分接口返回积分明细ID这样的接口设计是否违背单一职责原则呢是不是返回void或者boolean类型更合理呢

欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。