[漫谈] 软件设计的目标和途径

记录一下笔者关于软件设计的一些相关认知。在开始之前,先引入两个概念目标途径(这里可能会有些咬文嚼字,不过主要是为了区分主观和客观的一些细微差异)。

1 目标和途径

我们在做某一件事情的时候,总是会带有一定的目的性的:比如说一日三餐,是为了给身体补充所需的能量。那么这三餐具体如何落实呢,则会有多种多样的方式。比如你可以选择吃碳水食物、蔬菜、肉类、牛奶或者蛋类等等;也可以选择通过静脉注射一些所需的葡萄糖或者蛋白质。总之,能够为身体补充能量就可以了。

1.1 目标

那么在上述的小例子中,我们的目的就是给身体补充能量,用以维持正常的生命活动所需。当然也可以说是我们的目标,不过目标侧重于过程,目的则更强调结果。

1.2 途径

从上面的例子中可以看出有多种方式可以达成我们的上述目的。其中每一种方式都是一条达成目的途径,当然我们为了补充均衡的能量,通常会搭配组合几种不同的食物,我把这个称之为手段或者方法。手段方法带有一定的主观性;而途径则是在描述客观的可供选择的一种方式。

2 软件的目的

在开始讨论软件设计之前先问自己一个最基本的问题:我们为什么需要软件?

笔者认为是为了解决现实中某个领域的相关问题而存在的。就好比最初的计算机是用来计算导弹的弹道的。生活中常用的QQ和微信是为了满足人们的社交通信需求的,淘宝京东等是满足了人们的买买买的需求。

所以,软件存在的目的就是它能解决一些领域的相关问题,这是它存在的唯一理由

比如在黑客帝国这部电影中,不再被使用的程序只有一个下场,那就是被删除掉。

3 软件设计的目标

假如一开始就有了软件,其实要不要软件设计都不重要了。但是问题在于软件不是凭空产生的,不是从0到1没有中间过程就直接得到了想要的软件的。在软件从0到1的过程,就是软件设计的作用范围(所以在这里我用软件设计的目标这个概念)。因为软件存在的目的在于它能解决一些领域的相关问题,那么首先对软件的最低要求就是它能用,能用来解决问题。比如一个数学上的加减乘除计算器,最低最低的要求是你要能把结果算对吧。所以软件设计的目标是什么?笔者认为就是控制这个从0到1的过程,避免其失控(一旦失控你可能就连最低最低的软件的要求都达不到了)。

《领域驱动设计:软件核心复杂性应对之道》一书的副标题也是这个含义。它的侧重点在于如何利用面向对象的方式应对软件本身的复杂性,从而避免其失控。

那么笔者对软件设计的目标的认知就是:避免软件的失控。为什么是目标而不是目的呢?是因为软件设计在软件的整个生命周期中都是存在着的,这是一个持续的过程,直到软件不再被使用的那一天;而非只在刚开始设计一下,后续就一成不变了

4 失控的根本原因

上面推导出软件设计的目标是避免软件的失控。那么是什么东西导致的失控? 你面临的业务太复杂?项目遗留的代码太烂?团队成员水平参差不齐?工期太紧张导致你无暇做设计规划?也许吧,这些或多或少都确实是已经存在的事实。

  1. 业务太复杂难道是失控的原因吗?回想一下软件的目的是什么?解决一些领域的相关问题,那么我们可以让业务的复杂性会消失或者降低吗?答案是肯定的,不会!这里就有人要说你放屁。。。你敢说我们无法降低业务复杂性,打你噢。你就是打死我复杂性也不会降低的,,,复杂性是业务本身存在的客观属性,是不会以人的意志来改变的,除非你不做它了。就像你现在要在淘宝买一个手机,你人在北京,卖方在广州,无论你用什么快递方式,从广州到北京这段物理距离上的时间消耗是无法消除的。你说你比较着急,那好,卖方给你选择空运,很快你就收到货了。你说空运这不是降低了快递时间,和降低复杂性不是一样的吗? 其实并不是,因为复杂性指的是无论你用什么快递方式,从广州到北京这段物理距离上的时间消耗是无法消除的,指的是这个过程你无法消除。但是总觉得怪怪的对吗?是的,看起来是怪怪的,明明我收到货的时间缩短了,怎么复杂性没有改变呢?所以这里就引申出另外一个概念:业务交互方式所带来的影响。这个影响非常之大,但是往往被我们所忽略,比如你选择购买发货地是北京的卖方了,是不是时间又进一步大大缩短了?实际业务上也是这样的,业务本身具备的复杂性,以及我们在把业务转化为软件后的交互方式所带来的影响,业务本身的复杂性我们无法降低和消除,但是后者交互方式则是可以控制的,这也是软件设计的一部分,所以其实上面我们选择空运是改变了这部分。就好比你是一个B/S的应用软件,你的用户在浏览器中看到了Web页面。这背后你的Web页面从服务器到用户浏览器的过程和浏览器渲染页面的过程是无论如何也无法消除的,但是浏览器可以缓存它,当你下次再打开这个页面时,它就可以省掉上述的交互过程。
  2. 项目遗留的代码太烂是失控的原因吗?其实也不是,这是失控的一种表现结果。
  3. 团队成员水平参差不齐是失控的原因吗?也不是,这虽然是客观存在的事实,但是你这样把责任推到队友身上不合适吧,说不定队友也是这么看你的呢。
  4. 工期太紧张导致你无暇做设计规划是失控的原因吗? 当然也不是,这个是借口。。。就像你今天起床快要迟到了,你会选择光屁股不穿衣服就出门吗?

除了上述的一些事实,当然还有其他的一些因素,看起来都不像是导致失控的罪魁祸首。那么究竟是什么导致的失控???仔细回想一下,当我们觉得项目失控的时候通常是什么场景?

  1. 有个已知的bug,你改动的时候发现牵扯的东西太多了,牵一发而动全身,你不敢下手。你觉得代码无法控制了。。。
  2. 有个未知的bug,你找了好久找不到,代码太乱了。你觉得一股无力感。。。
  3. 有个新功能来了,你发现你要改这里那里,但是完全不知道改了会不会破坏现有的功能,也不知道新功能是不是真的可以work。你觉得你无法掌控这些代码了。。。
  4. 还有一些其他的情况,总之就是你觉得你无法掌控代码的真实行为了,你不知道你的代码会产生什么样的结果,就像薛定谔的代码一样。。。

那么还有一个场景,当你要开展一个新的项目,所有的一切都是新的,没有任何历史债务负担,这时候你是什么感觉?信心满满啊肯定是,这时候你不会觉得你会对接下来的代码失去控制,因为你现在一行代码都还没有。。。

所以是什么导致的失控?现存的无力维护(bug、新功能都是维护)的代码导致的失控,同时这也是失控的表现结果。那么你为什么会无力维护这些代码,因为它的真实行为和你理解的行为出现了偏差,你觉得它不可控了。这时候就是真的失控了,代码烂不烂其实并不是重点,只要你还能维护,这些都不是问题。

代码只会按照你编写的行为去执行,而不是按照你认为的行为去执行。

那么如何避免失控?编写可维护的代码。打死你噢,解释这么半天憋出这么一句废话,谁不知道要编写可维护的代码啊。。。

我只能说别着急,继续慢慢往下看。。。

5 目标-可维护性

既然我们的目标是避免失控,避免失控的途径则是编写可维护的代码。那么我就把可维护性作为软件设计的终极目标,而且没有之一。也称之为元原则,就是说我们目前所接触到的各自编程原则、建议和最佳实践等等都可以通过可维护性推导细化出来,并且不可与之相违背。

打个比喻,就好比宪法是其他一切法律的基础,任何法律如果违背了宪法,那么就是无效的。

那么根据可维护性可推导出来3个核心的原则:可理解性可测试性可隔离性

5.1 可理解性

这条原则看起来很有主观性的倾向,但是其实并不是。

比如说你刚写了一段代码,你觉得容易理解,他看起不容易理解;或者说代码是他写的,他看起来很容易理解,但是到你这里无法一下子理解他的思维,然后你就觉得不好理解。如果出现了这样的情况,那么则统统都是不可理解的。这时候你要说了:你要一棍子打死双方啊。是的,正是如此。再回想一下我们的目标是什么?可维护性! 这里的维护不单单是说你的代码你来维护,而是大家互相交叉着;你新增了一个功能,后续负责其他的事情去了,那么这时候就由你的队友来负责维护了;或者你接手维护别人的代码。

所以我们需要一个客观上的可理解性。那么到底什么才能叫客观?没法度量啊!其实也不复杂,就是看当你读到一段代码的时候,你是否需要额外的思考,额外的脑中维持一个上下文的环境才能明白这段代码的意图,如果需要,那么就是不可理解的,至少也是不易理解的。更简单点说就是这段代码应该让你不用思考就看的明白它的意图。比如下面的一个小例子,功能是完全等价的,但是差异非常微妙。

// 1
if(userList.isNotEmpty()){

}

// 2
if(userList.isEmpty() == false){

}

// 3
if(!userList.isEmpty()){

}

// 4
if(userList.length() != 0){

}

你觉得可理解性怎么排? 答案是肯定的吧?1 > 2 > 3 > 4

  1. 1是不是你根本就不用思考,直接读下来就知道其含义?
  2. 2则是有一个==fasle的过程,需要你进行简单的思考。
  3. 3则是接近于2,但是比2更差一点,因为取反符号在前面,但是其决定性的值则在后面,而你的阅读顺序是从左向右,所以你需要一个比2稍微更复杂一点的思考过程。
  4. 前三个还都一眼能看出来是或者非空的语境,但是4就更差了,4的字面意思是长度不等于0,逻辑上其实和非空是等价的,但是你需要在脑中做这样的一个映射长度!=0等同于非空,这个的抽象层级明显更低了一个层级。

不知道能否体会其中差细微差异。那么你觉得这些理解是客观的还是主观的呢?

5.2 可测试性

可理解性可以确保你可以快速的理解现存代码的意图,但是其真实的行为呢?是不是和你所认为的行为就是一致的?上面我说过:“代码只会按照你编写的行为去执行,而不是按照你认为的行为去执行”。

那么如何确保你真实的行为和你所认为的行为是一致的?那就是测试。把你认为的行为也写成代码,去验证你的业务代码执行的时候是不是会按照你给定的输入得到你期望的输出结果。借助自动化的CI,就可以在你每次改动代码时把现有的所有测试都运行一遍,然后你至少可以获得3点收益:

  1. 代码真的时按照你认为的行为去执行的。
  2. 确保你的改动不会破坏现有的代码行为。
  3. 倒逼你的代码进行合理的分解和抽象,不然你很难编写有效的测试。

当然你可能把测试写错了,,,这种概率就小多了吧。况且假设你真的写错了测试,时间久了,这个错误也就变成了feature。为什么呢?也许你代码的消费方已经按照它实际的行为去处理了,这时候你贸然把这个bug修复了,结果可能时消费方反而不能正常工作了。这时候这个错误的测试其实也就变成了消费方的一种契约测试。确保你不会把它改对,,,

比如C#的类库中有个DateTime,在处理时区问题时很多诡异的行为,这时候微软已经无法修正它了,只好再单独新增了一个DateTimeOffset,两者共存,慢慢的迁移过去。

5.3 可隔离性

那么现在你可以快速的理解现存的代码了,也可以确保你的新代码不会破坏已有的功能,也确认你的代码行为是你所认为的行为了。是不是就可以愉快的合并代码并且上线发布了?是的,差不多可以了。但是,凡是总有例外,我们不能把全部希望都寄托在我们能严格落实上述两点。总是要有个备选方案对吧?

可隔离性就是这样的一个备选方案,其意图就是隔离你的代码行为,哪怕它就是腐烂变质成了不可维护的代码,只要不影响其他的模块,那么就还算是可控的。就像万吨巨轮,底层的隔水舱总是一个个的独立的,一个进水了也不影响其他的,从而避免整体的失控。

6 途径

还记得文章开始介绍的目标途径的概念吧,上述的3个原则是我们的目标,那么想要达成这样的目标有哪些途径可供使用呢?

6.1 命名

曾经有这么一句话,计算机领域有两大难题:命名和缓存失效。一个好名字的重要性不必多说了吧?此外我还有一个心得体会:如果你觉得命名出现了困难,那么请从头审视一下你的设计,或许你走错了方向了。我认为一旦出现了命名困难的问题,那绝对就是你的设计出现了问题。也许时你的方法职责太多了,你无法用简洁的名字描述清楚,也许是你的字段所表达的含义不清,导致你无法准确的用一个简单的词语描述它

目标 效果 解释
可理解性 ++ 增加可读性。
可测试性 无影响。
可隔离性 无影响。

6.2 单一职责

几乎每个人都明白单一职责的重要性,但是却很容易就忽略它。比如下面的小例子:

// 1
public String sum(
    final Collection<BigDecimal> bigDecimalCollection
) {
  final BigDecimal sumResult = bigDecimalCollection
      .stream()
      .reduce(BigDecimal.ZERO, BigDecimal::add);

  final DecimalFormat format = new DecimalFormat("#,##0.00");
  return format.format(sumResult);
}

// 2
public BigDecimal sum(
    final Collection<BigDecimal> bigDecimalCollection
) {  
  return bigDecimalCollection
      .stream()
      .reduce(BigDecimal.ZERO, BigDecimal::add);
}

1的职责是不是有点多?

目标 效果 解释
可理解性 ++ 一个关注点使得代码可理解性大大的提升。
可测试性 ++ 也使得测试更容易实施。
可隔离性 ++ 单一单一,那不就是隔离开了吗?

6.3 数据模型匹配业务

数据模型匹配的含义是说让你的代码真实的表达实际的业务意图,而且这个意图必须要落实到数据层面,而非代码层面。简而言之就是让你的数据体现你的业务,而不是你的代码体现你的业务。感觉有点绕噢,什么鬼意思?我举个小例子:个税计算

// 1
(empployee.salary - 3500) * taxRate;

// 2 employee.exemption = 3500
(empployee.salary - employee.exemption) * taxRate;

你觉得哪种更合适?1就是业务被体现在了代码中,这时候2019年了,个税免征额提高到了5000,你怎么办?改代码呗,3500改成5000不就完事了。对,完事了,那么历史的数据怎么办?有人要对比一下新旧版本的差异,怎么算?没办法,你被逼着写了两个版本,2019年前一个版本的代码,2019年后的一个版本,然后混乱就开始了。

所以根本问题在哪?就是因为3500这个数字看起来虽然不起眼,但是它本身是业务的一部分,结果却被安置到了代码中。这就是典型的数据模型不匹配业务。这种细节有时候一开始很难察觉到,但是一旦发现可能就已经很难挽回了,代码可以随便改,但是已经存在的历史数据怎么办? 上述的例子还好说点,你可以刷一下历史数据给补上去。但是很多时候数据一开始没有记录,后续就无论如何也无法修补了,导致你的代码被死死的捆绑住,无法再添加新功能了。

笔者非常认同Linus torvalds的一句话:“烂程序员关心的是代码。好程序员关心的是数据结构和它们之间的关系。”[1]。Git的数据结构非常之稳定,它的底层实际上是一个内容寻址文件系统,在这样的一个底层数据结构之上,十几年来Git新增了n多个功能和命令,但是却一致保持着的兼容性(你用Git早期版本初始化操作一个repo,到了现在的最新版依然是完全匹配的)。

目标 效果 解释
可理解性 ++ 匹配的模型可以表达真实的业务意图,没有中间转换的环节,可以让你再理解代码时没有额外的心智负担。
可测试性 + 使得测试更能直观的描述真实的业务行为。
可隔离性 + 合理的模型划分可以有效的减少不必要的依赖,从而保持相对独立。

6.4 抽象层级

把大象放进冰箱需要几步?

  1. 把冰箱门打开。
  2. 把大象放进去。
  3. 把冰箱门关上。

就这么简单,这三件事都是在一个抽象的层级上的。那么再细化一些,打开冰箱门需要几步?还有现在没大象,我要去从动物园先弄过来一个,怎么办?这些细节和上述的三个步骤是不是在一个抽象层级上? 肯定不是吧!但是我们通常很多时候都是在干着这样的事情,比如业务代码中夹杂着如何拼接SQL语句的代码。当你读到这样的代码的时候会觉得很乱,为什么感觉乱?就是因为其涵盖了不同抽象层级的代码在一起,导致你在前脚还在想着如何把大象放进去这件事的时候,突然发现接下来的是我怎么才能从动物园弄个大象出来这些琐事。还记得上面的一个判断非空的一小段代码吧?

// 1
if(userList.isNotEmpty()){

}

// 4
if(userList.length() != 0){

}

4干的就样的事情,虽然很细微,但是就是这样一个一个细微的不同抽象层级的代码混在一块,就把你的代码搞乱了,搞的可理解性急剧下降。

目标 效果 解释
可理解性 ++ 阅读代码时避免分心去考虑一些不必要的细节问题。
可测试性 ++ 比如我用一个大象的毛绒玩具也可以完成第2步吧?这就大大的简化了测试的关注点和编写。
可隔离性 ++ 屏蔽了一些底层的细节。

6.5 奥卡姆剃刀

这又是个什么鬼?怎么剃刀都出来了,还嫌发际线不够高吗?其实不是的,这个一个关于简单行的原则,也称之为“如无必要,勿增实体”。就是说如果有两个途径可以完成同样一件事情,那就选择更简单假设更少的那一个。

目标 效果 解释
可理解性 + 选择更简单的有助于理解。
可测试性 无影响。
可隔离性 无影响。

7 一些误区

看到这里估计有人要忍不住要批判我了:

  1. 可复用性呢?GoF23种设计模式都强调构建可复用性的软件,可复用性跑哪去了?被你吃了啊。
  2. 可靠性呢?健壮性呢?
  3. 高可用性呢?

等等吧,就像当年软工课程上罗列的各种指标,或者各种的模式和架构等等。其实不是说这些东西不重要,或者我不认可这些东西,我认可,也理解它们的重要性。但是有一点要彻底搞清楚,哪些是我们的目标?哪些是我们的途径?

7.1 可复用性只是一种现象

可复用性难道是我们追求的目标吗?我的回答是:否,我们的目标是软件的可维护性!那么你说复用就会增加可维护性,其实不尽然,不合适的复用反而会降低可维护性,这是一把双刃剑,借用著哥的一句话:“越通用越无用”。那么你说不是目标也是途径吧!那么我的回答是:也不是途径,你这条途径可能会违宪,你觉得它合适吗?也不是目标,也不是途径,那么它到底是什么?答:只是一种现象,如果你落实了上述的5条途径中的某些途径,你会发现你的代码自然而然就可以复用了。

7.2 设计模式源自缺陷

首先我们看一下设计模式是什么: “是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性、程序的重用性。” 也就是说它是经过验证的一些最佳实践的经验性代码。那么问题来了,什么时候才需要最佳实践?,当你对你所使用的工具出现迷惑的时候,不太清楚怎么处理才好的时候,你需要借鉴一下其他人总结出来的比较好的处理方案才能完成你的工作的时候。这个处理方案,就是设计模式。那么此时你想一想,GoF23的设计模式是在弥补什么的缺陷?OO的啊,人家的副标题是“可复用面向对象软件的基础”。

当然设计模式也不是OO的专有的东西,凡是通用的那些已命名的最佳实践,都可以称之为设计模式。

7.3 OOP不是目的

很多时候在讨论代码的时候,看着代码觉得不舒服,一言不合就互相给对方扣上了一顶帽子,你的代码一点也不OO!这其实大可不必,OO是来解决一些问题的,但是它并不能解决全部问题,那么多static的类或者方法,它OO吗?OO只是解决我们问题的一种途径,也不是唯一的途径,千万不可把工具当目的。

7.4 DDD带来的问题比解决的问题更多

DDD自从诞生之初就面临很多争议。DDD本身出发点非常好(软件核心复杂性应对之道)。DDD是基于OO,在OO之上扩充了很多概念,希望借此最大程度的发挥出OO的优势。但是其扩充的概念太多了,而且千人千面,每个人心中的理解都不尽相同,而且可以说南辕北辙的都有,这就使得它非常难以在团队中达成理解上的共识。也就导致实施落地上的种种困难,即使一开始落地了一部分,随着时间的推移,则会变得越来越难以为继,好像侧重点都跑到了我这么写到底符合DDD的思想吗?而对业务的关注的变成了二等公民,这简直是个灾难,这时候代码的可理解性就非常脆弱了。所以根据奥卡姆剃刀原则,剃掉它是最优的选择。

8 总结

以上是笔者关于软件设计的一些思考过程:笔者认为其目标是避免软件的失控以及相关的途径措施,以及对一些常见到的一些概念的看法。如有不妥之处,欢迎来讨论。

9 引用

本文首发于://linianhui.github.io/talk/objective-and-approach-of-software-design/


  1. git actually has a simple design, with stable and reasonably well-documented data structures. In fact, I’m a huge proponent of designing your code around the data, rather than the other way around, and I think it’s one of the reasons git has been fairly successful […] I will, in fact, claim that the difference between a bad programmer and a good one is whether he considers his code or his data structures more important. Bad programmers worry about the code. Good programmers worry about data structures and their relationships. Message to Git mailing list ↩︎