对象的自治和行为的扩展与适配
在坏的设计中,数据往往是分散的,甚至是杂乱的,这就好像一群失去意识的猛兽,我们无法控制、协调以及管理它们。这种漫无头绪的散乱数据,犹如猛兽的肆意妄为,会给系统带来无尽的灾难。随着系统的演化,这种灾难会逐渐蔓延至系统的各个角落。因此,在面向对象设计过程中,对数据分类是识别对象的一个前提。但是,仅仅封装了数据的对象,如果没有操作数据的行为,仍旧是没有意识的死亡对象。
我始终认为,对象在拥有自己数据的情况下,应该是自治的。这种“自治”类似于SOA中服务自治的概念,但由于对象应该保持足够合理的细粒度,因此这种自治是有限度的自治;或者说它体现的是专家的自治。如果对象拥有足够的数据信息,就必须树立这些信息的权威,这些信息的处理就应该由对象自己来完成。如果它拥有的信息量不够,或者根本不具备,则可以委派给其他对象。此时,行为即对象的意识,是对象能够自治的前提。
对象自治依赖于面向对象设计的一个重要原则,即对象的数据与行为应该封装在一起。Craig Larman提出的“信息专家模式”正是说明了这一点,该模式认为拥有信息的对象才是处理这些信息的专家。
对象自治是一个很有趣的概念,我们把对象拟人化,使得对象成为组成社区的基本元素。在这个社区里,每个对象的行动都应该由自己来控制。无论是完成某个操作,还是发出请求,或者响应事件,对象都应该有自己的判断。判断的合理性来自于它掌握的信息量,以及我们赋予它的意识的灵性。在构建软件系统时,我们的目标就是要搭建这样一个由自治对象组成的社区,而不是无序的混沌世界。每当我们在操作数据时,发现数据开始具有发散、混乱、模糊、蔓延等特征时,就是封装数据的信号。不管这些数据的数量,还是大小,它都应该作为对象存在于系统,同时该对象应具备操作该数据的能力。
例如在报表系统中,我们试图将构建好的报表整体导出为Excel文件。我们为导出功能定义了专门的接口ExcelTableExporter,它接收一个报表对象和工作薄对象,导出报表到Excel文件中:
public interface ExcelTableExporter { public void export(ReportTable table, WritableWorkbook workbook); }
这一接口的定义并无不妥之处。然而,当我们在实现export()接口方法时,事情开始变得难以控制。我们需要在export()方法中遍历整个报表,获得报表的行头、列头以及数据单元格,然后计算它们的坐标,获得它们的格式,再写入到Excel单元格中。显然,ExcelTableExporter要做的事情太多了,而它所要处理的报表数据也开始变得发散而混乱。虽然我们对报表进行了合理的分解与封装,但坐标依旧是散乱的,格式也没有和报表对象封装在一起。组成报表的元素对象仅仅拥有展现的数据值,却不知道自己该放在哪个位置,又该以什么面貌展现。换言之,这些组成报表的对象都不具备充分的自主意识,使得操作它们的ExcelTableExporter心力憔悴。它需要观察每个报表元素对象的数据,元素之间的依赖关系,考虑如何计算它们的坐标,获得符合客户要求的格式。如果我们将这种展现和导出报表的功能看做是将报表数据绘制在Excel画布上,那么ExcelTableExporter就好似一位不太高明的画师,奔忙于全局的掌控与细节的刻画,却因为能力不够而无法二者兼顾。如果我们让这些组成报表的元素对象拥有绘制自身的能力,境况是否焕然一新呢?此时,ExcelTableExporter只需要取出元素对象,放在Excel画布上,它们自己就知道该往哪儿去,该怎么绘制,根本不用ExcelTableExporter来操心。
根据单一职责原则(SRP),报表元素对象与报表直接相关,本身不应该承担绘制的责任,但放在导出报表这个场景来看,却又是合乎情理的。而且,与绘制相关的数据本身就与报表数据直接相关,例如报表元素的坐标,就依赖于报表数据的个数,以决定它占用的行数和列数。报表的格式同样设置在报表元数据中。不过,从抽象的角度来看,我们应该为其定义不同的接口,这也符合接口隔离原则(ISP)。同时,我们还需要考虑绘制行为的扩展。例如,在未来我们可能需要考虑将报表绘制为HTML网页。因此,我们可以定义一个绘制元素的接口:
public interface DrawingElement { public void draw(ReportCanvas canvas); public object getElement(); }
draw()方法负责将报表元素绘制到ReportCanvas对象中。ReportCanvas体现了“画布”的隐喻,作为载体用来添加绘制出来的报表元素。
public interface ReportCanvas { public void addElement(DrawingElement element); }
对于Excel而言,实现draw()方法就是在内部创建单元格对象。如果使用开源项目jxl来完成excel文件的生成,则该单元格对象可以是Label对象,也可以是jxl.write.Number对象。不过,ReportCanvas是不关心这些的,它只需要能够添加DrawingElement即可。这里就体现出了抽象DrawingElement的好处。当报表元素对象在实现该接口时,如果是针对Excel的导出,就可以把诸如Label和Number这样的单元格对象封装到实现类中。例如报表中的行头对象就可以实现DrawingElement接口:
public class RowHeaderExcelElement implements DrawingElment{ private object cell; @Override public void draw(ReportCanvas canvas) { canvas.addElement(this); } @Override public object getElement() { if (isNumber()) { cell = createNumberCell(); } else { cell = createLabelCell(); } return cell; } }
倘若将来需要支持Html,可以定义RowHeaderHtmlElement类实现DrawingElement接口。如果二者之间存在一些共同逻辑,则可以提取一个共同的基类RowHeaderElementBase。
因为引入了DrawingElement接口,报表元素对象就将绘制元素对象的数据与行为都封装了起来,使其成为了自治的对象。由于报表元素对象自身具备绘制功能,使得ExcelTableExporter的工作变得轻松自如,只需发出绘制的请求即可:
for (DrawingElement element : table.getReportUnits()) {
element.draw(canvas);
}
在实现上,我们还有一个问题需要解决。ExcelTableExporter的export()方法实现使用了jxl,DrawingElement类封装的Label或Number对象事实上需要绘制到jxl的WritableSheet中,而不是我们自己抽象的ReportCanvas。为了保证DrawingElment接口的抽象性,以及未来的可扩展性,draw()方法的输入参数必须是与实现无关的抽象类型。如果修改方法的定义为接受WritableSheet对象,就会限定为jxl,无法轻易变更,这是绝对不可取的。它违背了“供应商绑定”的反模式。
由于WritableSheet对象与ReportCanvas之间没有任何关系,强制的类型转换也无法保证将WritableSheet对象传递给DrawingElement对象的draw()方法。除非我们修改WritableSheet的定义,使其实现ReportCanvas接口。但这是不可能的,因为WritableSheet是第三方提供的公开接口,我们不能修改。这时,就需要考虑二者之间的适配。通过运用Adapter模式,我们可以引入一个间接对象WritableSheetAdapter,让其实现ReportCanvas接口,同时重用WritableSheet提供的职责。在jxl中,WritableSheet被定义为接口,通过WritableWorkbook创建。所以,我们可以考虑将WritableWorkbook创建的对象传递给WritableSheetAdapter:
public class WritableSheetAdapter implements ReportCanvas { private WritableSheet sheet; public WritableSheetAdapter(WritableSheet sheet) { this.sheet = sheet; } @Override public void addElement(DrawingElement element) { sheet.addCell((WritableCell)element.getElement()); } }
WritableSheetAdapter既实现了ReportCanvas接口,同时又组合了WritableSheet对象,完成了WritableSheet到ReportCanvas的适配,使得DrawingElement对象可以接受它:
WritableSheet sheet = workbook.createSheet(sheetName, 0); WritableSheetAdapter sheetAdapter = new WritableSheetAdapter(sheet); //遍历报表元素,以数据单元格为例 for (DrawingElement element : table.getCellGroups()) { element.draw(sheetAdapter); }