UML软件工程组织

 

 

颠覆传统-面向对象的设计思想
 
2008-11-13 来源:cnblogs
 

颠覆传统-面向对象的设计思想(序章)

从我们最初接触面向对象思想的时候,我想我们接触到的第一个概念应该就是“类”,我们一直在讨论诸如如何设计类、如何实现类等高深的问题,但是我们有没有思索过到底什么叫做“类”,类的本质是什么?。按照大多数的面向对象的书籍中的介绍来看,类就是一个数据结构,封装了数据和操作,对于这样的答案,我估计大家都不会满意。

那到底什么是类呢?在讨论这个问题之前,我们先探讨一下类的由来。“类”在英语对应的单词是“Class”,如果大家翻一翻英语词典就可以查到“Class”的原意是指“种类、把...分类(或分等级)”。Class的概念最早应该是从分类学来的,意思是把对象进行归类(说的可能有些不太准确,欢迎那位高人指正),例如生物学上会根据某一个标准将生物分为动物和植物两大类,然后再根据其它的一些标准将动物又分为鱼类、爬行动物类、两栖动物类等不同的种类,如下图所示:

说到这里,可能大家会欢呼:原来面向对象的类就是分类,太好了!我最擅长这个了!别高兴的太早,谁知道面向对象的分类标准是什么吗?是生物学的标准,还是能不能爬树的标准?不同的标准,导致分类的结果完全不同,如下图所示:

假设现在需要要写一个弹涂鱼的类(又名虾虎鱼,英文名为Goby,一种可以爬上陆地并且会上树的鱼类,据说味道极其鲜美,有海上人参之说) ,怎么写?是不是太容易了,看下面的代码,分分钟就搞定了:

 1 '鱼
 2 Public Class Fish
 3 
 4 End Class
 5 
 6 '可爬树的鱼
 7 Public Class ClimbableFish
 8     Inherits Fish
 9 
10 End Class
11 
12 '弹涂鱼
13 Public Class Goby
14     Inherits ClimbableFish
15 
16 End Class

打完收功,貌似很完美的解决问题,但是这个时候又添加了一个分类标准,能吃的鱼和不能吃的鱼(鲨鱼能吃,俺吃过,味道不咋地,在这里假设鲨鱼不能吃),又该怎么办,Stupid,再写一个“EatableFish”类不就得了,让可爱的弹涂鱼从可以吃的鱼派生,我最喜欢能吃的鱼了!且慢!动手之前我想搞清楚一个问题:EatableFish从那个类派生?从ClimbableFish类派生?难道可以吃的鱼都是会爬树的鱼?从Fish派生,那么是不是说会爬树的鱼都不能吃?这个时候是不是该咒骂微软为什么不在.NET中支持多重继承?算了,还是转投Java阵营算了。旁边的一位兄弟弱弱的来了一句:好像Java也不支持多重继承吧。怎么办?难道我们就没有办法解决这个问题了吗?

貌似用分类学的搞法搞不定面向对象的类耶,我们错了吗?但是很多教科书上面就是这么说的类的继承是“Is A”(是一个)的关系呀,弹涂鱼是“Is A”能吃的鱼、弹涂鱼“Is A”能爬树的鱼,念起来蛮通顺的嘛。错了!我们都被教科书给误导了!面向对象关注什么?关注的是对象的行为,面向对象是使用行为来对对象进行分类的!在面向对象中派生类为什么能够替换基类(替换原则),不是因为派生类是一个基类,而是因为派生类具有与基类一致的行为,在派生类与基类的行为不一致的情况下派生类仍然是一个基类(如果有人敢否认这个,大家说怎么办?旁边有人喊道:砍死他!),但是这个时候派生类消减了基类的行为,违背了替换原则,这也是恶心设计的由来。所以说,对于面向对象而言我们要关注“Act As”,用“Act As”的标准来对对象进行归类,至于什么“Is A”之类的伪标准统统扔到它姥姥家去。

旁边有人不干了:你跟我说说属性是什么动作!对呀,属性是个什么动作呢?那么请有如此疑问的朋友仔细的考虑一下,是不是可以将属性考虑为GetXXX和SetXXX的两个方法,至于说字段怎么怎么地的某些兄弟俺就不多说了,回去自个好好想想吧,有些东西是属于开发平台为我们做了很多的工作,只不过我们不知道而已。

好,问题到这里已经有些眉目了,我们该讨论如何使用“Act As”来对对象进行分类了。

高手出招了,代码如下:

 1 //可爱的小鱼接口
 2 public interface IFish
 3 {
 4 }
 5 
 6 //可爱的爬树接口
 7 public interface IClimbable
 8 {
 9 }
10 
11 //可以吃接口
12 public interface IEatable
13 {
14 }
15 
16 //弹涂鱼出场了
17 //我要扮演鱼
18 //我要扮演爬树高手
19 //我要扮演可以吃的美味,貌似没有人愿意扮演这个
20 public class Goby : IFish, IClimbable, IEatable
21 {
22 }

高手!请问我需要怎样表演才能扮演成一条鱼呢?高手愕然。

看来我们的高手还是没有摆脱“Is A”的荼毒呀!我们不要鱼!我们要的是行为!行为由什么决定的呢?由要用你这个类的地方期望要的行为来决定的,例如我需要一个能够提供游泳行为的对象,你就可以抽象"ISwimable"这个动作(这个单词可能不对),然后寻求实现这个动作的对象就可以了(接口倒置原则)。

有些朋友可能会有一些疑问,既然是动作,那么动作之间怎么会有继承呢(接口的继承)?例如:

1 //我是一个数据提供源
2 public interface IDataSource : IDisposable
3 {
4 }
5 

仔细想想,这个是继承吗?是“Is A”吗?不是!不知道大家玩过拳皇或者其它的格斗游戏没有,要知道分别连续按键是可以组合出一个大招的,在某些情况下,对象的使用者或者理论一点的说法消费者,需要是对象同时提供上述的两种行为,不过分吧。软件设计的时候往往就是这个地方出问题,如果没有分清楚的话,很有可能把本应该拆分的动作当作一套组合拳给打了(接口隔离原则),这也是混乱的开始,重构的原因。

我们一直以来都从“Is A”的角度来对对象进行归类,但是仔细的想一想,“Is A”的标准是什么?我们怎么样才能判定一个对象“Is A”另外一个对象呢?大家是不是基本靠猜测或者凭经验在做?这也是软件设计一直被当作是一种艺术行为的原因。一下这个图是我的一个观点,请大家参考一下:

其中箭头表示对象的行为,我们关注的行为是指落在系统范围之内的行为,或者系统关心的行为。

好了,今天就写到这里吧,以后有时间我会再详细的讨论如何分拆动作,如何设计类的话题。

颠覆传统-面向对象的设计思想(序章续)

自从《颠覆传统-面向对象的设计思想(序章)》发布出来后看的朋友和评论的朋友很多,有说好的,也有说不好。当然也有很多朋友在文章的评论中发表了自己的见解,在这里我就一些比较典型的评论做一下解释。

  • 来自名为wanghualiang 的评论
很佩服楼主的发散型思维。但是远远还没到颠覆传统的地步。
这里谈谈我的观点,
面向对象设计时完全从接口来描述对象本身的特性是不是有问题。
从鱼是不是可吃应该只能作为其一个属性来辨识,
Class Fish
{
public bool IsEatable;
}
当客户想吃这条鱼的时候,IsEatable=
true;如果是河豚的话就是 False了。
当然有许多种不确定的因素,在可吃不可吃之间。那我们应该
[Flags]
Enum Eattype
{

DeliciousEate,
//美味
Distasteful,
Barbed,
..
}

Class Fish
{
public Eattype eattype;

我想wanghualiang 的评论代表了相当一部分朋友的看法,从传统的面向对象思想(或者说普遍接受)的观点来看这样做是没有问题的,甚至还被作为了Demo写入了很多的面向对象的教科书。但是实际上这种看法是存在问题的,至少我个人是这么认为的,首先我们需要考虑的问题是:鱼自己能不能决定自己能不能吃,能不能决定自己好不好吃?应该是不能吧,决定鱼能不能吃,好不好吃的应该是吃鱼的对象对吧。也许从普通人的角度来看河豚是不能吃的,但是从高明的大厨或者资深的老饕的角度来看河豚就是无比的美味了,这也是我在序章的最后专门添加一幅图片重点解说对象行为的原因。

话说回来,既然鱼不能决定能不能吃、好不好吃,也就是说明能不能吃的行为不是有鱼能够决定的,那么也许有人会问那为什么要实现IEatable接口呢,和直接做一个属性不是一样吗?这个问题问的非常的好,确实既然鱼不能够决定自己能不能吃、好不好吃,那么为什么鱼要实现IEatable接口呢。其实,在Fish上实现IEatable接口完全是出于使用方便性和接口的层次(后续的文章中会重点讨论这个问题)来考虑的,完全面向对象的搞法应该是有另外一个对象来鉴定这个鱼是否能吃、好不好吃的(这也是基于设计的平衡来考虑的,可以参看开放-封闭原则)。在这个地方使用接口和属性本质上没有什么差别,但是一旦鉴别鱼能不能吃、好不好吃的鉴别方式(实现方法)发生变化的时候,使用属性的方式就难以扩展了,只能修改代码了,但是使用接口的好处是我可以使用其它的方式补救,例如做一个实现IEatable接口的装饰对象来装饰鱼对象。

另外,导致需要鱼对象实现IEatable接口的原因可能是出于接口隔离原则的考虑,如果使用属性来辨别鱼是否能吃,必然使用的地方就依赖鱼对象了,提高了系统的耦合性。示意代码如下:

 1 '检索是否可以吃的食物
 2 Public Function GetEatableFish(ByVal foods As IEatable()) As IEatable()
 3 
 4     '用于保存列表
 5     Dim tempList As New ArrayList
 6 
 7     '循环的检索
 8     For Each item As IEatable In foods
 9 
10         '判断是否可以吃
11         If item.IsEatable Then
12 
13             tempList.Add(item)
14         End If
15     Next
16 
17     '返回结果
18     Return tempList.ToArray(GetType(IEatable))
19 
20 End Function
21 
22 '是否可以吃接口
23 Public Interface IEatable
24 
25     '是否可以吃
26     Function IsEatable() As Boolean
27 
28 End Interface

软件设计的时候没有一味的好,也没有一味的差,任何事情都有其两面性,这个是需要取舍的,我们能够做到的事情就是让设计可控,如果设计失控了,那就全部完蛋了。话说回来,如果能够确认当前的系统中评判鱼能不能吃的标准不会发生改变,把这个不会发生改变的东西集成到鱼对象中是完全可以的,在具体的实现上用属性来实现也是一个非常不错的搞法。

  • 来自名为 Anders Liu 的评论
嗯。。。建议你在多想想再继续写。

比如这个:
public class Goby : IFish, IClimbable, IEatable
我认为这样比较好:
public class Goby : Fish, IClimbable, IEatable

看到区别了么? 

至于Anders Liu 的评论应该与wanghualiang 的意见基本上是一致的,如果我没有理解错误的话。我们引入一个Class的时候需要考虑的是引入这个Class的目的,如果没有任何目的的引入一个类或者是仅仅简单的为了封装一个方法来引入一个类是不可靠的,后面我会写一篇随笔来专门讨论类和接口。

颠覆传统-面向对象的设计思想(神仙?妖怪?)

在我的前两篇随笔中,我大概的陈述了一下我对面向对象设计的一些想法。本人拙于言,不善表达,写文章陈述观点这种技术活就更不擅长了,如果在文章中有什么 表述不清之处,还请各位海涵。此外,非常欢迎大家对我的文章点评,无论是赞扬、还是批评我都笑纳,多多益善,如果有探讨类型的评论就更好了。

从《颠覆传统-面向对象的设计思想(序章)》和《颠覆传统-面向对象的设计思想(序章续)》的评论来看,对这两篇随笔持不同意见的主要原因是设计和需求之间关系的问题,很多朋友都在他们的评论中一再重申需求的重要性,认为需求和设计密不可分,需求是设计之母,离开需求谈设计都是空谈。

谁也不能说这些观点是错的,但是说这些有用吗?不可否认,谁也不可否认需求的重要性,但是我们在谈论需求的时候,甚至把这个名词当作一个放之四海而皆准的真理的时候,我们有没有仔细的考虑过我们一天到晚谈论的需求到底是个什么东西?

需求是什么,需求就是客户的欲望。人的欲望是无穷的,所以客户的需求永远也是无法满足的。我们做需求的目的是什么?不是满足客户的所有需求,也不可能满足客户的所有需求,而是保证我们能够尽可能的游走在客户勉强接受与暴走之间。说到这里,我不由的想到了“朝三暮四”这个典故,这个典故源于《庄子.齐物论》,说的是有一年碰上粮食欠收,养猴子的人对猴子说:“现在粮食不够了,必须节约点吃。每天早晨吃三颗橡子,晚上吃四颗,怎么样?”这群猴子听了非常生气,吵吵嚷嚷说:“太少了!怎么早晨吃的还没晚上多?”养猴子的人连忙说:“那么每天早晨吃四颗,晚上吃三颗,怎么样?”这群猴子听了都高兴起来,觉得早晨吃的比晚上多了,自己已经胜利了。

是不是很有意思,做需求和养猴子其实也没有什么太大的差别,关键点都是在如何保证自己利益的情况下,取悦客户!这个养猴人实在是一个天才设计师,完全把握住了如何取悦客户这个关键点,至于用户,在取悦了客户的大前提下,用户的利益是可以侵占的(我们做系统的同时就是在损害用户的利益。流程重组,企业再造是用来干什么的,就是用来裁员的)。请记住做需求的关键点是取悦客户,而不是讨好用户。

设计的目的是有以下的两点:

  • 保证公司的利益
  • 取悦客户

作为一个软件设计人员,如果能够同时站在公司的立场和客户的立场,做一个客户和公司都满意的解决方案就是一个非常合格的设计人员了,如果还能够高瞻远瞩的规划产品的远景目标,那么这个设计师绝对可以堪称是个高手。

对于软件设计而言,最难做到的是出于业务而超脱于业务,试想在一个团队中不是所有的组员都能够准确的把握和领会客户需求的,对于大多数开发人员而言能够出色的完成技术问题就是一个非常出色的开发人员了,他们不懂业务是非常正常的一件事情,既懂业务又懂技术的人一般就不会做开发人员了。为了解决开发人员不懂业务的现实,我们有必要为开发人员隔离实际的业务问题,架构设计就是这么一个比较高层次的、掺杂技术问题和业务问题的技术活动,它的主要目的是为大多数开发人员隔离业务,将业务需求翻译成为具体的技术要求。在这个层次的设计谈论什么面向对象的设计是一个过于技术性的话题,至少我是这么认为的,面向对象设计更加聚焦到软件工艺这一个层次,是一个非常技术性的话题,我们以后的讨论都会集中在这个层次的讨论,所以在以后的讨论中我们不再考虑具体的业务需求之类的问题。

刚刚提交就有一位朋友不满了,软件工艺中的需求和业务需求考虑的着眼点不同,但是评判的标准都是一致的就是:平衡。但是解决的问题是不一样的,一个解决的是业务问题,一个解决的是技术问题。在极端情况下,需求文档一个字都不改,整个项目重做的情况也不是不可能发生。

今天就到这里了,明天继续。本来是准备讨论类、接口的设计的,结果一激动就跑题了,后面改正,姑且当作小品文,随便看一看罢了,明天补上,望海涵。

颠覆传统-面向对象的设计思想(牛刀小试)

今天的随笔中,我将会通过一个实例来说明如何具体的进行面向对象设计。

  • 案例

现在需要为一个正式运营的系统开发一套日志处理工具,这个工具的目的是收集分散在各个地方的系统执行日志信息,并且按照一定的规则将这些日志信息合并到同一个日志中。以下是这个系统的网络拓扑图:

目前最迫切需要处理的日志信息分别为:位于负载均衡服务器上的日志信息(命名为LogA,格式为Squid格式的文本文件)、位于两台Web服务器上的日志信息(分别命名为IIS_Log1和IIS_Log2,格式都为IIS日志格式的文本文件)。需要合并的日志信息包含上述的日志信息,但是不仅限於上述的日志信息。日志合并的结果保存为Squid格式的文本文件,以方便使用第三方的日志分析工具进行分析。

  • 相关背景
    • 负载均衡服务器日志内容(部分):请求的时间(格林威治时间)、请求的Url、请求源IP(客户IP)、重定向的地址(重定向到哪台Web服务器)
    • Web服务器日志内容(部分):请求的时间(本地时间)、请求的Url,请求源IP(负载均衡服务器IP)
    • 监控服务器每五分钟请求一次负载均衡服务器(请求的页面是固定的),用于判定服务器运行是否正常(可以使用这个请求作为对齐标志来匹配日志信息)
    • 负载均衡服务器的日志顺序与IIS日志的顺序不一定匹配,例如在负载均衡服务器上的日志顺序为RequestA、RequestB,在IIS日志中的顺序有可能为RequestA、RequestB也有可能为RequesB、RequestA。
  • 补充说明
    1. 假定日志的匹配算法已经解决。
    2. 日志的处理结果需要保存到三个日志文件中:匹配的、未匹配以及被拦截的(负载均衡服务器拦截的),这三个日志文件的的格式不一定相同。
  • 案例行为(Act)整理
    • 日志处理活动图(日志处理的活动顺序不一定相同)
    • 行为(Act)分析

根据上述的日志处理活动图,我们可以很简单甚至是很随意的的确定日志处理过程中的几个潜在的关键活动:拷贝日志文件、匹配日志条目,输出日志条目。经过初步的筛选,我们就可以发现“考虑日志文件”这个活动与日志处理这个大的目标之间的关系不是很密切,可以简单的认为是日志处理的前置条件,可以暂时不考虑,那么现在需要重点考虑的活动是“匹配日志条目”和“输出日志条目”。好,根据上面的分析,我们绘制出如下的处理流程图:

我们现在根据处理示意图来仔细考虑一下,这个示意图是否能够很好的描述日志处理活动?是不是感觉缺失了一环?没错,就是缺少了日志行读取的行为!好,现在我们补充上日志读取的这一环,新的处理流程图如下:

好的,至少这个处理流程在表面上可以跑的通了。好的,我们现在再来考察流程图中每一个处理(Process)在问题域中的原子性或者说层次。其中读取日志条目这个处理过程,没有什么问题,可以简单的描述为一个方法,例如ReadLogRow(我认为判断一个处理在问题域中是否是原子性的,只需要判断这个处理是否可以描述为一次方法调用);对于输出日志条目这个处理过程,也没有问题,也可以简单的描述为一个方法调用,例如WriteLogRow;但是对于匹配日志条目这个处理过程,似乎没有办法描述为一个方法调用。

简单的分析一下,发现要实现匹配日志条目这个处理过程至少需要包含以下两个动作序列:合并两个IIS日志,将IIS日志与负载均衡服务器日志合并,并且合并日志条目这个处理过程包含了读取日志条目的处理过程。也就是说从逻辑层次角度看来,匹配日志条目的层次比读取日志条目的层次要高,以下的图示很清楚的说明了这个问题(两个图分别说明了两种不同的处理方式):

说明:上面的分析都基于一个用例包含的活动的粒度都应该处于同一个逻辑层次 ,与常规的面向对象的分析不同的是我采用的是自顶向下的分析方式,而不是常规的面向对象分析采用的自底向下的分析方式。至于自顶向下与自底向上的分析方式哪个好,是属于一个仁者见仁,智者见智的问题,没有什么好争论的。之所以我会采用这样一个分析方法的原因是为了适应用例的分析方法,通过分析用例的每一个交互(或者说活动)来分析出高层次的行为,然后对行为进行抽象(我的经验是行为抽象为接口,名词抽象为实体),这样做有以下几个好处:

  • 能够很自然的贴合用例,便于跟踪需求与设计之间的关系
  • 自顶向下的分析方式符合大多数人的习惯
  • 抽象的层次清楚,易于理解和使用
  • 抽象了行为很自然的符合面向对象的依赖导致原则
  • 基于实际需求场景的行为分析,容易保证抽象符合单一职责原则

我们从上面的图示中很容易的抽象出最高层次(就当前的活动)行为是“输入日志”和“输出日志”,抽象的依据可以看下面这幅示意图。

提取抽象的过程其实就是建模的过程,我们建立一个模型之后,也需要跟写代码一样需要进行测试,对于模型的测试可以通过各种场景(在面向对象中就是用例)来进行测试,看模型是否能够通过所有的应用场景。下面为场景的测试例子(在本例中使用时序图做为测试方法):

在测试通过之后就可以很容易的做设计了,以下为类设计的代码(如果熟悉设计模式的话,可以看出来这个地方引入了装饰模式):

 1 /// <summary>
 2 /// 描述日志列的信息
 3 /// </summary>
 4 public class LogField
 5 {
 6 }
 7 
 8 /// <summary>
 9 /// 描述了日志行的信息
10 /// </summary>
11 public class LogRow
12 {
13 }
14 
15 /// <summary>
16 /// 日志源
17 /// </summary>
18 public interface ILogSource
19 {
20     /// <summary>
21     /// 源日志列定义信息
22     /// </summary>
23     System.Collections.Generic.ICollection<LogField> Fields;
24 
25     /// <summary>
26     /// 当前的日志行
27     /// </summary>
28     LogRow CurrentRow;
29 
30     /// <summary>
31     /// 读取日志行
32     /// </summary>
33     /// <returns>读取结束的时候返回False</returns>
34     bool ReadLogRow();
35 }
36 
37 /// <summary>
38 /// 日志输出
39 /// </summary>
40 public interface ILogOutput
41 {
42     /// <summary>
43     /// 目标日志的定义信息
44     /// </summary>
45     System.Collections.Generic.ICollection<LogField> Fields;
46 
47     /// <summary>
48     /// 写入日志信息
49     /// </summary>
50     /// <param name="row"></param>
51     void WriteLogRow(LogRow row);
52 }

根据日志合并的要求需要引入以下的几个类:

 1 /// <summary>
 2 /// 日志合并日志源,用于处理两个日志之间的Union
 3 /// </summary>  4 public class LogUnionSource : ILogSource
 5 {
 6 }
 7 
 8 /// <summary>
 9 /// 日志Merge日志源,用于处理两个日志源之间的Merge
10 /// </summary>
11 public class LogMergeSource : ILogSource
12 {
13 }

对于日志字段数据的处理可以将逻辑放入到不同的字段类中。

由于这一阵子事情比较多,暂时就写到这里了。

相关随笔:

  1. 颠覆传统-面向对象的设计思想(序章)
  2. 颠覆传统-面向对象的设计思想(序章续)

另外补充一点:

使用地方的处理代码,从以下的代码可以看出这样设计和容易利用IOC框架或者配置驱动:

 1 //创建IIS合并日志源
 2 dim iisLogSource As LogUnionSource = new LogUnionSource ( new SimpleTextLogSource( IIS1 ),new SimpleTextLogSource( IIS2 ) )
 3 
 4 //创建合并日志源
 5 dim arrayLogSource as LogMergeSource = new LogMergeSource( iisLogSource, new SimpleTextLogSource( 防火墙日志 ))
 6 
 7 //创建日志输出
 8 dim logOutput as SimpleTextLogOutput = new SimpleTextLogOutput( FileName& nbsp;);
 9 
10 //写入日志
11 while arrayLogSource.ReadLogRow
12    
13    //写入日志条目
14    logOutput.WriteLogRow( arrayLogSource.CurrentRow )
15 end while
 

组织简介 | 联系我们 |   Copyright 2002 ®  UML软件工程组织 京ICP备10020922号

京公海网安备110108001071号