开篇词 | 怎样成长为优秀的软件架构师?

作者: 许式伟

完成时间:

总结时间:

你好,我是许式伟。从今天起,我想和你一起来聊聊架构的话题。

开始之前,我先来和你简单介绍下我自己。

我是2000年开始工作的,曾经做过WPS的首席架构师,也在盛大从事过技术研究方面的工作,后来在2011年创立了七牛云,现在我是一名创业者、CEO。但不管角色怎么轮换,我觉得我的另一面始终是一名程序员、架构师(如果你想了解更多我的经历,可以观看下面的短视频)。

让我们来想象一下,如果把信息世界看成一座大厦,把程序员看成这个世界的建筑师,那么,现在的你在负责什么样的工作呢?

当我们把程序员类比成建筑师时,按照能力水平来分,我觉得大体可以分为三个层次:搬砖师、工程师、架构师。

软件搬砖师之名对应到建筑行业的建筑工人,他们的编程能力和业务基本上停留在堆叠代码,按照要求去实现功能需求的层面。

只要能让程序跑起来,能正确地实现业务逻辑,就可以称为“会编程”的人。有时候,我们也会看见程序员自称为“码农”“搬砖的”,虽然二者的工种不同,但从基础工作的相似度来说,确实有可类比的成分。

很多外行的人都会觉得程序员是一个很神秘的职业,但实际上程序员的基础门槛并不算高。我自己从2016年2月开始至今,一直在教几位8~12岁的小朋友学习编程。这个实践经验告诉我:小学生完全有能力学编程。而且,并不是只有部分小学生可以,而是任何一位小学生都可以学会。

然而,只让代码跑起来是不够的。这个世界是不断变化的,作为程序员,我们更多的时间是用来维护代码:增加新的需求,对已有的功能进行调整,修改之前代码遗留下来的问题,优化性能等等。

这是因为一个软件诞生之后,后续就是需要花费大量的代价去维护它,演进它。一个人是完全维护不过来的,需要更多的人,很多的团队一起协作。如果面临了员工离职、岗位调整等情况,还会导致软件代码在不同人之间流转。

所以,一些有追求的程序员会关注代码的质量。代码质量的评判可以有这样一些基本维度:可阅读性(方便代码流转)、可扩展性/可维护性(方便修改功能,添加新功能)、可测试性(质量管理)、可复用性(简化后续功能开发的难度)。

这一类致力于不断提升软件代码的工程质量的程序员,我们可以称他们为软件工程师。

工程师不会简单把写代码看作一门工作,把任务交代过去就完事。他们会有“洁癖”,代码在他们眼里是一种艺术,是自己生命的一部分。

他们会把写出来的代码改了又改,直到让自己满意为止。阅读和维护软件工程师写的代码会有一种赏心悦目的感觉。

但是,大部分商业软件都是一项极其复杂的工程,它们远比很多传统的建筑工程复杂得多,无论是涉及的人力、时间还是业务的变数都要多很多。

人力上,大部分大型的软件系统都有几千甚至几万人的规模,而这几千几万人中,却没有两个人的工作是重复的,他们都是在从事着前所未有的创造性工作。

时间上,只要软件还在服务客户中,程序员们的创造过程便不会停止,软件系统仍然持续迭代更新,以便形成更好的市场竞争力。

这些都与传统建筑工程的模式大相径庭。一幢建筑自它完成之后,所有的变化便主要集中在一些软装的细节上,很少会再发生剧烈的变动,更不会持续地发生变动。但软件却不是这样,它从诞生之初到其生命周期结束,自始至终都在迭代变化,从未停止。

所以,光靠把控软件工程师的水平,依赖他们自觉保障的工程质量,是远远不够的。软件工程是一项非常复杂的系统工程,它需要依赖一个能够掌控整个工程全局的团队,来规划和引导整个系统的演变过程。这个团队就是架构师团队。

软件架构师的职责,并不单单是我们通常理解的,对软件系统进行边界划分和模块规格的定义。

从根本目标来说,软件架构师要对软件工程的执行结果负责,这包括:按时按质进行软件的迭代和发布、敏捷地响应需求变更、防范软件质量风险(避免发生软件质量事故)、降低迭代维护成本。

那怎么才能成长为优秀的软件架构师?软件架构师和软件工程师最根本的差别又在哪里?我认为关键在于四个字:掌控全局。

掌控全局,就是对系统的全貌了然于胸。从传统的建筑工程来说,建筑架构师并不单单要会画建筑图纸,而是要对地基构建、土质、材料、建筑工艺等等所有有可能影响建筑质量的因素都要了然于胸。

掌控全局,并不是无所不能,不是成为全栈。怎么做到掌控全局?核心在于对知识脉络的体系化梳理。这是架构能力构建和全面提升的关键。这种方法不单单是在软件工程中适用。

比如学数学,我个人非常喜欢做的一件事情是自己去推导书上所有的公式。每一个公式我都亲自推导而来。

这样做的核心意义在于,我在尝试从0开始,去构建整个精彩纷呈的数学世界,整个数学发展史在自己的笔下重新演绎了一遍,来龙去脉清清楚楚。有时候你甚至会推导出还没有学到的公式,但是在后面学到了。这种体验非常有趣而又让人满足。

是的,掌控全局的前提是:在自己心中去重新构建出整个世界。在这个过程中,你不需要一上来沉浸在某个技术的实现细节(除非它影响了你对这个世界构建过程的理解),但是你知道整个世界的脉络,知道整个世界的骨架。

这个时候,你对这个世界的感觉是完全不同的,因为,你已经成为了这个世界的构建者。

而架构的本质,不也正是构建和创造么?

作为一个软件行业的从业人员,我们可能接触各种各样的技术书籍。有讲编程语言的、讲数据结构与算法的、讲操作系统的、讲编译原理的、讲架构设计的,还有领域技术类的(比如数据库、存储、大数据、人工智能之类)。

大部分类别的技术书,多多少少都能够找到几本经典著作。但是,架构设计很可能是个例外,当我想推荐一本经典的架构设计书时,我并不能非常快速地想到应该推荐哪本。

从个人经验来说,我接触过的与架构相关的图书,大概有如下这些分类。

  • 架构思维类。这类图书通常从一些著名的架构理论讲起,比如开闭原则、单一职责原则、依赖倒置原则、接口分离原则,等等。这种图书的问题在于过度理论化。计算机科学归根到底属于工程技术类,实践第一。
  • 设计模式类。这一类图书则一下子进入架构的局部细节,每个模式的来龙去脉并不容易理解。就算理解了某个具体的模式,但是也很难真正做到活学活用,不知道还是不知道。
  • 分布式系统架构设计类。这类图书通常从服务端的通用问题如一致性、高可用、高并发挑战等话题讲起,讲大型业务系统面临的挑战。这些知识是非常有价值的,但无法延伸到通用业务架构,对大部分企业的架构实践并不具备真正的指导意义。
  • 重构类。这类图书主要讲怎么把坏代码一步步改进到好代码。我认为这是最实用的一类。但在没有优秀架构师主导的情况下,大部分公司的代码不可避免地越变越坏,直到不堪重负最后不得不重写。实际上,一个模块最初的地基是最重要的,基本决定了这座大厦能够撑多久,而重构更多侧重于大厦建成之后,在服务于人的前提下怎么去修修补补,延长生命。

这些架构类的图书并没有达到我个人的期望。因为它们都没有揭开架构设计的全貌。

我自己在职业生涯中前后大概做过十几次的架构类演讲,这也是我最为重视、重复次数最多的一类演讲。但同样地,这样零星的演讲对于传递架构设计思想来说,仍然远远不够。

所以一直以来,我就心存着这样一个念头:“要写一本不一样的架构类图书”。这个念想,也正是今天这个专栏的由来。

这个专栏的内容组织算是我的一次尝试。它和今天你看得到的大部分架构书并不太一样。我基本上围绕着两个脉络主线来展开内容:

  • 如何从零开始一步步构建出整个信息世界;
  • 在整个信息世界的构建过程中,都用了哪些重要的架构思维范式,以及这些范式如何去运用于你平常的工程实践中。

这两大脉络相辅相成。首先,我们通过还原信息世界的构建过程,剥离出了整个信息世界的核心骨架,这也是最真实、最宏大的架构实践案例。其次,我们结合这个宏大的架构实践来谈架构思维,避免因对架构思维的阐述过于理论化而让人难以理解。

我想,每个程序员都有一颗成为架构师的心。所以,从内容设计来说,我希望这是一个门槛最低的架构设计专栏,也希望它可以帮助到想成为架构师的初学者,达成自己的目标。

在行文上,我会尽量避免深奥的术语,尽可能以通俗易懂的文字,来描述信息世界构建者们的所思所想。如果你在阅读的过程中遇到了理解上的障碍,非常欢迎你来给我留言,我将尽可能地根据你的反馈,做出必要的调整。

如果你已经成为了架构师,我也希望可以为你规避一些错误的经验。在过去的工作经历里,我看到不少架构师都会倾向于把架构看作一项纯技术性的行为。他们的工作流程是这样的:产品经理根据用户的需求做出产品设计,然后架构师再依据产品设计给出实现,也就是软件的架构设计方案。

在我看来,这其实是个误解。架构关乎的是整个复杂的软件工程,它关乎实现它的人,它又因团队的能力而异。

同时,架构也关乎用户需求,作为架构师,我们不只是要知道当前的用户需求是什么,我们还要预测需求未来可能的变化,预判什么会发生,而什么一定不会发生。预测什么不会发生最为重要,只有做到这一点,才能真正防止架构的过度设计,把简单的事情复杂化。

谈了这么多,那么,应该怎样成长为优秀的软件架构师?**我想,一靠匠心,二靠悟心。**架构设计并无标准答案,但我仍然希望把我这些年的所思所想分享给你,更希望这些内容能给你一些启发。

| 架构设计的宏观视角

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。今天我们来谈谈架构设计的宏观视角。

在信息科技高度发展的今天,我们每个人随时随地都可以接触到由程序驱动的智能电子设备,包括手机(如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 语言?这些都是架构的一部分。

结语

今天,我们从“计算机是如何工作”开始,一起登高鸟瞰,总览了程序完整的架构体系。

可能有人看到今天的内容心里会有些担心:“原来架构师要学这么多东西,看来我离成为架构师好远。”

好消息是:我们就是来打消这个担心的。如果我们把写代码的能力比作武功招式,那么架构能力就好比内功。内功修炼好了,武功招式的运用才能得心应手。

**而架构能力的提升,本质上是对你的知识脉络(全身经络)的反复梳理与融会贯通的过程。**具备架构思维并不难,而且极有必要。不管今天的你是不是团队里的一位架构师,对任何一位程序员来说,具备架构思维将会成为让你脱颖而出的关键。

这就像你没有从事云计算行业,但是你仍然需要理解云计算的本质,需要驾驭云计算。你也不必去做出一个浏览器,但是你需要理解它们的思考方式,因为你在深度依赖于它们。

接下来我们将进一步展开来谈这个程序架构体系里面的每一个环节。你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 大厦基石:无生有,有生万物

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在上一讲中,我们把“构建一个应用程序”类比成“构建一座大厦”,并从宏观全局的视角剖析了应用程序这座大厦的构成。今天,我们将更加近距离地去解剖这座大厦的地基:冯·诺依曼体系结构。

解剖架构的关键点是什么?

在解剖之前,我想和你先谈谈“解剖学”:我们应该如何去分析架构设计中涉及的每一个零部件。换一句话说,当我们设计或分析一个零部件时,我们会关心哪些问题。

**第一个问题,是需求。**这个零部件的作用是什么?它能被用来做哪些事情?(某种意义上来说更重要的是)它不会被用来做哪些事情?

你可能会说,呀,这个问题很简单,既然我设计了这个零部件,自然知道它是用来干嘛的。但实质上这里真正艰难的是“为什么”:为何这个零件被设计成用来干这些事情的,而不是多干一点事情,或者为什么不是少干某些事情?

**第二个问题,是规格。**这个零部件接口是什么样的?它如何与其他零件连接在一起的?

规格是零部件的连接需求的抽象。符合规格的零部件可以有非常多种可能的实现方案,但是,一旦规格中某个条件不能满足了,它就无法正常完成与其他零件的连接,以达到预期的需求目标。

规格的约束条件会非常多样化,可能是外观(比如形状和颜色),可能是交互方式(比如用键盘、鼠标,或者语音和触摸屏),也可能是质量(比如硬度、耐热性等等)。

那么,冯·诺依曼体系结构的需求和规格又是什么样的呢?

为“解决一切的问题”而生

冯·诺依曼体系结构不但是应用程序这座大厦的地基,同时也是整个信息科技的地基。

当我们去审视整个信息科技时,仅把它形容为一座大厦显得如此不贴切,甚至你也不能用“一个城市”去形容它,事实上,它更像是一个无中生有的全新世界:在其中,有个体、有族群、有生态,还有喜怒哀乐。

冯·诺依曼体系结构的迷人之处在于,从需求来说,它想解决一切问题。解决一切可以用“计算”来解决的问题。

“计算”的边界在哪里?今天我们还没有人能够真正说得清。计算能不能解决“智能”的问题?通过计算能力,计算机是否终有一天可以获得和人类一样的智能?

今天人工智能热潮的兴起,证明对于这个问题我们很乐观:计算终将解决智能的问题。尽管我们不能确定什么时候能够达到,但是让人欣慰的是,我们一直在进步 —— 如果人类智能无法完成进一步的进化,那么我们就一直一直在前进,最终无限逼近甚至超越人类智能。

甚至有科幻小说家设想(例如在Google的“AlphaGo”大热后,霍炬和西乔创作的漫画“BetaCat”),计算机演进出超过人类的智能是生物进化的一个自然演进路径,它将取代人类成为新的食物链顶端,并最终基于其悠久的生命力,去完成人类有限生命无法实现的星际航行之路。

冯·诺依曼体系的规格

为了实现“解决一切可以用‘计算’来解决的问题”这个目标,冯·诺依曼引入了三类基础零部件:

  • 中央处理器;
  • 存储;
  • 输入输出设备。

首先我们来看看存储。它负责存放计算涉及的相关数据,作为计算的输入参数和输出结果。

我们日常见到的存储设备非常的多样化。比如:中央处理器自己内置的寄存器、内存、传统机械硬盘、USB固态硬盘、光盘等等。

从中央处理器的角度,存储可简单分为两类:一类是内置支持的存储,通过常规的处理器指令可直接访问,比如寄存器、内存、计算机主板的ROM。一类是外置存储,它们属于输入输出设备。中央处理器本身并不能直接读写其中的数据。

冯·诺依曼体系中涉及的“存储”,指的是中央处理器内置支持的存储。

我们再来看看输入输出设备。它是计算机开放性的体现,大大拓展了计算机的能力。每个设备通过一个端口与中央处理器连接。通过这个端口地址,中央处理器可以和设备进行数据交换。数据交换涉及的数据格式由设备定义,中央处理器并不理解。

但这并不影响设备的接入。设备数据交换的发起方(设备使用方)通常理解并可以解释所接收的数据含义。为了方便使用,设备厂商或操作系统厂商通常会提供设备相关的驱动程序,把设备数据交换的细节隐藏起来,设备的使用方只需要调用相关的接口函数就可以操作设备。

最后我们来看看中央处理器。它负责程序(指令序列)的执行。指令序列在哪里?也存放在存储里面。计算机加电启动后,中央处理器从一个固定的存储地址开始执行。

中央处理器支持的指令大体如下(我们在第一篇文章中也曾提到过):

  • 计算类,也就是支持我们大家都熟知的各类数学运算,如加减乘除、sin/cos等等;
  • I/O类,从存储读写数据,从输入输出设备读数据、写数据;
  • 指令跳转类,在满足特定条件下跳转到新的当前程序执行位置、调用自定义的函数。

和“解决一切可以用‘计算’来解决的问题”这个伟大的目标相比,冯·诺依曼体系的三类零部件的规格设计显得如此精简。

为什么这么简洁的规格设计,居然可以解决这么复杂的需求?

需求是怎么被满足的?

我们来设想一下:假如今天让我们从零开始设计一个叫电脑的东西,我们的目标是“解决一切可以用‘计算’来解决的问题”。

对于这么含糊的需求,如果你是“电脑”这个产品的主架构师,你会如何应对?

让我们来分析一下。

一方面,需求的变化点在于,要解决的问题是五花八门包罗万象的。如何以某种稳定但可扩展的架构来支持这样的变化?而另一方面,需求的稳定之处在于,电脑的核心能力是固定的,怎么表达电脑的核心能力?

电脑的核心能力是“计算”。什么是计算?计算就是对一个数据(输入)进行变换,变为另一个数据(输出)。在数学中我们把它叫“函数”。如下:

y = F(x)

这里 x、y 是数据。它们可能只是一个简单的数值,也可能是文本、图片、视频,各种我们对现实问题进行参数化建模后的测量值,当然也可能是多个输入数据。但无论它的逻辑含义为何,物理上都可以以一段连续的字节内容来表达。用 Go 的语法表达就是:

func F(x []byte) (y []byte)

那么 x、y 物理上在哪里?思路推理到这里,“存储” 这个概念自然就产生了:存储,就是存放计算所要操作的数据的所在。

下面的问题是:一个具体的计算(也就是 F 函数)怎么表达?

这里的难点在于,F 对于电脑的架构师来说是未知的。那么,怎么设计一种系统架构让用户可以表达任意复杂的计算(函数)?

逻辑上来看,无论多复杂的自定义函数,都可以通过下面这些元素的组合来定义:

  • 内置函数,比如整数或小数运算(加减乘除、sin/cos等);
  • 循环和条件分支;
  • 子函数(也是自定义函数)。

这样一来,对于任意的一个具体的计算(自定义函数)来说,都可以用一组指令序列来表达。

那么函数 F 物理上在哪里?以指令序列形式存放在存储里面。所以,存储不只存放计算所要操作的数据,也存放“计算”本身。

只是,存储里面存放的“计算”只是数据,需要有人理解并执行这些数据背后的计算行为,才变成真正意义的“计算”。这个执行者,就是中央处理器(CPU)。它支持很多计算指令,包括执行内置函数、循环和条件分支、执行子函数等。

所以,有了中央处理器+存储,就可以支持任意复杂的“计算”了。

只是如果电脑只有“中央处理器+存储”,那它就如同一个人只有头脑而没有四肢五官,尽管很可能很聪明,但是这种聪明无法展现出来,因为它没法和现实世界发生交互。

交互,抽象来看就是输入和输出。对人来说,输入靠的是五官:眼睛看、耳朵听、鼻子闻、舌头尝,以及肌肤接触产生的触觉。输出靠语言(说话)和各种动作,如微笑、眨眼、皱眉、手势等等。

对于电脑来说,输入输出的需求就更多了,不只是四肢五官,而可能是千肢万官。

从输入需求来说,可能采集静态图像、声音、视频;也可能采集结构化数据,如GPS位置、脉搏、心电图、温度、湿度等;还可能是用户控制指令如键盘按键、鼠标、触摸屏动作等。

从输出需求来说,可能是向屏幕输出信息;也可能是播放声音;还可能是执行某项动作,如交通灯开关、汽车马达转动、打印机打印等。

但不管是什么样交互用途的器官(设备),我们要做的只是定义好统一的数据交换协议。这个数据交换机制,和网络上两台电脑通过互联网,需要通过某种数据交换协议进行通讯,需求上没有实质性的差别。

也就是说,除了纯正的“计算”能力外,中央处理器还要有“数据交换”能力(或者叫IO能力)。最终,电脑可以被看做由 “中央处理器+存储+一系列的输入输出设备” 构成。如下图:

尽管输入输出设备引入的最初灵感可能是来自于“交互”,但是当我们去审视输入输出设备到底是什么的时候,我们很自然发现,它能够做的不单单是交互。

比如常见的外置存储如机械硬盘、光盘等,它们也是输入输出设备,但并不是用于交互,而是显著提升了电脑处理的数据体量。

输入输出设备从根本上解决的问题是什么?

是电脑无限可能的扩展能力。

最重要的一点,输入输出设备和电脑是完全异构的。输入输出设备对电脑来说就只是实现了某项能力的黑盒子。

这个黑盒子内部如何?没有规定。它可以只是一个原始的数字化的元器件,也可以是另一台冯·诺依曼架构的电脑,还可以是完全不同架构的电脑,比如GPU电脑、量子计算机。

你可以发现,引入了输入输出设备的电脑,不再只能做狭义上的“计算”(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的“计算”问题变得无所不包。

架构思维上我们学习到什么?

架构的第一步是需求分析。从需求分析角度来说,关键要抓住需求的稳定点和变化点。需求的稳定点,往往是系统的核心价值点;而需求的变化点,则往往需要相应去做开放性设计。

对于“电脑”这个产品而言,需求的稳定点是电脑的“计算”能力。需求的变化点,一是用户“计算”需求的多样性,二是用户交互方式的多样性。

电脑的“计算”能力,最终体现为中央处理器的指令集,这是需求相对稳定的部分。

用户“计算”需求的多样性,最终是通过在存储中的指令序列实现。计算机加电启动后,中央处理器并不是按自己固有的“计算”过程进行,而是从一个固定的存储地址加载指令序列执行。

通常,这个固定的存储地址指向计算机主板的ROM上的一段启动程序(BIOS)。这段启动程序通常包含以下这些内容。

  • 存储设备的驱动程序,用以识别常规的外置存储设备,比如硬盘、光驱、U盘。
  • 基础外部设备的驱动程序,比如键盘、鼠标、显示器(显卡)。
  • 设备和启动配置的基础管理能力。
  • 在外置存储上执行程序的能力(中央处理器只支持在内存上执行程序,当然它也为在外置存储执行程序提供了一些支持,比如内存页缺失的中断处理)。
  • 将执行权转移到外置存储(第一次安装操作系统的时候可能是光驱甚至是网络存储,平常通常是硬盘)上的操作系统启动程序。这样,操作系统就开始干活了。

这样一来,“计算”需求的多样性只需要通过调整计算机主板上的BIOS程序,乃至外置存储中的操作系统启动程序就可以实现,而不必去修改中央处理器本身。

用户交互方式的多样性,则通过定义外部设备与中央处理器的数据交换协议实现。

当我们把所有的变化点从电脑的最核心部件中央处理器剥离后,中央处理器的需求变得极其稳定,可独立作为产品进行其核心价值的演进。

结语

总结一下,今天,我们近距离地去解剖了整个信息世界地基:冯·诺依曼体系结构。

冯·诺依曼体系结构的不凡之处在于,它想“解决一切可以用‘计算’来解决的问题”。

为了实现这个目标,冯·诺依曼引入了三类基础零部件:中央处理器、存储、输入输出设备。所有计算机都可以看做由 “中央处理器+存储+一系列的输入输出设备” 构成。

为了方便理解,我在尝试用 Go 语言模拟来实现冯·诺依曼架构体系的电脑:

如果你对此感兴趣,欢迎 fork 并对其进行修改迭代。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 汇编:编程语言的诞生

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在上一讲中,我们一起解剖了架构大厦的地基:冯·诺依曼体系。接下来,我们就开始沿着这座大厦攀登,一起来聊聊编程语言。

对于现代计算机来说,虽然 CPU 指令是一个很有限的指令集,但是CPU 执行的指令序列(或者叫“程序”)并不是固定的,它依赖于保存在存储中的数据,由软件工程师(或者叫“程序员”)编写的软件决定。

从上一讲中,我们可以知道,计算机的程序可能被保存在计算机主板的ROM上(这段程序也叫计算机的启动程序),也可能被保存在外置的存储设备(比如硬盘)上,并在合适的时机加载执行。

程序称得上是计算机的灵魂。指令序列的可能性是无穷的,程序的可能性就是无穷的。今天计算机创造的世界如此多姿多彩,正是得益于程序无穷的可能性。

那么,软件工程师是怎么编写程序的?

编程的史前时代

在第一门面向程序员的编程语言出现前,人们只能通过理解CPU指令的二进制表示,将程序以二进制数据方式刻录到存储(比如ROM或硬盘)上。

这个时期的编程无疑是痛苦的,效率是极其低下的:且不说我们怎么去修改和迭代我们的程序,光将我们的想法表达出来就极其困难。

我们首先要把表达的执行指令翻译成二进制的比特数据,然后再把这些数据刻录到存储上。

这个时候软件和硬件的边界还非常模糊,并不存在所谓软件工程师(或者叫“程序员”)这样的职业。写程序也并不是一个纯软件的行为,把程序刻录到存储上往往还涉及了硬件的电气操作。

为了解决编程效率的问题,汇编语言(和解释它的编译器)诞生了。汇编语言的编译器将汇编语言写的程序编译成为CPU指令序列,并将其保存到外置的存储设备(比如硬盘)上。

汇编语言非常接近计算机的CPU 指令,一条汇编指令基本上和CPU指令一一对应。

与机器对话

汇编语言的出现,让写程序(编程)成为一个纯软件行为(出现“程序员”这个分工的标志),人们可以反复修改程序,然后通过汇编编译器将其翻译成机器语言,并写入到外置的存储设备(比如硬盘)。并且,程序员可以按需执行该程序。

在表达能力上,汇编语言主要做了如下效率优化。

  • 用文本符号(symbol)表达机器指令,例如 add 表示加法运算,而不用记忆对应的 CPU 指令的二进制表示。

  • 用文本符号(symbol)表达要操作的内存地址,并支持内存地址的自动分配。比如我们在程序中使用了“Hello” 这样一段文本,那么汇编编译器将为程序开辟一段静态存储区(通常我们叫“数据段”)来存放这段文本,并用一个文本符号(也就是“变量名-variable”)指向它。用变量名去表达一段内存数据,这样我们就不用去关注内存的物理地址,而把精力放在程序的逻辑表达上。

  • 用文本符号(symbol)表达要调用的函数(function,也叫“过程-procedure”)地址。对 CPU 指令来说,函数只有地址没有名字。但从编程的角度,函数是机器指令的扩展,和机器指令需要用文本符号来助记一样,函数的名称也需要用文本符号来助记。

  • 用文本符号(symbol)表达要跳转的目标地址。高级语言里面,流程控制的语法有很多,比如 goto、if .. else、for、while、until 等等。但是从汇编角度来说,只有两种基本的跳转指令:无条件跳转(jmp)和条件跳转(je、jne)。同样,跳转的目标地址用文本符号(也就是“标签-label”)有助于程序逻辑的表达,而不是让人把精力放在具体的指令跳转地址上。

总结来说,汇编从指令能力上来说,和机器指令并无二致,它只不过把人们从物理硬件地址中解脱出来,以便专注于程序逻辑的表达。

但是,这一步所解放的生产力是惊人的,毕竟如果有选择的话,没有人会愿意用0101这样的东西来表达自己的思想。

可自我迭代的计算机

从探究历史的角度,你可能会期望了解最真实的历史发展过程。比如:怎么产生了现代计算机(以键盘作为输入,显示器作为输出)?怎么产生了汇编语言?怎么产生了操作系统?

不过,本专栏是以架构设计为目的,我们目的并不是还原最真实的历史。架构的意义在于创造。我们甚至可以设想一个有趣的场景:假设今天我们的信息科技的一切尚不存在,那么从架构设计角度,我们从工程上来说,如何更高效地完成从0到1的信息科技的构建?

最早的输入输出设备并不是键盘和显示器,而是打孔卡和打印机。用打孔卡来作为机器指令的输入,早在18世纪初就被用在织布机上了。早期的数字计算机就是用打孔卡来表达程序指令和输入的数据。

下图是 IBM 制造的打孔卡:

我们可以想象一下,第一台以键盘+显示器为标准输入输出的现代计算机出现后,一个最小功能集的计算机主板的ROM上,应该刻上什么样的启动程序?换句话说,这个现代计算机具备的最基本功能是什么?

从高效的角度(不代表真实的历史,真实历史可能经历过很多曲折的发展过程),我想,它最好具备下面的这些能力。

  • 键盘和显示器的驱动程序。
  • 当时最主流的外置存储设备(不一定是现代的硬盘)的驱动程序。
  • 一个汇编程序编辑器。可从存储中读取汇编程序代码,修改并保存到存储中。
  • 一个汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到存储中。
  • 可以执行一段保存在外置存储设备中的机器代码程序。

本质上,我们是要实现一个最小化的计算能力可自我迭代的计算机。

这个时期还没有操作系统(当然,把ROM上的启动程序BIOS看做一种最小化的操作系统,我觉得也可以,但毕竟不是现实中我们说的操作系统)。

汇编语言的出现要早于操作系统。操作系统的核心目标是软件治理,只有在计算机需要管理很多的任务时,才需要有操作系统。

所以,在没有操作系统之前,BIOS 包含的内容很可能是下面这样的:

  • 外置存储设备的驱动程序;
  • 基础外部设备的驱动程序,比如键盘、显示器;
  • 汇编语言的编辑器、编译器;
  • 把程序的源代码写入磁盘,从磁盘读入的能力。

最早期的计算机毫无疑问是单任务的,计算的职能也多于存储的职能。每次做完任务,计算机的状态重新归零(回到初始状态)都没有关系。

但是,有了上面这样一个 BIOS 程序后,计算机就开始发展起它存储的能力:程序的源代码可以进行迭代演进了。

这一步非常非常重要。计算机的存储能力的重要性如同人类发明了纸。纸让人类存储了知识,一代代传递下去并不断演进,不断发扬光大。

而同样有了存储能力的计算机,我们的软件程序就会不断被传承,不断演进发扬光大,并最终演进出今天越来越多姿多彩的信息科技的世界。

结语

今天我们一起回到了编程的史前时代,共同回溯了编程语言诞生的历史。

为了不再用“0101”表达自己的思想,人们创造了汇编语言,这一步让编程成为一个纯软件行为,程序员这一个分工也由此诞生。

为了进一步支持程序员这个职业,我们设计了MVP版(最小化可行产品)的可自我迭代的计算机。有了这个计算机,我们就可以不断演进,并最终演进出今天越来越多姿多彩的信息科技的世界。

架构上的思考题

在上一讲中,我们谈架构思维时提到,我们在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。

今天,我们假设要实现一个最小化的计算能力可自我迭代的计算机,需求如上所述。

那么,它的变化点和稳定点分别是什么?为此,你会怎么设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?

欢迎把你的想法告诉我,我们一起讨论。感谢你的收听,再见。

| 编程语言的进化

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。今天我们继续来聊聊编程语言。

编程语言的出现,诞生了软件工程师(也叫程序员)这样一个职业,而汇编语言则可以看做是软件工程师这个分工出现的标志。

通过编程语言,软件工程师和计算机可以进行交互,表达自己的思想。但是,如果我们把掌握编程语言看做是软件工程师的专业技能,其实又低估了编程语言带来的革命性变化。

编程语言在信息科技发展中的位置,如同人类文明中语言所在的位置。而编程语言写出来的软件(及其源代码),如同人类文明中不断被传承下来的图书典籍。

软件是活的书籍

我个人一直有一个观点:软件是活的书籍,是我们人类知识传承能力的一次伟大进化。书籍能够通过文字来记载事件、传递情感、揭示规律、传承技术。书籍能够让人们进行远程的沟通(飞鸽传书),也能够让我们了解古人的生活习性,与古人沟通(虽然是单向的)。

这些事情软件都可以做到,而且做得更好。为什么我说软件是活的书籍,有两方面的原因。

**其一,表达方式的多样性。**书籍只能通过文字描述来进行表达,这种表达方式依赖于人们对文字的理解,以及人的想象能力对场景进行还原。软件除了能够通过文字,还能够通过超链接、声音、动画、视频、实时的交互反馈等方式来还原场景。

**其二,对技术的现场还原。**书籍只能通过文字来描述技术,但是因为人与人对同样的文字理解不同,领悟能力不同,这些都可能导致技术的传承会出现偏差,如果文字的记载不够详尽,可能就会出现“谁也看不懂,学不会”的情况,从而导致技术的失传。

但是,软件对技术的还原可以是精确的,甚至软件本身可以是技术的一部分。当软件是技术的一部分的时候,技术传承就是精确的,失传的概率就大大降低(除非技术本身适应不了潮流,退出了历史舞台)。

信息科技发展到今天,已经影响人类活动的方方面面。无论你从事什么职业,不管你是否会从事软件开发的工作,你都无法和信息科技脱节。如果希望能够站在职业发展的至高点,你就需要理解和计算机沟通的语言,也就需要理解软件工程师们的语言。

不仅如此,如果你把编程语言升华为人类知识传承能力的进化,你就更能够清晰地预判到这样的未来:每一个小孩的基础教育中一定会有编程教育,就如同每一个小孩都需要学习物理和数学一样。

编程范式的进化

编程语言从汇编开始,到今天还只有六十多年的历史,但是迭代之迅速,远超自然语言的迭代速度。从思想表达的角度来说,我们通常会听到以下这些编程范式。

**其一是过程式。过程式就是以一条条命令的方式,让计算机按我们的意愿来执行。**今天计算机的机器语言本身就是一条条指令构成,本身也是过程式的。所以过程式最为常见,每个语言都有一定过程式的影子。过程式语言的代表是 Fortran、C/C++、JavaScript、Go 等等。

过程式编程中最核心的两个概念是结构体(自定义的类型)和过程(也叫函数)。通过结构体对数据进行组合,可以构建出任意复杂的自定义数据结构。通过过程可以抽象出任意复杂的自定义指令,复用以前的成果,简化意图的表达。

其二是函数式。函数式本质上是过程式编程的一种约束,它最核心的主张就是变量不可变,函数尽可能没有副作用(对于通用语言来说,所有函数都没副作用是不可能的,内部有 IO 行为的函数就有副作用)。

既然变量不可变,函数没有副作用,自然人们犯错的机会也就更少,代码质量就会更高。函数式语言的代表是 Haskell、Erlang 等等。大部分语言会比较难以彻底实施函数式的编程思想,但在思想上会有所借鉴。

函数式编程相对小众。因为这样写代码质量虽然高,但是学习门槛也高。举一个最简单的例子:在过程式编程中,数组是一个最常规的数据结构,但是在函数式中因为变量不可变,对某个下标的数组元素的修改,就需要复制整个数组(因为数组作为一个变量它不可变),非常低效。

所以,函数式编程里面,需要通过一种复杂的平衡二叉树来实现一个使用界面(接口)上和过程式语言数组一致的“数组”。这个简单的例子表明,如果你想用函数式编程,你需要重修数据结构这门课程,大学里面学的数据结构是不顶用了。

其三是面向对象。面向对象在过程式的基础上,引入了对象(类)和对象方法(类成员函数),它主张尽可能把方法(其实就是过程)归纳到合适的对象(类)上,不主张全局函数(过程)。面向对象语言的代表是 Java、C#、C++、Go 等等。

从“面向对象”到“面向连接”

面向对象的核心思想是引入契约,基于对象这样一个概念对代码的使用界面进行抽象和封装。

它有两个显著的优点。

其一是清晰的使用界面,某种类型的对象有哪些方法一目了然,而不像过程式编程,数据结构和过程的关系是非常松散的。

**其二是信息的封装。**面向对象不主张绕过对象的使用接口侵入到对象的内部实现细节。因为这样做破坏了信息的封装,降低了类的可复用性,有一天对象的内部实现方式改变了,依赖该对象的相关代码也需要跟着调整。

面向对象还有一个至关重要的概念是接口。通过接口,我们可以优雅地实现过程式编程中很费劲才能做到的一个能力:多态

由于对象和对象方法的强关联,我们可以引入接口来抽象不同对象相同的行为(比如鸟和猪是不同的对象,但是它们有相同的方法,比如移动和吃东西)。这样不同对象就可以用相同的代码来实现类似的复杂行为,这就是多态了。

多数面向对象语言往往还会引入一个叫继承的概念。大家对这个概念褒贬不一。虽然继承带来了编码上的便捷性,但也带来了不必要的心智负担:本来复合对象的唯一构造方法是组合,现在多了一个选择,继承。

究竟什么时候应该用继承,什么时候应该用组合?这着实会让人纠结。不过,这件事情最完美的答案是 Go 语言给出来的:放弃继承,全面强化组合能力(要了解 Go 语言强大的组合能力,参阅我的演讲)。

不同编程范式并不是互斥的。虽然有些编程语言会有明确的编程范式主张,比如 Java 是纯正的面向对象语言,它反对全局过程。但是,也有一些语言明确主张说自己是多范式的,典型代表是 C++。

当然,可能 C++ 不是一个好例子,因为它太复杂了,让人觉得多范式会大大增加语言的复杂性,虽然其实 C++ 的复杂性和多范式并没有什么关系。

可能 Go 语言是多范式更好的例子。它没有声称自己是多范式的,但是实际上每一种编程范式它都保留了精华部分。这并没有使得 Go 语言变得很复杂,整个语言的特性极其精简。

Go 语言之所以没有像 C++ 那样声称是多范式的,是因为 Go 官方认为 Go 是一门面向连接的语言。

**什么是面向连接的语言?**在此之前,你可能并没有听过这样的编程范式,这应该算 Go 自己发明出来的范式名称。在我看来,所谓面向连接就是朴素的组合思想。研究连接,就是研究人与人如何组合,研究代码与代码之间怎么组合。

面向对象创造性地把契约的重要性提高到了非常重要的高度,但这还远远不够。这是因为,并不是只有对象需要契约,语言设计的方方面面都需要契约。

比如,代码规范约束了人的行为,是人与人的连接契约。如果面对同一种语言,大家写代码的方式很不一样,语言就可能存在很多种方言,这对达成共识十分不利。所以 Go 语言直接从语言设计上就消灭掉那些最容易发生口水的地方,让大家专注于意图的表达。

再比如,消息传递约束了进程(这里的进程是抽象意义上的,在 Go 语言中叫 goroutine)的行为,是进程与进程的连接契约。消息传递是多核背景下流行起来的一种编程思想,其核心主张是:尽可能用消息传递来取代共享内存,从而尽可能避免显式的锁,降低编程负担。

Go 语言不只是提供了语言内建的消息传递机制(channel),同时它的消息传递是类型安全的。这种类型安全的消息传递契约机制,大大降低了犯错的机会。

其他方面的进化

除了编程范式,编程语言的进化还体现在工程化能力的完善上。工程化能力主要体现在如下这些方面。

  • 包(package),即代码的发布单元。
  • 版本(version),即包的依赖管理。
  • 文档生成(doc)。
  • 单元测试(test)。

从语言的执行器的行为看,出现了这样三种分类的语言。

  • 编译的目标文件为可执行程序。典型代表是 Fortran、C/C++、Go 等。
  • 生成跨平台的虚拟机字节码,有独立的执行器(虚拟机)执行字节码 。典型代表为 Java、Erlang 等。
  • 直接解释执行。典型代表是 JavaScript。当然现在纯解释执行的语言已经不多。大多数语言也只是看起来直接执行,内部还是会有基于字节码的虚拟机以提升性能。

语言对架构的影响是什么?

我们思考一个问题:从架构设计角度来看,编程语言的选择对架构的影响是什么?

我们在第一篇“架构设计的宏观视角”中,介绍了服务端程序和客户端程序的整体架构图。细心的读者可能留意到了,在架构图中我用了三种不同的颜色来表示不同层次的依赖。

无论服务端,还是客户端,我们可以统一将其架构图简化为下图所示。

图中淡紫色是硬件层次的依赖,是我们程序工作的物理基础。淡绿色的是软件层次的依赖,是我们程序工作的生态环境。桔色的是库或源代码层次的依赖,是我们程序本身的组成部分。细分的话它又可以分两部分:一部分是业务无关的框架和基础库,还有一部分是业务架构。

从软件的业务架构来说,本身应该怎么拆分模块,每个模块具体做什么样的事情(业务边界是什么),这是业务需求本身决定的,和编程语言并没有关系。但在我们描述每个模块的规格时,采用的规格描述语言会面临如下两种选择:

  • 选择某种语言无关的接口表示;
  • 选择团队开发时采用的语言来描述接口。

两种选择的差异并不是实质性的。只要团队内有共识,选哪一种都无所谓。本着“如无必要勿增实体”的原则,我个人会倾向于后者,用开发语言来做接口表示。在七牛云的话自然就是选 Go 了。

站在唯技术论的角度,业务架构与语言无关,影响的只是模块规格的描述语法。但语言的选择在实践中对业务架构决策的影响仍然极其关键。

**原因之一是开发效率。**抛开语言本身的开发效率差异不谈,不同语言会有不同的社区资源。语言长期以来的演进,社区所沉淀下来的框架和基础库,还有你所在的企业长期发展形成的框架和基础库,都会导致巨大的开发效率上的差异。

**原因之二是后期维护。**语言的历史通常都很悠久,很难实质性地消亡。但是语言的确有它的生命周期,语言也会走向衰落。选择公司现在更熟悉的语言,还是选择一个面向未来更优的语言,对架构师来说也是一个两难选择。

结语

今天我们抛开具体的语言发展史,而从编程范式演进的角度来谈编程语言的进化。过程式、函数式、面向对象,这些都是大家熟悉的编程范式;所以我们把重点放在了介绍由 Go 语言带来的面向连接的编程思想,并将其与面向对象做了详细的对比。

未来编程语言还将出现什么样的新思想,我们不得而知。但可以预见,出现新的创造性思维的挑战将越来越大。历史的发展是曲折螺旋上升的。

要想有所突破,需要建立批判性思维。一种新思潮的兴起过程很容易用力过猛。面向对象是一个很好的例子。面向对象是非常重要的进步,但是继承是一个过度设计,不只让软件工程师在组合与继承中纠结,也产生了复杂的对象继承树。我们只有在实践中不断总结与反思,才有可能产生新的突破。

你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 思考题解读:如何实现可自我迭代的计算机?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在“03 | 汇编:编程语言的诞生”中,我给出了一个架构思考题:

第一台以键盘+显示器为标准输入输出的现代计算机出现后,一个最小功能集,但计算能力可自我迭代的计算机应该是什么样的?

从需求上来说,我们期望它有如下能力。

  • 键盘和显示器的驱动程序。
  • 当时最主流的外置存储设备(不一定是现代的硬盘)的驱动程序。
  • 一个汇编程序编辑器。可从存储中读取汇编程序代码,修改并保存到存储中。
  • 一个汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到存储中。
  • 支持执行一段保存在外置存储设备中的机器代码程序。

那么,它的变化点和稳定点分别是什么?为此,你会怎么设计,设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?

需求分析

我们前面谈架构思维时提到:做架构,第一件事情要学会做需求分析

需求分析的重要性怎么形容都不过分。准确的需求分析是做出良好架构设计的基础。我个人认为,架构师在整个架构的过程中,至少应该花费三分之一的精力在需求分析上。

这也是为什么很多非常优秀的架构师换到一个新领域后,一上来并不能保证一定能够设计出良好的架构,而是往往需要经过几次迭代才趋于稳定,原因就在于:领域的需求理解是需要一个过程的,对客户需求的理解不可能一蹴而就。

所以,一个优秀的架构师除了需要“在心里对需求反复推敲”的严谨态度外,对客户反馈的尊重之心也至关重要。只有心里装着客户,才能理解好需求,做好架构。

前面我们也强调过:在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。

那么今天我们来实战一番,要实现一个最小化的计算能力可自我迭代的计算机,我们怎么做需求分析。

怎么实现可自我迭代的计算机?

通过前面对计算机工作原理的分析,我们已经知道,计算机分为三大类的零部件:

  • 中央处理器;
  • 存储;
  • 输入输出设备。

中央处理器作为“计算”能力的核心,我们已经对它的工作范畴解剖清晰,这里不提。

存储,一方面作为“计算”的输入输出,另一方面作为“计算”本身的承载(也就是程序),主要的变数在后者。存储上的程序主要是:

  • 计算机主板ROM上的启动程序(BIOS);
  • 外置存储上的软件。

接下来我们要考虑清楚的是:BIOS 负责做什么,外置存储上的软件负责做什么。这里我们先不展开。

输入输出设备,除了键盘和显示器外,还有外置存储。键盘和显示器我们只需要准备好对应的驱动程序,并没有特别需要考虑的内容。主要的变数在外置存储上。

外置存储在我们为它准备好了驱动程序后,就可以对它进行数据的读写了,但是我们接着需要考虑的问题是:我们准备把外置存储的数据格式设计成什么样?

回答这个问题前,先回顾下我们要做什么。目前我们已知的功能需求有如下这些。

  • 键盘和显示器的驱动程序。
  • 外置存储设备的驱动程序。
  • 汇编程序编辑器。可从外置存储中读取汇编程序代码,修改并保存到外置存储中。
  • 汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到外置存储中。
  • 支持执行一段保存在外置存储设备中的机器代码程序。

我们可以看到,外置存储需要保存的内容有:

  • 汇编程序的源代码;

  • 汇编编译器编译出来的可执行程序。

可见,外置存储它不应该只能保存一个文件,而是应该是多个。既然是多个,就需要组织这些文件。那么,怎么组织呢?

今天我们当然知道,操作系统的设计者们设计了文件系统这样的东西,来组织这些文件。虽然文件系统的种类有很多(比如:FAT32、NTFS、EXT3、EXT4 等等),但是它们有统一的抽象:文件系统是一颗树;节点要么是目录,要么是文件;文件必然是叶节点;根节点是目录,目录可以有子节点。

但是,文件系统(File System)是否是唯一的可能性?当然不是。键值存储(Key-Value 存储)也挺好,尤其是早期外置存储容量很可能极其有限的情况下。可以做这样统一的抽象:

  • 每个文件都有一个名字(Key),通过名字(Key)可以唯一定位该文件,以进行文件内容的读写;
  • 为了方便管理文件,可以对文件名做模糊查询(List),查询(List)操作支持通配符(比如我们现在习惯用的*?);
  • 未来外置存储的空间有可能很大,需要考虑文件管理的延展性问题;可以考虑允许每个文件设定额外的元数据(Meta),例如创建时间、编辑时间、最后访问时间、以及其他用户自定义的元数据。通过元数据我们也可以检索(Search)到我们感兴趣的文件。

聊完了外置存储,让我们再回来看看 BIOS 和外置存储的软件怎么分工。

首先,BIOS 和外置存储上的软件分工的标准是什么?BIOS 是刻在计算机主板ROM上的启动程序,它的变更非常麻烦。所以 BIOS 负责的事情最好越少越好,只做最稳定不变的事情。

我们一一来看当前已知的需求。

首先是外部设备的驱动程序:键盘和显示器的驱动程序、外置存储设备的驱动程序。一方面,只要键盘、显示器、外置存储没有大的演进,驱动程序就不变,所以这块是稳定的;另一方面,它们是 BIOS 干其他业务的基础。所以,这个事情 BIOS 必然会做。

**其次是汇编程序编辑器。**编辑器的需求是模糊的,虽然我们知道它支持用户来编写程序,但是整个编辑器的操作范式是什么样的,没有规定。所以它不像是给键盘写一个驱动程序那样,是一个确定性的需求,而有很多额外的交互细节,需要去进一步明确。

你可以留意下自己日常使用的编辑器,去试着列一下它们的功能列表。你会发现小小的编辑器,功能远比你接触的大部分常规软件要多得多。

**再次是汇编编译器。**汇编编译器从输入输出来看,似乎需求相对确定。输入的是汇编源代码,输出的是可执行程序。但认真分析你会发现,它实际上也有很大的不确定性。

其一,CPU 会增加指令,这时候汇编指令也会相应地增加。对于大部分应用程序,CPU 新增的指令如果自己用不到,可以当它不存在。但是汇编语言及编译器需要完整呈现 CPU 的能力,因此需要及时跟进。

其二,虽然汇编指令基本上和机器指令一一对应,但是它毕竟是面向程序员的生产力工具,所以汇编语言还是会演进出一些高阶的语法,比如宏汇编指令。

所谓宏汇编指令,就是用一个命令去取代一小段汇编指令序列,它和C语言里面的宏非常类似。所以汇编语言并不是稳定的东西,它和其他高级语言类似,也会迭代变化。这就意味着汇编编译器也需要相应地迭代变化。

**最后,执行一段保存在外置存储设备中的机器代码程序。**这个需求看似比较明确,但是实际上需求也需要进一步细化。它究竟是基于外置存储的物理地址来执行程序,还是基于文件系统中的文件(文件内容逻辑上连续,但是物理上很可能不连续)来执行程序?

实现上,这两者有很大的不同。前者只需要依赖外置存储的驱动程序就可以完成,后者则还需要额外理解文件系统的格式才能做到。

那么,BIOS 到底怎么把执行控制权交到外置存储呢?

在学冯·诺依曼结构的时候,我们提到过,CPU 加电启动时,它会从存储的一个固定地址开始执行指令,这个固定地址指向的正是 BIOS 程序。

类似的,我们的 BIOS 也可以认定一个外置存储的固定地址来加载程序并执行,而无需关心磁盘的数据格式是什么样的。这个固定地址所在的数据区域,我们可以把它叫做引导区。

引导区的存在非常重要,它实际上是 BIOS 与操作系统的边界。

对于 BIOS 来说,执行外置存储上的程序能力肯定是需要具备的,否则它没有办法把执行权交给外置存储。但是这个能力可以是非常简约的。BIOS 只需要执行引导区的程序,这个程序并不长,完全可以直接读入到内存中,然后再执行。

我们是否需要基于文件系统中的文件来执行程序的能力?答案是需要。因为汇编编译器编译后的程序在外置存储中,需要有人能够去执行它。

综上,我们确认 BIOS 需要负责的事情是:

  • 键盘和显示器的驱动程序;
  • 外置存储设备的驱动程序;
  • 支持执行外置存储中引导区的机器代码程序;
  • 跳转到外置存储的固定地址,把执行权交给该地址上的引导程序。

而汇编程序编辑器、汇编编译器 ,以及支持执行文件系统中的程序,则不应该由 BIOS 来负责。

那么,外置存储上的引导程序拿到执行权后干什么呢?

我们再来总结下当前我们遇到的需求。

  • 需要有人负责支持外置存储的数据格式,提供统一的功能给其他程序使用。无论它是文件系统,还是Key-Value存储系统。
  • 需要有人提供管理外置存储的基础能力,比如查询(List)一下外置存储里面都有些什么文件。它可以实现为一个独立的程序,比如我们命名为 ls。
  • 需要有人执行外置存储上的可执行程序。它可以实现为一个独立的程序,比如我们命名为 sh。
  • 汇编程序编辑器。其实这个程序和汇编语言没什么关系,就是一个纯正的文本编辑器。我们可以把这个程序命名为 vi。
  • 汇编编译器。它可以实现为一个独立的程序,比如我们命名为 asm。

引导程序拿到执行权后,我们不管它额外做了哪些事情,最终它要把执行权交给 sh 程序。因为,sh 程序算得上是可自我迭代的计算机扩展性的体现:通过 sh 程序来执行外置存储上的任意程序,这也相当于在扩展 CPU 的指令集。

结语

我们来回顾一下今天的内容。一个最小功能集、计算能力可自我迭代的计算机,它的变化点和稳定点分别是什么?为此,你会怎么设计,设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?

需求的变化点在于下面这几点。

  • 外置存储的数据格式。对此,我们设计文件系统(或Key-Value存储)子系统来负责这件事情。另外,我们也提供了 ls 程序来管理外置存储中的文件。
  • 用户最终拿到这个计算机后,会迭代出什么能力。对此,我们设计了 sh 程序,让它支持在外置存储上执行任何应用程序。
  • 编辑器的交互范式。对此,我们设计了 vi 程序,让它迭代编辑器的能力。
  • 汇编语言的使用范式。对此,我们设计了 asm 程序,让它响应 CPU 指令集的迭代,以及汇编语言进化的迭代。

最终,我们设计出来的“可自我迭代的计算机”,它的系统架构看起来是这样的:

你的需求分析和系统设计跟上面的架构一致吗?

不一致非常正常,架构并无标准答案。但通过对比别人的方案与自己的不同之处,可以加深你对架构设计在决策上的体会。

另外,在 “可自我迭代的计算机” 这样相对模糊需求的细化过程中,也会很自然出现不太一样的理解,这些都是正常的,这也是需求分析的重要性所在,它本身就是一个需求从模糊到细化并最终清晰定义的过程。

如果你觉得系统过于复杂,不知道如何下手,也不要紧,设计“一个可自我迭代的计算机” 的确是一个复杂的系统,它并不是一个非常适合架构新手的任务。但是我仍然希望通过这样一个例子的剖析,你对需求分析中稳定点和变化点的判断有所感悟。

如果你有什么样的想法和疑问,欢迎你给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 操作系统进场

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在编程语言出现后,软件生产效率得到了大幅度的提升。随着越来越多软件的出现,自然而然就诞生了多个软件如何共处,也就是软件治理的需求。比如下面的这些需求场景。

  • 多个软件如何同时运行(多任务的需求)?
  • 多个软件如何共同使用计算机上的存储空间(内存管理、文件系统的需求)?
  • 多个软件如何共同使用同一个外部设备(设备管理的需求)?
  • 多个软件如何相互通讯,如何进行数据交换(进程间通讯、共享内存的需求)?
  • 病毒、恶意软件如何治理(安全管理的需求)?

如果没有一个中间的协调方,软件与软件之间并不知道彼此的存在,你不难想象出,这种没有统一规则约束下的场面,会有多么凌乱。

于是,操作系统就出现了。对于软件而言,它像一个大法官,制定规则并据此约束大家的行为。

操作系统的启动过程

操作系统是怎么获得执行权的?

这是计算机主板ROM上的启动程序(BIOS)交给它的。

计算机加电启动后,中央处理器(CPU)会从一个固定的存储地址加载指令序列执行。通常,这个固定的存储地址指向计算机主板的ROM上的一段启动程序(BIOS)。这段启动程序通常包含以下这些内容。

  • 存储设备的驱动程序,用以识别常规的外置存储设备,比如硬盘、光驱、U盘。
  • 基础外部设备的驱动程序,比如键盘、鼠标、显示器。
  • 设备和启动配置的基础管理能力。
  • 支持执行外置存储中引导区的机器代码程序。
  • 跳转到外置存储引导区的固定地址,把执行权交给该地址上的引导程序。

引导区的引导程序有长度限制(关于这一点我在上一讲已经介绍过),只能做非常少的事情。在常规情况下,它只是简单地跳转到真正的操作系统的启动程序,但有时计算机上安装了多个操作系统,此时引导程序会提供菜单让你选择要运行的操作系统。

这样,操作系统就开始干活了。

操作系统的需求演进

那么,操作系统是做什么的?前面我们说的“软件治理”是否可以涵盖它完整的目标?

让我们从操作系统的发展历程说起。

最早期的计算机是大型机。这个时期的计算机笨重、昂贵,并且操作困难,主要使用人群是搞科研性质的科学家或其他高端人群。

虽然这个时期催生了 IBM 这样的硬件巨头,但大多数人根本就意识不到,这玩意儿对后世人们的生活能够产生如此翻天覆地的变化。

这个时期的计算机还是单任务的,以计算为主,软件为操作硬件服务。如果我们认为“软件治理”是操作系统的根源需求的话,那么可以认为这个时期还不存在操作系统。但的确会有一些辅助工具库来简化用户使用计算机的负担,我们可以把它看做操作系统的萌芽。

从这个意义来说,提供计算机的“基础编程接口”,降低软件开发的负担,是操作系统更为原始的需求。

此后,小型机和个人计算机(PC)的崛起,分别诞生了 UNIX 和 DOS 这两个影响深远的操作系统。 UNIX 就不用说了,它几乎算得上今天所有现代操作系统的鼻祖。

DOS 的历史非常有趣。首先是 IBM 没把操作系统当回事儿,把这个活儿包给了微软。然后是微软只花了 5 万美元向西雅图公司购买了 86-DOS 操作系统的版权,更名为 MS-DOS。

那么 86-DOS 是怎么来的?西雅图公司的一个24岁小伙叫蒂姆·帕特森(Tim Paterson),单枪匹马花了4个月时间写出来的。

可以看到,这个时期人们对操作系统并没有太深刻的认知,多数人只把它看做硬件的附属品。IBM 不把它当回事,西雅图公司也没把它当回事,几万就把它卖了。只有微软认认真真地把它当做生意做了起来(在此之前微软的生意是卖 BASIC 语言的解析器起家,所以微软一直对 BASIC 语言情有独钟,直到很久以后微软搞出了 C# 语言后,情况才有所改变)。

等到 IBM 意识到操作系统是个金蛋,改由自己做 PC-DOS 操作系统的时候,微软已经通过推动 PC 兼容机的发展,让操作系统不再依赖特定的硬件设备,微软也就因此脱离 IBM 的臂膀,自己一飞冲天了。

回到问题。要回答操作系统在做什么,我们可以从客户价值和商业价值两个维度来看。

客户价值来说,操作系统首先要解决的是软件治理的问题,大体可分为以下六个子系统:进程管理、存储管理、输入设备管理、输出设备管理、网络管理、安全管理等。

**操作系统其次解决的是基础编程接口问题。**这些编程接口一方面简化了软件开发,另一方面提供了多软件共同运行的环境,实现了软件治理。

商业价值来说,操作系统是基础的刚需软件。计算机离开了操作系统就是一堆废铜烂铁。随着个人计算机采购需求的急速增加,光靠软件 License 的费用就让操作系统厂商赚翻了。

虽然第一个广为人知的操作系统是 UNIX,但从商业上来说最成功的操作系统则是 DOS/Windows,成就了微软的霸主地位。

为什么是 DOS/Windows 赢得了市场,这无关技术优劣,关键在于两者的商业路线差异:UNIX 走的是企业市场,而 DOS/Windows 选择了更为巨大的市场:个人计算机(PC)市场。

操作系统也是核心的流量入口。占领了操作系统,就占有了用户,想推什么内容给用户都很容易。微软对这一点显然心知肚明。

这也是为什么当年网景推 Netscape 浏览器的时候,微软很紧张。因为浏览器是另一个软件治理的入口,本质上是操作系统之上的操作系统。如果软件都运行在浏览器上,那么本地操作系统就沦为和硬件一般无二的管道了。

虽然早期操作系统没有应用市场(AppStore),但是通过操作系统预装软件的方式向软件厂商收租,这是一直以来都有的盈利方式。国内盗版的番茄花园 Windows 发行版就是通过在 Windows 系统上预装软件来盈利。

当然预装软件只是一种可能性,流量变现的方式还有很多。苹果的 iOS 操作系统开启了新的玩法,它构建了新的商业闭环:账号(Account)、支付(Pay)、应用市场(AppStore)。

我们把这个商业模式叫收税模式。帐号(注意是互联网账号,不是过去用于权限管理的本地账号)是前提。没有帐号,就没有支付系统,也没有办法判断用户是否购买过某个软件。

应用市场实现了应用的分发,既解决了系统能力的无限扩展问题(客户价值),也解决了预装软件的软件个数总归有限的问题(商业价值)。支付则是收税模式的承载体,无论是下载应用收费,还是应用内购买内容收费,都可以通过这个关卡去收税。

无论是本地操作系统 iOS 和 Android,还是 Web 操作系统(浏览器)如微信小程序,都实现了“帐号-支付-应用市场”这样的商业闭环。这类操作系统,我们不妨把它叫做现代操作系统。

操作系统的边界在哪里?

架构的第一步是需求分析。上一讲我提到了在架构设计过程中,需求分析至少应该花费三分之一的精力。通过这一节我们对操作系统演进过程的回顾,你可能更容易体会到这一点。

当我们说要做一个操作系统的时候,实际上我们自己对这句话的理解也是非常模糊的。尤其是我们正准备去做的事情是一个新生事物时,我们对其理解往往更加粗浅。

在本专栏开篇词中我也提过,架构也关乎用户需求,作为架构师我们不只是要知道当前的用户需求是什么,我们还要预测需求未来可能的变化,预判什么会发生,而什么一定不会发生。

我们可以问一下自己:我是否能够预料到,有一天支付(Pay)系统会成为操作系统的核心子系统?如果不能,那么怎么才能做到?

操作系统的边界到底在哪里?

要回答这个问题,我们需要看清楚这样三个角色的关系:

  • 硬件(个人计算机、手机或其他);
  • 操作系统;
  • 浏览器。

首先我们来看操作系统与硬件的关系。如果操作系统厂商不做硬件会怎样?我们知道个人计算机(PC)市场就是如此。微软虽然占据了 PC 操作系统(DOS/Windows)绝大部分江山,但是它自身并不生产硬件。这里面,PC 兼容机的发展对 DOS/Windows 的发展有着至关重要的支撑意义。它让操作系统厂商有了独立的生存空间。

到了移动时代,Google 收购 Android 后,通过免费策略占领移动操作系统的大半江山,一定程度上复制了微软的过程,但实际上并没有那么理想。

首先,Android 是免费的,Google 并没有从中收取软件 License 费用,而是借助 Android 的市场占有率来推动 Google 的服务(例如搜索、Gmail 等等),通过 Google 服务来获取商业回报。

其次,iOS 操作系统引入的 “账号-支付-应用市场” 的收税模式,受益方是硬件(手机)厂商,而非操作系统厂商。其中最关键的一点,几乎所有手机厂商都不接受把支付(Pay)这个核心系统交给 Google。

最后,不止支付系统,一旦手机厂商长大立足 ,Google 服务也会被逐步替换。所以 Google 和 Android 手机厂商之间的联盟并不可靠,养肥的手机厂商会不断试探 Google 的底线,而 Google 也会尝试去收紧政策,双方在博弈中达到平衡。

之所以会这样,我觉得原因有这么几个:

其一,历史是不可复制的,人们对操作系统的重要性认知已经非常充分。所以大部分手机厂商,都不会放弃操作系统的核心子系统的主控权。Android 系统的开源策略无法完全达到预期的目标,这也是 Google 最终还是免不了要自己做手机的原因。

其二,手机是个性化产品,硬件上并没有 PC 那么标准化。所以个人计算机有兼容机,而手机并没有所谓的标准化硬件。

分析完操作系统和硬件的关系,我们再来看它和浏览器的关系。在 PC 时期,操作系统和浏览器看起来至少需求上是有差异化的:操作系统,是以管理本地软件和内容为主(对内)。浏览器,是以管理互联网内容为主(对外)。

但,这个边界必然会越来越模糊。

操作系统不涉足互联网内容?这是不可能的。应用市场(AppStore)其实就是典型的互联网内容;而另一方面,在浏览器的生态里也有一些特殊角色:网址导航、搜索引擎、Web 应用市场,它们共同构成了探索互联网世界的“地图”。

问题在于:

  • 操作系统、浏览器和(互联网)搜索引擎的关系是什么;
  • 移动时代的浏览器会是什么样的;它和操作系统的关系又如何相处?

欢迎把你对这几个问题的想法告诉我。

结语

让我们简单回顾下今天我所讲到的内容。

从客户需求来说,操作系统的核心价值在于:

  • 实现软件治理,让多个软件和谐共处;
  • 提供基础的编程接口,降低软件开发难度。

从商业价值来说,操作系统是刚性需求,核心的流量入口,兵家必争之地。所以,围绕它的核心能力,操作系统必然会不断演化出新的形态。

我们把引入了 “账号-支付-应用市场” 商业闭环的收税模式的操作系统,称为现代操作系统。

操作系统的边界到底在什么地方?我们通过分析硬件、操作系统、浏览器三者的关系,也做了定性的分析。这样的分析将有助于你对需求发展做出预判。

最后,你可以在留言区给我留言,分享你对于操作系统技术、商业的看法,让我们一起交流。

| 软件运行机制及内存管理

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

操作系统的核心职能是软件治理,而软件治理的一个很重要的部分,就是让多个软件可以共同合理使用计算机的资源,不至于出现争抢的局面。

内存作为计算机最基础的硬件资源,有着非常特殊的位置。我们知道,CPU 可以直接访问的存储资源非常少,只有:寄存器、内存(RAM)、主板上的 ROM。

寄存器的访问速度非常非常快,但是数量很少,大部分程序员不直接打交道,而是由编程语言的编译器根据需要自动选择寄存器来优化程序的运行性能。

主板上的 ROM 是非易失的只读的存储。所谓非易失,是计算机重新启动后它里面的数据仍然会存在。这不像内存(RAM),计算机重新启动后它上面的数据就丢失了。ROM 非易失和只读的特点,决定了它非常适合存储计算机的启动程序(BIOS)。

所以你可以看到,内存的地位非常特殊,它是唯一的 CPU 内置支持,且和程序员直接会打交道的基础资源。

内存有什么用?前面我们在 “02 | 大厦基石:无生有,有生万物” 一讲中介绍冯·诺依曼结构的时候,画过一个图:

从图中可以看出,存储的作用有两个:一个是作为 “计算” 的操作对象,输入和输出数据存放的所在;另一个是存放 “计算” 本身,也就是程序员写的程序。

这里说的存储,主要指的就是内存。

计算机运行全过程

当然,这是从 CPU 角度看到的视图:对于 CPU 来说,“计算” 过程从计算机加电启动,执行 BIOS 程序的第一条指令开始,到最后计算机关机,整个就是一个完整的 “计算” 过程。这个过程有多少个“子的 ‘计算’过程”,它并不关心。

但是从操作系统的视角来看,计算机从开机到关机,整个 “计算” 过程,由很多软件,也就是子 “计算” 过程,共同完成。从时序来说,计算机完整的 “计算” 过程如下:

整个 “计算” 过程的每个子过程都有其明确的考量。

首先,BIOS 程序没有固化在 CPU 中,而是独立放到主板的 ROM 上,是因为不同历史时期的计算机输入输出设备很不一样,有键盘+鼠标+显示器的,有触摸屏的,也有纯语音交互的,外置存储则有软盘,硬盘,闪存,这些变化我们通过调整 BIOS 程序就可以应对,而不需要修改 CPU。

引导区引导程序,则是程序从内置存储(ROM)转到外置存储的边界。引导区引导程序很短,BIOS 只需要把它加载到内存执行就可以,但是这样系统的控制权就很巧妙地转到外置存储了。

引导区引导程序不固化在 BIOS 中,而是写在外置存储的引导区,是为了避免 BIOS 程序需要经常性修改。毕竟 BIOS 还是硬件,而引导区引导程序已经属于软件范畴了,修改起来会方便很多。

OS 引导程序,则是外置存储接手计算机控制权的真正开始。这里 OS 是操作系统(Operating System)的缩写。操作系统从这里开始干活了。这个过程发生了很多很多事情,这里我们先略过。但是最终所有的初始化工作完成后,操作系统会把执行权交给 OS Shell 程序。

OS Shell 程序负责操作系统与用户的交互。最早的时候,计算机的交互界面是字符界面,OS Shell 程序是一个命令行程序。DOS 中叫 command.com,而在 Linux 下则叫 sh 或者 bash 之类。这里的 sh 就是 shell 的缩写。

这个时期启动一个软件的方式就是在 Shell 程序中输入一个命令行,Shell 负责解释命令行理解用户的意图,然后启动相应的软件。到了图形界面时期,在 Shell 中启动软件就变成点点鼠标,或者动动手指(触摸屏)就行了,交互范式简化了很多。

在了解了计算机从开机到关机的整个过程后,你可能很快会发现,这里面有一个很关键的细节没有交代:计算机是如何运行外置存储上的软件的?

这和内存管理有关。

结合内存的作用,我们谈内存管理,只需要谈清楚两个问题:

  • 如何分配内存(给运行中的软件,避免它们发生资源争抢);
  • 如何运行外置存储(比如硬盘)上的软件?

在回答这两个问题之前,我们先了解一个背景知识:CPU 的实模式和保护模式。这两个模式 CPU 对内存的操作方式完全不同。在实模式下,CPU 直接通过物理地址访问内存。在保护模式下,CPU 通过一个地址映射表把虚拟的内存地址转为物理的内存地址,然后再去读取数据。

相应的,工作在实模式下的操作系统,我们叫实模式操作系统;工作在保护模式下的操作系统,我们叫保护模式操作系统。

实模式下的内存管理

先看实模式操作系统。

在实模式操作系统下,所有软件包括操作系统本身,都在同一个物理地址空间下。在 CPU 看来,它们是同一个程序。操作系统如何分配内存?至少有两种可行的方法。

其一,把操作系统内存管理相关的函数地址,放到一个大家公认的地方(比如 0x10000 处),每个软件要想申请内存就到这个地方取得内存管理函数并调用它。

其二,把内存管理功能设计为一个中断请求。所谓中断,是 CPU 响应硬件设备事件的一个机制。当某个输入输出设备发生了一件需要 CPU 来处理的事情,它就会触发一个中断。

内存的全局有一个中断向量表,本质上就是在一个大家公认的地方放了一堆函数地址。比如键盘按了一个键,它会触发 9 号中断。在 CPU 收到中断请求时,它会先停下手头的活来响应中断请求(到中断向量表找到第 9 项对应的函数地址并去执行它),完成后再回去干原来的活。

中断机制设计之初本来为响应硬件事件之用,但是 CPU 也提供了指令允许软件触发一个中断,我们把它叫软中断。比如我们约定 77 号中断为内存管理中断,操作系统在初始化时把自己的内存管理函数写到中断向量表的第 77 项。

所以,上面两种方法实质上是同一个方法,只是机制细节有所不同而已。中断机制远不止是函数向量表那么简单。比如中断会有优先级,高优先级中断可以打断低优先级中断,反之则不能。

那么,在实模式下,操作系统如何运行外置存储(比如硬盘)上的软件?

很简单,就是把软件完整从外置存储读入到内存然后执行它。不过,在执行前它干了一件事情,把浮动地址固定下来。为什么会有浮动地址?因为软件还没有加载到内存的时候并不知道自己会在哪里,所以有很多涉及数据的地址、函数的地址都没法固定下来,要在操作系统把它加载到内存时来确定。

整体来说,实模式内存管理的机制是非常容易理解的。因为它毕竟实质上是一个程序被拆分为很多个软件(程序代码片段),实现了程序代码片段的动态加载而已。

保护模式下的内存管理

但实模式有两个问题。

其一是安全性。操作系统以及所有软件都运行在一起,相互之间可以随意修改对方的数据甚至程序指令,这样搞破坏就非常容易。

其二是支持的软件复杂性低,同时可运行的软件数量少。

一方面,软件越复杂,它的程序代码量就越多,需要的存储空间越大,甚至可能出现单个软件的大小超过计算机的可用内存,这时在实模式下就没法执行它。

另一方面,哪怕单个软件可运行,但是一旦我们同时运行的软件多几个,操作系统对内存的需求量就会急剧增加。相比这么多软件加起来的内存需求量,内存的存储空间往往仍然是不足的。

但是为什么平常我们可以毫无顾忌地不断打开新的软件,从来不曾担心过内存会不足呢?

这就是保护模式的作用了。保护模式下,内存访问不再是直接通过物理内存,而是基于虚拟内存。虚拟内存模式下,整个内存空间被分成很多个连续的内存页。每个内存页大小是固定的,比如 64K。

这样,每次 CPU 访问某个虚拟内存地址中的数据,它都会先计算出这是要访问哪个内存页,然后 CPU 再通过一个地址映射表,把虚拟的内存地址转为物理的内存地址,然后到这个物理内存地址去读取数据。地址映射表是一个数组,下标是内存页页号,值是该内存页对应的物理内存首地址。

当然,也有可能某一个内存页对应的物理内存地址还不存在,这种情况叫缺页,没法读取数据,这时 CPU 就会发起一个缺页的中断请求。

这个缺页的中断请求会被操作系统接管。发生缺页时,操作系统会为这个内存页分配物理的内存,并恢复这个内存页的数据。如果没有空闲的物理内存可以分配,它就会选择一个最久没有被访问的内存页进行淘汰。

当然,淘汰前会把这个内存页的数据保存起来,因为下次 CPU 访问这个被淘汰的内存页时一样会发生缺页中断请求,那时操作系统还要去恢复数据。

通过这个虚拟内存的机制,操作系统并不需要一上来就把整个软件装进内存中,而是通过缺页中断按需加载对应的程序代码片段。多个软件同时运行的问题也解决了,内存不够用的时候,就把最久没有用过的内存页淘汰掉,腾出物理内存出来。

运行软件的问题解决了。那么,操作系统如何分配内存给运行中的软件?

其实,内存分配的问题也解决了,并不需要任何额外的机制。反正内存地址空间是虚拟的,操作系统可以一上来就给要运行的软件分配超级大的内存,你想怎么用随你。软件如果不用某个内存页,什么都不发生。软件一旦用了某个内存页,通过缺页中断,操作系统就分配真正的物理内存给它。

通过引入虚拟内存及其缺页机制,CPU 很好地解决了操作系统和软件的配合关系。

每个运行中的软件,我们把它叫进程,都有自己的地址映射表。也就是说,虚拟地址并不是全局的,而是每个进程有一个自己独立的虚拟地址空间。

在保护模式下,计算机的基础架构体系和操作系统共同在努力做的一件事情,就是让每个软件“感觉”自己在独占整个计算机的资源。独立的虚拟地址空间很好地伪装了这一点:看起来我独自在享用所有内存资源。在实模式下的浮动地址的问题也解决了,软件可以假设自己代码加载的绝对地址是什么,不需要在加载的时候重新调整 CPU 指令操作的地址。

这和实模式很不一样。在实模式下,所有进程都在同在物理内存的地址空间里,它们相互可以访问对方的数据,修改甚至破坏对方的数据,进而导致其他进程(包括操作系统本身的进程)崩溃。内存是进程运行的基础资源,保持进程基础资源的独立性,是软件治理的最基础的要求。这也是保护模式之所以叫“保护”模式的原因。

架构思维上我们学到什么?

虚拟内存它本质上要解决这样两个很核心的需求。

其一,软件越来越大,我们需要考虑在外置存储上执行指令,而不是完整加载到内存中。但是外置存储一方面它的数据 CPU 并不知道怎么读;另一方面就算知道怎么读,也不知道它的数据格式是什么样的,这依赖文件系统的设计。让 CPU 理解外置存储的实现细节?这并不是一个好的设计。

其二,要同时运行的软件越来越多,计算机内存的供给与软件运行的内存需求相比,捉襟见肘。怎么才能把有限的内存的使用效率最大化?一个很容易想到的思路是把不经常使用的内存数据交换到外置存储。但是问题仍然是,CPU 并不了解外置存储的实现细节,怎么才能把内存按需交换出去?

通过把虚拟内存地址分页,引入缺页中断,我们非常巧妙地解决了这个问题。缺页中断很像是 CPU 留给操作系统的回调函数,通过它对变化点实现了很好的开放性设计。

结语

总结一下。我们今天先概要地阐述了计算机运行的全过程,并对每一步的核心意义做了简单的介绍。然后我们把话题转到我们这一节的重心:内存管理。

谈内存管理,需要谈清楚两个核心问题:

  • 如何分配内存(给运行中的软件,避免它们发生资源争抢);
  • 如何运行外置存储(比如硬盘)上的软件?

我们分别就在实模式下和保护模式下的内存管理进行了讨论。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 操作系统内核与编程接口

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

今天我们在开发软件的时候,已经处于一些基础的架构设计之中,像冯·诺依曼计算机体系,像操作系统和编程语言,这些都是我们开发一个应用程序所依赖的基础架构。

在上一讲“07 | 软件运行机制及内存管理” 中,我们着重介绍了软件是如何被运行起来的。今天,我们着重聊一聊软件如何利用它所依赖的基础架构。

首先是冯·诺依曼计算机体系,它由 “中央处理器+存储+一系列的输入输出设备” 构成。这一层,提供了编程接口的是中央处理器(CPU),编程接口是 CPU 指令,但 CPU 指令非常难用。

为此,人们发明了编程语言来降低 CPU 指令的使用门槛。编程语言面向人类,CPU 指令面向机器,编译器负责将人类容易理解和掌握的编程语言的程序,翻译成机器能够理解的 CPU 指令序列。

其次是编程语言。虽然编程语言出现的起因是降低 CPU 指令的使用门槛,第一门编程语言汇编语言的能力也很接近 CPU 指令,但是语言的自然演化会越来越脱离 CPU 所限制的条条框框,大部分语言都会演化出很多基础的算法库。

比如,字符串算法库有:字符串连接(concat)、字符串子串(substring),字符串比较(compare)、字符串长度(length)等等。

系统调用

最后就是操作系统了

操作系统和前两者非常不同。软件都是某种编程语言写成的,而 CPU 和编程语言的能力,统一以语言的语法或者库体现。

操作系统则属于基础软件,它和我们编写的软件并不在同一个进程(进程是软件的一个运行后产生的实例,同一个软件可以运行多次得到多个进程)中。

如果是实模式下的操作系统,大家都在同一个地址空间下,那么只需要知道操作系统的接口函数地址,理论上就可以直接访问。但是今天主流的操作系统都是保护模式的,操作系统和软件不在同一个进程,软件怎么才能使用操作系统的能力呢?

你可能想说,那就用进程与进程之间的通信机制?

的确,操作系统提供了很多进程与进程之间通讯的机制,后面我们也会涉及。但是今天我们讲的操作系统的编程接口是更为基础的机制,它是所有软件进程使用操作系统能力的基础,包括进程与进程之间通讯的机制,也是建立在这个基础之上。

它应该是一种成本非常非常低的方案,性能上要接近函数调用,否则我们为保护模式付出的成本就太高了。

有这样的机制么?有,就是上一讲我们已经提到过的“中断”。

中断的设计初衷是 CPU 响应硬件设备事件的一个机制。当某个输入输出设备发生了一件需要 CPU 来处理的事情,它就会触发一个中断;但是 CPU 也提供了指令允许软件触发一个中断,我们把它叫软中断。

大部分情况下,操作系统的能力通过软中断向我们写的软件开放,为此还专门引入了一个术语叫 “系统调用(syscall)”。

系统调用是怎么工作的?

我们需要先理解下 CPU 的代码执行权限等级。

在保护模式下,CPU 引入了 “保护环(Protection Rings)” 的概念。说白了,代码有执行权限等级的,如果权限不够,有一些 CPU 指令就不能执行。

这一点比较容易理解:上一讲我们介绍过,从内存管理的角度,虚拟内存机制让软件运行在一个沙盒中,这个沙盒让软件感觉自己在独享系统的内存。但如果不对软件的执行权限进行约束,它就可以打破沙盒,了解到真实的世界。

我们通常说的操作系统是很泛的概念。完整的操作系统非常庞大。根据与应用的关系,我们可以把操作系统分为内核与外围。

所谓操作系统内核,其实就是指那些会向我们写的应用程序提供系统服务的子系统的集合,它们管理着计算机的所有硬件资源,也管理着所有运行中的应用软件(进程)。

操作系统内核的执行权限等级,和我们常规的软件进程不同。像 Intel CPU 通常把代码执行权限分为 Ring 0-3 四个等级。

操作系统内核通常运行在 Ring 0,而常规的软件进程运行在 Ring 3(当然近年来虚拟化机制流行,为了更好地提升虚拟化的效率,Intel CPU 又引入了 Ring -1 级别的指令,这些指令只允许虚拟机所在的宿主操作系统才能调用)。

系统调用所基于的软中断,它很像一次间接的“函数调用”,但是又颇有不同。在实模式下,这种区别并不强烈。但是在保护模式下,这种差异会十分明显。

原因在于,我们的应用程序运行在 Ring 3(我们通常叫用户态),而操作系统内核运行在 Ring 0(我们通常叫内核态)。所以一次中断调用,不只是“函数调用”,更重要的是改变了执行权限,从用户态跃迁到了内核态。

但是这似乎不够。我们之前说了,操作系统与我们编写的软件并不同属一个进程,两边的内存地址空间都是独立的,我们系统调用请求是过去了,但是我们传给操作系统的内存地址,对它真的有意义吗?

答案在于,从虚拟内存机制的视角,操作系统内核和所有进程都在同一个地址空间,也就是,操作系统内核,它是所有进程共享的内存。示意如下:

这非常有趣。操作系统内核的代码和数据,不只为所有进程所共享,而且在所有进程中拥有相同的地址。这样无论哪个进程请求过来,对内核来说看起来都是一次本进程内的请求。

从单个进程的视角,中断向量表的地址,以及操作系统内核的地址空间是一个契约。有了中断向量表的地址约定,用户态函数就可以发起一次系统调用(软中断)。

当然你可能要问:既然操作系统内核和我同属一个地址空间,我是否可以跳过中断,直接访问调用内核函数?

这不单单是执行权限的问题。你可能会说,也许某个内核函数里面没有调用任何特权指令,我是否可以调用?

当然不能。这涉及虚拟内存中的内存页保护机制。内存页可以设置 “可读、可写、可执行” 三个标记位。操作系统内核虽然和用户进程同属一个地址空间,但是被设置为“不可读、不可写、不可执行”。虽然这段地址空间是有内容的,但是对于用户来说是个黑洞。

编程接口

理解了操作系统内核,以及它的调用方法 “系统调用”,我们来聊一聊操作系统的编程接口。

自然,最原始的调用方式,是用软中断指令。在汇编语言里面通常是:

int <中断号> ;   // 对每个操作系统来说中断号是固定的,比如 Linux 是 0x80

这里的 int 不是整数(integer)的缩写,而是中断(interrupt)的缩写。

当然用汇编语言来写软件并不是一个好主意。大部分高级语言都实现了操作系统编程接口的封装。

前面我们说,操作系统(内核)有六大子系统:存储管理、输入设备管理、输出设备管理、进程管理、网络管理、安全管理。除了安全管理是一个“润物细无声”的能力外,其他子系统都会有所包装。

我们以 C 语言和 Go 语言为例给一个简表,方便大家索引:

这些标准库的能力,大部分与操作系统能力相关,但或多或少进行了适度的包装。

例如,HTTP 是应用层协议,和操作系统内核关联性并不大,基于 TCP 的编程接口可以自己实现,但由于 HTTP 协议细节非常多,这个网络协议又是互联网世界最为广泛应用的应用层协议,故此 Go 语言提供了对应的标准库。

进程内通讯最为复杂。虽然操作系统往往引入了 thread 这样的概念,但 Go 语言自己搞了一套goroutine 这样的东西,原因是什么,我们在后面讨论 “进程管理” 相关的内容时,再做详细讨论。

动态库

从操作系统的角度来说,它仅仅提供最原始的系统调用是不够的,有很多业务逻辑的封装,在用户态来做更合适。但是,它也无法去穷举所有的编程语言,然后一一为它们开发各种语言的基础库。那怎么办?

聪明的操作系统设计者们想了一个好办法:动态库。几乎所有主流操作系统都有自己的动态库设计,包括:

  • Windows 的 dll(Dynamic Link Library);
  • Linux/Android 的 so(shared object);
  • Mac/iOS 的 dylib(Mach-O Dynamic Library)。

动态库本质上是实现了一个语言无关的代码复用机制。它是二进制级别的复用,而不是代码级别的。这很有用,大大降低了编程语言标准库的工作量。

动态库的原理其实很简单,核心考虑两个东西。

  • 浮动地址。动态库本质上是在一个进程地址空间中动态加载程序片段,这个程序片段的地址显然在编译阶段是没法确定的,需要在加载动态库的过程把浮动地址固定下来。这块的技术非常成熟,我们在实模式下加载进程就已经在使用这样的技术了。
  • 导出函数表。动态库需要记录有哪些函数被导出(export),这样用户就可以通过函数的名字来取得对应的函数地址。

有了动态库,编程语言的设计者实现其标准库来说就多了一个选择:直接调用动态库的函数并进行适度的语义包装。大部分语言会选择这条路,而不是直接用系统调用。

操作系统与编程语言

我们这个专栏从计算机硬件结构讲起,然后再到编程语言,到现在开始介绍操作系统,有些同学可能会觉得话题有那么一些跳跃。虽然每一节的开头,我其实对话题的脉络有所交代,但是,今天我还是有必要去做一个梳理。

编程语言和操作系统是两个非常独立的演化方向,却又彼此交融,它们有点像是某种“孪生关系”。虽然操作系统的诞生离不开编程语言,但是操作系统和 CPU 一样,是编程语言背后所依赖的基础设施。

和这个话题相关的,有这么一些有趣的问题:

  • 先有编程语言,还是先有操作系统;
  • 编程语言怎么做到自举的(比如用C语言来实现C语言编译器);
  • 操作系统开发的环境是什么样的,能够做到操作系统自身迭代本操作系统(自举)么?

对于**第一个问题:先有编程语言,还是先有操作系统?**这个问题的答案比较简单,先有编程语言。之所以有这个疑问,是因为两点:

其一,大部分人习惯认为运行软件是操作系统的责任。少了责任方,软件是怎么跑起来的?但实际上软件跑起来是很容易的,看 BIOS 程序把控制权交给哪个软件。

其二,大部分常见的应用程序都直接或间接依赖操作系统的系统调用。这样来看,编程语言编译出来的程序是无法脱离操作系统而存在的。但是实际上常见的系统级语言(比如 C 语言)都是可以编写出不依赖任何内核的程序的。

对于第二个问题:编程语言怎么做到自举的?

从鸡生蛋的角度,编译器的进化史应该是这样的:先用机器码直接写第一个汇编语言的编译器,然后汇编语言编译器编出第一个 C 语言编译器。有了 C 语言编译器后,可以反过来用 C 语言重写汇编语言编译器和 C 语言编译器,做更多的功能增强。

这个过程理论上每出现一种新 CPU 指令集、新操作系统,就需要重新来一遍。但是人是聪明的。所以交叉编译这样的东西产生了。所谓交叉编译就是在一种 “CPU +操作系统” 架构下,生成另一种 “CPU +操作系统” 架构下的软件。这就避免了需要把整个编译器进化史重新演绎一遍。

对于第三个问题:操作系统能够做到自身迭代本操作系统(自举)么?

当然可以。通常一门新的操作系统开发之初,会用上面提到的交叉编译技术先干出来,然后等到新操作系统稳定到一定程度后再实现自举,也就是用本操作系统自己来做操作系统的后续迭代开发。

结语

这一节我们介绍了我们的基础架构:中央处理器(CPU)、编程语言、操作系统这三者对应用软件开放的编程接口。总结来看就是下面这样一幅图:

其中,我们着重介绍的是操作系统的系统调用背后的实现机理。通过系统调用这个机制,我们很好地实现了操作系统和应用软件的隔离性和安全性,同时仍然保证了极好的执行性能。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 外存管理与文件系统

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在 “07 | 软件运行机制及内存管理”中,我们已经聊了内存管理,这一讲我们聊聊外置存储设备的管理。

外存的分类

计算机有非常多样化的外置存储设备,比如:磁带、光盘、硬盘、U盘、SSD 等等。外置存储设备的种类是不可穷尽的。随着科技的发展,新的存储设备会不断涌现,有着更低的单位能耗(存储量/每日能源消耗成本),更低的单位存储成本(存储量/可存储的时间/设备价格),或者更高的访问性能。

但不管这些存储设备内部如何存储数据的原理怎么变,改变的主要是质量,而不是它的功能。对操作系统来说,管理它们的方式是非常一致的。这些外置存储设备依据其功能特性不同,简单可以分为如下三类。

  • 顺序读写型。如:磁带。
  • 随机只读型。更准确说是单次完整写入多次读取型,也就是每次写数据都是整个存储介质一次性完整写入数据。如:光盘(含可擦写光盘)。
  • 随机读写型。如:软盘、硬盘、U盘、SSD 等等。

顺序读写型的外置存储(如磁带)我们日常并不常见,它的主要应用场景是归档,也就是数据备份。今天我们略过不提。

随机只读型的外置存储(如光盘)我们日常有较多应用,常见的应用场景是资料分发和归档。资料发布的内容很广泛,比如:软件、娱乐媒体包括电影、MTV、音乐等等。

随机读写型的外置存储最为常见,我们今天在所有“能够称得上叫电脑”的设备上,无论是PC、笔记本、手机,还是手表、汽车,随处可见它们的身影。

外存的数据格式

外置存储和内存最大的区别是什么?

毫无疑问,外置存储是持久存储,它的目的是用来存储资料的。而内存是临时存储,虽然是存储,但是它实质上是为 CPU 的计算服务的。

那么,怎么让很多的软件进程同时使用这些外置存储设备,而不会乱呢?直接基于物理的存储地址进行读写肯定是行不通的,过上几个月你自己可能都不记得什么数据写到哪里了。

所以和内存管理不同,外部存储的管理,我们希望这些写到存储中的数据是“自描述”的某种数据格式,我们可以随时查看之前写了哪些内容,都什么时候写的。

这就是文件系统的来源。

文件系统把存储设备中的数据组织成为了一棵树。节点可以是目录(也叫“文件夹”),也可以是文件。

树的根节点为目录,我们叫根目录。如果是目录,那么它还可以有子节点,子节点同样可以是子目录或文件。文件则是叶节点,保存我们希望存储的资料。

每个节点,无论是目录还是文件,都有自己的名字、创建时间、最后编辑时间、最后访问时间等信息。有些文件系统还会提供最近一段时间的操作日志。这些信息有助于提醒我们有什么内容,以前都做过什么。

尽管几乎所有文件系统的接口是非常一致的,但文件系统的实现却有很多。对于随机只读型的外置存储(如光盘),常见的文件系统有如下这些。

由于这类存储设备的写特征是批量写,一次把所有的数据写完,所以它的数据格式通常偏向于读优化(存储系统一般都有读写操作,所谓读优化是指在数据结构和算法设计时尽可能考虑让读操作更高效)。整个文件系统的元数据和文件数据都会非常紧凑,比如文件数据不必支持分块等等。

对于随机读写型的存储(如硬盘),常见的文件系统有如下这些。

从文件系统格式的设计角度来说,它和架构关联性不大,更多的是数据结构与算法的问题;而且,不是基于内存的数据结构,而是基于外存的数据结构,这两者非常不同。

尽管文件系统的种类非常多,但是它们的设计思路其实基本相似。大部分现代文件系统都基于日志(journal)来改善文件系统的防灾难能力(比如突然断电,或不正常的 unmount 行为),基于 B 树或 B+ 树组织元数据。

古老的 DOS 引入的 FAT 文件系统(典型代表为 FAT32)是个例外,它直接把目录当作一个特殊的文件,里面依次列出了这个目录里的所有子节点的元信息。

这个结构简单是简单了,但是缺点非常明显,如果目录树深、目录里的子节点数量多,都会大幅降低文件系统的性能。

对于随机读写型的存储设备,操作系统往往还支持对其进行分区,尤其是在这个存储设备的容量非常大的情况下。分区是一个非常简单而容易理解的行为,本质上只是把一个存储设备模拟成多个存储设备来使用而已。

一般来说,拿到一块存储设备,我们往往第一步是对其进行分区(当然也可以省略这一步,把整个设备看做一个分区)。

**第二步是对每个分区进行格式化。**所谓格式化就是给这个分区生成文件系统的初始状态。格式化最重要的是标记分区的文件系统格式(用来告诉别人这个分区是数据是怎么组织的),并且生成文件系统的根目录。

**第三步是把该分区挂载(mount)到操作系统管理的文件系统名字空间中。**完成挂载后,该分区的文件系统管理程序就工作起来了,我们可以对这个文件系统进行目录和文件的读取、创建、删除、修改等操作。

外存的使用接口

怎么使用这些外置存储设备?

最简单的方式是用操作系统提供的命令行工具。例如:

  • 目录相关:ls, mkdir, mv, cp, rmdir 等。
  • 文件相关:cat, vi, mv, cp, rm 等。

当然,最原始的方式还是我们上一讲介绍的 “系统调用”。但大部分编程语言对此都有相应的封装,例如 Go 语言中的相关功能如下所示。

  • 目录相关:os.Mkdir, os.Rename, os.Remove 等。
  • 文件相关:os.Open/Create/OpenFile, os.Rename, os.Remove 等。

有意思的是,在早期,操作系统试图将所有的输入输出设备的接口都统一以 “文件” 来抽象它。

最典型的代表就是标准输入(stdin)和标准输出(stdout)这两个虚拟的文件,分别代表了键盘和显示器。在 UNIX 系里面有个 “一切皆文件” 的口号,便由此而来。

但事实证明 UNIX 错了。输入输出设备太多样化了,所谓的 “一切皆文件” 不过是象牙塔式的理想。就拿键盘和显示器来说,图形界面时代到来,所谓标准输入和标准输出就被推翻了,编程接口产生颠覆性的变化。

有了文件系统的使用接口,进程就可以互不影响地去使用这些外置存储设备。除非这些进程要操作的文件或目录的路径产生冲突(所谓路径,是指从根目录到该节点的访问序列。例如路径 /a/b/c 是从根目录访问子目录a,再访问子子目录b,最后访问节点c),一般情况下它们并不需要感知到其他进程的存在。

路径冲突是可以避免的,只要我们对路径取名进行一些基础的名字空间约定,但有时候也会故意利用这种路径的冲突,来实现进程间的通讯。

操作系统提供了一些冲突检查的机制。例如 “检查文件是否存在,不存在就创建它”,这个语义在保证原子性的前提下,就可以用于做进程间的互斥。例如,我们希望一个软件不要运行多个进程实例,就可以基于这个机制来实现。

虚拟内存的支持

前面我们在 “07 | 软件运行机制及内存管理” 一讲中提到,在物理内存不足的时候,操作系统会利用外存把一些很久没有使用的内存页的数据,保存到外存以进行淘汰。

在 UNIX 系的操作系统中,操作系统为此分配了一个磁盘分区叫 swap 分区,专门用于内存页的保存和恢复。在 Windows 操作系统中则通过一个具有隐藏属性的 .swp 文件来实现。

在缺页发生比较频繁时,内存页的数据经常性发生保存和恢复,这会发生大量的磁盘 IO 操作,非常占用 CPU 时间,这时候我们通常能够非常明显感觉到计算机变得很慢。

在计算机变慢,并且计算机的硬盘灯不停闪烁的时候,我们基本可以确定是物理内存严重不足,不能满足运行中的软件的内存需要。

结语

回顾一下我们今天的内容。整体来说,外存管理从架构角度来说比较简单,复杂性主要集中在外存数据格式,也就是文件系统的设计上。

文件系统的实现非常多。如果你希望进一步研究某个文件系统的具体实现细节,我这里推荐一个由七牛云开源的 BPL 语言(Binary Processing Language,二进制处理语言)。地址如下:

顾名思义,BPL 语言主要用于分析二进制数据格式。应用场景包括:文件格式分析(含磁盘分区格式,因为一个磁盘分区可以把它理解为一个大文件)、网络协议分析。

我们在后面的介绍文本处理相关的章节,还会专门拿出 BPL 语言进行讨论。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 输入和输出设备:交互的演进

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

前面我们已经介绍了操作系统的存储管理:内存和外存。今天,让我们来聊一聊操作系统是如何管理输入和输出设备的。

输入和输出设备有非常非常多。例如输入设备除了你常见的键盘、鼠标、触摸屏外,还有一些采集信息的传感器,如GPS位置、脉搏、心电图、温度、湿度等。我们会把关注点收敛在人机交互相关的设备管理上。

交互的演进

在计算机外部设备的演进上,人机交互设备的演进毫无疑问是最为剧烈的部分。

计算机刚出现的时候,人们使用“打孔卡+打印机”作为人机交互方式。这个时期还没有操作系统,表达意图非常痛苦,只能在打孔卡上打孔来发送指令。

但很快,人们找到了 “键盘+显示器”, 这对最为经典的人机交互设备。而随着计算机使用人群越来越多,以及使用场景的变化,人机交互的方式也相应地发生了一次又一次的改变。

总结这些改变,我们会发现,人机交互在往越来越自然的方向发展。所谓自然,就是越来越接近于两个人直接的面对面沟通。

那么,这些人机交互的改变,对于操作系统来说又意味着什么呢?

输入设备

我们先看输入设备:键盘、鼠标、麦克风、摄像头。

键盘

键盘输入的管理机制和窗口系统的设计密切相关。为了让用户清楚键盘输入的目的地,窗口系统往往会有一个焦点窗口。

在窗口系统里面,窗口间还有父子关系,焦点窗口还会有父窗口,还有父窗口的父窗口,这些窗口属于活动窗口。

大部分情况下,键盘输入的事件会先发给焦点窗口,焦点窗口不处理则发给其父窗口,按此传递,直到有人处理了该按键事件,或者直到顶层窗口。

键盘从功能上来说,**有两个不同的能力:其一是输入文本,其二是触发命令。**从输入文本的角度来说,要有一个输入光标(在Windows里面叫Caret)来指示输入的目的窗口。目的窗口也必然是焦点窗口,否则就会显得很不自然。

这个交互的呈现方式非常稳定,从 DOS到Windows/Mac,到iOS/Android 都是如此。但是从触发命令的角度来说,命令的响应并不一定是在焦点窗口,甚至不一定在活跃窗口。

比如Windows下就有热键(HotKey)的概念,能够让非活跃窗口(Inactive Window)也获得响应键盘命令的机会。一个常见的例子是截屏软件,往往需要一个热键来触发截屏。

到了移动时代,键盘不再是交互主体,但是,键盘作为输入文本的能力很难被替代(虽然有语音输入法),于是它便自然而然地保留下来。

不过移动设备不太会有人会基于键盘来触发命令,只有常见的热键需求比如截屏、调大或调小音量、拍照等等,被设计为系统功能(对应的,这些功能的热键也被设计为系统按键)保留下来。

鼠标

鼠标输入的管理机制和键盘一样,和窗口系统的设计密切相关。但鼠标因为有位置,确定鼠标事件的目的地相比键盘事件要简单的多,大部分情况下,鼠标事件总是交给鼠标位置所属的窗口来处理,但也会有一些例外的场景,比如拖放。

为了支持拖放,Windows操作系统引入了鼠标捕获(Mouse Capture)的概念,一旦鼠标被某个窗口捕获,哪怕鼠标已经移出该窗口,鼠标事件仍然会继续发往该窗口。

到了移动时代,鼠标已经完全消失(虽然在智能手机之前还是出现过WinCE这样的支持鼠标的移动操作系统),取而代之的是触摸屏。窗口系统也和PC时期完全不同,在屏幕可见范围内只有单个应用程序占满整个屏幕,这让交互的目的地确认不再是个问题。

麦克风

麦克风是一个非常有潜力的下一代输入设备。今天 IoT 领域如汽车、智能音箱都是很好的发展语音交互能力的场景。包括今天大行其道的手机,语音交互也是一个很好的补充。

交互方式不管怎么变化,其核心需要实现的都是这样的两大能力:输入文本和触发命令,这一点是不变的。

语音交互今天仍然还很不成熟,究其原因,语音交互在 IoT 领域还停留在触发命令为主,且哪怕是触发命令这一件事情,也还有重重关卡需要去突破。

在手机软件中,语音输入文本在部分软件中已经有较多应用,但是主要优势还在日常用语和长文本,在个性化场景如“输入人名之类”,较难达到好的结果。

从更本源的角度看,语音交互今天仍然在相对封闭的应用技术场景里面发展为主,而作为操作系统的主体交互手段,其能力必须是开放的。因为操作系统是开放的,场景是开放的。

摄像头

摄像头作为交互设备,除了引入语音,也引入了手势、表情。从表达能力来说,这是最为丰富也是最为自然的一种表达方法。但是技术所限,这种交互方式还只在萌芽阶段。微软的 Kinect 是一个非常经典的案例,它能够让玩家通过语音和手势发指令来玩游戏。

输出设备

输出设备主要负责向用户反馈信息。比如:显示器(显卡)、音箱(声卡)、打印机。输出设备的演化并不大,最主要的输出设备还是以显示器为主。

显示器

显示器虽然经历了 CRT 到液晶屏多代更新,但也只是支持的色彩更多(从黑白到彩色到真彩色),分辨率越来越高。实际上,从操作系统的软件治理角度来看,显示器并没有发生过实质性的变化。

为了让不同软件可以在同一显示器屏幕上呈现,操作系统引入了窗口系统的概念。每个软件有一个或多个窗口(Window,有时候也叫视图,即View)。在 PC 操作系统中,不同窗口还可以层叠(Cascade)或平铺(Tile)。

通过引入窗口,操作系统在逻辑上把显示器屏幕这个有限的设备资源,分配给了多个软件。和 PC 不同的是,移动设备由于屏幕过小,所以操作系统选择了让软件的顶层窗口全屏占据整个屏幕。这让显示器屏幕的管理变得更为简单。

除了窗口系统,显示设备管理的另一大挑战是绘制子系统。窗口里面的内容是什么,呈现成什么样子,完全是软件来决定的,这就意味着软件需要绘制能力。

绘制能力牵涉面非常之广,在操作系统里面,往往有一个独立的子系统(通常叫GDI)与之对应。这里我们简单罗列一下GDI子系统会涉及哪些东西。

  • **2D图形相关。**包含 Path(路径)、Brush(画刷)、Pen(画笔) 等概念。
  • **3D图形相关。**包含 Model(模型)、Material(材质)、Lighting(光照) 等概念。
  • **文本相关。**包含 Font(字体) 等概念。而字体又分点阵字体和 TrueType 字体。TrueType 字体的优势是可以自由缩放。今天我们见到的大部分字体都是 TrueType 字体。
  • **图像处理相关。**包含 Bitmap(位图) 对象及常见图像格式的编解码器(Encoder/Decoder)。

窗口系统结合输入设备对应的事件管理系统、绘制(GDI)系统,我们就可以随心所欲地实现各类用户体验非常友好的视窗软件了。

但是,为了进一步简化开发过程,操作系统往往还提供了一些通用的界面元素,通常我们称之为控件(Control)。常见的控件有如下这些:

  • 静态文本 (Label);
  • 按钮 (Button);
  • 单选框 (RadioBox);
  • 复选框 (CheckBox);
  • 输入框 (Input,也叫EditBox/EditText);
  • 进度条 (ProgressBar);
  • ……

不同操作系统提供的基础控件大同小异。不过一些处理细节上的差异往往会成为跨平台开发的坑,如果你希望一份代码多平台使用,在这方面就需要谨慎处理。

音箱

相比显示器的管理,音箱的设备管理要简单得多。我们很容易做到多个软件同时操作设备,而有合理的结果。

例如,调整音量我们遵循覆盖原则即可,谁后设置音量就听谁的。而声音的播放则可进行混音处理,多个软件播放的声音同时播放出来,让人听起来像是同时有多个人在说话。

当然,特定情况下要允许某个软件禁止其他软件播放出来的声音,比如接听电话的软件,需要在电话接通的时候屏蔽掉所有其他软件播放的声音。

打印机

打印机的管理方式又很不一样,软件使用打印机的过程基本上是互斥的。一个软件在打印文档的时候,其他的软件只能等待它打印完毕后,才能进行打印。

打印机的使用是以文档为互斥的单位。为了避免软件之间出现长时间的相互等待,操作系统往往在打印机的管理程序中引入很大的打印缓冲。

软件操作打印机的时候,并不是等待打印机真把内容打印出来,而是把文档打印到打印缓冲中就完成打印。这样,在大部分情况下多个软件不需要因为使用打印机而出现相互等待。

结语

后面我们在谈“桌面开发”一章中,还会涉及人机交互的更多细节,这一章侧重点在于领域无关的通用操作系统相关的问题域,相关的内容这里仅做概要性的阐述。

但是,仅通过简单对比所有输入和输出设备的管理方式,我们就可以看出,不同输入和输出设备的管理方法差异非常大,没有太大的共性可言。

尽管对 CPU 而言,所有外部设备有着相同的抽象,但这些设备的业务逻辑却如此不同,并不能统一抽象它们。正是因为有了操作系统这样的基础软件,这些设备业务逻辑的复杂性才从我们的软件开发过程中解放出来。

人机交互演化的核心变化是输入设备的变化。我们看到,输入手段的变化是非常剧烈的,且每一次演变都是颠覆性的变化。

事实上输入意图的理解越来越难了,**因为交互在朝着自然(Nature)和智能(Intelligence)的方向发展。**我们不可能让每一个软件都自己去做输入意图的理解(今天的现状是每个软件自己做),在未来,必然将由操作系统来实现智能交互的基础架构。

今天的内容就到这里。你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 多任务:进程、线程与协程

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到现在为止,我们已经介绍了操作系统的存储管理:内存与外存;也已经介绍了输入与输出设备的管理。

当然,考虑到输入与输出设备属于人机交互范畴,我们主要会留到下一章 “桌面软件开发” 去详细介绍,这一章,我们仅概要地回顾输入与输出设备的需求演进过程。

CPU + 存储 + 输入与输出,软件开发最基础的内容基本上就都覆盖到了。 今天开始,我们就来聊一聊多任务。

多任务与执行体

多任务的需求是随处可见的。常见的场景,比如我们想边工作边听音乐;又或者我们需要跑一个后台监控程序,以报告随时可能发生的异常。

那么,怎么才能做到多任务?

我们先从物理层面看。最早期的 CPU 基本上都是单核的,也就是同一时间只能执行一条指令。尽管如此,大家可能都听过 “摩尔定律”,简单地说就是,每隔一年半到两年,同样的钱能买到的计算力能够翻一倍。

这当然不是什么严谨的物理学定律,更多的是一定历史时期下的经验之谈。早期 CPU 工艺的发展,基本上是通过提高电子元器件的密集程度实现的;但是电子元器件大小总归有个极限,不可能无限小下去。

那么怎么办?不能更小的话,那就横向多铺几个,一颗 CPU 多加几颗核心。这样多核技术就出现了。多核的意思是说,单核速度我提不上去了,多给你几个,价格一样。

所以物理层面的多任务,有两个方法:一个是多颗 CPU,一个是单颗 CPU 多个核心。

在桌面端,大多数情况用的是后者,因为桌面端的产品(个人计算机、手机、手表等)还是很在意产品的体积如何尽可能做得更小;而服务器领域,通常同时使用两者,它更多关注的是如何尽可能提升单台计算机的计算力密度。

但如果我们实际就只有一个单核的 CPU,是否就没办法实现多任务呢?

当然可以。方法是把 CPU 的时间切成一段段时间片,每个时间片只运行某一个软件。这个时间片给软件 A,下一个时间片给软件 B。因为时间片很小,我们会感觉这些软件同时都在运行。这种分时间片实现的多任务系统,我们把它叫分时系统。

分时系统的原理说起来比较简单,把当前任务状态先保存起来,把另一个任务的状态恢复,并把执行权交给它即可。这里面涉及的问题有:

  • 任务是什么,怎么抽象任务这样一个概念;
  • 任务的状态都有什么?怎么保存与恢复;
  • 什么时机会发生任务切换?

从今天的现实看,任务的抽象并不是唯一的。大部分操作系统提供了两套:进程和线程。有的操作系统还会提供第三套叫协程(也叫纤程)。

我个人喜欢统一用来 “执行体” 一词来统称它们。所谓执行体,是指可被 CPU 赋予执行权的对象,它至少包含下一个执行位置(获得执行权后会从这里开始执行)以及其他的运行状态。

任务的状态都有什么?

从 CPU 的角度,执行程序主要依赖的是内置存储:寄存器和内存(RAM),它们构成执行体的上下文。

先看寄存器。寄存器的数量很少且可枚举,我们直接通过寄存器名进行数据的存取。

在我们把 CPU 的执行权从软件 A 切换到软件 B 的时候,要把软件 A 所有用到的寄存器先保存起来(以便后续轮到软件 A 执行的时候恢复),并且把寄存器的值恢复到软件 B 上一次执行时的值,然后才把执行权交给软件 B。

这样,在软件 A 和 B 的视角看来,它们好像一直都是独自在使用 CPU,从未受到过其他软件的打扰。

我们再看内存(RAM)。CPU 在实模式和保护模式下的内存访问机制完全不同,我们分别进行讨论。在实模式下,多个执行体同在一个内存地址空间,相互并无干扰(非恶意情况下)。

在保护模式下,不同任务可以有不同的地址空间,它主要通过不同的地址映射表来体现。怎么切换地址映射表?也是寄存器。

所以,总结就一句话:**执行体的上下文,就是一堆寄存器的值。要切换执行体,只需要保存和恢复一堆寄存器的值即可。**无论是进程、线程还是协程,都是如此。

进程与线程

那么,不同的执行体究竟有何不同?为何会出现不同种类的执行体?

进程是操作系统从安全角度来说的隔离单位,不同进程之间基于最低授权的原则。

在创建一个进程这个事情上,UNIX 偷了一次懒,用的是 fork(分叉)语义。所谓 fork,就是先 clone 然后再分支,父子进程各干各的。

这样创建进程很讨巧,不用传递一堆的参数,使用上非常便利。但我认为从架构设计的角度,这是 UNIX 操作系统设计中最糟糕的 API,没有之一。而更不幸的是 Linux 把这一点继承下来了。

为什么进程 fork 是糟糕的?这是因为:进程是操作系统最基本的隔离单元,我们怕的就是摘不清楚,但是 fork 偏偏要藕断丝连。

这一点 Windows 要清晰很多,哪些文件句柄在子进程中还要用到,一一明确点名,而不是 fork 一下糊里糊涂就继承过去了。

事实上我个人那么多年工程经验表明,除了会接管子进程的标准输入和标准输出,我们几乎从来不会通过向子进程传递文件句柄来通讯。

所以 fork 这种传递进程上下文的方式,是彻头彻尾的一次过度设计。甚至严重一点说,是设计事故。

线程的出现,则是因为操作系统发现同一个软件内还是会有多任务的需求,这些任务处在相同的地址空间,彼此之间相互可以信任。

从线程角度去理解 UNIX 的 fork,能够稍微理解一些设计者们当年的考量。

早期操作系统中没有线程的概念,也不会有人想到要搞两套执行体。所以进程实际上承担了一部分来自线程的需求:我需要父进程的环境。

协程与goroutine

协程并不是操作系统内核提供的,它有时候也被称为用户态线程。这是因为协程是在用户态下实现的。如果你感兴趣,也可以自己实现一个。

但为什么会出现协程呢?看起来它要应对的需求与线程一样,但是功能比线程弱很多?

答案是因为实现高性能的网络服务器的需要。对于常规的桌面程序来说,进程+线程绰绰有余。 但对于一个网络服务器,我们可以用下面这个简单的模型看它:

对网络服务器来说,大量的来自客户端的请求包和服务器的返回包,都是网络 IO;在响应请求的过程中,往往需要访问存储来保存和读取自身的状态,这也涉及本地或网络 IO。

如果这个网络服务器有很多客户,那么整个服务器就充斥着大量并行的 IO 请求。

操作系统提供的标准网络 IO 有以下这些成本:

  • 系统调用机制产生的开销;
  • 数据多次拷贝的开销(数据总是先写到操作系统缓存再到用户传入的内存);
  • 因为没有数据而阻塞,产生调度重新获得执行权,产生的时间成本;
  • 线程的空间成本和时间成本(标准 IO 请求都是同步调用,要想 IO 请求并行只能使用更多线程)。

在一些人心目中会有一个误区:操作系统的系统调用很慢。这句话很容易被错误地理解为系统调用机制产生的开销很大。

但这是很大的误解。系统调用虽然比函数调用多做了一点点事情,比如查询了中断向量表(这类似编程语言中的虚函数),比如改变 CPU 的执行权限(从用户态跃迁到内核态再回到用户态)。

但是注意这里并没有发生过调度行为,所以归根结底还是一次函数调用的成本。怎么理解操作系统内核我们示意如下:

从操作系统内核的主线程来说,内核是独立进程,但是从系统调用的角度来说,操作系统内核更像是一个多线程的程序,每个系统调用是来自某个线程的函数调用。

为了改进网络服务器的吞吐能力,现在主流的做法是用 epoll(Linux)或 IOCP(Windows)机制,这两个机制颇为类似,都是在需要 IO 时登记一个 IO 请求,然后统一在某个线程查询谁的 IO 先完成了,谁先完成了就让谁处理。

从系统调用次数的角度,epoll 或 IOCP 都是产生了更多次数的系统调用。从内存拷贝来说也没有减少。所以真正最有意义的事情是:减少了线程的数量。

既然不希望用太多的线程,网络服务器就不能用标准的同步 IO(read/write)来写程序。知名的异步 IO 网络库 libevent 就是对 epoll 和 IOCP 这些机制包装了一套跨平台的异步 IO 编程模型。

Node.js 一炮而红,也是因为把 JavaScript 的低门槛和 libevent 的高性能结合起来,给了前端程序员一个“我也能搞高性能服务器”的梦想。

但是异步 IO 编程真的很反人类,它让程序逻辑因为 IO 异步回调函数而碎片化。我们开始怀念写同步 IO 的那些日子了。

让我们再回头来看:我们为什么希望减少线程数量?因为线程的成本高?我们分析一下。

首先,我们看下时间成本。它可以拆解为:

  • 执行体切换本身的开销,它主要是寄存器保存和恢复的成本,可腾挪的余地非常有限;
  • 执行体的调度开销,它主要是如何在大量已准备好的执行体中选出谁获得执行权;
  • 执行体之间的同步与互斥成本。

我们再看线程的空间成本。它可以拆解为:

  • 执行体的执行状态;
  • TLS(线程局部存储);
  • 执行体的堆栈。

空间成本是第一根稻草。默认情况下 Linux 线程在数 MB 左右,其中最大的成本是堆栈(虽然,线程的堆栈大小是可以设置的,但是出于线程执行安全性的考虑,线程的堆栈不能太小)。

我们可以算一下,如果一个线程 1MB,那么有 1000 个线程就已经到 GB 级别了,消耗太快。

执行体的调度开销,以及执行体之间的同步与互斥成本,也是一个不可忽略的成本。虽然单位成本看起来还好,但是盖不住次数实在太多。

我们想象一下:系统中有大量的 IO 请求,大部分的 IO 请求并未命中而发生调度。另外,网络服务器的存储是个共享状态,也必然伴随着大量的同步与互斥操作。

综上,协程就是为了这样两个目的而来:

  • 回归到同步 IO 的编程模式;
  • 降低执行体的空间成本和时间成本。

但是,大部分你看到的协程(纤程)库只是一个半吊子。它们都只实现了协程的创建和执行权的切换,缺了非常多的内容。包括:

  • 协程的调度;
  • 协程的同步、互斥与通讯;
  • 协程的系统调用包装,尤其是网络 IO 请求的包装。

这包含太多的东西,基本上你看到的服务端操作系统所需的东西都要包装一遍。而且,大部分协程库,连协程的基础功能也是半吊子的。这里面最难搞的是堆栈。

为什么协程的堆栈是个难题?因为,协程的堆栈如果太小则可能不够用;而如果太大则协程的空间成本过高,影响能够处理的网络请求的并发数。理想情况下,堆栈大小需要能够自动适应需要。

所以,一个完备的协程库你可以把它理解为用户态的操作系统,而协程就是用户态操作系统里面的 “进程”。

这世界上有完备的协程库么?有。有两个语言干了这事儿:Erlang 和 Go 语言。Erlang 语言它基于虚拟机,但是道理上是一致的。Go 语言里面的用户态 “进程” 叫 goroutine。它有这样一些重要设计:

  • 堆栈开始很小(只有 4K),但可按需自动增长;
  • 坚决干掉了 “线程局部存储(TLS)” 特性的支持,让执行体更加精简;
  • 提供了同步、互斥和其他常规执行体间的通讯手段,包括大家非常喜欢的 channel;
  • 提供了几乎所有重要的系统调用(尤其是 IO 请求)的包装。

架构师的批判性思维

多任务的需求非常复杂。

为了满足需要,人们不只发明了三套执行体:进程、线程和协程,还发明了各种五花八门的执行体间的通讯机制(可以参考 “08 | 操作系统内核与编程接口” 中我们给出的表格)。有一些执行体间的通讯机制在逐渐消亡,退出历史舞台。

操作系统内核之中,不乏无数精妙的设计思想。但是,前辈们也并非圣贤,也可能会出现一些决策上失误,留下了诸多后遗症。

这非常正常。操作系统内核是非常庞大而复杂的基础软件。它并不像计算机基础体系结构,简洁优雅。

对 CPU 而言,统一的、接口一致的输入输出设备,到了操作系统这里,它需要依据每一种设备的需求特性,抽象出对应的更加用户友好的使用接口。这个工作既繁重,又需要极强的预见性。

而作为后辈的我们,在体会这些精妙的设计思想的同时,也要批判性去吸收。日常我们天天依赖于这些基础架构,受到它们的影响与约束,这些实在是最佳的学习材料。

结语

今天我们重点介绍了多任务,以及多任务带来的复杂需求,由此介绍了进程、线程和协程等三套执行体的设计。后面我们还会分进程内和进程间来介绍进程的通讯机制。

执行体的设计,有非常多值得反思的地方。UNIX 的 fork API 是否是一个好的设计?线程的设计是否成功?如果线程的设计是优良的,是不是就不再有 Go 语言这种在用户态重造执行体和 IO 子系统的必要性?

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 进程内协同:同步、互斥与通讯

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲开始我们进入了多任务的世界,我们详细介绍了三类执行体:进程、线程和协程,并且介绍了每一种执行体的特点。

既然启动了多个执行体,它们就需要相互协同,今天我们先讨论进程内的执行体协同。

考虑到进程内的执行体有两类:用户态的协程(以 Go 语言的 goroutine 为代表)、操作系统的线程,我们对这两类执行体的协同机制做个概要。如下:

让我们逐一详细分析一下它们。

原子操作

首先让我们看一下原子操作。需要注意的是,原子操作是 CPU 提供的能力,与操作系统无关。这里列上只是为了让你能够看到进程内通讯的全貌。

顾名思义,原子操作的每一个操作都是原子的,不会中途被人打断,这个原子性是 CPU 保证的,与执行体的种类无关,无论 goroutine 还是操作系统线程都适用。

从语义上来说,原子操作可以用互斥体来实现,只不过原子操作要快得多。

例如:

var val int32
...
newval = atomic.AddInt32(&val, delta)

等价于:

var val int32
var mutex sync.Mutex
...
mutex.Lock()
val += delta
newval = val
mutex.Unlock()

执行体的互斥

互斥体也叫锁。锁用于多个执行体之间的互斥访问,避免多个执行体同时操作一组数据产生竞争。其使用界面上大概是这样的:

func (m *Mutex) Lock()
func (m *Mutex) Unlock()

锁的使用范式比较简单:在操作需要互斥的数据前,先调用 Lock,操作完成后就调用 Unlock。但总是存在一些不求甚解的人,对锁存在各种误解。

有的人会说锁很慢。甚至我曾看到有 Go 程序员用 channel 来模拟锁,理由就是锁太慢了,尽量不要用锁。产生“锁慢,channel 快”这种错觉的一个原因,可能是人们经常看到这样的忠告:

不要通过共享内存(锁)来通信,要通过通信(channel)来共享内存。

不明就里的人们看到这话后,可能就有了这样的印象:锁是坏的,锁是性能杀手,channel 是好的,是 Go 发明的先进武器,应该尽可能用 channel,而不要用锁。

快慢是相对而言的。锁的确会导致代码串行执行,所以在某段代码并发度非常高的情况下,串行执行的确会导致性能的显著降低。但平心而论,相比其他的进程内通讯的原语来说,锁并不慢。从进程内通讯来说,比锁快的东西,只有原子操作。

例如 channel,作为进程内执行体间传递数据的设施来说,它本身是共享变量,所以 channel 的每个操作必然是有锁的。事实上,channel 的每个操作都比较耗时。关于这一点,在下文解释 channel 背后的工作机理后,你就会清楚知道。

那么锁的问题在哪里?锁的最大问题在于不容易控制。锁 Lock 了但是忘记 Unlock 后是灾难性的,因为相当于服务器挂了,所有和该锁相关的代码都不能被执行。

比如:

mutex.Lock()
doSth()
mutex.Unlock()

在考虑异常的情况下,这段代码是不安全的,如果 doSth 抛出了异常,那么服务器就会出现问题。

为此 Go 语言还专门发明了一个 defer 语法来保证配对:

mutex.Lock()
defer mutex.Unlock()
doSth()

这样可以保证即使 doSth 发生异常,mutex.Unlock 仍然会被正确地执行。这类在异常情况下也能够正常工作的代码,我们称之为 “对异常安全的代码”。如果语言不支持 defer,而是支持 try .. catch,那么代码可能是这样的:

mutex.Lock()
try {
    doSth()
} catch (e Exception) {
    mutex.Unlock()
    throw e
}
mutex.Unlock()

锁不容易控制的另一个表现是锁粒度的问题。例如上面 doSth 函数里面如果调用了网络 IO 请求,而网络 IO 请求在少数特殊情况下可能会出现慢请求,要好几秒才返回。那么这几秒对服务器来说就好像挂了,无法处理请求。

对服务器来说这是极为致命的。对后端程序员来说,有一句箴言要牢记:

不要在锁里面执行费时操作。

这里 “锁里面” 是指在mutex.Lockmutex.Unlock之间的代码。

在锁的最佳编程实践中,如果明确一组数据的并发访问符合 “绝大部分情况下是读操作,少量情况有写操作” ,这种 “读多写少” 特征,那么应该用读写锁。

所谓读写锁,是把锁里面的操作分为读操作和写操作两种,对应调用不同的互斥操作。

如果是读操作,代码如下:

mutex.RLock()
defer mutex.RUnlock()
doReadOnlyThings

如果是锁里面是写操作,代码就和普通锁一样,如下:

mutex.Lock()
defer mutex.Unlock()
doWriteThings

为什么在 “读多写少” 的情况下,这样的使用范式能够优化性能?

因为从需求上来说,如果当前我们正在执行某个读操作,那么再来一个新的读操作,是不应该挡在外面的,大家都不修改数据,可以安全地并发执行。但如果来的是写操作,就应该挡在外面,等待读操作执行完。整体来说,读写锁的特性就是:

读操作不阻止读操作,阻止写操作;

写操作阻止一切,不管读操作还是写操作。

执行体的同步

聊完了执行体的互斥,我们再来看下执行体之间的同步。

同步的一个最常见的场景是:把一个大任务分解为n个小任务,分配给n个执行体并行去做,等待它们一起做完。这种同步机制我们叫 “等待组”。

其使用界面上大概是这样的:

func (wg *WaitGroup) Add(n int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()

用法上大概是这样的:

var wg WaitGroup
...
wg.Add(n)
for 循环n次 {
    go func() {
        defer wg.Done()
        doTaski  // 执行第i个任务
    }()
}
wg.Wait()

简而言之,在每个任务开始的时候调用 wg.Add(1),结束的时候调用 wg.Done(),然后在主执行体调用 wg.Wait() 等待这些任务结束。

需要注意的是,wg.Add(1) 是要在任务的 goroutine 还没有开始就先调用,否则可能出现某个任务还没有开始执行就被认为结束了。

条件变量(Condition Variable)是一个更通用的同步原语,设计精巧又极为强大。强大到什么程度?像 channel 这样的通讯机制都可以用它来实现。

条件变量的使用界面上大概是这样的:

func NewCond(l Locker) *Cond
func (c *Cond) Broadcast()
func (c *Cond) Signal()
func (c *Cond) Wait()

那么,怎么用条件变量?

我们先看下初始化。条件变量初始化的时候需要传入一个互斥体,它可以是普通锁(Mutex),也可以是读写锁(RWMutex)。如下:

var mutex sync.Mutex  // 也可以是 sync.RWMutex
var cond = sync.NewCond(&mutex)
...

为什么创建条件变量需要传入锁?因为 cond.Wait() 的需要。Wait 内部实现逻辑是:

把自己加入到挂起队列
mutex.Unlock()
等待被唤醒  // 挂起的执行体会被后续的 cond.Broadcast 或 cond.Signal() 唤醒
mutex.Lock()

初始化了条件变量后,我们再来看看它的使用方式。条件变量的用法有一个标准化的模板,看起来大概是这样的:

mutex.Lock()
defer mutex.Unlock()
for conditionNotMetToDo {
    cond.Wait()
}
doSomething
if conditionNeedNotify {
    cond.Broadcast()
    // 有时可以优化为 cond.Signal()
}

看起来有些复杂,让我们来解释一下。加锁后,先用一个 for 循环判断当前是否能够做我们想做的事情,如果做不了就调用 cond.Wait() 进行等待。

这里很重要的一个细节是注意用的是 for 循环,而不是 if 语句。这是因为 cond.Wait() 得到了执行权后不代表我们想做的事情就一定能够干了,所以要再重新判断一次条件是否满足。

确定能够做事情了,于是 doSomething。在做的过程中间,如果我们判断可能挂起队列中的部分执行体满足了重新执行的条件,就用 cond.Broadcast 或 cond.Signal 唤醒它们。

cond.Broadcast 比较粗暴,它唤醒了所有在这个条件变量挂起的执行体,而 cond.Signal 则只唤醒其中的一个。

什么情况下应该用 cond.Broadcast,什么情况下应该用 cond.Signal?最偷懒的方式当然是不管三七二十一,用 cond.Broadcast 一定没问题。但是本着经济的角度,我们还是要交代清楚 cond.Signal 的适用范围:

  • 挂起在这个条件变量上的执行体,它们等待的条件是一致的;
  • 本次 doSomething 操作完成后,所释放的资源只够一个执行体来做事情。

Cond 原语虽然叫条件变量,但是实际上它既没有明白说变量具体是什么样的,也没有说条件具体是什么样的。变量是指 “一组要在多个执行体之间协同的数据”。条件是指做任务前 Wait 的 “前置条件”,和做任务时需要唤醒其它人的 “唤醒条件”。

这样的介绍相当的抽象。我们拿 Go 语言的 channel 开刀,自己实现一个。代码如下:

type Channel struct {
    mutex sync.Mutex
    cond *sync.Cond
    queue *Queue
    n int
}

func NewChannel(n int) *Channel {
    if n < 1 {
        panic("todo: support unbuffered channel")
    }
    c := new(Channel)
    c.cond = sync.NewCond(&c.mutex)
    c.queue = NewQueue()
    // 这里 NewQueue 得到一个普通的队列
    // 代码从略
    c.n = n
    return c
}

func (c *Channel) Push(v interface{}) {
    c.mutex.Lock()
    defer c.mutex.Unlock()
    for c.queue.Len() == c.n { // 等待队列不满
        c.cond.Wait()
    }
    if c.queue.Len() == 0 { // 原来队列是空的,可能有人等待数据,通知它们
        c.cond.Broadcast()
    }
    c.queue.Push(v)
}

func (c *Channel) Pop() (v interface{}) {
    c.mutex.Lock()
    defer c.mutex.Unlock()
    for c.queue.Len() == 0 { // 等待队列不空
        c.cond.Wait()
    }
    if c.queue.Len() == c.n { // 原来队列是满的,可能有人等着写数据,通知它们
        c.cond.Broadcast()
    }
    return c.queue.Pop()
}

func (c *Channel) TryPop() (v interface{}, ok bool) {
    c.mutex.Lock()
    defer c.mutex.Unlock()
    if c.queue.Len() == 0 { // 如果队列为空,直接返回
        return
    }
    if c.queue.Len() == c.n { // 原来队列是满的,可能有人等着写数据,通知它们
        c.cond.Broadcast()
    }
    return c.queue.Pop(), true
}

func (c *Channel) TryPush(v interface{}) (ok bool) {
    c.mutex.Lock()
    defer c.mutex.Unlock()
    if c.queue.Len() == c.n { // 如果队列满,直接返回
        return
    }
    if c.queue.Len() == 0 { // 原来队列是空的,可能有人等待数据,通知它们
        c.cond.Broadcast()
    }
    c.queue.Push(v)
    return true
}

对着这个 Channel 的实现,你是否对条件变量有感觉很多?顺便提醒一点,这个 Channel 的实现不支持无缓冲 channel,也就是不支持 NewChannel(0) 的情况。如果你感兴趣,可以改改这个问题。

执行体的通讯

聊完同步与互斥,我们接着聊执行体的通讯:怎么在执行体间收发消息。

管道是大家都很熟知的执行体间的通讯机制。规格如下:

func Pipe() (pr *PipeReader, pw PipeWriter)

用法上,先调用pr, pw := io.Pipe()得到管道的写入端和读出端,分别传给两个并行执行的 goroutine(其他语言也类似),然后一个 goroutine 读,一个 goroutine 写就好了。

管道用处很多。一个比较常见的用法是做读写转换,例如,假设我手头有一个算法:

func Foo(w io.Writer) error

这个算法生成的数据流,需要作为另一个函数的输入,但是这个函数的输入是 io.Reader,原型如下:

func Bar(r io.Reader)

那么怎么把它们串起来呢?用管道我们很容易实现这样的变换:

func FooReader() io.ReadCloser {
    pr, pw := io.Pipe()
    go func() {
        err := Foo(pw)
        pw.CloseWithError(err)
    }()
    return pr
}

这个 FooReader 函数几句话就把 Foo 变成了一个符合 io.Reader 接口的对象,它就可以很方便的和 Bar 函数结合了。

其实 Go 语言中引入的 channel 也是管道,只不过它是类型安全的管道。具体用法如下:

c := make(chan Type, n) // 创建一个能够传递 Type 类型数据的管道,缓冲大小为 n
...
go func() {
    val := <-c // 从管道读入
}()
...
go func() {
    c <- val // 向管道写入
}()

我们后面在 “服务端开发” 一章,我们还会比较详细讨论 channel,今天先了解一个大体的语义。

结语

总结一下,我们今天主要聊了执行体间的协同机制:原子操作、同步、互斥与通讯。我们重点聊了锁和同步原语 “条件变量”。

锁在一些人心中是有误解的,但实际上锁在服务端编程中的比重并不低,我们可能经常需要和它打交道,建议多花精力理解它们。

条件变量是最复杂的同步原语,功能强大。虽然平常我们直接使用条件变量的机会不是太多,大部分常见的场景往往有更高阶的原语(例如 channel)可以取代。但是它的设计精巧而高效,值得细细体会。

你会发现,操作系统课本上的信号量这样的同步原语,我们这里没有交代,这是因为它被更强大而且性能更好的同步原语 “条件变量” 所取代了。

上面我们为了介绍条件变量的用法,我们实作了一个 channel,你也可以考虑用信号量这样的东西来实现一遍,然后分析一下为什么我们说基于 “条件变量” 的版本是更优的。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。在下期,我们将讨论进程与进程之间的协同:进程间的同步互斥、资源共享与通讯。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 进程间的同步互斥、资源共享与通讯

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在上一讲,我们介绍了进程内执行体之间的协同机制。今天我们接着聊进程与进程之间的协同。

这些协同机制大体可分为:互斥、同步、资源共享以及通讯等原语。对于这些协同机制,我们对比了 Linux、Windows、iOS 这三大操作系统的支持情况,整理内容如下:

在逐一详细分析它们之前,我们先讨论一个问题:从需求角度来讲,进程内协同与进程间协同有何不同?

在早期,操作系统还只有进程这个唯一的执行体。而今天,进程内的执行体(线程与协程)被发明出来并蓬勃发展,事情发生了怎样的变化?

请先思考一下这个问题。我们在这一讲最后总结的时候一起聊聊。

启动进程

在讨论进程间的协同前,我们先看下怎么在一个进程中启动另一个进程。这通常有两种方法:

  • 创建子进程;
  • 让Shell配合执行某个动作。

前面在 “11 | 多任务:进程、线程与协程” 一讲中我们已经提到过,创建子进程 UNIX 系的操作系统都用了 fork API,它使用上很简洁,但是从架构角度来说是一个糟糕的设计。Windows 中我们用 CreateProcess,这个函数有很多的参数。

iOS 很有意思,它并不支持创建子进程。在进程启动这件事情上,它做了两个很重要的变化:

  • 软件不再创建多个进程实例,永远是单例的;
  • 一个进程要调用另一个进程的能力,不是去创建它,而是基于 URL Scheme 去打开它。

什么是 URL Scheme ?我们平常看到一个 URL 地址。比如:

这里面的 https 和 ftp 就是 URL Scheme,它代表了某种协议规范。在 iOS 下,一个软件可以声明自己实现了某种 URL Scheme,比如微信可能注册了“weixin”这个 URL Scheme,那么调用

UIApplication.openURL("weixin://...")

都会跳转到微信。通过这个机制,我们实现了支付宝和微信支付能力的对接。

URL Scheme 机制并不是 iOS 的发明,它应该是浏览器出现后形成的一种扩展机制。Windows 和 Linux 的桌面也支持类似的能力,在 Windows 下调用的是 ShellExecute 函数。

同步与互斥

聊完进程的启动,我们正式开始谈进程间的协同。

首先我们来看一下同步和互斥体。从上一讲 “12 | 进程内协同:同步、互斥与通讯”看,同步互斥相关的内容有:

  • 锁(Mutex);
  • 读写锁(RWMutex);
  • 信号量(Semaphore);
  • 等待组(WaitGroup);
  • 条件变量(Cond)。

进程间协同来说,主流操作系统支持了锁(Mutex)和信号量(Semaphore)。Windows 还额外支持了事件(Event)同步原语,这里我们略过不提。

进程间的锁(Mutex),语义上和进程内没有什么区别,只不过标识互斥资源的方法不同。Windows 最简单,用名称(Name)标识资源,iOS 用路径(Path),Linux 则用共享内存。

从使用接口看,Windows 和 iOS 更为合理,虽然大家背后实现上可能都是基于共享内存(对用户进程来说,操作系统内核对象都是共享的),但是没必要把实现机理暴露给用户。

我们再看信号量。

信号量(Semaphore)概念是 Dijkstra(学过数据结构可能会立刻回忆起图的最短路径算法,对的,就是他发明的)提出来的。信号量本身是一个整型数值,代表着某种共享资源的数量(简记为 S)。信号量的操作界面为 PV 操作。

P 操作意味着请求或等待资源。执行 P 操作 P(S) 时,S 的值减 1,如果 S < 0,说明没有资源可用,等待其他执行体释放资源。

V 操作意味着释放资源并唤醒执行体。执行 V 操作 V(S) 时,S 的值加 1,如果 S <= 0,则意味着有其他执行体在等待中,唤醒其中的一个。

看到这里,你可能敏锐地意识到,条件变量的设计灵感实际上是从信号量的 PV 操作进一步抽象而来,只不过信号量中的变量是确定的,条件也是确定的。

进程间的同步与互斥原语并没有进程内那么丰富(比如没有 WaitGroup,也没有 Cond),甚至没那么牢靠。

为什么?因为进程可能会异常挂掉,这会导致同步和互斥的状态发生异常。比如,进程获得了锁,但是在做任务的时候异常挂掉,这会导致锁没有得到正常的释放,那么另一个等待该锁的进程可能就会永远饥饿。

信号量同样有类似的问题,甚至更麻烦。对锁来说,进程挂掉还可能可以把释放锁的责任交给操作系统内核。但是信号量做不到这一点,操作系统并不清楚信号量的值(S)应该是多少才是合理的。

资源共享

两个进程再怎么被隔离,只要有共同的中间人,就可以相互对话(通讯)。中间人可以是谁?共享资源。进程之间都有哪些共享的存储型资源?比较典型的是:

  • 文件系统;
  • 剪贴板。

文件系统本身是因存储设备的管理而来。但因为存储设备本身天然是共享资源,某个进程在存储设备上创建一个文件或目录,其他进程自然可以访问到。

因此,文件系统天然是一个进程间通讯的中间人。而且,在很多操作系统里面,文件的概念被抽象化,“一切皆文件”。比如,命名管道就只是一种特殊的 “文件” 而已。

和文件系统相关的进程间协同机制有:

  • 文件;
  • 文件锁;
  • 管道(包括匿名管道和命名管道);
  • 共享内存。

这里我们重点介绍一下共享内存。

共享内存其实是虚拟内存机制的自然结果。关于虚拟内存的详细介绍,可以参阅 “07 | 软件运行机制及内存管理” 一讲。虚拟内存本来就需要在内存页与磁盘文件之间进行数据的保存与恢复。

将虚拟内存的内存页和磁盘文件的内容建立映射关系,在虚拟内存管理机制中原本就存在。

只需要让两个进程的内存页关联到同一个文件句柄,即可完成进程间的数据共享。这可能是性能最高的进程间数据通讯手段了。

Linux 的共享内存的使用界面大体是这样的:

func Map(addr unsafe.Pointer, len int64, prot, flags int, fd int, off int64) unsafe.Pointer
func Unmap(addr unsafe.Pointer, len int64)

其中,Map 是将文件 fd 中的[off, off+len)区间的数据,映射到[addr, addr+len) 这段虚拟内存地址上去。

addr 可以传入 nil 表示选择一段空闲的虚拟内存地址空间来进行映射。Unmap 则是将[addr, addr+len)这段虚拟内存地址对应的内存页取消映射,此后如果代码中还对这段内存地址进行访问,就会发生缺页异常。

在 Windows 下共享内存的使用界面和 Linux 略有不同,但语义上大同小异,这里略过不提。

真正值得注意的是 iOS,你会发现基于文件系统的进程间通讯机制,一律不支持。为什么?因为 iOS 操作系统做了一个极大的改变:软件被装到了一个沙箱(Sandbox)里面,不同进程间的存储完全隔离。

存储分为内存和外存。内存通过虚拟内存机制实现跨进程的隔离,这个之前我们已经谈到过。现在 iOS 更进一步,外存的文件系统也相互独立。软件 A 创建的文件,软件 B 默认情况下并不能访问。在一个个软件进程看来,自己在独享着整个外存的文件系统。

文件系统之外,进程间共享的存储型资源,就剩下剪贴板了。

但剪贴板并不是一个常规的进程间通讯方式。从进程间通讯角度来说它有很大的限制:剪贴板只有一个,有人共享数据上去,就会把别人存放的数据覆盖掉。

实践中,剪贴板通常作为一种用户实现跨进程交互的手段,而不太会被用来作为进程间的通讯。相反它更可能被恶意程序所利用。比如,写个木马程序来监听剪贴板,以此来窃取其他程序使用过程中留下的痕迹。

收发消息

那么,不用文件系统和剪贴板这样的共享资源,还有其他的通讯机制么?

有,基于网络。很重要的一个事实是:这些进程同在一台机器上,同在一个局域网中。

套接字作为网络通讯的抽象,本身就是最强大的通讯方式,没有之一。进程间基于套接字来进行通讯,也是极其自然的一个选择。

况且,UNIX 还发明了一个专门用于本地通讯的套接字:UNIX 域。UNIX 域不同于常规套接字的是,它通过一个 name 来作为访问地址,而不是用ip:port来作为访问地址。

Windows 平台并不支持 UNIX 域。但是有趣的是,Windows 的命名管道(NamedPipe)也不是一个常规意义上的管道那么简单,它更像是一个管道服务器(PipeServer),一个客户端连上来可以分配一个独立的管道给服务器和客户端进行通讯。从这个事实看,Windows 的命名管道和 UNIX 域在能力上是等价的。

关于套接字更详细的内容,后文在讨论网络设备管理时我们会进一步介绍。

架构思维上我们学习到什么?

对比不同操作系统的进程间协同机制,差异无疑是非常巨大的。

总结来说,进程间协同的机制真的很多了,五花八门,我们这里不见得就列全了。但是有趣的是,iOS 把其中绝大部分的协同机制给堵死了。

创新性的系统往往有其颠覆性,带着批判吸收的精神而来,做的是大大的减法。

iOS 就是这样的一个操作系统。它告诉我们:

  • 软件不需要启动多份实例。一个软件只需启动一个进程实例。
  • 大部分进程间的协同机制都是多余的。你只需要能够调用其他软件的能力(URL Scheme)、能够互斥、能够收发消息就够了。

这的确是一个让人五体投地的决策。虽然从进程间协同机制的角度,看起来 iOS 少了很多能力。但这恰恰也给了我们一个启示:这么多的进程通讯机制,是否都是必需的?

至少从桌面操作系统的视角看,进程间协同的机制,大部分都属于过度设计。当然,后面在 “服务端开发” 一章中,我们也会继续站在服务端开发视角来谈论这个话题。

并不是早期操作系统的设计者们喜欢过度设计。实际上这是因为有了线程和协程这样的进程内多任务设施之后,进程的边界已经发生了极大的变化。

前面我们讨论架构思维的时候说过,架构的第一步是做需求分析。那么需求分析之后呢?是概要设计。概要设计做什么?是做子系统的划分。它包括这样一些内容:

  • 子系统职责范围的定义;
  • 子系统的规格(接口),子系统与子系统之间的边界;
  • 需求分解与组合的过程,系统如何满足需求、需求适用性(变化点)的应对策略。

从架构角度来看,进程至少应该是子系统级别的边界。子系统和子系统应该尽可能是规格级别的协同,而不是某种实现框架级别的协同。规格强调的是自然体现需求,所以规格是稳定的,是子系统的契约。而实现框架是技巧,是不稳定的,也许下次重构的时候实现框架就改变了。

所以站在架构视角,站在子系统的边界看进程边界,我们就很清楚,进程间协同只需要有另一个进程能力的调用,而无需有复杂的高频协作、高度耦合的配合需求。

不过,为什么 iOS 会如此大刀阔斧地做出改变,除了这些机制的确多余之外,还有一个极其核心的原因,那就是安全。关于这一点,我们在后面探讨操作系统的安全管理时,会进一步进行分析。

结语

今天我们从进程启动开始入手,介绍了同步与互斥、资源共享、收发消息等进程间的协同机制。通过对比不同操作系统,我们会发现以 “剧烈变动” 来形容进程间协同的需求演进一点也不过分。

我认为 iOS 是对的。大刀阔斧干掉很多惯例要支持的功能后,进程这个执行体,相比线程和协程就有了更为清晰的分工。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。到这一讲为止,我们单机软件相关的内容就介绍完了。从下一讲开始我们将进入多姿多彩的互联网世界。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| IP 网络:连接世界的桥梁

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到目前为止,我们介绍了操作系统的六大子系统中的四项:进程、存储、输入、输出。当你理解了这些东西背后的道理,基本上做一款单机软件就游刃有余了。

但是,如果仅仅局限于单机,一台计算机并不见得比计算器高明太多,网络对整个信息科技的重要性不言而喻。它让计算机连接在了一起,这一连接就发生了巨大的变化。

没有了网络,我们只能用 Office 软件,玩玩扫雷。没有网络,就没有 QQ 和微信,不会有淘宝和支付宝,也不会有 BAT。

网络连接一切。它连接了人(个人和企业)、服务(由软件系统构建的服务接口)和物(大自然产物和智能终端),构建了多姿多彩的互联网。

它让地球上的任何两个人都可以随时随地进行沟通,远程做生意。在互联网出现之前,旧的商业文明我们可以一言以蔽之:一手交钱,一手交货。而建立在互联网之上的新商业文明,我们一手下单付款,一手收钱发货,足不出户,货物就通过便捷的物流服务送到了你手上。

这是多么巨大的效率变革,但这一切是怎么做到的呢?

数据的封包过程

网络和其他所有的输入输出设备一样,只能交换数据。无论你要对方做什么,你首先需要发送对方理解得了的数据给它。所以双方要就沟通的语言达成共识,这就是网络协议。

网络协议是计算机与计算机远程沟通的数据格式。它包含很多信息。这些信息不同部分的内容,有不同的职责,关心它的人也各有不同。

网络是传递数据的,是数字内容的物流。作为类比,我们可以看看实物快递的物流协议是什么样的。下面这张快递面单大家应该都很熟悉:

这个快递面单包含很多内容,其中最重要的当然是寄件人信息和收件人信息。有了收件人信息,物流系统才能够知道怎么把要邮寄的物品进行一站站中转,并最终到达目的地。

有了寄件人信息,收件人收到信息才知道是谁寄过来的,如果回复的话应该邮寄往何方。而在物流过程如果出错的话,物流系统也知道如何与寄件人沟通协调错误的处置方法。

寄件人关心什么?他很可能关心物流订单号。这是他掌握物流状态的唯一凭据。另外,为了传输过程的便捷,物流系统可能还会给我们要邮寄的物品用信封袋或者包装箱进行封装。

所有这些寄件人信息、收件人信息、物流订单号、信封袋、包装箱,都不是寄件人要邮寄的内容,而是物流系统对物流协议所产生的需求。

为了支持整个物流系统的不间断运转,我们会有很多不同的部门。有负责最后一公里的快递员,也有负责骨干线路的航空运输部门,或者火车货运部门等等。

同样的,数字物流系统也有很多不同的部门,有的部门负责局域网(LAN)内最后一公里的,也有人负责广域网(WAN)骨干线路的运输的。网络协议作为数字物流的载体,会收到来自这些不同部门的需求。

我们平常可能经常听人提及,网络协议有 OSI 模型,它把网络协议分成了七层结构,从上到下分别是:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。但这样描述有点过于抽象,我们不妨用下图来理解网络协议的分层结构。

让我们设想:我们要传输一部电影,它就是我们的 “物品”,或者用技术术语叫 “应用层数据”,怎么传?

**第一步,分批次。**数字物流系统单次能够传递的数据大小是有限的。如果数据太大,就必须分开多次来传输。从物理网络视角看,数据并不是流(stream),而是一个个大小有明确限制的数据包。

**第二步,套上信封,写好寄件单位的部门(源端口号)、收件单位的部门(目标端口号)、批次编号。**为什么要写部门(端口号)?方便知道由谁(哪个应用程序软件)来负责收件。为什么要写批次编号?是为了防止寄丢了,寄丢了就得重新传一份该批次编号的东西。

这层我们叫传输层。它主要是为了解决传输可靠性的问题。当然传输层有两套协议(两种信封),一套是 TCP 协议,另一套是 UDP 协议。UDP 协议不保证对方一定收到,信封上就没写批次编号。

**第三步,再套上一层信封,上面写上寄件单位地址(源 IP 地址)、收件单位地址(目标 IP 地址)。**有了地址,这封信在广域网(WAN)上流转,就知道自己应该去往何方了。这一层叫做网络层,它定义的信封格式,叫 IP 协议。

互联网的复杂性是在于,它不是一家数字物流公司的事。信寄到某处,可能就换一家物流公司了。它是所有的数字物流公司通力协作的结果。

所以 IP 协议最核心的意义是标准化,解决跨物流公司传输的问题。为什么我们要这样一层信封套一层信封?因为越往外层的信封,内容越和具体的物流公司相关。但无论你外层套的信封如何各有不同,拆到这一层,信的格式就是标准化的。

**第四步,再套上一层信封,这层我们叫它数据链路层。**具体信封上写什么,完全是具体负责这段路程的物流公司说了算。当信件从一个物流公司转到另一个物流公司做交接的时候,这一层信封拆掉,重新换上新的信封。

数据链路层的信封格式(网络协议)非常多样化。局域网(LAN)现在最流行的是以太网(Ethernet)协议,广域网(WAN)现在常见的有 HDLC、PPP、Frame-Relay 等网络协议。

无论如何,写完了特定物流公司所需要的信息,信件就可以进入数字物流系统(物理层)去流转了。

网络协议

上面那一层套一层的信件(网络协议)放的是用户要邮寄的东西,比如一部电影。但为了支持整个邮寄过程的顺利进行,获得更好的用户体验,还会有一些辅助用途的信件(网络协议)在网络上传递,有的是面向用户的,有的是面向数字网络系统内部的。

完整来说,在整个数字物流系统中,与数据传输这件事本身有关的网络协议,我们整理如下:

在这个图中,链路层协议最为复杂,MAC+LLC、PPP、HDLC、Frame-Replay,这些是目前最为常见的。未来也必然会出来很多新的网络通讯技术,用的是全新的协议。链路层之上,IP -> TCP/UDP,这些协议我们最为耳熟能详,上面我们也已经介绍过了。

其他都还有些什么?除了 ICMP 和 IGMP 协议,这些协议都和网络地址的解析有关。所以,在谈协议用途前,我们先聊一聊网络地址。

要通讯,首先要有地址。数字物流世界的地址有三层。最底下的是链路层地址。不同链路层协议的地址表示非常不同。

比如,局域网所采纳的以太网(Ethernet)协议用的是 MAC 地址。一台计算机有一个或多个网卡,每个网卡会有自己的唯一标识即 MAC 地址。这个标识跟随网卡设备存在,和网络环境无关。你把计算机从北京搬到上海,MAC 地址保持不变。

链路层的网络地址我们平常接触并不多,常规我们理解的网络地址是位于第二层的 IP 地址。

IP 地址类似于门牌号:你家住在哪个城市哪条路几号。它决定了网络路由怎么走,信息如何到达你的计算机网卡。IP 地址已经发展了两代,分别为 IPv4 和 IPv6。升级的原因是 IPv4 地址空间太小,只有 4G(即40多亿)个地址。

就像我们通常会更喜欢用 “我要去金茂大厦” 而不是 “我要去上海市浦东新区世纪大道88号” 一样,IP地址并不容易记忆,所以就有了第三层的网络地址:域名。

比如,我们会用 www.qiniu.com 这个地址来找到七牛云的官网,而不是记住枯燥的 IP 地址。

理解了这三类网络地址,我们一一介绍下这些协议的用途。

**首先是 DNS 协议。**这个协议就像是个地址簿,主要负责 “域名” => “IP地址” 的查询。每次我们要邮寄信件之前都要拿出来查一查。

**其次是 DHCP 协议。**DHCP 全称叫动态主机配置协议(Dynamic Host Configuration Protocol),主要负责计算机接入网络时的初始化。计算机刚开始就只有网卡的 MAC 地址,通过 DHCP 可以给它分配 IP 地址,并得到默认网关地址(这很重要,不知道网关就上不了网)和 DNS 服务器的地址。有了这些东西,这台计算机就可以和外界通讯了。

**然后是 ARP 协议。**ARP 全称叫地址解析协议(Address Resolution Protocol),它服务于现在局域网中最流行的以太网协议。在以太网中,ARP 协议负责解析远程主机 IP 地址对应的 MAC 地址。之所以需要 ARP 协议,是因为我们平常应用程序连接目标计算机进行网络通讯时,都是提供了域名或 IP 地址。但对以太网来说,要想发信件出去,它要的是对方的 MAC 地址。

**然后是 RARP 协议。**RARP 全称叫反向地址转换协议(Reverse Address Resolution Protocol)。顾名思义,它和 ARP 协议相反,负责的是 MAC 地址到 IP 地址的转换。RARP 协议已经被上面的 DHCP 协议所取代,平常用不太到了。

**然后是 ICMP 协议。**ICMP 全称叫互联网控制报文协议(Internet Control Message Protocol),它能够检测网路的连线状况,以保证连线的有效性。基于这个协议实现的常见程序有两个:ping 和 traceroute,它们可以用来判断和定位网络问题。

**最后是 IGMP 协议。**IGMP 全称叫互联网组管理协议(Internet Group Management Protocol),它负责 IP 组播(Multicast)成员管理。本文略过这块的内容。

数据传输过程

了解了数据包的结构,也了解了数据传输相关的网络协议,接下来我们聊一聊数据传输的过程。为了方便理解,我们画了一幅数据传输的示意图:

简化理解来说,我们可以认为,在需要传输数据的源主机和目标主机之间,它们通过若干路由器或交换机连接。我们分以下几种情况来分析:

情形一,源主机和目标主机在同一个局域网内,中间通过交换机连接,采用了最常见的以太网协议。

通讯开始的时候,源主机只有目标主机的 IP 地址,并没有 MAC 地址。但以太网通讯要的是 MAC 地址,所以源主机会发起一个 ARP 请求去获得目标 IP 对应的 MAC 地址。

当然,源主机会缓存这个对应关系。第二次继续给相同 IP 发信息的时候,就不需要重新发起 ARP 请求了。

无论是 ARP 请求,还是普通的数据包,都会先到达交换机。ARP 是一个广播请求,所以交换机会转发给所有其他主机,目标主机发现这个 IP 地址是自己的,于是返回自己的 MAC 地址。

有了目标主机的 MAC 地址,源主机就可以发数据了。同样的,所有数据包都发给了交换机。

交换机是性能极高的网络数据交换设备。它通常工作在网络协议的第二层,也就是数据链路层。这一层只认 MAC 地址,不认 IP 地址。MAC 地址本身是个唯一身份标识,就像我们的身份证号,并没有可寻址的作用。那么交换机怎么做到这么高的数据传输的效率?

交换机在工作的过程中,会不断地收集资料去创建一个地址映射表:MAC 地址 => 交换机端口。这个表很简单,它记录了某个MAC 地址是在哪个端口上被发现的。

交换机收到一个数据包后,首先会进行学习,把源 MAC 地址和收到数据包的交换机端口对应起来。然后交换机查看数据包的目标 MAC 地址,并在地址映射表中找,如果找到对应的端口,那么就往这个端口转发数据包。

如果没找到,交换机可能会把这个数据包 “扩散” 出去,就好像收到广播数据包一样。这时如果目标主机收到广播过来的数据包后,回复了这个数据包,那么它的 MAC 地址和交换机端口的映射关系就也会被学习到。

当交换机初次加入网络时,由于地址映射表是空的,所以,所有的数据包将发往局域网内的全部端口,直到交换机 “学习” 到各个MAC 地址为止。这样看来,交换机刚刚启动时与传统的共享式集线器类似,直到地址映射表比较完整地建立起来后,它才真正发挥它的高性能。

我们总结一下,当一台交换机安装配置好之后,其工作过程如下。

  • 收到某端口(设为A)MAC 地址为 X 的计算机发给 MAC 地址为 Y 的计算机的数据包。交换机从而记下了 MAC 地址 X 在端口 A。这称为学习(learning)。
  • 交换机还不知道 MAC 地址 Y 在哪个端口上,于是向除了 A 以外的所有端口转发该数据包。这称为泛洪(flooding)。
  • MAC 地址 Y 的计算机收到该数据包,向 MAC 地址 X 发出确认包。交换机收到该包后,从而记录下 MAC 地址 Y 所在的端口。
  • 交换机向 MAC 地址 X 转发确认包。这称为转发(forwarding)。
  • 交换机收到一个数据包,查表后发现该数据包的来源地址与目的地址属于同一端口。交换机将不处理该数据包。这称为过滤(filtering)。
  • 交换机内部的 “MAC 地址 => 端口” 查询表的每条记录采用时间戳记录最后一次访问的时间。早于某个阈值(用户可配置)的记录被清除。这称为老化(aging)。

情形二,源主机和目标主机都有公网 IP 地址,它们中间经过若干交换机和路由器相连。

路由器和交换机不太一样,交换机因为没有门牌号,通讯基本靠吼。好的一点是,圈子比较小,吼上一段时间后,路都记住了,闭着眼睛都不会走错。

但广域网太大了,靠吼没几个人听得见。所以路由器工作在网络协议的第三层,也就是网络层。网络层看到的是 IP 协议,能够知道数据传输的源 IP 地址和目标 IP 地址。

有了 IP 地址,就相当于有了门牌号,开启导航按图索骥就可以把东西带过去了。这也是路由器为什么叫路由器的原因,它有导航(路由)功能,知道哪些目标 IP 地址的数据包应该往哪条路走的。

路由器可以拥有一部分交换机的能力,比如,如果发现请求是局域网内的话,也可以引入类似交换机那样的基于 MAC 地址的映射表实现高速通讯。但总体来说,路由器要考虑的问题复杂很多,因为涉及 “最佳路由路径” 的问题。

简单说,所谓 “最佳路由路径” 是指,到达目标主机的路有很多种可能性,我应该选择哪一条。大家在大学可能都学过带权的有向图,路由器面临的正是这种情况。而且情况可能更复杂的是,每一小段路径的权重都是动态的,因为网络状况一直在变。

如果你对路由算法感兴趣,可以在维基百科查找 “路由” ,进一步研究。

路由器除了解决路由问题,它往往还要解决异构网络的封包转换问题。作为局域网的接入方,它可能走的是固网或 WiFi 网络。

作为 Internet 的接入方,它可能走的是光纤宽带。所以它需要把局域网的数据链路层的封包解开并重组,以适应广域网数据链路协议的需求。

理解了以上两点,我们回到话题:广域网的两台具备公网 IP 的主机之间如何完成数据传输?

大体来说,整个过程如下。

  • 首先,源主机发送的数据包,经由交换机(可选),到达本局域网的公网网关(路由器)。这个过程属于局域网内通讯,同情形一。
  • 路由器收到了数据包,发现目标主机是 Internet 上的某个远端的目标主机,于是对数据包进行拆包重组,形成新的数据包。
  • 循着自身的路由表,把这个新数据包层层转发,最后到达目标主机对应的公网网关(路由器)上。
  • 路由器发现是发给本局域网内的目标主机,于是再拆包重组,形成新的数据包。
  • 新数据包转到局域网内,经由交换机(可选),并最终到达目标主机。如此,整个数据传输过程就结束了。

情形三,源主机和目标主机至少有一方在局域网内且只有私有 IP 地址,它们中间经过若干交换机和路由器相连。

解释一下私有 IP 地址。在 IPv4 地址区间中,有一些区段,比如 10.0.0.0 ~ 10.255.255.255、172.16.0.0 ~ 172.31.255.255、192.168.0.0 ~ 192.168.255.255 ,这几个 IP 地址区间都是私有 IP 地址,只用于局域网内通讯。

常规来说,只有私有 IP 而没有公网 IP 的主机只能和局域网内的主机通讯,而无法和 Internet 上的其他主机相互通讯。

但这一点又和我们日常的感受不符:比如家庭用户往往网络结构是一个 WiFi 路由器连接公网,所有的家庭设备如手机、平板、笔记本,都以 WiFi 路由器为网关构成一个局域网。那么我们的这些设备是怎么上网的呢?

答案是 NAT(Network Address Translation,网络地址转换)技术。它的原理比较简单,假设我们现在源主机用的IP+端口为 iAddr:port1,经过 NAT 网关后,NAT 将源主机的 IP 换成自己的公网 IP,比如 eAddr,端口随机分配一个,比如 port2。

也就是从目标主机看来,这个数据包看起来是来自于 eAddr:port2。然后,目标主机把数据包回复到 eAddr:port2,NAT 网关再把它转发给 iAddr:port1。

也就是说,NAT 网关临时建立了一个双向的映射表 iAddr:port1 <=> eAddr:port2,一旦完成映射关系的建立,在映射关系删除前,eAddr:port2 就变成了 iAddr:port1 的 “替身”。这样,内网主机也就能够上网了。

NAT 网关并不一定是公网网关(路由器),它可以由局域网内任何一台有公网 IP 的主机担当。但显然如果公网网关担当 NAT 网关,链路的效率会高一点。

我们家用的 WiFi 路由器,就充当了 NAT 网关的作用,这也是我们能够上网的原因。

那么,最极端的情形,源主机和目标主机在不同的局域网内,且都没有公网 IP,它们是否可以通讯呢?

答案是不确定。

首先,在这种情况下,源主机和目标主机没法直接通讯,需要中间人去帮忙搭建通讯的链路。怎么做呢?找一个有公网 IP 的主机作为中间人服务器,目标主机向它发包,这样,在目标主机的 NAT 网关就形成了一对双向的映射表:

  • iDestAddr:portDest1 <=> eDestAddr:portDest2

然后,中间人服务器再把 eDestAddr:portDest2 告诉源主机。这样源主机就可以通过向 eDestAddr:portDest2 发送数据包来和目标主机 iDestAddr:portDest1 通讯了。

我们不少 P2P 软件就利用了这个技术实现 NAT 穿透,让两台不同内网的计算机相互能够直接通讯。

那么,答案为什么是不确定?因为上面这个机制只有在目标主机的 NAT 网关是 Full cone NAT,即一对一(one-to-one)NAT 网关时才成立。

什么是 Full cone NAT?它是指 NAT 网关临时建立了 iAddr:port1 <=> eAddr:port2 双向映射后,任何主机给 eAddr:port2 发送数据包,都会被转给 iAddr:port1,并不局限于构建这个映射时数据包发送的目标主机是谁。

但在其他类型的 NAT 网关下,一般都对回包的主机 IP 地址有约束。也就是说,NAT 网关形成的双向映射表是因为哪个目标主机建立的,那么回包也必须来自哪台主机。

这种情况下,中间人服务器就没办法来搭桥让它们直接通讯了,数据包需要由中间人服务器来中转。

结语

总结一下,今天我们介绍了数据封包过程、与 IP 数据包传输相关的网络协议,并对数据传输过程做了整体的描述。

我们主要介绍的重点是 IP 协议之下的网络工作过程。我们不讨论如何进行数据重传,更不关心数据到达应用层我们收到数据包后,如何去处理它们。

互联网背后的世界,和互联网一样精彩。精妙之处,值得细细体会。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将进一步来探讨一下网络世界的编程接口。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 可编程的互联网世界

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

前面我们讨论架构思维的时候说过,架构的第一步是做需求分析。需求分析之后呢?是概要设计。概要设计做什么?是做子系统的划分。它包括这样一些内容:

  • 子系统职责范围的定义;
  • 子系统的规格(接口),子系统与子系统之间的边界;
  • 需求分解与组合的过程,系统如何满足需求、需求适用性(变化点)的应对策略。

对于我们理解这个精彩的互联网世界来说,理解它的子系统的划分思路是非常非常重要的。

网络应用程序的全视图

在上一讲 “14 | IP 网络:连接世界的桥梁” 中我们介绍了 IP 网络的工作原理。我们还画了一幅与数据传输这件事本身有关的网络协议图,如下:

那么,从一个典型的网络应用程序角度来说,它的完整视图又是什么样子的呢?

上图是我给出的答案。当然,它并不代表所有的网络应用程序,但这不影响我们借它的结构来解释网络世界是怎么划分子系统的,每个子系统都负责了些什么。

**第一层是物理层。**你可以理解为网络设备的原生能力,它定义了硬件层次来看的基础网络协议。

**第二层是数据链路层。**它负责解决的是局部网络世界的数据传输能力。网络数据传输技术会层出不穷,今天主流有固网、WiFi、3G/4G,明天有 5G/6G,未来也必然还会出现更快速的网络新技术。

这些网络技术虽然都有自己独特的链路层协议,但都可以很自然融入整个互联网世界。原因在于什么?在于 IP 网络。

**所以第三层是 IP 网络层,它负责的是互联网世界的一体化,彼此包容与协作。**如果拿单机的应用程序的全视图来类比的话,IP 网络类似于单机体系中的操作系统。

在单机体系,操作系统是一台计算机真正可编程的开始。同样地,互联网世界的体系中,IP 网络是互联网 “操作系统” 的核心,是互联网世界可编程的开始。

**第四层是 TCP/UDP 传输层。**它也是互联网 “操作系统” 的重要组成部分,和 IP 网络一起构成互联网 “操作系统” 的内核。IP 网络解决的是网如何通的问题,而传输层解决的是如何让互联网通讯可信赖的问题,从而大幅降低互联网应用程序开发的负担。

互联网并不是世界上的第一张网。但是只有拥有了 TCP/IP 这一层 “操作系统”,这才真正实现了网络价值的最大化:连接一切。

有了操作系统,应用软件才得以蓬勃发展。上图我们列出的应用层协议,仅仅只是沧海一粟。但是,要说当前最主流的应用层协议,无疑当属 HTTP 协议(超文本传输协议,HyperText Transfer Protocol)和 SMTP/POP3 协议了。

HTTP 协议是因为万维网(World Wide Web,简称 WWW)这个应用场景而诞生,冲着传输静态网页而去的。但是由于设计上的开放性,几经演进到今天,已经俨然成为一个通用传输协议了。

通用到什么程度?DNS 地址簿这样的基础协议,也搞出来一个新的 HTTP DNS。当然今天 HTTP DNS 还只是传统 DNS 协议的补充,使用还并不广泛。但由此可知人们对 HTTP 协议的喜爱。

除了呈现网页之外,HTTP 协议也经常被用来作为业务开放协议 RESTful API 的承载。另外,一些通用 RPC 框架也基于 HTTP 协议,比如 Google 的 gRPC 框架。

SMTP/POP3 协议是电子邮件(Email)应用所采用的,它们没有像 HTTP 协议那么被广泛借用,只是局限于电子邮件应用领域。但 SMTP/POP3 协议使用仍然极为广泛,原因是因为电子邮件是最通用的连接协议,它连接了人和人,连接了企业和企业。

我们都很佩服微信的成功,因为它连接了几乎所有的中国人。但是相比电子邮件,微信仍然只是小巫见大巫,因为电子邮件连接了世界上的每一个人和企业。

这是怎么做到的?因为开放的力量。如果说有谁能够打败微信,那么我个人一个基本的思考是:用微信的方式打败微信恐怕很难,但微信是封闭协议,开放也许是一个打败微信的机会?

还有其他很多应用层协议上图没有列出来,比如 FTP、NFS、Telnet 等等。它们大都应用范围相对小,甚至有一些渐渐有被 HTTP 协议替代的趋势。

对于一个网络应用程序来说,它往往还依赖存储和数据库(DB/Storage)。目前存储和数据库这块使用 HTTP 的还不多,除了对象存储(Object Storage),大部分还是直接基于 TCP 协议为主。

对象存储作为一种最新颖的存储类型,现在主流都是基于 HTTP 协议来提供 RESTful API,比如七牛云的对象存储服务。

所以你可以看到,网络应用程序所基于的基础平台,比单机软件要庞大得多。前面我们介绍的单机软件所依赖的 CPU + 编程语言 + 操作系统就不说了,它一样要依赖。

上图所示的网络世界所构建的庞大基础平台,从物理层 -> 数据链路层 -> 网络层 -> 传输层 -> 应用平台层,也都是我们业务架构的依赖点。选择自定义网络协议,基于 gRPC,还是基于 HTTP 提供 RESTful API ?这是架构师需要做出的决策之一。

应用层协议与网关

上一讲 “14 | IP 网络:连接世界的桥梁” 中我们谈到两台主机是如何通讯时,我们介绍了让局域网主机能够上网的 NAT 技术。NAT 网关本质上是一个透明代理(中间人),工作在网络协议的第四层,即传输层,基于 TCP/UDP 协议。

如果我们限定传输的数据包一定是某种应用层协议时,就会出现所谓的应用层网关,工作在网络协议的第七层,所以有时候我们也叫七层网关。

我们熟知的 Nginx、Apache 都可以用作应用层网关。应用层协议通常我们采用的是 HTTP/HTTPS 协议。

为什么 HTTP 协议这么受欢迎,甚至获得了传输层协议才有的待遇,出现专用的网关?

这得益于 HTTP 协议的良好设计。

我们一起来看一看 HTTP 协议长什么样。先看获取资源的 GET 请求(Request):

GET /abc/example?id=123 HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Accept: */*

HTTP 协议的请求(Request)分协议头和正文两部分,中间以空行分隔。GET 请求一般正文为空。

协议头的第一行是请求的命令行,具体分为三部分,以空格分隔。第一部分为命令,常见有 GET、HEAD、PUT、POST、DELETE 等。第二部分是请求的资源路径。第三部分为协议版本。

协议头从第二行开始,每行均为请求的上下文环境或参数,我们不妨统一叫字段(Field)。格式为:

字段名: 字段值

HTTP 服务器收到一个请求后,往往会返回这样一个回复(Response):

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 68
ETag: W/"fb751fe2cb812eb5d466ed9e3c3cd519"

<html><head><title>Hello</title></head><body>qiniu.com</body></html>

HTTP 请求(Request)和回复(Response)格式上只有第一行不同。回复的第一行也分为三部分,以空格分割。

第一部分为协议版本。

第二部分是状态码(Status Code),用来表征请求的结果,200 表示成功,4xx 通常表示请求(Request)本身不合法,5xx 则通常表示 HTTP 服务器有异常。

第三部分是状态文本(Status Text),方便接收方看到回复后可以立刻判断问题,而不用去查状态码对应的文档。

当协议正文非空的时候,往往还需要用 Content-Type 字段来指示协议正文的格式。例如这里我们用 text/html 表征返回的协议正文是一个 html 文档。Content-Length 字段则用来指示协议正文的长度。

我们再来看一下修改资源的 POST 请求:

POST /abc/example HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Authorization: Qiniu dXNlcj14dXNoaXdlaSZwYXNzd2Q9MTIzCg
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 18

id=123&title=Hello

和 GET 不一样,修改资源往往需要授权,所以往往会有 Authorization 字段。另外这里我们用 Content-Type 字段表示我们协议正文用了表单(form)格式。

最后我们看下删除资源的 DELETE 请求:

DELETE /abc/example HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Authorization: Qiniu dXNlcj14dXNoaXdlaSZwYXNzd2Q9MTIzCg
Content-Type: application/json
Content-Length: 11

{"id": 123}

删除和修改完全类似。除了我这里刻意换了一种 Content-Type,协议正文用 json 格式了。实际业务中当然不是这样,通常会选择一致的表达方法。

大致了解了 HTTP 协议的样子,我们一起来分析一下它到底好在哪里?

毫无疑问,最关键的是它的协议头设计。具体表现在如下这些方面。

  • 极其开放的协议头设计。虽然 HTTP 定义了很多标准的协议头字段(Field),但是用户还是可以加自己的字段,惯例上以 X- 开头。例如,七牛引入了 X-Reqid 作为请求的内部调用过程的跟踪线索。关于 X-Reqid 本专栏后续我们还会继续谈到。
  • 规范了业务的表达范式。虽然业务有千千万万种可能,但是实质上不外乎有什么资源,以及对资源的 CURD(创建-修改-读取-删除)。相对应地,在HTTP 协议中以 “资源路径” 表达资源,以 PUT-POST-GET-DELETE 表达 CURD 操作(也有一些服务以 POST 而不是用 PUT 请求来创建资源)。
  • 规范了应用层的路由方式。我们知道,在传输层网络的路由基于 IP 地址,但是对于应用而言,IP 地址是一个无意义的字段,在 HTTP 协议头中,有一个字段是强制的,那就是 Host 字段,它用来表征请求的目标主机。通常,在正式生产环境下它是个域名,比如 api.qiniu.com 。以域名来表征目标主机,无疑更加能够体现业务特性。故而,对应用层而言,“域名+资源路径” 是更好的路由依据,方便进行业务的切分。

正因为 HTTP 协议的这些好处,逐渐地它成为了网络应用层协议的模板。无论业务具体是什么样子的,都可以基于 HTTP 协议表达自己的业务逻辑。

TCP/IP 层编程接口

理解清楚了我们网络应用程序的结构,也理解了我们最主流的应用层协议 HTTP 协议,那么我们就可以考虑去实现一个互联网软件了。

从编程接口来说,网络的可编程性是从网络层 IP 协议开始。这是最底层的网络 “操作系统” 的能力体现。

从基于 IP 协议的网络视角来看,数据并不是源源不断的流(stream),而是一个个大小有明确限制的 IP 数据包。IP 协议是无连接的,它可以在不连接对方的情况下向其发送数据。规格示意如下:

package net

type IPAddr struct {
    IP   IP
    Zone string // IPv6 scoped addressing zone
}

func DialIP(network string, laddr, raddr *IPAddr) (*IPConn, error)
func ListenIP(network string, laddr *IPAddr) (*IPConn, error)

func (c *IPConn) Read(b []byte) (int, error)
func (c *IPConn) ReadFrom(b []byte) (int, Addr, error)
func (c *IPConn) ReadFromIP(b []byte) (int, *IPAddr, error)
func (c *IPConn) Write(b []byte) (int, error)
func (c *IPConn) WriteTo(b []byte, addr Addr) (int, error)
func (c *IPConn) WriteToIP(b []byte, addr *IPAddr) (int, error)
func (c *IPConn) Close() error

IP 协议本身只定义了数据的目标 IP,那么这个 IP 地址对应的计算机收到数据后,究竟应该交给哪个软件应用程序来处理收到的数据呢?

为了解决这个问题,在 IP 协议的基础上定义了两套传输层的协议:UDP 和 TCP 协议。它们都引入了端口(port)的概念。

端口很好地解决了软件间的冲突问题。一个IP地址+端口,我们通常记为 ip:port,代表了软件层面上来说唯一定位的通讯地址。每个软件只处理自己所使用的 ip:port 的数据。

当然,既然 IP 和端口被传输层一起作为唯一地址,端口上一定程度上缓解了 IPv4 地址空间紧张的问题。

虽然从设计者的角度来说,最初端口的设计意图,更多是作为应用层协议的区分。例如 port = 80 表示 HTTP 协议,port = 25 表示 SMTP 协议。

应用协议的多样化很容易理解,这是应用的多样化决定的。尽管从架构的角度,我们并不太建议轻易去选择创造新的协议,我们会优先选择 HTTP 这样成熟的应用层协议。但是随着时间的沉淀,还是会不断诞生新的优秀的应用层协议。

但是,为什么需要有多套传输层的协议(TCP 和 UDP)呢?

还是因为应用需求是多样的。底层的 IP 协议不保证数据是否到达目标,也不保证数据到达的次序。出于编程便捷性的考虑,TCP 协议就产生了。

TCP 协议包含了 IP 数据包的序号、重传次数等信息,它可以解决丢包重传,纠正乱序,确保了数据传输的可靠性。

但是 TCP 协议对传输协议的可靠性保证,对某些应用场景来说并不是一个好特性。最典型的就是音视频的传输。在网络比较差的情况下,我们往往希望丢掉一些帧,但是由于 TCP 重传机制的存在,可能会反而加剧了网络拥塞的情况。

这种情况下,UDP 协议就比较理想,它在 IP 协议基础上的额外开销非常小,基本上可以认为除了引入端口(port)外并没有额外做什么,非常适合音视频的传输需求。

编程接口来说,TCP 的编程接口看起来是这样的:

package net

type TCPAddr struct {
    IP   IP
    Port int
    Zone string // IPv6 scoped addressing zone
}

func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error)

func (c *TCPConn) Read(b []byte) (int, error)
func (c *TCPConn) Write(b []byte) (int, error)
func (c *TCPConn) Close() error

func (l *TCPListener) Accept() (Conn, error)
func (l *TCPListener) AcceptTCP() (*TCPConn, error)
func (l *TCPListener) Close() error

UDP 的编程接口看起来是这样的:

package net

type UDPAddr struct {
    IP   IP
    Port int
    Zone string // IPv6 scoped addressing zone
}

func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error)
func ListenUDP(network string, laddr *UDPAddr) (*UDPConn, error)

func (c *UDPConn) Read(b []byte) (int, error)
func (c *UDPConn) ReadFrom(b []byte) (int, Addr, error)
func (c *UDPConn) ReadFromUDP(b []byte) (int, *UDPAddr, error)
func (c *UDPConn) Write(b []byte) (int, error)
func (c *UDPConn) WriteTo(b []byte, addr Addr) (int, error)
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error)
func (c *UDPConn) Close() error

对比看,IP 和 UDP 的区别非常小,都是无连接的协议,唯一差别就是 UDPAddr 在 IPAddr 基础上增加了一个端口。也正因为如此,我们很少有应用程序会直接基于 IP 协议来编程。

客户端来说,无论 TCP 还是 UDP,使用方式都很像,其示意代码如下:

c, err := net.Dial("tcp", addrServer)
c.Write(...)
c.Read(...)
c.Close()

net.Dial 背后会根据 network 字段选择调用 DialTCP 还是 DialUDP。然后我们就像操作一个文件一样来操作就行,理解上非常简单,只是 UDP 的读写在应用层面需要考虑可能会丢包。

但是服务端不太一样。服务端并不知道谁会给自己发信息,它只能监听自己的 “邮箱”,不时看看是不是有人来信了。

对于 TCP 协议,服务端示意代码如下:

l, err := net.Listen("tcp", addrServer)
for {
  c, err := l.Accept()
  if err != nil {
    错误处理
    continue
  }
  go handleConnection(c)
}

对于 UDP 协议,服务端示意代码如下:

c, err := net.ListenUDP("udp", addrServer)
for {
  n, srcAddr, err := c.ReadFromUDP(...)
  if err != nil {
    错误处理
    continue
  }
  // 根据 srcAddr.IP+port 确定是谁发过来的包,怎么处理
}

由于 TCP 基于连接(connection),所以每 Accept 一个连接后,我们可以有一个独立的执行体(goroutine)去处理它。但是 UDP 是无连接的,需要我们手工根据请求的来源 IP+port 来判断如何分派。

HTTP 层编程接口

尽管基于 TCP/IP 层编程是一个选择,但是在当前如果没有特殊的理由,架构师做业务架构的时候,往往还是优先选择基于 HTTP 协议。

我们简单来看一下 HTTP 层的编程接口:

package http

func Get(url string) (*Response, error)
func Post(url, contentType string, body io.Reader) (*Response, error)
func PostForm(url string, data url.Values) (*Response, error)

func NewRequest(method, url string, body io.Reader) (*Request, error)

var DefaultClient = new(Client)
func (c *Client) Do(req *Request) (*Response, error)

func NewServeMux() *ServeMux
func (mux *ServeMux) Handle(pattern string, handler Handler)
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request))
func ListenAndServe(addr string, handler Handler) error
func ListenAndServeTLS(addr, certFile, keyFile string, handler Handler) error

对于 HTTP 客户端,使用上要比 TCP/UDP 简单得多,常见情况下直接调用 Get、Post 这些函数调用就满足业务需求。

在需要在 HTTP 协议头写一些额外字段的,会略微麻烦一点,需要先 NewRequest 生成一个请求,并添加一些字段(Field),然后再调用 Client.Do 去发起请求。整体上比调用 Read/Write 这样的基础 IO 函数要简便得多。

对于 HTTP 服务端,使用上的示意代码如下:

mux := http.NewServeMux()
mux.HandleFunc("/abc/example", handleAbcExampe)
mux.HandleFunc("/abc/hello/", handleAbcHello)

http.ListenAndServe(addServer, mux)

简单解释一下,一个 HTTP 服务器最基础的就是需要有根据 “资源路径” 的路由能力,这依赖 ServeMux 对象来完成。

简单对比可以看出,基于 HTTP 协议的编程接口,和基于 TCP/IP 协议裸写业务,其复杂程度完全不可同日而语。前者一个程序的架子已经呈现,基本上只需要填写业务逻辑就好。这也是采纳通用的应用层协议的威力所在。

结语

这一讲我们希望给大家呈现的是应用程序的全貌。当然,我们现在看到的仍然是非常高维的样子,后面在 “服务端开发” 一章,我们将进一步展开所有的细节。

在应用层协议介绍上,我们很难有全面的介绍,因而我们把侧重点放在 HTTP 协议的概要介绍上。同样,后面我们在 “服务端开发” 一章会进一步介绍 HTTP 协议。

最后,我们整理了基于 TCP/UDP 协议编程和基于 HTTP 协议编程的主体逻辑。虽然介绍非常简要,但通过对比我们仍然可以感受到业务架构基于成熟的应用层协议的优势所在。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。网络编程本章就到此结束,后面我们有专门的章节来进一步展开。下一讲,我们将探讨操作系统的最后一个子系统:安全管理。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 安全管理:数字世界的守护

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。今天我们要聊的话题是操作系统的最后一个子系统:安全管理。

数字世界是高效的,但数字世界也是脆弱的。在越来越多的日常生活被数字化的今天,安全问题也越来越凸显出了它的重要性。

有经验的安全工程师都知道,做好安全的基本逻辑是:不要开太多的门和窗,最好所有人都在同一道门进出,安全检查工作就可以非常便利地进行。

要想构建一个安全可靠的环境,从最底层就开始设计显然是最好的。所以安全管理是一个基础架构问题。现代操作系统必然会越来越关注安全性相关的问题。因为一旦安全问题严重到触及人们的心里防线,整个数字世界都有可能随之崩塌。

让我们从头回顾一下操作系统安全能力的演进。

病毒与木马

首先是实模式的操作系统,以微软的 DOS 系统为代表。实模式的操作系统进程都运行在物理地址空间下。

这意味着,每个软件进程都可以访问到其它软件进程(包括操作系统)的内存数据,也可以随意地修改它。所以这个时期的计算机是非常脆弱的,它选择的是信任模式:我相信你不会搞破坏。

不过,好在这个时期网络还并不发达,所以一个单机版本的恶意软件,能够干的真正恶意的事情也很有限。这一时期恶意软件以计算机病毒为主,其特征主要是繁衍自己(复制自己),对计算机系统本身做某种程度的破坏。

现代操作系统基本上都是保护模式的操作系统。保护模式就是让软件运行的内存地址空间隔离,进程之间相互不能访问(除非基于共享内存技术,那也是进程自己主动选择,与被动无感知的情况下被人窥视不同)。

这从安全角度来说,是很重要的进步。不管怎么说,内存数据是最为敏感的,因为它无所不包。况且,从 Windows 开始,互联网逐步进入人们的视野。计算机的联网,一下子让安全问题变得严峻起来。

恶意软件目的开始变得不单纯。它不再只是黑客的技术炫耀,而是切切实实的黑色产业链的关键依赖。

这一时期恶意软件开始以木马为主。木马和病毒一样会去繁衍自己(复制自己),但是它较少以破坏计算机的运行为目的,相反它默默隐藏起来,窃取着你的隐私。然后,它再通过互联网把窃取的信息默默地传递出去(比如通过电子邮件)。

哪些信息是木马感兴趣的?有很多。比如以下这些信息:

  • 键盘按键;
  • 剪贴板的内容;
  • 内存数据;
  • 文件系统中关键文件的内容;
  • ……

你可能奇怪,前面不是说保护模式已经把内存数据隔离了么,为什么木马还是能够取到内存数据?

其实这一点不难想明白,虽然跨进程已经无法取得数据了,但是木马本来就是靠复制自己,把自己伪装成正常软件的一部分。这样,木马程序和正常的软件代码同属于一个进程内,所有信息对其仍然一览无余。

为了彻底阻止木马程序篡改正常的应用程序,聪明的操作系统创造者们想到了好方法:数字签名。

这本质上是白名单技术。所有正常发布的软件都到操作系统厂商那里登记一下。这样,一旦木马去修改软件,把自己附加上去,这个软件的签名验证就通不过,也就直接暴露了。

其实 Windows 操作系统已经引入了数字签名的概念,可以用以鉴别软件的可信度。但是考虑到从开放转向封闭有极大的历史负担,所以无论是 Windows 还是 macOS,都没有完全杜绝无签名的软件,最多当你运行无数字签名的软件时,会给个不可信的警告。

**第一个大规模把软件发布变成一个封闭环境的是苹果的 iOS 操作系统。**苹果通过引入 App Store,要求所有应用发布都必须通过 App Store 进行。今天无论是 Android 还是 iOS 操作系统都基于应用市场这样的封闭软件发布的形态。

这样一来,软件无法被非法修改,木马基本上就无所遁形了。当然,这并不代表木马在这些平台上就消失了。虽然不容易,但是通过感染开发人员的软件开发环境,还是可以在软件编译或其它环节中把木马注入到要发布的软件中。

要发现这种异常,iOS 和 Android 系统的厂商对软件进行数字签名前,往往会对其进行安全扫描,以发现各种潜在的安全风险。一旦某个软件被鉴定为恶意软件,就无法通过数字签名,也无法发布到应用市场上。

通过这些机制,木马很难再有机会得到传播。

软件的信息安全

但是,这意味着我们没有安全风险了么?当然不是。在移动设备上,安全问题的大环境发生了巨大的变化。

首先,移动时代随着我们数字世界对现实生活影响的加深,我们越来越多的敏感信息更加容易被软件触及。有很多新增的敏感信息是PC时代所不具备的,例如:

  • 通讯录和通话记录;
  • 短信;
  • 个人照片和视频;
  • 个人地理位置(GPS)信息;
  • 移动支付的支付密码、支付验证码;
  • 录像和录音权限;
  • 通话权限;
  • .……

正因为如此,尽管操作系统正变得越来越安全,但我们面临的安全威胁却也在日趋严重。

其实, iOS 操作系统在安全管理上的考虑不可谓不周全。

首先,在软件隔离机制上,除了基于 CPU 的保护模式,确保软件之间的内存隔离外,iOS 还引入了沙盒系统(Sandbox),确保软件之间文件系统隔离,相互之间不能访问对方保存在磁盘上的文件。

其次,通过上面我们已经提及的数字签名机制,防止了软件被恶意篡改,让病毒和木马无法传播繁衍。

最后,对涉及敏感信息的系统权限进行管控。各类敏感信息的授予均是在应用程序使用的过程中进行提示,提醒用户注意潜在的安全风险。

在这一点上,Android 操作系统往往则是在安装软件时索要权限。这两者看似只是时机不同,但是从安全管理角度来说, iOS 强很多。

还没有见到软件真身就让用户判断要不要给权限,用户往往只能无脑选择接受。而如果是在软件运行到特定场景时再索要权限,那么权限给不给就有合理的场景支持决策。

但是,在利益面前,软件厂商们是很难抵御住诱惑的。所以不仅仅是恶意软件会去过度索要系统权限,很多我们耳熟能详的常规软件也会索要运行该软件所不需要的权限。

移动时代,恶意软件的形态已经再一次发生变化。它既不是病毒也不是木马,而是“具备实用功能,但背地却通过获取用户的敏感信息来获利”的应用软件。

它通过诱导用户下载,然后在软件安装或者使用时索要敏感信息的获取权限。

一个软件到底是正常的还是恶意的?边界已经越来越模糊了。

以前病毒和木马都有复制和繁衍自己这样一个显著的特征,但如今它们的复制繁衍能力已经被操作系统的安全机制所阻止,所以恶意软件和普通软件一样,都是通过某种手段吸引用户下载安装。

怎么保护好用户的隐私信息?道高一尺,魔高一丈。攻防之间的斗争仍将继续下去。

网络环境的信息安全

如果我们不轻易尝试不可信的软件,就可以一切安全无虞?并不然,我们还要考虑我们的计算机所处的网络环境安全问题。

我们上网过程需要经过一系列的中间节点,有交换机,有路由器。我们的上网产生的所有数据包,都经由这些中间节点,这意味着我们有以下三个级别的安全风险。

  • 被窃听的风险。可能会有人在这些节点上监听你访问和提交的内容。
  • 被篡改的风险。可能会有人在这些节点上截获并修改你访问的内容。
  • 被钓鱼的风险。可能会有人冒充你要访问的服务提供方和你通讯。

虽然大部分的中间节点由网络运营商提供,我们刨除这些节点被黑客所黑的情形,基本上认为可信。但这并不绝对,至少在中国,运营商修改中转的数据包这样的事情是干得出来的,常见的手法有:

  • 在正常的 HTML 页面插入广告;
  • 修改用户下载的 apk 文件,替换成自己想分发的 apk 文件;
  • 修改 404 类型的 HTML 页面,替换成自己的搜索引擎的搜索页;
  • .……

其次是 WiFi 路由器。WiFi 路由器因为其提供方鱼龙混杂,天生是安全问题的大户。运营商能够干的事情它全都可以干,甚至可以更加肆无忌惮,以李鬼替换李逵,钓鱼的风险并不低。

比如你以为登录的是交通银行官网,它可能给你一个一模一样外观的网站,但是一旦你输入用户名和密码就会被它偷偷记录下来。

怎么解决中间人问题?

首先是怎么防篡改。应用场景是电子合同/公章、网络请求授权(例如你要用七牛的云服务,需要确认这个请求的确是你,而不是别人发出的)等。这类场景的特征是不在乎内容是否有人看到,在乎的是内容是不是真的是某个人写的。

解决方法是数字签名技术。一般来说,一个受数字签名保护的文档可示意如下:

其中,“要防篡改的内容” 是信息原文。“密钥提示” 是在数字签名的 “密钥” 有多个的情况下,通过 “密钥提示” 找到对应的 “密钥”。如果用于保护信息的 “密钥” 只有一个,那么可以没有 “密钥提示”。“指纹” 则是对信息使用特定 “密钥” 和信息摘要算法生成的信息摘要。

大部分情况下,数字签名的信息摘要算法会选择 HMAC MD5 或者 HMAC SHA1。在 Go 语言中,使用上示意如下:

import "crypto/hmac"
import "crypto/sha1"
import "encoding/base64"

textToProtected := "要防篡改的内容"
keyHint := "123"
key := findKey(keyHint) // 根据 keyHint 查找到 key []byte

h := hmac.New(sha1.New, key) // 这里用sha1,也可以改成别的
h.Write([]byte(textToProtected))
textDigest := base64.URLEncoding.EncodeToString(h.Sum(nil))
textResult := textToProtected + ":" + keyHint + ":" + textDigest

得到的 textResult 就是我们期望的不可篡改信息。验证信息是否被篡改和以上这个过程相反。

首先根据 textResult 分解得到 textToProtected、keyHint、textDigest,然后根据 keyHint 查找到 key;再根据 textToProtected 和 key 算一次我们期望的信息摘要 textDigestExp。

如果 textDigestExp 和 textDigest 相同,表示没被篡改,否则则表示信息不可信,应丢弃。

如果我们希望更彻底的隐私保护,避免被窃听、被篡改、被钓鱼,那么数字签名就不顶用了,而需要对内容进行加密。

加密算法上,一般分为对称加密和非对称加密。对称加密是指用什么样的密钥(key)加密,就用什么样的密钥解密,这比较符合大家惯常的思维。

非对称加密非常有趣。它有一对钥匙,分私钥(private key)和公钥(public key)。私钥自己拿着,永远不要给别人知道。公钥顾名思义是可以公开的,任何人都允许拿。

那么公私钥怎么配合?首先,通过公钥加密的文本,只有私钥才能解得开。这就解决了定向发送的问题。网络中间人看到加密后的信息是没有用的,因为没有私钥解不开。

另外,私钥拥有人可以用私钥对信息进行数字签名(防止篡改),所有有公钥的人都可以验证签名,以确认信息的确来自私钥的拥有者,这就解决了请求来源验证的问题。

那么 A、B 两个人怎么才能进行安全通讯呢?首先A、B两人都要有自己的公私钥,并把公钥发给对方。这样 A 就有 A-private-key、B-public-key,B 就有 B-private-key、A-public-key。通讯过程如下所示。

  • A 向 B 发信息 R。具体来说,A 首先用 A-private-key 对 R 进行签名,得到(R,R-digest);然后用 B-public-key 对(R,R-digest)加密,得到 encoded(R,R-digest);然后把最终的加密信息发出去。
  • B 收到 encoded(R,R-digest),用 B-private-key 解密得到(R,R-digest),然后再用 A-public-key 验证信息的确来自 A。
  • B 理解了 R 后,回复信息给 A。这时两人的角色互换,其他同上。

非对称加密机制非常有效地解决了在不可信的网络环境下的安全通讯问题。但是它也有一个缺点,那就是慢。相比之下,它的速度比对称加密慢很多。

所以,一个改善思路是结合两者。非对称加密仅用于传输关键信息,比如对称加密所需的密码。完整的通讯过程如下所示。

  • A 生成一个临时用的随机密码 random-key。
  • A 向 B 发送 random-key,机制用的就是上面的非对称加密,基于 B-public-key。
  • B 收到 A 发送的 random-key,把它记录下来,并回复 A 成功。回复的信息可以基于 random-key 做对称加密。
  • 此后,A 向 B 发、B 向 A 发信息,都用 random-key 作对称加密,直到本次会话结束。

你可能发现,整个过程中 A 自己已经不再需要非对称的公私钥对了。只要 A 事先有 B 的公钥(B-public-key)就可以。

当然,上面我们的讨论,没有涉及 B 如何把自己的 B-public-key 交给对方的。在假设网络不可信的前提下,这似乎是个难题。

我觉得有两个可能性。一个是 A 和 B 很熟悉,平常都经常一起玩。那么他们交换 public-key 完全可以不依赖任何现代通讯设备,包括电话和互联网,而是写在一张纸上,某天聚会的时候交换给对方。

另一个是更为常见的互联网世界场景:我要访问一个网站。我怎么才能避免被窃听、被篡改、被钓鱼?

通常我们用 HTTPS 协议。

在 HTTPS 协议中,第一步是 A 作为客户端(Client)去获取 B 作为网站的公钥(B-public-key)。

怎么获取?如果我们认为网络不可信,那么我们就需要找一个可信的中间人,第三方权威机构 G,由它来证明,我们网站 B 返回客户端 A 的公钥(B-public-key),的确来自于 B,中间没有被其他人篡改。

这意味着网站 B 不能直接返回自己的公钥(B-public-key)给客户端 A,而是需要返回由权威机构 G 做了数字签名的公证书(简称数字证书),里面记录了网站 B 的域名(domain),和对应的公钥(B-public-key),还有证书的颁发人 G 的代号。

这张数字证书的作用是什么?最重要的并不是它怎么在网络上传递的。而是它记录了这样一个事实:域名 domain 对应的公钥是 B-public-key,它是由权威机构 G 做出的公证,因为上面有 G 的数字签名。

所以这张数字证书并不需要临时生成,而是提前在网站部署时就已经生成好了,而且也可以随意传递给任何人,因为它是完全公开的信息。

当然这里还有一个前提,我们客户端 A 已经提前拥有第三方权威机构 G 的公钥(G-public-key)了。整个过程如下:

  • 客户端 A 向 网站 B 请求网站的数字证书。
  • 网站 B 返回它的数字证书。
  • 客户端 A 收到数字证书,用 G-public-key 验证该数字证书的确由权威机构 G 认证,于是选择相信证书里面的 (domain, public-key) 信息。
  • 客户端 A 检查证书中的 domain,和我们要访问的网站 B 域名是否一致。如果不一致,那么说明数字证书虽然是真的,但是是别人找权威机构 G 认证的其他域名的证书,于是结束会话;如果一致,于是相信证书中的 public-key 就是网站 B 的公钥(B-public-key)。

有了 B-public-key,客户端 A 就可以愉快地上网,不必担心网络通讯的安全了。

但是,HTTPS 并不能完全解决钓鱼问题。它假设用户对要访问的网站域名(domain)可靠性有自己的判断力。

这当然并不全是事实。所以,高级一点的浏览器(例如 Google Chrome),它会建立不靠谱网站域名的数据库,在用户访问这些网站时进行风险提示。

更多的信息安全话题

上面我们更多从服务终端用户角度,操作系统和浏览器以及我们的应用程序需要考虑的是信息安全问题。有以下这些信息安全问题没有涉及:

  • 服务器的安全问题(DDOS 攻击、漏洞与入侵);
  • 企业信息安全;
  • 社会工程学的安全问题;
  • ……

结语

总结一下,我们今天聊了软件安全态势的演变过程,从最早的病毒和木马,演化到今天敏感信息如通讯录等内容的窃取,正常软件与恶意软件的判断边界越来越模糊。

我们也聊了网络环境带来的安全问题。今天主流的假设是网络链路是不可信的,在不可信的网络之上如何去做安全的通讯,可以做到防窃听、防篡改、防钓鱼。这也是苹果前几年强制要求 iOS App 必须走 HTTPS 协议的原因。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。本章关于操作系统的话题到此就结束了。下一讲我们结合前面的内容,讨论并实战架构第一步,怎么做需求分析。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 架构:需求分析 (上)

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

前面我们多次提到过,架构的第一步是需求分析。那么,为什么要做需求分析?如何做好需求分析?

今天让我们一起聊一聊需求分析这个话题。

关于需求分析的那些事

为何要做需求分析?

首先,当然是因为我们做软件本身就是为了满足用户需求。那么,用户需求到底为何,我们需要清楚定义。

其次,需求边界定义的需要。用户需求理清楚了,不代表产品理清楚了。用户需求的满足一定会有行业分工,我们做什么,合作伙伴做什么,需要厘清大家的边界。

最后,架构设计的需要。架构需要切分子系统,需要我们梳理并对用户需求进行归纳与抽象。架构还需要防止过度设计,把简单的事情复杂化。

但什么是过度设计?不会发生的事情你考虑了并且为它做足了准备,就是过度设计。所以判断是不是过度设计是很困难的,需要对需求未来演化有很强的判断力。

从这几个维度来看,需求分析过程必然会涉及以下这些内容。

  • 我们要面向的核心用户人群是谁?
  • 用户原始需求是什么?最核心问题是哪几个?
  • 已经有哪些玩家在里面?上下游有哪些类型的公司,在我们之前,用户是怎么解决他们的问题的?我们的替换方案又是怎样的?
  • 进而,我们的产品创造的价值点是什么?用户最关注的核心指标是什么?
  • 用户需求潜在的变化在哪些地方?区分出需求的变化点和稳定点。

当然,我并不是说,我们应该在需求分析的文档中完整地回答这些问题。需求分析文档目的并不是回答这些问题。但是在我们梳理需求的过程中,我们无法回避对这些问题的思考。

可能有人会认为,这些问题是 CEO 或产品经理这样的角色需要回答的,而不是架构师需要回答的。

某种意义上来说这句话没错。回答这些问题的首要责任方是 CEO 或产品经理。他们有责任让团队中的每一个人理解我们的产品逻辑。

但是,如果架构师只是被动地接受产品需求,以按图索骥的方式来做架构设计,是不足以成为顶级架构师的。原因在于两点。

**一方面,用户需求的深层理解是很难传递的。**你看到的产品文档,是产品经理和用户沟通交流后的二次理解,是需求的提炼和二次加工,很难原汁原味地传递用户的述求。

所以架构师自己亲身近距离地接触用户,和用户沟通,去体会用户的述求是非常有必要的。

况且,大部分人并不会那么仔仔细细地阅读别人写的文档。当然这不完全是看文档的人单方面的原因,如果团队文档平均质量不高的话,也会影响到阅读者的心态。

**另一方面,产品设计过程需要架构师的深度参与,而不是单向的信息传递。**产品经理非常需要来自架构师的建设性意见。

为什么我会有这样的看法呢?这涉及我对产品的理解。产品本身是运用先进的技术来满足用户需求过程的产物。

用户需求的变化是缓慢的,真正改变的是需求的满足方式。而需求满足方式的变化,深层次来说,其背后往往由技术迭代所驱动。

从这个角度来说,**产品是桥,它一端连接了用户需求,一端连接了先进的技术。**产品经理是需要有技术高度的,他不一定要深刻了解技术的原理,但是一定要深刻理解新技术的边界。

某项技术能够做什么,不能做到什么,顶级产品经理甚至比实现这项技术的开发人员还要清楚。

认为产品经理不需要理解技术,这可能是我们普遍存在的社会现象,但很可能并不符合这个岗位的内在诉求。

回到架构师这个角色。

我经常说一个观点,产品经理和架构师其实是一体两面。两者都需要关心用户需求与产品定义。

只不过产品经理更多从用户需求出发,而架构师更多从技术实现出发,两者是在产品这座桥的两端相向而行,最终必然殊途同归。

这也是我为什么说架构师需要深度参与产品设计的原因。产品经理很可能会缺乏他应该有的技术广度,这就需要架构师去补位。产品定义过程需要反复推敲琢磨,并最终成型。

需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。

需求分析的重要性怎么形容都不过分。准确的需求分析是做出良好架构设计的基础。

前面我也说过,我个人认为架构师在整个架构设计的过程中,至少应该花费三分之一的精力在需求分析上。

这也是为什么很多非常优秀的架构师换到一个新领域后,一上来并不能保证一定能够设计出良好的架构,而是往往需要经过几次迭代才趋于稳定。

原因就在于:领域的需求理解是需要一个过程的,对客户需求的理解不可能一蹴而就。

怎么做需求分析

那么怎么才能做好需求分析?

**首先,心态第一,心里得装着用户。**除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。

**其次,对问题刨根究底,找到根源需求。**有很多用户反馈需求的时候,往往已经带着他自己给出的解决方案。

这种需求反馈已经属于二次加工的需求,而非原始需求。这个时候我们要多问多推敲,把它还原到不带任何技术实现假设的根源需求。

如上图所示,根源需求可能会有非常非常多的技术方案可以满足它。我们上面示意图中的小圆点是一个个用户反馈的需求。在用户提这些需求的时候,往往可能会带着他熟悉的技术方案的烙印。

对于那些我们明显不关心的需求,如上图的小红点,相对容易排除在外。毕竟产品的边界意识大家还是会有的,产品不可能无限制膨胀下去。

但是对于上面的小绿点,决策上就比较难了。不做?可能会丢了这个客户。做?如果我们手放宽一点,最后产品需求就会被放大(如上图中蓝色的圆圈),做出一个四不像的产品。

**最后,在理清楚需求后,要对需求进行归纳整理。**一方面,将需求分别归类到不同的子类别中。另一方面,形成需求的变化点和稳定点的基本判断。

前面我们也强调过:在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。

要注意的是,在讨论需求的变化点和稳定点的时候,我们需要有明确参考的坐标系。在不同视角下,稳定点和变化点的判断是完全不同的。

所以需要明确的一点是,当我们说需求的变化点和稳定点时,这是站在我们要设计的产品角度来说的。

比如我们要设计一台计算机,那么多样化的外部设备是一个变化点。但是如果我们今天是在设计一台显示器,问题域就完全变了,需求的变化点和稳定点也就完全发生了变化。

本质上来说,对变化点的梳理,是一次产品边界的确立过程。所谓的开放性设计,就是说我把这个功能交给了合作伙伴,但是我得考虑怎么和合作伙伴配合的问题。

开放性设计并不是一个纯粹的用户需求问题,它通常涉及技术方案的探讨。因此,产品边界的确立不是一个纯需求,也不是一个纯技术,而是两者合而为一的过程。

对变化点的梳理至关重要。产品功能必须是收敛的,必须是可完成的。

如果某个子类别的需求呈现出发散而无法收敛的趋势,这个事情,团队一定要坐下来一起去反复推敲。不断拷问,不断明确响应需求的正确姿势到底为何。

产品定义

需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义并不是简单的产品需求的归类。

上面我也说过,产品是桥,它一端连接了用户需求,一端连接了先进的技术。所以产品定义不可能做到和技术方案完全没关系。

**首先,需要明确产品中有哪些元素,或者叫资源,以及这些资源的各类操作方式。**如果我们从技术的视角来理解,这就是定义对象和方法。当然这仅仅是这么理解,实际上一个我们技术上的对象方法,从产品需求角度会有多条路径的操作方式来达到相同的目的。

**其次,需要对产品如何满足用户需求进行确认。**用户的使用场景未必全部是我们的产品所能直接满足的,面向特定的行业,有可能需要相应的行业解决方案,把我们的产品整合进去。


我们要避免把行业方案视作产品的一部分。更多的情况下,需要我们更加开放的心态来看待这件事情,优先寻找合作伙伴来一起完成这类行业的需求覆盖。

最后,产品定义还需要考虑市场策略,我们的产品如何进入市场,和既有市场格局中的其他主流解决方案的关系是什么样的。

我们希望获取的用户,可能大部分都已经有一个既有的产品和技术方案,在满足他的需求。在考虑如何让客户从既有方案迁移到我们的产品后,我们确定产品的边界时又会复杂很多。

在一些极其关键的市场,我们有可能会把迁移需求视作产品需求的一部分。但更多的情况下,我们产品上只为这些市场上的主流方案提供迁移路径,而不是完整的迁移方案。

为何架构课从基础平台开始?

很抱歉我说得很抽象,但是总结需求分析的方法论的确是一件很难的事情。

为什么我们谈架构会从 “基础平台” 讲起?为什么从硬件架构,到编程语言,再到操作系统,我们似乎绕了一大圈,还没有谈到架构?

有两个原因。

最直接的原因是 “基础平台” 是我们所依赖的环境,是我们应用的业务架构的一部分。越了解我们所处的环境,我们就越能够运用自如。

**但还有一个重要的原因是架构的探讨容易过度抽象。**所以我并没有先长篇大论谈架构方法论,谈需求应该怎么怎么去分析,而是围绕着基础平台的演进过程来谈需求分析。

信息世界的构建过程,本身就是一个最宏大的架构实践。我们通过对信息世界的骨架构成的参悟,自然能够感悟到架构思维的要点。

学内功需要悟心,学架构也需要悟心。怎么准确研判需求,对需求演进进行预测,这并不是靠技术技能,而是靠谦和求取的心态。

所以我们第一章 “基础平台” 篇整体来说,内容介绍以产品的需求分析为主、核心技术原理为辅。我们尝试把整个基础平台融为一个整体,宏观上不留任何疑惑。

实际上这一章的内容很难做到只看一遍就可以,可能要时时看,反复看。还需要查阅一些资料,也可以与人一起探讨。当然,我们也欢迎留言一起交流。

这一章我们介绍的内容,大部分内容都有一些对应的经典书籍,在后面 “基础平台篇: 回顾与总结” 一讲中,我也会给大家推荐一些经典的图书。

但我们并不是要重复这些书籍中的内容。我们的关注点在于:一是构建信息世界的宏观骨架,二是需求演进。

经典书籍虽然好,但是它们写作时候的历史背景和今天有很大不同。从架构视角来说,结合我们今天的现实情况来看,一方面我们可以总结今天区别于当初的所有变化,另一方面主动去思考为什么发生了这样的变化。以这样的视角去读经典书籍,会别有一番滋味。

结语

在我们介绍完第一章 “基础平台” 篇的所有内容后,今天我们终于正式开始谈架构思维。我们探讨的是架构的第一步:需求分析。

需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。

怎么提升需求分析能力,尤其是预判能力?

首先,心态第一,心里得装着用户。除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。

其次,对问题刨根究底,找到根源需求。

最后,对需求进行归纳整理。一方面,将需求分别归类到不同的子类别中。另一方面,形成需求的变化点和稳定点的基本判断。

需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义将明确产品的元素,明确产品的边界,与产业上下游、合作伙伴的分工。

为什么我们的架构课从日常最平常之处,我们日日接触的基础平台讲起?

你真了解它们吗?你真感悟到它们的不凡之处了吗?

学习架构,关键在于匠心与悟心。

用思考的方式去记忆,而不是用记忆的方式去思考。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲将是 “架构: 需求分析(下)· 实战案例”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 架构:需求分析 (下) · 实战案例

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

今天,我们继续上一讲关于架构第一步 “需求分析” 的讨论。为了能够获得更加具体的观感,我们选了两个实战的案例,如下:

  • 打造 “互联网”;
  • 存储新兵 “对象存储”。

案例: 打造 “互联网”

从对信息科技的影响面来说,最为标志性的两个事件,一个是计算机的诞生,另一个是互联网的诞生。

我们前面在 “05 | 思考题解读: 如何实现可自我迭代的计算机?”这一讲中,已经剖析过一个 MVP 版本的计算机是什么样的。

今天,我们就以 “互联网” 这个产品为题,看看应该怎么去做需求分析。

我们想象一下,把我们自己置身于互联网诞生之前。互联网并不是第一张网。在此之前的信息世界中,更多的是某个企业专用的局域网。不同的企业会选择不同公司所提供的网络方案。这些网络方案缺乏统一的规划,彼此并不兼容。

那么,怎么才能打造一个连接人与人、企业与企业,甚至是物与物,能够 “连接一切” 的 “互联网”?

首先,从根源需求来说,我们期望这不是某个巨头公司的网,也不是政府的网。这是需求的原点,这一点上的不同,产生的结果可能就很不一样。

如果我们忽略这一点,就有可能会把它做成微信网(WechatNet),或者中国网(ChinaNet)。它们可能会是一张巨大的网,但都不是 “互联网”。

所谓 “互联网” 首先应该是一张开放的网。它应该可以让很多国家很多公司参与其中,形成合力。它不应该存在 “造物主”,一个可以在这张网络中主宰一切的人。

开放,最基础的层次来说,意味着需要定义网络协议标准,尤其是跨网的数据交换标准。这里的跨网,指的是跨不同的网络设备,不同的网络运营商。

开放,从另一个角度来说,是对应用程序软件的开放。想要 “互联网” 真正能够连接一切,只是把物理的网络连接在一起是不够的,还要有能够丰富的 “连接一切” 的应用。

为了能够让更多应用可以更便捷地连接网络,我们需要提供方便应用接入的高层协议。这个协议需要屏蔽掉网络连接的复杂性(丢包重传等)。

但这还不够。“互联网” 这样的基础设施,启动阶段没有应用去吸引用户是不行的。所以我们需要 “吃自己的狗粮”,开发若干互联网应用的典型代表。

有一些需求可能非常非常重要,但是我们需要阶段性放弃,例如安全。加密传输并没有作为互联网的内建特性,这极大降低了互联网的实施难度。

从另一个角度考虑,为什么不把安全放在最底层,也要考虑方案的可持续性。一个安全方案是否能够长期有效,这非常存疑。

但是物理网络一旦存在,就很难做出改变(想想我们从 IPv4 过渡到 IPv6 需要多少年吧)。所以从这个角度来说,我们也不希望安全是一个网络的底层设施。

这并不意味着安全问题可以不解决,只是把这事儿留给了软件层,留给操作系统和应用程序。这是一个极其明智的选择。相比物理网络而言,软件层更加能够经受得起变更。

总结来说,要想把 “互联网” 这个项目做成,需要考虑这样一些事情。

  • 一个能够连接所有既有网络的协议标准,我们不妨叫它互联网协议(Internet Protocol),简称 IP 协议。
  • 一张连接城市的骨干网络,至少有两个城市互联的试点。
  • 打通骨干网络和主流企业专用网络的路由器。
  • 一套方便应用开发的高阶网络协议,工作在 IP 协议之上。
  • 一份支撑互联网应用程序的基础网络协议栈源代码或包(package),方便主流操作系统厂商、网络设备厂商集成。
  • 若干典型互联网应用,如电子邮件(Email)、万维网(WWW)等。
  • 一份安全传输的网络协议方案(远期),及其源代码或包(package)。

让我们先来看下物理网络的构建。

首先,构建骨干网络。不同城市可以由若干个骨干网路由器相连。骨干路由器可以看做是由一个负责路由算法的计算机,和若干网络端口构成,如下图所示。

每个端口可能和其他城市相连,也可能和该城市内的某些大型局域网相连。一个局域网和城际网络从抽象视角看,没有非常本质的不同,只不过是采用的网络技术有异,使用的网络协议有异。

一个局域网可以简化理解为由若干台交换机连接所有的计算机设备。而交换机同样也可以看做是由一个负责路由算法的计算机,和若干网络端口构成,如下图所示:

剩下的问题是怎么对接骨干网络和局域网。这需要有人负责进行网络协议转换,它就是路由器。一台路由器上有两类端口,一类端口为本地端口,连接局域网内的设备,比如交换机,或者直接连普通的计算机。另一类端口为远程端口,负责接入互联网。

理清楚了物理网络后,我们再来看应用构建。我们打算打造两个杀手级应用(Killer Application):电子邮件(Email)和万维网(WWW)。

在考虑应用的用户交互体验时,我们发现,物理网络能够处理的 IP 地址,和人类方便记忆的地址非常不同,故而我们决定引入域名(domain)作为人与人交流用途的地址。为此,我们引入了 DNS 地址簿协议,用于将域名解析为物理网络可理解的 IP 地址。

综上分析,最终我们得到 MVP 版本的 Internet 项目的各子系统如下:

案例: 存储新兵 “对象存储”

对象存储是非常新兴的一种存储系统。是什么样的需求满足方式的变化,导致人们要创造一种新的存储呢?

对象存储是伴随互联网的兴起,尤其是移动互联网的兴起而产生的。

**首先,互联网应用兴起,软件不再是单机软件,用户在使用应用软件的过程中产生的数据,并不是跟随设备,而是跟随账号。**这样,用户可以随心所欲地切换设备,不必考虑数据要在设备间倒来倒去的问题。

数据跟随账号,这是互联网应用的第一大特征,区别于单机软件的关键所在。

**其次,用户交互方式的变化。**用户不再打字用纯文本沟通,而是用照片、视频、语音等多媒体内容来表达自己的想法。

移动化加剧了这一趋势,在手机上打字是非常痛苦的事情。拍拍照、拍拍视频、说说话(语音输入)更加符合人的天性,尤其是手机用户覆盖面越来越宽,大部分用户属于没有经过专业培训的普通用户,这些手段是最低准入门槛的交互方式。

**最后,用户体验诉求的提升。**计算机显示器早年是黑白的,后来有了256色,有了真彩色(TrueColor);显示器的屏幕分辨率,也从320x240,到640x480,到今天我们再也不关心具体分辨率是多大。随之发生变化的,是一张照片从100K,到几兆,到几十兆。

这些趋势,对存储系统带来的挑战是什么?

**其一,规模。**那么多用户的数据,一台机器显然放不下了,要很多很多台机器一起来保存。

**其二,可靠。**用户单机对存储的要求并不高,机器硬盘出问题了,不会想着找操作系统厂商或者软件应用厂商去投诉。但是,用户数据在服务端,数据丢了那就是软件厂商的责任,要投诉。

**其三,成本。**从软件厂商来说,那么多的用户数据,怎么做才能让成本更低一些。

**其四,并发吞吐能力。**大量的用户同时操作,有读有写,怎么保证系统是高效的。

另外,从存储系统的操作接口来说,我们分为关系型存储(数据库,结构化数据)和文件型存储(非结构化数据)。我们今天的关注点在文件型存储上。

对于文件型存储来说,相关的备选解决方案有很多,我们简单罗列如下。


**第一类是大家最熟悉的、最古老的存储系统:本地文件系统。**虽然有很多种具体的实现方案,但是它们的使用接口大同小异,实现方案也只是在有限的几种选择中平衡。我们在 “09 | 外存管理与文件系统” 这一讲中已经有过详细的介绍,这里不提。

第二类是网络文件系统,可以统称为 NAS,如上面的 NFS、FTP、Samba(CIFS)、WebDAV,都只是 NAS 存储不同的访问接口。

第三类是数据库,它通常用于存储结构化数据,比较少作为文件型存储。但也有人在这么做,如果单个文件太大,会切成多个块放到多行。

第四类是 SAN,它是块存储。块存储和关系型存储、文件型存储都不同,它模拟的是硬盘,是非常底层的存储接口。很少会有应用直接基于块存储,更多的是 mount 到虚拟机或物理机上,然后供应用软件需要的存储系统使用。

第五类是分布式文件系统 GFS/HDFS。GFS 最早是为搜索引擎网页库的存储而设计,通常单个文件比较大,非常适合用于日志类数据的存储。这也是为什么 Hadoop最后从大数据领域跑出来,原因就是因为大数据处理的就是日志。

你可以看到,除了数据库和 SAN,我们不用细分析就知道它们不是文件型存储的最佳选择,其他几类包括本地文件系统、NAS、GFS/HDFS 有一个共同特征,就是它们的使用接口都是文件系统(FileSystem)。

那么,我们就来看下文件系统(FileSystem)对于大规模的文件型存储来说有什么问题。

最大的问题是,文件系统是一棵树(Tree)。除了对单个文件的操作只需要锁住该文件外,所有对树节点的修改操作,比如把 A 节点移到 B 处,都是一次事务操作,需要锁住整棵树。

这对规模和并发吞吐能力都是伤害。从规模来说,分布式事务是很难的(这也是为什么分布式数据库很难做的原因),做出来性能也往往好不到哪里去。从并发吞吐能力来说,如果系统存在大锁,即在锁里面执行费时的操作,就会大幅降低系统的并发吞吐能力。

传统的 NAS 出现比较早,所以它没有考虑“大规模条件下存储会有什么样的挑战”是非常正常的。

GFS/HDFS 为什么没有考虑大规模问题?这是 Google 设计 GFS 的背景导致的,网页库存储,或者日志型存储的共同特征是单个文件很大,可以到几个 G 级别,这样的话文件系统的元数据就会减少到单台机器就可以存储的级别。

所以对象存储出现了。它打破了文件型存储访问接口一定是文件系统(FileSystem)的惯例。它用的是键值存储(Key-Value Storage)。

从使用接口来说,首先选择文件所在的桶(Bucket),它类似于数据库的表(Table),只是一个逻辑划分的手段;然后选择文件的键(Key),就可以存取文件了。

这意味着文件之间并不存在关联(树型结构是文件之间的一种关联),可以通过某种算法将文件元信息分散到不同的机器上。

那么为什么文件型存储,不必考虑文件之间的关联?因为关系都在数据库里面,文件型存储只需要负责文件内容的存储,有个键(Key)能够找到文件内容即可。

从本质上来说,这是因为服务端和桌面软件面临的用户场景是完全不同的。文件系统是在桌面软件下的产物,桌面系统是单用户使用的,没有那么高的并发访问需求。

服务端一上来就面临着并发访问的问题,所以很早就出现了数据库这样的存储中间件。数据库的出现,其实已经证明文件系统并不适合服务端。只不过因为文件型存储在早期的服务端开发的比重并不大,所以没有被重视。

但是,互联网的发展极大地加速了文件型存储的发展。互联网增加的 90% 以上的数据,都是非结构化数据,包括图片、音频、视频、日志。

对象存储能够支撑的文件数量规模上非常非常大。比如七牛云存储,我们已经支持万亿级别的文件。

这在传统 NAS 这种基于文件系统访问接口的存储是难以想象的,我们看到的 NAS 存储 POC 测试要求,基本上都是要能够支持 1-2 亿级别的文件存储规模。

另外,对象存储的高速发展,很大程度上会逐步侵蚀 Hadoop 生态的市场。因为 HDFS 这种日志型存储,其实只是对象存储里面的一个特例。在人们习惯了对象存储后,他们并不希望需要学习太多的存储系统;所以大数据的整个生态会逐步过渡到以对象存储为基石。

这已经发生了。这两年你可能也能够听到,Hadoop 生态的公司活得挺不好的,几家公司合并了也没有解决掉没落的问题。这和大数据生态向对象存储迁徙是分不开的,只不过这方面我们国内还处在相对比较落后的阶段。

案例分析

通过对打造“互联网”和存储新兵“对象存储”这两个案例的分析,我们可以看出不同市场差异还是很大的。“互联网” 这个产品它并不是替换某种既有的方案,而是把既有的方案连接在一起。所以 “互联网” 的历史包袱很少,基本上不太需要考虑历史问题。

“对象存储” 产品则不同。在对象存储之前,存储已经经历了很长时间的发展。只不过因为文件型的数据爆发式的增长,带来了存储系统的新挑战,从而给对象存储这样的新技术一个市场机会。

当然,另外一个原因是云服务的诞生,让存储有了新的交付形态。我们不再需要拿着硬件往用户家里搬,这就出现了一个新的空白市场。

但是解决了空白市场的需求后,对象存储还是要面临 “既有市场中用户采用的老存储方案怎么搬迁” 的问题。所以存储网关这样的产品就出现了。存储网关做什么?简单说,就是把对象存储包装成 NAS,提供 NFS、FTP、Samba(CIFS)、WebDAV 这些访问接口给用户使用。

结语

需求分析相关的讨论就到此结束了。不同市场差异非常大,并不存在大一统的产品定义和市场策略,需要具体问题具体分析。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲将是我们第一章的回顾与总结。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 基础平台篇:回顾与总结

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到今天为止,我们第一章 “基础平台篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。

抽象信息世界的骨架

基础平台篇主要涉及的内容如下。

这些内容如果展开来讲,每一系统(或模块)都会是很厚的一本书。我们的目的,当然不是为了取代这里每一个领域知识相关的专业书籍。

我们的核心目标是以架构为导向,抽象出系统的骨架,融会贯通,把这些领域知识串起来,拼出完整的信息世界的版图。

抽象出系统骨架的过程时信息必然是有损的,怎么才能做到忽略掉众多的实现细节,把系统以简洁易于理解的方式呈现出来?

这很大程度取决于你对系统的理解程度和抽象能力。如果我们把系统想象成一个人,大部分情况下我们比较容易对其进行详尽而具体的描述,好比下图。

这相对容易。因为你只需要陈述你看到的事实,而不必拷问背后的原因。但实际上为了在最短的时间里让别人理解你的想法,你也许应该这样来描述它,见下图。

当你不是在描述这个系统本身,而是描述它与其他系统的相互关系时,你可能需要进一步简化它,变成如下图这样。

抽象有助于记忆,因为骨架需要逻辑的自洽。

这种抽象能力之所以重要,是因为它是融会贯通、疏通整个信息世界的知识脉络的关键。当你做到对世界的认知可宏观、可微观,自然一切皆在掌握。

比如,本章我们首先介绍的是冯·诺依曼体系结构,我们把它抽象为“中央处理器(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 自身只能做数数转换,输入是比特信息,输出还是比特信息。结合了输入/输出设备提供的数模和模数转换的 “算子”,连接了数字世界和物理世界的计算机,在数学上也就完备了。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。本章到此结束,我们将开始第二章:桌面开发的宏观视角。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

限时放送

推荐阅读专栏《Go语言核心36讲》正在拼团中,限时特惠79元,点击链接订阅专栏。

| 桌面开发的宏观视角

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。今天开始,我们进入第二章,谈谈桌面软件开发。

从架构的角度,无论你在什么样的终端设备(比如: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 语音助手来交互。

语音助手是被寄予厚望的新的交互范式。它可能开启了新的交互时代,我们不妨把它叫智能交互时代。但当前它与图形界面程序结构并不相容,而是完全自成体系,如下:

为什么语音交互和图形界面交互没法很好地融合在一起?我认为有两个原因。

**一是语音交互有很强的上下文,所以语音交互程序通常其业务代码也由语音交互系统提供的框架所驱动。**框架的特点是侵入性强,框架与框架之间很难融合。

二是语音交互还不成熟,所以独立发展更简单,如果有一天成熟了,完全可以重写框架,把语音和触摸屏结合起来,形成全新的交互范式。

未来交互会怎样?智能交互很可能不会止步于语音,而是视频(同是兼顾视觉和听觉),且与触摸屏完美融合。使用的交互设备有触摸屏+摄像头+麦克风+内置扬声器。整个程序的结构如下所示。

结语

通过以上对交互演化历程的回顾,我们看到交互范式的演进是非常剧烈的。交互体验越来越自然,但从编程的角度来说,如果没有操作系统支持,实现难度也将越来越高。

这也说明了一点,桌面操作系统和服务端操作系统的演进方向非常不一样。桌面操作系统的演进方向主要是交互范式的迭代,在向着越来越自然、越来越智能的交互前进。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将介绍:“图形界面程序的框架”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 图形界面程序的框架

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲我们回顾了交互的演化历程。今天,我们将关注点收敛到现在仍然占主流地位的图形界面程序。它的结构如下图所示。

实现一个图形界面程序,最大的复杂性在于不同操作系统的使用接口完全不同,差异非常巨大。这给开发一个跨平台的图形界面程序带来巨大挑战。

好在,尽管操作系统的使用接口有异,但基本的大逻辑差不多。今天我们从统一的视角来看待,谈谈图形界面程序的框架。

事件

无论是什么桌面操作系统,每个进程都有一个全局的事件队列(Event Queue)。当我们在键盘上按了一个键、移动或者点击鼠标、触摸屏幕等等,都会产生一个事件(Event),并由操作系统负责将它扔到进程的事件队列。整个过程大体如下。

  • 键盘、鼠标、触摸屏等硬件产生了一个硬件中断;
  • 操作系统的硬件中断处理程序收到对应的事件(Event);
  • 确定该事件的目标进程;
  • 将事件放入目标进程的事件队列(Event Queue)。

窗口与事件响应

窗口(Window),也有人会把它叫视图(View),是一个独立可复用的界面元素(UI Element)。一个窗口响应发送给它的事件(Event),修改内部的状态,然后调用 GDI 绘制子系统更新界面显示。

响应事件的常见机制有两种。

一种是事件处理类(EventHandler,在 iOS 中叫 Responder)。通常,我们自定义的窗口类会直接或间接从事件处理类继承。Windows 平台有些特殊,为了让窗口类可复用,且与语言无关,它将事件处理做成了回调函数,术语叫窗口过程(WindowProc)。这只是形式上的不同,并无本质差异。

另一种是用委托(delegate)。顾名思义,用委托的意思是事件处理不是收到事件的人自己来做,而是把它委托给了别人。这只是一种编程的手法。比如,在 Web 编程中我们给一个界面元素(UI Element)实现 onclick 方法,这可以理解为是一种委托(delegate)。

有一个事件比较特殊,它往往被叫做 onPaint 或 onDraw。为什么会有这样的事件?我们想象一下,当一个窗口在另一个窗口的上面,并且我们移动其中一个窗口时,部分被遮挡的窗口内容会显露出来。

这个过程我们可能觉得很自然,但实际上,操作系统并不会帮我们保存被遮挡的窗口内容,而是发送 onPaint 事件给对应的窗口让它重新绘制。

另外,不只是窗口可以响应事件,应用程序(Application)也可以。因为有一些事件并不是发送给窗口的,而是发给应用程序的,比如:本进程即将被杀死、手机低电量告警等等。

当然如果我们约定一定存在一个主窗口(Main Window),那么把应用程序级别的事件理解为是发给主窗口的也可以。

事件分派

事件是怎么从全局的事件队列(Event Queue)到窗口的呢?

这就是事件分派(Event Dispatch)过程,它通常由一个事件分派循环(Event Dispatch Loop)来完成。一些平台把这个过程隐藏起来,直接提供一个类似 RunLoop 这样的函数。也有一些平台则让你自己实现。

例如,对于 Windows 平台,它把事件叫消息(Message),事件分派循环的代码看起来是这样的:

func RunLoop() {
  for {
    msg, ok := winapi.GetMessage() // 从事件队列中取出一个消息
    if !ok {
      break
    }
    winapi.TranslateMessage(msg)
    winapi.DispatchMessage(msg)
  }
}

大体来说,就是一个简单的取消息(GetMessage)然后对消息进行分派(DispatchMessage)的过程。其中 TranslateMessage 函数你可能比较陌生,它负责的是将键盘按键事件(onKeyDown、onKeyUp)转化为字符事件(onChar)。

窗口有了父子和兄弟关系,就有了窗口系统。一旦界面涉及复杂的窗口系统,交互变得更为复杂。事件分派过程怎么知道应该由哪个窗口响应事件呢?

这就是事件处理链(EventHandler Chain)。

不同事件的分派过程并不一样。

对于鼠标或者触摸屏的触摸事件,事件的响应方理应是事件发生处所在的窗口。但也会有一些例外的场景,比如拖放。为了支持拖放,Windows 系统引入了鼠标捕获(Mouse Capture)的概念,一旦鼠标被某个窗口捕获,哪怕鼠标已经移出该窗口,事件仍然会继续发往该窗口。

对于键盘事件(onKeyDown/onKeyUp/onChar),则通常焦点窗口先响应,如果它不感兴趣再逐层上升,直到最顶层的窗口。

**键盘从功能上来说,有两个不同的能力:其一是输入文本,其二是触发命令。**从输入文本的角度来说,要有一个输入光标(在Windows里面叫Caret)来指示输入的目的窗口。目的窗口也必然是焦点窗口,否则就会显得很不自然。

但是从触发命令的角度来说,命令的响应并不一定是在焦点窗口,甚至不一定在活跃窗口。比如Windows下就有热键(HotKey)的概念,能够让非活跃窗口(Inactive Window)也获得响应键盘命令的机会。一个常见的例子是截屏软件,它往往需要一个热键来触发截屏。

到了移动时代,键盘不再是交互主体,但是,键盘作为输入文本的能力很难被替代(虽然有语音输入法),于是它便自然而然地保留下来。

不过在移动设备里,不太会有人会基于键盘来触发命令,只有常见的热键需求比如截屏、调大/调小音量、拍照等等,被设计为系统功能(对应的,这些功能的热键也被设计为系统按键)保留下来。

窗口内容绘制

在收到 onPaint 或 onDraw 消息时,我们就要绘制我们的窗口内容了,这时就需要操作系统的 GDI 子系统。

从大分类来说,我们首先要确定要绘制的内容是 2D 还是 3D 的。对于 2D 内容,操作系统 GDI 子系统往往有较好的支持,但是不同平台终究还是会有较大的差异。而对于 3D 内容来说,OpenGL 这样的跨平台方案占据了今天的主流市场,而 Vulkan 号称是 NextGL(下一代的 OpenGL),其潜力同样不容小觑。

从跨平台的难易程度来说,不同平台的 GDI 子系统往往概念上大同小异,相比整个桌面应用程序框架而言,更加容易抽象出跨平台的编程接口。

从另一个角度来说,GDI 是操作系统性能要求最高、最耗电的子系统。所以 GDI 优化往往通过硬件加速来完成,真正的关键角色是在硬件厂商这里。由此观之,由硬件厂商来推跨平台的 GDI 硬件加速方案可能会成为趋势。

通用控件

有了以上这些内容,窗口系统本身已经完备,我们就可以实现一个任意复杂的桌面应用程序了。

但是,为了进一步简化开发过程,操作系统往往还提供了一些通用的界面元素,通常我们称之为控件(Control)。常见的控件有如下这些:

  • 静态文本 (Label);
  • 按钮 (Button);
  • 单选框 (RadioBox);
  • 复选框 (CheckBox);
  • 输入框 (Input,也叫EditBox/EditText);
  • 进度条 (ProgressBar);
  • 等等。

不同操作系统提供的基础控件大同小异。不过一些处理细节上的差异往往会成为跨平台开发的坑,如果你希望一份代码多平台使用,在这方面就需要谨慎处理。

结语

总结来说,桌面应用程序通常由用户交互所驱动。我们身处在由操作系统约定的编程框架中,这是桌面编程的特点。

在操作系统的所有子系统中,交互相关的子系统是毫无疑问的差异性最大的子系统。我们这里列了一个简单的对比表格:

这还不是差异的全部。要做一个跨平台的桌面应用程序并不容易。我们需要面对的平台太多,简单罗列,如下所示。

  • PC:Windows、MacOS、Linux 等;
  • PC 浏览器:Chrome、Safri、Firefox 等;
  • 手机/平板/手表:Android(不同手机厂商也会有细节差异)、iOS 等;
  • 小程序:微信、支付宝、快应用等。

怎么安排不同平台的优先级?怎么规划未来版本的迭代计划?选择什么样的跨平台方案?这些问题在业务架构之外,但极其考验架构师的决策能力。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊 “桌面程序的架构建议”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 桌面程序的架构建议

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲我们介绍了图形界面程序的框架。站在操作系统交互子系统的角度来看,我们桌面应用程序的结构是下面这样的。

今天我们换一个角度,站在应用架构的角度,来聊聊如何设计一个桌面应用程序。

从 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 的每一层应该怎样去正确理解与设计,有哪些切实的问题需要去面对。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊基于浏览器的开发。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| Web开发:浏览器、小程序与PWA

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

前面几讲我们聊到桌面软件开发,是从原生应用(Native App)角度来讲的,我们的讨论范围还只是单机软件,没有涉及网络相关的部分。

虽然介绍 Model 层的时候,我拿基于数据库实现 Model 层来谈常见的两个误区,但这只是因为这种问题经常能够见到,比较典型。实际纯单机软件很少会基于数据库来做,通常是自己设计的内存中的数据结构。

浏览器

今天开始我们聊聊浏览器。从商业价值看,浏览器带来的最为重大的进步是如下这三点。

**其一,软件服务化。**当产品交付从单机软件转向云服务后,社会分工就发生了巨大变化。

互联网让 “24 小时不间断服务”成为可能。任何一个环节的力量都得到百倍乃至千倍的放大,都有可能成长出一个超级节点,进而吞噬上下游,让服务链条更短。

**其二,随时发布。**这极大改进了软件迭代的效率。人们快速试验自己的想法,不必过度因为顾虑软件质量召回而束手束脚。

**其三,跨平台。**浏览器消除了不同操作系统平台的差异性,让一份代码到处运行成为可能。

不过我们今天把重心放到界面开发这个视角。从作为界面开发框架的角度看,浏览器带来的最重大变化又是哪些?

**其一,操作系统的窗口系统被颠覆。**一个网页只是一个窗口,不再有父子窗口。所有网页中的界面元素,都是一个虚拟视图(Virtual View),无论是大家耳熟能详的通用控件(比如 input,image,div 等等),还是自绘窗口(canvas)都一样。

这一点非常关键。哪些元素是子 View,哪些元素是图形(Shape) 已经完全淡化了,更多的是通过一种统一机制来完成事件分派(Event Dispatch)。

**其二,窗口的绘制机制变了。**之前是调用操作系统的 GDI 生成界面,现在换成了 HTML+CSS。当然如果我们非要把 HTML+CSS 看作是另一种 GDI 语言,某种程度来看好像也可以。

但是实际上 GDI 与 HTML+CSS 有非常本质的差别。它们一个是在绘制界面,一个是在声明界面。这两者的本质差别,在视图更新(Update View)的时候一下子就显现出来。

上一讲我们在介绍 View 层的时候,介绍过 View 层的一大难点是做局部更新的优化。在 View 局部优化比较复杂的时候,我们甚至会引入 ViewModel 层来做视图局部更新的支持。

站在这个角度看 HTML+CSS,其实我们不能把它理解为 View 层,它其实是 ViewModel 层。View 层由谁干掉了?浏览器。在我们修改 HTML DOM 时,浏览器自动就更新了 View。怎么做到局部更新优化的?你不必关心,浏览器已经干完这件事情了。

这事的真正价值超过你的想象。它大幅提升了桌面应用开发的效率。

**其三,语言限制。**浏览器的确大幅改善了界面开发的效率,但是从语言支持的角度,大部分操作系统都支持各种语言作为开发工具,而浏览器长期以来只支持 JavaScript 一门语言。

这当然是一个不小的制约。所以有很多人在试图突破这个限制。Google 曾经想要把 Dart 语言打造为下一代的 JavaScript,但最终以失败告终。

今天主流的方案还是以代码转换器为主。也就是说,我可以用自己期望的语言(比如 Go 语言)来做开发。但是在发布前通过代码转换器转为 JavaScript。

今天还有一个重要的尝试是 WebAssembly。它的目标是打通各类语言与 Web 之间的桥梁。

**其四,B/S 架构。**无论是 B/S 还是 C/S,本质上还是软件服务化。这对软件架构产生了巨大影响。

一方面,从 Server 端的逻辑看,系统从单用户变成了多用户。另一方面,从 Browser 端(或 Client 端)看,仍然是单用户,但是没有了数据,数据都在 Server 端。这对应用架构提出了新的挑战。

应该怎么设计 Web 程序的架构?我们在下一讲中接着聊这个话题。

小程序

2016年9月,微信小程序(最初叫“应用号”)开始内测。下面是当天七牛云团队的一番内部对话。


许式伟:看下这篇,微信应用号来了


Gina:这个理念应该不是去构建一个Store,它的理念是用完即走,是场景通过扫码或者搜索触发的,并且应该打的是实体或者服务售卖群体,不会针对微信内消费,是订阅号的升级展现方式。


许式伟关于微信小程序(应用号),我能透露的几个细节,这一篇更详细一些。


微信没有必要在微信App内放Appstore,可以只有Web版本的Appstore,App不需要安装,甚至可能以消息的方式发给别人,以服务号的形式存在,这是迭代式开发。


以后终极形态还可以变,当前重心应该在runtime的稳定。通过上面的介绍,微信实际上升级了浏览器内核的标准,符合我之前说的新一代浏览器的定义。


Gina:小程序是一种不需要下载安装即可使用的应用,它实现了应用“触手可及”的梦想,用户扫一扫或者搜一下即可打开应用。也体现了“用完即走”的理念,用户不用关心是否安装太多应用的问题。应用将无处不在,随时可用,但又无需安装卸载。


徐倒立:WebApp 这个技术和 idea最早来自 Google 浏览器 ,微信是商业化的最佳实践。Google浏览器在支持开发者开发App时就提出Intents,并且和Android是可以互动的。


Gina:没有好的土壤有好的功能也是没意义的。


许式伟:是,微信小程序在别的App不是做不到,是做了意义太小。苹果和腾讯不约而同在IM里面做App是有道理的。


Gina:IM比搜索和浏览器的封闭性更强。用户不容易跳转出去。封闭性强的土壤才能构建App生态。


许式伟:所以移动时代最佳的浏览器是IM,不再是以前传统浏览器。


杜江华:我们应该多讨论to B巨头们怎么玩、怎么思考的,对我们现阶段才更有意义,支付宝、微信等都是to C的。


许式伟:不是这样的。to C 的生态变化,会影响 to B。to C 是根源,我们是帮用户做App的,如果不知道以后App是怎么玩的,怎么可能做好。


杜江华:理解了,那应该是客户群之一互联网部分,还有不少大B 需要有其他不同的思考方式。


Gina:大B的趋势我挺想听的。这周聚会,阿杜能否把最近大项目和大传统客户的一些动作详细聊一下。你这边离业务最近。


许式伟:其实比你想象得还要恐怖,不管你是什么大B,你都得拥抱微信,只有微信和QQ让整个7亿中国网民在里面安家了,这就是一个虚拟的国家。所以我的判断是没有大B不开发微信小程序,这只是个眼光和时间问题。


吕桂华:这个微信应用号我们是应该关注的,相当于市场上多了一个操作系统。


许式伟:微信应用号不只是一个新OS,而且是下一代OS,苹果和谷歌不会坐视不理。当然还有一个痛点是跨平台。


Gina:这个东西可能对营销生态有大的影响。我们也要开发些营销工具。


在这段对话之后的一个月内,我们做出了七牛的第一笔对外投资:“即速应用”,它致力于帮助企业开始快速构建自己的小程序。

为什么微信小程序必然会成功?

因为,有 7 亿人同时使用的操作系统,很少。如果我们把不同 Android 厂商归为不同的主体的话,微信小程序是当时世界上最大的单一来源的操作系统。

随后,支付宝发布了支付宝小程序,国内手机厂商联合发布了 “快应用”,今日头条也发布了自己的小程序。

一下子,小程序变成了一支巨大的新兴力量,成为真正意义上的国产操作系统,对抗着 Android 和 iOS 两大移动操作系统生态。

但是,目前来说,小程序生态仍然存在有诸多问题。

最为关键的,是标准不统一。虽然都叫小程序,但是它们的接口各自都在快速迭代,很难去建立统一的标准,更谈不上让开发者一次开发,到处可用。

这和 Android 不同。虽然 Android 厂商很多,但是不同 Android 的开发接口是一致的,开发工具链是一致的。

小程序的厂商们会好好坐下来谈一谈标准一致的事情吗?应该做,但可能他们现在没空管开发者们的体验,他们的关注点是怎么快速抢地盘。

聊了那么多,我们话题回到技术本身。小程序和传统的 Web 开发有何不同?

其实有很大不同。小程序更像是 Native 程序在线化,而不是 PC Web 移动化。

为什么我们这么说?因为小程序是一个应用,而不是由一个个 Web 页面构成。

我们需要提交应用给微信或支付宝,他们也会像苹果审核 AppStore 上的应用一样,掌控着 App 的生杀大权。

而且理论上可以比苹果更牛的是,他们可以下线一个已经有千万甚至上亿级别用户的 App,让他们一无所有。苹果可以掐掉一个 App 的新增,他们可以掐掉一个 App 的全部。

这会带来新的社会问题:操作系统厂商的权限边界究竟在哪里。这不是一个简单的技术问题,而是一个伦理与法律的问题。

正因为这个风险如此之高,所以所有的厂商在拥抱微信的同时,必然时时刻刻想着如何逃离微信。

刀刃,永远是两面的。

这也是我个人非常佩服Facebook扎克伯格的地方。他看到了终局,所以在发布 Libra 的时候,他选择的是让一步,放弃 Control。

我还是那句话,他会成功。

让一步,其实就是进一百步。

PWA

国内大厂们纷纷布局小程序的时候,Google 也在发力自己的移动浏览器方案,叫 PWA,全称 “Progressive Web App”。

其实 Google 想要让浏览器获得 Native 应用同等体验之心是路人皆知的事实。

在 PC 时期,Google 就搞了 Google Native Client (NaCl),后来转向了 WebAssembly。移动应用的在线化,Google 也同样在探索。

PWA 开始于 2015 年,比微信小程序早很多,并得到了苹果和微软的支持。从这个角度来说,PWA 的潜力不容小觑。

怎么理解 PWA?你可以理解为海外版的小程序。

那么它和小程序的差别在哪?

其一,演进思路不同。PWA 基本上以兼容并对 Web 进行逐步改造升级为导向。而小程序和 Web 还是有较大程度的差异。

其二,关注焦点不同。PWA 更技术化,它很大的精力重心放在如何让 PWA 在断网情况下和本地应用有更一致的体验。而小程序关注点在如何撬动这么庞大的用户市场,小程序之后专门出现小游戏,更加能够证明这一点。

其三,PWA 并没有中心化的 AppStore,它更像是一项技术标准,而不是一个封闭的操作系统。支持 PWA 的厂商们不用担心被人掐脖子,怎么更新你的应用自己说了算。

虽然技术上相似,但是如果以操作系统角度看,两者有代差。PWA 如果我们看作操作系统的话,相比小程序来说太传统。

为什么这么讲?因为小程序符合我前面介绍现代操作系统的 “账号(Account)-支付(Pay)-应用市场(AppStore)” 的商业闭环,但是 PWA 并没有账号,也没有支付。

怎么看待 PWA 的未来?

最终把 PWA 发扬光大的,很可能是 Facebook(当然 Facebook 也非常大概率选择放弃包袱,和小程序一样重新出发)。加上 Libra,秒杀微信小程序。

结语

今天我们聊了浏览器,结合浏览器的发展趋势,谈了现在仍然在高速迭代中的移动浏览器之争。有中国特色的小程序,和海外版小程序 PWA。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊 “跨平台与 Web 开发的建议”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 跨平台与 Web 开发的建议

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲我们聊了浏览器,以及移动浏览器之争:小程序与 PWA。

当我们思考浏览器从技术上带来了什么的时候,我们可以把它分为两点。

  • 跨平台桌面程序开发;
  • Web 开发(B/S 架构的新型应用)。

今天我们分别就跨平台桌面程序和 Web 开发展开来聊一聊。

跨平台桌面程序开发

跨平台的桌面程序开发是一个超级难题。无数人前仆后继,各种方案层出不穷,但至今为止,仍然没有称得上真正深入人心的解决方案。

原因很简单,因为桌面程序本身的范畴在变。有两个关键的因素会导致桌面开发产生巨大的差异性。

一个因素自然是操作系统。不同的操作系统抽象的界面程序框架并不一致。这些不一致必然导致开发工作量的增加。

放弃某个操作系统,就意味着放弃某个流量入口,也就意味着放弃这些用户。所以虽然很麻烦,我们还是不得不支持着每一个主流的操作系统。

另一个因素是屏幕尺寸。就算相同的操作系统,在不同尺寸的屏幕上,交互的范式也会存在很大的差异性,这也会导致不低的跨平台工作量。

首先我们看下操作系统。

  • PC 本地:Windows,macOS,Linux 等等;
  • PC Web:Chrome,Safari,FireFox 等等;
  • Mobile 本地:Android,iOS 等等;
  • Mobile Web:小程序,PWA 等等。

我们再看下屏幕尺寸。

  • 大屏:PC、笔记本,Pad 等等;
  • 中屏:手机;
  • 小屏:手表。

如此繁复多样的终端类型,无怪跨平台如此之难。我们来总结一下当前都有哪些跨平台的解决方案。

这个列表只是沧海一粟。之所以没有列那么多,也是因为大部分的跨平台框架都已经不怎么活跃,已经无疾而终了。

目前来说,还很难说哪个方案会胜出。

关于跨平台开发,我觉得有一句话特别深刻:“每一次统一的努力,都最终变成新的分裂”。当然,这样的事情在很多领域都会发生,只是跨平台开发更加如此。

但是无论如何,跨平台的梦还会继续。

Web 开发

聊完了跨平台,我们来聊聊浏览器带来的另一面:Web 开发。

Web 的 B/S 架构意味着编写软件有了更高的复杂性。这主要表现在以下几个方面。

**其一,多用户。**有了 Server 端,意味着用户的数据不再是保存在 Client(Browser)端,而是存储在 Server 端。

**其二,更高的数据可靠性要求。**数据在 Client 端,客户自己对数据的可靠性负责。硬盘坏了,数据丢了,用户会后悔没有对数据进行备份。

但是一旦数据在 Server 端,数据可靠性的责任方就到了软件厂商这边。如果厂商不小心把数据搞丢了,用户就会跳起来。

**其三,更多可能的分工安排。**详细来说,Web 应用从流派来说,分为两大类:胖前端与胖后端。

所谓胖前端,是指把尽可能多的业务逻辑放在前端。极端情况下,整个网站就是一个单页的应用。胖前端无论开发体验还是用户体验,都更接近于本地应用(Native App)。

所谓胖后端,是指主要逻辑都在后端,包括界面交互的事件响应,也通过网络调用交给了后端来实现。

我们先看客户端(Client),也就是浏览器端(Browser)。上一讲我们提到,浏览器的界面框架并没有窗口系统,它通过 HTML+CSS 来描述界面。

HTML+CSS 与其理解为 View 层,不如理解为 ViewModel 层,因为 HTML DOM 从数据角度完整描述了界面的样子。而 View 层已经被浏览器自己实现了。

这极大简化了界面开发的复杂性,因为界面的局部更新是一个复杂的话题,今天浏览器通过引入 HTML+CSS 这样的 ViewModel 层把它解决了。

这个时候我们重新看 MVC 框架在浏览器下的样子,你会发现它变成了 MVMP 模式,全称为 “Model-ViewModel-Presenter”。

**首先,我们看事件响应过程。**浏览器的 View 收到了用户的交互事件,它把这些事件委托(delegate)给了 ViewModel 层,并且通过 HTML DOM 暴露出来。通过修改 HTML 元素的事件响应属性,一般名字叫 onXXX(比如 onclick),可以获得事件的响应机会。

**然后我们看 Model 层的数据变化(DataChanged)事件。**在标准的 MVC 模式中,Model 层的数据变化是通知到 View 层,但是在浏览器下 View 是由浏览器实现的,要想让它接受 DataChanged 事件并且去处理是不太可能了。

所以解决思路自然是让 Controlller 层来做,这样就变成了 MVP 模式。 但是我们又不是标准的 MVP,因为 Presenter 层更新界面(Update View)并不是操作 View,而是 ViewModel。

综上,浏览器下的 MVC,最终实际上是 MVMP(Model-ViewModel-Presenter)。

聊完了浏览器端,我们在来看下服务端(Server)。虽然这一章我们的重点不是聊服务端,但是为了有个完整的认识,我们还是要概要地梳理一下 Server 端的架构。

前面我们在 “22 | 桌面程序的架构建议” 中,曾提到桌面软件除了要支持用户交互外,另一个很重要的需求是提供应用程序的二次开发接口(API)。

到了 Web 开发,我们同样需要二次开发接口,只不过这个二次开发接口不再是在 Client 端完成的,而是在 Server 端完成。Server 端支持直接的 API 调用,以支持自动化(Automation)方面的需求。

所以,对 Server 端来说,最底层的是一个多租户的 Model 层(Multi-User Model),它实现了自动化(Automation)所需的 API。

在 Multi-User Model 层之上,有一个 Web 层。Web 层和 Model 层的假设不同,Web 层是基于会话的(Session-based),因为它负责用户的接入,每个用户登录后,会形成一个个会话(Session)。

如果我们对Web 层细究的话,又分为 Model 层和 ViewModel 层。为了区分,Web 这边的 Model 层我们叫它 Session-based Model。相应地,ViewModel 层我们叫它 Session-based ViewModel。

在服务端,Session-based Model 和 Session-based ViewModel 并不发生直接关联,它们通过自己网络遥控浏览器这一侧的 Model 和 ViewModel,从而响应用户的交互。

Session-based Model 是什么样的呢?它其实是 Multi-User Model 层的转译。把多租户的 API 转译成单租户的场景。所以这一层并不需要太多的代码,甚至理论上自动实现也是有可能的。

Session-based ViewModel 是一些 HTML+JavaScript+CSS 文件。它是真正的 Web 业务入口。它通过互联网把自己的数据返回给浏览器,浏览器基于 ViewModel 渲染出 View,这样整个系统就运转起来了。

结语

今天我们聊了 Web 带来的两个重要改变。一个是跨平台,一个是 Web 开发,即 B/S 架构下的新型应用到底应该怎么实现。

从跨平台来说,这个话题是桌面程序员(也叫“大前端”)永远的痛。计划赶不上变化,用来形容大前端程序员面临的窘境是一点都不过分的。一个玩意还没搞熟悉了,另一个东西又出来了,变化太快,要跟上实属不易。

从 Web 开发来说,MVC 变成了 MVMP(Model-ViewModel-Presenter)。我们和单机的桌面软件一样的建议,认真对待 Model 层,认真思考它的使用接口是什么样的,把 Model 层做厚。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将结合一个实际的案例,来讲解一下桌面开发(含单机软件和 Web)到底是什么样的。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 桌面开发的未来

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

今天,我原本想结合一个实战例子,来回顾一下 “桌面软件开发” 一章到今天为止的内容,但是由于要准备的内容比较多,会延后一下。

所以,今天我还是会按原来大纲的内容,谈谈我个人对桌面开发未来趋势的判断。

桌面平台的演进与未来

谈未来,我们要先看过去。

在 PC 时期,本地桌面操作系统主流的有 Windows、MacOS、Linux。为了消除不同平台的差异,于是就出现了 QT、wxWidgets 这样的跨平台解决方案。

但是它们都败了,败给了一个它们并没有意想得到的对手:PC 浏览器

浏览器并不是为跨平台而来,但是除了干成了软件服务化外,也干成了跨平台这件事情。

虽然浏览器厂商很多,但是它们遵循相同的规范。这意味着支持了浏览器,就支持了所有的 PC 用户。

这太诱人了。

于是在软件服务化和跨平台开发的双重优势下,软件厂商们趋之若鹜,QT、wxWidgets 这些方案就变成小众需求。

QT 有机会反抗么?其实是有的。关于这一点我们后面再说。

然后,移动大潮来了。我记得 2006 年有一次我和雷军雷总吃饭,聊起对移动操作系统未来趋势的判断,我们俩各持己见。

雷总认为 WinCE 会赢,因为 Windows 已经培育了最大的开发者群体。而我认为 Symbian 会赢,因为它占据了最大的终端用户群。

结局大家已经知道了,最后赢的是谁都没有预料到的玩家:Android 和 iOS。

如果我们从事后看,实际上这个事情并不是完全没有迹象可循。iOS(诞生于2007年)固然当时还没有诞生,但是 Android 诞生于 2003 年,并于 2005 年被 Google 收购。作为搜索引擎厂商,Google 收购一个手机操作系统,显然不是随意为之的,而是公司发展战略上的考量。

Android 和 iOS 的诞生,一下子让操作系统的生态变得更为复杂。

操作系统不同,输入方式不同(鼠标 vs 触摸屏),屏幕大小不同,想要一套代码横跨 PC 和移动多个平台?太难。

这还不算。虽然还不像手机那么普遍,但是今天手表、电视机、汽车,以及各式各样的 IoT 传感设备,都需要操作系统的支持。

**从操作系统发展来说,我个人会倾向于按交互方式来分。**未来桌面操作系统和服务端操作系统会渐行渐远,差异越来越大。从交互来说,服务端会维持简约,经典的命令行交互会长期占据主流。

而桌面操作系统,笔记本市场,鼠标+键盘仍然会占据主流。虽然鼠标形态已经变了,变成了触控板,但是鼠标指针这种基于精确位置交互的方式会得到保留。多点触摸的交互,也会得到部分机型的支持。

移动市场,多点触摸+键盘会占据主流。但是语音助手也会得到一定程度的渗透。

**IoT 市场,语音助手会占据交互的主流。**但也会有一些设备引入多点触摸这种交互方式来补充。在这个市场,目前看技术上的 Amazon 和 Google 占据了领先地位。虽然苹果入场较早,但是 Siri 的表现还是和前两者有较大的差距。

IoT 设备会两极分化。**一类 IoT 设备是专用设备,它的应用场景非常固定,它对操作系统最大诉求是裁剪能力:最好不要给我太多的东西,匹配场景就好。**能不能提供AppStore?不是重点,有也只有很少的一些应用,其实直接找合作伙伴就好。

**一类 IoT 设备则有较大的通用性。**但受限于语音助手技术的限制,IoT 操作系统的开放性要比移动系统差很多。所以在有任何可能的时候,这些设备就会带上触摸屏变成一台由移动系统支持的设备。

长远来说,要看智能语音技术的发展。关于这一点,我个人抱谨慎乐观的态度。但显然,在很长一段时间里,我们面对的还是移动操作系统。

这么多操作系统怎么搞呢?

于是 React-Native 出现了。理论上,React-Native 可以横跨 PC 和移动。因为 React 本身基于 Web 技术,可以支持 PC 浏览器,而 React-Native 又支持 iOS 和 Android,从而做到 “Learn once,write anywhere”。

平台差异不能完全消除,只能尽可能地减少。

手机操作系统这场仗刚有了眉目,移动浏览器之争又起来了。

国内涌现了大量的小程序厂商,国外 Google 也在推 PWA。还有 Facebook 意见不明,不知道会去支持 PWA,还是基于自己的 React-Native 技术搞一套新的移动浏览器标准。

这下好了,统一的 Web 分裂成多个技术阵营。

移动浏览器,国内外不统一已经是既成事实。海外巨头们除了 Facebook,已经用明确的行动支持 PWA。小程序在海外要想有市场,要看头条腾讯阿里们的海外市场占有率。

移动 WebApp 技术的分裂是否会最终得到纠正?这仍然是未知之数。

但由此观之,终端操作系统的多元化已经是既成现实。这对开发者生态将产生重要的影响。

我们可能有人留意到,QT 今天基本上支持了所有的桌面操作系统,不管是 PC 还是移动。但是这还不太够,因为还差 Web、小程序和 PWA。

今天的跨平台,重点是要跨 Android、iOS、Web、小程序和 PWA。如果精力顾不上,PC 桌面操作系统的优先级反而可以缓一缓,毕竟 Web 也能够顶一下。

QT 的机会在这里。但是很明显它并没有意识到兼容 Web 开发对于一个跨平台工具的重要性。

就算在 PC 时期,一个同时支持 Web 和本地操作系统的跨平台工具也能够受到欢迎。今天随着桌面平台的多元化,跨平台工具的需求达到了历史最高点。

当然还有一种跨平台的思路,是垂直发展,比如专做游戏开发的跨平台。不过单就游戏开发这个领域而言,已经有强大的玩家,比如 Unity 在里面。

那么,通用的跨平台怎么做到?

Google Flutter 给了一条路,它把对操作系统的要求最小化,整个界面系统完全自己在用户态构建。

这个思路和 Go 语言有点像。Go 语言其实是在用户态完全重写了操作系统的进程管理和 IO 子系统。

那么 Flutter 会像 Go 语言一样成功么?

我个人持谨慎态度。不同操作系统的用户是有自己独特的交互范式的。比如 Android 和 iOS 用户的习惯就有一定的差异。而这可能恰恰是跨平台更难的一点。

另一个是软件体积问题。Android 是 Google 自己的,可以通过让 Android 预装基础库来减少体积。但是更多的系统有可能需要一个体积不小的跨平台层。

这会制约 Flutter 的发展。客户端软件的尺寸,对新用户的转化率有着至关重要的影响。何况像微信小程序这样的平台,还限制了小程序的尺寸,最早限制为 4M,后来放宽到 8M。

这和 Go 语言面临的环境不太一样。Go 语言因为面向的是服务端,用户对软件的尺寸不敏感,反倒是部署的便捷性更敏感。

我个人更倾向于尺寸更轻盈的跨平台工具。

其次是编程手法上的问题。大趋势是要用 Web 这种声明式的界面描述方式。至于是否需要在语法上进行一次重新梳理,我个人觉得是有必要的。React-Native 在这个方向的探索是个不错的尝试。

在这一点上,苹果的 SwiftUI 或许更值得关注。苹果以极简体验著称,SwiftUI 某种程度上来说代表了关于跨平台开发的可能方向。

儿童编程教育

在我们谈论桌面开发的时候,我认为其实还有一个重要但又很容易被忽视的趋势,是儿童编程教育的走向。

说到儿童编程教育,我们大多数人可能都知道 Scratch 语言。但是要说儿童编程的鼻祖,毫无疑问应该算 Logo 语言,海龟作图。

Scratch 语言由美国麻省理工大学(MIT)于 2007 年发布,到现在已经发展到了 3.0 版本,项目正变得越来越活跃。

在 Scratch 之后,Google 也曾经发布了 Blockly 语言进军儿童编程教育。但是由于缺乏社区基础,Blockly 语言一直不温不火。

但有两件有趣的事情。

其一,Scratch 3.0 是基于 Blockly 的源代码改造而成的,为此据说 Google 也投入了大量的技术人员进行协助,双方协同开发。

其二,Google 基于 Blockly 语言搞出了一个 App Inventor,用于教育儿童学习 Android 开发。

无独有偶的是,苹果推出的 Swift 语言启蒙教程也是针对儿童的,在 AppStore 上可以下载到,叫 “Swift Playgrounds”。

这意味着,我们原本以为两件风马牛不相及的事情,其实是密切相关的。

桌面开发的未来是什么?

从终局的视角来看,桌面开发的终极目标,是让儿童可以轻松编写出生产级的应用。

这不是痴人说梦。

在 iOS 出来之前,如果有人说他要开发一个让三岁小孩都会使用的电脑,可能会有很多人觉得绝无可能。

但是苹果的确做到了。虽然可能还不能完全识别电脑上常见的文字,但是一个三岁的儿童使用起 iPhone 或者 iPad 却毫不困难。

那么,让一个八岁刚刚上学没多久的小学生去做生产级的应用,这事也不是遥不可及的梦想。

桌面开发技术的演进,和儿童编程教育相向而行,有一天必然汇聚于一点上。

结语

今天我们结合桌面开发和儿童编程教育,聊了个人对桌面的未来演进趋势的判断。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将结合一个实际的案例,来讲解一下桌面开发(含单机软件和 Web)到底是什么样的。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(一):怎么设计一个“画图”程序?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到上一讲为止,桌面程序架构设计的基本结构就讲完了。直到现在为止,我们没有讨论任何与具体的应用业务逻辑本身相关的内容。这是因为探讨的内容是普适有效的设计理念,整个讨论会显得很抽象。

今天我们结合一个实际的应用案例,来回顾一下前面我们介绍的内容。

我们选择了做一个 “画图” 程序。选它主要的原因是画图程序比较常见,需求上不需要花费过多的时间来陈述。

我们前面说过,一个 B/S 结构的 Web 程序,基本上分下面几块内容。

  • Model 层:一个多用户(Multi-User)的 Model 层,和单租户的 Session-based Model。从服务端来说,Session-based Model 是一个很简单的转译层。但是从浏览器端来说,Session-based Model 是一个完整的单租户 DOM 模型。
  • View 层:实际是 ViewModel 层,真正的 View 层被浏览器实现了。ViewModel 只有 View 层的数据和可被委托的事件。
  • Controller 层:由多个相互解耦的 Controller 构成。切记不要让 Controller 之间相互知道对方,更不要让 View 知道某个具体的 Controller 存在。

画图程序的源代码可以在 Github 上下载,地址如下:

今天我们讨论浏览器端的 Model,View 和 Controller。

Model 层

我们先看 Model 层。浏览器端的 Model 层,代码就是一个 dom.js 文件。它是一棵 DOM 树,根节点为 QPaintDoc 类。整个 DOM 树的规格如下:

class QLineStyle {
properties:
  width: number
  color: string
methods:
  constructor(width: number, color: string)
}

class QLine {
properties:
  pt1, pt2: Points
  lineStyle: QLineStyle
methods:
  constructor(pt1, pt2: Point, lineStyle: QLineStyle)
  onpaint(ctx: CanvasRenderingContext2D): void
}

class QRect {
properties:
  x, y, width, height: number
  lineStyle: QLineStyle
methods:
   constructor(r: Rect, lineStyle: QLineStyle)
   onpaint(ctx: CanvasRenderingContext2D): void
}

class QEllipse {
properties:
  x, y, radiusX, radiusY: number
  lineStyle: QLineStyle
methods:
   constructor(x, y, radiusX, radiusY: number, lineStyle: QLineStyle)
   onpaint(ctx: CanvasRenderingContext2D): void
}

class QPath {
properties:
  points: []Point
  close: bool
  lineStyle: QLineStyle
methods:
   constructor(points: []Point, close: bool, lineStyle: QLineStyle)
   onpaint(ctx: CanvasRenderingContext2D): void
}

interface Shape {
  onpaint(ctx: CanvasRenderingContext2D): void
}

class QPaintDoc {
methods:
  addShape(shape: Shape): void
  onpaint(ctx: CanvasRenderingContext2D): void
}

目前这个 DOM 还是单机版本的,没有和服务端的 Session-based Model 连起来。关于怎么连,我们下一讲再讨论。

这个 Model 层的使用是非常容易理解的,也非常直观体现了业务。主要支持的能力有以下两个方面。

其一,添加图形(Shape),可以是 QLine,QRect,QEllipse,QPath 等等。

其二,绘制(onpaint)。前面我们介绍 MVC 的时候,我曾提到为了 View 层能够绘制,需要让 DOM 层把自己的数据暴露给 View 层。

但是从简洁的方式来说,是让 Model 层自己来绘制,这样就避免暴露 DOM 层的实现细节。虽然这样让 Model 层变得有那么一点点不纯粹,因为和 GDI 耦合了。但是我个人认为耦合 GDI 比暴露 DOM 的数据细节要好,因为 GDI 的接口通常来说更稳定。

依赖选择是考虑耦合的一个关键因素。在依赖选择上,我们会更倾向于依赖接口更为稳定的组件,因为这意味着我们的接口也更稳定。

ViewModel 层

我们再看 ViewModel 层。它的代码主要是一个 index.htm 文件和一个 view.js 文件。index.htm 是总控文件,主要包含两个东西:

  • 界面布局(Layout);
  • 应用初始化(InitApplication),比如加载哪些 Controllers。

view.js 是我们 ViewModel 层的核心,实现了 QPaintView 类。它的规格如下:

interface Controller {
  stop(): void
  onpaint(ctx: CanvasRenderingContext2D): void
}

class QPaintView {
properties:
  doc: QPaintDoc
  properties: {
    lineWidth: number
    lineColor: string
  }
  drawing: DOMElement
  controllers: map[string]Controller
methods:
  get currentKey: string
  get lineStyle: QLineStyle
  onpaint(ctx: CanvasRenderingContext2D): void
  invalidateRect(rect: Rect): void
  registerController(name: string, controller: Controller): void
  invokeController(name: string): void
  stopController(): void
  getMousePos(event: DOMEvent): Point
events:
  onmousedown: (event: DOMEvent):void
  onmousemove: (event: DOMEvent):void
  onmouseup: (event: DOMEvent):void
  ondblclick: (event: DOMEvent):void
  onkeydown: (event: DOMEvent):void
}

var qview = new QPaintView()

看起来 QPaintView 的内容有点多,我们归类一下:

和 Model 层相关的,就只有 doc: QPaintDoc 这个成员。有了它就可以操作 Model 层了。

**属于 ViewModel 层自身的,数据上只有 properties 和 drawing。**其中 properties 是典型的 ViewModel 数据,用来表示当前用户选择的 lineWidth 和 lineColor 等。drawing 则是浏览器对 HTML 元素的抽象,通过它以及 JavaScript 全局的 document 对象就可以操作 HTML DOM 了。

当然 ViewModel 层一个很重要的责任是绘制。onpaint 和 invalidRect 都是绘制相关。invalidRect 是让界面的某个区域重新绘制。当前为了实现简单,我们总是整个 View 全部重新绘制。

前面我说过, Web 开发一个很重要的优势是不用自己处理局部更新问题,为什么这里我们却又要自己处理呢?原因是我们没有用浏览器的 Virtual View,整个 DOM 的数据组织完全自己管理,这样我们面临的问题就和传统桌面开发完全一致。

剩下来的就是 Controller 相关的了。主要功能有:

  • registerController(登记一个 Controller),invokeController(激活一个 Controller 成为当前 Controller),stopController(停止当前 Controller),View 层并不关心具体的 Controller 都有些什么,但是会对它们的行为规则进行定义;
  • 事件委托(delegate),允许 Controller 选择自己感兴趣的事件进行响应;
  • getMousePos 只是一个辅助方法,用来获取鼠标事件中的鼠标位置。

View 层在 MVC 里面是承上启下的桥梁作用。所以 View 层的边界设定非常关键。

如果我们把实际绘制(onpaint)的工作交给 Model 层,那么 View 基本上就只是胶水层了。但是就算如此,View 层仍然承担了一些极其重要的责任。

  • 屏蔽平台的差异。Model 层很容易做到平台无关,除了 GDI 会略微费劲一点;Controller 层除了有少量的界面需要处理平台差异外,大部分代码都是响应事件处理业务逻辑,只要 View 对事件的抽象得当,也是跨平台的。
  • 定义界面布局。不同尺寸的设备,界面交互也会不太一样,在 View 层来控制不同设备的整体界面布局比较妥当。

Controller 层

最后我们看下 Controller 层。Controller 层的文件有很多,这还是一些 Controller 因为实现相近被合并到一个文件。详细信息如下。

其中,menu.js 主要涉及各种命令菜单和状态显示用途的界面元素。用于创建各类图形(Shape),选择当前 lineWidth、lineColor,以及显示鼠标当前位置。

在创建图形这些菜单项上,有两点需要注意。

其一,菜单并不直接和各类创建图形的 Controller 打交道,而是调用 qview.invokeController 来激活对应的 Controller,这就避免了两类 Controller 相互耦合。

其二,虽然前面 Model 层支持的图形只有 QLine、QRect、QEllipse、QPath 等四种,但是界面表现有六种:Line、Rect、Ellipse、Circle、Path、FreePath 等等。这是非常正常的现象。同一个 DOM API 在 Controller 层往往会有多条实现路径。

选择当前 lineWidth、lineColor 操作的对象是 ViewModel 的数据,不是 Model。这一点前面几讲我们也有过交代。我们当时举的例子是 Selection。其实你把当前 lineWith、lineColor 看作是某种意义上的 Selection ,也是完全正确的认知。

鼠标位置跟踪(MousePosTracker)是一个极其简单,但也是一个很特殊的 Controller,它并不操作任何正统意义的数据(Model 或 ViewModel),而是操作输入的事件。

剩下来的几个 JavaScript 文件都是创建某种图形。它们的工作机理非常相似,我们可以随意选一个看一下。比如 QRectCreator 类,它的规格如下:

class QRectCreator {
methods:
  constructor(shapeType: string)
  stop(): void
  onpaint(ctx: CanvasRenderingContext2D): void
  onmousedown: (event: DOMEvent):void
  onmousemove: (event: DOMEvent):void
  onmouseup: (event: DOMEvent):void
  onkeydown: (event: DOMEvent):void
}

在初始化(构造)时,QRectCreator 要求传入一个 shapeType。这是因为 QRectCreator 实际上并不只是用于创建 Rect 图形,还支持 Line、Ellipse、Circle。只要通过选择两个 points 来构建的图形,都可以用 QRectCreator 这个 Controlller 来做。

QRectCreator 接管了 View 委托的 mousedown、mousemove、mouseup、keydown 事件。

其中,mousedown 事件记录下第一个 point,并由此开启了图形所需数据的收集过程,mouseup 收集第二个 point,随后后创建相应的 Shape 并加入到 DOM 中。keydown 做什么?它用来支持按 ESC 放弃创建图形的过程。

架构思维上我们学习到什么?

通过分析这个 “画图” 程序,你对此最大的收获是什么?欢迎留言就此问题进行交流。这里我也说说我自己想强调的点。

首先,这个程序没有依赖任何第三方库,是裸写的 JavaScript 代码。关于这一点,我想强调的是:

第一,这并不是去鼓励裸写 JavaScript 代码,这只是为了消除不同人的喜好差异,避免因为不熟悉某个库而导致难以理解代码的逻辑;

第二,大家写代码的时候,不要被框架绑架,框架不应该增加代码的耦合,否则这样的框架就应该丢了;更真实的情况是,你很可能是在用一个好框架,但是是不是真用好了,还是取决于你自己的思维。

从架构设计角度来说,在完成需求分析之后,我们就进入了架构的第二步:概要设计(或者也可以叫系统设计)。这个阶段的核心话题是分解子系统,我们关心的问题是下面这些。

  • 每个子系统负责什么事情?
  • 它依赖哪些子系统?它能够少知道一些子系统的存在么?
  • 它们是通过什么接口耦合的?这个接口是否自然体现了两者的业务关系?它们之间的接口是否足够稳定?

MVC 是一个分解子系统的基本框架,它对于桌面程序尤为适用。通过今天对 “画图” 程序的解剖,我们基本能够建立桌面程序框架上非常一致的套路:

  • Model 层接口要自然体现业务逻辑;
  • View 层连接 Model 与 Controller,它提供事件委托(delegate)方便 Controller 接收感兴趣的事件,但它不应该知道任何具体的 Controller;
  • Controller 层中,每个 Controller 都彼此独立,一个 Controller 的职责基本上就是响应事件,然后调用 Model 或 ViewModel 的接口修改数据。

当然,这里没有讨论特定应用领域本身相关的架构问题。对于桌面程序而言,这件事通常发生在 Model 层。但对于我们今天的例子 “画图” 程序而言,Model 层比较简单,基本上还不太需要讨论。在后面,我们也可能会尝试把这个 “画图” 程序需求变复杂,看架构上应该怎么进行应对。

结语

今天我们结合一个大家非常熟悉的例子 “画图” 程序来介绍 MVC 架构。虽然我们基于 Web 开发,但是我们当前给出的画图程序本质上还是单机版的。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(二):怎么设计一个“画图”程序?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲开始,我们进入了实战模式。从目前看到的反馈看,我的预期目标并没有达到。

我复盘了一下,虽然这个程序看起来比较简单,但是实际上仍然有很多需要交代而没有交代清楚的东西。

我个人对这个例子的期望是比较高的。因为我认为 “画图” 程序非常适合作为架构实战的第一课。“画图” 程序需求的可伸缩性非常大,完完全全是一个迷你小 Office 程序,很适合由浅及深去谈架构的演进。

所以我今天微调了一下计划,把服务端对接往后延后一讲,增加一篇 “实战(中)” 篇。这个“中”篇一方面把前面 “实战(上)” 篇没有交代清楚的补一下,另一方面对 “画图” 程序做一次需求的迭代。

MVP 版画图程序

先回到 “实战(上)” 篇。这个版本对画图程序来说,基本上是一个 MVP 版本:只能增加新图形,没法删除,也没法修改。

怎么做?我们先看 Model 层,它的代码就是一个 dom.js 文件。从数据结构来说,它是一棵以 QPaintDoc 为根的 DOM 树。这个 DOM 树只有三级:Document -> Shape -> LineStyle。具体细节可以参阅下表:

这个表列出的是 Model 和 View、Controllers 的耦合关系:Model 都为它们提供了什么?可以看出,View 层当前对 Model 层除了绘制(onpaint),没有其他任何需求。而各个 Controller,对 Model 的需求看起来似乎方法数量不少,但是实质上目的也只有一个,那就是创建图形(addShape)。

我们再看 View 层。它的代码主要是一个 index.htm 文件和一个 view.js 文件。View 层只依赖 Model 层,并且只依赖一个 doc.onpaint 函数。所以我们把关注点放在 View 自身的功能。

View 层只有一个 QPaintView 类。我们将其功能分为了三类:属于 Model 层职责相关的,属于 View 自身职责相关的,以及为 Controller 层服务的,得到下表。

最后,我们来看 Controller 层。Controller 层的文件有很多,这还是一些 Controller 因为实现相近被合并到一个文件,如下所示。

Controller 位于 MVC 的最上层,我们对它的关注点就不再是它的规格本身,也没人去调用它的方法。所以我们把关注点放在了每个 Controller 都怎么用 Model 和 View 的。

我们列了个表,如下。注意 Controller 对事件(Event)的使用从 View 中单独列出来了。

通过以上三张表对照着看,可以清晰看出 Model、View、Controllers 是怎么关联起来的。

改进版的画图程序

MVP 版本的画图程序,用着就会发现不好用,毕竟图形创建完就没法改了。所以我们打算做一个新版本出来,功能上有这样一些改进。

  • 选择一个图形,允许删除、移动或者对其样式进行修改。
  • 图形样式增加 fillColor(填充色)。
  • 更加现代的交互范式:默认处于 ShapeSelector 状态,创建完图形后自动回到此状态。
  • 选择图形后,界面上的当前样式自动更新为被选图形的样式。

怎么改我们的程序?

完整的差异对比,请参见:

下面,我们将详细讲解这些修改背后的思考。

我们先看 Model 层,新的规格见下表。

为了方便大家理解,我们做了一个 Model 的 ChangeNotes 表格,如下:

大部分是新功能的增加,不提。我们重点关注一个点:QLineStyle 改名为 QShapeStyle,且其属性 width、color 被改名为 lineWidth、lineColor。这些属于不兼容修改,相当于做了一次小重构。

重构关键是要及时处理,把控质量。尤其对 JavaScript 这种弱类型语言,重构的心智负担较大。为了保证质量仍然可控,最好辅以足够多的单元测试。

这也是我个人会更喜欢静态类型语言的原因,重构有任何遗漏,编译器会告诉你哪里漏改了。当然,这并不意味着单元测试可以省略,对每一门语言来说,自动化的测试永远是质量保障的重要手段。

话题回到图形样式。最初我们 new QLine、QRect、QEllipse、QPath 的时候,传入的最后一个参数是 QLineStyle,从设计上这是一次失误,这意味着后面这些构造还是都需要增加更多参数如 QFillStyle 之类。

把最后一个参数改为 QShapeStyle,这从设计上就完备了。后面图形样式就算有更多的演进,也会集中到 QShapeStyle 这一个类上。

当前 QShapeStyle 的数据结构是这样的:

class QShapeStyle {
  lineWidth: number
  lineColor: string
  fillColor: string
}

那么,这是合理的么?未来潜在的演进是什么?

对需求演进的推演,关键是眼光看多远。当前各类 GDI 对 LineStyle、FillStyle 支持都非常丰富。所以如果作为一个实实在在要去迭代的画图程序来说,上面这个 QShapeStyle 必然还会面临一次重构。变成如下这个样子:

class QLineStyle {
  width: number
  color: string  
}

class QFillStyle {
  color: string  
}

class QShapeStyle {
  line: any
  fill: any
}

为什么 QShapeStyle 里面的 line 不是 QLineStyle,fill 不是 QFillStyle,而是 any 类型?因为它们都只是简单版本的线型样式和填充样式。

举个例子,在 GDI 系统中,FillStyle 往往还可以是一张图片平铺,也可以是多个颜色渐变填充,这些都无法用 QFillStyle 来表示。所以这里的 QFillStyle 更好的叫法也许是 QSimpleFillStyle。

聊完了 Model 层,我们再来看 View 层。

View 层的变化不大。为了给大家更直观的感觉,我这里也列了一个 ChangeNotes 表格,如下:

其中,properties 改名为 style,以及删除了 get lineStyle(),和 properties 统一为 style。这个和我上面说的 Model 层的小重构相关,并不是本次新版本的功能引起的。

所以 View 层真正的变化是两个:

  • 引入了 selection,当前只能单选一个 shape;在 selection 变化时会发出 onSelectionChanged 事件;
  • 引入了 onControllerReset 事件,它在 Controller 完成或放弃图形的创建时发出。

引入 selection 比较常规。View 变复杂了通常都会有 selection,唯一需要考虑的是 selection 会有什么样的变化,对于 Office 类程序,如果 selection 只允许是单 shape 这不太合理,但我们这里略过,不进行展开。

我们重点谈 onControllerReset 事件。

onControllerReset 事件是创建图形的 Controller(例如 QPathCreator、QRectCreator 等)发出,并由 Menu 这个 Controller 接收。

这就涉及了一个问题:类似情况还会有多少?以后是不是还会有更多的事件需要在 Controller 之间传递,需要 View 来中转的?

这个问题就涉及了 View 层事件机制的设计问题。和这个问题相关的有:

  • 要不要支持任意的事件;
  • 监听事件是支持单播还是多播?

从最通用的角度,肯定是支持任意事件、支持多播。比如我们定义一个 QEventManager 类,规格如下。

class QEventManager {
  fire(eventName: string, params: ...any): void
  addListener(eventName: string, handler: Handler): void
  removeListener(eventName: string, handler: Handler): void
}

但是,View 的事件机制设定,需要在通用性与架构的可控性之平衡。一旦 View 聚合了这个 QEventManager,通用是通用了,但是 Controller 之间会有什么样的事件飞来飞去,就比较难去从机制上把控了。

代码即文档。如果能够用代码约束的事情,最好不要在文档中来约束。

所以,就算是我们底层实现 QEventManager 类,我个人也不倾向于在 View 的接口中直接将它暴露出去,而是定义更具体的 fireControllerReset、 onControllerReset/offControllerReset 方法,让架构的依赖直观化。

具体代码看起来是这样的:

class QPaintView {
  constructor() {
    this._eventManager = new QEventManager()
  }
  onControllerReset(handler) {
    this._eventManager.addListener("onControllerReset", handler)
  }
  offControllerReset(handler) {
    this._eventManager.removeListener("onControllerReset", handler)
  }
  fireControllerReset() {
    this._eventManager.fire("onControllerReset")
  }
}

聊完了 View 层,我们接着聊 Controller 层。我们也把每个 Controller 怎么用 Model 和 View 列了个表,如下。

内容有点多。为了更清楚地看到差异,我们做了 ChangeNotes 表格,如下:

首先,Menu、QPathCreator、QFreePathCreator、QRectCreator 的变更,主要因为引入了新的交互范式导致,我们为此引入了 onControllerReset 事件。还有一个变化是 QLineStyle 变 QShapeStyle,这一点前面已经详细讨论,不提。

所以 Controller 层的变化其实主要是两个。

其一,PropSelectors。这个 Controller 要比上一版本的复杂很多:之前只是修改 View 的 properties (现在是 style) 属性,以便于创建图形时引用。现在是改变它时还会作用于 selection (被选中的图形),改变它的样式;而且,在 selection 改变时,会自动更新界面以反映被选图形的样式。

其二,QShapSelector。这是新增加的 Controller,支持选择图形,支持删除、移动被选择的图形。

通过这次的需求迭代我们可以看出,目前 Model、View、Controller 的分工,可以使需求的分解非常正交。

Model 只需要考虑需求导致的数据结构演进,并抽象出足够自然的业务接口。View 层非常稳定,主要起到各类角色之间的桥接作用。Controller 层每个 Controller 各司其职,彼此之间不会受到对方需求的干扰。

结语

今天我们结合“画图” 程序重新梳理了一遍 MVC 架构。并且我们更进一步,通过对画图程序进行一次需求演进,来观察 MVC 架构各个角色对需求变更的敏感性。需要再次强调的是,虽然我们基于 Web 开发,但是我们当前给出的画图程序本质上还是单机版的。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(三):怎么设计一个“画图”程序?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

前面的两节课结束后,我们的画图程序已经基本实用。它有如下功能:

  • 可以选择全局的图形样式(lineWidth、lineColor、fillColor);
  • 可以以全局的图形样式来创建各类图形(Path、FreePath、Line、Rect、Ellipse、Circle);
  • 可以选择已经创建的图形,并修改其图形样式;
  • 可以删除选择的图形;
  • 可以移动选择的图形。

前面有一些同学的反馈,我这里想回答一下。

有一个反馈是对 JavaScript 的使用,我为什么会用 class 关键字。

这是因为我不太希望这是一篇某个语言的教程,我选择的是如何用最接近大家思维的表达方式来表达程序逻辑,你就算没有系统学过 JavaScript,也应该能够理解这段程序想要做什么。

另外有一个反馈,是希望我不要一上来就从 MVC 这种模式讲起,而是如果没有 MVC,我们用最基础的裸写代码,会写出一个什么样的程序来,里面有哪些弊端,从而引入 MVC 来让程序架构变得更加清晰,功能之间解耦。

这个意见我觉得是比较中肯的,后面我们会补充一讲来裸写 MVP 版本的画图程序。

今天我们开始进入“实战:怎么设计一个‘画图’程序”的第三讲,怎么和服务端连接。

考虑到大家普遍反馈内容有点深,我们把服务端连接分为两节课去聊。今天这一讲我们谈的是在浏览器端进行持久化。

为什么需要在浏览器端进行持久化?

因为我们需要有更好的用户体验。在用户断网的情况下,这个画图程序还可以正常编辑,并且在恢复联网的情况下,需要能够把所有离线编辑的内容自动同步到服务端。

结合前面几讲的介绍,你可能立刻想到 Google 推的 PWA,它非常关注浏览器应用的离线体验。

但是当我们做一个技术选型的时候,显然首先要考虑的是这个技术的兼容性如何。我们今天并不基于 PWA 来干这件事情,而是基于更传统的 localStorage 技术来干。

具体我们改的代码如下:

最核心的变化是 Model 层。完整的离线支持的 Model 层代码如下:

对象 ID

为了支持持久化,我们给每一个 Model 层 DOM 树的根 —— QPaintDoc 类引入了两个 ID,如下:

  • localID: string
  • displayID: string

其中 displayID 顾名思义,是用户可见的ID。我们的画图程序之前本地调试的行为是打开 http://localhost:8888/ 来编辑一篇文档(QPaintDoc),但是现在会自动跳转到 http://localhost:8888/#t10001 或类似的 URL。这里 t10001 就是文档的 displayID。

其中,displayID 前面带 t 开头,表示这篇文档从它被创建开始,从未与服务器同步过,是一篇临时的文档。一旦它完成与服务端的同步后,就会改用服务端返回的文档 ID。

那么,localID 是什么?顾名思义,是这篇文档的本地 ID。在文档还没有和服务端同步时,它和 displayID 是有关系的,如果 displayID 是 t10001,那么 localID 就是 10001。但是文档第一次保存到服务端后,它的 displayID 会变化,而 localID 则并不改变。

这有什么好处?

好处在于,我们在 localStorage 存储 DOM 树的时候,并不是把整篇文档 JSON 化后保存,而是分层的,QPaintDoc 里面的 shapes 数组保存的只是 shapeID。

是的,每个 Shape(图形)也引入了一个 ID。这样,当 Shape 发生变化,比如修改图形样式、移动,我们修改 shapeID => shapeJsonData。

请注意,在浏览器的 localStorage 里面,shapeID 是要全局唯一的,我们实际存储的是 QPaintDoc.localID + ":" + shape.id。

看到这里我们回过头来看,为什么 QPaintDoc 有 displayID 和 localID 就可以理解了。如果只有一个 ID 并且这个 ID 是会发生变化的,那么在 ID 变化时,所有保存在 localStorage 中的这篇文档的图形对象 shapeID => shapeJsonData 数据都需要跟着变化。

引入 localID 就是让 QPaintDoc 一旦初始化(QPaintDoc.init 方法)后 ,ID 就固定下来了,只需要保证在同一个浏览器下是唯一就行。

所以,我们第一次访问 http://localhost:8888/ 自动跳转的是 http://localhost:8888/#t10001 ,第二次访问自动跳转的就是 http://localhost:8888/#t10002 了。这是因为在同一个浏览器下,我们不会让两个 QPaintDoc.localID 相同。

数据变更

我们把数据变更分为了两级:

  • shapeChanged
  • documentChanged

什么情况下叫 shapeChanged?有这样三种:

  • 增加一个图形(addShape),这个新增的 shape 发生了 shapeChanged;
  • 修改一个 shape 的图形样式(setProp),这个被修改的 shape 发生了 shapeChanged;
  • 移动一个 shape 的位置(move),这个位置改变的 shape 发生了 shapeChanged。

什么情况下发生 documentChanged?有这样两种:

  • 增加一个图形(addShape),它会导致文档的图形数量增加一个,发生 documentChanged;
  • 删除一个图形(deleteShape),它会导致文档的图形数量减少一个,发生 documentChanged。

当然,可以预见的未来,我们支持不同 shape 交换次序(改变 Z-Order),这时文档虽然图形的数目不变,但是 shapes 数组的内容还是发生了改变,发生 documentChanged。

发生数据变更做什么?

在 shapeChanged 时,更新 localStorage 中的 shapeID => shapeJsonData 数据。在 documentChanged 时,更新 localID => documentJsonData 数据。

从未来的预期来说,数据变更不只是发生在用户交互。考虑多人同时编辑一篇文档的场景。数据变更消息,也会来自其他浏览器端的变更。具体的过程是:

  • Client B 操作 => Client B 的 DOM 变更 => 服务端数据变更 => Client A 收到数据变更 => Client A 的 DOM 变更 => Client A 的 View 更新

在前面 26 讲、27 讲中,我们并没有引入数据变更事件,而是 Controller 变更完数据后,就自己主动调用 qview.invalidateRect 来通知 View 层重新绘制。这样做比较简单,虽然它并不符合标准的 MVC 架构。因为从 MVC 架构来说,界面更新并不是由 Controller 触发,而应该由 Model 层的数据变更(DataChanged)事件触发。

存储的容量限制与安全

localStorage 的存储容量是有限制的,不同的浏览器并不一样,大部分在 5-10M 这个级别。在同一个浏览器下,会有多个 QPaintDoc 的数据同时被保存在 localStorage 中。

这意味着,随着时间的推移,localStorage 的存储空间占用会越来越大,所以我们需要考虑数据清理的机制。

目前,我们通过 localStorage_setItem 函数来统一接管 localStorage.setItem 调用,一旦 setItem 发生 QuotaExceededError 异常,说明 localStorage 空间满,我们就淘汰掉最远创建的一篇文档。

这样,我们就不会因为 localStorage 太满而没法保存。只要我们及时联网同步文档,数据也就不会丢失了。

最后一个话题是安全。

既然我们把数据保存在了 localStorage 中,只要用户打开浏览器,就能够去通过特定手段来查看 localStorage 的数据。

这意味着如果文档中存在敏感数据的话,是可以被人感知的。尤其是我们画图程序如果未来支持多租户的话,在同一个浏览器下多个用户帐号登录登出时,就会发生多个用户的文档都在同一个 localStorage 中可见。

这意味着你登出帐号之后,其他人用这个浏览器,其实还是可以看到你的数据。这样就有隐私泄漏的风险。

解决这个问题最简单的方法是在用户帐号登出的时候,清空所有的 localStorage 中的文档。

结语

今天我们开始考虑 “画图” 程序的服务端连接。今天这一讲我们先做画图程序的本地浏览器存储的持久化,以便拥有更好的离线。

支持离线持久化存储的程序会很不一样。我们今天结合画图程序聊了 DOM 树在 JavaScript 内存和在 localStorage 存储上的差别。为了支持更新数据的粒度不是整个文档每次都保存一遍,存储分成 shape、document 两个级别。相应的,我们数据更新事件也分了 shapeChanged、documentChanged 两个级别。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(四):怎么设计一个“画图”程序?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

今天继续我们的画图程序。上一讲完成后,我们的画图程序不只是功能实用,并且还支持了离线编辑与存储。

今天我们开始考虑服务端。

我们从哪里开始?

第一步,我们要考虑的是网络协议。

网络协议

为了简化,我们暂时不考虑多租户带授权的场景。后面我们在下一章服务端开发篇会继续实战这个画图程序,将其改造为多租户。

在浏览器中,一个浏览器的页面编辑的是一个文档,不同页面编辑不同的文档。所以在我们的浏览器端的 dom.js 里面,大家可以看到,我们的 DOM 模型是单文档的设计。

但显然,服务端和浏览器端这一点是不同的,就算没有多租户,但是多文档是跑不了的。我们不妨把 QPaint 的文档叫drawing,如此服务端的功能基本上是以下这些:

  • 创建新 drawing 文档;
  • 获取 drawing 文档;
  • 删除 drawing 文档;
  • 在 drawing 文档中创建一个新 shape;
  • 取 drawing 文档中的一个 shape;
  • 修改 drawing 文档中的一个 shape,包括移动位置、修改图形样式;
  • 修改 drawing 文档中的一个 shape 的 zorder 次序(浏览器端未实现);
  • 删除 drawing 文档的一个 shape。

完整的网络协议见下表:

其中&lt;Shape&gt;是这样的:

"path": {
    "points": [
        {"x": <X>, "y": <Y>},
        ...
    ],
    "close": <Boolean>,
    "style": <ShapeStyle>
}

或:

"line": {
    "pt1": {"x": <X>, "y": <Y>},
    "pt2": {"x": <X>, "y": <Y>},
    "style": <ShapeStyle>
}

或:

"rect": {
    "x": <X>,
    "y": <Y>,
    "width": <Width>,
    "height": <Height>,
    "style": <ShapeStyle>
}

或:

"ellipse": {
    "x": <X>,
    "y": <Y>,
    "radiusX": <RadiusX>,
    "radiusY": <RadiusY>,
    "style": <ShapeStyle>
}

其中&lt;ShapeStyle&gt;是这样的:

{
    "lineWidth": <Width>,  // 线宽
    "lineColor": <Color>,  // 线型颜色
    "fillColor": <Color>   // 填充色
}

其中&lt;ZorderOperation&gt;可能的值为:

  • "top": 到最顶
  • "bottom": 到最底
  • "front": 往前一层
  • "back": 往后一层

整体来说,这套网络协议比较直白体现了其对应的功能含义。我们遵循这样一套网络协议定义的范式:

  • 创建对象:POST /objects
  • 修改对象:POST /objects/&lt;ObjectID&gt;
  • 删除对象:DELETE /objects/&lt;ObjectID&gt;
  • 查询对象:GET /objects/&lt;ObjectID&gt;

其实还有一个列出对象,只不过我们这里没有用到:

  • 列出所有对象:GET /objects
  • 列出符合条件的对象:GET /objects?key=value

另外,有一个在网络设计时需要特别注意的点是:对重试的友好性。

为什么我们必须要充分考虑重试的友好性?因为网络是不稳定的。这意味着,在发生一次网络请求失败时,在一些场景下你不一定能确定请求的真实状态。

在小概率的情况下,有可能服务端已经执行了预期的操作,只不过返还给客户端的时候网络出现了问题。在重试时你以为只是重试,但实际上是同一个操作执行了两遍。

所谓重试的友好性,是指同一个操作执行两遍,其执行结果和只执行一遍一致。

只读操作,比如查询对象或列出对象,毫无疑问显然是重试友好的。

创建对象(POST /objects)往往容易被实现为重试不友好的,执行两遍会创建出两个对象来。我们对比一下这里创建新drawing和创建新shape的差别:

POST /drawings
POST /drawings/<DrawingID>/shapes
Content-Type: application/json

{
    "id": <ShapeID>,
    <Shape>
}

可以看到,创建新 shape 时传入了 ShapeID,也就是说是由客户(浏览器端)分配 ShapeID。这样做的好处是如果上一次服务端已经执行过该对象的创建,可以返回对象已经存在的错误(我们用 status = 409 冲突来表示)。

而创建新 drawing 并没有传入什么参数,所以不会发生什么冲突,重复调用就会创建两个新 drawing 出来。

通过以上分析,我们可以认为:创建新 shape 是重试友好的,而创建 drawing 不是重试友好的。那么怎么解决这个问题?有这么几种可能:

  • 客户端传 id(和上面创建新 shape 一样);
  • 客户端传 name;
  • 客户端传 uuid。

当然这三种方式本质上的差别并不大。比如客户端传 name,如果后面其他操作引用时用的也是 name,那么本质上这个 name 就是 id。

传 uuid 可以认为是一种常规重试友好的改造手法。这里 uuid 并没有实际含义,你可以理解为它是 drawing 的唯一序列号,也可以理解为网络请求的唯一序列号。当然这两种不同理解的网络协议表现上会略有不同,如下:

POST /drawings
Content-Type: application/json

{
    "uuid": <DrawingUUID>
}
POST /drawings
Content-Type: application/json
X-Req-Uuid: <RequestUUID>

修改对象和删除对象,往往是比较容易做到重试友好。但这并不绝对,比如我们这个例子中 “修改shape的顺序”,它的网络协议是这样的:

POST /drawings/<DrawingID>/shapes/<ShapeID>
Content-Type: application/json

{
    "zorder": <ZorderOperation>
}

其中&lt;ZorderOperation&gt;可能的值为:

  • "top": 到最顶
  • "bottom": 到最底
  • "front": 往前一层
  • "back": 往后一层

在 ZorderOperation 为 "front" 或 "back" 时,重复执行两遍就会导致 shape 往前(或往后)移动 2 层。

怎么调整?

有两个办法。一个方法是把修改操作用绝对值表示,而不是相对值。比如 ZorderOperation 为 "front" 或 "back" 是相对值,但是 Zorder = 5 是绝对值。

另一个方法是通用的,就是用请求的序列号(RequestUUID),这个方法在上面创建新 drawing 已经用过了,这里还可以用:

POST /drawings/<DrawingID>/shapes/<ShapeID>
Content-Type: application/json
X-Req-Uuid: <RequestUUID>

{
    "zorder": <ZorderOperation>
}

当然用请求序列号是有额外代价的,因为这意味着服务端要把最近执行成功的所有的请求序列号(RequestUUID)记录下来,在收到带请求序列号的请求时,检查该序列号的请求是否已经成功执行,已经执行过就报冲突。

在网络协议的设计上,还有一个业务相关的细节值得一提。

细心的你可能留意到,我们 Shape 的 json 表示,在网络协议和 localStorage 存储的格式并不同。在网络协议中是:

{
    "id": <ShapeID>,
    "path": {
        "points": [
            {"x": <X>, "y": <Y>},
            ...
        ],
        "close": <Boolean>,
        "style": <ShapeStyle>
    }  
}

而在 localStorage 中的是:

{
    "type": "path",
    "id": <ShapeID>,
    "points": [
        {"x": <X>, "y": <Y>},
        ...
    ],
    "close": <Boolean>,
    "style": <ShapeStyle>
}

从结构化数据的 Schema 设计角度,localStorage 中的实现是无 Schema 模式,过于随意。这是因为 localStorage 只是本地自己用的缓存,影响范围比较小,故而我们选择了怎么方便怎么来的模式。而网络协议未来有可能作为业务的开放 API ,需要严谨对待。

版本升级

另外,这个画图程序毕竟只是一个 DEMO 程序,所以还有一些常见网络协议的问题并没有在考虑范围之内。

比如从更长远的角度,网络协议往往还涉及协议的版本管理问题。网络协议是一组开放 API 接口,一旦放出去了就很难收回,需要考虑协议的兼容。

为了便于未来协议升级的边界,很多网络协议都会带上版本号。比如:

POST /v1/objects
POST /v1/objects/<ObjectID>
DELETE /v1/objects/<ObjectID>
GET /v1/objects/<ObjectID>
GET /v1/objects?key=value

在协议发生了不兼容的变更时,我们会倾向于升级版本,比如升为 v2 版本:

POST /v2/objects
POST /v2/objects/<ObjectID>
DELETE /v2/objects/<ObjectID>
GET /v2/objects/<ObjectID>
GET /v2/objects?key=value

这样做有这么一些好处:

  • 可以逐步下线旧版本的流量,一段时间内让两个版本的协议并存;
  • 可以新老版本的业务服务器相互独立,前端由 nginx 或其他的应用网关来分派。

第一个实现版本

聊完了网络协议,我们就要开始考虑服务端的实现。在选择第一个实现版本怎么做时,有这样几种可能性。

第一种,当然是常规的憋大招模式。直接做业务架构设计、架构评审、编码、测试,并最后上线。

第二种,是做一个 Mock 版本的服务端程序。

两者有什么区别?

区别在于,服务端程序从架构设计角度,就算是非业务相关的通用型问题也是很多的,比如高可靠和高可用。

高可靠是指数据不能丢。就算服务器的硬盘坏了,数据也不能丢。这还没什么,很多服务甚至要求,在机房层面出现大面积事故比如地震,也不能出现数据丢失。

高可用是指服务不能存在单点故障。任何一台甚至几台服务器停机了,用户还要能够正常访问。一些服务比如支付宝,甚至要求做到跨机房的异地双活。在一个机房故障时,整个业务不能出现中断。

在没有好的基础设施下,要做好一个好的服务端程序并不那么容易。所以另一个选择是先做一个 Mock 版本的服务端程序。

这不是增加了工作量?有什么意义?

其一,是让团队工作并行。不同团队协作的基础就是网络协议。一个快速被打造的 Mock 的最小化版本服务端,可以让前端不用等待后端。而后端则可以非常便捷地自主针对网络协议进行单元测试,做很高的测试覆盖率以保证质量,进度不受前端影响。

其二 ,是让业务逻辑最快被串联,快速验证网络协议的有效性。中途如果发现网络协议不满足业务需求,可以及时调整过来。

所以我们第一版的服务端程序,是 Mock 的版本。Mock 版本不必考虑太多服务端领域的问题,它的核心价值就是串联业务。所以 Mock 版本的服务器甚至不需要依赖数据库,直接所有的业务逻辑基于内存中的数据结构就行。

代码如下:

正式版画图程序的服务端,我们会在后面服务端开发一章的实战中继续去完成。

从架构角度来说,这个 paintdom 程序分为两层:Model 层和 Controller 层。

我们首先看一下 Model 层。它的源代码是:

Model 层与网络无关,有的只是纯纯粹粹的业务核心逻辑。它实现了一个多文档版本的画图程序,逻辑结构也是一棵 DOM 树,只不过比浏览器端多了一层:

  • Document => Drawing => Shape => ShapeStyle

浏览器端的 QPaintDoc,对应的是这里的 Drawing,而不是这里的 Document。

我们再来看一下 Controller 层。它的源代码是:

Controller 层实现的是网络协议。你可能觉得奇怪,我为什么会把网络协议层看作 Controller 层,那么 MVC 中 View 层去了哪里。

首先服务端程序大部分情况下并不需要显示模块,所以不存在 View 层。网络协议层为什么可以看作 Controller 层,是因为它负责接受用户输入。只不过用户输入不是我们日常理解的用户交互,而是来自某个自动化控制(Automation)程序的 API 请求。

虽然这个 paintdom 程序的实现,有一些 Go 语言相关的知识点是挺值得讲的,尤其是网络协议实现相关的部分。不过我这里就不做展开了,感兴趣的同学可以自行学习一下 Go 语言。

总体来说,业务逻辑相关的部分理解起来相对容易,我们这里不再赘述。

结语

今天我们重点讨论了 “画图” 程序的网络协议,给出了常规网络协议设计上的一些考量点。网络协议的地位非常关键,它是一个 B/S 或 C/S 程序前后端耦合的使用界面,因而也是影响团队开发效率的关键点。

如何及早稳定网络协议?如何及早让前端程序员可以与服务端联调?这些都是我们应该重点关注的地方。

定义清楚网络协议后,我们给出了满足我们定义的网络协议的第一个服务端实现版本 paintdom 程序,用于串联业务逻辑。这个实现版本是 Mock 程序,它只关注业务逻辑,不关心服务端程序的固有的高可靠、高可用等需求。后续在下一章服务端开发中,我们会继续迭代它。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们会把这个 paintdom 服务端程序,和我们的 paintweb 画图程序串联起来。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(五):怎么设计一个“画图”程序?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

我们继续聊我们的话题。这是画图程序的最后一讲了。当然我们后续还会结合这个实战程序展开讨论有关于架构的方方面面。

宏观的系统架构

上一讲开始,我们的画图程序有了跨团队协作:因为我们开始有了 paintdom 和 paintweb 两大软件。paintdom 监听的地址是 localhost:9999,而 paintweb 监听的地址是 localhost:8888。

应当注意,在实际业务中它们是不同的软件,事实上我们 paintweb 程序也完全是以进程间协作的方式,通过反向代理机制来调用 paintdom 的功能。但是在我们这个画图 DEMO 程序中,它们同属一个进程,paintdom 作为 paintweb 的一个 goroutine 在跑。这纯粹是因为我们想让这两个程序 “同生共死”,方便调试的时候起停进程。

paintdom 和 paintweb 之间相互协作的基础,是它们之间所采用的网络协议。

当我们说起网络协议,它其实通常包含两个层面的意思:其一是我们网络协议的载体,也就是协议栈(我们这里采纳的是 HTTP 协议,而 HTTP 协议又基于 TCP/IP 协议);其二是我们网络协议承载的业务逻辑。

当我们谈架构的时候,也会同时聊这两个层面,只是它们在不同的维度。我们会关心网络协议的协议栈选择什么,是基于 HTTP 还是基于自定义的二进制协议,这个是属于基础架构的维度。我们也会关心网络协议的业务逻辑,判断它是否自然体现业务需求,这是属于应用架构的维度。

明确了网络协议后,我们实现了 Mock 版本的服务端程序 paintdom。在实际项目中,Mock 程序往往会大幅提速团队的开发效率。这是因为它能够达到如下两个大的核心目标:

  • 让团队的研发迭代并行,彼此可以独立演进。
  • 及早验证网络协议的合理性,在实战中达到用最短时间稳定协议的目的。

上一讲我们的 paintdom 和 paintweb 之间虽然定义了网络协议,并且实现了第一版,但是并没有去做两者的对接。

今天我们就来对接它们。

虽然 paintweb 没有对接服务端,但从文档编辑的角度来说,它的功能是非常完整的。我们对接 paintdom 和 paintweb 的目的不是加编辑功能,而是让文档可以存储到服务端,以便于人们在世界任何可以联网的角落都可以打开它。

当然严谨来说,说 paintweb 没有服务端是不正确的,paintweb 本身是一个 B/S 结构,它有它自己的服务端。如下:

var wwwServer = http.FileServer(http.Dir("www"))

func handleDefault(w http.ResponseWriter, req *http.Request) {
  if req.URL.Path == "/" {
    http.ServeFile(w, req, "www/index.htm")
	return
  }
  req.URL.RawQuery = "" // skip "?params"
  wwwServer.ServeHTTP(w, req)
}

func main() {
  http.HandleFunc("/", handleDefault)
  http.ListenAndServe(":8888", nil)
}

可以看出,paintweb 自己的服务端基本上没干什么事情,就是一个非常普通的静态文件下载服务器,提供给浏览器端下载 HTML + CSS + JavaScript 等内容。

所以 paintweb 的服务端完全是“平庸”的,与业务无关。具体的业务,都是通过 www 目录里面的文件来做到的。这些文件都是前端的浏览器端所依赖的,只不过被 “托管” 到 paintweb 服务端而已。

那么 paintweb 怎么对接 paintdom 呢?

物理上的对接比较简单,只是个反向代理服务器而已,代码如下:

func newReverseProxy(baseURL string) *httputil.ReverseProxy {
  rpURL, _ := url.Parse(baseURL)
  return httputil.NewSingleHostReverseProxy(rpURL)
}

var apiReverseProxy = newReverseProxy("http://localhost:9999")

func main() {
  http.Handle("/api/", http.StripPrefix("/api/", apiReverseProxy))
}

可以看出,paintweb 的服务端干的事情仍然是 “平庸” 的,只是把发往 http://localhost:8888/api/xxx 的请求,原封不动地发往 http://localhost:9999/xxx 而已。

在现实中,paintweb 的服务端干的事情稍微复杂一些。它背后不只是有业务服务器 paintdom,还有必不可少的帐号服务器(Account Service),用来支持用户登录/登出。

帐号服务器是一个基础架构类的服务,与业务无关。公司很可能不只有 QPaint 这样一个业务,还会有别的,但这些业务可以共享相同的帐号服务。更准确地说,是必须共享相同的帐号服务,否则一个公司弄出好多套独立的帐号体系来,用户也会有所诟病。

在需要对接帐号服务器的情况下,实际上 paintweb 的服务端并不是原封不动地转发业务请求,而是会对协议进行转义。

在 “24 | 跨平台与 Web 开发的建议”这一讲中我们提到过:

到了 Web 开发,我们同样需要二次开发接口,只不过这个二次开发接口不再是在 Client 端完成的,而是在 Server 端完成。Server 端支持直接的 API 调用,以支持自动化(Automation)方面的需求。

所以,对 Server 端来说,最底层的是一个多租户的 Model 层(Multi-User Model),它实现了自动化(Automation)所需的 API。

在 Multi-User Model 层之上,有一个 Web 层。Web 层和 Model 层的假设不同,Web 层是基于会话的(Session-based),因为它负责用户的接入,每个用户登录后,会形成一个个会话(Session)。

如果我们对Web 层细究的话,又分为 Model 层和 ViewModel 层。为了区分,Web 这边的 Model 层我们叫它 Session-based Model。相应地,ViewModel 层我们叫它 Session-based ViewModel。

在服务端,Session-based Model 和 Session-based ViewModel 并不发生直接关联,它们通过自己网络遥控浏览器这一侧的 Model 和 ViewModel,从而响应用户的交互。

Session-based Model 是什么样的呢?它其实是 Multi-User Model 层的转译。把多租户的 API 转译成单租户的场景。所以这一层并不需要太多的代码,甚至理论上自动实现也是有可能的。

Session-based ViewModel 是一些 HTML+JavaScript+CSS 文件。它是真正的 Web 业务入口。它通过互联网把自己的数据返回给浏览器,浏览器基于 ViewModel 渲染出View,这样整个系统就运转起来了。

这段话说的比较抽象,但结合 QPaint 这个实际的例子,就非常明朗了:

  • paintdom 就是这里说的 Multi-User Model 层,负责多租户的业务服务器。
  • paintweb 服务端实现 Session-based Model 层,负责 Session-based 到 Multi-User 的转译。由于我们当前这个例子还不支持多租户,转译就变成了简单的转发。后面我们在 “服务端开发” 一节中会给大家看实际的转译层是怎么做的。

所以你可以看到,其实 paintweb 自身的服务端是业务无关的。它做这样一些事情:

  • Web 前端文件的托管(作为静态文件下载服务器);
  • 支持帐号服务,实现 Web 的用户登录;
  • 做业务协议的转译,将 Session-based 的 API 请求转为 Multi-User 的 API 请求。

当然,我们这里假设 Web 自身的业务逻辑都是通过 JavaScript 来实现的。这意味着我们是基于 “胖前端” 模式的。

但这并不一定符合事实,有些公司会基于 “胖后端” 模式。这意味着大部分的前端用户行为,都是由后端支持的,比如我们用 PHP 来实现 Web 后端的业务代码。

胖后端模式的好处是 Web 代码比较安全。这里的 “安全” 是指 IT 资产保全方面的安全,不是指业务存在安全问题,因为别人看不到完整的 Web 业务逻辑代码。

但是胖后端模式的缺点是没办法支持离线。大部分的用户交互都需要 Web 后端来响应,一旦断了网就什么都干不了了。

在 “胖后端” 模式下,我个人会倾向于基于类似 PHP 这种胶水语言来实现 Web 后端的业务代码。而一旦我们这么做,paintweb 的业务逻辑就被剥离了,paintweb 自身的后端仍然是业务无关的,只是多了一个职责:支持 PHP 脚本语言。

真正 Web 后端业务逻辑,还是放在了 www 目录中,以 PHP 文件存在,这些文件就不是简单的静态资源,而是 “胖后端” 的业务代码。

既然 paintweb 后端是 “平庸” 的,与业务无关,那么整个业务逻辑的串联,靠的就是 www 里面的 js 文件,和 paintdom 提供的 API 接口。

上面我们说过,在连接 paintdom 之前,paintweb 程序独立看是完整的,它支持离线创建、编辑以及存储文档到浏览器本地的 localStorage 上。

对接 paintdom 与 paintweb 后我们并不会放弃离线编辑的能力,而是要能够做到:

  • 在断网情况下,表现为上一讲我们达到的效果,可以继续离线编辑和保存;
  • 一旦联网,所有离线编辑的内容可以自动保存到 paintdom 服务器。

计算变更

听起来挺简单一件事情?

其实很复杂。第一件要做的事情是:怎么知道断网后离线编辑过的内容有哪些?

思路一是不管三七二十一,每次都完整保存整篇文档。这很浪费,因为不单单刚恢复联网的时候我们需要保存文档,平常每一次编辑操作我们也都会自动保存修改的内容。

思路二是记录完整的编辑操作历史,每做一个编辑操作都将其记录到 localStorage。这个思路看似会更节约,但是实际上在很多情况下会更浪费。原因在于:

  • 一个对象编辑多次,会有很多条编辑操作的指令要保存;
  • 断网久了,编辑操作累计下来,其存储空间甚至可能超过文档大小。

所以这种方案缺乏很好的鲁棒性,在 badcase 情况下让人难以接受。

思路三是给对象增加版本号。通过对比整个文档的基版本(baseVer,即上一次同步完成时的版本),与某个对象的版本 ver。如果 ver > baseVer,说明上一次同步完成后,该对象发生了变更。完整的变更信息的计算逻辑如下:

prepareSync(baseVer) {
  let shapeIDs = []
  let changes = []
  let shapes = this._shapes
  for (let i in shapes) {
    let shape = shapes[i]
    if (shape.ver > baseVer) {
      changes.push(shape)
    }
    shapeIDs.push(shape.id)
  }
  let result = {
    shapes: shapeIDs,
    changes: changes,
    ver: this.ver
  }
  this.ver++
  return result
}

同步变更

有了变更的信息,怎么同步给服务端?

一个可能的思路是把变更还原为一条条编辑操作发给服务端。但是,这样做问题会很复杂,因为这些编辑操作一部分发送成功,一部分发送失败怎么办?

这种部分成功的中间态是最挑战我们程序员的编程水平的,很烧脑。

我个人一贯坚持的架构准则是不要烧脑。尤其对大部分非性能敏感的业务代码,简单易于实施为第一原则。

所以我们选择了修改网络协议。增加了同步接口:

这很有趣。在我们讨论相互配合的接口时,我们非常尊重业务逻辑,按照我们对业务的理解,定义了一系列的编辑操作。但是,到最后我们却发现,它们统统不管用,我们要的是一个同步协议。

是最初我们错了吗?

也不能这么说。最初我们定义协议的逻辑并没有错,只是没有考虑到支持离线编辑这样的需求而已。

复盘这件事情,我们可以这么说:

  • 需求的预见性非常重要。如果我们没有充分预见到需求,大部分情况下就会因为我们缺乏市场洞察而买单;
  • 进一步说明,及早推出 Mock,让前端可以快速迭代,进而及早去发现原先定义网络协议的不足是很有必要的。越晚做出协议调整,事情就越难,也越低效。

有了同步协议,我们就可以把变更信息同步给服务端了。这个事情我们交给了 QSynchronizer 类来完成(详细请看 dom.js#L204)。

加载文档

把变更详细推送给服务端后,理论上我们就可以在世界各地看到这篇文档。

怎么做到?

我们接下来就谈谈怎么来加载文档。这个过程的难点在于怎么根据服务端返回的 json 数据重建整个文档。

上一讲我们已经说过,我们图形(Shape)的网络协议中的数据格式,和 localStorage 中是不同的。这意味着我们需要做两套图形数据的加载工作。

这挺没有必要。

而且,从预测变更的角度,我们很容易预期的一个变化,就是画图程序支持的图形(Shape)的种类会越来越多。

这两个事情我们一起看。为此我们做了一次重构。重构目标是:

  • 统一 localStorage 和网络协议中的图形表示;
  • 增加新的图形种类要很容易,代码非常内聚,不必到处修改代码。

为此我们增加 qshapes: QSerializer 全局变量,允许各种图形类型注册自己的创建方法(creator)进去。示意代码如下:

qshapes.register("rect", function(json) {
  return new QRect(json)
})

为了支持 QSerializer 类(代码参见 dom.js#L89),每个图形需要增加两个方法:

interface Shape {
  constructor(json: Object)
  toJSON(): Object
}

这样我们就可以调用 qshapes.create(json) 来创建一个图形实例。

有了这个能力,我们加载文档就水到渠成了,具体代码请参考 QPaintDoc 类的 _loadRemote(displayID) 方法(参见 dom.js#L690)。

完整来说,加载文档的场景分为这样三类:

  • _loadBlank,即加载新文档。在联网情况下,会在服务端创建一个新 drawing。在非联网情况下,会本地创建一个临时文档(displayID 以 t 开头)。
  • _loadTempDoc,即加载一个临时文档。即该文档从创建之初到目前,一直都处于离线编辑的状态。同样它也分两个情况,如果当前处于联网状态下,会在服务端创建一个新 drawing,并把当前的离线编辑的数据同步过去。如果在非联网的情况下,会加载离线编辑的数据,并可继续离线编辑。
  • _loadRemote,即加载一个远程文档。该文档在本地有可能编辑过,那么会先加载本地缓存的离线编辑的数据。如果当前处于联网状态,会异步加载远程文档,成功后本地离线编辑的内容会被放弃。

另外,加载文档结束后,QPaintDoc 会发出 onload 消息。这个消息当前会被 QPaintView 响应,用来刷新界面,代码如下:

class QPaintView {
  constructor() {
    ...
    let view = this
    this.doc.onload = function() {
      view.invalidateRect(null)
    }
    ...
  }
}

之所以会有 onload 消息,是因为向服务器的 ajax 请求,什么时候完成是比较难预期的,我们加载文档是在异步 ajax 完成之后。这样来看,完成文档加载后发出 onload 事件,就可以避免 Model 层需要去理解 View 层的业务逻辑。

Model 层的厚度

介绍到这里,我们基本上把本次迭代的主体内容介绍清楚了。其他有些小细节的变动,我们不再赘述。详细的代码变更请参阅:

下面我想聊的话题是关于 Model 层的厚度问题。我们在 “22 | 桌面程序的架构建议” 中提到:

从界面编程角度看,Model 层越厚越好。为什么这么说?因为这是和操作系统的界面程序框架最为无关的部分,是最容易测试的部分,也同时是跨平台最容易的部分。我们把逻辑更多向 Model 层倾斜,那么 Controller 层就简洁很多,这对跨平台开发将极其有利。

我们秉承的理念是 Model 层越厚越好。事实上在这次 “画图” 程序实战中,我们在一直坚持这一点。让我们来观测两组数据。

其一,不同版本(v26..v30)的 Model 层(dom.js)对比:

  • MVP 版本(v26 版)的 dom.js ,约 120 行。
  • 最新版本(v30 版)的 dom.js ,约 860 行。

Model 层的代码行翻了多少倍?7.x 倍。

其二,不同版本(v26..v30)的变更历史:

v27:https://github.com/qiniu/qpaint/compare/v26...v27

v28:https://github.com/qiniu/qpaint/compare/v27...v28

v29:https://github.com/qiniu/qpaint/compare/v28...v29

v30:https://github.com/qiniu/qpaint/compare/v29...v30

不知道你看出什么来了吗?

一个有趣的事实是,多个版本的迭代,基本上都是以变更 Model 层为多。v29 版本的变更看似比较例外,没有修改 dom.js。但是实际上 v29 整个变更都是 Model 层的变更,因为是增加了服务端的 Model(我们前面把它叫做 Multi-User Model)。

我们深刻思考这个问题的话,我们会有这样一个推论:

  • 如果我们不是让 Model 层代码以内聚的方式放在一起,而是让它自由的散落于各处,那么我们的代码变更质量会非常不受控。

为什么?Model 层总体来说是最容易测试的,因为它的环境依赖最小。如果这些代码被分散到 View、Controller 层中,代码的阅读难度、维护难度、测试的难度都会大幅增加。

通过几轮的功能迭代,我们对 Model 层的认知在不断的加深。我们总结一下它的职责,如下:

  • 业务逻辑,对外暴露业务接口。它也是 Model 的最本职的工作。
  • 实现 View 层委托的 onpaint 事件,完成绘制功能。
  • 实现 Controller 层的 hitTest 接口,用来实现 selection 支持。
  • 实现与服务端 Multi-User Model 层的通讯,View、Controllers 组件都不需要感知服务端。
  • 实现离线编辑 localStorage 的存取。

除了少量 View(onpaint)、Controllers(hitTest)的需求,大部分都是 Model 层的正常业务范畴。

这些职责已经很多,所以 Model 层自然会胖。

结语

今天我们完成了画图程序前后端 paintdom、paintweb 的对接。由于考虑支持离线编辑,对接工作有较大的复杂性,你如果不能理解,建议仔细对代码进行研读。当然后面我们还会掰开来细谈这个案例。

这是最新版本的源代码:

到这里我们的实战过程就先告一段落了。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。到现在为止,我们探讨的都是一个完整的桌面应用程序(可能是单机的,也可能是 B/S 结构的)的业务架构。

下一讲我们会谈谈辅助界面元素(自定义控件)的架构设计,它和应用程序的业务架构考虑的问题颇有不同。

话外:按照大纲,当前进度还只有 1/3 的内容。看起来我们最终会比原计划的 58 讲超出不少,可能要往 90 讲去了。关于这一点,我总体还是以说清楚事情为目标,在聊的过程会根据反馈作出适当的调整。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 辅助界面元素的架构设计

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

我们第二章 “桌面软件开发” 今天开始进入尾声。前面我们主要围绕一个完整的桌面应用程序,从单机到 B/S 结构,我们的系统架构应该如何考虑。并且,我们通过五讲的 “画图” 程序实战,来验证我们的架构设计思路。

这个实战有点复杂。对于编码量不多的初学者,理解起来还是有点复杂性的。为了减轻理解的难度,我们从原计划的上下两讲,扩大到了五讲。尽管如此,理解上的难度仍然还是有的,后面我们做总结时,会给出一个不基于 MVC 架构的实现代码。

今天我们不谈桌面应用的架构,而是来谈谈辅助界面元素的架构设计。

辅助界面元素非常常见,它其实就是通用控件,或者我们自定义的控件。例如在我们画图程序中使用了线型选择控件(menu.js#L105),如下:

<select id="lineWidth" onchange="onIntPropChanged('lineWidth')">
  <option value="1">1</option>
  <option value="3">3</option>
  <option value="5">5</option>
  <option value="7">7</option>
  <option value="9">9</option>
  <option value="11">11</option>
</select>

还有颜色选择控件(menu.js#L115),如下:

<select id="lineColor" onchange="onPropChanged('lineColor')">
  <option value="black">black</option>
  <option value="red">red</option>
  <option value="blue">blue</option>
  <option value="green">green</option>
  <option value="yellow">yellow</option>
  <option value="gray">gray</option>
</select>

<select id="fillColor" onchange="onPropChanged('fillColor')">
  <option value="white">white</option>
  <option value="null">transparent</option>
  <option value="black">black</option>
  <option value="red">red</option>
  <option value="blue">blue</option>
  <option value="green">green</option>
  <option value="yellow">yellow</option>
  <option value="gray">gray</option>
</select>

我们统一用通用的 select 控件实现了一个线型选择器、两个颜色选择器的实例。虽然这种方式实现的颜色选择器不够美观,但是它们的确可以正常工作。

不过,产品经理很快就提出反对意见,说我们需要更加用户友好的界面。赶紧换一个更加可视化的颜色选择器吧?比如像下图这样的:

辅助界面元素的框架

怎么做到?

我们不妨把上面基础版本的线型选择器、颜色选择器叫做 BaseLineWidthPicker、BaseColorPicker,我们总结它们在画图程序中的使用接口如下:

我们解释一下这个表格中的各项内容。

id 是控件的 id,通过它可以获取到辅助界面元素的顶层结点。

value 是界面元素的值,其实也就是辅助界面元素的 Model 层的数据。从 MVC 架构角度来说,Model 层的数据一般是一棵 DOM 树。但是对很多辅助界面元素来说,它的 DOM 树比较简单,只是一个数值。比如线型选择器是一个 number,颜色选择器是一个 Color 值。

palette 是颜色选择器的调色板,用来指示颜色选择器可以选择哪些颜色。

blur() 方法是主动让一个界面元素失去焦点。

onchange 事件是在该界面元素的值(value)通过用户界面交互进行改变时发送的事件。需要注意的是,这个事件只在用户交互时发送。直接调用 element.value = xxx 这样的方式来修改界面元素的值是不会触发 onchange 事件的。

为了便于修改辅助界面元素,我们计划引入统一的辅助界面元素的框架。

这个框架长什么样?

首先,每个界面元素使用的时候,统一以 &lt;div type="xxx"&gt;来表示。比如上面的一个线型选择器、两个颜色选择器的实例可以这样来表示:

<div type="BaseLineWidthPicker" id="lineWidth" onchange="onIntPropChanged('lineWidth')"></div>

<div type="BaseColorPicker" id="lineColor" onchange="onPropChanged('lineColor')" palette="black,red,blue,green,yellow,gray"></div>

<div type="BaseColorPicker" id="fillColor" onchange="onPropChanged('fillColor')" palette="white,null(transparent),black,red,blue,green,yellow,gray"></div>

那么它是怎么被替换成前面的界面元素的?

我们引入一个全局的 qcontrols: QControls 实例,所有我们定义的控件都向它注册(register)自己。注册的代码如下:

class QControls {
  constructor() {
    this.data = {}
  }
  register(type, control) {
    this.data[type] = control
  }
}

可以看出,注册的逻辑基本上没做什么,只是建立了类型(type)和控件的构建函数(control)的关联。有了这个关联表,我们就可以在适当的时候,把所有的 &lt;div type="xxx"&gt;的div 替换为实际的控件。替换过程如下:

class QControls {
  init() {
    let divs = document.getElementsByTagName("div")
    let n = divs.length
    for (let i = n-1; i >= 0; i--) {
      let div = divs[i]
      let type = div.getAttribute("type")
      if (type != null) {
        let control = this.data[type]
        if (control) {
          control(div)
        }
      }
    }
  }
}

这段代码逻辑很简单,遍历文档中所有的 div,如果带 type 属性,就去查这个 type 有没有注册过,注册过就用注册时指定的构建函数去构建控件实例。

完整的辅助界面元素框架代码如下:

具体构建控件的代码是怎么样的?源代码请参考这两个文件:

我们拿 BaseColorPicker 作为例子看下吧:

function BaseColorPicker(div) {
  let id = div.id
  let onchange = div.onchange
  let palette = div.getAttribute("palette")
  let colors = palette.split(",")
  let options = []
  for (let i in colors) {
    let color = colors[i]
    let n = color.length
    if (color.charAt(n-1) == ")") {
      let offset = color.indexOf("(")
      options.push(`<option value="` + color.substring(0, offset) + `">` + color.substring(offset+1, n-1) + `</option>`)
    } else {
      options.push(`<option value="` + color + `">` + color + `</option>`)
    }
  }
  div.outerHTML = `<select id="` + id + `">` + options.join("") + `</select>`
  let elem = document.getElementById(id)
  if (onchange) {
    elem.onchange = onchange
  }
}

qcontrols.register("BaseColorPicker", BaseColorPicker)

可以看到,构建函数的代码大体分为如下三步。

第一步,从占位的 div 元素中读入所有的输入参数。这里是 id, onchange, palette。

第二步,把占位的 div 元素替换为实际的界面。也就是 div.outerHTML = xxx 这段代码。

第三步,如果用户对 onchange 事件感兴趣,把 onchange 响应函数安装到实际界面的 onchange 事件中。

jQuery 颜色选择器

接下来我们就开始考虑替换颜色选择器的实现了。新版本的颜色选择器,我们不妨命名为 ColorPicker。这个新版本的使用姿势必须和 BaseColorPicker 一样,也就是:

从使用的角度来说,我们只需要把之前的 BaseColorPicker 换成 ColorPicker。如下:

<div type="BaseLineWidthPicker" id="lineWidth" onchange="onIntPropChanged('lineWidth')"></div>

<div type="ColorPicker" id="lineColor" onchange="onPropChanged('lineColor')" palette="black,red,blue,green,yellow,gray"></div>

<div type="ColorPicker" id="fillColor" onchange="onPropChanged('fillColor')" palette="white,null(transparent),black,red,blue,green,yellow,gray"></div>

那么实现方面呢?

我们决定基于 jQuery 社区的 spectrum 颜色选择器。

我们的画图程序的主体并没有引用任何现成的框架代码。jQuery 是第一个被引入的。

对待 jQuery,我们可以有两种态度。一种是认为 jQuery 设计非常优良,我们很喜欢,决定将其作为团队的编程用的基础框架。

在这种态度下,我们允许 jQuery 风格的代码蔓延得到处都是,典型表现就是满屏皆是 $ 符号。

当然这种选择的风险是不低的。有一天我们不想再基于 jQuery 开发了,这意味着大量的模块需要进行调整,尤其是那些活跃的项目。

另一种态度是,认为 jQuery 并不是我们的主体框架,只是因为我们有些模块用了社区的成果,比如 spectrum 颜色选择器,它是基于 jQuery 实现的。这意味着我们要用 spectrum,就需要引入 jQuery。

这种团队下,我们会尽可能限制 jQuery 的使用范围,尽量不要让它的代码蔓延,而只是限制在颜色选择器等少量场景中。

我们这一讲假设我们的态度是后者。我们有自己的基础开发框架(虽然我们其实基本上接近裸写 JavaScript 的状态),所以不会大面积使用 jQuery。

这样我们需要包装 jQuery 组件。代码如下(参阅 controls/ColorPicker.js):

function ColorPicker(div) {
  let id = div.id
  let onchange = div.onchange
  let palette = div.getAttribute("palette")
  let colors = palette.split(",")
  let value = colors[0]
  div.outerHTML = `<input type="button" id="` + id + `" value="` + value + `">`
  let elem = $("#" + id)
  elem.spectrum({
    showInitial: true,
    showInput: true,
    showButtons: true,
    preferredFormat: "hex6"
  })
  if (onchange) {
    elem.change(onchange)
  }
  Object.defineProperty(document.getElementById(id), "value", {
    get() {
      return value
    },
    set(x) {
      if (this.busy) {
        return
      }
      value = x
      this.busy = true
      elem.spectrum("set", value)
      this.busy = false
    }
  })
}

qcontrols.register("ColorPicker", ColorPicker)

这里大部分代码比较常规,只有 Object.defineProperty 这一段看起来比较古怪一些。这段代码是在改写 document.getElementById(id) 这个界面元素的 value 属性的读写(get/set)函数。

为什么需要改写?

因为我们希望感知到使用者对 value 的改写。正常我们可能认为接管 onchange 就可以了,但是实际上 element.value = xxx 这样的属性改写是不会触发 onchange 事件的。所以我们只能从改写 value 属性的 set 函数来做。

set 函数收到 value 被改写后,会调用 elem.spectrum("set", value) 来改变 spectrum 颜色控件的当前值。

但这里又有个细节问题:elem.spectrum("set", value) 内部又会调用 element.value = value 来修改 document.getElementById(id) 这个界面元素的 value 属性,这样就出现了死循环。怎么办?我们通过引入一个 busy 标志来解决:如果当前已经处于 value 属性的 set 函数,就直接返回。

辅助界面元素的架构设计

到目前为止,我们实现了三个符合我们定义的控件规范的辅助界面元素。如下:

观察这些辅助界面元素的代码,你会发现它们都没有基于 MVC 架构。

是因为辅助界面元素不适合用 MVC 架构来编写么?

当然不是。

更本质的原因是因为它们规模太小了。这些界面元素的特点是 DOM 都是一个 value,并不是一棵树,这样 Model 层就没什么代码了。同样的逻辑,View 层、Control 层代码量都过于短小,就没必要有那么清楚的模块划分。View 负责界面呈现,Control 负责事件响应,只是在心里有谱就好了。

但并不是所有辅助界面元素都这么简单。

举一个简单的例子。让我们给自己设定一个新目标:把我们前面实战的 “画图” 程序,改造成一个标准的辅助界面元素,这可行么?

答案当然是肯定的。

但是这意味着我们有一些假设需要修正。这些假设通常都和唯一性有关。

比如,全局有唯一的 View 对象实例 qview: QPaintView。如果我们是辅助界面元素,意味着我们可能在同一个界面出现多个实例。在多实例的情况下,View 对象显然就应该有多个。

再比如,我们画图程序的辅助界面元素(参见 accel/menu.js)都是单例,具体表现为这些界面元素的 id 都是固定的。

当然,辅助界面元素的改造方案有多种可能性。一种方案是将辅助界面元素也改造为多例,使得每个 QPaint 实例都有自己的辅助界面元素。

另一种方案是继续保持单例,这意味着多个 QPaint 实例会有一个当前实例的概念。辅助界面元素根据场景,可以是操作全部实例,也可以是操作当前实例。

我们选择继续保持单例。这意味着 qview: QPaintView 这个全局变量可以继续存在,但是和之前的含义有了很大不同。之前 qview 代表的是单例,现在 qview 代表的是当前实例。

有了当前实例当然就有切换。这样就需要增加焦点相关的事件响应。

在画图程序中,很多 Controller 都是 View 实例相关的。比如:PathCreator、ShapeSelector 等。在 View 存在多例的情况下,这些 Controller 之前的 registerController 动作就需要重新考虑。

为了支持多例,我们引入了 onViewAdded、onCurrentViewChanged 事件。当一个新的 View 实例被创建时,会发送 onViewAdded 事件。Controller 可以响应该事件去完成 registerController 动作。如下:

onViewAdded(function(view) {
  view.registerController("PathCreator", function() {
    return new QPathCreator(view, false)
  })
})

原先,当前图形样式是放在 View 中的,通过 qview.style 可以访问到。这会导致多个 View 实例的当前图形样式不一样,但是我们辅助界面元素又是单例的,这就非常让人混淆。最后我们决定把 qview.style 挪到全局,改名叫 defaultStyle(参阅 accel/menu.js#L42)。

做完这些改造,我们的画图程序就有了成为一个标准控件的基础。具体代码如下(参阅 PaintView.js):

function newPaintView(drawingID) {
  let view = new QPaintView(drawingID)
  fireViewAdded(view)
  return view
}

function initPaintView(drawingID) {
  let view = newPaintView(drawingID)
  setCurrentView(view)
}

function PaintView(div) {
  let id = div.id
  let width = div.getAttribute("width")
  let height = div.getAttribute("height")
  div.outerHTML = `<canvas id="` + id + `" width="` + width + `" height="` + height + `">你的浏览器不支持Canvas!</canvas>`
  initPaintView(id)
}

qcontrols.register("PaintView", PaintView)

有了这个 PaintView 控件,我们就可以到处引用它了。我们做了一个 PaintView 控件的 DEMO 程序,它效果看起来是这样的(代码参阅 PaintDemo.htm):

从这个截图看,细心的你可能会留意到,还有一个问题是没有被修改的,那就是 URL 地址。我们的 QPaintView 在 load 文档后会修改 URL,这作为应用程序并没有问题。但是如果是一个控件,整个界面有好多个 PaintView,URL 中应该显示哪个文档的 ID?

显然谁都不合适。如果非要显示,可能要在 PaintView 实例附近放一个辅助界面元素来显示它。

怎么修改?

这个问题暂且留给大家。

结语

今天探讨了辅助界面元素,或者叫控件的架构设计。从大的实现逻辑来说,它和应用程序不应该有本质的不同。但控件总是要考虑支持多实例,这会带来一些细节上的差异。

支持多实例听起来是一项简单的工作,但是从我的观察看,对很多工程师来说实际上并不简单。不少初级工程师写代码往往容易全局变量满天飞,模块之间相互传递信息不假思索地基于全局变量来完成。这些不良习惯会导致代码极难控件化。

当然我们不见得什么桌面应用程序都要考虑把它控件化。但是我们花一些精力去思考控件化的话,会有助于你对架构设计中的一些决策提供帮助。

当然更重要的,其实是让你有机会形成更好的架构设计规范。

这一讲我们作出的修改如下:

这是最新版本的源代码:

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们会谈谈架构设计的第二步:如何做好系统架构。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 架构:系统的概要设计

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

我们第二章 “桌面开发篇” 就快要结束了。今天我们把话题重新回到架构上。

基础架构与业务架构

桌面开发篇我们主要涉及的内容如下。

对于一位架构师而言,其架构工作的内容可以大体分为两块,一块是基础架构,一块是业务架构。

基础架构,简单来说就是做技术选型。选择要支持的操作系统、选择编程语言、选择技术框架、选择第三方库,这些都可以归结为基础架构方面的工作。

基础架构的能力,考验的是选择能力。背后靠的是技术前瞻性和判断力。这并不简单。大部分架构师往往更容易把关注点放到业务架构上,但实际上基础架构的影响面更广,选错产生的代价更高。

架构师之间的差距,更大的是体现在其对待基础架构的态度和能力构建上。真正牛的架构师,一定会无比重视团队的技术选型,无比重视基础平台的建设。阿里提倡的 “大中台、小前台”,本质上也是在提倡基础平台建设,以此不断降低业务开发的成本,提升企业的创新能力。

业务架构,简单来说就是业务系统的分解能力。基础架构其实也是对业务系统的分解,只不过分解出了与业务属性几乎无关的部分,形成领域无关的基础设施。而业务架构更多的是分解领域问题 。

一旦我们谈业务架构,就避不开领域问题的理解。所谓领域问题,谈的是这个领域的用户群面临的普遍需求。所以我们需要对用户的需求进行分析。

在第一章,我们已经聊了需求分析:

这是我们开始业务架构的第一步。没有需求分析,就没有业务架构。在业务架构过程中,需求分析至少应该花费三分之一以上的精力。

今天,我们聊一聊架构的第二步:系统的概要设计,简称系统设计。

系统设计,简单来说就是 “对系统进行分解” 的能力。这个阶段核心要干的事情,就是明确子系统的职责边界和接口协议,把整个系统的大框架搭起来。

那么怎么分解系统?

首先我们需要明确的是分解系统优劣的评判标准。也就是说,我们需要知道什么样的系统分解方式是好的,什么样的分解方式是糟糕的。

最朴素的评判依据,是这样两个核心的点:

  • 功能的使用界面(或者叫接口),应尽可能符合业务需求对它的自然预期;
  • 功能的实现要高内聚,功能与功能之间的耦合尽可能低。

在软件系统中有多个层次的组织单元:子系统、模块、类、方法/函数。子系统如何分解模块?模块如何分解到更具体的类或函数?每一层的分解方式,都遵循相同的套路。也就是分解系统的方法论。

接口要自然体现业务需求

我们先看功能的使用界面(或者叫接口)。

什么是使用界面?

对于函数,它的使用界面就是函数原型。

package packageName

func FuncName(
  arg1 ArgType1, ..., argN ArgTypeN
  ) (ret1 RetType1, ..., retM RetTypeM)

它包含三部分信息。

  • 函数名。严谨来说是包含该函数所在的名字空间的函数名全称,比如上例是 packageName.FuncName。
  • 输入参数列表。每个参数包含参数名和参数类型。
  • 输出结果列表。每个输出结果包含结果名和结果类型。当然,很多语言的函数是单返回值的,也就是输出结果只有一个。这种情况下输出结果没有名称,只有一个结果类型,也叫返回值类型。

对于类,它的使用界面是类的公开属性和方法。

package packageName

type ClassName struct {
  Prop1 PropType1
  ...
  PropK PropTypeK
}

func (receiver *ClassName) MethodName1(
   arg11 ArgType11, ..., arg1N1 ArgType1N1
  ) (ret11 RetType11, ..., ret1M1 RetType1M1)

...

func (receiver *ClassName) MethodNameL(
   argL1 ArgTypeL1, ..., argLNL ArgTypeLNL
  ) (retL1 RetTypeL1, ..., retLML RetTypeLML)

它包含以下内容。

  • 类型名。严谨来说是包含该类型所在的名字空间的类型名全称,比如上例是 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),它的使用界面是网络协议。前面我们在 “画图” 程序实战(四)这一讲中也有定义过画图服务端的网络协议。如下:

对于命令行程序(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):

它的功能对应我们 “26 | 实战(一):怎么设计一个“画图”程序?” 这一讲中的最小化的画图程序。这是当时给出的源代码(分支 v26):

可以看到,v01 版本所有代码,包括 HTML+JavaScript,总共也就 470 行左右。所以这是一个非常小的架构实战案例。如果我们进一步减少案例的代码规模的话,可能就不太需要架构思想了。

我们不妨对比一下两个版本的差异。

一个最基础的对比是代码规模。v26 版本我们分拆了多个文件:

这些文件加起来的代码量大概在 580 行,比 v01 版本多了 110 行。

这说明 MVC 架构的价值并不是给我们降低总代码行数。实际上,它关注的重点是如何让我们团队协同作战,让工作并行。

怎么让工作并行?这就要求我们实现功能的时候,做到功能自身代码要高内聚,功能间的依赖要低耦合。v26 版本我们把功能分拆为 6 个文件(除了总控 index.htm 不算),可以交给 6 个团队成员来做,平均每个人写 100 行左右的代码。

当然,对于总体代码量 500 行不到的一个程序来说,这多多少少显得有点小题大做。但我们在此之后演进迭代了多个版本,功能越来越复杂,分工的必要性也就越来越大。

除了代码规模外,对比 v01 和 v26 版本,我们不妨从这样一些点来看。

  • 功能的高内聚。某个功能代码被分散在多少地方。
  • 功能间的低耦合。当然 v01 版本所有代码都揉在了一起,我们不妨从如何做系统分解的视角来推演 v26 版本用 MVC 架构的意义。
  • 怎么减少全局变量,为控件化做好准备。

结语

在我们介绍完第二章 “桌面开发” 篇的所有内容后,今天我们介绍了架构的第二步:系统的概要设计。

在概要设计阶段,我们一般以子系统为维度来阐述系统各个角色之间的关系。对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。

这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不作进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。

为了降低风险,概要设计阶段也应该有代码产出。

这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。

代码即文档。代码是理解一致性更强的文档。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们会回顾和总结第二章的内容。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 桌面开发篇:回顾与总结

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到今天为止,我们第二章 “桌面开发篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。本章我们主要涉及的内容如下。

这一章的内容主要分为三类。

一类是基础平台,也就是上图中的浅绿色背景部分,谈的是 Native 桌面操作系统和浏览器的演变过程。

一类是业务架构,也就是上图中的浅棕色背景部分,谈得是如何开发一个桌面软件。

最后一类是实战,也就是上图浅黄色背景部分,我们以画图程序作为例子谈业务架构,并对需求进行了多次的迭代。

通过本章的内容,我们总结一下桌面开发的特点。

**首先从基础平台看。它的特点是:种类多、迭代快、知识有效期短。**让桌面开发工程师(大前端)痛苦的是,时不时就有各种新平台、新语言、新框架冒出来,让人应接不暇。

**其次从要开发的产品本身看。它的特点是:需求多、迭代快。**桌面开发(大前端)负责的是和活生生的个体打交道,我们的开发人员需要为了功能丰富,体验便捷做各种努力。

为了让产品有竞争力,很多团队的发布周期都是至少一个月迭代一个版本,有的甚至是一周发布一个版本。而Web 前端就更夸张了,一些公司甚至没有统一的发版概念,只要某个功能产品经理验收了,测试验收了,就可以发。

最后我们从对程序员的技能要求看。它的特点是门槛极低,但天花板又极高。

桌面开发(大前端)的代码量大,代码变更又很频繁,所以它对程序员的第一要求,不是质量,而是数量上的需求极大。为什么 GitHub 的语言排行榜总是 JavaScript 排名第一?这不是别的原因,是市场需求所致。

与之相对的,服务端开发则非常不同。服务端开发并不是一上来就有的,是互联网出现后产生的新分工。它并不负责用户交互,所以在需求提炼时可以做到极强的可预测性。因而服务端的第一挑战往往不是快速响应,而是性能和稳定性等质量需求。

桌面开发的客观需求量大,这决定了它的门槛要求必须极低。我在描述桌面开发的未来也提到过,桌面开发技术的演进方向,是 7-8 岁的儿童也可以开发生产级的应用。这是门槛低的极致状态。

但是为什么我又说桌面开发的天花板又极高呢?因为桌面开发的团队人数多、人员质量参差不齐、代码量大、迭代变更频繁,这意味着桌面软件工程项目的管理难度极高。所以桌面开发对架构师能力、软件工程的水平要求之高,要远高于服务端开发。

当然,从国内的现状来说,凡是堆人和加班可以解决的,最终都是用堆人和加班解决。架构师能力培养和软件工程能力提升?对大部分公司来说,他们的想法可能是:这太慢了,等不起。

桌面开发篇的内容回顾

这一章前面我们讲了些什么?为了让你对第二章内容有个宏观的了解,我画了一幅图,如下。

我们首先从单机软件开发讲起。我们开篇第一讲首先回顾了桌面开发关于交互方式的变更。从最早命令行程序,到 2D/3D GUI 图形界面程序,到智能交互程序的萌芽。

为什么我们从交互变更谈起?因为这是桌面系统迭代的根源。每一次桌面系统大的变更周期,都是由一场新的交互革命所驱动。

随后,我们介绍了今天仍然处于主流地位的图形界面操作系统提供的编程框架。尽管使用接口各不相同,但是今天主流桌面操作系统的框架本质大同小异,都是基于事件分派做输入,GDI 做界面呈现。

互联网的出现,衍生出了浏览器,它支持了一种新的应用形态:Web 应用。这意味着在操作系统之上,产生了一个新操作系统。Web 应用也在演变,从静态页,到以 Gmail 为代表的 AJAX 应用,到 PWA,到小程序。

PC 浏览器之争已经结束,但移动浏览器的竞争才刚开始。

怎么做一个桌面程序?标准的套路是 MVC 架构。无论是单机还是 Web 应用,它都是适用的,只是 Web 程序需要考虑客户端与服务端的分工,需要引入网络协议。

跨平台开发,是桌面程序开发绕不过去的问题。几年前也许不明显,这得益于 Android 和 iOS 的垄断。但是现在又回到了群雄逐鹿的时期。Native 手机操作系统、传统 Web、众多的小程序种类、国际市场的 PWA 等等,需要综合考虑进行取舍。

聊完单机软件和 Web 应用,我们也探讨了桌面开发的未来趋势。桌面开发技术的演进,目标是越来越低的门槛,它和儿童编程教育相向而行,有一天必然汇聚于一点上。

为了让你更好地理解桌面开发的架构逻辑,我们引入了一个长达 5 讲的实战案例。这个案例建议深度消化。

为什么实战是很重要的?

学架构,我个人强调的理念是 “做中学”。

首先还是要勤动手。然后配合本专栏去思考和梳理背后的道理,如此方能快速进步。

我们不能把架构课学成理论课。计算机科学本身是一门实践科学,架构经验更是一线实战经验的积累和总结。

通过这个实战案例,我们也探讨了辅助界面元素,也就是控件的架构。控件架构没有什么特别的地方,唯一需要注意的是支持多实例。用多实例去思考你的应用程序架构的合理性,会有助于你对架构设计中的一些决策提供帮助。

当然更重要的,其实是让你有机会形成更好的架构设计规范。

作为最后收官,我们聊了架构第二步:系统的概要设计,简称系统设计。我们这个阶段关注的是全局性的风险,怎么保证项目可以按时、按质、高度并行化地被执行。

系统架构打的是地基。

这个阶段需要选择操作系统、选择语言、选择主框架,选择项目所依赖的最核心的基础设施。这就是我说的有关于基础架构的工作。

这个阶段也需要分解业务系统。我们一般以子系统为维度来阐述系统各个角色之间的关系。对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。

这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不作进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。

为了降低风险,概要设计阶段也应该有代码产出。

这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。

代码即文档。代码是理解一致性更强的文档。

桌面开发篇的参考资料

桌面开发的知识迭代更新非常快,所以很难去列经典书籍。

这里我列一下我认为值得重点关注的技术:

  • JavaScript。毫无疑问,这是当前桌面开发的第一大语言,务必要精通。这方面我推荐程劭非(winter)的极客时间专栏“重学前端”。
  • 微信小程序。这方面资料比较少,我推荐高磊的极客时间视频课“9小时搞定微信小程序开发”。
  • React 和 Vue。这应该当前比较知名的两大前端框架,可以学习一下。前者可以看下王沛的“React实战进阶45讲”,后者可以看下唐金州的“Vue开发实战”。
  • Flutter 和 SwiftUI。这两个技术很新,其中 Flutter 已经有一些资料,比如陈航的“Flutter核心技术与实战”。SwiftUI 与 Swift 语言关联很紧,在张杰的“Swift核心技术与实战”中有所涉略。
  • PWA 和 WebAssembly。这方面图书还比较少,不妨看官方材料结合实战来学习。

当然,经典的 Android、iOS 方面的开发资料,也值得看看。这方面资料非常多,我就不再去提名了。

结语

今天我们对本章内容做了概要的回顾,并借此对整个桌面开发的骨架进行了一次梳理。

这一章我们开始聊业务架构。学业务架构最好的方式是:“做中学”。做是最重要的,然后要有做后的反思,去思考并完善自己的理论体系。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们开始进入第三章:服务端开发篇。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 服务端开发的宏观视角

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

今天开始,我们进入第三章,谈谈服务端开发。

服务端的发展史

服务端开发这个分工,出现的历史极短。短得让人难以想象。

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)。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 流量调度与负载均衡

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:

  • 负载均衡(Load Balance);
  • 数据库或其他形式的存储(DB/Storage)。

为什么会需要负载均衡(Load Balance)?今天我们就聊一下有关于流量调度与负载均衡的那些事情。

上一讲我们画了服务端程序的体系架构图,如下:

什么是 “流量调度”?我们首先要了解这样几个常见的服务端程序运行实例(进程)相关的概念:

  • 连接数;
  • IOPS;
  • 流量,入向流量和出向流量。

我们知道,一个基本的服务端程序的服务请求,通常是由一个请求包(Request)和一个应答包(Response)构成。这样一问一答就是一次完整的服务。

连接数,有时候也会被称为并发数,指的是同时在服务中的请求数。也就是那些已经发送请求(Request),但是还没有收完应答(Response)的请求数量。

IOPS,指的是平均每秒完成的请求(一问一答)的数量。它可以用来判断服务端程序的做事效率。

流量分入向流量和出向流量。入向流量可以这么估算:

  • 平均每秒收到的请求包(Request)数量 * 请求包平均大小。

同样的,出向流量可以这么估算:

  • 平均每秒返回的应答包(Response)数量 * 应答包平均大小。

不考虑存在无效的请求包,也就是存在有问无答的情况(但实际生产环境下肯定是有的)的话,那么平均每秒收到的请求包(Request)数量、平均每秒返回的应答包(Response)数量就是 IOPS。故此:

  • 入向流量 ≈ IOPS * 请求包平均大小
  • 出向流量 ≈ IOPS * 应答包平均大小

所谓流量调度,就是把海量客户并发的请求包按特定策略分派到不同的服务端程序实例的过程。

有很多手段可以做流量调度。

DNS 流量调度

最基础的方式,是通过 DNS,如下图所示。

一个域名通过 DNS 解析到多个 IP,每个 IP 对应不同的服务端程序实例。这样就完成了流量调度。这里我们没有用到常规意义的负载均衡(Load Balance)软件,但是我们的确完成了流量调度。

那么这种做法有什么不足?

第一个问题,是升级不便。

要想升级 IP1 对应的服务端程序实例,必须先把 IP1 从 DNS 解析中去除,等 IP1 这个实例没有流量了,然后我们升级该实例,最后把 IP1 加回 DNS 解析中。

看起来还好,但是我们不要忘记,DNS 解析是有层层缓冲的。我们把 IP1 从 DNS 解析中去除,就算我们写明 TTL 是 15 分钟,但是过了一天可能都还稀稀拉拉有一些用户请求被发送到 IP1 这个实例。

所以通过调整 DNS 解析来实现升级,有极大的不确定性,完成一个实例的升级周期特别长。

假如一个实例升级需要 1 天,我们总共有 10 个实例,那么就需要 10 天。这太夸张了。

第二个问题,是流量调度不均衡。

DNS 服务器是有能力做一定的流量均衡的。比如第一次域名解析返回 IP1 优先,第二次域名解析让 IP2 优先,以此类推,它可以根据域名解析来均衡地返回 IP 列表。

但是域名解析均衡,并不代表真正的流量均衡。

一方面,不是每次用户请求都会对应一次 DNS 解析,客户端自己有缓存。另一方面,DNS 解析本身也有层层缓存,到 DNS 服务器的比例已经很少了。

所以在这样情况下,按域名解析做流量调度均衡,是非常粗糙的,实际结果并不可控。

那么,怎么让流量调度能够做到真正均衡?

网络层负载均衡

第一种做法,是在网络层(IP 层)做负载均衡。

章文嵩博士发起的负载均衡软件 LVS(Linux Virtual Server)就工作在这一层。我们以 LVS 为代表介绍一下工作原理。

LVS 支持三种调度模式。

  • VS/NAT:通过网络地址转换(NAT)技术做调度。请求和响应都会经过调度器中转,性能最差。
  • VS/TUN:把请求报文通过 IP 隧道转发至真实服务器,而真实服务器将响应直接返回给客户,所以调度器只处理请求报文。这种做法性能比 VS/NAT 好很多。
  • VS/DR:通过改写请求报文的MAC地址,将请求发送到真实服务器,真实服务器将响应直接返回给客户。这种做法相比 VS/TUN 少了 IP 隧道的开销,性能最好。

我们重点介绍下 VS/DR 技术。

如上图所示。设客户端的 IP 和 MAC 为 CIP、CMAC。

第 1 步,客户端发起请求,其 IP 报文中,源 IP 为用户的 CIP ,目标 IP 是 VIP;源 MAC 地址为 CMAC ,目标 MAC 地址为 DMAC。

第 2 步,请求包到达 LVS 调度器(Director Server)。我们保持源 IP 和目标 IP 不变,仅仅修改目标 MAC 地址为 RMAC,将请求转发到真实的业务服务器实例 RS(Real Server)。

第 3 步,RS 收到数据包并经过处理,直接响应发送给客户端。

这里面的关键技巧,是 VIP 绑定在多台机器上,所以我们把它叫做虚拟 IP(Virtual IP)。它既绑定在 LVS 调度器(Director Server)上,也绑定在所有的业务服务器实例 RS(Real Server)上。

当然这里有一个很重要的细节是,ARP 广播查询 VIP 对应的 MAC 地址得到什么?答案当然是 LVS 调度器(Director Server)。在真实的业务服务器实例 RS(Real Server)上,我们把 VIP 绑定在 lo 接口上,并对 ARP 请求作了抑制,这样就避免了 IP 冲突。

LVS 这种在网络层底层来做负载均衡,相比其他负载均衡技术来说,其特点是通用性强、性能优势高。

但它也有一些缺点。假如某个业务服务器实例 RS 挂掉,但 LVS 调度器(Director Server)还没有感知到,在这个短周期内转发到该实例的请求都会失败。这样的失败只能依赖客户端重试来解决。

应用层负载均衡

有办法避免出现这种请求失败的情况吗?

可以。答案是:服务端重试。

怎么做服务端重试?应用层负载均衡。有时候我们也把它叫做应用网关。

HTTP 协议是应用最为广泛的应用层协议。当前应用网关,绝大多数都是 HTTP 应用网关。

Nginx 和 Apache 都是大家最为耳熟能详的 HTTP 应用网关。因为知道应用层协议的细节,所以 HTTP 应用网关的能力通常非常强大。这一点我们后面还会进一步进行探讨,今天我们先聊负载均衡(Load Balance)相关的内容。

HTTP 网关收到一个 HTTP 请求(Request)后,根据一定调度算法把请求转发给后端真实的业务服务器实例 RS(Real Server),收到 RS 的应答(Response)后,再把它转发给客户端。

整个过程的逻辑非常简单,而且重试也非常好做。

在发现某个 RS 实例挂了后,HTTP 网关可以将同一个 HTTP 请求(Request)重新发给其他 RS 实例。

当然一个重要的细节是为了能够支持重试,HTTP 请求(Request)需要被保存起来。不保存 HTTP 请求做重试是有可能的,但是只能支持业务实例完全挂掉 HTTP 请求一个字节都没发过去的场景。但在断电或异常崩溃等情况,显然会有很多进行中的请求是不符合这个前提的,它们就没法做重试。

大部分 HTTP 请求不大,直接在内存中存储即可,保存代价不高。但是文件上传型的请求,由于请求包中包含文件内容,可能就需要依赖临时文件或其他手段来保存 HTTP 请求。

优雅升级

有了负载均衡,不只是可以实现了流量的均衡调度,连带业务服务器的升级也会方便多了。

对于前端是 LVS 这种网络层负载均衡的场景,升级的核心步骤为:

  • 升级系统通知 LVS 调度器(Director Server)下线要升级的业务服务器(Real Server)实例。
  • LVS 调度器(Director Server)将该实例从 RS 集合中去除,这样就不再调度新流量到它。
  • 升级系统通知要升级的 RS 实例退出。
  • 要升级的 RS 实例处理完所有处理中的请求,然后主动退出。
  • 升级系统更新 RS 实例到新版本,并重启。
  • 升级系统将 RS 实例重新加回 RS 集合参与调度。

对于前端是 HTTP 应用网关这种负载均衡的场景,升级的过程可以更加简单:

  • 升级系统通知升级的业务服务器(Real Server)实例退出。
  • 要升级的 RS 实例进入退出状态,这时新请求进来直接拒绝(返回一个特殊的 Status Code);处理完所有处理中的请求后,RS 实例主动退出。
  • 升级系统更新 RS 实例到新版本,并重启。

可以看出,因 HTTP 应用网关支持重试,业务服务器的升级过程就变得简单很多。

结语

今天我们从流量调度谈起,聊了几种典型的调度手段和负载均衡的方式。

从流量调度角度来说,负载均衡的最大价值是让多个业务服务器的压力均衡。这里面隐含的一个前提是负载均衡软件的抗压能力往往比业务服务器强很多(为什么?欢迎留言讨论)。

这表现在:其一,负载均衡的实例数/业务服务器的实例数往往大大小于1;其二,DNS 的调度不均衡,所以负载均衡的不同实例的压力不均衡,有的实例可能压力很大。

当然,负载均衡的价值并不只是做流量的均衡调度,它也让我们的业务服务器优雅升级成为可能。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊存储中间件。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 业务状态与存储中间件

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:

  • 负载均衡(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 小时不间断的服务。

这句话是理解服务端体系架构的核心,至关重要。但某种意义上来说更重要的原则是:

坚决不能丢失用户的数据,即他认为已经完成的业务状态。

存储即数据结构。存储中间件就是 “元数据结构”。

对于服务端来说,存储中间件至关重要。它不只是极大地解放了生产效率,也是服务端的性能瓶颈所在。几乎所有服务端程序扛不住压力,往往都是因为存储没有扛住压力。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊数据库。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 键值存储与数据库

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲我们介绍了存储中间件的由来。今天我们就聊一下应用最为广泛的存储中间件:数据库。

数据库的种类

从使用界面(接口)的角度来说,通常我们接触的数据库有以下这些。

使用最为广泛的,是关系型数据库(Relational Database),以 MySQL、Oracle、SQLSever 为代表。

这类数据库把数据每个条目(row)的数据分成多个项目(column),如果某个项目比较复杂,从数据结构角度来说是一个结构体,那么就搞一个新的表(table)来存储它,在主表只存储一个 ID 来引用。

这类数据库的特点是强 schema,每个项目(column)有明确的数据类型。从业务状态的角度看,可以把一个表(table)理解为一个结构体,当遇到结构体里面套结构体,那么就定义一个子表。

第二类是文档型数据库(Document Database),以 MongoDB 为代表。这类数据库把数据每个条目(row)称为文档(document),每个文档用 JSON或其他文档描述格式表示。

当前文档型数据库大部分是无 schema 的,也就是在插入文档时并不对文档的数据格式的有效性进行检查。

这有好有坏。好处是使用门槛低,升级数据格式方便。不好之处在于,质量保障体系弱化,数据可能被弄脏而不自知。可以预见的是,未来也会诞生强 schema 的文档型数据库。

第三类是键值存储(KV Storage),以 Cassandra 为代表。

键值存储从使用的角度来说,可以认为是数据库的特例。数据库往往是允许设定多个索引字段的,而键值存储明确只有唯一索引。

从实现角度来说,键值存储是数据库的基础。每一组数据库的索引,往往背后就是一组键值存储。

事务

无论是何种数据库,都面临一个重大选择:是否支持事务。这是一个艰难选择。从需求角度来说,事务功能非常强大,没道理不去支持。从实现角度来说,事务支持带来极大的负担,尤其是在分布式数据库的场景。

什么是事务?简单来说,事务就是把一系列数据库操作变成原子操作的能力。展开来说,事务的特性我们往往简称为 ACID,详细如下。

  • 原子性(Atomicity):在整个事务中的所有操作,要么全部完成,要么全部不做,没有中间状态。对于事务在执行中发生错误,所有的操作都会被回滚,整个事务就像从没被执行过一样。
  • 一致性(Consistency):事务的执行必须保证系统的一致性。这一点拿转账为例最容易理解。假设 A 有 500 元,B 有 300 元,如果在一个事务里 A 成功转给 B 50元,那么不管并行发生了其他什么事,A 账户一定得是 450 元,B 账户一定得是 350 元。
  • 隔离性(Isolation):事务与事务之间不会互相影响,一个事务的中间状态不会被其他事务感知。
  • 持久性(Durability):一旦事务完成了,那么事务对数据所做的变更就完全保存在了数据库中,即使发生停电,系统宕机也是如此。

如果我们忽略性能要求,事务是很好实现的,只需要用一把能够 Lock/Unlock 整个数据库的大锁就够了。

但这显然不现实,一把大锁下来,整个数据库就废了。从 IOPS(IO 吞吐能力)角度来说,为什么分布式数据库很讨厌事务是很容易理解的:如果没有事务,一次数据库操作很容易根据数据的分区特征快速将操作落到某个分区实例,剩下来的事情就纯粹是一个单机数据库的操作了。

一种常见的事务实现方式是乐观锁。

什么是乐观锁?

常规的锁是先互斥,再修改数据。不管是不是发生了冲突,我们都会先做互斥。

但乐观锁不同,它是先计算出所有修改的数据,然后最后一步统一提交修改。提交时会进行冲突检查,如果没有冲突,也就是说,在我之前没有人提交过新版本,或者虽然有人提交过新版本,但是修改的数据和我所依赖的数据并不相关,那么提交会成功。否则就是发生了冲突,会放弃本次修改。

这意味着,每个数据有可能有多个值。如下:

  • KEYi

-> [(VER0

, VAL0

), (VER1

, VAL1

), ...]

其中,VER0

对应当前已经提交的值 VAL0

,VER1

对应事务1

中修改后的值 VAL1

,以此类推。

除了修改后的值外,每个事务还需要记录自己读过哪些数据。不幸的是,它并不是记录读过的 KEY 列表那么简单,而是要记录所有的读条件。

例如,对于 SELECT name, age, address WHERE age&gt;17 这样一个查询,我们不是要记录读过哪些 name、age、address,而是认为我们读过所有 age&gt;17 的条目(row)。

在事务提交的时候,锁住整个数据库(前面修改过程事务间不冲突,所以不需要锁数据库),检查所有记录的读条件,如果这些读条件对应的条目(row)的已提交版本都&lt;=基版本(VER0

),那么说明不冲突,于是提交该事务所有的修改并释放锁。

如果事务提交的时候发现和其他已提交事务冲突,则放弃该事务,对所有修改进行回滚(其实是删除该事务产生的版本修改记录)。

到这里我们就可以理解为什么要用乐观锁了:至少它让锁数据库的粒度降到最低,判断冲突的逻辑也都是可预期的行为,这就避免了出现死锁的可能。

我们很容易可以推理得知,在所有并行执行的事务中,必然有一个事务的提交会成功。这样就避免了饥饿(永远都没人可以成功)。

主从结构

一旦我们考虑数据库的业务可用性和数据持久性,我们就需要考虑多副本存储数据。可用性(Availability)关注的是业务是否正常工作,而持久性(Durability)关注的是数据是否会被异常丢失。

当我们数据存在多个副本时,就有数据一致性的问题。因为不同副本的数据可能值不一样,我们到底应该听谁的。

我们的服务同时存在很多并发的请求,这就可能存在客户端 A 希望值是 VALa

,客户端 B 希望值是 VALb

的情况。

解决这个问题的方法之一是采用主从(Master-Slave)结构。主从结构采用的是一主多从模式,所有写操作都发往主(Master),所有从(Slave)都从主这边同步数据修改的操作。

这样,从(Slave)的数据版本只可能因为同步还没有完成,导致版本会比较旧,而不会出现比主(Master)还新的情况。

从(Slave)可以帮主(Master)分担一定的读压力。但是不是所有的读操作都可以被分担。大部分场景的读操作必须要读到最新的数据,否则就可能会出现逻辑错乱。只有那些纯粹用于界面呈现用途,而不是用于逻辑计算的场景,非敏感场景(比如财务场景是敏感场景)下能够接受读的旧版本数据,可以从从节点读。

从(Slave)最重要的是和主(Master)形成了互备关系。在主挂掉的时候,某个从节点可以替代成为新的主节点。这会发生一次选举行为,系统中超过一半的节点需要同意某个节点成为主,那么选举就会通过。

考虑选举的话,意味着集群的节点数为奇数比较好。比如,假设集群有 2 个节点,只有一主一从,那么在主挂掉后,因为只剩下一个节点参与选举,没有超过半数,选举不出新的主节点。

选择谁成为新的主是有讲究的,因为从的数据有可能不是最新的。一旦我们选择没有最新数据的从作为新的主节点,就意味着版本回退,也就意味着发生了数据丢失。

这是不能接受的事情。为了避免版本回退,写操作应该确保至少有一个从节点收到了最新的数据。这样在主挂掉后才可以确保能够选到一个拥有最新数据的节点成为新的主节点。

分布式

多副本让数据库的可用性和持久性有了保障,但是仍然有这样一些问题需要解决:

  • 数据规模大到一定程度后,单个物理节点存放不了那么大的数据量;
  • 主承受的读写压力太大,单台主节点承受不了这样高的 IOPS(吞吐能力)。

从目前存储技术的发展看,单台设备的存储量已经可以非常高,所以上面的第二种情况也会很常见。

怎么解决?

分布式。简单说,就是把数据分片存储到多台设备上的分片服务器一起构成一个单副本的数据库。分片的方式常见的有两种:

  • 哈希分片(Hash based sharding);
  • 范围分片(Range based sharding)。

无论哪个分片方式,都会面临因为扩容缩容导致的重新分片过程。重新分片意味着需要做数据的搬迁。

数据迁移阶段对数据访问的持续有不低的挑战,因为这时候对正在迁移的分片来说,有一部分数据在源节点,一部分数据在目标节点。

在分布式存储领域,有一个著名(CAP)理论。其中,C、A、P 分别代表一个我们要追求的目标。

  • 数据一致性(Consistency):如果系统对一个写操作返回成功,那么之后的读请求都必须读到这个新数据;如果返回失败,那么所有读操作都不能读到这个数据。
  • 服务可用性(Availability):所有读写请求在一定时间内得到响应,可终止、不会一直等待。
  • 分区容错性(Partition-tolerance):在网络分区的情况下,被分隔的节点仍能正常对外服务。

那么 CAP 理论说的是什么?简单说,就是 C、A、P 三个目标不能兼得,我们只能取其二。

假设我们不会放弃服务的可用性,那么我们决策一个分布式存储基本上在数据一致性(C)和分区容错性(P)之间权衡。

数据一致性(C)的选择基本上是业务特性决定的,业务要求是强一致,我们就不可能用最终一致性模型,相应的,我们只能在分区容错性(P)上去取舍。

结语

今天我们概要讨论了数据库相关的核心话题。我们第一关心的,当然还是使用界面(接口)。从使用界面角度,我们要考虑选择关系型数据库还是文档型数据库,以及是否需要事务特性。

确定了我们要使用什么样的数据库后,接着我们从实现角度,考虑主从结构和分布式方面的特性。

数据库是非常专业并且复杂的领域,限于篇幅我们这里不能展开太多,你如果有兴趣可以参考相关的资料。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊对象存储。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 文件系统与对象存储

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

存储系统从其与生俱来的使命来说,就难以摆脱复杂系统的魔咒。无论是从单机时代的文件系统,还是后来 C/S 或 B/S 结构下数据库这样的存储中间件兴起,还是如今炙手可热的云存储服务来说,存储都很复杂,而且是越来越复杂。

异常处理才是存储的业务逻辑

存储为什么会复杂,要从什么是存储谈起。

让我们简单回顾一下 “36 | 业务状态与存储中间件” 的核心逻辑。

存储这个词非常平凡,存储 + 计算(操作)就构成了一个朴素的计算机模型。简单来说,存储就是负责维持计算系统的状态的单元。从维持状态的角度,我们会有最朴素的可靠性要求。

比如单机时代的文件系统,机器断电、程序故障、系统重启等常规的异常,文件系统必须可以正确地应对,甚至对于磁盘扇区损坏,文件系统也需要考虑尽量将损失降到最低。

到了互联网时代,有了 C/S 或 B/S 结构,存储系统又有了新指标:可用性。为了保证服务质量,那些用户看不见的服务器程序必须时时保持在线,最好做到逻辑上是不宕机的(可用性100%)。

服务器程序怎么才能做到高可靠、高可用?

答案是存储中间件。没有存储中间件,意味着所有的业务程序,都必须考虑每做一步就对状态进行持久化,以便自己挂掉后另一台服务器(或者自己重启后),知道之前工作到哪里了,接下去应该做些什么。

但是对状态持久化工作(也就是存储)非常繁琐,如果每个业务都自己实现,负担无疑非常沉重。但如果有了高可用的存储中间件,服务器端的业务程序就只需操作存储中间件来更新状态,通过同时启动多份业务程序的实例做互备和负载均衡,很容易实现业务逻辑上不宕机。

对于大部分的业务程序而言,你只需要重点关注业务的正常分支流程就行,对于出乎意料的情况,通常只需抛出一个错误,告诉用户你不该这么玩。

但是,存储系统你需要花费绝大部分精力在各种异常情况的处理上,甚至你应该认为,这些庞杂的、多样的错误分支处理,才是存储系统的 “正常业务逻辑”。

所以,数据库这样的存储中间件出现基本上是历史必然。

从文件系统谈起

但尽管数据库很通用,它决不会是唯一的存储中间件。

比如,在服务端开发中,我们业务用到的多媒体(图片、音视频、Office文档等),我们很少会去存储到数据库中,更多的时候我们会把它们放在文件系统里。

但是单机时代诞生的文件系统,真的是最适合存储这些多媒体数据的吗?

不,文件系统需要改变,因为:

第一,伸缩性问题。单机文件系统的第一个问题是单机容量有限,在存储规模超过一台机器可管理的时候,应该怎么办的问题。

第二,性能瓶颈。单机文件系统通常在文件数目达到临界点后,性能快速下降。在 10TB 的大容量磁盘越来越普及的今天,这个临界点相当容易到达。

第三,可靠性,更严谨来说是持久性(Durability)问题。单机文件系统通常只是单副本的方案。但是,今天单副本的存储早已经无法满足业务的持久性要求。

数据需要有冗余(比较经典的做法是3副本),以便在磁盘损坏时及早修复丢失的数据,以避免所有的副本损坏造成数据丢失。

第四,可用性要求。单机文件系统通常只是单副本的方案,在该机器宕机后,数据就不可读取,也不可写入。

在分布式存储系统出现前,有一些基于单机文件系统的改良版本被一些应用采纳。比如在单机文件系统上加 RAID5 做数据冗余,来解决单机文件系统的可靠性问题。

假设 RAID5 的数据修复时间是 1 天(实际上往往做不到,尤其是业务系统本身压力比较大的情况下,留给 RAID 修复用的磁盘读写带宽很有限),这种方案单机的可靠性大概是 100 年丢失一次数据(即可靠性是2个9)。

看起来尚可?但是我们得考虑两个问题。

第一,你的集群规模会变大。如果你仍然沿用这个土方法,比如你现在有 100 台这样的机器,那么它就会变成 1 年就丢失一次数据。

第二,你采购的磁盘容量会变大。如果实际数据修复时间没那么理想,比如变成 3 天,那么单机的可靠性就直降至 4 年丢失一次数据。100 台这样的机器就会是 15 天就丢失一次数据。

这个数字显然无法让人接受。

所以服务端存储只要规模够大,就会使得很多看起来是小概率的事件,变成必然事件。

什么样的数据会有最大的存储规模?

答案是非结构化数据。这类数据的组织形式通常以用户体验友好为目标,而不是机器友好为目标。所以数据本身也自然不是以机器易于理解的结构化形式来组织。

图片、音视频、Office 文档等多媒体文件,就是比较典型的非结构化数据。互联网上 90% 以上传输的数据量都是非结构化数据。

移动互联网、人工智能与物联网技术的发展,进一步加快了非结构化数据的产生。从读图时代,到视频与实时互动,以及未来的 AR/VR 技术,人们正在一步步把物理世界映射到数字世界,通过数字世界实现更随时随地的、更自然的沟通体验,通过数字世界更好地理解和治理我们的物理世界。

Google GFS 是很多人阅读的第一份分布式存储的论文,这篇论文奠定了 3 副本在分布式存储系统里的地位。随后 Hadoop 参考此论文实现了开源版的 GFS —— HDFS。

但关于 Hadoop 的 HDFS 实际上业界有不少误区。GFS 的设计有很强的业务背景特征,本身是用来做搜索引擎的。HDFS 更适合做日志存储和日志分析(数据挖掘),而不是存储海量的富媒体文件。因为:

第一,HDFS 的 block 大小为 64M,如果文件不足 64M 也会占用 64M。而富媒体文件大部分仍然很小,比如图片常规尺寸在几百 K 左右。有人可能会说我可以调小 block 的尺寸来适应。但这是不正确的做法,HDFS 的架构为大文件而设计的,不可能简单通过调整 block 大小就可以满足海量小文件存储的需求。

第二,HDFS 是单 Master 结构,这决定了它能够存储的元数据条目数有限,伸缩性存在问题。当然作为大文件日志型存储(一般单个日志文件大小在 1GB 级别),这个瓶颈会非常晚才遇到;但是如果作为海量小文件的存储,这个瓶颈很快就会碰上。

第三,HDFS 仍然沿用文件系统的 API 形式,比如它有目录这样的概念。在分布式系统中维护文件系统的目录树结构,会遭遇诸多难题。所以 HDFS 想把 Master 扩展为分布式的元数据集群并不容易。

对象存储

非结构化数据的存储方式,最理想的绝对不是分布式文件系统。

文件系统只是桌面操作系统为了方便用户手工管理数据而设计的产物。服务端操作系统发展的初期,人们简单沿用了桌面操作系统的整套体系框架。

但从非结构化数据的存储开始,出现了分叉路口。对服务端体系架构来说,文件系统其实是一个过时的东西。

非结构化数据最佳的存储方式,还是键值存储(KV Storage)。用于存储非结构化数据的键值存储,有一个特殊的名字,叫对象存储(Object Storage)。它和结构化数据的键值存储,实现机制上往往有极大的差异。

对象存储的 Key,看起来也像一个文件系统的路径(Path),但仅仅是像而已。对于对象存储来说,Key 中出现的 “/” 字符,只是一个普通字符。

在对象存储中,并不存在目录(Directory)这样的概念。

既然对象存储是一个键值存储,就意味着我们可以通过对 Key 做 Hash,或者对 Key 按 Key Range 做分区,都能够让请求快速定位到特定某一台存储机器上,从而转化为单机问题。

这也是为什么在数据库之后,会冒出来那么多 NoSQL 数据库。因为数据库和文件系统一样,最早都是单机的,在伸缩性、性能瓶颈(在单机数据量太大时)、可靠性、可用性上遇到了相同的麻烦。

NoSQL 数据库的名字其实并不恰当,它们更多的不是去 SQL,而是去关系(我们知道数据库更完整的称呼是关系型数据库)。有关系意味着有多个索引,也就是有多个 Key,而这对数据库转为分布式存储系统来说非常不利。

七牛云存储的设计目标是针对海量小文件的存储,所以它对文件系统的第一个改变也是去关系,也就是去目录结构(有目录意味着有父子关系)。

所以七牛云存储不是文件系统(File System),而是对象存储(Object Storage)。蛮多七牛云的新手会问,为什么我在七牛的 API 中找不到创建目录这样的 API,根本原因还是受文件系统这个经典存储系统的影响。

第一个大家公认的对象存储是 AWS S3,你可以把它理解为一个非常简单的非结构化数据存储,它最基本的访问接口如下:

func PutObject(bucket, key string, object io.Reader) (err error)
func GetObject(bucket, key string) (object io.ReadCloser, err error)

七牛云存储并不仅仅是简单的分布式存储,它需要额外考虑以下这些问题。

第一,网络问题,也就是文件的上传下载问题。

文件上传方面,我们得考虑在相对比较差的网络条件下(比如 2G 网络),如何确保文件能够上传成功,大文件(七牛云存储的单文件大小理论极限是几个TB)如何能够上传成功,如何能够更快上传。

文件下载加速方面,考虑到 CDN 已经发展了 10 多年的历史,非常成熟,我们决定基于 CDN 技术来做下载加速。

第二,多媒体处理。当用户文件托管到了七牛,那么针对文件内容的数据处理需求也会自然衍生。比如我们第一个客户就给我们提了图片缩略图相关的需求。在音视频内容越来越多的时候,自然就有了音视频转码的需求。

所以从用户使用的角度来看,七牛云存储是这样的:

七牛云存储 = 对象存储 + 上传下载加速 + 多媒体处理

存储成本与持久性

既然对象存储的存储规模最大,占据了 90% 以上存储需求,那么毫无疑问,它最关心的就是单位存储成本问题。通常我们用每 GB 每月花费多少钱来表示单位存储成本。

前面我们说了,GFS 这个经典的分布式文件系统,采用的是 3 副本的方式。这样做的好处是可靠,不容易发生数据丢失。

但是它的问题也很明显,就是存储成本非常高。如果我们排除不同公司的采购能力差异外,存储成本最大的关联因素是以下两个东西。

其一是存储密度。存储密度越高,单台机器的存储量越大,单位成本越低。存储密度取决于:单台机器能够插的硬盘数量、单块磁盘的容量。

其二是冗余度。GFS 采用的是 3 副本,也就是冗余度为 3。当前降低冗余度,通常采用的是纠删码(EC)这样的算术冗余方案。

比如,假设 EC 采用的是 28 + 4,也就是把文件切分为 28 份,然后再根据这 28 份数据计算出 4 份冗余数据,最后把这 32 份数据存储在 32 台不同的机器上。

这样做的好处是既便宜,又提升了持久性和可用性。从成本角度,同样是要存储 1PB 的数据,要买的存储服务器只需 3 副本存储的 38%(32/28=1.14,1.14/3=38%),经济效益相当好。

从持久性方面,以前 3 副本只能允许同时损坏 2 块盘,现在能够允许同时损坏 4 块盘,直观来说这大大改善了持久性。

从可用性角度,以前能够接受 2 台服务器下线,现在能够同时允许 4 台服务器下线。

通过上面的分析可以看出,冗余度降低不一定会伤害集群的持久性和可用性,它们和冗余度不是正相关,而和集群的容错能力相关。

但是存储密度对系统的可用性和可靠性都会有一定的伤害。我们定性分析一下这里面的关系是什么样的。

我们重点考虑存储的核心指标:持久性(Durability)。它取决于以下两个关键指标。

一是单位修复时长,也就是一块磁盘损毁后,需要多久修复回来,假设这个修复时长为 T0

二是容错能力,也就是集群允许同时有几块硬盘损坏,假设我们采用的纠删码是 N + M 方案,那么我们的容错能力是可以接受同时损坏 M 块硬盘。

我们定性来看,T0

时间内同时坏 M 块盘的概率,就是我们丢失数据的概率。因此,持久性(Durability)和 T0

、M 这两个参数相关。

存储密度对持久性的影响是什么?

假设集群总的容量规模不变,我们把单台机器的磁盘数量增加一倍,那么我们需要的机器数量减少一半。但由于集群的磁盘数量不变,我们的修复时长 T0

也不变(假设网络和 CPU 计算力都不是瓶颈)。假设原本丢失数据的概率是 p,那么现在丢失数据的概率还是 p。

也就是说,在保证有足够的网络和计算力前提下,增加单台机器的磁盘数量,可能会降低可用性,但是对持久性几乎不会造成影响。

假设集群总的容量规模不变,但我们不是增加单台机器的磁盘数量,而是增加磁盘的密度。比如,我们把单盘容量增加一倍,那么我们集群的磁盘数也减少一半。这样我们的修复时长 T0

会变成 4T0

(修复时间和要修复的数据量成正比,和集群可用的磁盘数成反比)。

从这个角度看,提高磁盘密度对持久性的伤害还是比较大的。但是如果我们假设单块磁盘的坏盘概率和磁盘容量无关的话,由于磁盘数量减少了一半,这对集群整体的坏盘率又是一个正向的影响。

综合来说,假设原本丢失数据的概率是 p,那么现在丢失数据的概率是:

1 - [(1-p)^0.5]^4 ≈ 2p

即约等于 2p。

我们再来看一下集群的容量规模对持久性的影响。

假如我们将集群扩容一倍。那么我们的修复速度会快一倍,修复时长 T0

会变成 0.5T0

,这是一个很正面的影响。但是由于磁盘数量增加了一倍,所以坏盘概率会增加,这又是一个负面的影响。

综合来说,一正一反两相抵消,集群规模对集群整体的持久性大体可以忽略。不过这只是一种非常粗略的估计方法。更严谨的演算表明,集群规模增加整体上对集群的持久性(Durability)是正向的影响。

也就是说,集群规模越大,存储的可靠性越高。当然,这一点的前提是前面我们的假设,修复速度和集群规模成正比成立。

结语

今天我们讨论了对象存储相关的核心话题。我们从文件系统谈起,介绍了非结构化数据的存储系统的演进历史。

对象存储的出现,是服务端体系架构和桌面操作系统分道扬镳的开始。后续两者的演进方向变得越来越大不相同。

由于承载了最大体量的数据规模,对象存储对单位存储成本极其敏感。我们定性探讨了成本与持久性(Durability)之间的平衡关系。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊内存缓存。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 存储与缓存

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

前面接连三讲我们介绍了存储中间件的由来,以及最为常见的存储中间件,如:键值存储(KV Storage)、数据库(Database)、对象存储(Object Storage)。

当然,它们并不是全部。常见的存储中间件还有很多,比如消息队列(MQ)、搜索引擎(Search Engine)等等。

限于篇幅,我们不能一一对它们进行分析。今天,我们聊一聊缓存(Cache)。

memcached

缓存(Cache)是什么?

简单说,缓存是存储(Storage)的加速器。加速的原理通常是这样几种方法:

最常见的是用更高速的硬件来加速。比如,用 SSD 缓存加速 SATA 存储,用内存缓存加速基于外存的存储。

还有一种常见的方法是用更短的路径。比如,假设某个计算 y = F(x) 非常复杂,中间涉及很多步骤,发生了一系列的存储访问请求,但是这个计算经常会被用到,那么我们就可以用一个 x => y 的内存缓存来加速。

可见,缓存的数据结构从实现上来讲只需要是一个键值存储。所以它的接口可以非常简单:

type Cache {
  ...
}

func (cache *Cache) Get(key []byte) (val []byte, err error)
func (cache *Cache) Set(key, val []byte) (err error)
func (cache *Cache) Delete(key []byte) (err error)

第一个被广泛应用的内存缓存是 memcached。通常,我们会使用多个 memcached 实例构成一个集群,通过 Hash 分片或者 Range 分片将缓存数据分布到这些实例上。

一个典型的 memcached 的使用方式如下:

func FastF(x TypeX) (y TypeY) {
  key := toBytes(x)
  hash := hashOf(key)
  i := hash % countOf(memcaches)
  val, err := memcaches[i].Get(key)
  if err != nil {
    y = F(x)
    val = toBytes(y)
    memcaches[i].Set(key, val)
  } else {
    y = fromBytes(val)
  }
  return
}

类似的缓存逻辑大家应该比较经常见到。

这个示例我们采用的是简单 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 主页为:

从业务角度,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。它的使用方式大体如下:

var groupF = groupcache.NewGroup("F", cacheBytes, groupcache.GetterFunc(func(ctx groupcache.Context, key string, dest groupcache.Sink) error {
  x := fromString(key)
  y := F(x)
  return dest.SetBytes(toBytes(y))
}))

func FastF(x TypeX) (y TypeY) {
  key := toString(x)
  var val []byte
  groupF.Get(ctx, key, groupcache.AllocatingByteSliceSink(&val))
  y = fromBytes(val)
  return
}

这当然也就意味着它也不需要引入 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。

缓存是一个存储系统在服务器性能上的补丁。这个补丁并不是那么完美。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。我们服务端开发相关的基础软件介绍得差不多了,下一讲我们将聊聊服务端开发的架构建议。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 服务端的业务架构建议

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:

  • 负载均衡(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 协议暴露给开发者。

目前来看,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 框架的使用样例:

为了简化,这个样例用的是一个 mock 的授权机制。这种 mock 授权非常适合用来做业务系统的单元测试。

这个样例我们采用由 restrpc 框架自动实现路由的方式。这样可以减少一些代码量,但是对路由 API 对应的实现方法的名字有要求,看起来不是那么美观。如果不喜欢可以采用手工路由方式。具体怎么做,后面我们的实战案例会有体现。

单元测试

另外,这个样例我们的单元测试采用了七牛开源的 httptest 框架。其 Github 主页为:

这个 httptest 框架,最核心的逻辑是如何在不用写业务 API 的 Client SDK 的情况下,能够保持业务友好的方式来写测试案例。

它不只可以做单元测试,也可以做集成测试。

你可以通过下面这个演讲稿来了解它的核心思想:

这个 httptest 框架是非常通用的,所以它没有内建任何公司特有的授权机制。在七牛,我们会基于更贴近七牛自身业务的 qiniutest 进行测试。qiniutest 工具只是在 httptest 基础上作了少量的扩展,其 Github 主页为:

你可以依葫芦画瓢,实现一个适合你们公司的授权机制下的 httptest 工具。

在本章的实战案例中,我们也会让大家看到如何基于 httptest 来进行业务的单元测试。

结语

我们总结一下今天的内容。

服务端业务架构,主要是怎么做一个多租户的 Model 层。Model 层本身最重要的是自然体现业务逻辑,它和具体的行业的领域问题相关,对此我们无法进一步展开。

但服务端程序还是有它很鲜明的特点。

今天我们重点讨论了服务端业务架构相关的通用问题。包括:网络协议、授权、RPC 框架、单元测试等等。

当然其实还有一个问题,就是选什么样的存储中间件。它和具体的业务特征更为相关,这一点在后面我们实战案例中再做探讨。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。我们服务端开发相关的内容就暂时告一段落,下一讲开始我们进入实战。结束实战后,我们会结合实战对服务端开发的架构做一个总结。然后我们进入服务端的另一半:如何做好服务的运维,甚至也会涉及少量的运营相关的话题。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(一):“画图”程序后端实战

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到今天为止,服务端开发的基本内容已经讲完了。我们花了比较长的篇幅来介绍服务端的基础软件,包括负载均衡和各类存储中间件。然后我们上一讲介绍了服务端在业务架构上的一些通用问题。

今天我们开始进入实战。

对比服务端和桌面的内容可以看出,服务端开发和桌面端开发各自有各自的复杂性。服务端开发,难在基础软件很多,对程序员和架构师的知识面和理解深度都有较高的要求。但从业务复杂性来说,服务端的业务逻辑相对简单。而桌面端开发则相反,它的难点在于用户交互逻辑复杂,代码量大,业务架构的复杂性高。

上一章的实战篇,蛮多人反馈有点难,这某种程度来说和我们课程内容设计的规划有关。上一章我们从架构角度来说,偏重于介绍概要设计,也就是系统架构。所以我们对实现细节并没有做过多的剖析,而是把重心放在模块之间的接口耦合上。这是希望你把关注点放在全局,而不是一上来就进入局部细节。但是由于缺乏完整流程的剖析,大家没法把整个过程串起来,理解上就会打折扣。

这一章我们在架构上会偏重于详细设计。这在实战篇也会有所体现。

在上一章,我们实现了一个 mock 版本的服务端,代码如下:

接下来我们一步步把它变成一个产品级的服务端程序。

RPC 框架

第一步,我们引入 RPC 框架。

为了方便你理解,在上一章的实战中,我们的 mock 服务端程序没有引入任何非标准库的内容。代码如下:

整个 Service 大约 280 行代码。

我们改为基于七牛云开源的 restrpc 框架来实现,代码如下:

这样,整个 Service 就大约只剩下 163 行代码,只有原先的 60% 不到。

到底少写了哪些代码?我们拿创建一个新图形来看下。原先我们这样写:

func (p *Service) PostShapes(w http.ResponseWriter, req *http.Request, args []string) {
	id := args[0]
	drawing, err := p.doc.Get(id)
	if err != nil {
		ReplyError(w, err)
		return
	}

	var aShape serviceShape
	err = json.NewDecoder(req.Body).Decode(&aShape)
	if err != nil {
		ReplyError(w, err)
		return
	}

	err = drawing.Add(aShape.Get())
	if err != nil {
		ReplyError(w, err)
		return
	}
	ReplyCode(w, 200)
}

现在这样写:

func (p *Service) PostShapes(aShape *serviceShape, env *restrpc.Env) (err error) {
	id := env.Args[0]
	drawing, err := p.doc.Get(id)
	if err != nil {
		return
	}
	return drawing.Add(aShape.Get())
}

这个例子返回包比较简单,没有 HTTP 包的正文。

我们再来看一个返回包比较复杂的例子,取图形的内容。原先我们这样写:

func (p *Service) GetShape(w http.ResponseWriter, req *http.Request, args []string) {
	id := args[0]
	drawing, err := p.doc.Get(id)
	if err != nil {
		ReplyError(w, err)
		return
	}

	shapeID := args[1]
	shape, err := drawing.Get(shapeID)
	if err != nil {
		ReplyError(w, err)
		return
	}
	Reply(w, 200, shape)
}

现在这样写:

func (p *Service) GetShape(env *restrpc.Env) (shape Shape, err error) {
	id := env.Args[0]
	drawing, err := p.doc.Get(id)
	if err != nil {
		return
	}

	shapeID := env.Args[1]
	return drawing.Get(shapeID)
}

对比这两个例子,我们可以看出:

  • 原先这两个请求 POST /drawings/&lt;DrawingID&gt;/shapesGET /drawings/&lt;DrawingID&gt;/shapes/&lt;ShapeID&gt; 中的 URL 参数如 DrawingID、ShapeID 的值,是通过参数 args[0]、args[1] 传入,现在通过 env.Args[0]、env.Args[1] 传入。
  • 原先我们 PostShapes 需要自己定义 Shape 实例并解析 HTTP 请求包 req.Body 的内容。现在我们只需要在参数中指定 Shape 类型,restrpc 框架就自动完成参数的解析。
  • 原先我们 GetShape 需要自己回复错误或者返回正常的 HTTP 协议包。现在我们只需要在返回值列表中返回要回复的数据,restrpc 框架自动完成返回值的序列化并回复 HTTP 请求。

通过对比两个版本的代码差异,我们大体能够猜得出来,restrpc 的 HTTP 处理函数背后都干了些啥。其核心代码如下:

值得关注的是 Env 的支持,RPC 框架并没有限定 Env 类具体是什么样子的,只是规定它需要满足以下接口:

type itfEnv interface {
	OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error
	CloseEnv()
}

在 OpenEnv 方法中,我们一般进行 Env 的初始化工作。CloseEnv 方法则反之。为什么 OpenEnv 方法中,ResponseWriter 接口是以指针方式传入?因为可能会有客户希望改写 ResponseWriter 的实现。

比如,假设我们要给 RPC 框架扩展 API 审计日志的功能。那么我们就需要接管并记录用户返回的 HTTP 包,这时我们就需要改写 ResponseWriter 以达到接管并记录的目的。

另外值得注意的是,restrpc 版本的 HTTP 请求的处理函数,看起来不再那么像 HTTP 处理函数,倒像一个普通函数。

这意味着我们可以有两种方式来测试 Service 类。除了用正常测试 HTTP Service 的方法来测试它以外,我们也可以把 Service 类当成普通类来测试,这大大降低单元测试的成本。因为我们不用再需要包装服务的 Client SDK,然后再基于 Client SDK 做单元测试。

当然,我们有这样的一种低成本测试方式,但还是会担心这种测试方法可能不能覆盖一些编码上的小意外,毕竟我们没有走 HTTP 协议,心里多多少少有些不踏实。

理解了 restrpc 的 HTTP 处理函数,剩下的就是 restrpc 的路由功能。它是由 restrpc.Router 类的 Register 函数完成的。代码如下:

它支持两种路由方式,一种是根据方法名字自动路由。比如 POST /drawings/&lt;DrawingID&gt;/shapes 这样的请求,要求方法名为 “PostDrawings_Shapes”。GET /drawings/&lt;DrawingID&gt;/shapes/&lt;ShapeID&gt; 这样的请求,要求方法名为 “GetDrawings_Shapes_”。

规则倒是比较简单,路径中的 “/” 由单词首字母大写来分隔,URL 参数如 DrawingID、ShapeID 这些则替换为 “_”。

当然有的人会认为这种方法名字看起来很丑。那么就可以选择手工路由的方式,传入 routeTable。它看起来是这样的:

var routeTable = [][2]string{
	{"POST /drawings", "PostDrawings"},
	{"GET /drawings/*", "GetDrawing"},
	{"DELETE /drawings/*", "DeleteDrawing"},
	{"POST /drawings/*/sync", "PostDrawingSync"},
	{"POST /drawings/*/shapes", "PostShapes"},
	{"GET /drawings/*/shapes/*", "GetShape"},
	{"POST /drawings/*/shapes/*", "PostShape"},
	{"DELETE /drawings/*/shapes/*", "DeleteShape"},
}

虽然是手工路由,但是方法名仍然有限制,要求必须是 Get、Put、Post、Delete 开头。

业务逻辑的分层

理解了 restrpc 框架,我们再看下 QPaint 服务端的业务本身。可以看出,我们的服务端业务逻辑被分为两层:一层是业务逻辑的实现层,通常我们有意识地把它组织为一颗 DOM 树。代码如下:

另一层则是 RESTful API 层,它负责接收用户的网络请求,并转为对底层 DOM 树的方法调用。有了上面我们介绍的 restrpc 框架,这一层的每个方法往往都比较简单,甚至有的只是很简单的一句函数调用。比如:

func (p *Service) DeleteDrawing(env *restrpc.Env) (err error) {
	id := env.Args[0]
	return p.doc.Delete(id)
}

完整的RESTful API 层代码如下:

这样分层的原因,是因为我们实现核心业务逻辑的时候,并不会假设一定通过 RESTful API 暴露。我们考虑这样几种可能性:

其一,有可能我们根本不需要网络调用。

做个类比,我们都知道 mysql 是通过 TCP 协议提供服务接口的,而 sqlite 是嵌入式数据库,是通过本地的函数调用提供服务接口的。这里分层就类似于我实现 mysql 的时候,先在底层实现了一个类似 sqlite 的嵌入式数据库,然后再提供基于 TCP 协议的网络接口。

其二,有可能我们需要支持很多种网络协议。

我们今天流行 RESTful API,所以我们的接口是 RESTful 风格的。如果有一天我们像 Github 一样想改用 GraphQL,那么至少底层的业务逻辑实现层是不需要改变的,我们只需要实现相对薄的 GraphQL 层就行了。

而且,往往在这种情况下 RESTful API 和 GraphQL 是需要同时支持的。毕竟我们不可能为了赶时髦,就把老用户弃之不顾了。

在需要同时支持多套网络接口的时候,这种分层的价值就体现出来了,不同网络接口的模块之间,共享了同一份 DOM 树的实例,整个体系不仅实现了多协议并存,还实现了完美的解耦,彼此之间完全独立。

单元测试

聊完了业务,我们再来看看单元测试。

之前,我们单元测试基本上没怎么做:

代码如下:

type idRet struct {
	ID string `json:"id"`
}

func TestNewDrawing(t *testing.T) {
        ...
	var ret idRet
	err := Post(&ret, ts.URL + "/drawings", "")
	if err != nil {
		t.Fatal("Post /drawings failed:", err)
	}
	if ret.ID != "10001" {
		t.Log("new drawing id:", ret.ID)
	}
}

从这里的测试代码可以看出,我们就只是创建了一个 drawing,并且要求返回的 drawingID 为 "10001"。

从单元测试的角度,这样的测试力度当然是非常不足的。同样的测试案例,用我们上一讲介绍的 httptest 测试框架实现如下:

func TestNewDrawing(t *testing.T) {
    ...
    ctx := httptest.New(t)
    ctx.Exec(
    `
	post http://qpaint.com/drawings
	ret 200
	json '{"id": "10001"}'
    `)
}

当然,实际我们应该去测试更多的情况,比如:

func TestService(t *testing.T) {
        ...
	ctx := httptest.New(t)
	ctx.Exec(
	`
	post http://qpaint.com/drawings
	ret 200
	json '{
		"id": $(id1)
	}'
	match $(line1) '{
		"id": "1",
		"line": {
			"pt1": {"x": 2.0, "y": 3.0},
			"pt2": {"x": 15.0, "y": 30.0},
			"style": {
				"lineWidth": 3,
				"lineColor": "red"
			}
		}
	}'
	post http://qpaint.com/drawings/$(id1)/shapes
	json $(line1)
	ret 200
	get http://qpaint.com/drawings/$(id1)/shapes/1
	ret 200
	json $(line1)
	`)
	if !ctx.GetVar("id1").Equal("10001") {
		t.Fatal(`$(id1) != "10001"`)
	}
}

这个案例我们想演示什么?这是一个相对复杂的案例。首先我们创建了一个 drawing,并且将 drawingID 放到变量 $(id1) 中。随后,我们向该 drawing 中添加了一条直线 $(line1)。为了确认添加成功,我们取出了该图形对象,并且判断取得的图形和添加进去的 $(line1) 是否一致。

另外,它也演示了 qiniutest DSL 脚本和 Go 语言代码的互操作性。我们用 Go 代码取得变量 $(id1),并且判断它是否和 "10001" 相等。

关于 qiniutest 更多的内容,请查阅以下资料:

在我们的测试代码中,还使用了一个七牛云开源的 mockhttp 组件,它也非常有意思:

这个 mockhttp 并不真去监听端口,感兴趣的同学可以研究一下。

结语

我们总结一下今天的内容。

从今天开始我们会一步步将之前写的 mock 服务端改造为真实的服务端程序。

我们第一步改造的是 RPC 框架和单元测试。这样我们第一次开始依赖第三方的代码库,如下:

一旦有了外部依赖,我们就需要考虑依赖库的版本管理。好的一点是大多数现代语言都有很好的版本管理规范,对于 Go 语言我们用 go mod 来做版本管理。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲开始我们继续实战。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(二):“画图”程序后端实战

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在上一章,我们实现了一个 mock 版本的服务端,代码如下:

接下来我们将一步步迭代,把它变成一个产品级的服务端程序。

我们之前已经提到,服务端程序的业务逻辑被分为两层:底层是业务逻辑的实现层,通常我们有意识地把它组织为一颗 DOM 树。上层则是 RESTful API 层,它负责接收用户的网络请求,并转为对底层 DOM 树的方法调用。

上一讲我们关注的是 RESTful API 层。我们为了实现它,引入了 RPC 框架 restrpc 和单元测试框架 qiniutest

这一讲我们关注的是底层的业务逻辑实现层。

使用界面(接口)

我们先看下这一层的使用界面(接口)。从 DOM 树的角度来说,在这一讲之前,它的逻辑结构如下:

<Drawing1>
  <Shape11>
  ...
  <Shape1M>
...
<DrawingN>

从大的层次结构来说只有三层:

  • Document => Drawing => Shape

那么,在引入多租户(即多用户,每个用户有自己的 uid)之后的 DOM 树,会发生什么样的变化?

比如我们是否应该把它变成四层:

  • Document => User => Drawing => Shape
<User1>
  <Drawing11>
    <Shape111>
    ...
    <Shape11M>
  ...
  <Drawing1N>
...
<UserK>

我的答案是:多租户不应该影响 DOM 树的结构。所以正确的设计应该是:

<Drawing1>, 隶属于某个<uid>
  <Shape11>
  ...
  <Shape1M>
  ...
<DrawingN>, 隶属于某个<uid>

也就是说,多租户只会导致 DOM 树多了一些额外的约定,通常我们应该把它看作某种程度的安全约定,避免访问到没有权限访问到的资源。

所以多租户不会导致 DOM 层级变化,但是它会导致接口方法的变化。比如我们看 Document 类的方法。之前,Document 类接口看起来是这样的:

func (p *Document) Add() (drawing *Drawing, err error)
func (p *Document) Get(dgid string) (drawing *Drawing, err error)
func (p *Document) Delete(dgid string) (err error)

现在它变成了:

// Add 创建新drawing。
func (p *Document) Add(uid UserID) (drawing *Drawing, err error)

// Get 获取drawing。
// 我们会检查要获取的drawing是否为该uid所拥有,如果不属于则获取会失败。
func (p *Document) Get(uid UserID, dgid string) (drawing *Drawing, err error)

// Delete 删除drawing。
// 我们会检查要删除的drawing是否为该uid所拥有,如果不属于删除会失败。
func (p *Document) Delete(uid UserID, dgid string) (err error)

正如注释中说的那样,传入 uid 是一种约束,我们无论是获取还是删除 drawing ,都会看这个 drawing 是不是隶属于该用户。

对于 QPaint 程序来说,Document 类之外其他类的接口倒是没有发生变化。比如 Drawing 类的接口如下:

func (p *Drawing) GetID() string
func (p *Drawing) Add(shape Shape) (err error)
func (p *Drawing) List() (shapes []Shape, err error)
func (p *Drawing) Get(id ShapeID) (shape Shape, err error)
func (p *Drawing) Set(id ShapeID, shape Shape) (err error)
func (p *Drawing) SetZorder(id ShapeID, zorder string) (err error)
func (p *Drawing) Delete(id ShapeID) (err error)
func (p *Drawing) Sync(shapes []ShapeID, changes []Shape) (err error)

但是这只是因为 QPaint 程序的业务逻辑比较简单。虽然我们需要极力避免接口因为多租户而产生变化,但是这种影响有时候却是不可避免的。

另外,在描述类的使用界面时,我们不能只描述语言层面的约定。比如上面的 Drawing 类,我们引用图形(Shape)对象时,用的是 Go 语言的 interface。如下:

type ShapeID = string

type Shape interface {
    GetID() ShapeID
}

但是,是不是这一接口就是图形(Shape)的全部约束?

答案显然不是。

我们先看一个最基本的约束:考虑到 Drawing 类的 List 和 Get 返回的 Shape 实例,会被直接作为 RESTful API 的结果返回。所以,Shape 已知的一大约束是,其 json.Marshal 结果必须符合 API 层的预期。

至于在“实战二”的代码实现下,我们对 Shape 完整的约束是什么样的,欢迎你留言讨论。

数据结构

明确了使用界面,下一步就要考虑实现相关的内容。可能大家都听过这样一个说法:

程序 = 数据结构 + 算法

它是一个很好的指导思想。所以当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。

我们先看数据结构。

对于服务端程序,数据结构不完全是我们自己能够做主的。在 “36 | 业务状态与存储中间件”这一讲中我们说过,存储即数据结构。所以,服务端程序在数据结构这一点上,最为重要的一件事是选择合适的存储中间件。然后我们再在该存储中间件之上组织我们的数据。

对于 QPaint 的服务端程序来说,我们选择了 mongodb。

为何是 mongodb,而不是某种关系型数据库?

最重要的理由,是因为图形(Shape)对象的开放性。因为图形的种类很多,它的 Schema 不是我们今天所能够提前预期的。故此,文档型数据库更为合适。

确定了基于 mongodb 这个存储中间件,我们下一步就是定义表结构。当然表(Table)是在关系型数据库中的说法,在 mongodb 中我们叫集合(Collection)。但是出于惯例,我们很多时候还是以 “定义表结构” 一词来表达我们想干什么。

我们定义了两个表(Collection):drawing 和 shape。其中,drawing 表记录所有的 drawing,而 shape 表记录所有的 shape。具体如下:

我们重点关注索引的设计。

在 drawing 表中,我们为 uid 建立了索引。这个比较容易理解:虽然目前我们没有提供 List 某个用户所有 drawing 的方法,但这是迟早的事情。

在 shape 表中,我们为 (dgid, spid) 建立了联合唯一索引。这是因为 spid 作为 ShapeID ,是 drawing 内部唯一的,而不是全局唯一的。所以,它需要联合 dgid 作为唯一索引。

算法

谈清楚了数据结构,我们接着聊算法。

在 “程序 = 数据结构 + 算法” 这个说法中,“算法” 指的是什么?

在架构过程中,需求分析阶段,我们关注用户需求的精确表述,我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。

到了详细设计阶段,角色和用户故事就变成了子系统、模块、类或者函数的使用界面(接口)。我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也有自然的延续性。

所以算法,最直白的含义,指的是用户故事背后的实现机制。

数据结构 + 算法,是为了满足最初的角色与用户故事定义,这是架构的详细设计阶段核心关注点。以下是一些典型的用户故事:

创建新drawing (uid):

dgid = newObjectId()
db.drawing.insert({_id: dgid, uid: uid, shapes:[]})
return dgid

取得drawing的内容 (uid, dgid):

doc = db.drawing.findOne({_id: dgid, uid: uid})
shapes = []
foreach spid in doc.shapes {
    o = db.shape.findOne({dgid: dgid, spid: spid})
    shapes.push(o.shape)
}
return shapes

删除drawing (uid, dgid):

if db.drawing.remove({_id: dgid, uid: uid}) { // 确保用户可删除该drawing
    db.shape.remove({dgid: dgid})
}

创建新shape (uid, dgid, shape):

if db.drawing.find({_id: dgid, uid: uid}) { // 确保用户可以操作该drawing
    db.shape.insert({dgid: dgid, spid: shape.id, shape: shape})
    db.drawing.update({$push: {shapes: shape.id}})
}

删除shape (uid, dgid, spid):

if db.drawing.find({_id: dgid, uid: uid}) { // 确保用户可以操作该drawing
    if db.drawing.update({$pull: {shapes: spid}}) {
        db.shape.remove({dgid: dgid, spid: spid})
    }
}

这些算法的表达整体是一种伪代码。但它也不完全是伪代码。如果大家用过 mongo 的 shell 的话,其实能够知道这里面的每一条 mongo 数据库操作的代码都是真实有效的。

另外,从严谨的角度来说,以上算法中凡是涉及到多次修改操作的,都应该以事务形式来做。比如删除 drawing 的代码:

if db.drawing.remove({_id: dgid, uid: uid}) { // 确保用户可删除该drawing
    db.shape.remove({dgid: dgid})
}

假如第一句 drawing 表的 remove 操作执行成功,但是在此时发生了故障停机事件导致 shape 表的 remove 没有完成,那么从用户的业务逻辑角度来说一切都正常,但是从系统维护的角度来说,系统残留了一些孤立的 shape 对象,永远都没有机会被清除。

网络协议

考虑到底层的业务逻辑实现层已经支持多租户,我们网络协议也需要做出相应的修改。这一讲我们只做最简单的调整,引入一个 mock 的授权机制。如下:

Authorization QPaintStub <uid>

既然有了 Authorization,那么我们就不能继续用 restrpc.Env 作为 RPC 请求的环境了。我们自己实现一个 Env,如下:

type Env struct {
    restrpc.Env
    UID UserID
}

func (p *Env) OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error {
    auth := req.Header.Get("Authorization")
    pos := strings.Index(auth, " ")
    if pos < 0 || auth[:pos] != "QPaintStub" {
        return errBadToken
    }
    uid, err := strconv.Atoi(auth[pos+1:])
    if err != nil {
        return errBadToken
    }
    p.UID = UserID(uid)
    return p.Env.OpenEnv(rcvr, w, req)
}

把所有的 restrpc.Env 替换为我们自己的 Env,再对代码进行一些微调(Document 类的调用增加 env.UID 参数),我们就完成了基本的多租户改造。

改造后完整的 RESTful API 层代码如下:

结语

总结一下今天的内容。

今天我们主要改造的是底层的业务逻辑实现层。

一方面,我们对使用界面(接口)作了多租户的改造。多租户改造从网络协议角度来说,主要是增加授权(Authorization)。从底层的 DOM 接口角度来说,主要是 Document 类增加 uid 参数。

另一方面,我们基于 mongodb 完成了新的实现。我们对数据结构和算法作了详细的描述。要更完整了解实现细节,请重点阅读以下两个文件:

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲开始我们继续实战。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(三):“画图”程序后端实战

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在上一章,我们实现了一个 mock 版本的服务端,代码如下:

我们这一章实战的目标,是要把它改造成一个产品级的服务端程序。

前面两讲,我们一讲谈了 RESTful API 层,一讲谈了底层是业务逻辑的实现层。今天,我们要谈的是帐号(Account)与认证(Authorization)。

我们之前实现的 mock 版本服务端是匿名可访问的,不需要授权。在上一讲,我们开始引入了多租户,但为了简化,用的是一种 mock 的认证方式。

接下来我们就要动真格了。

但在此之前,我们仍然要先理解一下帐号和认证这两个概念。虽然这是两个大家非常耳熟能详的东西。

帐号(Account)

帐号,简单说就是某种表征用户身份的实体,它代表了一个“用户”。虽然一个物理的自然人用户可能会在同一个网站开多个帐号,但从业务角度,我们往往把这些帐号看作不同的用户。

互联网帐号的表征方式有很多,比较常见的有:

  • 电子邮件;
  • 手机号;
  • 用户自定义的网络 ID;
  • 自动分配的唯一 ID。

前三者大家容易理解。对于自动分配的 UUID,其实最典型的是银行。你的银行帐号从来都不是你自己定义的,而是预先分配好的一个卡号。

当然还有一些冷门的选择。比如有的网站选择用身份证号作为帐号 ID,这通常发生在政府公共服务类的业务。

授权(Authorization)

那么授权是什么?授权是帐号对服务的访问方式。

从这句话字面去理解,授权和帐号相关。有帐号,就会有授权。但是帐号和授权并不是对应的关系。同一个帐号,可能会有多种授权。

常见的授权机制有哪些?

前面我们在 “40 | 服务端的业务架构建议” 这一讲提过,当前主流的授权方式有两种:一种是基于 Token,一种是基于 AK/SK。

但实际上还有一种最常见的授权机制没有被提到,那就是:用户名+密码。

这里的 “用户名” 其实就是指 “帐号”。

当然,没有提的原因是因为当时我们是在讨论网络 API 协议的授权机制选择。我们在业界基本上看不到用 “用户名+密码” 来作为网络 API 的授权机制。

为什么不用?因为不安全。假如在每一次 API 请求中都带上密码,那么显然密码泄漏的概率会更大。

所以,安全性上的需求会导致我们倾向于尽可能减少密码在网络中传输的次数。“用户名+密码” 这种授权方式,必然会以尽可能少的频率去使用。

哪些情况会用 “用户名+密码” 授权?

其一,登录(login)。对于一个 Web 应用而言,授权的第一步是登录。登录最经典的方式就是 “用户名+密码” 授权。

“用户名+密码” 授权往往只发生在登录那一下,登录后就会生成一个会话(Session)用途的 Cookie。此后 Web 应用的授权都基于 Session,直到 Session 过期。

抱歉,我们的词汇有点贫乏。这里说的 Session 授权,和浏览器引入的 Session 不是一回事。Session 授权发生在登录之后,一般并不会随浏览器窗口的关闭而消失,往往有几天的有效期。

甚至有一些网站的 Session 有效期会自动顺延。也就是说,只要你在会话期内活跃的话,Session 授权就不会过期。超时时间从你最后一次活动算起,只有你连续几天都不活跃才会导致 Session 过期。

其二,作为 Token 授权的入口。其实 RESTful API 层中的 Token 授权,和 Web 应用中的 Session 授权的地位是非常像的。

Session 授权会有过期时间,Token 授权也会有过期时间。Session 授权有自动顺延,Token 授权有 Refresh。Session 授权的典型入口是登录(login),Token 授权也一样有 “用户名+密码” 授权这个入口。

这样来看,Token 授权和 Session 授权的差别只是应用场景不同,一个用于 API 层,一个用于 Web。而这也导致承载它们的机制有些不同,Token 授权基于 HTTP 的 Authorization 头,而 Session 授权则基于 Cookie。

OAuth 2.0

由于 QPaint 程序是一个 To C 的应用,所以在 API 层的授权机制选择上,我们很自然会选择 Token 授权。

当前推荐的 Token 授权标准是 OAuth 2.0,它得到了广泛的支持,如果你在使用各类 C 端应用程序的开放接口,会发现它们往往都是基于 OAuth 2.0 的。

有两种场景下我们会考虑 OAuth 2.0。

第一种场景,也是 OAuth 的核心场景,就是提供开放接口。

对于一个服务提供方来说,通过推广自己的 App ,来让更多用户使用自己的服务是一个常规的办法。但还有一个非常值得考虑的方式,就是把服务以 API 方式开放出来,让更多的 App 接入自己的服务。

一旦我们希望授权第三方应用程序来调用我们的服务,最好的选择是 OAuth 2.0。

第二种场景,是作为 OpenID 提供方。也就是说,第三方应用接入我的 OAuth 接口,并不是为了要调用我的什么能力,而只是为了复用我的用户。

这当然不是谁都能够做得到的,还是要有足够大的用户基数,并且有一定的入口价值才有可能被接受。国内被广泛使用的典型 OpenID 提供方有:

  • 微信和 QQ;
  • 支付宝;
  • 新浪微博。

为了支持 OAuth 2.0 作为 OpenID 的场景,OpenID Foundation 还专门引入了 OpenID Connect 协议规范。详细资料如下:

今天我们重点还是关注 OAuth 2.0 的核心场景。它涉及到以下三个角色:

  • 服务提供商。包括授权服务(Authorization Server)和资源服务(Resource Server)。
  • 终端用户,也就是资源拥有方(Resource Owner)。终端用户是服务提供商的用户,它的资源也存在于服务提供商提供的服务中。但是这些资源的归属是属于终端用户的,所以我们称之为资源拥有方。
  • 第三方应用,也就是客户端(Client)。在 OAuth 的视角中,官方应用和第三方应用并无大的区别,以相同的机制在工作。从这一点来说,称之为客户端会更加合理。

这三个角色交互的基本场景是:

首先,第三方应用,也就是客户端(Client),向服务提供商提出接入申请。这一步可以理解为类似把 App 注册到应用商店的过程,每个应用只需要做一次。

然后,客户端(Client)向终端用户,也就是资源拥有方(Resource Owner)申请访问权限。这个申请发生在服务提供商提供的环境中,所以服务提供商可以感知资源拥有方是拒绝,还是接受了客户端的请求。

然后,客户端(Client)向服务提供商的授权服务(Authorization Server)发起授权请求,并得到了可用于访问资源的 Token。

最后,客户端(Client)通过 Token 向服务提供商的资源服务(Resource Server)发起资源访问请求。

整个过程的具体流程如下:

(A)终端用户打开客户端以后,客户端要求终端用户给予授权。

(B)终端用户同意给予客户端授权。

(C)客户端使用上一步获得的授权,向认证服务器申请令牌(Token)。

(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。

(E)客户端使用令牌,向资源服务器申请获取资源。

(F)资源服务器确认令牌无误,同意向客户端开放资源。

这个图体现了 OAuth 2.0 的核心思想。但不同场景下,具体的授权流程有一定的差异。常见的授权模式有如下几种:

  • 授权码模式(Authorization Code);
  • 简化模式(Implicit);
  • 用户名+密码模式(Resource Owner Password Credentials);
  • 客户端模式(Client Credentials);
  • 访问令牌(Access Token);
  • 更新令牌(Refresh Token)。

其中,基于访问令牌(Access Token)的授权模式是最核心的一种,请求频率最大。更新令牌(Refresh Token)则次之。每次访问令牌(Access Token)失效后,通过更新令牌(Refresh Token)获得新的访问令牌(Access Token)。

其他所有的授权方式,是在不同场景下的授权入口。通过这些授权入口的任何一个都可以同时获得访问令牌(Access Token)和更新令牌(Refresh Token)。

用户名+密码模式(Resource Owner Password Credentials)不用过多解释,这是我们最为熟悉的一种授权方式。

我们重点解释下授权码模式(Authorization Code),这是 OAuth 作为第三方开放接口用的最多的一种场景。它的业务流程如下:

图片: https://uploader.shimo.im/f/7kw35bAyIoseOFoz.png

(A)终端用户访问某个网站,客户端(通常是一个标准的浏览器)将终端用户重定向到认证服务。

(B)终端用户选择是否给予该网站相应的授权。

(C)如果授权,认证服务器将用户导向网站事先指定好的 “重定向URI”(Redirection URI),同时附上一个授权码。

(D)该网站收到授权码,附上早先的 “重定向URI”,向认证服务器申请令牌。这一步是在网站的后端服务器上完成的,对终端用户不可见。

(E)认证服务器核对了授权码和重定向URI,确认无误后,网站的后端服务器返回访问令牌(access token)和更新令牌(refresh token)。

此后,该网站就可以通过后端服务器去访问相应的服务了。

结语

今天我们主要聊了帐号与授权相关的基础体系,重点介绍 OAuth 2.0 背后的逻辑。下一讲我们会讨论如何基于 OAuth 来完成 QPaint 的帐号与授权机制。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲开始我们继续实战。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 实战(四):“画图”程序后端实战

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲我们介绍了帐号与授权相关的基础体系,并重点介绍 OAuth 2.0 背后的逻辑。今天我们开始考虑如何让 QPaint 引入帐号与授权体系。

最常规的做法,当然是自己建立一个帐号数据库,做基于用户名+密码的登录授权并转为基于Cookie的会话(Session)。示例如下:

但我们考虑提供 Open API 的话,就需要考虑遵循 OAuth 2.0 的授权协议规范,以便第三方应用可以快速接入,而不是搞半天去研究我们自己发明的授权是怎么回事。

除此之外,我们也可以考虑基于微信、支付宝等 OpenID 来实现用户的快速登录,而不是让用户在注册环节折腾半天。

所以,比较理想的方式是我们基于 OpenID Connect 协议来提供帐号系统,基于 OAuth 2.0 协议来实现 Open API 体系。

这个选择与业务无关。所以很自然地,我们决定评估一下,看看是否有开源项目和我们想得一样。

最后,我们发现 CoreOS 团队搞了一个叫 dex 的项目,如下:

dex 项目的这么描述自己的:

dex - A federated OpenID Connect provider

OpenID Connect Identity (OIDC) and OAuth 2.0 Provider with Pluggable Connectors.

Dex is an identity service that uses OpenID Connect to drive authentication for other apps. Dex acts as a portal to other identity providers through "connectors." This lets dex defer authentication to LDAP servers, SAML providers, or established identity providers like GitHub, Google, and Active Directory. Clients write their authentication logic once to talk to dex, then dex handles the protocols for a given backend.

概要来说,dex 基于各类主流的 OpenID 来提供帐号系统,上游的 OpenID Provider(即下图中的 Upstream IdP)是以插件方式(Pluggable Connector)提供。这也是为什么把它叫联邦 OpenID(federated OpenID)的原因。然后,dex 再通过 OAuth 2.0 协议对客户端(即下图中的 Client app)提供授权服务。

图片: https://uploader.shimo.im/f/8SVN4368jw0ZFDNG.png

联邦 OpenID

我们先看 dex 在联邦 OpenID 这块的支持。当前已经支持的 Pluggable Connector 如下:

可以看出,对于那些支持 OpenID Connect 协议的 OpenID,比如 Google、Saleforce、Azure 等,可以统一用同一个 Connector 来支持。而对于其他的 OpenID,比如 Github,则实现一个独立的 Connector 来支持。

除了 OpenID Connect,我们也可以看到很多耳熟能详的开放帐号授权协议,比如在前面课程中,有人提议讲一讲的单点登录 SAML 2.0 和 LDAP。但这的确不是我们的重点。我们这里提供相关的链接供大家参考。

LDAP 的资料如下:

SAML 2.0 Web Browser Single-Sign-On 的资料如下:

不同的 OpenID Provider 作为后端,会导致一些细节上的差异。有的 OpenID Provider 不支持更新令牌(Refresh Token),有的会导致 ID Token 不支持 groups 字段。详细在以上 Connector 列表中有明确说明。

另外,虽然 dex 支持了颇为丰富的 OpenID Provider,但不幸的是,国内的主流 OpenID Provider,比如微信和支付宝,都没有在支持之列。

不过好在,它基于开放的插件机制,我们可以自己依葫芦画瓢实现一个。Pluggable Connector 相关的文档和插件如下:

国内也会有人想到做类似 dex 这种项目,比如:

看到我们熟悉的微信、支付宝、新浪微博了,所以想到点子并不难,但看架构设计就会看到两者巨大的差距。

当然,如果你看到了其他很好的开源实现,欢迎留言交流。

提供 OpenID + OAuth 2.0 服务

尽管 dex 底层所基于的 OpenID Provider 多种多样,但是 dex 对外统一提供了标准的 OpenID Connect 协议和 OAuth 2.0 服务。

OpenID Connect 作为 OAuth 2.0 的一个扩展,最重要的一个改进是引入了身份令牌(ID Token)概念。

为什么需要扩展 OAuth 2.0?

因为 OAuth 2.0 本身只关心授权,所以它会返回访问令牌(Access Token)和更新令牌(Refresh Token)。但无论是访问令牌还是更新令牌,都并没有包含身份(Identity)信息。没有身份信息,就没法作为 OpenID Provider。

身份令牌(ID Token)解决了这一问题。ID Token 是一个 JSON Web Token (JWT) ,支持你对 Token 进行解码(decode)并验证(verify)用户身份。关于 JSON Web Token 的详细介绍,请参阅 https://jwt.io/

dex 并不是一个包(package),而是一个可执行程序(application),它提供了帐号与授权服务。你可以这样运行它:

dex config.yaml

其中 config.yaml 是它的配置文件。其格式可参考以下这些样例:

使用 dex

有了 dex 服务,我们就可以开始回到 QPaint 业务,去支持帐号与授权了。

我们并不需要自己开发太多东西。

OAuth 2.0 的客户端 SDK,Go 语言自己有一个准官方的版本。如下:

OpenID Connect 的客户端 SDK,CoreOS 团队也开发了一个。如下:

具体如何对接 dex,CoreOS 团队也写了一个详细的说明文档。如下:

有了这些 SDK 和 dex 的使用说明,具体 QPaint 业务怎么对接 dex,就比较简单了。我们这里就不详细展开,详细代码请参考:

结语

总结一下今天的内容。

今天我们主要讨论如何基于 OAuth 2.0 来改造 QPaint 的帐号与授权机制。实际上这方面业界有非常成熟的实践,所以我们没有太大的必要去自己重新造一个轮子。我们的核心思路是,基于 OpenID Connect 协议来提供帐号系统,基于 OAuth 2.0 协议来实现 Open API 体系。

我们不只是用标准的协议,背后的实现也基于开源项目:CoreOS 团队开发的 dex。

这样,我们就可以把关注的重心放在 QPaint 业务本身上。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。我们服务端程序的实战到这里就要结束了。下一讲聊一聊 “架构:怎么做详细设计” 这个话题。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 架构:怎么做详细设计?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

我们第三章 “服务端开发篇” 就快要结束了。我们原计划的第三章会分拆为两章:

  • 第三章:服务端开发篇。主要介绍服务端的基础架构与业务架构。
  • 第四章:服务治理篇。主要介绍服务端程序上线与线上服务如何管理的问题。

原先计划的 “第五章:通用架构范式篇” 会取消,核心内容会融合到其他的章节中。详细的调整结果,近期我们会与大家同步新的大纲。

今天我们把话题重新回到架构上。

关于架构,前面我们已经聊了第一步的需求分析和第二步系统的概要设计:

需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。

需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义将明确产品的元素,明确产品的边界,与产业上下游、合作伙伴的分工。

在需求分析阶段,我们关注用户需求的精确表述。我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。

在概要设计阶段,我们一般以子系统为维度来阐述系统各个角色之间的关系。对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。

这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不做进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。

为了降低风险,概要设计阶段也应该有代码产出。

这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。

代码即文档。代码是理解一致性更强的文档。

经过系统的概要设计,整个系统的概貌就了然于胸了。详细设计阶段,是需要各个子系统或模块的负责人,对他负责的部分进行进一步的细化。

详细设计关注的是子系统或模块的全貌。

请记住,详细设计并不是只谈实现就完事,更不是一个架构图。它包括以下这些内容。

  • 现状与需求

    • 现在在哪里,遇到了什么问题,要做何改进。
  • 需求满足方式

    • 要做成啥样?交付物的规格,或者说使用界面(接口)。
    • 怎么做到?交付物的实现原理。

概要设计和详细设计的工作内容会有一定的重叠。

概要设计的核心目标是串联整个系统,消除系统的重大风险。在这个过程中,对一些关键模块的实现细节有所考虑是非常正常的。但从另一个角度来说,分解粒度也不能过粗,不应该把特别庞大的子系统直接分出去,这样项目执行的风险就太高了。

但两者的分工不同,考虑的问题重心不同。

比如,从使用界面(接口)来说,概要设计不一定会把子系统或模块的完整接口都列出来,实际上它只关注最核心的部分。但是从详细设计角度来说,接口描述的完备性是必需的。

现状与需求

我们先看看现状与需求。

从逻辑自洽的角度,我们任何一篇文档,首先关注的都应该是要解决的问题与目标。

现状与需求的陈述,要简明扼要。

现状大家都知道,所以不要长篇累牍。更多的是陈述与我们要做的改变相关的重要事实,侧重点在于强调这些事实的存在性和重要性。

比如,假设我们要对某个模块重构。那么,现状就是要谈清楚现在的业务架构是怎样的?它到底有什么样的问题。

需求陈述是对痛点和改进方向的一次共识确认。痛点只要够痛,大家都知道,所以同样不需要长篇累牍。

每个子系统或模块,都有自己的角色分工与用户故事。我们不用重新做一遍需求分析,但对需求分析的核心结论,在详细设计开始之前需要明确。

这很重要。它是我们详细设计所要满足的业务目标。

使用界面(接口)

聊完了现状与需求,接着我们就要谈需求的满足方式。它分两个方面:一方面是交付物的规格,或者说使用界面(接口)。另一方面是背后的实现原理,我们怎么做到的。

规格,或者说使用界面,体现的是别人要怎么使用我。

我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也要有自然的延续性。

使用界面这一部分要详细写,它是团队共识确认的关键。

我们的交付物有哪些可执行文件,有哪些包(package)?如果可执行文件,那么它是一个界面程序,还是服务?如果是服务,网络协议是什么样的?如果是包,它又包含哪些公开的类或函数。

在 “32 | 架构:系统的概要设计” 这一讲中,我们花了非常长的篇幅介绍使用界面(接口)是怎么回事,今天我们就不对这一点进行展开。

需要强调的是,使用界面需要有明确的书写规范。它也是团队共识管理的重要组成,是团队效率、团队默契形成的象征。

更需要强调的是,使用界面的稳定是至关重要的。

接口的变更需谨慎!

对使用界面的不兼容调整,可能出现严重的后果。技术上,可能会导致客户异常,出现编译失败需要重写代码,或者更严重的是,可能导致他们的系统崩溃。商业上,则可能导致大量的客户流失。

实现:数据结构+算法

聊完使用界面,接下来就要谈实现原理了,它要体现的是我如何做到。

在 “42 | 实战(二):“画图”程序后端实战” 一讲中,我们提到过以下这个大家耳熟能详的公式:

程序 = 数据结构 + 算法

它是一个很好的指导思想。当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。

我们先看数据结构。

数据结构从大的层面分,可分为基于内存的数据结构,和基于外存(比如 SSD 盘)的数据结构。

对于桌面程序,大部分情况下我们打交道的都是基于内存的数据结构。外存数据结构也会有所涉及,但往往局限于 IO 子系统。

但对于服务端程序,数据结构不完全是我们自己能够做主的。数据结构大部分情况下都是基于外存的,而且有极高的质量要求。

在 “36 | 业务状态与存储中间件” 这一讲中我们也说过,存储即数据结构。所以,服务端程序在数据结构这一点上,最为重要的一件事是选择合适的存储中间件。然后我们再在该存储中间件之上组织我们的数据。

这是数据库这样的存储中间件流行起来的原因。无论是关系型数据库,还是文档型数据库,他们都被设计为一种泛业务场景的数据结构,有很好的业务适应性。

所以在服务端我们谈数据结构,谈的不是内存数据结构,往往谈的是数据库的表结构设计。当然表(Table)是在关系型数据库中的说法,在 mongodb 中我们叫集合(Collection)。但不管我们用的是哪种数据库,出于惯例我们往往还是以 “定义表结构” 一词来表达我们想干什么。

描述表结构,核心需要包含以下内容:

  • 字段名;
  • 类型;
  • 字段含义,以及是否指向另一个表的某个字段;
  • 索引。

你会发现,其实定义表结构和定义内存数据结构本质是完全一致的。定义内存中的一个类(或结构体),我们也关心字段名(成员变量名)和类型,也关心字段的含义,以及它是否指向另一个类(或结构体)的某个字段(成员变量)。

但表结构比内存数据结构多了一个概念:索引。

索引为何存在?我认为有这样几方面的原因。一方面是因为数据库是泛业务场景的通用数据结构,它是动态的,需要依赖索引来提升数据访问的效率。另一方面是因为多租户。多租户导致数据量的爆发式增长,导致大部分情况下遍历查找变得不现实。

索引怎么设计?它完全取决于算法。算法里面使用了哪些数据访问的特征,这些数据访问的频次预期是多少,这些决定了我们添加哪些索引是最划算的。

在涉及的类比较多,或数据库的表结构比较复杂的时候,有时我们会用 UML 类图来对数据结构进行直观的呈现。

谈清楚了数据结构,我们接着聊算法。

在 “程序 = 数据结构 + 算法” 这个说法中,“算法” 指的是什么?在 “42 | 实战(二):“画图”程序后端实战” 一讲中,我们这么说:

在架构过程中,需求分析阶段,我们关注用户需求的精确表述,我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。

到了详细设计阶段,角色和用户故事就变成了子系统、模块、类或者函数的使用界面(接口)。我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也有自然的延续性。

所以算法,最直白的含义,指的是用户故事背后的实现机制。

数据结构 + 算法,是为了满足最初的角色与用户故事定义,这是架构的详细设计阶段核心关注点。

那么,怎么描述一个用户故事对应的算法?

一种方式是基于 UML 时序图(Sequence Diagram)。以下是我个人用过的很好的在线版 UML 时序图制作工具:

另一种方式是基于伪代码(Pseudo Code)。在逻辑较为复杂时,伪代码往往有更好的呈现效果。比如,服务端程序对数据库的 SQL 操作往往比较复杂,但是从 UML 时序图来说流程却并不长,这个时候去画 UML 时序图的意义就不大。

结语

今天我们聊的是怎么做详细设计。

详细设计并不是只谈实现就完事,更不是一个架构图。它包括以下这些内容。

  • 现状与需求

    • 现在在哪里,遇到了什么问题,要作何改进。
  • 需求满足方式

    • 要做成啥样?交付物的规格,或者说使用界面(接口)。
    • 怎么做到?交付物的实现原理。

“程序 = 数据结构 + 算法” 是我们很熟悉的一个公式。它其实是怎么描述实现原理的很好的指导方针。当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们对第三章 “服务端开发篇” 进行回顾与总结。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 服务端开发篇:回顾与总结

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到今天为止,我们第三章 “服务端开发篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。本章我们主要涉及的内容如下。

服务端开发这个分工,出现的历史极短。如果我们从互联网诞生算起也就 40 多年的历史。以进入民用市场为标志,它真正活跃的时段,其实只有 20 多年。

作为架构师,记住这一点非常非常重要。20 多年能够形成的有效经验并不多。这意味着我们不能固步自封,很多惯例是可以被挑战的,并且最终也必然被挑战的。

作为最底层的服务端操作系统,最初从桌面操作系统而来。但桌面操作系统自身在发展,服务端操作系统自身也在发展,两者渐行渐远。

桌面的领域特征是强交互,以事件为输入,GDI 为输出。

所以,桌面技术的迭代,是交互的迭代,是人机交互的革命。在 “13 | 进程间的同步互斥、资源共享与通讯” 一讲中,我们介绍了桌面操作系统中进程间协同方式的变迁。如果我们从业务需求角度看,这个变迁本质上也是交互的变迁(为什么我们这么说?欢迎留言探讨)。

而服务端程序有很强烈的服务特征。它的领域特征是大规模的用户请求,以及 24 小时不间断的服务。这些都不是业务功能上的需要,是客户服务的需要。

所以,服务端技术的迭代,虽然一开始沿用了桌面操作系统的整套体系框架,但它正逐步和桌面操作系统分道而行,转向数据中心操作系统(DCOS)之路。

服务端技术的迭代,有一些和服务端开发相关,会影响到业务架构。而更多则和业务架构无关,属于服务治理的范畴。

服务端开发与服务治理的边界在于,服务端开发致力于设计合适的业务架构来满足用户需求,而服务治理则致力于让服务端程序健康地为客户提供不间断的服务。

关于服务治理相关的内容,我们留到下一章来介绍。

服务端开发篇的内容回顾

本章服务端开发篇我们讲了些什么?为了让你对第三章内容有个宏观的了解,我画了一幅图,如下。

首先,从服务端开发来说,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:

  • 负载均衡(Load Balance);
  • 存储中间件:数据库或其他形式的存储(DB/Storage)。

负载均衡的最大价值是对客户的访问流量进行调度,让多个业务服务器的压力均衡。这里面隐含的一个前提是负载均衡软件的抗压能力往往比业务服务器强很多。 这表现在:

其一,负载均衡的实例数 / 业务服务器的实例数往往大大小于1;其二,DNS 的调度不均衡,所以负载均衡的不同实例的压力不均衡,有的实例可能压力很大。

当然,负载均衡的价值并不只是做流量的均衡调度,它也让我们的业务服务器优雅升级成为可能。

存储中间件即数据结构。

在服务端开发领域,有一个很知名的编程哲学,叫 “速错(Fail Fast)”,它的核心逻辑是,一旦发生非预期的错误时,应该立刻退出程序,而不要尝试为该错误去写防御代码,因为那样的话掩盖掉这个错误,并导致后续可能产生更隐晦难以定位的错误。

但是 “速错(Fail Fast)” 是以可靠的存储中间件为前提的。没有了可靠的存储,程序重新启动后就不知道自己正在做什么事情了。所以存储是不能速错的,它的编程哲学如此不同。作为存储系统的开发者,你需要花费绝大部分精力在各种异常情况的处理上,甚至你应该认为,这些庞杂的、多样的错误分支处理,才是存储系统的 “正常业务逻辑”。

对于服务端来说,存储中间件至关重要,它是服务端程序能够提供高并发访问和 24 小时不间断服务的基础。存储中间件极大地解放了生产效率,让开发人员可以把精力放在具体的业务需求上。

虽然我们不需要自己去开发存储中间件,但是深度理解其工作原理是非常有必要的。通常来说,存储中间件也是服务端的性能瓶颈所在。几乎所有服务端程序扛不住压力,往往都是因为存储没有扛住压力。

存储中间件的种类繁多,不完整的列表如下:

  • 键值存储(KV-Storage);
  • 对象存储(Object Storage);
  • 数据库(Database);
  • 消息队列(MQ);
  • 倒排索引(SearchEngine);
  • ......

对象存储的出现,是服务端体系架构和桌面操作系统分道扬镳的开始。文件系统(File System)不再是服务端存储中间件的标配。第一个大家公认的对象存储是 AWS S3,但它只是一个基础文件存取的组件。七牛云则在此基础上推出了第一个 “对象存储+CDN+多媒体处理” 融合的 PaaS 型云存储。

理解了负载均衡和存储中间件,我们开始谈服务端的业务架构

从业务架构的角度,服务端主要是实现一个多租户的 Model 层。Model 层本身最重要的是自然体现业务逻辑,它和具体行业的领域问题相关。但服务端程序还是有它很鲜明的特点,有一些和领域无关的业务架构通用问题。比如:网络协议、帐号与授权、RPC 框架、单元测试等等。

为了更好地理解服务端开发的架构逻辑,我们继续以画图程序的后端开发为实战案例,进行详细展开。

作为最后收官,我们聊了架构第三步:详细设计。详细设计关注的是子系统或模块的全貌。它并不是只谈实现就完事,更不是一个架构图。它包括以下这些内容。

  • 现状与需求

    • 现在在哪里,遇到了什么问题,要作何改进。
  • 需求满足方式

    • 要做成啥样?交付物的规格,或者说使用界面(接口)。
    • 怎么做到?交付物的实现原理。

“程序 = 数据结构 + 算法” 是我们很熟悉的一个公式。它其实是怎么描述实现原理的很好的指导方针。当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。

服务端开发篇的参考资料

整体来说,尽管服务端开发所需要的知识面更广,但是就开发本身的工作量和难度而言,服务端开发要大大低于桌面开发。

但将服务端程序开发出来只是个开始。如何让服务稳定健康地运行,是一个复杂的话题。所以近年来服务端技术蓬勃发展,主要以服务治理为主。

单单从服务端开发的角度,我们除了关注服务端操作系统、编程语言,还需要关注负载均衡和存储中间件。

这里我列一下我认为值得重点关注的技术:

  • Docker & Kubernetes。毫无疑问,数据中心操作系统(DCOS)是服务端操作系统的发展方向。关于 DCOS ,我们会在下一章涉及。
  • Go 语言。推荐 Brian W. Kernighan 写的《Go 程序设计语言》,本书为传世经典《C程序设计语言》的作者再次动笔所创。
  • LVS & Nginx。两大当前最主流的流量调度软件。其中 LVS 工作在网络层,Nginx 工作在应用层。
  • MySQL & MongoDB。两大当前最主流的数据库。虽然它们的使用范式差异较大,但背后的基础哲学实际上是相通的。
  • 对象存储。推荐 AWS S3 和 七牛云存储
  • 网络协议。虽然当前主流还是 RESTful API,但可以适当关注 GraphQL
  • RPC 框架。推荐七牛云开源的 restrpc,以及 Google 开源的 grpc
  • HTTP 测试。推荐七牛云开源的 httptest 框架和 qiniutest 实用程序。

大部分的服务端技术都还在快速迭代。对于网络资料相对较多的部分,这里我就不再去给出具体的相关资料了。

结语

今天我们对本章内容做了概要的回顾,并借此对整个服务端开发的骨架进行了一次梳理。

这一章我们继续聊业务架构,我们把侧重点放在后端业务开发。学业务架构最好的方式是:“做中学”。做是最重要的,然后要有做后的反思,去思考并完善自己的理论体系。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们开始进入第四章:服务治理篇。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

加餐 | 如何做HTTP服务的测试?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

基于 HTTP 协议提供服务的好处是显然的。除了 HTTP 服务有很多现成的客户端、服务端框架可以直接使用外,在 HTTP 服务的调试、测试、监控、负载均衡等领域都有现成的相关工具支撑。

在七牛,我们绝大部分的服务,包括内部服务,都是基于 HTTP 协议来提供服务。所以我们需要思考如何更有效地进行 HTTP 服务的测试。

七牛早期 HTTP 服务的测试方法很朴素:第一步先写好服务端,然后写一个客户端 SDK,再基于这个客户端 SDK 写测试案例。

这种方法多多少少会遇到一些问题。首先,客户端 SDK 的修改可能会导致测试案例编不过。其次,客户端 SDK 通常是使用方友好,而不是测试方友好。服务端开发过程和客户端 SDK 的耦合容易过早地陷入“客户端 SDK 如何抽象更合理” 的细节,而不能专注于测试服务逻辑本身。

我的核心诉求是对服务端开发过程和客户端开发过程进行解耦。在网络协议定好了以后,整个系统原则上就可以编写测试案例,而不用等客户端 SDK的成熟。

不写客户端 SDK 而直接做 HTTP 测试,一个直观的思路是直接基于 http.Client 类来写测试案例。这种方式的问题是代码比较冗长,而且它的业务逻辑表达不直观,很难一眼就看出这句话想干什么。虽然可以写一些辅助函数来改观,但做多了就会逐渐有写测试专用 SDK 的倾向。这种写法看起来也不是很可取,毕竟为测试写一个专门的 SDK,看起来成本有些高了。

七牛当前的做法是引入一种 httptest DSL 文法。这是七牛为 HTTP 测试而写的领域专用语言。这个 httptest 工具当前已经开源,项目主页为:

httptest 基础文法

这个语言的文法大概在 2012 年就已经被加入到七牛的代码库,后来有个同事根据这个 DSL 文法写了第一版本 qiniutest 程序。在决定推广用这个 DSL 来进行测试的过程中,我们对 DSL 不断地进行了调整和加强。虽然总体思路没有变化,但最终定稿的 DSL 与最初版本有较大的差异。目前来说,我已经可以十分确定地说,这个DSL可以满足 90% 以上的测试需求。它被推荐做为七牛内部的首选测试方案。

上图是这套 DSL 的 “hello world” 程序。它的执行预期是:下载 www.qiniu.com 首页,要求返回的 HTTP 状态码为 200。如果返回非 200,测试失败;否则测试通过,输出返回包的正文内容(resp.body 变量)。输出 resp.body 的内容通常是调试需要,而不是测试需要。自动化测试是不需要向屏幕去输出什么的。

我们再看该 DSL 的一个 “quick start(快速入门)” 样例。以 # 开始的内容是程序的注释部分。这里有一个很长很长的注释,描述了一个基本的 HTTP 请求测试的构成。后面我们会对这部分内容进行详细展开,这里暂时跳过。

这段代码的第一句话是定义了一个 auth 别名叫 qiniutest,这只是为了让后面具体的 HTTP 请求中授权语句更简短。紧接着是发起一个 POST 请求,创建一个内容为 {"a": "value1", "b": 1} 的对象,并将返回的对象 id 赋值给一个名为 id1 的变量。后面我们会详细解释这个赋值过程是如何进行的。

接着我们发起一个获取对象内容的 GET 请求,需要注意的是 GET 的 URL 中引用了 id1 变量的值,这意味着我们不是要取别的对象的内容,而是取刚刚创建成功的对象的内容,并且我们期望返回的对象内容和刚才POST上去的一样,也是 {"a": "value1", "b": 1}。这就是一个最基础的 HTTP 测试,它创建了一个对象,确认创建成功,并且尝试去取回这个对象,确认内容与我们期望的一致。这里上下两个请求是通过 id1 这个变量来建立关联的。

对这套DSL文法有了一个大概的印象后,我们开始来解剖它。先来看看它的语法结构。首先这套 httptest DSL 基于命令行文法:

command switch1 switch2 … arg1 arg2 …

整个命令行先是一个命令,然后紧接着是一个个开关(可选),最后是一个个的命令参数。和大家熟悉的命令行比如 Linux Shell 一样,它也会有一些参数需要转义,如果参数包含空格或其他特殊字符,则可以用 \ 前缀来进行转义。比如 '\ ' 表示 ''(空格),‘\t'表示 TAB 等。另外,我们也支持用 '…' 或者 "…" 去传递一个参数,比如 json 格式的多行文本。同 Linux Shell 类似,'…' 里面的内容没有转义,‘\ ’ 就是 ‘\ ’,‘\t'就是 '\t',而不是 TAB。而 "…" 则支持转义。

和 Linux Shell 不同的是,我们的 httptest DSL 虽然基于命令行文法,但是它的每一个参数都是有类型的,也就是说这个语言有类型系统,而不像 Linux Shell 命令行参数只有字符串。我们的 httptest DSL 支持且仅支持所有 json 支持的数据类型,包括:

  • string(如:"a"、application/json 等,在不引起歧义的情况下,可以省略双引号)
  • number(如:3.14159)
  • boolean(如:true、false)
  • array(如:[“a”, 200, {“b”: 2}])
  • object/dictionary(如:{“a”: 1, “b”: 2})

另外,我们的 httptest DSL 也有子命令的概念,它相当于一个函数,可以返回任意类型的数据。比如 qiniu f2weae23e6c9f jg35fae526kbce返回一个 auth object,这是用常规字符串无法表达的。

理解了 httptest DSL 后,我们来看看如何表达一个 HTTP 请求。它的基本形式如下:

req <http-method> <url>
header <key1> <val11> <val12>
header <key2> <val21> <val22>
auth <authorization>
body <content-type> <body-data>

第一句是 req 指令,带两个参数: 一个是 http method,即 HTTP 请求的方法,如 GET、POST 等。另一个是要请求的 URL。

接着是一个个自定义的 header(可选),每个 header 指令后面跟一个 key(键)和一个或多个 value(值)。

然后是一个可选的 auth 指令,用来指示这个请求的授权方式。如果没有 auth 语句,那么这个 HTTP 请求是匿名的,否则这就是一个带授权的请求。

最后一句是 body 指令,顾名思义它用来指定 HTTP 请求的正文。body 指令也有两个参数,一个是 content-type(内容格式),另一个是 body-data(请求正文)。

这样说比较抽象,我们看下实际的例子:

无授权的 GET 请求:

req GET http://www.qiniu.com/

带授权的 POST 请求:

req POST http://foo.com/objects
auth `qiniu f2weae23e6c9fjg35fae526kbce`
body application/json '{
  "a": "hello1",
  "b":2
}'

也可以简写成:

无授权的GET请求:

get http://www.qiniu.com/

带授权的Post请求:

post http://foo.com/objects
auth `qiniu f2weae23e6c9fjg35fae526kbce`
json '{
  "a": "hello1",
  "b":2
}'

发起了 HTTP 请求后,我们就可以收到 HTTP 返回包并对内容进行匹配。HTTP 返回包匹配的基本形式如下:

ret <expected-status-code>
header <key1> <expected-val11><expected-val12>
header <key2> <expected-val21><expected-val22>
body <expected-content-type><expected-body-data>

我们先看 ret 指令。实际上,请求发出去的时间是在 ret 指令执行的时候。前面 req、header、auth、body 指令仅仅表达了 HTTP 请求。如果没有调用 ret 指令,那么系统什么也不会发生。

ret 指令可以不带参数。不带参数的 ret 指令,其含义是发起 HTTP 请求,并将返回的 HTTP 返回包解析并存储到 resp 的变量中。而对于带参数的 ret 指令:

ret <expected-status-code>

它等价于:

ret
match <expected-status-code> $(resp.code)

match 指令

这里我们引入了一个新的指令:match 指令。

七牛所有 HTTP 返回包匹配的匹配文法,都可以用这个 match 来表达:

所以本质上来说,我们只需要一个不带参数的 ret,加上 match 指令,就可以搞定所有的返回包匹配过程。这也是我们为什么说 match 指令是这套 DSL 中最核心的概念的原因。

和其他自动化测试框架类似,这套 DSL 也提供了断言文法。它类似于 CppUnit 或 JUnit 之类的测试框架提供 assertEqual。具体如下:

equal <expected> <source>
  • 与 match 不同,这里 &lt;expected&gt;、&lt;source&gt;中都不允许出现未绑定的变量。
  • 与 match 不同,equal 要求&lt;expected&gt;、&lt;source&gt;的值精确相等。
equalSet <expected> <source>
  • 这里 SET 是指集合的意思。
  • 与 equal 不同,equalSet 要求 &lt;expected&gt;、&lt;source&gt;都是array,并且对 array 的元素进行排序后判断两者是否精确相等。
  • equalSet 的典型使用场景是测试 list 类的 API,比如列出一个目录下的所有文件,你可能预期这个目录下有哪些文件,但是不能预期他们会以什么样的次序返回。

以上介绍基本上就是这套 DSL 最核心的内容了。内容非常精简,但满足了绝大部分测试场景的需求。

测试环境的参数化

下面我们谈谈最后一个话题:测试环境的参数化。

为了让测试案例更加通用,我们需要对测试依赖的环境进行参数化。比如,为了让测试脚本能够同时用于 stage 环境和 product 环境,我们需要把服务的 Host 信息参数化。另外,为了方便测试脚本入口,我们通常还需要把 用户名/密码、AK/SK 等敏感性信息参数化,避免直接硬编码到测试案例中。

为了把服务器的 Host 信息(也就是服务器的位置)参数化,我们引入了 host 指令。例如:

host foo.com 127.0.0.1:8888
get http://foo.com/objects/a325gea2kgfd
auth qiniutest
ret 200
json '{
  "a": "hello1",
  "b":2
}'

这样,后文所有出现请求 foo.com 地方,都会把请求发送到 127.0.0.1:8888 这样一个服务器地址。要想让脚本测试另外的服务器实例,我们只需要调整 host 语句,将 127.0.0.1:8888 调整成其他即可。

除了服务器 Host 需要参数化外,其他常见的参数化需求是 用户名/密码、AK/SK 等。AK/SK 这样的信息非常敏感,如果在测试脚本里面硬编码这些信息,将不利于测试脚本代码的入库。一个典型的测试环境参数化后的测试脚本样例如下:

其中,env 指令用于取环境变量对应的值(返回值类型是 string),envdecode 指令则是先取得环境变量对应的值,然后对值进行 json decode 得到相应的 object/dictionary。有了$(env) 这个对象(object),就可以通过它获得各种测试环境参数,比如 $(env.FooHost)$(env.AK)$(env.SK) 等。

写好了测试脚本后,在执行测试脚本之前,我们需要先配置测试环境:

export QiniuTestEnv_stage='{
  "FooHost": "192.168.1.10:8888",
  "AK": "…",
  "SK": "…"
}'

export QiniuTestEnv_product='{
  "FooHost": "foo.com",
  "AK": "…",
  "SK": "…"
}'

这样我们就可以执行测试脚本了:

测试 stage 环境:

QiniuTestEnv=stage qiniutest ./testfoo.qtf

测试 product 环境:

QiniuTestEnv=product qiniutest ./testfoo.qtf

结语

测试是软件质量保障至关重要的一环。一个好的测试工具对提高开发效率的作用巨大。如果能够让开发人员的开发时间从一小时减少到半小时,那么日积月累就会得到惊人的效果。

去关注开发人员日常工作过程中的不爽和低效率是非常有必要的。任何开发效率提升相关的工作,其收益都是指数级的。这也是我们所推崇的做事风格。如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 服务治理的宏观视角

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

服务治理的目标

很多开发人员可能会习惯地认为,把软件开发出来交付给用户是其工作的结束。但实际上对于任何一个产品或者产品里面的某项功能来说,把东西开发出来只是个开始,实际上这个产品或功能在其被取代或去除之前,都会有很长一段时间的维护期。

上图是很基础的产品或功能的生命周期示意图。它并不只是对软件适用,而是对所有的商品适用。我们后面在 “软件工程篇” 中还会进一步探讨它。

对于这个示意图,我们核心需要理解的是两点:

其一,虽然功能开发阶段的成本是非常显性的,但是功能维护期,包括了功能迭代和售后维保,它的隐性成本往往更高。

其二,产品的功能开发期虽然有可能很短,但是它是起点,是源头。它每一分每一秒时间是怎么花的,很大程度上决定了这个产品或功能的最终维护代价。

互联网的诞生,对今天我们的生活产生了翻天覆地的影响。虽然细究起来它进入民用市场还只有短短二十多年的历史,但它的发展速度只能以 “恐怖” 来形容。

以互联网为载体的软件,它不只是在功能上要满足用户需求,还要提供健康的 24 小时不间断的服务。功能开发与维护的边界变得模糊,一些公司甚至每天都在发布新的版本。

要做到 24 小时不间断服务,这并不是那么容易的一件事情。

我们知道,传统意义上的操作系统,实现的是软件治理,它们的关注点是如何让众多的软件一起融洽相处,感觉上好像自己在独享着物理的硬件资源。

而服务治理的核心目标,除了软件治理外,更重要的是考虑如何确保这些软件能够真正做到 24 小时不间断的服务。

而这,才是服务端操作系统的使命。

服务治理系统

在上一讲,我们已经介绍了部分提供 24 小时不间断的服务所带来的挑战。但我们上一讲的侧重点在业务架构,所以我们主要关注点放在了对业务架构产生重要影响的内容,比如负载均衡和存储中间件。

从服务治理角度来说,把软件做出来只是一个开始。接下来我们面对的第一件事情,是如何把它发布出去。这就需要涉及部署、升级和版本管理等相关的话题。

软件在线上成功跑了起来,为用户提供了服务,我们接着面临的挑战是怎么保证它不会挂掉。这涉及非常多层面的事情。

首先是怎么知道服务是不是挂了,这就涉及监控与报警。在发现服务挂掉后,需要考虑尽快把它重启起来,恢复到正常的状态。

微观上某个软件进程挂掉不能影响到正常的服务。所以我们需要考虑各类故障域,尽可能全面地把单点故障的风险消除掉。

单点故障消除,有可能会是个运维问题,但更多时候我们也得从软件的业务架构层面去解决它。

服务治理并没有那么简单纯粹。虽然在理想情况下我们应该尽可能自动化所有故障的恢复,但故障的可能性太多,很多时候是我们无法提前预知的,这意味着人工介入无可避免。

所以,互联网不只是产生了服务端开发这样的工种,同时也产生了运维,或者说业务 SRE 这样的工种。

SRE 全称是 Site Reliability Engineer (网站可靠性工程师),这是 Google 引入的一个职位,后被各类公司所借鉴。区别于传统意义上的运维,SRE 也是一个特殊的工程师群体,和服务端开发一样,他们肩负着自己独特的使命。

从服务端近年来的发展来看,产业进化的方向无不与服务治理相关:如何保证服务 24 小时不间断地运行。

故障基本上是难于避免的。可以导致故障的因素非常多。我们大体可以分为这么几个层面。

其一,软硬件升级与各类配置变更。变更是故障的第一大问题源头。保证系统不出问题的最简单的方法当然是不去升级。

但从用户的服务体验和竞争力的角度来说,升级又是必需的。所以这是一个服务端开发与 SRE 之间做平衡的问题。

其二,软硬件环境的故障也可能引发我们的服务异常。软硬件环境的故障包括:单机故障如硬盘坏、内存坏、网卡坏、系统死机失去响应或重启等。机房或机架故障如断网、断电等。区域性故障如运营商网络故障、DNS服务商故障、自然灾害比如地震等。

对于一个规模化的服务系统,从不间断服务的角度,低概率的软硬件环境故障就会变成必然事件。比如我们考虑,假设一块硬盘的寿命是三年,也就是说每 1000 天可能会发生一次故障,但如果我们的服务集群有 1000 块硬盘,这就意味着平均每天都会坏一块盘。

其三,终端用户的请求也可能引发故障。比较典型的场景是秒杀类,短时间内大量的用户涌入,导致系统的承载能力超过规划,产生服务的过载。当然还有一些场景比如有针对性的恶意攻击、特定类型的用户请求导致的服务端资源大量消耗等,都可能引发服务故障。

所以,一个合理的服务治理系统,不只是需要能够及时反应业务系统的健康状况。更重要的是,要在发生了故障的情况下,能够提供故障跟踪与排查的有效线索,方便业务 SRE 可以快速定位跟踪的根因(Root Cause),并进行及时的止损。

当然,大部分情况下服务是正常的。但这并不代表我们就不会遇到麻烦。从服务单例用户的角度来说,我们服务可能没有发生故障,但是我们的某个用户就是访问不了我们的服务,或者访问服务没有得到预期的结果。

从单例用户的支持角度,我们还需要考虑服务的可支持性。为什么我访问不了?为什么我点击某个按钮没有反应或者报错?如果我们不体系化去考虑这些问题,我们的售后支持将极其低效。

综上所述,一个服务治理系统看起来是这样的:

这很不容易。

服务治理的发展历程

服务治理的发展进程涉及面非常之广。有自动化,有业务架构改造,还有人力(SRE)。

最早,我们可能从最基本的脚本开始。我们可能 SSH 进入某一台机器,执行特定脚本。

最初的自动化努力给我们争取了足够的时间和必不可少的经验。

脚本的适用性如何?怎么才能让单个脚本不是 “任务” 的抽象,而是 “服务治理方法论” 的结果?

我们的期望,是把服务治理建立成自治系统,而不是简单的自动化系统。

基于这样的思考,人们逐渐建立了基于物理机器资源的服务治理体系。脚本成为了平台。而平台的形成,正是脚本的抽象化、产品化、普适化的结果。

把一个服务实例绑定在某一台物理的服务器,虽然让服务视图看起来很直观,但是这种绑定让我们应对物理资源故障变得被动,同时也不利于服务器资源的充分利用。

所以虚拟机和容器技术的诞生,促使人们开始探索物理资源和应用服务之间的解耦。而一旦我们完成了这一步,服务的逻辑视图就完全语义化了,它与物理资源就只是一个应用的过程。物理资源环境发生任何故障,都可以迅速在新的硬件设备上重新构建。

对 SRE 来说,机器的损坏和生命周期管理基本上已经不需要任何操作了。硬件已经被池化。成千上万的机器加入系统,或者出现问题,被修复,这一切都不需要 SRE 的任何操作。

这意味着,随着系统的层次结构不断上升,我们完成了从手动触发,到自动触发,到自主化。

这正是今天 DCOS(数据中心操作系统)走的路。

结语

今天我们对本章服务治理篇做了概要的介绍。服务治理不是纯理论,没有简洁的抽象问题模型,我们面对的是现实世界的复杂性。这些现实的复杂性,必然带来解决方案的复杂性。

直到今天为止,很多问题仍然没有被圆满解决。但是,它们的确已经在被解决的边缘。相关领域的探索与发展,日新月异。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们聊聊 “事务与工程:什么是工程师思维”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 事务与工程:什么是工程师思维?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

服务治理的目标,是保障软件提供 24 小时不间断服务。服务治理没有简洁的抽象问题模型,我们需要面对的是现实世界的复杂性。

保障服务的健康运行,必然有大量的事务性工作,运维或 SRE(网站可靠性工程师)这样的职业也由此诞生。

事务与工程

但是如果我们停留在事务中不能出来,那么随着我们所服务的用户数量增加,必然需要招聘大量的人员来应对繁重的事务工作。

事务性的工作不会总是让人不开心,特别是工作不太多的时候。已知的、重复性的工作有一种让人平静的功效。完成这些事可以带来一种满足感和快速胜利感。事务工作可能是低风险低压力的活动,有些员工甚至喜欢做这种类型的工作。

但是我们必须清楚,在 SRE 所扮演的角色中,一定数量的事务工作是不可避免的,这其实是任何工程类工作都具有的特点。少量的事务存在不是什么大问题。但是一旦事务数量变多,就会有害了。如果事务特别繁重,那就应该非常担忧了。

如果花在工程项目上的时间太少,你的职业发展会变慢,甚至停滞。我们可以鼓励那些做脏活累活的人,但仅仅限于在这些工作不可避免,并有巨大的正面影响的时候才会这样做。没有人可以通过不停地做脏活累活实现自己的职业发展。

把问题彻底解决

那么,什么是工程师思维?

在部分所谓的技术导向型公司,可能存在一些思维惯性,销售和产品经理会觉得自己没有话语权,开发工程师会觉得自己的地位高人一等。

对此我其实很反感。推崇技术当然不是个问题,但是所有的健康公司都必然是业务导向的公司,所有的技术人员如果希望有好的职业发展,也必然需要去理解业务。

七牛是推崇工程师文化的,但工程师文化显然并不是去尊崇工程师这样的职业。

什么才是真正的工程师文化?

从浅层的意义来说,工程师就是要实现业务的自动化。DON'T REPEAT YOURSELF! 某件重复发生的事情只干一次就好,以后也不需要再重复做。

工程师的自动化思维,所体现的内在逻辑是如何把问题 Close,如何把问题彻底解决掉,而编码只是一种工具。

在我们日常生活中,很多问题不需要编码来解决,但是确实需要用 “彻底解决它” 的思维去完成。这种思维不仅限于工程师,同样适用于所有人。比如,我们开餐厅需要解决服务质量的问题,这一点可能海底捞就解决得很好,但是不一定是用编码的方式解决。同样地,假设我们办线下市场活动,要解决内容质量的问题。怎么彻底解决它,这是值得深度思考的问题。

很多人会习惯呆在自己的舒适区,习惯于做任务,每天重复相同的作业,这就不符合我们所说的 “工程师文化”。我们需要达到的状态是,今天干完一件事,明天开启新的事。

怎么判断自己在做新的事情?那就要看我们问题是否解决得够彻底。

比如我在做新媒体运营,每天写着不同的公众号文章,这是否代表我在做新的事情?答案显然是不一定。要回答这个问题,我们首先需要搞清楚的是,我每天发公众号文章,是在解决一个什么样的问题。如果我们没有想清楚这一点,那么我们就不是在 Close 问题,我们只是在做任务而已。

我们的目标显然不应该是每天发一篇文章。这是在定义一件事务,而不是定义一个目标。把问题定义清楚非常非常重要。清楚了问题,就是设定清楚了我们的目标。然后才能谈得上去彻底解决掉它。

从另一个维度看,工程师这种把问题 Close,彻底解决掉的思维,看重的是自己工作内容的长期价值。如果我们只是在做事务,如果我们并没有在实质性解决一个问题,那么这件事情的长期价值就是零。

**所以本质上,工程师文化也是产品文化,把问题以一种自动化的方式解决。**这才是我们真正应该尊崇的工程师文化。

一个公司各个岗位是彼此协作的团队,工程师并不是特殊群体。销售、技术支持、产品、开发工程师每一个角色都是平等的。每个人都应该秉承工程师精神,把一个个问题 Close,让它不要再发生。不需要显得很忙,忙不代表成就,真正的工程师文化应该是推动整个团队往前走,每个团队成员都在成长。

系统化思维与批判精神

从更深层次来说,工程师思维是一种系统化的思维。仅仅是编码和自动化是不够的,很可能你编码也只是在实现某种事务性工作,而不是用系统性或者说结构化的方案来解决问题。

真正的工程师会系统化地考虑方案的有效性。他们追求的是用最小化的编码工作来解决更大范围的问题。

少就是指数级的多!

现实中,一些工程师经常对于自己编写的代码形成一种情感依附,这是人之常情。一些人可能会在你删除多余代码时提出抗议:“如果我们以后需要这个代码怎么办?”“我们为什么只是把这些代码注释掉,这样稍后再使用它的时候会更容易吗?”“为什么不增加一个功能开关?”

这些都是糟糕的建议。源代码管理系统中的回滚其实很容易,但大量的注释代码则会造成干扰和混乱,尤其是我们还要继续演进时。那些由于功能开关没有启用而没有被执行的代码,更是像一个个定时炸弹一样等待爆炸。

极端地说,当你指望一个软件 24 小时不间断服务时,在某种程度上来说每一行代码都是负担。所以 SRE 需要推崇的实践是保证所有的代码行都有必须存在的目的。

另外,从软件工程角度来说,传统意义上的工程强调的是复制性,但软件的编码却是一项不确定性很强的创新性工作,我们总在不断迭代出新的技术。所以软件工程是颇为复杂的东西,它需要在不确定性和复制性这对儿矛盾中平衡。

所以优秀的工程师还需要有批判精神。经验当然是有价值的,但过于相信惯例就会抑制创新能力。寻求本源,不迷信惯例和权威。以数据为指导,从根源出发去系统性解决问题。

结语

今天看起来我们的话题有了一次比较大的跳跃,谈起了工程师思维和工程师文化。但服务治理不是纯理论,没有简洁的抽象问题模型。我们面对的是现实世界的复杂性。这些现实的复杂性,背后是大量的事务工作,尤其是我们对问题还不够了解的时候。

这个时候,工程师思维在背后起到了关键性的支撑。正是我们坚持了批判精神,坚持了以系统化的思维来把问题彻底解决,才有今天服务治理系统的日新月异的发展。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们聊聊 “发布、升级与版本管理”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 发布、升级与版本管理

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

今天我们探讨服务治理的第一个环节:发布与升级。

在应用开发工程师完成一个版本的迭代后,他们交付的是软件新版本的源代码,这些代码存储在源代码仓库中。

一次正常的发布过程,大体分为这样几个典型的步骤:

  • 构建:从源代码仓库检出源代码,编译出对应的目标文件,也就是我们新版本的软件。
  • 测试:对新版本的软件进行测试,以确认软件的质量符合期望。
  • 打包:将新版本的软件及其执行所需的相关文件,比如配置文件,一起打包并记录相应的版本号。
  • 部署:将打包好的新版本更新到线上环境。为了保证线上环境的质量,更新过程往往需要灰度,而不是一步到位直接全面切换到新版本。

当然,并不是所有的升级都是发布新版本的软件。有时候我们仅仅只是进行配置变更,也就是修改线上的配置参数。配置参数可能存在于软件配套的配置文件中,也可能存在于线上的某个配置数据库。

整个发布与升级的过程,大体可以用下图来表示。

从上面我们可以看出,发布是一个具备很强的事务特征的工作,过程很复杂。不仅如此,发布工作的心智负担也很大。所有 SRE 都应该牢牢记住以下这句七字箴言:

变更是故障之源。

我们应该怎么做,才能彻底解决发布与升级的问题?

让我们从 “工程师思维” 的角度,用系统化、产品化的思维来考虑这样一个复杂事务。

我们第一个要回答的问题是:我们的发布哲学是什么?

密闭性与可重复性

为保障服务可靠运行需要可靠的发布流程,我们首先要保证的是发布过程的密闭性与可重复性。

可重复性是我们的核心目标。相同的版本可以反复发布,不应该由此产生什么副作用。只有做到可重复,我们才可以安全地进行升级,或者在发现问题时安全地回滚。

要做到可重复性,就需要保证密闭性。

所谓密闭性(Hermetic),简单说就是环境的完整性。

比如,软件的源代码必须是密闭的,每次通过特定的版本号,检出内容必须是完整的,一致的且可重复的。编译的时候不需要再去任何第三方额外检出外部依赖的源代码。

再比如,从构建过程来说,同样必须确保一致性和可重复性。让两个工程师在两台不同的机器上基于同一个源代码版本构建同一个产品,构建结果应该是相同的。这意味着它不应该受构建机器上安装的第三方类库或者其他软件工具所影响。构建过程需要指定版本的构建工具,包括编译器,同时使用指定版本的依赖库(第三方类库)。编译过程是自包含的,不依赖于编译环境之外的任何其他服务。

从自动化到自服务

发布过程一方面是如此复杂,另一方面却又频繁地被执行。所以单单将发布事务做到单次发布的自动化是远远不够的。

为了应对大规模扩张,每个团队必须能够自给自足。故此,很多公司会成立工程效率团队。工程效率工程师将负责开发工程效率平台,包括发布相关的工具,制定发布的最佳实践。

这样,产品研发团队可以自己掌控和执行自己的发布流程。

每一个团队都可以决定多久或者什么时候来发布产品的新版本。发布过程可以自动化到“基本不需要工程效率工程师干预”的程度。很多项目都是利用自动构建工具和部署工具平台来自动构建、自动发布的。发布过程是真正自动化的,工程师仅仅在发生问题时才会进行干预。

这就是自服务的思想。

在这种配合模式下,团队之间配合有着清晰的边界。工程效率团队为发布平台的效率负责,产品研发团队为产品负责。用工程师土话来说,这叫 “吃自己的狗粮”。

追求速度

以什么样的频率来发布新版本比较好?

我们认为在质量保障,能力满足的前提下,越频繁越好。

可以从两个角度来看版本发布的频率。

其一是市场竞争。产品迭代速度可以看作市场竞争力的体现。尤其是面向用户的软件,发布频率往往需要非常频繁。甚至有的团队会采用一种 “测试通过即发布(Push On Green)” 的发布方式,也就是说,发布所有通过测试的版本。

其二是工程质量。我们认为,频繁的发布可以使得每个版本之间的变更减少。这种方式使得测试、出错的调试和定位工作变得更简单。

所以,无论是从市场竞争还是工程质量管理的角度,我们都鼓励这样的版本发布哲学:

少量发布、频繁发布。

从数据驱动的角度,我们需要监测各种数据,尤其是我们关注的核心指标。例如,我们需要监测发布速度,也就是从代码修改提交到部署,再到生产环境一共需要多长时间。

重视质量,尊重流程

在发布流程中,有很多需要进行质量保障的环节。包括:

  • 代码评审(Code Review),批准源代码改动;
  • 批准创建新的发布版本,基于源代码仓库的某个版本,以及可能的少量 Bug 修改;
  • 批准实际去部署某个发布版本;
  • 批准配置修改。

要确保在发布过程中,只有指定的人才能执行指定的操作,而不能随随便便跳过必要的环节进行发布。另外,SRE 需要非常了解某个新发布中包含的所有具体改动,以便在发布出现问题时可以更快地进行在线调试。

这意味着,自动化发布系统需要能够整合并提供每个发布中包含的所有改动的报告,包括但不限于源代码修改的记录、Bug Issue、配置修改等等。

配置管理

配置管理在发布过程中看起来很小,但是它其实是线上不稳定性的重要来源。

配置管理随时间在不停地发展。七牛云早期通过代码仓库来管理线上环节的所有配置。这有非常大的好处,所有的配置变更就如同源代码变更一样,可以被跟踪,也可以进行严格的代码评审。

但随着集群规模的增加,这种方式的弊端也越来越突出。

最大的问题是,配置变更并不完全来源于版本发布。线上故障也会引发配置变更,比如 A 机器由于某种原因要下线,可能需要把服务迁移到 B 机器,这也会引发配置变更。

随着机器数量的增加,线上配置变更就会变得相当频繁。

基于代码仓库做配置变更管理,在应对硬件故障时显得很拙劣。在理想情况下,硬件故障的响应应该是免操作的,不需要 SRE 进行任何操作。

有两个方式可以解决这个问题。

方式一是引入配置中心,把有些高频的配置变更支持做到应用逻辑中去。服务治理中有一个子课题叫 “服务发现”,就是基于这样的思想。

方式二是将配置管理与物理硬件环境彻底进行解耦,这也是数据中心操作系统(DCOS)在做的事情。本质上,你也可以把它理解成是将高频的配置变更支持做到应用逻辑中,只不过这由一个基础平台来实现罢了。

结语

今天我们探讨服务治理的第一个环节:发布与升级。它包括了以下这些子过程:

  • 构建;
  • 测试;
  • 打包;
  • 部署;
  • 配置变更。

我们并没有探讨具体的发布与升级系统怎么做,虽然业界针对发布的各个环节其实都有蛮多的实作案例。如果你正在评估应该采纳什么样的系统,可以结合我们今天探讨的发布哲学来进行评估。

发布系统非常复杂,有很大的事务工作量。要做到高效的发布能力,工程师思维是关键性的支撑,我们需要坚持以系统化的思维来彻底解决发布问题。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们聊聊 “日志、监控与报警”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 日志、监控与报警

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲我们介绍了发布与升级,这是一项复杂的事务,有非常长的业务流程,包括:构建、测试、打包、部署以及配置变更。但总体上来说,发布与升级在 SRE 的工作范畴中,还并不是最难工程化的事务工作。我们简单分析就可以明白:发布与升级总体上来说,只和集群中服务之间的调用关系有关,而与具体服务的业务特性没有太大的相关性。

真正难工程化的是监控与报警。

好的监控与报警系统是怎样的?

监控一个复杂的业务系统本身就是一项极其复杂的事务。即使在具有大量现成的基础设施的情况下,设计监控项、收集数据指标、显示并提供报警支持这些工作,通常需在10 人的 SRE 团队中,可能就需要有 1~2 人全职进行监控的构建和维护工作,这些工作都是非常定制化的,与具体业务密切相关。

如果我们把服务比作一个人的话,发布与升级更像是一个交通工具,尽管内部机制也许复杂,但是从功能上来说,它只是把我们载到某个目的地,人的个性在这里并不需要得到充分的重视。

但监控与报警不同,它更像是私人医生,需要因人而异,因地制宜,提供一套完整的健康保障方案。

监控与报警的目标是什么?

简单说,监控的核心目标就是要解决好两个问题:其一,什么东西出故障了。其二,为什么它出故障了,根因在哪里。

一个监控与报警的系统(简称监控系统),在于它可以让一个业务系统在发生故障时主动通知我们,或者告诉我们即将发生什么。当系统无法自动修复这个问题时,通过报警引导 SRE 人工来介入。SRE 首先判断目前是否真实存在某个故障,如果存在故障则采取手段来消除故障,解决故障后最终还要找出导致故障的根源问题,并推动各方去彻底解决掉它,避免未来出现类似的问题。

一个初级的监控系统是比较好做的。我们坚持不懈地往里面加各种监控项,系统看起来越来越完善。很多人会由此而感觉到了满足。

但是,一个有 “完善的监控项” 的监控系统,是不是就是一个好的监控系统了?

这当然不是。

要做好监控,我们一定要分清楚什么是现象,而什么是原因。

某个监控项不正常了,“某个东西出故障了”,这只是一种现象(Symptom)。但这个监控项 “为什么出问题” 了,则是原因。我们找到的原因,也可能只是中间原因,而不是根因(Root Cause)。

在一个复杂系统中,某一个服务的现象可能是另外一个服务的原因。例如,数据库性能问题。数据库读操作很缓慢是数据库 SRE 角度检测到的一个现象。然而,对前端业务的 SRE 来说,他们看到的是网站缓慢这个现象,而数据库读操作的缓慢则是原因。当然数据库慢只是一个中间原因,它还无法对应到一个明确的动作,但这对缩小问题定位范围已经产生了巨大作用。

一个完善的监控系统,并不是 “报警很多很完善” 的系统,而是 “信噪比高,有故障就报警,有报警就直指根因” 的监控系统。

“信噪比高”关注的是误报率问题。

我们不应该仅仅因为 “某东西看起来有点问题” 就发出报警。报警处理会占用员工的宝贵时间。如果该员工正在工作时间段,该报警的处理会打断他原本的工作。如果该员工正在家,该报警的处理则会影响他的个人生活,甚至是把他从睡眠中叫醒。

当报警出现得太频繁时,员工就会进入“狼来了” 效应,怀疑报警的有效性甚至忽略报警。有的时候在报警过多的时候,甚至会忽略掉真实发生的故障。由于无效信息太多,分析和修复可能会变慢,故障时间也会相应延长。高效的报警系统应该提供足够的信息,并且误报率非常低。

“有故障就报警” 关注的是报警的覆盖率。如果我们通过客户报障或其他手段发现故障,对于监控系统来说,就应该认为是一次监控事故。

“有报警就直指根因” 关注的是报警的有效性和排障的效率。一个初级的监控系统,往往很容易产生的报障现象是,线上发生一个故障会同时会产生大量的报警,这些报警杂乱无章,接警人看到一堆报警后,并没有有效的信息指引他如何快速消除故障,并找到故障的根本原因。

日志:监控与报警的基础

一个现代化的监控与报警系统,最底层往往基于一个日志系统。什么是日志?它不局限于业务服务输出的程序日志,实际上更多的数据来源是各种系统指标的采集。简单说,凡是时序相关的、持续产生的数据,都可以称之为日志。

原始的日志有可能是结构化的,也可能是非结构化的。如果是非结构化的数据,那这就需要先经过文本解析过程进行结构化。结构化后的日志存储系统,本质上就是一个时序数据库。

日志通过收集、结构化、清洗等步骤后,就可以对外提供日志分析和检索服务。分析和检索的结果可以直接提供数据结果,也可以用报表形式呈现,或者在满足特定条件下触发报警。

采用时序数据库来做监控系统的好处是,不依赖特定的脚本来判断系统是否正常工作,而是依赖一种标准数据分析模型进行报警。这就使得批量、大规模、低成本的数据收集变得可能。

收集回来的数据可以同时用来渲染图表和报警。报警规则往往用简单的数学表达式形式表达。这样,数据收集过程就不再是一个短暂的一次性过程,所有收集回来的历史数据都可以被用来作为报警规则的计算因素。

不同监控规则产生的报警(alert)可能有不同的优先级,比如紧急状态和一般状态。紧急状态的报警通常意味着已经产生或即将产生用户可见的影响,需要立刻介入处理。而一般状态则可以延迟到第二天再处理。报警的目标对象不一定是某个人,而可能是某个系统,比如工单。

当然,监控一个指标并不一定是出于报警的考虑。它还可以有各种原因,如下:

  • 分析长期趋势。例如每日活跃用户的数量,以及数量增长的速度。
  • 跨时间范围的比较,或者是观察 AB 测试组之间的区别。例如,增加新节点后,memcache 的缓存命中率是否增加?网站是否比上周速度要慢?使用 A 方案和 B 方案哪个更有助于用户的活跃?
  • 临时性的回溯分析,即在线调试。例如,我们网站的请求延迟刚刚大幅增加了,有没有其他的现象同时发生?

添加监控项

搭建好了监控系统,收集上来了监控数据,我们第一件事情就是添加监控项。不得不承认,它是监控与报警系统中最难的一件事情。我们都需要注意些什么呢?

4 个黄金指标

首先,让我们谈谈监控系统的 4 个黄金指标。它们分别是:延迟、流量、错误和饱和度。如果我们只允许监控某个系统的 4 个指标,那么就应该监控这 4 个指标。

延迟,也就是服务处理某个请求所需要的时间。延迟指标区分成功请求和失败请求很有必要。例如,某个由于数据库连接丢失或者其他后端问题造成的 HTTP 500 错误可能延迟很低。在计算总体延迟时,如果将 HTTP 500 回复的延迟也计算在内的话,可能会产生误导性的结果。但是,“慢” 错误要比 “快” 错误更糟!极少量的慢错误请求就可能导致系统吞吐能力的大幅降低。因此,监控错误回复的延迟是很重要的。

流量,是系统负载的度量方式。通常我们会使用某个高层次的指标来度量,比如 IOPS、每秒交易量等。不同的业务系统的流量指标有较大差别。例如,对于普通 Web 服务器来说,该指标通常是每秒 HTTP 请求量(IOPS),同时可能按请求类型分类(静态请求与动态请求)。对于音频流媒体系统来说,这个指标可能是网络 I/O 速率,或者并发会话数量。针对键值存储系统来说,指标可能是每秒交易数量,或每秒的读取操作数量。

错误,也就是请求失败的数量。请求失败的表现很多样。最简单的当然是显式失败,例如 HTTP 回复 500 状态码。还有的请求可能是隐式失败,例如 HTTP 回复虽然是 200,但回复内容中提示出现了错误。还有一种是策略原因导致的失败。例如,如果我们要求回复在 1s 内发出,任何超过 1s 的请求就都认为是失败请求。

饱和度(Saturation),它度量的是服务容量有多 “满”。通常是系统中目前最为受限的某种资源的某个具体指标的度量。比如,在内存受限的系统中,即为内存的使用率;在 I/O 受限的系统中,即为 I/O 的使用率。要注意,很多系统在达到 100% 利用率之前性能就会严重下降,增加一个利用率目标也是非常重要的。

饱和度是最需要进行预测的指标。比如,一个典型的预测是:“看起来数据库会在 5 个小时内填满硬盘”。

在复杂系统中,饱和度可以配合其他高层次的负载度量来使用。例如,该服务是否可以正常处理两倍的流量,是否可以应对 10% 的额外流量?这些是 SRE 面临的非常现实的容量规划上的问题。

为什么我们需要做负载测试,也是为了评判服务的饱和度,究竟受何种度量指标的影响。大部分服务都习惯使用某种间接指标,例如 CPU 利用率,或者网络带宽等来评判饱和度,因为这些指标通常有一个固定的已知的上限。

延迟增加是饱和度的前导现象。所以 99% 的请求延迟(在某一个小的时间范围内,例如五分钟)可以作为一个饱和度早期预警的指标。

如果我们度量所有这 4 个黄金指标,同时在某个指标出现故障时发出报警,或者对于饱和度来说,快要发生某类故障时进行预警。只要能做到这些,服务的监控就基本差不多了。

关于长尾问题

构建监控系统时,很多人往往会采用某种量化指标的平均值。比如,延迟的平均值,节点的平均CPU 使用率等。这些例子中,后者存在的问题是很明显的,因为 CPU 的利用率的波动可能很大。

但是其实同样的道理也适用于延迟。如果某个 Web 服务每秒处理 1000 个请求,平均请求延迟为 100ms。但是 1% 的请求可能会占用 5s 时间。如果用户依赖好几个这样的服务来渲染页面,那么某个后端请求的延迟的 99% 值很可能就会成为前端延迟的中位数。

区分平均值的 “慢” 和长尾值的 “慢” 的一个最简单办法是将请求按延迟分组计数。比如,延迟为 0~10ms 之间的请求数量有多少,30~100ms 之间,100~300ms 之间等。可以按分组制作成直方图。将直方图的边界定义为指数型增长(这个例子中倍数约为3)是直观展现请求分布的最好方式。

采用合适的精度

应该仔细设计度量指标的精确度,这涉及到监控的成本问题。例如,每秒收集CPU负载信息可能会产生一些有意思的数据,但是这种高频率收集、存储、分析可能成本很高。如果我们的监控目标需要高精度数据,但是却不需要极低的延迟,可以通过采样+汇总的方式降低成本。例如:

  • 将当前 CPU 利用率按秒记录。
  • 按 5%粒度分组,将对应的 CPU 利用率计数+1。
  • 将这些值每分钟汇总一次。

这种方式使我们可以观测到短暂的 CPU 热点,但是又不需要为此付出高额成本进行收集和保留高精度数据。

怎么添加监控项?

为什么我前面说,添加监控项是最难的事情?添加监控项看起来像是一个很繁琐的事务工作,但实际上它非常依赖你的架构能力。

一个很牛的监控 SRE,他要干的绝对不是不停地添加新的监控项以 “完善某个业务的监控”。

少就是指数级的多!

就和软件开发工程师需要经常需要重构,去删减掉自己历史的无效代码一样,负责业务监控的 SRE 也需要经常重构自己的监控指标,以期用最少的监控项,来全面覆盖和掌控系统的健康状况。

我们需要遵循的监控与报警的设计哲学。记住这些哲学将有助于鼓励团队在解决问题时向正确的方向进行。

当我们打算为监控系统增加新规则时,在心中回答以下问题:

  • 该规则是否能够检测到一个目前检测不到的、紧急的、有操作性的,即将发生或者已经发生的用户可见故障?
  • 是否可以忽略这条报警?是否还有其他人也会收到这条报警?
  • 这条报警是否确实显示了用户正在受到影响?是否存在用户没有受到影响也可以触发这条规则的情况?例如,系统维护状态下发出的报警是否应该被过滤掉。
  • 收到报警后,是否要进行某个操作?是否需要立即执行该操作,还是可以等到第二天早上再进行?该操作是否可以被安全地自动化?
  • 该操作的效果是长期的,还是短期的?

以上这些问题其实反映了在报警上的深层次的理念:

  • 每当收到紧急状态的报警时,应该立即需要我进行某种操作。每天只能进入紧急状态几次,太多就会导致“狼来了”效应。
  • 每个紧急状态的报警都应该是可以具体操作的。
  • 每个紧急状态的报警的处理都应该需要某种智力分析过程。如果某个报警只是需要一个固定的机械化动作,那么它就应该被自动化。
  • 每个紧急状态的报警都应该是关于某个新问题的,不应该彼此重叠。

接警:故障响应

接到报警我们应该怎么做?

接警后的第一哲学,是尽快消除故障。找根因不是第一位的。如果故障原因未知,我们可以尽量地保留现场,方便故障消除后进行事故的根因分析。

每一个监控项的报警应该尽可能代表一个清晰的故障场景。这会极大改善监控的有效性,直指根因,消除故障自然也就更快速。

虽然越少越好,但是不清楚故障原因的报警是难以避免的,否则我们的报警就难以完整覆盖所有的故障。比如,对于业务服务的入口级的故障,我们怎么也得报出来。每发生一次新的入口级的故障场景,我们就有必要把这个故障场景独立出来,这样下一次出现同类故障时我们就能够直接定位到根因了。

一般来说,有清晰的故障场景的监控报警,都应该有故障恢复的预案。而在那些故障原因不清晰的情况下,消除故障的最简方法是基于流量调度,它可以迅速把用户请求从故障域切走,同时保留了故障现场。

解决了线上的故障,我们就要开始做故障的根因分析,找到问题发生的源头。

这主要仰仗两种分析方法。

一种是看看同时间段下,除了我们的故障现象外,还有那些异常现象同时发生了。如果我们的监控数据足够全面,这种分析方法可以很快地定位到 “怀疑对象”。

另一种方式是分析故障请求的调用链。这方面的技术已经非常成熟。很多公司的业务实现都会把请求从前端入口到后端的整个调用过程通过一个 request id 串起来。

通过随机抽样一些故障请求的日志,然后在日志系统中搜索 request id 找到整个调用链,分析调用链找到问题的根源。

七牛云发布的日志系统 Pandora,提供了完整的监控、报警和故障的根因分析模块。除了传统的 SRE 方法论外,我们也探索基于 AI 的智能化监控与根因分析能力。

结语

监控与报警是一项非常复杂的事务,这种难度不是因为业务流程复杂导致的,而是因为与业务的高耦合导致。

监控系统需要跟随不断演变的软件一起变化。软件的全局或某个局部发生重构,负载特性和性能目标就会变化。

某个不常见的、自动化比较困难的报警,很可能随着用户增长很快变成一个经常触发、需要一个临时的脚本来应对的问题。这时,就需要有人去寻找和消除背后的根源问题,而不是在持续被故障牵着鼻子走。

解决故障的方案有可能是需要进行业务的架构调整。这也是监控与报警的复杂性所在:你需要和业务开发工程师一起配合去完善系统。

添加监控看起来像是一个很繁琐的事务工作,但实际上它非常依赖你的架构能力。

一个很牛的监控 SRE,他要干的绝对不是不停地添加新的监控项以 “完善某个业务的监控”。

少就是指数级的多!

就和软件开发工程师经常需要重构,去删减掉自己历史的无效代码一样,负责业务监控的 SRE 也需要经常重构自己的监控指标,以期用最少的监控项,来全面覆盖和掌控系统的健康状况。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们聊聊 “故障域与故障预案”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

加餐:怎么保障发布的效率与质量?

| 故障域与故障预案

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

我们前面介绍了服务的发布和监控,来保障线上业务的持续服务。但是,不管我们怎么小心谨慎,故障仍然会不可避免地发生。并不是所有的故障都会影响到用户,一些局部的故障如果处理得当,对用户并不产生可见的影响。

今天我们就聊聊故障产生的原因与对策。可以导致故障的因素非常多。在 “47 | 服务治理的宏观视角” 一讲中,我们大体将故障类型分为以下几种。

  • 软硬件升级与各类配置变更,即发布。
  • 软硬件环境的故障。
  • 终端用户的请求。比较典型的场景是秒杀类,短时间内大量的用户涌入,导致系统的承载能力超过规划,产生服务的过载。当然还有一些场景,比如有针对性的恶意攻击、特定类型的用户请求导致的服务端资源大量消耗等,都可能引发服务故障。

我们先来看看 “软硬件升级与各类配置变更”,也就是发布。发布过程引发的故障实际上有别于另外两种故障类型,它源于我们主动对系统作出变更,属于过程型的故障。

变更是故障的第一大问题源头。所以我们在发布的过程中多谨慎都不为过。不过我们今天的主题并不是以此类故障为主,我们已经在 “加餐 | 怎么保障发布的效率与质量?” 一讲中专门探讨了发布的过程管理应该怎么做。

大部分情况下,变更导致的故障在短期内就会得以暴露,这也是我们采用灰度发布这样的手段能够达到规避故障风险的原因。但当我们讨论故障域的时候,我们还是应该意识到,灰度并不能发现所有变更风险。有时代码变更引发的故障需要达到特定的条件下才得以触发,例如数据库规模达到某个临界点可能导致数据库操作异常。

这类有很长潜伏期的故障风险,是非常令人头疼的。一方面它爆发时点与风险产生的时点间隔太远,不容易定位。另一方面,有可能对有状态的服务而言,它发作起来就已经是不可控制的灾难。怎么才能避免这类问题?严谨的白盒代码审查和全面的测试覆盖率提升,才有可能消除此类风险,至少理论上是这样。

软硬件环境的故障

今天我们重点讨论的是 “软硬件环境的故障” 引发的服务异常。我们追求的是 24 小时不间断服务,所以站在更长的时间维度,或者更大的集群规模维度看,可以预期软硬件环境的故障是一种必然。

仍然拿我之前举的例子来说,假设一块硬盘的平均寿命是 3 年,也就是说大约 1000 天会出现一次坏盘。但是如果我们集群有 1000 块硬盘,那么平均每天就会坏 1 块盘。故障从偶然就变成了必然。

怎么为 “软硬件环境的故障” 做好故障预案?常见做法无非两种:要么用 SRE 的手段,通过监控系统发现特定的软硬件故障并进行报警,收到报警后并不是通知到人,而是触发去自动执行故障恢复的脚本。另一种做法是干脆把故障恢复的逻辑实现在业务服务的代码逻辑中,避免因软硬件故障而出现单点问题。

这两种方法本质上是相同的,只不过是选择的技术手段有所差别。无论通过哪种方式,要想找到所有可能的故障点,我们就需要对服务所有请求的路径进行分析。要正确画出请求链路,需要了解以下两个方面。其一,对我们所依赖的基础架构要了解,否则我们不知道请求是如何到达我们的业务服务器的。其二,要对我们服务的业务架构了解,知道请求的逻辑是怎么样的。当然对业务架构不甚了解问题不大,代码就在那里,我们可以看着一行行代码,把请求的链路忠实画出来。

如何通过源代码来看请求链路?

首先,IO 之外的普通业务代码是不太会出问题的。要出问题也是业务逻辑错误的问题,这类故障我们并不归类到 “软硬件环境的故障” ,而是 “软硬件升级与各类配置变更”。这个话题我们前面已经聊过,不再展开。

IO 代码包括哪些?常见的是磁盘 IO 和 网络 IO。当然完整来说,所有外部设备的操作都算,只不过我们极少会碰到。

考虑到代码的分支结构,我们的请求链路可以画出一棵树,树的每个节点是一次 IO 操作。一个示意性的请求链路图如下:

图片: https://uploader.shimo.im/f/Teq8OPTMQy4MkGzi.png

表面上看起来,这里每一个 IO 请求,我们都应该考虑万一失败了我应该怎么处理,以确保用户的 API 请求都被正确地处理。但实际写业务代码的时候,我们大部分情况下是比较心宽的,并不会处处小心翼翼去做异常的恢复。这里面的原因是因为前端有负载均衡。

下面这个图你很熟悉了,它是服务端程序的宏观体系架构图。

有了负载均衡,实现业务架构的心智负担小了很多。遇到任何异常,我们返回 5XX 错误的请求结果,这时负载均衡发现请求出错了,就会把该请求发给其他的业务服务器来重试。这就解决了业务架构的单点问题。

不同服务的 API 请求链路会有所差异。但是大体来说,一个经典的 API 请求,如果我们把所有基础架构也考虑在内的话,它的故障点主要有:

  • 网络链路,包括用户端网络和服务端网络;
  • DNS;
  • 机房;
  • 机架;
  • 交换机;
  • 负载均衡;
  • 物理服务器;
  • 业务服务本身;
  • 缓存/数据库/存储。

我们一一进行分析。

先看网络链路,它包括用户端网络和服务端网络。

如果是用户个人的网络出问题,就没法正常为这个用户服务了,但是从服务提供方来说,只要这个用户不是超级 VIP 用户,他的损失并不大,一般不会为他做太多的事情。

但如果不是个例用户问题,而是某个地区的运营商网络出问题了,那么对业务可能就造成了较大程度的影响。只是对于大部分业务服务提供方来说,他自身并没有太大的实力去解决它。但我们如果开放性考虑这个问题的话,这个问题也并不完全是无解,今天暂且不提。

只要用户端网络没问题,它连接到服务端网络就非常可控了。整个网络链路非常复杂,它是一个有向图。为了确保网络请求的通畅,服务端网络链路会准备多个,这时我们通常用多个服务的域名,每个域名代表一个网络链路,由客户端进行链路的选择与重试。

当然如果我们的服务是单机房的,那么它和用户端网络一样,也会有网络链路的单点故障。如果投入产出比可以的话,我们往往会做多机房的容灾。这一点我们分析机房级别的故障再展开。

我们再看 DNS,它负责的是将域名解析为 IP。DNS 服务分 DNS 权威服务器和 DNS 递归或转发服务器。DNS 权威服务器是域名解析服务的真正提供方,而 DNS 递归/转发服务器是域名解析的缓存。

DNS 权威服务怎么防止单点?提供多个就好。域名服务商通常允许我们为该域名配置多个 DNS 权威服务,比如 ns1.example.com 和 ns2.exmaple.com。这两个或多个 DNS 权威服务器最好不要在同一个机房,这样才可以达到容灾的目的。

DNS 递归/转发服务分用户侧与服务侧。用户侧来说,只要用户请求我们的服务,第一步就需要把我们服务的域名解析为 IP,就需要请求 DNS 递归/转发服务。除了用系统默认的之外,也有一些公共的 DNS 递归服务,比如 Google 提供了 8.8.8.8 和 4.4.4.4。

一般操作系统都允许用户自行设置 DNS 服务器,它可以是多个 DNS 递归或转发服务器。比如在 MAC 下的配置界面如下图。

服务端侧也其实是一样的,只不过它对 DNS 服务器的需要来源于业务服务向外部发起请求的场景,比如我的业务服务是个爬虫,它需要去抓网页,或者其他原因需要请求某种公网的服务。

但服务端侧的解决方案和用户侧差别比较大,我们很少会配置 8.8.8.8 和 4.4.4.4 这类公共的 DNS 递归服务器,而是自己在机房中搭建一组高可用的 DNS 服务器。同样地,我们可以通过给操作系统配置多个 DNS 服务器来达到避免单点故障的目的。

聊完 DNS,我们来看下机房。机房级别故障可以有多方面的原因,比如机房断电、断网。为了防止机房故障造成服务中断,我们就需要做多机房容灾。

如果我们的服务偏静态,也就是写很少,大部分情况下都是读操作,那么可以用 2AZ 架构(双机房容灾)。但是在大部分通用的业务场景下,我们建议 3AZ 架构(三个机房容灾)。

在整体业务的体量足够大的情况下,3AZ 不一定会比 2AZ 成本高。因为 2AZ 我们每个 AZ(可用区)的容量规划,必须得能够支撑全部的业务体量,否则不足以支撑某个 AZ 挂掉。而 3AZ 我们可以让每个 AZ 的容量只是全部业务体量的 1/2。这样我们总成本只花了 1.5 倍,而不是 2 倍。

任何容灾其实最麻烦的都是数据库和存储,也就是业务状态是如何容灾的。从数据库的主从的选举角度来说,3AZ 也比 2AZ 好。因为 2AZ 的话,一个 AZ 下线就意味着数据库有一半节点下线,这时候就没法选举出新的主(Master)节点。

机房故障会导致一批 IP 下线。这时我们就需要在 DNS 解析中把这批 IP 下线。但是 DNS 解析的调整生效周期比较长,具体时间与 DNS 条目设置的 TTL 相关,比如 1 小时。但有的 DNS 递归/转发服务器会忽略 TTL,这就导致 DNS 解析生效时间变得更加不确定。

要解决这个问题,通常的做法是在客户端引入 HTTP DNS 支持。顾名思义,HTTP DNS 基于 HTTP 协议来提供 DNS 解析服务,绕过了传统的 DNS。结合客户端自身适当做下的 DNS 缓存,就可以解决掉 DNS 解析生效不及时的问题。

接下来聊聊机架故障。它和机房故障类似,整排机架的机器都同时断电或断网。为了避免机架故障导致的负面影响,我们一般在服务编排时会考虑两点:

  • 同类服务尽可能分散到不同的机架上,避免因为机架故障导致某个服务整体下线。
  • 同一份数据的多个副本,或者同一 EC 条带的不同数据块,尽可能分散在不同的机架上,避免因为机架故障导致该数据不可访问。

聊完了机架,我们接着聊交换机。为了避免交换机故障导致大范围的机器下线,我们用两个交换机进行相互热备,这通过交换机之间的 “热备份路由协议”(Hot Stand by Router Protocol,简称 HSRP)进行。

下一个故障点是负载均衡。负载均衡的任何一个实例发生故障,意味着我们业务的入口发生故障。比如,假设我们负载均衡集群有 N 个实例,其中某个实例发生了故障,就意味着有 1/N 比例的用户受到了影响。这通常是不可接受的。

你可能会想到说,我把故障的负载均衡实例的 IP 从 DNS 解析中去除,就可以消除掉这个故障。这理论上可行,但是我们实际很少会这么做,原因和前面说的 DNS 解析的生效时间过长有关。我们不能够接受长达 1 小时的入口级故障。

比较好的做法是,所有负载均衡实例的 IP 是 VIP,即基于虚 IP 技术。关于 VIP 技术的介绍,你可以回顾一下 “35 | 流量调度与负载均衡”。一旦检测到 VIP 对应的主实例故障,我们就通过 VIP 技术把它的流量打到其他负载均衡实例上,这样就避免了因为负载均衡实例故障导致的损失。

下一个故障点是业务服务本身。这块相对容易。只要我们坚持业务服务器 “无状态”,那么任何一个业务服务器故障,都不会对用户产生实际的影响。这也是强大的基础架构带来的好处,它让我们做业务更轻松了。

这个架构课开课以来,看到一些人反馈说不太理解为什么从计算机底层原理开始谈起,好像和常规的架构课很不一样。事实上关于这一点我在开篇第0讲就解释过理由,今天我在这里重申一下:

  • 首先,基础架构是业务架构的一部分。不了解基础架构,你就不知道自己写的软件背后都发生了什么,你就无法掌控全局,这对你思考架构演进会有很大的局限性,因为你是 “戴着脚镣跳舞”。
  • 其次,基础架构是最宏大的架构实践案例,需要我们好好感悟。我们不只是要知道基础架构怎么用,还应该理解它为何演变成今天这样。对于优雅的基础架构设计,我们应该要有强烈的共鸣,惊喜赞叹。如果你没有感觉,说明你对架构思维也还没有感觉,也就更不可能构建出极致的架构。
  • 最后,在后面总结架构思维的时候,我们会以大家耳熟能详的基础架构作为例子,这一定程度会更加深入人心。当然,具体业务实战方面的案例也必不可少,我们会结合两者一起谈。

最后我们聊聊缓存、数据库和存储。这些服务有一个共同特征,它们都是有状态的服务。

缓存集群通常是单副本的,通过特定的分片算法,比如一致性哈希,来定位具体的缓存实例。

部分缓存实例挂掉,一般来说带来的冲击并不大,基本也就是缓存命中率瞬间有个下降,然后逐步回升。但是如果缓存实例挂掉过多,甚至极端情况下全部挂掉的情况下,就会导致后端数据库的压力很大,出现延时变高,甚至出现雪崩现象。

对于数据库压力太大导致雪崩,数据库再起来就又立刻被打爆,怎么都起不来的情况,最好的做法是在数据库层面就做好过载保护。在数据库不支持自我保护的情况下,一个替代的做法是通过 SRE 的手段来实现:一旦监控系统发现数据库过载了,就选择由负载均衡来扔掉部分用户请求。

如果雪崩已经发生,常见的做法是让负载均衡先扔掉足够多的用户请求,让数据库能够正常服务用户。然后观察数据库的负载情况,逐步减少负载均衡扔掉的用户请求量,直至最后完全正常提供服务。

数据库和存储要保证高可靠(高持久性)和高可用,必然是多实例的。无论是什么架构,对于特定的数据,这些实例有主(Master)有从(Slave),一旦主节点挂掉就会触发选举确定新的主。当然有一些老的数据库是基于一主一备,备节点 Stand-by 直到主节点挂掉时接替它继续工作。但是这种模式已经太过时了,并不推荐使用。

故障恢复

清楚了所有的故障点,我们就可以针对性去做故障预案。对于大部分的故障来说,我们会优先倾向于通过切流量来消除故障。

流量切换,需要遵循最小切量原则。能够通过更细粒度的切量动作来消除故障,就应该用细粒度的。

通过以上分析,我们可以看出流量切换的控制点有这样几个:

  • 负载均衡;
  • 负载均衡实例的 VIP 入口;
  • DNS 解析。

但是故障根因如果是有状态服务,比如数据库与存储,那么我们就很难通过切量来消除故障。这时我们应该用过载保护机制来对服务进行降级,也就是在特定环节把一定比例的用户请求扔掉。

扩容也是解决数据库与存储集群压力大的常规思路,这块我们后面再详细展开。

结语

今天我们就聊聊故障产生的原因与对策。可以导致故障的因素非常多,我们大体分为以下几种。

  • 软硬件升级与各类配置变更,即发布。
  • 软硬件环境的故障。
  • 终端用户的请求。

今天我们重点讨论的是 “软硬件环境的故障” 引发的服务异常及其故障预案。我们追求的是 24 小时不间断服务,所以站在更长的时间维度,或者更大的集群规模维度看,可以预期软硬件环境的故障是一种必然。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们聊聊 “故障排查与根因分析”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 故障排查与根因分析

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

写在故障排查之前

一旦 SRE 发现了我们线上业务出现故障,要如何解决呢?

正确解决问题的逻辑不是当场把问题修复好,而是用最快的方式让问题得以缓解或消失。考核 SRE 的核心指标中,通常会有季度故障率或年故障率指标。一次磨蹭的线上故障恢复,可能直接导致团队的年终奖取消。

及时故障恢复的手段应该怎么样,首先要看故障原因的初判。如果是在软硬件环境升级中发现的故障,通常的做法是版本回滚。如果是因为用户请求导致负载过高,则考虑扩容。如果扩容不能让问题消失,则考虑服务降级,关闭一些不重要的功能以降低负载,或者主动抛弃一定比例的请求。如果是软硬件环境本身故障导致的业务故障,则通常是进行流量切换,把用户流量导向其他没有问题的任务实例。

解决了故障,我们就要开始定位引发故障的根本原因。

需要理解的一点是,定位故障原因的细节必然是和具体业务服务息息相关。但是有效地应对紧急问题,及时恢复故障的方法论,对每个业务团队来说却往往都是完全通用的。

另外,并不一定要线上出现了故障才需要去排查原因。

我们做事不能这么 “故障驱动”,要居安思危,主动出击。一个 SRE 例行的工作应该包含服务入口的指标检查,一旦发现存在非预期的指标波动,虽然有可能还没有触发警告线水位,就应该去排查问题。这就如同医生需要对病人定期进行诊断一样,这样的工作不一定要病人真生病了才去进行。

例行的排查过程需要有结论,发现问题风险要有应对方案。要避免团队陷在例行指标检查中,把它看做一项纯事务的工作来做。

SRE 的确看起来会有很多事务性的工作。但其实对工程师来说,这是一份极好的工作。

为什么我这么说?

我想从我自己的工作经历谈起。我的第一份工作是在金山,做的是 WPS 办公软件。我还没有毕业就去实习了,第一项工作任务做的是存盘和读盘,我们简称 IO。

在大部分工程师看来,IO 是一个非常事务性的编码工作,否则也轮不到我一个新手实习生来做。IO 工作干的最多的事情就是把一个个变量写到磁盘中,再把它从磁盘读出来恢复变量内容。听起来很无聊,对吧?

但实际上它非常核心。

首先,用户文件对办公软件来说,是最重要的资产。它的位置就如同用户关系在微信和 QQ 中的地位类似。当时 WPS 存盘读盘用的是 MFC 序列化框架。用这套框架最大的问题是版本不兼容。新版本保存的文件,老版本读不出来。从需求分析角度来说,这是极不应该发生的,这根本就是在抛弃用户。

其次,数据是软件的灵魂。要了解一个软件的设计思想,最快的方法不是读代码,是研究它的数据结构。所以我当时虽然没有在做具体的某个功能,但是每每分析到微软的文件数据格式中出人意表的精巧设计,就会和团队中做对应功能的成员沟通,把微软设计思想告诉他们,而这往往能让他们受益匪浅,甚至改变自己的架构设计。

最后,业务功能是点,IO 是面。通过做存盘和读盘工作,我就成为了第一个对办公软件整体的架构设计思路有了全局理解的人。这很可能也是我仅仅毕业两年,就得以能够主导办公软件这样庞大的业务系统的整体架构的原因。

可能会有人觉得我太幸运了,第一个工作任务就是 IO 这样的核心工作。

但是我想说的是,遇到这种幸运的人,我显然不是第一个。对 WPS 团队来说,我只是第一个抓住这个幸运机会的人。

其实对于服务端工程师来说,SRE 工作也是一样的幸运机会。

对于一个服务端软件,最大的挑战并不是把业务逻辑写出来,而是写出来后如何保障 7x24 小时持续不间断的运行。

如果你不碰线上环境,你就也无法成为最理解系统的专家。SRE 工作是最有机会成为专家的,它和架构师一样,需要整体理解业务架构,并且通过线上环境的理解,去反向影响业务架构,以提高系统的稳定性。

从另一个角度来说,SRE 有越多的事务工作,就意味着这里面有越多的价值洼地还没有被挖掘。这也是为什么近年来服务治理的迭代如此日新月异的原因。

今天的 SRE 是幸运的,我们正处在历史变革的浪潮之中。

故障排查的方法论

回到正题。

今天我们想聊一聊的,是我们如何建立一种系统性或者说结构化的方案,来进行故障排查,找到问题的根因。

故障排查是 SRE 的一项关键技能。这项技能有时候会被认为是与生俱来的,有些人有,有些人没有。造成这种误解的原因通常是:对一个经常需要进行排除故障的人来说,让他去解释 “如何” 去进行故障排查是一件很难的事情。

但是我想说的是,故障排查是一个可以自我学习,也是一个可以传授的技能。

新手们常常不能有效地进行故障排查,是因为要做到高效排查的门槛比较高。理想情况下我们需要同时具备两个条件:

  • 对通用的故障排查过程的理解(不依靠任何特定系统)。
  • 对发生故障的系统的足够了解。虽然只依靠通用性的流程和手段也可以处理一些系统中的问题,但这样做通常是很低效的。对系统内部运行的了解往往限制了 SRE 处理系统问题的有效性,对系统设计方式和构建原理的知识是不可或缺的。

让我们一起来看一下基本的故障排查流程。从理论上讲,我们将故障排查过程定义为反复采用假设-验证排除手段的过程:针对某系统的一些观察结果和对该系统运行机制的理论认知,我们不断提出一个造成系统问题的假设,进而针对这些假设进行测试和排除。

我们从收到系统问题报告开始处理问题。通过观察监控系统的监测指标和日志信息了解系统目前的状态。再结合我们对系统构建原理、运行机制,以及失败模型的了解,提出一些可能的失败原因。

接下来,我们可以用以下两种方式测试假设是否成立。第一种方式,可以将我们的假设与观察到的系统状态进行对比,从中找出支持假设或者不支持假设的证据。另一种方式是,我们可以主动尝试 “治疗” 该系统,也就是对系统进行可控的调整,然后再观察操作的结果。

第二种方式务必要谨慎,以避免带来更大的故障。但它的确可以让我们更好地理解系统目前的状态,排查造成系统问题的可能原因。

无论用上述两种方式中的哪一种,都可以重复测试我们的假设,直到找到根本原因。

真正意义上的 “线上调试” 是很少发生的,毕竟我们遇到故障的时候,首先不是排查故障而是去恢复它,这有可能会破坏掉部分的现场。所以,服务端软件的 “线上调试” 往往在事后发生,我们主要依赖的就是日志。这里的日志是广义的,它包括监控系统背后的各类观测指标的时序数据,以及应用程序的程序日志。

为了排查故障,我们平常就需要准备。如果缺乏足够的日志信息,我们很有可能就无法定位到问题的原因。

首先,我们必须能够检查系统中每个组件的工作状态,以便了解整个系统是不是在正常工作。

在理想情况下,监控系统完整记录了整个系统的监控指标。这些监控指标是我们找到问题所在的开始。查看基于时间序列的监控项的报表,是理解某个系统组件工作情况的好办法,可以通过几个图表的相关性来初步进行问题根源的判定。

但是要记住,相关性(Correlation)不等于因果关系(Causation)。一些同时出现的现象,例如集群中的网络丢包现象和硬盘不可访问的现象可能是由同一个原因造成的,比如说断电。但是网络丢包现象并不是造成硬盘损坏现象的原因,反之亦然。况且,随着系统部署规模的不断增加,复杂性也在不断增加,监控指标越来越多。不可避免的,一些现象会恰巧和另外一些现象几乎同时发生。所以相关性(Correlation)只能找到问题的怀疑对象,但是,它是否是问题根源需要进一步的分析。

问题分解(Divide & Conquer)也是一个非常有用的通用解决方案。在一个多层系统中,整套系统需要多层组件共同协作完成。最好的办法通常是从系统的一端开始,逐个检查每一个组件,直到系统最底层。

但真正协助我们找根因的关键无疑是日志。在日志中记录每个操作的信息和对应的系统状态,可以让你了解在某一时刻整个组件究竟在做什么。一些跟踪工具,例如 Google Dapper 提供了非常有用的了解分布式系统工作情况的一种方式。类似 Hadoop HDFS 之于 Google GFS,当前开源领域中也有 Open Tracing 项目对应于 Google Dapper,其项目主页如下:

文本日志对实时调试非常有用。将日志记录为结构化的二进制文件通常可以保存更多信息,有助于利用一些工具进行事后分析。

在日志中支持多级是很重要的,尤其是可以在线动态调整日志级别。平常如果我们记录太多日志会导致成本过高,但是如果需要及时通过调试来定位问题,我们不需要通过重新发布新版本的软件来追加跟踪日志。这项功能可以让你在不重启进程的情况下详细检查某些或者全部操作,同时这项功能还允许当系统正常运行时,将系统日志级别还原。

根据服务的流量大小,有时可能采用采样记录的方式会更好,例如每 1000 次操作记录一次。

不同服务之间怎么把请求给串起来的?

答案是用 Request ID。在整个 Tracing 的业务链中,我们给每个用户的 API 请求分配一个 Request ID,该 API 请求的响应过程中所有相关的日志都会记录 Request ID,以便我们可以便捷地通过它检索到与该 API 请求相关的所有日志。

有时,我们会通过 HTTP 回复包(Respnose)来快捷地得到 API 请求链的概要版本,比如通过 X-RequestTrace 这样的 HTTP 头来得到。这是一种更轻盈的 Tracing 实现,实现非常容易。当然缺点也比较明显,它只能真正去在线调试,而无法像日志那样去查看历史。

仅仅通过 Request ID 来查看请求链,对于故障排查是不够的。定位问题本身就是 “假设-验证排除-再假设-再验证排除” 这样的循环,直至最后定位到问题。所以基于时序数据的日志系统,往往查询支持非常多样化的过滤条件,功能非常强大。如果你对这方面的详细内容感兴趣,欢迎体验七牛云的 Pandora 日志管理系统。

第三种重要的排查问题手段,是提供服务状态查询 API 和监控页面来暴露当前业务系统的状态。通过监控页面可以显示最近的 RPC 请求采样信息。这样我们可以直接了解该软件服务器正在运行的状态,包括与哪些机器在通信等等,而不必去查阅具体的架构文档。另外,这些监控页面可能同时也显示了每种类型的 RPC 错误率和延迟的直方图,这样可以快速查看哪些 RPC 存在问题。

这种方法很像 X-RequestTrace 机制,它非常轻便。如果故障的现场还在,或者故障过去的时间还不长,那么它将非常有助于问题的排查。但是如果问题的现场已经被破坏,那我们就只能另寻他途。

现实的实操建议

在现实中,要想让业务系统的故障排查更简单,我们可能最基本要做的是:

  • 增加系统的可观察性。不要等狼来了羊丢了才想着要补牢。在实现之初就给每个组件增加白盒监控指标和结构化日志。
  • 使用成熟的、观察性好的 RPC 框架。保证用户 API 请求信息用一个一致的方法在整个系统内传递。例如,使用 Request ID 这样的唯一标识标记所有组件产生的所有相关 RPC。这有效地降低了需要对应上游某条日志记录与下游组件某条日志记录的要求,加速了故障排查的效率。

代码中对现存错误的假设,以及环境改变也经常会导致需要故障排查。简化、控制,以及记录这些改变可以降低实际故障排查的需要,也能让故障排查更简单。

对于故障排查,我们需要建立系统化的对故障排查的手段,而不是依靠运气和经验。这将有助于限定你的服务的故障恢复时间(MTTR)。同时,就算是团队里的新手也可以更加快捷有效地解决问题。

造成故障排查过程低效的原因,通常集中在 “假设-验证排除” 环节上。这主要还是由于对业务系统不够了解而导致的。它通常表现在:

  • 在错误的方向上浪费时间。关注了错误的系统现象,或者错误地理解了系统现象的含义。
  • 试图解决与当前系统问题相关的一些问题,却没有认识到这些其实只是巧合,或者这些问题其实是由于当前系统的问题造成的。比如,数据库压力大的情况下可能导致机房局部环境温度也有所上升,于是试图解决环境温度问题。
  • 将问题过早地归结为极不可能的因素,或者念念不忘之前曾经发生过的系统问题,认为还是由之前的问题造成。
  • 不正确地修改了系统的配置信息、输入信息或者系统运行环境,造成不能安全和有效地测试假设,导致验证的逻辑本身存在问题。

理解我们逻辑推理过程中的错误是避免这些问题发生的第一步,这样才能更有效地解决问题。区分我们知道什么,我们不知道什么,我们还需要知道什么可以让查找问题原因和修复问题更容易。

另外,尽管系统能够帮上很多忙,但是故障排查有赖于很多背景知识,需要你更详细地学习业务系统的运行原理,了解分布式系统运行的基本模式。

结语

今天我们聊的是线上故障的排查与根因分析。

从理论上讲,我们将故障排查过程定义为反复采用“假设-验证排除”手段的过程:针对某系统的一些观察结果和对该系统运行机制的理论认知,我们不断提出一个造成系统问题的假设,进而针对这些假设进行测试和排除。

为了有效排查故障,日志系统在里面起到了关键作用。定位问题本身就是 “假设-验证排除-再假设-再验证排除” 这样的循环,直至最后定位到问题。所以基于时序数据的日志系统,往往查询支持非常多样化的过滤条件,功能非常强大。如果你对这方面的详细内容感兴趣,欢迎体验七牛云的 Pandora 日志管理系统。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们聊聊 “过载保护与容量规划”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 过载保护与容量规划

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

前面在 “47 | 服务治理的宏观视角” 一讲中,我们大体将故障类型分为以下几种。

  • 软硬件升级与各类配置变更,即发布。
  • 软硬件环境的故障。
  • 终端用户的请求。比较典型的场景是秒杀类,短时间内大量的用户涌入,导致系统的承载能力超过规划,产生服务的过载。当然还有一些场景,比如有针对性的恶意攻击、特定类型的用户请求导致的服务端资源大量消耗等,都可能引发服务故障。

软硬件升级与各类配置变更,主要通过发布与升级系统完成。软硬件环境的故障,可以参考 “51 | 故障域与故障预案”。

今天我们聊的是故障的第三大类:由终端用户请求导致的故障。它最典型的现象是 “过载”。

过载的成因与后果

我们要思考的第一个问题是:过载的原因是什么?

所谓过载,最直白的理解,当然就是因为活跃的用户超过了资源的承载能力范围,导致某类资源耗尽,进而体现出系统过载。

本质上,这是一个容量规划的问题。

资源怎么会不够了?往往有以下这么几个成因:

其一,用户增长太快了,资源规划上的预期没有跟上,导致资源储备不足。

其二,部分资源因为故障而下线,导致线上活跃的资源不足。举个例子,假设我们做了双机房容灾,但是往往这仅仅是架构上的容灾。从资源储备角度来说,我们需要按2倍的容量来做资源规划,才有可能在某个机房下线后系统不出问题。

其三,系统的关键资源负载能力变低,比如数据库。随着线上服务时间的推移,数据库越来越大,到达了某个临界点,可能就会导致数据库整体的延时变长,响应变慢,同时能够支撑的并发变低,从而导致过载。

其四,某类故障导致系统的反应过激,这通常是因为重试导致的。

为了提高可用性,通常我们在向服务器请求某个 API 失败后,都会进行重试。这种重试行为,可能由负载均衡发起,也可能是发生在客户端。

我们假设,一个 API 失败会重试 2 次,那么对于所有失败的请求来说,请求数放大了 3 倍。如果客户端和服务端都进行了重试,就放大了 9 倍。如果我们再假设,API 失败的原因并不是一上来就失败,而是执行到某个中间步骤,调用了另一个内部服务的 API 而失败,那么很可能内部 API 调用也重试了 3~9 次。这样对这个内部 API 来说,它失败重试的次数就是 9~81 倍。

这种因为重试而带来的请求次数放大,可能会导致系统的资源储备不足,进而引发了过载。

理解了过载的成因,我们第二个问题可能是:过载了,会带来什么样后果?

过载从表现上看,通常会体现为 “资源耗尽”。

比如,CPU 负荷过高,持续接近 100% 下不来。如果 CPU 资源不足以应对请求负载,一般来说,所有的请求都会变慢。这个场景会造成一系列的副作用,比如处理中的请求数量上升。因为处理请求需要较长的时间,同一时间服务器必须同时处理更多的请求(上升到一定数量可能会开始进入队列排队)。这会影响其他所有的资源,包括内存、socket连接以及其他后端服务器资源的消耗也相应增加。

所以需要强调的是,过载通常是会有连锁反应的。某类资源的耗尽,会导致其他资源出现问题。某个服务的过载,经常会出现一系列的资源过载现象,看起来都很像是根本问题,这会使得定位问题更加困难。

过载现象可能会是一个短时现象,过一段时间就撑过去了。但也有很多时候会由于正反馈循环(positive feedback)导致恶化,短时间内就快速形成雪崩效应,击垮系统。

雪崩效应是如何形成的?

我们假设某个服务实例能够承受的正常 QPS 为 10000,如果某个时刻正常业务所产生的自然请求数是 11000,那么其中就有 1000 个请求会失败,如果失败重试导致的请求放大至 9 倍的话,那么系统的 QPS 就增加到 20000,是正常负荷的 2 倍。

这样的高负荷,会直接压垮这个服务实例。在这种情况下,这个服务实例的所有请求会被转移到其他互备实例,从而导致这些互备实例承受了更为巨大的压力。故而,互备实例同样一个个很快被压垮。最终,该服务完全挂掉。

这就是雪崩效应。

过载的监控

过载的危害如此之大,我们怎么及早发现?

一种非常常见,也是很多公司都在做的方式,是给服务的 QPS 设置一个阈值,当 QPS > 阈值时,就触发服务已经过载或即将过载的告警。

这个方式看起来不错,但是它的维护成本很高。就算这个指标在某一个时间段内看起来工作还算良好,但它早晚也会发生变化。有些变动是逐渐发生的,有些则是非常突然的。例如某个服务或客户端的新版本发布,突然就使得某些请求消耗的资源大幅减少。

更好的解决方案,是直接基于该服务所依赖的关键资源,如CPU和内存等,来衡量服务的可用容量。我们为该服务预留了多少资源,这些资源已经用了多少,预计还能够用多久。

我们基于基础资源的用量来估算,比基于服务的 QPS 要稳定可靠得多。在绝大部分情况下(当然总会有例外情况),我们发现简单地基于 CPU 使用量作为服务容量的指标,效果已经非常好了。

过载的应对策略

最后一个问题,我们怎么才能够提前防范服务的过载,把过载可能造成的损失降到最低?

从大的思路来说,无非两个方向,一个是把过载发生的概率变低。另一个是即使发生了过载,也要杜绝雪崩效应,把因为过载产生的损失降到最低。

从技术手段来说,可以由服务的实现方来做,也可以由客户端,也就是服务的调用方来做。

我们先看服务端能够做什么。

首先,应该在过载情况下主动拒绝请求。服务器应该保护自己不进入过载崩溃状态。当前端或者后端进入过载模式时,应尽早尽快地将该请求标记为失败。

当然过载保护可以做得很粗,只有一个全局的负载保护。也可以很细,给每个用户设置独立的负载配额,部分特殊客户甚至可以单独调整负载配额。在理想情况下,当全局过载情况真的发生时,使服务只针对某些“异常”客户返回错误是非常关键的,这样其他用户就不会受影响。

过载保护可以基于 QPS,也可以基于资源利用率实现。但如前文已经说过的那样,基于资源的负载情况判断,会比基于 QPS 更加稳定。

过载保护也可以由负载均衡来做。避免过载是负载均衡策略的一个重要目标。这是个双保险,万一业务服务器没有考虑这块的时候,还有人能够阻止因为过载而崩溃情况的发生。

其次,应该进行容量规划。好的容量规划可以降低连锁反应发生的可能性。容量规划应该伴随着性能测试进行,以确定可能导致服务失败的负载程度。

进行容量规划只能减少触发连锁反应的可能性,但是并不能完全避免。当一个计划内或者计划外的事件导致大部分集群容量同时下线时,连锁反应是不可避免的。负载均衡问题、网络分区事件,或者突发性流量增长,都会导致意料之外的负载问题。有些系统可以根据需要动态增加容量,这可能防止过载发生,但是适当地进行容量规划还是必要的。

最后,服务优雅降级。如果说前面主动拒绝请求,是一种无脑、粗暴的降级方式的话,根据请求的类型和重要性级别来降级,则是一种更为优雅的降级方式。

值得强调的是,优雅降级不应该经常被触发。否则就显示了我们在容量规划上的失误。

另外,代码中平时不太触发的代码分支有可能是不能正常工作的。在稳定运行状态下,优雅降级不会经常触发,这意味着在这个模式下的 SRE 的经验很少,对相关的问题也不够熟悉,这就会升高它的危险性。我们可以通过定期针对一小部分的服务进行压力测试,以便更多地触发这个模式,保证这个代码分支还能正常工作。

聊完了服务端应对过载的手段,我们再来看看客户端能够做什么。

第一个话题是重试。我们可以有这样一些方式来降低重试导致的过载概率。

  • 限制每个请求的重试次数,比如 2 次。不要将请求无限重试。
  • 一定要使用随机化的、指数型递增的重试周期。如果重试不是随机分布在重试窗口里的,那么系统出现的一个小故障,比如发生某个网络问题,就可能导致这些重试请求同时出现,进而引发过载。另外,如果请求没有成功,以指数型延迟重试。比如第一次是 3 秒后重试,那么第二次 6 秒,第三次 12 秒,以此类推。
  • 考虑使用一个全局重试预算。例如,每个进程每分钟只允许重试60次,如果重试预算耗尽,那么直接将这个请求标记为失败,而不真正发送它。这个策略可以在全局范围内限制住重试造成的影响,容量规划失败可能只是会造成某些请求被丢弃,而不会造成全局性的故障。

第二个话题是请求的重要性级别(criticality)。可以考虑将发给服务端的请求重要性级别标记为 1~4 之间的数,它们分别代表 “可丢弃的”、“可延后处理的”、“重要的”、“非常重要的”。在服务端发生过载时,它将优先放弃 “可丢弃的” 请求,次之放弃 “可延后处理的” 请求,以此类推,直到系统负荷回归正常。

第三个话题是请求延迟和截止时间(deadline)。一个超长时间的请求,只是会让一个客户慢。但是结构性的超长时间的请求,它可能会导致系统持续恶化并引起雪崩效应。给 API 请求设置一个小但合理的超时时间,是大幅降低雪崩风险的有效手段。

如果处理请求的过程有多个阶段,比如每个阶段又是由一系列 API 请求组成,该服务器应该在每个阶段开始前检查截止时间,以避免做无用功。

第四个话题是客户端侧的节流机制,也就是是否可能在客户端做自适应的过载保护。客户端的过载保护有它天然的优势,在抛弃超过配额的请求时,它完全不会浪费服务端的资源。

当某个客户端检测到,最近的请求错误中的一大部分都是由于 “配额不足”错误导致时,该客户端就开始自行限制请求速度,限制它自己生成请求的数量。超过这个请求数量限制的请求直接在本地回复失败,而不会真正发到网络层。

我们使用一种称为自适应节流的技术来实现客户端节流。具体地说,每个客户端记录过去两分钟内的以下信息:

  • 请求数量(requests):应用层代码发出的所有请求的数量总计。
  • 请求接受数量(accepts):被服务端接受处理的请求数量。

在常规情况下,这两个值是相等的。随着后端任务开始拒绝请求,请求接受数量开始比请求数量小了。客户端可以继续发送请求直到 requests = K * accepts,一旦超过这个限制,客户端开始自行节流,新的请求在本地会有一定概率被拒绝(在客户端内部)。客户端请求拒绝概率公式如下:

当客户端开始自己拒绝请求时,requests 会持续上升,而继续超过 accepts。这里虽然看起来有点反直觉,因为本地拒绝的请求实际没有到达后端,但这恰恰是这个算法的重点。随着客户端发送请求的速度加快(相对后端接受请求的速度来说),我们希望提高本地丢弃请求的概率。

自适应节流算法可以在整体上保持一个非常稳定的请求速率。即使在超大型的过载情况下,后端服务基本上仍然可以保持 50% 的处理率。这个方式的一大优势是客户端完全依靠本地信息来做出决定,同时实现算法相对简单:不增加额外的依赖,也不会影响延迟。

K 值决定了过载时服务端的拒绝率,默认为 K=2。这意味着服务端过载的时候有 50% 的处理率。如果我们调整为 K=1.1,那么算法变得非常激进,服务端有 90% 的处理率。什么情况下可以这么激进?对那些处理请求消耗的资源和拒绝请求的资源相差无几的系统来说,用 50% 的资源来发送拒绝请求可能是不合理的,这时就可以更激进。

结语

总结下我们今天的内容。我们聊的话题主要是关于过载。

所谓过载,最直白的理解,当然就是因为活跃的用户超过了资源的承载能力范围,导致某类资源耗尽,进而体现出系统过载。

当一个系统过载时,某些东西总是要被牺牲掉。一旦一个服务越过了临界点,服务一些用户可见错误,或者低质量结果,要比尝试继续服务所有请求要好。理解这些临界点所在,以及超过临界点系统的行为模式,是所有想避免因过载而引发雪崩效应的 SRE 所必需具备的。

如果不加小心,某些原本为了降低服务背景错误率或者优化稳定状态的改变,反而会让服务更容易出现事故。比如,在请求失败的时候重试、负载自动转移、自动杀掉不健康的服务器、增加缓存以提高性能或者降低延迟等等。这些手段原本都是为了优化正常情况下的服务性能,但是他们某种程度上来说,也是过载与雪崩效应的成因。

过载引发的雪崩效应可能时间很短,所以考虑怎么让我们的系统能够自动应对过载是非常必要而且重要的事情。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊 “业务的可支持性与持续运营”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 业务的可支持性与持续运营

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

保障业务的 7x24 小时不间断服务并不容易,所以我们花费了非常长的篇幅来探讨这件事情。我们需要系统性的、结构化的解决方案,它的涉及面非常广,需要基础架构、中间件、SRE 工作平台等多个层次、多个工种之间的紧密配合。

客户支持

但就算线上没有出问题,我们的用户仍然会遇到各种麻烦。所以大部分公司都会成立客户支持团队来服务客户。客户支持团队可能使用工单、电话或者即时通讯工具来服务客户。

对于客户支持部门,我们如何评估他们的业务价值?

很多公司会关注服务质量,简单说就是客户对某个会话的服务满意度。这当然也是重要的,但对整个客户支持部门来说,最核心的还不是这一点。

我们最核心的关注点,是如何减少客户服务的人工成本。

通常来说,客户支持团队会收到客户各式各样的问题反馈。这些反馈大体可以分这样几类:

  • 使用姿势类;
  • 报障类;
  • 投诉与建议类。这个不是今天关注的内容,不再展开。

我们首先看 “使用姿势类”。这又细分为两种情况:一种是完全不知道应该怎么用我们的产品,需要有一步步引导的向导或者示范的 DEMO。另一种是接入了我们的产品,但是发生了非预期的结果,于是客户迷茫了,需要寻求帮助。

怎么才能避免用户拿到我们的产品,完全摸不到北,不知道应该怎样才能使用起来的情况发生?在产品中植入必要的引导是非常必要而且重要的。产品帮助文档虽然应该有,但是我们应该坚信的一点是,绝大部分客户的问题应该依靠产品自身来解决,而不是依靠产品文档来解决。

要想做到更好的产品引导,需要我们代入到客户的使用场景中去,预想客户各种可能遇到的问题是什么。

当然,在逐步迭代的场景中,历史的用户行为分析也能够指导我们找到需要改善的关键问题是什么。

客户接入了,但是发生了非预期的结果。在很多时候,出错的信息往往是很难理解的。这时候客户就被迫到产品文档网站或者搜索引擎去搜索出错对应的解决方案。如果迟迟无法得到解决,客户就会怀疑我们的产品,转向其他的替换方案。

所以错误信息的呈现,也是需要非常讲究的。我们非常有必要将错误信息的表达变得更加贴近用户的语言。甚至,我们在错误提示信息中,给出我们的建议,或者建议文档的链接。

还有一些错误的使用姿势,可能并不会在存在误用的现场直接表现为错误,而是在其他的某个场景下才发生错误。

这方面一个很好的例子是 Go 语言的 map,也就是大家通常理解的哈希表。Go 语言的 map 并不是多线程安全(准确地说,在 Go 语言里面其实是多 goroutine 安全)的,如果在多个 goroutine 中共享这个 map 但是却忘记了加锁,这个时候结果就是不可预期的。

按大部分人常规的逻辑,这样的误用在 Go 的文档中给予必要的提醒就好了。从职责来说,用户误用那是用户自身的问题。

但是 Go 语言团队显然不是这么看的。它特意在代码逻辑中加入了这种使用姿势上误用的检测,检测到错误后直接抛出异常,让程序停止运行。在异常信息的提示中,它告诉你,你的代码存在了什么样的问题。

这让用户更快地找到错误的根因,并且及时去修复错误。

这个细节让我感受颇多。产品被开发出来,对于很多研发人员的认知来说,是个结束。但是从业务的持续运营角度来说,这只是开始。

持续运营中产生的各类成本,无论是 SRE 做服务保障的成本,还是客户支持服务客户的成本,还是客户为了寻找问题根源而花费的时间成本,都需要我们去认真思考怎么进行大幅度的优化。

我们再来看下 “报障类”,也就是用户认为我们服务出问题了。当然实际问题不一定真是我们的服务有问题,也有可能是用户自身的环境,比如 Wi-Fi 或者本地运营商的问题。但无论原因如何我们都需要给客户一个确切的问题说明和建议的解决方案。

为了应对这种场景,我们就需要有意识地收集用户请求的整个调用链。这个机制和我们前面介绍的 “52 | 故障排查与根因分析” 一讲中介绍的 Tracing 机制类似,都会依赖 request id 这样的东西作为线索。只不过,为了有更加完整的调用链,这个过程不只是在服务端,同时还需要应用到客户端。

有了 Tracing 机制,客户端基本上就可以做到一键报障。在报障时客户端会携带自己的 IP 和 Tracing 日志。这样客户支持人员就可以通过客户 IP 和 request id 知道这个客户最近都发生了什么事情。

但某种程度来说,一键报障也是繁琐的。所以很多应用程序会让你签署一份用户体验改进协议,在获得你的同意后,程序就可以把 Tracing 日志主动同步给服务器。这样我们的客户支持团队就可以不是针对某个具体的客户,而是针对相对全局性的问题来进行改进。

当然,这也意味着我们需要建立合适的数据运营的体系。通过这个体系,我们可以迅速找到用户最经常遇到的问题是什么,并持续加以改进。七牛云的 Pandora 日志平台可以协助做好这方面的改进工作的开展。

有一些改进我们来不及系统化进行改进,但是我们已经形成了一些最佳实践,那么对于 VIP 类客户我们可以先主动进行最佳实践的推广。

用户报障也有可能是我们的业务真实发生了故障。这时客户报障通常会有一个突然的爆发式增长,并且往往容易让我们的客户支持团队应接不暇。

当线上发生故障的时候,什么时候对外宣布事故,什么时候主动通知客户?

首先,先宣布事故发生,随后找到一个简单解决方案,然后宣布事故结束,要比在问题已经持续几个小时之后才想起告知客户要更好。

所以针对事故,我们应当设立一个明确的宣布条件。比如,如果下面任何一条满足条件,这次事故应该被及时宣布:

  • 是否需要引入 SRE 之外的团队来一起处理问题?
  • 在处理了一小时后,这个问题是否依然没有得到解决?
  • 这次事故是否正在大范围地影响了最终用户?

如果平时不经常使用,事故流程管理的可靠性萎缩得很快。怎么才能使工程师不忘记他们的流程管理的技能呢?难道一定要制造更多事故吗?

一个可能的思路是,经常针对之前发生过的灾难进行角色扮演式的演习,比如演习另外一个地区处理过的问题,以更好地熟悉事故流程管理体系。

当然,事故流程管理框架其实也往往适用于其他的跨团队的常规运维变更过程。如果我们经常使用相同的流程管理方式来处理线上的变更请求,那么在事故来临时,就可以很好地利用这些流程管理手段来管理它。

BOSS 系统

客户使用上的困扰、排错、事故告知、投诉与建议,这些是客户支持所面临的常规工作。但实际上我们在服务客户过程中,往往还有更常规的业务支持工作要做,比如客户的业务开通、财务与发票、业务管理等。这类系统我们往往把它叫 BOSS 系统。

BOSS 系统面向的用户是企业的内部员工,为员工能够正常开展业务服务。

什么样的事务,应该被加入到 BOSS 系统?一个基本的策略是越高频执行的业务动作,越应该被固化到系统中。

业务过程固化到系统会有很多好处。

首先,最常规的,是业务效率提升。员工执行业务有更高的便捷性。

其次,安全风险管理。有一些业务过程可能会有潜在的风险需要防范,通过固化业务过程,进行必要的风险检查,可以避免掉最常见的已知风险。

最后,业务过程进一步被数字化,业务行为被记录,这为系统性地进行业务优化提供了可能。

除了客户支持系统、业务 BOSS 系统之外,还有一类极其重要的业务运营需求,就是客户增长的运营了。但客户增长运营是一个复杂的话题,我们这里就不再展开。

结语

今天我们聊的主要是一个视野的问题。除了产品的功能外,实际上为了产品能够更好地服务好客户,我们需要关注售前、售后支持能力的构建。

为了更好地支持客户,以及进行后续用户体验的改善,我们往往需要将用户行为记录写到日志系统中,以便于进一步地分析和挖掘。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊 “云计算、容器革命与服务端的未来”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 云计算、容器革命与服务端的未来

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到今天这一讲,我们服务治理相关的话题基本上接近尾声。通过前面的内容我们可以知道,服务治理比软件治理要复杂很多。它的涉及面非常广,需要有系统性的、结构化的解决方案,需要基础架构、中间件、SRE 工作平台等多个层次、多个工种之间的紧密配合。

软件的服务化过程本身是互联网的胜利。从最初以泛娱乐场景为主,到今天影响国民经济的方方面面,场景越来越严肃和多样化。

软件服务化使得工程师有了新的职能:on call。软件工程师并不是把软件开发出来就完了,还需要保证软件上线后的服务品质,比如稳定性。在线上出问题的时候,软件工程师还需要随时响应线上的 on call 请求,参与到故障排查的过程中去。

但是提供靠谱的服务是如此之难,尤其在软件并不是自己团队开发的情况下,保证服务质量的过程增加了许多不可控的因素。

云计算的诞生,是一次软件交付方式边界上的重新定义。在此之前,IT 技术供应商通常的交付物是可执行程序或源代码。这种交付方式更多的是软件功能的交付,但是并不参与到软件线上的运行状况的管理。

云计算定义了全新的交付方式,IT 技术供应商不再提供可执行程序或源代码,而是互联网服务。用户使用 IT 服务时并不需要了解背后运转机制。即使在线上出问题的时候,也是 IT 技术服务商安排技术人员去解决,而不是用户自己去想办法解决。

这意味着云计算改变了用户与 IT 技术服务商之间的配合方式。从之前的对交付过程负责,到对服务的质量结果负责,双方的职责更为简单清晰。

云计算发展到今天,大体经历了两个阶段。

资源交付革命

第一个阶段,是资源交付的云化。它的变革点在于 IT 资源交付的效率。

在云计算之前,业务上线过程大体上来说是这样的:

  • 首先,购买基础的 IT 资源,比如服务器和交换机等;
  • 然后,将服务器和交换机等通过物流系统运送到 IDC 机房并上架;
  • 安装上基础的操作系统;
  • 部署业务系统。

而云计算之后的业务上线被简化为:

  • 购买云上虚拟的云主机若干台,这些云主机已经按用户选择预装了基础的操作系统;
  • 部署业务系统。

这个阶段交付的 IT 产品形态并没有发生根本性的变化,以前是物理机,现在是云主机,两者使用上的用户体验并没有很大的差异性。但这里面 IT 资源交付效率发生了巨大的变化,它主要体现在以下几个方面。

  • 资金优化:消除了物流的成本。
  • 时间优化:物流时间、产品自助化水平(上架、操作系统安装)。
  • 资源复用率提升:云计算通过将不同用户的 IT 资源聚集在一起,提升了 IT 资源的使用效率,减少了社会资源的浪费。

容器革命

云计算的第二个阶段,以容器革命为标志,以 Kubernetes 为事实标准,迭代的是服务治理的能力。

前面我们在 “47 | 服务治理的宏观视角” 提到过以下服务治理系统的模型:

服务治理系统的影响因素非常复杂。我们大体将各类影响因素分为以下几种类型。

  • 软硬件升级与各类配置变更,即发布。
  • 软硬件环境的故障。
  • 终端用户的请求。比较典型的场景是秒杀类,短时间内大量的用户涌入,导致系统的承载能力超过规划,产生服务的过载。当然还有一些场景,比如有针对性的恶意攻击、特定类型的用户请求导致的服务端资源大量消耗等,都可能引发服务故障。

即使是在今天,在容器技术大范围应用之前,我们大部分公司的服务治理系统都建立在物理机或云主机的基础上。这种做法的问题在于系统的复杂性过高,不同的影响因素交织在一起,让彻底解决问题变得非常困难。

我们举配置变更作为例子。通常来说,配置变更是我们主动对系统进行软硬件升级或配置参数调整导致的,它应该被认为属于计划性的活动。

变更是故障之源。

只有计划性的配置变更才会按发布计划和检查清单有序地执行。但是,显然各类软硬件环境的故障是非预期的,不知道什么时候会来一下。但是如果我们基于物理机来做服务治理,必然需要面临因为不可预期的软硬件故障而进行配置变更。

这种为了恢复故障的变更有更大的临时性,自然也更难形成高质量的检查清单来检查配置变更的靠谱度。如果线上已经通过流量调度进行了必要的恢复那倒是还好,最多是对这样的临时变更做更多的人工检查来确保质量。

如果某种配置变更方式经常被用于某种故障恢复的场景,那么这样的配置变更就很可能被脚本化下来,以便让故障恢复过程更加高效。

这样随着时间的日积月累,我们就得到了基于物理机的服务治理系统。

但是,这种服务治理系统虽然做到了很高的自动化,但是它并不能算是一个高度自治的系统。

高度自治的服务治理系统对软硬件环境的故障有天然的免疫:我们什么都不用做,系统自己完成自我修复过程。

这就是容器革命带来的变化。

今天 Kubernetes 基本已经成为 DCOS(数据中心操作系统)的事实标准。它带来了以下重要的改变。

  • 用户操作的对象不再有机器这玩意,最核心的概念是服务。当然这是称之为数据中心操作系统概念的由来。
  • 硬件资源池化。软件或服务与硬件环境解绑,可以轻松从一台物理机迁移到另一台物理机。
  • 面向逻辑视图描述集群状态。配置中心的数据只体现业务系统的逻辑,不体现物理特性。

当然,今天容器革命仍然还在如火如荼地进行,完整的演进过程会很漫长。这背后的原因在于,容器技术虽然先进,但它从根本上改变了我们使用计算力的习惯。

服务端的未来

未来服务端技术的演进会走向何方?

服务治理系统的迭代,最终将让我们达到这样的状态。

  • 任何业务都可以轻松达到 7x24 小时不间断服务。高并发?高可用?高可靠?小菜一碟。
  • 做业务都足够的傻瓜化。服务端工程师?不存在的,我们要的只是 SQL 工程师。
  • 做一个新的有状态的存储中间件虽然比做业务麻烦一点,但是,一方面也没有多少新的存储中间件需要做的,另一方面做一个存储中间件有足够便捷的辅助设施,也不会比今天做一个内存中的哈希表难多少。

所以在我看来,服务端工程师很有可能只是一个阶段性的历史背景下的产物。随着互联网应用开发的基础设施越来越完善,服务端开发的成本越来越低,最终和前端工程师重新合而为一。

是的,我说的是重新回到软件时期的样子。那时候,并不存在前端和后端工程师之分。

结语

今天我们聊的话题是服务端的未来。云计算和容器革命将服务端的基础设施化推向了高潮。未来,也许我们并不需要专门的服务端开发工程师来做业务开发。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将对 “服务治理篇” 这一章的内容进行回顾与总结。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 服务治理篇:回顾与总结

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。到今天为止,我们第四章 “服务端治理篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。

服务端的话题被我分为了两章:“服务端开发篇” 与 “服务治理篇”。它们的边界在于,服务端开发致力于设计合适的业务架构来满足用户需求,而服务治理则致力于让服务端程序健康地为客户提供 7x24 小时不间断的服务。

从服务端开发的角度来看,服务端的迭代并不大。

上面这幅图我们已经很熟悉了。作为架构师,我们需要清楚的一点是,这个服务端体系架构图并不是逐步迭代出来的,实际上,从服务端开发这个分工出现之后,这个架构就没有发生过改变。这些年迭代的是什么?迭代的是负载均衡、数据库/存储中间件能力的丰富与完善。

服务端操作系统的演进

但从服务治理角度看,服务端技术的迭代很快。让我们先从操作系统谈起。

作为最底层的服务端操作系统,最初从桌面操作系统而来。但桌面操作系统自身在发展,服务端操作系统自身也在发展,两者渐行渐远。

桌面的领域特征是强交互,以事件为输入,GDI 为输出。所以,桌面技术的迭代,是交互的迭代,是人机交互的革命。

而服务端程序有很强烈的服务特征。它的领域特征是大规模的用户请求,以及 24 小时不间断的服务。这些都不是业务功能上的需要,是客户服务的需要。

所以服务端操作系统的演进,并不是因为服务端业务开发的需要,是服务治理的需要。

所以,服务端技术的迭代,虽然一开始沿用了桌面操作系统的整套体系框架,但它正逐步和桌面操作系统分道而行,转向数据中心操作系统(DCOS)之路。

第一个里程碑的事件是 Docker 的诞生。容器技术诞生已经多年,但是把容器技术的使用界面标准化,始于 Docker。它完成了服务端软件的标准化交付,与底层的服务端本地操作系统实现了解耦。

在 Docker 之前,不同服务端本地操作系统的软件交付有这样几个问题。

其一,标准不同。MacOS 有 brew,Linux 不同分支差别很大,有的是基于 rpm,有的是 apt,五花八门。

其二,不符合服务软件的交付规格需要。这些软件管理工具只实现了一个软件仓库,它虽然标准化了软件安装的过程,但并没有定义服务的运行规范。

其三,环境依赖。这些软件管理工具对软件的描述并不是自包含的。它们并没有非常干净的软件运行环境的描述,行为有较大的不确定性,甚至有大量的软件包在实际安装时会因为各种各样的系统环境问题而失败。

在这种情况下, Docker 诞生,一下子就火爆了。随后,OCI 标准组织也应运而生:

它定义了有关于容器的两大规范:

  • 运行时标准(Runtime Specification);
  • 镜像标准(Image Specification)。

Docker 出现后,紧接着 CoreOS 也推出了新的服务端操作系统。CoreOS 是专注于服务端的操作系统,它认为除了只读的操作系统内核外,所有的软件都应该是基于容器发布的。

这种思想很先进。但无奈的是,它一方面对用户习惯改变过大,另一方面也没有真正切中用户最关键的痛点,导致它一直没能够流行起来。

从商业角度来说,早期 Docker 和 CoreOS 表现得很互补的样子,但是双方的商业目标其实相同,都是希望能够成为数据中心操作系统(DCOS)的领导者。

所以,Docker 推出了 Docker Swarm,而 CoreOS 也有自己的集群版。这下,两家公司的友好协作的表象很快就被打破了。

但最后,Google 牵头推 Kubernetes,结束了 DCOS 之争。当然这事今天来重新回顾,它也在情理之中,毕竟容器技术实际上最早是在 Google 推动下被加入 Linux 内核,而它内部更是有 Borg 这样的 DCOS 系统,有着丰富的基于容器的服务治理实践经验。

无论是 Docker 还是 CoreOS,两家公司都大大低估了 DCOS 这件事情的难度。当然这事低估的并不只是他们,也包括七牛云。在 Docker 诞生后,我们就意识到 DCOS 是未来,所以 2014 年我们就成立了 QCOS 项目组来做这事,但最终这个项目组转向了拥抱 Kubernetes。

服务端治理篇的内容回顾

不单单是服务端操作系统,整个服务治理的变化都非常之快。

虽然到今天服务端技术已经有二十多年的历史,但是它仍然处于快速演进的状态,因为它太复杂了。服务治理比软件治理要复杂很多。它的涉及面非常广,需要有系统性的、结构化的解决方案,需要基础架构、中间件、SRE 工作平台等多个层次、多个工种之间的紧密配合。

本章开篇的第一篇,我们是用下图来描述服务治理系统的:

这个图非常抽象,基本上只是概要描述了服务治理系统的输入和输出。但从系统抽象的角度来说,第一步我们要理清楚的就是输入和输出,它们代表了系统的规格。

紧接着我们把话题转到了 “什么是工程师思维”。

为什么我们要谈工程师思维?因为从 “基础平台(硬件架构/编程语言/操作系统)”,到 “业务开发(桌面开发/服务端开发)”,再到 “业务治理(服务治理/技术支持/用户增长)”,我们的话题一个比一个更加 “不稳定”。它们有更高的需求复杂性,有更多的事务性工作。

只有我们沉下心来,认真理解用户需求,并对这些需求进行结构化的梳理,形成系统性的解决方案,才能将问题最终彻底解决掉。

为了让大家对整个服务治理篇的内容有个宏观的了解,我画了一幅图,如下。

我们从服务的变更(包括:发布、升级与版本管理)、服务的健康状况(包括:日志、监控与报警)、服务的故障处理(包括:故障域与故障预案、故障排查与根因分析、过载保护与容量规划)等几个方面来探讨服务治理的具体细节。

最后,我们探讨了云计算诞生的历史意义、发展阶段,以及未来服务端技术发展的前景。简单来说,云计算的诞生,标志着服务端分工的正式形成。未来,所谓服务端工程师很可能不再存在。要么,你往基础设施走,变成一个云计算基础设施的研发工程师。要么你深入行业,变成某个领域的研发工程师,但是这时,就别抱着自己是服务端工程师的态度不放,好好琢磨清楚业务需求。

架构思维

最后,我想从 “架构思维” 的角度来看服务治理篇。

首先,这是自信息科技诞生以来,最宏大的架构设计案例,没有之一。为什么我们这么说?因为需求太复杂。

从架构实践的角度来说,它显然并不是太适合架构新手学习的案例。但是从体会架构分解的核心指导思想来说,它却又是极好的学习材料。

我们举个例子,服务治理的一个核心话题是 “变更”。单就 “变更” 这一个需求点来说,它就涉及非常多样化的情况。包括:

  • 软硬件的升级;
  • 配置项调整;
  • 数据库表结构的调整;
  • 增加/减少机器;
  • 数据中心的搬迁;
  • 入口域名、IP 的调整;
  • ......

从具体的变更情况来说,太多太多,难以穷尽。

那么,怎么对其进行结构化的梳理,形成系统性的体系架构来抽象 “变更” 需求?

首先,我们对 “变更” 需求进行正交分解,分为 “主动性变更” 与 “被动变更”。“主动性变更” 是指有计划的变更行为,例如软硬件升级、数据库表结构的调整等等。“被动变更” 是指由于线上用户请求、业务负载、软硬件环境的故障等非预期的行为导致的变更需求,比如扩容、由于机房下线而导致的 DNS 配置项变更等等。

为了应对 “被动变更”,服务治理系统对服务的软硬件环境的依赖进行了系统性的梳理。最终,硬件被池化。业务系统的逻辑描述与硬件环境彻底解耦。

然后,我们对 “主动性变更” 进行进一步的正交分解,分为 “软件变更” 与 “软件数据的变更”。“软件变更” 通过版本化来表达。每个 “软件” 版本必须是自包含的,它自身有完整的环境,不会出现跑在 A 机器和 B 机器不一致的情况。

版本化是非常重要的概念。它意味着每个独立版本的数据都是确定性的、只读的、行为上可复现的。大家最熟悉的版本化的管理思想,就是源代码管理系统,比如 Git。在服务治理系统中,“软件变更” 和我们熟悉的源代码管理系统如出一辙。

最后,就只剩下 “软件数据的变更”。它是和具体业务关联性非常强的变更,没法进一步去抽象和简化,但好的一点是这类变更是非常低频的,可以统一通过软件升级的流程管理系统来管理它带来的风险。

我相信未来我们的大部分读者都会和服务治理系统打交道。它终将成为我们所依赖的基础设施,新一代的服务端操作系统,数据中心操作系统(DCOS)。在学习和研究服务治理系统的过程中,我们每一个人可以认真体会服务治理这个宏大的架构案例,并以此来提升我们对架构设计的感悟。

结语

今天我们对本章内容做了概要的回顾,到此为止,我们 “基础平台”、“桌面开发”、“服务端开发”、“服务治理” 这四大模块就结束了。从工程师架构设计角度来说,它们基本上涵盖了我们会打交道的绝大部分通用业务场景。

理解了这几章的内容,整个软件大厦的骨架就可以明了了。

下一步应该学什么?架构思维原则?或者是设计模式?

架构思维的确是有很多共性的东西,值得我们总结出来细细体会。比如 “开闭原则”,多么有力的架构思维的总结,值得我们时时拿出来提醒自己。

不过,我个人不太喜欢常规意义上的 “设计模式”。或者说,我们对设计模式常规的打开方式是有问题的。理解每一个设计模式,都应该放到它想要解决的问题域来看。所以,我个人更喜欢的架构范式更多的是 “设计场景” 的总结。“设计场景” 和设计模式的区别在于它有自己清晰的问题域定义,是一个实实在在的通用子系统。

是的,这些 “通用的设计场景”,才是架构师真正的武器库。如果我们架构师总能把自己所要解决的业务场景分解为多个 “通用的设计场景” 的组合,这就代表架构师有了极强的架构范式的抽象能力。而这一点,正是架构师成熟度的核心标志。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们正式开始进入第五章:架构思维篇。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 心性:架构师的修炼之道

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

今天开始,我们终于进入第五章,也就是大家常规认为的架构课的内容:架构思维篇。

怎么还没有谈架构?这可能是很多人心中的疑问。这个问题我们今天后面会给出它的答案。

但是我相信所有的读者最关心的一个问题无疑是:

怎么成为优秀的架构师?架构师的修炼之道究竟是什么?

我的答案是:修心。

心性,是架构师区别于一般软件工程师的地方。也是为什么他能够看到那么多人看不到的关键点的原因。

同理心的修炼:认同他人的能力

在前面几个章节,我们已经陆续介绍了架构的全过程:

但架构师面临的问题往往是错综复杂的。

给你一个明确的需求说明文档,干干净净地从头开始做 “需求分析”,做 “概要设计”,做模块的 “详细设计”,最后编码实现,这是理想场景。

现实中,大多数情况并不是这样。而是:你拿到了一份长长的源代码,加上少得可怜的几份过时文档。然后被安排做一个新功能,或者改一个顽固 Bug。

你接手的代码量,比前面我们架构实战案例 “画图程序” 长得多,动辄几百万甚至上千万行的源代码。文档也要少得多,没有清晰的网络协议和接口文档,更别提详细设计文档。有句程序员界的名言:“程序员最讨厌的两件事情:一件事情是写文档,一件事情是接手的代码发现没文档”。这是很真实的对现实的写照。

我知道对于 “画图程序” 这个案例,一些读者会说:怎么一上来就给我这么复杂的实战案例,就不能循序渐进一点么?

但它真的已经是最小的架构案例了,不到一千行的代码规模。相比在现实中你不得不面对几百万甚至上千万行的代码规模的工程,这只是小菜一碟。

下一章的 “软件工程篇”,我们会探讨怎么阅读别人的源代码。现在我们还是先回归到真实的场景:给一个几百万甚至上千万行的工程项目增加一个新功能,修改一个顽固 Bug,或者心一横做重构。

问题是:你真的是在改善系统,还是在破坏系统?

很多时候,你是在破坏系统。代码为什么会散发臭味?就是因为有很多很多缺乏良好架构思维能力的工程师在加功能,在做重构。

我说的不太客气。但需要认清的一点是,这就是绝大部分公司面临的现实问题。

最值得研究的是重构。重构不为改善用户体验,它的目标是为了改善系统质量,清除代码中的臭味。但现实中也有不小比例的重构实际上是在让问题变得更糟糕。

为什么会这样?

因为,这里有工程师发展成为架构师所需要的最重要的心性修炼:

认同他人的能力。

架构师最重要的是有同理心,要有认同他人的能力。不要在没有全面理解他人思想的情况下去调整既有代码的设计逻辑。

读懂他人的思想。

这很难。所以,如果真冲着读懂他人思想的目标去,接手一个模块刚开始往往会比较慢,具体需要花多久取决于你的经验积累。

理解一个系统的架构,如果当初做这个系统的几个核心人员你还联系得上,那么不要犹豫,争取一个小时的时间和他们做沟通。这比你直接一上来就啃代码要好。纯啃代码就好比做逆向工程把机器码转回源代码,是一件非常复杂的事情。

但是如果联系不到人,就只能老老实实去啃代码。结合文档看代码往往会事半功倍,但也要注意识别出文档和代码不一致的情形,把它们记录下来。

经验积累得多了,看到源代码就能很快体会别人的思想。这背后所依赖的,其实也是架构能力。架构师往往对一个需求场景会有多条实现路径的思考和评估。这样的思考和评估做多了,看到别人的代码就容易建立熟悉感,一眼看出别人的思路是什么。

架构师的同理心,也体现在需求分析上。

这在某种意义上来说是更难的一件事情。因为,接管系统的时候要了解的是其他架构师的思想,它毕竟是你熟悉的场景。而需求分析则是理清用户需求的能力,你需要代入用户,理解用户的核心诉求。所以需求分析需要更强的空杯心态,去认同他人。

全局观的修炼:保持好奇心与韧性

架构师第二大能力,是全局观的修炼。没有了全貌,那就是井底之蛙,谈何架构?

为什么我们的架构课不是一上来就谈架构思维?

这是因为,并不是我们理解了架构思维的原则,就能够做好架构。

架构之道,是虚实结合之道。

我们要理论与实践相结合。不可能只要理论,否则架构师满天飞了。如果两者只能取其一,我选实践。

从实悟虚,从虚就实,运用得当方得升华。这其实是最朴素的虚实结合的道理。对学架构来说尤其如此。架构思维的感悟并不能一步到位,永远有进步的空间,需要我们在不断实践中感悟,升华自己的认知。

架构课内容的前四章为 “基础平台”、“桌面开发”、“服务端开发”、“服务治理”。

从内容上来说,由 “基础平台(硬件架构/编程语言/操作系统)”,到 “业务开发(桌面开发/服务端开发)”,再到 “业务治理(服务治理/技术支持/用户增长)”,基本上覆盖了信息技术主体骨架的各个方面。

有了骨架,就有了全貌,有了全局的视角。

前面四章,我们内容体系的侧重点放在了架构演变的过程。我们研究什么东西在迭代。这样,我们就不是去学习一个 “静态的”、“不变的” 信息技术的骨架,更重要的是我们也在学信息技术的发展历史。

有了基础平台,有了前端与后端,有了过去与未来,我们就有了真真正正的全貌。

我们博览群书,为的就是不拘于一隅,串联我们自身的知识体系,形成我们的认知框架。

这很难。

很多人都有关于 “广度” 与 “深度” 的辩证与困惑。全局观这件事情,对于心性上的修炼,比的是好奇心与韧性。

保持对这个世界的好奇心。看到新科技与新思想,先认同它,去体会它,理解它产生的需求背景与技术脉络,以此融入自己的知识体系。

持续地学习。

架构师需要有学习韧性。但并不是所有的技术都值得深耕。我们也都没有这个精力去这么做。我们要做到的是,随时想深入耕耘就能深入。

怎么深耕,更多的是结合自己的工作内容和兴趣。很多工程师会有困惑,觉得自己的工作内容平淡无奇,没法让自己进步,但实际上瓶颈不在于工作内容,在于自己心性的修炼。

好的架构师,拥有化腐朽为神奇的能力。

迭代能力的修炼:学会否定自己

架构师第三大能力的修炼,是迭代,是反思,是自我批判。

不管你喜不喜欢,工程师天天都在接任务,码代码。但是差别在于,你究竟在用什么样的态度来接任务,码代码。

关于码代码,不少优秀的工程师都有这样的体会:洋洋洒洒写了好多代码,过了半年一年,自己看着怎么看怎么不爽。

如果你有同感,那么恭喜你:你进步了。

但,接下来的事情更重要。

你是捏着鼻子忍着,继续接老板安排下来的新任务;还是,百忙里抽出一点时间,把之前写的代码改到你满意的样子。

这个改代码的过程,它之所以重要,是因为它才是真真正正的架构能力升华的过程。

通过迭代而升华。这是架构能力提升之路。你的收益不会只是你重构的那一个模块本身。通过重构,你建立了新的知识体系。它是内在根本性的变化,看不见但你自己可以体会得到。

接任务的态度也很重要。

面对每一个新的开发任务,都是一次重新审视架构合理性的机会。

就算这个模块从来没有交给过他人,所有代码都是你自己写的,也不代表这个模块就不会老化,发出臭味。这里面的原因在于,很多时候你给模块加上新功能的时候,往往会出现很多当初架构没有考虑到的场景,导致不得不用打补丁的方式把需求给满足了。

一个需求捏着鼻子做,两个需求捏着鼻子做,慢慢的系统就不堪重负,变得很脆弱,到后面加功能就会变得越来越困难。

所以,发现架构无法很方便地支持某个需求,就意味着架构存在缺陷,这时要及时停下来思考以下问题:

  • 还有哪些潜在的需求,现在还没有收到,但是未来可能会需要去满足?
  • 如果这些需求当初就提出来了,架构做成什么样更合理一些?
  • 当前的架构设计,迭代到新架构设计,它的成本是什么样的?

在架构调整这件事情上,早迭代,小步迭代,比做一个大的重构版本要好。

结语

架构师成长之旅,是心性修炼之旅。我们需要记得,并不是理解了架构思维的原则,就能够做好架构。

架构之道,是虚实结合之道。理论与实践相结合。

从实悟虚,从虚就实,运用得当方得升华。架构思维的感悟并不能一步到位,永远有进步的空间,需要我们在不断实践中感悟,升华自己的认知。

从技能来说,我们可能把架构师能力去归结为:

  • 理需求的能力;
  • 读代码的能力;
  • 抽象系统的能力。

但是架构师修炼之道,更难的是在心性上,这包括:

  • 同理心的修炼,认同他人的能力。
  • 全局观的修炼,保持好奇心和学习的韧性。
  • 迭代能力的修炼,学会反思,学会在自我否定中不断成长。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们聊聊 “如何判断架构设计的优劣”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 如何判断架构设计的优劣?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

想要让自己进步,我们首先得知道什么是好的。所以我们今天的话题是,如何判断架构设计的优劣?

架构设计的基本准则

架构设计会有它的一些基本准则。比如:

  • KISS:简单比复杂好;
  • Modularity:着眼于模块而不是框架;
  • Testable:保证可测试性;
  • Orthogonal Decomposition:正交分解。

KISS 全称是 Keep it Simple, Stupid,用最直白的话说,“简单就是美”。不增加无谓的复杂性。正确理解系统的需求之后才进行设计。要避免过度设计,除非有人为复杂性买单。

KISS 的“简单”,强调的是易实施性。让模块容易实现,实现的时候心智负担低,比复杂的优化更重要。

KISS 的“简单”,也是主张让你的代码,包括接口,符合惯例。接口语义要自然,最好让人一看方法名就知道怎么回事,避免惊异。

Modularity,强调的是模块化。从架构设计角度来说,模块的规格,也就是模块的接口,比模块的实现机制更重要。

我们应着眼于模块而不是框架。框架是易变的。框架是业务流,可复用性相对更低。框架都将经历不断发展演化的过程,逐步得到完善。

所以不让模块为框架买单。模块设计时应忽略框架的存在。认真审视模块的接口,发现其中“过度的(或多余的)” 约束条件,把它提高到足够通用的、普适的场景来看。

Testable,强调的是模块的可测试性。设计应该以可测试性为第一目标。

可测试往往意味着低耦合。一个模块可以很方便地进行测试,那么就可以说它是一个设计优良的模块。模块测试的第一步是环境模拟。模块依赖的模块列表、模块的输入输出,这些是模块测试的需要,也是模块耦合度的表征。

当然,可测试性不单单因为是耦合的需要。测试让我们能够发现模块构架调整的潜在问题。通常模块在架构调整期(代码重构)最容易引入 Bug。 只有在模块开发过程中我们就不断积累典型的测试数据,以案例的形式固化所有已知 Bug,才可能在架构调整等最容易引发问题的情形下获得最佳的效果。

Orthogonal Decomposition,中文的意思是 “正交分解”。架构就是不断地对系统进行正交分解的过程。

相信大家都听过一个设计原则:“优先考虑组合,而不是继承”。如果我们用正交分解的角度来诠释这句话,它本质上是鼓励我们做乘法而不是做加法。组合是乘法,它是让我们用相互正交、完全没有相关性的模块,组合出我们要的业务场景。而继承是加法,通过叠加能力把一个模块改造成另一个模块。

核心系统的伤害值

正交分解,第一件事情就是要分出哪些是核心系统,哪些是周边子系统。核心系统构成了业务的最小功能集,而后通过不断增加新的周边功能,而演变成功能强大的复杂系统。

对于核心系统的变更要额外小心。如果某新功能早期没有规划,后期却被界定为属于核心功能,我们就需要认真评估它对既有架构的破坏性。

至于周边功能,我们核心考虑的是,如何降低添加一个新的周边功能对核心系统的影响?

不论哪一种情况,如果我们不够小心,系统就会由于不断增加功能而变老化,散发出臭味。

为了减少一个功能带来的负面影响,这个功能相关代码首先要做到尽可能内聚。

代码不一定要写到独立的模块(如果代码量不算大的话),但一定要写到独立的文件里面。对于周边系统,这部分独立出来的代码算是它的功能实现代码,不隶属于核心系统。

我们的关注点是某个周边功能对核心系统的影响。为了添加这个功能,它必然要求核心系统添加相关的代码以获得执行的机会。

我们根据经验可以初步判断,核心系统为这个周边功能增加的代码量越少,那么这个功能与核心系统的耦合就越低。那么,是否有可能把一个功能的添加对核心系统的影响降低到零,也就是不改一行代码?

这当然是可能的,只不过这要求核心系统需要提供所谓 “插件机制”。后续我们会继续探讨这个话题,今天暂且按下不表。

我们先把话题收回到架构设计质量的评估。

上面我们谈了一些架构设计的基本准则,但还谈不上是质量评估的方法。质量判定的方法可以是定性的,也可以是定量的。

定性的判断方法有一定的数据支撑,虽然这种支撑有可能是模糊而感性的。比如我们通常会说,“从 XXX 的角度看,我感觉这个更好”。这里 XXX 是某种定性分析的依据。

从科学严谨性的角度,有定量的判断方法是更理想的状态。可惜的是,到目前为止,我个人并没有听到过任何定量的判断方法来确定架构设计的优劣。但今天我会给出一些个人发明的判定公式。它们都只是经验公式,并没有经过严谨的数学证明。

我们假设,某个架构设计方案将系统分成了 n 个模块,记为:[M1

, M 2

, ..., M n

]。其中 M1

是核心系统,其他模块是周边系统。为简化,我们不妨假设周边系统与周边系统间是正交的,相互没有耦合。

那么,我们第一个最关注的问题是:

核心系统受到各周边系统的总伤害是多少?

这里有一个周边功能对核心系统总伤害的经验公式:

$$ \sum_ {对每一处修改} log_2(修改行数+1)$$

同一个周边功能相邻的代码行算作一处修改。不同周边功能的修改哪怕相邻也算作多处。

这个公式核心想表达的含义是:修改处数越多,伤害越大。对于每一处修改,鼓励尽可能减少到只修改一行,更多代码放到周边模块自己那里去。

这个伤害值公式,当然也同样适用于度量某个周边功能对核心系统的影响面。

核心系统越干净,增加新功能越容易。由于核心系统的地位,所以这个公式实际上是最重要的测量公式。

模块的耦合度测量

我们第二个关注的问题,是每个模块自身的质量。模块自身的质量具体来说,又包括模块接口的质量和模块实现的质量。

我们先看模块接口的质量,这是模块级别最重要的东西。它取决于以下两个方面:

其一,接口与业务的匹配性。简单说,就是接口越自然体现业务越好。然而,从机器判定的角度来说,这一点是不可计算的,完全依赖于个人的主观判断。我们在下一讲 “少谈点框架,多谈点业务” 中将会继续探讨这个话题。

其二,接口的外部依赖,也就是模块接口对外部环境的耦合度。

下面我们要介绍的是模块的 “耦合度测量公式”。它同时适用于模块实现和模块接口的耦合度测量。

假设,我们的模块实现(或模块接口)依赖了模块 A,那么我们的模块实现(或模块接口)与所依赖的模块 A 的耦合度为:

$$ \sum_{对每一个依赖的符号}log_2(符号的出现次数+1)$$

依赖的符号(symbol)是指:

  • 被引用的类型,包括 typedef(type alias)、class 或 struct;
  • 被引用的全局变量、全局函数或成员函数。

单个模块清楚了,我们看模块实现(或模块接口)的所有外部依赖,也就是该模块的总耦合度公式为:

$$ \sum_{对每一个依赖模块A} (耦合度_A * 不成熟度系数_A)$$

其中,耦合度A是该模块与依赖模块 A 的耦合程度,公式见上。不成熟度系数A 表征依赖模块 A 的不成熟度程度。如果依赖模块 A 完全成熟,不会再发生改变则为0;如果模块在发生非常剧烈变动,连规格都完全没法确定则为 1。

通过该耦合度测量公式可以看出,我们鼓励依赖外部成熟模块。理论上完全成熟的模块很可能就只有语言内置的数据类型如 int、string 等,其他模块多多少少还是会经受一些变化,所以还是尽量减少外部依赖。

另外值得一提的是,将模块接口引用的类型 A 改为 object 或 interface{} 类型并不能降低耦合度。也就是说如果某参数为 interface,那么这个 interface 的耦合度要看功能实际使用时,它存在各种可能的类型,都会计算在依赖中。

我们应该怎么看待耦合度测量公式?

需要强调的是,它是一个经验公式,仅仅是代表了某种价值主张。在实际应用中,计算得到的具体耦合度值并无物理含义,只能用来对比两个相同功能的系统(或模块)架构设计方案。对于两个功能完全不同的 A、B 系统(或模块),其计算结果并不能用于评判彼此的好坏。

结语

今天我们探讨的话题是如何评判架构设计的优劣。

首先我们谈的是架构设计的基本准则,它们虽然不足以明确说谁好或是不好,但是指明了方向。

然后我们开始对架构好坏做定性甚至定量的分析。考虑到核心系统的重要性,我们单独引入了一个伤害值来评估它的纯洁度。

最后,我们对模块自身的接口或实现,给出了耦合度测量公式。通过这个公式,我们明确了我们架构设计的价值主张。

但我们需要意识到的一点是,这些并不是全部。判断模块间的耦合度是复杂的。上面我们的公式某种程度上来说只考虑了静态依赖关系,而没有考虑动态依赖。

比如说,我们考虑两个网络模块 A 和 B,一个显而易见的耦合度判断是:

  • A 调用 B 的网络接口数量越多,依赖越大(静态依赖,上面我们已经考虑);
  • A 调用 B 的网络接口的次数越多,依赖越大(动态依赖,上面我们未考虑)。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “少谈点框架,多谈点业务”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 少谈点框架,多谈点业务

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

架构是共识确认的过程

对于架构这件事情,有不少让人误解的地方。前面在 “57 | 心性:架构师的修炼之道” 一讲中,我们提到过架构师需要掌握的三大技能:

  • 理需求的能力;
  • 读代码的能力;
  • 抽象系统的能力。

这里面除了 “读代码” 这件事可能允许没有什么显性的产出外(其实也应该有,去读代码通常意味着缺架构设计文档,所以按理应该去补文档),其他两类事情要做好都不容易。

就理需求的能力而言,很多架构师一不知道要做需求分析,二不知道需求分析的产出到底应该是什么样的。需求分析可以说是架构师最没有概念的一个环节,尽管它至关重要。这一块领域特征比较明显,课堂上讲师授课的方式,很难有好的成效,更适合以实训的方式来强化。

就抽象系统的能力而言,很多架构师爱画架构图。画完了架构图,他就认为架构做完了,下一步该去编码。

这有什么问题?

首先,架构过程是团队共识形成与确认的过程。共识是需要精确的、无歧义的。而架构图显然并不精确。

团队没有精确的共识很可怕,它可能导致不同模块的工作牛头不对马嘴,完全无法连接起来,但是这个风险没有被暴露,直到最后一刻里程碑时间要到了,要出版本了,大家才匆匆忙忙联调,临时解决因为架构不到位产生的“锅”。

这时候人们的动作通常会走形。追求的不再是架构设计的好坏,而是打补丁,怎么把里程碑的目标实现了,别影响了团队绩效。

我们作个工程师的类比,这种不精确的架构,就好比建筑工程中,设计师画了一个效果图,没有任何尺寸和关键细节的确认,然后大家就分头开工了。最后放在一起拼接(联调),发现彼此完全没法对上,只能临时修修改改,拼接得上就谢天谢地了。是不是能够和当初效果图匹配?让老天爷决定吧。

更精确描述架构的方法是定义每个模块的接口。接口可以用代码表达,这种表达是精确的、无歧义的。架构图则是辅助模块接口,用于说明模块接口之间的关联。

为了证明接口的有效性,架构师还应该过一遍所有的用户故事,以伪代码或流程图的方式,把所有用户故事过一遍,确认模块之间的接口串起来是可以正常工作的。

实际上更有效的方法是在概要设计(也叫系统设计)阶段就把框架代码写出来,真真正正用代码,而不是伪代码,把用户故事串一遍。

代码即文档。代码是理解一致性更强的文档。

这样做的好处是,我们把联调工作做到了前头,工程的最大风险就得到了管理。剩下来的就是每个模块自身的好坏,这就和组织能力无关,只取决于我们招聘的工程师个体素质了。

所以模块的接口,是架构设计的核心。

别让框架绑架业务

接口代表什么?接口代表业务。架构图代表什么?架构图代表框架。

不要让框架绑架业务。

在架构的两侧,一边是用户需求,一边是技术。接口代表用户需求,代表业务。框架代表技术,是我们满足需求的方法。

框架它是重要的。但是不要让框架反客为主,溢出模块边界。在系统迭代的过程中,框架会经受变化,以适应需求的演进过程。

抓住稳定的东西,比追逐变化更重要。

框架,体现的是需求泛化的能力。从架构思维角度上来说,它是通过抽象出需求模板,把多个需求场景中变化的部分抽离出来,形成相对稳定的泛化需求。

框架的抽象能力不是一蹴而就的,它既依赖我们抽象系统的能力,也依赖我们对领域需求的理解程度。所以框架会随着时间而迭代,逐步向最理想的状态逼近。

如果框架不能满足需求,但我们不迭代框架,而是硬生生去添加这样的功能需求,会发生什么?

结果是,代码逃逸出框架,把系统搅得支离破碎。这时候你可能能够嗅到一丝危险的气息。但是你可能说没办法,里程碑的截止时间就在那里,没办法。

这实际上是大框架面临的最大挑战。它最好能够提前预测所有可能的需求,以此抑制潜在代码逃逸的风险。

但这很难。

所以我们应该换一个角度看这个问题。在如何持续保证系统洁净的这件事情上,我个人给的建议是:

连接性的代码越少越好。

什么是连接性的代码?就是把两个子的业务系统连接,构成一个大业务场景的代码。如果有大业务场景,应该抽象出新的更大范畴的业务系统。

这样我们的焦点就始终在业务上。

**每个模块都是一个业务。**这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。

抽象出符合业务自然语义的接口,远比抽象出泛需求的框架要容易得多。因为,业务语义是稳定的。

关注业务接口的定义,我们自然就把焦点转向关注业务如何由相互正交的子业务组合而来。

我们举例来说明关注业务与关注框架这两种思维方式的差异性。

我们知道,在 IO 系统中,读取磁盘文件中的数据有两种常见的模型:SAX 和 DOM 模型。

SAX 模型是一种基于事件的读盘机制。在读完一个完整的数据单元时,就发送一个读到某数据单元的事件。比如在 XML 中,它的事件接口看起来是这样的:

type ContentHandler interface {
  StartDocument()
  StartElement(element string, attrs Attributes)
  Characters(chars []byte)
  EndElement(element string)
  EndDocument()
}

DOM 模型则基于对象的组织模型来提供数据读取的能力。细分来说,它又有两种不同的选择。一种是基于抽象的 DOM 树,它看起来是这样的:

type Nodes interface {
  Len() int
  Elem(i int) Node  
}

type Node interface {
  Childs() Nodes
  Name() string
  Type() NodeType
  Text() []byte
  Attributes() Attributes
}

另一种是基于更具体的业务逻辑,与具体的领域相关,比如对于 Word/WPS 这样的字处理软件看起来是这样的:

type Span interface {
  Text() []byte
  Attributes() SpanAttrs
}

type Spans interface {
  Len() int
  Elem(i int) Span
}

type Paragraph interface {
  Spans() Spans
  Attributes() ParagraphAttrs
}

type Paragraphs interface {
  Len() int
  Elem(i int) Paragraph
}

type Document interface {
  Paragraphs() Paragraphs
  Attributes() DocumentAttrs
}

基于 SAX 模型是非常典型的框架思维。它的确足够的通用,但它有两个问题。

一方面,基于事件模型是一个非常简陋的编程框架,与大部分 IO 子系统的需求方的诉求并不那么匹配。关于这一点我们在下一讲 “60 | 架构分解:边界,不断重新审视边界” 还会详细展开。

另一方面,它不体现业务,使用方不能在缺乏文档配合的情况下正确地使用这个接口。本来代码应该是精确的,但是这样的接口把精确性这个最佳的优点给放弃了。

基于 DOM 模型的两种模型中,看起来前者的接口很简洁,但实际上它和上面的 SAX 模型有类似的问题:不体现业务。而后者虽然看起来非常冗长,但是它可以脱离额外的接口说明文档而直接毫无心智负担地使用。毫无疑问,这才是我们该追寻的接口描述方式。

别用实现替代业务

在接口设计中,我们还看到另一种倾向,可以认为是用框架来替代业务的特例:用实现机制替代业务。

我个人经常给架构师们说的一句话是:

比框架(架构图)更重要的是数据结构,比数据结构更重要的是接口。

为什么数据结构比框架(架构图)更重要?业务数据结构是架构实现机制的灵魂。从共识确认的角度,数据结构相比框架而言,是更重要的共识。

一些架构师能够想清楚实现,但是想不清楚业务。他们用实现替代对业务系统的抽象。

用实现机制替代业务的典型案例是定义了数据结构,但是不抽象数据的业务逻辑,直接让使用方操作成员变量,或者定义一堆成员变量的 get/set 接口。

另一个例子是当我们用 ORM 框架操作数据库时,工程师非常容易犯的错误是,直接操作数据结构,而忽略定义业务接口的重要性。

结语

今天谈的内容,核心指向一点:

架构就是业务的正交分解。每个模块都有它自己的业务。

这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。

它看似简单,但是它太重要了,重要到需要单独一讲来把它谈清楚。它是一切架构动作的基础。

架构行为的三步曲:“需求分析”、“概要设计”、模块的 “详细设计”,背后都直指业务的正交分解,只是逐步递进,一步步从模糊到越来越强的确定性,直至最终形成业务设计的完整的、精确无歧义的解决方案。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “架构分解:边界,不断重新审视边界”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 架构分解:边界,不断重新审视边界

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在上一讲 “59 | 少谈点框架,多谈点业务” 中,我们强调:

架构就是业务的正交分解。每个模块都有它自己的业务。

这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。

接口是业务的抽象,同时也是它与使用方的耦合方式。在业务分解的过程中,我们需要认真审视模块的接口,发现其中 “过度的(或多余的)” 约束条件,把它提高到足够通用的、普适的场景来看。

IO 子系统的需求与初始架构

这样说太抽象了,今天我们拿一个实际的例子来说明我们在审视模块的业务边界时,需要用什么样的思维方式来思考。

我们选的例子,是办公软件的 IO 子系统。从需求来说,我们首先考虑支持的是:

  • 读盘、存盘;
  • 剪贴板的拷贝(存盘)、粘贴(读盘)。

读盘功能不只是要能够加载自定义格式的文件,也要支持业界主流的文件格式,如:

  • Word 文档、RTF 文档;
  • HTML 文档、纯文本文档。

存盘功能更复杂一些,它不只是要支持保存为以上基于文本逻辑的流式文档,还要支持基于分页显示的文档格式,如:

  • PDF 文档;
  • PS 文档。

对于这样的业务需求,我们应该怎么做架构设计?

我第一次看到的设计大概是这样的:

type Span struct {
  ...

  SaveWord(ctx *SaveWordContext) error
  SaveRTF(ctx *SaveRTFContext) error

  LoadWord(ctx *LoadWordContext) error
  LoadRTF(ctx *LoadRTFContext) error
}

type Paragraph struct {
  ...
  SpanCount() int
  GetSpan(i int) *Span

  SaveWord(ctx *SaveWordContext) error
  SaveRTF(ctx *SaveRTFContext) error

  LoadWord(ctx *LoadWordContext) error
  LoadRTF(ctx *LoadRTFContext) error
}

type TextPool struct {
  ...
  ParagraphCount() int
  GetParagraph(i int) *Paragraph

  SaveWord(ctx *SaveWordContext) error
  SaveRTF(ctx *SaveRTFContext) error

  LoadWord(ctx *LoadWordContext) error
  LoadRTF(ctx *LoadRTFContext) error
}

type Document struct {
  ...
  TextPool() *TextPool

  SaveWord(stg IStorage) error
  SaveRTF(f *os.File) error
  SaveFile(file string, format string) error

  LoadWord(stg IStorage) error
  LoadRTF(f *os.File) error
  LoadFile(file string) error
}

从上面的设计可以看出,读盘存盘的代码散落在核心系统的各处,几乎每个类都需要进行相关的修改。这类功能我们把它叫做 “全局性功能”。我们下一讲将专门讨论全局性功能怎么做设计。

全局性功能的架构设计要非常小心。如果按上面这种设计,我们无法称之为一个独立的子系统,它完完全全是核心系统的一部分。

某种程度上来说,这个架构是受了 OOP 思想的毒害,以为一切都应该以对象为中心,况且在微软的 MFC 框架里面有 Serialization 机制支持,进一步加剧了写这类存盘读盘代码的倾向。

这当然是不太好的。在良好的设计中,一方面核心系统功能要少,少到只有最小子集;另一方面核心功能要能够收敛,不能越加越多。

但读盘存盘的需求是开放的,今天支持 Word 和 RTF 文档,明天支持 HTML,后天微软又出来新的 docx 格式。文件格式总是层出不穷,难以收敛。

Visitor 模式

所以,以上读盘存盘的架构设计不是一个好的架构设计。那么应该怎么办呢?可能有人会想到设计模式中的 Visitor 模式。

什么是 Visitor 模式?简单来说,它的目的是为核心系统的 Model 层提供一套遍历数据的接口,数据最终是通过事件的方式接收。如下:

type Visitor interface {
  StartDocument(attrs *DocumentAttrs) error
  StartParagraph(attrs *ParagraphAttrs) error
  StartSpan(attrs *SpanAttrs) error
  Characters(chars []byte) error 
  EndSpan() error
  EndParagraph() error
  EndDocument() error
}

type VisitableDoc interface {
  Visit(visitor Visitor) error
}

type Document struct {
  ...
  Visit(visitor Visitor) error
}

func NewDocument() *Document
func LoadDocument(doc VisitableDoc) (*Document, error)

func SaveWord(stg IStorage, doc VisitableDoc) error
func SaveRTF(f *os.File, doc VisitableDoc) error
func SaveFile(file string, format string, doc VisitableDoc) error

func LoadWord(stg IStorage) (VisitableDoc, error)
func LoadRTF(f *os.File) (VisitableDoc, error)
func LoadFile(file string) (VisitableDoc, error)

这样做的好处是显然的。

一方面,核心系统为 IO 系统提供了统一的数据访问接口。这样 IO 子系统就从核心系统中抽离出来了。

另一方面,Word 文档的支持、RTF 文档的支持这些模块在 IO 子系统中也彼此完全独立,却又相互可以非常融洽地进行配合。比如我们可以很方便将 RTF 文件转为 Word 文件,代码如下:

func ConvRTF2Word(rtf *os.File, word IStorage) error {
  doc, err := LoadRTF(rtf)
  if err != nil {
    return err
  }
  return SaveWord(word, doc)
}

类似地,加载一个 Word 文件的代码如下:

func LoadWordDocument(stg IStorage) (*Document, error) {
  vdoc, err := LoadWord(stg)
  if err != nil {
    return nil, err
  }
  return LoadDocument(vdoc)
}

那么这个设计有什么问题?

如果你对比上一讲 “59 | 少谈点框架,多谈点业务” 提到的 SAX 和 DOM 模式,很容易看出这里的 Visitor 模式本质上就是 SAX 模式,只不过数据源不再是磁盘中的文件,而是换成了核心系统的 Model 层而已。

所以我前面讲的 SAX 模式的缺点它一样有。它最大的问题是有预设的数据访问逻辑,其客户未必期望以相同的逻辑访问数据。

基于事件模型是一个非常简陋的编程框架,与大部分 IO 子系统的需求方,比如我们这里的 Word 文档存盘、RTF 文档存盘的诉求并不那么匹配。解决这种不匹配的常规做法是把数据先缓存下来,等到我当前步骤所有需要的数据都已经发送过来了,再进行处理。

这个设计并不是假想的,实际上我当年在做 WPS Office IO 子系统第一版本的架构设计时,就采用了这个架构。但最终实践下来,我自己总结的时候认为它是一个非常失败的设计。

一方面,虽然 Visitor 或者 SAX 模式看起来是 “简洁而高效” 的,但是实际编码中程序员的心智负担比较大,有大量的冗余代码纯粹就是为了缓存数据,等待更多有效的数据。

另一方面,这个接口仍然是抽象而难以理解的。比如,不同事件的次序是什么样的,需要较长的文档说明。

这也是给架构师们提了个醒,我们架构设计的 KISS 原则提倡的简单,并不是接口外观上的简洁,而是业务语义表达上的准确无歧义。

IO DOM 模式

所以第二次的架构迭代,我们调整为基于 DOM 模式,如下:

type IoSpan interface {
  Text() []byte
  Attributes() IoSpanAttrs
}

type IoSpans interface {
  Len() int
  Elem(i int) IoSpan
}

type IoParagraph interface {
  Spans() IoSpans
  Attributes() IoParagraphAttrs
}

type IoParagraphs interface {
  Len() int
  Elem(i int) IoParagraph
}

type IoDocument interface {
  Paragraphs() IoParagraphs
  Attributes() IoDocumentAttrs
}

func NewIoDocument() IoDocument 

type Document struct {
  ...
  Io() IoDocument
}

func NewDocument() *Document

func SaveWord(stg IStorage, doc IoDocument) error
func SaveRTF(f *os.File, doc IoDocument) error
func SaveFile(file string, format string, doc IoDocument) error

func LoadWord(stg IStorage, doc IoDocument) error
func LoadRTF(f *os.File, doc IoDocument) error
func LoadFile(file string, doc IoDocument) error

在这个架构,我们认为有两套 DOM,一套是 IO DOM,即 IoDocument 接口及其相关的接口。一套是核心系统自己的 DOM,也就是 Document 类及其相关的接口。这两套接口几乎是雷同的,理论上 Document 只是 IoDocument 这个 DOM 的超集。

那么为什么不是直接在接口上体现出超集关系?从语法表达上很难,毕竟这是一个接口族,而不是一个接口。这里我们通过在 Document 类引入 Io() 函数来将其转为 IoDocument 接口,以体现双方的超集关系。

在这个方案下,将 RTF 文件转为 Word 文件的代码如下:

func ConvRTF2Word(rtf *os.File, word IStorage) error {
  doc := NewIoDocument()
  err := LoadRTF(rtf, doc)
  if err != nil {
    return err
  }
  return SaveWord(word, doc)
}

类似地,加载一个 Word 文件的代码如下:

func LoadWordDocument(stg IStorage) (*Document, error) {
  doc := NewDocument()
  err := LoadWord(stg, doc.Io())
  if err != nil {
    return nil, err
  }
  return doc, nil
}

相比前面的 Visitor 模式,采用 IO DOM 除了让所有存盘读盘的模块代码工程量变低,接口的理解一致性更好外,还有一个额外的好处,是 IO DOM 更自然,避免了惊异。因为核心系统的 Model 层通常就是通过 DOM 接口暴露的,而 IO DOM 从概念上只是一个子集关系,显然对客户的理解成本来说是最低的。而 Visitor 模式你可以理解为它是核心系统 Model 层为 IO 子系统提供的专用插件机制,它对核心系统来说是额外的成本。

事实上,在 DOM 模式基础上提供 Visitor 模式是有点多余的。DOM 模式通常提供了极度灵活的数据访问接口,可以适应几乎所有的数据读取场景。

回到最初的需求

我们是否解决了最初 IO 子系统的所有需求?

我们简单分析下各类用户故事(User Story)就能够发现其实并没有。我们解决了所有流式文档的存盘读盘,但是没有解决基于分页显示的文档格式支持,如:

  • PDF 文档;
  • PS 文档。

因为从核心系统 DOM 得到的文档,或者我们抽象的 IO DOM,都是流式文档,并没有分页信息。如果我们 PDF、PS 文档的存盘接口是这样的:

func SavePDF(f *os.File, doc IoDocument) error
func SavePS(f *os.File, doc IoDocument) error

那么意味着这些存盘模块的实现者需要对 IO DOM 进行排版(Render),得到具备分页信息的数据结构,然后以此进行存盘。

这意味着 IO 子系统在特定的场景下,其实与排版与绘制子系统相关,包括:

  • 屏幕绘制(onPaint);
  • 打印(onPrint)。

可能有些人能够回忆起来,前面在 “22 | 桌面程序的架构建议” 一讲介绍 Model 和 ViewModel 之间的关系时,我也是拿 Office 文档举例。核心系统的 DOM,或者 IO 子系统的 IO DOM,通过排版(Render)功能,可以渲染出 View 层所需的显示数据,我们不妨称之为 View DOM。

而有了 View DOM,我们就不只是可以进行屏幕绘制和打印,也可以支持 PDF/PS 文档的存盘了。代码如下:

func Render(doc IoDocument) (ViewDocument, error)

func SavePDF(f *os.File, doc ViewDocument) error
func SavePS(f *os.File, doc ViewDocument) error

如果你做需求分析的时候,没有把这些需求关联性找到,那就不是一次合格的需求分析过程。

不断重新审视边界

到此为止,我们的分析是否已经足够细致,把所有关键细节都想得足够清楚?

其实并没有,我们在理需求时,我们首先要考虑支持的是:

  • 剪贴板的拷贝(存盘)、粘贴(读盘)。

但是我们在整理用户故事(User Story)的时候仍然把它给漏了。当然,剪贴板带来的影响没有 PDF/PS 文档大,它只是意味着我们的数据流不再是 *os.File 可以表达,而是需要用更抽象的 io.Reader/Writer 来表示。也就是说,以下接口:

func SaveRTF(f *os.File, doc IoDocument) error
func LoadRTF(f *os.File, doc IoDocument) error

func SavePDF(f *os.File, doc ViewDocument) error
func SavePS(f *os.File, doc ViewDocument) error

要改为:

func SaveRTF(f io.Writer, doc IoDocument) error
func LoadRTF(f io.Reader, doc IoDocument) error

func SavePDF(f io.Writer, doc ViewDocument) error
func SavePS(f io.Writer, doc ViewDocument) error

这其实就是我前面强调的 “发现模块接口中多余的约束”的一种典型表现。在我们模块提高到足够通用的、普适的场景来看时,实际上并不需要剪贴板这样具体的用户场景,也能够及时地发现这种过度约束。

另外,我们的 IO 子系统的入口级的接口:

func SaveFile(file string, format string, doc IoDocument) error
func LoadFile(file string, doc IoDocument) error

我们且不说这里面怎么实现插件机制,以便于我们非常方便就能够不修改任何代码,就增加一种新的文件格式的读写支持。我们单就它的边界来看,也需要进一步探讨。

其一,LoadFile 方法我们可能希望知道加载的文件具体是文档格式,所以应该改为:

func LoadFile(file string, doc IoDocument) (format string, err error)

其二,考虑到剪贴板的支持,我们输入的数据源不一定是文件,还可能是 io.Reader、IStorage 等,在 Windows 平台下有 STGMEDIUM 结构体来表达通用的介质类型,可以参考。从跨平台的角度,也可以考虑直接用 Go 语言中的任意类型。如下:

func Save(src interface{}, format string, doc IoDocument) error
func Load(src interface{}, doc IoDocument) (format string, err error)

既然用了 interface{} 这样的任意类型,就意味着我们需要在文档层面上补充清楚我们都支持些什么,不支持些什么,避免在团队共识上遇到麻烦。

其三,考虑 PDF/PS 这类非流式文档的支持,我们不能用 IoDocument 作为输入文档的类型。也就是说,以下接口:

func Save(dest interface{}, format string, doc IoDocument) error

需要作出适当的调整。具体应该怎么调?欢迎留言发表你的观点。

结语

这一讲我们通过一个实际的例子,来剖析架构设计过程中我们如何在思考模块边界。

最重要的,当然是职责。不同的业务模块,分别做什么,它们之间通过什么样的方式耦合在一起。这种耦合方式的需求适应性如何,开发人员实现上的心智负担如何,是我们决策的影响因素。

为了避免留下难以调整的架构缺陷,我们强烈建议你认真细致做好需求分析,并且在架构设计时,认真细致地过一遍所有的用户故事(User Story),以确认我们的架构适应性。

最后,我们在具体接口的每个输入输出参数的类型选择上,一样要非常考究,尽可能去发现其中 “过度的(或多余的)” 约束。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题按照大纲是 “全局性功能的架构设计”,但我计划做一篇加餐,内容是架构思维实战,把前面我们的实战案例 “画图程序” 和这几讲的理论知识结合起来。

大家可以提前思考以下内容:对画图程序进行子系统的划分,我们的哪些代码是核心系统,哪些是周边系统?从判断架构设计的优劣的角度,我们如何评判它好还是不好?

如果你自己也实现了一个 “画图程序”,可以根据这几讲的内容,对比一下我们给出的样例,和自己写的有哪些架构思想上的不同,怎么评价它们的好坏?

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

加餐 | 实战:“画图程序” 的整体架构

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

我们先回顾一下 “架构思维篇” 前面几讲的内容:

我们先谈了怎么才能修炼成为一个好的架构师,其中最核心的一点是修心。这听起来好像一点都不像是在谈一门有关于工程的学科,但这又的的确确是产生优秀架构师最重要的基础。

接下来几篇,我们核心围绕着这样几个话题:

  • 什么是好的架构?
  • 架构的本质是业务的正交分解,分解后的每个模块业务上仍然是自洽的。

我们反复在强调 “业务” 一词。可以这样说,关注每个模块的业务属性,是架构的最高准则。

不同模块的重要程度不同,由此我们会区分出核心模块和周边模块。对于任何一个业务,它总可以分解出一个核心系统,和多个周边系统。不同周边系统相互正交。即使他们可能会发生关联,也是通过与核心系统打交道来建立彼此的间接联系。

今天我们将通过第二章 “桌面开发篇” 的实战案例 “画图程序” 来验证下我们这些想法。我们以最后一次迭代的版本 v44 为基础:

整体结构

我们先来分析整个 “画图” 程序的整体结构。除了 index.htm 作为总控的入口外,我们把其他的文件分为以下四类:

  • 核心系统(棕色):这些文件隶属于整个画图程序的业务核心,不可或缺;
  • 周边系统(黄色):这些文件属于业务的可选组件;
  • 通用控件(绿色):这些文件与画图程序的业务无关,属于通用的界面元素,由画图程序的周边系统所引用;
  • 基础框架(紫色):这些文件与画图程序的业务无关,属于第三方代码,或者更基础的底层框架。

我们可以有如下文件级别的系统组织结构:

图片: https://uploader.shimo.im/f/zKB8bU0Xia8FSf6z.png

通过这个图我们可以看出,这个画图程序的 “内核” 是非常小的,就三个文件:index.htm、view.js、dom.js。为了让你看到每个文件的复杂度,我把各个文件的代码规模也在图中标了出来。如果我们把所有的周边系统以及它们的依赖代码去除,整个程序仍然是可以工作的,只不过我们得到的是一个只读的画图程序的查看器(QPaintViewer)。

这很有意思,因为我们把所有的 Controllers 都做成了彼此完全正交的可选组件。

有了这个图,我们对各个文件之间的关系就很清楚了。接下来,正如我们在 “ 58 | 如何判断架构设计的优劣?” 中说的那样,我们最关心的还是周边系统,也就是这些 Controller 对核心系统的伤害是什么样的。

我们先把所有引用关系列出来:

我们先看 creator/rect.js 模块。它对 View 层,主要是 QPaintView 类的引用是 10 处,对 Model 层,主要是 QPaintDoc、Shape、QShapeStyle 这三者的引用是 6 处。每处引用都是 1 行代码,直接调用 View 层或 Model 层对外提供的接口方法。

单就 creator/rect.js 模块而言,它对核心系统的伤害值为 10 + 6 = 16。但是实际上这些接口方法绝大部分并不是专门提供给 creator/rect.js 模块的,这意味着所有周边模块应该共担这个伤害值。比如某个接口方法被 N 个周边模块引用,那么每个周边模块分担的伤害值为 1/N。

这个逻辑初听起来有点奇怪,我新增一个和我互不相关的周边模块,怎么会导致一个既有周边模块对核心系统的伤害值降低?

这是因为,我们的伤害值是工程测量值。我们往极端来说,如果有无穷多个周边模块都会引用某个接口方法,那么对于其中某个周边模块来说,它为此造成的伤害值为 0,因为这个接口太稳定了。这也证明,抽象出共性的业务方法,比给某个周边模块单独开绿灯要好。我们定义业务的接口要尽可能追求自然。

但是现实中,被无数个周边模块引用的接口是不存在的。你可能主观判断我这个接口是很通用的,但是它需要实证的依据。每增加一个引用方,这个实证就被加强一次。这也是为什么增加一个新周边模块会导致既有周边模块伤害值降低的原因,因为它证实了一些接口方法的确是通用的。

有一些接口当前只有 creator/rect.js 引用的,这些接口的引用代码在表格中我把它们标为红色,它们是:

  • new QLine
  • new QRect
  • new QEllipse
  • shape.onpaint

我们一眼看过去就很清楚,这些接口确实是非常通用的接口。之所以它们只有 creator/rect.js 引用,是因为这个 “画图” 程序当前的规模还比较小,随着越来越多的周边模块加入,逐步也会有更多人分担伤害值。

当前系统有 5 个周边模块。考虑多个周边模块共担伤害值的情况,creator/rect.js 模块对核心系统的伤害值是多少?

我们做个近似,只要某个接口已经被超过一个周边模块引用,就认为它的引用次数是 5,而不是一一去统计它。这样算的话,creator/rect.js 模块对核心系统的伤害值约 12/5 + 4 = 6.4。

类似地,我们可以计算其他周边模块对核心系统的伤害值,具体如下:

  • creator/path.js 模块,伤害值约 12/5 + 1 = 3.4。
  • creator/freepath.js 模块,伤害值约 13/5 = 2.6。
  • accel/select.js 模块,伤害值约 10/5 + 6 = 8。
  • accel/menu.js 模块,伤害值约 5/5 + 6 = 7。

如果我们把所有周边模块看作整体,它和核心系统的关系如下:

可以看出,整个周边系统对核心系统的引用是 31 处,也就是说它带来的伤害值为 31。这和上面我们近似计算得到的所有周边系统伤害值之和 6.4 + 3.4 + 2.6 + 8 + 7 = 27.4 不同。这中间的差异主要由于我们没有去实际统计接口方法的引用次数而直接统一用 5,所以估算的伤害值比实际会小一点。

Model 层

看完了整体,我们把关注点放到 Model 层。

对于这个画图程序,代码量最多的就是 Model 层,即 dom.js 文件,大约 850 多行代码。所以我们决定进一步分解它,得到如下结构:

当我们把 Model 层看作一个完整的业务时,它内部仍然可以分解出一个核心系统,和多个周边系统。并且同样地,我们把代码分为四类:

  • 核心系统:隶属于整个画图程序的业务核心,不可或缺,我们标记为棕色或白色;
  • 周边系统:属于业务的可选组件,主要是各类图形;
  • 操作系统相关的辅助函数:与业务无关,但是和平台相关,我们标记为绿色;
  • 纯算法的辅助函数:与业务无关,与操作系统也无关,我们标记为紫色。

上图的核心系统中,标记为棕色的模块与白色的模块的区别在于,标棕色的模块会被周边系统所引用,属于核心系统的 “接口级” 模块。标白色的模块只被核心系统内部所引用,不把它们画出来也是可以的。

另外,图中 Shape 接口因为 JavaScript 是弱类型语言,它在代码中并没有显式体现出来。这里我们将它用 Go 语法表达如下:

type number = float64
type any = interface{}

type HitResult struct {
  hitCode  number
  hitShape Shape
}

type Shape interface {
  style QShapeStyle
  onpaint(ctx CanvasRenderingContext2D)
  hitTest(pt Point) HitResult
  bound() Rect
  setProp(parent any, key string, val any)
  move(parent any, dx, dy number)
  toJSON() any
}

当然,和分析整个画图程序一样,我们最关心的还是周边系统对核心系统的伤害是什么样的。

我们先把所有引用关系列出来:

对于 Model 层来说,目前我们需求的开放性主要体现在图形(Shape)的种类。未来是否要支持图片,是否要支持艺术字等等,这些存在很大的变数。所以我们当前的周边模块,基本上都是某种图形。

通过这个表格我们可以看出,不同的图形对核心系统的需求完全一模一样。我们很容易计算得到,整个周边系统对核心系统的伤害值为 4,平均每一种图形的伤害值为 1。

通用控件库

聊了文件级别的组织结构,也聊了 Model 层,我们画图程序的整体脉络也就出来了。这里我再补充一个虽然和业务无关,但是也是一个不小的体系设计:通用控件库子系统。

控件的种类是无穷的,我们自然而然得去考虑怎么适应未来的需求。出于开放性架构的考虑,你会发现它也可以基于核心系统和周边系统来拆分,如下:

同样地,我们最关心的还是周边系统对核心系统的伤害是什么样的。

我们先把所有引用关系列出来:

通过这个表格我们可以看出,这些控件的实现本身和核心系统,即控件框架没什么关系,它们只是把自己注册到控件框架中。所有控件对核心系统的需求完全一模一样。我们很容易计算得到,整个周边系统对核心系统的伤害值为 1,平均每一种控件的伤害值为 1/3。

结语

这一讲我们通过前面实战的画图程序作为例子,来剖析架构设计过程业务是如何被分解的。

对于复杂系统,一定要理清核心系统和周边系统的边界,让整个程序的内核最小化。

另外,我们也实际分析了画图程序中,周边模块对核心系统的伤害值。这个数据可以很好地评判不同架构方案的好坏。

如果你自己也实现了一个 “画图程序”,可以根据这几讲的内容,对比一下我们给出的样例代码,和自己写的有哪些架构思想上的不同,这些不同之处的得失是什么?

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “全局性功能的架构设计”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 全局性功能的架构设计

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在上一讲 “加餐 | 实战:画图程序的整体架构” 中,我们结合前面几章的实战案例画图程序来实际探讨我们架构思维的运用。这一篇虽然以加餐名义体现,但它对理解怎么开展和评估架构工作非常关键。

在架构设计中,我们会有一些难啃的骨头。其中最为典型的,就是全局性功能。全局性功能的特征是很难被剥离出来成为独立模块的。我们仍然以大家熟悉的 Office 软件作为例子:

  • 读盘/存盘:每增加一个功能,都需要考虑这个功能的数据如何存储到磁盘,如何从磁盘中恢复。
  • Undo/Redo:每增加一个功能,都需要考虑这个功能如何回滚/重做,很难剥离。
  • 宏录制:每增加一个功能,都需要考虑这个功能执行的结果如何用 API 表达,并且得支持将界面操作翻译成 API 语句。
  • ……

也有一些功能看似比较全局,但实际上很容易做正交分解,比如服务端的所有 API 都需要鉴权,都需要记录日志。它们似乎有全局性的影响,但一方面,通常可以在 API 入口统一处理,另一方面就算只是提供辅助函数,具体的鉴权和记录日志都由每个 API 自行处理,心智负担不算太高。所以对于这类功能,我们可以不把它归为全局性功能。

正因为需求交织在一起,全局性功能往往难以彻底进行正交分解。但对于架构师来说,难不代表应该轻易就放弃对正交分解的追求。

不能放过任何一块难啃的骨头。

读盘/存盘功能

不能很好分解往往还是需求的分析没有到位所致。前面在 “60 | 架构分解:边界,不断重新审视边界” 这一讲中我们已经拿 “读盘/存盘” 作为案例进行过分析。最终我们选择了引入 IO DOM 来进行正交分解。这里面的关键点在于:

其一,“读盘/存盘” 本身需求是发散的,因为要支持的文档格式只会越来越多。所以我们必须把它独立成一个子系统,比如叫它 IO 子系统。

其二,既然要独立子系统,就需要抽象出它对核心系统的稳定依赖。为什么这个稳定依赖最后设计为 IO DOM,是因为 DOM 是核心系统的常规界面。

引入只与数据有关的 IO DOM,相当于给 DOM 规范了一个接口子集,用于和 IO 子系统交互。这样的好处是,虽然 IO DOM 是 IO 子系统对核心系统的侵入,但这是没办法的,因为读盘存盘是全局功能,我们没法消除这种全局性,但是可以尽可能削弱到最低。

如果 IO DOM 的确是 DOM 的子集,我们相当于已经找到了尽可能削弱的方法,因为 IO DOM 名称上虽然带了 IO,但是它只是一个归类,实际上这些接口都是核心系统的常规接口,并非为 IO 子系统定制。这样一来,读盘与存盘带来的全局性影响就近乎被消除了。

这是一种很好的思考方式。

全局性功能往往容易带来某种复杂的框架。这不难理解,毕竟它是全局性的,所以常规的思路是为这个功能实现一个库,并建立一套使用它的机制,也就是框架,以应用到核心系统中去。上面 IO DOM 则是反其道而行之,通过抽象核心系统的接口,让全局性功能反向依赖这些接口来完成。这不容易,但是这样做核心系统受到的伤害值最低。

Undo/Redo 功能

我们再看一个例子,比如 Undo/Redo 功能。

读过设计模式的小伙伴们可能都知道,在设计模式中有一个模式叫 Command 模式,专门用于解决 Undo/Redo 这个功能场景的。它的基本思路是,每个用户操作都实现为一个 Command,每个 Command 需要实现反操作,以便做到 Undo 的能力。

这是一个典型的 Undo/Redo 框架。实际上这个框架本身做的事情并不多,基本上就是维护一个 Command 队列,并基于这个队列提供 Undo 和 Redo 功能。

看起来不错的样子,但实际上框架只节省了 1% 的工作量。其余 99% 的工作量在实现一个个 Command 身上,框架使用方的心智负担不是一点点的大。

那么有可能让 Undo/Redo 与核心系统解耦么?

这当然是可能的。

我第一次对 Undo/Redo 实现机制反思的灵感,来自于做 IO 子系统的经历。前面某一讲中我也提过,在我实习的时候,做的第一份工作任务是读盘与存盘。在做需求分析的时候,我发现微软 Office 支持一个很有意思的功能,叫快速存盘。在编辑一份 Word 文档,打几个字存盘时,Word 很快就可以保存完毕,而 WPS 当时则会导致交互界面停顿,存盘没有完成时用户无法编辑。

微软怎么做到的呢?它背后的机制就是快速存盘。所谓快速存盘,就是存盘的时候并不是把完整的文档写到磁盘文件中,而是将上一次存盘到这一次存盘的增量部分,追加到文档的尾部。这样一个 Word 文件就有多个版本的文档,每次读盘的时候只需要读出一个最新版本即可。

当然要想避免系统无法响应用户编辑的另一个思路是异步存盘。也就是在存盘命令执行之初,我对整个文档的 DOM 建立一份镜像(Snapshot),存盘的时候基于镜像进行后台存盘,就不会影响到用户交互。

虽然镜像的实现代价不低,但这个思路有它的独特好处,比如支持异步打印。打印机是比磁盘更慢的 IO 设备。如果在打印的时候用户就没法编辑,也是不太好的用户体验。而打印显然也无法通过类似快速存盘这种机制来实现加速,但镜像功能则可以很好地提升打印的体验。

这些对 IO 子系统的思考,为什么会对我思考 Undo/Redo 机制设计有帮助?因为它们有一个共同点,就是都和数据本身密切相关。

比如 Word 文档支持存储多个版本,我们很容易就想到,其实这个机制可以用来做 Undo/Redo。想象一下,如果用户每进行一次编辑,我就自动执行一次快速存盘,这样就在磁盘中形成多个版本。这样在做 Undo 的时候,我们只需要回退到上一个版本的文档即可。

事实上,只要支持了多版本,就有了镜像能力,也有了 Undo/Redo 能力。

这些思考,就促进了后来数据层(DataLayer)的诞生。怎么理解这个数据层?你可以把它类比为服务端的数据库。它是一个存储中间件,负责托管所有的数据。

中肯地说,数据层的引入有好有坏。

好处不必多言,有了数据层,所有异步操作不是问题,Undo/Redo 不是问题,也还有更多想象空间。

不好的地方是,它是 Model 层的基础,对我们实现 Model 层的业务逻辑是有侵入的。基于内存数据结构写程序,和基于数据库写程序,体验上会有很大的差异。从避免绑定的角度,我们会尽可能将这种差异隐藏起来,把基于数据层与不基于数据层的差异消除。

当然,随着今天软件服务化(SaaS)大行其道,基于某种存储中间件来写业务逻辑,越来越多人意识到它已经是一种必然的趋势。

回顾我们解决 Undo/Redo 的思路,你会发现,它并不是在问题发生的地方解决。这也是需求分析的复杂性所在。

宏录制功能

我们再看 “宏录制” 功能。这个功能使用的人应该不太多,不少人甚至可能并不知道它的存在。要理解 “宏录制”,首先需要知道什么是宏(Macro)。

简单来说,所谓宏(Macro),是指二次开发的代码。微软几乎所有的产品都有二次开发接口,也就是 API 层,典型代表是 Office 和 Visual Studio。

有了二次开发接口,就可以有生态,有围绕着 Office 和 Visual Studio 的生态厂商,来增强产品的能力,也可以让 Office 和 Visual Studio 更容易地融入到企业的业务流中。可以说,支持宏是微软做得最牛的地方。

那么什么是 “宏录制”?简单说,就是把用户的界面操作用 API 调用的方式记录下来,把它变成一段二次开发代码。

这有几点好处。

其一,被录制下来的 “宏”,可以被反复重放,如果某件事情经常发生,它就可以改善我们的工作效率。你甚至可以为 “宏” 指定一个快捷键,这相当于你作为用户,竟然可以给系统添加新功能。

其二,被录制下来的 “宏”,可以进行修改迭代,进行功能的增强。这有助于二次开发的新手学习 Office 或 Visual Studio 的 API 接口,大幅降低二次开发的入门难度。

那么怎么支持 “宏录制”?这个功能和它比较像的是服务端的日志,只是略有不同。

比较像的地方是,宏录制也像日志一样,会去记录一段文本。我们想象一下,如果我们的 Model 层 DOM API 也基于 RESTful API 接口,那么我们就可以在 API 入口的地方去实现 “宏录制”。

不同的地方是,“宏录制” 需要考虑 API 嵌套,我们实现某个 API 可能会调用另外某个 API,但是录制的时候,肯定只能录最外层的 API,而不是所有 API 调用都被录制下来。

这些都比较好解决。所以 “宏录制” 相比前面的 “存盘/读盘”、“Undo/Redo” 而言,是一个侵略性相对小的功能,心智负担比较低。

架构师的信仰

通过这些例子,我们需要坚定的一个信念是,任何功能都是可以正交分解的,即使我目前还没有找到方法,那也是因为我还没有透彻理解需求。

这是架构师的信仰。

换句话说,怎么做业务分解?业务分解就是最小化的核心系统,加上多个正交分解的周边系统。核心系统一定要最小化,要稳定。坚持不要往核心系统中增加新功能,这样你的业务架构就不可能有臭味。

这是我们的信仰。重要的话要说三遍。

在模块演化的过程中,随着功能的增加,复杂模块的演化可能会经历剧烈的调整期。通常这种剧烈调整起源于需求理解的进一步深化,引发对原模块接口的反思。无论如何,记住最重要的一点:保持核心系统的纯洁性比什么都重要。

结语

架构分解中有两大难题。

其一,需求的交织。不同需求混杂在一起,也就是我们今天说的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。

但无论如何,我们需要坚持作为一名架构师的信仰:

任何功能都是可以正交分解的,即使我目前还没有找到方法。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是大家很熟悉的 “开闭原则(Open Closed Principle,OCP)”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 重新认识开闭原则 (OCP)

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

架构的本质是业务的正交分解。

在上一讲 “61 | 全局性功能的架构设计” 中我们提到,架构分解中有两大难题:其一,需求的交织。不同需求混杂在一起,也就是存在所谓的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。

我们可能经常会听到各种架构思维的原则或模式。但,为什么我们开始谈到架构思维了,也不是从那些耳熟能详的原则或模式谈起?

因为,万变不离其宗。

就架构的本质而言,我们核心要掌握的架构设计的工具其实就只有两个:

  • 组合。用小业务组装出大业务,组装出越来越复杂的系统。
  • 如何应对变化(开闭原则)。

开闭原则(OCP)

今天我们就聊聊怎么应对需求的变化。

谈应对变化,就不能不提著名的 “开闭原则(Open Closed Principle,OCP)”。一般认为,最早提出开闭原则这一术语的是勃兰特·梅耶(Bertrand Meyer)。他在 1988 年在 《面向对象软件构造》 中首次提出了开闭原则。

什么是开闭原则(OCP)?

软件实体(模块,类,函数等)应该对于功能扩展是开放的,但对于修改是封闭的。

一个软件产品只要在其生命周期内,都会不断发生变化。变化是一个事实,所以我们需要让软件去适应变化。我们应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现 “拥抱变化”。

开闭原则告诉我们,应尽量通过扩展软件实体的行为来应对变化,满足新的需求,而不是通过修改现有代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。

为什么会有这样的架构设计原则?它背后体现的架构哲学是什么?

本质上,开闭原则的背后,是推崇模块业务的确定性。我们可以修改模块代码的缺陷(Bug),但不要去随意调整模块的业务范畴,增加功能或减少功能都并不鼓励。这意味着,它认为模块的业务变更是需要极其谨慎的,需要经得起推敲的。

我个人非常推崇 “开闭原则”。它背后隐含的架构哲学,和我说的 “架构的本质是业务的正交分解” 一脉相承。

与其修改模块的业务,不如实现一个新业务。只要业务的分解一直被正确执行的话,实现一个新的业务模块来完成新的业务范畴,是一件极其轻松的事情。从这个角度来说,开闭原则鼓励写 “只读” 的业务模块,一经设计就不可修改,如果要修改业务就直接废弃它,转而实现新的业务模块。

这种 “只读” 思想,大家可能很熟悉。比如基于 Git 的源代码版本管理、基于容器的服务治理都是通过 “只读” 设计来改善系统的治理难度。

对于架构设计来说同样如此。“只读” 的架构分解让我们逐步沉淀下越来越多可复用的业务模块。如此,我们不断坚持下去,随着时间沉淀,我们的组织就会变得很强大,组装复杂业务系统也将变得越来越简单。

所以开闭原则,是架构治理的根本哲学。

CPU 背后的架构思维

一种广泛的误解认为,开闭原则是一种 “面向对象编程(OOP)” 领域提出来的编程思想。但这种理解显然太过狭隘。虽然开闭原则的正式提出可能较晚,但是在信息科技的发展历程中,开闭原则思想的应用就太多了,它是信息技术架构的基本原则。注意我这里没有用 “软件架构” 而是用 “信息技术架构”,因为它并不只适用于软件设计的范畴。

我们在 “02 | 大厦基石:无生有,有生万物” 一讲介绍冯·诺依曼体系的规格时就讲过:

从需求分析角度来说,关键要抓住需求的稳定点和变化点。需求的稳定点,往往是系统的核心价值点;而需求的变化点,则往往需要相应去做开放性设计。

冯·诺依曼体系的中央处理器(CPU)的设计完美体现了 “开闭原则” 的架构思想。它表现在:

  • 指令是稳定的,但指令序列是变化的,只有这样计算机才能够实现 “解决一切可以用 ‘计算’ 来解决的问题” 这个目标。
  • 计算是稳定的,但数据交换是多变的,只有这样才能够让计算机不必修改基础架构却可以适应不断发展变化的交互技术革命。

体会一下:我们怎么做到支持多变的指令序列的?我们由此发明了软件。我们怎么做到支持多变的输入输出设备的?我们定义了输入输出规范。

我们不必去修改 CPU,但是我们却支持了如此多姿多彩的信息世界。

多么优雅的设计。它与面向对象无关,完全是开闭原则带来的威力。

CPU 的优雅设计远不止于此。在 “07 | 软件运行机制及内存管理” 这一讲中,我们介绍了 CPU 对虚拟内存的支持。通过引入缺页中断,CPU 将自身与多变的外置存储设备,以及多变的文件系统格式进行了解耦。

中断机制,我们可以简单把它理解为 CPU 引入的回调函数。通过中断,CPU 把对计算机外设的演进能力交给了操作系统。这是开闭原则的鲜活案例。

插件机制

一些人对开闭原则的错误解读,认为开闭原则不鼓励修改软件的源代码来响应新需求。

这个说法当然有点极端化。开闭原则关注的焦点是模块,并不是最终形成的软件。模块应该坚持自己的业务不变,这是开闭原则所鼓励的。

当然软件也是一个业务系统,但对软件系统这个大模块来说,如果我们坚持它的业务范畴不变,就意味着我们放弃进步。

让软件的代码不变,但业务范畴却能够适应需求变化,有没有可能?

有这个可能性,这就是插件机制。

常规我们理解的插件,通常以动态库(dll/so)形式存在,这种插件机制是操作系统引入的,可以做到跨语言。当然部分语言,比如 Java,它有自己的插件机制,以 jar 包的形式存在。

在上一讲 “61 | 全局性功能的架构设计” 中我们提到,微软的大部分软件,以 Office 和 Visual Studio 为代表,都提供了二次开发能力。

这些二次开发接口构成了软件的插件机制,并最终让它成为一个生态型软件。

一般来说,提供插件机制的二次开发接口需要包含以下三个部分。

其一,软件自身能力的暴露,也就是我们经常说的 DOM API。插件以此来调用软件已经实现的功能,这是最基础的部分,我们这里不进一步展开。

其二,插件加载机制。通常,这基于文件系统,比如我们规定把所有插件放到某个目录下。在 Windows 平台下会多一个选择,把插件信息写到注册表。

其三,事件监听。这是关键,也是难点所在。没有事件,插件没有机会介入到业务中去。但是应该提供什么样的事件,提供多少个事件,这非常依赖架构能力。

原则来说,在提供的能力相同的情况下,事件当然越少越好。但是怎么做到少而精,这非常有讲究。一般来说,事件分以下三类:

其一,界面操作类。最原始的是鼠标和键盘操作,但它们太过于底层,提供出去会是双刃剑,一般对二次开发接口来说会选择不提供。更多的时候会选择暴露更高级的界面事件,比如菜单项或按钮的点击。

其二,数据变更类。在数据发生变化的时候,允许捕获它并做点什么。最为典型的是 onSelectionChanged 这个事件,基本上所有的软件二次开发接口都会提供。当然它属于界面数据变更,只能说是数据变更的特例。如果我们回忆一下 MVC 框架(参见 “22 | 桌面程序的架构建议”),就能够记得 Model 层会发出数据变更通知,也就是 onDataChanged 类的事件出来给 View 或 Controller。

其三,业务流程类。它通常发生在某个业务流的中间某个环节,或者业务流完成之后。比如对 Office 软件来说,打开文件之初或之后,都可能发出相应的事件,以便插件做些什么。

通过以上分析可以看出,完整的插件机制还是比较庞大的。但实际应用中插件机制未必要做得如此之重。

比如,Go语言中的 image 包,它提供的 Decode 和 DecodeConfig 等功能都支持插件,我们可以增加一种格式支持,而无需修改 image 包。

这里面最大的简化,是放弃了插件加载机制。我们自己手工来加载插件,比如:

import "image"
import _ "image/jpeg"
import _ "image/png"

这段代码为 image 包加载了两个插件,一个支持 jpeg,一个支持 png 格式。

如果大家仔细研究过我们实战案例 “画图程序” 的代码(参见 “加餐 | 实战:画图程序的整体架构”)就会发现,类似的插件机制的运用有很多。我们说的架构分解,把复杂系统分解为一个最小化的核心系统,加上多个相互正交的周边系统,它背后的机制往往就是我们这里提的插件机制。

插件机制的确让核心系统与周边系统耦合度大大降低。但插件机制并非没有成本。插件机制本身也是核心系统的一个功能,它本身也需要考虑与核心系统其他功能的耦合度。

如果某插件机制没有多少客户,也就是说,没有几个功能基于它开发,而它本身代码又散落在核心系统的各个角落,那么投入产出就显然不成比例。

所以维持足够的通用性,是提供插件机制的重大前提。

单一职责原则

到此为止,相信大家已经对开闭原则(OCP)非常了解了。总结来说就两点:

第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是架构治理的基础哲学。

第二,模块的业务变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统+多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

平常,我们大家也经常会听到 “单一职责原则(Single Responsibility Principle,SRP)”,它强调的是每个模块只负责一个业务,而不是同时干多个业务。而开闭原则强调的是把模块业务的变化点抽离出来,包给其他的模块。它们谈的本质上是同一个问题的两个面。

结语

从来没有人这样去谈架构的本质,也没有人这样解读开闭原则(OCP),对吧?

其实对于这部 “架构课” 的革命性,我自己从没怀疑过。它的内容是精心设计的,为此我准备了十几年。我们用了四章内容来谈信息科技的需求与架构的演进,然后才进入正题。

用写文章的角度来说,这个伏笔的确够深的。

当然这不完全是伏笔。如果我们把整个信息科技看作最大的一个业务系统,我们有无数人在为之努力奋进,迭代它的架构。大家在竟合中形成自然的分工。学习信息科技的演进史,是学习架构的必要组成部分。我们一方面从中学习怎么做需求分析,另一方面也从中体悟做架构的思维哲学。

当然,还有最重要的一点是,我们要知道演进的结果,也就是信息科技最终形成的基础架构。

作为架构师,我们除了做业务架构,还有一个同等难度的大事,就是选择合适的基础架构。基础架构+业务架构,才是你设计的软件的全部。作为架构师,千万不要一叶障目,不见泰山,忘记基础架构选择的重要性。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “接口设计的准则”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 接口设计的准则

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲 “62 | 重新认识开闭原则 (OCP)” 我们介绍了开闭原则。这一讲的内容非常非常重要,可以说是整个架构课的灵魂。总结来说,开闭原则包含以下两层含义:

第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是架构治理的基础哲学。我平常和小伙伴们探讨模块边界的时候,经常会说这样一句话:

每一个模块都应该是可完成的。

这实际上是开闭原则的业务范畴 “只读” 的架构治理思想的另一种表述方式。

第二,模块业务的变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统+多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

今天,我们想聊聊怎么做接口设计。

不过在探讨这个问题前,我想和大家探讨的第一个问题是:什么是接口?

你可能会觉得这个问题挺愚蠢的。毕竟这几乎是我们嘴巴里天天会提及的术语,会不知道?但让我们用科学家的严谨作风来看待这个问题。接口在不同的语义环境下,主要有两个不同含义。

一种是模块的使用界面,也就是规格,比如公开的类或函数的原型。我们前面在这个架构课中一直强调,模块的接口应该自然体现业务需求。这里的接口,指的就是模块的使用界面。

另一种是模块对依赖环境的抽象。这种情况下,接口是模块与模块之间的契约。在架构设计中我们经常也会听到 “契约式设计(Design by Contract)” 这样的说法,它鼓励模块与模块的交互基于接口作为契约,而不是依赖于具体实现。

对于这两类的接口语义,我们分别进行讨论。

模块的使用界面

对于模块的使用界面,最重要的是 KISS 原则,让人一眼就明白这个模块在做什么样的业务。

KISS 的全称是 Keep it Simple, Stupid,直译是简单化与傻瓜化。用土话来说,就是要 “让傻子也能够看得懂”,追求简单自然,符合惯例。

这样说比较抽象,我们拿七牛开源的 mockhttp 项目作为例子进行说明。

这个项目早期的项目地址为:

最新的项目地址变更为:

mockhttp 是做什么的呢?它用于启动 HTTP 服务作为测试用途。

当然 Go 的标准库 net/http/httptest 已经有自己的 HTTP 服务启动方法,如下:

package httptest

type Server struct {
  URL string
  ...
}

func NewServer(service http.Handler) (ts *Server)
func (ts *Server) Close()

httptest.NewServer 分配一个空闲可用的 TCP 端口,并将它与传入的 HTTP 服务器关联起来。最后我们得到的 ts.URL 就是服务器的访问地址。使用样例如下:

import "net/http"
import "net/http/httptest"

func TestXXX(t *testing.T) {
  service := ... // HTTP 业务服务器
  ts := httphtest.NewServer(service)
  defer ts.Close()

  resp, err := http.Get(ts.URL + "/foo/bar")
  ...
}

mockhttp 有所不同,它并不真的启动 HTTP 服务,没有端口占用。这里我们不谈具体的原理,我们看接口。mockhttp.v1 版本的使用界面如下:

package mockhttp

var Client rpc.Client

func Bind(host string, service interface{})

这里比较古怪的是 service,它并不是 http.Handler 类型。它背后做了一件事情,就是帮 service 这个 HTTP 服务器自动实现请求的路由分派能力。这有一定的好处,使用上比较便捷:

import "github.com/qiniu/mockhttp.v1"

func TestXXX(t *testing.T) {
  service := ... // HTTP 业务服务器
  mockhttp.Bind("example.com", service)
  resp, err := mockhttp.Client.Get("http://example.com/foo/bar")
  ...
}

但是它有两个问题。

一个问题是关于模块边界上的。严谨来说 mockhttp.v1 并不符合 “单一职责原则(SRP)”。它干了两个业务:

  • 启动 HTTP 测试服务;
  • 实现 HTTP 服务器请求的路由分派。

另一个是关于接口的 KISS 原则。mockhttp.Bind 虽然听起来不错,也很简单,但实际上并不符合 Go 语言的惯例语义。另外就是 mockhttp.Client 变量。按 Go 语义的惯例它可能叫 DefaultClient 会更好一些,另外它的类型是 rpc.Client,而不是 http.Client,这样方便是方便了,但却产生了多余的依赖。

mockhttp.v1 这种业务边界和接口的随意性,一定程度上是因为它是测试用途,所以有点怎么简单怎么来的意思。但是后来的发展表明,所有的偷懒总会还回来的。于是就有了 mockhttp.v2 版本。这个版本在我们做小型的 package 合并时,把它放到了https://github.com/qiniu/x 这个package 中。接口如下:

package mockhttp

var DefaultTransport *Transport
var DefaultClient *http.Client

func ListenAndServe(host string, service http.Handler)

这里暴露的方法和变量,一方面 Go 程序员一看即明其义,另一方面语义上和 Go 标准库既有的HTTP package 可自然融合。它的使用方式如下:

import "github.com/qiniu/x/mockhttp"

func TestXXX(t *testing.T) {
  service := ... // HTTP 业务服务器
  mockhttp.ListenAndServe("example.com", service)
  resp, err := mockhttp.DefaultClient.Get("http://example.com/foo/bar")
  ...
}

从上面的例子可以看出,我们说接口要 KISS,要简单自然,这里很重要的一点是符合语言和社区的惯例。如果某类业务在语言中已经有约定俗成的接口,我们尽可能沿用相同的接口语义。

模块的环境依赖

接口的另一种含义是模块对依赖环境的抽象,也就是模块与模块之间的契约。我们大部分情况下提到的接口,指的是这一点。

模块的环境依赖,也分两种,一种是使用界面依赖,一种是实现依赖。所谓使用界面依赖是指用户在使用该模块的使用界面时自然涉及的。所谓实现依赖则是指模块当前实现方案中涉及到的组件,它带来的依赖条件。如果我换一种实现方案,这类依赖可能就不再存在,或者变成另外的依赖。

在环境依赖上,我们遵循的是 “最小依赖原则”,或者叫 “最少知识原则(Least Knowledge Principle,LKP)”,去尽可能发现模块中多余的依赖。

具体到细节,使用界面依赖与实现依赖到处置方式往往还是有所不同。

从使用界面依赖来说,我们接口定义更多考虑的往往是对参数的泛化与抽象,以便让我们可以适应更广泛的场景。

比如,我们前面谈到 IO 系统的时候,把存盘与读盘的接口从 *.os.File 换成 io.Reader、io.Writer,以获得更强的通用性,比如对剪贴板的支持。

类似的情况还有很多,一个接口的参数类型稍加变化,就会获得更大的通用性。再比如,对于上面 mockhttp.v1 中 rpc.Client 这个接口就存在多余的依赖,改为 http.Client 会更好一些。

不过有的时候,我们看起来从接口定义似乎更加泛化,但是实际上却是场景的收紧,这需要特别注意避免的。比如上面 mockhttp.v1 的接口:

func Bind(host string, service interface{})

与 mockhttp.v2 的接口:

func ListenAndServe(host string, service http.Handler)

看似 v1 版本类型用的是 interface{},形式上更加泛化,但实际上 v1 版本有更强的假设,它内部通过反射机制实现了 HTTP 服务器请求的路由分派。而 v2 版本对 service 则用的是 HTTP 服务器的通用接口,是更加恰如其分的描述方式。

当然,在接口参数的抽象上,也不适合过度。如果某种泛化它不会发生,那就是过度设计。不要一开始就把系统设计得非常复杂,而陷入“过度设计”的深渊。应该让系统足够的简单,而却又不失扩展性,这其中的平衡完全依赖你对业务的理解,它是一个难点。

聊完使用界面依赖,我们接着聊实现依赖。

从模块实现的角度,我们环境依赖有两个选择:一个是直接依赖所基于的组件,一个是将所依赖的组件所有被引用的方法抽象成一个接口,让模块依赖接口而不是具体的组件。

那么,这两种方式应该怎么选择?

我的建议是,大部分情况下应该选择直接依赖组件,而不必去抽象它。

如无必要,勿增实体。

如果我们大量抽象所依赖的基础组件,意味着我们系统的可配置性(Configurable)更好,但学习成本也更高。

什么时候该当考虑把依赖抽象化?

其一,在需要提供多种选择的时候。比较典型的是日志的 Logger 组件。对于绝大部分的业务模块,都并不希望绑定 Logger 的选择,把决策权交给使用方。

但是有的时候,在这一点上过度设计也会比较常见。比如,不少业务模块会选择抽象对数据库的依赖,以便于在 MySQL 和 MongoDB 之间自由切换。但这种灵活性绝大部分情况下是一种过度设计。选择数据库应该是非常谨慎严谨的行为。

其二,在需要解除一个庞大的外部系统的依赖时。有时候我们并不是需要多个选择,而是某个外部依赖过重,我们测试或其他场景可能会选择 mock 一个外部依赖,以便降低测试系统的依赖。

其三,在依赖的外部系统为可选组件时。这个时候模块会实现一个 mock 的组件,并在初始化时将接口设置为 mock 组件。这样的好处是,除非用户关心,否则客户可以当模块不存在这个可选的配置项,这降低了学习门槛。

整体来说,对模块的实现依赖进行接口抽象,本质是对模块进行配置化,增加很多配置选项,这样的配置化需要谨慎,适可而止。

结语

接口设计是一个老生常谈的话题。接口有分模块的使用界面和模块的环境依赖这两种理解。

对于模块的使用界面,我们推崇 KISS 原则,简单自然,符合业务表达的惯例。

对于模块的环境依赖,我们遵循的是 “最小依赖原则”,或者叫 “最少知识原则(Least Knowledge Principle,LKP)”,尽可能发现模块中多余的依赖。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “不断完善的架构范式”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 不断完善的架构范式

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

我们在 “62 | 重新认识开闭原则 (OCP)” 这一讲中介绍了开闭原则。这篇内容非常非常重要,可以说是整个架构课的灵魂。

总结来说,开闭原则包含以下两层含义:

第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是架构治理的基础哲学。我平常和小伙伴们探讨模块边界的时候,经常会说这样一句话:

每一个模块都应该是可完成的。

这实际上是开闭原则的业务范畴 “只读” 的架构治理思想的另一种表述方式。

第二,模块业务的变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统+多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

上一讲我们介绍了接口设计。到此为止,我们的架构思维篇也已经基本接近尾声。可能有人会越来越奇怪,为什么我还是没有去聊那些大家耳熟能详的架构设计原则?

实际上,并不是这些架构设计原则不好,它们之中不乏精彩绝伦、振聋发聩的总结。比如:

  • 接口隔离原则(Interface Segregation Principle,ISP):一个模块与另一个模块之间的依赖性,应该依赖于尽可能小的接口。
  • 依赖倒置原则(Dependence Inversion Principle,DIP):高层模块不应该依赖于低层模块,它们应该依赖于抽象接口。
  • 无环依赖原则(Acyclic Dependencies Principle,ADP):不要让两个模块之间出现循环依赖。怎么解除循环依赖?见上一条。
  • 组合/聚合复用原则(Composition/Aggregation Reuse Principle,CARP):当要扩展功能时,优先考虑使用组合,而不是继承。
  • 高内聚与低耦合(High Cohesion and Low Coupling,HCLC):模块内部需要做到内聚度高,模块之间需要做到耦合度低。这是判断一个模块是在做一个业务还是多个业务的依据。如果是在做同一个业务,那么它所有的代码都是内聚的,相互有较强的依赖。
  • 惯例优于配置(Convention over Configuration,COC):灵活性会增加复杂性,所以除非这个灵活性是必须的,否则应尽量让惯例来减少配置,这样才能提高开发效率。如有可能,尽量做到 “零配置”。
  • 命令查询分离(Command Query Separation,CQS):读写操作要分离。在定义接口方法时,要区分哪些是命令(写操作),哪些是查询(读操作),要将它们分离,而不要揉到一起。
  • 关注点分离(Separation of Concerns,SOC):将一个复杂的问题分离为多个简单的问题,然后逐个解决这些简单的问题,那么这个复杂的问题就解决了。当然这条说了等于没说,难在如何进行分离,最终还是归结到对业务的理解上。

这些都是很好很好的。但是,我们需要意识到的一点是,熟读架构思维并不足以让我们成为优秀的架构师。

要始终记住的一点是,我们做的是软件工程。软件工程的复杂性它自然存在,不会因为好的架构思维而消除。

所以虽然理解架构思维是非常重要的,但是架构师真正的武器库并不是它们。

那么架构师的武器库是什么?

这就要从 “架构治理” 开始谈起。

前面我们说过,“开闭原则” 推崇模块业务 “只读” 的思想,是很好的架构治理哲学。它告诉我们,软件是可以以 “搭积木” 的方式搭出来的。

核心的一点是,我们如何形成更多的 “积木”,即一个个业务只读、接口稳定、易于组合的模块。

所以,真正提高我们工程效率的,是我们的业务分解能力和历史积累的成果。

前面我们说过,架构分解中有两大难题:其一,需求的交织。不同需求混杂在一起,也就是存在所谓的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。

在 “61 | 全局性功能的架构设计” 这一讲我们重点聊的是第一点。对于全局性功能怎么去拆解,把它从我们的业务中剥离出来,并无统一的解决思路。

但好的一点是,绝大部分全局性功能都会有很多人去拆解,并最终会被基础设施化。所以具体业务中我们会碰到的全局性功能并不会非常多。

比如,怎么做用户的鉴权?怎么保障软件 24 小时持续服务?怎么保障快速定位用户反馈的问题?这些需求和所有业务需求是交织在一起的,也足够普适,所以就会有很多人去思考对应的解决方案。

作为架构师,心性非常重要。

架构师需要有自己的信仰。我们需要坚持对业务进行正交分解的信念,要坚持不断地探索各类需求的架构分解方法。这样的思考多了,我们就逐步形成了各种各样的架构范式。

这些架构范式,并不仅仅是一些架构思维,而是 “一个个业务只读、接口稳定、易于组合的模块 + 组合的方法论”,它们才是架构师真正的武器库。

这个武器库包含哪些内容?

首先,它应该包括信息科技形成的基础架构。努力把前辈们的心血,变成我们自己真正的积累。光会用还不够,以深刻理解它们背后的架构逻辑,确保自己与基础架构最大程度上的 “同频共振”。

只有让基础架构完全融入自己的思维体系,同频共振,我们才有可能在架构设计需要的时候 “想到它们”。这一点很有趣。有些人看起来博学多才,头头是道,但是真做架构时完全想不到他的 “博学”。

从体系结构来说,这个基础架构包含哪些内容?

其一,基础平台。包括:冯·诺依曼体系、编程语言、操作系统。

其二,桌面开发平台。包括:窗口系统、GDI 系统、浏览器与小程序。当然我们也要理解桌面开发背后的架构逻辑,MVC 架构。

其三,服务端开发平台。包括:负载均衡、各类存储中间件。服务端业务开发的业务逻辑比桌面要简单得多。服务端难在如何形成有效的基础架构,其中大部分是存储中间件。

其四,服务治理平台。主要是以容器技术为核心的 DCOS(数据中心操作系统),以及围绕它形成的整个服务治理生态。这一块还在高速发展过程中,最终它将让服务端开发变得极其简单。

理解了这些基础架构,再加上你自己所处行业的领域知识,基本上设计出一个优秀业务系统,让它健康运行,持续不间断地向用户提供服务就不是问题。

读到这里,你可能终于明白,为什么这个架构课的内容结构是目前这个样子组织的。因为消化基础架构成为架构师自身的本领,远比消化架构设计原则,架构思维逻辑要难得多。

消化基础架构的过程,同时也是消化架构思维的过程。

把虚的事情往实里做,才有可能真正做好。

理解了基础架构,剩下的就是如何沉淀业务架构所需的武器库。这一般来说没有太统一的体系可以参考,如果有,大部分都会被基础设施化了。

所以,业务只能靠你自己的架构设计能力去构建它。而这,其实也是架构师的乐趣所在。

还没有被基础设施化但比较通用的,有一个大门类是数据相关的体系。数据是软件的灵魂。它可能包括以下这些内容:

  • 存盘与读盘(IO);
  • 文本处理;
  • 存储与数据结构;
  • Undo/Redo;
  • ……

我们在下一讲,会专门聊聊其中的 “文本处理” 这个子课题。

从完整性讲,我们的架构课并没有包括所有的基础架构。我们把话题收敛到了 “如何把软件跑起来,并保证它持续健康运行” 这件事情上。

但从企业的业务运营角度来说,这还远不是全部。“54 | 业务的可支持性与持续运营” 我们稍稍展开了一下这个话题。但要谈透这个话题,它会是另一本书,内容主题将会是 “数据治理与业务运营体系构建”。

我希望有一天能够完成它,但这可能要很久之后的事情了。它是我除架构课外的另一个心愿。

结语

我们在 “56 | 服务治理篇:回顾与总结” 这一讲,也就是第四章结束的时候,谈到我们下一章的内容时提到:

我个人不太喜欢常规意义上的 “设计模式”。或者说,我们对设计模式常规的打开方式是有问题的。理解每一个设计模式,都应该放到它想要解决的问题域来看。所以,我个人更喜欢的架构范式更多的是 “设计场景” 的总结。“设计场景” 和设计模式的区别在于它有自己清晰的问题域定义,是一个实实在在的通用子系统。

是的,这些 “通用的设计场景”,才是架构师真正的武器库。如果我们架构师总能把自己所要解决的业务场景分解为多个 “通用的设计场景” 的组合,这就代表架构师有了极强的架构范式的抽象能力。而这一点,正是架构师成熟度的核心标志。

结合今天这一讲我们聊的内容,相信你对这段话会有新的理解。

“开闭原则” 推崇模块业务 “只读” 的思想,是很好的架构治理哲学。它告诉我们,软件是可以以 “搭积木” 的方式搭出来的。核心的一点是,我们如何形成更多的 “积木”,即一个个业务只读、接口稳定、易于组合的模块。

结合今天这一讲的内容,相信你终于完全能理解我们这个架构课的内容组织为什么是现在你看到的样子了。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “架构范式:文本处理”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 架构范式:文本处理

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲 “64 | 不断完善的架构范式” 我们提到架构师的武器库是不断完善的架构范式。今天我们围绕一个具体的问题域,看看我们日常能够积累什么样的经验和成果,来完善作为一个架构师的知识体系。

我们选择的问题是 “文本处理”。

计算机之所以叫计算机,是因为计算机的能力基本上就是“计算+I/O”两部分。I/O 只是为了让计算机与物理世界打交道,它也是为计算服务的。所以数据是软件的灵魂,数据处理是软件的能力。

今天我们聊的文本处理,不是通用的数据处理能力,而是收敛在数据的 I/O 上。这里说的文本,是指写入到磁盘的非结构化数据。它可能真的是文本内容,比如 HTML 文档、CSS 文档;也可能是二进制内容,比如 Word 文档、Excel 文档。文本处理则是指对这类非结构化数据的处理过程,常见文本处理的需求场景有:

  • 数据验证(Data Validation)。比如判断用户输入的文本是否合法,值的范围是否符合期望。
  • 数据抽取(Data Extraction)。比如从某 HTML 页面中抽取出结构化的机票信息(什么时间,从哪里出发,到哪里去,价格几何等等)。
  • 编译器(Compiler)。特殊地,在文本格式是某种语言的代码时,我们可以将文本编译成可执行的机器码,或虚拟机解释执行的字节码。当然我们也可以边解释文本的语义边执行。
  • ……

从用户需求的角度来说,文本处理的需求场景是不可穷尽的。网络爬虫与搜索引擎需要文本处理,Office 软件需要文本处理,编程语言的编译器需要文本处理,网络协议解析需要文本处理,等等。

那么,怎么才能从这些多变的需求场景中,抽出正交分解后可复用的架构范式?

我们今天聊聊文本处理的通用思路。

我的文本处理技术栈演进

文本处理,很多人都会遇到,只不过大家各自遇到的场景不同。我这里先回顾下我个人遇到的文本处理场景。我总结了一个图,如下:

在 2000 年初,我作为实习生拿到的第一个任务,是金山电子表格自身的文件格式设计和 Excel 文件的读写。此后,我参与了多个版本的 Word 文件读写工作。为了便于分析 Excel 和 Word 文件的格式,我实现了 ExcelViewer 和 DocViewer 这两个文件格式查看器。

实际上这两个 Viewer 非常重要,因为它第一次让文件格式的理解过程用程序固化了下来。这非常利于知识传承。大家可以设想一下,假如没有 Viewer,那么后面接手的人基本上只能靠阅读 ExcelReader 和 DocReader 模块的代码来理解文件格式。

但是这有两个问题。其一,Reader 模块有大量的业务逻辑,对我们理解 Excel 和 Word 文件格式本身会造成干扰。其二,Reader 模块增加功能会比较慢,对于那些我们本身不支持的功能,或者我们还暂时来不及兼容的功能,是没有对应的解析代码的。

但是 Viewer 就不一样。我们会尽可能地把我们对 Excel 和 Word 的理解记录下来,成为稳定可传承的知识,而无需关心是否已经支持该功能。另外,从时间的维度来说,应该先有 Viewer,在理解了文件格式之后,再设计出 Reader 才比较合理。

这个时期的 ExcelViewer 和 DocViewer,它主要抽象出来的是界面呈现部分。具体 ExcelViewer 和 DocViewer 的代码不需要有一行涉及到界面。这有诸多好处。实际上可视化界面只是 ExcelViewer 和 DocViewer 的一种输出终端,它们同时也生成了一个纯文本结果到磁盘文件中。这有助于我们用常规的 diff 工具来对比两个文件的差异,从而加速我们对未知数据格式的了解。

但,此时的 ExcelViewer 和 DocViewer 并没有将文件格式的处理过程抽象出通用的模块。也可以说,还没有抽象出文本处理范式。

这个时期同期还有一个探索性的 WPS for Linux 项目。为了支持跨平台编译,我实现了一个简单的 mk 程序。这个程序区别于 Linux 标准化的 make 程序,没有那么复杂的逻辑需要理解。它的输入是一个类 Windows 平台的 ini 文件,里面只需要指定选择的编译器、相关的编译选项、源代码文件列表等,就可以进行编译。甚至源代码列表可以直接指定为从 Visual C++ 的项目配置 dsp 文件中抽取,极易使用。

这个 mk 程序除了要解析一个类 ini 的配置文件外,也会解析 C/C++ 源代码文件形成源代码文件的依赖表,以更好地支持增量编译。不只是源代码文件本身的修改会触发重新编译,任何依赖文件的修改也会触发重新编译。

同样地,这个时期的 mk 程序同样没有引入任何通用的文本处理范式。

此后大约在 2004 年,我开始在金山办公软件内部推 KSDN。KSDN 这个名字承自 MSDN,我们希望打造一个全局的文档系统,它自动从项目的源代码中提取并生成。每天日构建完毕后得到最新版本的 KSDN。

KSDN 处理的输入主要是 C++ 和 Delphi 源代码文件(当时的界面是 Delphi 写的),是纯文本的。这和 ExcelViewer、DocViewer 不同,他们的输入是二进制文件。

KSDN 第一次引入了一个通用的脚本,来表达我们想从源代码中抽取什么内容。整个 KSDN 处理单个源代码文件的工作原理可以描述为:

  • 通过文件后缀选择源代码文件的解析脚本,通过该脚本解析 C++ 或 Dephi 的源代码,并输出 XML 格式的文件;
  • 通过 XSLT 脚本,将 XML 文件渲染为一个或多个 HTML 文件。XSLT 全称是 Extensible Stylesheet Language Transformations(可扩展样式表转换),是 XML 生态中的一项技术。

在 2006 年的时候,我决定实现 KSDN 2.0 版本。这个版本主要想解决第一个版本的脚本语法表达能力比较局限的问题。

于是 C++ 版本的 TPL(Text Processing Language)诞生了。它非常类似于 Boost Spirit,但功能要强大很多。它的项目主页为:

它依赖基础库 stdext,项目主页为:

C++ 版本的 TPL 支持的表达能力,已经完全不弱于 UNIX 经典的 LEX + YACC 组合,使用上却轻量很多。KSDN 2.0 的工作原理变成了:

  • 基于 TPL 将 C++ 或 Delphi 文件转为 json 格式;
  • 与 XSLT 类似地,我们引入了 JSPT,即以 json 为输入,PHP 为 formatter,将内容转为一个或多个 HTML 文件。

这个过程非常通用,可以用于实现任意文件格式之间的变换。包括我们前面的 mk 程序,它本质上也是类 ini 文件格式变换到 Makefile 的过程,我们基于 TPL 很轻松就改造了一个 mk 2.0 版本。

2009 年的时候,我们基于 C++ 实现一个名为 CERL 的网络库,它和 Go 语言的 goroutine 类似,也是基于协程来实现高并发。在这个网络库中,我们定义了一个名为 SDL(Server Description Language)的语言来描述服务器的网络协议。很自然地,我们基于 TPL + JSPT 来实现了 SDL 文件的解析过程。

2011 年,七牛云成立,我们选择了 Go 语言作为技术栈的核心。在转 Go 语言后,除了 TPL,我个人沉淀的大部分 C++ 基础库都不再需要,因为它们往往已经被 Go 语言的标准库解决得很好。

在 2015 年的时候,出于某种原因我实现了一个网络爬虫,这个爬虫会在收到网页内容后,抽取网页中的结构化信息并存储起来。这个抽取信息的过程,最终导致 Go 语言版本 TPL 的诞生。它的项目主页为:

为了验证 Go 语言版本 TPL 的有效性,我在实现了经典的 “计算器(Calculator)” 之余,顺手实现了一门语言,这就是 qlang。它的项目主页为:

由于 Go 语言中实现泛型数据结构的需要,我给 qlang 实现了一个 embedded 版本,简称 eql。它是类似 erubis/erb 的东西。结合 go generate,它可以很方便地让 Go 支持模板(不是 html template,是指泛型)。

在 2017 年,出于 rtmp 网络协议理解的需要,我创建了 BPL(Binary Processing Language),它的项目主页为:

区别于 TPL 的是,BPL 主要用于处理二进制文档。前面我们谈到 ExcelViewer 和 DocViewer 时说过,我们并没有建立任何通用的架构范式。这一直是我引以为憾的事情,所以 2006 年 C++ 版本的 TPL 诞生后就有过 BPL 相关的尝试。这里是尝试残留的痕迹:

但是二进制文档的确很难,它的格式描述中通常有一定的条件判断逻辑,所以 BPL 背后需要依赖一门语言。在 qlang 诞生后,这个条件就得到了满足,这是最终 BPL 得以能够诞生的原因。

BPL 非常强大,它可以处理任意的二进制文件,也可以用于处理任意的 TCP 网络协议数据流。有了 BPL,我们最初的 ExcelViewer 和 DocViewer 可以轻松得以实现。关于 BPL 更详细的介绍,请参阅 https://github.com/qiniu/bpl 中的文档说明。

文本内容的处理范式

介绍了我个人文本处理的技术栈演进过程后,我们把话题重新回到架构范式。

首先,让我们把焦点放在文本内容的处理上。

文本内容的处理,有非常标准的方式。它通常分词法分析(Lex)和语法分析(Parser)两个阶段。UNIX 系的操作系统还提供了 lex 和 yacc 两个经典的程序来协助我们做文本文件的分析处理。

词法分析(Lex)通常由一个 Scanner 来完成,它负责将文本内容从字节流(Byte Stream)转为 Token 流(Token Stream)。我们以解析 Go 源代码的 Scanner 为例(参见 https://godoc.org/go/scanner),其 Scan 函数的原型如下:

type Scanner struct {
  Scan() (pos token.Pos, tok token.Token, lit string)
  ...
}

其使用范式如下:

import (
  "go/scanner"
  "go/token"
)

func doScan(s *scanner.Scanner) {
  for {
    pos, tok, lit := s.Scan()
    if tok == token.EOF {
      break
    }
    ...
    // pos 是这个 token 的位置
    // tok 是这个 token 的类型,见 https://godoc.org/go/token
    // lit 是这个 token 的文本内容
  }
}

Scanner 有时候也叫 Tokenizer。例如 Go 语言中 HTML 的 Tokenizer 类(参阅 https://godoc.org/golang.org/x/net/html)的原型如下:

type Token struct {
  Type     TokenType
  DataAtom atom.Atom
  Data     string
  Attr     []Attribute
}

type Tokenizer struct {
  Next() TokenType
  Err() error
  Token() Token
  ...
}

其使用范式如下:

import (
  "golang.org/x/net/html"
)

func doScan(z *html.Tokenizer) error {
  for {
  	if z.Next() == html.ErrorToken {
  	  // Returning io.EOF indicates success.
  	  return z.Err()
  	}
  	token := z.Token()
    ...
  }
}

词法分析(Lex)过程非常基础,大部分情况下我们不会直接和它打交道。我们打交道的基本都是语法分析器,通常叫 Parser。而从Parser 的使用方式来说,分为 SAX 和 DOM 两种模型。SAX 模型基于事件机制,DOM 模型则基于结构化的数据访问接口。

前面我们已经多次分析过 SAX 与 DOM 的优劣,这里不再展开。通常来说,我们会倾向于采用 DOM 模型。这里我们还是以 Go 文法和 HTML 文法的解析为例。

先看 Go 文法的 Parser(参阅 https://godoc.org/go/parser),它的原型如下:

func ParseExpr(x string) (ast.Expr, error)

func ParseFile(
  fset *token.FileSet,
  filename string, src interface{},
  mode Mode) (f *ast.File, err error)

这里看起来有点复杂的是 ParseFile,它输入的字节流(Byte Stream)可以是:

  • scr != nil,且为 io.Reader 类型;
  • src != nil,且为 string 或 []byte 类型;
  • src == nil,filename 非空,字节流从 filename 对应的文件中读取。

而 Parser 的输出则统一是一个抽象语法树(Abstract Syntax Tree,AST)。显然,它基于的是 DOM 模型。

我们再看 HTML 文法的 Parser(参阅 https://godoc.org/golang.org/x/net/html),它的原型如下:

func Parse(r io.Reader) (*Node, error)

超级简单的基于 DOM 模型的使用接口,任何解释都是多余的。

那么,我前面提的 TPL(Text Processing Language)是做什么的呢?它实现了一套通用的 Scanner + Parser 的机制。首先是词法分析,也就是 Scanner,它负责将文本流转换为 Token 序列。简单来说,就是一个从 text []byte 到 tokens []Token 的过程。

尽管世上语言多样,但是词法非常接近,所以在词法分析这块 ,TPL 抽象了一个 Tokenizer 接口,方便用户自定义。TPL 也内置了一个与 Go 语言词法类似的 Scanner,只是做了非常细微的调整,增加了 ?~@ 等操作符。

TPL 的 Parser 通过类 EBNF 文法表达。比如一个浮点运算的计算器(Calculator),支持加减乘除、函数调用、常量(如 pi 等)的类 EBNF 文法如下:

term = factor *('*' factor/mul | '/' factor/quo | '%' factor/mod)

doc = term *('+' term/add | '-' term/sub)

factor =
  FLOAT/push |
  '-' factor/neg |
  '(' doc ')' |
  (IDENT '(' doc %= ','/ARITY ')')/call |
  IDENT/ident |
  '+' factor

关于这个类 EBNF 文法,有以下补充说明:

  • 我们用 *G 和 +G 来表示重复,而不是用 {G}。要记住这条规则其实比较简单。在编译原理的图书中,我们看到往往是 G* 和 G+。但语言文法中除了 ++ 和 -- 运算符,很少是后缀形式,所以我们选择改为前缀。
  • 我们用 ?G 来表示可选,而不是用 [G]。同上,只要能够回忆起编译原理中我们用 G? 表示可选,我们就很容易理解这里为什么可选是用 ?G 表示。
  • 我们直接用 G1 G2 来表示串接,而不是 G1, G2。
  • 我们用 G1 % G2 和 G1 %= G2 表示 G1 G2 G1 G2 ... G1 这样的列表。其中 G1 % G2 和 G1 %= G2 的区别是前者不能为空列表,后者可以。在上面的例子中,我们用 doc %= , 表示函数的参数列表。
  • 我们用 G/action 表示在 G 匹配成功后执行 action 这个动作。action 最终是调用到 Go 语言中的回调函数。在上面这个计算器中大量使用了 G/action 文法。

与 UNIX 实用程序 yacc 不同的是,TPL 中文法描述的脚本,与执行代码尽可能分离,以加业务语义的可读性。

从模型的归属来说,TPL 属于 SAX 模型。但 G/action 不一定真的是动作。在 extractor 模式下,G/action 被视为 G/marker,TPL 变成 DOM 模型。也就是说,此时 action 只是一个标记,用于形成输出的 DOM 树。

关于 TPL 更详细的介绍需要很长的篇幅,你可以参考 TPL Doc

在文本内容处理的技术栈中,还有一个分支是正则表达式(Regular Expression)。在简单场景下,正则表达式是比较方便的,但是它的缺点也比较明显,可伸缩性和可读性都不强。

二进制内容的处理范式

接下来我们讨论二进制内容的通用处理范式。

二进制内容的处理过程整体来说,似乎比较 “容易”。如果要说出一点问题的话,那就是 “有点繁琐”。

还记得序列化机制吧?它基本上算得上二进制内容的 I/O 框架了。它看起来是这样的:

type Foo struct {
  A uint32
  B string
  C float64
  D Bar
}

func readFoo(foo *Foo, ar *Archive) {
  readUint32(&foo.A, ar)
  readString(&foo.B, ar)
  readFloat64(&foo.C, ar)
  readBar(&foo.D, ar)
}

在 C++ 的操作符重载的支持下,这段代码看起来会更简洁一些:

Archive& operator>>(Archive& ar, Foo& foo) {
  ar >> foo.A >> foo.B >> foo.C >> foo.D;
  return ar;
}

当然,上面只是最基础的情形,所以看起来还比较简洁。但在考虑可选、重复、数组等场景,实际上并不会那么简单。比如对于数组,理想情况下代码是下面这样的:

type Foo struct {
  N uint16
  Bars []Bar // [N]Bar
}

func readFoo(foo *Foo, ar *Archive) {
  readUint16(&foo.N, ar)
  readArray(&foo.Bars, int(foo.N), ar)
}

对于 Go 语言来说,这里我们想要的 readArray 并不存在。而在 C++ 则可以通过泛型来做到,我们示意如下:

template <class T>
void readArray(T[]& v, int n, Archive& ar) {
  v = new T[n];
  for (int i = 0; i < n; i++) {
    ar >> T[i];
  }
}

呼唤一下 Go 语言的泛型吧。不过泛型大概率需要破坏 Go 的一些基础假设,比如不支持重载。所以 Go 的泛型之路不会那么容易。

回到序列化机制。常规意义的序列化,通常还提供了 Object 动态序列化与反序列化的能力。但是实际上这个机制属于过度设计。

为什么这么说?

因为 Object 动态序列化的确带来了一定的便捷性,但是这个便捷性的背后是让使用者放弃了对磁盘文件格式设计的思考。这是非常不正确的指导思想。

数据是软件的灵魂,文件是软件最重要的资产。

文件 I/O 的序列化机制,最重要的是定义严谨的数据格式,而非提供任何出于便捷性考虑的智能。

所以我们只需要保留序列化的形式就好了,任何额外的 “智能” 都是多余的。

基于这样的基本原则,稍作探究你就会发现,在数据结构清晰的情况下,其实整个序列化的代码是非常平庸的。假如我们参考 TPL 的类 EBNF 文法,定义以下这样一条规则:

Foo = {
  N uint16
  Bars [N]Bar
}

这样,我们就可以自动帮这里的 Foo 类型实现它的序列化代码了。

而这正是 BPL 诞生的灵感来源。

BPL 设计的核心思想是,不破坏 TPL 的 EBNF 文法的任何语义,把自己作为 TPL 的扩展。这就好比,如果我们把 TPL 看作 C 的话,BPL 就是 C++。所有 TPL 的功能,BPL 都应该具备而且行为一致。

我们以 MongoDB 的网络协议为例,看看 BPL 文法是什么样的:

document = bson

MsgHeader = {/C
  int32   messageLength; // total message size, including this
  int32   requestID;     // identifier for this message
  int32   responseTo;    // requestID from the original request (used in responses from db)
  int32   opCode;        // request type - see table below
}

OP_UPDATE = {/C
  int32     ZERO;               // 0 - reserved for future use
  cstring   fullCollectionName; // "dbname.collectionname"
  int32     flags;              // bit vector. see below
  document  selector;           // the query to select the document
  document  update;             // specification of the update to perform
}

OP_INSERT = {/C
  int32      flags;              // bit vector - see below
  cstring    fullCollectionName; // "dbname.collectionname"
  document*  documents;          // one or more documents to insert into the collection
}

OP_QUERY = {/C
  int32     flags;                  // bit vector of query options.  See below for details.
  cstring   fullCollectionName;     // "dbname.collectionname"
  int32     numberToSkip;           // number of documents to skip
  int32     numberToReturn;         // number of documents to return
  	                                //  in the first OP_REPLY batch
  document  query;                  // query object.  See below for details.
  document? returnFieldsSelector;   // Optional. Selector indicating the fields
  	                                //  to return.  See below for details.
}

OP_GET_MORE = {/C
  int32     ZERO;               // 0 - reserved for future use
  cstring   fullCollectionName; // "dbname.collectionname"
  int32     numberToReturn;     // number of documents to return
  int64     cursorID;           // cursorID from the OP_REPLY
}

OP_DELETE = {/C
  int32     ZERO;               // 0 - reserved for future use
  cstring   fullCollectionName; // "dbname.collectionname"
  int32     flags;              // bit vector - see below for details.
  document  selector;           // query object.  See below for details.
}

OP_KILL_CURSORS = {/C
  int32     ZERO;              // 0 - reserved for future use
  int32     numberOfCursorIDs; // number of cursorIDs in message
  int64*    cursorIDs;         // sequence of cursorIDs to close
}

OP_MSG = {/C
  cstring   message; // message for the database
}

OP_REPLY = {/C
  int32     responseFlags;  // bit vector - see details below
  int64     cursorID;       // cursor id if client needs to do get more's
  int32     startingFrom;   // where in the cursor this reply is starting
  int32     numberReturned; // number of documents in the reply
  document* documents;      // documents
}

OP_REQ = {/C
  cstring  dbName;
  cstring  cmd;
  document param;
}

OP_RET = {/C
  document ret;
}

Message = {
  header MsgHeader   // standard message header
  let bodyLen = header.messageLength - sizeof(MsgHeader)
  read bodyLen do case header.opCode {
  	1:    OP_REPLY    // Reply to a client request. responseTo is set.
  	1000: OP_MSG      // Generic msg command followed by a string.
  	2001: OP_UPDATE
  	2002: OP_INSERT
  	2004: OP_QUERY
  	2005: OP_GET_MORE // Get more data from a query. See Cursors.
  	2006: OP_DELETE
  	2007: OP_KILL_CURSORS // Notify database that the client has finished with the cursor.
  	2010: OP_REQ
  	2011: OP_RET
  	default: {
      body [bodyLen]byte
    }
  }
}

doc = *Message

我们对比 MongoDB 官方的协议文档(参考 https://docs.mongodb.com/manual/reference/mongodb-wire-protocol/),你会发现很有趣的一点是,我们 BPL 文法几乎和 MongoDB 官方采用的伪代码完全一致,除了一个小细节:在 BPL 中,我们用 {...} 表示采用 Go 语言结构体的文法,而 {/C ... } 表示采用 C 语言结构体的文法。

当前 BPL 还只支持解释执行,但这只是暂时的。就像在 TPL 中我们除了动态解释执行外,也已经提供 tpl generator 来生成 Go 代码以静态编译执行。

要进一步了解 BPL 的功能,请参阅 https://github.com/qiniu/bpl。我们也还提供了不少具体 BPL 的样例,详细可参考:

结语

文本处理是一个非常庞大的课题,本文详细解剖了我个人在这个领域下的经验总结。相信这些经验对你面对相关场景时会有帮助。

但是更重要的一点是,我们平常需要有意识去分析我们工作中遇到的业务场景,从中提炼通用的需求场景形成架构范式的积累。

如此,架构的正交分解思想方能得到贯彻。而我们的业务迭代,也就越来越容易。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “架构老化与重构”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 架构老化与重构

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在 “64 | 不断完善的架构范式” 这一讲中,我们强调了架构师在日常工作过程中不断积累和完善架构范式的重要性。而上一讲 “65 | 架构范式:文本处理” 则以我个人经历为例,介绍了文本处理领域的通用架构范式。

架构的老化

架构的功夫全在平常。

无论是在我们架构范式的不断完善上,还是应对架构老化的经验积累上,都是在日常工作过程中见功夫。我们不能指望有一天架构水平会突飞猛进。架构能力提升全靠平常一点一滴地不断反思与打磨得来。

今天我们要聊的话题是架构老化与重构。

架构老化源于什么?

在我们不断给系统添加各种新功能的时候,往往会遇到功能需求的实现方式不在当初框架设定的范围之内,于是很多功能代码逸出框架的范围之外。

这些散落在各处的代码,把系统绞得支离破碎。久而久之,代码就出现老化,散发出臭味。

代码老化的标志,是添加功能越来越难,迭代效率降低,问题却是持续不断,解决了一个问题却又由此生出好几个新问题。

在理想的情况下,如果我们坚持以 “最小化的核心系统 + 多个相互正交的周边系统” 这个指导思想来构建应用,那么代码就很难出现老化。

当然,这毕竟是理想情况。现实情况下,有很多原因会导致架构老化难以避免,比如:

  • 软件工程师的技术能力不行,以功能完成为先,不考虑项目的长期维护成本;
  • 公司缺乏架构评审环节,系统的代码质量缺乏持续有效的关注;
  • 需求理解不深刻,最初架构设计无法满足迭代发展的需要;
  • 架构迭代不及时,大量因为赶时间而诞生的补丁式代码;
  • ……

那么,怎么应对架构老化?

这个问题可以从两个视角来看:

  • 该怎么重构系统,才能让我们的软件重新恢复活力?
  • 在重构系统之前,我们应该如何进行局部改善,如果增加新功能又应该如何考虑?

我们先聊后者,毕竟重构系统听起来是一件系统性的工程。而添加新功能与局部调整则在日常经常发生。

老系统怎么添加新功能

先说说添加新功能。

正常来说,我们添加功能的时候,尤其是自己加入项目组比较晚,已经有大量的历史代码沉淀在那里的时候,通常我们应该把自己要添加的功能定位为周边功能。对于周边功能,往往考虑最多的点是如何少给核心系统添加麻烦,能够少改就少改。

但是,这其实还不够。实际上当我们视角放在周边系统的时候,其实它本身也应该被看作独立业务系统。这样看的时候,我们自然而然会有新的要求:如何让新功能的代码与既有系统解耦,能够不依赖尽量不依赖。

这个不依赖是有讲究的。

不依赖核心的含义是业务不依赖。新功能的绝大部分代码独立于既有业务系统,只有少量桥接的代码是耦合的。

实际上对于任何被正交分解的周边系统 B 与核心系统 A,理想情况我们最终得到的应该是三个模块:A、B(与 A 无关部分)、A 与 B 桥接代码(与 A 相关的部分)。虽然从归属来说,A 与 B 桥接代码我们通常也会放到 B 模块,但是它应该尽可能小,且尽可能独立于与核心系统无关的代码。

理解这一点至关重要。只有这样我们才能保护自己的投资,今天开发新功能的投入产出可以最大程度得以保留。未来,万一需要做重构,我们的重构成本也能够尽可能最小化。

不依赖的另一个重要话题是要不要依赖公司内部的基础库。这一点需要辩证来看,不能简单回答依赖或不依赖。完全不依赖意味着放弃生产力。

这里基本的判断标准是,成熟度越高的基础库越值得依赖。成熟度的评估依赖于个人经验,首先应该评估的是模块规格的成熟度,因为实现上的问题让时间来解决就行。模块规格是否符合你的预期,以及经过了多少用户使用的打磨,这些是评估成熟度的依据。

还是以我做办公软件时期的经历为例。从重构角度来说它很典型,既有的代码有几百万行。我第一个做读盘与存盘之外的新功能是电子表格的智能填充。这个功能比较常用,用户可以选择一个区域,然后移动鼠标到被选区域右下角,在鼠标变成十字时,按下鼠标左键不放并移动鼠标以进行单元格内容的自动填充。填充方向是上下左右都可以。

我怎么做这个功能?首先是实现一个基本纯算法的模块,输入一个值矩阵(可以是数值、日期,也可以是字符串等),要预测的序列个数,输出对应预测的值矩阵。为什么自动填充的方向在算法这里消失了?因为我们按填充方向构建值矩阵,而不是用户屏幕上直观看到的矩阵。

然后抽象了核心系统的两个接口,一个是取一个区域的单元格数据,包括值和格式,一个是设置一个单元格的值和格式。基于这个抽象接口,我们实现了完整的自动填充逻辑。

最后,是对接这个自动填充模块与既有的业务系统。从 Model 层来说,只需要在既有的业务系统包装对应要求的接口即可。而且取区域单元格、设置一个单元格的值,这些是非常通用的接口,无论既有系统长什么样,我们都可以轻松去实现所需接口。

这就是做新功能的思路,尽可能与既有系统剥离,从独立业务视角去实现业务,抽象对环境的依赖。最后,用最少量的对接代码把整个系统串起来。

架构的局部优化

聊完添加新功能,我们谈谈局部调整。它的目标是优化某个功能与核心系统的耦合关系。

局部调整看似收效甚微,但是它的好处是可以快速推动。而且,日拱一卒,如果我们能够坚持下来,最后的效果远比你想象得好。

它有两种常见做法。

一种是重写,或者叫局部重构。它相当于从系统中彻底移除掉与该功能相关的代码,重新写一份新的。这和开发一个新功能没什么两样,最多看看被移除的代码里面,有哪些函数设计比较合理,可以直接拿过来用,或者稍微重新包装一下能够让规格更合理的。

但是我们不能太热衷于做局部重构。局部重构一定要发生在你对这块代码的业务比较了解的情形,比如你已经维护过它一阵子了。

另外,局部重构一定要把老代码清理干净,不要残留一些不必要的代码在系统里面。剩下来的事情,完全可以参考我上面提的实现新功能的方法论来执行。

另一种是依赖优化。它关注的重心不是某项功能本身的实现,而是它与系统之间的关系。

依赖优化整体上做的是代码的搬运工。怎么搬代码?和删除代码类似,我们要找到和该功能相关的所有代码。但是我们做的不是删除,而是将散落在系统中的代码集中起来。我们把对系统的每处修改变成一个函数,比如叫 doXXX_yyyy。这里 XXX 是功能代号,yyyy 则依据这段搬走的代码语义命个名。

你可能觉得这个名字太丑了。但是某种程度来说这是故意的。它可以作为团队的约定俗成,代表此处待重新考虑边界。

不要理解错了,它不是说我们需要重新思考我们现在正在做代码优化的功能边界。它是说我们要重新考虑核心系统的边界。尤其是如果某个地方有好几个功能都加了 doXXX_yyyy 这样的调用,这就意味着这里需要提供一个事件机制,以便这些功能能够进行监听。而一旦我们做了这件事,你就发现核心系统变得更稳定了,不再需要因为添加功能而修改代码。而这不正是 “开闭原则(OCP)” 所追求的么?

回到我们要进行依赖优化的功能。集中了这个功能所有代码后,这个功能与系统的耦合也就清楚了。有多少个 doXXX_yyyy,就有多少对系统的伤害(参阅 “58 | 如何判断架构设计的优劣?” 中的伤害值计算)。

如果伤害值不大,代表耦合在合理范围,做到这一步暂时不再往下走是可接受的。如果耦合过多,那就意味着我们需要站在这个功能本身的业务视角看依赖的合理性了。如果不合理,可以考虑推动局部重构。

所以,局部重构不应该很盲目,而应依赖于基于 “伤害值” 的客观判断。习惯于在不理解的情况下就重构,这实在不太好。认同他人是很重要的能力修炼。况且作为架构师,事情优先级的排列是第一位的,有太多重要的事情值得去做。

依赖优化的好处比较明显。其一,工作量小,做的是代码搬运,不改变任何业务逻辑。其二,可以不必深入功能的细节,只需要找到该功能的所有相关代码,这是难点,然后把它们集中起来。

尽可能把我们认为非核心系统的功能,都基于依赖优化的方式独立出去。这样核心系统与周边系统的耦合就理清楚了。

依赖优化,可以把周边系统对核心系统的代码注入,整理得清清楚楚。这是事件机制的需求来源。

依赖优化也能够及时发现糟糕的模块,和核心系统藕断丝连,斩不断理还乱,这时我们就需要对这个功能进行局部重构。

核心系统的重构

完成这些,我们下一步,就要进入重构的关键阶段,进行核心系统重构。

对于一个积弊已久的系统,要想成功完成整体的重构是非常艰难的。

如果我们一上来就去重构核心系统,风险太高。一方面,牵一发而动全身,我们无法保证工程的交付周期。另一方面,没有谁对全局有足够的了解,重构会过于盲目,项目的执行风险难以把控。

确定要对核心系统进行重构,那么最高优先级是确定它的边界,也就是使用界面(接口)。

能够在不修改实现的情况下调整核心系统的使用界面到我们期望的样子是最好的。

周边系统对核心系统的依赖无非两类:一是核心系统的功能,表现为它提供的 DOM 接口;二是核心系统提供的事件,让周边系统能够介入它的业务流程。

对所有周边模块进行依赖优化的整理,细加分析后可以初步确定核心系统需要暴露的事件集合。

进一步要做的事情是把核心系统的 DOM 接口也抽象出来。这一步比较复杂。它包含两件事情:

  • 让周边系统对它的依赖,变成依赖接口,而非依赖实现;
  • 审视核心系统功能的 DOM 接口的合理性,明确出我们期望的接口设计。

我们可以分步骤做。可以先做实现依赖到接口依赖的转变。这有点像前面依赖优化的工作。只不过它不是搬代码,而是把周边模块独立出去,将它与核心系统的依赖关系全部调整为接口。这样,不管抽离出来的 DOM 接口是否合理,至少它代表了当前系统的模块边界。

这一步做完,理论上 mock 一个核心系统出来和周边系统对接也是可行的。只不过可能这个 DOM 模型太大,要 mock 不那么容易。

接下来,就是最重要的时刻。

我们需要对核心系统的接口进行重新设计。这一步的难点在于:

第一,我们对业务的理解的确有了长足的进步。我们抽象的业务接口有了更加精炼符合业务本质的表达方式,而不是换汤不换药,否则我们就需要质疑这次重构的必要性。

第二,对周边系统切换到新接口的成本有充足的预计。对周边系统来说,这是从老接口过度到新接口的过程。虽然理论上让核心系统维护两套 DOM 接口同时存在,在技术上是可行的,但是这个过渡期不能太长,否则容易让人困惑,不清楚我们倡导的是什么。

完成了接口改造,剩下来就简单了。核心系统,每一个周边系统,彼此完全独立,可以单独调整和优化。嫌当前的核心系统太糟糕?那就搞搞。为什么可以这么轻松决策?因为就算我们要重新写核心系统,要做的事情也很收敛,不会影响到大局。

这不像那些系统边界分解得不清不楚的业务系统。要改核心系统的代码?

不要命了么?

结语

重构工作是很有技巧性的,很能培养一个人的架构能力。做多了,我们可以建立对代码耦合的条件反射,看一眼就知道架构是否合理。

但重构不是技巧性那么简单。

实际上从难度来说,重构比一个全新业务的架构过程要难得多。重构,不只是一个架构的合理性问题。它包含了架构合理性的考量,因为我们需要知道未来在哪里,我们迭代方向在哪里。

但重构的挑战远不只是这些。这是一个集架构设计(未来架构应该是什么样的)、资源规划与调度(与新功能开发的优先级怎么排)、阶段规划(如何把大任务变小,降低内部的抵触情绪和项目风险)以及持久战的韧性与毅力的庞大工程。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “架构思维篇:回顾与总结”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 架构思维篇:回顾与总结

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到今天为止,我们第五章 “架构思维篇” 就要结束了。今天这篇文章我们对整章的内容做一个回顾与总结。

架构之道

架构思维篇的内容大体如下图所示。

在前面几个章节,我们已经陆续介绍了架构的全过程:

但架构师面临的问题往往是错综复杂的。

给你一个明确的需求说明文档,干干净净地从头开始做 “需求分析”,做 “概要设计”,做模块的 “详细设计”,最后编码实现,这是理想场景。

现实中,大多数情况并不是这样。而是:你拿到了一份长长的源代码,加上少得可怜的几份过时的文档。然后被安排做一个新功能,或者改一个顽固缺陷(Bug)。

我们应该怎么做架构设计?

架构设计架构设计,设计为先,架构为魂。用架构的系统化和全局性思维来做设计。

整体来说,我们这个架构课的知识密度比较高。这在某种程度来说,也是一种必然结果,这是因为架构师需要 “掌控全局” 带来的。

所以这个架构课对大多数人而言,多多少少都会有一些盲点。如果遇到不能理解的地方,从构建完整知识体系的角度,建议通过其他的相关资料补上。当然也欢迎在专栏中提问。

相比一般的架构书籍来说,我们这一章架构思维篇的内容写得并不长。原因是架构思维的本源比架构规则重要。规则可能会因为环境变化而发生变化,会过时。但是架构思维的内核不会过时。

所以我们把关注的焦点放到了不变的思维内核上。

架构之道,是虚实结合之道。

我们要理论与实践相结合。架构设计不可能只需要熟读某些架构思维的理论,否则架构师早就满天飞了。如果两者只能取其一,我选实践。

从实悟虚,从虚就实,运用得当方得升华。这其实是最朴素的虚实结合的道理。对学架构这件事来说尤其如此。架构思维的感悟并不能一步到位,永远有进步的空间,需要我们在不断实践中感悟,升华自己的认知。

这个架构课内容的前四章为 “基础平台”、“桌面开发”、“服务端开发”、“服务治理”。

从内容上来说,由 “基础平台(硬件架构 / 编程语言 / 操作系统)”,到 “业务开发(桌面开发 / 服务端开发)”,再到 “业务治理(服务治理 / 技术支持 / 用户增长)”,基本上覆盖了信息技术主体骨架的各个方面。

有了骨架,就有了全貌,有了全局的视角。

前面四章,我们内容体系的侧重点放在了架构演变的过程。我们研究什么东西在迭代。这样,我们就不是去学习一个 “静态的”、“不变的” 信息技术的骨架,更重要的是我们也在学信息技术的发展历史。

有了基础平台,有了前端与后端,有了过去与未来,我们就有了真真正正的全貌。

我们博览群书,为的就是不拘于一隅,串联我们自身的知识体系,形成我们的认知框架。

信息科技的整体架构,与我们的应用软件架构息息相关。架构分基础架构和应用架构。选择基础架构也是构建业务竞争优势的重要组成部分。

从技能来说,我们可能把架构师能力去归结为:

  • 理需求的能力;
  • 读代码的能力;
  • 抽象系统的能力。

但架构师的成长之旅,首先是心性修炼之旅。这包括:

  • 同理心的修炼,认同他人的能力;
  • 全局观的修炼,保持好奇心和学习的韧性;
  • 迭代能力的修炼,学会反思,学会在自我否定中不断成长。

业务的正交分解

架构就是业务的正交分解。每个模块都有它自己的业务。

这里我们说的模块是一种泛指,它包括:函数、类、接口、包、子系统、网络服务程序、桌面程序等等。

这句话看似很简单,但是它太重要了,它是一切架构动作的基础。

架构行为的三步曲:“需求分析”、“概要设计”、模块的 “详细设计”,背后都直指业务的正交分解,只是逐步递进,一步步从模糊到越来越强的确定性,直至最终形成业务设计的完整的、精确无歧义的解决方案。

对业务进行分解得到的每一个模块来说,最重要的是模块边界,我们通常称之为 “接口”。

接口是业务的抽象,同时也是它与使用方的耦合方式。在业务分解的过程中,我们需要反复地审视模块的接口,发现其中 “过度的(或多余的)” 约束条件,把它提高到足够通用的、普适的场景来看。

在架构分解过程中有两大难题。

其一,需求的交织,不同需求混杂在一起。这是因为存在我们说的全局性功能。其二,需求的易变。不同客户,不同场景下需求看起来很不一样,场景呈发散趋势。

但无论如何,我们需要坚持作为一名架构师的信仰:

任何功能都是可以正交分解的,即使我目前还没有找到方法,那也是因为我还没有透彻理解需求。

怎么做业务分解?

业务分解就是最小化的核心系统,加上多个正交分解的周边系统。核心系统一定要最小化,要稳定。坚持不要往核心系统中增加新功能,这样你的业务架构就不可能有臭味。

所以业务做正交分解的第一件事情,就是要分出哪些是核心系统,哪些是周边子系统。核心系统构成了业务的最小功能集,而后通过不断增加新的周边功能,而演变成功能强大的复杂系统。

这里有一个周边功能对核心系统总伤害的经验公式:

$$ \sum_ {对每一处修改} log_2(修改行数+1)$$

同一个周边功能相邻的代码行算作一处修改。不同周边功能的修改哪怕相邻也算作多处。

这个公式核心想表达的含义是:修改处数越多,伤害越大。对于每一处修改,鼓励尽可能减少到只修改一行,更多代码放到周边模块自己那里去。

在 “62 | 重新认识开闭原则 (OCP)” 这一讲我们介绍了开闭原则。它非常非常重要,可以说是整个架构课的灵魂。总结来说,开闭原则包含以下两层含义:

第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是很好的架构治理的基础哲学。

这告诉我们,软件是可以以 “搭积木” 的方式搭出来的。核心的一点是,我们如何形成更多的 “积木”,即一个个业务只读、接口稳定、易于组合的模块。

我平常和小伙伴们探讨架构时,也经常说这样一句话:

每一个模块都应该是可完成的。

这实际上是开闭原则业务范畴 “只读” 的架构治理思想的另一种表述方式。

要坚持不断地探索各类需求的架构分解方法。这样的思考多了,我们就逐步形成了各种各样的架构范式。这些架构范式,并不仅仅是一些架构思维,而是 “一个个业务只读、接口稳定、易于组合的模块 + 组合的方法论”,它们才是架构师真正的武器库。

第二,模块的业务变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统+多个彼此正交的周边系统”。回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。

领域理解

应对业务需求的变化,最好的结构就是: 最小化的核心系统+多个彼此正交的周边系统。

但是光理解了这一点,并不足以根本性地改变你的架构能力,因为这里面最难的是领域理解。所以需求分析很关键。怎么做需求分析?这一点要讲透真的很难。

我们用的是笨方法。把整个信息科技的演进史讲了一遍。

我们用穷举的方式来讲信息科技的半部演进史。为什么我说是半部?整个信息科技的发展,我们把它分为程序驱动和数据驱动两个阶段。

程序驱动的本质,是自动化的极致。以前,自动化是非常机械的,要完成自动化需要极大的难度。但是,软件的出现让自动化成为一种普惠价值,这是信息科技的上半部演进史带来的核心收益。

但到了数据驱动,事情就变了。我们甚至有了新的专有名词,比如 “智能时代”,或者 “DT 时代”。很多人想到智能,想到的是深度学习,想到的是机器视觉。但其实这非常片面。马云把上半场叫 IT,下半场叫 DT(数据科技),非常形象而且深刻。

我们的架构课,把话题收敛到了 “如何把软件跑起来,并保证它持续健康运行” 这件事情上。

但从企业的业务运营角度来说,这还远不是全部。“54 | 业务的可支持性与持续运营” 我们稍稍展开了一下这个话题。但要谈透这个话题,它会是另一本书,内容主题将会是 “数据治理与业务运营体系构建”。

我希望有一天能够完成它,但这可能要很久之后的事情了。

结语

今天我们对本章内容做了概要的回顾,“架构思维篇” 到此就结束了。理解了本章的内容,对于如何构建一个高度可扩展的软件架构你就有了基本的认知。

但不要让自己仅仅停留在认知上,需要多多实践。

架构的功夫全在平常。

无论是在我们架构范式的不断完善上,还是应对架构老化的经验积累上,都是在日常工作过程中见功夫。我们不能指望有一天架构水平会突飞猛进。架构能力提升全靠平常一点一滴地不断反思与打磨得来。

在应对架构老化这件事情上,不要轻率地选择进行全局性的重构。要把功夫花在平常,让重构在润物细无声中发生。

从难度来说,全局性的重构比一个全新业务的架构过程要难得多。重构,不只是一个架构的合理性问题。它包含了架构合理性的考量,因为我们需要知道未来在哪里,我们迭代方向在哪里。

但重构的挑战远不只是这些。这是一个集架构设计(未来架构应该是什么样的)、资源规划与调度(与新功能开发的优先级怎么排)、阶段规划(如何把大任务变小,降低内部的抵触情绪和项目风险)以及持久战所需的韧性与毅力的庞大工程。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们正式开始进入第六章:软件工程篇。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 软件工程的宏观视角

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

软件工程

今天开始,我们进入第六章,谈谈软件工程。

我理解的架构师的职责其实是从软件工程出发的。也许大家都学过软件工程,但如果我们把软件工程这门课重新看待,这门学科到底谈的是什么?是软件项目管理的方法论?

无论如何,软件工程是一门最年轻的学科,相比其他动辄跨世纪的自然科学而言,软件工程只有 50 年的历史。这门学科的实践太少了,任何一门学科的实践时间短的话,都很难沉淀出真正高效的经验总结,因为这些总结通常都是需要很多代人共同推动来完成的。

为什么说它只有 50 年时间呢?

我们先来看看 C 语言,一般意义上来说,我们可能认为它是现代语言的开始。C 语言诞生于 1970 年,到现在是 49 年。再看 Fortran,它被认定为是第一个高级语言,诞生于 1954 年,那时候主要面向的领域是科学计算。Fortran 的程序代码量普遍都还不大,量不大的时候谈不上工程的概念。

这也是我为什么说软件工程这门学科很年轻,它只有 50 岁。对于这样一个年轻的学科,我们对它的认知肯定还是非常肤浅的。

我在这个架构课的序言 “开篇词 | 怎样成长为优秀的软件架构师?” 一上来就做了软件工程和建筑工程的对比。通过对比我们可以发现,二者有非常大的区别,具体在于两点:

其一,不确定性。为什么软件工程有很大的不确定性?大部分大型的软件系统都有几千甚至几万人的规模,而这几千几万人中,却没有两个人的工作是重复的。

虽然大家都在编程,但是编程的内容是不一样的。每个人昨天和今天的工作也是不一样的,没有人会写一模一样的代码,我们总是不停地写新的东西,做新的工作。这些东西是非常不同的,软件工程从事的是创造性的工作。

大家都知道创造是很难的,创造意味着会有大量的试错,因为我们没有做过。大部分软件的形成都是一项极其复杂的工程,它们远比传统的工程复杂得多,无论是涉及的人力、时间还是业务的变数都要多很多。这些都会导致软件工程有非常大的不确定性。

其二,快速变化。建筑工程在完工以后就结束了,基本上很少会进行变更。但在软件工程里,软件生产出来只是开始。只要软件还在服务客户中,程序员们的创造过程就不会停止,软件系统仍然持续迭代更新,以便形成更好的市场竞争力。

这些都与传统建筑工程的模式大相径庭。一幢建筑自它完成之后,所有的变化便主要集中在一些软装的细节上,很少会再发生剧烈的变动,更不会持续地发生变动。但软件却不是这样,它从诞生之初到其生命周期结束,自始至终都在迭代变化,从未停止。

以上这两点都会导致软件工程区别于传统意义上的所有工程,有非常强的管理难度。过去那么多年,工业界有非常多的工程实践,但是所有的工程实践对软件工程来说都是不适用的,因为二者有很大的不一样。

今天如果我们站在管理的视角再看软件工程的话,我们知道管理学谈的是确定性。管理学本身的目的之一就是要抑制不确定性,产生确定性。

比如,开发工期、时间成本是否能确定。比如,人力成本、研发成本以及后期运维的成本是否能确定。

所以,软件项目的管理又期望达到确定性。但软件工程本身是快速变化的,是不确定的。这就是软件工程本身的矛盾。我们的目标是在大量的不确定性中找到确定性,这其实就是软件工程最核心的点。

架构师的职责

如果用 “瀑布模型” 的方式来表达,现代软件工程的全过程大体如下:

从开始的需求与历史版本缺陷,到新版本的产品设计,到架构设计,到编码与测试,到最终的产品发布,到线上服务的持续维护。

贯穿整个工程始终的,还有不变的团队分工与协同,以及不变的质量管理。

更为重要的是,这个过程并不是只发生一遍,而是终其生命周期过程中,反复迭代演进。

它是一个生命周期往往以数年甚至数十年计的工程。对于传统工程,我们往往也把一个工程称为项目,项目工程。但软件工程不同,虽然我们平常也有项目的概念,但软件工程并不是一个项目,而是无数个项目。每个项目只是软件工程的一个里程碑(Milestone)。

所以,光靠把控软件工程师的水平,依赖他们自觉保障工程质量,是远远不够的。软件工程是一项非常复杂的系统工程,它需要依赖一个能够掌控整个工程全局的团队,来规划和引导整个系统的演变过程。这个团队就是架构师团队。

软件架构师的职责,并不单单是我们通常理解的,对软件系统进行边界划分和模块规格的定义。从根本目标来说,软件架构师要对软件工程的执行结果负责,这包括:按时按质进行软件的迭代和发布、敏捷地响应需求变更、防范软件质量风险(避免发生软件质量事故)、降低迭代维护成本。

因此,虽然架构师的确是一个技术岗,但是架构师干的事情,并不是那么纯技术。

首先是用户需求的解读。怎么提升需求分析能力,尤其是需求演进的预判能力?它无关技术,关键是心态,心里得装着用户。除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。

其次是产品设计。产品边界的确立过程虽然是产品经理主导,但是架构师理应深度参与其中。原因在于,产品功能的开放性设计不是一个纯粹的用户需求问题,它通常涉及技术方案的探讨。因此,产品边界的确立不是一个纯需求,也不是一个纯技术,而是两者合而为一的过程。

以上两点,是架构本身的专业性带来的,在前面五章中已经谈过很多,我们这里不再展开。在本章中,我们更多是从工程本身出发。这些话题是因软件工程的工程性而来,属于工程管理的范畴,但它们却又通常和架构师的工作密不可分。

这里面最为突出但也非常基础的,是贯穿软件工程始终的 “团队分工与协同” 问题、“软件的质量管理” 问题。从 “团队分工与协同” 来说,话题可以是团队的目标共识,也可以是做事方式的默契,各类规范的制定。从 “软件的质量管理” 来说,话题可能涉及软件的版本发布,质量保障的过程体系等等。

从更宏观的视角看,我们还涉及人力资源规划的问题。什么东西应该外包出去,包给谁?软件版本的计划是什么样的,哪些功能先做,哪些功能后做?

看起来,这些似乎和架构师的 “本职工作” 不那么直接相关。但是如果你认同架构师的职责是 “对软件工程的执行结果负责”,那么就能够理解为什么你需要去关注这些内容。

结语

软件工程本身是一个非常新兴、非常复杂的话题。可能需要再花费 50 年这样漫长的时间才能形成更清晰的认知(例如,我们第四章 “服务治理篇” 专门探讨了现代软件工程全过程最后一个环节 “线上服务管理” 这个话题)。

作为架构课的一部分,这一章我们将主要精选部分与架构师的工作关系密切的话题来进行讨论,主要包括:

  • 团队的共识管理;
  • 如何阅读别人的代码;
  • 怎么写设计文档;
  • 发布单元与版本管理;
  • 软件质量管理:单元测试、持续构建与发布;
  • 开源、云服务与外包管理;
  • 软件版本迭代的规划;
  • 软件工程的未来。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “团队的共识管理”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 团队的共识管理

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

软件工程是一项团体活动,大家有分工更有协同。不同的个体因为能力差别,可以形成十倍以上的生产力差距。而不同团体更是如此,他们的差距往往可以用天壤之别来形容。

这差距背后的原因,关乎的是协同的科学。

团队共识

有的团体像一盘散沙,充其量可以叫团伙。有的团体则有极强的凝聚力,整个团队上下同心,拧成一股绳,这种团体才是高效率组织,是真正意义上的团队。

团队靠什么上下同心?靠的是共识。

那么,什么是团队的共识?

团队的共识分很多层次。其一,团队是不是有共同的目标。其二,团队是不是有共同的行事做人的准则。其三,对产品与市场的要与不要,以及为什么要或为什么不要,是否已达成一致。其四,对执行路径有没有共同的认知。其五,有没有团队默契,是否日常沟通交流很多地方不必赘述,沟通上一点即透。

一个团体如果缺乏共同的目标,那么它最多能够算得上是一个团伙,而不能称之为团队。

团队的目标也分很多层次。为什么很多企业都会谈他们的使命和愿景,是因为它是这个企业作为一个团队存在的意义,是企业所有人共同的长远目标。

人是愿景型动物,需要看到未来。越高级的人才越在乎团队存在的意义。所以高科技公司的人才通常只能去影响,而不是像一些人心中理解的那样,认为管理是去控制。

愿景是一种心力。人有很强的主观能动性。一旦人相信企业的使命与愿景,员工就变得有很强烈的使命感,有强烈的原动力。员工的行为方式也就会潜移默化发生变化。

不过,有共同的远景目标的团队仍然有可能走向分裂。

中国有句古话说得好:“道不同,不相为谋”。团队有没有相同的价值观,有没有相同的行事做人的准则,这些更根本性的基础共识,极有可能会成为压垮团队的稻草。

共识大于能力。如果一个人有很强的个人能力,但是却和团队没有共同的愿景,或者没有共同的价值观,那么能力越大产生的破坏性也就越大。

怎么达成共识?

团队有了共同的使命、愿景与价值观,就有了共同努力把一件事情干成的最大基础。然而,这并不代表这个团队就不会遇到共识问题。

团队仅有远期的目标是不够的,还要有中短期的目标。企业的使命和愿景需要由一个个的战略行动来落地。我们的产品定位怎么样,选择哪些细分市场去切入,这些同样需要团队达成共识。

怎么去达成共识?

越 “聪明” 的团队负责人,往往越容易忽视达成共识的难度。他们通常会召开会议,然后把自己的想法说给大家听。半个小时后,兄弟们迷茫地回去了。

在团队还小的时候,这种简单共识的方式很可能是可以奏效的,尤其是当团队负责人还能够一一去检查每个人的工作内容时,所有的理解偏差都能够得到比较及时的纠正。

但是团队规模稍微变大一些,这种简单共识突然就失效了。“我明明已经告诉他们要做什么了。” 负责人有时候困惑于团队成员为什么并没有理解他的话。

这是因为他还并不理解真正的共识意味着什么。也没有对达成共识的难度有足够的认知。

让更多人参与到决策形成的过程现场,是更好的共识达成的方式。通过同步足够充分的信息,通过共创而非传达决策的方式让结论自然产生。

这个共创过程不必团队所有人都参与,但要确保所有影响落地的关键角色都在,并确保参与这个过程的人都能够产生思想的碰撞,而非做个吃西瓜群众。

契约与共识效率

目标与执行路径达成了共识,这还不够。我们还需要把共识表达出来,形成文字。

为什么这很重要?

因为共识之所以为共识,是因为它不是空中楼阁,不是口号,而是指导我们做战略选择的依据,指导我们平常行为的依据。

所以,共识就是团队协作的契约。契约的表达越是精确而无歧义,团队协作中主观能动性就越高,执行的效率也就越高。

对于架构过程同样如此。

架构过程实际上是团队共识形成与确认的过程。架构设计需要回答两个基本的问题:

  • 系统要做成什么样?
  • 怎么做?

架构设计为什么叫架构设计,是因为架构师的工作中除了架构,还有设计。设计其实谈的就是 “系统要做成什么样”。

设计高于架构。

设计强调规格,架构强调实现。规格设计是架构过程的最高共识。所以,规格高于实现。我们用架构的全局性和系统性思维去做设计。

一些架构师乐衷于画架构图,把它当作是架构师最重要的工作内容。但架构图在共识的表达上并不太好。因为共识是需要精确的、无歧义的。而架构图显然并不精确。

对于一个工程团队来说,没有精确的共识很可怕。它可能导致不同模块的工作牛头不对马嘴,完全无法连接起来,但是这个风险没有被暴露,直到最后一刻里程碑时间要到了,要出版本了,大家才匆匆忙忙联调,临时解决因为架构不到位产生的 “锅”。

这时候人们的动作通常会走形。追求的不再是架构设计的好坏,而是打补丁,怎么把里程碑的目标实现了,别影响了团队绩效。

我们作个类比,这种不精确的架构,就好比建筑工程中,设计师画了一个效果图,没有任何尺寸和关键细节的确认,然后大家就分头开工了。最后放在一起拼接(联调),发现彼此完全没法对上,只能临时修修改改,拼接得上就谢天谢地了。是不是能够和当初效果图匹配?让老天爷决定吧。

更精确描述架构的方法是定义每个模块的接口。接口可以用代码表达,这种表达是精确的、无歧义的。架构图则只是辅助模块接口,用于说明模块接口之间的关联。

尊重契约,尊重共识精确的、无歧义的表达,非常非常重要。

绝大部分哪怕是非常优秀的架构师,在系统设计(也叫概要设计)阶段通常也只会形成系统的概貌,把子系统的划分谈清楚,把子系统的接口规格谈清楚。

但实际上概要设计阶段最好的状态并不是只有设计文档。

为了降低风险,系统设计阶段也应该有代码产出。

这样做有两个方面的目的。其一,系统的初始框架代码。也就是说,系统的大体架子已经搭建起来了。其二,原型性的代码来验证。一些核心子系统在这个阶段提供了 mock 的系统。

这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。

代码即文档。代码是理解一致性更强的文档。

结语

这一讲我们谈的是协同的科学。为什么有的团队效率极高,有的团队却进展缓慢,从背后的协同效率来说,共识管理是根因中的根因。

共识有非常多的层次。不同层次的共识处于完全不同的维度。它们都极其重要,且相互不可替代。当某个层次的共识出问题的时候,我们需要在相应的层次去解决它。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们谈谈 “怎么写设计文档”。原计划我们下一讲是 “如何阅读别人的代码”,但是我想先顺着共识这个话题谈问题谈清楚。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 怎么写设计文档?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

在 “68 | 软件工程的宏观视角” 一讲中,我们用最基本的 “瀑布模型” 来描述现代软件工程的全过程,大体如下:

在这个过程中,有两个阶段非常关键:一个是 “产品设计”,一个是 “架构设计”。产品设计由产品经理主导,关注的是 “如何以产品特性来系统化地满足用户需求”。架构设计由架构师主导,关注的是 “业务系统如何系统化地进行分解与交付”。

“设计” 一词非常精妙。无论是 “产品设计”,还是 “架构设计”,其实谈的都是 “需求如何被满足” 这件事情的共识。无论是 “产品文档”,还是 “架构文档”,它们都是设计文档的一种,都有团队内及团队间的协同价值。

上一讲 “69 | 团队的共识管理” 我们已经从团队的协同角度,谈了共识的重要性。本质上,我们也是在谈 “设计” 的重要性。换个角度来说,一个企业的使命、愿景与价值观,何尝不是这个企业最高维度的 “设计” 呢?

产品经理与架构师是一体两面,对人的能力要求的确会比较像,但是分工不同,关注的维度不同。产品经理关注的维度,其关键词是:用户需求、技术赋能、商业成功。而架构师关注的维度,其关键词是:用户需求、技术实现、业务迭代。

今天我们谈的 “设计文档”,重点聊的是 “架构设计文档” 怎么写,但是本质上所有 “设计文档” 的内容组织逻辑,都应该是相通的。它们的内容大体如下:

  • 现状 :我们在哪里,现状是什么样的?

  • 需求:我们的问题或诉求是什么,要做何改进?

  • 需求满足方式: - 要做成什么样,交付物规格,或者说使用界面(接口)是什么?

    • 怎么做到?交付物的实现原理。

关于设计文档内容组织的详细说明,我们在前面 “45 | 架构:怎么做详细设计?” 中已经进行过交代。概括来说,这些设计文档要素的关键在于以下几点。

现状:不要长篇累牍。现状更多的是陈述与我们要做的改变相关的重要事实,侧重于强调这些事实的存在性和重要性。

需求:同样不需要长篇累牍。痛点只要够痛,大家都知道,所以需求陈述是对痛点和改进方向的一次共识确认。

需求满足方式:要详写,把我们的设计方案谈清楚。具体来说,它包括 “交付物规格” 和 “实现原理” 两个方面。

交付物规格,或者说使用界面,体现的是别人要怎么使用我。对于 “产品设计”,交付物规格可能是 “产品原型”。对于 “架构设计”,交付物规格可能是 “网络 API 协议” 或者 “包(package)导出的公开类或函数”。

实现原理,谈的是我们是怎么做到的。对于 “产品设计”,它谈的是用户需求对应的 UserStory 设计,也就是业务流具体是怎么完成的。而对于 “架构设计”,它谈的是 UserStory 具体如何被我们的程序逻辑所实现。

以下这个公式大家都耳熟能详了:

程序 = 数据结构 + 算法

它是一个很好的指导思想。当我们谈程序实现逻辑时,我们总是从数据结构和算法两个维度去描述它。其中,“数据结构” 可以是内存数据结构,也可以是外存数据结构,还可以是数据库的 “表结构”。“算法” 基于 “数据结构”,它描述的是 UserStory 的具体实现,它可以是 UML 时序图(Sequence Diagram),也可以是伪代码(Pseudo Code)。

多个设计方案的对比

在现实中,一篇设计文档有时候不是只有一个设计方案,而是有多个可能的需求实现方式。在这个时候,通常我们会概要地描述清楚两个设计方案的本质差别,并且从如下这些维度进行对比:

  • 方案的易实施性与可维护性。
  • 方案的时间复杂度与空间复杂度。

不同的业务系统倾向性不太一样。对于绝大部分业务,我们最关心的是工程效率,所以方案的易实施性与可维护性为先;但是对于部分对成本与性能非常敏感的业务,则通常在保证方案的时间复杂度与空间复杂度达到业务预期的前提下,再考虑工程效率。

在确定了设计方案的倾向性后,我们就不会就我们放弃的设计方案做过多的展开,整个设计文档还是以描述一种设计方案为主。

如果我们非要写两套设计方案,这时应该把设计文档分为两篇独立的设计文档,而不是揉在一起。

你可能觉得没有人会这么不怕麻烦,居然写两套设计方案。但是如果两套设计方案的比较优势没有那么显著时,现实中写两套设计方案确实是存在的,并且应该被鼓励。

为什么这么说?

这是因为 “设计” 是软件工程中的头等大事,我们应该在这里 “多浪费点时间”,这样的 “浪费” 最终会得到十倍甚至百倍以上的回报。

使用界面(接口)

在描述交付物的规格上,系统的概要设计,与模块的详细设计很不一样。

对于 “模块的详细设计” 来说,规格描述相对简单。因为我们关注的面只是模块本身,而非模块之间的关系。对于模块本身,我们核心关注点是以下两点:一是接口是否足够简单,是否自然体现业务需求。二是尽可能避免进行接口变更,接口要向前兼容。

关于接口变更,后面有机会我们还会进行详细的讨论,这一讲先略过。

但对于 “系统的概要设计” 来说,我们第一关心的是模块关系,第二关心的才是各个模块的核心接口。这些接口能够把系统的关键 UserStory 都串起来。

表达模块关系在某种程度来说的确非常重要,这可能是许多人喜欢画架构图的原因。

但描述模块间的关系的确是一件比较复杂的事情。我们在 “32 | 架构:系统的概要设计” 这一讲中实际上先回避了这个问题。

一种思路是我们不整体描述模块关系,直接基于一个个 UserStory 把模块之间的调用关系画出来。比如对于对象存储系统,我们上传一个文件的业务流程图看起来是这样的:

这类图相信大家见过不少。但它从模块关系表达上并不是好的选择,因为根本并没有对模块关系进行抽象。这类图更多被用在面向客户介绍 API SDK 的背后的实现原理时采用,而非出现在设计文档。

如果只是对于 UserStory 业务流程的表达来说,UML 时序图通常是更好的表达方式。

但是,怎么表达模块关系呢?

一个方法是对模块的调用接口进行分类。通过 “62 | 重新认识开闭原则 (OCP)” 这一讲我们知道,一个模块对外提供的访问接口无非是:

  • 常规 DOM API,即正常的模块功能调用;
  • 事件(Event)的发送与监听;
  • 插件(Plugin)的注册。

这些不同类型的访问接口,分别代表了模块间不同的依赖关系。我们回忆一下 MVC 的框架图,如下:

在图中,View 监听 Model 层的数据变更事件。View 转发用户交互事件给 Controller。Controller 则负责将用户交互事件转为 Model 层的 DOM API 调用。

另一个表达模块关系的视角,是从架构分解看,我们把系统看作 “一个最小化的核心系统 + 多个彼此正交分解的周边系统”。例如,我们实战案例 — 画图程序的模块关系图如下:

需要清楚的是,模块关系图的表达是非常粗糙的,虽然它有助于我们理解系统分解的逻辑。为了共识的精确,我们仍然需要将各个模块核心的使用界面(接口)表达出来。

实现原理

谈清楚了交付物的规格,我们就开始谈实现。对于 “系统的概要设计” 与 “模块的详细设计”,两者实现上的表达有所不同。

对于模块的详细设计来说,需要先交代清楚 “数据结构” 是什么样的,然后再将一个个 UserStory 的业务流程讲清楚。

对于系统的概要设计来说,核心是交代清楚不同模块的配合关系,所以无需交代数据结构,只需要把一个个 UserStory 的业务流程讲清楚。

无论是否要画 UML 时序图,在表达上伪代码(Pseudo Code)的设计都是必需的。

伪代码的表达方式及语义需要在团队内形成默契。这种伪代码的语义表达必须是精确的。

比如,对于网络请求相关的伪代码,我们可以基于类似 qiniu httptest 的语法,如下:

# 请求
post /v1/foo/bar json {...}

# 返回
ret json {...}

类似地,对于 MongoDB,我们可以直接用 MongoDB 的 JavaScript 脚本文法。对于 MySQL,则可以直接基于 SQL 语法。等等。

结语

前面在 “45 | 架构:怎么做详细设计?” 我们实际上已经大体介绍了模块级的设计文档怎么写。所以这一讲我们主要较为全面地补充了各类设计文档,包括产品设计、系统的概要设计等在细节上与模块设计文档的异同。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们谈谈 “如何阅读别人的代码”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 如何阅读别人的代码?

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。今天聊聊如何阅读别人的代码。

为何要读别人的代码?

我们去阅读别人的代码,通常会带有一定的目的性。完整把一个系统的代码 “读懂” 需要极大的精力。所以明确阅读代码的目标很重要,因为它决定了你最终能够为这事付出多大的精力,或者说成本。

大体来说,我们可以把目标分为这样几种类型:

  • 我要评估是否引入某个第三方模块;
  • 我要给某个模块局部修改一个 Bug(可能是因为使用的第三方模块遇到了一个问题,或者可能是你的上级临时指定了一个模块的 Bug 给你);
  • 我要以某个开源模块为榜样去学习;
  • 我要接手并长期维护某个模块。

为什么要把我们的目标搞清楚?

因为读懂源代码真的很难,它其实是架构的反向过程。它类似于反编译,但是并不是指令级的反编译,而是需要根据指令反推更高维的思想。

我们知道反编译软件能够将精确软件反编译为汇编,因为这个过程信息是无损的,只是一种等价变换。但是要让反编译软件能够精确还原出高级语言的代码,这就比较难。因为编译过程是有损的,大部分软件实体的名字已经在编译过程中被去除了。当然,大部分编译器在编译时会同时生成符号文件。它主要用于 debug 用途。否则我们在单步跟踪时,debug 软件就没法显示变量的名字。

即使我们能够拿到符号文件,精确还原出原始的高级语言的代码仍然非常难。它需要带一定的模型推理在里面,通过识别出这里面我们熟悉的 “套路”,然后按照套路进行还原。

我们可以想像一下,“一个精确还原的智能反编译器” 是怎么工作的。

第一步,它需要识别出所采用的编程语言和编译器。这通常相对容易,一个非常粗陋的分类器就可以完成。尤其是很多编译器都有 “署名”,也就是在编程出的软件中带上自己签名的习惯。如果假设所有软件都有署名,那么这一步甚至不需要训练与学习。

第二步,通过软件的二进制,结合可选的符号文件(没有符号文件的结果是很多软件实体,比如类或函数的名字,会是一个随机分配的符号),加上它对该编译器的套路理解,就可以进行反编译了。

编译器的套路,就如同一个人的行为,持续进行观察学习,是可以形成总结的。这只需要反编译程序持续地学习足够多的该编译器所产生的样本。

我之所以拿反编译过程来类比,是希望我们能够理解,阅读源代码过程一方面是很难的,另一方面来说,也是需要有产出的。

有产出的学习过程,才是最好的学习方式。

那么阅读源代码的产出应该是什么?答案是,构建这个程序的思路,也就是架构设计。

理解架构的核心脉络

怎么做到?

首先,有文档,一定要先看文档。如果原本就已经有写过架构设计的文档,我们还要坚持自己通过代码一步步去反向进行理解,那就太傻了。

但是,一定要记住文档和代码很容易发生脱节。所以我们看到的很可能是上一版本的,甚至是最初版本的设计。

就算已经发生过变化,阅读过时的架构设计思想对我们理解源代码也会有极大的帮助作用。在这个基础上,我们再看源代码,就可以相互进行印证。当然如果发生了冲突,我们需要及时修改文档到与代码一致的版本。

看源代码,我们首先要做到的是理解系统的概要设计。概要设计的关注点是各个软件实体的业务范畴,以及它们之间的关系。有了这些,我们就能够理解这个系统的架构设计的核心脉络。

具体来说,看源码的步骤应该是怎样的呢?

首先,把公开的软件实体(模块、类、函数、常量、全局变量等)的规格整理出来。

这一步往往有一些现成的工具。例如,对 Go 语言来说,运行 go doc 就可以帮忙整理出一个自动生成的版本。一些开源工具例如 doxygen 也能够做到类似的事情,而且它支持几乎所有的主流语言。

当然这一步只能让我们找到有哪些软件实体,以及它们的规格是什么样的。但是这些软件实体各自的业务范畴是什么,它们之间有什么关系?需要进一步分析。

一般来说,下一步我会先看 example、unit test 等。这些属于我们研究对象的客户,也就是使用方。它们能够辅助我们理解各个软件实体的语义。

通过软件实体的规格、说明文档、example、unit test 等信息,我们根据这些已知信息,甚至包括软件实体的名字本身背后隐含的语义理解,我们可以初步推测出各个软件实体的业务范畴,以及它们之间的关系。

接下来,我们需要进一步证实或证伪我们的结论。如果证伪了,我们需要重新梳理各个软件实体之间的关系。怎么去证实或证伪?我们选重点的类或函数,通过看它们的源代码来理解其业务流程,以此印证我们的猜测。

当然,如果你能够找到之前做过这块业务的人,不要犹豫,尽可能找到他们并且争取一个小时左右的交流机会,并提前准备好自己遇到迷惑的问题列表。这会大幅缩短你理解整个系统的过程。

最后,确保我们正确理解了系统,就需要将结论写下来,形成文档。这样,下一次有其他同学接手这个系统的时候,就不至于需要重新再来一次 “反编译”。

理解业务的实现机制

业务系统的概要设计、接口理清楚后,通常来说,我们对这个系统就初步有谱了。如果我们是评估第三方模块要不要采纳等相对轻的目标,那么到此基本就可以告一段落了。

只有在必要的情况下,我们才研究实现机制。刚才我们谈到系统架构梳理过程中,我们也部分涉及了源代码理解。但是,需要明确的是,前面我们研究部分核心代码的实现,其目的还是为了确认我们对业务划分猜测的正确性,而不是为了实现机制本身。

研究实现是非常费时的,毕竟系统的 UserStory 数量上就有很多。把一个个 UserStory 的具体业务流程都研究清楚写下来,是非常耗时的。如果这个业务系统不是我们接下来重点投入的方向,就没必要在这方面去过度投入。

这时候目标就很重要。

如果我们只是顺带解决一下遇到的 Bug,无论是用第三方代码遇到的,还是上级随手安排的临时任务,我们自然把关注点放在要解决的 Bug 本身相关的业务流程上。

如果我们是接手一个新的业务系统,我们也没有精力立刻把所有细节都搞清楚。这时候我们需要梳理的是关键业务流程。

怎么搞清楚业务流程?

程序 = 数据结构 + 算法

还是这个基础的公式。要搞清楚业务流程,接下来要做的事情是,把这些业务流程相关的数据结构先理清楚。

数据结构是容易梳理的,类的成员变量、数据库的表结构,通常都有快速提取的方式。除了 MongoDB 可能会难一些,因为弱 schema 的原因,我们需要通过阅读代码的方式去理解 schema。更麻烦的是,我们不确定历史上经历过多少轮的 schema 变更,这通过最新版本的源代码很可能看不出来。一个不小心,我们就可能会处理到非预期 schema 的数据。

理清楚数据结构,事情就解决了大半。

剩下来就是理各个 UserStory 的业务流程,并给这些业务流程画出它的 UML 时序图。这个过程随时可以补充。所以我们挑选对我们当前工作最为相关的来做就好了。

最后,还是同样地,我们要及时把我们整理的结论写下来,变成架构文档的一部分。这样随着越来越多人去补充完整架构设计文档,才有可能把我们的项目从混沌状态解脱出来。

结语

对于任何一个项目团队来说,阅读代码的能力都极其重要。哪怕你觉得你的团队共识管理很好,团队很默契,大家的工程习惯也很好,也都很乐意写文档,但这些都替代不了阅读代码这个基础活动。

阅读代码是不可或缺的能力。

为什么这么说?因为:代码即文档,代码是理解一致性更强的文档。

另外,作为一个小补充,我们需要指出的一点是:阅读代码的结果,有时不一定仅仅是架构设计文档的补充与完善。我们有时也会顺手修改几行代码。

这是正常现象,而且应该被鼓励。为什么鼓励改代码?是因为我们鼓励随时随地消除臭味。改几行明显风格不太好的代码,是非常好的一件事情。

但是我们也要有原则。

其一,不做大的改动,比如限定单个函数内的改动不能超过 10 行。

其二,确保改动前后的语义完全一致。这种一致需要包括所有 corner case 上的语义一致,例如错误码,条件语句的边界等。

其三,不管多自信,有改动就需要补全相关的单元测试,确保修改代码的条件边界都被覆盖。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们谈谈 “发布单元与版本管理”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 发布单元与版本管理

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

前面我们在 “68 | 软件工程的宏观视角” 一讲中谈到:一个软件工程往往是生命周期以数年甚至数十年计的工程。对于传统工程,我们往往把一个工程同时也称之为项目,项目工程。但软件工程不同,虽然我们平常也有项目的概念,但软件工程并不是一个项目,而是无数个项目。每个项目只是软件工程中的一个里程碑(Milestone)。

这意味着软件工程终其完整的生命周期中,是在反复迭代与演进的。这种反复迭代演进的工程,要保证其质量实际上相当困难。

源代码版本管理

怎么确保软件工程的质量?

很容易想到的一个思路是,万一出问题了,就召回,换用老版本。

这便是版本管理的来由。当然,如果仅仅只是为了召回,只需要对软件的可执行程序进行版本管理就好了。但我们如果要进一步定位软件质量问题的原因,那就需要找到一个方法能够稳定再现它。

这意味着我们需要对软件的源代码也进行版本管理,并且它的版本与可执行程序的版本保持一一对应。

但实际上这事并没有那么简单。

从软件的架构设计可知,软件是分模块开发的,不同模块可能由不同团队开发,甚至有些模块是外部第三方团队开发。这意味着,从细粒度的视角来看,一个软件工程的生命周期中,包含着很多个彼此完全独立的子软件工程。这些子软件工程它们有自己独立的迭代周期,我们软件只是它们的 “客户”。

这种拥有独立的迭代周期的软件实体,我们称之为 “发布单元”。你可能直觉认为它就是模块,但是实际上两者有很大的不同。

对于一个发布单元,我们直观的一个感受是它有自己独立的源代码仓库(repo)。

发布单元的输出不一定是可执行程序,它有如下可能:

  • 可执行程序,或某种虚拟机的字节码程序;
  • 动态库(so/dylib/dll);
  • 某种虚拟机自己定义的动态库,比如 JVM 平台下的 jar 包;
  • 静态库(.a 文件),它通常实际上是可执行程序的半成品,比较严谨来说的编译过程是先把每个模块编译成半成品,然后由链接器把各个模块组装成成品;
  • 源代码本身,一些语言的价值主张是源代码发布,比如 Go 语言。

发布单元的输入,常规理解主要包含以下两部分的内容:

  • 若干自己独立演进的模块,也就是源代码仓库(repo)托管的代码;
  • 自己依赖的发布单元列表,这些外部的发布单元有自己独立的迭代周期。

源代码仓库管理系统,比如 svn、git 等等,一般只能管到第一部分。它让我们对自己独立演进的代码可以有很好的质量跟踪。

我们以 github 为例,它提供了以下源代码质量的管理手段。

其一,团队成员开发活动的独立性。每个人可以极低成本地建立一个开发分支(branch),一个开发分支做一个功能(feature),这个工作没有完成时,他的工作对所有其他人不可见,所以团队成员有很好的并行开发的能力,彼此完全独立。

其二,完善的代码质量检查机制。当一个团队成员完成他某项功能(feature)开发时,他可以提交一个功能合并请求(pull request),以求将代码合并进主代码库。但在此之前,我们需要对这项新功能的代码质量进行检查。常见的手段如下:

  • 自动化运行单元测试案例(unit test);
  • 单元测试覆盖率检查(code coverage);
  • 静态代码质量检查(lint);
  • 人工的代码互审(code review);
  • ……

代码质量检查过程,需求显然比较易变。所以在这里 github 做了开放设计。我们再一次感受到了开闭原则的威力。

其三,完善的回滚机制(revert)。在代码已经合并到主代码库后,如果我们突然发现它有 Bug,这时候并不是落子无悔,而是可以自己对某次有 Bug 的 pull request 做回滚(revert),这样主干就可以得到去除了该功能后的一个新的发行版本。

对于第二部分,也就是发布单元的外部依赖管理,通常不同语言有自己的惯例。例如,Go 语言早期并没有官方的版本管理手段,所以导致有很多社区版本的实现方案。直到最新的 go mod 机制终于统一了这一纷争。

从基本原理来说,所有外部依赖管理无非要达到这样一个目标:指定我这个发布单元依赖的各个模块(嗯,这是通俗说法,其实是指依赖的发布单元)的建议版本是什么。

这样,我们理论上就可以稳定持续地通过源代码构建出相同能力的输出结果。

注意,这里有一个前提假设,是要求所有人都自觉遵循的:一个打好了版本号的发布单元是只读的,我们不能对其做任何改动。这句话的意思包括:

其一,我们不能修改发布单元自身包含的各个模块的的代码。这很容易理解,我们不展开。

其二,我们不能修改发布单元依赖的外部模块(同样地,其实指依赖的发布单元)的版本。比如我们依赖 opencv,把依赖的版本号从 v1.0 升级到 v2.0,这是不行的,这也是一次变更,需要修改我们的版本号。

如果有人破坏了版本的只读语义,就会导致所有依赖它的发布单元的版本只读语义也被破坏。这是我们需要极力去避免发生的事情。

从严谨意义来说,仅保证发布单元自身的源代码和依赖的外部模块只读,仍然不足以保证输出结果的确定性。为什么这么说,因为还有两个东西没有做到只读:

其一,操作系统内核。不同版本的操作系统内核行为不完全一致,它的一些动态库可能行为不完全一致,这些都可能会导致我们的软件行为有所不同。

其二,编译器。不同版本的编译器同样存在理论上与编译的结果行为上不一样的可能。

为什么没有把它们纳入到源代码版本管理的范畴管起来?这当然是因为操作系统和编译器大部分情况下质量是有所保证的,所以当软件在不同版本的操作系统下行为不一致时,这会被看做软件 Bug 记录下来,而不是修改操作系统。

软件发布的版本管理

但并不是在所有时刻,我们都能够相信操作系统和编译器。从源代码版本管理的角度,它的好处是软件构建(build)过程是一个相对封闭可预期的环境,这个环境我们甚至直接规定操作系统的种类和版本、编译器的版本,系统预装哪些软件等等。

但是软件发布过程却并非如此。

我们大家可能都接触过各种软件发布的管理工具,比如apt、rpm、brew 等等。在这些管理工具的使用过程中,我们每个人或多或少都有过不少 “失败教训”。并不是每一次软件安装过程都能够如愿。

这些软件发布的管理工具,背后有不少实际上基于的就是源代码的版本管理。但是为什么这个时候它会不 work 呢?因为用户之间系统环境的差异太大了。让每个软件的发布者都能够想到多样化的环境并加以适配,这是非常高的要求。

所以,软件安装有时会不成功,实在是在所难免。

怎么才能彻底解决这个问题?

答案是,容器化。

容器的镜像(image),不只是包含了软件发布的可执行程序本身,也完整包含了运行它的所有环境,包括依赖的动态库和运行时,甚至包括了它依赖的 “操作系统”。这意味着容器的镜像(image)的版本管理,比之源代码的版本管理更进一步,实现完完全全的自描述,不再依赖任何外部环境。

这给我们线上服务的版本管理带来了巨大的便捷性。新版本的服务有缺陷 ?回滚到老版本即可。

只读设计的确定性

版本的只读设计,带来巨大的收益,这是因为版本是一个 “基线”,对于这个基线,我们心理上对它的预期是确定性的。这种确定性非常重要。

在 “68 | 软件工程的宏观视角” 一讲中我们提到:

软件项目的管理期望达到确定性。但软件工程本身是快速变化的,是不确定的。这就是软件工程本身的矛盾。我们的目标是在大量的不确定性中找到确定性,这其实就是软件工程最核心的点。

只读设计提升了软件工程的确定性,所以只读思想被广泛运用。前面我们说开闭原则背后的架构治理哲学,也是模块,或者说软件实体,其业务范畴只读。在业务只读,接口稳定的预期下,模块与模块之间就可以自由组合,构建越来越复杂的系统。

往小里说,我们开发的时候,有时候会倾向于变量只读,以提高内心对确定性的预期。我并没有去用严谨的方式实证过变量只读的收益究竟有多大,但它的确成为了很重要的一种编程流派,即函数式编程。

函数式编程从编程范式来说比较小众,但是其只读思想被广泛借鉴。

这里面最典型的就是大数据领域的 Spark。Spark 的核心是建立在统一的抽象弹性分布式数据集(Resiliennt Distributed Datasets,RDD)之上。

而 RDD 的核心思想正是只读。对一个只读的 RDD 施加一个变换(transform),即得到另一个 RDD,这不就是函数式编程么?但这种只读设计,让我们的分布式运算在重试、延迟计算、缓存等过程都变得极其简单。

版本的兼容问题

版本管理的最后一个问题是兼容性。让一个模块依赖另一个模块(严谨来说是发布单元)的特定版本,这解决了版本的确定性问题。

但是,在某个特定的时刻,我们总是会希望将依赖的模块升级到新版本。无论是基于我们需要使用该模块的新功能,又或者是为了修复的 Bug,或者纯粹是心理上想要更好的东西。

更换到新版本多多少少冒了一些风险。这里面最大风险是所依赖的模块完成了一次重构。

为什么依赖模块的重构会给我们的系统带来未知风险?这其中的原因就在于版本兼容的难度。

兼容一个模块的主体功能并不复杂,既然我们重构了,这部分肯定是得到了解决。但兼容的难度全在细节上。错误码、低频的分支行为等等,这些都需要兼容。

如果这种分支兼容太麻烦,我们干脆就放弃兼容,连软件实体(如函数)的名字都改了。这倒是干脆,客户升级版本后一看,编译不过了,老老实实用新的接口进行重写,重新测试。

但有时候我们无法放弃兼容。这发生在我们在做一个互联网服务时。一旦我们发布了一个 api,它就很难收回,因为使用这个 api 的客户端可能有很多。如果我们放弃这个 api 就意味着我们放弃了很多用户,这是不可接受的。

为了应对这个问题,比较常见的做法是为所有 api 引入版本号,如 “/v2/foo/bar”。当我们对 api 发生不兼容的修改时,就升级版本号,比如 “/v3/foo/bar”。

这样做有一个额外的好处。如果我们对某个复杂模块进行了全局重构,并且兼容老版本的行为细节非常困难时,我们可以直接升级所有 api 的版本号。这样在线上我们可以保留两个版本的服务同时存在。这通过前面放 nginx 作为 api 分派的网关来做到。

这样两个版本服务并行,就不需要重构时做太细节的行为兼容。但应当注意,这也是不得已的办法,如果能够兼容,还是鼓励尽可能去兼容。毕竟客户端在升级版本之后,不兼容的地方越多,修改的心智负担就越大。

结语

今天我们聊的是怎么做版本管理。一个复杂的软件,总可以被分割为若干个独立迭代的发布单元,以便分而治之。发布单元的切割不宜过细,应该以一个小团队负责起来比较舒服为宜,不太小但也不太大。

版本的只读设计提高了系统的确定性预期,这是非常非常好的收益。但我们也应注意版本兼容上带来的坑。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们谈谈 “软件质量管理:单元测试、持续构建与发布”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 软件质量管理:单元测试、持续构建与发布

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

上一讲 “72 | 发布单元与版本管理” 我们聊了版本管理中,只读思想给软件工程带来的确定性价值,它在软件工程质量管理中也是很核心的一点。

软件质量管理

今天我们聊聊软件工程中,我们在质量管理上其他方面的一些思考。事实上,软件质量管理横跨了整个软件工程完整的生命周期。

软件工程与传统工程非常不同。它快速变化,充满不确定性。不仅如此,一个软件工程往往是生命周期以数年甚至数十年计的工程。对于传统工程,我们往往把一个工程同时也称之为项目,项目工程。但软件工程不同,虽然我们平常也有项目的概念,但软件工程并不是一个项目,而是无数个项目。每个项目只是软件工程中的一个里程碑(Milestone)。

这些都决定了软件工程质量管理的思想与传统工程截然不同。在传统工程中,设计的工作往往占比极少,重复性的工作占据其生命周期的绝大部分时间。所以传统工程有极大的确定性。检查清单(Check List)很可能就已经可以很好地实现其工程质量的管理。

但对于软件工程来说,设计工作在整个工程中持续发生。哪怕是非设计工作,比如编码实现,也仍然依赖个体的创造力,同样存在较强的不确定性。显然,检查清单(Check List)完全无法满足软件工程的质量管理需要。

那么,到底应该怎么管理软件工程的质量?每次谈软件工程质量保障的时候,我总会先画下面这张图:

它谈的是软件的生命周期,或者也可以理解为软件中某项功能的生命周期。我们把软件或软件的某项功能生命周期分成两个大的阶段,一个阶段是开发期,一个阶段是维护期。开发期与维护期是相对而言的,只是在表征上,开发期有更强的设计属性。维护期虽然也持续会有设计工作,但是工作量会小一个数量级以上。

为什么划分出开发期与维护期是重要的?

因为开发期的时间跨度虽然可能不长,但是它的影响太大了,基本决定了后期维护期的成本有多高。

这也意味着软件工程是需要有极强预见性的工程。我们在开发期恰如其分地多投入一分精力,后面在维护期就有十倍甚至百倍以上的回报。

设计工作的质量至关重要。但是它执行上又不太有复制性,可复制的只是设计范式和设计思维。

我们只能在这种执行的不确定性中找工程上的确定性。

如何做到?

单元测试

首先,做好自动化测试。自动化测试对软件工程的重要性是不言而喻的。如果是一项一次性的工程,我们可以基于常规的手工测试。但常规测试的缺点在于:

其一,一般常规测试是基于手工的,不具备可回归性。因此,常规测试的效率不高,一次完整的测试集跑下来可能需要几天甚至一周之久。

其二,易于缺乏效率,所以往往为了赶工会导致测试仅仅针对典型数据,测试的覆盖率往往也很低。

软件工程的生命周期往往几年甚至几十年之久,我们必然关注单次测试的效率。所以自动化测试的核心价值就在于可回归性与提高测试的覆盖率。

自动化测试与常规测试相比,风格上有很明显的不一样,它有如下重要特征。

  • 自动化、可回归性。
  • 静默(Quiet)。没有发生错误的时候,就不说话。
  • 案例执行的安全受控。某个案例执行的失败,不会影响其他案例的正常运行。

从分类来说,一般自动化测试我们分两个层次:一个是模块级的单元测试,一个是系统级的集成测试。

无论从什么角度来看,模块的单元测试都是重中之重的大事。原因是,单元测试的成本是最低的。

关于测试成本,我们可以从两个维度看。

其一,单元测试的实施成本低,最容易去做。不少高级语言比如 Go 语言甚至在语言内建的工具链上就直接支持。而集成测试虽然也有自动化的方法和支持工具,但是往往需要更高额的代价。

其二,减少问题发现的周期,进而降低问题的修复成本。单元测试将问题发现周期缩短,基本上在问题现场就发现问题,这降低了Bug的修复成本。如果问题在系统的集成测试阶段发现,那么从问题定位,到回忆当初实现这段代码时候的思路,到最终去解决掉它,必然需要多花费几倍甚至几十倍的时间。

因此,我们鼓励更严格的单元测试要求,更高的单元测试覆盖率,以尽可能把发现问题做到前头。

但仍然有不少公司在推广单元测试上遇到了不小的麻烦,推不起来。

对于这一点,我们认为首先要改变的是对推广单元测试这件事情的认知。我们不把推广单元测试看作是让大家去多做一件额外的事情,而是规范大家做单元测试的方法。

为什么这么说?因为实际上单元测试大家都会去做,很少有人会不经验证就直接交付。但是验证方式上可能有各种 “土” 方法,比如用 print,用可视化的界面做输入测试,用调试工具做单步跟踪等等。

但是这些方法代价其实一样不低,但是却不可回归,正确与否还需要人脑临时去判断。

更重要的是,这些方法最大的问题是没有办法去固化已知的 Bug,最大程度保留下来我们的测试案例。

这其实才是最核心的一个认知问题:我们应当重视我们的测试代码,它同样也是我们的开发成果,理应获得和模块的功能代码同等重要的地位,理应被保留下来。

解决了这个认知上的共识问题,自动化测试就能够被很好地推动起来。当前这方面的工具链已经非常完善,不至于会在工具上遇到太大的障碍。

持续构建,持续发布

其次,我们降低软件工程不确定性的方法是:持续构建,持续发布。

我们鼓励更小的发布。我们鼓励更短的发布周期,更高的发布频率。这能够让发布的负担降低到最低。

这种极度高频交付的机制与传统工程的质量管理机制迥异。但是它被证明是应对软件工程不确定性的最佳方式。为什么会这样?

其一,交付的功能越少,因为错误而发生回滚的代价越低,影响面越小。如果我们同时发布了数十个功能,却因为某一个功能不达标而影响整体交付,这其实是降低了软件的功能交付效率。更好的方式显然是把这个出问题的功能回滚,把其他所有功能都放行。

其二,交付频率越高,我们对交付过程的训练越频繁,过程的熟练度越高,执行效率也越高。当交付成为一个自然习惯后,我们会把交付看作功能开发的一部分,而不是以前大家对研发的理解,认为做完功能就完事,后续上不上线与我无关。我们会鼓励更多把研发的绩效与功能线上的表现关联起来,面向客户价值,而非仅仅面向功能开发。

当然这种极度高频交付的机制,意味着它对软件工程的系统化建设有更高的要求。

当然,除了日构建与发布平台外,我们也需要在其中加入各种质量管理的抓手。比如:

  • 自动化运行单元测试案例(unit test);
  • 单元测试覆盖率检查(code coverage);
  • 静态代码质量检查(lint);
  • 人工的代码互审(code review);
  • 灰度发布(gray release);
  • A/B 测试(A/B testing);
  • ……

结语

今天我们更加完整地探讨了软件工程的质量管理。整体来说,软件工程与传统工程在质量管理上的理念是迥异的,甚至往往是反其道而行之的。究其原因,还是因为软件工程的核心在于如何在高度的不确定性中找到确定性。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们谈谈 “开源、云服务与外包管理”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 开源、云服务与外包管理

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。今天我们聊的话题是有关于分工的。

在这一讲之前,我们涉及到分工这个话题,基本上都局限于企业内部,且大多数情况下主要在同一个团队内部。但今天我们聊的是更大的分工:跨组织的分工与协作。

外包及其理想模型

在软件工程中,我们第一个接触的外部分工毫无疑问是外包。所谓外包,就是将我们软件的全部或部分模块的实现职能交给外部团队来做。

但是,软件工程项目的外包实际上成功率非常低。这背后有其必然性,它主要表现在以下这些方面。

其一,任务表达的模糊,双方容易扯皮。期望需求方能够把需求边界说清楚,把产品原型画清楚,把业务流程讲清楚,这非常难。有这样专业的需求表达能力的,通常软件工程水平不低,遇到这样的需求方,绝对应该谢天谢地。这种专业型的甲方,它大部分情况下只发生在项目交付型外包,而非产品功能外包。更多的产品外包,一般是甲方不太懂技术,需要有团队替自己把事情干了,他好拿着产品去运营。

其二,交付的代码质量低下,长期维护的代价高。软件工程不是项目,它都需要长长久久地运行下去。但是接包方的选择相当重要。因为接包方的质量相当参差不齐,遇上搬砖的概率远高于设计能力优良的团队。事实上,有良好设计能力的团队,多数情况下也不甘于长期做外包。

其三,项目交接困难,知识传承效率很低。软件工程并非普通的工程,就算交付的结果理想,项目交接也非常困难。所以外包项目第一期结束后,如果运营得好,往往项目还继续会有第二期、第三期。这里的原因是你只能找同一拨人做,如果换一波人接着做,考虑到知识传承效率低下,往往需要很长的一个交接周期。

那么,外包的理想模型是什么?

上面我们已经说到,外包在通常情况下,专业的甲方需要说清楚需求,这样双方就没有分歧。但是,更好的做法其实不是外包需求,而是外包实现。

也就是说,作为专业的甲方,我自己做好需求分析,做好系统的概要设计。进一步,我们把每个模块的业务范畴与接口细化下来。我们以此作为外包边界。假设分了N个模块,我们可以把它们平均分给若干个接包方。

这种方式的外包,甲方相当于只留了架构师团队,实现完全交给了别人。但是它与普通的外包完全不同,因为根本不担心知识传承的问题。每个模块的接包方对甲方来说就真的只是干活的。

接包方拿到的是模块的规格说明书。他要做的是模块的详细设计的实现部分,其中最为核心的是数据结构设计。对于服务端,甲方可以规定所采用的数据库是什么,但是把表结构的设计交出去。

进一步,如果模块的外包说明书中还规定了单元测试的案例需要包含哪些,那么这个模块发生设计偏离的可能性就很低。

外包的验收需要包含模块的实现设计文档,里面描述了数据结构+算法。另外,单元测试部分,每个测试场景,也填上对应的测试函数的名称。

实际会有人这样去外包么?

我不确定。但我们可以把它看作一种分工的假想实验。这个假想实验可以充分说明架构师团队的重要性。有了一个好的架构师团队,他们设计合适的系统架构,对每个模块的规格都做了相应的定义,他们验收模块的实现。

这样,项目就可以有条不紊地展开。甚至,研发进度可以自如控制。嫌项目进展太慢?找一倍的接包方,就可以让工程加速一倍。

所以,这个外包假想实验也说明了一点:我们的平常项目之所以进度无法达到预期,无他,团队缺乏优秀的架构师而已。

让我们把软件工程看作一门科学。我们以工程师思维的严谨态度来看它。我们减少项目中的随意性,把架构设计的核心,模块规格,也就是接口,牢牢把控住。这样,项目的执行风险就完全消除了。

哦不,还有一个最大的执行风险没有消除。我怎么证明这个系统架构的分解是对的?不会出现每个模块做好了,但是最终却拼不起来?

我们前面在 “架构:系统的概要设计” 这一讲中实际上已经谈过这事的解决方法:系统设计的产出要有源代码,它是项目的原型。关键模块有 mock 的实现,业务系统的关键 UserStory 都串了一遍,确保系统设计的正确性。

这个假想实验是有趣的,它可以让你想明白很多事情。甚至可以把它看作理解这个专栏的架构思维核心思想的钥匙。

我希望,它不只是一个假想实验。

开源与众包

我们把话题拉回到跨组织的分工。

除了传统的外包外,在软件工程中出现的第二类外包是众包,它以开源这样一个形态出现。

从分工角度,开源的核心思想是让全社会的程序员共同来完成一个业务系统。

开源的优势非常明显。对于一个热门的开源项目,它的迭代进度是非常惊人的,因为它撬动的资源太大了。

但不是开源了就能够获得这样的好处。

虽然成功的开源项目风风火火,但是我们也应该意识到,对于那些并没有得到关注的开源项目,它们的迭代速度完全无法保障。最终,你可能还是只能靠自己的团队来完成它的演进。

从这个意义上看,开源是一种商业选择。你得持续经营它。没有经营的开源项目不会成功。你需要宣传它,你自己也得持续迭代它,你还要为它拉客户。有客户的开源项目自然就有了生命力。

另外,开源这种形态,注定了它只能做大众市场。如果一个业务系统它的受众很少,就比较难通过开源获得足够的外部支持。

所以绝大部分成功的开源项目,都属于基础设施性质的业务系统,有极其广泛适用的场景。例如,语言、操作系统、基础库、编程框架、浏览器、应用网关、各类中间件等等。我们这个架构课重点介绍的内容,大部分都有相应的开源实现。

开源对信息科技的影响极其巨大,它极大地加速了信息科技前进的进程,是全球共同精诚协作的典范。

没有参与过开源的程序员是需要心有遗憾的。开源沉淀下来的协同方法与工作流,今天被无数公司所借鉴。

没有开源,我们无法想象这件事情:那么多形形色色的企业,今天其中绝大部分,它们的软件工程协同方法与业务流竟然如此相似。

这是开源带来的另一种无形资产。

如果大家没有忘记的话,可能能够回忆起来,在谈完软件工程的宏观视角之后,我首先聊的是 “团队的共识管理”。为什么这很重要?因为它是团队协作效率的最大基础。如果连对协作的工作流都没有共识,那团队真的是一盘散沙了。

今天我们几乎不会遇到工作方式上的问题,不是别的原因,是开源给予我们的礼物。它让全球的程序员、全球的科技企业,都养成了一模一样的工程习惯。

云计算与服务外包

云服务是新的跨组织分工的形态。无论是传统的外包,还是开源的众包,它们都属于源代码外包。这类外包的共同特点是,它们不对结果负责。

对于传统外包,项目验收结束,双方一手交钱一手交货,至于用得好不好,那是甲方自己的事情。

对于开源软件来说,那更是完全免责,你爱用不用,用了有什么问题责任自负。当然有很多公司会购买开源软件的商业支持,这不难理解,除了有人能够帮助我一起完成项目上线外,最重要的是要有人能够给我分担出问题的责任。

互联网为跨组织协同带来了新的机会。我可以24小时为另一个组织服务,而无需跑到对方的办公室,和他们团队物理上处在一起。

这就是云计算。云计算从跨组织协同的角度来看,不过是一种新的交付方式。我们不再是源代码交付,而是服务交付。所以,你也可以把云计算看着一种外包,我们称之为服务外包。

大部分的基础设施,都可以以服务外包的方式进行交付。这中间释放的生产力是惊人的。

一方面,云计算与传统外包不同,它对结果负责,有服务 SLA 承诺。一旦出问题,问题也可以由云服务提供方自己解决,而无需业务方介入,这极大降低了双方的耦合,大家各司其职。

另一方面,它简化了业务方的业务系统,让它得以能专注自己真正的核心竞争力的构建。

站在生产效率角度看,不难理解为什么我们会坚信云服务是未来必然的方向。

外包方式的选择

任何企业都存在于社会生态之中,我们无法避开组织外部的分工协同问题。

怎么选择跨组织的协同方式?

在七牛,自成立以来我们就一直有一句话谈我们对跨组织协同的看法:

我们尽可能不要做太多事情。非核心竞争力相关的,能够外包的我们尽可能外包。

在外包选择上,我们优先选择云服务,次选开源,最后才考虑传统的外包。

这句话有它一定的道理,但也有它模糊的地方。

首先是关于 “核心竞争力相关”。我们并没有太清晰地去定义什么样的东西是我们核心竞争力相关,什么不相关。

一些程序员对此理解可能会比较 “技术化”,认为业务系统的核心模块就是核心竞争力。与它相关的东西就是核心竞争力相关。

但更合理的视角不是技术视角,而是业务视角。我们每一家企业都是因为服务客户而存在。所以,与服务客户的业务流越相关,越不能外包,而是要自己迭代优化,建立服务质量与效率的竞争优势。

另外,外包的选择需要非常谨慎。很多开发人员都有随意引用开源项目的习惯,这一定程度上给项目带来了不确定的风险。

我一直认为,开源项目的引入需要严格把关。严谨来说,开源项目引入大部分情况下是属于我说的 “基础架构” 选择的范畴,这同样是架构师团队需要承担的重要职责,一定要有正规的评估流程。

结语

今天我们聊的话题是跨组织的分工与协同。在形态上,我们可以分为:传统外包、开源与云服务。当然还有就是我们今天没有讨论的使用外部商业软件。

从形态来说,商业软件很接近传统外包,但是从它的边界来说,因为商业软件往往有明确的业务边界,所以在品质上会远高于外包。当然定制过于严重的商业软件例外,它在某种程度上来说退化为了传统外包。

在外包方式的选择上,我们的建议是:

我们尽可能不要做太多事情。非核心竞争力相关的,能够外包的我们尽可能外包。

在外包选择上,我们优先选择云服务,次选开源,最后才考虑传统的外包。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们谈谈 “软件版本迭代的规划”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 软件版本迭代的规划

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

到今天为止,我们专栏的话题主要集中在软件工程的质量与效率上。我们在专栏的开篇中就已经明确:

从根本目标来说,软件架构师要对软件工程的执行结果负责,这包括:按时按质进行软件的迭代和发布、敏捷地响应需求变更、防范软件质量风险(避免发生软件质量事故)、降低迭代维护成本。

但是今天,我们将探讨一个更高维的话题:软件版本迭代的规划。后续我们简称为 “版本规划”。简单说,就是下一步的重点应该放在哪里,到底哪些东西应该先做,哪些东西应该放到后面做。

这是一个极其关键的话题。它可以影响到一个业务的成败,一个企业的生死存亡。方向正确,并不代表能够走到最后,执行路径和方向同等重要。

那么,版本规划的套路是什么?

探讨这个问题前,我想先看一个实际的案例。这个案例大家很熟悉:Go 语言的版本迭代。

我们从 Go 语言的演进,一起来看看 Go 团队是如何做软件版本迭代规划的。这有点长,但是细致地琢磨对我们理解版本规划背后的逻辑是极其有益的。

Go 版本的演进历史

Go 语言的版本迭代有明确的周期,大体是每半年发布一个版本。

Go 1.0 发布于 2012 年 3 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1。它是 Go 语言发展的一个里程碑。

在这个版本,Go 官方发布了兼容性文档:https://tip.golang.org/doc/go1compat,承诺会保证未来的 Go 版本将保持向后兼容。也就是说,将始终兼容已有的代码,保证已有代码在 Go 新版本下编译和运行的正确性。

在 Go 1.0 之前,Go 在持续迭代它的使用范式,语法规范也在迭代优化。比如 os.Error 到了 Go 1.0 就变成了内置的 error 类型。这个改变看似很小,但实际上是一个至关重要的改变。因为 Go 推荐可能出错的函数返回值都带上 err 值,如果 os.Error 不改为内建类型,就会导致很多模块不得不因为 os.Error 类型而依赖 os 包。

Go 1.0 最被诟病的问题是它的 GC 效率。相比 Java 近 20 年的长期优化,其成熟度只能以稚嫩来形容。

与此相对应的是,Go 从一开始就是一门极度重视工程的语言。Go 1.0 就已经有非常完善的工程工具支持。比如:

  • 单元测试:go test;
  • 文档:go doc;
  • 静态检查工具:go vet;
  • 性能 Profile 工具: go tool pprof。

Go 1.1 发布于 2013 年 5 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.1。这个版本主要专注于语言内在机制的改善和性能提升(编译器、垃圾回收、map、goroutine调度)。改善后的效果如下:

这个版本还发布了一个竞态探测器(race detector),它对 Go 这种以高并发著称的语言显然是重要的。详细可参考 Go 官方博客文章:https://blog.golang.org/race-detector

Go 1.2 发布于 2013 年 12 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.2。这个版本发布了单元测试覆盖率检查工具:go tool cover。详细可参考 Go 官方博客文章:https://blog.golang.org/cover

Go 1.3 发布于 2014 年 6 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.3。这个版本栈的内存分配引入了连续段(contiguous segment)的分配模式,以提升执行效率。之前的分页式的栈分配方式(segment stack)存在频繁地分配/释放栈段导致栈内存分配耗时不稳定且效率较低。引入新机制后,分配稳定性和性能都有较大改善。

Go 1.3 还引入了 sync.Pool,即内存池组件,以减少内存分配的次数。标准库中的 encoding/json、net/http 等都受益于它带来的内存分配效率提升。另外,Go 还对 channel 进行了性能优化:

Go 1.4 发布于 2014 年 12 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.4。从功能来说,这个版本最大的一件事情是增加了 Android/iOS 支持(http://golang.org/x/mobile),Gopher 可以使用 Go 编写简单的 Android/iOS 应用。

但实际上如果从重要程度来说,Go 1.4 最重要的变化是将之前版本中大量用 C 语言和汇编语言实现的 runtime 改为用 Go 实现,这让垃圾回收器执行更精确,它让堆内存的分配减少了 10~30%。

另外,Go 1.4 引入了 go generate 工具。这是在没有泛型之前解决重复性代码问题的方案。详细见https://blog.golang.org/generate

Go 1.5 发布于 2015 年 8 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.5。这个版本让 Go 实现了自举。这让GC 效率优化成为可能。所以在这个版本中,GC 被全面重构。由于引入并发垃圾回收,回收阶段带来的延迟降低了一个数量级。

这个版本还有一个很重要的尝试,是引入了 vendor 机制以试图解决 Go 模块的版本管理问题。自从 Go 解决了 GC 效率后,Go 版本管理就成了老大难问题。下图是 Go 社区对 Go 面临的最大挑战的看法:

当然后来事实证明 vendor 机制并不成功。

另外,Go 1.5 引入了 go tool trace,通过该命令我们可以实现执行器的跟踪(trace)。详细参考 https://golang.org/cmd/trace/

Go 1.6 发布于 2016 年 2 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.6。垃圾回收器的延迟在这个版本中进一步降低。如下:

从功能上来说,这个版本支持了 HTTP/2。

Go 1.7 发布于 2016 年 8 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.7。这个版本有一个很重要的变化,是 context 包被加入标准库。这事之所以重要,是因为它和 os.Error 变成内建的 error 类型类似,在网络接口中,context 是传递上下文、超时控制及取消请求的一个标准设施。

另外,Go 编译器的性能得到了较大幅度的优化,编译速度更快,二进制文件size更小,有些时候幅度可达 20~30%。

Go 1.8 发布于 2017 年 2 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.8。GC 延迟在这个版本中进一步得到改善,延迟时间降到毫秒级别以下。

另外,这个版本还大幅提升了defer的性能。如下:

Go 1.9 发布于 2017 年 8 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.9。这个版本引入了 type alias 语法。例如:

type byte = uint8

这实际上是一个迟到的语法。我在 Go 1.0 就认为它应该被加入了。另外,sync 包增加了 Map 类型,以支持并发访问(原生 map 类型不支持)。

Go 1.10 发布于 2018 年 2 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.10。在这个版本中,go test 引入了一个新的缓存机制,所有通过测试的结果都将被缓存下来。当 test 没有变化时,重复执行 test 会节省大量时间。类似地,go build 也维护了一个已构建的包的缓存以加速构建效率。

Go 1.11 发布于 2018 年 8 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.11。这个版本最重要的新功能是 Go modules。前面我们说 Go 1.5 版本引入 vendor 机制以解决模块的版本管理问题,但是不太成功。这是 Go 团队决定推翻重来引入 module 机制的原因。

另外,这个版本引入了一个重要的试验功能:支持 WebAssembly。它允许开发人员将 Go 源码编译成一个兼容当前主流浏览器的 wasm 文件。这让 Go 作为 Web 开发语言成为可能。

Go 1.12 发布于 2019 年 2 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.12。这个版本的 go vet 命令基于 analysis 包进行了重写,使得 go vet 更为灵活并支持 Gopher 编写自己的 checker。详细参考 “How to Build Your Own Analyzer” 一文。

Go 1.13 发布于 2019 年 8 月,详细 ReleaseNote 见 https://tip.golang.org/doc/go1.13。这个版本的 sync.Pool 性能得到进一步的改善。当 GC 时,Pool 中对象不会被完全清理掉。它引入了一个 cache,用于在两次 GC 之前清理 Pool 中未使用的对象实例。

另外,这个版本的逃逸分析(escape analysis)被重新实现了,这让 Go 更少地在堆上分配内存。下图是新旧逃逸分析的基准测试对比:

另外,Go modules 引入的 GOPROXY 变量的默认值被改为:

GOPROXY=https://proxy.golang.org,direct

但在国内无法访问 Go 官方提供的 proxy.golang.org 站点。建议改为:

export GOPROXY=https://goproxy.cn,direct

这里 https://goproxy.cn 由七牛云赞助支持。

Go 版本迭代的背后

Go 语言的版本迭代的规划非常值得认真推敲与学习。

Go 的版本迭代还是比较高频的,但是有趣的是,在 Go 1.0 版本之后,语言本身的功能基本上已经非常稳定,只有极少量的变动。比如 type alias 这样的小特性,都已经可以算是关键语法变化了。

那么,这些年 Go 语言都在变化些什么?

其一,性能、性能、性能!尤其在 GC 效率这块,持续不断地优化。为了它,大范围重构 Go 的实现,完成了自举。其他还有很多,比如连续栈、内存池(sync.Pool)、更快的编译速度、更小的可执行文件尺寸。

其二,强化工程能力。各种 Go tool 的增加就不说了,这其中最为突出的就是 Go 模块的版本管理,先后尝试了 vendor 和 module 机制。

其三,标准库的能力增强,如 context,HTTP 2.0 等等。这块大部分比较常规,但 context 的引入可以算是对网络编程最佳实践的一次标准化过程。

其四,业务领域的扩展。这块 Go 整体还是比较专注于服务端领域,只是对 Android、iOS、WebAssembly 三个桌面平台做了经验性的支持。

如何做版本规划

蛮多技术背景的同学在做版本规划的时候,往往容易一开始就陷入到技术细节的泥潭。但其实对于一个从 0 到 1 的业务来说,首先应该把焦点放到什么地方,这个选择才至关重要。

Go 语言在这一点上给出了非常好的示范。它首先把焦点放在了用户使用姿势的迭代上。凡与此无关的事情,只要达到及格线了就可以先放一放。这也是 Go 为什么一上来虽然有很多关于 GC 效率的吐槽,但是他们安之若素,仍然专注于用户使用姿势的迭代。

但是一旦语言开始大规模推广,进入从 1 到 100 的扩张阶段,版本迭代的关注点反而切换到了用户看不见的地方:非功能性需求。生产环境中用户最关心的指标,就成了 Go 团队最为关注的事情,日复一日,不断进行迭代优化。

这是很了不起的战略定力:知道什么情况下,最该做的事情是什么。

那么,遇到重大的客户需求,对之前我们培养的用户习惯将形成重大挑战怎么办?一些人可能会习惯选择快速去支持这类重大需求,因为这些需求通常很可能听起来很让人振奋。

其实 Go 语言也遇到了这样的需求:泛型的支持。

泛型被 Go 团队非常认真地对待。可以预期的是,Go 2.0 一定会支持泛型。但是,他们并没有急着去实现它。Go 社区不少人在 Go 1.9 的时候,很激动地期待着 Go 2.0,期待着泛型,但是 Go 出来了 Go 1.10,甚至到现在的 Go 1.13。

显然,泛型被放到了一个旁路的版本。这个旁路版本独立演化直到最终验证已经成熟,才会被合并到 Go 1.x 中。这时,Go 2.0 就会诞生了。

这其实才是正确响应会招致巨大影响面的功能需求的姿势。

客户是需要尊重的。而尊重客户的正确姿势毫无疑问是:别折腾他们。

结语

今天我们聊的话题是版本迭代的规划。在不同阶段,版本迭代的侧重点会有极大的不同。从 0 到 1 阶段,我们验证的是用户使用姿势,性能并不是第一位的。但是进入扩张阶段,产品竞争力就是关键指标,这时候我们迭代的是用户价值最大的,也是用户真正最在乎的那部分。

遇到会对产品产生巨大冲击的需求,头脑别发热,谨慎处理。回到从 0 到 1 阶段的方法论,在少量客户上先做灰度。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们谈谈 “软件工程的未来”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 软件工程的未来

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。现在正值中国年,我在这里祝大家新年快乐。开开心心过大年的同时,注意安全第一,出门记得戴上口罩,少去人员聚集的地方。

好,那我们开始今天的学习,今天我们想聊聊软件工程的未来。

软件工程是一门非常年轻的学科,相比其他动辄跨世纪的自然科学而言,软件工程只有 50 年的历史。只有如此短暂实践的科学,今天我们来探讨它的未来,条件其实还并不算太充分。

但是我们的宗旨就是要每个领域都应该谈清楚过去(历史)与未来(趋势判断),所以今天不妨也理性来探讨一下。

在 “软件工程的宏观视角” 一讲中,我们引入了下图来表达软件工程的瀑布模型:

在这样一个模型里面,涉及的角色分工已经非常多:

  • 产品经理;
  • 架构师;
  • 开发工程师;
  • 质量保障(QA)工程师;
  • 网站可靠性工程师(SRE);
  • ……

但这还只是常规描述的工种。实际的分工要细致很多。更不要说对特殊的领域,比如企业服务,也就是大家常说的 2B 行业,它的基本过程是这样的:

比之纯粹的产品研发上线过程,它多了单个客户的跟进与落地实施过程,也由此引入更多的角色分工,比如:售前工程师、交付(实施)工程师、售后工程师、项目经理等。

未来软件工程会走向何方?

首先 “快速变化” 是软件工程的自然属性,其 “不确定性” 也只能抑制而无法消除。

但显而易见的是,软件工程的问题最终还是由软件解决。事实上今天很多问题已经解决得很好,比如源代码的管理。我们经历了 cvs、svn,最终到今天的 git。基本上开发人员的协同问题已经形成非常约定俗成的方法论,并以软件或云服务的方式被固化下来。

今天,线上服务管理正如火如荼的发展。假以时日,不需要多久之后,一个全新的时代开启,我们中大部分人不必再为线上服务的稳定性操心。关于这块更详细的讨论,可以参考第四章 “服务治理篇”。

需求管理与测试这块也已经得到很好的解决。唯一比较遗憾就是是界面(UI)相关的测试虽然也有相关的工具链,但当前的普及率仍然极低。

这可能与大部分公司都较难保证界面的稳定性有关。如果我们经常变动界面,这就如同我们经常调整一个模块对外的接口规格一样,必然导致相关的测试案例编译通不过,或者测试通不过。这会让人沮丧,进而丧失对实现界面(UI)测试自动化的信心。自动化测试极其依赖被测模块接口的稳定性,这是我们今天常规自动化测试方法的限制。

当然另一方面,这也与界面测试相对高维,大部分公司的质量保障水平都还没有到达这个级别有关。从现实来看,虽然单元测试方法论已经极其成熟,但是仍然有不少企业在推行中遇到不少障碍。

可以预期,随着企业的平均工程水平逐步提升,最终会形成越来越多的有效的界面测试最佳实践的方法论,并得以大范围的推广。

从全局来看,今天软件工程已经形成较为成熟的分工。但各类分工的最佳实践与软件系统,仍然是相对孤立的。

这一定程度上也与软件工程还很年轻有关。从软件工程的软件系统发展来说,可以预期的是,未来一定会形成更加一体化的系统,上一道 “工序” 的输出就是下一道 “工序” 的输入。

但是今天一些 “工序” 的输出仍然是人肉进行传递,甚至没有标准化的仓库管理它。例如,产品经理输出的产品界面设计原型、架构师输出的架构设计文档,其传递过程仍然有极大的随意性。

但是,软件工程的最大不确定性就来源于 “设计” 类工作,包括产品设计与软件的架构设计。今天虽然产品设计和架构设计也都有一些独立的工具,但普及度与刚才说的开发与测试类工程实践相比完全是小巫见大巫。

这是可以理解的,产品经理与架构师在软件工程中属于小众群体,其培养难度极高,很多经验也很难形成传统意义上的 “知识点” 来传递。所以真正意义上合格的产品经理与架构师是比较少的,和程序员(软件开发工程师)的规模完全无法相比。

就拿架构师这个岗位举例。架构师的职责是什么,架构师工作的方法论是什么、培养架构师的方法论又是怎样的,这些今天并没有一个被广泛接受的实践。

为什么我会写这个架构课专栏,以及为什么成立七牛大学开启线下的架构师实战训练营,也是希望能够在一定程度上找到这些问题的最佳答案。

而事实上,产品经理的培养有更高的难度。严格意义上来说,成为产品经理前,首先应该成为架构师。我这个观念可能与大部分人的常识相悖,但是我个人对此深信不疑。

软件工程的未来发展会怎样,细节上很难给出确定性的判断。但是,我们相信,软件工程极大成熟的标志,是一体化的软件工程支撑系统,和高效的人才培养体系。包括今天仍然极为稚嫩的架构师培养体系,和产品经理培养体系,都应该得到了极大的完善。

到那个时候,软件工程就成为了一门真正成熟的科学。

结语

软件工程项目迭代快速、充满变化、充满不确定性。这使得软件工程成为一门极其独特魅力的科学。今天这门科学仍然还非常年轻,其发展只能以日新月异来形容。

软件工程的未来,它的成熟不单单是工程方法论和业务系统软件的成熟,也需要包括人才培养体系的成熟。因为,软件工程的不确定性与它充满设计与创造有关,人的主观能动性是它的优势,但也意味着不确定性无法得到彻底的消除。

我们要做的,只能说在大量的不确定性中,找到尽可能多的确定性。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。至此,本章 “软件工程篇” 已经到尾声阶段,下一讲我们将对本章的内容进行回顾与总结。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

| 软件工程篇:回顾与总结

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

我们架构课的最后一章软件工程篇到此就要结束了。今天我们就本章的内容进行回顾与总结。

架构师并不是一个纯技术岗位。我们从软件工程的视角来看,架构师的职责就是要对软件工程的执行结果负责,这包括:按时按质进行软件的迭代和发布、敏捷地响应需求变更、防范软件质量风险(避免发生软件质量事故)、降低迭代维护成本。

软件工程所覆盖的范畴非常广泛。从开始的需求与历史版本缺陷,到新版本的产品设计,到架构设计,到编码与测试,到最终的产品发布,到线上服务的持续维护。

还有贯穿整个工程始终的,是不变的团队分工与协同,以及不变的质量管理。

我们这个专栏并没有打算站在完整的软件工程角度去谈,更多还是从架构师与软件工程的关联入手。

本章的内容大体如下图所示。

软件工程是一项团体活动,大家有分工更有协同。不同的个体因为能力差别,可以形成十倍以上的生产力差距。而不同的团体更是如此,他们的差距可能更上一个数量级,达到百倍以上的生产力差距。

百倍以上的差距是什么概念?这就是说,一个团队只需要三四天做出来的东西,另一个团队可能需要一年才能做出来。两者之间的差距之大,只能用天壤之别来形容。

个人与个人的差距,你可以认为是技术上的能力差距的反映。但团队与团队的差距,不是简单的技术上的能力差距,而是有着更为深刻的原因。

高效团队的效率,核心体现在以下两个方面:

  • 团队开发一个新功能的效率。它体现的是架构的老化程度。
  • 团队新人的融入效率。新人多快的速度可以融入到团队,理解业务系统的现状及团队的做事方式。

开发新功能的效率,主要取决于架构的优劣。这初听起来是一项纯技术上的事情。但如果我们站在时间维度上长达数年甚至数十年的软件工程的角度看,能够维持架构设计的持续优异,这绝非某个人的技术能力可以做到的事情,而是要靠团队共同的坚持。

而从新人融入效率看,更非技术能力所能够简单囊括,而是仰仗团队对业务传承的坚持。

这些东西的背后,关乎的都是有关于协同的科学。

有的团体像一盘散沙,充其量可以叫团伙。有的团体则有极强的凝聚力,整个团队上下同心,拧成一股绳,这种团体才是高效率组织,是真正意义上的团队。

共识是团队效率的基础。

从软件工程角度来说,产品设计和架构设计是团队最大的共识。架构过程就是一次团队共识确认的过程,从项目的混沌之初,到团队形成越来越清晰且一致的视图( Picture)。

高效团队往往还有极高的团队默契,这让他们无论是维护老项目还是做什么新项目都如鱼得水。团队默契可以包含很多东西,比如:

  • 共同的目标;
  • 团队的做事态度与价值观;
  • 编码规范;
  • 架构设计文档的模板;
  • 软件工程的方法论;
  • 基础架构及技术选型;
  • ……

对于一个团队新人来说,融入一个团队或一个项目的基础过程就是阅读别人写的源代码。既有的文档越清楚,新人阅读代码的障碍就越小,融入的速度就越快。

文档要怎样才能把问题说清楚?

文档传递的是思维方式。大多数程序员不善于写文档,甚至讨厌写文档。这背后的根源不在于文档本身,而在于有效的思维表达方式,这需要长期的训练。

软件工程的各个环节都有其交付物。理想情况下,上一个环节的输出是下一环节的输入。软件系统的质量管理一般从这些交付物的管理入手。例如:交付物的版本管理、单元测试、持续构建,灰度发布,等等。

从更宏观的视角看,我们还涉及人力资源规划的问题。什么东西应该外包出去,包给谁?软件版本的计划是什么样的,哪些功能先做,哪些功能后做?

这些选择非常非常重要。因为他们属于业务架构的顶层设计。

除了传统意义上的外包外,外包方式还有:开源(众包)、云服务(服务外包)、商业软件(产品外包)。在外包方式的选择上,我们的建议是:

我们尽可能不要做太多事情。非核心竞争力相关的,能够外包的我们尽可能外包。

在外包选择上,我们优先选择云服务,次选开源,最后才考虑传统的外包。

当然,哪些事情是非核心竞争力相关,这一点不同公司可能判断不尽相同。但基本的判断逻辑是,越与我们面向用户所提供的业务流程相关,越靠近企业的核心竞争力,也就越不能外包。

软件版本迭代的规划需要根据业务的发展阶段而定。在不同阶段,版本迭代的侧重点会有极大的不同。

从 0 到 1 阶段,我们验证的是用户使用姿势,也就是产品设计的规格。这时性能并不是第一位的。

但是进入扩张阶段,产品竞争力就是一些用户关心的关键指标。这时候我们迭代的不再是用户使用姿势,它已经非常稳定。我们迭代的往往是看不见的非功能性需求,是那些用户真正最在乎的部分。

而遇到会对产品产生巨大冲击的功能需求,头脑别发热,谨慎处理。回到从 0 到 1 阶段的方法论,在少量客户上先做灰度。

结语

软件工程还很年轻,只有 50 年的历史。有关于软件工程的系统与方法论都仍然在快速演化与迭代中。

这意味着意我们不必墨守成规。要勇于探索,勇于打破固有的惯例,去建立新的方法论,新的惯例。

但需要强调的是,打破惯例不是胡闹,不是要做不尊重科学的 “野蛮人”。今天仍然有那么一批工程师,人数还不在少数,他们随心所欲、任性而为,不喜欢写架构设计文档,不喜欢写单元测试,不喜欢代码互审(code review)。

我们首先需要尊重团队协同的科学,在尊重的基础上去探索新的更高效的协同方法论。

很早之前我说过以下这段话,它很长一段时间里,被贴在某家公司墙上:

严谨并非创新的对立面,而是创新的重要基础。每个人都有灵光乍现的时刻,但是唯有那些拥有严谨的科学态度的人才能抓住它,把它变成现实。

我想,它非常适合作为软件工程篇的结束语。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。至此,本章 “软件工程篇” 结束,下一讲将结束本专栏的内容。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

结束语 | 放下技术人的身段,用极限思维提升架构能力

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

这个专栏从去年 4 月份至今已经有 10 个月左右的时间,到了要和你说再见的时候了。感谢你的一路相伴,感谢你的坚持。也希望这些内容能够对你有所帮助。

从工程角度来说,架构师的存在几乎是一种必然。传统项目工程也有架构师的角色,只不过软件工程有其特殊性,它快速变化,充满了不确定性,所以架构师的重要性的比重会被进一步放大。

但是如何才能成为优秀的软件工程架构师?

传统的架构图书往往从架构思维开始。但是,我认为它们错了。这里面最关键的问题在于:

架构并不是 “知识点”。

架构思维的确非常非常重要。但是,熟读架构思维并不足以让人成为一名优秀的架构师。

关于这一点,我经常拿中国传统的武学文化做类比。武功招式可以精确传授,是 “知识点”,掌握了就是掌握了,理论上可以做到分毫不差。但是,架构不是武功招式。它更像内功,它不是 0 和 1,没有清晰的掌握和没有掌握这样泾渭分明的区别。

在架构能力上,没有最好,只有更好。

这是为什么我们的架构课并不是从架构思维开始,而是采用双线结构。它基本上围绕着以下两个脉络主线来展开内容:

  • 如何从零开始一步步构建出整个信息世界;
  • 在整个信息世界的构建过程中,都用了哪些重要的架构思维范式,以及这些范式如何去运用于你平常的工程实践中。

这两大脉络相辅相成。

首先,我们通过还原信息世界的构建过程,剥离出了整个信息世界的核心骨架,这也是最真实、最宏大的架构实践案例。

其次,我们结合这个宏大的架构实践来谈架构思维,避免因对架构思维的阐述过于理论化而让人难以理解。

最后,架构就是对业务系统的正交分解。因此,整个信息科技的演化过程,自然而然形成了分层:基础架构 + 业务架构。

基础架构的产生是对业务架构不断深入理解的过程。越来越多的共性需求从业务架构抽离出来,成为信息科技的基础设施。

作为架构师,我们需要坚持对业务进行正交分解的信念,要坚持不断地探索各类需求的架构分解方法。这样的思考多了,我们就逐步形成了各种各样的架构范式。

这些架构范式,并不仅仅是一些架构思维,而是 “一个个业务只读、接口稳定、易于组合的模块 + 组合的方法论”,它们才是架构师真正的武器库。

这个武器库包含哪些内容?

首先,它应该包括信息科技形成的基础架构。努力把前辈们的心血,变成我们自己真正的积累。光会用还不够,以深刻理解它们背后的架构逻辑,确保自己与基础架构最大程度上的 “同频共振”。

只有让基础架构完全融入自己的思维体系,同频共振,我们才有可能在架构设计需要的时候 “想到它们”。

这一点很有趣。有些人看起来博学多才,头头是道,但是真做架构时完全想不到他的 “博学”。

从体系结构来说,这个基础架构包含哪些内容?

其一,基础平台。包括:冯·诺依曼体系、编程语言、操作系统。

其二,桌面开发平台。包括:窗口系统、GDI 系统、浏览器与小程序。当然我们也要理解桌面开发背后的架构逻辑,MVC 架构。

其三,服务端开发平台。包括:负载均衡、各类存储中间件。服务端业务开发的业务逻辑比桌面要简单得多。服务端难在如何形成有效的基础架构,其中大部分是存储中间件。

其四,服务治理平台。主要是以容器技术为核心的 DCOS(数据中心操作系统),以及围绕它形成的整个服务治理生态。这一块还在高速发展过程中,最终它将让服务端开发变得极其简单。

理解了这些基础架构,再加上你自己所处行业的领域知识,设计出一个优秀业务系统对你来说就只是轻车熟路而已。

这也是为什么这个架构课的内容结构是目前这个样子组织的。因为消化基础架构成为架构师自身的本领,远比消化架构设计原则,架构思维逻辑要难得多。

消化基础架构的过程,同时也是消化架构思维的过程。

把虚的事情往实里做,才有可能真正做好。

当然提升架构能力,不完全与成为架构师这件事情等同。

架构能力其实是一种属性,并不是只有架构师需要架构能力。软件开发工程师、SRE、甚至包括产品经理,都需要具备架构能力。

而架构师这个特殊的岗位,则是因为软件工程的需要而产生的。它从更全局的视角来把控工程的演进方向,以确保整个业务系统经历几年甚至几十年的迭代,仍然可以快速适应变化,而不至于老化。

成为架构师并不是一件纯技能的事情。

架构师需要放下技术人的身段,学会 “共情”。与用户共情,理解用户的所思所想。与开发人员共情,理解技术人的所思所想。与公司共情,理解公司的发展诉求。

架构师需要学会 “认同他人,反思迭代自己”。不要在不了解背景的情况下,随意推翻别人写的代码,而理由可能仅仅是不符合你的个人风格。当然反过来完全看不到项目的问题同样要不得,但这往往是受限于个人能力。要提升自己的架构水平,需要在实践中不断反思,不断在自我否定中成长。

不过我们今天把话题的重心收敛到架构能力上。怎么才有意识地通过训练来提升自己的架构能力?

实践对架构能力不可或缺。

在现实中,不少技术人员连函数规格都想不清楚。他们关心你是怎么 “实现” 的,但是却不关心 “接口规格” 是什么样的,接口规格是否符合函数的 “业务语义”。

要提升架构能力,首先得做到规格为先,而不是实现为先。不要动不动问怎么实现的。要首先谈这个规格合不合理,是否存在多余的依赖。进一步来说,要多去谈这个函数(或软件实体)的业务范畴合不合理,是否应该换一个切分的姿势。

其实 review 自己的代码也是一种极佳的架构能力的提升手段。对自己刚刚写完的代码,去 review 它,从中找出问题。如此反复训练,就能实现自我能力的提升。

这其实是最高效的自我提升的方式。如果团队其他成员 code review 发现了你的问题,你得反思一下为什么自己发现不了。

很多人追逐实现新的业务系统,通过做新系统来找到满足感。但是实际上对架构师来说,恰恰是反复打磨既有系统是更加锻炼人的。如果你一年前实现的系统今天仍然很满意,那就需要警醒,因为这一年你在原地踏步。

在架构能力上,没有最好,只有更好。

这里我想分享一段我自己 review 自己代码的特殊经历。事情发生在我大学期间,当时的电脑相对我们大部分学生的购买力来说,还是非常昂贵。所以我和另外 4 个同学花了 7500 元合买了一台电脑。

结果就是,我们 5 个人轮流使用这台电脑。这意味着,我一周平均只能用一天多一点时间。再刨除上课时间,我真正能够上机的时间并不多。

而当时的我对编程非常着迷,所以我绝大部分的上机时间都花在编程上。作为物理系的学生,正常来说我学的编程语言是 Fortran。但我很快就把 Fortran 课程自学完了,并从老师口中和 Fortran 课程的附录中了解到了 C 语言。

于是我找物理系高年级的同学搞到了 Turbo C 2.0,开始翻遍学校图书馆的图书自学 C 语言。

为了能够高效利用一周只有一天多的上机时间,我尝试把程序写到纸上,并且提前进行 code review,确保尽可能多地发现程序中的错误,以减少上机过程中的调试时间。

在一次数学建模竞赛里,我和另外两位同学(廖唯棨和程胜峰)一组,其中用到了 Dijkstra 的最短路径算法。看完算法逻辑的介绍后,我直接一遍写成最终的代码,没有经过任何调试过程。

这让在旁边看着的同学廖唯棨觉得很神奇,问我是不是之前实现过 Dijkstra 算法。但其实于我而言,这不过是长期养成自我 review 代码习惯的结果而已。

这个习惯持续了三年之久。这三年里,我开始的时候都是先把代码写到纸上并完成 review,然后再到电脑上。但是到后期这个习惯就变了,我不再需要把所有细节都提前写到纸上,而是只需要提前准备好骨架:整个程序串起来的思路是什么。

我大学期间写过很多高代码量的程序。其实第一个 C 程序就不短,是一个仿 DOSKEY 的程序。后来也做过汇编语言的 IDE。这是因为学汇编的时候,发现没有好的汇编语言集成环境,于是就自己做了一个。至于为什么学汇编?是因为我想写一个 C++ 编译器,感受一下语言实现者的体验。另外,我也尝试在 DOS 操作系统下实现了一个图形界面库,并用它做了图片查看器和 MP3 播放器。

在代码量非常大的时候,人的脑容量就完全无法把这个实现装到头脑中。这时 “规格重于实现” 背后的意义就完全体现出来了。通过规格串起整个业务系统,以此把业务系统装到脑子里,这就是很朴素的架构 “骨架” 思维。

这不是一个假想实验。

它是我的亲身经历。这段经历启发我意识到极限思维对架构能力提升的重要性。

架构没有最好,只有更好。在极有限的上机时间里,在没有电脑的情况下,我们只能选择把更多的逻辑装进脑子里。

这个过程还可以更进一步。我们不断训练自己对不同业务领域的架构范式的理解。直至最终,我们头脑中可以装得下整个信息科技的骨架。

到那时,单就架构能力而言,你就是最顶级的架构师了。

备注:我在文末准备了一份调研问卷,也欢迎你点击下方的图片参与调研,期待你的反馈。

热点观察 | 我看Facebook发币(上):区块链、比特币与Libra币

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

Facebook(脸书)于6月18日发布了其加密数字货币项目白皮书。该数字货币被命名为 Libra(天秤座),象征着平衡与公正。

此前,BBC 报道说这个数字货币叫 GlobalCoin(全球币),但后来被纠正说这只是Facebook员工在内部对其的昵称。

不管是叫 Libra,还是 GlobalCoin,其实都透露出了Facebook的雄心壮志。

今天的Facebook 坐拥 27 亿的活跃用户,相当于全球1/3的人都在用 Facebook,这是一个比微信大得多的数字社群(微信月活为 11 亿,差不多全中国人民都在用微信)。

我之所以叫它“数字社群”,而不叫“数字王国”,是因为用科学的态度来谈,“国家” 这样的称呼没法随便用,“国家”得符合国家需要具备的特征。

国家有哪些特征?

武装是大家能够很快想得到的,国家得有自己的军队,但是数字世界毕竟是个虚拟世界,军队似乎没啥意义。

发行货币是国家另一个至关重要的权力。现在,拥有 27 亿人口的 Facebook 要发币,这意味着它离真正意义上的 “数字王国” 又靠近了一步,至关重要的一步。

整个世界将因此发生翻天覆地的变化。

今天我想借此机会,谈谈区块链和数字货币背后的社会需求动因、逻辑以及它们将产生的巨大影响。

我们的内容将会分为上下两部分,如下:

  • 我看 FB 发币(上):区块链、比特币与 Libra 币;
  • 我看 FB 发币(下):深入浅出理解 Libra 币。

区块链

我们先从区块链谈起。

介绍区块链技术的文章已经有很多了,我们今天不谈技术实现的细节。简单说,它是一个分散式防篡改的数字账本。

但是,区块链到底有什么用?它想解决什么样的问题?

有人说,区块链是为了“去中心化”。

那么“去中心化”是趋势么?互联网的趋势是“中心化”,而不是“去中心化”。

效率为先。如果一个事情可以两个人干更好,那么最终的结果就是应该两个人去干,而不会是十个人去干,这是最朴素的经济学原理。

而微信和 Facebook 的成功,也证明了中心化是趋势。人们之所以向往着去中心化,是因为人天然对垄断有抗拒之心,被人生杀予夺的感觉怎么想都不太美妙。

人们因为效率而中心化,因为垄断而去中心化。所以是一个中心还是几个中心,这是自然平衡的结果。

历史告诉我们,它选择的“去中心化”是开辟疆土。前有哥伦布发现新大陆,今有“钢铁侠”马斯克寻求火星殖民。这才是真正的“去中心化”,找到全新的盐碱地去开拓。

地球相比于整个宇宙,只不过是一粒尘埃。这样的“中心化”,又算得了什么呢?

当然也有人说,区块链是为了“去中介”。

那么“去中介化”是互联网的趋势么?互联网会减少尽可能多的中间环节,但是不会“去中介”。

每个人有他自己的专长。把自己不擅长的事情交给中介,这是特别自然的一件事情,我们为中介的专业性买单。

互联网让中介可以 24 小时为你服务,远程为你服务。这些都是以前没法完成的。这会导致什么呢?一些中介会茁壮成长,变成行业中的关键节点。它也有可能会顺带合并掉上下游的一些分工,让服务链条更短。

那么,区块链价值到底是什么?

**我认为,区块链本质上是一种“共识机制”,或者说“契约机制”。**分散式防篡改的数字账本,保障的核心是事实的不可抵赖。这对双方形成共识是极大的效率提升。在大部分情况下,没有共识往往是因为争议的双方对事实的认定不同。

从这个认知来看,一些鼓吹“区块链是下一个互联网基础设施”的人们可以洗洗睡了。

区块链不会重造一个新互联网底层,但它最有可能重塑金融与供应链。

比特币

比特币,数字货币的鼻祖,区块链技术的第一个杀手级应用。

为什么会出现比特币?它希望解决什么样的需求?

其一,希望能够解决政府动不动就发币,让你手头货币凭空贬值的问题。

这应该是比特币创始人中本聪的本意。所以比特币发币的机制是挖矿,谁挖到就算谁的。成本是购买计算机的成本和运行计算机挖矿所消耗的电费。

从这个角度看,比特币不能类比法币,它没有法币的发行者,国家信用作为背书。它更像是黄金,基于资源本身的稀缺性作为背书。

其二,希望能够解决经济全球化带来的货币跨境流通问题。

这是数字货币的价值投资者们的共同期望。

随着互联网技术在全球范围的不断普及,越来越多的生意被放到了网上。旧的商业文明可以一言以蔽之:一手交钱,一手交货。而建立在互联网之上的新商业文明,我们一手下单付款,一手收钱发货,足不出户,货物就通过便捷的物流服务送到了你手上。

但建立在互联网之上的新商业文明,遭遇了国家与国家之间的边界挑战。这体现在以下两点。

**其一,汇率。**我们都知道,法币与法币的兑换是有损的。你把钱从 A 币换成 B 币,再把 B 币换回 A 币,钱就少了。

**其二,关税。**一个商品从一个国家买到另一个国家,成本就变高了。刨除物流成本带来的影响因素外,最大的额外代价就是关税。

物流成本是可以解决的。贸易全球化带来的结果是生产全球化。

以前国与国之间的贸易,主要成分在成品贸易,这个成品的原料和加工的零件都来自于同一个国家,我生产出成品后销往世界各地。

但是现在国与国之间的贸易,主要成分在原料与零部件的交易。这意味着供应链已经越来越全球化,成品的生产很可能就在销售地,或者靠近销售地。

这样的好处,一方面当然是产品品质的需要,可以全球寻求优质供应商。另一方面可以极大程度地降低物流成本。原材料的运输相比成品来说,更易于利用规模化效应降低单位运输成本。

关税问题,也是可以国家之间协调解决的。超低关税,乃至最终零关税是历史发展的必然。

那么汇率问题呢?

这个问题不好解决。提供兑换货币服务的金融机构不可能是免费服务,他们自身的成本也在那里。只要有跨境交易,就有“用什么结算货币”的问题,随之而来的就有结汇的问题。

除非,有一种世界货币,它能够流通于各个国家,被各个国家的市场所接受。

解决汇率问题最好的办法当然是不用换汇。

比特币能够满足这个需求么?

不能。比特币有这样一些软肋,我列在了下面。

**其一,没法按需增发,价格波动大。**货币发行量最理想的情况是按市场需求来。如果市场对货币的需求量暴增,但是又没法增加货币发行量来调节,必然会导致货币增值。

比特币就属于这种情况,它只能靠挖矿来增加,而没有其他手段。那么一旦人们对它的需求增加速度超过挖矿速度,就会出现价格暴涨。

投机性的行为可能会助长这一点。但是从更长远的维度看,比特币的价值与“市场需求量/比特币流通量”成正比。

**其二,性能低。**比特币基于区块链技术,参与的节点众多。节点多对比特币是有极强的正面价值的,因为参与的节点越多,其被人把控的概率也就越低。

但是,节点越多,比特币交易的性能也就越低。那么现实中,比特币交易到底有多慢?平均每秒2-3笔交易。

这意味着,比特币完全无法满足支持世界货币所需的交易频次。

Libra 币

虽然比特币并不满足需求,但数字化的世界货币一定会诞生,只不过是谁的问题,这是贸易全球化决定的。

Libra 币就是冲着成为世界货币去的。在 Facebook 最近发布的《加密货币项目 Libra 白皮书》中提到:

我们的世界真正需要一套可靠的数字货币和金融基础设施,两者结合起来必须能兑现“货币互联网”的承诺。

可见,Libra 币其实不属于 Facebook,只不过是由Facebook 发起的。就像互联网一样,它期望的是一种开放式的架构。

“互联网”连接了世界上的所有人,但是国与国之间边界导致的“汇率”和“关税”问题,让贸易无法做到真正意义的全球化。

Libra 币旨在构建“货币互联网”,让贸易真正无国界,它如何做到?我们将在《我看 FB 发币(下):深入浅出理解 Libra 币》这篇文章中进行详细的介绍。

今天我们重点聊的是 Libra 币将带来什么。

第一个问题:Libra 币会不会成功?

我认为它会成功。唯一阻碍它成功的因素是美国政府。毕竟它给世界上任何一个国家的人们都带来了一个巨大的变化:

  • 除了本国的法币,还有一个货币能够用于人们之间的日常交易。

虽然此前已经有比特币也能够做到这一点,但是比特币的低吞吐能力决定了它必然无法成为世界货币。

Libra 币最直接挑战的是美元的地位。某种意义上来说,在它之前,美元在承担着世界货币的作用。

但是我认为美国政府会支持 Libra 币。有两个重要的理由支持他们这样做。

**其一,世界货币一定会产生。**与其让它发生在其他国家,不如发生在美国,由美国人来主导这件事情的发生。

其二,美元的物理属性,决定了美元对交易的渗透在非美元区(法币不是美元的地区)只能在线下,无法支持线上交易。

但线上交易的比重越来越大,意味着美国需要一个数字货币能够渗透到其他国家。Libra 币刚好满足了这个需求,所以,Libra 币很容易与美元达成结盟。

一旦美国支持 Libra 币,这件事情就成功了一半。欧洲本来就有欧元,相当于已经有一个自己小范围的世界货币了,货币发行本来就已经和国家政权解绑了。改用 Libra 币带来的冲击并不大。

搞定了欧美,Libra 币基本上就算成功了。其他地区的国家很难真正去反抗这一潮流。

为什么?

不拥抱 Libra 币,意味着放弃由“互联网+Libra币”共同构建的世界自由贸易体系。这对任何一个国家来说,都意味着闭关锁国,放弃经济增长。

另外搞一个世界货币与之对抗?

难。货币有很强的网络效应。如果一个国家或地区已经被 Libra 币渗透,另一个货币进来就难了。

所以如果非要干,最佳的时机是现在。

假设,另一个世界货币干起来了,全球被分成了两半,一东一西。

它们之间没有贸易么?贸易用哪个世界币?这带来了新的汇率问题。

如果可以让企业自由选择,那么这会是大鱼吃小鱼的故事。因此,第二个世界货币很难真正干起来。

第二个问题:如果 Libra 币成功的话,它会带来什么影响?

最大的变化,是货币的大一统。就像欧洲已经发生的那样,国家政权和货币脱钩,政府再也无法用货币杠杆来宏观调控市场。

第二个大变化,是交易的透明化。链上的支撑节点都可以看到全球所有的交易往来。虽然 Libra 币是匿名的身份,但是开通 Libra 账户过程在很多国家很可能是实名的。所以某种意义上来说,交易还是会和现实身份对应起来。

第三个问题:中国应该怎么应对?

这个问题,有点像是中国该不该加入世贸组织(WTO)一样。我的答案是:应该认认真真考虑下如何加入,什么时候加入,加入前应该做好哪些准备。

结语

总结一下我对 Facebook 发币这件事情的看法。

  • 世界货币一定会诞生,只不过是谁的问题。这是贸易全球化决定的。
  • 以前大家看好比特币,但是比特币有软肋。其一是波动大,其二是性能低。
  • Libra 币不属于 Facebook,只不过是由 Facebook 发起而已。它就像互联网一样,是开放式的架构。这也是其他巨头一拍即合,毫不犹豫地去支持的原因。
  • Libra 币会得到美国政府的支持,并逐步渗透到世界各个地区。它不只是流行于 Facebook 的 27 亿活跃用户,也会流行于其他如 PayPal、Booking 等主流服务。
  • “互联网 + Libra 币” 将共同构建全新的世界自由贸易体系。

站在未来看现在,Libra 币将会是极其重大的一个历史节点。我倾向于这样来描绘它的影响:

计算机 => 互联网 => Libra 币

货币有着巨大的网络效应,留给后来者的时间窗口极短。我想,我们都应该思考怎么面对这件事情了。

热点观察 | 我看Facebook发币(下):深入浅出理解 Libra 币

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。今天我们接着聊由 Facebook 推动的 Libra 币。

听到一个陌生概念后,人们往往习惯于把它和自己熟悉的东西做一个类比,方便和他人沟通的时候,用以表达自己的理解。

那么,和 Libra 最像的东西是什么呢?

货币储备

有人说,Libra 不就是腾讯的 Q 币么?但是,这种理解仅仅停留在非常表面的层次。两者共同之处,仅仅是因为两者都是一种虚拟币(甚至不能说它们都是数字货币,因为 Q 币是不能称之为数字货币的)。

首先,Q 币可以用货币购买,但是没法反向兑换现金。最关键的是,腾讯可以自由发行 Q 币,它说有多少就可以有多少,不需要被监管。所以如果 Q 币可以以稳定的价格兑换现金的话,相当于腾讯可以自由印钞票,这显然不会被政府所允许。

但是 Libra 并不是这样。Libra 可以自由兑换,另外 Facebook 也不能够自由发行 Libra 币。发行新的 Libra 币时,有两大核心约束。

**其一,发行新的 Libra 币需要保值的资产做为抵押,我们称之为 “Libra 储备”。**什么样的资产可以作为 Libra 储备?被 Libra 协会认可的,稳定且信誉良好的中央银行发行的货币,或者政府货币证券。也就是说,Libra 币锚定的是它认可的“一篮子货币”,而不是某一种货币。

**其二,由 Libra 协会来发行或销毁 Libra 币。**目前 Libra 协会执行这个动作完全是被动的。Libra 生态中,会有一些被授权的经销商,他们负责 Libra 币的买卖。

这些授权经销商会按照一定的汇率,以某种被 Libra 协会认可的货币,向 Libra 协会买入 Libra 币。这时,这些用于买入的货币就会成为 Libra 储备。

当然授权经销商也可能卖出 Libra 币,换成他所期望的某种货币。在这种情况下,被卖出的 Libra 币就会被销毁,相应地, Libra 储备就会减少。

所以你可以看到,Libra 并没有自己的货币政策,总共有多少 Libra 币完全是由市场的供需决定的。

关于货币储备更详细的内容,请参阅 “Libra 储备”。

稳定币

既然 Libra 不像 Q 币,那么它像什么?比特币吗?

相比 Q 币来说,Libra 与比特币的确更有比较意义,毕竟它们都是一种数字货币。但是我们在 “我看Facebook发币(上):区块链、比特币与 Libra 币” 中,也已经谈到过两者一些根本上的不同。

如果我们忽略所有的技术细节,单从货币角度来看,比特币和 Libra 最大的区别是价格的波动性。

通过上面我们对 “Libra 储备” 的介绍,我们很容易知道 Libra 是一种稳定币,它看起来更像是由一些保值资产抵押所形成的 “债券”。而在币圈,大家可能都经常会听到一个词,叫做 “炒币”。

如果我们细想一下这背后的逻辑,就会知道这个词其实挺神奇的。

货币的核心价值是什么?

货币的价值显然不是增值,而是流通。而流通用的货币,最重要的是价格稳定。而币圈则不同,这些各式各样存在的币,现在它们的核心价值变成了 “炒”,这就完全被扭曲了。

我这么说当然不是认为比特币不好。只不过如果我们真的对比特币好,就应该把关注点放在比特币流通价值的构建上;而不是“喜看币涨”,涨了就奔走相告。

世界货币

那么,究竟 Libra 最像什么呢?

我个人认为,虽然目前的实现机制有所不同,但是 Libra 最像的是欧元。欧元的出现,对欧盟国家之间的自由贸易往来发挥了巨大的贡献。

欧元和 Libra 的初衷是一样的,都是为了构建跨国的自由经济贸易体系。两者不同之处在于,当前 Libra 还没有央行,不能凭空去发行货币。

凡事都有两面,“世界货币” 同样是有缺陷的。欧债危机导致欧元贬值,这个教训大家还记忆犹新。它告诉我们,一个国家经济出现问题,如果它采用的是独立的主权货币,那么只是导致本国的货币贬值。

但是 “世界货币” 意味着大锅饭,它会传导到整个经济体,连带整个经济体的货币贬值。

那么 Libra 会出现自己的央行么?短期当然不会,当前最重要的是流通,是地盘的扩张。但是长远看只要 Libra 成功了,就一定会有。毕竟,没有 Libra 央行,就没有机会解决 “大锅饭” 的问题。

所以站在更长远的未来看,今天的 Libra 协会,有可能就会是未来的 “Libra 央行”。

Libra 协会

Libra 协会总部位于瑞士日内瓦,协会成员包括分布在不同地理区域的各种企业、非营利组织、多边组织和学术机构。初始协会成员共有28家(如下图),未来计划达到100家。


加入 Libra 协会,需要支付不低于 1000 万美元来购买 Libra 投资代币,注意,它并不能简单理解为 Libra 币,两者有很大不同,它有很多特殊的权益。

**其一,整个 “Libra 储备” 的处置权。**比如, Libra 储备到底放在哪里会比较安全靠谱?这是由Libra 协会决定的。但是每个成员是不是都是一票?并不是,票数是由这个成员持有的 Libra 投资代币数量决定的。具体规则,我在下面会讨论。

**其二,整个 “Libra 储备” 的利息,会被用来支撑 Libra 协会的日常运转。**比如工资和奖金激励,也会用于派发分红。分红会按照持有的 Libra 投资代币的多少来分配。

**其三,各类事务决策的投票权。**权重按持有的 Libra 投资代币来计算,每 1000 万美元有 1 个投票权,但是为了防止投票权过于集中,任何成员的投票权不能超过总票数的 1%。

超出部分的投票权将由 Libra 协会的董事会重新分配,比如授予具有社会影响力的合作伙伴(称为 SIP)或研究机构,前提是:他们有能力并致力于验证节点的运作,从而参与治理,并且确实无法作出最低 1000 万美元的投资。

**其四,运行验证节点。**这一点可以被看作义务,但也可以看作权益。义务角度来说,显然这事是需要 IT 成本的,如果连这都做不到,就会被剔除出去。从权益角度来说,那么多的交易数据都被你拿到了,这是多么有价值的数据。

关于 Libra 协会储备更详细的内容,请参阅 “Libra 协会”。

发展目标

当前 Libra 的验证节点是有限制的,是许可型的,也就是所谓的 “联盟链”。但是,未来它会向非许可型治理和共识节点运营转变,降低参与的准入门槛,并减少对创始人的依赖。

Libra 将逐步进行网络的开放,变成所谓的 “公链”。这时,新成员也能够通过投资 1000万美元来自动获得验证节点的运行许可,并持有 Libra 投资代币,从而分享网络权益(但是我估计投票权之类是没有的,只有投资收益)。这部分的详细政策,或许未来才会逐步明朗。

Libra 协会致力于尽可能减少协会的干预权。比如完全通过市场机制来调节 Libra 币的供需。所以除了说服更多人加入 Libra 协会,协会最重要的工作是确定 Libra 的技术演进路线。

但这一点真实情况下显然并不会那么理想。货币互联网和互联网不同的是,它管的毕竟是钱。一旦 Libra 网络发展壮大,一些货币相关的治理问题就会暴露出来。

降维打击

Libra 的影响面绝对超乎大部分人的想象。我们先看看一些具备技术背景的知名互联网公司 CEO 是怎么看的。具体参见下面的截图。

显然无论王兴还是王小川,都高度重视并反复评估 Libra 带来的影响和自己应该采取的行动。

Libra 带来的打击是全方位的,某种程度上来说甚至是极难抵御的降维打击。我们不妨从以下这些维度看。

  • 弱小国家的货币主权会不会就此被取代?
  • 中国的汇率管控会不会失效?就算没有失效,会不会变成闭关锁国?
  • Libra 是否会成为美国取代美元的新货币霸权?
  • 从企业与企业竞争的角度,这会不会成为 Facebook 对微信、支付宝的降维打击?

想完影响面想对策。从对策的角度来说无非两种,一是对抗,一是谋求对等权力下的协作。当然,这两条路都不会太容易。

结语

今天,我们从分析 Libra 的运行机制重新去理解 Libra 币。这里面的关键是:理解 Libra 协会究竟如何工作的。

我们没有分析 Libra 的技术细节,比如它怎么改变区块链的底层实现机制,又为什么要引入 Move 语言。如王兴所说,评判 Libra 的实现技术好不好,就好比评价美元钞票的印刷技术精细不精细。这事本身是有价值的,但不那么关键。

为什么我会写这两篇文章?

站在未来看现在,Libra 币将会是极其重大的一个历史节点。我倾向于这样来描绘它的影响:

计算机 => 互联网 => Libra 币

货币有着巨大的网络效应,留给后来者的时间窗口极短。我想,我们都应该思考怎么去面对这件事情了。

用户故事 | 站在更高的视角看架构

作者: Aaron

完成时间:

总结时间:

你好,许式伟老师的架构课已经更新了一段时间,不少同学反馈这段时间的学习很有收获,于是我们邀请了Aaron同学来和我们做一次分享,看看他这阶段都是怎样学习课程的,又有哪些收获。今天这节课为加餐,形式和正式的课程内容不同,需要你阅读文稿来学习,好,我们开始吧。

我是 Aaron,一名 Python 软件工程师, 目前主要从事运维开发DevOps工作。

一般开发的系统是内部使用的运维系统,最近主要的工作就是将产品需要的一些功能嵌入到类似CMDB系统当中,比如SaltStack、ELK(EFK)、Zabbix 等。

目前,运维自动化的困境是运维研发资源能力的不足,底层自动化的能力可以通过IaaS公有云来解决,但是在OS之上的运维自动化都是通过一些开源工具来解决的。开源工具的引入,加大了维护的难度和复杂度,而且,很多平台的可扩展能力非常薄弱。

理解这些开源工具的内部实现原理、优缺点,了解可以改善的地方,实现项目的深层次进化,是自己的额外追求。因为这个追求,学习架构设计就很有必要,从架构的思维来看这些迭代了五六年的开源项目,可以看到开源软件开发者的迭代开发流程和思考。发上等愿,结中等缘,享下等福,运维开发在公司偏辅助业务,我希望努力学习基础架构来实现自身价值。

我为什么要学习架构课?

极客时间推出《许式伟的架构课》的时候 ,我正在工作时间之外学习操作系统、计算机网络等课程。看了这门课的介绍觉得挺好的,知道作者是七牛云的许式伟老师,因为自己工作方向的原因,经常会接触到关于Go语言布道师(许式伟老师)的一些介绍,对于许老师非常崇拜 ,特别是许老师多年前就预测到 Go 语言会称霸云计算这一点,我特别佩服。

另外,我也一直希望能借助许老师的视角和高度,来看看架构这件事。

如何将用户的需求,一步步分解为程序员要实现的功能点,并展现给用户,这是架构师的职责。设计高可用、高性能、高并发的可靠系统需要匠心,悟心,保持谦和求取的心态。架构思维并不难 ,但是成为优秀的架构师却不易。我很想知道在许老师这样的高度上,他对架构的理解是什么样的。

我也希望可以通过对专栏的学习,能在工作实践中有所成长,成为优秀的架构师。所以我购买了《许式伟的架构课》,并开始学习。

我是怎样学习专栏的?

我学习这个专栏没有什么特别之处,无非做到了三点:反复学习、动手实践、留言打卡坚持。

许老师的课程是每周二、五更新,一般情况下我都会在当天先通读一下老师的文章 ,特别好的文章会通读好几遍 ,周末的时候会再拿出来细看 。 平时上下班路上的细碎时间,也会抽空看一次 、听一次来巩固学习。

具体算下来,整个学习的过程,基本上也和别的同学分享的类似,基本可以读懂的文章,学习两到三次;不是很精通的,云里雾里的,会至少看到五六次;听一遍不够,再看一遍也不行,那就再多读几遍,查看留言,看看其他同学的留言内容,来检验自己是否理解了,问题是否完全解决了。

许老师专栏中提到的实战项目, 像JS和Golang 的项目 ,我都要自己仔细敲一遍 ,落实到代码上。因为如果只是浮光掠影的话 ,不会理解到精髓的。知识还是要下苦工夫才能消化。

另外,我基本一直坚持学。可能很多同学可以看到我在老师课程下坚持留言,因为极客时间的课程我购买了不少,有些跟了一段时间就暂时搁置了,所以我在跟许老师的架构课程之初,就定下了Flag:一定要在老师正式的课程下多留言打卡,虽然留言质量未必很高,但是至少有一种方式可以让我尽量坚持下去。

专栏中最有收获的文章是哪几篇?

介绍几篇我个人觉得很有收获的文章:

很多开发人员对众多系统背后是如何工作的,原理一知半解 ,知其然不知其所以然,成为架构师就是成长为造房子的建筑工程师,需要宏观的全局掌握能力。

为什么要做需求分析呢?一是为了满足用户需求,二是满足边界的需要 ,三是架构设计的需要,防止过度设计, 把简单的事情复杂化。

如果只是被动接受产品需求,以按图索骥的方式做架构师,是不足以成为顶级架构师的,用户需求的深层理解是很难传递的。

产品设计过程需要架构师的深度参与,而不是单向的信息传递。产品是桥,一端连接用户需求 ,一端连接了先进的技术。

产品经理和架构师其实是一体两面,都需要关心用户需求和产品定义。架构师需要三分之一的精力(我目前感觉可能会更多一些)在需求分析上。

许老师介绍的这些点,都很有价值:

“心态第一,装着用户,刨根究底,找到根源需求,理清需求,对需求进行归纳整理。需求分析,用户的需求反馈到架构师那里,需要对功能进行拆解,对于部分超前的需求,分期进行实现。”

老师对 MVC 的讲解,当时看完了之后感觉到醍醐灌顶,看到下面留言说 ,这一篇文章就值回“票价”,我也是同样的感受。

后面的我就不一一举例了。

有哪些好的学习方法?

学习的方法无非就是坚持,坚持,坚持 !夯实基础,夯实基础,夯实基础!

去年的时候看到一位深度学习的大佬 Lan Goodfellow 的访谈, 说他人工智能本科阶段和博士早期阶段曾经多次咨询吴恩达老师寻求建议。 吴恩达建议他彻底精通这些基础知识和技能 (编程、调试、线性代数、概率论等等), 特别是基础数学。

以前的他特别不理解这个建议,觉得这些实在是很无聊 ,他以为吴恩达能建议他去学习超实数或者类似的东西 ,实践几年后,他发现老师当时给的建议实在是太正确了。

学习没什么捷径,不存在什么一蹴而就的高超技术,要真的能稳得住啃那些基础,能沉下心坚持再坚持。

夯实基础,坚持下去,就几个字,做到不易,愿你我共勉。每个开发者都希望能够有银弹,架构设计的其中一种是基础架构,涉及操作系统、分布式系统、嵌入式系统、数据库、计算机网络等,这些都需要扎实的基础作为后盾。研发底层基础设施,这个是自己近期的目标。

有很多人说 ,第一份工作带给你的能力,眼界,和圈子是非常重要的,一个人的能力决定他的上限,圈子决定了他的下限。

我对毕业之后的第一份工作没有足够的重视 ,发展不佳,但是非常庆幸参加了许老师的课程, “受益终生”四个字不足以完全表达我的谢意。 希望老师的公司发展蒸蒸日上, 也祝一起在极客时间学习的我们在努力坚持之后会心想事成 !

平时对外输出文字的机会不多 ,些许胡言,希望能够给大家抛砖引玉,谢谢大家。

答疑解惑 | 想当架构师,我需要成为“全才”吗?

作者: 极客时间编辑部

完成时间:

总结时间:

你好,这里是极客时间编辑部。

不知不觉,“许式伟的架构课”专栏已经更新了3个多月,我们的后台收到了同学们数以千计的留言。许式伟老师每天都在实时关注着留言并回答同学们的问题,同时根据同学们的留言不断优化调整课程的设置。

老师和同学在留言区的互动也十分热闹精彩,今天我们就精选出一批留言,一起来看一看。

精选问答

1.老师好,人精力有限,如果什么都懂,那不是不精了?通才还能做架构师吗?还是“一专多能”,先“专”,精通一样;再“多能”,了解其它技术?

答:挺好的问题。架构师绝对不是要把自己打造为全才。架构师掌控全局的核心思想是打通经络,让自己的内力在全身自然流通,浑然一体。在不影响理解的情况下,你需要放弃很多实现细节的专研,但有一天你需要细节的时候,你能够知道存在这些细节,并且快速钻研进去。

2.许老师,自己现在已经工作快三年了,想往架构师这个方向走,但现在自己有些迷茫,接触到的技术也算挺多了,但不知道该如何入手架构师,之前您也提到过先广度然后深度,但我想问达到什么算广度够了,怎么进行深度学习?

答:架构师核心是把知识串起来,构建一个完整的认知,不留疑惑。大部分知识是不需要深入细节的,只在你需要的时候深入,但深入的时候要很深。

3.如何来确定需求中哪些是稳定的?对架构角度,关注需求到什么层次?

答:挺好的问题。需求分析的重要性怎么形容都不过分。准确的需求分析是做出良好架构设计的基础。很多优秀的架构师之所以换到一个新领域一上来并不一定能够设计出好的架构,往往需要经过几次迭代才趋于稳定,原因在于新领域的需求理解需要一个过程。除了心里对需求的反复推敲的严谨态度外,对客户反馈的尊重之心也至关重要。

4.老师好,我有三个问题。

  • 现在运维开发基本上都用Go,Python慢慢变少了,Java也少用了……现在运维开发是要学Go吗?
  • 架构师要学数据结构和算法吗?很多都说算法是“内功”,中小公司好像学了都基本用不到。
  • 现在不是流行Docker+k8s、微服务、DevOps、AI等,那些主流技术都要了解吗?OpenStack云计算这两年基本不讲了,是否不用学习?

答:关于你的三个问题,我的意见是下面这样的。

  • 学Go挺好,建议学,生产效率很高的开发工具。
  • “算法用不到”其实更准确的说法是“想不到”,或者是已经有人实现了你只需要调用,不需要自己实现。但是只有你知道了背后的道理,你才能明白算法对应的限制在哪里,什么情况下应该用什么算法。
  • 高阶的技术可以按需学,按精力学,更根本的还是要打好基础,这也更有助于你判断是否应该深入学习某些技术。

5.“你可以发现,引入了输入输出设备的电脑,不再只能做狭义上的计算(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的计算问题变得无所不包。”

交互能力也看做一种计算能力吗?这句话应该怎么理解呢?这种交互不就是输入和输出?哪有计算?

答:广义的计算包含有副作用的函数(有IO的函数)。因为数据交换本身也是计算的需求,否则计算没有办法与现实世界相互作用。任何工具都需要解决现实问题才有用。计算器还有交互按钮呢,并不是只有纯正的计算。

6.我要做一个最小机器人系统,需要考虑需求的变化点和稳定点。该怎么考虑呢?

答:挺典型的问题。这个问法是一种典型的需求陈述误区。

描述需求需要有几个典型的要素:

  • 用户,面向什么人群;
  • 他们有什么要解决的问题;
  • 我解决这个问题的核心系统。

只有满足这几个要素的需求才能进一步讨论变化点和稳定点。最小机器人可能符合上面的第三点,但是用户人群和要解决的问题没有描述,也就无法进一步去思考到底哪些因素是稳定的,哪些是易变的。

7. 编程框架和编程范式具体有什么区别呢?感觉它们都具备约束、规范的作用。

答:最主要的差别是:编程框架通常是领域性的,比如面向消息编程是多核背景下的网络服务器编程框架;编程范式则是普适性的,不管解决什么领域的问题都可以适用。

8.老师提到了如果需要重修数据结构这门课程,大学里面学的数据结构是不顶用了。那应该学习什么呢,您可以给个建议吗?

答:这方面的资料不太多。可以给你一个我当年翻过的资料: Purely Functional Data Structures

你可以参考看看。

9.老师您好,我不太理解您说的继承是个过度设计的原因,我目前在架构过程中大量使用了继承,而且我也觉得继承功能将我的代码功能高度抽象化,给我带来了很大的方便。我想咨询下您如果不是用继承的话,用什么方法替代继承的功能呢?

答:建议继承只使用接口继承;正常情况下,优先用组合;当然因为大部分语言的组合功能不够强大,有时候从便捷性的角度继承可以适度使用,但是应当意识到如果过度使用继承对工程来说是有害的。

10.老师授课知识的角度很有深度,更贴切地说是一种思维方式,这种深度思考,从事情的本质重新推演与复盘的思考方式是很值得学习的。因为我们大部分人应该都没有想过自己去重新设计一个计算机的实现。不知道我这么理解的对不对。

答:从无到有到万物,我们这个课的脉络之一就是重新从零构建整个信息世界,这一点在开篇词中提到过,这一点非常非常关键。另一个脉络是架构思维的递进,这一章重点是需求分析。这两个脉络相辅相成,交织在一起。

11.许老师,您好,虽然自己是科班出身,但是对于下面这个问题困扰了我很久。

  • 一般来说程序的运行需要OS的支撑,那么在BIOS之前,选择运行哪个操作系统那一段程序是怎么运行的?再问一句,编译器可以独立于操作系统运行吗?
  • 可以用C语言去实现很多其他的语言,比如说Python、Go等,那在C语言之前,这么一直追寻下去,会衍生成鸡生蛋的问题。但是编程语言又具有自举的功能,那自举是怎么实现的?比如目前版本的Go核心实现中,很多是用Go本身实现的,它是怎么做到自己编译自己的?

答:先回答你的第一个问题,程序运行不需要操作系统支持,有BIOS支持就可以(把控制权交给它)。编译器可以独立于操作系统存在,而且它应该先于操作系统产生。

接下来是第二个问题,语言诞生的过程是这样的:机器码 =>汇编 =>C =>C写的汇编、C写的C(自举)。当然这个过程不需要每次新架构的CPU或操作系统都重新来一遍,因为人是聪明的,发明了交叉编译这样的东西,C =>新平台的C,这样就一下子完成整个语言的进化了。

12.许老师:选择某种语言无关的接口表示;能举个例吗?

答:先看看是网络协议层的接口,还是跨语言的二进制接口。

前者比如protobuf之类就挺好,后者可以了解一下IDL之类的东西,不过我觉得都有点重。如果要跨语言,我的建议在网络协议层跨,或者用操作系统的动态库机制(有点原始但很轻);如果语言内的接口,就别太复杂了,用语言自己的机制挺好的。

13.请问每个应用的虚拟内存地址是怎么分配的?起始地址都是0吗?函数F可以跨多个虚拟内存页吗?

答:操作系统会保留一个地址空间,0通常也在保留区间内,因为0开始往往是中断向量表的地址,其他的地址区间怎么分配其实应用自己说了算。函数和数据都可以跨内存页。

14.老师您好,有两个问题希望解答。

  • 淘汰的内存页数据保存在哪里;是保存在外置存储设备中吗;
  • CPU加载对应程序的代码段到内存中,那么CPU是如何知道这个对应程序的代码段在什么位置的呢?

答:第一个问题:是的,保存在外置存储中。对于unix系的系统往往是swap分区;windows则是一个隐藏属性的.swp文件。

第二个问题:代码段在哪里,是操作系统约定的,因为负责加载的人是操作系统,它设计程序文件的数据格式。

15.使用Java四年了,看到封装,继承,多态的描述,特别精准,又有了更深刻的理解。不了解Go语言,比如有一个表单的基类,里面有基本的处理,子类继承这个基类,有自己特殊的实现。这种情况,如何用组合实现呢?

答:这是受继承思维的影响了。其实继承实现了代码复用和多态两个东西,揉在一起。在Go里面,组合实现代码复用,接口实现多态,彼此完全独立,非常清晰。

16.请问一下CPU是如何检查是否有中断的。是怎么及时知道发生了中断?每执行完一条指令都去检查一次吗?

答:挺好的问题。硬件中断和软中断不一样。硬件中断你可以理解为总是会定期检查。软中断本身是一条指令,所以不存在检查这样的概念。

17.交叉编译是什么意思,不是很理解,老师能讲讲吗?

答:其实理解清楚一个实质:编译器就是把高级语言翻译成为机器码,更抽象说,它其实就是格式转换器。

目标格式是不是编译器正在运行的环境并不重要,只不过如果目标格式刚好是当前机器的CPU+操作系统,那么目标格式就可以直接执行,否则就编译出一个当前环境下无法执行的目标格式,这种情况就叫交叉编译。

18.关于外存管理,有个问题从之前就困扰我。

磁盘的IO是由CPU完成的吗?但之前见到的说法是“CPU只能操作内存”。既然今天又提到了这个问题,文中提到“大量的磁盘 IO 操作,非常占用 CPU 时间”,那这两种说法是否矛盾?

还想知道磁盘中的数据是怎么被加载到内存上来的呢?另外,更多的文章是说,“CPU的速度远远大于磁盘IO,CPU经常需要‘等待’磁盘IO”,这明显也是一种将CPU和外存割舍开的一种说法,而且按这种说法,CPU不光无需分配很多时间片给IO,而且还有很多“等待”时间。这也和本文中“非常占用CPU时间”相矛盾吧?

答:所有外设CPU都统一基于数据交换(IO)的方式操作。CPU并不知道数据的含义,但是设备的使用方和设备知道。

这种情况下你可以简单理解CPU只是一根网线,但是很重要的一点是它让设备使用方和设备可以交互。CPU并不负责磁盘IO,但是它要等它结束以接收数据。这方面当然也有一些新技术出现改善这一点,可以想一想可能的优化路径,这里不表。

19.有一个疑问:协程属于用户态的线程,它跟线程之间怎么对应呢?协程之间也需要切换,那线程切换的那些成本它一样有啊,没想明白它的优势在哪。

答:从单位时间成本来说,有一定优势但也不会特别大。主要少掉的代价是从用户态到内核态再回到用户态的成本。

这种差异类似于系统调用和普通函数调用的差异。因为高性能服务器上io次数实在太多了,所以单位成本上能够少一点,积累起来也是很惊人的。

20.这种对需求的前瞻性探索挺重要,但同时感觉也是最难的,应该如何培养呢

答:很多时候是思维方式的转变。首先要尝试去做前瞻,预测错了并不可怕,但可以事后复盘到底是缺失了什么重要的信息让你判断出现了什么偏差。

21.隐隐感觉到架构的主要难点在于对需求的前瞻性判断,这要求的不仅仅是技术能力。目前几乎所有的架构课程,都是基于确定的需求来讲技术架构,例如秒杀系统怎么做高可用高并发。不知道我这么理解对不对。

答:架构在于创造,如果你从事的事情总是重复别人,那这个公司又有何价值?即使有所参考,也应该有自己的精气神,这个精气神是需要架构师把它干出来的。

精选学习留言

恭喜@有铭和@Enthusiasm 两位同学,你们的留言被选为精选留言,极客时间将送出价值99元的专栏阅码一份。1个工作日之内,工作人员会与你取得联系。

@有铭 同学留言

对象范式的原始概念其实根本不包括类和继承,只有1.程序由对象组成,2.对象之间互相发送消息,协作完成任务。

最初世界上第一个面向对象语言是 Simula-67,第二个面向对象语言是 Smalltalk-71。

Smalltalk 受到了 Simula-67 的启发,基本出发点相同,但是最大的不同是Smalltalk是通过发消息来实现对象方法调用,而Simula是直接调用目标对象的方法。

Bjarne Stroustrup 在博士期间深入研究过 Simula,非常欣赏其思想,C++的面向对象思路直接受其影响,因为调用目标对象的方法来“传递消息”需要事先知道这个对象有哪些方法,因此,定义对象本身有哪些方法的“类”和“继承”的概念,一下超越了对象本身,而对象只不过是类这个模子里造出来的东西,反而不重要。

随着C++的大行其道,继承和封装变成了面向对象世界的核心概念,OOP 至此被扭曲为 COP ( Class Oriented Programming,面向类程序设计)。

但是COP这套概念本身是有缺陷的:每个程序员似乎都要先成为领域专家,然后成为领域分类学专家,然后构造一个完整的继承树,然后才能 new 出对象,让程序跑起来。

到了 1990 年代中期,问题已经十分明显。UML 中有一个对象活动图,其描述的就是运行时对象之间相互传递消息的模型。1994 年 Robert C. Martin 在《 Object-Oriented C++ Design Using Booch Method 》中,曾建议面向对象设计从对象活动图入手,而不是从类图入手。

而 1995 年出版的经典作品《 Design Patterns 》中,建议优先考虑组合而不是继承,这也是尽人皆知的事情。

这些迹象表明,在那个时候,面向对象社区里的思想领袖们,已经意识到“面向类的设计”并不好用。只可惜他们的革命精神还不够,Delphi 之父在创建.Net Framework 的时候,曾经不想要继承,在微软内部引起了很大的争议,最后是向市场低头,加上了继承。

2000 年后,工程界明确提出:“组合比继承重要,而且更灵活”,Go和Rust也许是第一批明确的对这种思路进行回应的语言,它们的对象根本不需要类本身来参与,也能完成对象范式的多态组合。

历史让 C++走上了舞台,历史也终将让 COP 重新回到 OOP 的本来面目

@Enthusiasm 同学学习笔记

总结:设计系统架构的前提是用户需求分析,用户需求包括分析出稳定需求点和变化需求点。从功能上看,稳定需求点一般是实现偏核心需求的需求点,变化需求点往往是实现偏扩展性需求的需求点。

从层次结构上看,稳定需求点往往在系统层次的底层,而变化需求点往往在更加抽象层(上层)。从从属关系上看,稳定点需要提供功能给变化点使用,变化点调用稳定点提供的功能。从时间顺序看,稳定需求往往先现是变化点实现的基础,变化点通过调用已经实现的稳定点提供的功能来实现更为抽象的功能。

系统架构类似于一个栈的结构,人机交互(变化点)放在栈顶,底层工作(稳定点)置于栈底。

这节课程让我联想到网络中的OSI 7层模型。大概其也体现了这种软件架构思想。好处就是架构清晰,职责明确,功能规范等等。

以往我认为的架构设计类似上面的描述,描述起来类似按自顶向下顺序,采用分治思想完成。但许老师的方法又有些巧:架构好比搭积木,许老师是先有了很多积木(需求点),然后把再确定这些积木放在哪一层次的格子里。这简化了架构设计的难度,好比用市场经济代替计划经济,很有趣。

架构设计博大精深,灵活多变,初学课程的我们,对架构设计的学习,也只能算是盲人摸象。


如果你在课程中有看不懂的地方,有想解答的架构问题,或者想分享的实战经验,都可以在文章下留言,如果你的留言被选中作为精选留言,我们将会为你送出价值99元的阅码一份。欢迎留言,与许式伟老师一起交流讨论,教学相长,共同精进。

课外阅读 | 从《孙子兵法》看底层的自然法则

作者: 许式伟

完成时间:

总结时间:

你好,我是七牛云许式伟。

近日在读《孙子兵法》,颇有感触。作为我国现存第一部,也是世界最早的军事著作。《孙子兵法》一直为众多政治家、军事家及企业管理者膜拜,被当作军事外交、企业管理的圣典。

时至今日,新经济、科技和社会结构下,很多人认为我们身处一种复杂社会,需要全新的军事、商业和公共策略。然而,不管远古农耕刀兵还是高科技信息社会竞争,时代、人文环境和科学技术的变迁,并不能改变人类社会生存竞争所依赖的基础系统环境和人性思维的规则。

英国空军元帅斯莱瑟曾说过:“孙武的思想有惊人之处——把一些词句稍加变换,他的箴言就像是昨天刚写出来的。”

《孙子兵法》讲的是战争,骨子里包含的却是人类社会生存竞争的底层规则。案例会变迁,背后的道理却很难过时。我们作为架构师,需要做到对世界的认知可宏观、可微观。从对宏观的理解来说,背后所依赖的正是我们对这些底层自然法则的体悟。

明道

《孙子兵法》讲的是规则。它通过战争,结合环境和人性探讨竞争中生存的规则;然后,在确定目标下,它又将战争行为分解成为一系列细分的规则。

《始计篇》开端,全书先讲总规则,就是“道”。“道者,令民与上同意也,故可以与之死,可以与之生,而不畏危。”如同国家有纲领,企业必须明确愿景,使命或是价值观一样。明心方能正道,明道方知力之所指。事实上,明白道之所求,术方能有指引,追随者才能有方向、有激情。对国家、企业而言“道”不仅是方向,还是战斗力和吸附力的来源,明道和传播非常重要。

“慎战”。“兵者,国之大事,生死之地,存亡之道,不可不察也。”战争和企业竞争涉及系统存亡关系重大,是管理者每日都要考虑的事情。“自古知兵非好战。”这种行为不是个人名利或得失,而是关系到万户千家,或者全体员工和家庭,必须慎之又慎,不可凭个人好恶,更不可好大喜功和拍脑袋。“上兵伐谋”、“不战而屈人之兵”。要牢记初心,保存敬畏和责任感,求胜同时要避免己方生命和资源的巨大消耗。“善战者,无智名,无勇功。”正如“善弈者通盘无妙手”,或是扁鹊治病于未发,“立于不败之地”非常之关键。

“避害第一”,先考虑失败的后果;“先胜后战”,不打无把握的战争。“因利而战”,考虑成本和结果;“一战而终”,准备要充分,动手要迅捷,毕其功于一役。

“兵无常势,水无常形。”环境、资源条件,心态、优劣势这些都可能随时变化,不能教条主义,须将策略灵活应用于变化的实际。满口教义,如赵括那般纸上谈兵,一棒子打死算了。“君将士卒皆有其道”。公司内也应各司其职,人人满口战略、创新和文化,“所有人都举着旗帜,他们用哪只手来战斗?”这样的公司很危险。

庙算

“先胜后战”,就要“未战先算”。战与棋,一样考的算力,所谓政治经济环境人心都要纳入数据输入。

庙算,非治一战或局部。而是知己知彼综合所获数据,依据规则全面分析,很像是系统竞争成败的“大案牍数”。

庙算策略,不是单一事件的静态考量,而是复杂动态的综合考量。五事,道、天、地、将、法;七计,主、将、天地、法令、兵众、士卒、赏罚都要纳入进来。知己还要知彼。内外部环境,天时地形人才人心钱粮规章,所有影响战争胜负的东西,一切可以数据化,能算计的全都计算进去。

依据庙算可确定“势”,依据“势”决断是否有利并采取相应行动。拥有这样的洞察和决断力,知道了什么时候该打什么时候不该打,真正战争行为才会很简单。战争中要“致人而不致于人”。曾说“朕观诸兵书,无出孙武”的李世民,还有林彪都非常擅长集中优势兵力,通过忍耐和调动对手,“多方以误”,引敌人失误而获取最终胜利。

恰如“木桶理论”,庙算还可以消灭己方短板,寻找或者调动对方出现短板。当然,《孙子兵》并不是每战必胜,也不是以弱胜强,化不可能为可能的魔法书。不宜“知其不可而为之”。一系列庙算都在规则下,寻找到可能性,获得相对优势来战胜对手。如集中优势兵力,实现局部优势。“以迂为直,以患为利。”利用天时地形和行军,训练扎营做饭都有明确的目标。速战还是持久战,都是依据各种条件资源推演分析的结果。“故知战之地,知战之日,则可千里而会战。”唯有算胜,方可一战。

庙算为术,战术不能超脱于战略。“将在外君命有所不受”只是指根据实际情况可以灵活战术,正如当年毛主席要求林彪东北战役要服从大局,大战略不容颠覆。不能因一人之利,一地之利,一时之利而改变,只能随整个系统变化而调整。

感知

“不可胜在己”,立于不败之地,自己是关键,其他都是辅因;“可胜在敌”,看敌人的软肋和什么时候失误。“内省。知彼,但先要知己。”曾国藩作战,也首先讲明己第一,稳扎稳打立于不败之地,克敌才能实现。胜利也并非都是好事,很多时候当不是自己太强,而是对手太弱或其他因素,造就的胜利只会麻痹自己和埋下根本性失败的伏笔。

“知己知彼”就是自我和对手的感知。“天时地利人和”是对环境的感知。战争错综复杂风云变化,很多时候考较的都是随机应变的感知力和应对能力。“用兵者,合于利而动,不合于利而止。”对是否进行战争的条件的感知。还有对于细节的感知,“汲而先饮者,渴也。”需要通过经验学习感知获取信息,将规则载入情景化和有效利用。感知,可用于内部观察和防患未然,也可以用于判断对手并拟定对策。

“攻其无备,出其不意。”需要重视情报和传播。“兵者,诡道也。”情报和传播,等于数据的输入和输出,很多时候决定战争或组织的成败。依据情报奇兵制胜,如邓艾灭蜀;与对手虚假的情报,像蒋干盗书。更典型的是反间计。李牧、范曾罢黜,赵国、项羽败亡。

法度

“五事七计”都提到法令和奖惩。楚汉之争刘邦胜,“约法三章”以及陈平的有效奖惩体系都为功不小,胜在乎法令执行和奖惩公平。另一方面,用兵能“破釜沉舟”的项羽,然“印刓敝,忍不能予”是其失败的重要原因。

“五德,智信仁勇乎。”五德相须,缺一不可。但强调“智”,将人才放第一。而法度是规则,人才必须在法度内。

“主孰有道,将孰有能。”

“将之至任,不可不察也。”

“将听吾计,用之必胜,留之。将不听吾计,用之必败,去之。”

“令行素。”平时一样重视,战时才能发挥作用。

对于复杂系统,内部崩溃往往是其失败的主要原因。当然如前所述,这种内因也可由对手或外因推动演化。对于企业来说,领导者素质和人才队伍,中坚力量的支撑很重要,而成为中坚的标准就是一致的价值观和遵循规范。另外,还要建立适应自身、对手和战争形态的管理架构和组织体系,以及有效的规章和奖惩体系。

辨证

“以正合,以奇胜。”《孙子兵法》可能是辨证思维最早期的著作和运用。

《孙子兵法》里,强弱虚实迂直恩仇甚至胜败都是辩证的,是可以对立转化的。

事实上,无论战争或是组织发展,都不是直线的,而是曲线的,或者周期的,充满了变化。胜负也是辩证和变化的,便是拥有《孙子兵法》,吴国后期数战数胜,开始骄傲自负,百姓疲敝,胜负环境逆转,终致败亡。“水因地而制流,兵因敌而制胜”。所有策略必须依据时机、环境、对手、人心变化而变化。

兵法不可能写尽所有情况,任何行为和结果也不是简单对应,而是因果交错。战争和系统竞争要处理的是复杂数据。《孙子兵法》注定“学者生,像者死”,必须根据特定时空、自己和对手的具体情况灵活化用。

《孙子兵法》重规则轻案例,舍事而言理,因而得以破越时空和领域,成为竞争、管理和生存的基础规则。

《大败局》一书中,吴晓波将中国大多数企业失败归结为缺乏道德感和人文关怀,缺乏对规则和秩序的尊重以及系统的职业精神的缺乏,这些因果在《孙子兵法》中都能够找得到对应;相对的,所有基业长青的公司,又都具备一系列适应变化,可以持续指导成长的一般性规则,这些规则在《孙子兵法》中也都能够找到对应。

作为一种超乎教材而更类似于宗教的力量,《孙子兵法》的价值在于其提供了实用性同时引导了对规则的思考。跨域2500年时空岁月,它的思想精髓仍如星辰闪耀。而且随着数据、时代演进、新元素的输入还会不断推动其演进和发展,即便再过100年,1000年,只要人类、系统和竞争存在,它就永不会过时。