许式伟谈架构1
开篇词 | 怎样成长为优秀的软件架构师?
让我们来想象一下,如果把信息世界看成一座大厦,把程序员看成这个世界的建筑师,那么,现在的你在负责什么样的工作呢?当我们把程序员类比成建筑师时,按照能力水平来分,我觉得大体可以分为三个层次:搬砖师、工程师、架构师。
软件搬砖师之名对应到建筑行业的建筑工人,他们的编程能力和业务基本上停留在堆叠代码,按照要求去实现功能需求的层面。只要能让程序跑起来,能正确地实现业务逻辑,就可以称为“会编程”的人。有时候,我们也会看见程序员自称为“码农”“搬砖的”,虽然二者的工种不同,但从基础工作的相似度来说,确实有可类比的成分。
很多外行的人都会觉得程序员是一个很神秘的职业,但实际上程序员的基础门槛并不算高。我自己从 2016 年 2 月开始至今,一直在教几位 8~12 岁的小朋友学习编程。这个实践经验告诉我:小学生完全有能力学编程。而且,并不是只有部分小学生可以,而是任何一位小学生都可以学会。
然而,只让代码跑起来是不够的。这个世界是不断变化的,作为程序员,我们更多的时间是用来维护代码:增加新的需求,对已有的功能进行调整,修改之前代码遗留下来的问题,优化性能等等。这是因为一个软件诞生之后,后续就是需要花费大量的代价去维护它,演进它。一个人是完全维护不过来的,需要更多的人,很多的团队一起协作。如果面临了员工离职、岗位调整等情况,还会导致软件代码在不同人之间流转。
所以,一些有追求的程序员会关注代码的质量。代码质量的评判可以有这样一些基本维度:可阅读性(方便代码流转)、可扩展性 / 可维护性(方便修改功能,添加新功能)、可测试性(质量管理)、可复用性(简化后续功能开发的难度)。
这一类致力于不断提升软件代码的工程质量的程序员,我们可以称他们为软件工程师。工程师不会简单把写代码看作一门工作,把任务交代过去就完事。他们会有“洁癖”,代码在他们眼里是一种艺术,是自己生命的一部分。他们会把写出来的代码改了又改,直到让自己满意为止。阅读和维护软件工程师写的代码会有一种赏心悦目的感觉。
但是,大部分商业软件都是一项极其复杂的工程,它们远比很多传统的建筑工程复杂得多,无论是涉及的人力、时间还是业务的变数都要多很多。人力上,大部分大型的软件系统都有几千甚至几万人的规模,而这几千几万人中,却没有两个人的工作是重复的,他们都是在从事着前所未有的创造性工作。
时间上,只要软件还在服务客户中,程序员们的创造过程便不会停止,软件系统仍然持续迭代更新,以便形成更好的市场竞争力。这些都与传统建筑工程的模式大相径庭。一幢建筑自它完成之后,所有的变化便主要集中在一些软装的细节上,很少会再发生剧烈的变动,更不会持续地发生变动。但软件却不是这样,它从诞生之初到其生命周期结束,自始至终都在迭代变化,从未停止。
所以,光靠把控软件工程师的水平,依赖他们自觉保障的工程质量,是远远不够的。软件工程是一项非常复杂的系统工程,它需要依赖一个能够掌控整个工程全局的团队,来规划和引导整个系统的演变过程。这个团队就是架构师团队。
软件架构师的职责,并不单单是我们通常理解的,对软件系统进行边界划分和模块规格的定义。从根本目标来说,软件架构师要对软件工程的执行结果负责,这包括:按时按质进行软件的迭代和发布、敏捷地响应需求变更、防范软件质量风险(避免发生软件质量事故)、降低迭代维护成本。
那怎么才能成长为优秀的软件架构师?软件架构师和软件工程师最根本的差别又在哪里?我认为关键在于四个字:掌控全局。
掌控全局,就是对系统的全貌了然于胸。从传统的建筑工程来说,建筑架构师并不单单要会画建筑图纸,而是要对地基构建、土质、材料、建筑工艺等等所有有可能影响建筑质量的因素都要了然于胸。
掌控全局,并不是无所不能,不是成为全栈。怎么做到掌控全局?核心在于对知识脉络的体系化梳理。这是架构能力构建和全面提升的关键。这种方法不单单是在软件工程中适用。
比如学数学,我个人非常喜欢做的一件事情是自己去推导书上所有的公式。每一个公式我都亲自推导而来。这样做的核心意义在于,我在尝试从 0 开始,去构建整个精彩纷呈的数学世界,整个数学发展史在自己的笔下重新演绎了一遍,来龙去脉清清楚楚。有时候你甚至会推导出还没有学到的公式,但是在后面学到了。这种体验非常有趣而又让人满足。
是的,掌控全局的前提是:在自己心中去重新构建出整个世界。在这个过程中,你不需要一上来沉浸在某个技术的实现细节(除非它影响了你对这个世界构建过程的理解),但是你知道整个世界的脉络,知道整个世界的骨架。这个时候,你对这个世界的感觉是完全不同的,因为,你已经成为了这个世界的构建者。
而架构的本质,不也正是构建和创造么?
作为一个软件行业的从业人员,我们可能接触各种各样的技术书籍。有讲编程语言的、讲数据结构与算法的、讲操作系统的、讲编译原理的、讲架构设计的,还有领域技术类的(比如数据库、存储、大数据、人工智能之类)。大部分类别的技术书,多多少少都能够找到几本经典著作。但是,架构设计很可能是个例外,当我想推荐一本经典的架构设计书时,我并不能非常快速地想到应该推荐哪本。
从个人经验来说,我接触过的与架构相关的图书,大概有如下这些分类。
- 架构思维类。这类图书通常从一些著名的架构理论讲起,比如开闭原则、单一职责原则、依赖倒置原则、接口分离原则,等等。这种图书的问题在于过度理论化。计算机科学归根到底属于工程技术类,实践第一。
- 设计模式类。这一类图书则一下子进入架构的局部细节,每个模式的来龙去脉并不容易理解。就算理解了某个具体的模式,但是也很难真正做到活学活用,不知道还是不知道。
- 分布式系统架构设计类。这类图书通常从服务端的通用问题如一致性、高可用、高并发挑战等话题讲起,讲大型业务系统面临的挑战。这些知识是非常有价值的,但无法延伸到通用业务架构,对大部分企业的架构实践并不具备真正的指导意义。
- 重构类。这类图书主要讲怎么把坏代码一步步改进到好代码。我认为这是最实用的一类。但在没有优秀架构师主导的情况下,大部分公司的代码不可避免地越变越坏,直到不堪重负最后不得不重写。实际上,一个模块最初的地基是最重要的,基本决定了这座大厦能够撑多久,而重构更多侧重于大厦建成之后,在服务于人的前提下怎么去修修补补,延长生命。
这些架构类的图书并没有达到我个人的期望。因为它们都没有揭开架构设计的全貌。我自己在职业生涯中前后大概做过十几次的架构类演讲,这也是我最为重视、重复次数最多的一类演讲。但同样地,这样零星的演讲对于传递架构设计思想来说,仍然远远不够。所以一直以来,我就心存着这样一个念头:“要写一本不一样的架构类图书”。这个念想,也正是今天这个专栏的由来。
这个专栏的内容组织算是我的一次尝试。它和今天你看得到的大部分架构书并不太一样。我基本上围绕着两个脉络主线来展开内容:
- 如何从零开始一步步构建出整个信息世界;
- 在整个信息世界的构建过程中,都用了哪些重要的架构思维范式,以及这些范式如何去运用于你平常的工程实践中。
这两大脉络相辅相成。首先,我们通过还原信息世界的构建过程,剥离出了整个信息世界的核心骨架,这也是最真实、最宏大的架构实践案例。其次,我们结合这个宏大的架构实践来谈架构思维,避免因对架构思维的阐述过于理论化而让人难以理解。
我想,每个程序员都有一颗成为架构师的心。所以,从内容设计来说,我希望这是一个门槛最低的架构设计专栏,也希望它可以帮助到想成为架构师的初学者,达成自己的目标。
在行文上,我会尽量避免深奥的术语,尽可能以通俗易懂的文字,来描述信息世界构建者们的所思所想。如果你在阅读的过程中遇到了理解上的障碍,非常欢迎你来给我留言,我将尽可能地根据你的反馈,做出必要的调整。
如果你已经成为了架构师,我也希望可以为你规避一些错误的经验。在过去的工作经历里,我看到不少架构师都会倾向于把架构看作一项纯技术性的行为。他们的工作流程是这样的:产品经理根据用户的需求做出产品设计,然后架构师再依据产品设计给出实现,也就是软件的架构设计方案。
在我看来,这其实是个误解。架构关乎的是整个复杂的软件工程,它关乎实现它的人,它又因团队的能力而异。
同时,架构也关乎用户需求,作为架构师,我们不只是要知道当前的用户需求是什么,我们还要预测需求未来可能的变化,预判什么会发生,而什么一定不会发生。预测什么不会发生最为重要,只有做到这一点,才能真正防止架构的过度设计,把简单的事情复杂化。
谈了这么多,那么,应该怎样成长为优秀的软件架构师?我想,一靠匠心,二靠悟心。架构设计并无标准答案,但我仍然希望把我这些年的所思所想分享给你,更希望这些内容能给你一些启发。
一、基础平台篇
01 | 架构设计的宏观视角
在信息科技高度发展的今天,我们每个人随时随地都可以接触到由程序驱动的智能电子设备,包括手机(如 iPhone、oppo 拍照手机)、平板电脑(如 iPad)、手表(如 iWatch、小天才智能手表)、音箱(如天猫精灵)、汽车(如特斯拉)等等。这些东西背后是怎么工作的?单就其中的软件系统而言,这些小小的设备上往往运行着成千上万个软件模块,这些模块是如何如此精密地一起协作的?
对此,我过去接触过很多的软件开发工程师,或者架构师,很多人对这些原理也是一知半解,虽然“知其然”,但却“不知其所以然”。甚至有些朋友可能觉得,学这些有什么用处呢,在我看来,这部分内容恰恰是我们成为架构师很重要的一门基础课。
为什么需要建立宏观视角?
如同造房子有建筑工人(负责搬砖)和建筑师(负责架构设计)一样,软件系统的开发过程同样需要有程序员(负责搬“砖”)和架构师(负责架构设计)。作为架构师,我们需要的第一个能力是宏观的全局掌控能力。
如果把应用程序比作一座大厦,那么我们作为大厦的架构师,需要把大厦的结构搭建好,让程序员可以把砖填充进去,我们都知道,一个大厦的结构建得是否稳固,与地基密不可分。所以,我们首先就需要从大厦的地基开始,熟悉这座大厦。毕竟,你对所依赖的基础架构了解得越全面,做业务架构设计就会越发从容。
介绍基础架构的知识点并不是让你真的去实现它们。但你仍然需要懂得它们的核心思想是什么,知道有哪些信息是你必须深刻理解的,以便可以更好地驾驭它们。我们的整个专栏内容也会从基础架构开始讲起,最后逐步过渡到业务架构,到最终完成一个完整应用程序的设计过程。
那么,在今天的开篇第一篇,我们需要站在宏观视角,从基础架构开始,逐渐来解剖一个应用程序的整体构成,我希望,通过今天的文章,可以让你对于一个程序的全貌,形成完整的认识。我们从头开始。
应用程序的基础架构
我们想学习一个程序的基础架构,其实就是弄清楚电脑的工作原理,以及程序的运行原理。无论是什么样的智能电子设备,手机也好,汽车也罢,它们都可以称为“电脑”。所有的电脑都可以统一看作由“中央处理器 + 存储 + 一系列的输入输出设备”构成。
中央处理器,也就是我们平常说的 CPU,负责按指令执行命令;存储负责保存数据,包括我们要执行的命令,也是以数据形式保存在存储中的。每次在打开电脑的电源后,中央处理器都会从存储的某个固定位置处开始读入数据(也就是指令),并且按指令执行命令,执行完一条指令就会继续执行下一条指令。电脑就这样开始工作了。你可能会说,就这么简单?是的,就是这么简单。
那这么简单的话,为何电脑能够完成这么多复杂而多样化的工作?
这整个过程,在我看来主要依赖两点。
**第一是可编程性。**大体来说,中央处理器(CPU)的指令分为如下这几类。
- 计算类,也就是支持我们大家都熟知的各类数学运算,如加减乘除、sin/cos 等等。
- I/O 类,(从存储读写数据)从输入输出设备读数据、写数据。
- 指令跳转类,在满足特定条件下跳转到新的当前程序执行位置。
虽然, CPU 指令是一个很有限的指令集,但是 CPU 执行的指令序列(或者叫“程序”)并不是固定的,而是依赖保存在存储中的数据—— 由软件工程师(或者叫“程序员”)编写的软件来决定。指令序列的可能性是无穷的,这也就意味着电脑能够做的事情的可能性也是无穷的。
**第二是开放设计的外部设备支持。**虽然我们电脑可以连接非常非常多种类的外部设备,比如键盘、打印机、屏幕、汽车马达等等,但 CPU 并不理解这些设备具体有什么样的能力,它只和这些设备交换数据。它能够做的是从某个编号的设备(通常这个设备编号被称为“端口”)读入一段数据,或者向设备的端口写入一段数据。
例如,当你在键盘上按下了 A 的时候,CPU 可以从键盘连接的端口读到一段数据,通过这段数据来表达你按了“A”,可能 CPU 会向打印机连接的端口发送一段数据,来驱动打印机打印特定的文本;还有可能 CPU 会向汽车马达所在的端口发送数据,来驱动马达转动,从而让汽车按照预期来行驶。
值得注意的是,CPU 知道的是如何和这些设备交换数据,但是并不理解数据代表什么含义。这些外部设备的厂商在提供设备硬件的同时,往往也需要提供和硬件匹配的软件,来完成和 CPU 的协作,让软件工程师可以轻松使用这些设备。
从上面可以看出,电脑的 CPU 是一个非常简洁的模型,它只读入和写出数据,对数据进行计算。这也是为什么我们往往把电脑也叫作“计算机”,这是因为 CPU 这个计算机的大脑的确只会做“计算”。
这个基础的设计体系,我们很多人都知道,这就是冯·诺依曼计算机体系。1945 年 6 月,冯·诺依曼以“关于 EDVAC 的报告草案”为题起草的长达 101 页的总结报告,定义了“冯·诺依曼体系结构”,他现在也被称为计算机之父。我想看到这里,你应该不难理解他的伟大之处了吧?有了这个基础的计算机体系之后,我们就可以编写软件了。
当然我们遇到的第一个问题是直接用机器指令编写软件太累,而且这些机器指令像天书一样没人看得懂,没法维护。所以,编程语言 + 编译器就出现了。编译器负责把我们人类容易理解的语言,转换为机器可以理解的机器指令,这样一来就大大解放了编写软件的门槛。
在编写软件不是问题时,我们遇到的第二个问题,就是多个软件在同一个电脑上怎么共处。多个软件大家往同一个存储地址写数据冲突怎么办?一起往打印机去发送打印指令怎么办?有的软件可能偷偷搞破坏怎么办?
于是,操作系统就出现了。**它首先要解决的是软件治理的问题。**它要建立安全保护机制,确保你的电脑免受恶意软件侵害。同时,它也要建立软件之间的协作秩序,让大家按照期望的方式进行协作。比如存储你写到这里,那么我就要写到别处;使用打印机要排队,你打完了,我才能接着去打印。
操作系统其次解决的是基础编程接口问题。这些编程接口一方面简化了软件开发,另一方面提供了多软件共存(多任务)的环境,实现了软件治理。
例如,对于屏幕设备,操作系统需要提供多任务窗口系统,以避免屏幕被多个软件画得乱七八糟;对于键盘输入设备,操作系统引入焦点窗口,以确定键盘输入的事件被正确发送到正确的软件程序。
你会发现,今天的我们开发软件的时候,已经处于一些基础的架构设计之中。像冯·诺依曼计算机体系,像操作系统和编程语言,这些都是我们开发一个应用程序所依赖的基础架构。
基础架构解决的是与业务无关的一些通用性的问题,这些问题往往无论你具体要做什么样的应用都需要面对。而且,基础架构通常以独立的软件存在,所以也称为基础软件。
例如,我们熟知的 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 这样的传统跨平台方案。
整体来说,对于一个客户端应用程序来说,其完整的架构体系大体如下:
对于架构师来说,不仅仅只是想清楚业务应该怎么去做好分解,整个应用从底到最顶层的上层建筑,每一层都需要进行各种决策。先做 iOS 版本,还是先做小程序?是选择 Java 还是 Go 语言?这些都是架构的一部分。
结语
今天,我们从“计算机是如何工作”开始,一起登高鸟瞰,总览了程序完整的架构体系。可能有人看到今天的内容心里会有些担心:“原来架构师要学这么多东西,看来我离成为架构师好远。”
好消息是:我们就是来打消这个担心的。如果我们把写代码的能力比作武功招式,那么架构能力就好比内功。内功修炼好了,武功招式的运用才能得心应手。
而架构能力的提升,本质上是对你的知识脉络(全身经络)的反复梳理与融会贯通的过程。具备架构思维并不难,而且极有必要。不管今天的你是不是团队里的一位架构师,对任何一位程序员来说,具备架构思维将会成为让你脱颖而出的关键。
这就像你没有从事云计算行业,但是你仍然需要理解云计算的本质,需要驾驭云计算。你也不必去做出一个浏览器,但是你需要理解它们的思考方式,因为你在深度依赖于它们。接下来我们将进一步展开来谈这个程序架构体系里面的每一个环节。
02 | 大厦基石:无生有,有生万物
解剖架构的关键点是什么?
在解剖之前,我想和你先谈谈“解剖学”:我们应该如何去分析架构设计中涉及的每一个零部件。换一句话说,当我们设计或分析一个零部件时,我们会关心哪些问题。
**第一个问题,是需求。**这个零部件的作用是什么?它能被用来做哪些事情?(某种意义上来说更重要的是)它不会被用来做哪些事情?
你可能会说,呀,这个问题很简单,既然我设计了这个零部件,自然知道它是用来干嘛的。但实质上这里真正艰难的是“为什么”:为何这个零件被设计成用来干这些事情的,而不是多干一点事情,或者为什么不是少干某些事情?
**第二个问题,是规格。**这个零部件接口是什么样的?它如何与其他零件连接在一起的?规格是零部件的连接需求的抽象。符合规格的零部件可以有非常多种可能的实现方案,但是,一旦规格中某个条件不能满足了,它就无法正常完成与其他零件的连接,以达到预期的需求目标。
规格的约束条件会非常多样化,可能是外观(比如形状和颜色),可能是交互方式(比如用键盘、鼠标,或者语音和触摸屏),也可能是质量(比如硬度、耐热性等等)。那么,冯·诺依曼体系结构的需求和规格又是什么样的呢?
为“解决一切的问题”而生
冯·诺依曼体系结构不但是应用程序这座大厦的地基,同时也是整个信息科技的地基。当我们去审视整个信息科技时,仅把它形容为一座大厦显得如此不贴切,甚至你也不能用“一个城市”去形容它,事实上,它更像是一个无中生有的全新世界:在其中,有个体、有族群、有生态,还有喜怒哀乐。
冯·诺依曼体系结构的迷人之处在于,从需求来说,它想解决一切问题。解决一切可以用“计算”来解决的问题。“计算”的边界在哪里?今天我们还没有人能够真正说得清。计算能不能解决“智能”的问题?通过计算能力,计算机是否终有一天可以获得和人类一样的智能?
今天人工智能热潮的兴起,证明对于这个问题我们很乐观:计算终将解决智能的问题。尽管我们不能确定什么时候能够达到,但是让人欣慰的是,我们一直在进步 —— 如果人类智能无法完成进一步的进化,那么我们就一直一直在前进,最终无限逼近甚至超越人类智能。
......
17 | 架构:需求分析 (上)
前面我们多次提到过,架构的第一步是需求分析。那么,为什么要做需求分析?如何做好需求分析?今天让我们一起聊一聊需求分析这个话题。
关于需求分析的那些事
为何要做需求分析?
**首先,**当然是因为我们做软件本身就是为了满足用户需求。那么,用户需求到底为何,我们需要清楚定义。
其次,需求边界定义的需要。用户需求理清楚了,不代表产品理清楚了。用户需求的满足一定会有行业分工,我们做什么,合作伙伴做什么,需要厘清大家的边界。
最后,架构设计的需要。架构需要切分子系统,需要我们梳理并对用户需求进行归纳与抽象。架构还需要防止过度设计,把简单的事情复杂化。
但什么是过度设计?不会发生的事情你考虑了并且为它做足了准备,就是过度设计。所以判断是不是过度设计是很困难的,需要对需求未来演化有很强的判断力。从这几个维度来看,需求分析过程必然会涉及以下这些内容。
我们要面向的核心用户人群是谁?
用户原始需求是什么?最核心问题是哪几个?
已经有哪些玩家在里面?上下游有哪些类型的公司,在我们之前,用户是怎么解决他们的问题的?我们的替换方案又是怎样的?
进而,我们的产品创造的价值点是什么?用户最关注的核心指标是什么?
用户需求潜在的变化在哪些地方?区分出需求的变化点和稳定点。
当然,我并不是说,我们应该在需求分析的文档中完整地回答这些问题。需求分析文档目的并不是回答这些问题。但是在我们梳理需求的过程中,我们无法回避对这些问题的思考。
可能有人会认为,这些问题是 CEO 或产品经理这样的角色需要回答的,而不是架构师需要回答的。某种意义上来说这句话没错。回答这些问题的首要责任方是 CEO 或产品经理。他们有责任让团队中的每一个人理解我们的产品逻辑。
但是,如果架构师只是被动地接受产品需求,以按图索骥的方式来做架构设计,是不足以成为顶级架构师的。原因在于两点。
一方面,用户需求的深层理解是很难传递的。你看到的产品文档,是产品经理和用户沟通交流后的二次理解,是需求的提炼和二次加工,很难原汁原味地传递用户的述求。所以架构师自己亲身近距离地接触用户,和用户沟通,去体会用户的述求是非常有必要的。况且,大部分人并不会那么仔仔细细地阅读别人写的文档。当然这不完全是看文档的人单方面的原因,如果团队文档平均质量不高的话,也会影响到阅读者的心态。
另一方面,产品设计过程需要架构师的深度参与,而不是单向的信息传递。产品经理非常需要来自架构师的建设性意见。为什么我会有这样的看法呢?这涉及我对产品的理解。产品本身是运用先进的技术来满足用户需求过程的产物。用户需求的变化是缓慢的,真正改变的是需求的满足方式。而需求满足方式的变化,深层次来说,其背后往往由技术迭代所驱动。
从这个角度来说,产品是桥,它一端连接了用户需求,一端连接了先进的技术。产品经理是需要有技术高度的,他不一定要深刻了解技术的原理,但是一定要深刻理解新技术的边界。某项技术能够做什么,不能做到什么,顶级产品经理甚至比实现这项技术的开发人员还要清楚。认为产品经理不需要理解技术,这可能是我们普遍存在的社会现象,但很可能并不符合这个岗位的内在诉求。
回到架构师这个角色。
我经常说一个观点,产品经理和架构师其实是一体两面。两者都需要关心用户需求与产品定义。只不过产品经理更多从用户需求出发,而架构师更多从技术实现出发,两者是在产品这座桥的两端相向而行,最终必然殊途同归。
这也是我为什么说架构师需要深度参与产品设计的原因。产品经理很可能会缺乏他应该有的技术广度,这就需要架构师去补位。产品定义过程需要反复推敲琢磨,并最终成型。
需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。
需求分析的重要性怎么形容都不过分。准确的需求分析是做出良好架构设计的基础。
前面我也说过,我个人认为架构师在整个架构设计的过程中,至少应该花费三分之一的精力在需求分析上。这也是为什么很多非常优秀的架构师换到一个新领域后,一上来并不能保证一定能够设计出良好的架构,而是往往需要经过几次迭代才趋于稳定。
原因就在于:领域的需求理解是需要一个过程的,对客户需求的理解不可能一蹴而就。
怎么做需求分析
那么怎么才能做好需求分析?
首先,心态第一,心里得装着用户。除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。
其次,对问题刨根究底,找到根源需求。有很多用户反馈需求的时候,往往已经带着他自己给出的解决方案。这种需求反馈已经属于二次加工的需求,而非原始需求。这个时候我们要多问多推敲,把它还原到不带任何技术实现假设的根源需求。
\t如上图所示,根源需求可能会有非常非常多的技术方案可以满足它。我们上面示意图中的小圆点是一个个用户反馈的需求。在用户提这些需求的时候,往往可能会带着他熟悉的技术方案的烙印。
对于那些我们明显不关心的需求,如上图的小红点,相对容易排除在外。毕竟产品的边界意识大家还是会有的,产品不可能无限制膨胀下去。
但是对于上面的小绿点,决策上就比较难了。不做?可能会丢了这个客户。做?如果我们手放宽一点,最后产品需求就会被放大(如上图中蓝色的圆圈),做出一个四不像的产品。
**最后,在理清楚需求后,要对需求进行归纳整理。**一方面,将需求分别归类到不同的子类别中。另一方面,形成需求的变化点和稳定点的基本判断。
前面我们也强调过:在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。要注意的是,在讨论需求的变化点和稳定点的时候,我们需要有明确参考的坐标系。在不同视角下,稳定点和变化点的判断是完全不同的。
所以需要明确的一点是,当我们说需求的变化点和稳定点时,这是站在我们要设计的产品角度来说的。比如我们要设计一台计算机,那么多样化的外部设备是一个变化点。但是如果我们今天是在设计一台显示器,问题域就完全变了,需求的变化点和稳定点也就完全发生了变化。
本质上来说,对变化点的梳理,是一次产品边界的确立过程。所谓的开放性设计,就是说我把这个功能交给了合作伙伴,但是我得考虑怎么和合作伙伴配合的问题。
开放性设计并不是一个纯粹的用户需求问题,它通常涉及技术方案的探讨。因此,产品边界的确立不是一个纯需求,也不是一个纯技术,而是两者合而为一的过程。
对变化点的梳理至关重要。产品功能必须是收敛的,必须是可完成的。如果某个子类别的需求呈现出发散而无法收敛的趋势,这个事情,团队一定要坐下来一起去反复推敲。不断拷问,不断明确响应需求的正确姿势到底为何。
产品定义
需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义并不是简单的产品需求的归类。
上面我也说过,产品是桥,它一端连接了用户需求,一端连接了先进的技术。所以产品定义不可能做到和技术方案完全没关系。
首先,需要明确产品中有哪些元素,或者叫资源,以及这些资源的各类操作方式。如果我们从技术的视角来理解,这就是定义对象和方法。当然这仅仅是这么理解,实际上一个我们技术上的对象方法,从产品需求角度会有多条路径的操作方式来达到相同的目的。
**其次,需要对产品如何满足用户需求进行确认。**用户的使用场景未必全部是我们的产品所能直接满足的,面向特定的行业,有可能需要相应的行业解决方案,把我们的产品整合进去。我们要避免把行业方案视作产品的一部分。更多的情况下,需要我们更加开放的心态来看待这件事情,优先寻找合作伙伴来一起完成这类行业的需求覆盖。
最后,产品定义还需要考虑市场策略,我们的产品如何进入市场,和既有市场格局中的其他主流解决方案的关系是什么样的。
\t我们希望获取的用户,可能大部分都已经有一个既有的产品和技术方案,在满足他的需求。在考虑如何让客户从既有方案迁移到我们的产品后,我们确定产品的边界时又会复杂很多。在一些极其关键的市场,我们有可能会把迁移需求视作产品需求的一部分。但更多的情况下,我们产品上只为这些市场上的主流方案提供迁移路径,而不是完整的迁移方案。
为何架构课从基础平台开始?
很抱歉我说得很抽象,但是总结需求分析的方法论的确是一件很难的事情。
为什么我们谈架构会从 “基础平台” 讲起?为什么从硬件架构,到编程语言,再到操作系统,我们似乎绕了一大圈,还没有谈到架构?有两个原因。
最直接的原因是 “基础平台” 是我们所依赖的环境,是我们应用的业务架构的一部分。越了解我们所处的环境,我们就越能够运用自如。
但还有一个重要的原因是架构的探讨容易过度抽象。所以我并没有先长篇大论谈架构方法论,谈需求应该怎么怎么去分析,而是围绕着基础平台的演进过程来谈需求分析。
信息世界的构建过程,本身就是一个最宏大的架构实践。我们通过对信息世界的骨架构成的参悟,自然能够感悟到架构思维的要点。学内功需要悟心,学架构也需要悟心。怎么准确研判需求,对需求演进进行预测,这并不是靠技术技能,而是靠谦和求取的心态。
所以我们第一章 “基础平台” 篇整体来说,内容介绍以产品的需求分析为主、核心技术原理为辅。我们尝试把整个基础平台融为一个整体,宏观上不留任何疑惑。实际上这一章的内容很难做到只看一遍就可以,可能要时时看,反复看。还需要查阅一些资料,也可以与人一起探讨。当然,我们也欢迎留言一起交流。
这一章我们介绍的内容,大部分内容都有一些对应的经典书籍,在后面 “基础平台篇: 回顾与总结” 一讲中,我也会给大家推荐一些经典的图书。但我们并不是要重复这些书籍中的内容。**我们的关注点在于:一是构建信息世界的宏观骨架,二是需求演进。**经典书籍虽然好,但是它们写作时候的历史背景和今天有很大不同。从架构视角来说,结合我们今天的现实情况来看,一方面我们可以总结今天区别于当初的所有变化,另一方面主动去思考为什么发生了这样的变化。以这样的视角去读经典书籍,会别有一番滋味。
结语
在我们介绍完第一章 “基础平台” 篇的所有内容后,今天我们终于正式开始谈架构思维。我们探讨的是架构的第一步:需求分析。需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。
怎么提升需求分析能力,尤其是预判能力?
首先,心态第一,心里得装着用户。除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。
其次,对问题刨根究底,找到根源需求。
最后,对需求进行归纳整理。一方面,将需求分别归类到不同的子类别中。另一方面,形成需求的变化点和稳定点的基本判断。
需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义将明确产品的元素,明确产品的边界,与产业上下游、合作伙伴的分工。
为什么我们的架构课从日常最平常之处,我们日日接触的基础平台讲起?你真了解它们吗?你真感悟到它们的不凡之处了吗?学习架构,关键在于匠心与悟心。
用思考的方式去记忆,而不是用记忆的方式去思考。
19 | 基础平台篇:回顾与总结
到今天为止,我们第一章 “基础平台篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。
抽象信息世界的骨架
基础平台篇主要涉及的内容如下。
这些内容如果展开来讲,每一系统(或模块)都会是很厚的一本书。我们的目的,当然不是为了取代这里每一个领域知识相关的专业书籍。我们的核心目标是以架构为导向,抽象出系统的骨架,融会贯通,把这些领域知识串起来,拼出完整的信息世界的版图。
抽象出系统骨架的过程时信息必然是有损的,怎么才能做到忽略掉众多的实现细节,把系统以简洁易于理解的方式呈现出来?这很大程度取决于你对系统的理解程度和抽象能力。如果我们把系统想象成一个人,大部分情况下我们比较容易对其进行详尽而具体的描述,好比下图。
这相对容易。因为你只需要陈述你看到的事实,而不必拷问背后的原因。但实际上为了在最短的时间里让别人理解你的想法,你也许应该这样来描述它,见下图。
当你不是在描述这个系统本身,而是描述它与其他系统的相互关系时,你可能需要进一步简化它,变成如下图这样。
抽象有助于记忆,因为骨架需要逻辑的自洽。
这种抽象能力之所以重要,是因为它是融会贯通、疏通整个信息世界的知识脉络的关键。当你做到对世界的认知可宏观、可微观,自然一切皆在掌握。比如,本章我们首先介绍的是冯·诺依曼体系结构,我们把它抽象为“中央处理器(CPU)+ 存储 + 一系列的输入输出设备”,并给出了系统的示意图如下。
这个图相当笼统,并没有涉及中央处理器(CPU)指令设计的真正细节。比如,我们没有介绍栈(stack)这个概念,虽然它实际上也非常关键。
为什么需要引入栈?它在中央处理器中起到了什么样的作用?要了解这个问题,你就需要深入到中央处理器的架构设计中去。如果你对梳理中央处理器的架构设计感兴趣,可以尝试写一篇介绍它的文字。
做这样的事情会对你非常的锻炼。“你自己理解一个事物”和“把你的理解表述成文,去引导其他人也能够理解它”,是完全不同难度的事情。如果你对中央处理器的设计细节感兴趣,可以进一步查阅相关的参考资料。也欢迎与我分享你的心得体会。
基础平台篇的内容回顾
这一章前面我们讲了些什么?为了让大家对第一章内容有个宏观的了解,我画了一幅图,如下。
首先,我们介绍了冯·诺依曼体系结构。从需求演进角度看,虽然我们信息科技发展日新月异,但是底层设计并没有发生过变化,非常稳定。从这一点来说,我们不能不佩服他们的远见。
随后,我们介绍了编程语言的演进。从汇编语言的诞生,出现了程序员这个新职业开始,此后编程语言的演进便进入高速发展期。
然而,尽管语言很多,但是编程范式的演进却并不剧烈。大家熟知的过程式、函数式、面向对象基本上能够把几乎所有的语言都囊括其中。Go 语言独树一帜地宣称自己是面向连接的语言,我们着重对比了面向对象与面向连接思想上的差异。编程语言本身与业务架构的设计关联性不大,虽然模块规格的描述会借助语言的文法。但是语言长期演进所沉淀下来的社区资源,是我们架构设计所依赖的重要基础。充分利用好这些资源可以大大降低系统的研发成本。
最后,我们开始聊操作系统。从 UNIX => DOS => Windows/Mac/Linux => iOS/Android,从用户交互、进程管理、安全管理等角度看,操作系统的需求演变非常剧烈。
传统操作系统主要包含五个子系统:设备管理(包括存储设备、输入 / 输出设备、网络设备)、进程管理和安全管理。
输入 / 输出设备主要和交互有关,我们概要描述,基本上一笔带过。我会在后面 “桌面软件开发” 这一章再详加讨论。而服务端的交互比较简单,命令行基本上就满足需求,所以 “服务端开发” 一章我们不会再特意去展开。
另外,操作系统的商业模式也发生了剧烈的变化。早期操作系统的营收模式以软件销售收入为主。但是从苹果的 iOS 开始,操作系统都无一例外地增加了以下三个模块:
账号(Account);支付(Pay);应用市场(AppStore)。
注意,这里我们说的账号是指互联网账号。传统操作系统虽然也有账号概念,但是,它是本地账号,属于多用户权限隔离所需。而互联网账号的价值完全不同,它是支付和应用商店的基础。没有账号,就没有支付系统,也没有办法判断用户是否在应用市场上购买过软件。
实现了“帐号 - 支付 - 应用市场”这样的商业闭环,意味着操作系统的商业模式,从软件销售转向了收税模式。这类操作系统,我们称之为现代操作系统。所有现代操作系统,所凭借的都是自己拥有巨大的流量红利。
基础平台篇的参考资料
概要回顾了我们 “基础平台篇” 的内容后,我们这里补充一下有助于理解我们内容的相关资料,如下。
有了本专栏梳理的骨架,相信对你学习和理解以上这些材料会一定的指引意义。如果你有什么推荐的优秀参考资料,也欢迎在留言区分享,我补充到这个表格中来,我们一起来完善它。
架构之美在于悟
信息世界是无中生有创造出来的,我们不需要去记忆,而是要找到创造背后的骨架和逻辑。
架构即创造。
学架构在于匠心和悟心。它靠的是悟,不是记忆。用思考的方式去记忆,而不是用记忆的方式去思考。
我们日常所依赖的基础平台,随处可见的架构之美,看到了,悟到了,就学到了。如果你只能从你自己写业务代码中感受架构之道,那么你可能就要多留些心思了。
比如,如果你日常用的是 Go 语言,那么你可以做一个作业:“谈谈 Go 语言之美”。你从 Go 语言的设计中感悟到了什么样的架构思维?当然如果你不常接触 Go 语言,可以给自己换一个题目,比如 “Java 语言之美”。
作为架构师,如何构建需求分析能力,尤其是需求的预判能力?
首先,归纳总结能力很重要。分析现象背后的原因,并对未来可能性进行推测。判断错了并不要紧,分析一下你的推测哪些地方漏判了,哪些重要信息没有考虑到。
另外,批判精神也同样至关重要。批判不是无中生有的批评,而是切实找到技术中存在的效率瓶颈和心智负担。尤其在你看经典书籍的时候,要善于找出现状与书的历史背景差异,总结技术演进的螺旋上升之路,培养科学的批判方法论。
结语
今天我们对本章内容做了概要的回顾,并借此对整个基础平台的骨架进行了一次梳理。我们最为依赖,也最为强调的,是抽象能力。它对于构建信息世界的骨架至关重要。为此我们需要不断改造自己的抽象体系。例如,前面 “02 | 大厦基石:无生有,有生万物” 这一讲中提到过:
引入了输入输出设备的电脑,不再只能做狭义上的“计算”(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的“计算”问题变得无所不包。
有同学留言问:输入 / 输出设备提供的明明是一种 IO 能力,怎么能够算得上是“计算”?
但是实际上,我们人类其实就是在这种“否定自己,不断延展自己的抽象体系”,补全自己的想象力。我们以数学中最为基础的 “数” 为例子。数的演化大概经历了:
自然数 => 整数 => 有理数 => 实数 => 复数
输入 / 输出能力算不算是“计算”?我们不妨以广义的“计算”角度来看。
输入(Input),无非是采集物理世界的信息,将其数字化,所以一个输入设备其实可以看作是一个模数转换的“算子”。只不过这个算子非 CPU 的指令可以表达。
输出(Output),无非是将数字内容反作用于物理世界,一个输出设备其实可以看作是一个数模转换的“算子”。同样,这个算子非 CPU 的指令可以表达。
计算机 CPU 自身只能做数数转换,输入是比特信息,输出还是比特信息。结合了输入 / 输出设备提供的数模和模数转换的 “算子”,连接了数字世界和物理世界的计算机,在数学上也就完备了。
二、桌面开发篇
20 | 桌面开发的宏观视角
从架构的角度,无论你在什么样的终端设备(比如:PC、手机、手表、手机等等),也无论你在做 Native 应用,还是基于 Web 和小程序,我们都可以统一称之为桌面程序。如前文所述,一个桌面程序完整的架构体系如下:
对于桌面程序,最核心的话题是交互。为了把关注点收敛到交互上,我们下面重点讨论操作系统对交互范式的设计。从需求角度看,桌面程序的交互方式并不稳定,它的交互范式经历了很多次的迭代。
命令行交互
最早出现的交互范式是命令行交互程序。使用的交互设备为键盘 + 显示器。输入被抽象为一段以回车(Enter 键)为结束的文本(通常是单行文本,要输入多行文本,需要在行末输入“ \\ ”对回车进行转义)。
输入方式有二:一是命令行,二是标准输入(stdin)。输出也是文本,但输出目标可能是标准输出(stdout),也可能是标准错误(stderr)。正常情况下,标准输出(stdout)和标准错误(stderr)都是向屏幕输出。这种情况下,肉眼并不能区分输出的内容是标准输出,还是标准错误。
命令行交互程序的输入输出可以被重定向。一个程序的输出,可以被重定向写入到文件(标准输出和标准错误可以输出到不同的文件以进行区分),也可以通过管道功能重定向为另一个程序的输入。总结一下,命令行交互程序的结构可示意如下:
但命令行程序的限制过大了,人们很容易发现,在很多需求场景下这是非常反人类的,最典型的就是编辑器。稍微想象一下,你就会为怎么做好交互设计而头疼不已。
字符界面
于是,字符界面程序出现了。使用的交互设备仍然是键盘 + 显示器,但是输入不再是一段文本,而是键盘按键事件(KeyDown 和 KeyUp)。输出也不是一段文本,而是可以修改屏幕任何位置显示的字符(屏幕被分割成 M*N 的格子,每个格子可以显示一个字符)。
这个时候,键盘的功用在需求上分化为两个:一是输入文本,二是输入命令(通常通过扩展键比如方向键,或者组合键比如 Ctrl-A、Alt-X)。从输入文本的角度,需要有当前输入的光标(Caret)位置。字符界面程序保留命令行输入的方式,但一般不太使用标准输入。其结构示意如下:
上图的 TDI 含义是字符设备接口(Text Device Interface),它指的是一组向屏幕绘制文本的方法集合。大体看起来是这样的:
func ClearScreen()
func DrawText(x, y int, text string)
...
但是,字符界面程序也有很大的局限。最典型的需求场景是游戏。一些简单的游戏比如俄罗斯方块是可以基于字符界面做出来的,但大部分情况下,字符界面对于游戏类场景能够做的事情非常有限。
图形界面
于是,图形界面程序出现了。使用的交互设备是键盘 + 鼠标 + 显示器 + 音箱。从交互演进角度,这是一个划时代的变化。与字符界面时期相比,图形界面时代变化的根源是输出的变化:从字符变成像素。屏幕被分割为精度更高的 M * N 的格子,每个格子显示的是一个很小很小的像素,每个像素可以有不同的颜色。
为什么会出现鼠标?因为屏幕精度太高,用键盘的方向键改变当前位置,不只是看起来非常笨拙,而且操作上也很不自然。为什么出现音箱的原因则比较平凡,它只不过是声音设备演进的自然结果。事实上在字符交互时期声音设备就已经有了,计算机主板上有内置的喇叭。这个喇叭最大的用途是出现重大错误(比如计算机启动失败)的时候会响几声给予提示。开发人员可以通过向标准输出(stdout)或标准错误(stderr)输出一个特殊的字符让喇叭响一声。
前面我们说过,输出到标准输出和标准错误对肉眼来说不可区分,所以如果我们向标准错误输出文本前让喇叭响一声,也是一个不错的一种交互范式。与字符界面程序相比,图形界面程序还有一个重大变化,是多窗口(当然,部分复杂的字符界面程序也是多窗口的,比如 Turbo C++ 3.0,它用的是 Turbo Vision 这个知名的字符界面库)。
窗口(Window),也有人会把它叫视图(View),是一个独立可复用的界面元素。复杂的窗口可以切分出多个逻辑独立的子窗口,以降低单个窗口的程序复杂性。窗口有了父子和兄弟关系,就有了窗口系统。一旦界面涉及复杂的窗口系统,交互变得更为复杂。例如,键盘和鼠标事件的目标窗口的确定,需要一系列复杂的逻辑。
为了降低编程的负担,窗口系统往往接管了桌面程序的主逻辑,提供了一套基于事件驱动的编程框架,业务代码由窗口系统提供的界面框架所驱动。整个程序的结构如下所示。
上图的 GDI 含义是图形设备接口(Graphic Device Interface),它指的是一组向指定窗口(注意不是屏幕)绘制图形的方法集合。绘制的对象包括有几何图形、图像、文本等。此后,到了移动时代,手机成了最主流的计算机。使用的交互设备发生了变化,变成了触摸屏 + 麦克风 + 内置扬声器。
鼠标交互方式被淘汰,变成了多点触摸。键盘 + 鼠标 + 显示器的能力被融合到触摸屏上。音箱也被内置到手机中,变成内置扬声器。这些变化都因移动设备便携性的述求引起。从架构的角度,它们并没有引起实质性的变化,只是鼠标事件变成了触摸事件。
智能交互
麦克风让计算机多了一个输入:语音。有三种典型的用法。一是在应用内把语音录下来,直接作为类似照片视频的媒体消息,或者识别为文本去应用(比如语音搜索)。二是作为语音输入法输入文本(逻辑上可以认为是第一种情况的特例,只不过输入法在操作系统中往往有其特殊的地位)。三是基于类似 Siri 语音助手来交互。
语音助手是被寄予厚望的新的交互范式。它可能开启了新的交互时代,我们不妨把它叫智能交互时代。但当前它与图形界面程序结构并不相容,而是完全自成体系,如下:
为什么语音交互和图形界面交互没法很好地融合在一起?我认为有两个原因。一是语音交互有很强的上下文,所以语音交互程序通常其业务代码也由语音交互系统提供的框架所驱动。框架的特点是侵入性强,框架与框架之间很难融合。二是语音交互还不成熟,所以独立发展更简单,如果有一天成熟了,完全可以重写框架,把语音和触摸屏结合起来,形成全新的交互范式。
未来交互会怎样?智能交互很可能不会止步于语音,而是视频(同是兼顾视觉和听觉),且与触摸屏完美融合。使用的交互设备有触摸屏 + 摄像头 + 麦克风 + 内置扬声器。整个程序的结构如下所示。
结语
通过以上对交互演化历程的回顾,我们看到交互范式的演进是非常剧烈的。交互体验越来越自然,但从编程的角度来说,如果没有操作系统支持,实现难度也将越来越高。这也说明了一点,桌面操作系统和服务端操作系统的演进方向非常不一样。桌面操作系统的演进方向主要是交互范式的迭代,在向着越来越自然、越来越智能的交互前进。
22 | 桌面程序的架构建议
上一讲我们介绍了图形界面程序的框架。站在操作系统交互子系统的角度来看,我们桌面应用程序的结构是下面这样的。
今天我们换一个角度,站在应用架构的角度,来聊聊如何设计一个桌面应用程序。
从 MVC 说起
关于桌面程序,我想你听得最多的莫过于 MVC 这个架构范式。MVC 全称是 “模型 (Model)- 视图 (View)- 控制器 (Controller)”。
怎么理解 MVC 呢?一种理解是,Model 是 Input,View 是 Output,Controller 是 Process,认为 MVC 与计算机的 Input-Process-Ouput 这个基础模型暗合。但更准确的解释是:Model 是数据,View 是数据的显示结果,同时也接受用户的交互动作,也就是事件。从这个意义来说,说 Model 是 Input 并不严谨,View 接受的用户交互,也是 Input 的一部分。
Controller 负责 Process(处理),它接受 “Model + 由 View 转发的事件” 作为 Input,处理的结果(Output)仍然是 Model,它更新了 Model 的数据。
View 之所以被理解为 Output,是因为 Model 的数据更新后,会发送 DataChanged(数据更新)事件,View 会在监听并收到 DataChanged 事件后,更新 View。所以把 View 理解为 Output 也并不算错,它从数据角度看其实是 Model 的镜像。
对 MVC 模式做些细微的调整,就会产生一些变种。比如,Model 的数据更新发出 DataChanged 事件后,由 Controller 负责监听并 Update View,这样就变成了 MVP 架构。MVP 全称是 “模型 (Model)- 视图 (View)- 表现 (Presenter)”。
那么,我们究竟应该选择哪一种架构范式比较好?要想判断我们写的程序架构是否优良,那么我们心中就要有架构优劣的评判标准。比较知名且重要的一些基本原则如下。
- 最低耦合原则:不同子系统(或模块)之间有最少的交互频率,最简洁且自然的接口。
- 单一职责原则:不要让一个子系统(或模块)干多件事情,也不要让它不干事情。
如果在我们心中以遵循架构法则为导向,回过头再来看 MVC,又会有不同的理解。
理解 Model 层
我们先看 Model。如果你真正理解 Model 层的价值,那么可以认为你的架构水平已经达到了较高层次的水准。因为 Model 层太重要了。我上面说 Model 层是数据,这其实还不是太准确。更准确来说,Model 层是承载业务逻辑的 DOM,即 “文档对象模型(Document Object Model)”。直白理解,DOM 是 “面向对象” 意义上的数据。它不只是有数据结构,也有访问接口。
为了便于理解,假设我们基于数据库来实现 Model 层。这种情况下会有两种常见的架构误区。一种是直接让 Controller 层直接操作数据库,也就是拿数据库的读写接口作为 Model 层的接口。另一种看起来高级一些,用所谓的 ORM 技术来实现 Model 层,让 Controller 直接操作 ORM。
为什么我们说这两种做法都有问题呢?原因就在于对 Model 层的价值不明。Model 层的使用接口最重要的是要自然体现业务的需求。只有这样,Model 层的边界才是稳定的,与你基于的技术无关。是用了 MySQL,还是用了 NoSQL?是直接裸写 SQL 语句,还是基于 ORM?这都没关系,未来喜欢了还可以改。
另外,从界面编程角度看,Model 层越厚越好。为什么这么说?因为这是和操作系统的界面程序框架最为无关的部分,是最容易测试的部分,也同时是跨平台最容易的部分。我们把逻辑更多向 Model 层倾斜,那么 Controller 层就简洁很多,这对跨平台开发将极其有利。
这样来看,直接让 Controller 层直接操作数据库,或者基于 ORM 操作数据库,都是让 Model 层啥事不干,这非常非常浪费,同样也违背了 “单一职责原则”。我们需要强调,单一职责不只是要求不要让一个子系统(或模块)干多件事情,同时也要求不要让它不干事情。
如果我们用一句话来描述 Model 层的职责,那么应该是 “负责业务需求的内核逻辑”,我们以前经常叫它 “DataCore”。那么 Model 层为何要发出 DataChanged 事件?
这是从 Model 层的独立性考虑。Model 层作为架构的最底层,它不需要知道其他层的存在,不需要知道到底是 MVC 还是 MVP,或者是其他的架构范式。有了 DataChanged 事件,上层就能够感知到 Model 层的变化,从而作出自己的反应。
如果还记得第一章我们反复强调的稳定点与变化点,那么显然,DataChanged 事件就是 Model 层面对需求变化点的对策。大部分 Model 层的接口会自然体现业务需求,这是核心价值点,是稳定的。
但是业务的用户交互可能会变化多端,与 PC 还是手机,与屏幕尺寸,甚至可能与地区人文都有关系,是多变的。
用事件回调来解决需求的变化点,这一点 CPU 干过,操作系统也干过,今天你做业务架构也这么干,这就很赞。
理解 View 层
View 层首要的责任,是负责界面呈现。界面呈现只有两个选择,要么自己直接调用 GDI 接口自己画,要么创建子 View 让别人画。View 层另一个责任是被自然带来的,那就是:它是响应用户交互事件的入口,这是操作系统的界面编程框架决定的。比较理想的情况下,View 应该把自己所有的事件都委托(delegate)出去,不要自己干。
但在 View 的设计细节中,也有很多问题需要考虑。
其一,View 层不一定会负责生成所有用户看到的 View。有的 View 是 Controller 在做某个逻辑的过程中临时生成的,那么这样的 View 就应该是 Controller 的一部分,而不应该是 MVC 里面的 View 层的一部分。
其二,View 层可能需要非常友好的委托(delegate)机制的支持。例如,支持一组界面元素的交互事件共同做委托(delegate)。
其三,负责界面呈现,意味着 View 层和 Model 层的关系非常紧密,紧密到需要知道数据结构的细节,这可能会导致 Model 层要为 View 层提供一些专享的只读访问接口。这合乎情理,只是要确保这些访问接口不要扩散使用。
其四,负责界面呈现,看似只是根据数据绘制界面,似乎很简单,但实则不简单。原因在于:为了效率,我们往往需要做局部更新的优化。如果我们收到 onPaint 消息,永远是不管三七二十一,直接重新绘制,那么事情就很好办。但是在大部分情况下,只要业务稍微复杂一点,这样的做法都会遇到性能挑战。
在局部更新这个优化足够复杂时,我们往往不得不在 Model 和 View 之间,再额外引入一层 ViewModel 层来做这个事情。ViewModel 层顾名思义,是为 View 的界面呈现而设计的 Model 层,它的数据组织更接近于 View 的表达,和 View 自身的数据呈一一对应关系(Bidi-data-binding)。
一个极端但又很典型的例子是 Word。它是数据流式的文档,但是界面显示人们用得最多的却是页面视图,内容是分页显示的。这种情况下就需要有一个 ViewModel 层是按分页显示的结构来组织数据。其中负责维持 Model 与 ViewModel 层的数据一致性的模块,我们叫排版引擎。
从理解上来讲,我个人会倾向于认为 ViewModel 是 View 层的一部分,只不过是 View 层太复杂而进行了再次拆分的结果。也就是说,我并不倾向于认为存在所谓的 “Model-View-ViewModel” 这样的模式。
理解Controller层
Controller 层是负责用户交互的。可以有很多个 Controller,分别负责不同的用户交互需求。这和 Model 层、View 层不太一样。我们会倾向于认为 Model 层是一个整体。虽然这一个层会有很多类,但是它们共同构成了一个完整的逻辑:DOM。而 View 层也是如此,它是 DOM 的界面呈现,是 DOM 的镜像,同样是一个整体。
但负责用户交互的 Controller 层,是可以被正交分解的,而且应该作正交分解,彼此完全没有耦合关系。一个 Controller 模块,可能包含一些属于自己的辅助 View,也会接受 View 层委托的一些事件,由事件驱动自己状态,并最终通过调用 Model 层的使用接口来完成一项业务。
Controller 模块的辅助 View 可能是持续可见的,比如菜单和工具条;也可能是一些临时性的,比如 Office 软件中旋转图形的控制点。对于后者,如果存在 ViewModel 层的话,也有可能会被归到 ViewModel + View 来解决,因为 ViewModel 层可以有 Selection 这样的东西来表示 View 里面被选中的对象。
Controller 层最应该思考的问题是代码的内聚性。哪些代码是相关的,是应该放在一起的,需要一一理清。这也是我上面说的正交分解的含义。如果我们做得恰当,Controller 之间应该是完全无关的。而且要干掉某一个交互特别容易,都不需要删除该 Controller 本身相关的代码,只需要把创建该 Controller 的一行代码注释掉就可以了。
从分层角度,我们会倾向于认为 Model 层在最底层;View 层在中间,它持有 Model 层的 DOM 指针;Controller 层在最上方,它知道 Model 和 View 层,它通过 DOM 接口操作 Model 层,但它并不操作 View 去改变数据,而只是监听自己感兴趣的事件。
如果 View 层提供了抽象得当的事件绑定接口,你会发现,其实 Controller 层大部分的逻辑都与操作系统提供的界面编程框架无关(除了少量辅助 View),是跨平台的。
**谁负责把 MVC 各个模块串起来呢?**当然是应用程序(Application)了。在应用开始的时候,它就把 Model 层、View 层,我们感兴趣的若干 Controller 模块都创建好,建立了彼此的关联,一切就如我们期望的那样工作起来了。
兼顾 API 与交互
MVC 是很好的模型来支持用户交互。但这不是桌面程序面临的全部。另一个很重要的需求是提供应用程序的二次开发接口(API,全称为 Application Programming Interface)。提供了 API 的应用程序,意味着它身处一个应用生态之中,可以与其他应用程序完美协作。
通过哪一层提供 API 接口?我个人会倾向于认为最佳的选择是在 ViewModel 层。Model 层也很容易提供 API,但是它可能会缺少一些重要的东西,比如 Selection。
结语
这一讲我们探讨了一个桌面应用程序的业务架构设计。我们探讨了大家耳熟能详的 MVC 架构范式。一千个人眼中有一千个哈姆雷特,虽然都在谈 MVC,但是大家眼中的 MVC 各有不同。我们站在什么样的架构是好架构的角度,剖析了 MVC 的每一层应该怎样去正确理解与设计,有哪些切实的问题需要去面对。
32 | 架构:系统的概要设计
我们第二章 “桌面开发篇” 就快要结束了。今天我们把话题重新回到架构上。
基础架构与业务架构
桌面开发篇我们主要涉及的内容如下。
对于一位架构师而言,其架构工作的内容可以大体分为两块,一块是基础架构,一块是业务架构。
基础架构,简单来说就是做技术选型。选择要支持的操作系统、选择编程语言、选择技术框架、选择第三方库,这些都可以归结为基础架构方面的工作。基础架构的能力,考验的是选择能力。背后靠的是技术前瞻性和判断力。这并不简单。大部分架构师往往更容易把关注点放到业务架构上,但实际上基础架构的影响面更广,选错产生的代价更高。
架构师之间的差距,更大的是体现在其对待基础架构的态度和能力构建上。真正牛的架构师,一定会无比重视团队的技术选型,无比重视基础平台的建设。阿里提倡的 “大中台、小前台”,本质上也是在提倡基础平台建设,以此不断降低业务开发的成本,提升企业的创新能力。
业务架构,简单来说就是业务系统的分解能力。基础架构其实也是对业务系统的分解,只不过分解出了与业务属性几乎无关的部分,形成领域无关的基础设施。而业务架构更多的是分解领域问题 。一旦我们谈业务架构,就避不开领域问题的理解。所谓领域问题,谈的是这个领域的用户群面临的普遍需求。所以我们需要对用户的需求进行分析。
在第一章,我们已经聊了需求分析:
17 | 架构:需求分析(上)18 | 架构:需求分析(下)- 实战案例
这是我们开始业务架构的第一步。没有需求分析,就没有业务架构。在业务架构过程中,需求分析至少应该花费三分之一以上的精力。今天,我们聊一聊架构的第二步:系统的概要设计,简称系统设计。
系统设计,简单来说就是 “对系统进行分解” 的能力。这个阶段核心要干的事情,就是明确子系统的职责边界和接口协议,把整个系统的大框架搭起来。
那么怎么分解系统?首先我们需要明确的是分解系统优劣的评判标准。也就是说,我们需要知道什么样的系统分解方式是好的,什么样的分解方式是糟糕的。最朴素的评判依据,是这样两个核心的点:
- 功能的使用界面(或者叫接口),应尽可能符合业务需求对它的自然预期;
- 功能的实现要高内聚,功能与功能之间的耦合尽可能低。
在软件系统中有多个层次的组织单元:子系统、模块、类、方法 / 函数。子系统如何分解模块?模块如何分解到更具体的类或函数?每一层的分解方式,都遵循相同的套路。也就是分解系统的方法论。
接口要自然体现业务需求
我们先看功能的使用界面(或者叫接口)。
什么是使用界面?对于函数,它的使用界面就是函数原型。
它包含三部分信息。
- 函数名。严谨来说是包含该函数所在的名字空间的函数名全称,比如上例是 packageName.FuncName。
- 输入参数列表。每个参数包含参数名和参数类型。
- 输出结果列表。每个输出结果包含结果名和结果类型。当然,很多语言的函数是单返回值的,也就是输出结果只有一个。这种情况下输出结果没有名称,只有一个结果类型,也叫返回值类型。
对于类,它的使用界面是类的公开属性和方法。
它包含以下内容。
- 类型名。严谨来说是包含该类型所在的名字空间的类型名全称,比如上例是 packageName.ClassName。
- 公开属性列表。每个属性包含属性名和属性类型。Go 语言对属性的支持比较有限,直接基于类型的成员变量来表达。而一些语言,比如 JavaScript,对属性的支持比较高级,允许给某个属性设定 get/set 方法。这样就能够做到只读、只写、可读写三种属性。
- 公开方法列表。
方法和函数本质上是一样的,有的只是细节不同。这表现在下面几点。
- 名字空间不同。普通函数的函数名全称是 packageName.FuncName,而方法的方法名全称是 packageName.(*ClassName).MethodName 这种形式。
- 方法相比函数多了一个概念叫 receiver(接受者),也就是方法所作用的对象。在 Go 语言中 receiver 是显式表达的。但大部分语言中 receiver 是隐藏的,通常名字叫 this 或 self。
对于模块,它的使用界面比较多样,需要看模块类型。典型的模块类型有这样一些:
- 包(package)。一些语言中也叫静态库(static library)。
- 动态库(dynamic library)。在 Go 语言中有个特殊的名称叫插件(plugin)。
- 可执行程序(application)。
对于包(package)和动态库(dynamic library),这两者都是代码的一种发布形态,只是标准的制定方不同。包(package)一般是由编程语言定义的,对开发者比较友好。而动态库(dynamic library)一般是操作系统定义的,可以做到跨语言,但是对开发者往往不太友好。为什么不友好?因为它要定义跨语言的符号定义和类型定义的标准。这意味着它只能取多个编程语言之间的共性部分。
对于可执行程序(application),又要分多种情况。最常见的可执行程序有这么几类:
- 网络服务程序(service);
- 命令行程序(command line application);
- 桌面程序(GUI application)
对于网络服务程序(service),它的使用界面是网络协议。前面我们在 “画图” 程序实战(四)这一讲中也有定义过画图服务端的网络协议。如下:
\t
对于命令行程序(command line application),它的使用界面包括:
- 命令行,包括:命令名称、开关列表、参数列表。例如:CommandName -Switch1 ... -SwitchN Arg1 ... ArgM。
- 标准输入(stdin)。
- 标准输出(stdout)。
对于桌面程序(GUI application),它的使用界面就是用户的操作方式。桌面程序的界面外观当然是重要的,但不是最重要的。最重要的是交互范式,即用户如何完成功能的业务流程的定义。为什么我们需要专门引入产品经理这样的角色来定义产品,正是因为使用界面的重要性。
以上这些组织单元都物理上存在,最后我们还剩一个概念:子系统。在实际开发中,并不存在物理的实体与子系统这个概念对应,它只存在于架构设计的文档中。
那么怎么理解子系统?
子系统是一个逻辑的概念,物理上可能对应一个模块(Module),也可能是多个模块。你可以把子系统理解为一个逻辑上的大模块(Big Module),这个大模块我们同样会去定义它的使用接口。子系统与模块的对应方式有两种常见的情况。
一种情况,也是最常见的情况,子系统由一个根模块(总控模块)和若干子模块构成。子系统的使用接口,就是根模块的使用接口。
另一种情况,是子系统由多个相似的模块构成。例如对于 Office 程序来说,IO 子系统由很多相似模块构成,例如 Word 文档读写、HTML 文档读写、TXT 文档读写、PDF 文档读写等等,这些模块往往有统一的使用界面。
通过上面对子系统、模块、类、函数的使用界面的解释,你会发现其实它们是有共性的。它们都是在定义完成业务需求的方法,只不过需求满足方式的层次不一样。类和函数是从语言级的函数调用来完成业务,网络服务程序是通过网络 RPC 请求来完成业务,桌面程序是通过用户交互来完成业务。
理解了这一点,你就很容易明白,“功能的使用界面应尽可能符合业务需求对它的自然预期” 这句话背后的含义。
一个程序员的系统分解能力强不强,其实一眼就可以看出来。你都不需要看实现细节,只需要看他定义的模块、类和函数的使用接口。如果存在大量说不清业务意图的函数,或者存在大量职责不清的模块和类,就知道他基本上还处在搬砖阶段。
无论是子系统、模块、类还是函数,都有自己的业务边界。它的职责是否足够单一足够清晰,使用接口是否足够简单明了,是否自然体现业务需求(甚至无需配备额外的说明文档),这些都体现了架构功力。
功能实现准则:高内聚低耦合
系统分解的套路中,除了功能自身的使用界面之外,我们还关注功能与功能之间是如何被连接起来的。当然这就涉及了功能的实现。功能实现的基本准则是:功能自身代码要高内聚,功能与功能之间要低耦合。
什么叫高内聚?简单来说,就是一个功能的代码应该尽可能写在一起,而不是散落在各处。代码高内聚的好处是,多大的团队协作都会很顺畅,代码提交基本上不怎么发生冲突。我个人在高内聚这个方向上养成的习惯是:
- 一个功能的代码尽可能单独一个文件,不要和其他功能混在一起;
- 一些小功能的代码可能放在一起放在同一个文件中,但是中间也会用“// ------------------ ”这样的注释行分割成很多逻辑上的 “小文件”,代表这是一段独立的小功能。
那么什么叫低耦合?简单来说就是实现某个功能所依赖的外部环境少,易于构建。功能实现的外部依赖分两种。一种是对业务无关的基础组件依赖,一种是对底层业务模块的依赖。
基础组件可能是开源项目,当然也可能来自公司的基础平台部。关于基础组件的依赖,我们核心的关注点是稳定。稳定体现在如下两个方面。
- 一方面是组件的成熟度。这个组件已经诞生多久了,使用接口是不是已经不怎么会调整了,功能缺陷(issue)是不是已经比较少了。
- 另一方面是组件的持久性。组件的维护者是谁,是不是有足够良好的社区信用(credit),这个项目是不是还很活跃,有多少人在参与其中,为其贡献代码。
当然从架构角度,我们关注的重点不是基础组件的依赖,而是对其他业务模块的依赖。它更符合业务系统分解的本来含义。
对底层业务模块的依赖少、耦合低的表现为:
- 对底层业务的依赖是 “通用” 的,尽量不要出现让底层业务模块专门为我定制接口;
- 依赖的业务接口的个数少,调用频次低。
怎么做系统分解?
有了系统分解的优劣评判标准,那么我们具体怎么去做呢?总体来说,系统分解是一个领域性的问题,它依赖你对用户需求的理解,并不存在放之四海皆可用的办法。
系统分解首先要从需求归纳出发。用户需求分析清楚很重要。把需求功能点涉及的数据(对象)、操作接口理清楚,并归纳整理,把每个功能都归于某一类。然后把类与类的关系理清楚,做到逻辑上自洽,那么一个基本的系统框架就形成了。
在系统的概要设计阶段,我们一般以子系统为维度来阐述系统各个角色之间的关系。
对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。但这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不作进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。
为了降低风险,系统的概要设计阶段也应该有代码产出。这个阶段的代码用意是什么?有两个方面的目的。其一,系统的初始框架代码。也就是说,系统的大体架子已经搭建起来了。其二,原型性的代码来验证。一些核心子系统在这个阶段提供了 mock 的系统。
这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。代码即文档。代码是理解一致性更强的文档。
再谈 MVC
本章我们主要探讨的是桌面程序开发。虽然不同桌面应用的业务千差万别,但是桌面本身是一个很确定性的领域,因此会形成自己固有的系统分解的套路。大家已经知道了,桌面程序系统分解的套路就是 MVC 架构。虽然不同历史时期的桌面程序的交互方式不太一样,有基于键盘 + 鼠标的、有基于触摸屏的,但是它们的框架结构是非常一致的,都是基于事件分派做输入,GDI 做界面呈现。
那么为什么会形成 Model-View-Controller(简称 MVC)架构?我们第一章探讨需求分析时,我们反复强调一点:要分清需求的稳定点和变化点。稳定点是系统的核心能力,而变化点则需要做好开放性设计。
从这个角度来看,我们可以认为,业务的核心逻辑是稳定的,除非出现了新的技术革命导致产品的内在逻辑发生了质的变化。所以我们最底层一般以类和函数的形态来组织业务的核心逻辑,这就是 Model 层。但用户交互是一个变化点。大家都是一个 “画图” 程序,无论是在 PC 桌面和手机上,Model 层是一样的,但是用户交互方式并不一样,View、Controllers 就有不小的差别。
当然 Model 层也有自己的变化点。它的变化点在于存储和网络。Model 层要考虑持久化,就会和存储打交道,就有自己的 IO 子系统。Model 层要考虑互联网化,就要考虑 B/S 架构,考虑网络协议。不过无论是存储还是网络,从架构视角来说变化都是可预期的。存储介质会变,网络技术会变,但是变的只是实现,它们的使用接口并没变化。这意味着 Model 层不只是核心逻辑稳定,IO 和网络子系统也都很稳定。当然这也是把它们归于 Model 层的原因。如果它们是易变的,可能就被从 Model 层独立出去了。
用户交互这个变化点,主要体现在两个方面。一方面是屏幕尺寸导致的变化。更小的屏幕意味着界面上的信息需要被更高效地组织起来。另一方面则是交互的变化,鼠标交互和触摸屏的多点触摸交互是完全不同的。
View 层主要承担了界面呈现的工作。当然这也意味着它也承担了屏幕尺寸这个变化点。
Controller 层主要承担的是交互。具体来说就是响应用户的输入事件,把用户的操作转化为对 Model 层的业务请求。Controller 层有很多 Controller。这些 Controller 通常各自负责不同的业务功能点。
也就是说,Model 层是一个整体,负责的是业务的核心逻辑。View 层也是一个整体,但在不同的屏幕尺寸和平台可能有不同的实现,但数量不会太多。而且现在流行所谓的响应式布局,也是鼓励尽可能在不同屏幕尺寸不同平台下共享同一个 View 的实现。Controller 层并不是一个整体,它是以插件化的形式存在,不同 Controlller 非常独立。
这样做的好处是可以快速适应交互的变化。比如以创建矩形这样一个功能为例,在 PC 鼠标 + 键盘的交互方式下有一个 RectCreator Controller,在触摸屏的交互方式可以是一个全新的 RectCreator Controller。在不同平台下,我们可以初始化不同的 Controller 实例来适应该平台的交互方式。
当然前面在 “22 | 桌面程序的架构建议” 一讲中,我们也介绍过 MVC 结构的一些变种,比如 MVP(Model-View-Presenter),主要是 Model 的数据更新发出 DataChanged 事件后,由 Controller 负责监听并 Update View,而不是由 View 层响应 DataChanged 事件并 Update View。这些不同模型的差异其实只是细节的权衡、取舍,并不改变实质。
怎么看待实战?
第一章 “基础平台篇”,从架构的角度,我们主要是在学习基础架构。我们总体是从学历史的角度在聊,大家也是以听故事的方式为主。但是第二章开始,我们话题逐步过渡到业务架构,同时也开始引入实战案例:“画图” 程序。为什么实战是很重要的?
学架构,我个人强调的理念是 “做中学”。首先还是要勤动手。然后配合本专栏去思考和梳理背后的道理,如此方能快速进步。
我们不能把架构课学成理论课。计算机科学本身是一门实践科学,架构经验更是一线实战经验的积累和总结。
为了方便大家进一步看清楚架构演变过程,我给画图程序实现了一个所有代码都揉在一起的非 MVC 版本(分支 v01):www/index.htm
它的功能对应我们 “26 | 实战 (一):怎么设计一个“画图”程序?” 这一讲中的最小化的画图程序。这是当时给出的源代码(分支 v26):www/*
可以看到,v01 版本所有代码,包括 HTML+JavaScript,总共也就 470 行左右。所以这是一个非常小的架构实战案例。如果我们进一步减少案例的代码规模的话,可能就不太需要架构思想了。我们不妨对比一下两个版本的差异。一个最基础的对比是代码规模。v26 版本我们分拆了多个文件:
- Model:dom.js(100 行)
- View:view.js(112 行)
- Controllers:accel/menu.js(86 行)creator/path.js(90 行)creator/freepath.js(71 行)creator/rect.js(108 行)
- 总控:index.htm(18 行)
这些文件加起来的代码量大概在 580 行,比 v01 版本多了 110 行。这说明 MVC 架构的价值并不是给我们降低总代码行数。实际上,它关注的重点是如何让我们团队协同作战,让工作并行。怎么让工作并行?这就要求我们实现功能的时候,做到功能自身代码要高内聚,功能间的依赖要低耦合。v26 版本我们把功能分拆为 6 个文件(除了总控 index.htm 不算),可以交给 6 个团队成员来做,平均每个人写 100 行左右的代码。
当然,对于总体代码量 500 行不到的一个程序来说,这多多少少显得有点小题大做。但我们在此之后演进迭代了多个版本,功能越来越复杂,分工的必要性也就越来越大。除了代码规模外,对比 v01 和 v26 版本,我们不妨从这样一些点来看。
- 功能的高内聚。某个功能代码被分散在多少地方。
- 功能间的低耦合。当然 v01 版本所有代码都揉在了一起,我们不妨从如何做系统分解的视角来推演 v26 版本用 MVC 架构的意义。
- 怎么减少全局变量,为控件化做好准备。
结语
在我们介绍完第二章 “桌面开发” 篇的所有内容后,今天我们介绍了架构的第二步:系统的概要设计。在概要设计阶段,我们一般以子系统为维度来阐述系统各个角色之间的关系。对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不作进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。
为了降低风险,概要设计阶段也应该有代码产出。这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。代码即文档。代码是理解一致性更强的文档。
三、服务端开发篇
34 | 服务端开发的宏观视角
今天开始,我们进入第三章,谈谈服务端开发。
服务端的发展史
服务端开发这个分工,出现的历史极短。短得让人难以想象。1946 年,第一台电子计算机问世。1954 年,第一门高级语言 Fortran 发布。整个信息科技发展到今天,大约也就 60~70 年的历史。1974 年,Internet 诞生。1989 年,万维网(WWW)诞生,但刚开始只限于政府和学术研究用途,1993 年才开始进入民用市场。从这个角度来说,服务端开发这个分工,从互联网诞生算起也就 40 多年的历史。真正活跃的时段,其实只有 20 多年。
但其发展速度是非常惊人的。我们简单罗列下这些年来的标志性事件。
1971 年,电子邮件诞生。
1974 年,Internet 诞生。
1974 年,第一个数据库系统 IBM System R 诞生。SQL 语言诞生。
1989 年,万维网(WWW)诞生。
1993 年,世界上第一个 Web 服务器 NCSA HTTPd 诞生,它也是大名鼎鼎的 Apache 开源 Web 服务器的前身。
1998 年,Akamai 诞生,提供内容分发网络(CDN)服务。这应该算全球第一个企业云服务,虽然当时还没有云计算这样的概念。
2006 年,Amazon 发布弹性计算云(Elastic Compute Cloud),简称 EC2。这被看作云计算诞生的标志性事件。
2007 年,Amazon 发布简单存储服务(Simple Storage Service),简称 S3。这是全球第一个对象存储服务。
2008 年,Google 发布 GAE(Google App Engine)。
2009 年,Go 语言诞生。Derek Collison 曾预言 Go 语言将制霸云计算领域。
2011 年,七牛云诞生,发布了 “对象存储 +CDN+ 多媒体处理” 融合的 PaaS 型云存储,为企业提供一站式的图片、音视频等多媒体内容的托管服务。
2013 年,Docker 诞生。
2013 年,CoreOS 诞生。这是第一个专门面向服务端的操作系统。
2014 年,Kubernetes 诞生。当前被认为是数据中心操作系统(DCOS)的事实标准。
通过回顾服务端的发展历史,我们可以发现,它和桌面开发技术迭代的背后驱动力是完全不同的。桌面开发技术的迭代,是交互的迭代,是人机交互的革命。而服务端开发技术的迭代,虽然一开始沿用了桌面操作系统的整套体系框架,但它正逐步和桌面操作系统分道而行,转向数据中心操作系统(DCOS)之路。
服务端程序的需求
这些演进趋势的根源是什么?
其一是规模。桌面程序是为单个用户服务的,所以它关注点是用户交互体验的不断升级。服务端程序是被所有用户所共享,为所有用户服务的。一台物理的机器资源总归是有限的,能够服务的用户数必然存在上限,所以一个服务端程序在用户规模到达一定程度后,需要分布式化,跑在多台机器上以服务用户。
其二是连续服务时长。桌面程序是为单个用户服务的,用户在单个桌面程序的连续使用时长通常不会太长。但是服务端程序不同,它通常都是 7x24 小时不间断服务的。当用户规模达到一定基数后,每一秒都会有用户在使用它,不存在关闭程序这样的概念。
其三是质量要求。每个桌面程序的实例都是为单个用户服务的,有一亿的用户就有一亿个桌面程序的实例。但是服务端程序不同,不可能有一亿个用户就跑一亿个,每个用户单独用一个,而是很多用户共享使用一个程序实例。这意味着两者对程序运行崩溃的容忍度不同。一个桌面程序实例运行崩溃,它只影响一个用户。但一个服务端程序实例崩溃,可能影响几十万甚至几百万的用户。这是不可接受的。一个服务端程序的实例可以崩溃,但是它的工作必须立刻转交给其他的实例重新做,否则损失太大了。
所以服务端程序必须能够实现用户的自动转移。一个实例崩溃了,或者因为需要功能升级而重启了,它正在服务的用户需要转给其他实例来服务。所以,服务端程序必须是多实例的。单个程序实例的临时不可用状态,要做到用户无感知。从用户视角看,服务端程序 7x24 小时持续服务,任何时刻都不应该崩溃。就如同水电煤一样。
服务端开发的体系架构
在 “01 | 架构设计的宏观视角” 这一讲中,我们将一个服务端程序完整的体系架构归纳如下:
这个架构体系,是为了方便你和桌面开发的体系架构建立自然的对应关系而画的。它当然是对的,但它只是从服务端程序的单个实例看的,不是服务端程序体系架构的全部。在 “15 | 可编程的互联网世界” 这一讲中,我们把 TCP/IP 层比作网络的操作系统,一个网络程序的体系架构如下:
一个服务端程序当然也是一个网络程序,它符合网络程序的体系架构。但它也不是服务端程序体系架构的全部。从宏观视角看,一个服务端程序应该首先是一个多实例的分布式程序。其宏观体系架构示意如下:
相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:
- 负载均衡(Load Balance);
- 数据库或其他形式的存储(DB/Storage)。
为什么会需要负载均衡(Load Balance)?为什么会需要数据库或其他形式的存储?你可以留言探讨一下。我们在接下来的几讲将聊聊负载均衡和存储。
结语
今天我们从服务端的发展历程、服务端开发的需求谈起,以此方便你理解服务端开发的生态会怎么演化,技术迭代会走向何方。我们这里探讨的需求和具体业务无关,它属于服务端本身的领域特征。就像桌面的领域特征是强交互,以事件为输入,GDI 为输出一样,服务端的领域特征是大规模的用户请求,以及 24 小时不间断的服务。
这些领域特征直接导致了服务端开发的体系架构和桌面必然是如此的不同。如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊负载均衡(Load Balance)。
36 | 业务状态与存储中间件
相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:负载均衡(Load Balance);数据库或其他形式的存储(DB/Storage)。存储在服务端开发中是什么样的一个地位?今天我们就聊一下有关于存储中间件的那些事情。
业务状态
让我们从头开始。首先我们思考一个问题:桌面程序和服务端程序的相似之处在哪里,不同之处又在哪里?对于这样一个开放性的问题,我们不同人可能有非常不同的答案。今天让我们从数据的视角来看这个问题。
我们知道,一个桌面程序基本上是由一系列的 “用户交互事件” 所驱动。你可以把它理解为一个状态机:假设在 i 时刻,该桌面程序的状态为业务状态i ,它收到用户交互事件i 后,状态变化为业务状态i+1 。这个过程示意如下:业务状态i+1 = F( 用户交互事件i ,业务状态i )
用状态转换图表示如下:
那么,服务端呢?仔细考虑你会发现,其实服务端程序可以用一模一样的模型来看待。只不过它不是由 “用户交互事件” 来驱动,而是由 “网络 API 请求” 所驱动。你同样可以把它理解为一个状态机:假设在 i 时刻,该服务端程序的状态为业务状态i ,它收到网络 API 请求i 后,状态变化为业务状态i+1 。这个过程示意如下:
业务状态i+1 = F( 网络 API 请求i ,业务状态i )
用状态转换图表示如下:
那么,桌面程序和服务端程序的差别在哪?它们最大的差别是业务状态的表示不同。桌面程序的业务状态是如何表示的?内存中的数据结构。我们在上一章中提到,桌面程序的 Model 层是一棵 DOM 树,根结点通常叫 Document。这棵 DOM 树其实就是桌面程序的业务状态。
服务端程序的业务状态如何表示?用内存中的数据结构可以吗?答案当然是不能。如果业务状态在内存中,服务端程序一挂,数据就丢了。前面我们在 “34 | 服务端开发的宏观视角” 提到过:服务端的领域特征是大规模的用户请求,以及 24 小时不间断的服务。
这句话是理解服务端体系架构的核心,至关重要。但某种意义上来说更重要的原则是:坚决不能丢失用户的数据,即他认为已经完成的业务状态。
服务端对用户来说是个黑盒,既然用户收到某个 “网络 API 请求” 成功的反馈,那么他会认为这个成功是确认的。所以,服务端必须保证其业务状态的可靠性。这与桌面程序不同,桌面程序往往需要明确的用户交互事件,比如 Ctrl+S 命令,来完成数据的存盘操作,这时业务状态才持久化写入到外存。而且对于大部分桌面程序来说,它并不需要支持持久化。
存储中间件与容灾级别
在没有存储中间件的情况下,服务端需要自己在响应完每一个网络 API 请求之后,对业务状态进行持久化。听起来这好像不复杂?其实不然,服务端程序的业务状态持久化难度,比桌面程序要高很多。还是同样的原因,桌面程序是单用户使用的,持久化的时候什么别的事情也不干,看起来用户体验也可以接受。
但是对服务端程序而言,如果我们在某个 API 请求完成并持久化的时候,其他 API 请求如果只能排队等着的话,往轻了说服务的吞吐能力太差了;往严重里说,在持久化执行的那个时段,服务端在用户眼里就停止服务了。所以持久化的时间必须要足够短,短到让人感知不到服务停顿。
服务端程序的业务状态并不简单。这是一个多租户的持久化状态。就算一个用户的业务状态数据只有 100K,有个 100 万用户,那么需要持久化的数据也有 100G。这显然不能用“常规桌面程序每次完全重新生成一个新文件”的持久化思路做到,它需要被设计为一种增量式的存储系统。
如果每一个做服务端程序的开发人员需要自己考虑如何持久化业务状态,这个代价显然过高了。于是,存储中间件就应运而生了。从历史上来看,第一个存储中间件是数据库,出现在 1974 年,它就是 IBM System R。
这一年 Internet 刚刚被发明出来。所以数据库的诞生背景,很可能是为工作站服务的,也算网络服务的范畴。桌面程序很少用数据库。只有一些需要增量持久化业务状态的场景会被采用,比较典型的是微信。微信的本地聊天纪录应该是基于数据库存储的,只不过用的是嵌入式数据库,比如 SQLite。
最早期人们对存储中间件的容灾级别要求并不高。数据库都是单机版本,没有主从。人们对存储中间件的诉求是高性能的、稳定的、经过验证的。数据的可靠性如何保证?晚上选个服务的低峰时期对数据库做个离线备份就完事了。对服务端开发来说,数据库的出现是革命性的,它大大提升了开发效率。
但在容灾级别这个事情上,随着互联网的普及,我们对它的要求越来越高。
首先,单机数据库是不够的,需要多机相互热备,这就是数据库主从结构的来由。这样我们就不需要担心数据库单机故障会导致服务临时不可访问,甚至出现更严重的数据丢失。
其次,单机数据库是不够的,单机存储量终归有上限,这样我们服务的用户数就有上限。在分布式数据库出现之前,人们的解决方案是手工的分库分表。总之,业务上我们需要做到规模可伸缩,不必担心单机物理存储容量的限制。
最后,单机房的可靠性也是不够的,机房可能会出现网络中断,极端情况下还可能因为自然灾害,比如地震,导致整个机房的数据丢失。于是就出现了“两地三中心”,跨机房容灾的数据灾备方案。
存储即数据结构
那么问题来了,数据库能够解决所有服务端程序的业务状态持久化需求吗?答案当然是不能。对比桌面程序我们能够知道,业务状态其实就是数据结构。虽然数据库这个数据结构的确通用性很强,但是它不是银弹,在很多场合下它并不适用。
存储即数据结构。
存储中间件是什么?存储中间件就是 “元数据结构”。
这个结论的逻辑在于下面几个方面。
首先,和桌面开发不同,桌面端的数据结构基本上都是基于内存的,实现难度较低。但是在服务端不同。我们每一次的业务状态改变都需要考虑持久化,所以服务端的核心数据结构都是基于外存的。
其次,服务端的数据结构对稳定性要求、并发性能(IOPS)要求极高。简单分析就可以知道,服务端程序的伸缩能力完全取决于存储的伸缩能力。业务服务器往往是无状态的,压力大了新增加一台业务服务器非常容易。但是存储压力大了,并不能简单加一台机器了事,可能涉及数据的重新划分和搬迁工作。
这意味着,在服务端实现一个数据结构是非常困难的。我们举一个很简单的例子,在内存中我们实现一个 KV 存储非常容易,很多语言都有 Dictionary 或者 Map 这样的数据结构来做这事。就算不用库,我们自己花上几十分钟或一个小时来实现,也是非常轻松的一件事情。但是,一个服务端的 KV 存储非常非常复杂,绝非一个人花上一天两天就可以干出来。就算干出来了,也没人敢立刻投入使用,需要经过非常庞大的测试案例进行方方面面的验证,才敢投入生产环境。并且,即使敢投入生产环境了,为了以策万全,刚开始往往也是采用“双写”的方式:同时使用一个成熟存储系统和我们新上线的存储。存储系统的品控,至关重要。
正因为服务端的数据结构实现如此之难,所以对于服务端来说,所有业务需要涉及的数据结构都需要抽象出来,成为一个存储中间件。存储中间件会有多少?
这与服务端开发的模型抽象有关。今天没有比较系统性的理论告诉大家,有了这样一些数据结构就完备了。但是从更长远发展的角度来看,我们很可能需要回答这个问题。所以,存储中间件是 “元数据结构”。
这里说的 “元数据结构”,是我自己发明的一个词。它表达的含义是,数据结构的种类是非常有限的,并且最好理论可被证明,有了这样一些基本的数据结构,所有的业务需求都可以高效地实现。这些基本的数据结构,就是我说的 “元数据结构”。今天我们接触的存储中间件有哪些?不完整的列表如下:
键值存储(KV-Storage);对象存储(Object Storage);数据库(Database);消息队列(MQ);倒排索引(SearchEngine);等等。
目前看,存储中间件的种类是不可枚举的。但它很可能只是受限于我自己的认知,也许有一天我们能够在这个问题上找到更加完美的答案。
结语
今天我们从桌面端程序和服务端程序的业务状态开始,探讨了存储中间件的由来。前面我们在 “34 | 服务端开发的宏观视角” 提到过:服务端的领域特征是大规模的用户请求,以及 24 小时不间断的服务。这句话是理解服务端体系架构的核心,至关重要。但某种意义上来说更重要的原则是:坚决不能丢失用户的数据,即他认为已经完成的业务状态。
存储即数据结构。存储中间件就是 “元数据结构”。对于服务端来说,存储中间件至关重要。它不只是极大地解放了生产效率,也是服务端的性能瓶颈所在。几乎所有服务端程序扛不住压力,往往都是因为存储没有扛住压力。
39 | 存储与缓存
前面接连三讲我们介绍了存储中间件的由来,以及最为常见的存储中间件,如:键值存储(KV Storage)、数据库(Database)、对象存储(Object Storage)。当然,它们并不是全部。常见的存储中间件还有很多,比如消息队列(MQ)、搜索引擎(Search Engine)等等。限于篇幅,我们不能一一对它们进行分析。今天,我们聊一聊缓存(Cache)。
memcached
缓存(Cache)是什么?简单说,缓存是存储(Storage)的加速器。加速的原理通常是这样几种方法:最常见的是用更高速的硬件来加速。比如,用 SSD 缓存加速 SATA 存储,用内存缓存加速基于外存的存储。还有一种常见的方法是用更短的路径。比如,假设某个计算 y = F(x) 非常复杂,中间涉及很多步骤,发生了一系列的存储访问请求,但是这个计算经常会被用到,那么我们就可以用一个 x => y 的内存缓存来加速。
可见,缓存的数据结构从实现上来讲只需要是一个键值存储。所以它的接口可以非常简单:
第一个被广泛应用的内存缓存是 memcached。通常,我们会使用多个 memcached 实例构成一个集群,通过 Hash 分片或者 Range 分片将缓存数据分布到这些实例上。一个典型的 memcached 的使用方式如下:
类似的缓存逻辑大家应该比较经常见到。这个示例我们采用的是简单 Hash 分片的方法,它的好处是非常容易理解。当然不太好的地方在于,一旦我们要对 memcached 集群扩容,countOf(memcaches) 就会变化,导致大量的 key 原先落在某个分片,现在就落到一个新的分片。
这会导致大量的缓存未命中(Cache Miss),也就是 cache.Get(key) 返回失败。在缓存未命中的情况下,FastF(x) 不只是没有加速 F(x),还增加了两次网络请求:cache.Get 和 cache.Set。所以缓存系统的一个核心指标是缓存命中率(Cache Hit Rate),即在一段时间内,FastF 缓存命中的次数 / 所有 FastF 的调用次数。
为了避免 memcached 集群扩容导致缓存命中率大幅降低,一般我们不会用简单哈希分片,而是用一致性哈希。什么情况下需要扩容?一旦缓存命中率趋势下降,且下降到某个阈值,就要考虑给缓存集群扩容。
缓存 vs 存储
通过以上的介绍可以看出,缓存的基础逻辑是非常简单的。问题是:缓存(Cache)和存储(Storage)是什么关系?它也是一种存储中间件么?既是也不是。
首先,缓存和一般的存储中间件一样,也在维持着业务状态。从这个角度看,缓存的确是一类存储。
但是,缓存允许数据发生丢失,所以缓存通常是单副本的。一个内存缓存的集群挂了一个实例,或者一个外存缓存的集群坏了一块硬盘,单就缓存集群本身而言,就出现数据丢失。缓存数据丢失,这事可大可小。只要不是发生大片大片的缓存数据丢失的情形,通常只是会造成后端存储(Storage)的短时压力变大。但在极端的情况下,可能会出现雪崩的情况。
雪崩怎么形成?首先是部分缓存实例宕机,导致缓存命中率(Cache Hit Rate)下降,大量的请求落到后端存储上,导致后端存储过载,也出现宕机。这时就会出现连锁反应,形成雪崩现象。后端存储就算重新启动起来,又会继续被巨大的用户请求压垮,整个系统怎么启动也启动不了。应该怎么应对雪崩?最简单的办法,是后端存储自己要有过载保护能力。一旦并发的请求超过预期,就要丢弃部分请求,以减少压力。
我们在本章开篇第一讲 “34 | 服务端开发的宏观视角” 中,总结服务端开发的体系架构如下:
在这个图中,我们并没有把缓存(Cache)画出来。但结合上面介绍的缓存典型使用方式,我们很容易脑补它在图中处于什么样的位置。回到前面的问题,缓存(Cache)和存储(Storage)到底是什么关系?我个人认为,缓存其实应该被认为是存储的补丁,而且是理论上来说不太完美的补丁。为什么说它是补丁?
因为如果存储本身非常匹配业务场景的话,它不应该需要缓存在它前面挡一道,内部自己就有缓存。至于把一个复杂的 F(x) 缓存起来,更根本的原因还是存储和业务场景不那么直接匹配所致。但是实现一个存储很难,所以存储的业务场景匹配性很难做到处处都很好。出现事务(Transaction),是为了改善存储的业务场景“写操作”的匹配性,把一个复杂操作包装成一个原子操作。
出现缓存(Cache),则是为了改善存储的业务场景“读操作”的匹配性,提升高频读操作的效率。所以我们说,缓存是一个存储的补丁。那么为什么我们说这是一个不太完美的补丁呢?因为上面的 FastF(x) 并没有被包装成一个原子的读操作。从严谨的角度来说,这段代码逻辑是有问题的,它会破坏数据的一致性。
对于一个确定的 x 值,如果 F(x) 永远不变,这就没问题。但如果 F(x) 值会发生变化,会有多个版本的值,那就有可能会出现并发的两个 F(x) 请求得到的结果不同,从而导致缓存中的值和存储中的值不一致。这种情况后果有可能会比较严重。尤其是如果我们有一些业务逻辑是基于 FastF(x) 得到的值,就有可能会出现逻辑错乱。
groupcache
为了避免发生这类一致性问题,memcached 的作者 Brad Fitzpatrick(bradfitz)搞了一个新的内存缓存系统,叫 groupcache。groupcache 基于 Go 语言实现,其 Github 主页为:\thttps://github.com/golang/groupcache
从业务角度,groupcache 主要做了两大变化:
其一,引入 group 的概念。这是一个重要改动,也是 groupcache 这个名字的来由。在同一个缓存集群,可能会需要缓存多个复杂操作,比如 F(x)、G(x)。如果没有 group,那么我们就不能只是记录 x => y 这样的键值对,而是要记录 F#x => y,G#x => y 这样的键值对。中间的 # 只是一个分隔符,换其他的也可以。看起来好像也还可以?其实不然,因为 F(x)、G(x) 在同一个内存缓存集群就意味着它们相互之间会淘汰对方,这里面的淘汰规则不是我们能够控制的,很难保证结果符合我们的预期。
那么有 group 会变成什么样?首先你可以创建 F、G 两个独立的 group,每个 group 可以设定独立的内存占用上限(cacheBytes)。这样,每个 group 就只淘汰自己这个 group 内的数据,相当于有多个逻辑上独立的内存缓存集群。另外,在 group 中只需要记录 x => y 这样的键值对,不再需要用 F#x、G#x 这种手工连接字符串的方式来模拟出名字空间。
其二,值不可修改。一旦某个 x 值 Get 到的值为 y,那么就一直为 y。它的使用方式大体如下:
这当然也就意味着它也不需要引入 memcached 中的缓存失效时间这样的概念。因为值是不会过时的,它只会因为内存不足而被淘汰。一致性问题也被解决了。既然值不可修改,那么自然就不存在一致性问题。当然,groupcache 是一个理论完美的内存缓存系统,它解决了 memcached 存在的一致性缺陷。但是 groupcache 对使用者来说是有挑战的,某种意义上来说,它鼓励我们用函数式编程的方式来实现业务逻辑。但是你也知道,函数式编程是比较小众的。所以怎么用好 groupcache,挑战并不低。
Redis
谈到存储与缓存的关系,不能不提 Redis。Redis 在定位上特别奇怪,以至于不同的人对它的认知并不相同。有的人会认为它是内存缓存,有的人会认为它是存储。Redis 的确可以当作缓存来用,我们可以设置内存上限,当内存使用达到上限后,Redis 就会执行缓存淘汰算法。只不过,如果我们把它当作内存缓存,那么其实它只需要是一个简单的键值存储(KV Storage)就行。
但是 Redis 实际上是 key => document,它的值可以是各类数据结构,比如:字符串,哈希表,列表,集合,有序集合(支持 Range 查询),等等。不仅如此,Redis 还支持执行 Lua 脚本来做存储过程。这些都让 Redis 看起来更像一个数据库类的存储中间件。但当我们把 Redis 看作存储,我们有这样一些重要的问题需要考虑。这些问题非常非常重要,存储系统可不是闹着玩的。
问题一,是持久性(Durability)。Redis 毕竟是基于内存的存储,虽然它也支持定期写到外存中,但是定期持久化的策略对于一个服务端的存储系统来说是不合格的。因为如果发生宕机,上一次持久化之后的新数据就丢了。所以 Redis 需要其他的提升持久性的方案,比如多副本。Redis 的确支持多副本。但是只是同机房多台机器的多副本是没有用的,因为它没有办法防止机房整体断电这类的故障。当出现机房级的故障时,就有极大概率会丢失数据。对于存储系统来说,这是不可接受的。因为相比人们对持久性的要求,机房整体断电并不是一个太小概率的事件。所以 Redis 如果要作为存储的话,必须保证用多机房多副本的方式,才能保证在持久性这一点上能够达标。但是多机房多副本这样的方式,显然实施条件过于苛刻。会有多少企业仅仅是为了部署 Redis 去搞多个机房呢?
问题二,是重试的友好性。在 “29 | 实战(四):怎么设计一个“画图”程序?” 中我们提到过,考虑网络的不稳定性,我们设计网络协议的时候需要考虑重试的友好性。在 Redis 的协议中,有不少请求用户很友好,但是对重试并不友好。比如,LPUSH 请求用来给列表(List)增加一个元素。但是在重试时一个不小心,我们很可能就往列表中添加了多个相同的元素进去。总结来说,Redis 如果我们把它作为存储的话,坑还是不少的。它和 memcached 都是实用型的瑞士军刀,很有用,但是我们站在分布式系统的理论角度看时,它们都有那么一点不完美的地方。
结语
今天我们讨论了存储与缓存之间的关系,也分别介绍了三个模型迥异的缓存系统:memcached、groupcache、Redis。缓存是一个存储系统在服务器性能上的补丁。这个补丁并不是那么完美。
40 | 服务端的业务架构建议
相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:
- 负载均衡(Load Balance);
- 数据库或其他形式的存储(DB/Storage)。
我们前面几讲已经介绍了负载均衡和常见的存储中间件。今天,让我们就把焦点放在上图中的业务架构上。大方向来说,业务架构必然是领域性的,与你所从事的行业息息相关。但就如同桌面程序会有自己的架构体系的套路一样,服务端的业务架构也会有自己的套路。
在第二章 “24 | 跨平台与 Web 开发的建议” 这一讲中,我们概要地画过服务端的体系架构,如下图所示。
在图中,我们把服务端分成了两层。底层是 Multi-User Model 层,一般情况下它对外提供了一套 RESTful API 接口。上层是 Web 层,对外提供 Web API。Web 层又分为 Session-based Model 层和 Session-based ViewModel 层。一般来说,Session-based Model 是一个非常简单的转译层。而在胖前端的模式下,Session-based ViewModel 层也几乎没有任何后端的代码,就是一些托管的资源文件,包含一些 HTML + CSS + JavaScript 文件。
我个人会倾向于认为,Session-based ViewModel 层属于桌面开发的范畴,哪怕是胖后端的模式下也会这样去归类。只不过在胖后端的方式下,桌面程序的很多逻辑不再是由 JavaScript 完成,而是由类似 PHP 之类的语言完成。故此,我们今天探讨的业务架构,主要谈的是 Multi-User Model 层。
网络协议
探讨 Multi-User Model 层,第一个重要话题是网络协议,它是服务端程序的使用界面(接口)。考虑到这一层网络协议往往提供的是 RESTful API,所以有时它也会被称为 RESTful API 层。大家可能经常听到 RESTful,但它到底代表什么?所谓 RESTful,是指符合 REST 原则。REST 的全称是 “Representational State Transfer”。它强调的是:
第一,客户端和服务器之间的交互在请求之间是 “无状态” 的。这里的无状态更严谨的说法是 “无会话(Session)” 的,从客户端到服务器的每个请求,都必须包含理解请求所必需的完整信息。服务器可以在请求之间的任何时间点重启,客户端不会得到通知。在 “36 | 业务状态与存储中间件” 这一讲中,我们把桌面程序和服务端程序都看作一个状态机。桌面程序的状态转化由 “用户交互事件” 所驱动,如下图。
而服务端程序的状态转化由 “网络 API 请求” 所驱动,如下图。
但是从状态转化角度来说,桌面程序和服务端程序很不一样。桌面程序的状态转化往往存在中间的 “临时状态”,这其实也是 Controller 层的价值所在。
在桌面程序的 MVC 架构中,Model 层提供核心业务,它不存在 “临时状态”,每一个对外提供的接口(API)都完成一项完整的业务。View 层提供呈现,和我们的话题关联不大,这里不展开来讲。Controller 层负责把 “用户交互事件” 翻译成 Model 层的业务 API。在 Controller 层往往存在 “临时状态” 的,它需要把多个连续的 “用户交互事件” 组装起来完成一项业务。我们第二章实战的 “画图” 程序,它的各类 Controllers,比如 FreePathCreator、RectCreator 等等,都是很好的例子。
服务端程序的状态转化,并不存在 “临时状态”。也就是说,它是 “无会话(Session)” 的,每个 “网络 API 请求” 都包含了实现一个业务的完整参数。而这,正是 REST 原则所强调的。这也是我们把服务端程序看作是 Model 层的原因。如果存在会话(Session),这就意味着服务端也需要实现 Controllers,这样就太糟糕了。
REST 原则第二个强调的点,是统一的表现规范,也就是 Representational 一词传递的意思。它认为,所有网络 API 请求都应该统一抽象为对某种资源 URI 的 GET、PUT、POST、DELETE 操作。由于 RESTful API 简单明了,易于理解和实施,今天已经基本成为事实上的网络 API 的定义规范。当然,RESTful API 显然并不是唯一选择。比如,基于 XML 的有 SOAP(简易对象访问协议)、WSDL(Web 服务描述语言)等。
还有一些人会觉得基于文本协议效率不够好,所以用二进制的协议。比如,Facebook 早年搞了个 thrift,不过 Facebook 自己应该不怎么用了。而 Google 也搞了个 protobuf 协议,并且基于 protobuf 搞了一个 grpc 框架。还有一个选择是 GraphQL,它推崇企业在有多个业务的时候,不要发布很多套 RESTful API,而是基于一个统一的数据图,并通过 GraphQL 协议暴露给开发者。
\t目前来看,GraphQL 理念虽然先进,但是概念复杂,并不易于掌握,现在仍然处于不温不火状态。知乎甚至有一帖讨论 GraphQL 为何没有火起来?这么多选择,应该怎么选?我的答案大家已经知道了,我个人还是倾向于 RESTful API。虽然 GraphQL 值得关注,但是目前来看,它的投入产出比还远没有达到让人放弃简洁的 RESTful API 的地步。
至于二进制协议,虽然理论上效率更高,但是考虑到 HTTP 协议的江湖地位,各路豪杰纷纷贡献自己的智慧,提供支撑工具和效率优化,它实际的效率并不低。只有 HTTP 协议,才有被广泛采纳的专门的应用层网关,比如 nginx 和 apache。这一点千万不要忘记。
就拿 Google 的 grpc 来说,它其实也是基于 HTTP 协议的,只不过它更推荐 HTTP 2.0,因为效率已经经过高度的优化。所以虽然 protobuf 是二进制的,但它取代的不是 HTTP 协议,而是 json、xml 或 Web 表单(form)。这可能也是 protobuf 还很活跃,而 thrift 已经半死不活的原因。凡是想对 HTTP 协议取而代之的,都会挂掉。一旦确定我们要用 RESTful API,还是用 protobuf,剩下的就是如何定义具体的业务 API 了。这块是具体的领域相关内容,这里先略过。
授权(Authorization)
确定好我们要选择什么样的网络协议,我们第二个要考虑的是授权(Authorization)。当前,主流的授权方式有两种:一种是基于 Token,一种是基于 AK/SK。这两种授权方式的场景非常不同。基于 AK/SK 的授权,多数发生在面向企业用户提供 API,也就是说提供的是一个 To B 的云服务。如果大家经常使用各类云计算服务,对 AK/SK 这类授权应该并不陌生。
AK/SK 授权的背后是数字签名。我们强调一下,AK/SK 并不是公私钥。实际上 AK 是密钥提示(keyHint),SK 是数字签名的密钥(key)。关于数字签名的原理,你可以回顾一下 “16 | 安全管理:数字世界的守护” 这一讲中的内容。
基于 Token 的授权,多数发生在面向终端用户的场景,也就是我要做一个 To C 的应用。当前推荐的 Token 授权标准是 OAuth 2.0,它得到了广泛的支持,大家如果有在使用各类 C 端应用程序的开放接口,会发现他们往往都是基于 OAuth 2.0 的(有的还会同时支持 OAuth 1.x 版本)。
OAuth 2.0 的优势是对外提供 Open API,而不仅仅局限于自己的 App 用。OAuth 2.0 提供了一个很好的方式,能够让我们的客户不用向第三方应用去暴露自己的用户隐私(比如用户名和密码)的前提下,调用 API 来使用我们的服务。
所以总体来说,授权这块的选择是相对简单的。我们更多要考虑的,反而是如何构建业务无关的用户帐号体系和授权系统。它们隶属于通用的帐号与授权子系统,可以做到与业务无关。后面在本章的实战案例中,我们会对这块内容进一步展开。
RPC 框架
明确了授权机制,确定了业务 API,那么下一步就是怎么实现的问题了。如果业务 API 选择了基于 protobuf,那么 grpc 框架是个不错的选择。对于 RESTful API,七牛云对外开源了一套非常精简的 restrpc 服务器框架,其 Github 主页为:
这个 restrpc 框架主要的特点有:
- URL 路由(URL Route)。支持用手工写 URL 路由表,也支持由 restrpc 框架自动实现路由。
- 参数的解析。可以支持 json、Web 表单(form)等格式的解释。对于其他格式对数据,可以由用户自己来解释。
- 返回值的序列化。默认序列化为 json,如果需要,用户也可自己做序列化。
- 授权(Authorization)。以开放框架的方式实现授权机制,以便用户可以选择自己的授权方式。
- 适度的开放机制。我们主要为了实现开放的授权机制而开放,但这个开放机制可以用来做各类扩展,而不只是局限于授权。
这里我们给了一个 restrpc 框架的使用样例:examples/authrestrpc
为了简化,这个样例用的是一个 mock 的授权机制。这种 mock 授权非常适合用来做业务系统的单元测试。这个样例我们采用由 restrpc 框架自动实现路由的方式。这样可以减少一些代码量,但是对路由 API 对应的实现方法的名字有要求,看起来不是那么美观。如果不喜欢可以采用手工路由方式。具体怎么做,后面我们的实战案例会有体现。
单元测试
另外,这个样例我们的单元测试采用了七牛开源的 httptest 框架。其 Github 主页为:
https://github.com/qiniu/httptest
这个 httptest 框架,最核心的逻辑是如何在不用写业务 API 的 Client SDK 的情况下,能够保持业务友好的方式来写测试案例。它不只可以做单元测试,也可以做集成测试。你可以通过下面这个演讲稿来了解它的核心思想:http://open.qiniudn.com/qiniutest.pdf
这个 httptest 框架是非常通用的,所以它没有内建任何公司特有的授权机制。在七牛,我们会基于更贴近七牛自身业务的 qiniutest 进行测试。qiniutest 工具只是在 httptest 基础上作了少量的扩展,其 Github 主页为:
https://github.com/qiniu/qiniutest
你可以依葫芦画瓢,实现一个适合你们公司的授权机制下的 httptest 工具。在本章的实战案例中,我们也会让大家看到如何基于 httptest 来进行业务的单元测试。
总结
我们总结一下今天的内容。服务端业务架构,主要是怎么做一个多租户的 Model 层。Model 层本身最重要的是自然体现业务逻辑,它和具体的行业的领域问题相关,对此我们无法进一步展开。但服务端程序还是有它很鲜明的特点。
今天我们重点讨论了服务端业务架构相关的通用问题。包括:网络协议、授权、RPC 框架、单元测试等等。当然其实还有一个问题,就是选什么样的存储中间件。它和具体的业务特征更为相关,这一点在后面我们实战案例中再做探讨。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。我们服务端开发相关的内容就暂时告一段落,下一讲开始我们进入实战。结束实战后,我们会结合实战对服务端开发的架构做一个总结。然后我们进入服务端的另一半:如何做好服务的运维,甚至也会涉及少量的运营相关的话题。
45 | 架构:怎么做详细设计?
今天我们把话题重新回到架构上。关于架构,前面我们已经聊了第一步的需求分析和第二步系统的概要设计:
17 | 架构:需求分析(上)
18 | 架构:需求分析(下)- 实战案例
32 | 架构:系统的概要设计
需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义将明确产品的元素,明确产品的边界,与产业上下游、合作伙伴的分工。在需求分析阶段,我们关注用户需求的精确表述。我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。
在概要设计阶段,我们一般以子系统为维度来阐述系统各个角色之间的关系。对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不做进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。为了降低风险,概要设计阶段也应该有代码产出。
这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。代码即文档。代码是理解一致性更强的文档。经过系统的概要设计,整个系统的概貌就了然于胸了。详细设计阶段,是需要各个子系统或模块的负责人,对他负责的部分进行进一步的细化。
详细设计关注的是子系统或模块的全貌。请记住,详细设计并不是只谈实现就完事,更不是一个架构图。它包括以下这些内容。
- 现状与需求现在在哪里,遇到了什么问题,要做何改进。
- 需求满足方式要做成啥样?交付物的规格,或者说使用界面(接口)。怎么做到?交付物的实现原理。
概要设计和详细设计的工作内容会有一定的重叠。概要设计的核心目标是串联整个系统,消除系统的重大风险。在这个过程中,对一些关键模块的实现细节有所考虑是非常正常的。但从另一个角度来说,分解粒度也不能过粗,不应该把特别庞大的子系统直接分出去,这样项目执行的风险就太高了。但两者的分工不同,考虑的问题重心不同。
比如,从使用界面(接口)来说,概要设计不一定会把子系统或模块的完整接口都列出来,实际上它只关注最核心的部分。但是从详细设计角度来说,接口描述的完备性是必需的。
现状和需求
我们先看看现状与需求。从逻辑自洽的角度,我们任何一篇文档,首先关注的都应该是要解决的问题与目标。现状与需求的陈述,要简明扼要。
现状大家都知道,所以不要长篇累牍。更多的是陈述与我们要做的改变相关的重要事实,侧重点在于强调这些事实的存在性和重要性。比如,假设我们要对某个模块重构。那么,现状就是要谈清楚现在的业务架构是怎样的?它到底有什么样的问题。需求陈述是对痛点和改进方向的一次共识确认。痛点只要够痛,大家都知道,所以同样不需要长篇累牍。
每个子系统或模块,都有自己的角色分工与用户故事。我们不用重新做一遍需求分析,但对需求分析的核心结论,在详细设计开始之前需要明确。这很重要。它是我们详细设计所要满足的业务目标。
使用界面(接口)
聊完了现状与需求,接着我们就要谈需求的满足方式。它分两个方面:一方面是交付物的规格,或者说使用界面(接口)。另一方面是背后的实现原理,我们怎么做到的。规格,或者说使用界面,体现的是别人要怎么使用我。
我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也要有自然的延续性。使用界面这一部分要详细写,它是团队共识确认的关键。
我们的交付物有哪些可执行文件,有哪些包(package)?如果可执行文件,那么它是一个界面程序,还是服务?如果是服务,网络协议是什么样的?如果是包,它又包含哪些公开的类或函数。
在 “32 | 架构:系统的概要设计” 这一讲中,我们花了非常长的篇幅介绍使用界面(接口)是怎么回事,今天我们就不对这一点进行展开。需要强调的是,使用界面需要有明确的书写规范。它也是团队共识管理的重要组成,是团队效率、团队默契形成的象征。
更需要强调的是,使用界面的稳定是至关重要的。接口的变更需谨慎!对使用界面的不兼容调整,可能出现严重的后果。技术上,可能会导致客户异常,出现编译失败需要重写代码,或者更严重的是,可能导致他们的系统崩溃。商业上,则可能导致大量的客户流失。
实现:数据结构 + 算法
聊完使用界面,接下来就要谈实现原理了,它要体现的是我如何做到。在 “42 | 实战(二):“画图”程序后端实战” 一讲中,我们提到过以下这个大家耳熟能详的公式:
程序 = 数据结构 + 算法
它是一个很好的指导思想。当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。我们先看数据结构。数据结构从大的层面分,可分为基于内存的数据结构,和基于外存(比如 SSD 盘)的数据结构。
对于桌面程序,大部分情况下我们打交道的都是基于内存的数据结构。外存数据结构也会有所涉及,但往往局限于 IO 子系统。但对于服务端程序,数据结构不完全是我们自己能够做主的。数据结构大部分情况下都是基于外存的,而且有极高的质量要求。
在 “36 | 业务状态与存储中间件” 这一讲中我们也说过,存储即数据结构。所以,服务端程序在数据结构这一点上,最为重要的一件事是选择合适的存储中间件。然后我们再在该存储中间件之上组织我们的数据。
这是数据库这样的存储中间件流行起来的原因。无论是关系型数据库,还是文档型数据库,他们都被设计为一种泛业务场景的数据结构,有很好的业务适应性。
所以在服务端我们谈数据结构,谈的不是内存数据结构,往往谈的是数据库的表结构设计。当然表(Table)是在关系型数据库中的说法,在 mongodb 中我们叫集合(Collection)。但不管我们用的是哪种数据库,出于惯例我们往往还是以 “定义表结构” 一词来表达我们想干什么。描述表结构,核心需要包含以下内容:
- 字段名;
- 类型;
- 字段含义,以及是否指向另一个表的某个字段;
- 索引。
你会发现,其实定义表结构和定义内存数据结构本质是完全一致的。定义内存中的一个类(或结构体),我们也关心字段名(成员变量名)和类型,也关心字段的含义,以及它是否指向另一个类(或结构体)的某个字段(成员变量)。但表结构比内存数据结构多了一个概念:索引。
索引为何存在?我认为有这样几方面的原因。一方面是因为数据库是泛业务场景的通用数据结构,它是动态的,需要依赖索引来提升数据访问的效率。另一方面是因为多租户。多租户导致数据量的爆发式增长,导致大部分情况下遍历查找变得不现实。索引怎么设计?它完全取决于算法。算法里面使用了哪些数据访问的特征,这些数据访问的频次预期是多少,这些决定了我们添加哪些索引是最划算的。
在涉及的类比较多,或数据库的表结构比较复杂的时候,有时我们会用 UML 类图来对数据结构进行直观的呈现。谈清楚了数据结构,我们接着聊算法。
在 “程序 = 数据结构 + 算法” 这个说法中,“算法” 指的是什么?在 “42 | 实战(二):“画图”程序后端实战” 一讲中,我们这么说:
在架构过程中,需求分析阶段,我们关注用户需求的精确表述,我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。
到了详细设计阶段,角色和用户故事就变成了子系统、模块、类或者函数的使用界面(接口)。我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也有自然的延续性。
所以算法,最直白的含义,指的是用户故事背后的实现机制。数据结构 + 算法,是为了满足最初的角色与用户故事定义,这是架构的详细设计阶段核心关注点。
那么,怎么描述一个用户故事对应的算法?一种方式是基于 UML 时序图(Sequence Diagram)。以下是我个人用过的很好的在线版 UML 时序图制作工具:https://www.websequencediagrams.com/
另一种方式是基于伪代码(Pseudo Code)。在逻辑较为复杂时,伪代码往往有更好的呈现效果。比如,服务端程序对数据库的 SQL 操作往往比较复杂,但是从 UML 时序图来说流程却并不长,这个时候去画 UML 时序图的意义就不大。
总结
今天我们聊的是怎么做详细设计。详细设计并不是只谈实现就完事,更不是一个架构图。它包括以下这些内容。
现状与需求现在在哪里,遇到了什么问题,要作何改进。需求满足方式要做成啥样?交付物的规格,或者说使用界面(接口)。怎么做到?交付物的实现原理。
“程序 = 数据结构 + 算法” 是我们很熟悉的一个公式。它其实是怎么描述实现原理的很好的指导方针。当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。
