面向对象之设计实战

面向对象之设计实战

前言

在人类所制造的工具中, 有的可以将人送上月球, 有的可以骂驭原子的火焰, 但只有计算机软件, 才能透彻地反照出人类的心智。软件是人类心灵和智慧在虚拟空间中的投射。软件的性能就是人类能力的扩展, 它的活动就是人类心智活动的反映。软件直接表达出设计者对目标的理解. 对用户的期待, 以及时自己的定位。
然而, 人类的软件却是问题多多。面对不断变化的性能要求, 软件系统往住过于僵硬.过于脆弱, 不易复用, 很难维护。一个设计师驾驭软件系统的能力, 就像他统治一个虚拟王国, 或者主宰一个虚拟世界的能力一样: 而这些软件设计中的问题, 其实就是人类自身心灵和智慧的不足在镜子中的倒影。它反映出的,不仅仅是技术的不足,还包括了科学、艺术、心理和哲学的不足。
——摘自阎宏博士的《java与模式》

阎宏博士以道家哲学的角度向读者讲解软件设计模式的理论,它的简要历史,以及它与中国道家文化的渊源。成书很特别,也很新颖,它以一种能让中国人特有的方式理解这本书,这里向大家推荐这本书。

在大多数的Spring项目中,虽然是以Java为开发语言,但也只是用Java在做面向Spring开发,甚至有的项目只是在用Java做过程开发,我就亲身经历过这样的项目。相信很多人在做项目时,心里都有过这样的疑虑“自己是在做面向对象设计吗?为什么自己理解的面向对象,和现实不一样?”。这困惑着绝大多数的Java从业者,我将以自己的经历为引,希望对读者在MIS系统的面向对象设计上有那么一点点的帮助。

文章面向有一定软件设计经验的从业者,首先对面向对象做一些个人理解性的阐述,再以一个虚拟需求为引,用实战的形式对它做了设计与开发示例,其中使用到了单例模式、工厂模式、观察者模式、装饰模式、组合模式、模板模式、命令模式、代理模式、桥梁模式等等,希望能给读者带来一个不一样的面向对象设计展示,让大家有耳目一新之感。

在进入正题之前,我先讲一个真实的故事,它在我的工作中起到了非常重要的指引作用,同时,我也想用这个故事纪念天堂中的父亲。

我的父亲是一名小学教师,有一次学校做教室翻新,组织教职员工把校外的红砖搬到教室中。我们的学校是一所农村学校,比较小,校门也比较窄。我们所有人只能通过这个校门把砖一点一点搬入教室,而搬砖的人却很多,门就成了我们搬砖运动的瓶颈。这时,父亲想出了一个办法,他让所有人排成了三排,人与人以左手衔着右手拉开的距离间隔着,起点是红砖堆,终点是教室,然后,头一个人把砖传给后面的人,后面的人再传给下一个人…,直到把传到教室,这样就很好的解决了大门拥挤的问题,砖也很快搬完了。这个事情,一直存放在我的脑海里,我把父亲当做天人一般,直到我参加工作,学习新的知识,遇到困难,这个故事就会浮现在我的面前,带给我灵感,指引着我前进。

这个故事蕴含着一些设计思想,这也充分说明了,知识来源于实践与生活。比如:前人传后人的做法,那么前人就是后人的生产者,后人就是消费者。后人再传给下一个人,那么他又成了下一个人的生产者,他的下一个人就是他的消费者…。每一排人都做着相同的事情,三排人为了完成同一个目标,这种方式就是一次完美的分布式调度。如果把一块砖从砖堆运到教室是一次完整任务的话,每个人都在做着自己的事情,他们把一个完整的任务拆分成了多个小任务,最后,所有的砖(小任务的目标),就汇集到了教室,这和Hadoop的MapReduce的原理如出一辙。

生活充满了活力与色彩,有向往的人会从那里得到希望与指引,同时,我们也在丰富着自己的生活!

一、什么是面向对象

1.1 初识面向对象

很多关于面向对象设计的书籍会举一些相关的例子,比如,用面向对象来设计人,那么,名字、头、手、腿、脚等就是人的属性,走、跑、跳就是人的动作,实例化之后就是一个具体的人,区别于其他实例化的人(对象)。用面向对象设计汽车,发动机、车门、变速器、车牌就是车的属性,发动、行驶、加速就是他的动作,实例化之后就是一辆具体的汽车,区别于其它实例化的车辆(对象)。读者会发现,虽然这些例子说明了应该如何“面向对象”,但总觉得还不够,还差那么一点什么,这些内容还是不能帮助设计人员,告诉他们应该在项目里如何面向对象,难道要对涉及到的所有实体抽象吗?这就是面向对象了吗?

如何在现实中使用面向对象,设计和开发人员还是一头雾水,我就是从这样的泥潭里,一步一步走过来的,回头看还有很多人在不断的挣扎,甚至已经有人迷失在其中,甚至迷失在是否还要坚持面向对象,有人甚至认为,只要用了支持Java的技术框架就是在做面向对象的设计与实现,但这还远远不够。

1.2 面向对象的前提

面向对象要求设计人员要有抽象的思维,现实如何体现在虚拟的世界里,就要看设计师是以何种角度“看待”现实世界的。抽象即面向对象,面向对象即抽象。如果设计只适用于一个具体环境,那么只能说成功地得到了一棵树木,同时也成功地失去了一片森林。当设计满足了一份需求时,它还要有良好的可扩展性,满足开闭原则,它可在需求变更时,不用去修改一个完整的功能,这时才能说明设计有了良好的适应性。如果需求变更导致整体设计需要调整,则表示抽象还存在缺失,并未看到“现实世界”的精髓。当接触到一个新的领域时,一个充分、详细的调研是必要的前提,要调研的是这个领域,而不能只停留在雇主所描述的范围,只有这样才能让抽象做得更完整。

除了抽象思维,还有一道坎横在面向对象的前面。

通常,面向对象实现MIS系统,要同时涉及到对象范式和关系范式,混合范式会对对象模型产生一定的影响,对象与关系是否能和谐共处就是横在面向对象设计之前必须面对的一道坎。

要解决混合范式的问题是,似乎只有两个选择:1、数据库是对象的主要存储库;2、数据库是为这个系统设计的,但它的任务不是用于存储对象;如前述人和汽车的例子,采用选项1可能是一个不错的选择,但是使用对象存储,复杂度会增高,易用度会下降,尤其在大数据应用上,对象存储并没有太多的优势。那么选项2是否是最好的选择呢?人和汽车的例子,并不利于复用与扩展。试想一下,一个具体环境下的人和汽车,如果发生需求变更,就可能导致修改类的设计,这就像对一个数据表增加字段一样(需求变更甚至也能引起数据表的变更),可能会让程序发生连锁反应,所以,关系与对象不和谐,即使采用选项2也不是正确答案。既然关系会影响对象,那么让两个模型的耦合度不那么高,甚至是完全解耦是否有可能呢?这样,选项2是不是就可行了呢?后面的实战章节会有一个参考答案。

我在一个公司工作了近20年,大都在一个领域做设计和开发的工作,这个领域告诉我一个道理,一个合同的甲方所描述的需求,也只是他们的需求而已,并不代表这个领域真实的面貌,如果设计只满足它,那么必然不会满足这个领域中另一个甲方的要求,所以重复的工作就是不可避免的,而且在这个领域有了若干项目合同之后,你会发现维护工作是一个非常麻烦的事情,因为项目A和项目B的维护人员并不能互通互用。如果能抽象出这个领域,那么,很多重复工作就可以避免,A和B项目的开发与维护人员,只是在做不同项目的扩展与组件替换而已。

1.3 不得不说的敏捷

人们总是把软件开发比喻成制造业。这个比喻的一个推论是:经验丰富的工程师做设计工作,而技能水平较低的劳动力负责组装产品。这种做法使许多项目陷入困境,原因很简单﹣软件开发就是设计。虽然开发团队中的每个成员都有自己的职责,但是将分析、建模、设计和编程工作过度分离会对 MODEL - DRIVEN DESIGN 产生不良影响。
——摘自领域驱动设计Eric Evans的著作《domain-driven design》一书的3.4 模式:Hands-On Modeler

现在,整个软件行业都在唱衰先设计再开发的统一设计模式,大师们说出来的话,很多从业者都奉为金科玉律。是深刻理解,还是以偏概全?这不禁让我想起一句话:存在即是道理。统一设计并未完全消失,全面否定是否有其片面性。

Eric Evans实际上反对的是把设计与开发严格分离,他鼓励设计就是开发,要与开发人员一起完成项目,那么,请大家仔细想一想,设计参与开发的目的不就是想让项目能够按照统一设计完成吗?这不就是统一设计的完美实现吗?
统一与敏捷,二者实际上是辩证的关系,统一设计不影响敏捷开发,同时可有效的促进敏捷开发的效率,而敏捷开发需要统一设计,只有这样,敏捷才能有统一的指导。我参与过的项目中,有的只强调敏捷开发,整体项目被拆分成若干部分,开发人员分别在对应的若干小组中。这样的敏捷让需求、设计与实现完全隔离,接口需要小组在需求交叉时,由小组中负责的同事一起沟通要如何来实现。这样的“敏捷”已经做到了极致,这是一个项目管理者极其不负责任的表现,这种“敏捷”在很大程度上压缩了项目管理成本,缺少管理必然造成混乱,所以项目变得臃肿起来,且一些需要特别对待的地方要付出很大的代价才能完成。很不幸,这样的项目,我也经历过。

一个系统要做面向对象设计,只有对一个领域有完整的抽象才能做好它。割裂只能让抽象局部化,犹如管中窥豹,只见一斑尔。

二、实战前的准备

2.1 数据库选型

数据库应该怎样选择,要结合项目的具体情况而定,不能一概而论,主要参考几个方面:

首先,业务是否要求强一致性,它决定了是否采用关系型数据库的单实例模式,即使采用了分布式,也要使用分布式关系型数据库,现在国内的分布式关系型数据库都可以满足事务一致性的要求。如果要求强一致,尤其是微服务模式下,不建议采用最终一致的方案(除非系统的业务场景非常的简单),否则,最终一致会让你的程序复杂度增加,数据维护工作量会猛增。 如果一致性要求不高,你可以采用任意一种数据库,或者同一数据库的多实例还是单实例,只要能掌控得住就可以了。

其次,数据递增量是否很高。如果数据递增量很大,比如:一天就有上千万的数据量,或者一个月有数亿的数据量增加,那么就只能使用大型数据库,比如oracle,还可以考虑使用分布式关系型数据库,比如DRDS。如果系统的数据量很小,只要能掌控得住就可以了。

最后, 数据库并发访问量是否很高。如果并发访问量很高,那么就不能单纯地采用单实例数据库,即使是oracle,它的并发访问也会存在问题,只要一个性能较差的SQL,就会要了它的命。可以考虑使用分布式关系型数据库, 它天生就可适应高并发访问。

如果系统对数据有强一致要求,且数据递增也比较高,并发访问量也高,那要如何应对呢?这样的话,可以考虑关系型数据库与分布式数据库混合使用,关系型数据库主要负责数据持久化,比如oracle,它也可以应对大数据量递增,且数据的准确性以它为主。然后,使用分布式数据库来应对高并发访问,比如HBase;还有一种选择,就是直接使用分布式关系型数据库,比如DRDS,它也可以应对强数据一致性和大数据量递增,即使这样,建议还是要考虑使用分布式存储,比如HBase,因为它可以补充DRDS非拆分键条件访问数据而导致的全表扫描问题,但也要有针对性的设计才能达到想要的效果。

2.2 缓存的使用

缓存是一个可提供高并发访问的中间件,这是它最大的特点,在应用中要极大的发挥它这个优点。首先,要分离出什么样的数据才需要被高并发访问才是重点,其次,要考虑数据是否存在非对等访问的问题。比如,在一个分布式关系型数据里,用DRDS举例来说,当A、B两个表的数据采用不同的属性进行数据拆分(分库分表),这时就不能做两表的关联查询,这时就可考虑使用缓存来解决这个问题,这在后面的实战章节会有说明。

高并发场景都有一个共同特点:大都需要获取基础数据,比如一个以人为中心的业务系统,人员信息就是基础信息,所有的业务数据都是以人为基础存在的,除了人员信息查询外,还有业务数据的获取、业务处理过程,都需要对人员信息进行大量的集中访问。还有一种情况,当一个业务数据表不是以个人编号拆分的,但是在应用里却需要关联人员信息,以获取其中的姓名、证件号码,家庭住址等等属性,这时就可以考虑在应用中访问缓存里的人员信息,这样就解决了这种非对等访问(关联)的问题。

曾经有人向我指出,在非对等访问时,也可以遍历数据库获取基本信息,这样不是不可以,但是有它的局限性。如果业务数据量比较小,这是没有问题的,如果数据量很大,就是一个灾难。我做过一次性结算几千万人的业务系统设计,如果每一条业务数据都需要获取一次数据库中的人员信息,那么数据的访问时间就是一个天文数字。这种情况,把基础数据缓存起来,就是一个比较好的解决方案,这也可以认为是一种单位时间内高并发访问基础数据的场景,那么获取缓存中的基础数据,就变成了一种通用解决方案。

2.3 Hadoop的应用

在使用Hadoop的时候,实际上,就是在使用这个家族中的多种产品。在一个MIS系统中,如果选择了分布式关系型数据库,那么,建议使用HBase同步数据库的数据,然后使用Hive对HBase中的数据进行大数据分析。同步数据到HBase后,Hive就可以用CREATE EXTERNAL TABLE方式使用HBase中的数据,也就省去了定期把数据导入Hive的麻烦。同时HBase也可以提供数据检索服务。有了HBase与Hive做基础,大数据分析再往更深层次设计、应用,都可以搭载在它们之上。

有两种方式同步数据到HBase中,1、在业务处理时,一并写数据到HBase中;2、定期把数据同步到HBase中。第一种方式,会有数据一致性的问题,建议不要在保存HBase数据失败时,影响正常的业务处理,除非你的系统数据存储就是以HBase为主的。可以在失败后记录日志,之后再有目的性的同步数据到HBase中。第二种方式,如果数据源是一个关系型数据库,即使是分布式关系型数据库,也会导致全表扫描的问题,同步数据的设计时,要考虑是否会影响数据库的稳定性,我经历过的项目中(数据量都比较大),同步数据到大数据平台,基本上都遇到了这个问题。后面的实战章节会有参考方案。

三、举例实战

3.1 一份假设的需求

(需求是虚构出来的,并无实际调研)建立一个汽车租赁平台,它对外提供汽车租赁服务,车主在自己的爱车闲置的情况下,可在平台上实名注册自己的爱车,供有需要的用户在平台上实名订车,租车的价格由车主自己定价,租车行为成交后,平台收取相应的服务费。平台的具体需求如下:
1、租车的用户可到店取车,平台还可以提供送车上门服务。

2、平台可公布线上可用车辆。

3、平台可根据注册车辆情况,建议租车的价格,可设置租车的最高值,防止过高的费用,导致平台可用数据过低。

4、提供到店还车,指定地点还车,和上门取车服务。

3.2 消化需求

根据需求的描述,开始会觉得应该有四个人员管理系统:一个员工管理系统、车主管理系统、用户管理系统和调度人员管理系统,一个车辆管理系统和一个车辆租赁系统。随着,我们对需求的深度理解,发现,四类人存在交叉的情况,员工、用户和调度可以与车主互为对方的角色,那么,没有必要让这四类人分别在四个系统内管理,可以把他们合并在一起,作为一个系统——人员管理系统。在人员管理系统内,一个人只需要注册一次,就可以在系统内直接用来登记为不同角色(注意,员工和调度的身份不可让一个人同时具有)。

数据规模评估:预估一下可提供车辆的车主大概有一百万人,也就是说可提供大约一百万注册车辆, 要面对全国14亿人租车的业务(假设使用本系统的用户有1亿人),预估一天的租车成交量大概在千万级。注册的调度人员,按十万级预估。
隐性需求:
1、平台需要做大数据分析,来提高车源和成单的效率。

2、汽车租赁系统虽然是以TOC业务为主,但租车用户在查询可用车辆的时候,需要解决大数据检索的问题。

扩展的需求
1、可对系统内人员发送通知,比如优惠信息、长时间未使用。

根据预估的数据量,与数据增量,数据库可选择DRDS,搭配使用HBase和Hive用来做大数据分析,Redis用来缓存人员基本信息和管理序列,分布式任务调度XXLJOB用来完成大数据类的业务。

3.3 对业务过程的抽象

大部分面向对象教程和我接触过的真实项目,在做面向对象设计时,都是以现实存在的实体为基础,这个虚拟项目,采用了对业务过程进行抽象,做面向对象设计。通过分析,不难发现所有业务都有一个共同点,它们都存在四个阶段:数据准备阶段、业务逻辑处理阶段、数据持久化阶段和记录业务日志阶段。
在这里插入图片描述
图3-3
上图表示了业务过程的四个阶段,依赖关系表示后一阶段需要前一阶段提供的数据,关联(聚合)关系表示逻辑处理需要数据准备阶段的数据,并且需要知道做多少个数据表的持久化,和要产生几类业务日志,依此可以采用生产者、消费者模式来进行设计,这样每个阶段就有了解耦的可能,每个阶段之间的依赖关系就是数据的流转。

3.3.1 数据准备阶段

我参与过的项目中,绝大多数在数据准备阶段,采用的是数据精确筛选,然后再针对筛选出来的数据做具体业务处理。对于非TOC类的业务,有两个明显的缺点:1、如果待处理的数据比较多,无法对不符合业务逻辑的数据进行问题定位,如果事后对这类数据怀疑,只能做事后数据分析,不仅分析有难度,而且,有时也无法确定数据的准确性(事后分析时,数据可能已发生了变化);2、系统运行的环境所限,比如精确筛选数据,一般情况下都需要做多表关联查询,我见过8张以上的数据表关联查询的游标,这已经非常的不合理。如果选择的是分布式关系型数据库,那么多表关联的缺点就会更加的明显,这样的SQL一般情况下都存在性能问题,所以系统会给这样的程序运行预留一个单独的时间,把它“保护起来”,实际上是怕它伤害到别人。

即使在处理TOC业务时想使用精确筛,想把涉及到的多表数据一起检索出来,但是因为有不符合业务逻辑的条件存在,导致数据并不能被检索到,这时,会出现无法确定问题的弊端。

逆向思维一下,如果在数据准备阶段,只是粗选而非精选。这样在遇到了不符合业务逻辑的数据时,会在逻辑处理阶段判断出来,然后调用业务日志处理(进入日志处理阶段),反之,会调用数据持久化(进入保存数据阶段)。如果出现上述需要多表的数据,这时,不选择使用多表关联,而是采用针对数据表的独立数据检索,每个检索动作都在独立的对象中完成,这就是图3-3中数据准备阶段与业务逻辑处理阶段的聚合关系。
在这里插入图片描述

图3-3-1

上图中的数据缓存处在数据准备阶段,需要多少个数据表,那么就会有多少了数据缓存类,它们聚合到数据缓存任务中,这里说的“缓存”并不是特指要把数据放到Redis这样的缓存中间件中,而是一种泛指,把DB中的数据缓冲到内存中,也可以认为是数据缓存。接下来,数据有了,在使用时就会涉及到一个重要问题——数据关联,因为数据间存在关联关系,当数据缓冲到内存时,要如何关联上呢?内存检索可以完成这个任务,比如利用哈希、排序(折半)等,而且内存检索性能非常高。

利用独立检索的特点,可以采用并行的方式对数据进行查询,这样就可以达到优化数据检索的作用。

3.3.2 数据持久化阶段

数据持久化,即把符合业务逻辑的数据持久化到数据库中的阶段,那么在逻辑处理阶段完成后,就可以把数据流转到持久化阶段来处理。

在这里插入图片描述

图3-3-2

上图就是数据持久化的设计,经过业务逻辑处理的数据,会交给Observable对象,它负责通知数据观察者完成对数据的收集与提交。真正完成数据持久化的是数据数据工厂对象,根据分层设计的原则,数据层要与领域层分开,这样数据工厂只有数据收集与持久化方法的方法,它并不包含任何的逻辑代码,那么数据工厂就具备了可复用性。数据观察者处在领域层,那么,数据工厂被聚合到数据观察者后,观察者就可以完成告知数据工厂的任务,什么样的数据可以被收集,什么样的数据需要被过滤,那么逻辑代码就可以写在观察者中。聚合关系表示一个观察者对象,可以为多个数据工厂收集数据,并告知这些数据工厂可以做数据持久化;Observable可以管理多个观察者,当逻辑处理阶段完成后,达到通知观察者的目的。

3.3.3 业务日志阶段

业务日志阶段的设计与数据持久化阶段的设计是一样的,负责日志保存的也是数据工厂,通过数据观察者告知完成持久化的任务,唯一不同的是在业务逻辑处理时,把不符合逻辑的数据告知日志观察者(当然,也可以把符合业务逻辑写到日志中,这要看设计师的目的要达到什么样的结果)。

在这里需要特殊说明的是,日志和业务数据持久化有一点不同,业务数据需要事务一致性,而日志对这方面的要求比较弱,根据这个特点,业务日志阶段的设计就会更多样化一些。

3.3.4 逻辑处理阶段

当数据缓存任务完成后,进入到数据逻辑处理阶段,这个阶段的任务是按照业务逻辑处理数据。业务处理需要有原子性(不可再分割),也就是一个完整业务拆分出来的最小业务粒度,那么一个完整的业务可以认为是由原子业务组合而成的。下图是业务处理与各阶段之间的依赖关系。
在这里插入图片描述

图3-3-4
可以看出,业务处理与其它阶段的耦合度还是比较高的,这样的设计有些不合理,需要一个拆分设计来降低耦合度,同时,还要考虑到业务处理的原子性。

3.3.5 业务的拆分与整合

根据上一节内容所述,要对业务处理进行拆分,那么采用哪种设计,又陷入到了一个混沌状态。前面提到过,这个实战采用的是对业务过程进行抽象,数据准备、业务处理、数据持久化与业务日志四个阶段已经被抽象出来,只是业务处理阶段与其它阶段的耦合度比较高而已,如果把四个阶段独立,然后采用组合四个阶段达到一个完整业务过程,似乎就是一个合理的选择,那么采用模板模式完成四个阶段的组装就顺理成章了。

在这里插入图片描述

图3-3-5
上图是一个模板模式的设计,由它来组装四个阶段,可以看出业务处理类和业务模板只是简单的关联关系,前面提到,业务处理要有原子性,所以它和模板之间并没有聚合关系。模板知道什么时候应该触发哪个阶段,知道数据以何种方式流转到下一个阶段,由它来协调各阶段工作,由此来完成一个完整的业务处理。

3.3.6 小结

上面五个章节,把如何按照业务过程抽象讲完了,这个设计可以处理系统内所有业务,只是每个业务中四个阶段的实现类不一样,也就是说,模板组装不同(四个阶段涉及的)对象,完成业务处理过程,这些对象都是各阶段抽象类与接口的不同形态与实现,它们是下一章组件化设计的前提条件。

3.4 组件化设计

组件化设计是业务软件统一设计的助推剂,它可以让开发人员书同文,这样的优势就不言而喻了。同时,组件的多态可以让需求变更、同类业务的不同处理用组件的替换来完成。

3.4.1 组件化设计的前提

组件化的设计,要求软件设计在抽象阶段,抓住通用性、灵活性与选择性这三点。
1、通用性:
意味着足够基础和常见且不带业务属性,参与设计的每个人应该知道这个组件的功能及目的,同时一定要具备扩展性。
2、灵活性:
要求组件的组合需要灵活, 可以在不同场景下,可以通过相互组合来快速构建, 并根据不同变化来适应新的业务需求。
3、选择性性:
指的是适用于多个业务, 在设计过程和研发过程中可以高频转换。

模板的设计让业务的四个阶段实现了解耦,图3-3中显示的逻辑处理与其它三个阶段的聚合关系没有了,它们的设计相对独立了起来。模板用来控制数据在各阶段之间流转,而每个阶段只负责自己的任务。模板可灵活的组装不同阶段的实现,用以完成相应的业务,如果需求发生变更,这些对象可被随时替换。如果不同阶段的实现足够丰富,在理想状态下,可在不实现或者只实现几个新对象的前提下,完成一个新的功能。

3.4.2 组件的定义

依前所述,目前的设计已经可以满足组件化,那么组件应该如何定义,才能让参与设计的每个人都知道组件的功能及目的呢?虽然对业务过程抽象出来四个阶段,但是它们对于组件化来说,还是略显粗糙,还不足以达到前一章所说的三个优点,接下来,对四个阶段做一次回顾。

1、数据准备阶段,在图3-3-1中,数据准备除了完成数据检索,还要提供并发功能,这样,除了数据缓存外,还需要一个缓存任务,它用来协调数据缓存的状态,包含并发启动、数据检索状态等。可以把数据缓存对象和缓存任务对象分别定义为数据缓存组件和缓存任务组件。

2、业务处理阶段,如图3-3-4,业务处理关联了逻辑判断对象(聚合关系),也就是说,在一次具有原子性的业务处理过程中,可能需要多个逻辑判断对象来配合。可以这样来理解,一次原子业务处理有多个业务逻辑需要判断,那么每一个逻辑判断都需要一个逻辑判断对象,这样,可以认为原子业务处理就是一个原子业务组件,一个业务逻辑判断处理就是一个逻辑判断组件。

3、数据持久化阶段,如图3-3-2,持久化阶段需要一个Observable对象聚合数据观察者对象,数据观察者为(可聚合多个)数据工厂提供业务逻辑控制,保证数据的准确性,可以把它们分别定义为数据观察者组件和数据工厂组件。

4、业务日志阶段,它的设计与数据持久化一样,只是通常情况下数据持久化针对的是满足业务逻辑的数据,而业务日志记录的是未满足业务逻辑的原因,所以它使用的也是数据观察者组件与数据工厂组件。

经过对四个阶段的回顾,总结出数据缓存组件、缓存任务组件、原子业务组件、逻辑判断组件、数据观察者组件和数据工厂组件,这些组件是目前最直观的,那么还有一些未涉及到的,下面再来剖析一下。

数据准备阶段的“准备”实际上是从数据库中检索数据供原子业务使用,所以还需要一个数据检索组件,区别于DAO与JPA,把持久化与数据检索分开,目的就是为了组件化。数据工厂组件可以达到一次编写随处可用的目的,也就是具备了通用性,下面再来看一下数据检索的设计:
在这里插入图片描述
如图所示,数据仓储属于数据层,数据检索工厂处于领域层,与数据观察者、数据工厂的设计思路相同,数据仓储一旦建立,它就是针对一个数据表检索的通用实现,而数据检索工厂包含了业务逻辑代码,也就是说,常用检索方法1…*这些方法,包含了针对表查询的通用条件组合,一旦需要附加条件检索,就可以通过一个常用检索方法+设置附加条件与参数方法来完成一次动态扩展查询。 数据检索工厂聚合了数据仓储,可以理解为,数据检索工厂在检索数据时,如果数据在多个数据源中,那么就可以使用不同的数据仓储对象,这样设计是为了达到对开发的透明,开发人员关注的是数据来自于哪个数据表,而不关注是哪个数据源①。

数据准备阶段之后,数据在应用中体现为原始形态,在逻辑处理阶段之前,数据需要以原子业务可处理的形态供原子业务组件使用,所以还需要在两个阶段之间增加一个数据刷新组件的设计,它的目的就是把数据从一个形态转换成另一个形态,当然数据的原始的形态可能就是原子业务需要的,这时数据刷新组件就可以忽略。数据刷新组件就像一座桥梁,架通了这两个阶段。

注:①数据源是泛指,并不专指一个DataSource对象,比如前面提到的缓存使用,数据检索工厂要针对一个数据表A,而A中的数据属于基础数据,它会被同步存储到Redis中,开发人员在使用这些数据时,并不关注数据是来自于数据表,还是缓存的。

3.5 数据存储设计

设计数据表时,有几点重要需要先说明一下:
1、在业务数据表中,都有“键”字段,借鉴于Hive的Bucket设计,这些数据会按照设计规则固定在一个"键"中,在实战中,采用了编号对一个整数(模数)取余,那么余数就是“键”。根据之前的需求分析,人员信息使用的模数可为10W-100W之间(每个键会包含100-1000人),车辆信息使用的模数为10000(每个键包含100辆车)。

2、人员信息使用个人编号设计“键”,车辆信息使用汽车ID设计“键”,车辆租赁信息使用租赁人编号设计“键”。在使用DRDS时,这些数据表就使用“键”来对数据进行分库分表。日志信息,使用业务编号进行分库分表。日志详细信息,使用任务单元进行分库分表。

3、根据需求分析得出的数据量,需要100个RDS,那么一个分库会存储100W人的业务数据。

3.5.1 基础数据

根据前面的需求分析,可以认为人员信息和车辆信息都是基础信息,这两类信息虽然都有增长空间,但都不会出现膨胀性增长。为了区分身份,还需要一个身份注册信息;为了记录基本信息的变更历史,还会有人员和车辆的变更信息表,设计如下:
在这里插入图片描述

图3-5-1
1、人员基本信息:
1)身份证号码与姓名,可为工作人员提供信息检索定位功能。
2)手机号与微信号可以用来联系注册人员或发送信息。
3)登录时间,当登录行为发生时,人员基本信息中的登录时间还是上次登录时间,同时,需要修改人员基本信息中的登录时间为本次时间。
4)注册时间,表示初次在系统中注册时间。

2、身份注册信息:
1)身份类别,区分员工、车主、调度,如果一个人存在多种身份,就会有多条身份注册记录,注册信息并不会区分租车人员信息,因为所有类别人员都会有租车行为,也就是只要存在人员基本信息,都可以租车。
2)身份状态,正常、异常,用来区分身份是否可用。比如三个月内无登录记录,则自动修改为异常状态。
3)注册时间,表示身份注册的时间。

3、人员变更信息:
1)变更类型,它可以认为是一个大类,可以把多个具体业务归为几个粒度较粗的大类,这样便于数据的统计。
2)变更时间,业务的发生时间。
3)变更流水号,与日志表中的业务流水对应,可以通过它与日志关联。
4、车辆信息:
1)注册行政区:用来识别车辆所在地,便于租车检索可用车辆。
2)汽车级别:区分轿车、SUV、MPV、皮卡等,可认为是大类。
3)级别小类:汽车级别的小类,对每个级别再细分,便于租车时详细筛选。
4)车辆可用开始时间、终止时间:表示车辆登记时,可供用户租赁的时间段。

5、汽车变更信息同人员变更信息,不再赘述。

3.5.2 车辆租赁信息

租赁信息是用车行为的一个详细记录,一是为了数据可追溯,二是为了做大数据分析。所以,租车信息设计越详细越好,实战里只做了基础功能,下面是设计:
在这里插入图片描述
图3-5-2

1、租赁人编号,取自人员基本信息中的个人编号。
2、汽车ID,表示租车人具体使用了哪辆车。
3、取车方式,记录了店里取车、送车上门,还是上门取车。
4、调度人编号,记录了具体的送车人。
5、订单状态,表示目前已形成的订单状态,包含:已接订单、正在取车、使用中、完成。
6、订单流水号与日志表中的业务流水号关联,一次交易会有一个订单流水号。

3.5.3 日志信息

日志信息的设计有三个目的:一、完成系统业务的痕迹记录;二、可记录业务办理时的异常信息;三、如果是一个大数据量的业务,可以记录运行进度。设计如下:
在这里插入图片描述
图3-5-3

1、日志信息:
1)任务ID,如果运行的是一个分布式任务调度,那么会在日志信息中产生对应应用结点的日志信息,这时,任务ID是同值,表示同一个任务产生的日志信息。
2)业务编号,每个业务都有自己的唯一编号,在日志中可直观分辨是什么业务产生的日志。
3)业务流水号,一次业务处理会产生一个流水号,在业务数据表中都会有一个对应的流水号,用来关联业务与日志。
4)任务总数、成功数、失败数,用来记录进度。
5)任务状态,用来表示任务是否已完成。
6)服务器,记录了业务当时运行的服务器(IP、hostName),便于获取服务器日志文件。
7)业务粒度,通常情况下,可以认为同一粒度不可同时执行。

2、日志详细信息:
1)日志ID,用来与日志信息关联。
2)任务单元,即原子业务的粒度,记录的就是它的编号。比如:人员注册业务,它记录人员编号;如果是车辆注册,就是车辆ID;
3)任务信息,如果业务失败,用它来记录详细的错误(异常)信息。

3.5.4 HBase表设计

HBase采用空间换时间的策略,如图3-5-2中汽车租赁信息需要在HBase中体现为五个表,分别是以注册行政区划、注册城市、汽车品牌、汽车级别、级别小类为行键前缀,列族包含人员、车辆、车辆租赁。
1、create ‘carRental_行政区划’,‘人员信息’,‘车辆信息’,‘租赁信息’
rowKey:行政区划+业务属期+汽车租赁ID

2、create ‘carRental_注册城市’,‘人员信息’,‘车辆信息’,‘租赁信息’
rowKey:注册城市+业务属期+汽车租赁ID

3、create ‘carRental_汽车品牌’,‘人员信息’,‘车辆信息’,‘租赁信息’
rowKey:汽车品牌+业务属期+汽车租赁ID

4、create ‘carRental_汽车级别’,‘人员信息’,‘车辆信息’,‘租赁信息’
rowKey:汽车级别+业务属期+汽车租赁ID

5、create ‘carRental_汽车小类’,‘人员信息’,‘车辆信息’,‘租赁信息’
rowKey:汽车小类+业务属期+汽车租赁ID

行键中要包含什么属性,需要根据业务的具体情况而定,实战里只加上了业务属期,认为会按照租赁的月份来统计分析数据。

3.5.5 Redis设计

在第2.2章节中介绍过,缓存中需要常驻基础数据,依据前面章节的描述,人员信息、车辆信息属于基础数据,那么这两类信息如何设计存储形式呢?

表中的信息是以字段名与字段值形式存在的,也可以认为是一种key/value对的形式,参照3.5和3.5.1节中的描述,基础信息增加了“键”,那么缓存也需要提供以键来获取方式这样才能让数据检索工厂不用关注缓存还是数据表,或者显式的选择基于缓存还是数据表。基于此,可以采用散列数据类型来存储二维数据表,key为数据表中的“键”,field为能标识基础信息唯一的属性,value为一行数据的json数据格式。

3.5.5.1 人员信息

人员信息在Redis中的数据存储以两个形式存在,一、以数据表“键”为key的人员信息。二、用身份证号码为索引的缓存信息。

1、以“键”为key的人员信息:
field为个人编号,value为一个人的人员信息,以json形式保存。

2、身份证号码缓存索引:
key与field的设计,要看具体的数据检索习惯,这里就不再做示例演示了。设计原则是索引对应上个人编号,然后用个人编号再检索一下缓存中的人员信息。

3.5.5.2 车辆信息

数据表是以汽车ID拆分的数据,但是实际车辆租赁时,用户是以城市或者行政区来检索可用车辆的,而车主是用车牌号来检索数据的。所以,车辆信息在Redis中的数据存储以四种形式存在,一、以数据表“键”为key的车辆信息。二、以注册城市为索引的缓存信息;三、以注册行政区划为索引的缓存信息;四、以车牌号为索引的缓存信息。

1、以“键”为key的车辆信息
field为车辆ID,value为车的信息,以json形式保存。

2、以注册城市为索引的缓存信息
key为地市行政区划编码,field为车辆ID,value为车的信息,以json形式保存。

3、以注册行政区划为索引的缓存信息
key为行政区划编码,field为需求中,检索数据的其它属性组成,value为车的信息,以json形式保存。

4、以车牌号为索引的缓存信息,因为车牌号有唯一性,所以可直接使用字符串类型存储。
key为车牌号,value为车的信息,以json形式保存。

3.5.6 小结

如果HBase与Redis中的数据,是在业务过程中保存的,需要注意一个问题,HBase与Redis是无法保证数据一致性的,所以设计时,要注意转换思考方向,如果在数据不一致时,能判断出HBase与Redis中的数据是不正常的即可,这样在数据使用时,遇到这样的数据就可以检索数据库,同时,可以使用定时任务清理HBase与Redis中存在此类问题的数据。

关于数据在缓存与数据库中的关系,在以往的经历中,我见过有人采用缓存不存在,即使用数据库的设计方式,这是存在很大弊端的,当并发访问缓存时,大量的请求检索不到数据,那么数据库的压力就会剧增,如果访问的是缓存中建立的“索引”,这时在没有检索到数据的情况下,再去检索数据库,这就是灾难①。那么,在设计时应考虑将数据先进缓存,后进数据库的方式,这样,保证无论在什么情况下,缓存中的数据都是最先存在的,这样,就可以避免这种弊端了。

注:①既然使用了缓存中建立“索引”,表示检索条件并不包含DRDS的分库分表键,这种情况下检索数据库,就是全表扫描。

3.6 代码示例

结合以上章节,本章会做几个功能实现的展示,向读者更详细的讲解设计是如何实现的。

3.6.1 数据在应用中的体现

在我经历过的项目中,应用中的数据都是以DTO、DO、ENTITY、VO为载体,在以内存计算应用越来越广的情况下,这就显得有些笨拙。这些对象过于具体化,个体之间存在差异,比如个人编号,在DTO A中定义为psnNO,在DTO B中定义为personNO,这种差异导致DTO之间并不能通用。当应用需要使用内存计算操作数据时,这种差异就显得不那么灵活,比如,要实现内存检索、排序等操作,很难有通用的实现。

在应用中,使用java.util.List<java.util.Map>来表达二维表,Map对象表示一行,List对象则是一张二维表(行集),这样会让数据表达和应用实现灵活得多。Map是Key/Value对象,只要限制好Key,那么Map就可通用在应用中。通常情况下,应用中的数据就是数据表中字段/值对,所以,可以在应用中定义一个静态类,用来定义字段的映射,设计与开发约定应用开发时,都要使用这个静态类来引用字段①。我常用的方法是用中文(直接使用字段的注释中文)映射,这样有一个好处就是在阅读代码时,它就是很可靠的注释说明。

注:①Maven提出一句名言——约定大于配置。它也是一种编程思维,它开启了灵活的思维方式。但是要注意一点,数据表设计时,不同的字段要用不同的命名,否则在Map对象中,Key相同会引起数据覆盖问题,这需要在整体设计时,定义规则来避免这种情况,或者由一个设计师来设计数据表。

3.6.2 创建工程

人员管理系统、租赁管理系统需要建立两个项目,领域层与数据层分开,一个项目就需要拆分成两个。

各个项目需要一个共享资源,还需要一个工程。

把一个项目拆分成多个项目,多个项目可合可分,分开就是微服务,合并就是一个服务。如果项目对数据一致性的要求不是很高,那么是否采用微服务,或者服务间是否跨库,就变得都不重要了,重要的是要保证数据最终一致。相反,在微服务的状态下,不要涉及多个服务同时需要数据持久化的情况,否则,建议要数据库同实例,且需要合并在一起发布,不要让自己陷入解决分布式事务的泥潭里。

按上述原则,还需要一个综合项目,用来聚合其它项目,至于是需要一个这样的综合项目还是多个,取决于具体项目是否需要多服务部署。
在这里插入图片描述
图3-6-1
上图是虚拟系统的项目拆分情况,svc是个聚合项目(POM工程):
1、car-rental-api:它是各项目的共享工程,包含接口定义、枚举、静态类定义(前面提到的数据表字段的映射)等等。

2、car-rental-basic-db:它是人员与车辆管理系统的数据层工程,包含了与DRDS、Redis与HBase交互的数据层代码。

3、car-rental-basic-biz:它是人员与车辆管理系统的业务工程,包含了人员与车辆管理的领域代码。它依赖car-rental-basic-db项目。

4、car-rental-order-db:它是租赁订单管理系统的数据层工程,包含了与DRDS、Redis与HBase交互的数据层代码。

5、car-rental-order-biz:它是租赁订单系统的业务工程,包含了租赁管理的领域代码。它依赖了car-rental-order-db项目。

6、car-rental-public-agg:它是一个聚合工程,是需要独立部署的工程,如果是微服务部署,就需要多个这样的聚合项目。

如果项目使用的是同一个数据库实例,还需要微服务部署的话,可以用car-rental-public-agg来聚合需要的具体项目来完成。当人员、车辆管理需要访问租赁系统中的数据时,可以用car-rental-basic-biz依赖car-rental-order-db的方式解决,如果人员管理需要一个独立的服务部署,可以用car-rental-public-agg聚合car-rental-basic-biz即可。同样,租赁系统需要访问人员管理中的数据时,可以用car-rental-order-biz依赖car-rental-basic-db,如果租赁订单系统需要一个独立的服务部署,可以用car-rental-public-agg聚合car-rental-order-biz。如果不采用微服务部署,则可直接使用car-rental-public-agg聚合car-rental-basic-biz和car-rental-order-biz即可。

3.6.3 人员注册

人员注册业务是这个系统最基本的功能,示例代码如下:

/**
 * Description 个人变更业务
 */
@RestController("/person/change")
@Api(tags = "个人变更业务")
public class BasicChangeController {

    /**
     * Description 人员登记
     * 
     * @param  para 参数集
     * @return
     */
    @SuppressWarnings("unchecked")
    @RequestMapping(value = "/checkin", method = RequestMethod.GET)
    public String checkIn(@RequestBody Map<String, Object> para) {
        if (MapUtils.isEmpty(para))
            throw new RuntimeException("参数不可为空!");

        Map<String, Object> basicInfo = (Map<String, Object>) para.get(ShareStoreName.人员信息);
        String cardID = basicInfo.get(ShareFields.身份证号码).toString();

        // 业务编号
        String busiID = "BasicChange000001";
        // 日志ID
        String logID = GenerateSeqUtil.assignID();
        // 业务流水号
        String busiSeq = GenerateSeqUtil.assignSeq();
        // 业务粒度,通常情况下,可以认为同一粒度下不可同时执行。
        String busiGranularity = cardID;
        // 模板需要的业务参数
        Map<String, Object> param = new HashMap<String, Object>();
        param.put(ShareFields.变更类型, ChangeTypeEnum.人员登记.getCodeValue());
        // 模板需要的原子业务集合
        List<Map<String, Object>> basicList = new ArrayList<>();
        basicList.add(basicInfo);
        SingleTemplate template = new SingleTemplate(logID, busiSeq, busiGranularity, param, basicList);
        JobConf jobConf = new JobConf();
        jobConf.setBusinessID(busiID);// 设置业务编号
        jobConf.setLogUnitField(ShareFields.身份证号码);// 设置日志单元

        // 数据缓存组件
        jobConf.addCacheStore(ShareStoreName.人员信息, PersonBasicCacheStore.class);

        jobConf.setFlush(PersonCheckInDataFlush.class);// 数据刷新组件
        jobConf.setAtomic(PersonCheckInAtomicBusiness.class);// 原子业务组件

        // 数据观察者组件
        jobConf.addObserver(PersonBasicObserver.class);
        jobConf.addObserver(PersonChangeObserver.class);

        // 业务逻辑判断组件
        jobConf.addBeforeCommand(ExistsPersonBasicCommand.class);

        // 启动模板
        template.doStart(jobConfig);

        return busiSeq;
    }

这个示例展示了如何运用组件完成人员注册功能,说明如下:
1、JobConf,它是组件载体,同时也负责给模板传递参数。这个设计借鉴了Apache Hadoop 的MapReduce任务中的JobConf。

2、SingleTemplate,single模板可理解为只会整体请求一次数据持久化,适用于简单的业务处理。相对的,还会有一个MultiTempate模板,multi可理解为一个任务需要多次请求数据持久化(如果模板处理的结果不需要数据持久化,那么可理解为请求的是业务结果的处理)。

3、setLogUnitField,模板具有通用性,所以需要告知它,具体的业务日志需要以什么属性记录。

4、示例中,组件是按分类逐步添加到jobConf对象中的,但是模板执行的时候,不需要以这个顺序严格执行,这要看设计师是如何设计模板的。

5、addBeforeCommand,它用来添加业务逻辑判断组件,与它相对还有addMidCommand和addLastCommand,分别表示在业务(原子业务)前、中、后执行,这里要特殊说明一下“中”,它是由原子业务控制具体的执行时机,原子业务组件依赖了业务逻辑判断的组合对象。

接下来展示组件的示例代码:

3.6.3.1 数据缓存组件
public class PersonBasicCacheStore extends AbsCacheStore<Map<String, Object>> {

    public PersonBasicCacheStore(Map<String, Object> para) {
        super(para);
    }

    @Override
    public void cacheData() {
        // 什么也不做。
    }

    @Override
    public Map<String, Object> getData(Map<String, Object> sourceData) {
        return this.para;
    }
}

构造方法中的para,就是模板构造方法param和basicList(中的Map对象)合集,组件中的cacheData实际上什么也不需要做,因为数据都前端传入的,直接获取即可。

3.6.3.2 数据刷新组件
public class PersonCheckInDataFlush implements IDataFlush {
    @Override
    @SuppressWarnings("unchecked")
    public List<Map<String, Object>> flush(ICacheJob cacheJob) {
        List<Map<String, Object>> result = new ArrayList<>();
        Map<String, Object> personBasic = (Map<String, Object>) cacheJob.getTaskObject(ShareStoreName.人员信息).getData(null);
        if (MapUtils.isNotEmpty(personBasic))
            result.add(personBasic);

        return result;
    }
}

组件依赖了cacheJob(数据缓存任务对象),它可以提供所有数据缓存组件返回的数据集。人员注册这个功能,只需要把前端传递过来的ShareStoreName.人员信息“整理”之后,反馈给原子业务即可。

3.6.3.3 原子业务组件
public class PersonCheckInAtomicBusiness extends AbsAtomicBusiness {

    @Override
    public void run(JobConfig jobConfig, ICacheJob cacheJob, AbsCommandComposite commandComposite, Map<String, Object> sourceData) {
        commandComposite.execute(cacheJob, sourceData);

        sourceData.put(ShareFields.变更时间, FunctionUtil.getSysdate(8));
        sourceData.put(ShareFields.变更流水号, sourceData.get(ShareFields.业务流水号));
    }

}

组件依赖了cacheJob(数据缓存任务对象)、commandComposite(业务逻辑判断组合对象)。在原子业务中同样也可以获取数据缓存组件返回的数据集。commandComposite.execute()执行的是jobConf.addMidCommand()组装的逻辑判断组件。sourceData是一个Map对象,它就是原子业务需要的一行数据,也就是最小的数据粒度。

3.6.3.4 数据观察者组件

// 人员信息

public class PersonBasicObserver extends AbsBasicObserver {

    /**
     * Description 构造函数
     *
     */
    public PersonBasicObserver() {
        super(Long.valueOf(FunctionUtil.getSysdate(20)), new PersonBasicDataFactory(), new PersonBasicCacheFactory());
    }

    @Override
    protected void dbCollect(Map<String, Object> sourceData) {
        this.dataFactory.setData(sourceData);
    }

    @Override
    protected void cacheCollect(Map<String, Object> sourceData) {
        this.basicCacheFactory.setData(sourceData);
    }

}

// 人员变更信息

public class PersonChangeObserver extends AbsObserver {

    private TreeSet<String> persons;

    /**
     * Description 构造函数
     * 
     */
    public PersonChangeObserver() {
        super(new PersonChangeDataFactory());

        this.persons = new TreeSet<>();
    }

    @Override
    public void collect(Map<String, Object> sourceData) {
        String personID = sourceData.get(ShareFields.个人编号).toString();
        if (this.persons.add(personID) == false)
            return;

        this.dataFactory.setData(sourceData);
    }

}

这两个数据观察者的父类是不一样的,AbsObserver只负责为数据库收集数据与提交数据给数据库,PersonChangeDataFactory对人员变更信息负责,而AbsBasicObserver是AbsObserver的子类,扩展了基础功能,可同步数据给Redis。

3.6.3.5 业务逻辑判断组件
public class ExistsPersonBasicCommand implements ICommand {

    @Override
    public void execute(ICacheJob cacheJob, Map<String, Object> sourceData) {
        // 前提:在前端人员注册时,已经做过实名认证,也就是身份证传入是真实的。
        String cardID = sourceData.get(ShareFields.身份证号码).toString();

        PersonBasicQueryFactory queryFactory = new PersonBasicQueryFactory();
        List<Map<String, Object>> personBasic = queryFactory.getByCardID(cardID);
        if (CollectionUtils.isNotEmpty(personBasic)) {
            String name = sourceData.get(ShareFields.姓名).toString();
            throw new RuntimeException("【" + name + "】,已经在系统中登记!");
        }
    }
}

逻辑判断组件依赖了cacheJob(数据缓存任务对象),可获取任意想要的数据缓存组件反馈的数据。示例只抛出了原生的Runtime异常,如果模板需要对异常做更细致的处理,可以在逻辑判断组件中使用自定义异常类。

有的读者可能注意到PersonBasicQueryFactory(人员信息数据查询工厂对象),使用的是cardID(身份证号码)为条件查询的数据,而人员信息数据表是以个人编号拆分的数据,这样的数据检索条件性能是比较差的。前面的PersonBasicObserver数据观察者组件,已经为Redis收集了数据,那么证件号码就表示已经在Redis中建立了索引,这样在使用证件号码检索时,如果相同身份证号码已经注册过,那么就可以从缓存中检索到人员信息,即表示人员已登记。

示例中的逻辑判断在前端输入人员信息时就可以做到,是否还需要使用逻辑判断组件再做一次?首先,这是一个示例代码,旨在说明组件要如何使用;其次,从业务角度出发,有的逻辑判断虽然在前端可以做到,出于严谨的目的,建议也要把它完善在业务功能中,这也算是对广大从业者的一点建议吧。

最后在功能扩展方面,如果需要增加新表保存额外信息,可以直接添加DataFactory与Observer组件,再用JobConf添加Observer组件即可,非常简单就实现了开闭原则。

3.6.4 车辆租赁

车辆租赁涉及到人员信息、车辆信息,最终生成租赁信息,需要注意在用户选择车辆信息并下单时,要排斥另一个人对同一车辆车下单,示例代码如下:

/**
 * Description 车辆租赁业务
 */
@RestController("/car")
@Api(tags = "车辆租赁")
public class RentalOrderController {

    /**
     * Description 租赁下单
     * 
     * @param  para 参数集
     * @return
     */
    @SuppressWarnings("unchecked")
    @RequestMapping(value = "/rental", method = RequestMethod.GET)
    public String rental(@RequestBody Map<String, Object> para) {
        if (MapUtils.isEmpty(para))
            throw new RuntimeException("参数不可为空!");

        Map<String, Object> orderInfo = (Map<String, Object>) para.get(ShareStoreName.租赁订单信息);
        Map<String, Object> carInfo = (Map<String, Object>) para.get(ShareStoreName.车辆信息);
        String carID = carInfo.get(ShareFields.汽车ID).toString();
        Map<String, Object> personInfo = (Map<String, Object>) para.get(ShareStoreName.人员信息);

        // 业务编号
        String busiID = "RentalOrder000001";
        // 日志ID
        String logID = GenerateSeqUtil.assignID();
        // 业务流水号
        String busiSeq = GenerateSeqUtil.assignSeq();
        // 业务粒度,通常情况下,可以认为同一粒度下不可同时执行。
        String busiGranularity = carID;
        // 模板需要的业务参数
        Map<String, Object> param = new HashMap<String, Object>();
        param.put(ShareStoreName.车辆信息, carInfo);
        param.put(ShareStoreName.人员信息, personInfo);
        // 模板需要的原子业务集合
        List<Map<String, Object>> orderList = new ArrayList<>();
        orderList.add(orderInfo);
        SingleTemplate template = new SingleTemplate(logID, busiSeq, busiGranularity, param, orderList);
        JobConf jobConf = new JobCon();
        jobConf.setBusinessID(busiID);
        jobConf.setLogUnitField(ShareFields.汽车ID);

        jobConf.addCacheStore(ShareStoreName.租赁订单信息, RentalOrderCacheStore.class);

        jobConf.setFlush(RentalOrderDataFlush.class);
        jobConf.setAtomic(RentalOrderAtomicBusiness.class);

        jobConf.addObserver(RentalOrderObserver.class);
        jobConf.addObserver(ModifyCarStatusObserver.class);

        jobConfig.addBeforeCommand(CheckCarStatusCommand.class);
        jobConfig.addLastCommand(CheckCarStatusCommand.class);
        
        template.doStart(jobConfig);

        return busiSeq;
    }

业务粒度使用了车辆ID,也就是确定车辆订单的同时,只要carID已存在于业务日志中(有可能是另一个用户抢先确认了订单,但租赁业务还并未完成),则模板会自动做排斥判断,也就实现了多人不能同时租赁同一辆车的目的。

3.6.4.1 数据缓存组件
public class RentalOrderCacheStore extends AbsCacheStore<Map<String, Object>> {

    public RentalOrderCacheStore(Map<String, Object> para) {
        super(para);
    }

    @Override
    public void cacheData() {
        // 什么也不做。
    }

    @Override
    public Map<String, Object> getData(Map<String, Object> sourceData) {
        return this.para;
    }
}

这里只使用了租赁订单信息,因为人员信息与车辆信息已经在模板的参数param中,在原子业务组件中可以直接获取。orderList中包含的就是订单信息。

3.6.4.2 数据刷新组件
public class RentalOrderDataFlush implements IDataFlush {

    @Override
    @SuppressWarnings("unchecked")
    public List<Map<String, Object>> flush(ICacheJob cacheJob) {
        List<Map<String, Object>> result = new ArrayList<>();
        Map<String, Object> orderInfo = (Map<String, Object>) cacheJob.getTaskObject(ShareStoreName.租赁订单信息).getData(null);
        if (MapUtils.isNotEmpty(orderInfo))
            result.add(orderInfo);
        
        return result;
    }
}
3.6.4.3 原子业务组件
public class RentalOrderAtomicBusiness extends AbsAtomicBusiness {

    @Override
    @SuppressWarnings("unchecked")
    public void run(JobConfig jobConfig, ICacheJob cacheJob, AbsCommandComposite commandComposite, Map<String, Object> sourceData) {
        Map<String, Object> personInfo = (Map<String, Object>) sourceData.get(ShareStoreName.人员信息);
        if (MapUtils.isNotEmpty(personInfo))
            sourceData.putAll(personInfo);
        Map<String, Object> carInfo = (Map<String, Object>) sourceData.get(ShareStoreName.车辆信息);
        if (MapUtils.isNotEmpty(carInfo))
            sourceData.putAll(carInfo);

        commandComposite.execute(cacheJob, sourceData);

        sourceData.put(ShareFields.业务属期, AbsFunctionUtil.getInstance().getSysdate(6));
        sourceData.put(ShareFields.租赁时间, AbsFunctionUtil.getInstance().getSysdate(14));
        sourceData.put(ShareFields.订单流水号, sourceData.get(ShareFields.业务流水号));
        sourceData.put(ShareFields.订单状态, OrderStatusEnum.已接订单.getCodeValue());

        return null;
    }

因为模板的param参数里已经包含了人员信息与车辆信息,所以在原子业务组件的sourceData中就包含了他们,可直接取出来使用。从代码中可以看到,sourceData中虽然包含了人员和车辆信息,也会取出并再putAll到sourceData中,这是为了数据流转到下一阶段时,涉及到的组件(多数是为了Observer和DataFactory)只认为value是一个值,而不关心是否存在一个数据对象,这样做,也是为了组件的通用性更好。

3.6.4.4 数据观察者组件

// 租赁订

public class RentalOrderObserver extends AbsObserver {

    /**
     * Description 构造函数
     * 
     */
    public RentalOrderObserver() {
        super(new RentalOrderDataFactory());
    }

    @Override
    public void collect(Map<String, Object> sourceData) {
        this.dataFactory.setData(sourceData);
    }
}

// 修改车辆订单状态

public class ModifyCarStatusObserver extends AbsBasicObserver {

    public ModifyCarStatusObserver() {
        super(Long.valueOf(FunctionUtil.getInstance().getSysdate(20)), new ModifyCarStatusDataFactory(), new CarBasicCacheFactory());
    }

    @Override
    protected void dbCollect(Map<String, Object> sourceData) {
        this.dataFactory.setData(sourceData);
    }

    @Override
    protected void cacheCollect(Map<String, Object> sourceData) {
        this.basicCacheFactory.setData(sourceData);
    }

}

用户租赁成功后,会修改车辆状态,以免作为可用车辆再被其它用户检索出来。因为车辆信息属于基础数据,所以在观察者组件里,既要完成数据表更新(ModifyCarStatusDataFactory),同时也要完成缓存的更新(CarBasicCacheFactory)。

3.6.4.5 数据观察者的扩展

在3.5.4章节中提到,汽车租赁信息是需要同步到HBase中的,这时,可以扩展数据观察者组件来完成这个目的,设计如下:
在这里插入图片描述
租赁订单数据工厂就是现在的实现RentalOrderDataFactory,数据工厂接口增加了数据工厂装饰类,且可聚合数据工厂的实现,数据工厂装饰类有五个(粒度)的数据工厂子类(这里没有展示个人与车辆各五个粒度的数据工厂),那么重构一下RentalOrderObserver代码,如下:

public class RentalOrderObserver extends AbsObserver {

    /**
     * Description 构造函数
     * 
     */
    public RentalOrderObserver() {
        super(new CarFamilyCompositeDataFactory(new PersonFamilyCompositeDataFactory(new OrderFamilyCompositeDataFactory(new RentalOrderDataFactory()))));
    }

    @Override
    public void collect(Map<String, Object> sourceData) {
        this.dataFactory.setData(sourceData);
    }

}

其中CarFamilyCompositeDataFactory、PersonFamilyCompositeDataFactory和OrderFamilyCompositeDataFactory就是人员、车辆和租赁订单的组合类,上图是以订单为例,那么以订单的组合代码为例:

public class OrderFamilyCompositeDataFactory extends AbsHBaseDecoratorDataFactory {

    /**
     * Description 构造函数
     *
     * @param dataFactory
     */
    public OrderFamilyCompositeDataFactory(IDataFactory dataFactory) {
        super(new OrderOfSubModelDataFactory(new OrderOfModelDataFactory(new OrderOfBrandDataFactory(new OrderOfCityDataFactory(new OrderOfRegionDataFactory(dataFactory))))));
    }

}

这样,租赁订单的观察者就扩展完了。当然,也可以选择一个表所有列族提交一次数据,这样就需要新增一个数据观察者组件,且需要在里面实现收集多个数据结构的集合给不同的列族。示例中的实现是在一个数据观察者中只收集一种数据结构的集合即可,但是需要向HBase提交多次数据。

3.6.4.6 逻辑判断组件
public class CheckCarStatusCommand implements ICommand {

    @Override
    public void execute(ICacheJob cacheJob, Map<String, Object> sourceData) {
        String carID = sourceData.get(ShareFields.汽车ID).toString();
        CarBasicQueryFactory queryFactory = new CarBasicQueryFactory();
        List<Map<String, Object>> carInfo = queryFactory.getByCarID(carID);
        if (CollectionUtils.isEmpty(carInfo))
            return;

        String status = carInfo.get(0).get(ShareFields.车辆状态).toString();
        if (CarStatusEnum.租赁中.getCodeValue().equals(status))
            throw new RuntimeException("车辆已被预订!");
    }

}

在前面的代码示例中,此组件被以addBefore和addLast两种方式添加到模板中,表示在业务前与业务后都会判断一次用户选择的车辆是否已经被租赁出去。通过模板的排斥功能,也只能处理租赁业务针对同一辆汽车同时发生时的情况,如果两个用户都在前端通过检索功能找到了同一辆汽车,当一个用户确认订单时,另一个用户已经先于他完成了订单,这时逻辑判断组件就可起到应有的作用了。

3.6.4.5 数据检索组件

这个功能还有一点需要说明,前端需要展示可用车辆,才可以准确下订单,所以,检索可用车辆是一个非常重要的实现。一般情况下,用户为了方便取车,会检索自己所有行政区划下的可用车辆,如果没有的情况下,会全市的可用车辆中选择自己需要的。因为DRDS中的车辆信息表使用了车辆ID对数据进行拆分,所以无论是以行政区划为条件,还是以注册城市为条件检索数据库,都会导致全表扫描,所以需要在数据检索工厂中实现缓存索引的检索,设计如下:
在这里插入图片描述

基础数据检索工厂和基础数据仓储分别扩展了数据检索工厂和数据仓储,并提供了获取Key下所有数据的方法,这样就可以方便获取缓存中的索引,代码示例如下:

public class CarBasicQueryFactory extends AbsBasicQueryFactory {

    /**
     * Description 构造函数
     * 
     */
    public CarBasicQueryFactory() {
    }

    /**
     * Description 构造函数
     *
     * @param repository
     */
    public CarBasicQueryFactory(AbsBasicRepository repository) {
        super(repository);
    }

    /**
     * Description 通过注册城市获取
     * 
     * @param  city      注册城市
     * @param  startDate 开始时间
     * @param  endDate   终止时间
     * @return
     */
    public List<Map<String, Object>> getByCity(String city, Integer startDate, Integer endDate) {
        if (StringUtils.isEmpty(city))
            throw new RuntimeException("参数注册城市不可为空!");
        if (startDate == null)
            throw new RuntimeException("参数开始时间不可为空!");
        if (endDate == null)
            throw new RuntimeException("参数终止时间不可为空!");

        this.repository = new CarBasicRepository(new BasicCityIndexCacheEntity());
        Map<String, Object> args = new HashMap<>();
        args.put(ShareFields.注册城市, city);
        args.put(ShareFields.汽车ID, "");
        args.put(ShareFields.车辆可用开始时间, startDate);
        args.put(ShareFields.车辆可用终止时间, endDate);

        return this.getData(args);
    }

    /**
     * Description 通过注册行政区划获取
     * 
     * @param  region    行政区划
     * @param  startDate 开始时间
     * @param  endDate   终止时间
     * @return
     */
    public List<Map<String, Object>> getByRegion(String region, Integer startDate, Integer endDate) {
        if (StringUtils.isEmpty(region))
            throw new RuntimeException("参数注册行政区划不可为空!");
        if (startDate == null)
            throw new RuntimeException("参数开始时间不可为空!");
        if (endDate == null)
            throw new RuntimeException("参数终止时间不可为空!");

        this.repository = new CarBasicRepository(new BasicCityIndexCacheEntity());
        Map<String, Object> args = new HashMap<>();
        args.put(ShareFields.注册行政区划, region);
        args.put(ShareFields.汽车ID, "");
        args.put(ShareFields.车辆可用开始时间, startDate);
        args.put(ShareFields.车辆可用终止时间, endDate);

        return this.getData(args);
    }

}

在设计关于城市与行政区划索引时,并未包含时间属性,所以开始时间与终止时间可以在BasicCityIndexCacheEntity.getData方法中,直接过滤Scan出来的数据即可,至于前端可能还会选择汽车品牌、级别等条件,可以在AbsQueryFactory增加一个可做数据过滤的组件,在QueryFactory执行数据检索前,告诉工厂需要使用的数据过滤组件即可,示例就不再详细描述了。

3.6.5 优惠通知

优惠通知功能可对系统内所有人员无差别进行发送,此功能使用了xxlJob做分布式任务调度,代码示例如下:

@JobHandler(value = "noticeJobHandler")
@Component(value = "noticeJobHandler")
public class NoticeJobHandler extends IJobHandler {

    @Override
    @SuppressWarnings("unchecked")
    public ReturnT<String> execute(String para) throws Exception {
        Map<String, Object> paraMap = (Map<String, Object>) AbsTransformUtil.getInstance().jsonToMap(para);
        if (paraMap.get(ShareFields.任务ID) == null)
            throw new RuntimeException("参数中任务ID不可为空!");
        String logJobID = paraMap.get(ShareFields.任务ID).toString();

        if (paraMap.get(ShareFields.业务流水号) == null)
            throw new RuntimeException("参数中业务流水号不可为空!");
        String busiSeq = paraMap.get(ShareFields.业务流水号).toString();

        if (paraMap.get(ShareFields.通知内容) == null)
            throw new RuntimeException("参数中通知内容不可为空!");
        String notice = paraMap.get(ShareFields.通知内容).toString();

        List<Integer> keys = new ArrayList<>();
        ShardingUtil.ShardingVO shardingVO = ShardingUtil.getShardingVo();
        // 分片数
        int total = shardingVO.getTotal();
        // 当前分片索引
        int index = shardingVO.getIndex();
        int module = 100000;
        BigDecimal b_index = new BigDecimal(index);
        BigDecimal b_total = new BigDecimal(total);
        for (int i = 0; i < module; i++) {
            BigDecimal remainder = (new BigDecimal(i)).remainder(b_total);
            if (remainder.compareTo(b_index) == 0) {
                keys.add(i);
            }
        }

        String logID = GenerateSeqUtil.assignID();
        String busiID = "DiscountNotice000001";
        String busiGranularity = busiID;
        Map<String, Object> param = new HashMap<>();
        param.put(ShareFields.通知内容, notice);
        MultiTemplate template = new MultiTemplate(logID, logJobID, busiSeq, busiGranularity, keys, param);
        JobConf jobConf = new JobConf();
        jobConf.setBusinessID(busiID);
        jobConf.setLogUnitField(ShareFields.个人编号);

        jobConf.addCacheStore(ShareStoreName.人员信息, PersonBasicCacheStore.class);

        jobConf.setDataFlush(NoticeDataFlush.class);
        jobConf.setAtomicBusiness(NoticeAtomicBusiness.class);

        jobConf.addObserver(SMSNotificationObserver.class);
        jobConf.addObserver(WetchatNotificationObserver.class);

        template.doStart(jobConf);

        return ReturnT.SUCCESS;
    }

分布式任务调度采用了哈希算法,让“键”对模数取余,余数为0表示可在执行器上运行任务,也就是说,每个执行器会选择以“键”为范围的数据。此功能使用了Multi模板,它会以“键”为单位向短信发送服务、微信发送服务提交一次数据,也就说,一个执行器运行了多少个“键”的数据,就会向两个服务提交多少次数据。

调度任务也可由controller触发,示例如下:

/**
 * Description 通知业务
 */
@RestController("/notice")
@Api(tags = "通知业务")
public class NoticeController {

    @Resource
    private RestTemplate restTemplate;

    @Value("${xxl.job.admin.addresses}")
    private String adminAddresses;

    /**
     * Description 优惠通知
     * 
     * @param  para
     * @return
     */
    @RequestMapping(value = "/discount", method = RequestMethod.GET)
    public String discount() {
        Map<String, Object> paraMap = new HashMap<>();
        String jobID = GenerateSeqUtil.assignID();
        String busiSeq = GenerateSeqUtil.assignSeq();
        paraMap.put(ShareFields.任务ID, jobID);
        paraMap.put(ShareFields.业务流水号, busiSeq);
        paraMap.put(ShareFields.通知内容, "**********************");

        // NoticeJobHandler在xxlJob任务平台中的ID,调度平台通过这个ID通知执行器运行具体的任务。
        String xxlJobID = "10000***";
        Map<String, Object> map = new HashMap<>();
        // taskID
        map.put("id", xxlJobID);
        // 执行器参数
        map.put("executorParam", AbsTransformUtil.getInstance().mapToJson(paraMap));

        String url = adminAddresses + "******";
        restTemplate.postForEntity(url, map, String.class);

        return busiSeq;
    }

}
3.6.5.1 数据缓存组件
public class PersonBasicCacheStore extends AbsCacheStore<List<Map<String, Object>>> {

    private List<Map<String, Object>> result;

    public PersonBasicCacheStore(Map<String, Object> para) {
        super( para);
    }

    @Override
    public void cacheData() {
        Integer key = Integer.valueOf(this.para.get(ShareFields.).toString());
        PersonBasicQueryFactory queryFactory = new PersonBasicQueryFactory();
        queryFactory.noCaching(); //设置不使用缓存,直接通过数据库查询。
        this.result = queryFactory.getByKey(key);
    }

    @Override
    public List<Map<String, Object>> getData(Map<String, Object> sourceData) {
        return this.result;
    }

}

此组件使用人员信息检索工厂,每次检索一个“键”的数据, 这些数据就是业务进度的一个刻度。

3.6.5.2 数据刷新组件
public class NoticeDataFlush implements IDataFlush {

    @Override
    @SuppressWarnings("unchecked")
    public List<Map<String, Object>> flush(ICacheJob cacheJob, Map<String, Object> interPara) {
        List<Map<String, Object>> result = (List<Map<String, Object>>) cacheJob.getTaskObject(ShareStoreName.人员信息).getData(null);

        return result;
    }

}

此组件只获取了人员信息,并不需要对数据进行任何“刷新”操作。

3.6.5.3 原子业务组件
public class NoticeAtomicBusiness extends AbsAtomicBusiness {

    @Override
    public void run(JobCon jobConf, ICacheJob cacheJob, AbsCommandComposite commandComposite, Map<String, Object> sourceData) {
        commandComposite.execute(cacheJob, sourceData);

        // 如果通知内容需要加入一些个体化相关内容,可以在此处处理。

        return this.dataCarrier;
    }
}
3.6.5.4 数据观察者组件
public class SMSNotificationObserver extends AbsObserver {

    @Override
    public void collect(Map<String, Object> sourceData) {
        // sourceData中已包含了短信需要的内容

        // TODO 如果短信服务需要特定的参数对象,可在观察者定义全局属性,然后在方法内完成收集。

    }

    @Override
    public void commit() {
        // TODO 调用短信服务
    }

}
public class WetchatNotificationObserver extends AbsObserver {

    @Override
    public void collect(Map<String, Object> sourceData) {
        // sourceData中已包含了微信需要的内容

        // TODO 如果微信服务需要特定的参数对象,可在观察者定义全局属性,然后在方法内完成收集。

    }

    @Override
    public void commit() {
        // TODO 调用微信服务
    }

}

优惠通知功能的实现相对简单一些,只是需要由分布式任务调度触发,来提高效率。“键”的设计,可随时任意取出一部分数据,方便了调度的执行。

3.6.6 小结

在1.2章中提到要让关系与对象解耦,通过三个功能的实现,相信读者已经找到了解耦的门径。以Spring为例,它并未对混合范式存在的矛盾提出解决方案,相信很多人都有着同感,随着项目的深入spring的数据层会越来越臃肿,如果数据表发生变更,那么涉及到的数据层对象都会发生变更,与之存在依赖关系的业务层代码也不可避免的会发生变更,这种连锁反应,是我经历过的项目中,不可避免的痛。

在三个功能的示例中,就不会存在这样的连锁反应,因为领域层与数据层是松耦合关系,且各组件之间只在乎数据的流转,数据对象是通用的List结构,不再使用太过具体化的实体对象,这样数据层组件的变化,并不会引起领域层的连锁反应。

  • 6
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值