Improving the code from the official Go RESTful API tutorial

Ben Hoyt

改进官方 Go RESTful API 教程中的代码

原文由 Ben Hoyt 发布,订阅该博客

摘要:本文介绍了我对官方 Go 教程“Developing a RESTful API with Go and Gin”中代码的重写实现。我的版本增加了一些功能,修复了若干问题,添加了测试,并且仅使用 Go 标准库。

最近我阅读了新的教程:使用 Go 和 Gin 开发 RESTful API,与 Go 其他优秀的文档相比,这个教程似乎存在一些质量问题,而且官方文档使用第三方库(Gin)而非推广标准库,让我觉得有些奇怪。

因此我决定仅使用标准库重写代码,并修复因缺少加锁而导致的并发问题。我还加入了一些功能,比如输入校验和更完善的错误处理——我认为这些应该是任何“真正的” Web 服务都应具备的。

重写完成后,我在 Go 开发邮件列表 golang-dev 上就这些问题发起了讨论,Go 技术负责人 Russ Cox 回复道:

这是我们计划推出的几个使用 Go 第三方包生态的教程中的第一个。其目的是展示那些被广泛使用、能简化常见用例的包。

我想这也能说得通,尤其是在有了 Go modules 之后。当我提到代码中具体的那些问题时,他表示更倾向于保持教程原样。他的回复很有帮助:

我认为这些都超出了本教程的范围。真正的系统根本不会使用内存数据库,所以内存数据库缺少加锁并不算什么大问题。专辑校验等情况也是如此。教程的目标是简短、聚焦于阐释某个具体想法,在这里就是基于 JSON 的 RESTful API。它有意省略了真实系统中会出现的全部输入校验、认证以及其他复杂情况。你提到的这些都是值得强调的好问题,也很感谢你花时间写了这篇博文,但如果把它们加入这个特定的教程,反而会分散其原本集中的重点。

这么说也算合理。不过,我依然认为教程不应该包含 bug,所以我很希望看到他们修复这个并发问题,或者至少把它作为一种简化处理明确指出来。在许多初学者通过复制示例代码来学习的情况下,忽略重要细节是有风险的。所以我认为在这些代码中树立更好的榜样是很必要的。

下面我会讨论在自己的版本中所做的改进。完整源码见 GitHub 上的 benhoyt/web-service-stdlib

改进一览

以下是我在原始代码中发现并在自己版本中修改或改进的地方(附有下方更详细章节的链接):

  • 标准库。如前所述,原版使用了 Gin Web 框架。我的版本仅使用标准库中的包。
  • 校验。原版对“创建新专辑”输入没有任何校验(除了确保是 JSON),因此很容易添加一个 ID 为空、价格为负数等的专辑。我已改为做一些基本校验,并向客户端返回可解析的校验错误。
  • 唯一的专辑 ID。现有代码会欣然接受重复 ID 的专辑,而 /albums/:id 接口只会返回第一个。这似乎有问题:它或许应该要么使用 PUT /albums/:id 来直接更新给定 ID 的专辑,要么仍然使用 POST /albums 但对重复项返回错误——我选择了后一种做法。
  • 并发。原版代码中的全局 albums 切片在读写时没有加锁,因此该 Web 服务无法被并发访问,尝试并发访问时会 panic。我知道这只是一个示例,真实的数据库不会有这个问题,但加上一个简单的互斥锁就能让这段代码变得安全,这并不难。我的版本添加了锁,并增加了一个测试来确保没有数据竞争。
  • 十进制货币。专辑的 Price 字段是 float64。用二进制浮点数来存储和处理货币值并不好,因为二进制浮点无法精确表示十进制小数,对其进行运算时会引入舍入误差。我已将 Price 字段改为以分为单位的整数(“定点数”)。
  • JSON 错误。Gin 路由器的默认 Not Found 错误返回 Content-Type: text/plain,因此这些错误返回的是纯文本而非 JSON。不过 getAlbumByID 中显式返回的 Not Found 倒是返回了 JSON。同样,Gin 的 BindJSON 在收到非法输入时也不会返回 JSON 错误。我的版本将所有错误都以 JSON 形式返回。
  • 方法未找到。Gin(至少在教程所用的默认配置下)在 URL 有效但方法不正确时会返回 404 Not Found 而非 405 Method Not Allowed。我已将其修正为在这些情况下返回标准的 405 状态码。
  • 测试。原版没有任何测试。这没关系,因为这并非教程的目的。但使用 Go 的 httptest 库来测试 HTTP 处理器非常容易,我已为所有功能(包括错误情况)添加了测试。
  • 数据库接口。我为数据库方法使用了显式的接口(可以返回 ErrDoesNotExist 等已定义的错误),并提供了一个与原版类似的内存实现
  • 关注点分离。在原版中,“数据库”代码与处理器代码混在一起。部分得益于使用了数据库接口,在我的版本中数据库代码与 HTTP 处理器代码完全分离,使得测试错误处理等变得更容易,也便于在需要时替换为真实数据库。

我的版本代码量明显更多(约 300 行代码,而不是 50 行,另有约 300 行测试代码),但这主要是因为增加了额外功能。我认为我的版本所展示的代码更加健壮、更易于维护。

下面我们更深入地看一下上述每一点。

标准库

Gin 提供了 URL 路由(包括 URL 参数)和几个 JSON 编组函数。在我的版本中,我编写了一些简单的路由代码,并添加了几个自定义的 JSON 辅助函数。

我在别处曾深入探讨过 Go 中不同的 HTTP 路由方案,但这里的路由非常简单,所以我使用了正则分支方案的简化版本,用正则表达式来解析 /albums/:id 路由。因此在这里我们甚至不需要标准库的 http.ServeMux

如果不用正则表达式,/albums/:id 路由也能实现得相当简单,但用正则处理边界情况会更简洁一些:确保 ID 至少有一个字符且不包含斜杠。

我的代码还会处理 HTTP 方法,包括正确处理 405 Method Not Allowed。以下是完整的路由代码:

// Regex to match "/albums/:id" (id must be one or more non-slash chars).
var reAlbumsID = regexp.MustCompile(`^/albums/([^/]+)$`)

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    path := r.URL.Path
    s.log.Printf("%s %s", r.Method, path)

    var id string

    switch {
    case path == "/albums":
        switch r.Method {
        case "GET":
            s.getAlbums(w, r)
        case "POST":
            s.addAlbum(w, r)
        default:
            w.Header().Set("Allow", "GET, POST")
            s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
        }

    case match(path, reAlbumsID, &id):
        switch r.Method {
        case "GET":
            s.getAlbumByID(w, r, id)
        default:
            w.Header().Set("Allow", "GET")
            s.jsonError(w, http.StatusMethodNotAllowed, ErrorMethodNotAllowed, nil)
        }

    default:
        s.jsonError(w, http.StatusNotFound, ErrorNotFound, nil)
    }
}

代码有点冗长,但非常清晰明确,也避免了为了让第三方路由器以 JSON 返回错误并正确返回 405 而进行额外配置的麻烦。

Gin 让代码变短的另一个地方是它的 IndentedJSONBindJSON 辅助函数,它们分别用于 JSON 的编组和解组。好在仅使用标准的 encoding/json 包,使用 JSON 也非常容易。我编写了几个小型的辅助函数来封装这一过程并处理错误:

// writeJSON marshals v to JSON and writes it to the response, handling
// errors as appropriate. It also sets the Content-Type header to
// "application/json".
func (s *Server) writeJSON(w http.ResponseWriter, status int, v interface{}) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    b, err := json.MarshalIndent(v, "", "    ")
    if err != nil {
        s.log.Printf("error marshaling JSON: %v", err)
        http.Error(w, `{"error":"`+ErrorInternal+`"}`, http.StatusInternalServerError)
        return
    }
    w.WriteHeader(status)
    _, err = w.Write(b)
    if err != nil {
        // Very unlikely to happen, but log any error (not much more we can do)
        s.log.Printf("error writing JSON: %v", err)
    }
}

// readJSON reads the request body and unmarshals it from JSON, handling
// errors as appropriate. It returns true on success; the caller should
// return from the handler early if it returns false.
func (s *Server) readJSON(w http.ResponseWriter, r *http.Request, v interface{}) bool {
    b, err := io.ReadAll(r.Body)
    if err != nil {
        s.log.Printf("error reading JSON body: %v", err)
        s.jsonError(w, http.StatusInternalServerError, ErrorInternal, nil)
        return false
    }
    err = json.Unmarshal(b, v)
    if err != nil {
        data := map[string]interface{}{"message": err.Error()}
        s.jsonError(w, http.StatusBadRequest, ErrorMalformedJSON, data)
        return false
    }
    return true
}

我本可以使用 json.Encoder 直接流式写入响应。然而,错误处理会有点棘手:如果出现了 JSON 编组错误,而 Encoder.Encode 已经向响应写入了部分内容,就无法再返回非 200 的 HTTP 状态了。在 Album 这种情况下出错的可能性很小(甚至不可能?),因为它是一个非常简单的结构体,但在一般情况下 JSON 编码是可能返回错误的,所以我先将结构体编组为 []byte

同样,对于解组,你可以使用 json.Decoder 直接从请求体读取——不过,它其实是为流式数据设计的

注意,对于 Internal Server Error,我们是将 err 记录到日志中——它可能包含敏感信息(或者只是信息过多),所以将其记入日志,而不是包含在响应中。

校验

这是 Web 安全乃至任何软件的第一条规则之一:始终校验用户输入。如果没有校验,服务的用户就可能添加一个没有 ID、没有标题或艺术家姓名、或者价格为负数(或大得离谱)的专辑。

我在添加专辑的端点中加入了几行校验代码,并提供了一种结构化的方式来返回校验错误,以便客户端能展示有用的错误信息。以下是完整的校验代码:

// Validate the input and build a map of validation issues
type validationIssue struct {
    Error   string `json:"error"`
    Message string `json:"message,omitempty"`
}
issues := make(map[string]interface{})
if album.ID == "" {
    issues["id"] = validationIssue{"required", ""}
}
if album.Title == "" {
    issues["title"] = validationIssue{"required", ""}
}
if album.Artist == "" {
    issues["artist"] = validationIssue{"required", ""}
}
if album.Price < 0 || album.Price >= 100000 {
    issues["price"] = validationIssue{"out-of-range",
        "price must be between 0 and $1000"}
}
if len(issues) > 0 {
    s.jsonError(w, http.StatusBadRequest, ErrorValidation, issues)
    return
}

在这个例子中,我允许价格为零,我认为零可以表示“无价格”,比如免费或不适用(例如家庭收藏目录)。

我们不需要带领域特定语言的框架,只需用简单的 if 语句来检查需要的内容。我们构建一个问题映射(以字段名为键),如果存在任何校验问题,就在 JSON 错误中将其返回给调用方。以下是校验错误响应的样子:

$ curl http://localhost:8080/albums -d '{"price":-1}'
{
    "status": 400,
    "error": "validation",
    "data": {
        "artist": {
            "error": "required"
        },
        "id": {
            "error": "required"
        },
        "price": {
            "error": "out-of-range",
            "message": "price must be between 0 and $1000"
        },
        "title": {
            "error": "required"
        }
    }
}

对于更大的 Web 服务,我可能会将其进一步规范化,并酌情为结构体添加一个 Validate() map[string]ValidationIssue 方法。不过,有时同一个结构体在不同上下文中需要不同的校验,所以也许保持简单的这种做法就挺好。

唯一的专辑 ID

如前所述,原版代码在你添加具有重复 ID 的专辑时不会返回错误,例如:

$ curl http://localhost:8080/albums -d '{"id":"foo"}'
...
$ curl http://localhost:8080/albums -d '{"id":"foo"}'
...
$ curl http://localhost:8080/albums
[
    ...
    {
        "id": "foo",
        "title": "",
        "artist": "",
        "price": 0
    },
    {
        "id": "foo",
        "title": "",
        "artist": "",
        "price": 0
    }
]

我已修复这个问题,让“数据库”拒绝已存在的 ID。此时数据库的 AddAlbum 方法会返回 ErrAlreadyExists,而处理器代码会检查该错误并以 409 Conflict 响应:

// Database method:
func (d *MemoryDatabase) AddAlbum(album Album) error {
    d.lock.Lock()
    defer d.lock.Unlock()

    if _, ok := d.albums[album.ID]; ok {
        return ErrAlreadyExists
    }
    d.albums[album.ID] = album
    return nil
}

// Handler error checking:
func (s *Server) addAlbum(w http.ResponseWriter, r *http.Request) {
    // ... JSON parsing and validation ...

    err := s.db.AddAlbum(album)
    if errors.Is(err, ErrAlreadyExists) {
        s.jsonError(w, http.StatusConflict, ErrorAlreadyExists, nil)
        return
    } else if err != nil {
        s.log.Printf("error adding album ID %q: %v", album.ID, err)
        s.jsonError(w, http.StatusInternalServerError, ErrorDatabase, nil)
        return
    }

    s.writeJSON(w, http.StatusCreated, album)
}

更新:正如一位评论者所指出的,让数据库生成唯一的专辑 ID,而不是由用户来设定,会更好。

并发

原版代码在你访问 GET 端点的同时有人 POST 专辑时存在数据竞争。显然,使用 SQL 数据库就能解决这个问题,因为这类数据库自带并发安全。但在访问内存结构时加一个互斥锁也并不难。

在这里我使用的是 sync.RWMutex,因为专辑被查看的频率几乎肯定远高于被添加的频率。因此我在读操作周围加上了 RLock/RUnlock 调用,在写操作周围加上了 Lock/Unlock

更有意思的是,我添加了一个测试,如果没有加锁,它会在 Go 的竞态检测器下失败——想看效果的话,可以试着注释掉加锁和解锁的调用,然后运行 go test -race

该测试会启动一堆 goroutine,每个都会对全部三个端点发起读写请求:

func TestConcurrentRequests(t *testing.T) {
    server := newTestServer()
    for i := 0; i < 100; i++ {
        go func(i int) {
            result := serve(t, server, newRequest(t, "GET", "/albums", nil))
            ensureStatus(t, result, http.StatusOK)

            albumID := "c" + strconv.Itoa(i)
            body := `{"id": "` + albumID + `", "title": "T", "artist": "A"}`
            result = serve(t, server, newRequest(t, "POST", "/albums", strings.NewReader(body)))
            ensureStatus(t, result, http.StatusCreated)

            result = serve(t, server, newRequest(t, "GET", "/albums/"+albumID, nil))
            ensureStatus(t, result, http.StatusOK)
        }(i)
    }
}

十进制货币

一般来说,用二进制浮点数来存储和处理货币值是不明智的——你无法精确存储十进制小数(分),对这些值进行运算时会不断累积误差。

为了解决这个问题,我已将专辑的 Price 字段从 float64 改为 int,使其能够以整数分的形式精确存储。这是准确存储货币值的一种常见方式。另一种方式是使用十进制数学库,例如 shopspring/decimal

JSON 错误

对于 API 客户端来说,当 Web 服务始终返回 JSON 时会更友好,即使是 Not Found 这类错误也是如此。这样客户端就可以用同一套代码路径始终将响应解码为 JSON。

在我的版本中,我让所有错误都以 JSON 形式返回,使用了一个小型的 jsonError 辅助函数,它会调用上文提到的 writeJSON 辅助函数:

// jsonError writes a structured error as JSON to the response, with
// optional structured data in the "data" field.
func (s *Server) jsonError(w http.ResponseWriter, status int,
        error string, data map[string]interface{}) {
    response := struct {
        Status int                    `json:"status"`
        Error  string                 `json:"error"`
        Data   map[string]interface{} `json:"data,omitempty"`
    }{
        Status: status,
        Error:  error,
        Data:   data,
    }
    s.writeJSON(w, status, response)
}

通常“data”字段为空,但在 Bad Request 错误中,给调用方提供一些关于其出错原因的额外信息会很有用(例如在上文所示的校验代码中)。

Error 字段是若干已定义常量之一,用作 JSON 错误码,例如 ErrorValidation

方法未找到

这是一个非常小的细节,但 Gin(在教程代码所用的默认配置下)在 URL 有效但方法不正确时会返回 404 Not Found 而非 405 Method Not Allowed。

路由代码所示,我已将其改为在这些情况下返回 HTTP 405 状态码。

测试

我为服务器添加了大量测试:这些测试覆盖了所有端点,以及错误行为、校验问题等。

通过 go test -coverprofile 查看的测试覆盖率显示,除了极简的 main 函数和 writeJSON 错误处理中一处难以测试的部分(在实际中几乎不可能发生)外,我已测试了所有代码。一般来说,我认为追求 100% 的测试覆盖率并不是一个合理的目标,但在这里能如此轻松地覆盖这么多代码,还是很不错的。

这些测试都遵循相同的基本模式:创建一个测试服务器,针对 httptest.ResponseRecorder 执行一个或多个请求,然后验证响应是否正确——包括状态码和 JSON 数据。

我实现了一些测试辅助函数(标有 T.Helper),用于创建新请求、处理单个请求、对 JSON 响应进行解组等。它们每一项都只有几行,却能在很大程度上减少测试中的样板代码。

以下是一个测试示例,以及 ensureStatus 辅助函数:

func TestGetAlbums(t *testing.T) {
    server := newTestServer()
    result := serve(t, server, newRequest(t, "GET", "/albums", nil))
    ensureStatus(t, result, http.StatusOK)

    var got []testAlbum
    unmarshalResponse(t, result, &got)
    want := []testAlbum{
        {ID: "a1", Title: "9th Symphony", Artist: "Beethoven", Price: 795},
        {ID: "a2", Title: "Hey Jude", Artist: "The Beatles", Price: 2000},
    }
    if !reflect.DeepEqual(got, want) {
        t.Fatalf("bad response: got vs want:\n%#v\n%#v", got, want)
    }
}

func ensureStatus(t *testing.T, response *http.Response, want int) {
    t.Helper()
    if response.StatusCode != want {
        t.Fatalf("bad status code: got %d, want %d", response.StatusCode, want)
    }
}

注意,我并没有单独测试服务器所使用的 MemoryDatabase 实现。相反,它的功能是作为整体服务器测试的一部分来验证的。在可行的情况下,使用内存中的假实现、避免记录“mock”调用的繁琐,是一种更简单、更不易脆弱的测试写法。

这些测试中还有几个其他有意思的地方:

数据库接口

Go 的接口非常强大,也有些独特:你可以实现一个具体类型,比如带有各种访问方法的数据库结构体,而实现本身不需要声明它实现或继承自任何东西。只需写代码即可。

然后,使用该数据库的对象,在这里是 Server,会定义一个只包含它所需方法的接口(这很可能只是实现方法的一个子集)。在我们的例子中,它看起来像这样:

// Server is the album HTTP server.
type Server struct {
    db  Database
    log *log.Logger
}

// Database is the interface used by the server to load and store albums.
type Database interface {
    // GetAlbums returns a copy of all albums, sorted by ID.
    GetAlbums() ([]Album, error)

    // GetAlbumsByID returns a single album by ID, or ErrDoesNotExist if
    // an album with that ID does not exist.
    GetAlbumByID(id string) (Album, error)

    // AddAlbum adds a single album, or ErrAlreadyExists if an album with
    // the given ID already exists.
    AddAlbum(album Album) error
}

var (
    ErrDoesNotExist  = errors.New("does not exist")
    ErrAlreadyExists = errors.New("already exists")
)

如你所见,Server 拥有一个 Database,它可以是像我定义的 MemoryDatabase 这样的内存实现,也可以在磁盘上,或使用外部的 SQL 数据库。或者它也可以是我们在 TestDatabaseErrors 中用来测试数据库错误处理的那种始终返回错误的 errorDatabase

定义一个好的接口需要一些 API 设计考量。我一开始没有设置 error 返回值,AddAlbum 函数返回的是一个“是否真的添加成功”的布尔值。然而,真实的数据库需要返回错误,所以我们不妨一开始就把良好的错误处理加上。

注意 GetAlbumByIDAddAlbum 的文档注释是如何描述在专辑不存在(或已存在)时返回的特殊错误值的。这使得处理器可以检测该错误值(使用 ==errors.Is)并向调用方返回合适的 HTTP 状态码。

在更大的项目中,ServerDatabase 很可能会被定义在 server 包中,而 MemoryDatabase 则很可能会被定义在单独的 testdb 包中。为了简单起见(这个项目只有几百行代码),我把所有内容都放在了单个 main.go 文件中。在 Go 中一个很好的经验法则是:只有在需要时才将内容拆分到不同的包中。

数据库实现

对于我的数据库实现,我仍然像原教程一样使用一个简单的内存数据库。不过,它现在是通过一个结构体来实现的(以满足上述 Database 接口),并且我添加了加锁来修复那些并发问题。完整实现如下:

// MemoryDatabase is a Database implementation that uses a simple
// in-memory map to store the albums.
type MemoryDatabase struct {
    lock   sync.RWMutex
    albums map[string]Album
}

// NewMemoryDatabase creates a new in-memory database.
func NewMemoryDatabase() *MemoryDatabase {
    return &MemoryDatabase{albums: make(map[string]Album)}
}

func (d *MemoryDatabase) GetAlbums() ([]Album, error) {
    d.lock.RLock()
    defer d.lock.RUnlock()

    // Make a copy of the albums map (as a slice)
    albums := make([]Album, 0, len(d.albums))
    for _, album := range d.albums {
        albums = append(albums, album)
    }

    // Sort by ID so we return them in a defined order
    sort.Slice(albums, func(i, j int) bool {
        return albums[i].ID < albums[j].ID
    })
    return albums, nil
}

func (d *MemoryDatabase) GetAlbumByID(id string) (Album, error) {
    d.lock.RLock()
    defer d.lock.RUnlock()

    album, ok := d.albums[id]
    if !ok {
        return Album{}, ErrDoesNotExist
    }
    return album, nil
}

func (d *MemoryDatabase) AddAlbum(album Album) error {
    d.lock.Lock()
    defer d.lock.Unlock()

    if _, ok := d.albums[album.ID]; ok {
        return ErrAlreadyExists
    }
    d.albums[album.ID] = album
    return nil
}

除了互斥锁之外,与原始方案唯一显著的区别是使用以 ID 为键的 map 而非切片来存储专辑。这使得按 ID 查找的时间复杂度为常数。

然而,由于 Go 的 map 没有确定的遍历顺序,我让 GetAlbums 按 ID 排序,以确保它以一致的顺序返回专辑。原始代码(也许是无意的?)是按从旧到新的顺序返回的。如果使用真实的数据库,你可能会使用 ORDER BY 子句按某些与用户相关的标准(如标题)来排序。

关注点分离

这很大程度上是数据库接口自然带来的结果:在原始代码中,HTTP 处理器代码(如 JSON 编组)与数据库代码混在一起。数据库接口迫使关注点分离,使得测试数据库错误处理变得更容易。当需要换成真实数据库时,也会非常直接——只需添加一个 SQLDatabase 结构体,并用 SQL 查询来实现它的方法即可。

结论

重写并尝试改进这些代码是一次有趣的练习,希望你喜欢它或从中学到了一些东西。我当然希望它更加健壮、更易于维护,并且避免了学习和更新第三方依赖所带来的麻烦。

完整源码见 GitHub 上的 benhoyt/web-service-stdlib

如果你有任何反馈,或对改进我的代码或本文有任何建议,请告诉我!

本文章由 muse-spark-1.2-contributor 进行翻译

评论