温故知新,跟着七牛云CEO许式伟学架构(01),大厦地基之冯·诺依曼计算机体系
怎样成长为优秀的软件架构师?
让我们来想象一下,如果把信息世界看成一座大厦,把程序员看成这个世界的建筑师,那么,现在的你在负责什么样的工作呢?
当把程序员类比成建筑师时,按照能力水平来分,我觉得大体可以分为三个层次:
- 搬砖师
- 工程师
- 架构师
软件搬砖师之名对应到建筑行业的建筑工人,他们的编程能力和业务基本上停留在堆叠代码,按照要求去实现功能需求的层面。只要能让程序跑起来,能正确地实现业务逻辑,就可以称为“会编程”的人。有时候,我们也会看见程序员自称为“码农”、“搬砖的”,虽然二者的工种不同,但从基础工作的相似度来说,确实有可类比的成分。
代码质量的评判可以有这样一些基本维度:
- 可阅读性(方便代码流转)
- 可扩展性 / 可维护性(方便修改功能,添加新功能)
- 可测试性(质量管理)、可复用性(简化后续功能开发的难度)
一些有追求的程序员会关注代码的质量。这一类致力于不断提升软件代码的工程质量的程序员,我们可以称他们为软件工程师。工程师不会简单把写代码看作一门工作,把任务交代过去就完事。他们会有“洁癖”,代码在他们眼里是一种艺术,是自己生命的一部分。他们会把写出来的代码改了又改,直到让自己满意为止。阅读和维护软件工程师写的代码会有一种赏心悦目的感觉。
大部分商业软件都是一项极其复杂的工程,它们远比很多传统的建筑工程复杂得多,无论是涉及的人力、时间还是业务的变数都要多很多。人力上,大部分大型的软件系统都有几千甚至几万人的规模,而这几千几万人中,却没有两个人的工作是重复的,他们都是在从事着前所未有的创造性工作。时间上,只要软件还在服务客户中,程序员们的创造过程便不会停止,软件系统仍然持续迭代更新,以便形成更好的市场竞争力。这些都与传统建筑工程的模式大相径庭。一幢建筑自它完成之后,所有的变化便主要集中在一些软装的细节上,很少会再发生剧烈的变动,更不会持续地发生变动。
但软件却不是这样,它从诞生之初到其生命周期结束,自始至终都在迭代变化,从未停止。
所以,光靠把控软件工程师的水平,依赖他们自觉保障的工程质量,是远远不够的。软件工程是一项非常复杂的系统工程,它需要依赖一个能够掌控整个工程全局的团队,来规划和引导整个系统的演变过程。这个团队就是架构师团队。
从根本目标来说,软件架构师要对软件工程的执行结果负责,这包括:
- 按时按质进行软件的迭代和发布
- 敏捷地响应需求变更
- 防范软件质量风险(避免发生软件质量事故)
- 降低迭代维护成本
那怎么才能成长为优秀的软件架构师?软件架构师和软件工程师最根本的差别又在哪里?
关键在于四个字:掌控全局。
掌控全局,就是对系统的全貌了然于胸。从传统的建筑工程来说,建筑架构师并不单单要会画建筑图纸,而是要对地基构建、土质、材料、建筑工艺等等所有有可能影响建筑质量的因素都要了然于胸。
掌控全局,并不是无所不能,不是成为全栈。怎么做到掌控全局?核心在于对知识脉络的体系化梳理。这是架构能力构建和全面提升的关键。这种方法不单单是在软件工程中适用。
与架构相关的图书,大概有如下这些分类:
-
架构思维类。这类图书通常从一些著名的架构理论讲起,比如开闭原则、单一职责原则、依赖倒置原则、接口分离原则,等等。这种图书的问题在于过度理论化。计算机科学归根到底属于工程技术类,实践第一。
-
设计模式类。这一类图书则一下子进入架构的局部细节,每个模式的来龙去脉并不容易理解。就算理解了某个具体的模式,但是也很难真正做到活学活用,不知道还是不知道。
-
分布式系统架构设计类。这类图书通常从服务端的通用问题如一致性、高可用、高并发挑战等话题讲起,讲大型业务系统面临的挑战。这些知识是非常有价值的,但无法延伸到通用业务架构,对大部分企业的架构实践并不具备真正的指导意义。
-
重构类。这类图书主要讲怎么把坏代码一步步改进到好代码。我认为这是最实用的一类。但在没有优秀架构师主导的情况下,大部分公司的代码不可避免地越变越坏,直到不堪重负最后不得不重写。实际上,一个模块最初的地基是最重要的,基本决定了这座大厦能够撑多久,而重构更多侧重于大厦建成之后,在服务于人的前提下怎么去修修补补,延长生命。
那么,应该怎样成长为优秀的软件架构师?一靠匠心,二靠悟心
应用程序的基础架构
无论是什么样的智能电子设备,手机也好,汽车也罢,它们都可以称为“电脑”。所有的电脑都可以统一看作由“中央处理器 + 存储 + 一系列的输入输出设备”构成。
中央处理器,也就是我们平常说的CPU,负责按指令执行命令;存储负责保存数据,包括我们要执行的命令,也是以数据形式保存在存储中的。每次在打开电脑的电源后,中央处理器都会从存储的某个固定位置处开始读入数据(也就是指令),并且按指令执行命令,执行完一条指令就会继续执行下一条指令。电脑就这样开始工作了。
为何电脑能够完成这么多复杂而多样化的工作?
可编程性。大体来说,中央处理器(CPU)的指令分为如下这几类:
- 计算类,也就是支持我们大家都熟知的各类数学运算,如加减乘除、sin/cos等等。
- I/O类,(从存储读写数据)从输入输出设备读数据、写数据。
- 指令跳转类,在满足特定条件下跳转到新的当前程序执行位置。
虽然,CPU指令是一个很有限的指令集,但是CPU执行的指令序列(或者叫“程序”)并不是固定的,而是依赖保存在存储中的数据—— 由软件工程师(或者叫“程序员”)编写的软件来决定。指令序列的可能性是无穷的,这也就意味着电脑能够做的事情的可能性也是无穷的。
开放设计的外部设备支持。虽然我们电脑可以连接非常非常多种类的外部设备,比如键盘、打印机、屏幕、汽车马达等等,但CPU并不理解这些设备具体有什么样的能力,它只和这些设备交换数据。它能够做的是从某个编号的设备(通常这个设备编号被称为“端口”)读入一段数据,或者向设备的端口写入一段数据。例如,当你在键盘上按下了A的时候,CPU可以从键盘连接的端口读到一段数据,通过这段数据来表达你按了“A”,可能CPU会向打印机连接的端口发送一段数据,来驱动打印机打印特定的文本;还有可能CPU会向汽车马达所在的端口发送数据,来驱动马达转动,从而让汽车按照预期来行驶。值得注意的是,CPU知道的是如何和这些设备交换数据,但是并不理解数据代表什么含义。 这些外部设备的厂商在提供设备硬件的同时,往往也需要提供和硬件匹配的软件,来完成和CPU的协作,让软件工程师可以轻松使用这些设备。
电脑的CPU是一个非常简洁的模型,它只读入和写出数据,对数据进行计算。这也是为什么我们往往把电脑也叫作“计算机”,这是因为 CPU这个计算机的大脑的确只会做“计算”。这个基础的设计体系,我们很多人都知道,这就是冯·诺依曼计算机体系。1945年6 月,冯·诺依曼以“关于EDVAC的报告第一份草案(First Draft of a Report on the EDVAC)”为题起草的长达101页的总结报告,定义了“冯·诺依曼体系结构”,他现在也被称为计算机之父。
《EDVAC报告书的第一份草案》,一般简称为第一份草案,是由约翰·冯·诺伊曼所撰写共101页未完成文件,于1945年6月30日,由ENIAC机密计划的安全官赫尔曼·戈德斯坦所发表。内容包含了第一次公诸于世的使用存储程序概念电脑的逻辑设计描述,即后来成为众所熟知且颇具争议的冯·诺伊曼结构。
有了这个基础的计算机体系之后,我们就可以编写软件了。当然我们遇到的第一个问题是直接用机器指令编写软件太累,而且这些机器指令像天书一样没人看得懂,没法维护。所以,编程语言 + 编译器就出现了。编译器负责把我们人类容易理解的语言,转换为机器可以理解的机器指令,这样一来就大大解放了编写软件的门槛。
在编写软件不是问题时,我们遇到的第二个问题,就是多个软件在同一个电脑上怎么共处。多个软件大家往同一个存储地址写数据冲突怎么办?一起往打印机去发送打印指令怎么办?有的软件可能偷偷搞破坏怎么办?于是,操作系统就出现了。它首先要解决的是软件治理的问题。它要建立安全保护机制,确保你的电脑免受恶意软件侵害。同时,它也要建立软件之间的协作秩序,让大家按照期望的方式进行协作。比如存储你写到这里,那么我就要写到别处;使用打印机要排队,你打完了,我才能接着去打印。操作系统其次解决的是基础编程接口问题。这些编程接口一方面简化了软件开发,另一方面提供了多软件共存(多任务)的环境,实现了软件治理。例如,对于屏幕设备,操作系统需要提供多任务窗口系统,以避免屏幕被多个软件画得乱七八糟;对于键盘输入设备,操作系统引入焦点窗口,以确定键盘输入的事件被正确发送到正确的软件程序。
今天的我们开发软件的时候,已经处于一些基础的架构设计之中。像冯·诺依曼计算机体系,像操作系统和编程语言,这些都是我们开发一个应用程序所依赖的基础架构。基础架构解决的是与业务无关的一些通用性的问题,这些问题往往无论你具体要做什么样的应用都需要面对。而且,基础架构通常以独立的软件存在,所以也称为基础软件。 例如,我们熟知的Linux、Nginx、MySQL、PHP等这些软件都属于基础软件,这些基础软件极大地降低了应用开发的难度。在今天软件服务化的大趋势下,很多基础软件最终以互联网服务的方式提供,这就是所谓的“云计算”。
完整的程序架构是怎样的?
在越强大的基础架构支撑下,应用程序开发需要关注的问题就越收敛,我们的开发效率就越高。在我们只需要关注应用程序本身的业务问题如何构建时,我们说自己是在设计应用程序的业务架构(或者叫“应用架构”)。
业务架构虽然会因为应用的领域不同而有很大的差异,但不同业务架构之间,仍然会有许多共通的东西。它们不只遵循相同的架构原则,还可以遵循相同的设计范式。一些设计范式被人们以应用程序框架的方式固化下来。例如,在用户交互领域有著名的MVC框架(如 JavaScript语言的Angular,PHP语言的Zend,Python语言的Django),在游戏开发领域有各种游戏引擎(如JavaScript语言的 Phaser,C#语言的Unity3D),等等。
对于一个服务端应用程序来说,其完整的架构体系大体如下:
对于客户端应用程序来说,和服务端的情况会有非常大的差别。客户端首先面临的是多样性的挑战。单就操作系统来说,PC就有 Windows、Mac、Linux等数十种,手机也有Android、iOS,Windows Mobile等等。而设备种类而言就更多了,不只有笔记本、平板电脑,还有手机、手表、汽车,未来只会更加多样化。
第一个想消除客户端的多样性,并且跨平台提供统一编程接口的,是浏览器。可能在很多人看来,浏览器主要改变的是软件分发的方式,让软件可以即取即用,无需安装。但从技术角度来说,底层操作系统对软件的支持同样可以做到即取即用。这方面苹果在iOS上已经在尝试,大家可能已经留意到,如果你一个软件很久没有用,iPhone就会把这个软件从本地清理出去,而在你下一次使用它时又自动安装回来。假如软件包足够小,那么这种行为和Web应用就毫无区别。不同之处只在于Web应用基于的指令不是机器码,而是更高阶的JavaScript 脚本。JavaScript因为指令更高阶,所以程序的尺寸比机器码会有优势。但另一方面来说JavaScript是文本指令,表达效率又要比机器码低。
但这一点也在发生变化,近年来WebAssembly技术开始蓬勃发展,JavaScript作为浏览器的机器码的地位会被逐步改变,我们前端开发会面临更多的可能性。浏览器的地位非常特殊,我们可以看作操作系统之上的操作系统。一旦某种浏览器流行起来,开发人员都在浏览器上做应用,那么必然会导致底层操作系统管道化,这是操作系统厂商所不愿意看到的。而如果浏览器用户量比较少,那么通过它能够触达的用户量就太少,消除不同底层操作系统差异的价值就不存在,开发人员也就不乐意在上面开发应用。我们知道,PC的浏览器之战打到今天,基本上就剩下Chrome、Internet Explorer、Safari、Firefox等。
有趣的是,移动浏览器的战场似乎是从中国开始打起的,这就是微信引发的小程序之战,它本质上是一场浏览器的战争。浏览器是一个基础软件,它能够解决多大的问题,依赖于它的市场占有率。但是基于同样的浏览器技术核心也可以构建出跨平台的应用框架。我们看到 React Native就是沿着这个思路走的。当然这不是唯一的一条路,还有人会基于类似QT这样的传统跨平台方案。
整体来说,对于一个客户端应用程序来说,其完整的架构体系大体如下:
解剖架构的关键点是什么?
当我们设计或分析一个零部件时,我们会关心哪些问题。
-
需求。这个零部件的作用是什么?它能被用来做哪些事情?(某种意义上来说更重要的是)它不会被用来做哪些事情?你可能会说,呀,这个问题很简单,既然我设计了这个零部件,自然知道它是用来干嘛的。但实质上这里真正艰难的是“为什么”:为何这个零件被设计成用来干这些事情的,而不是多干一点事情,或者为什么不是少干某些事情?
-
规格。这个零部件接口是什么样的?它如何与其他零件连接在一起的?规格是零部件的连接需求的抽象。符合规格的零部件可以有非常多种可能的实现方案,但是,一旦规格中某个条件不能满足了,它就无法正常完成与其他零件的连接,以达到预期的需求目标。规格的约束条件会非常多样化,可能是外观(比如形状和颜色),可能是交互方式(比如用键盘、鼠标,或者语音和触摸屏),也可能是质量(比如硬度、耐热性等等)。
为“解决一切的问题”而生
冯·诺依曼体系结构不但是应用程序这座大厦的地基,同时也是整个信息科技的地基。
当我们去审视整个信息科技时,仅把它形容为一座大厦显得如此不贴切,甚至你也不能用“一个城市”去形容它,事实上,它更像是一个无中生有的全新世界:在其中,有个体、有族群、有生态,还有喜怒哀乐。
冯·诺依曼体系结构的迷人之处在于,从需求来说,它想解决一切问题。解决一切可以用“计算”来解决的问题。
甚至有科幻小说家设想(例如在Google的“AlphaGo”大热后,霍炬和西乔创作的漫画“BetaCat”),计算机演进出超过人类的智能是生物进化的一个自然演进路径,它将取代人类成为新的食物链顶端,并最终基于其悠久的生命力,去完成人类有限生命无法实现的星际航行之路。
冯·诺依曼体系的规格
为了实现“解决一切可以用‘计算’来解决的问题”这个目标,冯·诺依曼引入了三类基础零部件:
- 中央处理器
- 存储
- 输入输出设备
存储。它负责存放计算涉及的相关数据,作为计算的输入参数和输出结果。我们日常见到的存储设备非常的多样化。比如:中央处理器自己内置的寄存器、内存、传统机械硬盘、USB固态硬盘、光盘等等。从中央处理器的角度,存储可简单分为两类:一类是内置支持的存储,通过常规的处理器指令可直接访问,比如寄存器、内存、计算机主板的ROM。一类是外置存储,它们属于输入输出设备。中央处理器本身并不能直接读写其中的数据。冯·诺依曼体系中涉及的“存储”,指的是中央处理器内置支持的存储。
输入输出设备。它是计算机开放性的体现,大大拓展了计算机的能力。每个设备通过一个端口与中央处理器连接。通过这个端口地址,中央处理器可以和设备进行数据交换。数据交换涉及的数据格式由设备定义,中央处理器并不理解。但这并不影响设备的接入。设备数据交换的发起方(设备使用方)通常理解并可以解释所接收的数据含义。为了方便使用,设备厂商或操作系统厂商通常会提供设备相关的驱动程序,把设备数据交换的细节隐藏起来,设备的使用方只需要调用相关的接口函数就可以操作设备。
中央处理器。它负责程序(指令序列)的执行。指令序列在哪里?也存放在存储里面。计算机加电启动后,中央处理器从一个固定的存储地址开始执行。中央处理器支持的指令大体如下:计算类,也就是支持我们大家都熟知的各类数学运算,如加减乘除、sin/cos等等;I/O类,从存储读写数据,从输入输出设备读数据、写数据;指令跳转类,在满足特定条件下跳转到新的当前程序执行位置、调用自定义的函数。
需求是怎么被满足的?
假如今天让我们从零开始设计一个叫电脑的东西,我们的目标是“解决一切可以用‘计算’来解决的问题”。对于这么含糊的需求,如果你是“电脑”这个产品的主架构师,你会如何应对?
一方面,需求的变化点在于,要解决的问题是五花八门包罗万象的。如何以某种稳定但可扩展的架构来支持这样的变化?而另一方面,需求的稳定之处在于,电脑的核心能力是固定的,怎么表达电脑的核心能力?
电脑的核心能力是“计算”。
什么是计算?
计算就是对一个数据(输入)进行变换,变为另一个数据(输出)。在数学中我们把它叫“函数”。如下:
y = F(x)
这里x、y是数据。它们可能只是一个简单的数值,也可能是文本、图片、视频,各种我们对现实问题进行参数化建模后的测量值,当然也可能是多个输入数据。但无论它的逻辑含义为何,物理上都可以以一段连续的字节内容来表达。
那么x、y物理上在哪里?“存储” 这个概念自然就产生了:存储,就是存放计算所要操作的数据的所在。
一个具体的计算(也就是F函数)怎么表达?
F对于电脑的架构师来说是未知的。那么,怎么设计一种系统架构让用户可以表达任意复杂的计算(函数)?逻辑上来看,无论多复杂的自定义函数,都可以通过下面这些元素的组合来定义:
- 内置函数,比如整数或小数运算(加减乘除、sin/cos 等)
- 循环和条件分支
- 子函数(也是自定义函数)
这样一来,对于任意的一个具体的计算(自定义函数)来说,都可以用一组指令序列来表达。
函数F物理上在哪里?以指令序列形式存放在存储里面。所以,存储不只存放计算所要操作的数据,也存放“计算”本身。 只是,存储里面存放的“计算”只是数据,需要有人理解并执行这些数据背后的计算行为,才变成真正意义的“计算”。这个执行者,就是中央处理器(CPU)。它支持很多计算指令,包括执行内置函数、循环和条件分支、执行子函数等。
只是如果电脑只有“中央处理器 + 存储”,那它就如同一个人只有头脑而没有四肢五官,尽管很可能很聪明,但是这种聪明无法展现出来,因为它没法和现实世界发生交互。交互,抽象来看就是输入和输出。
从输入需求来说,可能采集静态图像、声音、视频;也可能采集结构化数据,如GPS位置、脉搏、心电图、温度、湿度等;还可能是用户控制指令如键盘按键、鼠标、触摸屏动作等。从输出需求来说,可能是向屏幕输出信息;也可能是播放声音;还可能是执行某项动作,如交通灯开关、汽车马达转动、打印机打印等。但不管是什么样交互用途的器官(设备),我们要做的只是定义好统一的数据交换协议。这个数据交换机制,和网络上两台电脑通过互联网,需要通过某种数据交换协议进行通讯,需求上没有实质性的差别。 也就是说,除了纯正的“计算”能力外,中央处理器还要有“数据交换”能力(或者叫IO能力)。
最终,电脑可以被看做由 “中央处理器 + 存储 + 一系列的输入输出设备” 构成。
输入输出设备从根本上解决的问题是什么?
是电脑无限可能的扩展能力。最重要的一点,输入输出设备和电脑是完全异构的。输入输出设备对电脑来说就只是实现了某项能力的黑盒子。这个黑盒子内部如何?没有规定。它可以只是一个原始的数字化的元器件,也可以是另一台冯·诺依曼架构的电脑,还可以是完全不同架构的电脑,比如GPU电脑、量子计算机。你可以发现,引入了输入输出设备的电脑,不再只能做狭义上的“计算”(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的“计算”问题变得无所不包。
架构思维上我们学习到什么?
需求分析。从需求分析角度来说,关键要抓住需求的稳定点和变化点。需求的稳定点,往往是系统的核心价值点;而需求的变化点,则往往需要相应去做开放性设计。对于“电脑”这个产品而言,需求的稳定点是电脑的“计算”能力。需求的变化点,一是用户“计算”需求的多样性,二是用户交互方式的多样性。电脑的“计算”能力,最终体现为中央处理器的指令集,这是需求相对稳定的部分。用户“计算”需求的多样性,最终是通过在存储中的指令序列实现。计算机加电启动后,中央处理器并不是按自己固有的“计算”过程进行,而是从一个固定的存储地址加载指令序列执行。
通常,这个固定的存储地址指向计算机主板的ROM上的一段启动程序(BIOS)。这段启动程序通常包含以下这些内容:
- 存储设备的驱动程序,用以识别常规的外置存储设备,比如硬盘、光驱、U盘。
- 基础外部设备的驱动程序,比如键盘、鼠标、显示器(显卡)。
- 设备和启动配置的基础管理能力。
- 在外置存储上执行程序的能力(中央处理器只支持在内存上执行程序,当然它也为在外置存储执行程序提供了一些支持,比如内存页缺失的中断处理)。
- 将执行权转移到外置存储(第一次安装操作系统的时候可能是光驱甚至是网络存储,平常通常是硬盘)上的操作系统启动程序。这样,操作系统就开始干活了。
这样一来,“计算”需求的多样性只需要通过调整计算机主板上的BIOS程序,乃至外置存储中的操作系统启动程序就可以实现,而不必去修改中央处理器本身。用户交互方式的多样性,则通过定义外部设备与中央处理器的数据交换协议实现。
当我们把所有的变化点从电脑的最核心部件中央处理器剥离后,中央处理器的需求变得极其稳定,可独立作为产品进行其核心价值的演进。