角色建模读书笔记

作者:林星

                                         

这篇文章源自于 Martin Fowler 的角色建模的文章,在吸纳大师观点的同时,加入了自己的一些看法。故将之整理为一篇读书笔记。

面向对象技术其实是一种以现实世界的自然观点看待软件代码的方法。原先的软件的编程方式是过程序的,但是这种过程序的编程方式没有办法处理过多的代码行,因为人的思考能力是有限的,很少有人同一时刻看的代码能够超过 500 行以上的,当然,有特异功能者除外。人们发明了非常多的方法来使过程序编程方法更加的方便,其基本的思路都是通过一些技术,对大段的代码行进行分割,例如代码块、函数、模块、工程、静态联编、动态联编等等。但是直到面向对象技术出现之前,并没有出项完整、彻底的解决方案。

我们刚开始学习面向对象的时候,总是要了解上一堆狗啊,哺乳动物啊之类的关系,其实这些都是为了向我们展示面向对象是符合人类对世界的认识的,也就是符合人类的世界观的。但是糟糕的是,面向对象技术看起来似乎应该是符合人们的世界观的,但是好像却要比过程序编程技术要难的多。好吧,我们撰写此文的目的就是为了解决这个似是而非的问题,揭开面向对象的面纱。你会发现,面向对象其实就是这么自然。

我们所有讨论的例子都集中在企业应用中,一方面的原因是企业应用其实是人类社会的一个缩影,能够灵活的运用面向对象技术来分析企业应用,那应该说你已经到了老鸟的级别了。另一个方面则是因为个人的原因,本人并没有接触过其它性质的系统,所以这也是没有办法的事情。

首先我们要谈的是对象所扮演的角色的问题。我们说狗是对象扮演的角色,文具也是物件扮演的角色。对象能够模拟出现实世界中各种各样的概念、事物、事件。我们把扮演特定角色的对象称为对象角色(object role)。现在我们先抛出第一个问题,一个公司中拥有各种各样不同的雇员,包括工程师(engineers)、销售员(salesmen)、主管(directors)、会计(accountants)。我们如何把这些现实世界中的概念(或者说是事物,这取决于你如何看待这些名词,以及你的需求)对应到面向对象中?或者说,我们如何用对象来表示这些概念?类似的问题还有很多,但我们这里就不多说了。值得注意的是,我们这里提到的对象可以理解为类,其实还包括类型,不过在有些语言中类型也被当作类来处理,而类的具体实例,我们会称之为对象实例。

第一种处理方法,也是最简单的处理方法――使用单个对象来表示统一的概念。这是下文中所有方法的基础,因此不要小看它。这是什么意思呢?也就是说,不论你要处理的概念是工程师,还是主管,或是会计。我都用

public class Employee

来表示它们。那么我应该如何分辨员工是工程师,还是会计呢。好,最简单的方法是使用一个 string 来表示员工职业的分别:

public class Employee{
  String employeeType;
}

当然,其它的类型信息也是允许的:

瀆ublic class Employee{
JobDescription employeeType;

这里的 JobDescription 是另一个用于描述职位信息的类。是不是非常的简单?这只是一个开始。但是要注意,根据我的经验,越是简单的处理方式其实越是实用。对没有太大差别的概念使用统一的对象来表示它们,并使用字段来表示概念的区别,对于大部分的需求都已经足够了。而困挠我们迟迟不敢做出简单的决策的原因往往是对未来变化的恐惧,也许你认为在未来用户可能会修改他们的需求,要求加入新的业务逻辑,处理不同的职位。例如,工资的算法依赖于不同的职位。对未知事物的恐惧往往导致我们进行过分的设计。放轻松些,变化并没有什么了不起的,就像下文提到的,只要接口设计足够的稳定,变化不足畏惧,适应变化正是面向对象所擅长的。因此,记住这一点,永远只是处理当前的需求,不要进行过分设计。这也是敏捷方法所提倡的。

好吧,这时候我们需求开始发生变化了。工程师、主管、销售员以及会计这些员工之间出现了非常大的差异。因此我们尝试使用另一种方法:每个单独的概念对应到一个独立的对象上。这样,我们就有了工程师对象、主管对象、销售员对象、会计对象。为简便起见,这里的代码我就不写了,和前一个方法的代码非常的类似,只是代码的量变大了而已。相信很多程序员对这种处理方法都不陌生。这种方法的好处是它能够把不同类型员工给分开,并使得基于不同职业进行业务逻辑处理成为可能,而且不会导致对象间耦合度的上升。

有利就必有弊。这种方法有两个致命的缺点:第一种是重复代码的产生,我们注意到,不论是工程师,还是主管,他们都属于员工,因此他们都有姓名、性别、年龄、住址等相同的个人信息。由于使用的不同的对象来建模它们,这些属性不得不重复好多遍。就算我们有这个耐心把这些属性一一放到各个对象中,但噩梦仍然没有结束,如果我们那需要增加一些属性,而这些属性恰巧是属于各个对象所共有的。那么我们不得不在各个对象中依次加入这些属性。天哪,总有一天我会疯掉的。什么,继承?你说对了,这就是我们要使用继承机制的一大原因。关于这一点,我们在下文讨论。

现在我们来看第二个缺点完整性不足。举一个例子,如果有一位员工,他既是工程师,又是主管。如何处理这种情况?我们只好为这个人建立两个对象,但是我们就没有办法分辨出它们其实表示的是同一个人。如果工程师和主管之间存在依赖关系的话,那可就糟糕了。这种问题在企业应用中时有发生,最经典的例子是供货商和客户同属于一个客户的情况。在 http://www.erptao.org 上有一篇文章就是专门讨论对这种情况的解决方法的。大家可以参考。

总的来说,这种方法不算是一种好的处理思路,能不用就不用吧。不过仍然是要具体情况具体分析。

铛铛铛铛!继承机制进场!我们回到第一个问题结束时留下的疑问,现在我们希望充分利用面向对象的三大特性之一的继承来解决我们遇到的问题。好,我们首先定义一个员工对象(或是其它什么名字),这个对象定义了员工的公用属性以及公用逻辑。然后我们再让工程师对象继承自员工对象,在工程师对象中实现工程师特有的属性和逻辑。好,依法炮制,我们得到了一个父对象-员工,以及三个子对象。如果我们需要处理的层次仍然停留在员工层次上,我们只需要处理员工对象,如果我们需要处理特定的员工,那我们就单独处理员工对象的子对象。如果我们有一位员工既是工程师,有时主管,那我们就也可以创建相应的子对象,如果我们希望增加退休员工的概念,我们也可以再加一个子对象。如果 ... 等等,还如果呢,出问题了。只要是有面向对象编程经验的程序员都知道单根继承和多重继承的区别,而现在的很多语言都是不支持多重继承的。那么,我如果要在不支持多重继承的语言中处理员工既是工程师又是主管的例子,我就需要创建一个工程师/主管的子对象,那么不用多少时间,我们手上的子对象的数量就会急剧上升。这就是典型的子类爆炸的情形。此外,继承的机制一般都是静态的,而不是动态的,也就是说,我不能够在运行时随便改变对象的类型(注意,和多态的概念不同)。

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

好,现在我们设计出的接口看起来像是这样的:

檯nterface Person {
瀆ublic String name();
瀆ublic void name(String newName);
瀆ublic Money salary ();
瀆ublic void salary (Money newSalary);
瀆ublic Money payAmount ();
瀆ublic void makeManager ();
 
檯nterface Engineer extends Person{
瀆ublic void numberOfPatents (int value);
瀆ublic int numberOfPatents ();
 
檯nterface Salesman extends Person{
瀆ublic void numberOfSales (int numberOfSales);
瀆ublic int numberOfSales ();
 
檯nterface Manager extends Person{
瀆ublic void budget (Money value);
瀆ublic Money budget ();

接口看起来不错,但是如何实现它呢,大致的做法有三种:内部标示(Internal Flag)、隐藏委托(Hidden Delegate)、 以及状态对象(State Object

下面的例子只实现了销售员部分的代码,没有使用到多重继承或是动态继承,但却较好的解决了前述的问题:

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;
}

这部分的代码使用了内部标示。在这个例子中我们用了一个单独的员工类来实现四个接口。因此我们需要对不同的子对象进行区分。newSalesman makeSalesman 以及 isSalesman 方法就是这种区分子对象的方法的具体实现。注意,其中 newSalesman 是一个静态方法,它起到了一个构造器的作用。此外,这里使用的方法属于显式类型方法(Explicit Type Method)。为了保证销售员的实现代码仅为销售员对象所调用,上面的代码中使用一个私有方法 requireIsSalesman 来作为守卫,如果有非销售员对象调用了销售员接口的方法,就会引发一个运行时错误。对于显式类型方法来说,一般的名称可以使用 isTypename 或是 beTypename,这种处理方法的好处是接口比较简单,但是如果加入新的类型的时候,基类的接口必须跟着变化。

从需求上来看,payAmount 操作应该是一个多型性操作,由员工基类定义,但由不同的子对象来实现。我们的实现方法是使用了一个 case 语句。一般来说,面向对象技术是不提倡使用 case 语句的,但这里使用它却无伤大雅,因为 case 语句是隐藏在员工类中的。因为我们的处理方式不能够使用到面向对象的多态机制,因此我们只好采用内部 case 语句的方式来实现这种多型性。

public Money payAmount (){
if (isSalesman()) return payAmountSalesman();
if (isEngineer()) return payAmountEngineer();
throw new PreconditionViolation ("Invalid Person");
};
private Money payAmountSalesman () {
return _salary.add (Money.dollars(5).multiply(_numberOfSales));
};
private Money payAmountEngineer () {
return _salary.add (Money.dollars(2).multiply(_numberOfPatents));
};

可以看到,内部标示的做法为复杂的分类提供了一种可能的实现,但是它却孕育了一个复杂的员工类,它囊括了所有子对象的数据和行为,并提供了选择机制如果不对它加以控制,那么它将会变成一个庞然大物。可以看出,这种做法虽然利用到了面向对象的思路,但是实现的方法还是过程序的,但是它有自己的很多好处。所以,只要员工类不至于失控,这种设计思路还是非常有价值的。在应用系统的很多地方,都可以采用该方法或是该方法的变种。应该认识到,面向接口编程和面向对象编程还是有差别的。

结束了内部标示的示例后,我们开始讨论另一种做法:隐藏委托(Hidden Delegate

public class PersonImpHD implements Person, Salesman, Engineer,Manager{
// implement manager
public void makeManager () {
 _manager = new ManagerImpHD();
};
public boolean isManager (){
 return (_manager != null);
};
private void requireIsManager () {
 if (! isManager()) throw new PreconditionViolation ("Not a Manager") ;
};
public void budget (Money value) {
 requireIsManager();
 _manager.budget(value);
};
public Money budget () {
 requireIsManager ();
 return _manager.budget();
};
private ManagerImpHD _manager;
}
 
class ManagerImpHD {
public ManagerImpHD () {
};
public void budget (Money value){
 _budget = value;
 };
public Money budget (){
 return _budget;
};
private Money _budget;
}

使用这种隐藏委托的方法,我们把和经理相关的行为和数据移到了经理对象中,从而大大简化了员工类。这种方法非常适用于角色的子类包含了很多的额外行为和特性的情况。但是这种方法还是没能够克服原来问题的一个缺陷-员工类仍然持有角色子对象的所有接口,而且员工类必须处理对不同方法的选择,即决定调用何种方法,传递何种参数。这样我们又理所当然的想到了继承机制,它能够很好的处理多态,为什么我们不能够利用面向对象语言提供给我们的便利之处呢?

于是乎,我们想到了利用四人帮(GOF)在设计模式一书中谈到的状态对象模式的设计思路。如果我们的子对象比较稳定,相互之间也不存在并集,那么采用这种模式预先划分好子对象是非常好的一种思路。当然,我们这里已经吸髓知味,知道说隐藏对象的技巧有助于向客户端隐藏具体的实现。因此,我们继续沿用这种思路,这样,我们的思路就基本上可以这样描述:

为每一个角色定义一个对象,这些对象对客户端不可见,因此称为隐藏对象。为这些隐藏对象定义一个父类,一方面可以利用继承的多态机制实现方法的选择和类型安全,另一方面可以把角色的通用行为移至父类中,以实现代码的重用。

设计一个会话类,实现所有的角色接口,但其自身不需要处理实现,而是把具体的实现委托给前面设计好的继承树。

从下面的代码中,我们可以清晰的看到我们提出的这几点步骤是如何贯彻到代码中的:

public class PersonImpHD implements Person, Salesman, Engineer,Manager{
public static Salesman newSalesman (String name){
PersonImpHD result;
result = new PersonImpHD (name);
result.makeSalesman();
return result;
};
public void makeSalesman () {
_job = new SalesmanJobHD();
};
public boolean isSalesman () {
return _job.isSalesman();
};
public void numberOfSales (int value){
_job.numberOfSales(value);
};
public int numberOfSales () {
return _job.numberOfSales();
};
private JobHD _job;
};
 
abstract public class JobHD {
private void incorrectTypeError() {
throw new PreconditionViolation("Incorrect Job Type");
};
public boolean isSalesman () {
return false;
};
public void numberOfSales (int value) {
incorrectTypeError();
};
public int numberOfSales (){
incorrectTypeError();
return 0;//value not returned since exception is thrown,
compiler needs return
};
}
public class SalesmanJobHD extends JobHD{
public boolean isSalesman () {
return true;
};
public void numberOfSales (int value){
_numberOfSales = value;
};
public int numberOfSales () {
return _numberOfSales;
};
private int _numberOfSales = 0;
}

在员工类中几乎不存在任何的实现代码,所有的请求都委托给了隐藏对象们。从这里的例子还不能够明显的看出多态的优势,我们再看一个例子:

public class PersonImpHD implements Person, Salesman, Engineer,Manager{
public Money payAmount (){
return _job.payAmount(this).add(managerBonus());
};
abstract public class JobHD {
abstract public Money payAmount (Person thePerson);
public class SalesmanJobHD extends JobHD{
public Money payAmount (Person thePerson) {
  return thePerson.salary().add (Money.dollars(5).multiply(_numberOfSales));
};

这一段计算薪水的代码就完全体现了多态的优势了。这里还有一些小技巧,可以看到,payAmount 方法需要用到 person 类的数据,因此,person 类在调用 payAmount 方法时将自己作为参数传递给了 JobHD 类。这种方式称为自委托(Self Delegation[Beck]

可以看到,对新增加角色的支持也同样简单,只需要从 JobHD 再继承一个子类下来就行了。由于接口是由 person 类来实现的,因此所有的改动都是在背后完成的,这可能就是隐藏对象的优势所在了。对于客户端来说,对于任何一个接口,都只需要对 person 进行实例化,然后根据不同的接口定义的不同的标准来调用就可以了,至于所有的实现细节,客户端根本就不知道。当然必须增加接口,但现在无论是哪一种平台都支持这种机制。因此,我们可以对从内部标志模式到隐藏委托模式,再到状态对象模式做一个简单的总结:

1.           接口和实现是分离的。

2.         接口符合现实世界的世界观,因此接口应该相对稳定。

3.         实现需要根据需求的复杂度,可能的变化、工作量的大小等因素来考虑使用何种方式。

4.         用户不需要知道具体的实现,所有方法选择、类型判定,算法实现等操作都是在用户一无所知的情况下进行的。

5.         使用一个外观类(person 类)来达到上面一点的效果。可以根据自己的因素,来考虑在外观类中实现多少细节。

1.           外观类中的实现细节可以完全移到新的类中,这取决于我们那的实际情况。

2.         三种不同的处理方法是可以根据需求的复杂程度来选择的,当然,相应的投入也会增大。

应该说,这种处理思路是非常自然的面向接口的设计思路,虽然不同的方法有着不同的实现技巧,但是主要的思路相似的,先从简单的处理出发,然后慢慢的对结构进行优化。这种思路使用于所有的软件设计。在这个例子中,实际上,在现实的软件开发中,一般不会定义三种的处理方式,而只会选取最优的那一种-即对象状态模式,因为模式一旦优化整理完毕,再次实现所花费的工作量其实是不大的,当然前提是你的模式整理工作要做的好。而且这种做法有助于保持软件、软件团队的一致性。因此,我们在处理一个问题时,可以先把所有的实现放在一个单独的类中(该例中为 person类),然后再试着把属性和方法从这个类中剥离出来,让不同的类来负责不同的职责,最后将设计完毕的方法整理为模式,在软件团队内部推广,并在实践中维护、改进该模式,以使其适合更复杂多变的需求。就像是隐藏对象一样,这种思路也是隐藏在面向对象设计背后的设计思路,值得我们花时间来掌握它。

需求说变就变,我们原先对单个人承担多个角色的担心还是发生了。按照上面的设计,要处理这种实现是很麻烦,很不自然的。因为你必须为一个现实中的人创建两个 person 对象。这是非常要命的,尤其是角色之间有相互冲突、相互调用的业务逻辑的时候。它可能会对整个设计产生致命的影响。看来,我们又有必要挥举无情重构的大刀来向我们的代码砍去了。

无情的重构是XP非常提倡的最优实践之一。应该承认,重构的思路是非常正确的。但是重构是不是一定需要做到如此彻底的程度呢,这方面我有着不同的看法。对代码的每一次修改都可能造成成本的上升,当一个团队,而不是一个个人在开发软件的时候,你更多需要考虑的是成本、质量、实现的辨正的统一。尤其是你需要对团队中的每一位程序设计师非常的了解,了解他们的能力,了解他们的性格。因此不顾一切的重构在现实中往往会遇到各种各样的阻力,但是如果重构最终达到的效果并不十分明显,或是远低于贯彻它所付出的代价的时候,重构就是不合适的。但是应该说,在适当的情况下的这种实践是非常有必要的,特别是需要研究代码重用的时候,比如我们现在讨论的对模式的改进的问题。这种情况下代码的每一步优化都将极大的提高生产力。

在稍微离题之后,重新回到我们的讨论之中。我们已经知道需求要求我们为一个对象表示多种角色。所以,person personRole 之间的关系演变为一对多的关系,这样就能够解决一个员工可以拥有多种职位的需求。可以想象到,这种做法的一个不好的地方是,用户需要开始了解部分的实现了,因为原先定义的接口已经不合用了。在这个例子中,用户除了需要知道员工自身(person 类),还需要知道职位的信息(personrole),这样才能够处理员工和职位之间一对多的关系。在这种情况下,我们对职位信息的处理,还是可以采用上文所讨论的显示的方式,例如 isSalesman() 方法。但是由于部分实现已经暴露给客户端了,这种方式已经失去了它的存在意义。更好的方法是采用参数化的hasType(String)方法。这种方法的优点是为各职位对象定义了统一的逻辑接口,同时可以动态的增加职位对象,而对接口不会有影响。和上面的所有讨论的方法类似的,有一利就有一弊。这种方法定义的接口不够清晰,至少我们需要了解 string 参数的定义。此外,编译器不再为我们执行类型检查的工作,这部分的压力移到了程序员的身上。因此,这种动态的参数化技巧的使用是需要非常小心的,只有确保它为你带来的利益能够超出它的成本的时候才能够使用它。这种参数化的方法的命名一般可以采用 hasType( typename) 或是 beType( typenam ) 的方法。

class Person {
public void addRole(PersonRole value) {
  _roles.addElement(value);
};
public PersonRole roleOf(String roleName) {
  Enumeration e = _roles.elements();
  while (e.hasMoreElements()) {
    PersonRole each = (PersonRole) e.nextElement();
    if (each.hasType(roleName)) return each;
  };
  return null;
};
private Vector _roles = new Vector();
  
}
 
public class PersonRole{
public boolean hasType (String value) {
  return false;
};
public class Salesman extends PersonRole{
public boolean hasType (String value) {
  if (value.equalsIgnoreCase("Salesman")) return true;
  if (value.equalsIgnoreCase("JobRole")) return true;
  else return super.hasType(value);
};
public void numberOfSales (int value){
  _numberOfSales = value;
};
public int numberOfSales () {
  return _numberOfSales;
};
private int _numberOfSales = 0;
}
// To set a salesman’s sales we do the following
Person subject;
Salesman subjectSalesman = (Salesman) subject.roleOf("Salesman");
subjectSalesman.numberOfSales(50);

我们来分析这段代码。和以往的代码不同的是,接口已经消失了。因为在原先的需求中,我们是不关心员工和职位之间的关系的,换言之,我们认为员工和职位是一体的,只是不同的职位对于我们来说有着不同的处理方法。但是根据现在的需求,我们需要处理员工和职位两者的关系,对于我们来说,员工和职位已经是两个并行的类树了。所以,我们看到客户端的调用代码中是先声明员工,再调用员工的 roleOf 方法来调用员工所从事的某种职位。因此,客户端需要知道职位的信息,才能够正确的获得职位对象。注意 hasType 方法中的方法检查,它是根据多态的基本原则来实现的,对于 Salesman 类型和 Salesman 的父类都返回真,其实,对父类类型的判断也可以放到父类的方法中。

上面的例子看不出具体的应用,我们可以考虑一种情况,我们需要对公司中的所有销售人员(可能有人身兼多职)进行一项业务逻辑处理。我们的代码可以写成这样:

Enumeration e = persons.elements();
while (e.hasMoreElements()) {
   Person each = (Person) e.nextElement();
   if (each.hasRole("Salesman")) {
     Salesman sm = (Salesman) each.roleOf("Salesman");
 
       //其它的处理

hasRole 方法是一个新增加的函数,其实现的机制类似于 roleOf 方法。

好吧,使用现在的代码,我们已经能够处理员工和职位之间一对多的情况了。那么,我们有没有想过另一种的情况呢,员工和职位之间是多对一的情况。比如最简单的例子是 1141000 的人工台。对于用户来说,只存在接线员这个角色,而每次接线员这个角色对应的人是不同的。因此,我们的视角就转移了,转移到了角色上,类似的,我们同样可以根据需求来改进我们的设计,但是接口一定已经不同了,因为需求的变化非常之大。因此,我们再一次深入到设计的背后,思考为什么我们采用 A 设计,而不是 B 设计。这些都是有根据的,决不是因为 A 设计比较 Cool 之类的原因。最大的设计选择的影响因素就是需求,包括功能性需求和非功能性需求(约束条件)Martin Fowler 感叹到,设计其实是一种权衡策略,一针见血的指出了设计的本质。结合到我们的例子中,为什么我们采用的设计是在员工类中增加一个角色列表,而不是相反的情况呢。这是因为需求要求我们这样做,职位是员工的某一类属性,虽然这类的属性比较特殊,但员工对象仍然处于核心的地位。假设我们的需求发生了变化,对员工的信息不再关心,而转而关注职位信息,那样我们的设计就会截然不同。如果在处理信息系统中,往往职位和员工都占有很重要的位置,大量的信息和逻辑都是基于这组概念的。这样我们可以就需要在员工和职位之间设计双向关联,来表现他们之间的多对多的关系。最简单的例子是,一般的信息处理是从员工角度出发的,但是人力资源系统的职位处理就需要从职位角度出发。还是那句话,关键还是取决于我们的视角,我们的需求

好吧,我们再次回到设计中,和你想象的一样,我们需求又发生了变化。现在我们需要加入限制逻辑了。我们规定,一位员工不能够从事两份的工作性职位,但可以在一份工作性职位外再兼任一份管理性职位。

废话少说,我们立刻开始修改我们的代码:

class Person {
public void addRole(PersonRole value) throws CannotAddRole {
if (! canAddRole(value)) throw new CannotAddRole();
  _roles.addElement(value);
};
private boolean canAddRole(PersonRole value){
  if (value.hasType("JobRole")){
    Enumeration e = _roles.elements();
    while (e.hasMoreElements()) {
      PersonRole each = (PersonRole) e.nextElement();
      if (each.hasType("JobRole")) return false;
    };
  };
  return true;
};

非常精彩的代码。我们来看一看这段代码。我们要处理限制逻辑(约束条件),首先要解决的问题是了解清楚它的来源。因为这一类的设计往往源自于非功能性需求。限制逻辑的实现策略有非常多种。例如,把限制逻辑放在接口层,或是把限制逻辑用数据库的 constraint 来实现。我个人的意见比较偏向于把限制逻辑放在业务层,就像我们的代码所处理的那样。原因有三:首先,接口层往往部署在大量的客户机器上,逻辑的修改非常不方便;其次,不同数据库的 constraint 有所差别,移植不便;最后,限制逻辑过于分散的话,会造成修改维护成本的上升。基于这几点原因的考虑,我比较倾向于集中在业务层实现限制逻辑。当然,和以上的设计思路一样,这还是要根据实际情况进行调整的。

下面一个考虑的因素是,限制逻辑放在哪些类中,又该放在类的哪个位置上。这是一直以来困扰我的一大问题,因为最经常遇到的一种情况是,限制条件往往是跨类的-即它和多个类相关,因此很难能够决定它的适合位置。这里我给出两种解决建议。之所以说是解决建议,是因为这个问题是无解的,只能够凭借各位的经验来做具体的判断,但是有些建议能够帮助大家做决定。

第一种建议是,和你的领域专家讨论限制条件的位置。因为只有领域专家才有足够的经验和权威做出需求的决策。中间可以使用到一些技巧,例如 CRC 卡片,来帮助讨论。一般在这种讨论会上主要是把限制条件分配给某个或某几个业务实体类,要想真正确定其位置,还要结合建议二。

第二种建议是,如果遇到涉及到多个类的限制条件,那么也许意味着你需要设计某种体系结构来处理把这几个类整合起来。这种体系结构可能是继承树、管理类、外观类的组合。例如我们的代码就是这样。如果没有 person 类,我们可以不知道把这个限制逻辑放于何处。

好,在讨论了限制逻辑之后,我们又提出一个问题,这里的代码中,限制逻辑只有一个,如果限制逻辑很多怎么办呢?这时候你可以考虑专门为限制逻辑设计一种方法(类似于 canAddRole),甚至是一个类来集中基础限制逻辑。但是如果你的限制逻辑太多,到了一种有碍观瞻的地步的时候,也许正说明你的设计结构存在某种问题。比如,我们完全可以不使用限制逻辑方法就实现以上的需求:

class Person {
public void jobRole(JobRole value){
  _jobRole = value;
};
public PersonRole jobRole() {
  return _jobRole;
};
private JobRole _jobRole;
}

代码是不是简单了很多?因此某些时候,无情的重构还是有必要的,它能够大大的简化代码。但是应该认识到,凡事都不可能一开始就完美无缺,一定存在一个进化的过程。对于代码的来说,结构的优化始终都是最重要的。因此,设计程序如果能够着眼于设计,着眼于代码结构,那出产的代码一定是高质量的

噩梦终于要到头了,需求的最后一次变化是增加了职业分组的功能。这种情况是非常普遍的,比如每种职位需要属于某个部门,这就是对职业进行某一种分组。从另外一个层面上来说,这其实是连接其它设计模块的桥梁。因为我们讨论的模式虽然独立,但是对于软件来说,将各个独立的模式有机的连接起来也是非常重要的。因此,在下面的代码中,我们可以看看模式是如何扩展的:

class Person {
public void addRole(PersonRole value)throws CannotAddRole {
  _roles.addElement(value);
};
public PersonRole roleOf(String roleName, Group group) {
  Enumeration e = _roles.elements();
  while (e.hasMoreElements()) {
    PersonRole each = (PersonRole) e.nextElement();
    if ((each.hasType(roleName)) && (each.group() == group)) return each;
  };
  return null;
};
private Vector _roles = new Vector();
public class PersonRole{
protected PersonRole (Group group){
  _group = group;
};
public Group group(){
  return _group;
};
protected Group _group;
public class Salesman extends PersonRole{
public Salesman (Group group){
  super (group);
};

注意到,这里的连接设计是采用参数的方式进行的。分组作为一个参数传递给 person personrole。由于我们建立了 personrole group 之间多对一的关系,因此在 personrole 中设置了类型为 group 的私有变量来保存这种关系。这里,我们认为职位和职位分组是一种单向的关系,因此不需要在 group 类中同步的处理。同样的,如果要加入限制条件的话,仍然需要考虑其实现位置。

我们想象一下,在 group 类的背后,可能也隐藏着大量的内部实现类或是协作类。这些类通过 group personrole 之间的关系连接在了一起。

小结:

简单设计,不断改进

美好的设计不可能一开始就完成,做事情的正确方式应当是循序渐进。记住简单的原则,尽量不要为未来的需求而编程。与此同时,我们还需要处理变与不变之间的辨正关系。从需求中识别出变化的部分,以及不变的部分,对变化的部分进行抽象,识别出变化的表面之下的不变的内涵。这种思考方式就是抽象的思想。

改进包括两种概念,一种是对功能的改进,一种是对代码的改进。前者我们在下面的讨论中会提到,XP中的重构的思想有助于我们处理后者。重构要用的好,我的经验是配合代码复审会议来使用。从这样的活动中总结出适合你自己的重构标准。我提倡小规模的、不断进行的重构,反对大规模的重构。因为后者的风险过高。要形成团队不断重构的习惯并不是一件容易的事情,需要额外的动力才能够实行。

设计来自于需求,设计的改进源自于需求的演变

所有的设计都不是孤立存在的,强制在自己的项目中应用设计模式,应用刚学到的面向对象技巧,都只是削足适履。设计应该从需求出发,并满足需求。脱离需求的设计并不是一个好的设计。注意到,文章的前半部分讨论的设计和后半部分讨论的设计截然不同,因为需求发生了变化,但两种设计都属于优秀的设计。记住 Martin Fowler 的一句话:设计在于权衡。

需求在演进,相应的设计也需要演进。设计的演进往往被认为是一个痛苦的过程,不错。但是还是有很多的办法能够解决这个问题的。下面一点的讨论就是一种方法。

针对接口设计,接口应该能够满足现实世界观

针对接口设计是一种新思想,它能够帮助我们分离接口和实现部分,实际上,它们分离的是上文中提到的变化和不变的部分。接口是稳定的,因此它属于不变的部分,实现是不稳定的,属于变化的部分,随着需求的演进而演进。设计一个稳定的接口是软件设计中非常重要的部分,它能够保证在用户不察觉的情况下对需求进行扩展,对代码进行升级。但是应该认识到,设计一个稳定的接口并不是一件容易的事情,而其中起到决定性因素的,不是面向对象的设计能力,而是领域经验。换句话说,如果你对某个领域有深入的了解,能够体会到领域中的抽象本质,那设计出的代码自然会非常的优秀。因此,我们强调,界面要能够符合现实世界观。做到这一步需要时间和经验