最近在园子里看到一篇文章,其中作者提出了一个普遍而又有趣的问题:
“打个比方我们要设计一个网络书店,经过分析我们决定建立一个表,Book来存储书的信息,里面有ISBN啊,书名等信息。结果等我们做到一半,客户突然说,好像不同的书要存储的属性不大一样,幼儿图书需要标明适合阅读的年龄层次,科技类图书需要标明相关的技术类型。这个时候你是不是要抓狂?这个时候就是对系统模型要进行就改了。错误在哪里呢?也许就是在需求调研的时候不够仔细,少问了几个为什么。在对系统模型做更改的时候,包括增减数据库的字段都算是类似的情况,这个时候任何架构都不能保证不对每个层修改代码。这是必然的。”
嗯,这是这篇文章中的一段。我们不去追究这个需求是否合理或者客户是否令人生厌或者修改合同。如果我们哪天遇到了这个难缠的客户,我们该怎么办。或者这干脆就是一个需求变更,我们如何处理呢?
其实深入分析下来,就会发现这是一个很普遍而且很有意思的问题。如果我们用面向对象来设计这个需求变更,那么这根本就谈不上什么大事,只需要在原有的“书”类上派生出“幼儿图书类”和“科技图书类”就行了。(插一句话,本文假定所有读者都具备完整的OOD能力)。
伪代码如下:
2
3 {
4
5 public string ISBN { get; private set; }
6
7 public string 书名 { get; private set; }
8
9 //
10
11 }
12
13
14
15 public class 幼儿图书 : 书
16
17 {
18
19 public 年龄层次枚举 年龄层次 { get; private set; }
20
21 }
22
23
24
25 public class 科技图书 : 书
26
27 {
28
29 public 技术类型枚举 技术类型 { get; private set; }
30
31 }
那么很显然的,一个具备良好OOD思想的设计师都能够想到,在具体实现上,其实只是Model和UI的一个扩展而已,不必修改现有任何代码。
但是数据库怎么办?我们现在所用的数据库都是关系型数据库,里面的数据可不会提供什么继承和多态给我们来OOD。
熟悉范式的朋友一定会说了,这个很简单么。
数据表“书”
列名 | 类型 | 备注 |
ID | int | 主键 |
ISBN | string | |
书名 | string |
数据表“幼儿图书”
列名 | 类型 | 备注 |
ID | int | 主键 |
书 | string | 外键 => “书” |
年龄层次 | enum/string |
数据表“科技图书”
列名 | 类型 | 备注 |
ID | int | 主键 |
书 | string | 外键 => “书” |
技术类型 | enum/string |
没有任何冗余数据,完全遵循范式的设计。
下面我们假设这个数据量都很大(因为数据量不大的情况下,任何数据设计都是恰当的),而且我们在字段上都做好了恰当的索引。我们来看看用户会有些什么需求吧。
1、用户要求,可以列出所有的书名和ISBN。
这个很好办,只需要检索“书”表就可以了
2、用户要求,可以按照“年龄层次”检索幼儿图书,也可以按照“技术类型”检索科技图书。并显示书名和ISBN。
这个也好办,只需要检索“幼儿图书”或者“技术类型”然后INNER JOIN“书”就可以了。
3、用户要求,显示图书详细信息时,如果是“幼儿图书”,那么需要显示年龄层次。
嗯,如果你和我一样在十秒钟内就想到了这是一个OUTER JOIN,那么恭喜你。嗯,索引这个书的数据时“书” LEFT OUTER JOIN “幼儿图书”,就行了。
不过,如果这个图书的分类不是幼儿图书科技图书这么简单,比如说,有二十种分类,那么你就需要LEFT OUTER JOIN二十个表了。问题开始凸显了。(不要说不可能,如果这个书店是“京东商城”,那么分类的方式还不止二十种)。
说到这里,我想有些朋友会不满了,我们为什么要为每一个分类建立一个表呢?我们建立一个通用的表不就行了:
数据表“图书附加类型”
列名 | 类型 | 备注 |
ID | int | 主键 |
书 | string | 外键 => “书” |
附加类型 | string |
因为显然,“幼儿图书”表和“科技图书”表是同构的。
从现有的需求出发,我们的确可以认为这是两个同构的表,但是如果这个客户非常难伺候,当你就要用同构表解决问题的时候,客户突然说,“科技图书”除了有“技术类型”之外,还有“中文本”、“译文本”和“英文原文本”这个类型……
马上,这两个表就不同构了。
当然也不排除有执迷不悟的选择:
列名 | 类型 | 备注 |
ID | int | 主键 |
书 | string | 外键 => “书” |
附加类型1 | string | |
附加类型2 | string |
这种糟糕的设计我想就不要纳入本文的讨论范围了。(题外话:技术永远不是全部,京东商城就用这种糟糕的设计卖了个盆满钵满。)
回到问题,如果有很多种分类,我们可以用程序来处理这件事情,比如说我们先获取一个“书”的基本信息(ID、ISBN和书名……),然后通过ID检索各个附加类型表(“幼儿图书”、“技术类型”),补充完“书”的信息。我们也完全可以在“书”表上增加一个字段“图书种类”用来告诉我们应该去检索哪些附加类型表。
在图书的详细信息页面上这么做,那么设计是恰当的。因为每次显示的不过区区一条数据。用OO来编写页面也可以简单的适应各种数据。但是……
4、客户要求,图书的列表页面上也要可以显示各种图书的这些附加类型数据……
在表现层上这很好做,如果我们可以给表现层一个IList<书>然后表现层透过一个类似于“Control UIFactory.GetView( 书 obj )”方法获取一个个的呈现塞到DataList或者Repeater里面就OK了。
但是,数据层如何做呢?
我们可以先取出一个图书基本数据列表然后再对每一条数据去获取附加的类型数据,再生成出具体的类型(“技术图书”、“幼儿图书”),再整到一个List里面。
呃,在恰当的优化之下(比如说分页),每个列表的数据不会太多,再加上缓存什么的,这样做也没有什么问题。
但是如此未免太过于中规中矩,前面做了这么多铺垫,现在我们终于开始切入正题。
抛开这些教条吧,如果我们只是需要一个具体的类型(“技术图书”、“幼儿图书”)实例,何必这么麻烦:
数据表“书”
列名 | 类型 | 备注 |
ID | int | 主键 |
ISBN | string | |
书名 | string | |
书籍类型 | enum/string | |
具体实例 | binary | 冗余字段,存放具体类型实例的序列化结果 |
哈哈,我想这样的冗余数据可不是所有人都能接受的,如果您看到这里觉得这是歪门邪说,就当这是一个糟糕透顶博人一笑的设计好了。但是如果你觉得这样的设计也有其道理的话,那么问题就在于到底是什么束缚了我们不这么设计。今天胡言乱语到此结束,但是这个话题显然还没结束。有空再聊……