This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
<audio id="audio" title="34 | 服务端开发的宏观视角" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/de/b22d56c2e59ed1dcfcf606902fb9e0de.mp3"></audio>
你好,我是七牛云许式伟。
今天开始,我们进入第三章,谈谈服务端开发。
## 服务端的发展史
服务端开发这个分工,出现的历史极短。短得让人难以想象。
1946 年第一台电子计算机问世。1954 年,第一门高级语言 Fortran 发布。整个信息科技发展到今天,大约也就 60~70 年的历史。
1974 年Internet 诞生。1989 年万维网WWW诞生但刚开始只限于政府和学术研究用途1993 年才开始进入民用市场。
从这个角度来说,服务端开发这个分工,从互联网诞生算起也就 40 多年的历史。真正活跃的时段,其实只有 20 多年。
但其发展速度是非常惊人的。我们简单罗列下这些年来的标志性事件。
- 1971 年,电子邮件诞生。
- 1974 年Internet 诞生。
- 1974 年,第一个数据库系统 IBM System R 诞生。SQL 语言诞生。
- 1989 年万维网WWW诞生。
- 1993 年,世界上第一个 Web 服务器 NCSA HTTPd 诞生,它也是大名鼎鼎的 Apache 开源 Web 服务器的前身。
- 1998 年Akamai 诞生提供内容分发网络CDN服务。这应该算全球第一个企业云服务虽然当时还没有云计算这样的概念。
- 2006 年Amazon 发布弹性计算云Elastic Compute Cloud简称 EC2。这被看作云计算诞生的标志性事件。
- 2007 年Amazon 发布简单存储服务Simple Storage Service简称 S3。这是全球第一个对象存储服务。
- 2008 年Google 发布 GAEGoogle App Engine
- 2009 年Go 语言诞生。Derek Collison 曾预言 Go 语言将制霸云计算领域。
- 2011 年,七牛云诞生,发布了 “对象存储+CDN+多媒体处理” 融合的 PaaS 型云存储,为企业提供一站式的图片、音视频等多媒体内容的托管服务。
- 2013 年Docker 诞生。
- 2013 年CoreOS 诞生。这是第一个专门面向服务端的操作系统。
- 2014 年Kubernetes 诞生。当前被认为是数据中心操作系统DCOS的事实标准。
通过回顾服务端的发展历史,我们可以发现,它和桌面开发技术迭代的背后驱动力是完全不同的。
桌面开发技术的迭代是交互的迭代是人机交互的革命。而服务端开发技术的迭代虽然一开始沿用了桌面操作系统的整套体系框架但它正逐步和桌面操作系统分道而行转向数据中心操作系统DCOS之路。
## 服务端程序的需求
这些演进趋势的根源是什么?
**其一是规模。**
桌面程序是为单个用户服务的,所以它关注点是用户交互体验的不断升级。
服务端程序是被所有用户所共享,为所有用户服务的。一台物理的机器资源总归是有限的,能够服务的用户数必然存在上限,所以一个服务端程序在用户规模到达一定程度后,需要分布式化,跑在多台机器上以服务用户。
**其二是连续服务时长。**
桌面程序是为单个用户服务的,用户在单个桌面程序的连续使用时长通常不会太长。
但是服务端程序不同,它通常都是 7x24 小时不间断服务的。当用户规模达到一定基数后,每一秒都会有用户在使用它,不存在关闭程序这样的概念。
**其三是质量要求。**
每个桌面程序的实例都是为单个用户服务的,有一亿的用户就有一亿个桌面程序的实例。
但是服务端程序不同,不可能有一亿个用户就跑一亿个,每个用户单独用一个,而是很多用户共享使用一个程序实例。
这意味着两者对程序运行崩溃的容忍度不同。
一个桌面程序实例运行崩溃,它只影响一个用户。
但一个服务端程序实例崩溃,可能影响几十万甚至几百万的用户。
这是不可接受的。
一个服务端程序的实例可以崩溃,但是它的工作必须立刻转交给其他的实例重新做,否则损失太大了。
所以服务端程序必须能够实现用户的自动转移。一个实例崩溃了,或者因为需要功能升级而重启了,它正在服务的用户需要转给其他实例来服务。
所以,服务端程序必须是多实例的。单个程序实例的临时不可用状态,要做到用户无感知。
从用户视角看,服务端程序 7x24 小时持续服务,任何时刻都不应该崩溃。就如同水电煤一样。
## 服务端开发的体系架构
在 “[01 | 架构设计的宏观视角](https://time.geekbang.org/column/article/90170)” 这一讲中,我们将一个服务端程序完整的体系架构归纳如下:
<img src="https://static001.geekbang.org/resource/image/55/37/5553453858eb86bf88a5623255f20037.png" alt="">
这个架构体系,是为了方便你和桌面开发的体系架构建立自然的对应关系而画的。
它当然是对的,但它只是从服务端程序的单个实例看的,不是服务端程序体系架构的全部。
在 “[15 | 可编程的互联网世界](https://time.geekbang.org/column/article/99184)” 这一讲中,我们把 TCP/IP 层比作网络的操作系统,一个网络程序的体系架构如下:
<img src="https://static001.geekbang.org/resource/image/27/35/272a1a5319c226fc6472bb4f5f256c35.png" alt="">
一个服务端程序当然也是一个网络程序,它符合网络程序的体系架构。
但它也不是服务端程序体系架构的全部。
从宏观视角看,一个服务端程序应该首先是一个多实例的分布式程序。其宏观体系架构示意如下:
<img src="https://static001.geekbang.org/resource/image/89/82/895dbf7e39fb562215e0176ca4aad382.png" alt="">
相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:
- 负载均衡Load Balance
- 数据库或其他形式的存储DB/Storage
为什么会需要负载均衡Load Balance为什么会需要数据库或其他形式的存储你可以留言探讨一下。我们在接下来的几讲将聊聊负载均衡和存储。
## 结语
今天我们从服务端的发展历程、服务端开发的需求谈起,以此方便你理解服务端开发的生态会怎么演化,技术迭代会走向何方。
我们这里探讨的需求和具体业务无关它属于服务端本身的领域特征。就像桌面的领域特征是强交互以事件为输入GDI 为输出一样,服务端的领域特征是大规模的用户请求,以及 24 小时不间断的服务。
这些领域特征直接导致了服务端开发的体系架构和桌面必然是如此的不同。
如果你对今天的内容有什么思考与解读欢迎给我留言我们一起讨论。下一讲我们将聊聊负载均衡Load Balance
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="35 | 流量调度与负载均衡" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/24/33883dbeaf77a968ffe91a3399b50924.mp3"></audio>
你好,我是七牛云许式伟。
相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:
- 负载均衡Load Balance
- 数据库或其他形式的存储DB/Storage
为什么会需要负载均衡Load Balance今天我们就聊一下有关于流量调度与负载均衡的那些事情。
上一讲我们画了服务端程序的体系架构图,如下:
<img src="https://static001.geekbang.org/resource/image/89/82/895dbf7e39fb562215e0176ca4aad382.png" alt="">
什么是 “流量调度”?我们首先要了解这样几个常见的服务端程序运行实例(进程)相关的概念:
- 连接数;
- IOPS
- 流量,入向流量和出向流量。
我们知道一个基本的服务端程序的服务请求通常是由一个请求包Request和一个应答包Response构成。这样一问一答就是一次完整的服务。
连接数有时候也会被称为并发数指的是同时在服务中的请求数。也就是那些已经发送请求Request但是还没有收完应答Response的请求数量。
IOPS指的是平均每秒完成的请求一问一答的数量。它可以用来判断服务端程序的做事效率。
流量分入向流量和出向流量。入向流量可以这么估算:
- 平均每秒收到的请求包Request数量 `*` 请求包平均大小。
同样的,出向流量可以这么估算:
- 平均每秒返回的应答包Response数量 `*` 应答包平均大小。
不考虑存在无效的请求包也就是存在有问无答的情况但实际生产环境下肯定是有的的话那么平均每秒收到的请求包Request数量、平均每秒返回的应答包Response数量就是 IOPS。故此
- 入向流量 ≈ IOPS `*` 请求包平均大小
- 出向流量 ≈ IOPS `*` 应答包平均大小
所谓流量调度,就是把海量客户并发的请求包按特定策略分派到不同的服务端程序实例的过程。
有很多手段可以做流量调度。
## DNS 流量调度
最基础的方式,是通过 DNS如下图所示。
<img src="https://static001.geekbang.org/resource/image/79/cd/793c5e6b7a884e6816a60ebe2ee803cd.png" alt="">
一个域名通过 DNS 解析到多个 IP每个 IP 对应不同的服务端程序实例。这样就完成了流量调度。这里我们没有用到常规意义的负载均衡Load Balance软件但是我们的确完成了流量调度。
那么这种做法有什么不足?
**第一个问题,是升级不便。**
要想升级 IP1 对应的服务端程序实例,必须先把 IP1 从 DNS 解析中去除,等 IP1 这个实例没有流量了,然后我们升级该实例,最后把 IP1 加回 DNS 解析中。
看起来还好但是我们不要忘记DNS 解析是有层层缓冲的。我们把 IP1 从 DNS 解析中去除,就算我们写明 TTL 是 15 分钟,但是过了一天可能都还稀稀拉拉有一些用户请求被发送到 IP1 这个实例。
所以通过调整 DNS 解析来实现升级,有极大的不确定性,完成一个实例的升级周期特别长。
假如一个实例升级需要 1 天,我们总共有 10 个实例,那么就需要 10 天。这太夸张了。
**第二个问题,是流量调度不均衡。**
DNS 服务器是有能力做一定的流量均衡的。比如第一次域名解析返回 IP1 优先,第二次域名解析让 IP2 优先,以此类推,它可以根据域名解析来均衡地返回 IP 列表。
但是域名解析均衡,并不代表真正的流量均衡。
一方面,不是每次用户请求都会对应一次 DNS 解析客户端自己有缓存。另一方面DNS 解析本身也有层层缓存,到 DNS 服务器的比例已经很少了。
所以在这样情况下,按域名解析做流量调度均衡,是非常粗糙的,实际结果并不可控。
那么,怎么让流量调度能够做到真正均衡?
## 网络层负载均衡
第一种做法是在网络层IP 层)做负载均衡。
章文嵩博士发起的负载均衡软件 LVSLinux Virtual Server就工作在这一层。我们以 LVS 为代表介绍一下工作原理。
LVS 支持三种调度模式。
- VS/NAT通过网络地址转换NAT技术做调度。请求和响应都会经过调度器中转性能最差。
- VS/TUN把请求报文通过 IP 隧道转发至真实服务器,而真实服务器将响应直接返回给客户,所以调度器只处理请求报文。这种做法性能比 VS/NAT 好很多。
- VS/DR通过改写请求报文的MAC地址将请求发送到真实服务器真实服务器将响应直接返回给客户。这种做法相比 VS/TUN 少了 IP 隧道的开销,性能最好。
我们重点介绍下 VS/DR 技术。
<img src="https://static001.geekbang.org/resource/image/02/32/02d193a74158940f18a8562b771de732.png" alt="">
如上图所示。设客户端的 IP 和 MAC 为 CIP、CMAC。
第 1 步,客户端发起请求,其 IP 报文中,源 IP 为用户的 CIP ,目标 IP 是 VIP源 MAC 地址为 CMAC ,目标 MAC 地址为 DMAC。
第 2 步,请求包到达 LVS 调度器Director Server。我们保持源 IP 和目标 IP 不变,仅仅修改目标 MAC 地址为 RMAC将请求转发到真实的业务服务器实例 RSReal Server
第 3 步RS 收到数据包并经过处理,直接响应发送给客户端。
这里面的关键技巧,是 VIP 绑定在多台机器上,所以我们把它叫做虚拟 IPVirtual IP。它既绑定在 LVS 调度器Director Server也绑定在所有的业务服务器实例 RSReal Server上。
当然这里有一个很重要的细节是ARP 广播查询 VIP 对应的 MAC 地址得到什么?答案当然是 LVS 调度器Director Server。在真实的业务服务器实例 RSReal Server我们把 VIP 绑定在 lo 接口上,并对 ARP 请求作了抑制,这样就避免了 IP 冲突。
LVS 这种在网络层底层来做负载均衡,相比其他负载均衡技术来说,其特点是通用性强、性能优势高。
但它也有一些缺点。假如某个业务服务器实例 RS 挂掉,但 LVS 调度器Director Server还没有感知到在这个短周期内转发到该实例的请求都会失败。这样的失败只能依赖客户端重试来解决。
## 应用层负载均衡
有办法避免出现这种请求失败的情况吗?
可以。答案是:服务端重试。
怎么做服务端重试?应用层负载均衡。有时候我们也把它叫做应用网关。
HTTP 协议是应用最为广泛的应用层协议。当前应用网关,绝大多数都是 HTTP 应用网关。
Nginx 和 Apache 都是大家最为耳熟能详的 HTTP 应用网关。因为知道应用层协议的细节,所以 HTTP 应用网关的能力通常非常强大。这一点我们后面还会进一步进行探讨今天我们先聊负载均衡Load Balance相关的内容。
HTTP 网关收到一个 HTTP 请求Request根据一定调度算法把请求转发给后端真实的业务服务器实例 RSReal Server收到 RS 的应答Response再把它转发给客户端。
整个过程的逻辑非常简单,而且重试也非常好做。
在发现某个 RS 实例挂了后HTTP 网关可以将同一个 HTTP 请求Request重新发给其他 RS 实例。
当然一个重要的细节是为了能够支持重试HTTP 请求Request需要被保存起来。不保存 HTTP 请求做重试是有可能的,但是只能支持业务实例完全挂掉 HTTP 请求一个字节都没发过去的场景。但在断电或异常崩溃等情况,显然会有很多进行中的请求是不符合这个前提的,它们就没法做重试。
大部分 HTTP 请求不大,直接在内存中存储即可,保存代价不高。但是文件上传型的请求,由于请求包中包含文件内容,可能就需要依赖临时文件或其他手段来保存 HTTP 请求。
## 优雅升级
有了负载均衡,不只是可以实现了流量的均衡调度,连带业务服务器的升级也会方便多了。
对于前端是 LVS 这种网络层负载均衡的场景,升级的核心步骤为:
- 升级系统通知 LVS 调度器Director Server下线要升级的业务服务器Real Server实例。
- LVS 调度器Director Server将该实例从 RS 集合中去除,这样就不再调度新流量到它。
- 升级系统通知要升级的 RS 实例退出。
- 要升级的 RS 实例处理完所有处理中的请求,然后主动退出。
- 升级系统更新 RS 实例到新版本,并重启。
- 升级系统将 RS 实例重新加回 RS 集合参与调度。
对于前端是 HTTP 应用网关这种负载均衡的场景,升级的过程可以更加简单:
- 升级系统通知升级的业务服务器Real Server实例退出。
- 要升级的 RS 实例进入退出状态,这时新请求进来直接拒绝(返回一个特殊的 Status Code处理完所有处理中的请求后RS 实例主动退出。
- 升级系统更新 RS 实例到新版本,并重启。
可以看出,因 HTTP 应用网关支持重试,业务服务器的升级过程就变得简单很多。
## 结语
今天我们从流量调度谈起,聊了几种典型的调度手段和负载均衡的方式。
从流量调度角度来说,负载均衡的最大价值是让多个业务服务器的压力均衡。这里面隐含的一个前提是负载均衡软件的抗压能力往往比业务服务器强很多(为什么?欢迎留言讨论)。
这表现在:其一,负载均衡的实例数/业务服务器的实例数往往大大小于1其二DNS 的调度不均衡,所以负载均衡的不同实例的压力不均衡,有的实例可能压力很大。
当然,负载均衡的价值并不只是做流量的均衡调度,它也让我们的业务服务器优雅升级成为可能。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊存储中间件。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,175 @@
<audio id="audio" title="36 | 业务状态与存储中间件" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/4a/35f3749b17f6aa2ea683a8d8004d6f4a.mp3"></audio>
你好,我是七牛云许式伟。
相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:
- 负载均衡Load Balance
- 数据库或其他形式的存储DB/Storage
存储在服务端开发中是什么样的一个地位?今天我们就聊一下有关于存储中间件的那些事情。
<img src="https://static001.geekbang.org/resource/image/89/82/895dbf7e39fb562215e0176ca4aad382.png" alt="">
## 业务状态
让我们从头开始。
首先我们思考一个问题:桌面程序和服务端程序的相似之处在哪里,不同之处又在哪里?对于这样一个开放性的问题,我们不同人可能有非常不同的答案。
今天让我们从数据的视角来看这个问题。
我们知道,一个桌面程序基本上是由一系列的 “用户交互事件” 所驱动。你可以把它理解为一个状态机:假设在** i ** 时刻,该桌面程序的状态为**业务状态<sub>i</sub>** ,它收到**用户交互事件<sub>i</sub> <strong>后,状态变化为**业务状态<sub>i+1</sub></strong> 。这个过程示意如下:
>
**业务状态<sub>i+1</sub> = F( 用户交互事件<sub>i </sub>,业务状态<sub>i </sub>)**
用状态转换图表示如下:
<img src="https://static001.geekbang.org/resource/image/b7/cb/b78bf287f43735f81ad7ac30dcf7d1cb.png" alt="">
那么,服务端呢?
仔细考虑你会发现,其实服务端程序可以用一模一样的模型来看待。只不过它不是由 “用户交互事件” 来驱动,而是由 “网络API请求” 所驱动。
你同样可以把它理解为一个状态机:假设在** i ** 时刻,该服务端程序的状态为**业务状态<sub>i</sub>** ,它收到**网络API请求<sub>i </sub><strong>后,状态变化为**业务状态<sub>i+1 <sub></sub></sub></strong>。这个过程示意如下:
>
**业务状态<sub>i+1</sub> = F( 网络API请求<sub>i </sub>,业务状态<sub>i</sub> )**
用状态转换图表示如下:
<img src="https://static001.geekbang.org/resource/image/d4/6b/d4adc97bcf06721304ad0d6c30c99c6b.png" alt="">
那么,桌面程序和服务端程序的差别在哪?
它们最大的差别是业务状态的表示不同。
桌面程序的业务状态是如何表示的?内存中的数据结构。我们在上一章中提到,桌面程序的 Model 层是一棵 DOM 树,根结点通常叫 Document。这棵 DOM 树其实就是桌面程序的业务状态。
服务端程序的业务状态如何表示?用内存中的数据结构可以吗?
答案当然是不能。如果业务状态在内存中,服务端程序一挂,数据就丢了。
前面我们在 “[34 | 服务端开发的宏观视角](https://time.geekbang.org/column/article/120049)” 提到过:
>
服务端的领域特征是大规模的用户请求,以及 24 小时不间断的服务。
这句话是理解服务端体系架构的核心,至关重要。但某种意义上来说更重要的原则是:
>
坚决不能丢失用户的数据,即他认为已经完成的业务状态。
服务端对用户来说是个黑盒,既然用户收到某个 “网络API请求” 成功的反馈,那么他会认为这个成功是确认的。
所以,服务端必须保证其业务状态的可靠性。这与桌面程序不同,桌面程序往往需要明确的用户交互事件,比如 Ctrl+S 命令,来完成数据的存盘操作,这时业务状态才持久化写入到外存。而且对于大部分桌面程序来说,它并不需要支持持久化。
## 存储中间件与容灾级别
在没有存储中间件的情况下,服务端需要自己在响应完每一个网络 API 请求之后,对业务状态进行持久化。
听起来这好像不复杂?
其实不然,服务端程序的业务状态持久化难度,比桌面程序要高很多。还是同样的原因,桌面程序是单用户使用的,持久化的时候什么别的事情也不干,看起来用户体验也可以接受。
但是对服务端程序而言,如果我们在某个 API 请求完成并持久化的时候,其他 API 请求如果只能排队等着的话,往轻了说服务的吞吐能力太差了;往严重里说,在持久化执行的那个时段,服务端在用户眼里就停止服务了。所以持久化的时间必须要足够短,短到让人感知不到服务停顿。
服务端程序的业务状态并不简单。这是一个多租户的持久化状态。就算一个用户的业务状态数据只有 100K有个 100 万用户,那么需要持久化的数据也有 100G。这显然不能用“常规桌面程序每次完全重新生成一个新文件”的持久化思路做到它需要被设计为一种增量式的存储系统。
如果每一个做服务端程序的开发人员需要自己考虑如何持久化业务状态,这个代价显然过高了。
于是,存储中间件就应运而生了。
从历史上来看,第一个存储中间件是数据库,出现在 1974 年,它就是 IBM System R。
这一年 Internet 刚刚被发明出来。所以数据库的诞生背景,很可能是为工作站服务的,也算网络服务的范畴。
桌面程序很少用数据库。只有一些需要增量持久化业务状态的场景会被采用,比较典型的是微信。微信的本地聊天纪录应该是基于数据库存储的,只不过用的是嵌入式数据库,比如 SQLite。
最早期人们对存储中间件的容灾级别要求并不高。数据库都是单机版本,没有主从。人们对存储中间件的诉求是高性能的、稳定的、经过验证的。数据的可靠性如何保证?晚上选个服务的低峰时期对数据库做个离线备份就完事了。
对服务端开发来说,数据库的出现是革命性的,它大大提升了开发效率。
但在容灾级别这个事情上,随着互联网的普及,我们对它的要求越来越高。
首先,单机数据库是不够的,需要多机相互热备,这就是数据库主从结构的来由。这样我们就不需要担心数据库单机故障会导致服务临时不可访问,甚至出现更严重的数据丢失。
其次,单机数据库是不够的,单机存储量终归有上限,这样我们服务的用户数就有上限。在分布式数据库出现之前,人们的解决方案是手工的分库分表。总之,业务上我们需要做到规模可伸缩,不必担心单机物理存储容量的限制。
最后,单机房的可靠性也是不够的,机房可能会出现网络中断,极端情况下还可能因为自然灾害,比如地震,导致整个机房的数据丢失。于是就出现了“两地三中心”,跨机房容灾的数据灾备方案。
## 存储即数据结构
那么问题来了,数据库能够解决所有服务端程序的业务状态持久化需求吗?
答案当然是不能。
对比桌面程序我们能够知道,业务状态其实就是数据结构。虽然数据库这个数据结构的确通用性很强,但是它不是银弹,在很多场合下它并不适用。
存储即数据结构。
存储中间件是什么?存储中间件就是 “元数据结构”。
这个结论的逻辑在于下面几个方面。
首先,和桌面开发不同,桌面端的数据结构基本上都是基于内存的,实现难度较低。但是在服务端不同。我们每一次的业务状态改变都需要考虑持久化,所以服务端的核心数据结构都是基于外存的。
其次服务端的数据结构对稳定性要求、并发性能IOPS要求极高。简单分析就可以知道服务端程序的伸缩能力完全取决于存储的伸缩能力。
业务服务器往往是无状态的,压力大了新增加一台业务服务器非常容易。但是存储压力大了,并不能简单加一台机器了事,可能涉及数据的重新划分和搬迁工作。
这意味着,在服务端实现一个数据结构是非常困难的。我们举一个很简单的例子,在内存中我们实现一个 KV 存储非常容易,很多语言都有 Dictionary 或者 Map 这样的数据结构来做这事。就算不用库,我们自己花上几十分钟或一个小时来实现,也是非常轻松的一件事情。
但是,一个服务端的 KV 存储非常非常复杂,绝非一个人花上一天两天就可以干出来。就算干出来了,也没人敢立刻投入使用,需要经过非常庞大的测试案例进行方方面面的验证,才敢投入生产环境。并且,即使敢投入生产环境了,为了以策万全,刚开始往往也是采用“双写”的方式:同时使用一个成熟存储系统和我们新上线的存储。
存储系统的品控,至关重要。
正因为服务端的数据结构实现如此之难,所以对于服务端来说,所有业务需要涉及的数据结构都需要抽象出来,成为一个存储中间件。
存储中间件会有多少?
这与服务端开发的模型抽象有关。今天没有比较系统性的理论告诉大家,有了这样一些数据结构就完备了。但是从更长远发展的角度来看,我们很可能需要回答这个问题。
所以,存储中间件是 “元数据结构”。
这里说的 “元数据结构”,是我自己发明的一个词。它表达的含义是,数据结构的种类是非常有限的,并且最好理论可被证明,有了这样一些基本的数据结构,所有的业务需求都可以高效地实现。这些基本的数据结构,就是我说的 “元数据结构”。
今天我们接触的存储中间件有哪些?不完整的列表如下:
- 键值存储KV-Storage
- 对象存储Object Storage
- 数据库Database
- 消息队列MQ
- 倒排索引SearchEngine
- 等等。
目前看,存储中间件的种类是不可枚举的。但它很可能只是受限于我自己的认知,也许有一天我们能够在这个问题上找到更加完美的答案。
## 结语
今天我们从桌面端程序和服务端程序的业务状态开始,探讨了存储中间件的由来。
前面我们在 “[34 | 服务端开发的宏观视角](https://time.geekbang.org/column/article/120049)” 提到过:
>
服务端的领域特征是大规模的用户请求,以及 24 小时不间断的服务。
这句话是理解服务端体系架构的核心,至关重要。但某种意义上来说更重要的原则是:
>
坚决不能丢失用户的数据,即他认为已经完成的业务状态。
存储即数据结构。存储中间件就是 “元数据结构”。
对于服务端来说,存储中间件至关重要。它不只是极大地解放了生产效率,也是服务端的性能瓶颈所在。几乎所有服务端程序扛不住压力,往往都是因为存储没有扛住压力。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊数据库。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,134 @@
<audio id="audio" title="37 | 键值存储与数据库" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/64/4d708ce5797e68a97b0ac62a2cafaf64.mp3"></audio>
你好,我是七牛云许式伟。
上一讲我们介绍了存储中间件的由来。今天我们就聊一下应用最为广泛的存储中间件:数据库。
## 数据库的种类
从使用界面(接口)的角度来说,通常我们接触的数据库有以下这些。
使用最为广泛的是关系型数据库Relational Database以 MySQL、Oracle、SQLSever 为代表。
这类数据库把数据每个条目row的数据分成多个项目column如果某个项目比较复杂从数据结构角度来说是一个结构体那么就搞一个新的表table来存储它在主表只存储一个 ID 来引用。
这类数据库的特点是强 schema每个项目column有明确的数据类型。从业务状态的角度看可以把一个表table理解为一个结构体当遇到结构体里面套结构体那么就定义一个子表。
第二类是文档型数据库Document Database以 MongoDB 为代表。这类数据库把数据每个条目row称为文档document每个文档用 JSON或其他文档描述格式表示。
当前文档型数据库大部分是无 schema 的,也就是在插入文档时并不对文档的数据格式的有效性进行检查。
这有好有坏。好处是使用门槛低,升级数据格式方便。不好之处在于,质量保障体系弱化,数据可能被弄脏而不自知。可以预见的是,未来也会诞生强 schema 的文档型数据库。
第三类是键值存储KV Storage以 Cassandra 为代表。
键值存储从使用的角度来说,可以认为是数据库的特例。数据库往往是允许设定多个索引字段的,而键值存储明确只有唯一索引。
从实现角度来说,键值存储是数据库的基础。每一组数据库的索引,往往背后就是一组键值存储。
## 事务
无论是何种数据库,都面临一个重大选择:是否支持事务。这是一个艰难选择。从需求角度来说,事务功能非常强大,没道理不去支持。从实现角度来说,事务支持带来极大的负担,尤其是在分布式数据库的场景。
什么是事务?简单来说,事务就是把一系列数据库操作变成原子操作的能力。展开来说,事务的特性我们往往简称为 ACID详细如下。
- 原子性Atomicity在整个事务中的所有操作要么全部完成要么全部不做没有中间状态。对于事务在执行中发生错误所有的操作都会被回滚整个事务就像从没被执行过一样。
- 一致性Consistency事务的执行必须保证系统的一致性。这一点拿转账为例最容易理解。假设 A 有 500 元B 有 300 元,如果在一个事务里 A 成功转给 B 50元那么不管并行发生了其他什么事A 账户一定得是 450 元B 账户一定得是 350 元。
- 隔离性Isolation事务与事务之间不会互相影响一个事务的中间状态不会被其他事务感知。
- 持久性Durability一旦事务完成了那么事务对数据所做的变更就完全保存在了数据库中即使发生停电系统宕机也是如此。
如果我们忽略性能要求,事务是很好实现的,只需要用一把能够 Lock/Unlock 整个数据库的大锁就够了。
但这显然不现实,一把大锁下来,整个数据库就废了。从 IOPSIO 吞吐能力)角度来说,为什么分布式数据库很讨厌事务是很容易理解的:如果没有事务,一次数据库操作很容易根据数据的分区特征快速将操作落到某个分区实例,剩下来的事情就纯粹是一个单机数据库的操作了。
一种常见的事务实现方式是乐观锁。
什么是乐观锁?
常规的锁是先互斥,再修改数据。不管是不是发生了冲突,我们都会先做互斥。
但乐观锁不同,它是先计算出所有修改的数据,然后最后一步统一提交修改。提交时会进行冲突检查,如果没有冲突,也就是说,在我之前没有人提交过新版本,或者虽然有人提交过新版本,但是修改的数据和我所依赖的数据并不相关,那么提交会成功。否则就是发生了冲突,会放弃本次修改。
这意味着,每个数据有可能有多个值。如下:
- KEY<sub>i</sub> -&gt; [(VER<sub>0</sub>, VAL<sub>0</sub>), (VER<sub>1</sub>, VAL<sub>1</sub>), ...]
其中VER<sub>0</sub> 对应当前已经提交的值 VAL<sub>0</sub>VER<sub>1</sub> 对应事务<sub>1</sub> 中修改后的值 VAL<sub>1</sub>,以此类推。
除了修改后的值外,每个事务还需要记录自己读过哪些数据。不幸的是,它并不是记录读过的 KEY 列表那么简单,而是要记录所有的读条件。
例如,对于 SELECT name, age, address WHERE age`&gt;`17 这样一个查询,我们不是要记录读过哪些 name、age、address而是认为我们读过所有 age`&gt;`17 的条目row
在事务提交的时候锁住整个数据库前面修改过程事务间不冲突所以不需要锁数据库检查所有记录的读条件如果这些读条件对应的条目row的已提交版本都`&lt;=`基版本VER<sub>0</sub>),那么说明不冲突,于是提交该事务所有的修改并释放锁。
如果事务提交的时候发现和其他已提交事务冲突,则放弃该事务,对所有修改进行回滚(其实是删除该事务产生的版本修改记录)。
到这里我们就可以理解为什么要用乐观锁了:至少它让锁数据库的粒度降到最低,判断冲突的逻辑也都是可预期的行为,这就避免了出现死锁的可能。
我们很容易可以推理得知,在所有并行执行的事务中,必然有一个事务的提交会成功。这样就避免了饥饿(永远都没人可以成功)。
## 主从结构
一旦我们考虑数据库的业务可用性和数据持久性我们就需要考虑多副本存储数据。可用性Availability关注的是业务是否正常工作而持久性Durability关注的是数据是否会被异常丢失。
当我们数据存在多个副本时,就有数据一致性的问题。因为不同副本的数据可能值不一样,我们到底应该听谁的。
我们的服务同时存在很多并发的请求,这就可能存在客户端 A 希望值是 VAL<sub>a</sub> ,客户端 B 希望值是 VAL<sub>b</sub> 的情况。
解决这个问题的方法之一是采用主从Master-Slave结构。主从结构采用的是一主多从模式所有写操作都发往主Master所有从Slave都从主这边同步数据修改的操作。
这样Slave的数据版本只可能因为同步还没有完成导致版本会比较旧而不会出现比主Master还新的情况。
Slave可以帮主Master分担一定的读压力。但是不是所有的读操作都可以被分担。大部分场景的读操作必须要读到最新的数据否则就可能会出现逻辑错乱。只有那些纯粹用于界面呈现用途而不是用于逻辑计算的场景非敏感场景比如财务场景是敏感场景下能够接受读的旧版本数据可以从从节点读。
Slave最重要的是和主Master形成了互备关系。在主挂掉的时候某个从节点可以替代成为新的主节点。这会发生一次选举行为系统中超过一半的节点需要同意某个节点成为主那么选举就会通过。
考虑选举的话,意味着集群的节点数为奇数比较好。比如,假设集群有 2 个节点,只有一主一从,那么在主挂掉后,因为只剩下一个节点参与选举,没有超过半数,选举不出新的主节点。
选择谁成为新的主是有讲究的,因为从的数据有可能不是最新的。一旦我们选择没有最新数据的从作为新的主节点,就意味着版本回退,也就意味着发生了数据丢失。
这是不能接受的事情。为了避免版本回退,写操作应该确保至少有一个从节点收到了最新的数据。这样在主挂掉后才可以确保能够选到一个拥有最新数据的节点成为新的主节点。
## 分布式
多副本让数据库的可用性和持久性有了保障,但是仍然有这样一些问题需要解决:
- 数据规模大到一定程度后,单个物理节点存放不了那么大的数据量;
- 主承受的读写压力太大,单台主节点承受不了这样高的 IOPS吞吐能力
从目前存储技术的发展看,单台设备的存储量已经可以非常高,所以上面的第二种情况也会很常见。
怎么解决?
分布式。简单说,就是把数据分片存储到多台设备上的分片服务器一起构成一个单副本的数据库。分片的方式常见的有两种:
- 哈希分片Hash based sharding
- 范围分片Range based sharding
无论哪个分片方式,都会面临因为扩容缩容导致的重新分片过程。重新分片意味着需要做数据的搬迁。
数据迁移阶段对数据访问的持续有不低的挑战,因为这时候对正在迁移的分片来说,有一部分数据在源节点,一部分数据在目标节点。
在分布式存储领域有一个著名CAP理论。其中C、A、P 分别代表一个我们要追求的目标。
- 数据一致性(Consistency):如果系统对一个写操作返回成功,那么之后的读请求都必须读到这个新数据;如果返回失败,那么所有读操作都不能读到这个数据。
- 服务可用性(Availability):所有读写请求在一定时间内得到响应,可终止、不会一直等待。
- 分区容错性(Partition-tolerance):在网络分区的情况下,被分隔的节点仍能正常对外服务。
那么 CAP 理论说的是什么?简单说,就是 C、A、P 三个目标不能兼得,我们只能取其二。
假设我们不会放弃服务的可用性那么我们决策一个分布式存储基本上在数据一致性C和分区容错性P之间权衡。
数据一致性C的选择基本上是业务特性决定的业务要求是强一致我们就不可能用最终一致性模型相应的我们只能在分区容错性P上去取舍。
## 结语
今天我们概要讨论了数据库相关的核心话题。我们第一关心的,当然还是使用界面(接口)。从使用界面角度,我们要考虑选择关系型数据库还是文档型数据库,以及是否需要事务特性。
确定了我们要使用什么样的数据库后,接着我们从实现角度,考虑主从结构和分布式方面的特性。
数据库是非常专业并且复杂的领域,限于篇幅我们这里不能展开太多,你如果有兴趣可以参考相关的资料。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊对象存储。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,197 @@
<audio id="audio" title="38 | 文件系统与对象存储" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/8f/e91ca6ca6af862c57f883dc25831f48f.mp3"></audio>
你好,我是七牛云许式伟。
存储系统从其与生俱来的使命来说,就难以摆脱复杂系统的魔咒。无论是从单机时代的文件系统,还是后来 C/S 或 B/S 结构下数据库这样的存储中间件兴起,还是如今炙手可热的云存储服务来说,存储都很复杂,而且是越来越复杂。
## 异常处理才是存储的业务逻辑
存储为什么会复杂,要从什么是存储谈起。
让我们简单回顾一下 “[36 | 业务状态与存储中间件](http://time.geekbang.org/column/article/127490)” 的核心逻辑。
存储这个词非常平凡,存储 + 计算(操作)就构成了一个朴素的计算机模型。简单来说,存储就是负责维持计算系统的状态的单元。从维持状态的角度,我们会有最朴素的可靠性要求。
比如单机时代的文件系统,机器断电、程序故障、系统重启等常规的异常,文件系统必须可以正确地应对,甚至对于磁盘扇区损坏,文件系统也需要考虑尽量将损失降到最低。
到了互联网时代,有了 C/S 或 B/S 结构存储系统又有了新指标可用性。为了保证服务质量那些用户看不见的服务器程序必须时时保持在线最好做到逻辑上是不宕机的可用性100%)。
服务器程序怎么才能做到高可靠、高可用?
答案是存储中间件。没有存储中间件,意味着所有的业务程序,都必须考虑每做一步就对状态进行持久化,以便自己挂掉后另一台服务器(或者自己重启后),知道之前工作到哪里了,接下去应该做些什么。
但是对状态持久化工作(也就是存储)非常繁琐,如果每个业务都自己实现,负担无疑非常沉重。但如果有了高可用的存储中间件,服务器端的业务程序就只需操作存储中间件来更新状态,通过同时启动多份业务程序的实例做互备和负载均衡,很容易实现业务逻辑上不宕机。
对于大部分的业务程序而言,你只需要重点关注业务的正常分支流程就行,对于出乎意料的情况,通常只需抛出一个错误,告诉用户你不该这么玩。
**但是,存储系统你需要花费绝大部分精力在各种异常情况的处理上,甚至你应该认为,这些庞杂的、多样的错误分支处理,才是存储系统的 “正常业务逻辑”。**
所以,数据库这样的存储中间件出现基本上是历史必然。
## 从文件系统谈起
但尽管数据库很通用,它决不会是唯一的存储中间件。
比如在服务端开发中我们业务用到的多媒体图片、音视频、Office文档等我们很少会去存储到数据库中更多的时候我们会把它们放在文件系统里。
但是单机时代诞生的文件系统,真的是最适合存储这些多媒体数据的吗?
不,文件系统需要改变,因为:
第一,伸缩性问题。单机文件系统的第一个问题是单机容量有限,在存储规模超过一台机器可管理的时候,应该怎么办的问题。
第二,性能瓶颈。单机文件系统通常在文件数目达到临界点后,性能快速下降。在 10TB 的大容量磁盘越来越普及的今天,这个临界点相当容易到达。
第三可靠性更严谨来说是持久性Durability问题。单机文件系统通常只是单副本的方案。但是今天单副本的存储早已经无法满足业务的持久性要求。
数据需要有冗余比较经典的做法是3副本以便在磁盘损坏时及早修复丢失的数据以避免所有的副本损坏造成数据丢失。
第四,可用性要求。单机文件系统通常只是单副本的方案,在该机器宕机后,数据就不可读取,也不可写入。
在分布式存储系统出现前,有一些基于单机文件系统的改良版本被一些应用采纳。比如在单机文件系统上加 RAID5 做数据冗余,来解决单机文件系统的可靠性问题。
假设 RAID5 的数据修复时间是 1 天(实际上往往做不到,尤其是业务系统本身压力比较大的情况下,留给 RAID 修复用的磁盘读写带宽很有限),这种方案单机的可靠性大概是 100 年丢失一次数据即可靠性是2个9
看起来尚可?但是我们得考虑两个问题。
第一,你的集群规模会变大。如果你仍然沿用这个土方法,比如你现在有 100 台这样的机器,那么它就会变成 1 年就丢失一次数据。
第二,你采购的磁盘容量会变大。如果实际数据修复时间没那么理想,比如变成 3 天,那么单机的可靠性就直降至 4 年丢失一次数据。100 台这样的机器就会是 15 天就丢失一次数据。
这个数字显然无法让人接受。
所以服务端存储只要规模够大,就会使得很多看起来是小概率的事件,变成必然事件。
什么样的数据会有最大的存储规模?
答案是非结构化数据。这类数据的组织形式通常以用户体验友好为目标,而不是机器友好为目标。所以数据本身也自然不是以机器易于理解的结构化形式来组织。
图片、音视频、Office 文档等多媒体文件,就是比较典型的非结构化数据。互联网上 90% 以上传输的数据量都是非结构化数据。
移动互联网、人工智能与物联网技术的发展,进一步加快了非结构化数据的产生。从读图时代,到视频与实时互动,以及未来的 AR/VR 技术,人们正在一步步把物理世界映射到数字世界,通过数字世界实现更随时随地的、更自然的沟通体验,通过数字世界更好地理解和治理我们的物理世界。
Google GFS 是很多人阅读的第一份分布式存储的论文,这篇论文奠定了 3 副本在分布式存储系统里的地位。随后 Hadoop 参考此论文实现了开源版的 GFS —— HDFS。
但关于 Hadoop 的 HDFS 实际上业界有不少误区。GFS 的设计有很强的业务背景特征本身是用来做搜索引擎的。HDFS 更适合做日志存储和日志分析(数据挖掘),而不是存储海量的富媒体文件。因为:
第一HDFS 的 block 大小为 64M如果文件不足 64M 也会占用 64M。而富媒体文件大部分仍然很小比如图片常规尺寸在几百 K 左右。有人可能会说我可以调小 block 的尺寸来适应。但这是不正确的做法HDFS 的架构为大文件而设计的,不可能简单通过调整 block 大小就可以满足海量小文件存储的需求。
第二HDFS 是单 Master 结构,这决定了它能够存储的元数据条目数有限,伸缩性存在问题。当然作为大文件日志型存储(一般单个日志文件大小在 1GB 级别),这个瓶颈会非常晚才遇到;但是如果作为海量小文件的存储,这个瓶颈很快就会碰上。
第三HDFS 仍然沿用文件系统的 API 形式,比如它有目录这样的概念。在分布式系统中维护文件系统的目录树结构,会遭遇诸多难题。所以 HDFS 想把 Master 扩展为分布式的元数据集群并不容易。
## 对象存储
非结构化数据的存储方式,最理想的绝对不是分布式文件系统。
文件系统只是桌面操作系统为了方便用户手工管理数据而设计的产物。服务端操作系统发展的初期,人们简单沿用了桌面操作系统的整套体系框架。
但从非结构化数据的存储开始,出现了分叉路口。对服务端体系架构来说,文件系统其实是一个过时的东西。
非结构化数据最佳的存储方式还是键值存储KV Storage。用于存储非结构化数据的键值存储有一个特殊的名字叫对象存储Object Storage。它和结构化数据的键值存储实现机制上往往有极大的差异。
对象存储的 Key看起来也像一个文件系统的路径Path但仅仅是像而已。对于对象存储来说Key 中出现的 “/” 字符,只是一个普通字符。
在对象存储中并不存在目录Directory这样的概念。
既然对象存储是一个键值存储,就意味着我们可以通过对 Key 做 Hash或者对 Key 按 Key Range 做分区,都能够让请求快速定位到特定某一台存储机器上,从而转化为单机问题。
这也是为什么在数据库之后,会冒出来那么多 NoSQL 数据库。因为数据库和文件系统一样,最早都是单机的,在伸缩性、性能瓶颈(在单机数据量太大时)、可靠性、可用性上遇到了相同的麻烦。
NoSQL 数据库的名字其实并不恰当,它们更多的不是去 SQL而是去关系我们知道数据库更完整的称呼是关系型数据库。有关系意味着有多个索引也就是有多个 Key而这对数据库转为分布式存储系统来说非常不利。
七牛云存储的设计目标是针对海量小文件的存储,所以它对文件系统的第一个改变也是去关系,也就是去目录结构(有目录意味着有父子关系)。
所以七牛云存储不是文件系统File System而是对象存储Object Storage。蛮多七牛云的新手会问为什么我在七牛的 API 中找不到创建目录这样的 API根本原因还是受文件系统这个经典存储系统的影响。
第一个大家公认的对象存储是 AWS S3你可以把它理解为一个非常简单的非结构化数据存储它最基本的访问接口如下
```
func PutObject(bucket, key string, object io.Reader) (err error)
func GetObject(bucket, key string) (object io.ReadCloser, err error)
```
七牛云存储并不仅仅是简单的分布式存储,它需要额外考虑以下这些问题。
第一,网络问题,也就是文件的上传下载问题。
文件上传方面,我们得考虑在相对比较差的网络条件下(比如 2G 网络如何确保文件能够上传成功大文件七牛云存储的单文件大小理论极限是几个TB如何能够上传成功如何能够更快上传。
文件下载加速方面,考虑到 CDN 已经发展了 10 多年的历史,非常成熟,我们决定基于 CDN 技术来做下载加速。
第二,多媒体处理。当用户文件托管到了七牛,那么针对文件内容的数据处理需求也会自然衍生。比如我们第一个客户就给我们提了图片缩略图相关的需求。在音视频内容越来越多的时候,自然就有了音视频转码的需求。
所以从用户使用的角度来看,七牛云存储是这样的:
>
七牛云存储 = 对象存储 + 上传下载加速 + 多媒体处理
## 存储成本与持久性
既然对象存储的存储规模最大,占据了 90% 以上存储需求,那么毫无疑问,它最关心的就是单位存储成本问题。通常我们用每 GB 每月花费多少钱来表示单位存储成本。
前面我们说了GFS 这个经典的分布式文件系统,采用的是 3 副本的方式。这样做的好处是可靠,不容易发生数据丢失。
但是它的问题也很明显,就是存储成本非常高。如果我们排除不同公司的采购能力差异外,存储成本最大的关联因素是以下两个东西。
其一是存储密度。存储密度越高,单台机器的存储量越大,单位成本越低。存储密度取决于:单台机器能够插的硬盘数量、单块磁盘的容量。
其二是冗余度。GFS 采用的是 3 副本,也就是冗余度为 3。当前降低冗余度通常采用的是纠删码EC这样的算术冗余方案。
比如,假设 EC 采用的是 28 + 4也就是把文件切分为 28 份,然后再根据这 28 份数据计算出 4 份冗余数据,最后把这 32 份数据存储在 32 台不同的机器上。
这样做的好处是既便宜,又提升了持久性和可用性。从成本角度,同样是要存储 1PB 的数据,要买的存储服务器只需 3 副本存储的 38%32/28=1.141.14/3=38%),经济效益相当好。
从持久性方面,以前 3 副本只能允许同时损坏 2 块盘,现在能够允许同时损坏 4 块盘,直观来说这大大改善了持久性。
从可用性角度,以前能够接受 2 台服务器下线,现在能够同时允许 4 台服务器下线。
通过上面的分析可以看出,冗余度降低不一定会伤害集群的持久性和可用性,它们和冗余度不是正相关,而和集群的容错能力相关。
但是存储密度对系统的可用性和可靠性都会有一定的伤害。我们定性分析一下这里面的关系是什么样的。
我们重点考虑存储的核心指标持久性Durability。它取决于以下两个关键指标。
一是单位修复时长,也就是一块磁盘损毁后,需要多久修复回来,假设这个修复时长为 T<sub>0</sub>
二是容错能力,也就是集群允许同时有几块硬盘损坏,假设我们采用的纠删码是 N + M 方案,那么我们的容错能力是可以接受同时损坏 M 块硬盘。
我们定性来看T<sub>0</sub> 时间内同时坏 M 块盘的概率就是我们丢失数据的概率。因此持久性Durability和 T<sub>0</sub>、M 这两个参数相关。
存储密度对持久性的影响是什么?
假设集群总的容量规模不变,我们把单台机器的磁盘数量增加一倍,那么我们需要的机器数量减少一半。但由于集群的磁盘数量不变,我们的修复时长 T<sub>0</sub> 也不变(假设网络和 CPU 计算力都不是瓶颈)。假设原本丢失数据的概率是 p那么现在丢失数据的概率还是 p。
也就是说,在保证有足够的网络和计算力前提下,增加单台机器的磁盘数量,可能会降低可用性,但是对持久性几乎不会造成影响。
假设集群总的容量规模不变,但我们不是增加单台机器的磁盘数量,而是增加磁盘的密度。比如,我们把单盘容量增加一倍,那么我们集群的磁盘数也减少一半。这样我们的修复时长 T<sub>0</sub> 会变成 4T<sub>0</sub>(修复时间和要修复的数据量成正比,和集群可用的磁盘数成反比)。
从这个角度看,提高磁盘密度对持久性的伤害还是比较大的。但是如果我们假设单块磁盘的坏盘概率和磁盘容量无关的话,由于磁盘数量减少了一半,这对集群整体的坏盘率又是一个正向的影响。
综合来说,假设原本丢失数据的概率是 p那么现在丢失数据的概率是
1 - [(1-p)^0.5]^4 ≈ 2p
即约等于 2p。
我们再来看一下集群的容量规模对持久性的影响。
假如我们将集群扩容一倍。那么我们的修复速度会快一倍,修复时长 T<sub>0</sub> 会变成 0.5T<sub>0</sub>,这是一个很正面的影响。但是由于磁盘数量增加了一倍,所以坏盘概率会增加,这又是一个负面的影响。
综合来说一正一反两相抵消集群规模对集群整体的持久性大体可以忽略。不过这只是一种非常粗略的估计方法。更严谨的演算表明集群规模增加整体上对集群的持久性Durability是正向的影响。
也就是说,集群规模越大,存储的可靠性越高。当然,这一点的前提是前面我们的假设,修复速度和集群规模成正比成立。
## 结语
今天我们讨论了对象存储相关的核心话题。我们从文件系统谈起,介绍了非结构化数据的存储系统的演进历史。
对象存储的出现,是服务端体系架构和桌面操作系统分道扬镳的开始。后续两者的演进方向变得越来越大不相同。
由于承载了最大体量的数据规模对象存储对单位存储成本极其敏感。我们定性探讨了成本与持久性Durability之间的平衡关系。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊内存缓存。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,212 @@
<audio id="audio" title="39 | 存储与缓存" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cf/b4/cffbae90c161b7b33556745b597670b4.mp3"></audio>
你好,我是七牛云许式伟。
前面接连三讲我们介绍了存储中间件的由来以及最为常见的存储中间件键值存储KV Storage、数据库Database、对象存储Object Storage
当然它们并不是全部。常见的存储中间件还有很多比如消息队列MQ、搜索引擎Search Engine等等。
限于篇幅我们不能一一对它们进行分析。今天我们聊一聊缓存Cache
## memcached
缓存Cache是什么
简单说缓存是存储Storage的加速器。加速的原理通常是这样几种方法
最常见的是用更高速的硬件来加速。比如,用 SSD 缓存加速 SATA 存储,用内存缓存加速基于外存的存储。
还有一种常见的方法是用更短的路径。比如,假设某个计算 y = F(x) 非常复杂,中间涉及很多步骤,发生了一系列的存储访问请求,但是这个计算经常会被用到,那么我们就可以用一个 x =&gt; y 的内存缓存来加速。
可见,缓存的数据结构从实现上来讲只需要是一个键值存储。所以它的接口可以非常简单:
```
type Cache {
...
}
func (cache *Cache) Get(key []byte) (val []byte, err error)
func (cache *Cache) Set(key, val []byte) (err error)
func (cache *Cache) Delete(key []byte) (err error)
```
第一个被广泛应用的内存缓存是 memcached。通常我们会使用多个 memcached 实例构成一个集群,通过 Hash 分片或者 Range 分片将缓存数据分布到这些实例上。
一个典型的 memcached 的使用方式如下:
```
func FastF(x TypeX) (y TypeY) {
key := toBytes(x)
hash := hashOf(key)
i := hash % countOf(memcaches)
val, err := memcaches[i].Get(key)
if err != nil {
y = F(x)
val = toBytes(y)
memcaches[i].Set(key, val)
} else {
y = fromBytes(val)
}
return
}
```
类似的缓存逻辑大家应该比较经常见到。
这个示例我们采用的是简单 Hash 分片的方法,它的好处是非常容易理解。当然不太好的地方在于,一旦我们要对 memcached 集群扩容countOf(memcaches) 就会变化,导致大量的 key 原先落在某个分片,现在就落到一个新的分片。
这会导致大量的缓存未命中Cache Miss也就是 cache.Get(key) 返回失败。在缓存未命中的情况下FastF(x) 不只是没有加速 F(x)还增加了两次网络请求cache.Get 和 cache.Set。
所以缓存系统的一个核心指标是缓存命中率Cache Hit Rate即在一段时间内FastF 缓存命中的次数 / 所有 FastF 的调用次数。
为了避免 memcached 集群扩容导致缓存命中率大幅降低,一般我们不会用简单哈希分片,而是用一致性哈希。
什么情况下需要扩容?一旦缓存命中率趋势下降,且下降到某个阈值,就要考虑给缓存集群扩容。
## 缓存 vs 存储
通过以上的介绍可以看出,缓存的基础逻辑是非常简单的。问题是:
缓存Cache和存储Storage是什么关系它也是一种存储中间件么
既是也不是。
首先,缓存和一般的存储中间件一样,也在维持着业务状态。从这个角度看,缓存的确是一类存储。
但是,缓存允许数据发生丢失,所以缓存通常是单副本的。一个内存缓存的集群挂了一个实例,或者一个外存缓存的集群坏了一块硬盘,单就缓存集群本身而言,就出现数据丢失。
缓存数据丢失这事可大可小。只要不是发生大片大片的缓存数据丢失的情形通常只是会造成后端存储Storage的短时压力变大。
但在极端的情况下,可能会出现雪崩的情况。
雪崩怎么形成首先是部分缓存实例宕机导致缓存命中率Cache Hit Rate下降大量的请求落到后端存储上导致后端存储过载也出现宕机。
这时就会出现连锁反应,形成雪崩现象。后端存储就算重新启动起来,又会继续被巨大的用户请求压垮,整个系统怎么启动也启动不了。
应该怎么应对雪崩?最简单的办法,是后端存储自己要有过载保护能力。一旦并发的请求超过预期,就要丢弃部分请求,以减少压力。
我们在本章开篇第一讲 “[34 | 服务端开发的宏观视角](https://time.geekbang.org/column/article/120049)” 中,总结服务端开发的体系架构如下:
<img src="https://static001.geekbang.org/resource/image/89/82/895dbf7e39fb562215e0176ca4aad382.png" alt=""><br>
在这个图中我们并没有把缓存Cache画出来。但结合上面介绍的缓存典型使用方式我们很容易脑补它在图中处于什么样的位置。
回到前面的问题缓存Cache和存储Storage到底是什么关系
我个人认为,缓存其实应该被认为是存储的补丁,而且是理论上来说不太完美的补丁。
为什么说它是补丁?
因为如果存储本身非常匹配业务场景的话,它不应该需要缓存在它前面挡一道,内部自己就有缓存。至于把一个复杂的 F(x) 缓存起来,更根本的原因还是存储和业务场景不那么直接匹配所致。
但是实现一个存储很难,所以存储的业务场景匹配性很难做到处处都很好。
出现事务Transaction是为了改善存储的业务场景“写操作”的匹配性把一个复杂操作包装成一个原子操作。
出现缓存Cache则是为了改善存储的业务场景“读操作”的匹配性提升高频读操作的效率。
所以我们说,缓存是一个存储的补丁。
那么为什么我们说这是一个不太完美的补丁呢?
因为上面的 FastF(x) 并没有被包装成一个原子的读操作。从严谨的角度来说,这段代码逻辑是有问题的,它会破坏数据的一致性。
对于一个确定的 x 值,如果 F(x) 永远不变,这就没问题。但如果 F(x) 值会发生变化,会有多个版本的值,那就有可能会出现并发的两个 F(x) 请求得到的结果不同,从而导致缓存中的值和存储中的值不一致。
这种情况后果有可能会比较严重。尤其是如果我们有一些业务逻辑是基于 FastF(x) 得到的值,就有可能会出现逻辑错乱。
## groupcache
为了避免发生这类一致性问题memcached 的作者 Brad Fitzpatrickbradfitz搞了一个新的内存缓存系统叫 groupcache。
groupcache 基于 Go 语言实现,其 Github 主页为:
- [https://github.com/golang/groupcache](https://github.com/golang/groupcache)
从业务角度groupcache 主要做了两大变化:
其一,引入 group 的概念。这是一个重要改动,也是 groupcache 这个名字的来由。
在同一个缓存集群,可能会需要缓存多个复杂操作,比如 F(x)、G(x)。如果没有 group那么我们就不能只是记录 x =&gt; y 这样的键值对,而是要记录 F#x =&gt; yG#x =&gt; y 这样的键值对。中间的 # 只是一个分隔符,换其他的也可以。
看起来好像也还可以?
其实不然,因为 F(x)、G(x) 在同一个内存缓存集群就意味着它们相互之间会淘汰对方,这里面的淘汰规则不是我们能够控制的,很难保证结果符合我们的预期。
那么有 group 会变成什么样?首先你可以创建 F、G 两个独立的 group每个 group 可以设定独立的内存占用上限cacheBytes
这样,每个 group 就只淘汰自己这个 group 内的数据,相当于有多个逻辑上独立的内存缓存集群。
另外,在 group 中只需要记录 x =&gt; y 这样的键值对,不再需要用 F#x、G#x 这种手工连接字符串的方式来模拟出名字空间。
其二,值不可修改。一旦某个 x 值 Get 到的值为 y那么就一直为 y。它的使用方式大体如下
```
var groupF = groupcache.NewGroup(&quot;F&quot;, cacheBytes, groupcache.GetterFunc(func(ctx groupcache.Context, key string, dest groupcache.Sink) error {
x := fromString(key)
y := F(x)
return dest.SetBytes(toBytes(y))
}))
func FastF(x TypeX) (y TypeY) {
key := toString(x)
var val []byte
groupF.Get(ctx, key, groupcache.AllocatingByteSliceSink(&amp;val))
y = fromBytes(val)
return
}
```
这当然也就意味着它也不需要引入 memcached 中的缓存失效时间这样的概念。因为值是不会过时的,它只会因为内存不足而被淘汰。
一致性问题也被解决了。既然值不可修改,那么自然就不存在一致性问题。
当然groupcache 是一个理论完美的内存缓存系统,它解决了 memcached存在的一致性缺陷。但是 groupcache 对使用者来说是有挑战的,某种意义上来说,它鼓励我们用函数式编程的方式来实现业务逻辑。
但是你也知道,函数式编程是比较小众的。所以怎么用好 groupcache挑战并不低。
## Redis
谈到存储与缓存的关系,不能不提 Redis。
Redis 在定位上特别奇怪,以至于不同的人对它的认知并不相同。有的人会认为它是内存缓存,有的人会认为它是存储。
Redis 的确可以当作缓存来用我们可以设置内存上限当内存使用达到上限后Redis 就会执行缓存淘汰算法。只不过如果我们把它当作内存缓存那么其实它只需要是一个简单的键值存储KV Storage就行。
但是 Redis 实际上是 key =&gt; document它的值可以是各类数据结构比如字符串哈希表列表集合有序集合支持 Range 查询),等等。
不仅如此Redis 还支持执行 Lua 脚本来做存储过程。
这些都让 Redis 看起来更像一个数据库类的存储中间件。
但当我们把 Redis 看作存储,我们有这样一些重要的问题需要考虑。这些问题非常非常重要,存储系统可不是闹着玩的。
问题一是持久性Durability。Redis 毕竟是基于内存的存储,虽然它也支持定期写到外存中,但是定期持久化的策略对于一个服务端的存储系统来说是不合格的。因为如果发生宕机,上一次持久化之后的新数据就丢了。
所以 Redis 需要其他的提升持久性的方案,比如多副本。
Redis 的确支持多副本。但是只是同机房多台机器的多副本是没有用的,因为它没有办法防止机房整体断电这类的故障。当出现机房级的故障时,就有极大概率会丢失数据。
对于存储系统来说,这是不可接受的。因为相比人们对持久性的要求,机房整体断电并不是一个太小概率的事件。
所以 Redis 如果要作为存储的话,必须保证用多机房多副本的方式,才能保证在持久性这一点上能够达标。
但是多机房多副本这样的方式,显然实施条件过于苛刻。会有多少企业仅仅是为了部署 Redis 去搞多个机房呢?
问题二,是重试的友好性。在 “[29 | 实战(四):怎么设计一个“画图”程序?](https://time.geekbang.org/column/article/111289)” 中我们提到过,考虑网络的不稳定性,我们设计网络协议的时候需要考虑重试的友好性。
在 Redis 的协议中有不少请求用户很友好但是对重试并不友好。比如LPUSH 请求用来给列表List增加一个元素。但是在重试时一个不小心我们很可能就往列表中添加了多个相同的元素进去。
总结来说Redis 如果我们把它作为存储的话,坑还是不少的。它和 memcached 都是实用型的瑞士军刀,很有用,但是我们站在分布式系统的理论角度看时,它们都有那么一点不完美的地方。
## 结语
今天我们讨论了存储与缓存之间的关系也分别介绍了三个模型迥异的缓存系统memcached、groupcache、Redis。
缓存是一个存储系统在服务器性能上的补丁。这个补丁并不是那么完美。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。我们服务端开发相关的基础软件介绍得差不多了,下一讲我们将聊聊服务端开发的架构建议。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="40 | 服务端的业务架构建议" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/7d/7611694e2c3b57ff5667c232478da57d.mp3"></audio>
你好,我是七牛云许式伟。
相比桌面程序而言,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:
- 负载均衡Load Balance
- 数据库或其他形式的存储DB/Storage
<img src="https://static001.geekbang.org/resource/image/89/82/895dbf7e39fb562215e0176ca4aad382.png" alt="">
我们前面几讲已经介绍了负载均衡和常见的存储中间件。今天,让我们就把焦点放在上图中的业务架构上。
大方向来说,业务架构必然是领域性的,与你所从事的行业息息相关。但就如同桌面程序会有自己的架构体系的套路一样,服务端的业务架构也会有自己的套路。
在第二章 “[24 | 跨平台与 Web 开发的建议](https://time.geekbang.org/column/article/107128)” 这一讲中,我们概要地画过服务端的体系架构,如下图所示。
<img src="https://static001.geekbang.org/resource/image/ab/19/ab04644742a45037db12b5f1708ec019.png" alt="">
在图中,我们把服务端分成了两层。底层是 Multi-User Model 层,一般情况下它对外提供了一套 RESTful API 接口。上层是 Web 层,对外提供 Web API。Web 层又分为 Session-based Model 层和 Session-based ViewModel 层。
一般来说Session-based Model 是一个非常简单的转译层。而在胖前端的模式下Session-based ViewModel 层也几乎没有任何后端的代码,就是一些托管的资源文件,包含一些 HTML + CSS + JavaScript 文件。
我个人会倾向于认为Session-based ViewModel 层属于桌面开发的范畴,哪怕是胖后端的模式下也会这样去归类。只不过在胖后端的方式下,桌面程序的很多逻辑不再是由 JavaScript 完成,而是由类似 PHP 之类的语言完成。
故此,我们今天探讨的业务架构,主要谈的是 Multi-User Model 层。
## 网络协议
探讨 Multi-User Model 层,第一个重要话题是网络协议,它是服务端程序的使用界面(接口)。考虑到这一层网络协议往往提供的是 RESTful API所以有时它也会被称为 RESTful API 层。
大家可能经常听到 RESTful但它到底代表什么
所谓 RESTful是指符合 REST 原则。REST 的全称是 “Representational State Transfer”。它强调的是
第一,客户端和服务器之间的交互在请求之间是 “无状态” 的。这里的无状态更严谨的说法是 “无会话Session” 的,从客户端到服务器的每个请求,都必须包含理解请求所必需的完整信息。服务器可以在请求之间的任何时间点重启,客户端不会得到通知。
在 “[36 | 业务状态与存储中间件](https://time.geekbang.org/column/article/127490)” 这一讲中,我们把桌面程序和服务端程序都看作一个状态机。桌面程序的状态转化由 “用户交互事件” 所驱动,如下图。
<img src="https://static001.geekbang.org/resource/image/b7/cb/b78bf287f43735f81ad7ac30dcf7d1cb.png" alt="">
而服务端程序的状态转化由 “网络 API 请求” 所驱动,如下图。
<img src="https://static001.geekbang.org/resource/image/d4/6b/d4adc97bcf06721304ad0d6c30c99c6b.png" alt="">
但是从状态转化角度来说,桌面程序和服务端程序很不一样。桌面程序的状态转化往往存在中间的 “临时状态”,这其实也是 Controller 层的价值所在。
在桌面程序的 MVC 架构中Model 层提供核心业务,它不存在 “临时状态”每一个对外提供的接口API都完成一项完整的业务。View 层提供呈现和我们的话题关联不大这里不展开来讲。Controller 层负责把 “用户交互事件” 翻译成 Model 层的业务 API。在 Controller 层往往存在 “临时状态” 的,它需要把多个连续的 “用户交互事件” 组装起来完成一项业务。我们第二章实战的 “画图” 程序,它的各类 Controllers比如 FreePathCreator、RectCreator 等等,都是很好的例子。
服务端程序的状态转化,并不存在 “临时状态”。也就是说,它是 “无会话Session” 的,每个 “网络 API 请求” 都包含了实现一个业务的完整参数。
而这,正是 REST 原则所强调的。
这也是我们把服务端程序看作是 Model 层的原因。如果存在会话Session这就意味着服务端也需要实现 Controllers这样就太糟糕了。
REST 原则第二个强调的点,是统一的表现规范,也就是 Representational 一词传递的意思。它认为,所有网络 API 请求都应该统一抽象为对某种资源 URI 的 GET、PUT、POST、DELETE 操作。
由于 RESTful API 简单明了,易于理解和实施,今天已经基本成为事实上的网络 API 的定义规范。
当然RESTful API 显然并不是唯一选择。比如,基于 XML 的有 SOAP简易对象访问协议、WSDLWeb 服务描述语言)等。
还有一些人会觉得基于文本协议效率不够好所以用二进制的协议。比如Facebook 早年搞了个 thrift不过 Facebook 自己应该不怎么用了。而 Google 也搞了个 protobuf 协议,并且基于 protobuf 搞了一个 grpc 框架。
还有一个选择是 GraphQL它推崇企业在有多个业务的时候不要发布很多套 RESTful API而是基于一个统一的数据图并通过 GraphQL 协议暴露给开发者。
<img src="https://static001.geekbang.org/resource/image/36/35/36e45fbadb455b1f353036f124734735.png" alt="">
目前来看GraphQL 理念虽然先进,但是概念复杂,并不易于掌握,现在仍然处于不温不火状态。知乎甚至有一帖讨论 [GraphQL 为何没有火起来?](https://www.zhihu.com/question/38596306)
这么多选择,应该怎么选?
我的答案大家已经知道了,我个人还是倾向于 RESTful API。虽然 GraphQL 值得关注,但是目前来看,它的投入产出比还远没有达到让人放弃简洁的 RESTful API 的地步。
至于二进制协议,虽然理论上效率更高,但是考虑到 HTTP 协议的江湖地位,各路豪杰纷纷贡献自己的智慧,提供支撑工具和效率优化,它实际的效率并不低。
只有 HTTP 协议,才有被广泛采纳的专门的应用层网关,比如 nginx 和 apache。这一点千万不要忘记。
就拿 Google 的 grpc 来说,它其实也是基于 HTTP 协议的,只不过它更推荐 HTTP 2.0,因为效率已经经过高度的优化。所以虽然 protobuf 是二进制的,但它取代的不是 HTTP 协议,而是 json、xml 或 Web 表单form
这可能也是 protobuf 还很活跃,而 thrift 已经半死不活的原因。凡是想对 HTTP 协议取而代之的,都会挂掉。
一旦确定我们要用 RESTful API还是用 protobuf剩下的就是如何定义具体的业务 API 了。这块是具体的领域相关内容,这里先略过。
## 授权Authorization
确定好我们要选择什么样的网络协议我们第二个要考虑的是授权Authorization
当前,主流的授权方式有两种:一种是基于 Token一种是基于 AK/SK。这两种授权方式的场景非常不同。
基于 AK/SK 的授权,多数发生在面向企业用户提供 API也就是说提供的是一个 To B 的云服务。如果大家经常使用各类云计算服务,对 AK/SK 这类授权应该并不陌生。
AK/SK 授权的背后是数字签名。
我们强调一下AK/SK 并不是公私钥。实际上 AK 是密钥提示keyHintSK 是数字签名的密钥key
关于数字签名的原理,你可以回顾一下 “[16 | 安全管理:数字世界的守护](https://time.geekbang.org/column/article/99636)” 这一讲中的内容。
基于 Token 的授权,多数发生在面向终端用户的场景,也就是我要做一个 To C 的应用。
当前推荐的 Token 授权标准是 OAuth 2.0,它得到了广泛的支持,大家如果有在使用各类 C 端应用程序的开放接口,会发现他们往往都是基于 OAuth 2.0 的(有的还会同时支持 OAuth 1.x 版本)。
OAuth 2.0 的优势是对外提供 Open API而不仅仅局限于自己的 App 用。OAuth 2.0 提供了一个很好的方式,能够让我们的客户不用向第三方应用去暴露自己的用户隐私(比如用户名和密码)的前提下,调用 API 来使用我们的服务。
所以总体来说,授权这块的选择是相对简单的。我们更多要考虑的,反而是如何构建业务无关的用户帐号体系和授权系统。它们隶属于通用的帐号与授权子系统,可以做到与业务无关。
后面在本章的实战案例中,我们会对这块内容进一步展开。
## RPC 框架
明确了授权机制,确定了业务 API那么下一步就是怎么实现的问题了。
如果业务 API 选择了基于 protobuf那么 grpc 框架是个不错的选择。
对于 RESTful API七牛云对外开源了一套非常精简的 restrpc 服务器框架,其 Github 主页为:
- [https://github.com/qiniu/http](https://github.com/qiniu/http)
这个 restrpc 框架主要的特点有:
- URL 路由URL Route。支持用手工写 URL 路由表,也支持由 restrpc 框架自动实现路由。
- 参数的解析。可以支持 json、Web 表单form等格式的解释。对于其他格式对数据可以由用户自己来解释。
- 返回值的序列化。默认序列化为 json如果需要用户也可自己做序列化。
- 授权Authorization。以开放框架的方式实现授权机制以便用户可以选择自己的授权方式。
- 适度的开放机制。我们主要为了实现开放的授权机制而开放,但这个开放机制可以用来做各类扩展,而不只是局限于授权。
这里我们给了一个 restrpc 框架的使用样例:
- [examples/authrestrpc](https://github.com/qiniu/http/tree/master/examples/authrestrpc)
为了简化,这个样例用的是一个 mock 的授权机制。这种 mock 授权非常适合用来做业务系统的单元测试。
这个样例我们采用由 restrpc 框架自动实现路由的方式。这样可以减少一些代码量,但是对路由 API 对应的实现方法的名字有要求,看起来不是那么美观。如果不喜欢可以采用手工路由方式。具体怎么做,后面我们的实战案例会有体现。
## 单元测试
另外,这个样例我们的单元测试采用了七牛开源的 httptest 框架。其 Github 主页为:
- [https://github.com/qiniu/httptest](https://github.com/qiniu/httptest)
这个 httptest 框架,最核心的逻辑是如何在不用写业务 API 的 Client SDK 的情况下,能够保持业务友好的方式来写测试案例。
它不只可以做单元测试,也可以做集成测试。
你可以通过下面这个演讲稿来了解它的核心思想:
- [http://open.qiniudn.com/qiniutest.pdf](http://open.qiniudn.com/qiniutest.pdf)
这个 httptest 框架是非常通用的,所以它没有内建任何公司特有的授权机制。在七牛,我们会基于更贴近七牛自身业务的 qiniutest 进行测试。qiniutest 工具只是在 httptest 基础上作了少量的扩展,其 Github 主页为:
- [https://github.com/qiniu/qiniutest](https://github.com/qiniu/qiniutest)
你可以依葫芦画瓢,实现一个适合你们公司的授权机制下的 httptest 工具。
在本章的实战案例中,我们也会让大家看到如何基于 httptest 来进行业务的单元测试。
## 结语
我们总结一下今天的内容。
服务端业务架构,主要是怎么做一个多租户的 Model 层。Model 层本身最重要的是自然体现业务逻辑,它和具体的行业的领域问题相关,对此我们无法进一步展开。
但服务端程序还是有它很鲜明的特点。
今天我们重点讨论了服务端业务架构相关的通用问题。包括网络协议、授权、RPC 框架、单元测试等等。
当然其实还有一个问题,就是选什么样的存储中间件。它和具体的业务特征更为相关,这一点在后面我们实战案例中再做探讨。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。我们服务端开发相关的内容就暂时告一段落,下一讲开始我们进入实战。结束实战后,我们会结合实战对服务端开发的架构做一个总结。然后我们进入服务端的另一半:如何做好服务的运维,甚至也会涉及少量的运营相关的话题。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,327 @@
<audio id="audio" title="41 | 实战(一):“画图”程序后端实战" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9c/82/9c1e7d3ef1yy6057f5d14c343a74b182.mp3"></audio>
你好,我是七牛云许式伟。
到今天为止,服务端开发的基本内容已经讲完了。我们花了比较长的篇幅来介绍服务端的基础软件,包括负载均衡和各类存储中间件。然后我们上一讲介绍了服务端在业务架构上的一些通用问题。
今天我们开始进入实战。
对比服务端和桌面的内容可以看出,服务端开发和桌面端开发各自有各自的复杂性。服务端开发,难在基础软件很多,对程序员和架构师的知识面和理解深度都有较高的要求。但从业务复杂性来说,服务端的业务逻辑相对简单。而桌面端开发则相反,它的难点在于用户交互逻辑复杂,代码量大,业务架构的复杂性高。
上一章的实战篇,蛮多人反馈有点难,这某种程度来说和我们课程内容设计的规划有关。上一章我们从架构角度来说,偏重于介绍概要设计,也就是系统架构。所以我们对实现细节并没有做过多的剖析,而是把重心放在模块之间的接口耦合上。这是希望你把关注点放在全局,而不是一上来就进入局部细节。但是由于缺乏完整流程的剖析,大家没法把整个过程串起来,理解上就会打折扣。
这一章我们在架构上会偏重于详细设计。这在实战篇也会有所体现。
在上一章,我们实现了一个 mock 版本的服务端,代码如下:
- [https://github.com/qiniu/qpaint/tree/v31/paintdom](https://github.com/qiniu/qpaint/tree/v31/paintdom)
接下来我们一步步把它变成一个产品级的服务端程序。
## RPC 框架
第一步,我们引入 RPC 框架。
为了方便你理解,在上一章的实战中,我们的 mock 服务端程序没有引入任何非标准库的内容。代码如下:
- [https://github.com/qiniu/qpaint/blob/v31/paintdom/service.go](https://github.com/qiniu/qpaint/blob/v31/paintdom/service.go)
整个 Service 大约 280 行代码。
我们改为基于七牛云开源的 [restrpc](https://github.com/qiniu/http/tree/v2.0.1/restrpc) 框架来实现,代码如下:
- [https://github.com/qiniu/qpaint/blob/v41/paintdom/service.go](https://github.com/qiniu/qpaint/blob/v41/paintdom/service.go)
这样,整个 Service 就大约只剩下 163 行代码,只有原先的 60% 不到。
到底少写了哪些代码?我们拿创建一个新图形来看下。原先我们这样写:
```
func (p *Service) PostShapes(w http.ResponseWriter, req *http.Request, args []string) {
id := args[0]
drawing, err := p.doc.Get(id)
if err != nil {
ReplyError(w, err)
return
}
var aShape serviceShape
err = json.NewDecoder(req.Body).Decode(&amp;aShape)
if err != nil {
ReplyError(w, err)
return
}
err = drawing.Add(aShape.Get())
if err != nil {
ReplyError(w, err)
return
}
ReplyCode(w, 200)
}
```
现在这样写:
```
func (p *Service) PostShapes(aShape *serviceShape, env *restrpc.Env) (err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil {
return
}
return drawing.Add(aShape.Get())
}
```
这个例子返回包比较简单,没有 HTTP 包的正文。
我们再来看一个返回包比较复杂的例子,取图形的内容。原先我们这样写:
```
func (p *Service) GetShape(w http.ResponseWriter, req *http.Request, args []string) {
id := args[0]
drawing, err := p.doc.Get(id)
if err != nil {
ReplyError(w, err)
return
}
shapeID := args[1]
shape, err := drawing.Get(shapeID)
if err != nil {
ReplyError(w, err)
return
}
Reply(w, 200, shape)
}
```
现在这样写:
```
func (p *Service) GetShape(env *restrpc.Env) (shape Shape, err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil {
return
}
shapeID := env.Args[1]
return drawing.Get(shapeID)
}
```
对比这两个例子,我们可以看出:
- 原先这两个请求 `POST /drawings/&lt;DrawingID&gt;/shapes``GET /drawings/&lt;DrawingID&gt;/shapes/&lt;ShapeID&gt;` 中的 URL 参数如 DrawingID、ShapeID 的值,是通过参数 args[0]、args[1] 传入,现在通过 env.Args[0]、env.Args[1] 传入。
- 原先我们 PostShapes 需要自己定义 Shape 实例并解析 HTTP 请求包 req.Body 的内容。现在我们只需要在参数中指定 Shape 类型restrpc 框架就自动完成参数的解析。
- 原先我们 GetShape 需要自己回复错误或者返回正常的 HTTP 协议包。现在我们只需要在返回值列表中返回要回复的数据restrpc 框架自动完成返回值的序列化并回复 HTTP 请求。
通过对比两个版本的代码差异我们大体能够猜得出来restrpc 的 HTTP 处理函数背后都干了些啥。其核心代码如下:
- [https://github.com/qiniu/http/blob/v2.0.2/rpcutil/rpc_util.go#L96](https://github.com/qiniu/http/blob/v2.0.2/rpcutil/rpc_util.go#L96)
值得关注的是 Env 的支持RPC 框架并没有限定 Env 类具体是什么样子的,只是规定它需要满足以下接口:
```
type itfEnv interface {
OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error
CloseEnv()
}
```
在 OpenEnv 方法中,我们一般进行 Env 的初始化工作。CloseEnv 方法则反之。为什么 OpenEnv 方法中ResponseWriter 接口是以指针方式传入?因为可能会有客户希望改写 ResponseWriter 的实现。
比如,假设我们要给 RPC 框架扩展 API 审计日志的功能。那么我们就需要接管并记录用户返回的 HTTP 包,这时我们就需要改写 ResponseWriter 以达到接管并记录的目的。
另外值得注意的是restrpc 版本的 HTTP 请求的处理函数,看起来不再那么像 HTTP 处理函数,倒像一个普通函数。
这意味着我们可以有两种方式来测试 Service 类。除了用正常测试 HTTP Service 的方法来测试它以外,我们也可以把 Service 类当成普通类来测试,这大大降低单元测试的成本。因为我们不用再需要包装服务的 Client SDK然后再基于 Client SDK 做单元测试。
当然,我们有这样的一种低成本测试方式,但还是会担心这种测试方法可能不能覆盖一些编码上的小意外,毕竟我们没有走 HTTP 协议,心里多多少少有些不踏实。
理解了 restrpc 的 HTTP 处理函数,剩下的就是 restrpc 的路由功能。它是由 restrpc.Router 类的 Register 函数完成的。代码如下:
- [https://github.com/qiniu/http/blob/v2.0.1/restrpc/restroute.go#L39](https://github.com/qiniu/http/blob/v2.0.1/restrpc/restroute.go#L39)
它支持两种路由方式,一种是根据方法名字自动路由。比如 `POST /drawings/&lt;DrawingID&gt;/shapes` 这样的请求,要求方法名为 “PostDrawings_Shapes”。`GET /drawings/&lt;DrawingID&gt;/shapes/&lt;ShapeID&gt;` 这样的请求,要求方法名为 “GetDrawings_Shapes_”。
规则倒是比较简单,路径中的 “/” 由单词首字母大写来分隔URL 参数如 DrawingID、ShapeID 这些则替换为 “_”。
当然有的人会认为这种方法名字看起来很丑。那么就可以选择手工路由的方式,传入 routeTable。它看起来是这样的
```
var routeTable = [][2]string{
{&quot;POST /drawings&quot;, &quot;PostDrawings&quot;},
{&quot;GET /drawings/*&quot;, &quot;GetDrawing&quot;},
{&quot;DELETE /drawings/*&quot;, &quot;DeleteDrawing&quot;},
{&quot;POST /drawings/*/sync&quot;, &quot;PostDrawingSync&quot;},
{&quot;POST /drawings/*/shapes&quot;, &quot;PostShapes&quot;},
{&quot;GET /drawings/*/shapes/*&quot;, &quot;GetShape&quot;},
{&quot;POST /drawings/*/shapes/*&quot;, &quot;PostShape&quot;},
{&quot;DELETE /drawings/*/shapes/*&quot;, &quot;DeleteShape&quot;},
}
```
虽然是手工路由,但是方法名仍然有限制,要求必须是 Get、Put、Post、Delete 开头。
## 业务逻辑的分层
理解了 restrpc 框架,我们再看下 QPaint 服务端的业务本身。可以看出,我们的服务端业务逻辑被分为两层:一层是业务逻辑的实现层,通常我们有意识地把它组织为一颗 DOM 树。代码如下:
- [https://github.com/qiniu/qpaint/blob/v41/paintdom/drawing.go](https://github.com/qiniu/qpaint/blob/v41/paintdom/drawing.go)
- [https://github.com/qiniu/qpaint/blob/v41/paintdom/shape.go](https://github.com/qiniu/qpaint/blob/v41/paintdom/shape.go)
另一层则是 RESTful API 层,它负责接收用户的网络请求,并转为对底层 DOM 树的方法调用。有了上面我们介绍的 restrpc 框架,这一层的每个方法往往都比较简单,甚至有的只是很简单的一句函数调用。比如:
```
func (p *Service) DeleteDrawing(env *restrpc.Env) (err error) {
id := env.Args[0]
return p.doc.Delete(id)
}
```
完整的RESTful API 层代码如下:
- [https://github.com/qiniu/qpaint/blob/v41/paintdom/service.go](https://github.com/qiniu/qpaint/blob/v41/paintdom/service.go)
这样分层的原因,是因为我们实现核心业务逻辑的时候,并不会假设一定通过 RESTful API 暴露。我们考虑这样几种可能性:
其一,有可能我们根本不需要网络调用。
做个类比,我们都知道 mysql 是通过 TCP 协议提供服务接口的,而 sqlite 是嵌入式数据库,是通过本地的函数调用提供服务接口的。这里分层就类似于我实现 mysql 的时候,先在底层实现了一个类似 sqlite 的嵌入式数据库,然后再提供基于 TCP 协议的网络接口。
其二,有可能我们需要支持很多种网络协议。
我们今天流行 RESTful API所以我们的接口是 RESTful 风格的。如果有一天我们像 Github 一样想改用 GraphQL那么至少底层的业务逻辑实现层是不需要改变的我们只需要实现相对薄的 GraphQL 层就行了。
而且,往往在这种情况下 RESTful API 和 GraphQL 是需要同时支持的。毕竟我们不可能为了赶时髦,就把老用户弃之不顾了。
在需要同时支持多套网络接口的时候,这种分层的价值就体现出来了,不同网络接口的模块之间,共享了同一份 DOM 树的实例,整个体系不仅实现了多协议并存,还实现了完美的解耦,彼此之间完全独立。
## 单元测试
聊完了业务,我们再来看看单元测试。
之前,我们单元测试基本上没怎么做:
- [https://github.com/qiniu/qpaint/blob/v31/paintdom/service_test.go#L62](https://github.com/qiniu/qpaint/blob/v31/paintdom/service_test.go#L62)
代码如下:
```
type idRet struct {
ID string `json:&quot;id&quot;`
}
func TestNewDrawing(t *testing.T) {
...
var ret idRet
err := Post(&amp;ret, ts.URL + &quot;/drawings&quot;, &quot;&quot;)
if err != nil {
t.Fatal(&quot;Post /drawings failed:&quot;, err)
}
if ret.ID != &quot;10001&quot; {
t.Log(&quot;new drawing id:&quot;, ret.ID)
}
}
```
从这里的测试代码可以看出,我们就只是创建了一个 drawing并且要求返回的 drawingID 为 "10001"。
从单元测试的角度,这样的测试力度当然是非常不足的。同样的测试案例,用我们上一讲介绍的 [httptest](https://github.com/qiniu/httptest) 测试框架实现如下:
```
func TestNewDrawing(t *testing.T) {
...
ctx := httptest.New(t)
ctx.Exec(
`
post http://qpaint.com/drawings
ret 200
json '{&quot;id&quot;: &quot;10001&quot;}'
`)
}
```
当然,实际我们应该去测试更多的情况,比如:
```
func TestService(t *testing.T) {
...
ctx := httptest.New(t)
ctx.Exec(
`
post http://qpaint.com/drawings
ret 200
json '{
&quot;id&quot;: $(id1)
}'
match $(line1) '{
&quot;id&quot;: &quot;1&quot;,
&quot;line&quot;: {
&quot;pt1&quot;: {&quot;x&quot;: 2.0, &quot;y&quot;: 3.0},
&quot;pt2&quot;: {&quot;x&quot;: 15.0, &quot;y&quot;: 30.0},
&quot;style&quot;: {
&quot;lineWidth&quot;: 3,
&quot;lineColor&quot;: &quot;red&quot;
}
}
}'
post http://qpaint.com/drawings/$(id1)/shapes
json $(line1)
ret 200
get http://qpaint.com/drawings/$(id1)/shapes/1
ret 200
json $(line1)
`)
if !ctx.GetVar(&quot;id1&quot;).Equal(&quot;10001&quot;) {
t.Fatal(`$(id1) != &quot;10001&quot;`)
}
}
```
这个案例我们想演示什么?这是一个相对复杂的案例。首先我们创建了一个 drawing并且将 drawingID 放到变量 `$(id1)` 中。随后,我们向该 drawing 中添加了一条直线 `$(line1)`。为了确认添加成功,我们取出了该图形对象,并且判断取得的图形和添加进去的 `$(line1)` 是否一致。
另外,它也演示了 qiniutest DSL 脚本和 Go 语言代码的互操作性。我们用 Go 代码取得变量 `$(id1)`,并且判断它是否和 "10001" 相等。
关于 qiniutest 更多的内容,请查阅以下资料:
- [https://github.com/qiniu/httptest](https://github.com/qiniu/httptest)
- [https://github.com/qiniu/qiniutest](https://github.com/qiniu/qiniutest)
- 演讲稿:[http://open.qiniudn.com/qiniutest.pdf](http://open.qiniudn.com/qiniutest.pdf)
在我们的测试代码中,还使用了一个七牛云开源的 mockhttp 组件,它也非常有意思:
- [https://github.com/qiniu/x/blob/v8.0.1/mockhttp/mockhttp.go](https://github.com/qiniu/x/blob/v8.0.1/mockhttp/mockhttp.go)
这个 mockhttp 并不真去监听端口,感兴趣的同学可以研究一下。
## 结语
我们总结一下今天的内容。
从今天开始我们会一步步将之前写的 mock 服务端改造为真实的服务端程序。
我们第一步改造的是 RPC 框架和单元测试。这样我们第一次开始依赖第三方的代码库,如下:
- [http://github.com/qiniu/http](http://github.com/qiniu/http) (用到 restrpc
- [http://github.com/qiniu/qiniutest](http://github.com/qiniu/qiniutest)
- [http://github.com/qiniu/x](http://github.com/qiniu/x) (用到 mockhttp
一旦有了外部依赖,我们就需要考虑依赖库的版本管理。好的一点是大多数现代语言都有很好的版本管理规范,对于 Go 语言我们用 go mod 来做版本管理。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲开始我们继续实战。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,295 @@
<audio id="audio" title="42 | 实战(二):“画图”程序后端实战" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/fa/8bf176376617f8d1b1e868fb0d6c80fa.mp3"></audio>
你好,我是七牛云许式伟。
在上一章,我们实现了一个 mock 版本的服务端,代码如下:
- [https://github.com/qiniu/qpaint/tree/v31/paintdom](https://github.com/qiniu/qpaint/tree/v31/paintdom)
接下来我们将一步步迭代,把它变成一个产品级的服务端程序。
我们之前已经提到,服务端程序的业务逻辑被分为两层:底层是业务逻辑的实现层,通常我们有意识地把它组织为一颗 DOM 树。上层则是 RESTful API 层,它负责接收用户的网络请求,并转为对底层 DOM 树的方法调用。
上一讲我们关注的是 RESTful API 层。我们为了实现它,引入了 RPC 框架[ restrpc](https://github.com/qiniu/http) 和单元测试框架 [qiniutest](https://github.com/qiniu/qiniutest)。
这一讲我们关注的是底层的业务逻辑实现层。
## 使用界面(接口)
我们先看下这一层的使用界面(接口)。从 DOM 树的角度来说,在这一讲之前,它的逻辑结构如下:
```
&lt;Drawing1&gt;
&lt;Shape11&gt;
...
&lt;Shape1M&gt;
...
&lt;DrawingN&gt;
```
从大的层次结构来说只有三层:
- Document =&gt; Drawing =&gt; Shape
那么,在引入多租户(即多用户,每个用户有自己的 uid之后的 DOM 树,会发生什么样的变化?
比如我们是否应该把它变成四层:
- Document =&gt; User =&gt; Drawing =&gt; Shape
```
&lt;User1&gt;
&lt;Drawing11&gt;
&lt;Shape111&gt;
...
&lt;Shape11M&gt;
...
&lt;Drawing1N&gt;
...
&lt;UserK&gt;
```
我的答案是:多租户不应该影响 DOM 树的结构。所以正确的设计应该是:
```
&lt;Drawing1&gt;, 隶属于某个&lt;uid&gt;
&lt;Shape11&gt;
...
&lt;Shape1M&gt;
...
&lt;DrawingN&gt;, 隶属于某个&lt;uid&gt;
```
也就是说,多租户只会导致 DOM 树多了一些额外的约定,通常我们应该把它看作某种程度的安全约定,避免访问到没有权限访问到的资源。
所以多租户不会导致 DOM 层级变化,但是它会导致接口方法的变化。比如我们看 Document 类的方法。之前Document 类接口看起来是这样的:
```
func (p *Document) Add() (drawing *Drawing, err error)
func (p *Document) Get(dgid string) (drawing *Drawing, err error)
func (p *Document) Delete(dgid string) (err error)
```
现在它变成了:
```
// Add 创建新drawing。
func (p *Document) Add(uid UserID) (drawing *Drawing, err error)
// Get 获取drawing。
// 我们会检查要获取的drawing是否为该uid所拥有如果不属于则获取会失败。
func (p *Document) Get(uid UserID, dgid string) (drawing *Drawing, err error)
// Delete 删除drawing。
// 我们会检查要删除的drawing是否为该uid所拥有如果不属于删除会失败。
func (p *Document) Delete(uid UserID, dgid string) (err error)
```
正如注释中说的那样,传入 uid 是一种约束,我们无论是获取还是删除 drawing ,都会看这个 drawing 是不是隶属于该用户。
对于 QPaint 程序来说Document 类之外其他类的接口倒是没有发生变化。比如 Drawing 类的接口如下:
```
func (p *Drawing) GetID() string
func (p *Drawing) Add(shape Shape) (err error)
func (p *Drawing) List() (shapes []Shape, err error)
func (p *Drawing) Get(id ShapeID) (shape Shape, err error)
func (p *Drawing) Set(id ShapeID, shape Shape) (err error)
func (p *Drawing) SetZorder(id ShapeID, zorder string) (err error)
func (p *Drawing) Delete(id ShapeID) (err error)
func (p *Drawing) Sync(shapes []ShapeID, changes []Shape) (err error)
```
但是这只是因为 QPaint 程序的业务逻辑比较简单。虽然我们需要极力避免接口因为多租户而产生变化,但是这种影响有时候却是不可避免的。
另外,在描述类的使用界面时,我们不能只描述语言层面的约定。比如上面的 Drawing 类我们引用图形Shape对象时用的是 Go 语言的 interface。如下
```
type ShapeID = string
type Shape interface {
GetID() ShapeID
}
```
但是是不是这一接口就是图形Shape的全部约束
答案显然不是。
我们先看一个最基本的约束:考虑到 Drawing 类的 List 和 Get 返回的 Shape 实例,会被直接作为 RESTful API 的结果返回。所以Shape 已知的一大约束是,其 json.Marshal 结果必须符合 API 层的预期。
至于在“实战二”的代码实现下,我们对 Shape 完整的约束是什么样的,欢迎你留言讨论。
## 数据结构
明确了使用界面,下一步就要考虑实现相关的内容。可能大家都听过这样一个说法:
>
程序 = 数据结构 + 算法
它是一个很好的指导思想。所以当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。
我们先看数据结构。
对于服务端程序,数据结构不完全是我们自己能够做主的。在 “[36 | 业务状态与存储中间件](https://time.geekbang.org/column/article/127490)”这一讲中我们说过,存储即数据结构。所以,服务端程序在数据结构这一点上,最为重要的一件事是选择合适的存储中间件。然后我们再在该存储中间件之上组织我们的数据。
对于 QPaint 的服务端程序来说,我们选择了 mongodb。
为何是 mongodb而不是某种关系型数据库
最重要的理由是因为图形Shape对象的开放性。因为图形的种类很多它的 Schema 不是我们今天所能够提前预期的。故此,文档型数据库更为合适。
确定了基于 mongodb 这个存储中间件我们下一步就是定义表结构。当然表Table是在关系型数据库中的说法在 mongodb 中我们叫集合Collection。但是出于惯例我们很多时候还是以 “定义表结构” 一词来表达我们想干什么。
我们定义了两个表Collectiondrawing 和 shape。其中drawing 表记录所有的 drawing而 shape 表记录所有的 shape。具体如下
<img src="https://static001.geekbang.org/resource/image/9f/5b/9ffb0216c8f979633347484bc920d35b.png" alt="">
我们重点关注索引的设计。
在 drawing 表中,我们为 uid 建立了索引。这个比较容易理解:虽然目前我们没有提供 List 某个用户所有 drawing 的方法,但这是迟早的事情。
在 shape 表中,我们为 (dgid, spid) 建立了联合唯一索引。这是因为 spid 作为 ShapeID ,是 drawing 内部唯一的,而不是全局唯一的。所以,它需要联合 dgid 作为唯一索引。
## 算法
谈清楚了数据结构,我们接着聊算法。
在 “程序 = 数据结构 + 算法” 这个说法中,“算法” 指的是什么?
在架构过程中,需求分析阶段,我们关注用户需求的精确表述,我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。
到了详细设计阶段,角色和用户故事就变成了子系统、模块、类或者函数的使用界面(接口)。我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也有自然的延续性。
所以算法,最直白的含义,指的是用户故事背后的实现机制。
数据结构 + 算法,是为了满足最初的角色与用户故事定义,这是架构的详细设计阶段核心关注点。以下是一些典型的用户故事:
**创建新drawing (uid):**
```
dgid = newObjectId()
db.drawing.insert({_id: dgid, uid: uid, shapes:[]})
return dgid
```
**取得drawing的内容 (uid, dgid):**
```
doc = db.drawing.findOne({_id: dgid, uid: uid})
shapes = []
foreach spid in doc.shapes {
o = db.shape.findOne({dgid: dgid, spid: spid})
shapes.push(o.shape)
}
return shapes
```
**删除drawing (uid, dgid):**
```
if db.drawing.remove({_id: dgid, uid: uid}) { // 确保用户可删除该drawing
db.shape.remove({dgid: dgid})
}
```
**创建新shape (uid, dgid, shape):**
```
if db.drawing.find({_id: dgid, uid: uid}) { // 确保用户可以操作该drawing
db.shape.insert({dgid: dgid, spid: shape.id, shape: shape})
db.drawing.update({$push: {shapes: shape.id}})
}
```
**删除shape (uid, dgid, spid):**
```
if db.drawing.find({_id: dgid, uid: uid}) { // 确保用户可以操作该drawing
if db.drawing.update({$pull: {shapes: spid}}) {
db.shape.remove({dgid: dgid, spid: spid})
}
}
```
这些算法的表达整体是一种伪代码。但它也不完全是伪代码。如果大家用过 mongo 的 shell 的话,其实能够知道这里面的每一条 mongo 数据库操作的代码都是真实有效的。
另外,从严谨的角度来说,以上算法中凡是涉及到多次修改操作的,都应该以事务形式来做。比如删除 drawing 的代码:
```
if db.drawing.remove({_id: dgid, uid: uid}) { // 确保用户可删除该drawing
db.shape.remove({dgid: dgid})
}
```
假如第一句 drawing 表的 remove 操作执行成功,但是在此时发生了故障停机事件导致 shape 表的 remove 没有完成,那么从用户的业务逻辑角度来说一切都正常,但是从系统维护的角度来说,系统残留了一些孤立的 shape 对象,永远都没有机会被清除。
## 网络协议
考虑到底层的业务逻辑实现层已经支持多租户,我们网络协议也需要做出相应的修改。这一讲我们只做最简单的调整,引入一个 mock 的授权机制。如下:
```
Authorization QPaintStub &lt;uid&gt;
```
既然有了 Authorization那么我们就不能继续用 restrpc.Env 作为 RPC 请求的环境了。我们自己实现一个 Env如下
```
type Env struct {
restrpc.Env
UID UserID
}
func (p *Env) OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error {
auth := req.Header.Get(&quot;Authorization&quot;)
pos := strings.Index(auth, &quot; &quot;)
if pos &lt; 0 || auth[:pos] != &quot;QPaintStub&quot; {
return errBadToken
}
uid, err := strconv.Atoi(auth[pos+1:])
if err != nil {
return errBadToken
}
p.UID = UserID(uid)
return p.Env.OpenEnv(rcvr, w, req)
}
```
把所有的 restrpc.Env 替换为我们自己的 Env再对代码进行一些微调Document 类的调用增加 env.UID 参数),我们就完成了基本的多租户改造。
改造后完整的 RESTful API 层代码如下:
- [https://github.com/qiniu/qpaint/blob/v42/paintdom/service.go](https://github.com/qiniu/qpaint/blob/v42/paintdom/service.go)
## 结语
总结一下今天的内容。
今天我们主要改造的是底层的业务逻辑实现层。
一方面我们对使用界面接口作了多租户的改造。多租户改造从网络协议角度来说主要是增加授权Authorization。从底层的 DOM 接口角度来说,主要是 Document 类增加 uid 参数。
另一方面,我们基于 mongodb 完成了新的实现。我们对数据结构和算法作了详细的描述。要更完整了解实现细节,请重点阅读以下两个文件:
- [https://github.com/qiniu/qpaint/blob/v42/paintdom/README_IMPL.md](https://github.com/qiniu/qpaint/blob/v42/paintdom/README_IMPL.md)
- [https://github.com/qiniu/qpaint/blob/v42/paintdom/drawing.go](https://github.com/qiniu/qpaint/blob/v42/paintdom/drawing.go)
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲开始我们继续实战。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,156 @@
<audio id="audio" title="43 | 实战(三):“画图”程序后端实战" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/f1/4dbb193e66602e77a902c696f792acf1.mp3"></audio>
你好,我是七牛云许式伟。
在上一章,我们实现了一个 mock 版本的服务端,代码如下:
- [https://github.com/qiniu/qpaint/tree/v31/paintdom](https://github.com/qiniu/qpaint/tree/v31/paintdom)
我们这一章实战的目标,是要把它改造成一个产品级的服务端程序。
前面两讲,我们一讲谈了 RESTful API 层一讲谈了底层是业务逻辑的实现层。今天我们要谈的是帐号Account与认证Authorization
我们之前实现的 mock 版本服务端是匿名可访问的,不需要授权。在上一讲,我们开始引入了多租户,但为了简化,用的是一种 mock 的认证方式。
接下来我们就要动真格了。
但在此之前,我们仍然要先理解一下帐号和认证这两个概念。虽然这是两个大家非常耳熟能详的东西。
## 帐号Account
帐号,简单说就是某种表征用户身份的实体,它代表了一个“用户”。虽然一个物理的自然人用户可能会在同一个网站开多个帐号,但从业务角度,我们往往把这些帐号看作不同的用户。
互联网帐号的表征方式有很多,比较常见的有:
- 电子邮件;
- 手机号;
- 用户自定义的网络 ID
- 自动分配的唯一 ID。
前三者大家容易理解。对于自动分配的 UUID其实最典型的是银行。你的银行帐号从来都不是你自己定义的而是预先分配好的一个卡号。
当然还有一些冷门的选择。比如有的网站选择用身份证号作为帐号 ID这通常发生在政府公共服务类的业务。
## 授权Authorization
那么授权是什么?授权是帐号对服务的访问方式。
从这句话字面去理解,授权和帐号相关。有帐号,就会有授权。但是帐号和授权并不是对应的关系。同一个帐号,可能会有多种授权。
常见的授权机制有哪些?
前面我们在 “[40 | 服务端的业务架构建议](https://time.geekbang.org/column/article/134384)” 这一讲提过,当前主流的授权方式有两种:一种是基于 Token一种是基于 AK/SK。
但实际上还有一种最常见的授权机制没有被提到,那就是:用户名+密码。
这里的 “用户名” 其实就是指 “帐号”。
当然,没有提的原因是因为当时我们是在讨论网络 API 协议的授权机制选择。我们在业界基本上看不到用 “用户名+密码” 来作为网络 API 的授权机制。
为什么不用?因为不安全。假如在每一次 API 请求中都带上密码,那么显然密码泄漏的概率会更大。
所以,安全性上的需求会导致我们倾向于尽可能减少密码在网络中传输的次数。“用户名+密码” 这种授权方式,必然会以尽可能少的频率去使用。
哪些情况会用 “用户名+密码” 授权?
其一登录login。对于一个 Web 应用而言,授权的第一步是登录。登录最经典的方式就是 “用户名+密码” 授权。
“用户名+密码” 授权往往只发生在登录那一下登录后就会生成一个会话Session用途的 Cookie。此后 Web 应用的授权都基于 Session直到 Session 过期。
抱歉,我们的词汇有点贫乏。这里说的 Session 授权,和浏览器引入的 Session 不是一回事。Session 授权发生在登录之后,一般并不会随浏览器窗口的关闭而消失,往往有几天的有效期。
甚至有一些网站的 Session 有效期会自动顺延。也就是说只要你在会话期内活跃的话Session 授权就不会过期。超时时间从你最后一次活动算起,只有你连续几天都不活跃才会导致 Session 过期。
其二,作为 Token 授权的入口。其实 RESTful API 层中的 Token 授权,和 Web 应用中的 Session 授权的地位是非常像的。
Session 授权会有过期时间Token 授权也会有过期时间。Session 授权有自动顺延Token 授权有 Refresh。Session 授权的典型入口是登录loginToken 授权也一样有 “用户名+密码” 授权这个入口。
这样来看Token 授权和 Session 授权的差别只是应用场景不同,一个用于 API 层,一个用于 Web。而这也导致承载它们的机制有些不同Token 授权基于 HTTP 的 Authorization 头,而 Session 授权则基于 Cookie。
## OAuth 2.0
由于 QPaint 程序是一个 To C 的应用,所以在 API 层的授权机制选择上,我们很自然会选择 Token 授权。
当前推荐的 Token 授权标准是 OAuth 2.0,它得到了广泛的支持,如果你在使用各类 C 端应用程序的开放接口,会发现它们往往都是基于 OAuth 2.0 的。
有两种场景下我们会考虑 OAuth 2.0。
第一种场景,也是 OAuth 的核心场景,就是提供开放接口。
对于一个服务提供方来说,通过推广自己的 App ,来让更多用户使用自己的服务是一个常规的办法。但还有一个非常值得考虑的方式,就是把服务以 API 方式开放出来,让更多的 App 接入自己的服务。
一旦我们希望授权第三方应用程序来调用我们的服务,最好的选择是 OAuth 2.0。
第二种场景,是作为 OpenID 提供方。也就是说,第三方应用接入我的 OAuth 接口,并不是为了要调用我的什么能力,而只是为了复用我的用户。
这当然不是谁都能够做得到的,还是要有足够大的用户基数,并且有一定的入口价值才有可能被接受。国内被广泛使用的典型 OpenID 提供方有:
- 微信和 QQ
- 支付宝;
- 新浪微博。
为了支持 OAuth 2.0 作为 OpenID 的场景OpenID Foundation 还专门引入了 OpenID Connect 协议规范。详细资料如下:
- [https://openid.net/connect/](https://openid.net/connect/)
今天我们重点还是关注 OAuth 2.0 的核心场景。它涉及到以下三个角色:
- 服务提供商。包括授权服务Authorization Server和资源服务Resource Server
- 终端用户也就是资源拥有方Resource Owner。终端用户是服务提供商的用户它的资源也存在于服务提供商提供的服务中。但是这些资源的归属是属于终端用户的所以我们称之为资源拥有方。
- 第三方应用也就是客户端Client。在 OAuth 的视角中,官方应用和第三方应用并无大的区别,以相同的机制在工作。从这一点来说,称之为客户端会更加合理。
这三个角色交互的基本场景是:
首先第三方应用也就是客户端Client向服务提供商提出接入申请。这一步可以理解为类似把 App 注册到应用商店的过程,每个应用只需要做一次。
然后客户端Client向终端用户也就是资源拥有方Resource Owner申请访问权限。这个申请发生在服务提供商提供的环境中所以服务提供商可以感知资源拥有方是拒绝还是接受了客户端的请求。
然后客户端Client向服务提供商的授权服务Authorization Server发起授权请求并得到了可用于访问资源的 Token。
最后客户端Client通过 Token 向服务提供商的资源服务Resource Server发起资源访问请求。
整个过程的具体流程如下:
<img src="https://static001.geekbang.org/resource/image/48/01/489deed0e9dc2d8464112cd0cd3b4801.png" alt="">
A终端用户打开客户端以后客户端要求终端用户给予授权。<br>
B终端用户同意给予客户端授权。<br>
C客户端使用上一步获得的授权向认证服务器申请令牌Token<br>
D认证服务器对客户端进行认证以后确认无误同意发放令牌。<br>
E客户端使用令牌向资源服务器申请获取资源。<br>
F资源服务器确认令牌无误同意向客户端开放资源。
这个图体现了 OAuth 2.0 的核心思想。但不同场景下,具体的授权流程有一定的差异。常见的授权模式有如下几种:
- 授权码模式Authorization Code
- 简化模式Implicit
- 用户名+密码模式Resource Owner Password Credentials
- 客户端模式Client Credentials
- 访问令牌Access Token
- 更新令牌Refresh Token
其中基于访问令牌Access Token的授权模式是最核心的一种请求频率最大。更新令牌Refresh Token则次之。每次访问令牌Access Token失效后通过更新令牌Refresh Token获得新的访问令牌Access Token
其他所有的授权方式是在不同场景下的授权入口。通过这些授权入口的任何一个都可以同时获得访问令牌Access Token和更新令牌Refresh Token
用户名+密码模式Resource Owner Password Credentials不用过多解释这是我们最为熟悉的一种授权方式。
我们重点解释下授权码模式Authorization Code这是 OAuth 作为第三方开放接口用的最多的一种场景。它的业务流程如下:
<img src="https://static001.geekbang.org/resource/image/0e/fe/0e357b47943b75dae1666b90a55aabfe.png" alt="图片: https://uploader.shimo.im/f/7kw35bAyIoseOFoz.png">
A终端用户访问某个网站客户端通常是一个标准的浏览器将终端用户重定向到认证服务。<br>
B终端用户选择是否给予该网站相应的授权。<br>
C如果授权认证服务器将用户导向网站事先指定好的 “重定向URI”Redirection URI同时附上一个授权码。<br>
D该网站收到授权码附上早先的 “重定向URI”向认证服务器申请令牌。这一步是在网站的后端服务器上完成的对终端用户不可见。<br>
E认证服务器核对了授权码和重定向URI确认无误后网站的后端服务器返回访问令牌access token和更新令牌refresh token
此后,该网站就可以通过后端服务器去访问相应的服务了。
## 结语
今天我们主要聊了帐号与授权相关的基础体系,重点介绍 OAuth 2.0 背后的逻辑。下一讲我们会讨论如何基于 OAuth 来完成 QPaint 的帐号与授权机制。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲开始我们继续实战。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,137 @@
<audio id="audio" title="44 | 实战(四):“画图”程序后端实战" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/8f/a4b9f2yye7a42a51caef39b55994278f.mp3"></audio>
你好,我是七牛云许式伟。
上一讲我们介绍了帐号与授权相关的基础体系,并重点介绍 OAuth 2.0 背后的逻辑。今天我们开始考虑如何让 QPaint 引入帐号与授权体系。
最常规的做法,当然是自己建立一个帐号数据库,做基于用户名+密码的登录授权并转为基于Cookie的会话Session。示例如下
- [https://github.com/qiniu/qpaint/tree/v44-bear](https://github.com/qiniu/qpaint/tree/v44-bear)
- [https://github.com/qiniu/qpaint/compare/v42...v44-bear](https://github.com/qiniu/qpaint/compare/v42...v44-bear)
但我们考虑提供 Open API 的话,就需要考虑遵循 OAuth 2.0 的授权协议规范,以便第三方应用可以快速接入,而不是搞半天去研究我们自己发明的授权是怎么回事。
除此之外,我们也可以考虑基于微信、支付宝等 OpenID 来实现用户的快速登录,而不是让用户在注册环节折腾半天。
所以,比较理想的方式是我们基于[ OpenID Connect](https://openid.net/connect/) 协议来提供帐号系统,基于 OAuth 2.0 协议来实现 [Open API ](https://oauth.net/2/)体系。
这个选择与业务无关。所以很自然地,我们决定评估一下,看看是否有开源项目和我们想得一样。
最后,我们发现 CoreOS 团队搞了一个叫 dex 的项目,如下:
- [https://github.com/dexidp/dex](https://github.com/dexidp/dex)
- [https://github.com/xushiwei/dex](https://github.com/xushiwei/dex) (部分依赖库受 GFW 的影响,我们调整 Makefile 改为基于 go -mod=vendor 来编译。)
dex 项目的这么描述自己的:
>
<p>dex - A federated OpenID Connect provider<br>
OpenID Connect Identity (OIDC) and OAuth 2.0 Provider with Pluggable Connectors.</p>
>
Dex is an identity service that uses OpenID Connect to drive authentication for other apps. Dex acts as a portal to other identity providers through "connectors." This lets dex defer authentication to LDAP servers, SAML providers, or established identity providers like GitHub, Google, and Active Directory. Clients write their authentication logic once to talk to dex, then dex handles the protocols for a given backend.
概要来说dex 基于各类主流的 OpenID 来提供帐号系统,上游的 OpenID Provider即下图中的 Upstream IdP是以插件方式Pluggable Connector提供。这也是为什么把它叫联邦 OpenIDfederated OpenID的原因。然后dex 再通过 OAuth 2.0 协议对客户端(即下图中的 Client app提供授权服务。
<img src="https://static001.geekbang.org/resource/image/08/7c/08f27c67c945d18b16bdcb6e61c22a7c.png" alt="图片: https://uploader.shimo.im/f/8SVN4368jw0ZFDNG.png">
## 联邦 OpenID
我们先看 dex 在联邦 OpenID 这块的支持。当前已经支持的 Pluggable Connector 如下:
<img src="https://static001.geekbang.org/resource/image/80/d1/80204fe57a0fb569a258e98a3fe4d3d1.png" alt="">
可以看出,对于那些支持 [OpenID Connect](https://openid.net/connect/) 协议的 OpenID比如 Google、Saleforce、Azure 等,可以统一用同一个 Connector 来支持。而对于其他的 OpenID比如 Github则实现一个独立的 Connector 来支持。
除了 OpenID Connect我们也可以看到很多耳熟能详的开放帐号授权协议比如在前面课程中有人提议讲一讲的单点登录 SAML 2.0 和 LDAP。但这的确不是我们的重点。我们这里提供相关的链接供大家参考。
LDAP 的资料如下:
- [https://www.openldap.org/](https://www.openldap.org/)
SAML 2.0 Web Browser Single-Sign-On 的资料如下:
- [https://en.wikipedia.org/wiki/SAML_2.0](https://en.wikipedia.org/wiki/SAML_2.0)
- [http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html)
不同的 OpenID Provider 作为后端,会导致一些细节上的差异。有的 OpenID Provider 不支持更新令牌Refresh Token有的会导致 ID Token 不支持 groups 字段。详细在以上 Connector 列表中有明确说明。
另外,虽然 dex 支持了颇为丰富的 OpenID Provider但不幸的是国内的主流 OpenID Provider比如微信和支付宝都没有在支持之列。
不过好在它基于开放的插件机制我们可以自己依葫芦画瓢实现一个。Pluggable Connector 相关的文档和插件如下:
- [https://godoc.org/github.com/dexidp/dex/connector](https://godoc.org/github.com/dexidp/dex/connector)
国内也会有人想到做类似 dex 这种项目,比如:
- [https://github.com/tiantour/union](https://github.com/tiantour/union)
看到我们熟悉的微信、支付宝、新浪微博了,所以想到点子并不难,但看架构设计就会看到两者巨大的差距。
当然,如果你看到了其他很好的开源实现,欢迎留言交流。
## 提供 OpenID + OAuth 2.0 服务
尽管 dex 底层所基于的 OpenID Provider 多种多样,但是 dex 对外统一提供了标准的 [OpenID Connect](https://openid.net/connect/) 协议和 [OAuth 2.0](https://oauth.net/2/) 服务。
OpenID Connect 作为 OAuth 2.0 的一个扩展最重要的一个改进是引入了身份令牌ID Token概念。
为什么需要扩展 OAuth 2.0
因为 OAuth 2.0 本身只关心授权所以它会返回访问令牌Access Token和更新令牌Refresh Token。但无论是访问令牌还是更新令牌都并没有包含身份Identity信息。没有身份信息就没法作为 OpenID Provider。
身份令牌ID Token解决了这一问题。ID Token 是一个 [JSON Web Token (JWT)](https://jwt.io) ,支持你对 Token 进行解码decode并验证verify用户身份。关于 JSON Web Token 的详细介绍,请参阅 [https://jwt.io/](https://jwt.io/) 。
dex 并不是一个包package而是一个可执行程序application它提供了帐号与授权服务。你可以这样运行它
```
dex config.yaml
```
其中 config.yaml 是它的配置文件。其格式可参考以下这些样例:
- [examples/config-dev.yaml](https://github.com/xushiwei/dex/blob/master/examples/config-dev.yaml)(开发用途,用 mock 的帐号与授权服务。)
- [examples/config-ldap.yaml](https://github.com/xushiwei/dex/blob/master/examples/config-ldap.yaml)(基于 LDAP 来做帐号与授权服务。)
## 使用 dex
有了 dex 服务,我们就可以开始回到 QPaint 业务,去支持帐号与授权了。
我们并不需要自己开发太多东西。
OAuth 2.0 的客户端 SDKGo 语言自己有一个准官方的版本。如下:
- 包名:[golang.org/x/oauth2](https://godoc.org/golang.org/x/oauth2)
- 项目地址:[https://github.com/golang/oauth2/](https://github.com/golang/oauth2/)
OpenID Connect 的客户端 SDKCoreOS 团队也开发了一个。如下:
- [https://github.com/coreos/go-oidc](https://github.com/coreos/go-oidc)
具体如何对接 dexCoreOS 团队也写了一个详细的说明文档。如下:
- [https://github.com/xushiwei/dex/blob/master/Documentation/using-dex.md](https://github.com/xushiwei/dex/blob/master/Documentation/using-dex.md)
有了这些 SDK 和 dex 的使用说明,具体 QPaint 业务怎么对接 dex就比较简单了。我们这里就不详细展开详细代码请参考
- [https://github.com/qiniu/qpaint/tree/v44](https://github.com/qiniu/qpaint/tree/v44)
- [https://github.com/qiniu/qpaint/compare/v42...v44](https://github.com/qiniu/qpaint/compare/v42...v44)
## 结语
总结一下今天的内容。
今天我们主要讨论如何基于 OAuth 2.0 来改造 QPaint 的帐号与授权机制。实际上这方面业界有非常成熟的实践,所以我们没有太大的必要去自己重新造一个轮子。我们的核心思路是,基于 [OpenID Connect](https://openid.net/connect/) 协议来提供帐号系统,基于 [OAuth 2.0](https://oauth.net/2/) 协议来实现 Open API 体系。
我们不只是用标准的协议背后的实现也基于开源项目CoreOS 团队开发的 dex。
- [https://github.com/dexidp/dex](https://github.com/dexidp/dex)
这样,我们就可以把关注的重心放在 QPaint 业务本身上。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。我们服务端程序的实战到这里就要结束了。下一讲聊一聊 “架构:怎么做详细设计” 这个话题。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,191 @@
<audio id="audio" title="45 | 架构:怎么做详细设计?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5a/cb/5aa0400c71fa490c142c60b465b41ecb.mp3"></audio>
你好,我是七牛云许式伟。
我们第三章 “服务端开发篇” 就快要结束了。我们原计划的第三章会分拆为两章:
- 第三章:服务端开发篇。主要介绍服务端的基础架构与业务架构。
- 第四章:服务治理篇。主要介绍服务端程序上线与线上服务如何管理的问题。
原先计划的 “第五章:通用架构范式篇” 会取消,核心内容会融合到其他的章节中。详细的调整结果,近期我们会与大家同步新的大纲。
今天我们把话题重新回到架构上。
关于架构,前面我们已经聊了第一步的需求分析和第二步系统的概要设计:
- [17 | 架构:需求分析(上)](https://time.geekbang.org/column/article/100140)
- [18 | 架构:需求分析(下)- 实战案例](https://time.geekbang.org/column/article/100930)
- [32 | 架构:系统的概要设计](https://time.geekbang.org/column/article/117783)
需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。
需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义将明确产品的元素,明确产品的边界,与产业上下游、合作伙伴的分工。
在需求分析阶段,我们关注用户需求的精确表述。我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。
在概要设计阶段,我们一般以子系统为维度来阐述系统各个角色之间的关系。对于关键的子系统,我们还会进一步分解它,甚至详细到把该子系统的所有模块的职责和接口都确定下来。
这个阶段我们的核心意图并不是确定系统完整的模块列表,我们的焦点是整个系统如何被有效地串联起来。如果某个子系统不做进一步的分解也不会在项目上有什么风险,那么我们并不需要在这个阶段对其细化。
为了降低风险,概要设计阶段也应该有代码产出。
这样做的好处是,一上来我们就关注了全局系统性风险的消除,并且给了每个子系统或模块的负责人一个更具象且确定性的认知。
代码即文档。代码是理解一致性更强的文档。
经过系统的概要设计,整个系统的概貌就了然于胸了。详细设计阶段,是需要各个子系统或模块的负责人,对他负责的部分进行进一步的细化。
详细设计关注的是子系统或模块的全貌。
请记住,详细设计并不是只谈实现就完事,更不是一个架构图。它包括以下这些内容。
<li>
**现状与需求**
<ul>
- 现在在哪里,遇到了什么问题,要做何改进。
**需求满足方式**
- 要做成啥样?交付物的规格,或者说使用界面(接口)。
- 怎么做到?交付物的实现原理。
概要设计和详细设计的工作内容会有一定的重叠。
概要设计的核心目标是串联整个系统,消除系统的重大风险。在这个过程中,对一些关键模块的实现细节有所考虑是非常正常的。但从另一个角度来说,分解粒度也不能过粗,不应该把特别庞大的子系统直接分出去,这样项目执行的风险就太高了。
但两者的分工不同,考虑的问题重心不同。
比如,从使用界面(接口)来说,概要设计不一定会把子系统或模块的完整接口都列出来,实际上它只关注最核心的部分。但是从详细设计角度来说,接口描述的完备性是必需的。
## 现状与需求
我们先看看现状与需求。
从逻辑自洽的角度,我们任何一篇文档,首先关注的都应该是要解决的问题与目标。
现状与需求的陈述,要简明扼要。
现状大家都知道,所以不要长篇累牍。更多的是陈述与我们要做的改变相关的重要事实,侧重点在于强调这些事实的存在性和重要性。
比如,假设我们要对某个模块重构。那么,现状就是要谈清楚现在的业务架构是怎样的?它到底有什么样的问题。
需求陈述是对痛点和改进方向的一次共识确认。痛点只要够痛,大家都知道,所以同样不需要长篇累牍。
每个子系统或模块,都有自己的角色分工与用户故事。我们不用重新做一遍需求分析,但对需求分析的核心结论,在详细设计开始之前需要明确。
这很重要。它是我们详细设计所要满足的业务目标。
## 使用界面(接口)
聊完了现状与需求,接着我们就要谈需求的满足方式。它分两个方面:一方面是交付物的规格,或者说使用界面(接口)。另一方面是背后的实现原理,我们怎么做到的。
规格,或者说使用界面,体现的是别人要怎么使用我。
我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也要有自然的延续性。
使用界面这一部分要详细写,它是团队共识确认的关键。
我们的交付物有哪些可执行文件有哪些包package如果可执行文件那么它是一个界面程序还是服务如果是服务网络协议是什么样的如果是包它又包含哪些公开的类或函数。
在 “[32 | 架构:系统的概要设计](https://time.geekbang.org/column/article/117783)” 这一讲中,我们花了非常长的篇幅介绍使用界面(接口)是怎么回事,今天我们就不对这一点进行展开。
需要强调的是,使用界面需要有明确的书写规范。它也是团队共识管理的重要组成,是团队效率、团队默契形成的象征。
更需要强调的是,使用界面的稳定是至关重要的。
接口的变更需谨慎!
对使用界面的不兼容调整,可能出现严重的后果。技术上,可能会导致客户异常,出现编译失败需要重写代码,或者更严重的是,可能导致他们的系统崩溃。商业上,则可能导致大量的客户流失。
## 实现:数据结构+算法
聊完使用界面,接下来就要谈实现原理了,它要体现的是我如何做到。
在 “[42 | 实战(二):“画图”程序后端实战](https://time.geekbang.org/column/article/136884)” 一讲中,我们提到过以下这个大家耳熟能详的公式:
>
程序 = 数据结构 + 算法
它是一个很好的指导思想。当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。
我们先看数据结构。
数据结构从大的层面分,可分为基于内存的数据结构,和基于外存(比如 SSD 盘)的数据结构。
对于桌面程序,大部分情况下我们打交道的都是基于内存的数据结构。外存数据结构也会有所涉及,但往往局限于 IO 子系统。
但对于服务端程序,数据结构不完全是我们自己能够做主的。数据结构大部分情况下都是基于外存的,而且有极高的质量要求。
在 “[36 | 业务状态与存储中间件](https://time.geekbang.org/column/article/127490)” 这一讲中我们也说过,存储即数据结构。所以,服务端程序在数据结构这一点上,最为重要的一件事是选择合适的存储中间件。然后我们再在该存储中间件之上组织我们的数据。
这是数据库这样的存储中间件流行起来的原因。无论是关系型数据库,还是文档型数据库,他们都被设计为一种泛业务场景的数据结构,有很好的业务适应性。
所以在服务端我们谈数据结构谈的不是内存数据结构往往谈的是数据库的表结构设计。当然表Table是在关系型数据库中的说法在 mongodb 中我们叫集合Collection。但不管我们用的是哪种数据库出于惯例我们往往还是以 “定义表结构” 一词来表达我们想干什么。
描述表结构,核心需要包含以下内容:
- 字段名;
- 类型;
- 字段含义,以及是否指向另一个表的某个字段;
- 索引。
你会发现,其实定义表结构和定义内存数据结构本质是完全一致的。定义内存中的一个类(或结构体),我们也关心字段名(成员变量名)和类型,也关心字段的含义,以及它是否指向另一个类(或结构体)的某个字段(成员变量)。
但表结构比内存数据结构多了一个概念:索引。
索引为何存在?我认为有这样几方面的原因。一方面是因为数据库是泛业务场景的通用数据结构,它是动态的,需要依赖索引来提升数据访问的效率。另一方面是因为多租户。多租户导致数据量的爆发式增长,导致大部分情况下遍历查找变得不现实。
索引怎么设计?它完全取决于算法。算法里面使用了哪些数据访问的特征,这些数据访问的频次预期是多少,这些决定了我们添加哪些索引是最划算的。
在涉及的类比较多,或数据库的表结构比较复杂的时候,有时我们会用 UML 类图来对数据结构进行直观的呈现。
谈清楚了数据结构,我们接着聊算法。
在 “程序 = 数据结构 + 算法” 这个说法中,“算法” 指的是什么?在 “[42 | 实战(二):“画图”程序后端实战](https://time.geekbang.org/column/article/136884)” 一讲中,我们这么说:
>
在架构过程中,需求分析阶段,我们关注用户需求的精确表述,我们会引入角色,也就是系统的各类参与方,以及角色间的交互方式,也就是用户故事。
>
到了详细设计阶段,角色和用户故事就变成了子系统、模块、类或者函数的使用界面(接口)。我们前面一直在强调,使用界面(接口)应该自然体现业务需求,就是强调程序是为用户需求服务的。而我们的架构设计,在需求分析与后续的概要设计、详细设计等过程之间也有自然的延续性。
>
所以算法,最直白的含义,指的是用户故事背后的实现机制。
>
数据结构 + 算法,是为了满足最初的角色与用户故事定义,这是架构的详细设计阶段核心关注点。
那么,怎么描述一个用户故事对应的算法?
一种方式是基于 UML 时序图Sequence Diagram。以下是我个人用过的很好的在线版 UML 时序图制作工具:
- [https://www.websequencediagrams.com/](https://www.websequencediagrams.com/)
另一种方式是基于伪代码Pseudo Code。在逻辑较为复杂时伪代码往往有更好的呈现效果。比如服务端程序对数据库的 SQL 操作往往比较复杂,但是从 UML 时序图来说流程却并不长,这个时候去画 UML 时序图的意义就不大。
## 结语
今天我们聊的是怎么做详细设计。
详细设计并不是只谈实现就完事,更不是一个架构图。它包括以下这些内容。
<li>
**现状与需求**
<ul>
- 现在在哪里,遇到了什么问题,要作何改进。
**需求满足方式**
- 要做成啥样?交付物的规格,或者说使用界面(接口)。
- 怎么做到?交付物的实现原理。
“程序 = 数据结构 + 算法” 是我们很熟悉的一个公式。它其实是怎么描述实现原理的很好的指导方针。当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们对第三章 “服务端开发篇” 进行回顾与总结。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,118 @@
<audio id="audio" title="46 | 服务端开发篇:回顾与总结" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ee/ef/eebdaefa2fa73984ab37eda345fe43ef.mp3"></audio>
你好,我是七牛云许式伟。
到今天为止,我们第三章 “服务端开发篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。本章我们主要涉及的内容如下。
<img src="https://static001.geekbang.org/resource/image/c2/74/c27e45e3ed686e4f007b6df957ba1b74.png" alt="">
服务端开发这个分工,出现的历史极短。如果我们从互联网诞生算起也就 40 多年的历史。以进入民用市场为标志,它真正活跃的时段,其实只有 20 多年。
作为架构师记住这一点非常非常重要。20 多年能够形成的有效经验并不多。这意味着我们不能固步自封,很多惯例是可以被挑战的,并且最终也必然被挑战的。
作为最底层的服务端操作系统,最初从桌面操作系统而来。但桌面操作系统自身在发展,服务端操作系统自身也在发展,两者渐行渐远。
桌面的领域特征是强交互以事件为输入GDI 为输出。
所以,桌面技术的迭代,是交互的迭代,是人机交互的革命。在 “[13 | 进程间的同步互斥、资源共享与通讯](https://time.geekbang.org/column/article/97617)” 一讲中,我们介绍了桌面操作系统中进程间协同方式的变迁。如果我们从业务需求角度看,这个变迁本质上也是交互的变迁(为什么我们这么说?欢迎留言探讨)。
而服务端程序有很强烈的服务特征。它的领域特征是大规模的用户请求,以及 24 小时不间断的服务。这些都不是业务功能上的需要,是客户服务的需要。
所以服务端技术的迭代虽然一开始沿用了桌面操作系统的整套体系框架但它正逐步和桌面操作系统分道而行转向数据中心操作系统DCOS之路。
服务端技术的迭代,有一些和服务端开发相关,会影响到业务架构。而更多则和业务架构无关,属于服务治理的范畴。
服务端开发与服务治理的边界在于,服务端开发致力于设计合适的业务架构来满足用户需求,而服务治理则致力于让服务端程序健康地为客户提供不间断的服务。
关于服务治理相关的内容,我们留到下一章来介绍。
## 服务端开发篇的内容回顾
本章服务端开发篇我们讲了些什么?为了让你对第三章内容有个宏观的了解,我画了一幅图,如下。
<img src="https://static001.geekbang.org/resource/image/0b/7c/0b39991f3d579bccdf331b001cd9247c.png" alt="">
首先,从服务端开发来说,服务端程序依赖的基础软件不只是操作系统和编程语言,还多了两类:
- 负载均衡Load Balance
- 存储中间件数据库或其他形式的存储DB/Storage
<img src="https://static001.geekbang.org/resource/image/d2/91/d2e0682e63b374dde55a1eef79ee5d91.png" alt="">
负载均衡的最大价值是对客户的访问流量进行调度,让多个业务服务器的压力均衡。这里面隐含的一个前提是负载均衡软件的抗压能力往往比业务服务器强很多。 这表现在:
其一,负载均衡的实例数 / 业务服务器的实例数往往大大小于1其二DNS 的调度不均衡,所以负载均衡的不同实例的压力不均衡,有的实例可能压力很大。
当然,负载均衡的价值并不只是做流量的均衡调度,它也让我们的业务服务器优雅升级成为可能。
存储中间件即数据结构。
在服务端开发领域,有一个很知名的编程哲学,叫 “速错Fail Fast它的核心逻辑是一旦发生非预期的错误时应该立刻退出程序而不要尝试为该错误去写防御代码因为那样的话掩盖掉这个错误并导致后续可能产生更隐晦难以定位的错误。
但是 “速错Fail Fast” 是以可靠的存储中间件为前提的。没有了可靠的存储,程序重新启动后就不知道自己正在做什么事情了。所以存储是不能速错的,它的编程哲学如此不同。作为存储系统的开发者,你需要花费绝大部分精力在各种异常情况的处理上,甚至你应该认为,这些庞杂的、多样的错误分支处理,才是存储系统的 “正常业务逻辑”。
对于服务端来说,存储中间件至关重要,它是服务端程序能够提供高并发访问和 24 小时不间断服务的基础。存储中间件极大地解放了生产效率,让开发人员可以把精力放在具体的业务需求上。
虽然我们不需要自己去开发存储中间件,但是深度理解其工作原理是非常有必要的。通常来说,存储中间件也是服务端的性能瓶颈所在。几乎所有服务端程序扛不住压力,往往都是因为存储没有扛住压力。
存储中间件的种类繁多,不完整的列表如下:
- 键值存储KV-Storage
- 对象存储Object Storage
- 数据库Database
- 消息队列MQ
- 倒排索引SearchEngine
- ......
对象存储的出现是服务端体系架构和桌面操作系统分道扬镳的开始。文件系统File System不再是服务端存储中间件的标配。第一个大家公认的对象存储是 AWS S3但它只是一个基础文件存取的组件。七牛云则在此基础上推出了第一个 “对象存储+CDN+多媒体处理” 融合的 PaaS 型云存储。
理解了负载均衡和存储中间件,我们开始谈[服务端的业务架构](https://time.geekbang.org/column/article/134384)。
从业务架构的角度,服务端主要是实现一个多租户的 Model 层。Model 层本身最重要的是自然体现业务逻辑它和具体行业的领域问题相关。但服务端程序还是有它很鲜明的特点有一些和领域无关的业务架构通用问题。比如网络协议、帐号与授权、RPC 框架、单元测试等等。
为了更好地理解服务端开发的架构逻辑,我们继续以画图程序的后端开发为实战案例,进行详细展开。
作为最后收官,我们聊了架构[第三步:详细设计](https://time.geekbang.org/column/article/142032)。详细设计关注的是子系统或模块的全貌。它并不是只谈实现就完事,更不是一个架构图。它包括以下这些内容。
<li>
现状与需求
<ul>
- 现在在哪里,遇到了什么问题,要作何改进。
需求满足方式
- 要做成啥样?交付物的规格,或者说使用界面(接口)。
- 怎么做到?交付物的实现原理。
“程序 = 数据结构 + 算法” 是我们很熟悉的一个公式。它其实是怎么描述实现原理的很好的指导方针。当我们谈程序的实现时,我们总是从数据结构和算法两个维度去描述它。
## 服务端开发篇的参考资料
整体来说,尽管服务端开发所需要的知识面更广,但是就开发本身的工作量和难度而言,服务端开发要大大低于桌面开发。
但将服务端程序开发出来只是个开始。如何让服务稳定健康地运行,是一个复杂的话题。所以近年来服务端技术蓬勃发展,主要以服务治理为主。
单单从服务端开发的角度,我们除了关注服务端操作系统、编程语言,还需要关注负载均衡和存储中间件。
这里我列一下我认为值得重点关注的技术:
- Docker &amp; Kubernetes。毫无疑问数据中心操作系统DCOS是服务端操作系统的发展方向。关于 DCOS ,我们会在下一章涉及。
- Go 语言。推荐 Brian W. Kernighan 写的《Go 程序设计语言》本书为传世经典《C程序设计语言》的作者再次动笔所创。
- LVS &amp; Nginx。两大当前最主流的流量调度软件。其中 LVS 工作在网络层Nginx 工作在应用层。
- MySQL &amp; MongoDB。两大当前最主流的数据库。虽然它们的使用范式差异较大但背后的基础哲学实际上是相通的。
- 对象存储。推荐 AWS S3 和 [七牛云存储](//https://www.qiniu.com)。
- 网络协议。虽然当前主流还是 RESTful API但可以适当关注 [GraphQL](https://graphql.org)。
- RPC 框架。推荐七牛云开源的 [restrpc](https://github.com/qiniu/http),以及 Google 开源的 [grpc](https://github.com/grpc/grpc-go)。
- HTTP 测试。推荐七牛云开源的 [httptest](https://github.com/qiniu/httptest) 框架和 [qiniutest](https://github.com/qiniu/qiniutest) 实用程序。
大部分的服务端技术都还在快速迭代。对于网络资料相对较多的部分,这里我就不再去给出具体的相关资料了。
## 结语
今天我们对本章内容做了概要的回顾,并借此对整个服务端开发的骨架进行了一次梳理。
这一章我们继续聊业务架构,我们把侧重点放在后端业务开发。学业务架构最好的方式是:“做中学”。做是最重要的,然后要有做后的反思,去思考并完善自己的理论体系。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们开始进入第四章:服务治理篇。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@@ -0,0 +1,246 @@
<audio id="audio" title="加餐 | 如何做HTTP服务的测试" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/21/fa/219a118f83f9677254e1281de165b9fa.mp3"></audio>
你好,我是七牛云许式伟。
基于 HTTP 协议提供服务的好处是显然的。除了 HTTP 服务有很多现成的客户端、服务端框架可以直接使用外,在 HTTP 服务的调试、测试、监控、负载均衡等领域都有现成的相关工具支撑。
在七牛,我们绝大部分的服务,包括内部服务,都是基于 HTTP 协议来提供服务。所以我们需要思考如何更有效地进行 HTTP 服务的测试。
七牛早期 HTTP 服务的测试方法很朴素:第一步先写好服务端,然后写一个客户端 SDK再基于这个客户端 SDK 写测试案例。
这种方法多多少少会遇到一些问题。首先,客户端 SDK 的修改可能会导致测试案例编不过。其次,客户端 SDK 通常是使用方友好,而不是测试方友好。服务端开发过程和客户端 SDK 的耦合容易过早地陷入“客户端 SDK 如何抽象更合理” 的细节,而不能专注于测试服务逻辑本身。
我的核心诉求是对服务端开发过程和客户端开发过程进行解耦。在网络协议定好了以后,整个系统原则上就可以编写测试案例,而不用等客户端 SDK的成熟。
不写客户端 SDK 而直接做 HTTP 测试,一个直观的思路是直接基于 http.Client 类来写测试案例。这种方式的问题是代码比较冗长,而且它的业务逻辑表达不直观,很难一眼就看出这句话想干什么。虽然可以写一些辅助函数来改观,但做多了就会逐渐有写测试专用 SDK 的倾向。这种写法看起来也不是很可取,毕竟为测试写一个专门的 SDK看起来成本有些高了。
七牛当前的做法是引入一种 httptest DSL 文法。这是七牛为 HTTP 测试而写的领域专用语言。这个 httptest 工具当前已经开源,项目主页为:
- [https://github.com/qiniu/httptest](https://github.com/qiniu/httptest)httptest 框架)
- [https://github.com/qiniu/qiniutest](https://github.com/qiniu/qiniutest)(支持七牛帐号与授权机制的 qiniutest 工具)
## httptest 基础文法
这个语言的文法大概在 2012 年就已经被加入到七牛的代码库,后来有个同事根据这个 DSL 文法写了第一版本 qiniutest 程序。在决定推广用这个 DSL 来进行测试的过程中,我们对 DSL 不断地进行了调整和加强。虽然总体思路没有变化,但最终定稿的 DSL 与最初版本有较大的差异。目前来说我已经可以十分确定地说这个DSL可以满足 90% 以上的测试需求。它被推荐做为七牛内部的首选测试方案。
<img src="https://static001.geekbang.org/resource/image/45/82/45e35f9cf7ab35fea48b6135160c0a82.png" alt="">
上图是这套 DSL 的 “hello world” 程序。它的执行预期是:下载 www.qiniu.com 首页,要求返回的 HTTP 状态码为 200。如果返回非 200测试失败否则测试通过输出返回包的正文内容resp.body 变量)。输出 resp.body 的内容通常是调试需要,而不是测试需要。自动化测试是不需要向屏幕去输出什么的。
<img src="https://static001.geekbang.org/resource/image/c7/ac/c761fdaac0a959f13a5ee14d4eaddaac.png" alt="">
我们再看该 DSL 的一个 “quick start快速入门” 样例。以 # 开始的内容是程序的注释部分。这里有一个很长很长的注释,描述了一个基本的 HTTP 请求测试的构成。后面我们会对这部分内容进行详细展开,这里暂时跳过。
这段代码的第一句话是定义了一个 auth 别名叫 qiniutest这只是为了让后面具体的 HTTP 请求中授权语句更简短。紧接着是发起一个 POST 请求,创建一个内容为 {"a": "value1", "b": 1} 的对象,并将返回的对象 id 赋值给一个名为 id1 的变量。后面我们会详细解释这个赋值过程是如何进行的。
接着我们发起一个获取对象内容的 GET 请求,需要注意的是 GET 的 URL 中引用了 id1 变量的值这意味着我们不是要取别的对象的内容而是取刚刚创建成功的对象的内容并且我们期望返回的对象内容和刚才POST上去的一样也是 {"a": "value1", "b": 1}。这就是一个最基础的 HTTP 测试,它创建了一个对象,确认创建成功,并且尝试去取回这个对象,确认内容与我们期望的一致。这里上下两个请求是通过 id1 这个变量来建立关联的。
对这套DSL文法有了一个大概的印象后我们开始来解剖它。先来看看它的语法结构。首先这套 httptest DSL 基于命令行文法:
```
command switch1 switch2 … arg1 arg2 …
```
整个命令行先是一个命令,然后紧接着是一个个开关(可选),最后是一个个的命令参数。和大家熟悉的命令行比如 Linux Shell 一样,它也会有一些参数需要转义,如果参数包含空格或其他特殊字符,则可以用 \ 前缀来进行转义。比如 '\ ' 表示 ''(空格),‘\t'表示 TAB 等。另外,我们也支持用 '…' 或者 "…" 去传递一个参数,比如 json 格式的多行文本。同 Linux Shell 类似,'…' 里面的内容没有转义,‘\ 就是 \ \t'就是 '\t',而不是 TAB。而 "…" 则支持转义。
和 Linux Shell 不同的是,我们的 httptest DSL 虽然基于命令行文法,但是它的每一个参数都是有类型的,也就是说这个语言有类型系统,而不像 Linux Shell 命令行参数只有字符串。我们的 httptest DSL 支持且仅支持所有 json 支持的数据类型,包括:
- string"a"、application/json 等,在不引起歧义的情况下,可以省略双引号)
- number3.14159
- booleantrue、false
- array[“a”, 200, {“b”: 2}]
- object/dictionary{“a”: 1, “b”: 2}
另外,我们的 httptest DSL 也有子命令的概念,它相当于一个函数,可以返回任意类型的数据。比如 qiniu f2weae23e6c9f jg35fae526kbce返回一个 auth object这是用常规字符串无法表达的。
理解了 httptest DSL 后,我们来看看如何表达一个 HTTP 请求。它的基本形式如下:
```
req &lt;http-method&gt; &lt;url&gt;
header &lt;key1&gt; &lt;val11&gt; &lt;val12&gt;
header &lt;key2&gt; &lt;val21&gt; &lt;val22&gt;
auth &lt;authorization&gt;
body &lt;content-type&gt; &lt;body-data&gt;
```
第一句是 req 指令,带两个参数: 一个是 http method即 HTTP 请求的方法,如 GET、POST 等。另一个是要请求的 URL。
接着是一个个自定义的 header可选每个 header 指令后面跟一个 key和一个或多个 value
然后是一个可选的 auth 指令,用来指示这个请求的授权方式。如果没有 auth 语句,那么这个 HTTP 请求是匿名的,否则这就是一个带授权的请求。
最后一句是 body 指令,顾名思义它用来指定 HTTP 请求的正文。body 指令也有两个参数,一个是 content-type内容格式另一个是 body-data请求正文
这样说比较抽象,我们看下实际的例子:
无授权的 GET 请求:
```
req GET http://www.qiniu.com/
```
带授权的 POST 请求:
```
req POST http://foo.com/objects
auth `qiniu f2weae23e6c9fjg35fae526kbce`
body application/json '{
&quot;a&quot;: &quot;hello1&quot;,
&quot;b&quot;:2
}'
```
也可以简写成:
无授权的GET请求
```
get http://www.qiniu.com/
```
带授权的Post请求
```
post http://foo.com/objects
auth `qiniu f2weae23e6c9fjg35fae526kbce`
json '{
&quot;a&quot;: &quot;hello1&quot;,
&quot;b&quot;:2
}'
```
发起了 HTTP 请求后,我们就可以收到 HTTP 返回包并对内容进行匹配。HTTP 返回包匹配的基本形式如下:
```
ret &lt;expected-status-code&gt;
header &lt;key1&gt; &lt;expected-val11&gt;&lt;expected-val12&gt;
header &lt;key2&gt; &lt;expected-val21&gt;&lt;expected-val22&gt;
body &lt;expected-content-type&gt;&lt;expected-body-data&gt;
```
我们先看 ret 指令。实际上,请求发出去的时间是在 ret 指令执行的时候。前面 req、header、auth、body 指令仅仅表达了 HTTP 请求。如果没有调用 ret 指令,那么系统什么也不会发生。
ret 指令可以不带参数。不带参数的 ret 指令,其含义是发起 HTTP 请求,并将返回的 HTTP 返回包解析并存储到 resp 的变量中。而对于带参数的 ret 指令:
```
ret &lt;expected-status-code&gt;
```
它等价于:
```
ret
match &lt;expected-status-code&gt; $(resp.code)
```
## match 指令
这里我们引入了一个新的指令match 指令。
<img src="https://static001.geekbang.org/resource/image/ba/c4/bac83312bf630b33c40f1f28ab1d3dc4.png" alt="">
七牛所有 HTTP 返回包匹配的匹配文法,都可以用这个 match 来表达:
<img src="https://static001.geekbang.org/resource/image/52/60/52a851227b667cc52b8ada69667b4060.png" alt="">
所以本质上来说,我们只需要一个不带参数的 ret加上 match 指令,就可以搞定所有的返回包匹配过程。这也是我们为什么说 match 指令是这套 DSL 中最核心的概念的原因。
和其他自动化测试框架类似,这套 DSL 也提供了断言文法。它类似于 CppUnit 或 JUnit 之类的测试框架提供 assertEqual。具体如下
```
equal &lt;expected&gt; &lt;source&gt;
```
- 与 match 不同,这里 `&lt;expected&gt;、&lt;source&gt;`中都不允许出现未绑定的变量。
- 与 match 不同equal 要求`&lt;expected&gt;、&lt;source&gt;`的值精确相等。
```
equalSet &lt;expected&gt; &lt;source&gt;
```
- 这里 SET 是指集合的意思。
- 与 equal 不同equalSet 要求 `&lt;expected&gt;、&lt;source&gt;`都是array并且对 array 的元素进行排序后判断两者是否精确相等。
- equalSet 的典型使用场景是测试 list 类的 API比如列出一个目录下的所有文件你可能预期这个目录下有哪些文件但是不能预期他们会以什么样的次序返回。
以上介绍基本上就是这套 DSL 最核心的内容了。内容非常精简,但满足了绝大部分测试场景的需求。
## 测试环境的参数化
下面我们谈谈最后一个话题:测试环境的参数化。
为了让测试案例更加通用,我们需要对测试依赖的环境进行参数化。比如,为了让测试脚本能够同时用于 stage 环境和 product 环境,我们需要把服务的 Host 信息参数化。另外,为了方便测试脚本入口,我们通常还需要把 用户名/密码、AK/SK 等敏感性信息参数化,避免直接硬编码到测试案例中。
为了把服务器的 Host 信息(也就是服务器的位置)参数化,我们引入了 host 指令。例如:
```
host foo.com 127.0.0.1:8888
get http://foo.com/objects/a325gea2kgfd
auth qiniutest
ret 200
json '{
&quot;a&quot;: &quot;hello1&quot;,
&quot;b&quot;:2
}'
```
这样,后文所有出现请求 foo.com 地方,都会把请求发送到 127.0.0.1:8888 这样一个服务器地址。要想让脚本测试另外的服务器实例,我们只需要调整 host 语句,将 127.0.0.1:8888 调整成其他即可。
除了服务器 Host 需要参数化外,其他常见的参数化需求是 用户名/密码、AK/SK 等。AK/SK 这样的信息非常敏感,如果在测试脚本里面硬编码这些信息,将不利于测试脚本代码的入库。一个典型的测试环境参数化后的测试脚本样例如下:
<img src="https://static001.geekbang.org/resource/image/a2/fb/a2d588df5daf9cb3535569a6f404acfb.png" alt="">
其中env 指令用于取环境变量对应的值(返回值类型是 stringenvdecode 指令则是先取得环境变量对应的值,然后对值进行 json decode 得到相应的 object/dictionary。有了`$(env)` 这个对象`(object)`,就可以通过它获得各种测试环境参数,比如 `$(env.FooHost)``$(env.AK)``$(env.SK)` 等。
写好了测试脚本后,在执行测试脚本之前,我们需要先配置测试环境:
```
export QiniuTestEnv_stage='{
&quot;FooHost&quot;: &quot;192.168.1.10:8888&quot;,
&quot;AK&quot;: &quot;…&quot;,
&quot;SK&quot;: &quot;…&quot;
}'
export QiniuTestEnv_product='{
&quot;FooHost&quot;: &quot;foo.com&quot;,
&quot;AK&quot;: &quot;…&quot;,
&quot;SK&quot;: &quot;…&quot;
}'
```
这样我们就可以执行测试脚本了:
测试 stage 环境:
```
QiniuTestEnv=stage qiniutest ./testfoo.qtf
```
测试 product 环境:
```
QiniuTestEnv=product qiniutest ./testfoo.qtf
```
## 结语
测试是软件质量保障至关重要的一环。一个好的测试工具对提高开发效率的作用巨大。如果能够让开发人员的开发时间从一小时减少到半小时,那么日积月累就会得到惊人的效果。
去关注开发人员日常工作过程中的不爽和低效率是非常有必要的。任何开发效率提升相关的工作,其收益都是指数级的。这也是我们所推崇的做事风格。如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。