2007年5月8日星期二

面向对象设计散谈
  1. 初衷

最早开始接触面向对象的设计应该是在今年6月份皖北项目中。在这之前我做的程序可以说完全谈不上设计,更谈不上面向对象。大多是一些jsp页面,将数据从一个地方存到另一个地方而已。皖北的情况有所不同,当时用户对业务有需求,不是能够用jsp页面或者以某种方式移动数据所能解决的。用户的要求可以被理解为建立一个树状的模型,模型中的每一个上级节点的值是它所对应的下级节点的值的合计。为了实现这个需求,开始了第一次面向对象设计的实践。


面向对象的程序设计的核心原则是“开—闭”原则。开闭原则讲的是系统应该对扩充开对修改闭。这很好理解,就如同我们盖楼房,大家都盖3层。如果我的设计考虑到了日后的扩充问题,我按5层的标准设计。可想而知,如果日后房子不够了要增加层数,我就直接在3层上面加几层就可以了。而其他的人可能要把楼拆了重盖。


面向对象设计的其他原则有:里氏代换,依赖倒转等。而“开-闭”原则则是这些原则的核心,其他的一系列的原则好像都是为了实现开闭而提出的。包括很多,比如在设计的时候要使对象尽可能的内聚,同时减少耦合;要适当的抽象,复用的部分被提取到抽象类中;要尽可能的针对接口编程,或者说针对变化编程。而且封装的意义也已经扩大了,封装不仅仅指数据的隐藏而是任何形式的隐藏,事实上封装的是变化,也就是说系统变化的部分将会被封装。这说起来很简单,而实际上要求我们改变我们分析问题的思路和看待问题的视角,而所作的一切都是为了和开闭原则靠拢,最终的目的是设计出灵活的健壮的易于理解并且高效的系统。


设计模式的提出,很大程度上也是为了实现这个目标。提到模式我想起了我放假回家时打麻将的经历,牌桌上的朋友其实也不比我聪明,学历也没我高,可是我就是打不过他们,他们对于麻将的技巧十分的娴熟,甚至可以通过手中的牌和打出的牌来推测各家手中的情况。同时他们也有很多术语,什么“夹三条”,“边二万”等等。失败的原因在于,我一年几乎只打一次麻将,而他们几乎天天都在牌桌上。他们对于麻将的认识和我相比是有很大的区别的,他们了解很多“模式”不需要计算,直接从手中的牌就能看出怎么打,而我却需要很多计算,才能决定如何出牌,最终失败成必然。人天生就具有认识模式的能力,模式是一种针对特定系统的高级的抽象,而对于模式的理解与运用,只有通过实践才能最终纯熟。


既然提到了模式,我还想谈谈上帝模式。其实也许程序设计的最高境界就是这个上帝模式。在我们的系统中,有一个全能的上帝,它可以完成系统所需要的所有任务。其他的程序只需要向他“祈祷”(发送一个请求)就可以得到所需要的结果。当然,世界上没有全能的上帝,程序里面也没有,我所知道的系统,没有一个是这样设计的,也无法这样设计。事实上,软件设计模拟的是人类的意识,特别是人类的认识活动,分析活动。系统必然会被分解,形成很多概念,或者说是实体,也就是对象。我们总会从中去寻找我们所熟悉的东西,然后通过类比,分析,归纳等方式最终认识一个系统。认识的过程是反复的渐进的,程序设计也一样,这就是为什么要提出重构的原因。


  1. ETL中间表导入程序

    1. 概述

我们主要要讲的是这个简单的etl中间表导入程序。这是淮北项目数据分析系统的一部分。这个程序的目的是从一张定义好的数据库中的表里面讲数据按照某一个规则到如到相关的事实表和维表中去。涉及到的主要问题有:


  1. 事务操作。

  2. 同步的增量问题。

  3. 实现不同的插入数据库方法,需要适当的抽象。

  4. 日志。

  5. 以恰当的方式制定规则。

这些问题是设计时要考虑的。


下图是系统最高抽象的一个类图:

1

系统设计整体分为三个部分,分别是:

  1. 数据抽取部分

数据抽取部分的功能是将数据用源数据库抽取出来,当然这里源数据库中的数据是按照我们的要求存放的,也就是说是一种清洗过的数据。通过数据的抽取,生成系统的元数据(MetaData)。这一部分对应的类就是Generater


  1. 数据包装过滤部分

系统的数据核心是MetaData(元数据),图一中的Container接口其实是一个数据容器,它和它里面包装的数据一起实现了系统的元数据。


  1. 数据操作部分

系统的操作核心是类MetaTransformer(元数据转换器),它其实是一个非常简单的类,他所描述的对象的功能也十分的简单,它的作用仅仅是把数据和操作连接起来。SourceType(源类型)是对数据源类型的一种抽象操作。针对不同的数据源类型,产生不同的操作。这种类型的区分,实际上是由操作人员指定的。对程序来说,它将按照一个固定的规则来产生SourceType的实现者。


下面我们对上面几个类的相互依赖做一个分析。上面这个类图是用eclipse的一个免费插件euml2分析出来的,这个插件尤其擅长分析依赖关系。在图一里面,除了MetaTransformer这个类以外,都是单向的依赖关系。类之间没有出现循环依赖。MetaTransformer之所以和很多类有依赖关系,是因为它本身就是承担连接数据和操作的责任,必须把数据与操作连接起来,所以它必须和数据部分以及操作部分关联。没有循环依赖,其实是一个很重要的设计细则,这样也十分有利与单元测试。

    1. MetaData (元数据)

2

元数据中保存了操作时所需要的主要数据。这样设计的目的是将数据提取和数据插入两部分的功能分开。同时让他们相互独立。在设计的时候有一个原则就是要尽可能的使程序的耦合性降低内聚性增高。其实就是讲要将程序中能够独立出来的功能尽可能的独立出来,这样做也是满足开闭原则的必要条件,如果你不这样做,程序耦合度极高,牵一发而动全身是不可能满足开闭原则的。


元数据是整个系统的一个核心,所有的操作都会围绕着它来进行。从设计上来看这个元数据类,好像就是《重构》里面提到的一种叫“纯稚的数据类”的坏味道。但我却不得不这样做,否则我的数据就会被打散放在数据库里了。所以我就是需要这么一个纯稚的数据类,这样也方便我操作数据。MetaData和它的容器Container一起构成了系统逻辑上的数据部分。这里有几点要注意,MetaData要设计的尽量简洁,内容越少越好,最好只有变化的数据和它的一个标识。而且它应该采用单例模式,保证它是唯一的,在系统的运行期间只有一个实例存在。


另外它有可能会非常的巨大,如果这样可能会对系统的健壮性有影响。因此在构建的时候应该有一个限制,目前这一部分功能没有实现。


这里面还要提到一下这个Container的抽象,Container抽象的目的主要是考虑到性能的问题。目前使用的数据容器是用Vector来实现的,众所周知,Vector的效率比较低下,但是使用起来顺手,如果考虑到性能的要求的话,这个数据容器还可以考虑用其他的方式来实现。当然这种抽象也应该通过一种配置由工厂方法来生成子类。但是这个也没有实现,所以这个抽象是不完整的。


另外还要说明的一点,就是MetaData这个对象是可以序列化出来的。借助jdk的功能,这个对象可以序列化成一个xml文件。这个功能没有严格的设计,实现的方法十分简单就是使用jdk里面的java.beans.XMLDecoderjava.beans.XMLEncoder这两个类,其中一个是编码,一个是解码。通过他们可以把对象生成一个xml文档。

    1. Generater (生成器)

3

生成器的作用是将数据从一个源按照指定的规则抽取出来。如果纯粹是针对数据库操作的,其实也没有抽象的必要,这样设计其实是考虑如果需要从其他的数据源抽取的时候可以直接增加一个子类而不必改程序其他的部分。当然要实现这个目的至少还要配备一个工厂方法,以一种方式(通常是配置或者用户界面的指定)来决定到底是什么数据源。这个工厂方法没有实现,因为觉得出现这种需求的情况可能性比较小。所以其实这个抽象是聋子的耳朵——纯粹的摆设而已,没有什么实际的意义。


生成器里面有一个比较重要的操作就是要实现增量查询。我采用的方法是将源表中的时间戳序列化出来,成一个xml文档。这样做的坏处是较为复杂,好处是有了一个将源表中需要的数据导出的方法。另外仅操作源和目的无关,有助于减少系统之间不必要的耦合性。这里面还有一些设计的细节,比如不同的数据库对日期的操作是不同的,这里需要区分数据库,区分的方法采用webber.core里面的方法。就是通过分析配置文件配置项来产生多态。这里不细说了。

    1. MetaTransformer (元数据转换器)

4

数据转换的核心就是SourceType这个类型。它被设计为一个接口。因为我虽然知道源数据的类型,但是不能保证源数据如同我们想象的那么单纯,它有可能会比较复杂和特殊。所以我做了一个接口,这样可以保证SourceType类型在使用的时候系统其他部分不会受SourceType内部变化的影响。如果我们需要为SourceType增加一种功能,可以写一个实现这个接口的类就可以了,而系统的其他部分则不会受到影响。当然,由于SourceType的工厂方法负责根据条件产生它的子类,所以这个工厂方法也会受到影响,但总的来说,变化是可以控制的。不会扩散到系统的其他部分。


具体看一下这个抽象层次的设计。有一个抽象类SourceTypeDim用来描述维的操作。因为根据需求源数据的存放有三种可能:它是事实(SourceTypeDimBase)、他是维度(SourceTypeDimNone)、他是具有父子关系语意的维度(SourceTypeDimClass)。注意,这里使用了面向对象设计原则里面的依赖倒转原则,就是说复用的部分放在了抽象类里面。也就是说,元数据存放的三种可能所对应的三种操作其公用的部分被放在了抽象类里面。这样做的好处是减少冗余的代码,坏处是提高了抽象和具体的耦合性。所以这里面就有一个抽象层次划分的问题。我的设计明显少一个层次。应该设计为两个平行的抽象类,一个代表没有语意的维度描述,一个代表有语意的维度描述。但是由于有语意的描述只有一种情况,所以就暂时没有这么做。


具体什么时候实例化什么子类是由一个简单工厂方法SourceTypeDimFactory(参看图一)来决定的。它会读取配置文件,根据配置文件里面的描述来决定实例化那一个子类。


工厂方法——以上老是提到工厂方法。工厂方法是设计模式中的一种对象构建型模式,就是为生成子类所准备的。工厂方法的好处是把子类的产生变成一种规则,以后我们只要通过这种规则来构建子类,就不用担心其他的问题了。

例如:

这是我用的方法,为SourceType这个类型实例化子类。我是通过制定了一个类名的规则来实例化子类的。这是一种偷懒的很不好的方法。比较好的方法应该是通过配置文件,采用注册的机制,安全的产生子类。我这样做是因为这个继承关系十分的简单,也就是说SourceType这个类型的产品十分单一,特别的简单,所以工厂也相对简单了。

    1. 系统的其他组成

系统的其他组成主要有:XML描述文件、系统配置文件(未实现)、日志系统、系统全局常量、工具箱。


XML描述文件,用来描述数据源的特征,包括提取的sql语句,以及字段的含义。


系统配置文件,用来实现系统的一些配置,比如Container使用哪个子类,日志是否需要在后台黑窗中显示等。


日志系统,使用jdk提供的日志类,为系统增加日志。


系统全局常量,作用和配置文件一样,由于没有实现配置文件,就用它替代了配置文件。


工具箱,包含了一些需要的工具。比如一些转换,计算等等。

  1. 小结

在这篇文档里面,我提出了我对于面向对象程序设计的一些想法,这些想法很多都是幼稚的,不成熟的。虽然我也读了很多关于设计方面的书籍,但是我觉得程序的设计始终都是一种需要实践的艺术,动手比什么都重要。设计的核心其实还是人本身。一个系统设计的好还是坏,关键在于设计者对于系统的理解程度,没有理解就淡不上分析更谈不上设计。


正如我前面所谈到的人的认识过程是一个渐进的过程,所以重构在设计里面占一个极大的分量。上述的程序实际就是经过多次重构才最终成型的,而且目前也有很多的不足。因为这是一个客观的情况,所以我们在设计的时候切忌对象间的耦合度过高。耦合度越低越利于重构。同时我们也不要妄想一下子设计出一个完美的系统,毕竟人也不是上帝。此外还要学会利用别人已经完成的功能,在我的程序里面XML解析,日志生成都是使用了别人的成果。不要想着大包大揽,毕竟站在巨人的肩膀上才更容易成功。




没有评论: