早在几年前,云存储还只是存在于业界大佬们口中的一个概念,其应用场景仅供大公司使用。突飞猛进的网络技术似乎在一瞬间就把这个概念普及到千家万户,现在云存储已经是大家司空见惯的一个网络服务了。比如大家用的百度云盘、已经成为实质上的业界标准的亚马逊S3、微软的OneDrive、苹果公司的iCloud和谷歌的Google Cloud等。
现在市面上和云存储相关的图书本来就不多,而专门讲述对象存储实现的书就更是难得一见,且内容大多以OpenStack、Swift和Ceph这些已经较为成熟的开源软件的架构和实现为例。读者一开始就知道怎么做,然后解释为什么要这么做。
《分布式对象存储——原理、架构及Go语言实现》则另辟蹊径,完全从云存储的需求出发讲述对象存储的原理,循序渐进、从无到有地建立起一个分布式对象存储的架构,并且将软件实现出来。换句话说,本书首先介绍为什么要这么做,然后解释怎么做。
本书选择用来实现分布式对象存储软件的编程语言是Go,但并不是非它不可的。读者也可以在了解对象存储的原理之后选用其他的语言来实现。
在读完本书之后,每一位读者都将对对象存储服务这一概念有一个较为深入的理解,部分读者甚至能够实现自己的对象存储服务。
对象存储和云存储的关系
对象存储是云存储的一部分,它提供了云存储后端的存储服务。云存储是建立在对象存储之上的一个整体的解决方案,除了后端的存储服务之外,它还需要包括各种操作系统和平台上运行的客户端、身份认证、多种管理和监控功能等。
本书主要集中在对象存储的原理架构和实现上,对云存储其他组件也会有一定的介绍,但不会是本书的主要内容。
分布式存储的好处
传统的高端服务器性能强劲、成本高昂,以前只有大公司用来搭建自己的私有存储。互联网生态下的云存储则用数量弥补质量,以大量低成本的普通PC服务器组成网络集群来提供服务。相比传统的高端服务器来说,同样价格下分布式存储提供的服务更好、性价比更高,且新节点的扩展以及坏旧节点的替换更为方便。
1.1 和传统网络存储的区别
要理解对象存储,我们首先要来谈谈传统的网络存储。传统的网络存储主要有两类,分别是NAS和SAN。
NAS是Network Attached Storage的简称,是一个提供了存储功能和文件系统的网络服务器。客户端可以访问NAS上的文件系统,还可以上传和下载文件。NAS客户端和服务端之间使用的协议有SMB、NFS以及AFS等网络文件系统协议。对于客户端来说,NAS就是一个网络上的文件服务器。
SAN是Storage Area Network的简称。它和NAS的区别是SAN只提供了块存储,而把文件系统的抽象交给客户端来管理。SAN的客户端和服务端之间的协议有Fibre Channel、iSCSI、ATA over Ethernet(AoE)和HyperSCSI。对于客户端来说,SAN就是一块磁盘,可以对其格式化、创建文件系统并挂载。
NAS和SAN并不是完全对立的,现代的网络存储通常都是两者混合使用,可以同时提供文件级别的协议和块级别的协议。
介绍完传统的网络存储,那么对象存储跟它们又有什么区别呢?首先是对数据的管理方式不同。
对于网络文件系统来说,数据是以一个个文件的形式来管理的;对于块存储来说,数据是以数据块的形式来管理的,每个数据块有它自己的地址,但是没有额外的背景信息;对象存储则是以对象的方式来管理数据的,一个对象通常包含了3个部分:对象的数据、对象的元数据以及一个全局唯一的标识符(即对象的ID)。
对象的数据就是该对象中存储的数据本身。一个对象可以用来保存大量无结构的数据,比如一首歌、一张照片或是一个在线文档。
对象的元数据是对象的描述信息,为了和对象的数据本身区分开来,我们称其为元数据。比如某首歌的歌名、某张照片拍摄的时间、某个文档的大小等都属于描述信息,也就是元数据。对于对象的元数据,我们在第3章会详细介绍,这里不多展开。
对象的标识符用于引用该对象。和对象的名字不同,标识符具有全局唯一性。名字不具有这个特性,例如张三家的猫名字叫阿黄,李四家的狗名字也可以叫阿黄,名字为阿黄的对象可以有很多个。但若是用标识符来引用就只可能有一个。
除了对数据的管理方式不同以外,对象存储跟网络存储访问数据的方式也不同。
网络文件系统的客户端通过NFS等网络协议访问某个远程服务器上存储的文件。块存储的客户端通过数据块的地址访问SAN上的数据块。对象存储则通过REST网络服务访问对象。
REST是Representational State Transfer的简称。REST网络服务通过标准HTTP服务对网络资源提供一套预先定义的无状态操作。在万维网刚兴起的时候,网络资源被定义为可以通过URL访问的文档或文件。现如今对于它的定义已经更为宽泛和抽象:网络上一切可以通过任何方式被标识、命名、引用或处理的东西都是一种网络资源。对于对象存储来说,对象当然就是一种网络资源,但除了对象本身以外,我们也需要提供一些其他的网络资源用来访问对象存储的各种功能,本书后续会一一介绍。
客户端向REST网络服务发起请求并接收响应,以确认网络资源发生了某种变化。HTTP预定义的请求方法(Request Method)通常包括且不限于GET、POST、PUT、DELETE等,它们分别对应不同的处理方式:GET方法在REST标准中通常用来获取某个网络资源;PUT通常用于创建或替换某个网络资源(注意,它跟PUT的区别是POST一般不同于替换网络资源,如果该资源已经存在,POST通常会返回一个错误而不是覆盖它);POST通常用于创建某个网络资源,DELETE通常用于删除某个网络资源。
我们会在本书的后续章节看到对象存储的接口是如何使用这些HTTP请求方法的。
对象存储首先提升了存储系统的扩展性。当一个存储系统中保存的数据越来越多时,存储系统也需要同步扩展,然而由于存储架构的硬性限制,传统网络存储系统的管理开销会呈指数上升。而对象存储架构的扩展只需要添加新的存储节点就可以。
对象存储的另一大优势在于以更低的代价提供了数据冗余的能力。在分布式对象存储系统中一个或多个节点失效的情况下,对象依然可用,且大多数情况下客户都不会意识到有节点出了问题。传统网络存储对于数据冗余通常采用的方式是保留多个副本(一般至少3份,这样当其中一个副本出了错,我们还能用少数服从多数的方式解决争议),而对象存储的冗余效率则更高。我们会在第5章讨论数据冗余的问题。
本文将要实现的是一个单机版的对象存储原型,目的是让读者对我们讨论的对象存储有一个直观的了解。一个单机版的服务程序还称不上分布式服务,但是我们可以借此了解对象存储的接口,也就是说我们将了解客户端是如何通过REST接口上传和下载一个对象的,以及这个对象又是以什么样的形式被保存在服务器端的。从下一章开始,我们还将不断丰富架构和功能来适应各种新的需求。
在一台服务器上运行了一个HTTP服务提供的REST接口,该服务通过访问本地磁盘来进行对象的存取,见图1-1。
图1-1 单机版对象存储的架构
单机版的REST接口极其简单,只实现了对象的PUT和GET方法。
1PUT/objects/<object_name>
请求正文(Request Body)
客户端通过PUT方法将一个对象上传至服务器,服务器则将该对象保存在本地磁盘上。
这里/objects/<object_name>是标识该对象网络资源的URL。URL是Uniform Resource Locator的简称,也就是一个网络地址,用于引用某个网络资源在网络上的位置。
在对象存储中,通常使用PUT方法来上传一个对象。
1GET/objects/<object_name>
响应正文(Response Body)
客户端通过GET方法从服务器上下载一个对象,服务器在本地磁盘上查找并读取该对象,如果该对象不存在,则服务器返回HTTP错误代码404 Not Found。
在对象存储中,总是使用GET方法来下载一个对象。
我们可以用一张简单的图来概括PUT流程,见图1-2。
图1-2 单机版对象PUT流程
客户端的PUT请求提供了对象的名字<object_name>和对象的数据<content of object>,它们最终被保存在本地磁盘上的文件$STORAGE_ROOT/objects/<object_name>中。$STORAGE_ROOT环境变量保存着我们在本地磁盘上的存储根目录的名字。
对象GET流程见图1-3。
![](https://i-blog.csdnimg.cn/blog_migrate/c6998bbcf5c5df8ae402e0ff8158f9d1.png)
图1-3 单机版对象GET流程
客户端的GET请求提供了<object_name>,我们的服务进程从本地磁盘上的文件$STORAGE_ ROOT/objects/<object_name>中读取对象并将其写入HTTP响应正文。
流程介绍完了,接下来让我们去看一下如何用Go语言实现这样一个服务。
首先让我们来看一下main函数的实现。和大多数语言一样,Go语言也有一个main函数作为系统的入口点。在main函数中我们需要做的只是注册一个HTTP处理函数并开始监听端口,见例1-1。
例1-1 main函数
1func main() {
2 http.HandleFunc("/objects/", objects.Handler)
3 log.Fatal(http.ListenAndServe(os.Getenv("LISTEN_ADDRESS"), nil))
4}
http.HandleFunc的作用是注册HTTP处理函数objects.Handler,如果有客户端访问本机的HTTP服务且URL以“/objects/”开头,那么该请求将由objects.Handler负责处理。除此以外的HTTP请求会默认返回HTTP错误代码404 Not Found。
处理函数注册成功后,我们调用http.ListenAndServe正式开始监听端口,该端口由系统环境变量LISTEN_ADDRESS定义。正常情况下该函数永远不会返回,程序运行后会始终监听端口上的请求,除非我们发送信号中断进程。非正常情况下,该函数会将错误返回,此时log.Fatal会打印错误的信息并退出程序。
objects.Handler函数属于objects包,该包一共有3个函数,除了Handler以外还有put和get函数。在Go语言中,某个变量或函数名的首字母大写,则意味着该变量或函数可在包外部被引用;如果是首字母小写,则意味着该变量或函数名仅可在包内部被引用。Handler函数的首字母H大写就表明该函数可以在objects包外部被调用(被main包的main函数调用),而put和get函数则仅在objects包内部可见。Handler函数见例1-2。
例1-2 objects.Handler函数
1func Handler(w http.ResponseWriter, r *http.Request) {
2 m := r.Method
3 if m == http.MethodPut {
4 put(w, r)
5 return
6 }
7 if m == http.MethodGet {
8 get(w, r)
9 return
10 }
11 w.WriteHeader(http.StatusMethodNotAllowed)
12}
Handler有两个参数。w的类型是http.ResponseWriter,用于写入HTTP的响应。它的WriteHeader方法用于写HTTP响应的错误代码,它的Write方法则用于写HTTP响应的正文。r的类型是*http.Request,是一个指向http.Request结构体的指针,代表当前处理的HTTP的请求。它的Method成员变量记录了该HTTP请求的方法。
Handler函数会首先检查HTTP请求的方法:如果是PUT,则调用put函数;如果是GET,则调用get函数;两者皆否,则返回HTTP错误代码405 Method Not Allowed。
put函数负责处理HTTP的PUT请求,将PUT上来的对象存储在本地硬盘上,见例1-3。
例1-3 objects.put函数
1func put(w http.ResponseWriter, r *http.Request) {
2 f, e := os.Create(os.Getenv("STORAGE_ROOT") + "/objects/" +
3 strings.Split(r.URL.EscapedPath(), "/")[2])
4 if e != nil {
5 log.Println(e)
6 w.WriteHeader(http.StatusInternalServerError)
7 return
8 }
9 defer f.Close()
10 io.Copy(f, r.Body)
11}
put函数首先获取URL中<object_name>的部分,r.URL成员变量记录了HTTP请求的URL,它的EscapedPath方法用于获取经过转义以后的路径部分,该路径的形式是/objects/<object_name>
strings.Split函数的功能是用分隔符将一个字符串分割成多个字符串,它有两个输入变量,第一个输入变量是需要分割的字符串,第二个输入变量则是分隔符,分割后的结果以字符串的数组形式返回。在这里,strings.Split会将我们的URL路径拆分成3个字符串,分别是“”“objects”和<object_name>。所以数组的第三个元素就是<object_name>。
然后我们会调用os.Create在本地文件系统的根存储目录的objects子目录下创建一个同名文件f(根存储目录由系统环境变量STORAGE_ROOT定义。除了objects子目录以外,还有一些其他的子目录,我们还会在后续章节看到它们的用处)。如果创建失败则返回HTTP错误代码500;如果创建成功则将r.Body用io.Copy写入文件f。io.Copy接收两个参数,第一个参数是用于写入的io.Writer,在这里我们的文件f就是一个io.Writer,任何写入f的数据都会被写入f所代表的文件;io.Copy接收的第二个参数则是一个用于读取的io.Reader,在这里就是r.Body。http.Request的Body成员变量是一个io.Reader,用来读取HTTP请求的正文内容。
这里需要提醒一点:我们的实现代码默认用户提供的URL的<object_name>部分不能包含“/”,如果包含,则“/”后的部分将被丢弃。真实生产环境中的代码需要对用户的所有输入都进行严格的校验,如果用户输入的URL不符合我们的预期,则服务器必须返回一个错误的信息,而不是忽略。
细心的读者看到这里可能要问:那如果我的对象名字里就需要有一个“/”怎么办呢?别担心,任何对象名字在被放入URL之前都需要在客户端进行URL编码。在编码时,一些不适合放入URL的字符会被转义。比如说用户有一个对象名字是“C:/中文目录/a&c.txt”,编码后的名字是“C%3A%2F%E4%B8%AD%E6%96%87%E7%9B% AE%E5%BD%95%2Fa%5Cb%26c.txt”,其中的“/”被转义成“%2F”。然后客户端会以“/objects/ C%3A%2F%E4%B8%AD%E6%96%87%E7%9B%AE%E5%BD%95%2Fa% 5Cb%26c.txt”作为HTTP请求的URL访问我们的服务。我们在获取URL时使用的r.URL.EscapedPath方法得到的就是这个字符串。
get函数负责处理HTTP的GET请求,从本地硬盘上读取内容并将其作为HTTP的响应输出,见例1-4。
例1-4 objects.get函数
1func get(w http.ResponseWriter, r *http.Request) {
2 f, e := os.Open(os.Getenv("STORAGE_ROOT") + "/objects/" +
3 strings.Split(r.URL.EscapedPath(), "/")[2])
4 if e != nil {
5 log.Println(e)
6 w.WriteHeader(http.StatusNotFound)
7 return
8 }
9 defer f.Close()
10 io.Copy(w, f)
11}
和put函数类似,get函数首先获取<object_name>并调用os.Open尝试打开本地文件系统根存储目录objects子目录中的同名文件f,如果打开失败,则返回HTTP错误代码404;如果打开成功,则用io.Copy将f的内容写入w,此时f作为读取内容的io.Reader,而w则作为写入的io.Writer。
你可能会觉得诧异,刚才我们介绍put的时候还说f是一个io.Writer,现在怎么又变成了io.Reader?事实上f本身的类型是*os.File,一个指向os.File结构体的指针。os.File这个结构体同时实现了io.Writer和io.Reader这两个接口(Go语言中称为interface)。在Go语言中,实现接口只需要实现该接口所要求的全部方法,io.Write接口只要求实现一个Write方法,而io.Read接口则只要求实现一个Read方法。os.File同时实现了Write和Read方法,于是它既是一个io.Writer也是一个io.Reader。http.ResponseWriter也是一个接口,这个接口除了实现了WriteHeader方法以外也实现了Write方法,所以它也是一个io.Write接口。
Go语言的实现介绍完了,接下来我们需要把程序运行起来,并进行功能测试来验证我们的实现。
我们需要先运行服务器程序。
1$ LISTEN_ADDRESS=:12345 STORAGE_ROOT=/tmp go run server.go
别忘了在存储根目录/tmp下创建相应的objects子目录。
1mkdir /tmp/objects
接下来我们用curl命令作为客户端来访问服务器,试图GET一个名为test的对象。
1$ curl -v 10.29.102.172:12345/objects/test
2* About to connect() to 10.29.102.172 port 12345 (#0)
3* Trying 10.29.102.172... connected
4> GET /objects/test HTTP/1.1
5> User-Agent: curl/7.22.0 (x86_64-pc-linux-gnu) libcurl/7.22.0 OpenSSL/1.0.1 zlib/1.2.3.4
6libidn/1.23 librtmp/2.3
7> Host: 10.29.102.172:12345
8> Accept: */*
9>
10< HTTP / 1.1 404 Not Found
11< Date: Mon, 26 Jun 2017 14:08:10 GMT
12< Content-Length: 0
13< Content-Type: text/plain; charset=utf-8
14<
15* Connection #0 to host 10.29.102.172 left intact
16* Closing connection #0
很好,服务器给出了预期的404错误,因为我们还从来没有PUT过一个叫test的对象。
那么,接下来我们PUT一个test对象。
1$ curl -v 10.29.102.172:12345/objects/test -XPUT -d"this is a test object"
2* About to connect() to 10.29.102.172 port 12345 (#0)
3* Trying 10.29.102.172... connected
4> PUT /objects/test HTTP/1.1
5> User-Agent: curl/7.22.0 (x86_64-pc-linux-gnu) libcurl/7.22.0 OpenSSL/1.0.1 zlib/1.2.3.4
6libidn/1.23 librtmp/2.3
7> Host: 10.29.102.172:12345
8> Accept: */*
9> Content-Length: 21
10> Content-Type: application/x-www-form-urlencoded
11>
12* upload completely sent off: 21out of 21 bytes
13< HTTP/1.1 200 OK
14< Date: Mon, 26 Jun 2017 14:10:34 GMT
15< Content-Length: 0
16< Content-Type: text/plain; charset=utf-8
17<
18* Connection #0 to host 10.29.102.172 left intact
19* Closing connection #0
我们用curl命令PUT了一个名为test的对象,该对象的内容为“this is a test object”。服务器返回“200 OK”表示PUT成功。接下来让我们再次GET这个test对象。
1$ curl -v 10.29.102.172:12345/objects/test
2* About to connect() to 10.29.102.172 port 12345 (#0)
3* Trying 10.29.102.172... connected
4> GET /objects/test HTTP/1.1
5> User-Agent: curl/7.22.0 (x86_64-pc-linux-gnu) libcurl/7.22.0 OpenSSL/ 1.0.1 zlib/1.2.3.4
6libidn/1.23 librtmp/2.3
7> Host: 10.29.102.172:12345
8> Accept: */*
9>
10< HTTP/1.1 200 OK
11< Date: Mon, 26 Jun 2017 14:12:44 GMT
12< Content-Type: text/plain; charset=utf-8
13< Transfer-Encoding: chunked
14<
15* Connection #0 to host 10.29.102.172 left intact
16* Closing connection #0
17this is a test object
太棒了!我们成功获取了之前PUT上去的那个对象的内容。
我们在本文实现了一个单机版的对象存储服务,它提供了对象的PUT和GET方法。当客户端以PUT方法访问我们的服务时,客户端会提供对象的名字和内容,我们的服务就可以把对象的内容以文件的形式存储在服务器的本地磁盘上;当客户端以GET方法访问我们的服务时,我们就可以从服务器的本地磁盘上读取文件的内容并将其作为HTTP的响应输出。
这样一个单机的对象存储服务离我们最终要实现的版本还很远,它只是一个热身,让我们熟悉一下使用的方式。我们最终的目标是要实现一个云版本的对象存储。那么,相比一个云版本的对象存储来说,目前的单机版最大的问题是什么呢?
答案是可扩展性!
分布式对象存储服务必须是可扩展的,当现有的服务器集群无法满足容量、吞吐量、时延等性能指标时,我们必须能够轻易扩展现有的服务器集群。在单机版的架构中,接口和数据存储被紧紧耦合在一起,服务器只能访问本地磁盘。当一台服务器无法满足日益增长的HTTP客户端请求数量时,我们将无法通过加入一个新的服务器来扩展集群,因为新的服务器无法访问旧服务器的磁盘。
![](https://i-blog.csdnimg.cn/blog_migrate/80860273e84b2db0fae0ad348b4c7580.png)
《分布式对象存储——原理、架构及Go语言实现》
胡世杰 著
送价值249元的云存储专家视频课程
掌握云存储理论,动手搭建分布式对象存储架构
本书首先从一个最简单的对象存储服务原型开始,讨论在原型中存在的问题并介绍对象存储服务中一些常见的概念以及设计理念,然后通过改变架构或添加功能的方式解决这些问题。这一迭代步骤将发生多次,最终我们会收获一个足够完善的对象存储服务。
今日互动(限于微信端活动,活动地址:微信端点击参加)
儿童节打算怎么过?截止时间6月8日17时,留言+转发本活动到朋友圈,小编将抽奖选出3名读者赠送纸书1本和2张e读版20元异步社区现金券,(留言点赞最多的自动获得一张)。
异步图书后台回复“5月新书”进入新书交流群,获得第一手新书信息
推荐阅读
![](https://i-blog.csdnimg.cn/blog_migrate/a10ac042d02512fa96ebe10327eda273.webp?x-image-process=image/format,png)
![](https://i-blog.csdnimg.cn/blog_migrate/a42ba90b80745b19db7e798a823d4274.webp?x-image-process=image/format,png)
每天与你分享IT好文。
在“异步图书”后台回复“关注”,即可免费获得2000门在线视频课程
点击阅读原文,购买《分布式对象存储 原理 架构及Go语言实现》