如何用GO语言编写缓存服务?

存储 存储软件
随着互联网的飞速发展,各行各业对互联网服务的要求也越来越高,服务架构能撑起多大的业务数据?服务响应的速度能不能达到要求?我们的架构师每天都在思考这些问题。

 随着互联网的飞速发展,各行各业对互联网服务的要求也越来越高,服务架构能撑起多大的业务数据?服务响应的速度能不能达到要求?我们的架构师每天都在思考这些问题。

对于数据库或者对象存储等服务来说,它们受限于自己先天的设计目标,往往不能具有很好的性能,响应时间通常是秒级。此时就需要高性能的缓存来为我们的服务提速了,缓存服务的响应时间通常是毫秒级,甚至小于1ms。

缓存服务需要被设置在其他服务的前端,客户端首先访问缓存,查询自己的数据,仅当客户端需要的数据不存在于缓存中时,才去访问实际的服务。从实际的服务中获取到的数据会被放在缓存中,以备下次使用。

[[254501]]

缓存的设计目标就是尽可能地快,但它引起了其他的问题。比如目前业界使用较多的缓存服务有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方法进行。我们可以查询的缓存服务状态包括当前缓存了多少对键值对,所有的键一共占据了多少字节,所有的值一共占据了多少字节。

  1. PUT /cache/<key
  2. 请求正文 
  3. ●  <value> 

客户端通过HTTP的PUT方法将一对键值对设置进缓存服务器,服务器将该键值对保存在内存堆上创建的map里。

这里/cache/是一个URL,它标识了缓存的值(value)所在的位置。URL是Uniform Resource Locator的缩写,它是一个网络地址,用于引用某个网络资源在网络上的位置。HTTP的请求正文(request body)里包含了该key对应的value的内容。

  1. GET /cache/<key
  2. 响应正文 
  3. ●  <value> 

客户端通过HTTP的GET方法从缓存服务器上获取key对应的value,服务器在map中查找该key,如果key不存在,服务器返回HTTP错误代码404 NOT FOUND;如果key存在,则服务器在HTTP响应正文(response body)中返回相应的value。

 

  1. DELETE /cache/ 

客户端通过HTTP的DELETE方法将key从缓存中删除。无论之前该key是否存在,之后它都将不存在,服务器始终返回HTTP错误代码200 OK。

  1. GET /status 
  2. 响应正文 
  3. ●  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函数

  1. func main() { 
  2.         c := cache.New("inmemory"
  3.         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接口

  1. type Cache interface { 
  2.           Set(string, []byte) error 
  3.           Get(string) ([]byte, error) 
  4.           Del(string) error 
  5.           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结构体相关实现

  1. type Stat struct { 
  2.             Count      int64 
  3.             KeySize    int64 
  4.             ValueSize  int64 
  5.  
  6. func (s *Stat) add(k string, v []byte) { 
  7.            s.Count += 1 
  8.            s.KeySize += int64(len(k)) 
  9.            s.ValueSize += int64(len(v)) 
  10.  
  11. func (s *Stat) del(k string, v []byte) { 
  12.            s.Count -= 1 
  13.            s.KeySize -= int64(len(k)) 
  14.            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函数实现

  1. func New(typ string) Cache { 
  2.           var c Cache 
  3.           if typ == "inmemory" { 
  4.                   c = newInMemoryCache() 
  5.           } 
  6.           if c == nil { 
  7.                   panic("unknown cache type " + typ) 
  8.           } 
  9.           log.Println(typ, "ready to serve"
  10.           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相关代码

  1. type inMemoryCache struct { 
  2.           c     map[string][]byte 
  3.           mutex sync.RWMutex 
  4.           Stat 
  5.  
  6. func (c *inMemoryCache) Set(k string, v []byte) error { 
  7.             c.mutex.Lock() 
  8.             defer c.mutex.Unlock() 
  9.             tmp, exist := c.c[k] 
  10.             if exist { 
  11.                          c.del(k, tmp) 
  12.             } 
  13.             c.c[k] = v 
  14.             c.add(k, v) 
  15.             return nil 
  16.  
  17. func (c *inMemoryCache) Get(k string) ([]byte, error) { 
  18.             c.mutex.RLock() 
  19.             defer c.mutex.RUnlock() 
  20.             return c.c[k], nil 
  21.  
  22. func (c *inMemoryCache) Del(k string) error { 
  23.             c.mutex.Lock() 
  24.             defer c.mutex.Unlock() 
  25.             v, exist := c.c[k] 
  26.             if exist { 
  27.                           delete(c.c, k) 
  28.                           c.del(k, v) 
  29.             } 
  30.             return nil 
  31.  
  32. func (c *inMemoryCache) GetStat() Stat { 
  33.             return c.Stat 
  34.  
  35. func newInMemoryCache() *inMemoryCache { 
  36.       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相关实现

  1. type Server struct { 
  2.            cache.Cache 
  3.  
  4. func (s *Server) Listen() { 
  5.            http.Handle("/cache/", s.cacheHandler()) 
  6.            http.Handle("/status", s.statusHandler()) 
  7.            http.ListenAndServe(":12345", nil) 
  8.  
  9. func New(c cache.Cache) *Server { 
  10.           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相关实现

  1. type cacheHandler struct { 
  2.           *Server 
  3.  
  4. func (h *cacheHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 
  5.           key := strings.Split(r.URL.EscapedPath(), "/")[2] 
  6.           if len(key) == 0 { 
  7.                        w.WriteHeader(http.StatusBadRequest) 
  8.                       return 
  9.           } 
  10.           m := r.Method 
  11.           if m == http.MethodPut { 
  12.                      b, _ := ioutil.ReadAll(r.Body) 
  13.                      if len(b) != 0 { 
  14.                       e := h.Set(key, b) 
  15.                       if e != nil { 
  16.                            log.Println(e) 
  17.                            w.WriteHeader(http.Status InternalServerError) 
  18.                       } 
  19.                      } 
  20.                      return 
  21.           } 
  22.           if m == http.MethodGet { 
  23.                      b, e := h.Get(key
  24.                      if e != nil { 
  25.                              log.Println(e) 
  26.                              w.WriteHeader(http.StatusInternalServer Error) 
  27.                              return 
  28.                      } 
  29.                      if len(b) == 0 { 
  30.                              w.WriteHeader(http.StatusNotFound) 
  31.                              return 
  32.                      } 
  33.                      w.Write(b) 
  34.                      return 
  35.           } 
  36.           if m == http.MethodDelete { 
  37.                      e := h.Del(key
  38.                      if e != nil { 
  39.                      log.Println(e)  
  40.                      w.WriteHeader(http.StatusInternal ServerError) 
  41.                      } 
  42.                      return 
  43.            } 
  44.            w.WriteHeader(http.StatusMethodNotAllowed) 
  45.  
  46. func (s *Server) cacheHandler() http.Handler { 
  47.             return &cacheHandler{s} 

cacheHandler结构体内嵌了一个Server结构体的指针,并实现了ServeHTTP方法,实现该方法就意味着实现了http.Handler接口。例1-8展示了Go语言标准包net/http对Handler接口的定义。

例1-8 Go标准包net/http中Handler接口的定义

  1. type Handler interface { 
  2.           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相关实现

  1. type statusHandler struct { 
  2.            *Server 
  3.  
  4. func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 
  5.           if r.Method != http.MethodGet { 
  6.                       w.WriteHeader(http.StatusMethodNotAllowed) 
  7.                       return 
  8.           } 
  9.           b, e := json.Marshal(h.GetStat()) 
  10.           if e != nil { 
  11.                          log.Println(e) 
  12.                          w.WriteHeader(http.StatusInternalServerError) 
  13.                          return 
  14.           } 
  15.           w.Write(b) 
  16.  
  17. func (s *Server) statusHandler() http.Handler { 
  18.             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语言的实现介绍完了,接下来我们需要把程序运行起来,并进行功能测试来验证我们的实现。

责任编辑:武晓燕 来源: 异步图书
相关推荐

2018-02-28 17:05:19

UbuntuGo语言Git

2019-10-11 15:10:09

GVMGoLinux

2017-09-15 09:43:59

Go语言web请求开发

2024-07-30 09:02:15

2010-04-20 14:06:56

Oracle SQL语

2023-02-26 01:37:57

goORM代码

2013-03-12 09:50:45

GoRESTful Web

2018-12-06 08:40:43

PythonR函数编程语言

2021-08-05 16:10:03

进程缓存缓存服务Java

2024-01-15 00:42:55

Go语言应用程序

2011-02-25 10:12:09

GoWeb

2011-05-17 14:53:35

C

2020-03-17 10:24:12

Go语言停止写障碍

2023-05-19 08:01:57

Go 语言map

2015-09-16 17:30:20

安装Go语言Linux

2017-10-26 11:44:19

工具语言编写

2023-10-26 11:03:50

C语言宏定义

2018-03-12 22:13:46

GO语言编程软件

2022-09-20 08:43:37

Go编程语言Web

2010-06-30 10:52:12

snmp服务Cacti
点赞
收藏

51CTO技术栈公众号