Go语言实战(一)| 图书管理API服务

本文是学习极客时间专栏《Go语言第一课》的笔记记录,本文主要介绍了一整套的服务设计模式,基于世界情况的图书管理API服务。

关键词:API

明确业务逻辑

我们模拟的是一个基于真实世界的图书管理后端服务,这个服务为平台前端以及其他客户端,提供针对图书的CURD(创建、检索、更新、删除)的基于HTTP协议的API。API采用典型的RESTful API设计风格。

首先明确这个服务提供的API集合如下:

这个API的业务逻辑并不复杂。简单来说,我们通过唯一id来标识一本书,对于图书来说这个一般是ISBN号码。

对于在客户端和服务端请求与响应的数据,我们放在HTTP请求的Body体中的JSON数据来承载。

项目建立与布局设计

按照下面步骤创建一个Go项目并创建对应的Go Module:

通过第一部分的业务说明,我们把服务大体拆分成两大模块,一部分是HTTP服务器,用于对外提供HTTP服务;另一部分是图书数据的存储模块,所有的数据都存储在这里。

由于这是以构建可执行程序为目的Go项目,我们参考专栏中的项目布局,将这个项目布局设计成如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
├── cmd/
│ └── bookstore/ // 放置bookstore main包源码
│ └── main.go
├── go.mod // module bookstore的go.mod
├── go.sum
├── internal/ // 存放项目内部包的目录
│ └── store/
│ └── memstore.go
├── server/ // HTTP服务器模块
│ ├── middleware/
│ │ └── middleware.go
│ └── server.go
└── store/ // 图书数据存储模块
├── factory/
│ └── factory.go
└── store.go

上图给出了这个项目的结构布局,也给出了这个项目最终实现的源码文件分布情况,下面将从main包开始,自上而下查看项目的模块设计与实现。

项目main包

main包是主要包,首先要理清各个模块的关系,最好能给出main包的逻辑实现图

img

首先来分析一下main包下的main函数代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
 package main

import (
_ "bookstore/internal/store"
"bookstore/server"
"bookstore/store/factory"
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
)

func main() {
s, err := factory.New("mem") // 创建图书数据存储模块实例
if err != nil {
panic(err)
}

srv := server.NewBookStoreServer(":8080", s) // 创建http服务实例

errChan, err := srv.ListenAndServe() // 运行http服务
if err != nil {
log.Println("web server start failed:", err)
return
}
log.Println("web server start ok")

c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)

select { // 监视来自errChan以及c的事件
case err = <-errChan:
log.Println("web server run failed:", err)
return
case <-c:
log.Println("bookstore program is exiting...")
ctx, cf := context.WithTimeout(context.Background(), time.Second)
defer cf()
err = srv.Shutdown(ctx) // 优雅关闭http服务实例
}

if err != nil {
log.Println("bookstore program exit error:", err)
return
}
log.Println("bookstore program exit ok")
}

main包不仅包含整个程序的入口,还是整个程序中主要模块初始化与组装的场所。本程序的主要模块是第16行的创建图书存储模块实例,以及21行创建HTTP服务模块实例。第21行创建HTTP服务模块实例时,我们把第16行的创建图书存储模块实例s,传递给了NewBookStoreServer函数。稍后会讲述这两个实例的创建原理。

我们重点来看30行到第42行。这里实现了通过监视系统信号实现了http服务实例的优雅退出

优雅退出指的是程序有机会等待其他的事情处理完再退出,比如尚未完成的事务处理、清理资源(关闭文件标识符,关闭socket)、保存必要中间状态,内存数据持久化落盘等。优雅退出是使用Go编写http服务时必须要考虑的问题。

在本程序的实现上,本文通过signal包的Notify函数捕获了SIGINT、SIGTERM这两个系统信号,当应用程序捕获到这两个信号而不得不关闭时,我们的http服务实例都有机会做出一些清理工作。

再来看图书数据存储模块和HTTP服务模块的实现。

图书数据存储模块(store)

图书数据存储有很多方式,最简单的方式莫过于是在内存中创建一个map,以图书id作为key,用来保存图书信息,也就是采用不持久化的方式,本文也会采用这种方式。但是如果我们要考虑到上生产环境,数据要用来持久化,那么最实际的方式就是通过NoSQL甚至关系型数据库来实现对图书数据的存储与管理。

考虑到对多种存储实现方式的支持,我们将针对图书的有限种存储操作,放置在一个接口类型的Store中,如下源码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// store/store.go

type Book struct {
Id string `json:"id"` // 图书ISBN ID
Name string `json:"name"` // 图书名称
Authors []string `json:"authors"` // 图书作者
Press string `json:"press"` // 出版社
}

type Store interface {
Create(*Book) error // 创建一个新图书条目
Update(*Book) error // 更新某图书条目
Get(string) (Book, error) // 获取某图书信息
GetAll() ([]Book, error) // 获取所有图书信息
Delete(string) error // 删除某图书条目
}

我们建立了一个对应图书条目的抽象数据Book,以及针对Book存取的接口类型Store。

对于想要进行图书数据的存储操作的一方来说,它只需要得到一个满足Store接口的实例,就可以实现对图书数据的存储操作了,而不需要关心图书数据究竟采用了何种存储接口方式。这实现了图书存储操作和底层图书数据存储方式的解耦

这种面向接口编程也是Go组合设计哲学的一种体现。

面向接口编程就是先把客户的业务逻辑线提取出来,作为接口,业务具体实现通过该接口的实现类来完成。 当客户需求变化时,只需编写该业务逻辑的新的实现类,通过更改配置文件或者其他方式实现该接口的实现类就可以完成需求,这种方式不需要改写现有代码,可以减少对系统的影响。

参考《设计模式》中提供的Go风格的工厂模式来满足Store接口实例的创建,下面实现了store/factory包的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// store/factory/factory.go

var (
providersMu sync.RWMutex
providers = make(map[string]store.Store)
)

func Register(name string, p store.Store) {
providersMu.Lock()
defer providersMu.Unlock()
if p == nil {
panic("store: Register provider is nil")
}

if _, dup := providers[name]; dup {
panic("store: Register called twice for provider " + name)
}
providers[name] = p
}

func New(providerName string) (store.Store, error) {
providersMu.RLock()
p, ok := providers[providerName]
providersMu.RUnlock()
if !ok {
return nil, fmt.Errorf("store: unknown provider %s", providerName)
}

return p, nil
}

源码效仿了Go标准库的database/sql包采用的方式。factory采用一个map类型变量,对工厂可以"制造"的、满足Store接口的实例类型进行管理。factory提供了Register函数,让各个实现Store的接口类型可以把自己"注册"到工厂中来。

一旦注册成功,factory包可以"制造"出满足这种Store接口的类型实例,而依赖Store接口的使用方,只需要调用factory包的New函数、传入期望实现的存储名称,就可以得到对应的类型实例了。

下面提供了基于内存map的Store接口的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// internal/store/memstore.go

package store

import (
mystore "bookstore/store"
factory "bookstore/store/factory"
"sync"
)

func init() {
factory.Register("mem", &MemStore{
books: make(map[string]*mystore.Book),
})
}

type MemStore struct {
sync.RWMutex
books map[string]*mystore.Book
}

上述代码在init函数调用了factory包提供的Register函数,并把自己的实例以"mem"的名称导入到factory中。依赖Store接口进行图书馆里的一方,只需要导入internal/store这个包,就可以自动完成注册动作了。

再来看下main包中创建图书数据存储模块实例时采用的代码:

1
2
3
4
5
6
7
8
9
10
11
12
import (
... ...
_ "bookstore/internal/store" // internal/store将自身注册到factory中
)

func main() {
s, err := factory.New("mem") // 创建名为"mem"的图书数据存储模块实例
if err != nil {
panic(err)
}
... ...
}

HTTP服务模块(server)

HTTP服务模块的职责是对外提供HTTP API服务,处理来自客户端的各种请求,并通过Store接口实例执行针对图书数据的各种操作。

我们抽象处理定义一个server包,其中包含一个BookStoreServer类型如下:

1
2
3
4
5
6
// server/server.go

type BookStoreServer struct {
s store.Store
srv *http.Server
}

BookStoreServer类型实质上就是一个标准库的 http.Server,并且组合了来自 store.Store 接口的能力。server 包提供了 NewBookStoreServer 函数,用来创建一个 BookStoreServer 类型实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// server/server.go

func NewBookStoreServer(addr string, s store.Store) *BookStoreServer {
srv := &BookStoreServer{
s: s,
srv: &http.Server{
Addr: addr,
},
}

router := mux.NewRouter()
router.HandleFunc("/book", srv.createBookHandler).Methods("POST")
router.HandleFunc("/book/{id}", srv.updateBookHandler).Methods("POST")
router.HandleFunc("/book/{id}", srv.getBookHandler).Methods("GET")
router.HandleFunc("/book", srv.getAllBooksHandler).Methods("GET")
router.HandleFunc("/book/{id}", srv.delBookHandler).Methods("DELETE")

srv.srv.Handler = middleware.Logging(middleware.Validating(router))
return srv
}

NewBookStoreServer接受了两个参数,一个是HTTP监听的服务地址,另外一个是实现了store.Store接口类型的实例。

这种函数原型的设计是Go语言的一种常见设计方法,即接受一个接口参数,返回一个具体类型。返回的具体类型组合传入的接口类型的能力。

还需要为HTTP服务器设置请求的处理函数,也就是业务接口,根据请求参数和请求方法处理对应的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// server/server.go

func (bs *BookStoreServer) createBookHandler(w http.ResponseWriter, req *http.Request) {
dec := json.NewDecoder(req.Body)
var book store.Book
if err := dec.Decode(&book); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

if err := bs.s.Create(&book); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}

func (bs *BookStoreServer) getBookHandler(w http.ResponseWriter, req *http.Request) {
id, ok := mux.Vars(req)["id"]
if !ok {
http.Error(w, "no id found in request", http.StatusBadRequest)
return
}

book, err := bs.s.Get(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

response(w, book)
}

func response(w http.ResponseWriter, v interface{}) {
data, err := json.Marshal(v)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}

这些逻辑都大同小异,都是根据http请求获取http body,然后通过go-json库解码为我们需要的store.Store结构体,再通过Store接口对图书数据进行相应的操作。如果是获取图书数据的请求,那么处理函数将通过response函数,把取出的图书数据编码为http响应,并返回客户端。

在NewBookStoreServer函数的尾部,有一行适配器类型的代码:

1
srv.srv.Handler = middleware.Logging(middleware.Validating(router))

这行代码的意思是在route的外围包含了两层middleware,middleware是通用的http处理函数

以下是通用的http处理函数的处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// server/middleware/middleware.go

func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
log.Printf("recv a %s request from %s", req.Method, req.RemoteAddr)
next.ServeHTTP(w, req)
})
}

func Validating(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
contentType := req.Header.Get("Content-Type")
mediatype, _, err := mime.ParseMediaType(contentType)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if mediatype != "application/json" {
http.Error(w, "invalid Content-Type", http.StatusUnsupportedMediaType)
return
}
next.ServeHTTP(w, req)
})
}

Logging函数主要用来输出每个到达的HTTP请求的一些概要信息,Validating函数会每个http请求头进行检查,确定媒体类型是否是application/json。这些通用的处理逻辑,会被串联每个真正的处理函数之前,避免我们在每个处理函数里处理这些逻辑。

创建完BookStoreServer实例后,就可以调用ListenAndServe方法运行这个http服务了,显然这个是效仿http.Server类型的同名方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// server/server.go

func (bs *BookStoreServer) ListenAndServe() (<-chan error, error) {
var err error
errChan := make(chan error)
go func() {
err = bs.srv.ListenAndServe()
errChan <- err
}()

select {
case err = <-errChan:
return nil, err
case <-time.After(time.Second):
return errChan, nil
}
}

这个函数是BookStoreServer内部的http.Server的运行,放置到一个单独的goroutine里。http.Server.ListenAndServe会阻塞代码的继续运行,如果不把此行放在goroutine里,后面的代码将会无法执行。

为了检测到http.Server.ListenAndServe的运行状态,我们再通过一个channel创建一个子goroutine和主goroutine的通信渠道。通过这个channel就可以及时得到http server的运行状态。