

👉目录
0 前言
1 面向对象的发展史
2 厘清概念
3 用例建模
4 面向对象的分析 (OOA)
5 面向对象设计OOD
6 后记
本文系统解析了面向对象思想在软件工程中的核心价值与应用体系。作者从发展史切入,厘清面向对象与UML、软件工程、DDD等概念的关系,重点阐述用例建模方法、面向对象分析(OOA)的建模过程(含静态/动态建模及深层模型)、面向对象设计(OOD)的职责驱动模式(GRASP),最终构建出从理论到实践的完整知识框架。
对每个研发人员都有非常强的阅读价值,建议点赞收藏,国庆期间细细品味
关注腾讯云开发者,一手技术干货提前解锁👇
00
前言
本文篇幅较长,避免浪费读者时间,建议:
若是只是了解面向对象,阅读第1~2章即可。
若是想系统性了解面向对象,可以全文阅读。
若是想深入学习面向对象,可以阅读第六章所列参考书目。
01
面向对象的发展史
面向对象,作为计算机编程语言中绕不开的概念,所有科班生都是从大学时稍稍入门后就开始接触了。然而诡异的是,在真正应用的时候,却又常常被忽视、被低估。
一方面,很多业务团队或者开发者,都不会另外强调面向对象,潜意识的把面向对象连同其思想丢进了废纸堆。这是对面向对象最大的误区,认为面向对象过时了,是上个时代的产物。
另一方面,一些开发者会说我每天编码的时候都在用面向对象,编程语言默认就是面向对象,所以没必要再去学习面向对象了。这是另外一个误区,即认为面向对象仅局限于编程语言,对面向对象的认知还停留在最初接触面向对象的水平。
因此有必要系统性的阐述一下面向对象的方方面面。
1.1 面向对象的定义
什么是对象?关于对象的概念,五花八门,似乎没有完整精确的定义,就像物理学中的物体、数学中的几何体、天文中的天体概念一样,是特定学科对事物的一种简化、理想化的认知。对象的英文Object 本身就是物体的意思。下面找到一些关于对象的描述或者定义。
1.1.1 对象可触摸的实体
这个观点只囊括可物理上的事物,没有涵盖思维上、感知层面以及概念层面的事物。例如社会主义是不是一个对象?乌托邦是不是一个对象?生活中的爱恨情仇是不是对象?从狭义的层面来看,物理上可感知到的事物最容易理解,但是在更广义的层面,人类的思想、认知、感情、还有更多微观世界、形而上的概念知识等都可以被看做对象。
1.1.2 对象是一切可叫出名字的事物
换言之,所有可以用名词表达的都是对象。 这句话乍一看也有道理,但仔细一想又有点滥用对象的嫌疑。 在有些时候,例如我们说车是个对象,在这个语境下,车的名字、颜色就不是对象,而是对象的一个属性。名字、颜色这两个词虽然也是名词,但是不具有动态性,所涵盖的内容过于单薄,尤其是计算机视角下无法承载一个独立概念。
1.1.3 对象代表了按模块分解的系统的组件,或者是知识表达的模块化单元
这个说法已经非常精准了,但是又太抽象不够具体。
以上都是从更抽象、普适的层面给出面向对象的定义。要得到关于对象更精准的定义还是回到计算机体系内。
1.1.4 对象是计算机世界中封装数据与行为的实体
这个说法已经非常精准了,但是又太抽象不够具体。
以上都是从更抽象、普适的层面给出面向对象的定义。要得到关于对象更精准的定义还是回到计算机体系内。
1.2 面向对象的发展

编程语言发展史
1.2.1 第一代:语言数学翻译器
代表语言:Fortran algol flowmatic
在19世纪50年代,高级语言,主要是为了解决数学中的公式计算问题。只支持基本的数据类型,所有的计算逻辑都在一个文件中完成。本质上就是数学表达式。但是好在,引入了编译器,人们不再手写机器码来驱动计算机工作。相较于更早期的汇编型语言,已经取得了很大进步。
1.2.2 第二代:过程式编程
代表语言有:Fortran 1、 Lisp 和 Cobol 三者都是聚焦于解决某个细分场景的问题。
-
Fortran :专为科学计算和数值计算而设计。
-
Cobol :专为商业应用程序开发而设计。
-
Lisp :为人工智能研究而设计。
第二代编程语言,以子程序结构的引入为标志,意味着编程语言有了基本的封装和复用能力,人们可以专注于把一些计算逻辑或者算法封装到一个独立的基本单元中去。子程序之间可以通过共享全局数据来协作,在数据类型上仍然没有突破固有限制。通过对子程序的编排可以实现更为复杂的业务逻辑处理。子程序都是一个算法,表达一个动作,驱动子程序之间的协作这种模式也被称作面向过程的开发。
1.2.3 第三代:结构化编程(1960s-1970s)
代表语言:Pascal (1970)、C (1972),
主要特点:
-
引入控制结构(if-else、while、for等)。
-
使用函数/子程序进行代码组织。
-
强调代码的模块化和可读性。
-
避免使用goto语句。
-
支持用户自定义数据类型。
结构化编程在过程式编程基础上,增加了更严格的程序结构控制,同时提供了模块化的组织能力。在过程式开发中,子函数往往只能表达单一独立的动作语义。有了模块化能力,封装的能力进一步提升,可以通过不同控制语句把子程序组织成一个具有丰富业务语义的模块。区别于过程式编程,结构化编程中在数据层面支持能够创建具有更丰富表达能力的结构体,例如c语言中的struct。 自此,编程语言不仅可以在算法上通过子函数和模块化来封装,还可以在数据层面进行封装抽象。
1.2.4 第四代:面向对象编程
代表语言:Smalltalk (1980)、C++ (1985)、Java (1995) 。
主要特点:
-
将数据和操作数据的方法封装在对象中。
-
支持继承、多态等面向对象特性。
-
强调代码的重用性和可扩展性。
第四代编程语言,对用户自定义的数据类型进一步扩展,引入了类的概念,把类作为一系列拥有相同数据和行为的抽象。而对象则是类的一种具体化表现。类的出现极大的提高了编程语言的封装和抽象能力。从类和对象的视角来看,人们可以用更加贴合人类认知方式去操控计算机。
面向对象在编程语言的诞生后,随着UML等可视化的表达体系完善,面向对象也开始在软件领域中的分析与设计环节中崭露头角。至今在软件领域过程中已经衍生出大量的应用,包括面向对象编程OOP、面向对象设计OOD,面向对象分析OOA,下文将结合软件领域过程阐述。不过在此之前还是先来厘清几组概念,识别这些概念有助于加深对面向对象的理解。
02
厘清概念
2.1 面向对象与uml的关系

UML的发展
UML是统一的可视化建模语言。UML在面向对象成熟的基础上发展而来,UML为面向对象提供了丰富的描述和表达工具。二者相互成就,面相对象思想的成熟推动了UML标准化的制定,UML的标准化又进一步推广了面向对象的使用场景。UML之于面向对象系统,如同透视法之于绘画艺术,为计算机科学提供了一种对现实世界规范化的表达范式,使得软件工程对现实世界的抽象描述获得了方法论层面的跃升。
2.2 面向对象与软件工程的关系
面向对象是方法论,软件工程是一系列工序组成,包含需求、设计、实现、测试、部署。

面向对象可以作为方法辅助软件工程。配合着UML建模工具,面向对象让软件工程从手工化时代进入到了工业化时代,人们能够管理和控制更加复杂的系统。脱离了面向对象,软件工程的发展势必艰难很多。在软件工程中,分析常常被表达为需求分析,而设计被表达为承接需求分析到代码的中间过程。实际上,在通常意义上的设计又隐含了业务模型分析与设计两个工序。当提到分析的时候,要意识到是哪个工序层面的分析。需求分析着眼于需求的理解和建模。分析与设计层面的分析着眼于构建业务领域模型,还原业务本质。在软件工程中,面向对象被应用在三个环节: 面向对象的分析(OOA)、面向对象的设计(OOD)、面向对象的实现(OOP)。
2.3 分析与设计的关系

实物、分析与设计的关系
分析的目的是还原业务本质,他回答的是:业务是什么的问题。其交付的产物就是通常所说的概念模型或者领域模型。
注意,在分析阶段不涉及技术细节,分析阶段是不受技术实现的约束的。
所谓的还原业务本质,强调的是本质,如果说还原业务是构建的是概念模型,还原业务的本质则是构建业务的深层模型。仅仅是依葫芦画瓢的还原的模型是不稳定的,很容易随着时间推移而改变。只是这个模型究竟要本质到什么程度,则需要把握和权衡。换句话,抽象到什么层次的业务模型才是合适的?
过于抽象的模型虽然稳定,但是脱离实际,不利于理解,过于简单的模型又流于表面,随时会受业务特性变迁和人员变动的影响。在 martinfowler 的分析模式中提到一个例子,要做一个模拟斯诺克的软件。
最简单的模型就是球、球杆、球桌。球具有静态属性颜色和号码,还有动态属性位置坐标。球杆的属性可以有力以及大小,球桌是一个拥有坐标体系的平面,平面上有不同的球洞分布。球与球杆撞击的时候会随机出一条路线。这就是最简单的概念模型。
但是这种模型无法模拟出球杆打击球时运动轨迹的效果。其深层模型是牛顿的力学与运动学定律。基于这个模型再去分析斯诺克就很贴切了。顺着这个思路,为啥不用爱因斯坦相对论模型呢?显然爱因斯坦的模型更具有普适性。这个模型虽然也符合,但就会因为过于抽象引入了不必要的复杂。
设计阶段,是从技术视角对分析阶段的概念模型进行扩展和细化,给出技术性的解决方案,涉及到需要规划模块、划分多少接口、类之间关系如何、用什么存储、使用什么语言以及如何处理各种异常、并发等问题。
分析的交付产物会作为设计阶段的输入,而设计阶段的交付产物又会指导编码阶段的落地,最终反向表达模型。设计阶段的交付产物会作为指导编码的关键依赖。
分析与设计的关系,就像做物理题或者数学证明题一样。分析是理解题干意思,根据题干意思画出对应的物理或者数学模型图,而设计就是基于这个模型就求解,可能是加几条辅助线,增加如果假设条件等。
分析与设计的边界常常被有意无意模糊掉,另外很多程序员大多从开发做起,很少有机会自上而下参与从需求、分析、再到设计、实现全链路。加之互联网行追求的敏捷快速迭代模式,更不会给建模留下太多时间。大多数情况下,能跑就行,导致的后果,开发人员普遍重设计而轻分析。但实际,我们在过往的工作经验中或多或少都涉及到分析的工作。回想一下我们平时交付的方案文档中,其中都会包含着一个业务架构图或者功能图,这就是分析的产物。这种框框图到处都是,只是以一种约定俗称的共识来进行。我们很多日常也一直做着分析这个事,只是没有显现的表达出来,也没有用正规的普适的表达方法呈现出来。
2.4 面向对象与建模的关系

使用面向对象思想进行软件分析得到的产物是就是模型,这个过程就是建模。模型的出现早于软件工程的诞生,更早于计算机的诞生,所以这不是一门新学问。模型是人类简化理解这个世界的方式,按照不同的视角理解世界会得到不同的模型,例如心理学中的MBTI模型、经济学中的供需模型、物理学中的天体运行模型等等。
面向对象的分析就是使用软件工程的视角,借助面向对象方法对世界进行建模。
分析中面向对象是方法论,模型是目的。在分析的时候要跳出工程师的视野、技术概念束缚,回到本源。
想象一下,银行存取业务已经发展了几百年,而计算机软件的发展才不足百年。在计算机发展之前银行业务的运行也是很稳定的,如果要对银行业务建模,就要摆脱现有计算机技术的窠臼。
想一想,没有计算机之前应该是什么样?
再想一下,没有计算机资源的约束下,这个模型该是怎么样?
就像物理学中常见的假设摩擦力为0,经济学中的理性人假设,科斯定理中的交易成本为0的假设。这些假设是模型得以适应的前提,那面向对象分析中的假设又是什么呢?
面向对象分析的假设是技术层面的约束为0,也就是拥有无限的计算资源,无限大的带宽和存储,拥有绝对的一致性保障机制。
这种思维方法也叫阿布思考法。基于这样的假设,再去深入到业务中做分析,便可以去除很多障碍,简化模型,更本源的还原业务。
2.5 面向对象与DDD的关系

本文提到的DDD《领域驱动设计》是Eric Evans 于2004 年提出的一种软件设计方法和理念。这个方法在微服务时代大行其道,也被很多业务团队使用。在学习面向对象过程,很自然的就会将其与DDD的差异。更具体的来说:
面向对象的分析与设计与DDD究竟有何异同?
DDD认为传统的软件工程从分析到设计再到实现,在传递过程中必然会引起信息丢失。
-
一方面分析出来的模型往往因为没有经过实现的检验,会缺失细节或者未挖掘到本质的模型。
-
另一方面,很多业务知识或者隐式概念只有到了实现层面才能暴露出来。
-
最后,在实践层面,分析与设计的分离的情况下,无法对实现进行强约束,开发人员在实现代码时, 在技术与业务模型上有冲突的时候,会采用妥协的方式,一步步的突破模型,最终导致实现与模型差异越来越远。
DDD的理想情况是把分析、设计、实现全都打通,破除建模人员、编码人员之间的边界。其理想是 "代码就是模型的表达,改变代码就改变模型,程序员就是建模人员。"。而面向对象恰恰相反,面向对象中分析、设计、编码严格分离,但又紧密连接,相互依赖,上游的交付产物是下游深加工的原料。在不同的抽象层面,关注不同问题域,最终完成业务功能的交付。
那DDD是在通过什么方式来把分析、设计、实现融为一体呢?其关键就在于创造了一套既能表达模型概念、又绑定代码实现的语言概念体系。举个例子,DDD 中的模型语言 Service 天然就对应开发中的接口。其中聚合根、实体、值对象、资源等概念既有业务语义,又能很自然的应用到代码中。 理想很美好,但 DDD 依然以其枯燥难以入手、不得要领著称。
例如Service、factory这两个概念,个人认为是两个很失败的概念,极易混淆,设想一个没有编程经验的人去做DDD是很难理解这个概念的,而做过设计的人又极易被这两个词语带入到技术实现的层面,可谓两边都不讨好。
之前也看了很多DDD书籍和相关文档,说实在的总感觉云里雾里的,总是难以深刻理解。发现大部分要么在照本宣科解释 DDD 中的概念名词,要么在从开发的视角去看怎么从代码层面适应 DDD 模型。鲜有详细推导如何从 DDD 来构建一个业务模型的。更极少能看到严格的DDD建模实践流程,即先做啥,再做啥,最后做啥,每一个环节交付啥,又如何把交付内容传递下去。
Eric Evans的书更大的价值在于提供了很多思想层面的突破和贡献,真要实践下来,难度不小。实际上,首次读这本书,也是被其中的一些具有深刻洞察的概念给震撼到。例如"值对象"这个词的定义既精准又通俗,比面向对象中的属性更精确。另外像统一语言、隐式概念挖掘、核心域、支撑域等洞察也非常独到而深刻。
另外,DDD的目标是把分析、设计、实现融为一体,但并不意味着去掉了分析。分析这个过程依然是重中之重。
很多业务实践或者开发者脱离了 DDD 的初衷,舍本逐末,一味的追求形式上的 DDD,忽略业务模型的分析与挖掘。
核心原因还是没有识别到分析的重要作用。DDD 无论提出什么样的概念和思想,其核心都是去分析,去建模。用DDD的体系先分析出一个业务模型,服务划分和代码组织自然就出来了。
当然这里并不是说DDD不好,而是想表达DDD落地之难,不在于其思想,而在于其形式上如何结合业务形成一个标准化的SOP流程。
值得注意的是,很多开发人员是从MVC模式或者传统的ao\dao分层模式过度到DDD,误以为DDD是MVC、ao\dao这种模式的升级与迭代。这是一种张冠李戴式的认知,把两种不相关的东西嫁接成继承与发展的关系。
这种自主不自主的潜在认知,是很多人无法真正理解DDD的根源。实际上,从学术血缘上来说,面向对象与DDD二者是方法论上的继承、替代与互补关系,二者都是软件工程的方法论,都是聚焦于完成软件工程中的业务到模型再到编码的过程。DDD在某些方面继承了面向对象的成果,例如领域层这个概念早在martinfowler的1996年出版的<分析模式>一书中就有提到过。DDD成书于是2003年,彼时面向对象思想与技术已经非常成熟。
在学习DDD的过程笔者也走了很多弯路,一直不得要领,直到最近对面向对象有了更系统性的认知后,再回头看DDD,有豁然开朗之感。Eric Evans《领域驱动设计》依然是一本值得反复阅读的好书。
所以如果你在DDD的学习中仍然处于混沌状态,不防先花点时间,学习一下面向对象的知识,识别出分析与设计的区别,再进入DDD,可能会事半功倍。
03
用例建模
下面我们正式进入软件工程视角下的面向对象,主要阐述面向对象的分析与设计。在此之前,有必要说一下用例建模。虽然该步骤不涉及面向对象的思想,但是其与后续的分析与设计紧密相关。用例建模的交付产物是用例规约,用例规约可以作为面向对象分析的原始材料。严格来说,用例规约是分析得以进行的先决条件。
用例规约以介于自然语言和程序语言之间的形式来表达业务需求。比自然语言更精准,比代码语言更精炼。
用例建模的过程就是把混沌的思维、模棱两可的语言、表述不清的文字转换成精确的形式化语言。
当然,不好的一面是用例规约的书写和阅读都有一定的成本,尤其是阅读用例规约不似自然语言那样流畅,在互联网研发体系下,很多业务团队会把用例建模这一步骤省掉。
3.1 用例定义
用例use case,顾名思义是使用案例的意思。本质上是用文本形式表达的系统与使用者之间的契约。 描述的是软件系统对外部不同请求所做出的响应。用例把系统当做一个黑盒,忽略其内部的细节,关注系统与外部执行者之间的交互序列。其中与系统交互的又叫执行者,执行者可以是人、其他设备、系统或者时间。以银行系统为例:
用户执行者:用户可以通过ATM机进行取钱、查看余额、存钱、转账。
时间执行者:用户可以设置定时转账给其他账户,参与方是时间。
系统执行者:用户在微信支付设置了代扣功能,微信支付作为参与方触发银行进行转账。
3.2 用例与用例图
值得注意的是,区别于我们常用UML中画的用例图,用例的载体是文本,有固定的格式和规范。因为是面向真实的用户,所以采用自然语言似的伪代码来表达。相比于代码形式略显啰嗦,相比于自然语言又显得晦涩生硬。用例关注的是功能层面的完整性,避免像实现层面那样事无巨细。所以在写用例的时候,要注意表达的是执行者与系统之间的交互序列,避免涉及系统内部的实现细节。而用例图可以看成是用例的索引。 如下图所示:

用例图:主要包含三个部分,一个小人、箭头和椭圆。其中小人表示执行者,箭头标识二者之间的关联,椭圆里面的文字代表的是用例名称,一般动宾结构。以购买保险为例:
|------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 用例名称 | 购买保险 |
| 参与者 | 主要参与者:投保人 |
| 前置条件 | 1.投保人已登录保险平台 2.投保人年龄、身份等符合投保要求 |
| 主路径 | 1.用户通过保险系统搜索目标产品 2.系统展示产品详情(保障范围、保费、条款等) 3.用户输入简要信息(年龄、性别)并设置保障方案(保额、保障期限等) 4.系统根据用户输入计算并展示预估保费 5.用户填写投保、被保人等详细信息,并完成健康告知 6.系统验证信息格式有效 7.系统启动核保流程,并给用户反馈核保状态 8.系统确认核保状态为通过 9.用户核对保单信息,并选择支付完成付款 10.系统生成电子保单,并发送至用户邮箱 |
| 扩展路径 | 8.a 核保不通过需要补充资料 8.a.1 系统提示用户补充体检资料 8.a.2 用户提交体检资料 8.b 核保状态为拒绝 8.b.1 系统提醒用户无法投保 8.b.2 用户关闭页面 |
用例之间可以形成包含关系和扩展关系,用例可以作为一个独立的逻辑构件被包含或者扩展。
3.3 写用例的一些准则
关于用例的书写,Cockburn在其书中《编写有效的用例》给出了10种最佳准则。简要阐述一下。
1.用简单的语法
句子要足够简单, 体现出主语+谓语+宾语就行了。例如:系统展示产品详细信息。
2.明确地写出"谁控制球"
类比于踢足球,在足球场上,当把球剔出去的时候,要明确知道球要传给谁,下一个控制球的是谁。
映射到用例中,即,写完一个步骤后,要清晰明确下一个步骤的执行者是谁。谁来执行接下来的步骤,是系统还是执行者。
3.从俯视的角度来编写用例
很多人写用例常犯的错误,是写着写着就混淆了系统内部和外部的动作。习惯性的从系统内部的视角来写用例,把系统的内部执行细节写出来了。例如上面购买保险用例中。
|----------------------------------------------|----------------------------------|
| 内部视角: | 俯视角度: |
| 系统读取http请求报文,转成具体结构体, 系统进行参数校验, 系统验证信息格式有效性。 | 用户输入简要信息(年龄、性别)并设置保障方案(保额、保障期限等) |
系统根据用户输入计算并展示预估保费。从俯视的角度来写,就是你站的很远,执行者和系统在你眼里就是两个圆点,你只能看到二者的交互,看不到其内在机理。
4.显示过程向前推移
描述动作步骤时,要体现动作的目标,而不是动作的细节。过多的体现目标就会让用例场景的推进会非常缓慢。
示例:用户点击输入框,用户输入姓名和密码。 修改后: 用户输入姓名和密码。
5.显示执行者的意图而不是动作
这里是指当在描述时,出现在同一个方向上连续的动作时,可以将其合并成步骤。例如:
|-----------------------------------------------------------------------|---------------------------|
| 包含详细动作 | 体现意图 |
| 1)系统要求用户输入名字。 2)用户输入名字。 3)系统要求输入地址。 4)用户输入地址。 5)用户点击"确定"。 6)系统显示用户的简介 | 1)用户输入名字和地址。 2)系统显示用户的简介。 |
这种写法可以减少用例文档的规模,降低不必要信息负担。
6.包含合理的活动集
一个步骤是一个独立的事务,逻辑上包含以下完整的四个部分。
|---------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| 独立的事务逻辑上包含四个部分 | 示例 |
| 1)主执行者向系统发送请求和数据。 2)系统验证请求和数据。 3)系统更改内部状态。 4)系统向执行者返回结果 | 1)用户输入订购号码。 2)系统发现这个号码与本月的中奖号码匹配。 3)系统将用户和订购号码注册为当月的中奖者。 4)系统发电子邮件给销售经理。 5)系统祝贺客户,并告诉他们如何领取奖金 |
7.使用"确认"而不是"检查是否"
"确认"是明确的动词,推动动作前进,而"检查是否"则是条件语句,不自然的后续就要跟着如果是 和如果否等条件从句。这会让异常分支过早的干扰主成功场景的叙述。
|----------------------------------------------------|------------------------------|
| 使用"检查是否" | 使用 "确认" |
| 1)系统检查密码是否正确, 2)如果正确系统向用户展示成功页, 3)如果失败系统提示用户重新输入密码 | 1)系统确认用户密码正确, 2)系统向用户展示登录成功页 |
8.可选择地体现时间限制
大部分的描述都是一步接着一步,有些动作可能伴随某几个步骤动作,这时候就可以提现时间。格式:当用户xxx时,系统xxx
当系统xxx时,用户将xxxx。示例:
-
用户浏览商品信息
-
用户编辑收藏夹
当在步骤1)和步骤2)的任何时候,系统将给用发送通知。
9.习惯用语:用户让系统A与系统B交互
|-----------------------------------|-------------------|
| 示例 | 优化后 |
| 1)用户通知系统从系统B获取数据。 2)系统从系统B获取后台数据。 | 用户命令系统从系统B获取基本数据。 |
示例方式显得笨拙,而修改后变得更加精简,明确体现不同执行者的职责。
10.习惯用语:循环执行步骤X到Y,直到条件满足
该模版是为了表达循环的动作,示例:
-
用户选择一个商品,并做上购买的标记。
-
系统将这个商品加入顾客的"购物车" 中。
顾客重复步骤1)~2),直到顾客指明他/ 她完成了选购。
04
面向对象的分析 (OOA)
面向对象的分析就是利用面向对象的思想进行软件工程领域的分析。而面向对象的核心思想就是围绕类展开的,把万世万物看成类。所以OOA的研究对象也是类,这里的类不是编码层面的类,只是一种认知事务的视角。
4.1 划分三大类
OOA对类进行了更为具体的划分,把类统分为三种:边界类、实体类、控制类。三者之间的关系如下图所示:

边界类: 用户或者外部系统与本系统交互的门面,是最直接面对用户的类,会承载一些基本的逻辑校验和协议转换。
控制类:负责业务逻辑编排的类,控制类对应需求中的一个用例,控制类负责对实体类的编排。一般来说控制类的名字就是用例名字+控制标识。
实体类:实际的领域概念类,具有业务语义,实体类是需要着重挖掘的类,也是控制类编排的基本单位。
在用例建模中提到的投保用例中,边界类是手机客户端,控制类是投保控制类,概念类是交易订单、保单、支付单、用户信息等领域类。
边界类和控制类都很好寻找和定义,那如何识别概念类呢?
4.2 识别实体类
如前文所述,用例一般是由业务专家参与书写,使用了自然语言,包含了非常丰富的业务领域知识。因此很容易通过名词法识别到概念类。根据自然语言分析,找出其中的名词短语,再进一步缩小范围明确实体类。
以前文中的购买保险用例为例,按照名称分析法。
找到实体类:投保人、产品、保障方案、被保人、保单、体检资料、保障范围、保费、健康告知、保额。
当然仅仅找到这些名词还不够,还要注意区分属性与类。属性在现实世界中被映射为数字或者文本。属性描述类的某个特征的值。如上述投保人、被保人、产品、保单这些都有具体的实体映射。因此可以当成概念类,而像保费、保额、保障范围这些都可以映射为现实世界中的数字或者文本,因此要划分成保单的属性。
另外,在给概念类命名时也有几项要注意:
-
避免使用技术词汇:像保单表、人员信息库、订单类等这些都是把技术带入到业务模型中。
-
遵循行业惯例:行业惯例就是约定俗称的概念和词汇,直接沿用即可,无需生搬硬造。像退保这个概念,言简意赅,无需在画蛇添足另起名取消保单。
-
统一语言:既然是建模,就要整个模型,自上而下使用一个词汇表达。统一语言是除了要在模型中体现出来,还要在日常沟通中,注意识别并纠正。
-
精准表达:有些时候虽然可能表达的是同一物理个体,但是在不同场景下其概念是完全不同的。例如对于一个自然人,注册了网站这个时候对于网站来说是用户,购买保险后就成为投保人,保单通过后就成为了被保险人。对于这些概念要精准区分,而不是统称为用户或者客户。
实体类是整个模型的构建基础,提取出实体类后下一步就是寻找这些实体类之间的关系。
4.3 构建静态模型
实体并不是孤立的,实体之间组成了整个系统,对外交付价值。实体之间产生的关系可以分为:关联、组合、聚合、泛化。 所有实体类的静态关系组成的类关系图就是静态模型,在OOA中的所谓的建模过程最终的产物就是静态模型图。实体之间的关系线很容易混淆,总结起来:
直线无箭头的是关联、菱形空心箭头是聚合、菱形实心箭头是组合,三角形箭头是泛化。
另外,一个记忆小技巧:无论是菱形还是,三角形,其方向都是指向抽象层次更高纬度的类。在组合中是部分指向整体,在泛化中是子类指向基类。下面针对每一类关系来简要说明。
4.3.1 关联
指明了两个类之间朴素的结构化关系。在UML中,两个类之间通过直线连接表达关联。直线两侧数值表示多重性。直线上标明关联的特定语义。如下表,列出了主要的关联类型:
|-------|---------------------------------------------------------------------------------|------------------------------------------------------------------------------|
| 关联类型 | 关联示例 | 解释 |
| 一对一关联 | | 一对一关联表达,关联的双方在双向是必须是一对一。 一个CEO只属于一个公司,一个公司只有一个CEO。 |
| 一对多关联 | | 两个类之间在一个方向是一对多关联,在反方向时一对一关联。 正方向:一个保单可以包含多理赔单(1对多), 反方向向:一个理赔单必须属于一个保单(1对1); |
| 多对多关联 | | 两个类之间,在两个方向上都是一对多关系。 如一个经纪人可以服务的多个投保人。 同样一个投保人可以被多个经纪人服务。 |
| 自我关联 | | 类的一个对象可以与另外一个对象形成关联,例如:经纪人可以服务别的经纪人。 |
4.3.2 组合与聚合
组合和聚合都是表达类之间的整体与部分的关系,表达的是has-a的含义。聚合是组合的一种弱化。
|----|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
| 类型 | 图形化表达示例 | 区别 |
| 组合 | | 保单,由投保方案、保障方案、理赔条款组成,脱离保单,这三个实体没有独立存在的意义。 |
| 聚合 | | 保险经纪公司由保险产品、保险客户、经纪人聚合而成 保险经纪公司倒闭了,保险产品、保险客户、经纪人不受影响。保险产品可以被其他保险经纪公司继续售卖。保险客户可以转投其他家,经纪人也可以与其他经纪公司重新签约。 |
组合:表示部分组成整体,脱离整体,部分的存在也没有意义,部分的声明周期与整体保持一致。组合用菱形实心箭头表示,又部分指向整体。
聚合:表示包含关系,部分与整体是相互独立的,聚合由空性菱形箭头组成,又部分指向整体。
4.3.3 泛化
表示类之间的继承关系,表达is-a的含义。用断点用三角空心箭头,又子类指向基类。

如上图所示,保险产品可以泛化为寿险产品、意外险产品、重疾险产品。
4.3.4 示例
最终构造出来的静态概念模型图如下图所示:

保险交易相关的概念模型图
这是一个非严格推导的保险交易相关的概念模型图,仅做示例展示用,方便对概念模型图有一个直观的认知。如果实际严格构造出来,会非常庞大。
4.4 动态建模
静态关系表达的是类之间的固有关系,像模型的毛细血管一样组成大大小小的通道,把各个实体连接起来。而动态关系主要为类之间的协作关系,描述的是实体之间是如何协作,如何进行信息交互的,进行了哪些必要的信息交互。
动态建模的产物是分析序列图。如下图所示,基于用户投保用例创建的分析序列图。

分析序列图体现的是业务交互完整过程,与静态模型一起构建了领域模型。
4.5 深层模型
通过以上步骤,基本构建了静态模型。对静态模型进一步分析和抽象,可以得到更为凝练的深层概念模型。martinfowler在<分析模式>这本书里推导演绎了很多场景的深层模型,可以开箱即用。本文列举一下其中常用的责任模式和观察测量模式。
4.5.1 责任模式
参与方模式:
在研究特定系统的时候,同一个抽象层次下,常常会遇到个人、公司、政府等实体,这些实体拥有共同的一些属性和行为。按照最常规的建模,如下图所示概念模型,一个人可以拥有0到多个电话号码、邮箱地址,1到多个地址,类似的,一个公司可以拥有0到多个电话号码、邮箱,1到多个地址。但是当系统变的复杂,需要引入更多的实体,如医院、政府、学校、政党、教会,每次引入新的实体,都要对模型进行变更。从形式上来看,冗余了很多实体,但表达的却是同一个意思。 从实现上来看,模型的改变会冲击到逻辑层,带来更高的实现成本。为此,抽象出一个概念,参与方(party),构建如下图所示的参与方模式。


参与方是对个人、公司、政府、医院等这些实体的进一步抽象。辅以参与方的约束,该模型就比较稳定了,无论是新增还是减少实体,都只是在参与方下增加或者减少一个继承节点即可。具体使用场景来说,在前文提到的保险系统中,一次投保行为,会涉及到投保人、被保人、代理人、保司、承销商、监管机构等实体。在构建具体的保险交易模型中,可以把这些实体抽象成参与方。
组织层级模式:
对于绝大多数组织来说,其组织结构都是呈金字塔状一层层的向下扩展的。大到政府间的组织关系,小到公司的层级结构,皆有类似结构。例如某公司组织结构中,经营单位包含若干地区,每个地区又包含若干分布,每个分布又包含多个销售办事处。

如果只是单纯的罗列出组织层级,新增或者撤销组织节点,就会牵涉比较大。如撤销地区的下的分部,在销售办事处下新增销售小组。这种改变会导致模型变化很大。为此可以使用组织层级模式来建模,如下图:

组织结构中包含父节点、子节点,每个节点可以被泛化为经营单位、地区、分部、销售办事处。对于每个泛化的节点,辅以约束规则,确保该节点在组织层次中的合法性,如,对于经营单位,约束其没有父节点。该模式下,新增或者删除节点,调整泛化节点或者调整约束规则即可。
然而实际中组织结构的层次并不是单纯的一条线,如该公司中的销售办事处,既又产品团队也有销售团队,那该办事处既要向上销售线回报,又要向上一级产品线汇报。为此形成如下,两套组织层级模型。

然而现实更复杂,考虑到销售办事处中还设有党委、一些虚线、团体,这些支支线线下,该模型就会变得异常复杂。
组织结构模式:
针对层级模式无法解决的多套层级关系,把表达组织间关系显示化,引入组织结构,注意关系显示化是分析模式中很重要的一个方法。当关系变的复杂时,通过显示化关系,构造特定语义,把复杂的关系收敛到特定的语义中,从而不改变整体模型。如下图所示:

组织结构表达组织之间的关系,该关系又可以划分不同的类型,即组织结构类型。很多组织结构都是有时间限制,因此又加上组织结构时间段限制。这样就可以表达,在某个时间段,组织中某个节点与其父节点或者子节点拥有哪几种类型的组织结构。
责任模式:
上述模式已经很好的处理组织结构问题,实际中,组织中必不可少的就是人,人是否可以与某个组织或者其他人也发生类似关系呢。结合参与方的模式,进一步抽象出责任模式。

在责任模式中,把参与方之间的关系定义为责任,责任对应一个责任类型和特定的时间段。对于参与方来说,包含两个实例化的责任,委托方责任、责任方责任。责任的可以延展处很多人与组织关系,组织与组织的关系,人与人的关系。如:
-
人与组织的雇佣关系:小明在腾讯担任工程师,责任类型为雇佣关系,小马是责任方,腾讯是委托方。
-
人与人之间的汇报关系:小明是腾讯的leader,需要向其总监小马汇报。小明与小马之间的责任类型可以定义为汇报关系,责任方是小明,委托方是小马。
-
组织与组织之间的合同关系:A公司使用B公司的系统来实现其产品,责任关系为合同关系,A公司为责任方,B公司为委托方。二者构成合同责任关系。
知识域与操作域分离:
因为责任类型比组织结构类型多得多,使用责任模式时,责任类型会迅速膨胀,使系统变得复杂。 为此把模型拆解为知识层与操作层。操作层包括责任、参与方、以及其相互关系。知识层,包含责任类型、参与方类型以及其相互关系。如下图所示。

在知识层定义了一个责任类型的委托方和责任方的类型集合。如约束中,对于责任类型a, 其委托方必须属于a类型下的委托方集合,且其责任方必须属于a类型下的责任方类型集合。
例如对于雇佣责任类型来说,在知识层可以定义为:委托方集合为腾讯及其下属所有子公司,责任方集合为腾讯所有在职员工。
简单来说,知识层定义了操作层的合法性配置。操作层的所有行为都要遵照知识层的约束。这种分离确保了操作层的清晰,降低了模型的整体复杂度。
操作层与知识层广泛存在很多模型中,将其显示化并分离,有助于构建清晰整洁的模型。
4.5.2 观察和测量模式
数量模式:
建模中,不可避免的会遇到一些属性测量值,例如体检模型,要记录个人的身高、体重、血压等数值。常规做法是,在个人实体下增加属性,但是以固定的单位存储。例如身高单位cm、体重单位kg,血压单位mmhg。这种记录方只是体现了数值,并没有体现单位,在使用时往往需要写死,容易出错。为此引入数量模式,如下图所示数量模式

数量包含两部分:记录数值的总数和单位。把个人属性项记录为数量的对象。在使用时,就可以快速准确知道数量的精确含义。
数量模式还可以应用到币值上,通过数量记录交易中数值和币种。
换算率模式:
因为数量模式中包含了单位,这也为换算率模式奠定了基础。如下图,

对于一个单位,可以拥有多个换算率这些换算率包含不同的来源和目的单位。通过换算率,输入一个数值,可以获取特定单位的数量对象。
复合单位模式
单位既可以是原子的也可以是复合的。复合单位时原子单位的组合,例如平方米,千瓦,加速度等。复合单位需要记录原子单位以及其对应的幂。例如3600平方米,要记录原子单位米的数值60,又要记录单位的幂2。

在复合单位模型中,引入单位引用概念表达复合单位与原子单位的关系。这也是关系显示化的又一事例。复合单位通过单位引用多个原子单位。
测量模式:
在普通的体检中,个人的测量值是有限的,数量模式尚且可以应付,一旦把视野放到整个医疗系统中,关于人的测量项可能会涉及上千上万项。在人的实体中测量属性值会变得异常庞大。为此引入测量这个实体,把对人的关注上转变为对测量的关注。

一个测量记录了一个人在某个现象类型下的数值。现象类型就是对所有要测量事物的抽象,一个人可以拥有多个测量。
例如,对于一个测量的实例化,小明的身高是180cm,表现在模型中,小明测量对象,数量是180cm,现象类型为身高。
测量模式下,在实现层面,一个人拥有很多测量记录,在不同场景需要研究不同的测量集合,例如在入职体检时关注身高、体重、传染病,在常规体检就要关注很多内在指标的测量例如尿酸、肝胆等指标。
观察模式:
有了测量对象仅仅实现了记录的复杂性,在很多场景需要依赖测量的记录来做成判别,例如是否高血压、是否糖尿病。表达由测量数值推导出的检测结果可以用观察模式。引入观察概念,一个观察是由测量和分类观察组成,分类观察表达基于测量的产生的定性判别。同样按照知识层和操作层来划分。知识层来管理观察涉及到的现象类型以及现象。

05
面向对象设计OOD
面向对象的设计,就是使用面向对象思想进行设计。交付的产物包含类的关系图、设计序列图。基于分析阶段的静态模型,利用职责驱动设计,构造出更为细化的类图结构。在分析序列图基础上,推演出设计序列图。
5.1 基于职责的驱动设计模式(RDD )
作为职责驱动设计就是把软件对象当成具有某种职责的人,他要与其他人协作以完成工作。
职责包括行为职责和认知职责。行为职责是指具有完成某项功能的能力。认知职责是指具有表达某种业务知识的能力。
对于一个保单来说计算保费是行为职责,获取保障方案是认知职责。
职责驱动设计意味着在设计类的时候,要理清类之间的协作关系,按照一定的策略给不同类分配不同职责。
实践上关于职责驱动的设计已经沉淀了一套基础的模式即GRASP(通用职责分配模式)。下面简要阐述一下。
5.1.1 信息专家InformationExport
考虑一个问题,该把职责分配给谁?
信息专家认为,应该把职责分配拥有完成该职责所需信息最多的对象。所谓能者多劳, 能力越强责任越大。更具体的来说,应该把某个函数放在哪个类中实现。这样做的目的是为了避免实现引入耦合,试想,一个类为了实现某个功能需要引入更多的外部类,那该类与其他类之间就产生了耦合。例如,在保险交易系统中,有交易订单TradeDeal、保单Insurance、券Coupon三个类。
cs
class TradeDeal { public: // 计算支付金额职责,分配给交易单 int64_t CaculatePayFee() { return insurance.CaculateInsuranceFee() - coupon.coupon_fee(); }; private: Insurance insurance; // 保单 Coupon coupon; // 优惠金额};// 优惠券class Coupon { int64_t coupon_fee();};class Insurance {public: int64_t CaculateInsuranceFee(); // 计算保费 职责 int64_t CaculateSumInsured(); // 计算保额 职责private: UserInfo user_info; // 用户信息 InsurancePlan insurance_plan; // 保障方案};
考虑分配计算支付金额的职责, 因为交易订单中包含保单和券信息,计算逻辑依赖这两个信息。所以按照信息专家模式,需要把计算支付金额的职责分配给交易订单。
而计算保费的逻辑需要依赖用户信息、保障方案、保单信息。这些信息都在保单里,所以把计算保费的职责分配给保单。同理计算保额的职责也在保单里。
5.1.2 创建者Createor
在面向对象中,创建对象是最频繁的活动之一。因此有必要考究一下谁应该负责创建某个类的对象?
这个问题的本质就是分配类创建对象的职责。分配职责上有一些通用原则,具体来说,对于类A和类B,如果要把类A的创建职责分配给B,name需要满足以下条件之一:
-
B 包含 A
-
B记录A
-
B直接使用A
-
B具有A的初始化数据,并且在创建A时会将这些数据传递给A
回到信息专家的例子中,对于保单的创建职责应该交给谁呢?按照创建者模式,交易订单包含了保单,且交易订单会直接使用保单。所以创建保单对象的职责就分配给交易订单。
5.1.3 控制器Controller
控制器的定义:控制器模式将处理系统事件的责任分配给代表整个系统或用例场景的非 UI 类。
控制器模式要求,在接受UI与领域层之间有一个控制类。简单的做法就是,一个控制器类映射一个用例,从用例中获取控制器的类,负责对下游领域逻辑的编排。这样做的好处,用例很好的映射到代码中,确保分析与设计的一致性。例如下图:

购买保险是一个用例,在设计层面映射出一个控制类DealController ,该类职责是对下游领域逻辑进行编排。
5.1.4 低耦合LowCoupling
耦合是指系统元素之间的联系紧密程度,包括类与类之间、类与存储资源之间、类与配置之间,模块之间、系统之间。耦合过高,会导致变更点影响面大,增加系统复杂度。在职责驱动的设计下,分配职责的时候,应当朝着降降低耦合方向进行。
例如:
cpp
// 1. 支付接口class IPayment {public: virtual bool pay(double amount) = 0;};// 2. 具体支付方式class Alipay : public IPayment {public: bool pay(double amount) override { std::cout << "支付宝支付: " << amount << "元" << std::endl; return true; }};class WechatPay : public IPayment {public: bool pay(double amount) override { std::cout << "微信支付: " << amount << "元" << std::endl; return true; }};// 3. 订单处理类class Order {private: IPayment* payment; // 通过接口引用支付方式public: Order(IPayment* pay) : payment(pay) {} void process(double amount) { payment->pay(amount); }};
5.1.5 高内聚HighCohesion
高内聚是指在分配职责的时候,要考虑职责之间的关联度,把关联度紧密的职责分配到一个类中,让类的职能更单一。具体来说有三层含义:
-
把关联性高的职责分配到一个类中。
-
把与完成一个职责相关的所有方法聚集到一个类中。
-
不同类之间职责正交无重叠。
这样的好处,可以让不同的类各司其职,相互协作完成更复杂的功能。
5.1.6 多态性Polymorphism
多态是指,在面对具有同一个职责的多类型时,把类型选择逻辑用多态的方式消化掉。

举例来说,支付这个职责,可以有支付宝支付,也可以有微信支付。在完成交易的业务流程中,只需要感知一个抽象的支付基类,无需感知是使用支付宝支付还是微信支付。而这通过基类派生,并以多态的形式在运行期来决策使用何种。这种方式可以减少类型选择逻辑对核心业务逻辑的入侵。
5.1.7 纯虚构 PureFabrication
纯虚构就是字面意思,构造出一些虚构的类。注意与编程语言中的纯虚函数区分,二者不是一个意思。通常来说,设计中的类要尽量依照分析模型中的类。也即分析中有的概念类,在设计中也要有对应的类,以保障设计不会偏离模型。按照专家模式,应该把职责分配给拥有实现该职责所需信息最多的类。
因此,保单类具有计算保费的职责,然而计算保费又是一项非常复杂的事项,里面涉及大量的计费规则,例如不同人群、不同年龄、健康状态不一样都会导致保费不一样。
那在保单类中为了实现计算保费职责,耦合这么的规则显然也是不合理的。
为此,构造出在分析模式中不存在的类型的类,保费计算器类。保费计算器类负责与诸多计费规则打交道,避免了保单类过多的耦合。纯虚构这里的虚构是相对分析模型中的类而言,是面向功能的,具有可复用性。设计始终是围绕着模型的,但是,严格遵循模型又会导致设计类中耦合。纯虚构就是为了调和这种矛盾。
5.1.8 间接性 Indirection
间接性,是指关联的两个类,由于关联的一方或者双方都有复杂的适配逻辑,这个时候双方直接联系会产生耦合,为此引入一个间接类(中介类)来避免双方的接触。最为典型的就是适配器模式、外观模式、观察者模式。
那句著名语录就是间接性作用的最好表达:"计算机科学中大多数的问题都可以通过增加一层间接性来解决"。当然还有另外一句镜面问题:"计算机中大多数性能问题,都可以通过减少一层间接性来解决。"
5.1.9 防止变异 Protected Variations
防止变异是指如何分配职责以应对变异。这里的变异是指变化点和进化点。变化点表示当前的设计中一定会发生变化的,进化点,是指预测将来可能会发生变化的点。 对于变化点要设计出稳定的接口来应对变化,像适配器、db的操作实例,各种抽象的接口都是防止变异的体现。对于进化点,考虑到未来的不确定性,过早的干预反而会让系统陷入复杂,因此需要谨慎对待。
5.2 设计序列图
相比于分析序列图,设计序列图会更加灵活些,其抽象粒度可以是模块,可以是类,也可以是类中的函数关系。设计序列图是开发最擅长也最常画的。在设计序列图中,除了要体现业务信息流的交互,还要体现在不同异常情况下,具体类或者模块的响应。如下图投保的序列图,抽象粒度是服务,可以与分析阶段的分析序列图对比着看。

这里的异常不仅仅是业务上的异常,还包括技术层面各种情况引起的异常,如网络抖动、超时、消息丢失、消息重放等。其中最典型的就是超时,因为对于发送方来说,超时的结果是非明确的,可能是成功,也可能是失败。一个基本的序列图中要明确体现异常的应对策略,确保所有异常被处理。
5.3 总结
设计阶段是指引编码阶段的纲领性文件,设计阶段的类就是编码(OOP)阶段的具体类。另外,设计模式还有一套更加流行的方法论,就是四人帮的GOF,总结了23种面向对象的设计模式。GRASP是面向对象设计的基础原则,GOF模式更为全面详细,是GRASP的高阶版本,篇幅原因,这里就不再展开。
06
后记
本文前前后后花费了差不多一年多的时间得以完成,当然更多的时间花在构建面向对象的知识体系上。与其说是一篇面向对象的综述文章,不如说是个人的学习笔记。虽然我本想详尽地呈现面向对象思想的精髓,但是奈何其内容过于庞大,每一个点都可以延展出很多实践经验,加之本人水平有限,很多内容只能蜻蜓点水,文中也难免有纰漏,未尽完善之处,欢迎各位斧正。
本文的完成有赖于微信支付浓厚的面向对象氛围、详尽的学习资料、以及围绕软件方法构建的丰富的工具体系,这些让我得以有机会深入接触和学习,感恩!
另外,非常感谢周俊、王鹏程和白广元的悉心指导,耳濡目染中进入了面向对象丰富而开阔的世界。
最后,在学习面向对象过程中,断断续续阅读了十几本相关书籍,发现大部分书籍要么止于表面不够深入,要么过于啰嗦抓不到重点。经过筛选后剩7本经典书,可以放心反复阅读。当然在阅读过程中,也建议不必急于一次性搞懂全部,遇到难以理解的知识点,也不必急于一次两次就能掌握。毕竟知识的获取与理解有时候需要反复的揣摩与实践。
参考书目
|-------------------------|---------------------------------------------------------------------------------|-----------------------------------------------|
| 书名 | 封面 | 备注 |
| UML和模式应用 Craig Larman | | 入门必读 |
| 软件建模与设计 Hassan Gomaa | | 从软件工程领域介绍建模和设计。 |
| 软件方法 潘加宇 | | 包含上下两册,有面向对象的整体认知框架后再读,收获会更大。值得反复读。 |
| 领域驱动设计 Eric Evans | | 了解面向对象的分析与设计后再来读此书更好。 |
| 分析模式Martin Fowler | | 本书阅读难度较大,注意译者,适合进阶者。 老版本的翻译特别烂,完全读不下去。注意甄别版本。 |
| 编写有效用例Alistair Cockburn | | 编写用例的权威 |
| 面向对象分析与设计Grady Booch | | UML创始人的代表作 |
-End-
原创作者|李亚楠
感谢你读到这里,不如关注一下?👇

设计和分析,你觉得哪个对团队更重要?欢迎评论留言分享。我们将选取1则优质的评论,送出腾讯云定制文件袋套装1个(见下图)。10月9日中午12点开奖。





