若要获得良好的对象设计,就必须对职责进行合理的分配。每个对象承担的职责不能太多,也不能太少,恰如其分即可。职责分配如乐谱中对音符的组织,高明的音乐家总是能让不同的音符放在合理的位置,奏成悦耳的心曲,表达音乐家的内心感情。然而,即使设计师明确职责分配的重要性,在面临纷乱复杂的需求时,常常被乱花迷了眼,或者无法识别正确的职责,又或者顾此失彼,将职责放错了位置,变成了对职责混乱的涂鸦。
要识别职责,进而合理分配职责,有许多秘诀,或云“技巧”。不过,将对象的角色作为职责分配的开始,不失为一个好的起点。角色是对象的身份,若以拟人化的方式思考对象世界,就可以设想:究竟是怎样的身份,需得承担怎样的职责,才会与其身份相当,不至于乱了规矩。红楼梦中,刘姥姥进了大观园,出尽了洋相,就是因为身份失当;又可以想想倘若林黛玉像尤三姐那般爱恨分明,也不至于见花落泪,惹人爱怜了。故而在分配职责时,我们能首先明确对象的角色,即可将思想带入到这一角色中,设身处地,推断这一角色可以或者必须承担哪些职责。
在Object Design:Roles, Responsibility, and Collaborations一书中,将对象的角色分为了五种,分别为信息持有者、构造者、服务提供者、协调者和控制者。这是一种设计上的抽象划分,它迥异于你针对具体业务识别的角色。然而,在分辨职责并企图对其进行分配时,确乎有一定的参考价值。以此为基础,在进行软件设计时,可以思考你要设计的对象,究竟属于哪一种角色。
信息持有者
首先来看信息持有者。顾名思义,这种角色的对象必然持有相关的信息。不过,俯瞰对象世界,除了某些特殊的行为对象而言,大多数对象都必然持有相关的信息。所以,这里的角色划定,其主要意图在于让设计者明确,与信息相关的行为,如处理信息的方式,信息变化造成的影响等,都应首先考虑是否由该信息的持有者来承担。这近似于Larman在Applying UML and Patterns一书中提到的“信息专家模式”。
在面向对象设计时,许多设计者往往忽略了这一点,这也是设计出“贫血对象”的错误根源。其实,在进行面向对象设计时,需得设计者有一颗“拟人”的想象力,将你需要设计的一个个对象看做是能够思考具有智力判断的人物就好了。那么,在人类生活中,将专业的事情交给专业的人去做,不是理所当然的吗?何谓“专业”?不就是他或者她拥有与此领域有关的知识吗?映射到OO世界,所谓“知识”就是对象所拥有的信息,而所谓“专业的事情”则相当于操作信息的行为;故而首当其冲地设计结果就是将操作信息的行为与拥有信息的对象有机的结合起来,这也是所谓的“数据与行为应该封装在一起”原则。当然,这里的行为当以“角色”的角度观察之,反映到代码层面,则可以是接口。袁英杰的文章小类,大对象庶几阐述了这一设计思想。
让我们还是回到信息持有者这个话题上。
例如,我们需要设计一个Web服务器,它提供了一个对象HttpProcessor
,能够接收由HttpConnector
发送来的Socket请求,对Request进行处理,并在处理后将相关信息放入Response中。请求和响应被封装在对应的HttpRequest
和HttpResponse
对象中。在处理请求和响应信息时,需要对Socket消息进行处理,并为Request和Response对象设置相关属性。
对于消息的解析工作,这里存在两个设计选择。其一是放在HttpProcessor
对象中,看起来(从命名看)它才是消息的专项处理者;其二则是将对Request和Response的解析工作分别放到各自的HttpRequest
与HttpResponse
对象中。
该如何选择?我们遵循信息持有者的设计要求,答案不言而喻。如下图所示:
遵循信息持有者的特征,HttpProcessor
、HttpRequest
与HttpResponse
之间的权责变得更加清晰。此外,这一设计方式还有利于改善性能。某些Http请求解析可能牵涉到系统开销较大的字符串操作,而解析的内容并不是在一开始就需要使用。将解析职责转移到HttpRequest
中,就使得HttpProcessor
的process()操作可以快速完成,并将相关请求数据流高效地塞到HttpRequest
对象中。只有真正需要相关请求信息时,才向HttpRequest对象发出解析的请求消息。这种方式颇像是对象的Lazy Load。
构造者
构造者角色主要承担对象的创建,以及对复合对象的组装。如果熟悉设计模式,可以发现构造者角色基本上涵盖了构造型模式的意图。例如创建对象,组合对象,以及选择对象构造的方式。此外,还有一种特殊的构造者角色对象,即它可能具有双重角色,一方面作为构造者角色,另一方面也作为构造者所创建出来的产品。这种双重角色的构造者角色,常常会形成一条构造链。
例如,在JMS中,若要获得Queue
对象,就可能由ConnectionFactory
对象创建出Connection
对象,则通过该对象创建Session
对象,最后由Session
对象创建的Queue
。如下图所示:
为何需要构造者角色?毕竟对象自身可以拥有构造函数,以提供给调用者完成对象的创建。通常情况下,之所以引入构造者角色,主要是为了:
- 应对创建的变化,例如Factory Method模式或Abstract Factory模式;
- 隐藏对象创建的复杂逻辑,例如Static Factory模式或Builder模式;
- 控制对象创建的时机或数量,例如Singleton模式。
服务提供者
关于服务提供者,一个重要认识是:它能提供具有“业务价值”的行为。所谓“业务价值”,即一定是实现业务逻辑中不可缺少的,且相对独立完整的功能。这就意味着,担任服务提供者角色的对象,常常是一个职责完备的,实现了某个业务关注点的可重用对象。此外,业务价值是有层次之分的。在最外层,可能意味着一个完整的业务流程,此时服务对象暴露给客户端的,是一个封装了服务实现细节的对象(可能是接口);而为了实现该外层服务,又可能在整个实现中,需要更为细粒度的内层服务对象提供各个实现步骤的支撑。
站在架构角度思考,这种对服务提供者的分层,可能正好对应DDD分层架构中的Application Service与Domain Service。若设计为Application Service,需得遵循DDD的语义,对外而言,它确实代表了整体的业务逻辑,对内,则不过扮演了Facade的角色,是对多个Domain Service的一种封装而已。从某种意义上讲,这样的Application Service更像是后面要说的“协调者”角色。但由于它具有非常明确的业务含义,我更倾向于将它视为服务提供者。
例如,系统需要定期根据用户提交的数据生成税务报表。假设它的业务流程是读取报表数据后,对数据流进行处理,并以HTML格式呈现,最后生成PDF文件。对外而言,税务报表的生成是一个完整的服务,客户端的调用者无需了解这个服务的实现细节。因而对外可以定义TaxReportGenerator
服务对象,它对外接收给定的报表名,结果则是生成报表的PDF文件。显然,它具有非常重要的业务价值。
接下来考虑该对象的内部实现。由于报表生成需要执行多个业务步骤,如果将这些职责均交给TaxReportGenerator
来处理,无疑会导致该对象承担过重的职责。此外,呈现HTML格式与PDF文件生成对于税务报表生成而言,是整个业务流程中的一环;但从单个职责而言,无疑它们又是独立的。可以设想,倘若系统还有其他业务功能需要生成PDF文件,又或者需要按照规定形式呈现为HTML页面,将这些职责封装到单独的职责中,就可能很好地支持重用。从“业务价值”的角度看,它们无疑同样具备了服务提供者的能力。整个TaxReportGenerator
对象的内部协作如下图所示:
协调者
协调者有些像设计模式的Mediator模式,即用于协调对象职责的协作,又或者负责转发或委派请求。协调者是孜孜不倦助人为乐的居委会大妈,既善于也乐于协调邻里之间的纠纷。除了可以以中间人的身份协调对象,从而简化对象之间的协作,降低复杂的依赖关系外,协调者还能很好地隐藏这些交互细节。这就使得调用者变得简单,还能让这种关系协调的实现集中在一处,即使将来协调关系发生了变化,也可以做到仅修改一处,即可应对变化。从这一点来看,似乎协调者又体现了Facade模式。
正如前面所言,DDD中的Application Service颇为接近协调者角色。然而,我之所以不希望将这二者混为一谈,还是从业务(领域)的角度来思考问题。我认为协调者的引入仅仅是为了改善设计质量的,它本身(无论是对外,还是对内)并不具有业务价值,这是至为关键的一点区别。
在一个大型复杂系统中,提供了许多Web Service。不同的Web Service可能需要支持不同的消费者,而这些服务的部署位置也可能并不相同。消费者需要准确定位到相关服务,然后通过一些相对复杂的实现逻辑,完成对服务的调用。这类逻辑就牵涉到消费者、服务以及服务调用与服务位置之间的协作。如果没有合适的对象去封装,既可能导致细节暴露,增加复杂度,也无法做到有效重用。一旦协作的逻辑发生变化,可能还会导致这种变化蔓延到系统的各个地方。这时,就是体现协调者价值所在了。
在这个场景下,我们可以引入ServiceLocator
对象来负责整个协调逻辑,它能够根据消费者请求的服务类型定位服务,然后找到服务端口,发送服务请求。下图展示了这种协调逻辑的具体做法,注意不同的服务消费者都经由相同的ServiceLocator角色完成了不同的服务调用:
控制者
看到控制者,我们或许会想到MVC模式的Controller。确乎它们具有相似的特性,即用于控制多个对象之间的交互,甚至是驱动对象。我们可以将这里所谓的控制者角色,看做是Controller的外延,即它具有更加宽泛的职责意义。凡是需要控制角色交互,并具有一定控制逻辑的对象,均可视为控制者角色。注意,控制者角色与协调者角色的区别,前者多少具有一定的管理特征,被控制的对象在级别上要低于控制者角色;后者则体现出一种平等的层级关系。简而言之,前者是政府官员,后者是居委会大妈。
当然,在设计时,有时很难泾渭分明地界定这二者。这就好似用例中的包含(include)与扩展(extend)关系,许多设计者还在孜孜以求,绞尽脑汁地要分辨出二者的不同,以保证正确地运用用例关系,求得完美的设计,孰知早有用例专家(Corkburn)给出忠言,不必一定区分包含与扩展,因为它对用例的编写不会产生直接的重大影响。参考此例,我也希望设计师不必钻牛角尖,只需明白此两种角色,其本质还在于隐藏对象的协作或交互细节,降低复杂度,保证重用以及对变化的应对。
在软件设计中,我们经常遇到控制者角色。一个常见的例子是由控制者角色承担判断逻辑,根据不同的请求,经由不同的分支调用不同的对象。例如在一个系统中,我们需要对页面的内容合法性进行验证。不同的内容对验证的要求不尽相同。一个简单的判断是看内容是否只需要对页面头进行验证,如果为非,则需要对整个页面进行验证。在设计时,我们引入了ValidationProcessor
来控制这种验证逻辑。站在调用者的角度,验证的事情交给ValidationProcessor去处理就好,管它是否仅是一个控制枢纽,真正的验证却是它要委派的对象呢?
当然,在这里的ContentController
同样属于控制者角色,事实就是MVC模式中的Controller,用于控制Content
与ContentView
之间的交互。而ValidatorProcessor
与MVC风马牛不相及,但在本文的语义中,仍可以看做是控制者角色。
结语
我通常将这五种对象角色划分为两大类:领域对象与设计对象。信息持有者和服务提供者都属于领域对象,因为它或者持有了真正的领域逻辑,或者它在接口层面上代表了客户端需要使用的领域逻辑;而构造者、协调者与控制者皆属于设计对象,引入的目的只是为改进设计质量,本身与领域逻辑无关;但它们在软件设计中的地位却举足轻重,没有它们,设计就可能走向混乱,无法保证重用性与扩展性,并导致系统对象之间的协作变得复杂。
如果我们能识辨出系统模型中各种对象的角色,就可以根据角色的特征来分配角色。又或者,我们可以根据角色来判别现有的职责分配是否合理,是否均衡,甚至能够帮助我们找到缺失的对象。每当我们在分配职责时,若有顾此失彼的感觉存在,就可能说明缺乏了承担不同角色作用的这一类对象。找到它,并给它以承担职责的权利,设计一定会大为改观。
找对象不是一件容易的事情,要找到一个好对象更其不容易。这句话适合于单身汪或者单身喵,同样适合于程序员和设计者。没有定论的方法与过程可以帮我们解决这个问题,正如你无法单纯地拿相亲标准去寻觅那个未来陪伴你一生的另一半。学会分辨对象的角色,或许是我们可以尝试的。它在一定程度上分解了设计难题,制造了约束,反而令你在相对狭小的空间内更显游刃有余。