随着互联网的飞速发展, 各行各业对互联网服务的要求也越来越高, 服务架构能撑起多大的业务数据? 服务响应的速度能不能达到要求? 我们的架构师每天都在思考这些问题.
对于数据库或者对象存储等服务来说, 它们受限于自己先天的设计目标, 往往不能具有很好的性能, 响应时间通常是秒级. 此时就需要高性能的缓存来为我们的服务提速了, 缓存服务的响应时间通常是毫秒级, 甚至小于 1ms.
缓存服务需要被设置在其他服务的前端, 客户端首先访问缓存, 查询自己的数据, 仅当客户端需要的数据不存在于缓存中时, 才去访问实际的服务. 从实际的服务中获取到的数据会被放在缓存中, 以备下次使用.
缓存的设计目标就是尽可能地快, 但它引起了其他的问题. 比如目前业界使用较多的缓存服务有 Memcached 和 Redis 等, 它们都是内存内缓存, 单节点最大的容量不能超过整个系统的内存.
且一旦服务器重启, 对于 Memcached 来说就是内容彻底丢失; Redis 稍好一点, 但也要花费不少时间从磁盘上的数据文件中重新读入内存.
当我们决定要用 Go 语言编写一个缓存服务的时候, 首先想到的就是 HTTP 服务. 因为用 Go 语言写基于 HTTP 的缓存服务真的是太方便了, 我们只需要一个 map 来保存数据, 写一个 handler 负责处理请求, 然后调用 http.ListenAndServe, 最后用 go run 运行. 一切就是这么简单, 你不需要去考虑复杂的并发问题, 也不需要自己设计一套网络协议, Go 语言的 HTTP 服务框架会帮你处理好底层的一切.
我们在本文将要实现的是一个简单的内存缓存服务, 所有的缓存数据都存储在服务器的内存中. 一旦服务器重启, 所有的数据都将被清零.
缓存服务的接口
1.1.1 REST 接口
本章的接口支持缓存的设置 (Set), 获取(Get) 和删除 (Del) 这 3 个基本操作, 同时还支持对缓存服务状态的查询. Set 操作用于将一对键值对 (key value pair) 设置进缓存服务器, 它通过 HTTP 的 PUT 方法进行; Get 操作用于查询某个键并获取其值, 它通过 HTTP 的 GET 方法进行; Del 操作用于从缓存中删除某个键, 它通过 HTTP 的 DELETE 方法进行. 我们可以查询的缓存服务状态包括当前缓存了多少对键值对, 所有的键一共占据了多少字节, 所有的值一共占据了多少字节.
PUT /cache/
请求正文
●
客户端通过 HTTP 的 PUT 方法将一对键值对设置进缓存服务器, 服务器将该键值对保存在内存堆上创建的 map 里.
这里 / cache / 是一个 URL, 它标识了缓存的值 (value) 所在的位置. URL 是 Uniform Resource Locator 的缩写, 它是一个网络地址, 用于引用某个网络资源在网络上的位置. HTTP 的请求正文 (request body) 里包含了该 key 对应的 value 的内容.
GET /cache/
响应正文
●
客户端通过 HTTP 的 GET 方法从缓存服务器上获取 key 对应的 value, 服务器在 map 中查找该 key, 如果 key 不存在, 服务器返回 HTTP 错误代码 404 NOT FOUND; 如果 key 存在, 则服务器在 HTTP 响应正文 (response body) 中返回相应的 value.
DELETE /cache/
客户端通过 HTTP 的 DELETE 方法将 key 从缓存中删除. 无论之前该 key 是否存在, 之后它都将不存在, 服务器始终返回 HTTP 错误代码 200 OK.
GET /status
响应正文
● JSON 格式的缓存状态
客户端通过这个接口获取缓存服务的状态, 在 HTTP 响应正文中返回的状态是以 JSON 格式编码的一个 cache.Stat 结构体(见例 1-3).
1.1.2 缓存 Set 流程
我们可以用一张简单的图来概括 Set 流程, 见图 1-1.
图 1-1 in memory 缓存的 Set 流程
客户端的 PUT 请求提供了 key 和 value.cacheHandler 实现了 http.Handler 接口, 其 ServeHTTP 方法对 HTTP 请求进行解析, 并调用 cache.Cache 接口的 Set 方法.
在 cache 模块中, inMemoryCache 结构体实现 Cache 接口, 其 Set 方法最终将键值对保存在内存的 map 中. cacheHandler 最后会返回客户端一个 HTTP 错误号来表示结果, 如果成功则返回的是 200 OK, 否则返回 500 Internal Server Error.
Go 语言中的 map 的含义和用法跟大多数现代编程语言中的 map 一样, map 是一种用于保存键值对的散列表数据结构, 可以通过中括号 [ ] 进行 key 的查询和设置.
由于程序会对 key 进行散列和掩码运算以直接获取存储 key 的偏移量, 所以能获得近乎 O(1)的查询和设置复杂度. 之所以说近乎 O(1)是因为两个 key 在经过散列和掩码运算后有可能会具有相同的偏移量, 此时将不得不继续进行线性搜索, 不过发生这种不幸情况的概率很小.
1.1.3 缓存 Get 流程
缓存 Get 流程见图 1-2.
图 1-2 in memory 缓存的 Get 流程
客户端的 Get 请求提供了 key.cacheHandler 的 ServeHTTP 方法对 HTTP 请求进行解析, 并调用 cache.Cache 接口的 Get 方法. inMemoryCache 结构体的 Get 方法在 map 中查询 key 对应的 value 并返回. cacheHandler 会将 value 写入 HTTP 响应正文并返回 200 OK, 如果 cache.Cache.Get 方法返回错误, cacheHandler 会返回 500 Internal Server Error. 如果 value 长度为 0, 说明该 key 不存在, cacheHandler 会返回 404 Not Found.
1.1.4 缓存 Del 流程
缓存 Del 流程见图 1-3.
图 1-3 in memory 缓存的 Del 流程
客户端的 DELETE 请求提供了 key.cacheHandler 的 ServeHTTP 方法对 HTTP 请求进行解析, 并调用 cache.Cache 接口的 Del 方法. inMemoryCache 结构体的 Del 方法在 map 中查询 key 是否存在, 如果存在则调用 delete 函数删除该 key. 如果 cache.Cache.Del 方法返回错误, cacheHandler 会返回 500 Internal Server Error, 否则返回 200 OK.
REST 接口和处理流程介绍完了, 接下来我们来看看如何实现.
Go 语言实现
1.2.1 main 包的实现
缓存服务的 main 包只有一个函数, 就是 main 函数. 在 Go 语言中, 如果某个项目需要被编译为可执行程序, 那么它的源码需要有一个 main 包, 其中需要有一个 main 函数, 它用来作为可执行程序的入口函数. 如果某个项目不需要被编译为可执行程序, 只是实现一个库, 则可以没有 main 包和 main 函数. 我们的缓存服务需要被编译成一个可执行程序, 所以需要提供 main 包和 main 函数. main 函数的实现见例 1-1:
例 1-1 main 函数
- func main() {
- c := cache.New("inmemory")
- http.New(c).Listen()
- }
我们的 main 函数非常简单, 它需要做的只是调用 cache.New 函数创建一个新的 cache.Cache 接口的实例 c, 然后以 c 为参数调用 http.New 函数创建一个指向 http.Server 结构体的指针并调用其 Listen 方法.
cache.New 这样的写法则是指定我们调用的 New 函数属于 cache 包. Go 语言调用同一个包内的函数不需要在函数前面带上包名, Go 编译器会默认在当前包内查找. 调用另一个包中的函数则需要指定包名, 让 Go 编译器知道去哪里查找这个函数. 这里我们是在 main 包中调用 cache 包的 New 函数, 所以需要指定包名.
1.2.2 cache 包的实现
我们在 cache 包中实现服务的缓存功能. 在 cache 包内, 我们首先声明了一个 Cache 接口, 见例 1-2.
例 1-2 Cache 接口
- type Cache interface {
- Set(string, []byte) error
- Get(string) ([]byte, error)
- Del(string) error
- GetStat() Stat
- }
在 Go 语言中, 接口和实现是完全分开的. 接口甚至拥有它自己的类型(type interface). 开发者可以自由声明一个接口, 然后以一种或多种方式去实现这个接口. 在例 1-2 中, 我们看到的就是一个名为 Cache 的接口声明.
在接口内, 我们会声明一些方法, 一个接口就是该接口内所有方法的集合. 任何结构体只要实现了某个接口声明的所有方法, 我们就认为该结构体实现了该接口. 实现某个接口的结构体可以不止一个, 这意味着同样的接口实现的方式可以有很多种, Go 语言就是用这种方式来实现多态.
我们的 Cache 接口一共声明了 4 个方法, 分别是 Set,Get,Del 和 GetStat.
Set 方法用于将键值对设置进缓存, 它接收两个参数, 类型分别是 string 和 [ ]byte, 其中 string 是 key 的类型, 而[ ]byte 则是 value 的类型, byte 前面的中括号意味着它的类型是字节(byte) 的切片 (slice).Go 语言中切片的内部实现可以被认为是一个指向切片第一个元素的地址和该切片的长度. 切片和数组(Array) 的区别在于数组的长度是固定的, 而切片则是底层数组的一个视图, 其长度可以动态调整. Set 方法的返回值只有一个. 若返回值的类型是 error, 则用于返回 Set 操作的错误, 当 Set 操作成功时, 返回 nil.
Get 方法根据 key 从缓存中获取 value, 所以它接收一个 string 类型的参数, 返回值则是两个, 分别是 [ ]byte 和 error. 在 Go 语言中, 当函数具有多个返回值时, 需要用小括号 () 将它们括在一起.
Del 方法从缓存中删除 key, 所以它只有一个 string 类型的参数和一个 error 类型的返回值.
GetStat 方法用于获取缓存的状态, 它没有参数, 只有一个 Stat 类型的返回值. Stat 是一种结构体, 见例 1-3.
例 1-3 Stat 结构体相关实现
- type Stat struct {
- Count int64
- KeySize int64
- ValueSize int64
- }
- func (s *Stat) add(k string, v []byte) {
- s.Count += 1
- s.KeySize += int64(len(k))
- s.ValueSize += int64(len(v))
- }
- func (s *Stat) del(k string, v []byte) {
- s.Count -= 1
- s.KeySize -= int64(len(k))
- s.ValueSize -= int64(len(v))
- }
Go 语言编程仅仅声明接口类型 (type interface) 是没用的, 还必须实现接口. 而接口的实现需要依附于某个结构体类型(type struct).Stat 就是一个结构体, 它的内部有 3 个字段, Count 用于表示缓存目前保存的键值对数量, KeySize 和 ValueSize 分别表示 key 和 value 占据的总字节数.
结构体也可以包含方法, 和接口不同的地方在于结构体必须实现这些方法, 而接口只需要声明. Stat 结构体实现了 add 和 del 两个方法, 这两个方法分别用于新加键值对和删除键值对时改变缓存的状态.
在了解完整个 Cache 接口之后, 我们就可以去看看 New 函数的实现了, 见例 1-4.
例 1-4 New 函数实现
- func New(typ string) Cache {
- var c Cache
- if typ == "inmemory" {
- c = newInMemoryCache()
- }
- if c == nil {
- panic("unknown cache type" + typ)
- }
- log.Println(typ, "ready to serve")
- return c
- }
cache 包的 New 函数用来创建并返回一个 Cache 接口, 它接收一个 string 类型的参数 typ,typ 用于指定需要创建的 Cache 接口的具体结构体类型.
我们在函数体的第一行声明了一个类型为 Cache 接口的变量 c, 当 typ 字符串等于 "inmemory" 时, 我们将 newInMemoryCache 函数的返回值赋值给 c. 如果 c 为 nil, 我们调用 panic 报错并退出整个程序, 否则我们打印一条日志通知缓存开始服务并将 c 返回.
本文实现的缓存服务是一种内存缓存(in memory), 实现 Cache 接口的结构体名为 inMemoryCache, 见例 1-5.
例 1-5 inMemoryCache 相关代码
- type inMemoryCache struct {
- c map[string][]byte
- mutex sync.RWMutex
- Stat
- }
- func (c *inMemoryCache) Set(k string, v []byte) error {
- c.mutex.Lock()
- defer c.mutex.Unlock()
- tmp, exist := c.c[k]
- if exist {
- c.del(k, tmp)
- }
- c.c[k] = v
- c.add(k, v)
- return nil
- }
- func (c *inMemoryCache) Get(k string) ([]byte, error) {
- c.mutex.RLock()
- defer c.mutex.RUnlock()
- return c.c[k], nil
- }
- func (c *inMemoryCache) Del(k string) error {
- c.mutex.Lock()
- defer c.mutex.Unlock()
- v, exist := c.c[k]
- if exist {
- delete(c.c, k)
- c.del(k, v)
- }
- return nil
- }
- func (c *inMemoryCache) GetStat() Stat {
- return c.Stat
- }
- func newInMemoryCache() *inMemoryCache {
- return &inMemoryCache{make(map[string][]byte), sync.RWMutex{}, Stat{}}
- }
inMemoryCache 结构体包含一个成员 c, 类型是以 string 为 key, 以 [ ]byte 为 value 的 map, 用来保存键值对; 一个 mutex, 类型是 sync.RWMutex, 用来对 map 的并发访问提供读写锁保护; 一个 Stat, 用来记录缓存状态.
Go 语言的 map 可以支持多个 goroutine 同时读, 但不能支持多个 goroutine 同时写或同时既读又写, 所以我们必须用一个读写锁保护 map 的并发读写, 当多个 goroutine 同时读时, 它们会调用 mutex.RLock(), 互不影响.
当有至少一个 goroutine 需要写时, 它会调用 mutex.Lock(), 此时它会等待所有其他读写锁释放, 然后自己加锁, 在它加锁后其他 goroutine 需要加锁则必须等待它先解锁. 读写锁 mutex 的类型是 sync.RWMutex,sync 是 Go 语言自带的一个标准包, 它提供了包括 Mutex,RWMutex 在内的多种互斥锁的实现.
需要特别注意的是 Stat, 它的类型是 Stat 结构体, 但是它没有提供成员名字, 这种写法在 Go 语言中被称为内嵌. 结构体可以内嵌多个结构体和接口, 接则只能内嵌多个接口.
Go 语言通过内嵌来实现继承, 内嵌结构体 / 接口可以被认为是外层结构体 / 接口的父类. 一个内嵌结构体 / 接口的所有成员 / 方法都可以通过外层结构体 / 接口直接访问, 那些成员 / 方法的首字母不需要大写.(通常我们从一个结构体外部只能访问其首字母大写的成员 / 方法, 访问自己的内嵌成员的成员 / 方法不受此限制.)当我们需要访问某个内嵌成员本身时, 我们可以直接用它的类型指代它, 就如同我们在 inMemoryCache.GetStat 函数中做的那样.
1.2.3 HTTP 包的实现
HTTP 包用来实现我们的 HTTP 服务功能. 由于不需要使用多态, 我们在 HTTP 包里并没有声明接口, 而是直接声明了一个 Server 结构体, 见例 1-6.
例 1-6 Server 相关实现
- type Server struct {
- cache.Cache
- }
- func (s *Server) Listen() {
- http.Handle("/cache/", s.cacheHandler())
- http.Handle("/status", s.statusHandler())
- http.ListenAndServe(":12345", nil)
- }
- func New(c cache.Cache) *Server {
- return &Server{c}
- }
Server 结构体中内嵌了 cache.Cache,cache.Cache 就是之前介绍的 cache 包的 Cache 接口. HTTP 包的 Server 结构体内嵌该接口意味着 http.Server 也实现了 cache.Cache 接口, 而实现的方式则由实际的内嵌结构体决定.
接下来我们看到 Server 的 Listen 方法会调用 http.Handle 函数, 它会注册两个 Handler 分别用来处理 / cache / 和 / status 这两个 HTTP 协议的端点.
这里需要注意的是 http.Handle 函数并不属于我们的 HTTP 包, 而是 Go 语言自己的 net/http 标准包. 还记得吗? Server 结构体自身就处于我们的 HTTP 包里, 引用自己包内的名字无需指定包名, 所以当我们指定 HTTP 包名时, Go 语言编译器会知道去 net/http 包中查找名字.
Server.cacheHandler 方法返回的是一个 http.Handler 接口, 它用来处理 HTTP 端点 / cache / 的请求, 也就是缓存的 Set,Get,Del 这 3 个基本操作, 见例 1-7.
例 1-7 cacheHandler 相关实现
- type cacheHandler struct {
- *Server
- }
- func (h *cacheHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- key := strings.Split(r.URL.EscapedPath(), "/")[2]
- if len(key) == 0 {
- w.WriteHeader(http.StatusBadRequest)
- return
- }
- m := r.Method
- if m == http.MethodPut {
- b, _ := ioutil.ReadAll(r.Body)
- if len(b) != 0 {
- e := h.Set(key, b)
- if e != nil {
- log.Println(e)
- w.WriteHeader(http.Status InternalServerError)
- }
- }
- return
- }
- if m == http.MethodGet {
- b, e := h.Get(key)
- if e != nil {
- log.Println(e)
- w.WriteHeader(http.StatusInternalServer Error)
- return
- }
- if len(b) == 0 {
- w.WriteHeader(http.StatusNotFound)
- return
- }
- w.Write(b)
- return
- }
- if m == http.MethodDelete {
- e := h.Del(key)
- if e != nil {
- log.Println(e)
- w.WriteHeader(http.StatusInternal ServerError)
- }
- return
- }
- w.WriteHeader(http.StatusMethodNotAllowed)
- }
- func (s *Server) cacheHandler() http.Handler {
- return &cacheHandler{s}
- }
cacheHandler 结构体内嵌了一个 Server 结构体的指针, 并实现了 ServeHTTP 方法, 实现该方法就意味着实现了 http.Handler 接口. 例 1-8 展示了 Go 语言标准包 net/http 对 Handler 接口的定义.
例 1-8 Go 标准包 net/http 中 Handler 接口的定义
- type Handler interface {
- ServeHTTP(ResponseWriter, *Request)
- }
cacheHandler 的 ServeHTTP 方法解析 URL 以获取 key, 并根据 HTTP 请求的 3 种方式 PUT/GET/DELETE 决定调用 cache.Cache 的 Set/Get/Del 方法.
这里我们看到了 Go 语言内嵌的高阶使用方式 -- 多重内嵌: cacheHandler 内嵌了 Server 结构体指针, 而 Server 内嵌了 cache.Cache 接口. 于是 cacheHandler 就可以直接访问 cache.Cache 的方法了.
Server.statusHandler 方法同样返回一个 http.Handler 接口, 其实现见例 1-9.
例 1-9 statusHandler 相关实现
- type statusHandler struct {
- *Server
- }
- func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet {
- w.WriteHeader(http.StatusMethodNotAllowed)
- return
- }
- b, e := JSON.Marshal(h.GetStat())
- if e != nil {
- log.Println(e)
- w.WriteHeader(http.StatusInternalServerError)
- return
- }
- w.Write(b)
- }
- func (s *Server) statusHandler() http.Handler {
- return &statusHandler{s}
- }
和 cacheHandler 一样, statusHandler 内嵌 Server 结构体指针并实现 ServeHTTP 方法. 该方法调用 cache.Cache 的 GetStat 方法并将返回的 cache.Stat 结构体用 JSON 格式编码成字节切片 b, 写入 HTTP 的响应正文.
如果你是一位程序员, 看到这里你的心里可能会有一个疑问. 我们这样实现会不会太复杂了? 为了处理两个 HTTP 端点的请求, 我们需要实现两个 Handler 结构体并分别实现它们的 ServeHTTP 方法, 能不能直接在 Server 结构体上实现 ServeHTTP 方法并根据 URL 区分不同的 HTTP 请求?
从实现上来说是可行的, 但是那意味着 Server 的 ServeHTTP 需要承担两个不同的职责, 处理两类 HTTP 请求. 将这两类请求分开到不同的结构体内实现符合 SOLID 的单一职责原则.
Go 语言的实现介绍完了, 接下来我们需要把程序运行起来, 并进行功能测试来验证我们的实现.
来源: http://stor.51cto.com/art/201901/589641.htm