小例子背后的大道理——用户需求+设计原则+正确应用 =设计方案
上回问题回顾
上回的最后,来了两个用户,分别提出了两个不同的需求。一个要求用两个开关控制一个灯,一个要求用一个开关控制所有的灯。本回将就这两个需求进行分析。我写这段话的时候并没有想出这个需求的具体方案,重要的过程,思路有时候比结果更重要。所以,我的方案可能会"跑偏";但是如果你能从过程中体会到些什么,那这篇就没有白写。
两个开关控制一个灯。这个问题好像很简单,把两个Switcher的Switchee都设置为同一个灯不就结了吗?画个对象图会是这个样子。
图1 由双开关控制的灯
有问题吗?
用户的真实需求
考虑一下这个问题。如果你用Switcher1开了灯,再去开一下Switcher2,灯应该是保持开着还是关了呢?从技术人员的角度来讲,调用的Switcher的开,当然应该保持开啦。但是策划会说,这两个开关应该是相互作用的,还拿出了电路图给我看。这是的确是张真实情况下的双开关电路图。
图2 双路开关电路
Switcher1的开关,拨到左边是开还是关,取决于Switcher2现在是拨在左边儿还是右边。电路图的天然连通性就自然而然地做到了这一点。现实中的Switcher1不会去问Switcher2:嘿,哥们,你现在是个啥状态?而我们的代码中的两个Switcher间也不应该有什么交集。
总而言之,在这个需求的要求下,用户要做的,就是拨一下开关而已(图3中JustSwitch方法的作用)。
对当前设计的改进
在以上需求的约束下,就第一篇开始所写的Switcher而言,就会存在着一个问题。先不说双开关,单单一个开关我们的设计就是不符合产品策划的要求。因为之前写的Switcher类是有两个函数作开关控制的。
public class Switcher
{
public ISwitchable Switchee { get; set; }
public void TurnOn() { Switchee.TurnOn(); }
public void TurnOff() { Switchee.TurnOff(); }
}
代码1
这是有问题的。因为Switcher是直接给用户用的。你觉得用户是想用哪种开关呢?
是 还是 呢?
总不能让用户根据现在灯是开着还是关着让用户按不同的按钮。(使用不同的函数。)所以Switcher的代码应该是这个样子的。
public class Switcher
{
private bool isOn;
public ISwitchable Switchee { get; set; }
public void JustSwitch()
{
// 根据当前状态选择正确的操作。
if (isOn)
{
Switchee.TurnOff();
isOn = false;
}
else
{
Switchee.TurnOn();
isOn = true;
}
}
}
代码2
Switcher自己保存最后一次操作的结果(当前状态),并自动选择正确的操作。
支持双开关
当每个灯只有一个开关的时候,这个代码没有任何问题。但是出现两个开关的话就没这么好办了,自己保存的状态是无效的,可能会被另一个开关改掉。如果要达到和电路图一样的效果,Switcher1要么问Switcher2现在是什么状态,要么问Light是什么状态。
直觉上,问Switcher2这事儿不是个好选择,因为以后还可能会有Switcher3、4。但灯就一个。但是等等,我们现在的接口是什么样的?
图3. 现在的设计
ISwitchable接口只定义了TurnOn和TurnOff两个函数,没有可以用于查询灯的当前状态的方法。这太糟糕了,这意味着接口要改了。改接口永远是最糟糕的事情。《软件框架设计的艺术》里说"API就如同恒星,一旦出现,便与我们永恒存在。",听上去接口写了就不能改,但是我们的情况要好很多,这个接口是公司自己定义的,没有别人用过。所以改改无妨。J只要小小的加一个方法就可以了。
图4. 添加查询接口以支持双开关
Switcher的代码会是这样的。Switcher暴露给用户的应该只有 一个接口。
public interface ISwitchable
{
void TurnOn();
void TurnOff();
bool IsOn();
}
public class Switcher
{
public ISwitchable Switchee { get; set; }
public void JustSwitch()
{
// 根据当前状态选择正确的操作。
if (Switchee.IsOn())
Switchee.TurnOff();
else
Switchee.TurnOn();
}
}
代码3
另一个极品方案
软件开发与建筑施工的最大区别是,软件开发可以选择先盖地下室还是天花板。
——我
当我们把要做的事情抽象一下,就能很容易地从更高的层次思考问题。比如上面,开关要知道灯的状态。可以抽象为:
图5. 开关开灯例子的高度抽象
各位可以想到什么设计上的问题?比较明显的问题有两个。
- 拉模式 VS推模式。既然图中为拉模式,那么另一个思路就是推模式。也许你听说一个说法,就是推模式比拉模式要好。但是如果真把推模式用在开关开灯的例子上,就成了灯的亮与熄,要去通知开关,以便开关下次Switch的时候,能做出正确地动作。想到这里,我邪恶地笑了。这得多蛋痛啊?模式的应用,永远要看上下文。为了抚慰一些推模式死忠们脆弱的心灵,下面会介绍一个可行的推模式开灯设计。
- A依赖B。虽然我们有ISwitchable接口,开关不直接依赖灯,但是你看,我们为什么要在ISwitchable接口里加入IsOn函数呢?因为开关需要知道灯的状态。所以说,他们之间不但存在着依赖,而且还直接决定了接口的定义。但是与我第一篇文章中介绍的DIP原则是否冲突呢?这取决于你对开关的定位。如果只是单纯的开关,那么IsOn函数的引入,就是对灯的功能的抽象,也就违反了DIP原则;如果你希望开关有点儿AI,那么显然它得知道更多的信息(但是这违反了单一责任原则)。所以看上去,无论从哪个角度来讲,IsOn的引入都是要违反XXXX原则的。
好,为了不违反所有现有的原则。构想出设计出如下的设计:
图6. 引入AI系统对开关操作进行决策(拉模式)
理智一些吧,我们的开关公司没有上市,既没有资本做AI系统,也没有卡马克这种不要钱只要汉堡和网络的技术狂人,我们的用户也不会像暗黑的死忠一样傻等10年,然后等到一个需要接网线才能使用的电灯开关还能用得很愉悦。在这个发展阶段要做的,只是尽快满足当前的需求。
在达成需求前,技术方案的完美度,永远是第二位的。第一位的,是有效率地执行 + 新颖的思路和方向。思路放后面,是因为对99.9%的情况来说,最不值钱的就是点子,你能想到的,别人可能都已经做出来了。即使是Jobs这种用新意折服世界的人,也要靠"现实扭曲力场"的帮助把自己的观点有效地推行下去。
所以,这个方案虽然很不错,但是我不愿意继续讨论了,因为这在现实中没有意义,脱离现实的例子也就不再是好例子了。
我还想说一句,做项目和做人,都不能走极端。另一个极端是:以敏捷之名,无视一切编码前的设计。我猜这在群人眼中,这个系列的文章没有任何意义。
———————————————————牢骚的休止符—————————————————————
再一个方案
有人可能会说,让灯自己控制自己的状态,也可以解决问题。像下面这样。
图7 另一种解决方案
然后把Light类实现成这个样子:(多酷啊,目前为止代码量最少的方案)
public class Light : ISwitchable
{
private bool isGlowing;
public void JustSwitch()
{
isGlowing = !isGlowing;
}
}
代码 4
这个设计的确可行,但是哪个方案更好呢?这个问题就留给各位读者吧。就拿几个Principle逐个分析下应该就可以分析出个所以然来。(下一节有简单提示)
设计思想(原则)及技术方案的滥用
每种设计都有他的思路和道理。你觉得不可理喻的设计可能恰恰是别人深思熟虑的结果,只是每个人的思路不一样,结果自然也不相同。但是如果设计思路被某种设计思想占据了绝对主导的地位,就可能会出现设计上的偏差。
我把滥用大体上总结成如下三种:
- 单纯的滥用。因为我会这么做,所以就顺便这么做了。他们的理论基础是:虽然现在没有这要的需求,谁知道以后有没有呢?我多做一点儿还不好?最典型的症状是,所有的类,都有相应的接口,全部使用Dependency Injection来实例化。这不是有病么?
这会引出一个比较大的话题,就是怎样的设计算是过度设计?这需要单独写一节来讨论这个问题。就不在这里展开了。
- 程度上的滥用。图7的设计,就体现了一个叫做"Tell, Don't Ask"的原则,或者说是为了将这个原则"发挥到极致"而形成的设计。而在这个原则之下写出的代码4又是如此简洁和优美;以至于让想出这个方案的人,很难主动抛弃这个方案。直到看到这个方案不能满足的需求才肯承认问题。
- 适用范围滥用。把某个原则或是技术方案当万金油。只要能用得上,就一定要用上一用。导致一叶蔽目,不愿意寻求其它更加合适的技术方案(项目时间紧是个最常见的借口,一知道某个方案可行,就马上付诸行动)。一时兴起,画了个漫画。
图8. 因为熟悉或懒惰而舍近求远
第一篇就说过,优秀的设计不是藉由几个原则、模式就可以保证的。何况是某一个原则呢?物极必反。今天就不再啰嗦了。大家也都懂。设计过程中对某个特定的设计思想或是技术过于执着,往往会形成一个虽可行、却畸形的设计。图7即是一个例子。
一个真实的案例
有一家著名的咨询公司,2009年接了一个银行的大单,为期一年,预计可以赚到五个亿。但是这个项目现在都还没有结束,项目延期不仅要给银行赔偿,还要继续免费给银行把这个项目做完。(想想国内公司会怎么做?)2010-2011年度,公司接的其它项目赚的钱几乎全部贴给了这个项目。当年全公司员工没有奖金,部分相关高层降职降薪。
为什么?一个可以赚到五个亿的项目却亏了几个亿?
目前项目内员工的工作效率极低,一个普通开发者要两天才能完成一个报表的修改。(现阶段是修改,不是全新开发)。所以说人员成本非常大。项目拖上一个月,数百万就打了水漂。
那么效率为什么这么低?他们所有的业务逻辑都用PL-SQL实现,大的报表,涉及到的PL-SQL动辄上万行,而且层层调用,加之整个系统有数千个表。代码的测试,都要先去数据库造假数据。效率能高就怪了。
为什么要这么搞?因为一开始做系统设计的人,对PL-SQL比较熟悉,对Java不熟悉,所以就把Java当成了UI Wrapper来用。狗屎吧。当然还会有很多其它的因素,但是在技术层面,这绝对是重要因素之一。因为自己对某个技术比较熟悉,而不愿意在项目中了解和应用更适合的其它技术方案的人套上CTO之类的外衣,恒等于搅屎棍。
支持开关控制多个灯
简单而言,要让一个开关去控制所有的灯。听上去很简单,而且有很多种实现方式。但是如果仔细想想,会发现有很多问题。
需求分析,不同于简单的需求整理
用户给出的需求总会是很概括甚至模糊的,不是用户懒得说,而是用户觉得自己已经说清楚了。以开灯为例,用户需求就是:"要有一个统一开关。",你如果再去追问用户,要怎么个统一法?用户可能就会不高兴了,因为他觉得这是你应该解决的问题。但是如果简单地把"要有统一开关"这个用户需求直接写进需求文档的话,就等着项目失败或是延期吧。
需求分析的第一步,就是要对用户的需求进行分解、细节,找出合理用例。比如:用户要求的是,要有统一开关,但是并没有说每个灯就没有自己的开关。如果每个灯又都有自己的开关,统一开关应该如何与各个专属开关协作呢?从这个角度,就可以找到一些用例。
- 两个灯,都开着。这时去按统一开关,应该是全关对吧。也就是说,统一开关应该知道当前灯的状态。并按当前灯的状态去执行操作。
- 三个灯,两个开着。这时去按统一开关呢?统一开关的意思就是要有统一的行为,用户肯定不会希望这个统一开关的行为是:把开着的灯关掉,把关着的灯打开。怎么办?统计现在开着的比率?开着的多就全关?那如果是两个灯,一个开着,一个关着呢?还有一个办法是,让统一开关使用代码2的方案:自己记住上次的操作结果。上次是全关,这次就全开;反之亦然。
也就是说,用例1和用例2都是合理的,但却是冲突的。这种冲突甚至是一种逻辑上的冲突,已经不是技术局限性的问题了。这种情况,在软件开发中也是很常见的。这时,我们拿着自己的分析、自己的想法去询问用户的意见,用户就会很乐意了。人们都喜欢做选择题,而不是做问答题。不是么?
现实中的电路
现实中的电路图有两种做法。(非标准电路,请意会。强弱电相关专业也许可以参考这里)
图9. 现实中的两种简单的总控加分支开关电路
前一张图,分支开关有绝对的控制权;后一张图中开关中有一个二极管,开关的开合用于控制二极管的极性,总开关的作用就是:把开着的关掉,把关着的打开。
看上去就是两种开关嘛。
基于派生类的方案
为了让一个开关可以控制多个灯,对现有程序设计上的改动很小。类图如下:
图10. 多控开关设计
通过派生新的Switcher,来提供不同的功能。在当前的需求下,这个方案是可行的。未来的需求,就留给未来去解决吧。
小结
本节本来是想讲讲需求决定设计这个理念,从两个需求引出两个互不兼容的设计方案。所以找了两个需求一起讲,但是最后这两个需求并没多大的冲突,也就没有达到预期的目标。不过想说的倒是说出来了。就是项目的设计,最终还是要依赖于需求,任何要做出能够适应未来需求的设计的想法,都是不切实际、劳民伤财的。(不知道有多少人会把这句话曲解为"设计无用论",如果我认为设计无用,还会写这个系列吗?)
这个系列并不是要讲设计模式,也不是用小例子做各种分析。只是想通过最简单的例子讲一些大道理(原则)。如果没有合适的大道理可说,分析出一百个需求、做出一百个设计,也只是鱼,而非我想说的渔。
距上节的发布也已经很久了,因为每回发表时,都会先想好下回写什么、怎么写,并结尾留个引子。这次很可惜,想好了下回写什么,却死活想不出合适的、简单的需求来引出这个问题,也就不知道怎么写了。所以下回什么能写好我也不知道。大体上,也许还会有下而一些道理希望能和大家分享:
- 过度设计及关于"过度"的度量。
- 局部设计最优化与全局次优化之间的权衡。
- 什么是设计经验及如何正确地借鉴。
新的用户
下一回讲哪个还没有讲好,但是用户的需求却如潮水般涌来。先权且记下:
- 在开关上加一个小灯,表示现在的灯是开还是关。(因为开关和灯可能并不在一起)
- 通过开关调节灯的亮度。
- 在开关上显示灯的耗电量、温度、预期寿命等。
- 定时开关。
- 开关声控、手势控。
- 开关权限控制。