软件设计与架构笔记(9)
面向对象——分析与设计
分析(Analysis)与设计(Design)是软件开发过程中的两种重要活动。分析研究需求和问题本身,决定“做正确的事”;设计则侧重于提出概念性解决方案,决定“正确地做事”[CLA01]。在软件工程实践中,分析主要围绕需求进行,这里既包括功能需求,也包括非功能需求;设计则围绕具体实现进行,例如计划设计、技术设计、用户体验设计、测试设计、运维设计等。面向对象建模技术在上世纪90年代中期的兴起,使其被广泛应用于软件的分析和设计过程,逐渐成为企业级开发领域的主宰范式。
面向对象分析(OOA)
OOA的目的是识别和描述问题域中的概念和对象。问题域通常以软件需求为主要的呈现形式,其中包括了功能需求和非功能需求。前者描述面向用户的软件行为,后者则包括了其它类型的需求,例如可用性、可靠性、性能和可支持性(可维护性)等,此类软件属性也被称作软件的质量属性(Quality attributes)。功能需求可通过用例模型描绘出更多细节,非功能需求则作为前者的补充性规格说明进行呈现。如何恰当地描述用例可以进一步参考[ACB99],本文后续部分假设用例和非功能需求已经被有效地描述,因为这是进行面向对象分析的重要前提。
用例建模(Use case modeling)
当具有初步的用例描述时,可以采用用例模型对问题域进一步分析,从而整理出系统事件(System event)及其相应的输入和输出,为之后的逻辑设计建立基础。构建用例模型的过程被称为用例建模,可用的工具和方法包括用例图、系统时序图和系统契约等。
用例图实现了对基于文本描述用例的可视化表示,使其在概念级别更易于理解和沟通,通常采用UML用例图作为工具。
系统时序图与UML的时序图类似,不同之处在于前者把整个系统作为黑盒,主要描述系统的外部交互过程。这些交互可以是位于用户与系统间,也可以位于不同系统之间。在系统时序图中,系统事件是系统与外部交互的唯一方式,而为了描述系统事件则需首先确定系统边界(System boundary),即系统的功能集合。系统时序图着重于描述系统事件类型、参数、发生顺序和其它关键特性。
系统契约能够进一步描述系统事件所引起的领域模型中对象的状态变化规则,相当于系统级别的设计契约,针对后者的讨论可参考下文面向对象设计一节的设计契约部分。
用例模型是需求分析的重要交付物,其实质是针对领域的过程化描述,但这种形式不足以启发后续的面向对象设计,因此需要进一步建立接近面向对象概念的领域模型,针对后者的构建方法就是接下来要讨论的领域建模。
领域建模(Domain modeling)
领域模型描述了问题域中的概念类(Conceptual class),概念类与面向对象中的类有一定相似性,但与设计和实现阶段创建的类没有必然的对应关系。领域模型有时也被称作概念模型、领域对象模型或分析对象模型,其通常应包含如下信息:
领域对象或概念类。
概念类之间的关联关系。
概念类的属性。
一个采用UML类图描述的领域模型例子如下图所示:
领域建模首先需要识别概念类。[MO95]提出了概念类的三要素:1.符号(Symbol),用于表示概念类的文字或图像信息;2.含义(Intension),概念类的定义;3.扩展(Extension),描述所有同属于该概念类的案例集合。例如,概念类Sale应包含以下信息:1.表示概念类的符号“Sale”;2.其含义是一次购买交易的事件,包括了date和time两个附加属性;3.扩展即是所有销售的案例集合。对概念类Sale的三要素进行可视化的例子如下图所示:
在复杂的问题域中,为了识别出所有概念类,需要对领域进行实体分解。一般方法是从用例描述中的名词短语直接提取概念类,但是受限于自然语言的随意性,把任何提取出的词汇都映射为概念类极有可能出错。除了进一步澄清所提取的词汇外,还可以借助概念类分类表(Conceptual class category list),后者是对特定领域中常用概念类的一种分类形式,可用于对照用例描述中的词汇获取预设的概念类。例如在商场中,一次交易可以包含Sale或者Payment等概念,而在机场可能还涉及Reservation等概念。通过上述方法可以获取候选的概念类列表,例如在表示交易的领域模型中,我们可以提取出Store、Register和Sale等作为候选概念类列表。需知候选概念类列表并非是唯一确定的,其具体的覆盖范围应结合上下文综合考虑。
下面讨论领域建模的一般步骤。
结合上下文列出候选概念类列表。
采用领域模型对概念类进行可视化表示,即UML类图的最基本形式。
添加概念类之间的关系记录。作为领域模型的重要信息之一,关联关系有助于深入理解领域模型,下文会做进一步讨论。
添加概念类内部的属性记录。领域模型中的属性表示概念类中需要被记录的信息,其既可以是由用例描述中显式给出的,也可以是间接获取的。属性通常包含属性名和类型两个部分,其中属性类型可以是原始类型(Primitive type)或值对象(Value object)。此外,领域模型的属性应当排除外键属性的情况,后者应在设计阶段再予以考虑。
关联是指概念类之间的、持续一定时长的关系,这里主要是为了强调关联的时间概念,用于和瞬间性关系进行区别。领域模型中的关系主要包含以下两类:1.须知型关系,即用例中明确指出的关联关系,属于领域模型的核心内容;2.理解型关系,即用例未显式说明但实际有助于理解领域模型的关联关系。
与概念类的三要素类似,关联也具有三个重要组成部分,分别是关联名字(Name)、多重性(Multiplicity)和导航性(Navigability)。其中关系命名可以遵照TypeName-VerbPhrase-TypeName的规则,在UML类图中体现为由上至下、由左至右的顺序;多重性与UML类图的概念基本一致;导航性是指关联关系在其两端概念类各自的可见性,例如可以是单向关联或是双向关联。
与提取概念类相似,关联关系可以通过从用例描述中提取动词短语发现,也可以对照通用关联关系表(Common Associations List)——一种领域特定的常见关联关系分类表。虽然在真实场景中可能存在非常多的概念类间关系,领域模型中仅需要表示重要关系,具体应依用例描述和上下文而定。
一个完整的领域模型例子如下图所示:
基于彩色UML的通用领域建模模式
当面对复杂问题域或特殊上下文时,朴素的领域建模方法会面临挑战。为了控制风险,常见解决办法是在分析过程中应用模式(Pattern)。模式是一种经过验证的经验性方法,且能够满足大多数的软件开发需求。领域建模的模式可以是基于特定领域的,也可以不限领域——即所谓的通用模式。本文不会就模式本身或其它类型的领域建模模式展开,下面仅讨论一种较为通用的领域建模模式。
[CDL99]描述了一种基于彩色UML的通用领域建模模式。该模式的作者从数百种领域模型中总结出了一种领域中立组件(Domain-neutral component)模型,该模型可以被应用至大部分的领域建模过程,从而得到理想的领域模型。领域中立组件模型由下面四种最基本的原型(Archetype)组成:
- 时刻或时段(Moment-interval)原型,表示在某个时刻或时段内出于业务或合法性原因需要追踪的事物。例如一次销售在某个时刻发生,那么这次销售就有日期和时间;一次租赁发生在某个时间段,于是有起始时间和终止时间;一次销售甚至可以发生在某个时间段,从而允许对整个销售过程的效率进行评估。采用UML类图表示如下:
- 角色(Role)原型,表示参与者、地点或事物(统称实体或Player)的参与方式。同一个实体可能扮演了多个角色,同一个角色也可能由多个实体扮演。实体自身拥有与角色无关的核心属性和行为,但有时也会具有跨角色的接口方法,用UML表示如下:
参与者、地点或事物(Party, place or thing)原型,表示问题域中的人、组织、地点或者事物,即实体。
描述(Description)原型,这种原型与目录项类似(Catalog-entry-like),表示反复出现的值的集合以及这些集合项所共有的行为。例如一辆汽车拥有序列号、购买日期、颜色、里程表等多种信息,其中与目录项类似的描述主要是指车辆描述,例如制造商、型号、生产日期以及可选颜色等。
为了在UML中描述前述原型,一般可以采用UML中的构造型(Stereotype)工具,但是当复杂性进一步增加时这种方式就不够直观,进而妨碍理解和后续建模。[CDL99]给UML添加了一个新的视觉维度,即采用粉-黄-绿-蓝四种颜色分别代表上述四种原型,颜色体现了原型的重要性程度,下图描述了原型、颜色及其相互关联:
每个原型在属性、链接、方法、插入点和交互等方面具有相应特征:
时刻或时段原型是领域模型的核心,该原型的对象通常用于封装最有价值的内容,包含序号、日期、时刻或时段、优先级、总数和状态等属性。该对象方法包括创建支持业务流程的对象、添加细节、计算总数、完成或取消时刻或时段对象、以及访问其它同类对象,例如获取同类对象列表、计算平均时刻或时段等。当某些业务流程较复杂时,需要引入插入点使其更加适应变化。
角色原型是第二重要的部分,该原型的对象包含序号、状态等属性。角色原型对象通常会链接到对应的时刻或时段对象。该对象方法包括确定相应实体的可用性、提供业务值或性能估算,且都需要和对应的时刻或时段对象交互。
接下来是实体原型,该原型的对象通常作为角色对象的容器,包含序号、地址和自定义值等属性。实体对象通常会链接到角色对象。该对象方法包括查询可用性(自身状态或角色对象上的状态)、获取自定义值(当其不可用时则从对应的描述对象获取默认值)以及提供业务值或性能估算(与对应的角色对象交互)。
最后是描述原型,该原型的对象包含类型、描述、编号和默认值等属性。描述对象通常会链接到实体对象,但某些时候也会直接链接到时刻或时段对象。该对象方法包括查询可用的实体、计算可用实体的数量,这些方法都需要和对应的实体对象交互。当对象具备复杂算法的行为时,需要引入一个支持提供替代行为的插入点。
在领域建模中,根据所属原型的特征定义概念类的职责,从而使原本偏向静态表示的UML类图进一步表达了类似时序图或协作图的动态交互信息,这也是彩色UML工具的一大优势。此外,[CDL99]借助领域中立组件模式构建了12种领域特定组件(Domain-specific component),经验表明这些领域特定组件能够直接应用于大多数领域建模过程。在面临复杂系统时,可以通过恰当的领域特定模型描述系统中不同的子概念,然后基于是否需要可扩展性,采用直接连接或插入点连接实现组件间联通,从而组装成最终的领域模型。
面向对象设计(OOD)
OOA通常要求领域专家、需求分析和技术专家共同参与,体现的是团队对问题域的一致性描述,也就是本文最开始提到的“做正确的软件”。为了“正确的做软件”,OOD更多需要技术团队的更多参与,进一步描述实现中所需对象及其协作的定义。与OOA不同的是,OOD更关注实现细节,例如创建与实际编码中对应的对象、定义对象行为以及对象间的交互等。在过去数十年中,一系列工具、方法、原则、模式等被提出和应用至OOD,限于篇幅本文不可能覆盖前述所有主题,下面仅讨论具有代表性的工具和方法。
GRASP:基于通用职责分配软件模式的面向对象设计
[CLA01]提出了一种基于通用职责分配软件模式(General Responsibility Assignment Software Patterns, GRASP)的OOD方法,该方法被用于生成可供参考实现的设计模型(Design Model)。其基本思路是从用例模型和领域模型出发创建交互图,采用GRASP模式对交互图进行不断优化,并随时补全所需的类图,最终得到的交互图和类图就是设计模型的主要内容。前文已经介绍过时序图和通信图,这里不再赘述。下面讨论GRASP模式及其在设计中的应用。
在UML中,职责是指契约或者义务,这些通常是通过对象的行为体现出来的。职责包含以下两种类型:
行为型(Doing)责任,例如自身行为、驱动其它对象的行为以及在其它对象中起到控制和协调作用的行为。
知识型(Knowing)责任,例如自身封装的数据和功能以及与自身相关的其它对象。
在领域模型中,概念类的职责通常是显式的。但在实际设计中,来自领域模型的职责需要被分配至不同粒度的类或方法。例如“创建一个销售”这类职责可能只需要一两个方法,但是“提供关系数据库的连接”这类职责就可能需要更多类和方法了。为了完成某项职责,对象可以仅通过自身方法完成,也可能依赖其它对象的方法,因此可以认为OOD的本质就是把职责分配到不同的对象中,这也是GRASP的核心思想。
由职责的定义可知,最能完整体现职责的UML工具是交互图,因为后者同时包含了对象、方法和交互信息,这也是从交互图出发进行OOD的原因。GRASP为职责分配提供了一系列指南,以下用其中5个代表性条目为例:
信息专家(Information Expert),指如果某个对象具有满足职责的必要信息,那么职责就分配给该对象。同时满足该职责所需的信息可能保存在不同对象中,那么就需要把职责分配至不同的对象,并通过对象协作完成整个职责。
创造者(Creator),指如果对象A的职责是创建对象B,那么需要满足A对B的紧密包含关系,例如组合、聚合、强使用关联、强数据关联等场景。
高内聚(High Cohesion),指职责分配应保证高内聚,低内聚意味着对象包含了许多无关或者过多的职责,从而影响可理解性、可复用性、可维护性等方面,同时极易被外部影响而发生改动。
低耦合(Low Coupling),指职责分配应保证低耦合,强耦合意味着对象更易受到外部变的影响,低可理解性和低可复用性。
控制器(Controller),指接收和处理系统消息的职责应分配给具备以下条件的对象:1.代表整个子系统;2.代表某个用例场景。前者相当于系统的外观控制器(Facade Controller),后者则只表示一个事件处理器(Handler)或协调器(Coordinator)。
基于GRASP的设计过程相当于把职责从领域模型转移到设计模型,优先从设计模型中选择已有的类,如果没有该类再从领域模型中抽取概念类,直到满足全部用例。
类职责协作卡(Class-responsibility-collaboration card, CRC)
目前为止,UML在OOA和OOD中都占据了主流地位,但是我们仅采用了其中不到5%的内容。同时,UML及相关的计算机辅助软件工程(Computer-aided software engineering, CASE)工具,使OOD在纸面上花费了大量时间,却难以应付软件工程中出现的各种变化。强调团队协作是应对变化的重要手段之一,因此OOD需要与之相适应的轻量级工具,其中有代表性的就是本节将讨论的类职责协作卡(CRC)。
CRC是一种专注于描述类的职责分配和对象协作的轻量级OOD工具,其最初被用于针对团队的OOD培训并取得了良好效果[BC89],后来因其面向团队协作的特点,被广泛应用至协作式的OOD活动。下图展示了CRC的基本格式:
CRC的形式很容易理解,也比UML的交互图更加轻便,但其可表达的信息类型数和精确性不如后者,其优势在于团队协作设计。基于CRC的团队协作设计流程大致如下:
集合团队,首先解释当前用例及其具体场景描述。
从场景中提取相应职责,并将其分配给合适的CRC卡(即类)。
确定该职责是否需要协作类,并且移动卡片使相互关联的类挨在一起。
如果存在替代方案,可以快速创建新的卡片,并将其和原方案放在一起比较,经过团队讨论做出进一步设计决定。
应当注意,上述活动中除了步骤1之外,都离不开OOD原则和模式的指导。经过准确解释和高效协作,CRC能够帮助团队快速理解上下文并就OOD达成一致的方案。当然,CRC的成功应用离不开一系列敏捷实践的共同作用,否则很容易导致“欲速则不达”的后果。
设计契约(Design by contract)
随着OOD/OOP在软件设计和开发中流行,如何保证对象间协作的可靠性逐渐受到重视。软件的可靠性包含了正确性和鲁棒性(或者可直观表达为bug的数量)。我们知道,检验软件本身及其设计的正确性是非常困难的。因此,在设计和开发阶段采取措施是加强可靠性的重要手段。例如采用防御式编程(Defensive programming),即意味着在对象方法的执行过程中做大量检查,例如参数合法性、状态合法性、返回结果合法性等。但是,防御式编程会引入大量冗余且复杂的代码,进而降低软件的可维护性,也不利于维持可靠性。[BM92]提出了设计契约的概念,通过在面向对象的类和方法级别定义合适的契约,从而在设计阶段就引入可靠性保障,该思想同时被引入到实现过程中,成为软件开发的重要技巧之一。
断言(Assertion)是设计契约的核心,断言要求对应的布尔表达式返回值必须为True,否则即意味着异常和潜在bug并且立即报错。同时,断言应当仅被用于表示OOD的契约,而非具体用例场景,因此其只能被用于debug和测试场景。OOD的设计契约包含三种类型的断言:
前提条件(Pre-condition),指某个方法执行前的期望,例如检查输入参数是否合法。采用断言作为前置条件,即意味着输入合法性应实际由方法的调用者保证。而断言的引入则确保在debug和测试阶段验证调用者是否实现了正确的参数检查。
后置条件(Post-condition),指某个方法执行后的期望。与方法体本身不同的是,后置条件语句侧重于描述执行结果,而非前者的过程。
不变量(Invariant),指某个类本身的断言。不变量应当对类的所有对象都始终为True,也就是说,不变量实际上是所有公共方法的默认前提条件和后置条件。
面向对象中的继承给断言带来了一定的复杂性。对于不变量和后置条件来说,子类只能选择强化父类中的断言或维持不变;但是对前提条件而言,子类只能选择弱化父类断言或维持不变。前述约束是由继承引入的动态绑定特性所带来的[MFR03]。
结论
本文所讨论的OOA和OOD分别对应了不同的动机、方法论和评价方式。工具和方法离不开原则和模式的指导,原则和模式在工具和方法的帮助下实现了解释和落地,这些设计元素已然形成了有机整体。前述这些方法论极大促进了软件分析和设计朝着“两个正确”的方向前进。另一方面,以80年代末SEI发起的CMM(Capability Maturity Model)和90年代中期UML的标准化为标志,软件设计乃至整个软件工程开始迈向标准化时代。但是,看似科学的设计和过程随即受到互联网时代巨大变化的冲击,敏捷的思想就是在这种背景下诞生和发展起来的。例如,敏捷强调良好的沟通和协作胜于功能强大的工具,在OOD中正如UML这类大杀器,但也有更轻量的CRC,因此后者被更多用于敏捷软件设计活动。无论如何,在可以预见的未来,业界在“两个正确”的方向上仍有很长一段路要走。
引用
ACB99, Writing Effective Use Cases
MO95, Object-oriented methods: a foundation
CDL99, Java Modeling in Color with UML
BC89, A Laboratory For Teaching Object-Oriented Thinking
BM92, Applying “Design by Contract”
MFR03, UML Distilled