在上一篇里面,我们初步了解了OO设计,OO设计的最独特之处在于他看待需求的方式。用这样的方式,我们不需要急于确定软件需要实现哪些流程、设计哪些功能点、制作哪些画面,而是要关注需求中一些更加基本的概念。首先根据这些概念开发出一些零件,然后把这些零件组装起来实现需要的功能。用这样的方式,我们不需要一开始就去知道所有的业务需求,只需要知道一些比较重要的需求,就可以开始开发了。这样开发出来的程序不仅可以实现当前的需要,同时也是一个业务开发的平台,在这个平台上可以不断的开发新的功能。
这种设计思想有很多实际的例子,比如Microsoft Office。下面的图就展示了Excel里面最基本的几个对象:
Excel的各项功能都是建立在这个对象模型基础上的。比如要实现设置字体的功能,就可以这样编写:先打开一个字体对话框,使用者选择字型、字号,然后把这个字体设置到区域上:
Application.ActiveWorkbook.ActiveSheet.Range("A1").Font = font;
Excel还把这些对象的引用暴露给了脚本引擎,于是我们就可以使用VBA调用他们,实现我们自己想要的各种功能,这就是Office宏。我们可以编写一段VB脚本,把鼠标选中区域的单元格复制到另一个工作表中,然后把某一个的单元格的值赋值为一个公式,计算出我们需要的数值。Excel不仅本身是一个好用的制表工具,他更是一个强大、易用的开发平台。使用者可以随时根据自己的想法,开发出需要的功能。一些大型的软件系统都是具有这样的特点,一开始就明确所有的功能需求是不可能的,重要的是形成一个业务开发的平台,提供一些业务编程接口,在这个平台上就可以不断的开发出新的功能。这样的开发方式不使用OO设计是很难实现的。
软件的第一个维护者和第一个使用者就是开发者本人,因此,开发迅速、功能灵活、维护简单——这些特点在精心设计的软件中经常是同时具有的。
运用OO方法设计程序的时候会遇到的这样困难:我们从需求中发现了一些模糊的概念,但是怎样才能根据这些概念建立合理的对象模型呢,到底哪些概念应该是一个类,哪些概念只应该是一个方法和属性,这些类之间应该是什么样的关系?要解决这个问题,最根本的途径当然是尽量深入的了解需求(比如说翻翻会计原理,看看应收款未收和已收的时候应该如何记账,其中一个记账原则也许就是一个重要的对象;协助用户做一个供电方案,随手画出的草图,或者某个计算公式就是一个重要的对象)。在解决了一个个困难之后,有人总结了经验,形成了一些解决特定问题的固定套路,这样的套路就是设计模式。
有些设计模式和OO没有什么必然的关系,比如层次模式,消息模式。但是大部分设计模式都是在OO设计中形成的,这些模式可以帮助我们发现系统中的对象、设计对象之间的关系。了解这些模式可以帮助我们把软件设计的更加合理。并且,在探索需求的过程中,我们也可以从模式中得到一些启发,获得设计的灵感,发现需求的真实面貌。
在上一篇我们看见了“费用”这个类型:Fee,其实这就是一个简单的设计模式:组合模式(Composite)。这个类的结构如下:
Fee类型是他本身的一个聚合,可以使用GetChildren方法得到某个费用包含的其他费用。如果一个费用没有包含其他费用,他的金额就是由他自己确定的,否则就是由他包含的费用相加确定的,这两种情况对外界提供的都是相同的方法:GetValue。这样,我们想显示一个账单费用的时候,不用再去判断他是否包含了其他的费用,调用起来就简单了很多。组合模式很好的体现了账单费用的层次包含关系。
刚才的情况里面,聚合类和元素类都是同样的类型。也有些情况他们分别属于不同的类型。比如一个企业,他的营销网络是由下面一些元素组成的:公司,市场部,直销店,代理商,自由代理人,营业员。如同下面的情况:
公司按照行政区域建立了多个市场部,市场部建立了自己的直销店,同时也与很多代理商和独立代理人进行合作,直销店和代理商雇用了营业员。每天公司需要对每个销售网点的情况进行查询和分析,需要知道他们定下了多少订单、收了多少货款、发展了多少新的客户。
这是一个比较复杂的结构关系,网点类型比较多,他们的销售方式差异很大,各类数据统计的方式也不同。并且在统计一些数值的时候,需要把下属网点的数量加起来,再加上自身的数量。如果采用组合模式,就可以解决这种问题。
我们可以设计一个类,叫做销售单位(SaleUnit)。这个类是他本身的一个聚合,可以通过一个集合成员访问到他下属的单位。并且他的每一种下属单元也是SaleUnit的子类。各种销售网点统计数据的方式是不同的:有的数据保存在数据库表里面,经过一些统计运算可以得到;有的直接放在数据表的某个字段里面,直接查出来就可以了;还有的是每天发过来的一个Excel电子表格。对于每一种不同的销售网点,都可以使用一致的接口对他们进行访问,得到需要的数据。
组合模式可以很精确的反映销售网点间的聚合关系,并且对查询和统计提供了非常一致的接口,调用者不必区分具体的网点类型。类似这样的情况,当我们发现需求中一些对象具有聚合关系,并且我们希望对他们做一些共同的事情,就可以采用组合模式。
现在产生了一个严重的问题:是的,调用一个对象的确实没有必要去区分具体的网点类型了,但是他们是在哪里被创建的呢,创建的时候还是要区分网点的类型,复杂的代码只是从一个地方转移到另一个地方罢了,这样做有什么好处呢?为了解释这个疑问,下面会介绍另一个常见的模式:工厂模式(Factory)。
工厂模式用来彻底的断绝调用者和被调用的具体类型之间的关系,他使用一个工厂创建具体的类型,调用者从工厂中取得对象的实例。调用者既不需要知道对象是怎样被创建的,也不需要知道创建的是什么类型。下面通过一个例子说明一下工厂模式的用处。
这是一个电气设备监控系统,他的一个任务是从安装在各处的传感器上采集各种设备的运行数据,集中显示在监视器上。还可以在设备上定义告警条件,当采集到的数据满足告警条件的时候,向监控人员发出告警,监视器上显示告警标志。基本的情况是这样的:
一个设备上可以有多个信号,比如一个变压器,上面可以有电压、电流、冷却剂温度等各种信号,分别由不同的传感器采集。每个信号隔一段时间会采集到一个数据,有的数据是直接采集到的,另一些是根据多个信号的情况计算出来的(比如一个电网环路上有n个节点,我们已经知道了其中n-1个节点的电压,就可以计算出最后一个节点的电压),还有的数据是一个推测值(需要根据一些经验数据进行推测)。传感器采集到信号数据以后,程序要判断这个数据的值,有时还要结合其他的信号,判断是否满足告警的条件,发出告警。
数据采集是一项十分复杂的工作,需要监控的设备种类繁多,数据意义复杂,传感器的通信方式也不相同。好在用户已经建设了了一个综合采集系统,解决了设备的实时数据采集工作。综合采集系统与各种传感器进行通信,将采集到的实时数据不断的输入到一个数据表里面。下面就是这个表里面的一些数据:
有了这么一个系统,直接采集数据的问题算是解决了。但是不同的信号类型对这个数据的解释仍然是不同的,我们仍然要应付这个问题。粗略的划分一下,有下面三种信号:
1、模拟信号:从综合采集系统里面查到实时数据,然后加上一个单位(比如伏特、安培),就可以显示了;
2、状态信号:需要定义一个状态描述。比如一个开关,采集数据高于0的时候就是闭合,等于0的时候就是断开;
3、推测信号:一些信号的数据从综合采集系统里面是无法得到的,必须通过公式计算出来。计算出来数值之后,加上一个单位显示出来。
我们可以把信号的定义存储在数据库里面,数据如下:
TYPE字段表示这个信号的类型:A是模拟信号,S是状态信号,P是推测信号。根据这个字段建立对应的信号实例,不同类型信号的数据处理就由对应的子类去负责。模拟信号会把采集到的数据加上单位(UNIT)显示出来;状态信号会把采集到的数据根据状态描述(STATE_DESCRIPTION)的定义显示出来;推测信号会按照推测公式(PRESUME_FORMULE)的定义去计算信号的值,然后加上单位(UNIT)显示出来。
我们可以在设备中处理Signal子类的创建,这样也不是不可以。但是,如果我们采用一个工厂,由他来负责Signal对象的建立,这样就完全隔离了设备和信号的每个子类的关系。设备在调用信号对象的时候,完全不需要知道这个实例是属于哪个类型。工厂的代码如下:
{
public static Signal CreateSignal(string dev, string sig)
{
//获取信号的定义
string sql =
"SELECT * FROM SIG WHERE DEV='" + dev + "' AND SIG='" + sig + "'";
//查数据库表
//判断需要创建的类型
Signal signal = null;
if (type == 'A')
{
signal = new AnalogSignal();
}
else if (type == 'S')
{
signal = new StateSignal();
}
else if (type == 'P')
{
signal = new PresumeSignal();
}
else
{
return null;
}
//设置Signal的配置参数
signal.SetUnit(unit);
signal.SetStateDescription(state_description);
signal.SetPresumeFormule(presume_formule)
return signal;
}
}
如果我们需要显示设备上的某个信号,这样就可以了:
string s = sig.GetDisplayString();
其实,我们还可以采用一些小手段,比如利用反射的方式,彻底的把Signal的各个子类与其他的代码隔离开,甚至连SignalFactory都不需要和子类产生联系。我们可以把信号配置的数据修改一下:
TYPE字段原先设计的是一个标志(A、S和P),现在直接记录类型的命名空间和名称。SignalFactory在创建实例的时候,直接查出TYPE字段的内容,然后按照这个类的名称,就可以用反射的方式创建需要的实例。这样,无论是Signal的创建者,还是调用者,都不需要知道他们创建和调用的实际类型是哪一个,各种信号的数据和显示处理完全是由Signal的每个子类负责,程序就很好的符合了开放闭合原则。假如以后出现了一些很独特的信号采集和计算方式,甚至不得不采用硬编码的方式去实现,也不会对其他代码造成不良影响影响,维护起来非常的方便。
我们利用一个工厂解决了信号数据采集的问题,并且为下一步可能发生的变化留下了扩展的可能。下面看看告警应该怎样处理。我们先简单的考虑一下告警的形成:首先是在设备上采集到最新的实时数据,然后按照某个规则去判断这些数据是不是符合了告警的条件。在符合条件的情况下,在设备上面产生告警。在大部分情况下,一个告警只和一个设备有关,但是也有这样的情况:某个告警条件需要同时判断多个设备上的多个信号。于是我们设计出下面这样的结构:
告警的定义保存在告警定义数据表里面,如下:
表里面的CONDITIONA字段表示告警条件,这是一个公式,判断的时候把信号的数值代入,然后判断公式条件是否得到满足。如果满足条件,就产生一个告警。
程序的运行时序是这样:设备对象得到自己所包含的信号上的实时数据,然后找到与自己相关的每一个告警对象,依次调用他们的Judge方法。Judge方法根据告警条件公式判断是否有告警存在,如果存在的话,就把相关设备的HasAlarm属性设置为True。就这样,程序的主要功能实现了。
下面是这个程序的一个客户端界面:
界面的左侧是一个树,表示设备的分类关系,每个叶子表示一个设备;当鼠标在树上点击一个设备,右侧的列表视图上显示这个设备上的信号和采集数据;如果设备上有告警,对应的树节点要标示一个显著的颜色,并且状态栏上要显示最近的告警。
要实现界面的刷新,最简单的方式莫过于在窗体上设置一个定时器,每隔一段时间检查一下所有设备的HasAlarm属性,发现有告警的设备,就把这个设备在树上的图标换掉,然后再把告警的内容显示在状态栏上。但是这样做有一个缺点,定时器的时间间隔无论怎样设置都是不合适的,时间太长了,可能会有一些告警要很久才能显示出来;时间太短的话,可能很多次刷新都没有告警,白白的消耗资源。这种刷新的机制是不合理的。要解决这个问题,可以采用观察模式(Observer)。一个对象需要等待另一个对象发出一个消息,然后再采取响应措施,等待消息的对象不需要知道消息如何发生、何时发生,发出消息的对象也不需要知道谁会关注这个消息、如何响应。这种情况就可以采用观察模式。
使用C#实现观察模式有一种非常简单的方法,那就是事件。我们可以在设备上定义一个事件:告警。当设备的HasAlarm属性被设置的时候,他会检查参数,如果发现参数为True,就发出告警事件。设备的代码片段如下:
{
public event System.EventHandler Alarm;//定义告警事件
public Device()
{
this.Alarm += new System.EventHandler(this.Device_Alarm);
}
public void SetHasAlarm(bool has)
{
if (has == true)
{
Alarm(this, null);//发出告警事件
}
}
private void Device_Alarm(object sender, EventArgs e)
{
}
}
当设备上产生告警的时候,设备对象会发出Alarm事件。界面上的树视图可以捕获这个事件,将对应的树节点图标设为红色;状态栏也可以捕获这个事件,把设备上的告警显示出来。比起定时轮循,这是一种更加合理高效的方式。
对象设计是不是合理,所参照的标准是这个设计是不是反映了业务需求的实际概念。要做到真实的反映业务需求,最根本的方法是要深刻的去理解需求,深入的探索业务人员的工作和思想,甚至去留意他们自己都无法用语言表达的思维环节。在一个涉及者众多的企业支撑系统中,这种情况很常见的。有经验的业务人员肯定会积累很多这样的思想,体现了这些思想的对象模型才是最优秀的。要让软件系统来帮助业务人员进行工作,也就必然无法回避这样的问题。合理的使用设计模式可以最大限度的降低系统的复杂程度,但是归根到底,复杂程度是由业务需求所决定的。当对象设计基本清晰之后,设计模式可以帮助设计人员更好的处理对象之间的复杂关系,建立一个更加简单、稳定的对象模型。同时,设计人员也可以从设计模式中得到启发,去发现一些原本没有留意的细节。