作者:不拔
面向对象是符合人认识事物的基本方法
人是怎么认识事物的
在面向对象出现之前,已有面向过程的分析方法,为什么面向对象被提出了呢?究其本质原因,人们发现面向过程并不是按照人正常认识事物的方式去分析软件,那么人究竟是怎么认识事物的呢,Yourdon 在《面向对象的分析》一书中提到,人类认识事物是遵循分类学的原理,分类学主要包含三点:区分对象及其属性;区分整体对象及其组成部分;不同对象类的形成及区分。
我们现在可以回想下我们认识事物的过程,是不是和分类学所提到的 3 个要点很相似,看到一个事物,大概会感知到它的组成结构是怎样的,形状是怎样的,属于什么分类。所以,人认识事物是以对象的视角切入的,然后赋于对象具体的概念,比如苹果、梨子、汽车等等概念名称。
分类与分层的两种思维
我们面对的现实世界是非常复杂的,应对复杂事物的有一个重要的方法即是抽象,抽象在实际应用过程中,又体现在两种方法上:分层和分类。分类即是将有差异的事物归类到不同的分组中,正如我们常听到的"物以类聚、人以群分"的道理一样,产生分类的原因有两点:一点是事物间的关联紧密程度,不需要将所有的事物都耦合在一起;另一点是人掌握事物是有局限的,只能掌握少量的要点,比如 5~7 个要点,超过了容易忘记。
分层是通过不同的视角看事物,每一层的关注点是不一样的,这种关注点不同是由自己的视角造成的,比如我们理解计算机,并不需要深入到二进制电信号去理解计算机。层次特性在软件设计中我们经常遇到,比如计算机体系结构、TCP 七层协议等,层次特性有一个特点:越往上越具体、越往下越抽象,越往上的内容越不稳定,也即是容易变化。
问题域到解空间的映射
我们把需要解决的问题称之为问题域,或者问题空间,把解决方案称之为解空间。正向上一小节中提到的事物有层次特性,不同的人理解的事物是站在各自理解的视角,这样大家的理解、沟通并不一致的。如果我们看到的问题空间是表层的,那么基于浅层次理解设计出来的方案就会不稳定,可能下次有一个小变化导致方案需要重新设计。
我们可以把一个软件划分成三层:场景、功能和实体,场景层是经常会变的,比如发放优惠券场景就非常多,比如有天降红包领取优惠、分享有礼领取优惠券、新人注册领取优惠券等,这种场景的更迭随着业务的调整变化得非常快,因此场景层是不稳定的。功能支撑某一些的场景集合,对比场景,功能相对而言稳定些,就像前面提到的发放优惠券场景,本质就是给用户发放优惠券,只需要提供发放优惠券的功能即可,至于哪些场景来调用它并不关注,但功能还是基于场景的集合抽象出来的,如果场景场景类型变化了,功能也就随之变化,比如担保交易和预售交易就不一样。实体是稳定的,以担保交易和预售交易为例,它的订单模型大致是一样的,只是新增加了一些信息而已。
因此,我们希望从问题空间到解空间,大家看到的、理解的是一致的,而且看到的是问题的本质而非表象,往往场景、功能是不稳定的,而面向过程又是以功能驱动的,所以在易变化的场景下,它面临的问题就比较多。比较稳定的是问题空间中的实体对象,所以面向对象分析是现实的需要。面向过程和面向对象是两个不同的视角的分析方法:面向过程是一种归纳的分析方法,由外到内的过程;面向对象是一种演绎的分析方法,由内到外的过程。
三个一致性
软件开发会经历需要分析、概要设计、详细设计、编码、测试、上线主要阶段,我们不希望每块是割裂的,比如分析做完之后,做设计阶段又要重新去做分析的工作,那么这里面就涉及到一致性的问题,即需求到分析的一致性、分析到设计的一致性、设计到编码的一致性。这样做的好处可以保证无信息失真,因此我们急需求一种分析设计方法能做到这一点,面向对象分析与设计就能做到,因此全流程是以对象作为分析与设计的目标,在最终编码中也都是对象。
面向对象的底层逻辑
提到面向对象,有部分人会提到封装、继承、多态等特性,然后这些并不是面向对象的本质特性,比如封装,面向过程中也有封装,多态面向过程也有体现,这些特性算不上面向对象特有的特性。面向对象的底层逻辑是基于现实事物做的抽象映射:现实事物对应软件中的对象,我们讨论解空间能对应到问题空间中的对象,两者是一一直接映射的,其它的分析方法是问题空间到解空间的间接映射。
面向对象分析与设计的全景图
我们面临的问题是什么
从顶层看,我们要完成需求到编码的工作,然而从需求到编码又会经过多个阶段,如需求分析、方案设计等,从大的层面讲,我们主要遇到三个问题:
1. 做什么的问题
看似这是一个简单的问题,但在复杂的业务场景下,对做什么的理解太重要了,因为不同的人对需求的理解是不同的,比如最近做了一个项目,有一个业务判断规则是只针对跨境订单计税,最开始开发同学的理解是判断卖家类型是否是跨境卖家,然而到了测试阶段,发现大家对这个业务规则判断理解是不一致的,跨境订单跟卖家类型是没有关系的,真正的跨境订单计税场景是 shipTo(收货地址)和 shipFrom(发货地址)国家地址是不一样的。在大项项目中,涉及到多个团队之间的协同,这样的问题异常突出。而且从业务诉求到产品需求,再到技术方案,这其中是经过了 2 次变换,每次变换是不同的角色在里面,大家的认识也会不一样。
2. 怎么做的问题
落实到事情具体要怎么做时,往往大家并不会出大的问题,怎么做偏具体执行阶段,程序员往往在逻辑严密性上没多大的问题,往往出问题是在第一个问题上,相当于方向弄错了,所做的工作也是无用的。
3. 方法指导的问题
我们往往希望不劳而获得到一种万能的方法,能够应对所有的问题,同时又看不起低级的方法,比如大部分人对用例分析方法嗤之以鼻,想要能体现技术水平高大上的方法。其实自上世纪 70、80 年代,软件的分析设计方法并没有太大的变化,而且在我们大学期间都学过,只是大家并不认为它是一种高大上的方法而已。
分析到设计的过程
在本节中,我们推导软件分析到设计的过程,由粗到细,最终落实到我们接触到的 UML 知识上。从需求提出到编码实现,这中间有两个关键问题:一是界定目标,即是定义清楚要做什么的问题,相当于是我们做事的方向、目标;二是具体如何做的问题,即通过怎样具体的方案支撑需求目标实现。因此,我们需要一种方法能够帮助我们界定目标和表示具体方案,而且是大家互认的一种通用的方法。
通过用例图可以帮我们界定目标,用例中有三个关键要素:用户、场景和目标。比如交易下单是一个用例,它的用户是买家,场景包含下单成功和下单失败两个场景,用例的目标是买家可以购买心仪的商品。当用例目标确定了,相当于界定了目标,知道需求要做什么,这个过程要反复和业务方确认好,至到最终大家对目标的理解是一致的,方向对了,具体怎么做就好办了。
具体怎么做用时序图表示,画时序图需要注意的一点是顶层的对象层次要一致,不能有的对象表示具体的实体对象,有的表示系统对象,即对象的层级是一致的,要么大家都是系统,比如导购系统调用交易系统,交易系统调用支付系统,要么大家都是对象,比如商品、订单等。通过时序图可以看到一个完整功能的执行步骤,它就包含具体执行的细节,如正常流程、异常流程。
其实在上面有一个问题,在画时序图时要确定好对象,那么这个对象是怎么来的呢?它是由健壮性图分析出来的,它里面有三个关键的对象:一个是边界对象,这个比较好理解,比如UI界面就是边界对象;另一个是控制对象,即是控制业务流程的对象,如下单服务就可以看作是控制对象;实体对象即是问题空间中的业务对象,比如订单。画健壮性图是有规则的,一般是边界对象调用控制对象,控制对象产生实体对象,比如用户下单界面是边界对象,下单服务是控制对象,订单就是实体对象。
寻找对象之路
对象从哪里来
在本文第一部分第三小节中已经提到,问题空间到解空间是一一映射,我们讨论解空间中的对象时,其实它映射到问题空间中的对象,而问题空间中的对象主要来源于业务概念、业务规则、关键事件。大部分的对象是显现的,我们通过理解业务能发现,有的对象是隐性的,需要我们持续对业务有更深的理解才能发掘出来。好的对象模型是需要经过多次迭代打磨出来的,并非一次就能设计得十全十美。
发现对象的方法
在本文第二部分第二小节中已经提到寻找对象的方法,不过那还只是关键显现的对象,在本节中主要讲述完整对象发现的方法,主要方法分成四个步骤:
1. 通过健壮性图找到关键的实体对象;
2. 通过结构分析方法找出更多的实体对象;
3. 将对象组成有机的对象模型;
4. 最后通过用例走查对象模型是否完备。
这里以一个案例来说明发现对象的过程,案例是用户在下单时,在订单上展示税的金额。首先画出健壮性图,这里的边界对象是下单界面,控制对象有两个,一个是下单服务,另一个是计税服务,实体对象也有两个,一个是计税单,一个是订单。有了计税单和订单这两个实体对象后,接下来通过结构分析方法,分析出更多的对象。
对象都是有结构的,只要我们掌握了对象的结构,基本上就能掌握对象的概貌,因此我们从对象的结构入手,去分析对象内部的结构、对象关联的结构,实质上是从两个维度出发:一是从自身的角度出发,看自己内部还包含了哪些对象,如主订单包含了子订单;另一个是从外部的角度出发,看自己还与哪些对象相关联,如计税单与订单是有关联的。这种找对象的方法我称之为结构分析方法,因为本身结构又是事物本质的一种表达方式,比如化学分子结构决定化学现象。
为了更好地表达出对象的结构,我的一个经验是给对象下好定义,下定义可以从不同的维度,比如功能性维度、价值性维度、目的性维度、结构性维度等,这里可以从结构性的维度去给对象下定义。以计税单为例,可以给它下一个定义:计税单是将订单金额信息转成若干个标的物计税的单据模型,从这个定义中,我们可以看到计税单是与订单有关联关系的,另一个是计税单是包含了若干个标的物,我们可以画出计税单的对象模型。
当对象模型画出来后,后续我们讨论业务基本上围绕这个对象模型去讨论业务问题的,比如商品标的物哪些金额要参与计税、计税金额的计算口径是怎样的,到这里,大家再体会下"问题空间到解空间一一直接映射"这句话,业务上的诉求也无非是哪些订单费用项要计税,计税的逻辑是怎样的,有可能在这个场景下要扣减金本位优惠,在另外一种场景下金本位优惠不需要扣减,基于对象模型与产品、测试同学讨论问题,大家都是处于同一个维度的视角看问题,沟通理解成本会少很多。
对象模型是一种可视化的表达,我们大部分的沟通问题是缺乏显性表达造成的,这句话可以这样理解,也可以那样理解,导致大家理解有偏差,现在用模型的形式沟通问题,很多偏差、歧义就消除了。
组织对象结构
当我们分析出一堆的对象后,还需要经过一定的组织,正如前面提到,人对事物理解是有局限的,不能一下子接受太多的事物,因此可以将它们分成一个个小的域,比如商品域、订单域、税务域等,这样当聚集一个问题时,可以只看某个子域里的对象模型即可。
如何分配职责
职责是怎么来的
面向对象最难的点有两个:一个是找出对象;另一个是分配职责。UML 把职责定义为"类元的契约或义务",因此职责的划分从本质来讲还是类元本身决定的,比如订单,它要提供订单渲染、订单创建、订单修改、订单查询的义务。
职责分为两类:一类是认知职责;另一类是行为职责。
- 认知职责包含:
- 对私有数据封装的认知。
- 对相关对象的认知。
- 对其能够导出或计算的事物的认识。
- 行为职责包含:
- 自己执行的行为,包括创建对象或计算。
- 初始化其它对象的动作。
- 控制或协调其它对象的活动。
分配职责的逻辑
上一小节中提到的职责有两类,认知职责是对象自身的认知范围,即它只能基于自身属性完成相应的职责,举一个例子,假如一主多子的订单,要计算总的订单金额,怎么分配职责呢?首先商品只能查到自身价格的信息,它的认识是基于商品 price 属性,一个子订单可以有多个商品,那么它也只能计算出子订单的金额信息,它的认知是基于 item 和 quantity两个属性,主订单包含所有子订单的信息,那么就可以计算出总的订单金额。
从上面的例子中我们可以看出,认知职责是基于对象属性的,正所谓"不在其位、不谋其政",认知职责一定不会超过它的认识范围的。
行为职责是偏领域服务的,有的时候一个职责不属于某一个对象,比如转账,就是一个行为,让其它的职责承担并不合适,这类行为职责往往是一个显著的业务活动,比如订单渲染、订单创建就是行为职责而非认知职责。
分配职责一定要遵循"信息专家"模式,它的含义是将职责分配给具有完成该职责所需要信息的那个类,也即上面提到的认识产生职责。
验证职责分配的合理性
我们期望分配的职责满足"高内聚、低耦合",怎么检验呢?我们再回过头来思考职责的定义:类元的契约或义务,换句话讲,职责是满足其它对象来调用的,这个就与我们画时序图的目的是一致的,每次发生一次调用,即意味着其它的对象要提供一个职责出来,因此我们可以在时序图中看对象间的调用频次,如果一个对象被调用得非常频繁,有可能这个对象承担了太多的职责,是不是可以对其拆分,把职责分配一部分出去。因此,对象职责分配并不是一蹴而就的,需要不断审视、检验。
分配职责是要遵循一定的原则,如创建者模式、信息专家模式、纯虚构模式等,这些原则会在下一篇中单独去讲。
案例
案例背景
这里举一个例子,说明面向过程和面向对象在分析、编写代码的差异性,计税需要判断是否满足计税规则,比如虚拟商品不计税(手机充值之类)、有些免税地址不计税、小 B 买家也不计税等,因此需要提供一个计税过滤判断逻辑。
常规面向过程实现
面向过程的思路很简单,提供一个过滤方法依次处理下面逻辑:过滤虚拟商品计税请求、过滤免税地址计税请求、过滤小 B 买家计税请求。
public void filter(List<TaxCalculateRequest> request){
// 过滤虚拟商品计税请求
filterVirtualItem(request);
// 过滤免税地址计税请求(即外岛)
filterOuterIsland(request);
// 过滤小B买家计税请求
filterPurchaseType(reqeust);
}
面向对象实现
面向过程是从过程视角或者是功能视角分析问题,而面向对象是从对象的视角分析问题,过滤计税请求是计税过滤器判断计税请求是否满足计税规则,这里就包含了两个对象:计税过滤器和计税规则,判断是否满足计税要求这个职责应该是在具体的计税规则处理器中,比如是否是小 B 买家等,因此我们可以画出对象模型。
关键代码如下:
public abstract class AbstractRuleHandler {
/**
* 抽象的业务规则处理
*
* @param request
*/
public abstract void handler(TaxCalculateRequest request);
/**
* 构造函数里完成注册
*/
public AbstractRuleHandler() {
TaxCaluclateFilter.register(this);
}
}
总结
在文章中提到,面向对象的底层逻辑是基于现实事物做的抽象映射,重要的不是要面向对象具体技术的使用上,而是分析问题的思维上,这是最难的,它最大的好处是问题空间到解空间是一一直接映射的,请注意是一一直接映射,它意味着我们在讨论方案的时候,完全可以映射到问题空间,如果是间接映射,也就意味着设计的方案后面会面临重新设计的可能性,因为它是基于场景或功能做出的归纳设计,而且是表层的设计。真正掌握了面向对象分析和设计的方法,也体会到其中的益处,对理解业务、方案设计、编码开发都有好处。