铜剑技校

仝键的个人技术博客

0%

上一篇里,我们实现了一个小应用,而且用两种设计。本文将给出更多的设计实现以继续探讨设计问题。

我们会看到,即便是纸上的图,也可以进行重构。由于纸是我们大脑的延伸,也可以称之为脑中的重构。而这种重构的结果由于并不是真正的代码,所以即便重构错了方向,设计不合理,抛弃也非常简单,相对于改代码而言,几乎没有成本。

简单的改进

第一步我们先做得简单一点,上一节我们停在这张图上:

image1

我们可能会觉得上一节的图有点啰嗦,比如buildStudentInfoPromptString和buildStudentSeqencePromptString两个函数,甚至generateReport都啰嗦了。那我就可以重构一下,去掉这些啰嗦的内容,变成下图:

image2

上面的改进可能我们不太满意,我们试着走另一个方向,从整个程序的角度来减少输入输出。我们前面说了,每个函数的输入输出种类越少越好,那么在我们这个题目里,推到极致就是每个函数不自己处理打印和读取,所有的打印和读取都放到一处。不但不打印和读取,还不调用其他函数,那么就会变得像下面这张图:

image3

概念性思考

上面这个实现在过程层面已经实现了只在一个函数中打印和读取用户输入。然而这个最大的函数却很臃肿,除了不干具体的活,这个系统中所有的逻辑它都知道。这种全知全能的函数,如果有更多层级,更多菜单的应用,这个函数很容易就臃肿到不能维护的地步。

那么我们就来想一下,有没有一种设计,在层级更多的时候表现的更好。这个时候我们需要一种思考能力,我们称之为概念性思考。
概念性思考一般分为四步:

  1. 看到复杂场景背后的核心本质
  2. 识别到两个不相关的情景的相似之处
  3. 用比喻或类比来解释场景
  4. 用一个框架去解决问题

那么我们按照这四步走,当前的复杂场景就是这个应用,看起来每次输出之后,还会有下一次输出之前的提示,好像每次的提示,输入,输入后的打印三个是一组的,其实完全可以把输入后的打印和下一步的提示看作一次处理,也就变成了根据用户一次输入进行一种计算,将计算的结果转化为一次输出。这也就是它背后的核心本质。

简化成这种核心本质后,有没有一种已经存在的类似情景呢?我们通过观察很容易可以看出,一个简单的Web应用和一个命令行应用没什么区别,当然是没有ajax的传统web应用。用户每做一次请求,然后得到一个响应,这个响应会渲染成一个页面。

如果这两个东西有些相似性的话,那么什么是一次请求到一次响应的结束呢?在这个应用里,输入完字符串之后,敲击回车就是一次请求。当敲击回车后到下一次看到要求输入时为止就是一次响应。

在我们这个应用里举一个具体的例子来类比一下,在主界面输入1,并敲击回车,为一次请求;从敲击回车后,经历诸如下列的文字被打印出来的阶段:

1
请输入学生信息(格式:姓名, 学号, 民族, 班级, 学科: 成绩, ...),按回车提交:

一直到变成可以进行下一次输入的状态为止,为一次响应。其余的情况以此类推都可以类似的理解为请求和响应。

那么有没有现成的一个web开发的框架可以照搬来处理这个命令行应用呢?那可以选择的就多了,各种经典的WebMVC框架都可以。当然照搬任何一个WebMVC框架的话的话可能会比较啰嗦,反而降低效率,我们可以在理解它们的概念的前提之下仿造一个,这就是下一节的内容了。

至此,我把我在这件事情上进行概念性思考的过程展示给了大家。做一个优秀的程序员,分析性思考和概念性思考是两大关键思考能力,希望每个读者都可以通过这一系列教程理解这两种能力,从而在工作中进行刻意练习直到掌握这两种能力。

我们的框架

有些话要说在前面,这个题目比较简单,但我给出的解决方案是可以处理更多层级,更多菜单的,只是如果我给出的题目太过复杂的话,大家可能连读完需求的耐心都没有,更不要提理解解决方案了。所以我们算是用了一个复杂的方案来解决一个简单的题目,中间的差距请大家自行想象,这个方案在更多层级和更多菜单时,才会真正显现威力。

言归正传,在我们的这个框架里,我们的过程方面有三个概念:Router,Command和Service。
Router负责当每一个用户输入进来的时候,它知道去找哪一个具体的响应者,这个响应者就是我们的Command。
Router通过简单的输入解析,找到具体的Command,Command负责处理各种具体的用户输入解析,但是有一些核心的计算,比如说添加学生信息,我们定义为Service。Command会调用Service函数和将Service计算结果翻译为用户友好的输出。当Command处理完一切,就会返回结果,结果就会统一的输出。Command一般不直接输出到用户接口,这样就会便于测试,就像我们所有的WebMVC框架一样。管理这些输入输出的是我们的main函数,它还会负责把我们的Router,Command配置在一起,像极了WebMVC里的配置文件。

过程方面分析完了,在数据方面,我们只需要把Service和Command之间的通讯,Command和最外层之间的通讯抽象出一些概念就可以了,比如Service和Command之间的数据可以叫Response,Command和最外层之间的数据可以叫View。

所以综上,我们可以开始画图了,但是这个画图呢比较难画在一张图上,所以我们就把一次“请求”和一次“响应”画成一张图,这也是一种可视化的技巧,当我们把一件事画成多张图的时候,我们的大脑会自己把这些图联系到一起,并不需要我们在图上表示出,他们是有关系的。

总之,这个应用可以画成的图如下所示:

应用启动:

application-example-home

去界面1:
application-example-goto-add

添加学生成绩:
application-example-add

去界面2:
application-example-goto-print

打印学生成绩:
application-example-print

退出:
application-example

将上述图片翻译成Task的时候,就会用到Response和View。

课后练习

  1. 列出所有的任务
  2. 改进需求1: 请引入加分策略,参照第二篇
  3. 改进需求2: 请把每次添加学生信息输出到文件,每次打印成绩单时,从文件读取。画出图和任务列表。

题外话-1

问:一个用户输入到一个用户输出算一张图的话,这个命令行程序还好说,正常应用里的拖拽怎么办呢?
答: 首先,拖拽的时间是可以切分成时间片的。对于每一类时间片,其实我们可以画一张图。(当然,你会嫌烦,有这功夫不如做出来了)其次,如果我们拖拽的话,整个过程其实只在前面出现效果而已,没有任何数据往后发送,只有拖拽结束那一刻,才会有严肃的数据产生。所以我们一般不关注效果部分,因为他不触达核心,所以即便想的不全,改起来也不困难。所以完全可以先做再改。

题外话-2

为什么这么划分图?
除了像request和response之外,之所以这么划分,还因为这是一个个的业务场景。我们在前面的几节里,使用的思维主要是结构化思维,就是把一个整体的分解为多个模块的思路。而在这里面,我们使用的是场景化思维,每一张图实际上都是一个业务场景。需求的场景化是非常重要的一种思维,这会有助于我们把业务、组织和软件进行有机的设计。

【旧文搬家】

之前写过一篇文章《编程的精进之法》,总结了ThoughtWorks中一点工作方法。现在看来,那篇文章其实应该叫个人精进之法。然而现在不是个人英雄的年代了,我们需要再深想一步,一个团队应该怎么办?

当我们在带领一个团队的时候,我们想的总是,如何做好任务分配,平衡团队战斗能力,交付最好的结果。于是做的时候就会下意识的去简单、被动的因材分工,那么随着项目的进展,人员的流动,各种意外的发生使得我们在项目后期感到处处掣肘,于是只能加班以示诚意。

我刚入行的时候,经历的各个项目都是如此,一直觉得这种事情就是天经地义的,直到认识了一个项目经理。该项目经理是个高人,他在项目开始的时候,问清楚每个人擅长的部分,然后让每个人去做自己不擅长的部分,不会?去找擅长的人帮忙。比如,张三说我以前做过用户权限管理,李四说我以前做过单据管理,王五说我以前做过工作流。(交代一下例子的上下文,那家公司主要就做一个大的领域,那个时候也不像现在前后端分这么清楚,项目经理有时候还要身兼Tech Lead)他就会说,好,张三去做工作流,王五去做单据管理,李四去做用户权限管理,遇到不会的,谁擅长什么你们都知道了啊,去问。

虽然看起来有点乱来,但是他负责的项目从来没出过问题。后来我加入了ThoughtWorks才知道,听到一个口号:“把项目成功交付看作能力建设的副产品”,才知道这是这口号的一种朴素实现。
很多团队能力不强,团队的领导者就总是在向外寻找方法的帮助。寻找方法帮助其实没有错,但是寻找方法帮助的人,心态往往都是错的。当我们在向外诉求方法的时候,很多人的潜意识,是假设我们团队成员能力不变的情况下,通过一种魔法般的方法,就可以改变团队的绩效,这种思路在真实世界里是走不远的。

在ThoughtWorks,我们认为,软件开发中的一切问题,根本上都是人的能力问题。如何发展每个成员才是问题的关键,因为成员如果没有进步,始终是治标不治本的。所以我们采用的一切实践,不管是以前曾采用的还是以后会采用的,核心目的都只有一个:发展人的能力。因此才有了那个听起来很耸动的口号:“把项目成功交付当成能力建设副产品”。

如何发展人的能力?讲东西吗?不太靠谱,信息仅靠分享是没用的,我经常把刚讲过一遍的知识,让人复述;把结对时刚写完的代码全删掉让同伴重写一遍,能做到的人不多。记也记不住,做也做不到。

就像我之前《然而培训并没有什么用》里说的,做练习?没时间,项目太忙了。而且,就算你有时间,我们拿出时间来做练习,你能保证到了跟练习不一样的场景下,团队成员们都能用好吗?把学会的知识在新场景下用好这件事,还是挺看天赋的。

讲东西不靠谱,做练习没时间,那难怪大家不考虑能力建设了。不过,如果我们反过来想,这个问题就变得没那么难办了,既然没有时间做能力建设,那么也许一切活动都可以看作是能力建设。所以那个项目经理的招数虽然看起来比较乱来,但却是这个思路,我在项目开始的时候,不是着急去以最快的速度交付结果,而是通过任务分配,发展团队成员的能力。在一个较长的时期里平均来看,我们就是在以最快的速度交付结果。

所以,回到我们的主题,就是团队的精进之道就是把交付过程中的一切活动都看作能力建设,把整个团队构造成促进每个成员成长的生态系统。

说起来好像挺简单,我只要换个角度看就好了,然而如果想要做到并没有那么简单。这里面差异微妙而关键。

比如以上一篇文章《软件开发的精进之法》讲到的方法为例。一个人要划任务,然后估时间,然后做的时候计时,根据实际结果进行反思。我们可以把这个方法做成非常邪恶的,仿佛流水线上工人的强制要求。我不关心你为什么超时,就通过这种方法来控制程序员,要求每个人都严格按照一个死板而僵化的步骤做一些简单重复的机械动作。也可以用这个方法来锻炼一个人的自我认知和发现知识漏洞等能力,促使他以最快的速度成长,等他成长起来马上给他更重要的任务,比如评估技术、评估项目、带新人、做架构等等。这两种结果的差异,背后就是领导者认识的差异,团队成员认识的差异。从这个认识的不同我们早在很多年前,就被一些大牛们观察到,作为敏捷宣言里的一句话表达了出来:“个体与交互 胜过 流程和工具”。

团队里的流程和工具,是为了成就个体,促进交互,还是为了抹杀个体,消除交互,这个微小而关键的差异,是一切的本质。有多少团队学了ThoughtWorks的一些实践,搞了看板、开放工作空间、TDD、CI,团队氛围依然压抑,成员之间交流不畅,个体成长不受尊重,领导与员工玩“猫和老鼠”。这样只学了形没有学到神的做法,最后的结果不会太好。

与之相反的做法呢?在上一篇文章《软件开发的精进之法》的开篇曾经简单的提到,新时代的管理者比起老板,更像老师。师者,传道,授业,解惑。各位老师,你们准备好了么?

【旧文搬家】
(作者注:阅读本文需要一定的编程经验和对一些敏捷实践,如TDD,有一定的了解)

编程,众所周知被定义为知识工作,所有的知识工作,从业者和门外汉都喜欢把它神秘化,将整个过程以不可知论的风格来解释。理由往往只是简单粗暴的讲诉一些体力工作时代形成的方法照搬到知识工作中来失败的故事,也毫无理论依据。偶有几个人写理论理由,写出来的理由跟癔症者的呓语也无甚差别。我个人是反对将知识工作神秘化的,我是科学管理原理的忠实粉丝,我觉得尽管科学管理原理的具体案例都过时了,但泰勒的研究方法依然是工作的,只是研究者和被研究者发生了一些微妙的变化。

彼得德鲁克在《21世纪的管理挑战》中提到,知识工作者需要自我管理,那么很明显不是体力工作时代形成的方法不能用在知识工作中,而是不能指望个体之外的人对个体进行简单的粗暴的分析和命令就可以形成很高的生产效率了。这个分析和下命令的人必须是知识工作者自己,我们需要自己纪录自己的行为,然后分析、优化,才能得到生产力的提升,任何向外诉求都会很快的撞上一个“天花板”而无法提升。如果非要寻求外部控制,那么我们只能说,对于新时代的管理者的定位,比起老板,更像老师,以引导和帮助训练为主,每个人真正的效率提升主要还是要靠自己。

那么如文章标题所述,追求知识工作的一种——编程的效率,是本文关注的重点。但我们首先要声明,本文不是给一个可以直接产生高生产效率的方法,而是给一个可以可视化自己生产效率以找到瓶颈的方法。至少在不改变质量的前提下,可以极大的提升你的效率,如果使用得当,可能会质量和效率双提升。

本文引入的方法也并不新鲜,简单说来,就是任务列表法+PDCA的一套组合使用而已。大道至简,坚持者寡,而坚持下来的人往往都可以获得数倍的效率提升。

任务列表法

我们做任何事情都应该划出任务列表,按照任务列表一项项去完成,这不是什么特别少见的工作方式。然而,很多人的问题在于,列出的问题列表不能达到完全穷尽,各自独立。

完全穷尽是什么意思呢?
当我开始做事情的时候,我不能把所有的事情穷尽,我列出的列表跟我做的事情是不完全等价的,这说明我们的工作行为是非常混沌不可视的,哪怕是对自己。
有时事情看起来在大面上是穷尽了,但是做的时候,发现又出现了新的任务。那说明每一项任务的输入和输出没有想清楚,所以当发现输入输出有欠缺的时候(主要是输入,输出欠缺的结果也是要补输入),就需要新的任务来准备输入,于是任务列表就增加了,这也是一种没有穷尽。

各自独立是什么意思呢?

意味着,每一项任务都可以单独做完,而不需要先做完其中一项任务,才能做完另一项。
假如我有三项任务

1
2
3
# 任务1
# 任务2
# 任务3

我做的时候,必须把任务2做完,任务1才能做完。任务3做完,任务2才能做完。结果我就从任务1开始一路做到任务3,最后再逐步回到任务1,整个过程非常混乱,那就不是各自独立的。

在现实生活中想做到各项任务都独立挑战还是比较大,但是在编程的世界里,挑战没有那么大,程序世界做到这一点真的太轻松了。优秀的设计都是要求解耦的,如果做不到,基本等于活儿比较烂。

当我们做到任务的完全穷尽与各自独立之后,我们任务列表法才算达标,这之后才能高效的工作,然而达到这一点并不是一蹴而就的,没有谁可以一上来就做到任务划分可以完全穷尽、各自独立,需要不停的刻意练习。所以我们称之为编程的精进之法。

PDCA

PDCA是Plan-Do-Check-Action四个词的组合。这是著名的戴明环。讲究从计划开始,经过实践,再反思,产生的改进行动再纳入下一轮计划的持续改进过程。

当我们把这一套从工业领域搬过来的时候,我们对计划的理解还是工业领域那一套。如果用在个人提升方面,我们应该把PDCA微观化,这之后就有两个问题需要被解答,一个是Plan是什么?一个是Check什么?

第一个问题的答案是很显然的,我们前面讲的任务列表法就是在形成这个Plan。

第二个问题本身是一个母问题,每当我们对这个问题的回答,都要回答一个衍生出来的子问题:我们要做点什么才能在需要Check的时候能够Check。常用的套路有两个:

  1. Plan的时候估计一个时间,然后开始做,做的时候计时,做完就要Check这个时间是否达标,无论快了还是慢了(通常是比较明显的差距才反思,比如20%以上的差距),Check都要反思并产生Action,纳入到未来的Plan中去。
  2. 估计的任务列表和实际做的任务列表是否是一样多的?往往是会多出来,这时就要反思,自己在哪里有不足导致了这个差别。

这些反思往往是发现自己的问题,比如自己不熟悉的知识点,不熟悉的方法,甚至不熟悉的业务知识,最后的Action也往往都是进行刻意练习来提升生产效率,比如反复练类似题目。有时也会是通过一些效率工具的使用来提升效率,比如抽取live template,使用快捷键,只是效率工具的使用往往也需要刻意练习就是了。有时也可以通过复用技术(其实live template已经是复用技术了)来提升生产效率,然而可复用模式的识别与抽取本身也是需要练习的,否则在那里纠结浪费的时间更长。

有些同学会感觉到,记录了时间却不知道哪里有问题,这个时候可以跟TDD相结合,把时间划分为写测试的时间,写实现的时间和测试通过的时间。其实除去这几种时间,还有其他时间消耗,比如调研的时间。不管怎么划分,将时间消耗结构化掉,一部分一部分的追求最高效率是一种可行的办法。

举例

我们做一个简单的修改用户信息功能的API。那么我们在某一个Java技术栈上可能的任务列表是长这样的:

1
2
1. 写UserController (10分钟)
2. 写UserDAO (15分钟)

当你真正开始做的时候,会碰到两种主要的意外:

  1. 任务列表扩张
  2. 时间估计不准

我们下面就这个例子,讲一讲遇到这两种意外,我们应该怎么反思和处理。

任务列表扩张

任务列表扩张,顾名思义,就是指我们一开始估计的任务的数量随着我们开始工作变的比预想的多,可能有两种主要原因:

  1. 技术原因
  2. 业务原因

技术原因,比如在这个案例里面,第二项任务:写UserDAO,就是一个没想清楚的事情。我们还需要建数据库表,我们在一个有migration脚本支持的技术栈设计上工作,我们还需要写初始化脚本和回滚脚本,也许这是我的第一个表,所以我还得配置数据库,搞不好还要把ORM的基础代码都写完,所以这些导致了我可能任务估少了。再比如,我们的项目规范要求我们Controller不能直接调DAO,要在中间加一个Service,尽管我个人觉得这是一件很二的规范,然而规范就是规范,我对项目技术规范不熟悉结果导致我画出来的任务缺少了一些必要的任务。再比如,我们的项目采用的是Jersey,根本没有Controller这么一个东西,那么不了解技术框架导致我的任务根本就划错了。

这种情况属于我对技术了解不足,通过对任务列表扩张原因的Check,我会得出一些Action:去了解技术规范,去深入了解项目的技术架构,现有的代码,以防止以后的任务画错。

业务原因,也比如在这个例子里,我们的系统需要在更新用户信息的API里不能更新密码,所以我们还需要一个专门的修改密码的API。再比如,这是一个遗留系统,用户信息的修改会触发数据库里的一系列触发器,从而修改系统的其他数据,然而有些修改是有前提的,那么我就需要更多的任务去处理这些前提条件;或者当数据变化时,要求我去修改系统里的其他数据,那么我就需要更多的任务去完成这些工作。

这种情况属于我对整个系统的业务了解不足,通过对任务列表扩张原因的Check,我会得出一些Action:通读数据库表,通读代码,更全面的阅读需求,或者跟需求方更多的沟通,以了解业务。

时间估计不准

时间估计不准就简单了很多,在这个例子里,就是这两个任务我估计的时间与我做的时间不相匹配。可能的主要原因也有三个:

  1. 任务列表扩张了,但是我没意识到,比如UserDAO写起来没有我想的那么简单,所以多花了时间;
  2. 单纯的技术不熟练;
  3. 花了太多时间在纠结上;

对于隐藏的任务列表扩张,时间估计不准给了我们一个很好的线索去发现。一旦发现了,可以前文所述去处理,也就不再赘述

对于单纯的技术不熟练,就如前文所述,要去设计刻意练习。比如我就曾设计过对数据库的增删改查的一组训练以提升自己的速度,使得我即便使用TDD依然保持一个极高的速度。我们自己没有意识到,基础能力的不熟练对于我们的高级能力的限制有多严重,这种体验也只有基础能力已经熟练的人去教基础能力不熟练的人一些高级技能的时候才会发现。这种视而不见的收益,使得大多数人都会轻视基本功的练习。哪怕已经获得收益的人,也容易鼓吹要更多的启发而忽略了基本功的价值。

对于花了太多时间在纠结上,这其实也是一种不熟练,是对设计知识和能力的不熟练。之前看的设计知识只能有一个大概的感觉,对于每个知识的边界、使用之后的发展、如何从一种设计过渡为另一种设计了解不清,从而害怕在那一刻犯错。实际上真正值得纠结的部分没有那么多,大多是自己吓自己,或者引入了过度设计。当然也有一种情况是暴露出了架构级的问题,比如我们对于应该提出的原则性规范没有提出,所以导致我们每个地方都要现想,大家可以想象没有RESTful之前设计Web API,我们可能真的是每一个API都现想的,有了它之后,我们的纠结时间就变少了。这种情况下,通过本方法,架构师也算是有了相应的数据支持,那么架构师也就有了发现问题的一种工具。

结论

总的来说,任务列表法+PDCA式工作法形成的组合方法,是一个通过逐渐提升个人能力以达到高效工作的方法。这两种方法单独拿出来用,都会由于各自的局限而有各自的天花板,只有有机的结合才能真正突破这个天花板。刚开始使用起来,对于很多人来说会有一些痛苦,这一点上我只能说,提升就是痛苦的,而新的习惯一旦养成,痛苦也就不翼而飞,所以美国心理学之父威廉詹姆士说,“我们需要在尽可能早的时候,让尽可能多的有用的动作变成自动的和习惯的……一段痛苦的艰难时期之后就是自由的时光”。当我们基础能力达到一个极高的效率之后,我们会发现我们争取自由的筹码会变得更多。

广告时间

有人可能会觉得列出符合上文所述标准的任务列表比较难,欢迎阅读拙作《像机器一样思考》系列文章:

像机器一样思考(一)—— 宏观的基础
像机器一样思考(二)—— 数据的细节
像机器一样思考(三)—— 穷尽就是力量
像机器一样思考(四)—— 一图抵千言
像机器一样思考(五)—— 第一个应用
像机器一样思考(六) —— 脑中的重构
像机器一样思考(七) —— 跨应用思考

经过前面四篇,我们已经具备了最基本的思考编程的能力。我们开始做一个真正的应用吧。

例题

我们现在做一个应用,该应用是一个命令行应用。当程序启动的时候,我们会看到一个命令行的主界面:

1
2
3
4
1. 添加学生
2. 生成成绩单
3. 退出
请输入你的选择(1~3):

如果我们输入1,那么界面就会变成:

1
请输入学生信息(格式:姓名, 学号, 民族, 班级, 学科: 成绩, ...),按回车提交:

如果输入格式不正确,就返回:

1
请按正确的格式输入(格式:姓名, 学号, 学科: 成绩, ...):

如果输入格式正确就会返回

1
学生xxx的成绩被添加

然后打印

1
2
3
4
1. 添加学生
2. 生成成绩单
3. 退出
请输入你的选择(1~3):

等于回到了主界面。
如果我们在主界面输入了2,那么界面就会变成:

1
请输入要打印的学生的学号(格式: 学号, 学号,...),按回车提交:

如果我们输入的不正确,就会打印:

1
请按正确的格式输入要打印的学生的学号(格式: 学号, 学号,...),按回车提交:

如果输入的格式正确,则会打印成绩单并回到主界面。

1
2
3
4
5
6
7
8
成绩单
姓名|数学|语文|英语|编程|平均分|总分
========================
张三|75|95|80|80|82.5|330
李四|85|80|70|90|81.25|325
========================
全班总分平均数:xxx
全班总分中位数:xxx

如果我们输入的学号不存在,该学号在计算时就会被忽略。

讲解

这个题目比之前的复杂一点,我们可以先按照功能简单列一下任务列表。

1
2
3
4
#1 打印主菜单
#2 添加学生成绩
#3 生成成绩单
#4 退出

然而这个列表是跟我们的程序不等价的。我们还是应该列出跟程序等价的任务列表。为了便于表达,我们就需要画图。我们先不考虑面向对象,直接面向过程的划分,我们画出来的图大概是下面这个样子:

image

也许你觉得这个做法并不够好,我也这么觉得,那我们可以重构这个图,画出一种更好的解法:

image

可以看出,这两种做法,在图形上看并没有明显的区别,但是命名上暴露出了思路的不同,后者明显减少了程序对外输入输出的次数。所以不要随便命名。
但如果没有上面那个图,我们是看不出来buildReport这个函数他打印了还是没打印,这就需要我们有一套约定俗成的用语规范,从用语规范中可以看出来我们到底做了什么。

课后练习

  1. 针对两张图,分别列出所有的任务。
  2. 在脑中模拟一下,如果测试先行的方式写代码,两种实现的时间花费各会是多少。按照任务列表逐项估出时间,并比较两种实现的时间差异。

题外话 - 1

对于接触本文章的编程初学者,我需要多说几句:我们可以看到,其实一个真正的应用也是由一些简单函数构成的,那就说明编程思想和技巧并不受限于应用的表象。前端、后端、应用、系统之类的名词,往往是从分工的角度来讲的,如果从个人发展的角度来讲,不要拿这些名词限制自己。这也是我们这个系列只讲一些简单应用的缘故,抽象来讲思路是一样的。

题外话 - 2

到这里,你应该能感觉到我们这些题目,其实是一个个思维训练,对于这个思维训练,最重要的事情有三点:

  1. 语文问题(用词精确,前后一致)
  2. 接口问题(完全穷尽)
  3. 每个函数之间互相不知道对方的内在实现(各自独立)
    能做好这三点,代码就能完胜行业里的大多数人:)

【旧文搬家】
当我们把一个完整的功能拆解为一个个输入输出穷尽,互相独立的任务后,它是容易转化为代码了,可是这种方式并不容易思考规模更大的问题(光从哪来到哪去就够我们绕的)。把我们的大脑看成一台电脑,我们就是那种内存很低的电脑,问题规模一大,我们就会死机,然后就只能重启了。具体表现为我们思考时会觉得晕。每次晕的时候可能都重启了一下:)。

怎么办呢?其实也很简单,内存不够硬盘来凑。对我们的大脑来说,最常见的“硬盘”就是纸。而正如电脑的硬盘传输速度总是不如内存的,加了硬盘计算效率不一定快。我们需要一种对传输友好的编码方式,这种方式就是画图。

画图的规则

我们的画图方法受时序图启发而发明,具体的规则如下:

  1. 本图基本元素由方块和带箭头的线组成
  2. 一个方块只代表一个函数或一个代码块,通常是函数,方块中可以写字,可以表达函数是属于哪个类或哪个实例等信息。
  3. 指向方块的线代表该函数的输入,背离方块的线代表函数的输出。
  4. 数据流动的时间轴遵守先从左到右,再从上到下的顺序。
  5. 每一对输入输出(输入在上,输出在下)加一个方块,表达了一次函数调用。

举例:
比如下列代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function c(){

}

function b(){
c();
}

function a(){
b();
}

a();

画成图是这个样子的

 function call

在这个图上我们可以清晰的看出来,函数a调用了函数b,函数b调用了函数c。而函数a自己,是在最顶层调用的,也就是所谓的程序入口。
整张图是从左往右表示时间顺序。

什么情况下既有从左到右,也有从上到下呢?比如下面这个代码:

1
2
3
4
5
6
7
8
9
10
11
12
function b(){
// b codes
}
function c(){
// c codes
}
function a(){
b();
//a codes;
c();
//a codes;
}

函数a先调用了函数b,然后再执行一段a里面的代码,再调了函数c,然后再执行了一段a里面的代码,然后返回。

 a call b+c

正常的使用方式

我们正常使用这个实践的时候,这个过程是反过来的,我们可能先看画出了上面这张图。不过这回,我们要画的认真一点,为了后面可以导出任务列表,我需要加上标号,如下图:

(a-b)+(a-c) with detail

然后从这张图里按照标号导出任务列表,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#1 函数a
输入:
paramX: TypaX
输出:
bValue: TypeA

#2 函数b
输入:
paramY: TypeY
输出:
cValue: TypeB

#3 函数c
输入:
paramZ: TypeZ
输出:
aValue: TypeC

然而这里有个问题,对于函数b函数c来说,输入输出倒是很正确,对于函数a来说,难道函数b函数c的返回值不是它的输入吗?相应的,函数b函数c的参数也是它的输出。

所以真正完善的IO是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#1 函数a
输入:
paramX: TypaX
cValue: TypeB: 函数b
aValue: TypeC: 函数c
输出:
bValue: TypeA
paramY: TypeY: 函数b
paramZ: TypeZ: 函数c

#2 函数b
输入:
paramY: TypeY
输出:
cValue: TypeB

#3 函数c
输入:
paramZ: TypeZ
输出:
aValue: TypeC

写出的代码大概如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function b(){
// b codes
return result;
}
function c(){
// c codes
return result;
}
function a(){
let bValue = b(paramX);
//a codes;
let cValue = c(paramZ);
//a codes;
return result;
}

在在真正的工作中,函数b函数c这样的函数是非常少的,大部分都是函数a这样既被调也调别人的函数。如果每一个都按照这样去切分,恐怕就不是一个高效的方法了,即便列出来也很乱。不但不能帮助思考,反而会阻碍思考。

当按照模块的角度去拆分task不work的时候,我们就要按照测试的角度来切分task了,这个思维的切换是TDD的核心。我们需要按照测试的视角来切分任务,从一个函数的实现视角转为一个函数的调用视角。

这样做的好处有三个:

  1. 封装复杂性,当我们按照模块去拆分的时候,如前文所说,复杂性会变高,思考负担会加大。而按照测试的视角来切分呢,复杂性当然不会消失,但是被封装了,方便我们在分析的时候减少思考负担。
  2. 跳出盒子外来看盒子的视角。我们在画前面的图的时候,实际上是在我们要实现的这个程序内在看,完全没有使用者的视角,也就是所谓的在盒子内,。当我们站在测试的视角看的时候,我们就跳出了盒子外,他更容易让我们发现哪里可能设计上有问题,比如设计出的接口是不是好用。
  3. 在ThoughtWorks,我们有一个观点:叫做任务不是步骤。当我们按步骤来考虑问题的时候,对怎么算做完这个问题的答案往往是模糊的(往往只落在行为上,而不是结果上)。当我们只考虑函数实现的时候,也会有类似的问题,因为一个数据类型包含的情况太多了,想到某种类型的数据我们就会停止思考当前问题转而去想其他问题,于是我们很容易漏掉一些情况,以测试角度看待任务会让我们更容易看清楚我们的工作是不是真的做完了。

最棒的是,我们可以照着任务列表写出测试

1
2
3
4
5
6
7
8
9
10
it("test case 1 for function b", () =>{
let paramX = // TypeX的测试数据

let actualBValue = b(paramX); // 调用b函数的实际返回值

let exceptedBValue = // 调用b函数的期望的返回值
expect(actualBValue).is(expectedBValue); //断言
})

// 以此类推...

所以,我们是在以测试驱动的方式做任务划分,你可以叫它测试驱动的任务切分。

如果要映射到测试,我们的任务列表就缺了一些东西,那就是所谓的测试用例。因为同一个函数可能有不同的测试用例,所以加上用例我们的任务列表应该长成这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#1 场景一
输入:
paramX: TypaX
输出:
bValue: TypeA
测试用例:
用例1:
inputValue1
----
outputValue1
用例2:
inputValue2
----
outputValue2


// 以此类推...

我们看到因为输入输出的顺序已经定好了,为了书写的速度,我们就省略了名字。如果你觉得不舒服也可以写上名字。如果你觉得这样太浪费时间,你可以把每组用例用一句话描述。毕竟一切为了实用嘛。

是不是感觉后面讲的很抽象啊,不知道具体怎么做啊?不要着急,后面有例子。

练习

  1. 请把上一篇的任务列表,画成图
  2. 试着按照测试用例的方式画,没有思路可以阅读完第五篇和第六篇再来画。

题外话

题外话-1

视角的切换对于软件开发来说是至关重要的一个能力。为了说明这个的价值,我们甚至发明了一个概念——数字化人才

然而大多数人都不具备,所以对于大多数人来说,具备这个能力,最起码的好处就是变成了一种稀缺性人才,对于获得高薪是有帮助的。

经过前两篇的内容学习,我相信大家已经差不多学会了这个思考模型。本篇的重点是用它来解决更复杂的问题。当我们开始解决一些稍微复杂点的问题的时候,我们会发现差不多的态度是不行的,我们需要严谨的态度进行缜密的思考才能真正发挥出这个思考模型的力量。

慢慢你会发现,这个思考模型本身不会让你思维缜密,而思维缜密了才能用好这个思考模型。它带来的最大的好处,是让你自己开始看到自己思维的欠缺,从而不再是个思维世界的盲人。

穷尽

那么,我们开始做点复杂的题目。也没复杂多少,我们扩展一下上一篇的题目,算学生的成绩单:

打印所有人的成绩单。已知输入的格式是

[“学号”, “学号”, “学号”]
比如:
[“TWA20160101”, “TWA20160102”, “TWA20160103”]

我们有一个全局函数可以给我们提供所有的学生的成绩:

1
2
3
4
5
6
7
8
9
10
11
12
function loadAllScore(){
return [{
name: "张三",
id: "TWA20160101",
chinese: "95",
english: "80",
math: "95",
programming: "80"
},
....
]
}

要求打印出成绩单类似于:

1
2
3
4
5
6
7
8
成绩单
姓名|数学|语文|英语|编程|平均分|总分
========================
张三|75|95|80|80|82.5|330
李四|85|80|70|90|81.25|325
========================
全班总分平均数:xxx
全班总分中位数:xxx

仅对这个题目进行划分,我们一定觉得很简单,对吧。就仿照着之前的写呗:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#1 生成成绩单view model
输入:
studentIds: [String]
输出:
scoreSheet: {
studentScores:[{
name: String,
chinese: String,
english: String,
math: String,
programming: String,
average: String,
summary: String
}]
summary: {
totalAverage: Number,
totalMidden: Number
}
}
#2 打印成绩单
输入:
scoreSheet
输出:
result: String

我相信很多人都是这么想的,然而不幸的告诉这么想的同学,这种写法是错误的。原因其实也很简单,请问,#1输出里的chinese等成绩是怎么得到的呢?没有来源吧?你说你调了loadAllScore函数?那为什么不写在输入里呢?

所以说,我们遗漏了一些输入。回到我们开始的标题上:穷尽。

可能有很多人听说过一个分析问题的基本原则:完全穷尽,各自独立。很多人听到这个时候,会很困惑:穷尽什么?独立什么?经过我们这些练习,我相信在编程领域,你们这个困惑会小很多。

所谓各自独立,说的就是在我们划分任务的过程中,每一个任务都对应一个代码块或一个函数,这些代码块和函数,是互相不包含的(不是不依赖,这是翻译的问题,各自独立的独立指的是Exclusive不是Independent)。

所谓的完全穷尽,说的是我们需要穷尽这个代码块或函数里所有的输入和输出。不能遗漏任何一个输入,任何一个输出。我们的每一项,它的属性,也不能有遗漏,我不能说分析studentScores只想到部分属性,比如说:

1
2
3
4
5
studentScores:[{
chinese: String,
english: String,
summary: String
}]

这样是不行的。如果我们不严于律己穷尽所有的数据项,我们就会在写代码的时候遇到各种问题。能否穷尽与否,也看出来你思维的缜密与否。

是不是开始感觉到麻烦了,刚开始做的时候是有些慢的,但我们坚持穷尽这个好习惯,就会渐渐的感受到自己能力的成长。如果你穷尽了所有的输入输出,那么各种可能遇到的问题就像是如来佛手里的孙猴子,无论有什么变数也尽在你掌握之中了。

那么如果我们穷尽输入输出的话,我们这个题目真正的任务应该怎么分解呢?可以写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#1 获得学生成绩
输入:
studentIds: [String]
studentInfo: [{
id: String,
name: String,
chinese: String,
english: String,
math: String,
programming: String,
}]
输出:
studentScores:[{
name: String
chinese: String,
english: String,
math: String,
programming: String,
average: String,
summary: String
}]

#2 计算总计
输入:
studentScores
输出:
summary: {
totalAverage: Number,
totalMidden: Number
}

#3 打印成绩单
输入:
studentScores
summary
输出:
result: String

可能你会奇怪,为什么会分成3步呢?或者说,我们该怎么判断分几步呢?这其实没有一个标准答案,我建议初学者尽量步子小一点,多分几步,经验丰富的人就可以步子大一点。不过有一个反直觉的经验可以分享给大家,你步子大了,开发速度不见得快,因为人是会犯错的。

这个题是写出来了,但是我们还是不太清楚怎么穷尽对吧。说是穷尽输入输出,到底输入输出都有多少大类呢?这个也是可以穷尽的。

输入总共有下面几大类:

  1. 参数
  2. 读取全局变量
  3. 调用全局函数后得到的返回值
  4. 读取局部作用域变量(比如this)
  5. 调用局部函数后得到的返回值
  6. hard code的数据

输出总共有下面几大类:

  1. 返回值
  2. 修改全局变量
  3. 调用全局函数时传的参数
  4. 修改局部作用域变量(比如this)
  5. 调用局部函数时传的参数

来去

听起来不错,不过从哪来,到哪去,还是要写清楚的,我们的studentInfo从哪里来?我们的result又到哪里去了?加上这个来去,我们最终的版本是长这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#1 获得学生成绩
输入:
studentIds: [String]
studentInfo: [{
id: String,
name: String,
chinese: String,
english: String,
math: String,
programming: String,
}]: loadAllScore()
输出:
studentScores:[{
name: String,
chinese: String,
english: String,
math: String,
programming: String,
average: String,
summary: String
}]

#2 计算总计
输入:
studentScores
输出:
summary: {
totalAverage: Number,
totalMidden: Number
}

#3 打印成绩单
输入:
studentScores
summary
输出:
result: String: console.log()

练习

引入加分策略
少数民族 +10分
体育特长 +20分
艺术特长 +15分

题外话

好像输入和输出的可能性太多了,这很容易让人乱啊。
是这样的,所以为什么到了函数式编程我们需要强调纯函数,只有一个输入来源和一个输出去处,一般来讲就是输入只有参数,输出只有返回值。所以你看,如果你把一个领域都穷尽掉,你也会自己发明出那些靠谱的实践,换句话说,如果你能在用这套思维模型的过程中逐渐发现跟各种最佳实践都很容易配合使用,那就对了。(其实也不是什么配合使用,因为它跟代码是等价的,所以容易写代码的方法就容易用它,这是当然的。)

与很多人想的不同,同理心是一件强大的控制工具,你有多理解你要控制的对象,你就多容易控制它。所以我们学习像机器一样思考并不是为了变成机器,而是为了更好的控制机器为我们服务。从这一篇开始,我们进一步讲解这种思考模型的细节。

随着对细节的深入,我们会发现,我们会越来越看不清楚机器的思维和人的思维的疆界。仿佛这之间的区别是很小的,有时候你不知道是因为人这么思考所以这么设计的机器,还是机器这么思考所以我们需要迁就它。

同时,可能你会感到一些些麻烦和思维上的束缚,但总之请记住,一切不得不接受的束缚,都是为了更大的自由。Rails之父DHH曾经说过:

约束是你的朋友。

这很反直觉,然而随着你编程的经验增多,你会体会到这句话是多么的正确。

类型

编程语言分为静态类型(比如Java,C#,Scala)和动态类型(比如JavaScript,Ruby,Python)两种。但不管是静态类型还是动态类型,你不可否认类型总是在那里的,哪怕你不显式的表达出来,也绕不开它的存在,你思考的时候必须思考它。

上一篇里,我们有一个例子用到了类型:

1
2
3
4
5
6
加法函数
输入:
a: Number
b: Number
输出:
result: Number

在这一个例子里,我们用到了一种类型:Number。表明我们的参数只能是数字。假如在强类型的语言里,如果我们传进去的参数类型不是数字,就会报错。假如在弱类型的语言里,我们传进去的参数类型不是数字倒是不会报错,但是如果我们使用>-之类的运算符对其进行运算的时候,可能就会报错。所以强弱类型的语言无非是出错的时机不同,为了保证我们程序的正确性,不管你用哪种,都摆脱不了要思考数据的类型问题。

仅仅是一个个体数据的时候,考虑类型还是容易考虑的。当我们有一组数据的时候呢?比如上一篇里我们还有这样一个例子:

1
2
3
4
5
6
#1 选出集合中的偶数
输入:
inputArray: [Number]
输出:
evenArray: [Number]
...

在这个例子里,inputArray的类型到底是什么呢?是数字呢?还是数组呢?还是只有数字的数组呢?泛泛的说,inputArray的类型是数组,而精确地讲,inputArray的类型是只有数字的数组。为什么需要精确的指定,只有数字呢?这其实是从实用角度出发,如果我们一个数组里面放各式各样类型的数据,我们就会越来越想不清楚而头脑陷入混乱,在写代码的时候也是每当用到一种类型的数据就要加一个if,就容易漏掉一些情况而引入bug。所以为了我们自己好,还是一个数组里只放一种数据类型吧。

在一些强类型语言(比如Java)里,为了保证这一点,他们还使用了所谓的泛型。有一种常用的情况就是跟我们这个情况类似,大概长成这样:List<Integer>。我们就会了解,当涉及到集合的时候,为了防止滥用,人们不惜动用语法。

以上就是我们上一篇用到的类型相关的知识,在这一篇里,我们会走的更深入。

结构

在上一篇里,我们的类型还是一个黑盒,在这一篇里,我们要引入结构。每一种语言都有一些基本类型,但是用来描述这个世界的数据,基本结构是不够的。所以每个语言又都提供了一种方法,让我们可以以基本数据类型为“积木”,通过结构化的方式来自定义类型。在大多数语言里,我们使用类来做这件事,但是不管你用什么,最后表达的信息都是类似的。

举例来说,当我们想要表达一个数字的类型时,我们用Number,我们想表达一个字符串的类型时,我们用String。那当我们想表达一个人,这个人有一个姓名属性是String类型的,有一个年龄属性是Number类型的时候。我们需要创造出一个这样的类型,我们就可以这样写:

1
2
3
4
{
name: String,
age: Number
}

在这里,我就用到了一种结构来表达了人的类型,大家可以看到我用的方式是借鉴了JSON的表达方式,只是把本应该写值的部分换成了类型。这是一种便于在纸上书写的表达方式,我们在日常工作中进行沟通的时候,随手拿过一张纸,很容易写清楚我想表达的结构。

而最重要的是,它可以简单的表达出很深层的结构,比如我们想要定义一个人这个人有一个孩子,孩子当然也是一个人,也有相同的属性,那我们可以这么写:

1
2
3
4
5
6
7
8
{
name: String,
age: Number,
child: {
name: String,
age: Number
}
}

当然我们可能会说了,万一不止一个孩子呢?好,这就引入了自定义类型中的数组怎么表达的问题。其实可能大家已经发现了,自定义类型它也是一种类型,它存在的位置就是原来写String,Number的地方,那么问题的答案就很符合直觉了:

1
2
3
4
5
6
7
8
{
name: String,
age: Number,
children: [{
name: String,
age: Number
}]
}

看起来跟[Number]很像吧?那么对应一个具体的Javascript对象,代码是怎么写的呢?

1
2
3
4
5
6
7
8
9
10
11
let zhangsan = {
name: "张三",
age: 38,
children: [{
name: "李四",
age: 10
},{
name: "王五",
age: 5
}]
}

前文的结构,描述的就是这个代码里的对象的结构。前文的结构是为了限制我们的数据定义,然而有了这个限制,我们就可以思考更复杂的问题。

练习

废话不多说,我们还是老规矩,做个练习看看怎么用。

打印某人的成绩单。已知输入的格式是

[“姓名”, “语文成绩”, “英语成绩”, “数学成绩”, “编程成绩”]
比如: [“张三”, “95”, “80”, “75”, “80”]

要求打印出成绩单:

1
2
3
4
5
6
7
8
9
10
11
成绩单
姓名:张三
===============
数学:75
语文:95
英语:80
编程:80
***************
平均分:82.5
总分:330
===============

如果对这个题目进行任务划分,我会分成两步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#1 转换为ViewModel
输入:
scoresArray: [String]
输出:
scoreSheet: {
name: String,
chinese: String,
english: String,
math: String,
programming: String,
average: String,
summary: String
}

#2 打印成绩单
输入:
scoreSheet
输出:
result: String

这两步我干了什么呢:

第一步,我把字符串数组转换为了一个具体的对象类型,它有六个属性,分别对应着我们要打印的成绩单上的六个数据:语文,英语,数学,编程,平均分和总分。
第二步,我把对象转成了一个字符串,打印了出来。

你可能会很奇怪了,打印没有返回值啊?为什么写在输出里?好问题,且听我下回分解~

照例,题外话环节。

题外话1: 为什么我们不直接用数组就拼这个字符串呢?一方面,我是在演示结构的使用,另一方面,这是一个小技巧,我们最后打印的这一部分字符串的部分,被称之为表现层(就是表现在人眼前的那一部分),在表现层你最后使用的数据的结构,尽量跟你的表现层的结构一致,这样出bug的几率最低。

题外话2: 这是在js里,如果我们采用强类型语言,这些结构该怎么定义呢?其实很简单嘛,比如那个人的类型结构:

1
2
3
4
5
Person {
name: String,
age: Number,
children: [Person]
}

done。

题外话3: 那我们用类图可不可以呢?当然也可以,只是要画框子很容易在写属性的时候发现画小了,而且当你画更深层关系的时候就比较麻烦。

//TODO: 这里需要写几个对照例子

课后作业

打印所有人的成绩单。已知输入的格式是

[
[“姓名”, “语文成绩”, “英语成绩”, “数学成绩”, “编程成绩”],

]

比如:


[“张三”, “95”, “80”, “75”, “80”],
[“李四”, “80”, “70”, “85”, “90”]
]

要求打印出成绩单:

1
2
3
4
5
6
7
8
成绩单
姓名|数学|语文|英语|编程|平均分|总分
========================
张三|75|95|80|80|82.5|330
李四|85|80|70|90|81.25|325
========================
全班总平均分:xxx
全班总分中位数:xxx

请列出本题的任务

好多年前,徐昊说过的一句话给了我很大启发,他说“纸上的不是架构,每个人脑子里的才是”。这句话告诉我们,即便是天天工作在一个团队里的人,对架构的认识也可能是不一样的。每个人嘴上说的是类似的话,但心里想象的画面仍然是不一样的。在我多年的工作中,我越来越认可这句话所揭示出的道理。软件开发是一个团队协作的工作,混乱的理解会造成架构的无意义腐化、技术债的无意识积累、维护成本的无价值上升。

最近听到一句话,“那些精妙的方案之所以落不了地,是因为设计上没有兼容人类的愚蠢”。话糙理不糙,虽然最终人们选择的方案的思想都是十年前甚至几十年前就已经存在的,然而在技术升级到足以“兼容”人类的愚蠢之前,这些思想只能在学术的故纸堆里睡大觉。然而话糙确实也会有一个问题,将一个思想性问题转化成了一个情绪性问题。人们容易把一些糟心的事情归因到人类的愚蠢,宣泄了自己的不满情绪后就停止思考了。作为知识工作者,我们的思维不能停步,我们需要思考到底人类有哪些愚蠢,分别用什么方法去避免或者“兼容”。

可以肯定彼此明明对自己开发的软件有不一样的认识却天天在一起讨论问题并试图把软件做好是一件愚蠢的事情,为了兼容这种愚蠢我们需要采用可视化的方法。

为什么需要可视化呢,主要还是语言不靠谱。人类语言真的是太随意了,只要你想,你可以说你见过一个方形的圆,并为此与别人辩论。但是无论如何你也画不出来一个方形的圆,这就是我们需要可视化的原因。

今天我们介绍一个工具,叫做C4 model,这是我近几年见到的一个比较难得跟我的认知有大量共鸣的工具。

该工具的作者在多年的咨询中经常发现,很多个人画出来的架构图都是不一样的,但也不是说他们谁画错了,而是每个人的抽象层次不一样。抽象层次这种东西,说起来好像存在,但真要说清楚还挺难,于是作者类比地图,提出了缩放的概念。(两年前我在教学生的时候提过同样的概念)如下图:

国家级

省级

市级

道路级

上面的四张地图就是想说明,当我们看待真实世界的“架构图”的时候,也是要不停的缩放,在每一个层次刻意忽略一些细节才能表达好当前抽象层次的信息。所以他类比着把架构也提出了四个抽象层次:

image.png

从上到下依次是系统System、容器Container、组件Component和代码Code。(咦,那为什么叫C4呢,因为系统的图叫System Context,系统上下文图。为了凑四个C也是够拼的。)

基于这四个层次的抽象,C4模型由4张核心图和3张附属图组成,分别用于描述不同的场景,下面我们一一介绍一下。

四张核心图

系统上下文图

image.png
如上图所示,这个图表达的是你所开发的系统和它的用户以及它所依赖的系统之间的关系。从这个图上我们已经看出来C4图形的几个关键图形:

image.png

C4说穿了就是几个要素:关系——带箭头的线、元素——方块和角色、关系描述——线上的文字、元素的描述——方块和角色里的文字、元素的标记——方块和角色的颜色、虚线框(在C4里面虚线框的表达力被极大的限制了,我觉得可以给虚线框更大的扩展空间)

通过在不同的抽象层次上,重新定义方块和虚线框的含义来限制我们只能在一个抽象层次上表达,从而避免在表达的时候产生抽象层次混乱的问题。

那么在系统上下文图里,方块指代的是软件系统,蓝色的表示我们聚焦的系统,也就是我开发的系统(也可能是我分析的系统,取决于我是谁),灰色表示我们直接依赖的系统,虚线框表示的是企业的边界。通过这些图形化的元素表达我们可以看出来各个系统彼此之间的关系。

容器图

image.png

当我们放大一个系统,就会看到容器,如上图所示,C4模型认为系统是由容器组成的。容器是我个人认为,C4模型最大的创举,尤其是在这个单体架构快速崩塌的时代。所谓容器,既不是Docker的容器,也不是JavaEE里的容器,是借用了进程模型,每一个容器都是指有自己独立的进程空间的一种存在。不管是在服务器上的单独进程空间,还是在浏览器里的单独进程空间,只要是单独的进程空间就可以看作一个容器。当然如果你容器化做得好,Docker的Container和这个Container可以一一对应。有了这个概念的存在我们就可以更清晰的去表达我们的架构,而不是总是用一些模糊的东西。

组件图

image.png

当我们放大一个容器,我们就会看到组件,如上图所示。组件在这里面很好的把接口和它的实现类打包成一个概念来表达关系。我个人觉得有时候一些存在于代码中,但又不是接口的某些东西,比如Service、Controller、Repository之类也可以用组件图来表达,如果你学了一些没有明确抽象层次的架构知识或者一些单体时代的遗留经验的时候,你可以画出来一些组件图,来印证自己的理解,如下图,我画的我对DDD战术设计里面的一些概念的理解:

image.png

比起模糊的,堆砌在一起文字,这种表达要清晰的很多,哪怕我的理解是不对的,也容易指出和讨论。

代码图

代码图没什么可说的,就是UML里的类图之类很细节的图。一般是不画的,都是代码生成出来。除非非常重要的且还没有写出代码的组件才画代码图。

以上就是C4的核心图,我们可以看到四种不同的抽象层次的定义会让我们更容易固定住我们讨论的层次,这点上我觉得C4是非常有价值的。

三张扩展图

架构设计设计要考虑的维度很多,仅四张核心图是不够的,所以作者又提供了三张扩展图,可以让我们关注更多的维度。

系统景观图

image.png

看得出来,系统景观图是比上下文图更丰富的系统级别的表达。不像上下文图只关注聚焦系统和它的直接关系,连一些间接相关的系统都会标示出来,那些系统的用户以及用户之间的关系也会标示出来,只是内部的用户会用灰色标记。

这个图有什么用呢?在我们分析一个企业的时候,我们需要一个工具帮助我们把一家公司给挖个底掉,做到完全穷尽,才能看到企业的全景图从而理解局部的正确定位以做好局部设计为全局优化服务。之前我试过以四色建模的红卡、事件风暴的事件两种工具教人去掌握这种能力,当学员是程序员的时候都无法快速掌握这种顺藤摸瓜的分析技巧,毕竟跟程序员的思维还是有些差异的。但是我用了系统景观图之后,学员毫不费力的就掌握了这种分析能力。所以我后来都是用这个图来教程序员探索企业的数字化全景图,效果极好,推荐给大家。

动态图

image.png

动态图不同于其他图都是表达静态关系的,它是用来表达动态关系的,也就是不同的元素之间是如何调用来完成一个业务的。所以动态图不仅仅在一个层面上可以工作,它在系统级、容器级和组件级都可以画,表达的目标是不一样的。

我之前曾经写过名为《像机器一样思考》的一系列文章,里面我也发明了类似的图,不同于他的关系线上标注的是调用的方法、函数,我更关注的是数据,使用效果也很好。

什么时候是用动态图呢?举个小例子,我之前做一个内部的小系统,只有一个有经验的工程师带着10多个毕业生,我便要求他们在开始工作之前都画出动态图来,交由有经验的工程师去评估他们的思路是否正确,如果有问题,就在开始之前就扼杀掉了烂设计。不管是毕业生还是初级工程师,改代码的能力都比写代码的能力要差很多,所以将烂设计扼杀在实现之前还是有帮助的。

部署图

image.png

前面的几张图都是站在开发的角度思考,但是一个没有充分思考过部署的架构很容易变成一个运维的灾难。所以作者提供了一个部署图。考虑到DevOps运动如火如荼,这个图可以变成很好的Dev和Ops之间沟通的桥梁。我们在实操中发现,Dev和Ops关注点的不同、语言的不一致,在这张图上表现得非常清楚。

图上最大的的实线框不同于虚线框,它表达的是数据中心,当你开始考虑异地载备的时候它就有了意义。数据的同步、实例的数量都会影响你部署图的内容。部署图基本都是容器级的,它会很好的表达出来容器到底部署了几个实例,部署在什么样的操作系统上,一个节点部署了几个容器之类,我们在实际使用中,发现需要考虑的信息太多,自己就抽象出了类似于亚马逊上实例规格的Small、Large之类的术语来表达机器配置,增进了开发和运维之间的交流准确性。

为什么C4值得推荐

够直观,对于程序员来说容易理解,容易使用。
我们在开头的时候说过,只有每个人脑子里的才是架构图,那么如果我们使用一个工具,工具本身的理解就很容易出错,那就陷入理解的死循环了。经过很多不同工具的教授尝试,C4模型是最容易理解,最容易使用的工具。可能它的概念是复用了程序员已有的一些认知模型,教给程序员后,都可以迅速的使用起来,并问出一些高质量的问题。

总结

在思维的世界里,我们都是盲人,很多东西我们以为自己知道,实际上画出来之后,才发现自己很多东西没想到,很多想的是乱的,同时别人也才可以给我们反馈。

有了上面的这个工具,我们就可以开始可视化的架构设计之路了,但路上还有一个心魔需要战胜。我们的文化里出错是一件很丢人的事情,所以我们喜欢用一些模糊的描述避免被别人挑战,而可视化是让我们精确的描述出我们的理解,来欢迎别人的挑战。这一个坎不太容易跨过去,但是一旦跨过去了,大家形成了正向的互动之后,我们的进步速度会变得很快,从而把封闭的人远远的甩在后面,获得组织级的成长推力。我自己就在跟别人的交流之后获得了更深入的洞见,本文已经分享了一些,还有一些内容下一篇文章再跟大家分享。

图片来源:
https://c4model.com/

【旧文搬家】
本文的起源是因为思考一个问题,什么样的人适合做程序员。
我曾经苦恼得思考着这个问题,直到我在SICP上看到了答案。说的白话一点就是,能像机器一样思考的人就适合做程序员。
那么计算机这台机器是怎么思考的呢?这里是我的答案:

我们所有的计算机,都是下面这个模型,江湖人称“冯・诺伊曼体系”
冯・诺伊曼体系

从这个模型上我们看到了什么吗?嗯,可能太多噪音了,看的不够清楚,我给你们再抽象一层:
输入、处理、输出

现在清楚了吧,计算机在中间,两边是输出输出。所有的问题都从输入和输出的角度去思考,这就是计算机这台机器的思考方式。也就是说你能做到这样思考,你就会像机器一样思考了。

很简单吧,但是新的问题又产生了,处理自然要处理输入产生输出了。输入,输出是些什么呢?这就要在微观层面理解机器是怎么思考的,这一部分叫,机器在加工什么?

SICP中又说了,非形式的讲,我们只在处理两种东西,数据和过程,他们还不是严格区分的。
先不管不严格区分那半句,我们回看我们的模型,中间处理的部分其实就是过程,输入和输出其实就是数据。(在冯诺伊曼体系里,数据和过程被称之为数据和指令)那说到数据,我们有一门学科叫做数据结构,它很好的表达了什么是数据。我们还有面向对象,类型系统之类的知识,他们都会帮助我们很好的定义数据。

各位看官估计心里犯嘀咕了,扯了这么多,还是无法想象怎么就算像机器一样思考了啊?不急,下面我们拿几个例子来学习一下。

我们来写一个加法函数,接受两个参数作为加数和被加数,返回一个和,这个太简单了,几乎任何一个程序员都可以在几秒钟内写完。拆成机器的思维是什么样呢?

1
2
3
4
5
6
加法函数
输入:
a
b
输出:
result

大概就长这样,输入是a和b,输出是一个结果,我们起名叫result。它到底表达了个啥样的代码呢?大概长这样:(本文所有的代码都会采用javascript描述,但是不代表本文内容只适合描述前端开发)

1
2
3
function add(a, b) {
return a + b;
}

咦?result哪去了?在你调用的地方可能会有一行代码 var result = add(1,2);
这个表达方式不仅仅可以用来描述函数定义,用来描述表达式也是可以的。比如,如果我们把前面的输入输出思维描述改为加法表达式。你会发现其实这段描述“编译”成代码大概长这样:

1
var result = a + b;

所以不仅仅可以用来描述函数定义,还可以描述代码块。

但是到这里就结束了吗?感觉好像对数据的表述不够细致啊。确实,我们忘了加类型了。不加类型这描述简直万灵丹么,反正俩参数一个返回值的都能用,这不行,我们还得把类型加上看着才清楚点。加上类型就变成了这样:

1
2
3
4
5
6
加法函数
输入:
a: Number
b: Number
输出:
result: Number

这看着就好多了,是不是比刚才理解上文所讲的像机器一样思考了呢?好吧,你可能会说,这玩意有啥用啊,我有分析的这个空,我代码都写完了啊。不急,我们接着往后看。

刚才那个题目有点太简单了,我们做一个稍微复杂的。比如下面这个:写一个函数,可以选出一个由数字组成的集合当中所有的偶数的最大值。

这回一步做出来可能就有点难了,没关系,我们可以成两步:

  1. 选出集合中的偶数
  2. 选出偶数中的最大值
    这两步呢,按照我们之前的格式写一下,大概是下面这个样子:
1
2
3
4
5
6
7
8
9
10
11
#1 选出集合中的偶数
输入:
inputArray
输出:
evenArray

#2 选出偶数中的最大值
输入:
evenArray
输出:
max:Number

哎呀,突然发觉不知道该怎么描述集合呢。Javascript里就用数组就好了,但是还是不知道怎么描述数组啊。这个其实很简单,这不是一个由数字组成的数组吗?我们只要写成[Number]就可以了。因为我们的一个好习惯是一个集合里不要放两种类型的元素,所以就这么写就好了。那么加上去的话,大概就长这样:

1
2
3
4
5
6
7
8
9
10
11
#1 选出集合中的偶数
输入:
inputArray: [Number]
输出:
evenArray: [Number]

#2 选出偶数中的最大值
输入:
evenArray
输出:
max: Number

咦,第二步的evenArray没有写类型。嗯,因为evenArray是第一步的输出,我就把它省了,相信大家也能看明白。
耐着性子看到这里,你估计已经发现了,我还是没有回答你这个思维方式有什么用这个问题。我很想忽悠着你再做一道题,不过估计你坚持不完就会转身离开了。那我们就这两道题试着讲一讲。

第一道题,我们只是展示了这个思维,第二道题,我们才开始使用到它的威力。尽管这道题也不复杂,但是思考过程还是展示了:

  1. 分解问题
  2. 找到子问题之间的关联(通过输入输出关联起来)
  3. 找到问题的边界,明确假设与结果

上述三点看着简单,却是思维清楚与否的关键。我们管这个能力叫Analytical Thinking。

思维清楚带来的收益是什么?这些步骤可以直接转化为工作的任务列表,而且可测试。这样分解出来的任务列表,完成效率是极高的。我们曾经做过实验,按这个思路分解过的人,比没有分解过的人,完成效率可以高3倍以上,而且前者只学了一周的编程。
一个完全不会写程序的人,只要学会了这个思维,就可以开始编程之旅了,而且威力非常巨大。

听起来好简单啊,有那么神吗?不是编程的人都应该会吗?然而并不是的,很多人思考编程这件事情是靠感觉的。
我前几天面了40多个外包公司外派来的人,只有5个人,可以按照输入输出来对问题进行分解。所以我觉得我还是有必要写点东西来讲讲这个。

除了对初学者有益之外,对Team Lead也是有益的。当你觉得你遇到的人没sense的时候,你可以试着让他们这么表达一下程序。一般就会发现一些问题。

题外话

题外话-1:
我们像机器一样思考,不就都变成机器了吗?嗯,其实不是的。所谓我们像机器一样思考,那机器这种思考方式又是从哪里来的呢?机器的思考模型是一个叫“图灵机”的计算模型,而图灵机则是图灵祖师爷模拟人思考而发明出来的。所以,其实不存在什么像机器一样思考,只不过是学会一种人类的思考方式而已。
考虑到图灵只能以自己和自己周围的天才科学家的作为人类的具体实例来抽象图灵机,所以我们学习的其实不是什么机器的思考方式,而是天才的思考方式,这篇文章其实应该叫《像天才一样思考》。

题外话-2:
这个不就是面向过程编程吗?如果的思考仅仅停在这里,那就是面向过程编程了。如果我们接着想下去,当数据复杂到一定程度的时候,我们会自然的引入封装,于是面向对象诞生了。回到数据与过程不严格区分那半句,当我们试图模糊数据和过程的界限,将过程像数据一样纳入输入输出的范畴,我们就走上了函数式编程之路。

题外话-3:
有人觉得练习不够吗?请留言,如果感兴趣的人多,我就加紧写更多练习的解析。

相关文章:

像机器一样思考(二)—— 数据的细节
像机器一样思考(三)—— 穷尽就是力量
像机器一样思考(四)—— 一图抵千言
像机器一样思考(五)—— 第一个应用
像机器一样思考(六) —— 脑中的重构
像机器一样思考(七) —— 跨应用思考