UML软件工程组织

敏捷思维: 架构设计中的方法学(10) -- 分层 (下)
林星 ( iamlinx@21cn.com )
上篇我们用了大量的篇幅来观察了一个实际的例子,相信大家已经对分层有了一个比较具体的概念了。在这一篇中我们就对分层在实践中可能会遇到的问题做一个讨论。分层在架构设计中是一种非常常见的,但是又很不容易用好的技术。因此我们这里花了很大的气力来讨论它。

由于这是一篇介绍软件设计技术的文章,为了尽可能让更多的人理解,本应该尽可能不涉及到过于具体的技术或平台。但是这个目标可能很难实现,因为软件设计是没办法脱离具体的实现技术的。因此本文能够做到的是尽可能的不涉及具体的编码细节。

何时使用分层技术?

分层技术实际上是把技术复杂化了。和以往简单的CS结构的系统不同,分层往往需要使用特定的技术平台来实现。当然,不使用这些技术平台也是可能的,但是效果可能就没有那么好了。支持分层技术的平台有很多,包括目前主流的J2EE和.NET。甚至在不同厂商的开发平台上,要求也不一样。使用分层技术实现的多层架构,成本要比普通的CS架构高得多。

这就产生了一个非常现实的问题-并不是所有的软件都适合采用分层技术的。一般来说,小型的软件使用分层并没有太大的意义,因为分层导致的成本超过它所能带来的好处。在一般的CS结构中,可以把界面控制、逻辑处理和数据库访问都放在一块儿。这种设计方式在纯粹的多层主义者看来简直就是十恶不赦。但是对于小型的软件而言,这并不是什么大不了的事情。因为从表示层到数据层的整套功能都被囊括在一个功能块中,同样能够实现较好的封装。而且,如果结构设计的足够好,也能够避免表示层、业务层和数据层之间出现过高的耦合度。因此,除非确实需要,不然没有必要使用分层技术。

尤其在处理一些特殊的项目时,严格的区分三层结构并不理想。比如在快速开发windows界面的应用时,往往会用到一些对数据库敏感的控件,这种处理方法跨越了三个层次,但是却很实用,成本也比较低。又比如一些框架,给出了从界面层到数据库的综合的解决方案,和windows的应用类似,严格的三层技术也不适用于这种情况。

如何使用分层技术?

从某种意义上来看,层其实是一个粗粒度的组件。就像我们使用组件技术是为了对系统进行一种划分一样,层的一个很大的作用也是如此。其目的是为了系统更容易被理解,不同的部分能够被较容易的替换。

使用分层技术的依据是软件开发人员的实际需要。如果你是在使用某些优秀的面向对象的软件开发平台的话,那它们一般都会建议(或是强制)你使用某一种分层机制。这是你采用分层技术的一大参考。

对于大多数有一定经验的软件团队而言,一般都会积累一些软件开发经验。其中包含了很多在某些特定的领域中使用的基础的类或组件。这些元素构成了一个系统的通用层次。这个层次也是分层时需要考虑的。例如一些应用软件中使用的一些通用的Currency对象或是Organization对象。分析模式一书对此类的对象进行了充分细致的阐述。这个层次一般被称为跨领域层(cross-domain layer),或称为工具层(utility layer)。

目前的很多软件都采用了数据库映射技术。数据库映射层对于企业应用系统非常的重要,因此也需要纳入考虑之列。数据库映射技术用起来简单,但是要实现可不容易。如果不是非常有必要,尽可能使用现成的框架,或是采用其中部分的设计思路。试图构建一个大而全的映射层次的代价是非常高昂的,认识不到这一点会带来很大的麻烦。数据库映射技术的知识,我们在下文中还有专门的篇幅来讨论。

如何存放数据(状态)?

在学习EJB的过程中,最先要理解的一定是有状态和无状态的概念。可以说,整个概念是多层体系的核心。为什么这么说呢?这里的状态指的是类的状态,例如类的属性、变量等。由于状态的不同,类也表现出差异来。而对于多层结构的软件,创建和销毁一个类的开销是很大的,如果该软件支持分布式的话尤为如此。所以如果系统的不同层次间进行频繁的调用-创建一个类,再销毁一个类。这种做法是非常消耗资源的。在应用系统的设计中,一般不单独使用COM,就是这个原因。所以我们很自然的想到了一种经典的设计-缓冲池。把对象存放在缓冲池中,当需要的时候从池中取出一个,当不需要的时候再把对象放入池中。这种设计思路能够大幅度的提高效率。但是这对能够放在池中的对象也提出了苛刻的要求-所有的对象必须是无差异的,也就是无状态的。只有这样才能够实现缓冲池。

一般来说,对象缓冲池的技术是用在中间的业务层上的。既然中间业务层上不能够保留有状态,那就出现了一个状态转移的问题。这里有两种的选择,一种是前移,把状态移到用户端,最典型的是使用cookie。这种选择一般是由于状态和用户端有关,不需要长时间保存。另一种选择是后移,把状态移到数据层,由数据库来实现持久性状态,当需要时才把状态提交给业务层。这种方式是企业应用软件中采用最多的,但是也增大了数据库的负担。

处理好接口

由于使用了分层技术,因此原先那种在CS结构中类之间存在复杂关系就有必要重新评估了。一般层间的耦合度不宜过大。因此需要慎重的设计层之间的类调用方式。一些分布式软件体系(例如J2EE)对层之间的调用方式以接口的形式给出了要求。同时,不同层之间仅仅知道目标层的接口,而不知道目标层的具体实现。EJB的home接口和remote接口就是这样。在COM+体系中,也需要在设计类的同时,把接口公布出来,以供客户方使用。

在设计层间的接口时,除了考虑开发平台的约束之外,还有一点是开发人员必须考虑的。那就是业务需要。业务层中往往有非常多的对象和方法,它们之间的关系也非常的负责,但对于其它的层次来说,它并不关心这些细节。因此业务层公布的接口必须要简单,而且和实现无关。因此,可以使用设计模式的Facade模式来简化层间的接口。这种做法非常有效,EJB中的SessionBean和EntityBean区分就含有这种设计思路。

同样的,不同层之间的数据传递也存在问题。如果不同层的物理节点在一起还好办,如果不在一起,那就需要使用到分布式技术了。因为不同机器的内存地址编码是不同的,如果接口之间采用对象引用的方式,那一定会出现问题。因此会将对象打包成字符串,发送到目标机器后再还原为对象。所有的分布式平台都提供了对这种技术的支持,这里就不多说了。但是这种实现技术会对我们的设计思路产生影响,少量的数据直接使用字符串来传递,数据量大的话,就需要使用封装了数据的对象。这类对象的设计需要非常的小心。在设计过程中可以参照开发平台提供的一些标准做法。同样的,数据的请求的频率也是难点之一。过于频繁的操作来自后端的数据会加大系统的开销。因此,在设计调用方法时同样需要结合实际应用来考虑。

兼顾效率

一般来说,纯粹的面向对象设计者设计出的软件都会比较完美。但是需要付出一定的代价。在一些大的软件平台上编程的时候,往往需要利用到平台的一些机制。最典型的就是平台的事务机制(最典型的包括J2EE平台的JTS,以及COM+平台的MTS),但是事务机制的实现往往需要平台大量对象的支撑。这种情况下,创建一个支持事务的对象的开销是很大的。处理这种问题有一种变通的办法,就是仅仅对需要事务支撑的对象提供事务支持。这就意味着,一个单独的业务实体类,可能需要根据是否支持事务分为两种类:对该业务实体的select方法不需要事务的支持,只有update和delete方法才需要有事务的支持。这是不符合纯面向对象设计者的观点的。但是这种做法却可以获得比较优秀的效率。

 

图1 将单个的业务实体分为不同的实现
图1 将单个的业务实体分为不同的实现

 

应该承认,这种提高效率的做法加大了复杂度。因为对于客户端来说,它们并不关心具体的实现技术。要求客户端在某一种情况下调用这个类,在其它情况下又调用另一个类,这种做法既不符合面向对象的设计思路,也增大了层间耦合度及复杂性。因此,我们可以考虑使用接口或是外观类(参见设计模式一书中的facade模式),把具体的实现封装起来,而只把用户关心的部分提供给用户。这方面的技巧我们在下面的章节中还会提到。

以迭代的方式进行分层

软件设计中的迭代做法同样可以适用于分层。根据自己的经验,在一开始就定义好所有的层次是很难的。除非有着非常丰富的经验,都则实现和原先的设计总有或大或小的差距。因此调整势在必行。每一次的迭代都能够对分层技术进行改进,并为后一个项目积累了经验。

这里的分层迭代不可以过于频繁,每一次的迭代都是对架构的重大修改,都是需要投入人力的,而且会影响到软件开发的进度。但是成功的迭代的效果是非常明显的,能够在接下来的开发周期中起到稳定架构,减少代码量,提升软件质量的功效。注意,不要让新潮技术成为分层迭代的推动力。这是开发人员都常犯的毛病,这并不是什么缺点,只能称为一种职业病吧。分层迭代的推动力应该源自于需求的演进以及现有架构的不稳定已经妨碍了软件进一步的开发。因此这需要团队中的技术主管对技术有着非常好的把握。

重构能够对迭代有所帮助。嗅出代码中隐藏的坏味道并加以改进。应该说,迭代是一种比较激烈的做法,更好的做法是在开发中不断的对架构、层次进行调整。但这对团队、技术、方法、过程都有着很高的要求。因此迭代仍然是一种主要的改进手段。

层内的细分

分层的思路还可以适用于层的内部。层内的细分并没有固定的方式,其驱动因素往往是出于封装性和重用的考虑。例如,在EJB体系中的业务层中,实体Bean负责实现业务对象,因此一个应用往往拥有大量的实体Bean。而用户端并不需要了解每一个的实体Bean,对它们来说,只要能够完全一些业务逻辑就可以了,但完成这些业务逻辑则需要和多个实体Bean打交道。因此EJB提供了会话Bean,来负责把实体Bean封装起来,用户只知道会话Bean,不知道实体Bean的存在。这样既保证了实体Bean的重用性,又很好的实现了封装。

面向接口编程

在前面的章节中,我们提到一个接口设计的例子。为什么我们提倡接口的设计呢?Martin Fowler在他的分析模式一书中指出,分析问题应该站在概念的层次上,而不是站在实现的层次上。什么叫做概念的层次呢?简单的说就是分析对象该做什么,而不是分析对象怎么做。前者属于分析的阶段,后者属于设计甚至是实现的阶段。在需求工程中有一种称为CRC卡片的玩艺儿,是用来分析类的职责和关系的,其实那种方法就是从概念层次上进行面向对象设计。因此,如果要从概念层次上进行分析,这就要求你从领域专家的角度来看待程序是如何表示现实世界中的概念的。下面的这句话有些拗口,从实现的角度上来说,概念层次对应于合同,合同的实现形式包括接口和基类。简单的说吧,在概念层次上进行分析就是设计出接口(或是基类),而不用关心具体的接口实现(实现推迟到子类再实现)。结合上面的论述,我们也可以这样推断,接口应该是要符合现实世界的观念的。

在Martin Fowler的另一篇著作中提到了这样一个例子,非常好的解释了接口编程的思路:

 


interface Person {
  public String name();
  public void name(String newName);
  public Money salary ();
  public void salary (Money newSalary);
  public Money payAmount ();
  public void makeManager ();
}
interface Engineer extends Person{
  public void numberOfPatents (int value);
  public int numberOfPatents ();
}
interface Salesman extends Person{
  public void numberOfSales (int numberOfSales);
  public int numberOfSales ();
}
interface Manager extends Person{
  public void budget (Money value);
  public Money budget ();
}
   

 

可以看到,为了表示现实世界中人(这里其实指的是员工的概念)、工程师、销售员、经理的概念,代码根据人的自然观点设计了继承层次结构,并很好的实现了重用。而且,我们可以认定该接口是相对稳定的。我们再来看看实现部分:

 


public class PersonImpFlag implements Person, Salesman, Engineer,Manager{
// Implementing Salesman
public static Salesman newSalesman (String name){
  PersonImpFlag result;
  result = new PersonImpFlag (name);
  result.makeSalesman();
  return result;
};
public void makeSalesman () {
  _jobTitle = 1;
};
public boolean isSalesman () {
  return _jobTitle == 1;
};
public void numberOfSales (int value){
  requireIsSalesman () ;
  _numberOfSales = value;
};
public int numberOfSales () {
  requireIsSalesman ();
  return _numberOfSales;
};
private void requireIsSalesman () {
  if (! isSalesman()) throw new PreconditionViolation ("Not a Salesman") ;
};
  private int _numberOfSales;
  private int _jobTitle;
}
   

 

这是其中一种被称为内部标示(Internal Flag)的实现方法。这里我们只是举出一个例子,实际上我们还有非常多的解决方法,但我们并不关心。因为只要接口足够稳定,内部实现发生再大的变化都是允许的。如果对实现的方式感兴趣,可以参考Matrin Fowler的角色建模的文章或是我在阅读这篇文章的一篇笔记。

通过上面的例子,我们可以了解到,接口和实现分离的最大好处就是能够在客户端未知的情况下修改实现代码。这个特性对于分层技术是非常适用的。一种是用在层和层之间的调用。层和层之间是最忌讳耦合度过高或是改变过于频繁的。设计优秀的接口能够解决这个问题。另一种是用在那些不稳定的部分上。如果某些需求的变化性很大,那么定义接口也是一种解决之道。举个不恰当的例子,设计良好的接口就像是我们日常使用的万用插座一样,不论插头如何变化,都可以使用。

最后强调一点,良好的接口定义一定是来自于需求的,它绝对不是程序员绞尽脑汁想出来的。

数据映射层

在各个层的设计中,可能比较令人困惑的就是数据映射层了。由于篇幅的关系,我们不可能在这个问题上讨论太多,只能是抛砖引玉。如果有机会,我们还可以来谈谈这方面的话题。

面向对象技术已经成为软件开发的一种趋势,越来越多的人开始了解、学习和使用面向对象技术。而大多数的面向对象技术都只是解决了内存中的面向对象的问题。但是鲜有提到持久性的面向对象问题。

面向对象设计的机制与关系模型有很大的不同,这造成了面向对象设计与关系数据库设计之间的不匹配。面向对象设计的基本理论包括耦合、聚合、封装、继承、多态,而关系数据模型的理论则完全不同,它的基本原理是数据库的三大范式。最明显的一个例子是,Order对象包括一组的OrderItem对象,因此我们需要在Order类中设计一个容器(各个编程语言都提供了一组的容器对象及相关操作以供使用)来存储OrderItem,也就是说Order类中的指针指向OrderItem。假设Order类和OrderItem分别对应于数据库的两张表(最简单的映射情况),那么,我们要实现二者之间的关系,是通过在OrderItem表(假设名称一样)增加指向Order表的外键。这是两种完全不同的设置。数据映射层的作用就是向用户端隐藏关系数据库的存在。

自己开发一个对象/关系映射工具是非常诱人的。但是应该考虑到,开发这样一个工具并不是一件容易的事,需要付出很大的成本。尤其是手工处理数据一致性和事务处理的问题上。它比你想象的要难的多。因此,获取一个对象/关系映射工具的最好途径是购买,而不是开发。

总结

分层对现代的软件开发而言是非常重要的概念。也是我们必须学习的知识。分层的总体思路并没有什么特别的地方,但是要和自己的开发环境、应用环境结合起来,你还需要付出很多的努力才行。

在完成了分层之后,软件架构其实已经清晰化了。下面的讨论将围绕着如何改进架构,如何使架构稳定方面的问题进行。

作者简介:

林星,辰讯软件工作室项目管理组资深项目经理,有多年项目实施经验。辰讯软件工作室致力于先进软件思想、软件技术的应用,主要的研究方向在于软件过程思想、Linux集群技术、OO技术和软件工厂模式。您可以通过电子邮件 iamlinx@21cn.com 和他联系。

 
 

版权所有:UML软件工程组织