本篇阅读笔记全部整理自知乎in nek开设的软件架构设计专栏。若需详细阅读请移步专栏获得更多资料。另外,全部一级标题均使用原文文章标题,可以直接点击标题链接到原文章进行详细阅读。
从了解到in nek,然后关注他,并且一直阅读他的文章,从中学习了许多做人做事的道理,获益良多。关于软件开发,作者也是深入浅出,娓娓道来,有时甚至有茅塞顿开的感觉。因为作者发布的博文越来越多,知识量也越来越大,故需要个人总结,方能体会得更深刻。
README
作者开宗明义,直接指出专栏的定位以及写作专栏的目的,并说明自己的名称空间。
先介绍开发的一些基本的技能,比如怎么做单元测试,怎么做数据结构定义等。这些知识看来简单,实际上很多开发者都学错了,在这种问题上有错误的理解,后面我们谈什么都没有用,之后我们才会看是谈构架设计上的各种考量
软件工程师是最在乎“断”的人之一了。对的判断,和错的判断,对软件工程师来说都是“有效的”,无用的判断,才是无效的。因为软件的成本都来自逻辑量(代码量),if(a&b&c&(d|e|f))这个判断如果写反了,前面加一个!就解决问题了,但如果这个判断整个写乱了,这个代码就彻底没有用了
我说的这个“有效(对/错)/无效”的名称空间就好像老子写《道德经》前面的道可道非常道,名可名非常名一样,是全文的根本。后面读者们应该可以看到,我的所有表述,其实都是对这个空间定义的证明
总结:区分有效/无效,对/错基本概念,不是说对的就是有效的,错就是无效的,而是根据事情本身影响来判断,如果推动事情发展,那就是有效的;如果对于事件的发展毫无影响,那就是无效的。
什么是软件架构
作者具体阐述了什么是软件设计?设计文档应该达到什么样的要求?其主要观点就是:设计文档的形式并不重要,每个人每个团队都可以有自己的形式,其关键在于能否清晰理解软件的逻辑,让架构设计变成最后的代码才是难点
。
我们在这个问题上是很迷惘的。我们经常纠缠于类似这样的问题: “软件设计文档要写到多细才叫设计好了?”,“软件设计文档是否要列出所有的函数?是否要指明这些函数所有的输入输出?”
现在看来,这个问题其实很简单。什么东西能够严格定义一个软件的行为?当然是源代码啊。对于每个情形软件的状态机应该如何反应,不是只有软件源代码本身才能完整表述吗?理解这一点,你就会发现,严格定义的尽头,就是源代码本身,包括编译这个源代码的脚本。最严谨的设计文档,就是源代码。
既然如此,如果我们要写一个“完美”的设计文档,就应该直接开始写源代码。实际上,现在很多人都是这样的,我们常常都是直接写源代码的。所以,并没有什么必然的如何写设计文档的方法,关键是你的脑子有多清晰
软件架构本质上是绘制一幅复杂素描所打的草稿
大型软件架构设计
《道德经》说得好,大曰逝,逝曰远,远曰反。一件事情变大以后,原来近在眼前的事情看到的策略,方法,都会反过来。
所以,看我的文章,你不要看我的结论,你要看我的Pattern,我的结论在不同的情形下是不断会变的,我要给你讲的是Pattern,不是要给你讲结论。
整个软件,其实就是一组逻辑,每行软件代码,都是针对不同角度的的逻辑判断。而我们在控制这个软件的所有逻辑被建成以前。我们的逻辑量永远都是不足的啊,如果足了,你根本就不需要设计(包括架构设计)了,你已经拥有你的代码了。所以,构架设计是要在逻辑量不足(逻辑不严谨)的情况下对未来进行预判和控制。而你指望建设好所有的逻辑来解决这个问题?这么明显的逻辑死循环你都没有注意到吗?那你已经忘掉架构设计本来的工作是什么了。
架构师,就是设计团队的设计领导者
架构师具体设计什么
程序员不见得要成为架构师,但程序员不能不理解架构师的工作。就好像乐队的乐手不能不理解乐队指挥的工作一样。
软件不是有代码量就可以工作的,代码量是工作量的限制,但如果设计方向错了,所有的工作量都扔水里了。所以,你必须在你的设计阶段,就可以准备好一个水道,当洪水来临的时候,你能控制这些水让你可以发电,或者用来浇灌农田,而不是把你淹没。水道的挖掘,才是架构设计的核心。
架构设计,是架构师加予每个团队设计人员的约束,基于这个约束,当每个成员开始展开他们的代码的时候,要有办法限制住这些代码最终是为设计目标服务的。
所以,架构师的工作不是指导每个开发人员怎么工作。因为指导是无限度的。架构师的工作是“限制”每个开发人员怎么工作。
作者关于架构设计的阐述:
“决策派”的结论,架构设计,是一组设计决策,架构师通过这组决策约束所有的开发活动,从而最终得到符合要求的软件。
但是,描述约束,是描述“道”,而“道”要通过“名”来描述,我们需要名称空间来描述约束。名称空间是什么?呵呵,就是“组成派”的观点了:架构设计,是对组成系统的组件,以及这些组件之间关系的描述。
构架师工作要考虑的问题是:
- 如何正确定义约束,保证给每个设计者最大的设计自由度,但不会偏离你的设计目标。这是设计方法问题
- 如何把你的约束传递给你的工程师。这是设计编档问题。
- 如何控制工程师不会偏离你的设计。这是设计管理问题。
- 如何在新的信息不断加入的时候,调整你的设计以不断聚焦你的设计投资。这是设计变更管理问题。
观点:架构师通过名称空间给程序员建立约束,不要做无谓的工作量的浪费。
Use Case图有什么作用?
Use Case图”解决需求定义的边界,为设计阶段的决策提供核心的约束”
作者通过自问自答形式详细回答了Use Case图作用:
第一部分:问题
这篇博客我分上中下三个部分写。第一部分我先请读者做一个思考,到底Use Case图有什么用?我在各种交流,乃至招聘中,都问过这个问题。大部分的回答都是:Use Case图的作用是列出所有的使用场景(Use Case)。
如果Use Case的作用是列出所有的使用场景,它比起“需求(场景)列表”好在哪里?我们把系统分层变成图,把类关系画成图,是因为图比文字(或者列表)更清晰表达了层级,组件之间的关系,如果Use Case图的作用仅仅是重新展示一个个的需求(或者说需求的主要场景),那么,Use Case图是用来解决什么问题的?
第二部分:回答
Use Case图的目的不是为了说明Use Case,而是为了说明系统的边界。所以Use Case图和需求列表完全不是一个东西。有心的读者可能注意到了,无论是结构化建模方法还是面向对象建模方法,他们首先解决的问题都是系统的边界:到底什么在设计的范围,什么不在设计范围内
架构师不是程序的开发者,他在设计层面不能代替开发者。当一个架构师画下一副Use Case图,或者一副类图,他的目的不是要告诉下一级的设计师,具体应该如何设计这个东西,而是要告诉下一级的设计师:我将会用这些概念作为逻辑进入我这个层面的设计,你们必须为我圆这些概念的慌。由于这些慌不一定全部都可以圆,所以构架师必须给他的概念定义留有余地,而且容易理解。所以他才常常用图来取代文字描述,因为图形更有利于表达那种“一眼”就能看出关系的概念,并留下足够的修改余地。架构师做这种定义必须严格遵循两个原则:
第一,图是为了让人基本来一眼就能看出概念来,如果它的逻辑比文字还难以组织,这个图就没有意义了
第二,架构师不能取代设计师进行细节设计,所以,在必然能够实现的设计上,架构师不能着墨
最基础的解决方法:
说远一点,说说中国现在的计算机教育,我看到很多从业人员写的各种设计,有一个小学语文就应该解决的问题都没有解决:就是保证自己知道自己写下来的每行字,都有自己知道的主语。比如这样的初始化流程:
- 先发一个信号i给ABC
- 返回信号j给DEF
- DEF开始重启动
- 然后ABC开始重启动
……
拜托,谁发一个信号i给ABC啊?谁返回信号j给DEF啊。“然后”?“然”是什么啊?是什么之后啊?
使用软件的四种方法
作者为了反驳我们不需要架构也同样可以做出不错的软件
,提出了使用软件
的概念空间,将软件的使用方法分成如下四中:
第一种,在看不见软件的情况下使用软件。比如电梯,你按下一个键,电梯决定向什么方向走,这背后其实由软件控制着,这是使用软件的第一种方法,你感知不到软件,但软件在为你提供服务。
第二种,在看得见软件的情况下使用软件,比如Office,你知道你自己面对着一个软件(比如Word),然后你基于这个软件提供的UI来使用这个软件。
第三种,通过写软件来使用软件,比如Unix系统的维护,你写bash脚本,写Python脚本,或者在Windows下你写PowerShell脚本,来维护Unix或者Windows操作系统。这种情况下,你也是在写软件,但操作系统本身不是你写的,你仍是在使用这个软件。
最后一种,通过修改软件来使用软件,比如开源软件,或者芯片套片提供商的SDK。你在开发这个软件本身,但其实你并非在建立这个软件,你仍在使用这个软件。
然后,作者正面回答为什么没有架构设计也可以做出不错的软件?
因为很多情况下,我们并不是在开发软件,而是在使用软件而已。
使用软件并非开发软件,在中国,真的“开发”软件的公司是很少的,很多不过是在“使用”软件。使用软件你成功了,然后你以为那是你的本事,然后你总觉得这些经验也可以指导你做这个操作系统,那个数据库,还对构架设计方法不屑一顾,这就是你们为什么总不能开发出这些看来技术上已经没有任何障碍的东西来。
最后,作者重新指出架构的功能以及在软件开发中的意义:
架构设计,控制的是软件没有成长起来之前,如何保证现有的投资先可以养活未成气候的软件,以及软件成长起来后,如何控制它自己不会被自己的逻辑挤死。所以,如何保持软件的活性就是软件构架的第一步,这都要有意识地进行控制,不懂这种控制,如果没有遇到竞争,一般软件都还可以活下去(因为毕竟死了也可以回退到前面的版本,或者针对场景进行分支处理),但如果面对强有力的竞争,它就活不下去了,就会走向灭亡。这种时候,我们才会真正体会到软件架构的重要性。
从单元测试理解软件
单元测试的目的,是测试本单元(就是本C程序,必要的时候,也不包括头文件)的所有可以执行的流程,都在测试范围内。
单元测试基本要领:
首先,我的习惯是在工程之外建UT工程,比如你有一个工程在abc目录下,里面有aaa.c, bbb.c, ccc.c, Makefile乃至http://configure.in等,这些东西我都不想影响,我可以在abc之外建一个abc.ut的目录来放我的单元测试代码,也可以在abc之内,放一个ut的目录
第二,单元测试是测试你的本.c的代码,有一个重要要领是,不要尝试在数据结构上建立多余关联。前面我已经说过了,很大程度上,我们不测试所包含的头文件(特别是系统头文件)。所以,比如你包含了<device.h>,你没有必要真的包含它,你写个空文件让你的.c包含就好了,如果你用到struct device,你也没有必要把device.h中的定义拷贝进来,你在你的xxx.ut.c中增加一个空定义就好了:struct device {}; 然后你的程序中用到其中某个成员,你就增加那个成员的定义即可。这种方法可以有效隔离你的代码和其他模块。
第三,我们要正确理解单元测试的目的,单元测试的目的是测试你写下来的每行代码本身的逻辑组织是否和你的预期一致。
关于单元测试的目的,作者更详细的叙述如下:
这里说到两个非常重要的概念,第一个是“你写下的每行代码本身”,上面被测试的那个程序,调用了一个函数wake_up_process(), 如果那个函数在你写的那个被测试的.c中,那个属于“你写下的每行代码本身”,如果不是,它就不是,它仅仅表示你调用了一个函数,它的工作是否正常,不是你单元测试考虑的范围,你可以对它有预期,以此来修正你自己的行为,但你不是在测试它的行为是否正确。
第二个概念是“你的预期”,还是用这个wake_up_process来说,你写程序的时候,对这个函数是有期望的,但它不一定符合你的期望,而我们前面说过了,你测试的是你怎么办,不是测试“别人应该怎么样”,所以,你测试“你的预期”,而不是测试那个函数的行为。
单元测试和集成测试的区别:
单元测试测试的是你的那个“单元”,不是你的单元和其他单元发生作用的时候怎么样,前者是单元测试,后者是集成测试。单元测试是保证软件质量的第一步,在简单的系统上,我们甚至不需要做集成测试,但单元测试是不应该被省略的(特别是对于长期使用的商用部件)。
如何构造单元测试:
你一定不能关注到函数的实现细节,你必须从实现细节上抽离,回到你要解决的问题上,只想“对于什么样的输入,你认为一定会输出什么”,这样你才不会写那种“本来就是这个结果”的测试用例来。如前所述,单元测试照理说是可以过滤掉大部分的逻辑错误的,如果你发现这样的逻辑错误最后在后面的过程中被发现,那你就要反省一下当时为什么没有构造出这样的逻辑来了。
对于用例的构建,就真的是个经验问题了,我会按如下方向来考虑测试用例:
- 构建随机输入,先判断是否出现比如内存越界等问题
- 验证边界,极限数据是否有可能错
- 双算法校验,这个通常用于非常关键的算法,写两个算法(通常高效的算法用于工作系统,低效但可靠的算法用于测试),然后用大量的数据进行轰击,看两个算法的输出是否一致。
程序语义和自然语义的区分问题:
构架设计关注的是自然语义,让计算机理解人的要求,而单元测试,关注的是程序语义,是看计算机是否按设计要求的流程运作。单元测试的时候,我们要彻底把程序看作一个“管道”系统,而不去关心这个管道中到底流的是水,沙子,还是卫生巾。
程序的动力和传动机构
单元测试还会让我们清晰地区分一个程序的动力源和传动机构。我们前面已经看到了,函数其实是一个管道系统,不同的水倒进来,会被分流到不同的位置。它本身是不会动的。如果从机械的角度,函数是一个刚性部件,没有动力的时候,这个部件不会动。
把数据灌进这个管道系统,这是动力。我们做单元测试的时候,是为每个测试用例提供一个动力源,单独看函数在这个动力下,是否按正常的方式运转。
当这些函数组织成程序,动力源就是线程,由于函数是刚体,多个动力源就不能作用在同一个函数实例上面(多线程执行同一个函数是执行这个函数的多个实例,因为他们的局部变量是不同),否则就会造成速率匹配问题。通常要进行速率匹配,我们需要非刚体,对程序来说,常见的是队列。
但除了队列,函数内部是有可能产生刚性匹配的,那就是锁和全局变量了。