案例分析:基于消息的分布式架构(一)

本文发表于InfoQ中文站2012年5月《架构师》,文章链接:http://www.infoq.com/cn/articles/message-based-distributed-architecture

美国计算机科学家,LaTex的作者Leslie Lamport说:“分布式系统就是这样一个系统,系统中一个你甚至都不知道的计算机出了故障,却可能导致你自己的计算机不可用。”一语道破了开发分布式系统的玄机,那就是它的复杂与不可控。所以Martin Fowler强调:分布式调用的第一原则就是不要分布式。这句话看似颇具哲理,然而就企业应用系统而言,只要整个系统在不停地演化,并有多个子系统共同存在时,这条原则就会被迫打破。盖因为在当今的企业应用系统中,很难寻找到完全不需要分布式调用的场景。Martin Fowler提出的这条原则,一方面是希望设计者能够审慎地对待分布式调用,另一方面却也是分布式系统自身存在的缺陷所致。无论是CORBA,还是EJB 2;无论是RPC平台,还是Web Service,都因为驻留在不同进程空间的分布式组件,而引入额外的复杂度,并可能对系统的效率、可靠性、可预测性等诸多方面带来负面的影响。

然而,不可否认的是在企业应用系统领域,我们总是会面对不同系统之间的通信、集成与整合,尤其当面临异构系统时,这种分布式的调用与通信变得越重要,它在架构设计中就更加凸显其价值。并且,从业务分析与架构质量的角度来讲,我们也希望在系统架构中尽可能地形成对服务的重用,通过独立运行在进程中服务的形式,彻底解除客户端与服务端的耦合。这常常是架构演化的必然道路。在我的同事陈金洲发表在InfoQ上的文章《架构腐化之谜》中,就认为可以通过“将独立的模块放入独立的进程”来解决架构因为代码规模变大而腐化的问题。

随着网络基础设施的逐步成熟,从RPC进化到Web Service,并在业界开始普遍推行SOA,再到后来的RESTful平台以及云计算中的PaaS与SaaS概念的推广,分布式架构在企业应用中开始呈现出不同的风貌,然而殊途同归,这些分布式架构的目标仍然是希望回到建造巴别塔的时代,系统之间的交流不再为不同语言与平台的隔阂而产生障碍。正如Martin Fowler在《企业集成模式》一书的序中写道:“集成之所以重要是因为相互独立的应用是没有生命力的。我们需要一种技术能将在设计时并未考虑互操作的应用集成起来,打破它们之间的隔阂,获得比单个应用更多的效益”。这或许是分布式架构存在的主要意义。

1、集成模式中的消息模式

归根结底,企业应用系统就是对数据的处理,而对于一个拥有多个子系统的企业应用系统而言,它的基础支撑无疑就是对消息的处理。与对象不同,消息本质上是一种数据结构(当然,对象也可以看做是一种特殊的消息),它包含消费者与服务双方都能识别的数据,这些数据需要在不同的进程(机器)之间进行传递,并可能会被多个完全不同的客户端消费。在众多分布式技术中,消息传递相较文件传递与远程过程调用(RPC)而言,似乎更胜一筹,因为它具有更好的平台无关性,并能够很好地支持并发与异步调用。对于Web Service与RESTful而言,则可以看做是消息传递技术的一种衍生或封装。在《面向模式的软件架构(卷四)》一书中,将关于消息传递的模式划归为分布式基础设施的范畴,这是因为诸多消息中间件产品的出现,使得原来需要开发人员自己实现的功能,已经可以直接重用。这极大地降低了包括设计成本、实现成本在内的开发成本。因此,对于架构师的要求也就从原来的设计实现,转变为对业务场景和功能需求的判断,从而能够正确地进行架构决策、技术选型与模式运用。

常用的消息模式

在我参与过的所有企业应用系统中,无一例外地都采用(或在某些子系统与模块中部分采用)了基于消息的分布式架构。但是不同之处在于,让我们做出架构决策的证据却迥然而异,这也直接影响我们所要应用的消息模式。

消息通道(Message Channel)模式

我们常常运用的消息模式是Message Channel(消息通道)模式,如图1所示。

image1
图1 Message Channel模式(图片来自eaipatterns

消息通道作为在客户端(消费者,Consumer)与服务(生产者,Producer)之间引入的间接层,可以有效地解除二者之间的耦合。只要实现规定双方需要通信的消息格式,以及处理消息的机制与时机,就可以做到消费者对生产者的“无知”。事实上,该模式可以支持多个生产者与消费者。例如,我们可以让多个生产者向消息通道发送消息,因为消费者对生产者的无知性,它不必考虑究竟是哪个生产者发来的消息。

虽然消息通道解除了生产者与消费者之间的耦合,使得我们可以任意地对生产者与消费者进行扩展,但它又同时引入了各自对消息通道的依赖,因为它们必须知道通道资源的位置。要解除这种对通道的依赖,可以考虑引入Lookup服务来查找该通道资源。例如,在JMS中就可以通过JNDI来获取消息通道Queue。若要做到充分的灵活性,可以将与通道相关的信息存储到配置文件中,Lookup服务首先通过读取配置文件来获得通道。

消息通道通常以队列的形式存在,这种先进先出的数据结构无疑最为适合这种处理消息的场景。微软的MSMQ、IBM MQ、JBoss MQ以及开源的RabbitMQApache ActiveMQ都通过队列实现了Message Channel模式。因此,在选择运用Message Channel模式时,更多地是要从质量属性的层面对各种实现了该模式的产品进行全方位的分析与权衡。例如,消息通道对并发的支持以及在性能上的表现;消息通道是否充分地考虑了错误处理;对消息安全的支持;以及关于消息持久化、灾备(fail over)与集群等方面的支持。因为通道传递的消息往往是一些重要的业务数据,一旦通道成为故障点或安全性的突破点,对系统就会造成灾难性的影响。在本文的第二部分,我将给出一个实际案例来阐释在进行架构决策时应该考虑的架构因素,并由此做出正确地决策。

发布者-订阅者(Publisher-Subscriber)模式

一旦消息通道需要支持多个消费者时,就可能面临两种模型的选择:拉模型与推模型。拉模型是由消息的消费者发起的,主动权把握在消费者手中,它会根据自己的情况对生产者发起调用。如图2所示:

image2 图2  拉模型

拉模型的另一种体现则由生产者在状态发生变更时,通知消费者其状态发生了改变。但得到通知的消费者却会以回调方式,通过调用传递过来的消费者对象获取更多细节消息。

在基于消息的分布式系统中,拉模型的消费者通常以Batch Job的形式,根据事先设定的时间间隔,定期侦听通道的情况。一旦发现有消息传递进来,就会转而将消息传递给真正的处理器(也可以看做是消费者)处理消息,执行相关的业务。在本文第二部分介绍的医疗卫生系统,正是通过引入Quartz.NET实现了Batch Job,完成对消息通道中消息的处理。

推模型的主动权常常掌握在生产者手中,消费者被动地等待生产者发出的通知,这就要求生产者必须了解消费者的相关信息。如图3所示:

image3 图3 推模型

对于推模型而言,消费者无需了解生产者。在生产者通知消费者时,传递的往往是消息(或事件),而非生产者自身。同时,生产者还可以根据不同的情况,注册不同的消费者,又或者在封装的通知逻辑中,根据不同的状态变化,通知不同的消费者。

两种模型各有优势。拉模型的好处在于可以进一步解除消费者对通道的依赖,通过后台任务去定期访问消息通道。坏处是需要引入一个单独的服务进程,以Schedule形式执行。而对于推模型而言,消息通道事实上会作为消费者观察的主体,一旦发现消息进入,就会通知消费者执行对消息的处理。无论推模型,拉模型,对于消息对象而言,都可能采用类似Observer模式的机制,实现消费者对生产者的订阅,因此这种机制通常又被称为Publisher-Subscriber模式,如图4所示:

image4
图4 Publisher-Subscriber模式(图片来自eaipatterns )

通常情况下,发布者和订阅者都会被注册到用于传播变更的基础设施(即消息通道)上。发布者会主动地了解消息通道,使其能够将消息发送到通道中;消息通道一旦接收到消息,会主动地调用注册在通道中的订阅者,进而完成对消息内容的消费。

对于订阅者而言,有两种处理消息的方式。一种是广播机制,这时消息通道中的消息在出列的同时,还需要复制消息对象,将消息传递给多个订阅者。例如,有多个子系统都需要获取从CRM系统传来的客户信息,并根据传递过来的客户信息,进行相应的处理。此时的消息通道又被称为Propagation通道。另一种方式则属于抢占机制,它遵循同步方式,在同一时间只能有一个订阅者能够处理该消息。实现Publisher-Subscriber模式的消息通道会选择当前空闲的唯一订阅者,并将消息出列,并传递给订阅者的消息处理方法。

目前,有许多消息中间件都能够很好地支持Publisher-Subscriber模式,例如JMS接口规约中对于Topic对象提供的MessagePublisher与MessageSubscriber接口。RabbitMQ也提供了自己对该模式的实现。微软的MSMQ虽然引入了事件机制,可以在队列收到消息时触发事件,通知订阅者。但它并非严格意义上的Publisher-Subscriber模式实现。由微软MVP Udi Dahan作为主要贡献者的NServiceBus,则对MSMQ以及WCF做了进一层包装,并能够很好地实现这一模式。

消息路由(Message Router)模式

无论是Message Channel模式,还是Publisher-Subscriber模式,队列在其中都扮演了举足轻重的角色。然而,在企业应用系统中,当系统变得越来越复杂时,对性能的要求也会越来越高,此时对于系统而言,可能就需要支持同时部署多个队列,并可能要求分布式部署不同的队列。这些队列可以根据定义接收不同的消息,例如订单处理的消息,日志信息,查询任务消息等。这时,对于消息的生产者和消费者而言,并不适宜承担决定消息传递路径的职责。事实上,根据S单一职责原则,这种职责分配也是不合理的,它既不利于业务逻辑的重用,也会造成生产者、消费者与消息队列之间的耦合,从而影响系统的扩展。

既然这三种对象(组件)都不宜承担这样的职责,就有必要引入一个新的对象专门负责传递路径选择的功能,这就是所谓的Message Router模式,如图5所示:

image5 图5 Message Router模式(图片来自eaipatterns )

通过消息路由,我们可以配置路由规则指定消息传递的路径,以及指定具体的消费者消费对应的生产者。例如指定路由的关键字,并由它来绑定具体的队列与指定的生产者(或消费者)。路由的支持提供了消息传递与处理的灵活性,也有利于提高整个系统的消息处理能力。同时,路由对象有效地封装了寻找与匹配消息路径的逻辑,就好似一个调停者(Meditator),负责协调消息、队列与路径寻址之间关系。

除了以上的模式之外,Messaging模式提供了一个通信基础架构,使得我们可以将独立开发的服务整合到一个完整的系统中。 Message Translator模式则完成对消息的解析,使得不同的消息通道能够接收和识别不同格式的消息。而且通过引入这样的对象,也能够很好地避免出现盘根错节,彼此依赖的多个服务。Message Bus模式可以为企业提供一个面向服务的体系架构。它可以完成对消息的传递,对服务的适配与协调管理,并要求这些服务以统一的方式完成协作。

编码的艺术

本文发表于InfoQ中文站:http://www.infoq.com/cn/articles/art-of-readable-code

codeartcover

这是一本关注编码细节的书。或许你会认为本书所讲皆为小道,诸如方法命名、变量定义、语句组织、任务分解等内容,俱是细枝末节,微不足道。然而,对于一个整体的软件系统而言,既需要宏观的架构决策、设计与指导原则,也必须重视微观的代码细节。正如作文,提纲主旨是文章的根与枝,但一词一句,也需精雕细作,才能立起文章的精气神。所谓“细节决定成败”,软件历史上,有许多影响深远的重大失败,其根由往往是编码细节出现了疏漏。

我一直坚持“代码即架构”的观点,正如小说需要角色来说话一般,软件系统的质量好坏,归根结底还是需要代码来告知。代码的优劣不仅直接决定了软件的质量,还将直接影响软件成本。Yourdon【1】和Constantine【2】在著作Structured Design中认为:软件成本由开发成本与维护成本组成,而往往维护成本要远高于开发成本。这其中付出的主要成本就是由于理解代码和修改代码造成的。正如本书的书名The Art of Readable Code所表示的含义,好的代码常常是可阅读的,要做到这一点,则近似于一种艺术之美了。

与本书相似的一本书是Robert C. Martin的Clean Code(中文版:代码整洁之道)。该书在业界已经得到了广泛赞誉。如果你还在为写出丑陋的代码而烦恼,必须阅读该书。它带给你的冲击,好似阿凡达那无与伦比的3D效果带给你感官上的震撼。在某种程度上,The Art of Readable Code一书几乎可以与Clean Code比肩。或许本书在深度上与Clean Code相比还有所不及,但在内容广度上,却超过了Clean Code。因为它关注编码本身,所以并不局限于某一种语言,而是列举了大量C++、Python、JavaScript和Java代码,涵盖了主流的静态语言和动态语言。这就使得本书的内容具有更强的普适性。

本书一共分为四部分。第一部分Surface-Level Improvements主要介绍了变量、方法和类的命名,以及如何编写好的注释。第二部分Simplifying Loops And Logic则将注意力转移到语句和表达式。第三部分Reorganizing Your Code则主要介绍了有关任务分解、职责分配和设计意图的内容。最后一部分是一些扩展话题,例如如何编写测试,提高代码可读性,并在最后一章给出了一个完整的案例,全面而又系统地结合案例回顾了全书所讲的主要内容。

本书给出了许多改善编码质量的技巧,尤其它结合了大量真实案例,给出了具体的代码片段,并从正反两面对案例进行分析,这就使得作者的讲解不再流于空洞,既让人信服,又能帮助读者理解。

例如在讲解命名如何表达意图时,作者给出了Google代码中的一个反面教材(之所以作者能够给出Google代码的案例,是因为本书的两位作者Dustin Boswell和Trevor Foucher曾经或者正就职于Google )。例如在Google的一段代码中定义了一个宏,用于禁止“邪恶”的构造函数:

class ClassName {
 private:
  DISALLOW_EVIL_CONSTRUCTORS(ClassName);

 public:
  //...
};

宏的定义如下:

#define DISALLOW_EVIL_CONSTRUCTORS(ClassName) \
  ClassName(const ClassName&); \
  void operator=(const ClassName&);

这个宏禁止了构造函数和Copy构造函数(即=操作)。然而从宏的名称来看,这个含义是不明确的,会让读者认为仅仅禁用了构造函数。只需要改个名字,意图就可以变得更加清晰:

#define DISALLOW_COPY_AND_ASSIGN(ClassName) [...]

书中各章的案例非常翔实,并且总是先给出糟糕的版本,逐步分析推导,最后给出好的实现,作为直观鲜明的对照。为避免读者陷入相对独立而散乱的小案例中,作者又专门独立出一章内容, 讲解了一个完整的案例Minute/Hour Counter。该案例从问题域的提出,到对接口的设计和实现,层层推进。作者从方法命名和注释开始,抽丝剥茧展开分析,先后给出了三个解决方案,渐进地对编码实现进行了改善,使之在性能、灵活性都有了很好的改观,类的职责更为清晰,代码结构简洁而又易于理解。最后,作者还给出了三个解决方案的比较,从代码行数、时间复杂度、内存消耗和准确率四个因素出发,全面权衡了各个解决方案的优劣,以此来印证作者在本书中一直推崇的编码技巧。

本书作者并不满足于通过文字和代码来彰显这些技巧的力量,书中附带的漫画插图起到了很好的辅助作用。如果将本书比作一盘精美的佳肴,这些漫画就起到了调料的作用。例如作者希望表达出名字误用所带来的危险时,给出了这样的一幅漫画:

codeartcatoon

图文并茂、案例翔实是本书的主要特色。在阅读本书时,代码从丑陋到美丽的蜕变让人振奋;但是,我们不能满足于结果的获得,而应该享受这个过程。我的建议是,在阅读时,多思考作者给出的反面教材,不要急于了解结果,而应掩卷遐思,分析这段代码的问题,并结合自身经验与能力给出自己的方案。然后再比较作者的方案,两相印证,辨别两个方案各自的优劣之处。最后再去仔细阅读作者的分析过程,才能更好地理解本书,提升自己的编码能力。

唯一让我觉得美中不足的是书中第14章。这一章主要通过一个实际案例,讲解了测试与代码可读性之间的关系。这个案例是一个测试方法,作者认为代码至少存在8个问题,并由此展开对这一测试方法的分析。然而,或许作者过于追求代码的优雅,使得本来较为简单的测试变得相对复杂,引入了许多不必要的代码。尤其是引入的ScoredDocsFromString()方法,它是为测试服务的辅助方法,如下所示:

vector<ScoredDocument> ScoredDocsFromString(string scores) {
	vector<ScoredDocument> docs;
	replace(scores.begin(), scores.end(), ',', ' ');
	// Populate 'docs' from a string of space-separated scores.
	istringstream stream(scores);
	double score;
	while (stream >> score) {
AddScoredDoc(docs, score);
              }
             return docs;
}

对于一个测试辅助方法而言,它的逻辑显得过于复杂了。最关键的是对于这样一个相对复杂的方法,而它本身又属于测试的一部分,该谁来保证它自身的正确性呢?这样对测试代码的改写有过度设计的嫌疑,事实上加大了理解测试的复杂度。作者在书中也写到:
This may seem like a lot of code at first glance, but what it lets you do is incredibly powerful. Because you can write an entire test with just one call to CheckScoresBeforeAfter(), you’ll be inclined to add more tests. (as we’ll be doing later in the chapter).(粗略一瞥,好似代码量增加了,实则它会给你带来难以置信的威力。这是因为现在所写的整个测试仅仅调用了一次CheckScoresBeforeAfter()方法,但在将来你会添加更多的测试。我们将在本章后面讨论)。

遗憾的是,直至本章末尾,我也未曾看到作者有足够说服力的分析,让我愿意为了这样的重用性与灵活性做出如此复杂的实现。不过,本章末尾指出测试方法存在的8个问题,确实精辟独到,值得我们反复阅读,细细体味个中奥秘。

全书一共204页,与那些瀚如烟海的高文大册相比,本书显得轻而薄,但它胜在精专。作者没有囊括所有编码技巧的野心,更没有卖弄地展现自己的设计技巧和博识广学。它的专注可能会因此失去一大部分读者群,但这样的书才是我们程序员真正需要的。遗憾的是目前国内并没有出版本书的中文版或影印版。幸运的是,在本书出版商O’Reilly的官方网站上,给出了本书的免费在线版本。有兴趣的读者可以通过访问http://ofps.oreilly.com/titles/9780596802295/阅读本书。

文学与编程

jiucan 卡尔维诺在哈佛大学的文学讲座(即诺顿论坛,是为纪念美国著名学者诺顿开设的,每年邀请世界文化名人作讲座,艾略特、博尔赫斯也曾获邀参加诺顿讲座)被他的妻子编成了一本独立的书《美国讲稿》。这本书展现了卡尔维诺的文学精神,体现了他的文学态度和气质。不过,我在阅读该书时,却发现了一些与编程有关的内容。

1、文学中的重构

dafenqi 达芬奇在《大西洋草图》中记述了他幻想中海怪的形象,进行了前后三次重构。最初的描述是:

啊,人们多次在波浪翻滚的广阔海洋之中看到你,看到你那长满鬃毛的黑色背脊,你像一座大山,傲慢地徐徐前进!

然后,他试图使海怪的行动生动些,加了个动词“翻转”:

啊,人们多次在波浪翻滚的广阔海洋之中看到你,看到你在海水中傲慢地徐徐翻转身躯,看见你那长满鬃毛的黑色背脊。你像一座大山屹立在海浪之上!

然而,他觉得“翻转”这个词削弱了他想留给人们的那种雄伟与庄严的印象,于是选择了“分开”这个动词,并改变了句子结构,使句子变得更紧凑,更有节奏。

啊,人们多次在波浪翻滚的广阔海洋中看到你!你像一座山屹立在海浪之上。你傲慢地徐徐前进,用那长满鬃毛的黑色背脊把海水分成两半!

在文学创作中,重构其实会经常发生,这源于作家对文学作品高质量追求的精益求精,他们常常通过修改句式,修辞手法或改变词语来改善文字,使之体现恰如其分的美。编码艺术同样如此,即使是代码的结构,以及变量、方法和类的命名,排版样式,只要给予足够的重视,锤炼这方面的技能,坚持重构,就能改善编码质量。

2、封装与接口

卡尔维诺在“精确”一章中引述了哲学家们对语言和外部世界关系的思考:

使用语言是对事物的不断追求,不是渐渐接近事物的本质而是接近事物那无休止的变化,接近事物那多种多样的、无穷无尽的表面。正如霍夫曼斯塔尔所说:“深层应该掩盖起来。掩盖在哪里?掩盖在表层下面。” 维特根斯坦走得更远,他说:“凡被掩盖的东西,我们都没有兴趣。”

在软件开发中,我们常常运用封装来隐藏内部的实现细节。它带来的好处是使得调用变得简单,重用成为可能,很好地隔离了内部实现的变化。当然,文学更善于挖掘内部的玄奥,却常常使用抽象的语言描述出一种晦涩,试图掩盖这种玄奥,以此追求一种寻找“高山流水”知音般心灵激荡的玄妙与浃肌沦髓。

文学也尝试用变化去处理变化,这似乎矛盾,却恰好是文学艺术让人着迷的地方。软件又何尝不是如此。卡尔维诺看到了两种变与不变的模式:

最近我偶然读到生物形成过程的模式:“一边是晶体(象征表面结构稳定而规则),一边是火焰(虽然它的内部在不停地激荡,但外部形式不变)。”……火焰与晶体这两种形象代表了生物学上的两种选择。

皮亚杰观点的哲学蕴含是“从噪音到有序”,即火焰;乔姆斯基观点的哲学蕴含是“自我编制系统”,即晶体。

america 这仿佛让我洞悉了面向对象设计的玄机。火焰代表了接口,无论如何变化,其外部形式总是不变。接口的引入使得软件设计可以从混沌(即皮亚杰所说的“噪音”)走向有序。至于晶体,则是遵循了信息专家模式的对象,因为它封装了数据以及操作该数据的行为,使得它具有了自我判断的意识。它的表面结构仍然是稳定的,却有一套自我约束的规则。晶体看起来是宁静的,而火焰却如此的灵活。融合晶体与火焰的系统,是否代表了对变化的封装,以及对不变概念的抽象呢?

3、纠缠的细节

薄伽丘在一篇故事中(《十日谈》第六天第一个故事)谈到讲故事的艺术,正好回顾了这种感觉。

“奥丽达太太,要是你不讨厌的话,我想讲一个世界上最大的故事给你听,叫你听得津津有味,就像骑了一匹马一样,往了路途的遥远。”

“啊,再好没有了,先生,”那位太太说,“请你快给我讲一个故事吧。”

于是绅士开始讲故事给她听。故事倒很精彩,可惜他讲故事的本领,只抵得上他使用他身边那把佩剑的工夫,实在太不高明,时常把一句话颠来倒去的说了又说,甚至说上六七遍,过了一会,忽然又倒过头来说道:“哎呀,我说错啦!”对于故事中的人名地名常常纠缠不清,张冠李戴,弄得别人莫名奇妙。他那说话的声气又跟故事里的人物、情景一点都配不上,真是听得奥丽达太太头晕目眩,冷汗一身,只觉得大祸临头,连命都快要保不住了。到最后,她忍无可忍,又看见那位绅士正愈说愈糊涂,已经迷了路,失了方向,只是在那儿团团打转,再也跑不出来了,就和悦地对他说:“先生,你那匹马跑得太野,请你还是让我下了马吧。”

在软件设计过程中,最要紧的是思路要清晰,既不能迷失在需求分析中,也不能迷失在复杂的实现细节中。最好能够结合实际的场景,列出我们要达到的目标,需要完成的任务,有序地进行分析和设计。编写代码时,切忌功能之间互相纠缠,虽然体现了对象的协作,但由于职责分配混乱,使得对象之间的协作变得无规律可循,颠三倒四,最后让人忍无可忍,也只能翻身下马了。

对象的职责(二)

第一篇:http://www.agiledon.com/?p=602

我一直比较喜欢使用时序图来驱动我的设计,例如帮助我寻找领域模型中缺失的领域对象以及这些对象所应该承担的职责。不过,从另一方面,时序图以其直观的方式帮助我们判断对象的职责分配是否合理。这种方式,我称之为图形化的设计坏味道。它既可以在设计时帮助我们识别合理的职责分配,也可以在实现之后通过逆向工程将代码生成时序图,以便分辨遗留系统中错误的职责分配。如下图所示:

sequence

图中的如下图示,可以作为识别职责分配的关注点:
1.    保证调用的发起者只需调用一次(红色矩形图示)
2.    判断被请求者的调用数量(绿色圆形图示)
3.    时序图是否过宽
4.    时序图是否过深

如果违背第1点,则说明设计不利于调用者的调用,可以考虑引入一个间接的Façade对象;对于第2点,如果被请求者的调用数量非常多,就给人一种失去平衡的印象,说明某个对象承担的职责过多,可以考虑再次分配职责,引入新的对象;如果时序图过宽,则说明在该场景下,分解对象的粒度过细,导致对象之间的协作过于复杂;如果确实存在多个职责需要多个对象协作,则可以考虑在其中引入间接的Façade对象,封装对象之间复杂的协作关系,即将时序图拆分成多个子时序图;如果时序图过深,则说明对象的分解过粗,少数几个对象之间的协作,表明参与协作的对象承担了太多职责。

以上关注点并非必然标示它一定是坏的设计,需要重新考虑分配职责,但可以作为一个衡量指标,以便我们观察和分析对象之间的协作方式。

职责分配不仅仅发生在对象之间,也包含模块(包)之间的职责分配。模块之间的分配同样需要遵循高内聚的原则,一个模块中包含的组件与类,在功能上应该为同一个业务价值目标提供支持,属于同一个关注点。在设计和分解模块时,可以考虑模块与模块之间的调用关系,一旦发现双向依赖或循环依赖,又或者违背了共同复用原则,就应该考虑对该模块中包含的组件或类,进行分解。例如下图所示:

package 在report模块中,分别包含了exporter、runtime与xml三个组件。exporter组件与报表的导出有关,而runtime组件则负责在生成报表时,将报表模板与数据绑定起来,生成指定的报表;xml组件与报表模板的配置有关,它会读取配置文件,生成报表模板。然而,由于整个系统只定义了一个配置文件,与entity有关的配置也放到该文件中。若要将数据解析为entity,则entity模块也需要调用report模块。反过来,report模块的runtime组件需要绑定数据,就需要获得entity对象,这就导致了两个模块之间的双向依赖。此外,reportdesigner作为swing应用程序,负责设计报表,同样需要读取配置文件,但它并不关注报表的导出与数据绑定。为了调用xml组件,却需要将这个report模块导入到reportdesigner中,这是不合理的设计。

在设计时,如果我们能够随时绘制包图,清晰地表现模块之间的依赖关系,一旦发现有不合理的地依赖,就需要及时重构,重新分配各个模块的职责。这样的包图必须根据系统架构的演化而演化,并最终能够获得一个合理的架构模块图。这种模块结构图与分层架构相辅相成,体现了职责分离(或关注点分离)的原则。例如在下图中,我们抽离出单独的xml模块,就能够很好地解决之前出现的问题:

package1

从图中,还可以看到比较清晰的层次概念,xml属于基础设施层,与公共模块一样,都属于整个系统的低层次模块。虽然report和entity模块都依赖于xml模块,但entity却无需依赖于report,使得report、entity与xml之间的逻辑分解可以进一步演化为物理分解,entity模块和xml模块变得更加独立,有利于将来对它们的重用。

软件系统的稳定性

stability 软件系统的稳定性,主要决定于整体的系统架构设计,然而也不可忽略编程的细节,正所谓“千里之堤,溃于蚁穴”,一旦考虑不周,看似无关紧要的代码片段可能会带来整体软件系统的崩溃。这正是我阅读Release It!的直接感受。究其原因,一方面是程序员对代码质量的追求不够,在项目进度的压力下,只考虑了功能实现,而不用过多的追求质量属性;第二则是对编程语言的正确编码方式不够了解,不知如何有效而正确的编码;第三则是知识量的不足,在编程时没有意识到实现会对哪些因素造成影响。

例如在Release It!一书中,给出了如下的Java代码片段:

package com.example.cf.flightsearch;
//...
public class FlightSearch implements SessionBean {
	private MonitoredDataSource connectionPool;
	public List lookupByCity(. . .) throws SQLException, RemoteException {
		Connection conn = null;
		Statement stmt = null;
		try {
			conn = connectionPool.getConnection();
			stmt = conn.createStatement();

			// Do the lookup logic
			// return a list of results
		} finally {
			if (stmt != null) {
				stmt.close();
			}
			if (conn != null) {
				conn.close();
			}
		}
	}
}

正是这一小段代码,是造成Airline系统崩溃的罪魁祸首。程序员充分地考虑了资源的释放,但在这段代码中他却没有对多个资源的释放给予足够的重视,而是以释放单资源的做法去处理多资源。在finally语句块中,如果释放Statement资源的操作失败了,就可能抛出异常,因为在finally中并没有捕获这种异常,就会导致后面的conn.close()语句没有执行,从而导致Connection资源未能及时释放。最终导致连接池中存放了大量未能及时释放的Connection资源,却不能得到使用,直到连接池满。当后续请求lookupByCity()时,就会在调用connectionPool.getConnection()方法时被阻塞。这些被阻塞的请求会越来越多,最后导致资源耗尽,整个系统崩溃。

releaseit

Release It!的作者对Java中同步方法的使用也提出了警告。同步方法虽然可以较好地解决并发问题,在一定程度上可以避免出现资源抢占、竟态条件和死锁的情况。但它的一个副作用同步锁可能导致线程阻塞。这就要求同步方法的执行时间不能太长。此外,Java的接口方法是不能标记synchronized关键字。当我们在调用封装好的第三方API时,基于“面向接口设计”的原理,可能调用者只知道公开的接口方法,却不知道实现类事实上将其实现为同步方法,这种未知性就可能存在隐患。

假设有这样的一个接口:

public interface GlobalObjectCache {
	public Object get(String id);
}

如果接口方法get()的实现如下:

public synchronized Object get(String id){
	Object obj = items.get(id);
	if(obj == null) {
		obj = create(id);
		items.put(id, obj);
	}
	return obj;
}

protected Object create(String id) {
	//...
}

这段代码很简单,当调用者试图根据id获得目标对象时,首先会在Cache中寻找,如果有就直接返回;否则通过create()方法获得目标对象,然后再将它存储到Cache中。create()方法是该类定义的一个非final方法,它执行了DB的查询功能。现在,假设使用该类的用户对它进行了扩展,例如定义RemoteAvailabilityCache类派生该类,并重写create()方法,将原来的本地调用改为远程调用。问题出现了。由于采用create()方法是远程调用,当服务端比较繁忙时,发出的远程调用请求可能会被阻塞。由于get()方法是同步方法,在方法体内,每次只能有一个线程访问它,直到方法执行完毕释放锁。现在create()方法被阻塞,就会导致其他试图调用RemoteAvailabilityCache对象的get()方法的线程随之而被阻塞。进而可能导致系统崩溃。

当然,我们可以认为这种扩展本身是不合理的。但从设计的角度来看,它并没有违背Liskove替换原则。从接口的角度看,它的行为也没有发生任何改变,仅仅是实现发生了变化。如果不是同步方法,则一个调用线程的阻塞并不会影响到其他调用线程,问题就可以避免了。当然,这里的同步方法本身是合理的,因为只有采取同步的方式才能保证对Cache的读取是支持并发的。书中给出这个例子,无非是要说明同步方法潜在的危险,提示我们在编写代码时,需要考虑周全。