[漫谈] 软件设计的目标和途径
记录一下笔者关于软件设计的一些相关认知。在开始之前先引入两个概念目标和途径(这里可能会有些咬文嚼字不过主要是为了区分主观和客观的一些细微差异)。1 目标和途径我们在做某一件事情的时候总是会带有一定的目的性的比如说一日三餐是为了给身体补充所需的能量。那么这三餐具体如何落实呢则会有多种多样的方式。比如你可以选择吃碳水食物、蔬菜、肉类、牛奶或者蛋类等等也可以选择通过静脉注射一些所需的葡萄糖或者蛋白质。总之能够为身体补充能量就可以了。1.1 目标那么在上述的小例子中我们的目的就是给身体补充能量用以维持正常的生命活动所需。当然也可以说是我们的目标不过目标侧重于过程目的则更强调结果。1.2 途径从上面的例子中可以看出有多种方式可以达成我们的上述目的。其中每一种方式都是一条达成目的的途径当然我们为了补充均衡的能量通常会搭配组合几种不同的食物我把这个称之为手段或者方法。手段和方法带有一定的主观性而途径则是在描述客观的可供选择的一种方式。2 软件的目的在开始讨论软件设计之前先问自己一个最基本的问题我们为什么需要软件笔者认为是为了解决现实中某个领域的相关问题而存在的。就好比最初的计算机是用来计算导弹的弹道的。生活中常用的QQ和微信是为了满足人们的社交通信需求的淘宝京东等是满足了人们的买买买的需求。所以软件存在的目的就是它能解决一些领域的相关问题这是它存在的唯一理由。比如在黑客帝国这部电影中不再被使用的程序只有一个下场那就是被删除掉。3 软件设计的目标假如一开始就有了软件其实要不要软件设计都不重要了。但是问题在于软件不是凭空产生的不是从0到1没有中间过程就直接得到了想要的软件的。在软件从0到1的过程就是软件设计的作用范围所以在这里我用软件设计的目标这个概念。因为软件存在的目的在于它能解决一些领域的相关问题那么首先对软件的最低要求就是它能用能用来解决问题。比如一个数学上的加减乘除计算器最低最低的要求是你要能把结果算对吧。所以软件设计的目标是什么笔者认为就是控制这个从0到1的过程避免其失控一旦失控你可能就连最低最低的软件的要求都达不到了。《领域驱动设计软件核心复杂性应对之道》一书的副标题也是这个含义。它的侧重点在于如何利用面向对象的方式应对软件本身的复杂性从而避免其失控。那么笔者对软件设计的目标的认知就是避免软件的失控。为什么是目标而不是目的呢是因为软件设计在软件的整个生命周期中都是存在着的这是一个持续的过程直到软件不再被使用的那一天而非只在刚开始设计一下后续就一成不变了。4 失控的根本原因上面推导出软件设计的目标是避免软件的失控。那么是什么东西导致的失控? 你面临的业务太复杂项目遗留的代码太烂团队成员水平参差不齐工期太紧张导致你无暇做设计规划也许吧这些或多或少都确实是已经存在的事实。业务太复杂难道是失控的原因吗回想一下软件的目的是什么解决一些领域的相关问题那么我们可以让业务的复杂性会消失或者降低吗答案是肯定的不会这里就有人要说你放屁。。。你敢说我们无法降低业务复杂性打你噢。你就是打死我复杂性也不会降低的复杂性是业务本身存在的客观属性是不会以人的意志来改变的除非你不做它了。就像你现在要在淘宝买一个手机你人在北京卖方在广州无论你用什么快递方式从广州到北京这段物理距离上的时间消耗是无法消除的。你说你比较着急那好卖方给你选择空运很快你就收到货了。你说空运这不是降低了快递时间和降低复杂性不是一样的吗 其实并不是因为复杂性指的是无论你用什么快递方式从广州到北京这段物理距离上的时间消耗是无法消除的指的是这个过程你无法消除。但是总觉得怪怪的对吗是的看起来是怪怪的明明我收到货的时间缩短了怎么复杂性没有改变呢所以这里就引申出另外一个概念业务交互方式所带来的影响。这个影响非常之大但是往往被我们所忽略比如你选择购买发货地是北京的卖方了是不是时间又进一步大大缩短了实际业务上也是这样的业务本身具备的复杂性以及我们在把业务转化为软件后的交互方式所带来的影响业务本身的复杂性我们无法降低和消除但是后者交互方式则是可以控制的这也是软件设计的一部分所以其实上面我们选择空运是改变了这部分。就好比你是一个B/S的应用软件你的用户在浏览器中看到了Web页面。这背后你的Web页面从服务器到用户浏览器的过程和浏览器渲染页面的过程是无论如何也无法消除的但是浏览器可以缓存它当你下次再打开这个页面时它就可以省掉上述的交互过程。项目遗留的代码太烂是失控的原因吗其实也不是这是失控的一种表现结果。团队成员水平参差不齐是失控的原因吗也不是这虽然是客观存在的事实但是你这样把责任推到队友身上不合适吧说不定队友也是这么看你的呢。工期太紧张导致你无暇做设计规划是失控的原因吗? 当然也不是这个是借口。。。就像你今天起床快要迟到了你会选择光屁股不穿衣服就出门吗除了上述的一些事实当然还有其他的一些因素看起来都不像是导致失控的罪魁祸首。那么究竟是什么导致的失控仔细回想一下当我们觉得项目失控的时候通常是什么场景有个已知的bug你改动的时候发现牵扯的东西太多了牵一发而动全身你不敢下手。你觉得代码无法控制了。。。有个未知的bug你找了好久找不到代码太乱了。你觉得一股无力感。。。有个新功能来了你发现你要改这里那里但是完全不知道改了会不会破坏现有的功能也不知道新功能是不是真的可以work。你觉得你无法掌控这些代码了。。。还有一些其他的情况总之就是你觉得你无法掌控代码的真实行为了你不知道你的代码会产生什么样的结果就像薛定谔的代码一样。。。那么还有一个场景当你要开展一个新的项目所有的一切都是新的没有任何历史债务负担这时候你是什么感觉信心满满啊肯定是这时候你不会觉得你会对接下来的代码失去控制因为你现在一行代码都还没有。。。所以是什么导致的失控现存的无力维护(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是不是你根本就不用思考直接读下来就知道其含义2则是有一个fasle的过程需要你进行简单的思考。3则是接近于2但是比2更差一点因为取反符号在前面但是其决定性的值则在后面而你的阅读顺序是从左向右所以你需要一个比2稍微更复杂一点的思考过程。前三个还都一眼能看出来是空或者非空的语境但是4就更差了4的字面意思是长度不等于0逻辑上其实和非空是等价的但是你需要在脑中做这样的一个映射长度!0等同于非空这个的抽象层级明显更低了一个层级。不知道能否体会其中差细微差异。那么你觉得这些理解是客观的还是主观的呢?5.2 可测试性可理解性可以确保你可以快速的理解现存代码的意图但是其真实的行为呢是不是和你所认为的行为就是一致的上面我说过“代码只会按照你编写的行为去执行而不是按照你认为的行为去执行”。那么如何确保你真实的行为和你所认为的行为是一致的那就是测试。把你认为的行为也写成代码去验证你的业务代码执行的时候是不是会按照你给定的输入得到你期望的输出结果。借助自动化的CI就可以在你每次改动代码时把现有的所有测试都运行一遍然后你至少可以获得3点收益代码真的时按照你认为的行为去执行的。确保你的改动不会破坏现有的代码行为。倒逼你的代码进行合理的分解和抽象不然你很难编写有效的测试。当然你可能把测试写错了这种概率就小多了吧。况且假设你真的写错了测试时间久了这个错误也就变成了feature。为什么呢也许你代码的消费方已经按照它实际的行为去处理了这时候你贸然把这个bug修复了结果可能时消费方反而不能正常工作了。这时候这个错误的测试其实也就变成了消费方的一种契约测试。确保你不会把它改对比如C#的类库中有个DateTime在处理时区问题时很多诡异的行为这时候微软已经无法修正它了只好再单独新增了一个DateTimeOffset两者共存慢慢的迁移过去。5.3 可隔离性那么现在你可以快速的理解现存的代码了也可以确保你的新代码不会破坏已有的功能也确认你的代码行为是你所认为的行为了。是不是就可以愉快的合并代码并且上线发布了是的差不多可以了。但是凡是总有例外我们不能把全部希望都寄托在我们能严格落实上述两点。总是要有个备选方案对吧可隔离性就是这样的一个备选方案其意图就是隔离你的代码行为哪怕它就是腐烂变质成了不可维护的代码只要不影响其他的模块那么就还算是可控的。就像万吨巨轮底层的隔水舱总是一个个的独立的一个进水了也不影响其他的从而避免整体的失控。6 途径还记得文章开始介绍的目标和途径的概念吧上述的3个原则是我们的目标那么想要达成这样的目标有哪些途径可供使用呢6.1 命名曾经有这么一句话计算机领域有两大难题命名和缓存失效。一个好名字的重要性不必多说了吧此外我还有一个心得体会如果你觉得命名出现了困难那么请从头审视一下你的设计或许你走错了方向了。我认为一旦出现了命名困难的问题那绝对就是你的设计出现了问题。也许时你的方法职责太多了你无法用简洁的名字描述清楚也许是你的字段所表达的含义不清导致你无法准确的用一个简单的词语描述它。目标效果解释可理解性增加可读性。可测试性无无影响。可隔离性无无影响。6.2 单一职责几乎每个人都明白单一职责的重要性但是却很容易就忽略它。比如下面的小例子// 1 public String sum( final CollectionBigDecimal 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 CollectionBigDecimal 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 抽象层级把大象放进冰箱需要几步把冰箱门打开。把大象放进去。把冰箱门关上。就这么简单这三件事都是在一个抽象的层级上的。那么再细化一些打开冰箱门需要几步还有现在没大象我要去从动物园先弄过来一个怎么办?这些细节和上述的三个步骤是不是在一个抽象层级上? 肯定不是吧但是我们通常很多时候都是在干着这样的事情比如业务代码中夹杂着如何拼接SQL语句的代码。当你读到这样的代码的时候会觉得很乱为什么感觉乱就是因为其涵盖了不同抽象层级的代码在一起导致你在前脚还在想着如何把大象放进去这件事的时候突然发现接下来的是我怎么才能从动物园弄个大象出来这些琐事。还记得上面的一个判断非空的一小段代码吧// 1 if(userList.isNotEmpty()){ } // 4 if(userList.length() ! 0){ }4干的就样的事情虽然很细微但是就是这样一个一个细微的不同抽象层级的代码混在一块就把你的代码搞乱了搞的可理解性急剧下降。目标效果解释可理解性阅读代码时避免分心去考虑一些不必要的细节问题。可测试性比如我用一个大象的毛绒玩具也可以完成第2步吧这就大大的简化了测试的关注点和编写。可隔离性屏蔽了一些底层的细节。6.5 奥卡姆剃刀这又是个什么鬼怎么剃刀都出来了还嫌发际线不够高吗其实不是的这个一个关于简单行的原则也称之为“如无必要勿增实体”。就是说如果有两个途径可以完成同样一件事情那就选择更简单假设更少的那一个。目标效果解释可理解性选择更简单的有助于理解。可测试性无无影响。可隔离性无无影响。7 一些误区看到这里估计有人要忍不住要批判我了可复用性呢GoF23种设计模式都强调构建可复用性的软件可复用性跑哪去了被你吃了啊。可靠性呢健壮性呢高可用性呢等等吧就像当年软工课程上罗列的各种指标或者各种的模式和架构等等。其实不是说这些东西不重要或者我不认可这些东西我认可也理解它们的重要性。但是有一点要彻底搞清楚哪些是我们的目标哪些是我们的途径7.1 可复用性只是一种现象可复用性难道是我们追求的目标吗我的回答是否我们的目标是软件的可维护性那么你说复用就会增加可维护性其实不尽然不合适的复用反而会降低可维护性这是一把双刃剑借用著哥的一句话“越通用越无用”。那么你说不是目标也是途径吧那么我的回答是也不是途径你这条途径可能会违宪你觉得它合适吗也不是目标也不是途径那么它到底是什么答只是一种现象如果你落实了上述的5条途径中的某些途径你会发现你的代码自然而然就可以复用了。7.2 设计模式源自缺陷