云原生 Go——构建云原生服务

317 阅读16分钟

第二次世界大战前,生活很简单。之后,我们有了系统。
——格蕾丝·霍普(Grace Hopper),《OCLC通讯》(1987年)

在本章中,我们的实际工作终于开始了。
我们将结合第二部分中讨论的许多材料,创建一个服务,作为本书剩余部分的起点。在接下来的章节中,我们将逐步迭代我们在这里开始的工作,每一章都为其添加功能层,直到最后,我们拥有一个真正的云原生应用程序。
当然,它不会是“生产就绪的”——例如,它可能缺少一些重要的安全特性——但它仍然会提供一个坚实的基础,供我们在此基础上构建。

那么,我们构建什么呢?

让我们构建一个服务!

好吧,那么,我们需要构建一些东西。
它应该在概念上简单,足够直观,能够实现其最基本的形式,但又不简单,具有可扩展性和分布式特性。也就是说,能够在本书剩余部分中迭代完善的东西。我花了很多时间考虑我们的应用程序会是什么样子,考虑了不同的想法,但最终答案显而易见。
我们将构建一个分布式键值存储。

什么是键值存储?

键值存储是一种非关系型数据库,它将数据存储为一组键值对。它们与我们熟知的关系型数据库,如Microsoft SQL Server或PostgreSQL,完全不同。关系型数据库将数据结构化存储在固定的表格中,并定义明确的数据类型,而键值存储则更加简单,允许用户将一个唯一标识符(键)与一个任意值关联起来。
换句话说,键值存储的本质实际上就是一个带有服务端点的映射,如图5-1所示。它是最简单的数据库。

image.png

需求

在本章结束时,我们将构建一个简单的非分布式键值存储,它能够完成一个(单体)键值存储应该做的所有事情:

  • 它必须能够存储任意的键值对。
  • 它必须提供服务端点,允许用户进行键值对的存储(put)、获取(get)和删除(delete)。
  • 它必须能够以某种方式持久化存储其数据。

最后,服务必须是幂等的。那么,为什么要这样做呢?

什么是幂等性,它为什么重要?

幂等性的概念源于代数,它描述了某些数学运算的特定性质。幸运的是,这不是一本数学书,我们不会讨论这些内容(除了本节末的边栏)。
在编程世界中,一个操作(例如方法或服务调用)是幂等的,如果调用一次和调用多次的效果是相同的。例如,赋值操作 x=1 是幂等的,因为无论你赋多少次,x 的值始终是 1。类似地,HTTP 的 PUT 方法也是幂等的,因为多次将资源 PUT 到同一个地方不会改变任何东西:第二次 PUT 资源不会使它变得“更多”。然而,操作 x+=1 不是幂等的,因为每次调用它时,都会产生一个新的状态。

较少讨论但也很重要的是相关的“无效性”(nullipotence)属性,其中一个函数或操作完全没有副作用。例如,x=1 的赋值操作和 HTTP 的 PUT 方法是幂等的,但不是无效的,因为它们触发了状态变化。而将值赋给它自身,比如 x=x,则是无效的,因为没有因它而改变任何状态。类似地,读取数据(如 HTTP 的 GET 方法)通常没有副作用,因此也是无效的。

当然,这些理论上的讨论很有趣,但在现实世界中我们为什么要关心这些呢?事实证明,将服务方法设计为幂等操作带来了许多非常实际的好处:

幂等操作更安全
如果你向一个服务发出了请求,但没有收到响应,会发生什么?你可能会尝试再次发送请求。但如果服务第一次已经接收到请求怎么办?如果服务方法是幂等的,那就没问题。但如果它不是幂等的,你可能就会遇到问题。这种情况比你想象的更常见。网络不可靠,响应可能会延迟,数据包可能会丢失。

幂等操作通常更简单
幂等操作更加自包含,更容易实现。比如,一个幂等的 PUT 方法只是将一个键值对添加到后端数据存储中,而一个类似但非幂等的 CREATE 方法则会在数据存储已包含该键时返回错误。PUT 的逻辑很简单:接收请求,设置值。而 CREATE 方法则需要额外的错误检查和处理,可能还需要在服务副本之间进行分布式锁定和协调,这使得它的服务更难以扩展。

幂等操作更具声明性
构建幂等 API 鼓励设计者专注于最终状态,鼓励生成更具声明性的方法:它们允许用户告诉服务需要做什么,而不是告诉它怎么做。这看起来可能是一个细微的差别,但声明性方法——与命令式方法相对——让用户不必处理低级构造,能够专注于自己的目标,最小化潜在的副作用。

实际上,幂等性提供的这种优势,特别是在云原生环境中,已经被一些非常聪明的人认为是“云原生”的同义词。我并不认为我会走得那么远,但我会说,如果你的服务目标是云原生,那么接受低于幂等性的设计就是在自找麻烦。

幂等性的数学定义
幂等性的起源在于数学,它描述了可以多次应用而不改变结果的操作。
在纯数学术语中:一个函数是幂等的,如果对于所有的 x,都有 f(f(x)) = f(x)
例如,取一个整数 x 的绝对值 abs(x) 是一个幂等函数,因为对于每个实数 xabs(abs(x)) = abs(x) 成立。

最终目标

这些需求确实需要我们认真思考,但它们代表了我们的键值存储能够使用的最低限度。之后,我们将添加一些重要的基础功能,例如支持多个用户和传输中的数据加密。更重要的是,我们将引入一些技术和方法,使得服务更加可扩展、弹性更强,并且能够在一个充满挑战和不确定性的环境中生存并繁荣。

第0代:核心功能

好了,开始吧。首先,首先。让我们先构建核心功能,而不必担心像用户请求和持久化之类的事情;这样它们可以稍后通过我们决定使用的任何Web框架来调用:

存储任意的键值对

现在我们可以通过一个普通的映射来实现这一点,但使用哪种类型呢?为了简单起见,我们将键和值限制为字符串类型,尽管稍后可以选择允许任意类型。我们将使用 map[string]string 作为我们的核心数据结构。

允许put、get和delete操作

在这个初始版本中,我们将创建一个最简单的Go API,可以调用它来执行基本的修改操作。将功能与使用它的代码分开,可以使它更容易测试,并且在未来的迭代中更容易更新。

你的超简单API

我们首先需要做的是创建一个将作为我们键值存储核心的映射:

var store = make(map[string]string)

多简单啊!是不是很美?别担心,我们稍后会让它变得更复杂。

我们将创建的第一个函数是 Put,它将用于向存储中添加记录。它做的正是它名字所暗示的事情:接受键和值字符串并将它们放入 store 中。Put 的函数签名包含一个错误返回值,这是我们稍后需要的:

func Put(key, value string) error {
    store[key] = value
    return nil
}

因为我们有意识地选择创建一个幂等的服务,所以 Put 不会检查是否正在覆盖现有的键值对,如果被要求,它会毫不犹豫地这么做。使用相同参数多次执行 Put 会得到相同的结果,无论当前状态如何。

现在我们已经建立了一个基本的模式,编写 GetDelete 操作只是一个顺理成章的过程:

var ErrorNoSuchKey = errors.New("no such key")

func Get(key string) (string, error) {
    value, ok := store[key]

    if !ok {
        return "", ErrorNoSuchKey
    }

    return value, nil
}

func Delete(key string) error {
    delete(store, key)
    return nil
}

仔细看看:你是否注意到 Get 返回错误时,并没有使用 errors.New?而是返回了预先构建的 ErrorNoSuchKey 错误值。为什么呢?这是一个典型的哨兵错误(sentinel error)示例,它允许消费服务准确地判断收到的是什么类型的错误,并做出相应的处理。例如,它可能会这样做:

if errors.Is(err, ErrorNoSuchKey) {
    http.Error(w, err.Error(), http.StatusNotFound)
    return
}

现在你已经有了一个绝对最小的功能集(真的,非常最小),别忘了编写测试。我们在这里不做测试,但如果你急于前进(或者懒——懒也没关系),你可以从为本书创建的GitHub仓库中获取代码。

第1代:单体应用

现在你已经有了一个最基本的键值API,可以开始围绕它构建服务了。有几种不同的方式来实现这一点。首先,你可以使用像GraphQL这样的技术,虽然有一些不错的开源包可以使用,但你的数据结构并不复杂,不需要用到这些技术。

其次,你可以使用远程过程调用(RPC),标准的 net/rpc 包或甚至是 gRPC 都支持这一点,但它们需要为客户端增加额外的开销,而且再次强调,你的数据结构并不复杂,不需要使用这些技术。

那我们就选择使用表述性状态转移(REST)。虽然REST并不是很多人的最爱,但它简单,完全足够满足我们的需求。

使用 net/http 构建 HTTP 服务器

Go 没有像 Django 或 Flask 那样复杂或历史悠久的 Web 框架。然而,它有一套强大的标准库,对于 80% 的使用场景来说完全足够。更好的是,这些库设计得非常可扩展,因此有许多 Go Web 框架是建立在这些库之上的。

现在,我们来看看 Go 中标准的 HTTP 处理程序写法,通过一个用 net/http 实现的“Hello, World!”示例:

package main

import (
    "fmt"
    "log"
    "net/http"
)

func helloGoHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello net/http!")
}

func main() {
    http.HandleFunc("/", helloGoHandler)

    log.Fatal(http.ListenAndServe(":8080", nil))
}

在这个例子中,我们定义了一个函数 helloGoHandler,它满足了 http.HandlerFunc 的定义:

type HandlerFunc func(http.ResponseWriter, *http.Request)

http.ResponseWriter*http.Request 参数分别用于构造 HTTP 响应和获取请求。我们可以使用 http.HandleFunc 函数注册 helloGoHandler,使其成为处理任何匹配给定模式(在此示例中是根路径)请求的处理函数。

一旦注册了处理程序,你可以调用 ListenAndServe,它会监听指定的地址。它还接受第二个参数,虽然在这个示例中它设置为 nil,但你会在本章稍后使用它。

你会注意到,ListenAndServe 也被包裹在 log.Fatal 调用中。这是因为 ListenAndServe 总是阻止执行流程,只有在发生错误时才会返回。因此,它总是返回一个非 nil 的错误,我们希望始终记录这些错误。

这个示例是一个完整的程序,可以通过 go run 编译和运行:

$ go run .

恭喜!你现在正在运行世界上最小的 Web 服务。现在可以使用 curl 或你喜欢的 Web 浏览器来测试它:

$ curl http://localhost:8080
Hello net/http!

LISTENANDSERVE、HANDLERS 和 HTTP 请求多路复用器

http.ListenAndServe 函数使用给定的地址和处理程序启动一个 HTTP 服务器。如果处理程序是 nil,通常在只使用标准的 net/http 库时,它将使用 DefaultServeMux 值。那么,什么是处理程序?什么是 DefaultServeMux?什么是“mux”?

一个 Handler(与 HandlerFunc 不同)是任何满足 Handler 接口的类型,该接口通过提供 ServeHTTP 方法来定义:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

大多数处理程序实现,包括默认的处理程序,都充当一个“mux”(多路复用器)。它可以将传入的请求信号导向多个可能的函数之一。当通过 ListenAndServe 启动的服务接收到请求时,mux 的任务是将请求的 URL 与注册的模式进行比较,并调用与匹配的模式最接近的处理程序函数。

DefaultServeMux 是一个全局值,类型为 ServeMux,它实现了默认的 HTTP 多路复用逻辑。当 ListenAndServe 的处理程序参数为 nil 时,DefaultServeMux 就会被使用。

使用 gorilla/mux 构建 HTTP 服务器

对于许多 Web 服务,net/httpDefaultServeMux 已经足够使用。然而,有时你可能需要第三方 Web 工具包提供的额外功能。一个流行的选择是 Gorilla,虽然它比 Django 或 Flask 相对较新、开发和资源不如它们丰富,但它建立在 Go 的标准 net/http 包之上,提供了一些非常出色的增强功能。

gorilla/mux 包是 Gorilla Web 工具包中的一个组件,它提供了一个 HTTP 请求路由器和调度器,能够完全替代 DefaultServeMux,Go 的默认服务处理程序,从而为请求路由和处理添加几个非常有用的增强功能。我们现在不会利用所有这些功能,但它们在未来会派上用场。如果你有兴趣,或者迫不及待了,你可以查看 gorilla/mux 的文档以获取更多信息。

创建一个最小化的服务

使用最小的 gorilla/mux 路由器,只需添加一个导入语句和一行代码:初始化一个新的路由器,并将其传递给 ListenAndServe 的处理程序参数:

package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/gorilla/mux"
)

func helloMuxHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello gorilla/mux!")
}

func main() {
    r := mux.NewRouter()

    r.HandleFunc("/", helloMuxHandler)

    log.Fatal(http.ListenAndServe(":8080", r))
}

现在你应该能够使用 go run 来运行这个程序了,对吧?试试看:

$ go run .
main.go:8:2: no required module provides package github.com/gorilla/mux; to add:
        go get github.com/gorilla/mux

结果你不能(还不能)。由于你现在使用了一个第三方包——这个包位于标准库之外——你需要使用 Go 模块。

使用 Go 模块初始化你的项目

使用标准库之外的包需要使用 Go 模块,它是在 Go 1.12 中引入的,用来替代一个几乎不存在的依赖管理系统,取而代之的是一个明确且使用起来相当简单的系统。你将使用的一些管理依赖的操作都将使用少数几个 go mod 命令。

你需要做的第一件事是初始化你的项目。首先创建一个新的空目录,进入该目录,然后将你的服务 Go 文件创建(或移动)到该目录。此时你的目录应该只包含一个 Go 文件。

接下来,使用 go mod init 命令初始化项目。通常,如果项目将被其他项目导入,它将需要使用其导入路径进行初始化。然而,对于像我们这样独立的服务来说,这并不那么重要,所以你可以稍微放宽对名称的要求。我将使用 example.com/gorilla;你可以使用你喜欢的任何名称:

$ go mod init example.com/gorilla
go: creating new go.mod: module example.com/gorilla

现在你的目录中应该有一个(几乎)空的模块文件 go.mod

$ cat go.mod
module example.com/gorilla

go 1.20

接下来,我们需要添加依赖项,这可以通过 go mod tidy 自动完成:

$ go mod tidy
go: finding module for package github.com/gorilla/mux
go: found github.com/gorilla/mux in github.com/gorilla/mux v1.8.0

如果你检查你的 go.mod 文件,你会看到依赖(以及版本号)已被添加:

$ cat go.mod
module example.com/gorilla

go 1.20

require github.com/gorilla/mux v1.8.0

相信不相信,这就是你需要的全部。如果将来你的依赖项发生变化,你只需要再次运行 go mod tidy 来重新构建文件。现在再次尝试启动你的服务:

$ go run .

由于服务运行在前台,你的终端应该会暂停。用另一个终端的 curl 或浏览器访问该端点,应该能得到预期的响应:

$ curl http://localhost:8080
Hello gorilla/mux!

成功了!但你当然不希望你的服务仅仅打印一个字符串,对吧?当然,你希望它做得更多。继续阅读吧!

URI 路径中的变量

Gorilla Web 工具包提供了比标准 net/http 包更多的功能,但有一个特性现在特别有趣:能够创建包含变量段的路径,甚至可以选择性地包含正则表达式模式。使用 gorilla/mux 包,程序员可以使用 {name}{name:pattern} 的格式定义变量,如下所示:

r := mux.NewRouter()
r.HandleFunc("/products/{key}", ProductHandler)
r.HandleFunc("/articles/{category}/", ArticlesCategoryHandler)
r.HandleFunc("/articles/{category}/{id:[0-9]+}", ArticleHandler)

mux.Vars 函数方便地允许处理函数将变量名和值作为 map[string]string 来检索:

vars := mux.Vars(request)
category := vars["category"]

在接下来的部分,我们将利用这个功能,允许客户端对任意的键进行操作。

多种匹配器

gorilla/mux 提供的另一个特性是,它允许向路由添加多种匹配器,让程序员能够添加多种附加的请求匹配标准。这些包括(但不限于)特定的域或子域、路径前缀、协议、头部,甚至是自定义的匹配函数。

通过在 Gorilla 的 HandleFunc 实现返回的 *Route 值上调用适当的函数,可以应用匹配器。每个匹配器函数都会返回受影响的 *Route,因此它们可以链式调用。例如:

r := mux.NewRouter()

r.HandleFunc("/products", ProductsHandler).
    Host("www.example.com").                // 仅匹配特定域
    Methods("GET", "PUT").                  // 仅匹配 GET 和 PUT 方法
    Schemes("http")                         // 仅匹配 http 协议

可以查看 gorilla/mux 文档,获取可用匹配器函数的完整列表。

构建一个 RESTful 服务

现在你已经知道如何使用几种不同的 Go HTTP 库,你可以使用其中一个来创建一个 RESTful 服务,让客户端与之交互,执行对你在“超简单 API”中构建的 API 的调用。一旦完成,你就实现了一个最简单可用的键值存储。

你的 RESTful 方法

我们将尽力遵循 RESTful 约定,因此我们的 API 将把每个键值对视为一个独立的资源,具有一个独特的 URI,可以使用各种 HTTP 方法进行操作。我们的三个基本操作——Put、Get 和 Delete——将使用不同的 HTTP 方法进行请求,具体概述如下表 5-1。

你的键值对资源的 URI 将具有以下格式 /v1/key/{key},其中 {key} 是唯一的键字符串。v1 部分表示 API 版本。这个约定通常用于管理 API 更改,虽然这种做法并非必需或普遍,但它有助于管理可能会破坏现有客户端集成的未来更改的影响。

表 5-1:你的 RESTful 方法

功能方法可能的状态
将键值对存入存储PUT201 (Created)
从存储中读取键值对GET200 (OK), 404 (Not Found)
删除键值对DELETE200 (OK)

在“URI 路径中的变量”部分中,我们讨论了如何使用 gorilla/mux 包来注册包含变量段的路径,这将允许你定义一个处理所有键的单一变量路径,免去你需要为每个键单独注册路径的麻烦。然后,在“如此多的匹配器”部分,我们讨论了如何使用路由匹配器根据各种非路径标准将请求定向到特定的处理函数,这些匹配器可以用来为你将支持的五个 HTTP 方法中的每一个创建一个单独的处理函数。

实现创建功能

好了,你现在拥有了开始的所有必要条件!让我们继续实现处理键值对创建的处理函数。此函数必须满足几个要求:

  • 它必须仅匹配 /v1/key/{key} 的 HTTP PUT 请求。
  • 它必须调用“超简单 API”中的 Put 函数。
  • 当键值对被创建时,必须响应状态 201 (Created)。
  • 必须在出现意外错误时响应 500 (Internal Server Error)。

所有这些要求都在下面的 putHandler 函数中得到了实现。注意如何从请求体中获取键的值:

// putHandler expects to be called with a PUT request for
// the "/v1/key/{key}" resource.
func putHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)                     // Retrieve "key" from the request
    key := vars["key"]

    value, err := io.ReadAll(r.Body)        // The request body has our value
    if err != nil {                         // If we have an error, report it
        http.Error(w,
            err.Error(),
            http.StatusInternalServerError)
        return
    }

    defer r.Body.Close()

    err = Put(key, string(value))           // Store the value as a string
    if err != nil {                         // If we have an error, report it
        http.Error(w,
            err.Error(),
            http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusCreated)       // Success! Return StatusCreated
}

现在你已经有了“键值对创建”处理函数,你可以将它注册到 Gorilla 请求路由器中,指定路径和方法:

func main() {
    r := mux.NewRouter()

    // Register putHandler as the handler function for PUT
    // requests matching "/v1/key/{key}"
    r.HandleFunc("/v1/key/{key}", putHandler).Methods("PUT")

    log.Fatal(http.ListenAndServe(":8080", r))
}

现在你已经将服务搭建完成,可以通过从项目根目录运行 go run . 来启动它。试试看,发送一些请求查看它的响应。

首先,使用我们老朋友 curl/v1/key/key-a 端点发送一个 PUT 请求,包含一个简短的文本片段,以创建一个名为 key-a 的键,值为 Hello, key-value store!

$ curl -X PUT -d 'Hello, key-value store!' -v http://localhost:8080/v1/key/key-a

执行这个命令后会显示以下输出。完整的输出比较冗长,所以我选择了相关部分以便于阅读:

> PUT /v1/key/key-a HTTP/1.1
< HTTP/1.1 201 Created

输出的第一部分,前缀为大于号(>),显示了请求的相关细节。最后部分,前缀为小于号(<),显示了服务器响应的相关信息。

在这个输出中,你可以看到确实向 /v1/key/key-a 端点发送了 PUT 请求,并且服务器响应了 201 Created——如预期。

如果你使用不支持的 GET 方法访问 /v1/key/key-a 端点会怎样?因为我们仅包含了 PUT 匹配器,你应该收到错误信息:

$ curl -X GET -v http://localhost:8080/v1/key/key-a
> GET /v1/key/key-a HTTP/1.1
< HTTP/1.1 405 Method Not Allowed

确实,服务器返回了 405 Method Not Allowed 错误。一切看起来都正常。

实现读取功能

现在你的服务已经有了完全功能的 PUT 方法,如果能读取存储中的数据就更好了!接下来,我们将实现 GET 功能,它需要满足以下要求:

  • 它必须仅匹配 /v1/key/{key} 的 HTTP GET 请求。
  • 它必须调用“超简单 API”中的 Get 函数。
  • 当请求的键不存在时,必须响应 404 (Not Found)。
  • 如果键存在,必须返回请求的值,并响应 200 (OK)。
  • 必须在出现意外错误时响应 500 (Internal Server Error)。

所有这些要求都在下面的 getHandler 函数中得到了实现。注意,值在从键值 API 检索后被写入 w——处理函数的 http.ResponseWriter 参数:

func getHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)                   // Retrieve "key" from the request
    key := vars["key"]

    value, err := Get(key)                // Get value for key
    if errors.Is(err, ErrorNoSuchKey) {
        http.Error(w, err.Error(), http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    fmt.Fprint(w, value)                 // Write the value to the response
}

现在你有了 GET 处理函数,可以将它与 PUT 处理函数一起注册到请求路由器中:

func main() {
    r := mux.NewRouter()

    r.HandleFunc("/v1/key/{key}", putHandler).Methods("PUT")
    r.HandleFunc("/v1/key/{key}", getHandler).Methods("GET")

    log.Fatal(http.ListenAndServe(":8080", r))
}

让我们启动你刚刚改进的服务,看看它是否有效:

$ curl -X PUT -d 'Hello, key-value store!' -v http://localhost:8080/v1/key/key-a
> PUT /v1/key/key-a HTTP/1.1
< HTTP/1.1 201 Created

$ curl -v http://localhost:8080/v1/key/key-a
> GET /v1/key/key-a HTTP/1.1
< HTTP/1.1 200 OK
Hello, key-value store!

它工作了!现在你可以获取值,并且可以测试幂等性。让我们重复请求,确保你获得相同的结果:

$ curl -X PUT -d 'Hello, key-value store!' -v http://localhost:8080/v1/key/key-a
> PUT /v1/key/key-a HTTP/1.1
< HTTP/1.1 201 Created

$ curl -v http://localhost:8080/v1/key/key-a
> GET /v1/key/key-a HTTP/1.1
< HTTP/1.1 200 OK
Hello, key-value store!

确实如此!但如果你想用新值覆盖键,会怎样呢?后续的 GET 请求会返回新值吗?你可以通过稍微修改 curl 发送的值来测试这一点,将其改为 Hello, again, key-value store!

$ curl -X PUT -d 'Hello, again, key-value store!' \
    -v http://localhost:8080/v1/key/key-a
> PUT /v1/key/key-a HTTP/1.1
< HTTP/1.1 201 Created

$ curl -v http://localhost:8080/v1/key/key-a
> GET /v1/key/key-a HTTP/1.1
< HTTP/1.1 200 OK
Hello, again, key-value store!

正如预期的那样,GET 请求响应了 200 状态并返回了你新的值。

最后,为了完成方法集,你只需要为 DELETE 方法创建一个处理程序。这个作为练习留给你吧。享受其中的乐趣!

使数据结构具备并发安全性

Go 中的映射(Map)不是原子性的,且不适合并发使用。不幸的是,您现在有一个设计用于处理并发请求的服务,而这个服务正好围绕着这样一个映射进行构建。

那么,您该怎么办呢?通常,当程序员有一个数据结构需要被多个并发执行的 goroutine 读写时,他们会使用类似互斥锁(mutex)这样的同步机制。通过使用互斥锁,您可以确保只有一个进程能够独占访问特定资源。

幸运的是,您不需要自己实现这个机制:正如您从《互斥锁》中回忆的那样,Go 的 sync 包提供了您所需的功能,具体表现为 sync.RWMutex。以下声明定义了一个 LockableMap 结构体,它包含了我们的映射,并且嵌入了 sync.RWMutex

type LockableMap struct {
    sync.RWMutex
    m map[string]string
}

var myMap = LockableMap {
    m: make(map[string]string),
}

myMap 变量继承了嵌入的 sync.RWMutex 的所有方法,允许您在写入 myMap 映射时使用 Lock 方法来获取写锁:

myMap.Lock()                         // 获取写锁
defer myMap.Unlock()                 // 释放写锁

myMap.m["some_key"] = "some_value"

如果另一个进程已获得读锁或写锁,Lock 会阻塞,直到该锁被释放。

类似地,要从映射中读取数据,您使用 RLock 方法获取读锁:

myMap.RLock()                        // 获取读锁
defer myMap.RUnlock()                // 释放读锁

value := myMap.m["some_key"]

fmt.Println("some_key:", value)

读锁比写锁的限制要少,任何数量的进程可以同时获取读锁。然而,RLock 会被阻塞,直到所有的写锁被释放。

注意,在两个示例中,我们将互斥锁的解锁操作放在 defer 语句中,这样它们将在函数返回时无论何时何种方式被执行。虽然这不是严格要求的,但这是处理简单函数中解锁操作的常见方法。

将读写互斥锁集成到您的应用中

现在,我们已经回顾了如何使用 sync.RWMutex,您可以将它集成到您为《您的超级简单 API》创建的代码中。

使用我们之前定义的 LockableMap,您可以像创建 myMap 值一样重新创建您的存储值,使其包含映射和嵌入的 sync.RWMutex

var store = LockableMap {
    m: make(map[string]string),
}

现在,您已经有了 store 结构体,您可以更新 GetPut 函数以建立适当的锁。由于 Get 只需要读取存储映射,因此它将使用 RLock 仅获取读锁。而 Put 需要修改映射,因此它将使用 Lock 获取写锁:

func Get(key string) (string, error) {
    store.RLock()
    defer store.RUnlock()

    value, ok := store.m[key]

    if !ok {
        return "", ErrorNoSuchKey
    }

    return value, nil
}

func Put(key string, value string) error {
    store.Lock()
    defer store.Unlock()

    store.m[key] = value

    return nil
}

这里的模式很明确:如果一个函数需要修改映射(如 PutDelete),它将使用 Lock 获取写锁。如果它只需要读取现有数据(如 Get),它将使用 RLock 获取读锁。我们将 Delete 函数的实现留给读者作为练习。

警告

不要忘记释放您的锁,并确保释放正确类型的锁!

第二代:持久化资源状态

在云原生应用程序中,最棘手的挑战之一是如何处理状态。

有多种技术可以在多个服务实例之间分配应用资源的状态,但目前我们只关注最小可行产品,并考虑两种维持应用状态的方式:

  1. 在“存储状态到事务日志文件”中,您将使用基于文件的事务日志来记录每次资源被修改的情况。如果服务崩溃、重启或以其他方式进入不一致状态,事务日志可以通过重放事务来帮助服务恢复到原始状态。
  2. 在“存储状态到外部数据库”中,您将使用外部数据库代替文件来存储事务日志。考虑到您构建的应用程序的性质,使用数据库似乎有些多余,但将数据外部化到另一个专门为此目的设计的服务中是服务副本之间共享状态并提供弹性的一种常见方式。

您可能会想,为什么要使用事务日志策略来记录事件,而不直接使用数据库来存储这些值。事实上,当您打算将数据大多数时间存储在内存中时,事务日志方法实际上具有一些优势。例如,它减少了执行变更操作时所需的写锁量,这可以显著提高吞吐量,并且如果服务器崩溃,日志可以用来重建它的所有状态,包括历史记录。

使用事务日志还为我们提供了一个机会:鉴于我们正在创建两种具有类似功能的实现—一个是写入文件的事务日志,一个是写入数据库的事务日志—我们可以用一个接口来描述我们的功能,该接口可以被两种实现满足。这对于我们根据需要无缝切换实现会非常有用。

应用状态与资源状态

在云原生架构中,“无状态”这个术语使用得很频繁,状态通常被视为一个非常糟糕的东西。但究竟什么是状态?为什么它这么糟糕?为了使应用程序成为“云原生”,是否必须完全没有任何状态?答案是……嗯,这个问题比较复杂。

首先,需要区分应用状态和资源状态。这两者非常不同,但很容易混淆:

  • 应用状态:关于应用程序本身或客户端如何使用应用程序的服务器端数据。一个常见的例子是客户端会话跟踪,用于将用户与其访问凭证或其他应用上下文关联起来。
  • 资源状态:服务中资源的当前状态。它对于每个客户端都是相同的,并且与客户端和服务器之间的交互无关。

任何状态都会引入技术挑战,但应用状态尤为棘手,因为它通常迫使服务依赖于服务器亲和性——将每个用户的请求发送到其会话启动的同一服务器——这使得应用程序更加复杂,并且很难销毁或替换服务副本。

关于状态和无状态的讨论会在《状态与无状态》中进行更详细的阐述。

什么是事务日志?

我们一直在谈论使用事务日志,但到目前为止,我们还没有真正定义事务日志到底是什么。

最基本的形式下,事务日志只是一个日志文件,记录由数据存储执行的所有变更操作的历史。如果服务崩溃、重启或以其他方式进入不一致状态,事务日志可以通过重放这些事务来重建服务的功能状态和历史记录。

事务日志通常被数据库管理系统用来提供一定程度的数据弹性,防止崩溃或硬件故障。然而,尽管这种技术可以变得非常复杂,但我们将保持简单直观。

事务日志格式

在我们编写代码之前,让我们决定事务日志应包含哪些内容。

我们假设事务日志将在服务重启或需要恢复其状态时读取,并且它会从上到下按顺序重放每个事件。因此,您的事务日志将由一系列变更事件组成。为了提高速度和简便性,事务日志通常是追加式的,因此当您从键值存储中删除一条记录时,例如,删除事件将被记录在日志中。

根据我们迄今为止的讨论,每个记录的事务事件将需要包含以下属性:

  • 序列号:一个唯一的记录ID,按单调递增的顺序。
  • 事件类型:描述所执行操作的类型;这可以是 PUT 或 DELETE。
  • :一个字符串,包含受此事务影响的键。
  • :如果事件是 PUT,则是事务的值。

简单明了。希望我们可以保持这样。

事务日志接口

我们要做的第一件事是定义一个 TransactionLogger 接口。目前,我们只定义两个方法:WritePutWriteDelete,分别用于将 PUT 和 DELETE 事件写入事务日志:

type TransactionLogger interface {
    WriteDelete(key string)
    WritePut(key, value string)
}

您无疑会在之后添加其他方法,但我们将在遇到需要时再进行处理。目前,让我们专注于第一个实现,并随着需求的出现逐步添加更多的方法。

在事务日志文件中存储状态

我们将采用的第一个方法是使用最基本(也是最常见)的事务日志形式,它只是一个追加式日志文件,记录由数据存储执行的所有变更操作的历史。基于文件的实现有一些诱人的优点,但也有一些相当显著的缺点:

优点

  • 没有下游依赖
    不依赖任何可能失败的外部服务,或是我们可能无法访问的服务。
  • 技术上简单
    逻辑并不复杂,我们可以迅速上线。

缺点

  • 难以扩展
    当您需要扩展节点时,您需要额外的方式来分配状态。
  • 增长无限制
    这些日志必须存储在磁盘上,因此不能让它们无限增长。您需要某种方式定期压缩日志。

构建事务日志器原型

在开始编写代码之前,让我们做一些设计决策。首先,为了简单起见,日志将以纯文本格式编写;虽然二进制压缩格式可能在时间和空间上更高效,但我们可以稍后进行优化。其次,每个条目将单独写在一行上;这样可以方便以后读取数据。最后,每个事务将包含“您的事务日志格式”中列出的四个字段,字段之间用制表符分隔。

既然我们已经确定了这些基本原则,让我们定义一个类型 FileTransactionLogger,它将通过定义 WritePutWriteDelete 方法来隐式实现“您的事务日志器接口”中描述的 TransactionLogger 接口,分别用于将 PUT 和 DELETE 事件写入事务日志:

type FileTransactionLogger struct {
    // 一些字段
}

func (l *FileTransactionLogger) WritePut(key, value string) {
    // 一些逻辑
}

func (l *FileTransactionLogger) WriteDelete(key string) {
    // 一些逻辑
}

显然,这些方法还不完全,但我们很快就会完善它们!

定义事件类型

展望未来,我们可能希望 WritePutWriteDelete 方法能够异步操作。您可以使用某种事件通道来实现,让一些并发的 goroutine 从中读取并执行日志写入。这听起来是个不错的主意,但如果要这样做,您需要一种表示“事件”的内部结构。

这不应该给您带来太大麻烦。结合我们在“您的事务日志格式”中列出的所有字段,我们可以定义类似以下的 Event 结构体:

type Event struct {
    Sequence  uint64                // 唯一的记录 ID
    EventType EventType             // 执行的操作类型
    Key       string                // 此事务影响的键
    Value     string                // 事务的值
}

看起来很直接,对吧?Sequence 是序列号,KeyValue 不言自明。但是...什么是 EventType 呢?嗯,它就是我们定义的常量,用于表示不同类型的事件,我们已经确定会包括 PUT 和 DELETE 事件。

一种做法是为这些常量赋予一些字节值,如下所示:

const (
    EventDelete byte = 1
    EventPut    byte = 2
)

当然,这样也可以,但 Go 实际上提供了一种更好的(更符合惯例的)方式:iotaiota 是一个预定义的值,可以在常量声明中用来构建一系列相关的常量。

使用 iota 声明常量

在常量声明中使用 iota 时,iota 代表一组连续的无类型整数常量,可用于构建一组相关的常量。它的值在每次常量声明时从零重新开始,并随着每个常量赋值自动递增(无论是否实际引用了 iota 标识符)。

iota 还可以进行操作。我们在下面演示了如何在乘法、左移位和除法操作中使用 iota

const (
    a = 42 * iota           // iota == 0; a == 0
    b = 1 << iota           // iota == 1; b == 2
    c = 3                   // iota == 2; c == 3 (iota 递增)
    d = iota / 2            // iota == 3; d == 1
)

因为 iota 本身是一个无类型的数字,您可以用它来进行类型赋值而无需显式类型转换。您甚至可以将 iota 赋值给 float64 类型:

const (
    u         = iota * 42   // iota == 0; u == 0 (无类型整数常量)
    v float64 = iota * 42   // iota == 1; v == 42.0 (float64 常量)
)

iota 关键字允许隐式重复,这使得创建任意长度的相关常量集合变得轻松,像下面这样定义各种数字单位的字节数:

type ByteSize uint64

const (
    _           = iota                  // iota == 0; 忽略零值
    KB ByteSize = 1 << (10 * iota)      // iota == 1; KB == 2^10
    MB                                  // iota == 2; MB == 2^20
    GB                                  // iota == 3; GB == 2^30
    TB                                  // iota == 4; TB == 2^40
    PB                                  // iota == 5; PB == 2^50
)

使用 iota 技巧,您无需手动为常量赋值。您可以像下面这样进行:

type EventType byte

const (
    _                     = iota         // iota == 0; 忽略零值
    EventDelete EventType = iota         // iota == 1
    EventPut                             // iota == 2; 自动递增
)

当只有两个常量时,这可能不是什么大问题,但当有多个相关常量时,这种方法会非常有用,因为它不需要手动追踪每个常量的赋值。

警告

如果您在序列化中使用 iota 作为枚举(如我们这里所做),请注意只向列表中追加常量,避免重新排序或插入值,否则以后无法进行反序列化。

我们现在已经有了 TransactionLogger 的大致结构,以及两个主要的写方法。我们还定义了描述单个事件的结构体,并使用 iotaEventType 类型定义了合法值。现在,我们终于准备好开始编码了。

实现您的 FileTransactionLogger

我们已经取得了一些进展。我们知道我们需要一个 TransactionLogger 实现,并且已经在代码中创建了事件的描述。那么,FileTransactionLogger 本身该如何实现呢?

服务需要跟踪事务日志的物理位置,因此,拥有一个 os.File 属性来表示该位置是合理的。它还需要记住上次分配的序列号,以便正确设置每个事件的序列号;这可以作为一个无符号 64 位整数属性存储。这些很好,但 FileTransactionLogger 将如何实际写入事件呢?

一种可能的方法是保持一个 io.WriterWritePutWriteDelete 方法可以直接在其上操作,但这种方法是单线程的,所以除非您显式地在 goroutine 中执行它们,否则可能会发现自己在 I/O 上花费的时间比预期的要多。或者,您可以创建一个由多个 Event 值组成的缓冲区,由一个独立的 goroutine 处理。这确实是个不错的选择,但太复杂了。

毕竟,为什么要做这么多工作,我们可以直接使用标准的缓冲通道呢?遵循自己的建议,我们最终得到如下的 FileTransactionLogger 和写方法:

type FileTransactionLogger struct {
    events       chan<- Event       // 用于发送事件的只写通道
    errors       <-chan error       // 用于接收错误的只读通道
    lastSequence uint64             // 上次使用的事件序列号
    file         *os.File           // 事务日志的存储位置
}

func (l *FileTransactionLogger) WritePut(key, value string) {
    l.events <- Event{EventType: EventPut, Key: key, Value: value}
}

func (l *FileTransactionLogger) WriteDelete(key string) {
    l.events <- Event{EventType: EventDelete, Key: key}
}

func (l *FileTransactionLogger) Err() <-chan error {
    return l.errors
}

现在您已经拥有了 FileTransactionLogger,它具有一个 uint64 类型的值,用于跟踪上次使用的事件序列号,一个只写通道接收 Event 值,以及 WritePutWriteDelete 方法,它们将事件值发送到该通道。

但是,看起来还有一部分没有实现:Err 方法返回了一个接收错误的只读通道。这是有原因的。我们已经提到过,写入事务日志将由一个 goroutine 并发执行,该 goroutine 从事件通道中接收事件。虽然这种方式提高了写入效率,但也意味着 WritePutWriteDelete 无法在遇到问题时直接返回错误,因此我们提供了一个专门的错误通道来传递错误。

创建一个新的 FileTransactionLogger

如果您到目前为止跟上了进度,您可能已经注意到 FileTransactionLogger 中的属性尚未初始化。如果您不解决这个问题,它将导致一些问题。不过,Go 并没有构造函数,因此为了解决这个问题,您需要定义一个构造函数,我们称之为 NewFileTransactionLogger

func NewFileTransactionLogger(filename string) (TransactionLogger, error) {
    file, err := os.OpenFile(filename, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0755)
    if err != nil {
        return nil, fmt.Errorf("cannot open transaction log file: %w", err)
    }

    return &FileTransactionLogger{file: file}, nil
}

警告

注意 NewFileTransactionLogger 返回的是指针类型,但它的返回列表中指定的是非指针的 TransactionLogger 接口类型。这是有原因的:虽然 Go 允许指针类型实现接口,但它不允许指针类型实现指向接口类型的指针。

NewFileTransactionLogger 调用 os.OpenFile 函数打开由 filename 参数指定的文件。您会注意到,它接受多个通过按位 OR 组合的标志来设置其行为:

  • os.O_RDWR:以读写模式打开文件。
  • os.O_APPEND:所有写入该文件的内容将追加到文件末尾,而不是覆盖。
  • os.O_CREATE:如果文件不存在,则创建文件。

除了我们在这里使用的这三个标志之外,还有许多其他标志。请查看 os 包的文档,获取完整的标志列表。

我们现在有了一个确保事务日志文件正确创建的构造函数。但通道如何处理呢?我们可以在 NewFileTransactionLogger 中创建通道并启动一个 goroutine,但那样感觉会增加过多的神秘功能。相反,我们将创建一个 Run 方法。

向事务日志追加条目

到目前为止,事件通道没有被读取,这是不理想的。更糟糕的是,通道甚至没有初始化。让我们通过创建一个 Run 方法来解决这个问题,如下所示:

func (l *FileTransactionLogger) Run() {
    events := make(chan Event, 16)              // 创建一个事件通道
    l.events = events

    errors := make(chan error, 1)               // 创建一个错误通道
    l.errors = errors

    go func() {
        for e := range events {                 // 获取下一个事件
            l.lastSequence++                    // 增加序列号

            _, err := fmt.Fprintf(              // 将事件写入日志
                l.file,
                "%d\t%d\t%s\t%s\n",
                l.lastSequence, e.EventType, e.Key, e.Value)

            if err != nil {
                errors <- err
                return
            }
        }
    }()
}

注意

该实现非常基础,它甚至无法正确处理包含空格或多行的条目!

Run 函数执行了几件重要的事情。首先,它创建了一个缓冲的事件通道。在 TransactionLogger 中使用缓冲通道意味着,只要缓冲区未满,WritePutWriteDelete 方法的调用就不会阻塞。这使得服务能够处理短时间内的事件高峰,而不会被磁盘 I/O 降速。如果缓冲区已满,写入方法会阻塞,直到写入 goroutine 跟上进度。

其次,它创建了一个缓冲的错误通道,我们将用它来传递在并发写入事务日志的 goroutine 中发生的任何错误。缓冲区大小为 1,允许它非阻塞地发送错误。

最后,它启动了一个 goroutine,该 goroutine 从事件通道中读取 Event 值,并使用 fmt.Fprintf 函数将它们写入事务日志。如果 fmt.Fprintf 返回错误,goroutine 会将错误发送到错误通道并停止。

使用 bufio.Scanner 播放文件事务日志

即使是最好的事务日志,如果不读取也是没有用的。那么,如何读取它呢?

您需要从头开始读取日志并解析每一行;io.ReadStringfmt.Sscanf 让您可以轻松做到这一点。

通道(我们的可靠朋友)将让您的服务在检索结果时实时将其流式传输给消费者。虽然这可能开始感觉有些例行公事,但请稍作停顿,欣赏一下。在大多数其他语言中,最直接的方式是读取整个文件,将其存储在数组中,然后循环遍历该数组以重放事件。但 Go 的并发原语使得将数据以更加高效的空间和内存方式流式传输给消费者变得几乎是微不足道的。

ReadEvents 方法演示了这一点:

func (l *FileTransactionLogger) ReadEvents() (<-chan Event, <-chan error) {
    scanner := bufio.NewScanner(l.file)     // 为 l.file 创建一个扫描器
    outEvent := make(chan Event)            // 一个无缓冲的事件通道
    outError := make(chan error, 1)         // 一个缓冲的错误通道

    go func() {
        var e Event

        defer close(outEvent)               // 在 goroutine 结束时关闭通道
        defer close(outError)

        for scanner.Scan() {
            line := scanner.Text()

            if err := fmt.Sscanf(line, "%d\t%d\t%s\t%s",
                &e.Sequence, &e.EventType, &e.Key, &e.Value); err != nil {

                outError <- fmt.Errorf("input parse error: %w", err)
                return
            }

            // 简单检查!序列号是否按递增顺序排列?
            if l.lastSequence >= e.Sequence {
                outError <- fmt.Errorf("transaction numbers out of sequence")
                return
            }

            l.lastSequence = e.Sequence     // 更新上次使用的序列号

            outEvent <- e                   // 发送事件
        }

        if err := scanner.Err(); err != nil {
            outError <- fmt.Errorf("transaction log read failure: %w", err)
            return
        }
    }()

    return outEvent, outError
}

ReadEvents 方法实际上可以被看作是两个功能:外部函数初始化文件读取器并创建并返回事件和错误通道;内部函数并发地逐行读取文件内容,并将结果发送到通道中。

有趣的是,TransactionLoggerfile 属性是 *os.File 类型,它具有满足 io.Reader 接口的 Read 方法。Read 是一个相对低级的方法,但如果您想,实际上可以用它来检索数据。然而,bufio 包提供了一个更好的方式:Scanner 接口,它为读取按行分隔的文本提供了一个方便的方式。

您的事务日志器接口(Redux版)

现在您已经实现了一个完全功能的 FileTransactionLogger,是时候回过头来看我们可以将哪些新方法整合进 TransactionLogger 接口中。实际上,似乎有很多方法我们希望在任何实现中保留,从而形成最终版本的 TransactionLogger 接口:

type TransactionLogger interface {
    WriteDelete(key string)
    WritePut(key, value string)
    Err() <-chan error

    ReadEvents() (<-chan Event, <-chan error)

    Run()
}

现在这一切都已经确定,您终于可以开始将事务日志集成到您的键值服务中。

在您的 Web 服务中初始化 FileTransactionLogger

FileTransactionLogger 现在已经完成!接下来要做的就是将其集成到您的 Web 服务中。第一步是添加一个新的函数来创建新的 TransactionLogger 实例,读取并重放任何现有事件,并调用 Run

首先,让我们在 service.go 中添加一个 TransactionLogger 引用。您可以将其命名为 logger,因为命名总是很困难:

var logger TransactionLogger

现在您已经解决了这个细节,接下来定义您的初始化函数,看起来可能如下所示:

func initializeTransactionLog() error {
    var err error

    logger, err = NewFileTransactionLogger("transaction.log")
    if err != nil {
        return fmt.Errorf("failed to create event logger: %w", err)
    }

    events, errors := logger.ReadEvents()

    e := Event{}
    ok := true

    for ok && err == nil {
        select {
        case err, ok = <-errors:            // 获取任何错误
        case e, ok = <-events:
            switch e.EventType {
            case EventDelete:               // 收到 DELETE 事件!
                err = Delete(e.Key)
            case EventPut:                  // 收到 PUT 事件!
                err = Put(e.Key, e.Value)
            }
        }
    }

    logger.Run()

    return err
}

这个函数的开始部分和您预期的差不多:它调用 NewFileTransactionLogger 并将其分配给 logger

接下来的部分更有趣:它调用 logger.ReadEvents 并根据从中接收到的 Event 值重放结果。这是通过在 select 中循环进行的,其中包含了 eventserrors 通道的 case。注意 select 中的 case 是如何使用 case foo, ok = <-ch 的格式的。如果通过这种方式从通道读取的布尔值为 false,则表示通道已关闭,设置 ok 的值并终止 for 循环。

如果从 events 通道中获取到一个 Event 值,我们将根据事件类型调用 DeletePut;如果从 errors 通道中获取到错误,err 将被设置为一个非 nil 值,for 循环将终止。

FileTransactionLogger 集成到您的 Web 服务中

现在初始化逻辑已经完成,要完成 TransactionLogger 的集成,剩下的工作就是将三个函数调用添加到 Web 服务中。这是相当直接的,所以我们不会在这里一一讲解。不过,简要地说,您需要做以下几件事:

  • main 方法中调用 initializeTransactionLog
  • deleteHandler 中调用 logger.WriteDelete
  • putHandler 中调用 logger.WritePut

我们将实际的集成留给读者作为练习。

未来的改进

我们可能已经完成了事务日志器的最小可行实现,但它仍然存在许多问题和改进的机会,如下所示:

  • 没有测试。
  • 没有 Close 方法来优雅地关闭事务日志文件。
  • 服务可能会在写缓冲区中仍有事件时关闭:事件可能会丢失。
  • 键和值没有在事务日志中编码:多行或空格可能无法正确解析。
  • 键和值的大小没有限制:可以添加非常大的键或值,从而填满磁盘。
  • 事务日志以纯文本格式写入:它可能占用比实际需要更多的磁盘空间。
  • 日志会永久保留已删除值的记录:日志会无限增长。

所有这些问题在生产环境中都会成为障碍。我鼓励您花时间考虑—甚至实现—其中一个或多个问题的解决方案。

在外部数据库中存储状态

数据库和数据是许多(如果不是大多数)商业和 Web 应用程序的核心,因此 Go 在其核心库中包含了一个标准的 SQL(或类似 SQL 的)数据库接口,这完全是合乎逻辑的。

但是,使用 SQL 数据库来支撑我们的键值存储是否合理?毕竟,我们的数据存储仅仅依赖于另一个数据存储不是有些冗余吗?是的,确实如此。但是将服务的数据外部化到另一个专门为此目的设计的服务——即数据库——是一种常见模式,可以在服务副本之间共享状态并提供数据弹性。此外,本节的重点是展示如何与数据库交互,而不是设计完美的应用程序。

在这一部分,您将实现一个由外部数据库支持的事务日志,并实现与之前“在事务日志文件中存储状态”相同的 TransactionLogger 接口。这显然是可行的,并且与之前提到的有一些好处,但也有一些权衡:

优点

  • 外部化应用状态
    减少了对分布式状态的关注,更接近“云原生”。
  • 更容易扩展
    不需要在副本之间共享数据,扩展变得更容易(但并不简单)。

缺点

  • 引入瓶颈
    如果您需要大规模扩展怎么办?如果所有副本必须同时从数据库读取怎么办?
  • 引入上游依赖
    创建对可能会失败的另一个资源的依赖。
  • 需要初始化
    如果 Transactions 表不存在怎么办?
  • 增加复杂性
    又多了一个需要管理和配置的东西。

在 Go 中与数据库工作

数据库,特别是 SQL 和类似 SQL 的数据库,随处可见。您可以尝试避免使用它们,但如果您构建的应用程序涉及某种数据组件,您最终会不得不与它们交互。

幸运的是,Go 的标准库为我们提供了 database/sql 包,它提供了一个惯用且轻量的 SQL(和类似 SQL)数据库接口。在这一部分,我们将简要演示如何使用此包,并指出其中的一些注意事项。

database/sql 包中最常见的成员之一是 sql.DB:Go 的主要数据库抽象和创建语句、事务、执行查询以及获取结果的入口点。虽然它的名字可能让您误以为它映射到某个特定的数据库或模式概念,但实际上它做了很多事情,包括但不限于与数据库的连接协商以及管理数据库连接池。

接下来,我们将讨论如何创建 sql.DB 值。但在此之前,我们必须先谈谈数据库驱动。

导入数据库驱动

虽然 sql.DB 类型提供了与 SQL 数据库交互的通用接口,但它依赖于数据库驱动来实现特定数据库类型的细节。在撰写本文时,Go 仓库中列出了 60 多个驱动。

在接下来的部分中,我们将使用 Postgres 数据库,因此我们将使用第三方的 lib/pq Postgres 驱动实现。

要加载数据库驱动,您需要通过将其包限定符别名为 _(下划线)来匿名导入驱动包。这会触发该包的任何初始化器,同时通知编译器您不打算直接使用该包:

import (
    "database/sql"
    _ "github.com/lib/pq"   // 匿名导入驱动包
)

完成这一步后,您就可以创建 sql.DB 值并访问数据库了。

实现您的 PostgresTransactionLogger

在《您的事务日志器接口(redux版)》中,我们展示了完整的 TransactionLogger 接口,它为通用事务日志提供了标准定义。您可能记得它定义了启动日志记录器以及读取和写入日志事件的方法,具体如下:

type TransactionLogger interface {
    WriteDelete(key string)
    WritePut(key, value string)
    Err() <-chan error

    ReadEvents() (<-chan Event, <-chan error)

    Run()
}

我们现在的目标是创建一个由数据库支持的 TransactionLogger 实现。幸运的是,我们的大部分工作已经为我们完成了。回顾一下《实现您的 FileTransactionLogger》,似乎我们可以使用非常类似的逻辑来创建 PostgresTransactionLogger

WritePutWriteDeleteErr 方法开始,您可以这样做:

type PostgresTransactionLogger struct {
    events       chan<- Event   // 用于发送事件的只写通道
    errors       <-chan error   // 用于接收错误的只读通道
    db           *sql.DB        // 数据库访问接口
}

func (l *PostgresTransactionLogger) WritePut(key, value string) {
    l.events <- Event{EventType: EventPut, Key: key, Value: value}
}

func (l *PostgresTransactionLogger) WriteDelete(key string) {
    l.events <- Event{EventType: EventDelete, Key: key}
}

func (l *PostgresTransactionLogger) Err() <-chan error {
    return l.errors
}

如果您将这个与 FileTransactionLogger 进行比较,会发现到目前为止的代码几乎是完全一样的。我们真正做的改动只有:

  • (显然)将类型重命名为 PostgresTransactionLogger
  • *os.File 替换为 *sql.DB
  • 移除 lastSequence;您可以让数据库来处理序列化。

创建一个新的 PostgresTransactionLogger

这很好,但我们还没有讨论如何创建 *sql.DB 值。我知道您一定很好奇。悬念感十足。

就像我们在 NewFileTransactionLogger 函数中做的那样,我们将为 PostgresTransactionLogger 创建一个构造函数,称之为 NewPostgresTransactionLogger。不过,和 NewFileTransactionLogger 打开文件不同,它将与数据库建立连接,如果失败,则返回错误。

不过有一个小细节。也就是说,Postgres 连接的设置需要很多参数。至少我们需要知道数据库所在的主机、数据库名称、用户名和密码。处理这一问题的一种方式是创建一个如下所示的函数,该函数接受多个字符串参数:

func NewPostgresTransactionLogger(host, dbName, user, password string)
    (TransactionLogger, error) { ... }

然而,这种方式比较丑陋。而且,如果以后您想添加一个新的参数怎么办?您会将它追加到参数列表的末尾,破坏任何已经使用该函数的代码吗?更糟糕的是,没有查看文档的话,参数的顺序也不清晰。

必须有更好的方法。所以,我们可以创建一个小的帮助结构体:

type PostgresDBParams struct {
    dbName   string
    host     string
    user     string
    password string
}

与大字符串包的方式不同,这个结构体小巧、易读且易于扩展。使用它时,您可以创建一个 PostgresDBParams 变量并将其传递给构造函数。如下所示:

logger, err = NewPostgresTransactionLogger(PostgresDBParams{
    host:     "localhost",
    dbName:   "kvs",
    user:     "test",
    password: "hunter2"
})

新的构造函数看起来可能如下:

func NewPostgresTransactionLogger(config PostgresDBParams) (TransactionLogger, error) {

    connStr := fmt.Sprintf("host=%s dbname=%s user=%s password=%s",
        config.host, config.dbName, config.user, config.password)

    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, fmt.Errorf("failed to open db: %w", err)
    }

    err = db.Ping()  // 测试数据库连接
    if err != nil {
        return nil, fmt.Errorf("failed to open db connection: %w", err)
    }

    logger := &PostgresTransactionLogger{db: db}

    exists, err := logger.verifyTableExists()
    if err != nil {
        return nil, fmt.Errorf("failed to verify table exists: %w", err)
    }
    if !exists {
        if err = logger.createTable(); err != nil {
            return nil, fmt.Errorf("failed to create table: %w", err)
        }
    }

    return logger, nil
}

这个过程完成了很多操作,但本质上与 NewFileTransactionLogger 并没有太大区别。

首先,它使用 sql.Open 获取 *sql.DB 值。您会注意到,传递给 sql.Open 的连接字符串包含了多个参数;lib/pq 包支持的参数比这里展示的要多。有关完整的列表,请参阅包文档。

许多驱动,包括 lib/pq,实际上并不会立即与数据库建立连接,因此它使用 db.Ping 强制驱动程序建立并测试连接。

最后,它创建了 PostgresTransactionLogger 并使用它来验证 transactions 表是否存在,必要时创建该表。如果没有这一步,PostgresTransactionLogger 将默认认为表已经存在,如果没有将会失败。

您可能注意到 verifyTableExistscreateTable 方法没有在这里实现。这是完全故意的。作为练习,您可以深入研究 database/sql 文档,并思考如何实现这些方法。如果不想实现,也可以在本书附带的 GitHub 仓库中找到相应的实现。

现在您有了一个构造函数,它建立了与数据库的连接并返回一个新创建的 TransactionLogger。但再次强调,您还需要让它开始工作。为此,您需要实现 Run 方法,创建事件和错误通道,并启动事件获取的 goroutine。

使用 db.Exec 执行 SQL INSERT

对于 FileTransactionLogger,您实现了一个 Run 方法,初始化了通道并创建了负责写入事务日志的 goroutine。
PostgresTransactionLogger 与之类似。但是,它不再是向文件追加一行,而是使用 db.Exec 执行 SQL INSERT 来实现相同的效果:

func (l *PostgresTransactionLogger) Run() {
    events := make(chan Event, 16)              // 创建一个事件通道
    l.events = events

    errors := make(chan error, 1)               // 创建一个错误通道
    l.errors = errors

    go func() {                                 // 执行 INSERT 查询
        query := `INSERT INTO transactions
            (event_type, key, value)
            VALUES ($1, $2, $3)`

        for e := range events {                 // 获取下一个事件

            _, err := l.db.Exec(                // 执行 INSERT 查询
                query,
                e.EventType, e.Key, e.Value)

            if err != nil {
                errors <- err
            }
        }
    }()
}

这个 Run 方法的实现几乎与 FileTransactionLogger 中的实现一样:它创建了缓冲的事件和错误通道,并启动了一个 goroutine 从事件通道中检索 Event 值,并将它们写入事务日志。

FileTransactionLogger 向文件追加数据不同,这个 goroutine 使用 db.Exec 执行 SQL 查询,向 transactions 表追加一行数据。查询中的编号参数(1,1, 2, $3)是占位符查询参数,必须在调用 db.Exec 时提供具体的值。

使用 db.Query 播放 Postgres 事务日志

在《使用 bufio.Scanner 播放文件事务日志》中,您使用 bufio.Scanner 来读取之前写入的事务日志条目。
Postgres 实现不会像文件版本那样直接,但原则是相同的:您指向数据源的顶部,读取直到底部:

func (l *PostgresTransactionLogger) ReadEvents() (<-chan Event, <-chan error) {
    outEvent := make(chan Event)                // 一个无缓冲的事件通道
    outError := make(chan error, 1)             // 一个缓冲的错误通道

    go func() {
        defer close(outEvent)                   // goroutine 结束时关闭通道
        defer close(outError)

        query := `SELECT sequence, event_type, key, value
                  FROM transactions
                  ORDER BY sequence`

        rows, err := l.db.Query(query)          // 执行查询并获取结果集
        if err != nil {
            outError <- fmt.Errorf("sql query error: %w", err)
            return
        }

        defer rows.Close()                      // 关闭结果集

        e := Event{}                            // 创建一个空的 Event

        for rows.Next() {                       // 遍历结果集中的行

            err = rows.Scan(                    // 读取行中的值到 Event
                &e.Sequence, &e.EventType,      // row 中的值
                &e.Key, &e.Value)

            if err != nil {
                outError <- fmt.Errorf("error reading row: %w", err)
                return
            }

            outEvent <- e                       // 发送事件到输出通道
        }

        err = rows.Err()
        if err != nil {
            outError <- fmt.Errorf("transaction log read failure: %w", err)
        }
    }()

    return outEvent, outError
}

所有有趣(或者至少是新的)部分都发生在 goroutine 中。让我们分解它们:

  • query 是一个包含 SQL 查询的字符串。此查询请求四个列:sequenceevent_typekeyvalue
  • db.Query 将查询发送到数据库并返回类型为 *sql.Rowserror 的结果。
  • 我们使用 defer 调用 rows.Close(),这样可以确保在结束时关闭结果集,否则可能会导致连接泄漏。
  • rows.Next 允许我们遍历结果集。如果没有更多的行或出现错误,它返回 false
  • rows.Scan 将当前行的列值复制到我们在调用中指定的变量。
  • 最后,我们将事件 e 发送到输出通道 outEvent

在 Web 服务中初始化 PostgresTransactionLogger

PostgresTransactionLogger 基本上已经完成。现在,让我们将其集成到 Web 服务中。
幸运的是,由于我们之前已经有了 FileTransactionLogger,我们只需要修改一行代码:

logger, err = NewFileTransactionLogger("transaction.log")

这行代码变成:

logger, err = NewPostgresTransactionLogger(PostgresDBParams{
    host:     "localhost",
    dbName:   "db-name",
    user:     "db-user",
    password: "db-password"
})

就这样,完成了。因为这代表了 TransactionLogger 接口的完整实现,所以其他部分保持不变。您可以使用与之前相同的方法与 PostgresTransactionLogger 进行交互。

未来的改进

FileTransactionLogger 一样,PostgresTransactionLogger 代表了事务日志器的最小可行实现,仍有许多改进的空间。以下是一些改进的方向,当然,这些并不局限于此:

  • 我们假设数据库和表已经存在,如果没有,将会报错。
  • 连接字符串是硬编码的,甚至密码也如此。
  • 仍然没有 Close 方法来清理打开的连接。
  • 服务可能会在写缓冲区中仍有事件时关闭,导致事件丢失。
  • 日志会永久保留已删除值的记录,日志会无限增长。

所有这些问题在生产环境中都可能成为障碍。我鼓励您花时间思考—甚至实现—这些问题的解决方案。

第三代:实现传输层安全

安全性。无论你喜欢与否,安全性是任何应用程序(无论是云原生的还是其他类型)的一个关键特性。不幸的是,安全性经常被视为事后考虑,这可能会带来灾难性的后果。

对于传统环境,已经有丰富的工具和既定的安全最佳实践,但是对于云原生应用程序,这种情况则有所不同。云原生应用程序通常由几个小型的、往往是短暂的微服务组成。虽然这种架构提供了显著的灵活性和可扩展性好处,但它也为潜在的攻击者提供了一个明显的机会:每个服务之间的通信都是通过网络传输的,容易受到窃听和篡改的威胁。

安全问题本身可以写成一本书,因此我们将重点介绍一种常见技术:加密。加密“传输中的数据”(或“网络中的数据”)通常用来防止窃听和消息篡改,任何有价值的编程语言——包括,特别是 Go——都会使其相对容易实现。

传输层安全

传输层安全(Transport Layer Security,TLS)是一种加密协议,旨在提供计算机网络上的通信安全。它的使用广泛,几乎适用于任何互联网通信。你很可能已经熟悉它(并且现在可能正在使用它)以 HTTPS 形式存在——也就是在 TLS 上的 HTTP,它使用 TLS 对 HTTP 的交换进行加密。

TLS 使用公钥加密来加密消息,我们将在《非对称加密》部分中对这一点进行更深入的讨论。然而,简单来说,双方各自拥有一对密钥,包括一个公开密钥,它是公开的,可以自由分发;另一个是私有密钥,仅持有者知道,如图 5-2 所示。任何人都可以使用公钥加密消息,但只有使用相应的私钥才能解密消息。通过这种协议,想要进行私密通信的双方可以交换各自的公钥,之后可以用这些公钥来加密所有后续的通信,而只有持有相应私钥的接收方能够解读这些通信。

image.png

证书、证书颁发机构和信任

如果 TLS 有一个座右铭,那就是“信任但要验证。” 实际上,去掉“信任”这一部分,验证一切。

仅仅提供一个公钥对于服务来说是不够的。每个公钥都必须与一个数字证书相关联,这是一个用于证明密钥所有权的电子文档。证书显示公钥的所有者实际上是证书中所列的主体(所有者),并描述该密钥的使用方式。这使得接收方能够将证书与各种“信任”进行比较,以决定是否接受该证书为有效。

首先,证书必须由证书颁发机构(CA)进行数字签名和认证,CA 是一个受信任的实体,负责颁发数字证书。

其次,证书的主体必须与客户端试图连接的服务的域名匹配。除此之外,这有助于确保您接收到的证书是有效的,并且没有被中间人篡改。

只有在这两者都满足的情况下,您的通信才会继续进行。

警告

Web 浏览器或其他工具通常会允许您在证书无法验证时选择继续进行。例如,如果您在开发过程中使用的是自签名证书,这种做法可能是合适的。但一般而言,请遵循警告。

私钥和证书文件

TLS(及其前身安全套接字层 [SSL])已经存在了很长时间,您可能认为我们已经确定了一个统一的密钥容器格式,但事实并非如此。搜索“key file format”将会返回各种各样的文件扩展名:.csr、.key、.pkcs12、.der 和 .pem 等等。

然而,在这些格式中,.pem 似乎是最常见的格式。它恰好也是 Go 的 net/http 包最容易支持的格式,所以我们将使用它。

隐私增强邮件(PEM)文件格式

隐私增强邮件(PEM)是一种常见的证书容器格式,通常存储在 .pem 文件中,但 .cer 或 .crt(用于证书)和 .key(用于公钥或私钥)也很常见。方便的是,PEM 格式也采用 base64 编码,因此可以在文本编辑器中查看,甚至可以安全地粘贴到(例如)电子邮件正文中。

通常,.pem 文件会成对出现,表示完整的密钥对:

  • cert.pem:服务器证书(包括 CA 签名的公钥)
  • key.pem:私钥,不能共享

接下来,我们假设您的密钥采用这种配置。如果您还没有密钥并需要为开发目的生成密钥,网上有多个地方提供了相关的说明。如果您已经有了其他格式的密钥文件,如何转换它们超出了本书的范围。不过,互联网是一个神奇的地方,网上有许多教程可以帮助您在常见的密钥格式之间进行转换。

使用 HTTPS 保护您的 Web 服务

既然我们已经确定了安全性应该得到严肃对待,并且通过 TLS 进行通信是确保我们通信安全的最低起步,接下来我们该如何做呢?

一种方法是将反向代理放在我们的服务前面,反向代理可以处理 HTTPS 请求,并将其转发到我们的键值服务作为 HTTP,但除非这两个服务部署在同一台服务器上,否则我们仍然是在网络上发送未加密的消息。此外,额外的服务增加了架构复杂性,我们可能希望避免这种情况。也许我们可以让我们的键值服务直接提供 HTTPS 服务?

实际上,我们可以。回顾一下《使用 net/http 构建 HTTP 服务器》中的内容,您可能记得 net/http 包包含了一个函数,ListenAndServe,它的最基本形式看起来像这样:

func main() {
    http.HandleFunc("/", helloGoHandler)            // 添加一个根路径处理器

    http.ListenAndServe(":8080", nil)               // 启动 HTTP 服务器
}

在这个例子中,我们调用 HandleFunc 为根路径添加一个处理函数,然后使用 ListenAndServe 启动服务监听和提供服务。为了简化起见,我们忽略了 ListenAndServe 返回的任何错误。

这里的组件不多,这倒是挺好的。秉承这种理念,net/http 的设计者们为我们提供了一个启用 TLS 的 ListenAndServe 变体,我们非常熟悉它:

func ListenAndServeTLS(addr, certFile, keyFile string, handler Handler) error {}

如您所见,ListenAndServeTLS 看起来和使用方式几乎和 ListenAndServe 完全一样,只不过它多了两个参数:certFilekeyFile。如果您手头有证书和私钥的 PEM 文件,那么提供 HTTPS 加密连接就只需要将这些文件的文件名传递给 ListenAndServeTLS

http.ListenAndServeTLS(":8080", "cert.pem", "key.pem", nil)

这看起来非常方便,但是它能正常工作吗?让我们启动服务(使用自签名证书)来看看效果。

打开我们的老朋友 curl,尝试插入一个键值对。注意,我们在 URL 中使用了 https 协议,而不是 http:

$ curl -X PUT -d 'Hello, key-value store!' -v https://localhost:8080/v1/key/key-a
* SSL certificate problem: self signed certificate
curl: (60) SSL certificate problem: self signed certificate

嗯,这并没有按计划进行。如我们在《证书、证书颁发机构和信任》中提到的,TLS 期望任何证书都由 CA 签名。它不喜欢自签名证书。

幸运的是,我们可以通过 curl 中的 --insecure 标志关闭这个安全检查:

$ curl -X PUT -d 'Hello, key-value store!' --insecure -v \
    https://localhost:8080/v1/key/key-a
* SSL certificate verify result: self signed certificate (18), continuing anyway.
> PUT /v1/key/key-a HTTP/2
< HTTP/2 201

我们收到了一个严厉的警告,但它成功了!

传输层总结

我们在短短几页中涵盖了很多内容。安全性的话题非常广泛,我们无法全面讲解,但至少我们介绍了 TLS 以及它如何作为更大安全策略中的一个相对低成本、高回报的组件。

我们还展示了如何在 Go 的 net/http Web 服务中实现 TLS,并且看到只要我们有有效的证书,就能够轻松地保护服务的通信。

总结

这是一个长章节,我们涉及了许多不同的话题。想想我们已经完成了多少工作吧!

从最基本的原理开始,我们设计并实现了一个简单的单体键值存储,使用 net/httpgorilla/mux 构建了一个围绕由一个小型、独立且易于测试的 Go 库提供的功能的 RESTful 服务。

我们利用 Go 强大的接口功能,制作了两个完全不同的事务日志记录器实现,一个基于本地文件,使用 os.Filefmtbufio 包,另一个由 Postgres 数据库支持,使用 database/sqlgithub.com/lib/pq Postgres 驱动包。

我们讨论了安全性的重要性,介绍了 TLS 的一些基础知识,作为更大安全策略的一部分,并在我们的服务中实现了 HTTPS。

最后,我们涵盖了容器化,这是云原生技术的核心之一,包括如何构建镜像以及如何运行和管理容器。我们甚至容器化了我们的应用程序以及它的构建过程。

接下来,我们将在引入新概念时以各种方式扩展我们的键值服务,所以请继续关注。接下来的内容会更加有趣。