实战一(下):如何实现一个遵从设计原则的积分兑换系统?
# 24 | 实战一(下):如何实现一个遵从设计原则的积分兑换系统?
上一节课中,我们讲了积分系统的需求分析和系统设计。今天,我们来讲它的代码实现。
上一节课中,我们把积分赚取和消费的渠道和规则的管理维护工作,划分到了上层系统中,所以,积分系统的功能变得非常简单。相应地,代码实现也比较简单。如果你有一定的项目开发经验,那实现这样一个系统,对你来说并不是件难事。
所以,我们今天讲解的重点,并不是教你如何来实现积分系统的每个功能、每个接口,更不是教你如何编写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三层,每层都会定义相应的数据对象,它们分别是VO(ViewObject)、BO(BusinessObject)、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.总结用到的设计原则和思想
从表面上看,做业务开发可能并不是特别有技术挑战,但是实际上,如果你要做到知其然知其所以然,做到透彻理解、真的懂,并不是件容易的事情。深挖一下,你会发现这其中还是蕴含了很多设计原则、思想和模式的。
# 课堂讨论
上节课中,我们讲到,下层系统不要包含太多上层系统的业务信息。但在今天的数据库设计中,积分明细表中 credit_transaction 中包含 event_id,channel_id 这些跟上层业务相关的字段,那这样的设计是否合理呢?
我们经常说,修改和查询不要耦合在一个接口中,要分成两个接口来做。赚取积分和消费积分接口返回积分明细 ID,这样的接口设计是否违背单一职责原则呢?是不是返回 void 或者 boolean 类型更合理呢?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。
# 精选评论
点击查看
感谢争哥分享。 个人觉得积分系统只有流水记录不太够,设想下消费积分场景,要完成扣积分的动作,没有积分余额表容易造成多扣,特别是针对羊毛党。当然上层系统,比如活动系统也可以做好幂等。 尝试回答下课后讨论 1.保留上层应用id和channel完全符合设计原理,冗余业务信息方便日后做数据统计,沉淀数据资产反推营销策略迭代;一次消费或赚取积分行为可能存在多次调用情况,方便幂等,不至于多次记账;方便业务系统查询某次赚取或消费的积分明细;暂时想到这么多。 2.尽量不要返回void或boolean,有些业务需要反向关键积分流水id做单笔流水查询。 其实这两个讨论都类似于现实生活,去便利店买东西会给你小票,支付宝扫码付款会返回交易流水。都是为了方便真是场景解决“纠纷”(查询)用的。
反馈一个文章朗读的小问题:音频3:00左右facade设计模式应该读做/fə'sɑ:d/,冯老师读的不是很准确。”吹毛求疵“,希望专栏做的更专业。
1.不符合一对上下游系统的设计要求,但适合当下业务场景的需求。下游积分核心系统设计上不该持有事件和渠道字段,因为它不该去关心上游业务,事件或渠道与对应积分明细的关联应该由上游系统来维护,或则在上游系统和积分系统之间再加一层积分系统的业务层,用于维护这层关系(关于易复用性的中台思想)。当下的业务场景,积分的管理系统是有必要维护一份事件渠道的值对象的。因为带有这个值对象,积分系统管理员才能不需要再多个系统中寻找积分增减关系,进而可以独立满足管理积分这件事的整个业务域。(事件和渠道只能作为值对象冗余在积分系统)。
2.不符合单一职责的限制,但满足当前业务场景的诉求。该接口做了增减积分和返回积分id两件事,且语义上并没有返回积分id的相关字眼,所以方法名定义也不明确。但是上游系统在变更积分后,需要获取积分id以作为上游系统变更事件与积分记录的关联key。而这个key只有在当前变更操作获得,所以就只能写这种语义不明且违反单一职责的方法。(让我来设计,我会把积分id的生成作为积分系统的一个外放接口,上游业务调用该接口获取id,记录关联关系,然后走mq推积分系统实现最终扣减。这样就可以规避上述这种无奈的场景)。
针对争哥的第一个问题,从设计角度来说不应该记录渠道和事件。从业务来说,必须记录交易的渠道和事件。基于这种妥协可以设计一张表。那是否也可以设计两张表?
积分交易表和明细表: 1、credit_transaction trans_id user_id channel_id event_id create_time 2、积分明细表credit_detail,只记录积分加减 trans_id credit(积分加减) create_time expire_time
1增加上层相关字段有利于出现问题时的排查工作,并且ods等系统在抽数据时也可以提供完整数据。 2不违背单一指责原则,赚取积分和消费积分的业务逻辑必然会伴随积分余额查询,业务上应属同一逻辑,拆分反而shi程序复杂。
实际中没见过同时定义voboentity的
说说第一个问题,系统设计跟数据库设计没必然联系,这样设计合理! 数据库设计中有点像服务器请求设计 1.数据库拆分过细,各表字段间关联变麻烦了,提高数据不一致性风险! 2.数据库中表多意味着访问可能性变大,数据库的连接,建立都是需要时间和消耗性能的!
争哥给出的是一个基础的积分系统案例,主要是从设计的角度来分析我们用到哪些原则和模式。真实场景的话肯定需要考虑高并发和大数据量的问题,那样的话可能需要单独出来积分账户表,存储积分余额,明细表需要分库分表。 针对问题一,我觉的很有必要,毕竟业务需要查询积分的来源和做幂等性检验。 问题二,这个主要看业务系统是否需要,大部分情况下我觉得是不需要的,业务系统增加积分或者消费积分就成了。需要的话也是为了对账,我哪个动作增加的哪笔积分,对账意义大于业务意义。
设计模式_24 #作业 1.下层系统中包含了上层系统的event_id,channel_id,这个显然是包含了(不该有的)上层信息,这样的坏处是,上层如果需要增加信息,底层的数据库要跟着改动。 当然,这么做的原因与本项目使用贫血模式的原因一样,这个项目的业务相对简单,我们认为除了事件和渠道之外,长时间内不会有其他的属性加入。后面如果真的需要添加,最好做成关联表的形式,而不是再加属性了。 2.如果把创建积分明细看做是一个原子操作,那么返回boolean是更合理的,因为这样就已经知道操作结果了。只是在业务上,常常需要同步地拿到刚刚创建的明细ID,如果接口不返回,再查询会非常麻烦,在这里返回是总成本最低的方式,当然,代价就是部分调用处并不需要这个返回值。
#感想 日常的工作中,我们常常也会考虑到各种做法是否违背某原则的情况,而且,这些场景总是两难的,刚开始的时候会死守某一条原则,硬着头皮把代码改成“明显有问题”的样子,后面会慢慢地做折中和妥协。
不能为了设计而设计.
所以我认为以上两个问题都是与正常的业务设计,并不违反规则.
第一个: 需要关联事件与积分的关系.
第二个:为了后续的也许需要返回id字段无可厚非.
1.设计原则不是银弹,包含上层信息是因为业务上需要这些字段来获取信息,如果包装成一个类,依赖的内容就更多了,对调用方来说就不简介,不知道服务提供方到底需要什么。
2.单一原则也是取决于具体业务的,如果消费和赚取的积分在后续还有操作,就需要再度去查询一下这个积分。还要多操作一次数据库,如果服务端直接有提供,就可以避开这一次操作。任何服务都是为了业务实现的。大部分场景都是面向sql编程。当然,如果业务场景不需要后续操作,就不需要返回了,不过统一来说,多一个返回值,也没什么影响。
课堂讨论1: 业务驱动的系统还是应该从业务的角度出发去做设计,这两个字段在积分明细查询中是不可或缺的,所以我认为是合理的。既然是不可或缺的,如果不记录在这张表中,就要记录在其他表中,或者查询不便,或者破坏内聚。 2.根据个人经验,insert操作的都是返回记录id,原因的个人观点是为调用方提供便利。还请老师解答
1.上节课中,我们讲到,下层系统不要包含太多上层系统的业务信息。但在今天的数据库设计中,积分明细表中credit_transaction中包含event_id,channel_id这些跟上层业务相关的字段,那这样的设计是否合理呢? 合理.这里只是包含了必要的上层系统的业务信息,没有包含太多,积分系统需要维护积分数据的变化记录信息,倘若没有记录积分变化的渠道id和事件id将无法找到该条数据对应的积分变动事件. 2.我们经常说,修改和查询不要耦合在一个接口中,要分成两个接口来做。赚取积分和消费积分接口返回积分明细ID,这样的接口设计是否违背单一职责原则呢?是不是返回void或者boolean类型更合理呢? 如果只是简单的单体系统且不与外部进行交互,返回void或者boolean也是合理的,由积分系统维护积分明细列表的查询功能足够了.但如果是需要供外部系统使用的积分系统,上层系统配置了不同的积分规则,产生了不同规则的积分变化数据,为了方便该上层业务系统查询具体的积分明细就需要记录积分明细id,这时返回void或者boolean就不合适了.
争哥, Vo和Bo应该分别放到Controller层、service层呢,还是统一放到domain层呢?
今天通过代码简单实现了这篇文章的内容。针对课堂讨论,个人理解如下: 1.积分明细表中credit_transaction中包含event_id,channel_id这些跟上层业务相关的字段。如果放在上层业务系统,那么业务系统需要自己维护积分信息,对于上层系统来说这好像是一个无关的东西,且是自己不可控的。另外如果积分系统需要做统计各个业务的积分数据,那就不好办了。我觉得部分上层系统的相关字段是放到积分系统比较合适的。虽然有一定的维护成本,但应该是可接受的。 2.对于“修改和查询不要耦合在一个接口中”我觉得在业务比较简单的情况下放到一个接口中即可,这样体现了代码的内聚性。如果业务比较简单,将其拆分成两个接口,提高了维护成本。如果后期随着业务需求迭代,如修改的需求越来越多,可考虑将其拆分。“赚取积分和消费积分接口返回积分明细ID,这样的接口设计是否违背单一职责原则呢?是不是返回void或者boolean类型更合理呢?”对于这个问题,我之前做接口是对于新增数据是返回一个boolean类型表示接口调用成功与否,此处返回id,个人理解为上层系统做业务铺垫,可能上层系统调用完接口后需要一个id做后续的业务逻辑处理。
想到最近工作中遇到的一个外部api接口升级的问题,以后项目中在调用外部api的时候一定要对这个api接口中的方法重新建个service类封装一下,并将其参数和返回值封装,这样如果换了新的api,我们只需要改这个封装的类,否则代码中全局一搜,几十个service类都用到了api中的方法,改起来真的很恶心,测试更是想吐