[译]用 Go 语言编写一个微服务博客 — 第三部分 — 嵌入数据库并提供 JSON 服务
12 min read
原文链接:Go Microservices, part 3 - embedded database and JSON
在第 3 部分中,我们将使我们的 Accountservice 做一些有用的事情。
- 声明一个 Account 结构体。
- 嵌入一个简单的键值存储,这样我们就可以在其中存储 Account 结构体。
- 将结构体序列化为 JSON,并通过我们的 /accounts/{accountId} HTTP 服务提供服务。
代码
和本博客系列的所有后续部分一样,您可以通过克隆源代码(请参阅第 2 部分)并切换到 P3 分支来获得该部分的完整代码,如:
git checkout P3
声明一个账户结构体
有关 Go 结构体的更详细介绍,请查看这个指南。
在我们的项目中,在 /accountservice 下创建一个名为 model 的文件夹。
mkdir model
然后在 model 文件夹中创建一个名为 account.go 的文件,其内容如下:
package model
type Account struct {
Id string `json:"id"`
Name string `json:"name"`
}
这段代码声明了我们的 Account 抽象,它基本上就是一个 id 和一个 name。变量名第一个字母的大小写表示作用域(大写表示包外可见,小写表示包外不可见)。我们还使用标准库的 json.Marshal 函数和标签来表示 Go 应如何序列化每个字段。
嵌入键值存储
为此,我们将使用 BoltDB 键值存储数据库。它简单,快速且易于使用。我们需要在声明使用依赖项之前使用 go get 来下载依赖项:
go get github.com/boltdb/bolt
接下来,在 /goblog/accountservice 文件夹中,创建一个名为 dbclient 的文件夹和一个名为 boltclient.go 的文件。为了使以后的模拟测试更加容易,我们先声明一个接口,该接口定义了我们需要实现的函数:
package dbclient
import (
"github.com/callistaenterprise/goblog/accountservice/model"
)
type IBoltClient interface {
OpenBoltDb()
QueryAccount(accountId string) (model.Account, error)
Seed()
}
在同一文件中,我们将提供此接口的实现。首先声明一个结构体,该结构体封装了一个指向 bolt.DB 实例的指针。
// Real implementation
type BoltClient struct {
boltDB *bolt.DB
}
然后是 OpenBoltDb() 的实现。
func (bc *BoltClient) OpenBoltDb() {
var err error
bc.boltDB, err = bolt.Open("accounts.db", 0600, nil)
if err != nil {
log.Fatal(err)
}
}
我们将函数绑定到结构体上的 Go 语法可能会有些奇怪。现在,我们的结构体隐式实现了三种方法之一。
我们将在某个地方需要这个 bolt client 的实例。让我们在 /goblog/accountservice/service/handlers.go 中将其放在要被使用的地方。创建该文件并添加结构体实例:
package service
import (
"github.com/callistaenterprise/goblog/accountservice/dbclient"
)
var DBClient dbclient.IBoltClient
更新 main.go,以便它在启动时打开数据库:
func main() {
fmt.Printf("Starting %v\n", appName)
initializeBoltClient() // NEW
service.StartWebServer("6767")
}
// Creates instance and calls the OpenBoltDb and Seed funcs
func initializeBoltClient() {
service.DBClient = &dbclient.BoltClient{}
service.DBClient.OpenBoltDb()
service.DBClient.Seed()
}
现在,我们的微服务应该可以在启动时创建一个数据库。但是,在运行之前,我们将添加一段代码,这些代码将在启动时为我们添加一些假帐户数据。
启动时添加一些假账户数据
再次打开 boltclient.go 并添加以下函数:
// Start seeding accounts
func (bc *BoltClient) Seed() {
initializeBucket()
seedAccounts()
}
// Creates an "AccountBucket" in our BoltDB. It will overwrite any existing bucket of the same name.
func (bc *BoltClient) initializeBucket() {
bc.boltDB.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucket([]byte("AccountBucket"))
if err != nil {
return fmt.Errorf("create bucket failed: %s", err)
}
return nil
})
}
// Seed (n) make-believe account objects into the AcountBucket bucket.
func (bc *BoltClient) seedAccounts() {
total := 100
for i := 0; i < total; i++ {
// Generate a key 10000 or larger
key := strconv.Itoa(10000 + i)
// Create an instance of our Account struct
acc := model.Account{
Id: key,
Name: "Person_" + strconv.Itoa(i),
}
// Serialize the struct to JSON
jsonBytes, _ := json.Marshal(acc)
// Write the data to the AccountBucket
bc.boltDB.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("AccountBucket"))
err := b.Put([]byte(key), jsonBytes)
return err
})
}
fmt.Printf("Seeded %v fake accounts...\n", total)
}
有关 Bolt API 的更多信息以及 Update 方法如何接收我们的函数的详细信息,请参阅 BoltDB 文档。
目前为止,我们已经完成了跟 BoltDB 有关的部分。让我们再次编译并运行:
> go run *.go
Starting accountservice
Seeded 100 fake accounts...
2017/01/31 16:30:59 Starting HTTP service at 6767
很好!可以使用 Ctrl+C 停止运行。
添加一个查询方法
现在,我们通过向 boltclient.go 添加 Query 方法来完成我们的数据库 API:
func (bc *BoltClient) QueryAccount(accountId string) (model.Account, error) {
// Allocate an empty Account instance we'll let json.Unmarhal populate for us in a bit.
account := model.Account{}
// Read an object from the bucket using boltDB.View
err := bc.boltDB.View(func(tx *bolt.Tx) error {
// Read the bucket from the DB
b := tx.Bucket([]byte("AccountBucket"))
// Read the value identified by our accountId supplied as []byte
accountBytes := b.Get([]byte(accountId))
if accountBytes == nil {
return fmt.Errorf("No account found for " + accountId)
}
// Unmarshal the returned bytes into the account struct we created at
// the top of the function
json.Unmarshal(accountBytes, &account)
// Return nil to indicate nothing went wrong, e.g no error
return nil
})
// If there were an error, return the error
if err != nil {
return model.Account{}, err
}
// Return the Account struct and nil as error.
return account, nil
}
如果代码不够清晰,可以理解一些注释。该函数将使用提供的 accountId 参数查询 BoltDB,并将返回 Account 结构体或一个 error。
通过 HTTP 提供账户服务
让我们修复在 /service/routes.go 中声明的 /accounts/{accountId} 路由,以便它正确返回刚才我们建立的假数据 Account 结构体。打开 routes.go 并用我们稍后将创建的函数 GetAccount 替换原来的 func(w http.ResponseWriter,r * http.Request){:
Route{
"GetAccount", // Name
"GET", // HTTP method
"/accounts/{accountId}", // Route pattern
GetAccount,
},
接下来使用符合 HTTP handler 函数签名的 GetAccount 函数来更新 /service/handlers.go:
var DBClient dbclient.IBoltClient
func GetAccount(w http.ResponseWriter, r *http.Request) {
// Read the 'accountId' path parameter from the mux map
var accountId = mux.Vars(r)["accountId"]
// Read the account struct BoltDB
account, err := DBClient.QueryAccount(accountId)
// If err, return a 404
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
// If found, marshal into JSON, write headers and content
data, _ := json.Marshal(account)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
w.Write(data)
}
GetAccount 函数符合 handler 函数签名,所以当 Gorilla 检测到对 /accounts/{accountId} 的调用时,它会把请求路由到 GetAccount 函数中。让我们来运行它看看!
> go run *.go
Starting accountservice
Seeded 100 fake accounts...
2017/01/31 16:30:59 Starting HTTP service at 6767
使用 curl 调用该 API。注意,我们前面创建了 100 个 ID 从 10000 开始的假帐户。
> curl http://localhost:6767/accounts/10000
{"id":"10000","name":"Person_0"}
很好!现在,我们的微服务真正地通过 HTTP 从数据库中提供 JSON 数据。
运行时和性能表现
让我们检查与第 2 部分中相同的内存和 CPU 使用率指标:场景分别是负载测试前、负载测试中和负载测试后。
启动后的内存使用情况
2.1 mb,还是很不错!添加嵌入式 BoltDB 和一些其他代码来处理路由,使得我们的初始内存占用增加了 300kb。让我们以 1K req/s 的速度来运行 Gatling 测试。现在,我们返回的是从 BoltDB 获取的真实的 Account 对象,而且该对象已序列化为 JSON:
负载测试后的内存使用情况
31.2 mb 的内存占用。与第 2 部分中的简单服务相比,使用嵌入式数据库并达到 1K req/s 的额外开销确实很小。
性能和 CPU 占用率
1K req/s 约占单个核心的 10%。BoltDB 和 JSON 序列化的开销不是很大,很好!顺便说一下:最上面的 Java 进程是我们的 Gatling 测试,实际上它使用的 CPU 资源约为我们服务的 3 倍。
平均响应时间仍小于一毫秒。
也许我们应该在更重的负载下进行测试,比如说 4K req/s?(注意,可能需要在操作系统级别上增加可用文件句柄的数量):
内存使用率为 4K req/s
约 120 mb。几乎正好增加了 4 倍。几乎可以肯定,使用 n/o 并发请求导致的内存增加是由于 Go 运行时,或者是 Gorilla 随着负载提高而增加的用于为请求提供服务的 goroutine。
4K req/s 的性能
在 4K req/s 下,CPU 使用率保持在 30% 以下。此时,即便在配备 16 GB RAM/Core i7 的笔记本电脑上运行,瓶颈也可能会出现在 IO 或文件句柄,而不是 CPU。
现在,平均等待时间终于提升为 1 ms,其中 95% 的请求保持在 3 ms 以下。虽然我们确实看到延迟在 4K req/s 时受到冲击,但是我个人认为带有嵌入式 BoltDB 的小型服务 Accountservice 的性能确实很好。
与其他平台的比较
其他人可能会写一些有趣的博客文章,关于将该 accountservice 在 JVM、NodeJS 或 CLR 等上实现,并对其进行基准测试。
我在 2015 年底对此进行了一些简单的不够准确的基准测试(使用 Gatling 测试套件),比较了分别在 Go 1.5、Java 8 Spring Boot 和 NodeJS 中实现的 HTTP/JSON 服务 + MongoDB 读取。在该特殊情况下,基于 JVM 和 Go 的解决方案都可以很好地扩展,但 Go 与JVM 的延迟相比略有优势。NodeJS 的性能与上述方案相似,直到单个内核上的 CPU 利用率达到 100%,这时延迟就开始出现下降了。
请不要将上述基准测试当作某种依据,因为这只是我自己做的一个快速而不专业的测试。
因此,尽管我已经展示了使用 Go 1.7 编写的 accountservice 在 4K req/s 下可观的性能数据,但其他平台也有可能达到这个性能,不过我也怀疑它们的内存使用是否比 Go 少。也许您的体验和我不同。
后记
在本博客系列的下一部分中,我们将看看如何使用 GoConvey 并模拟 BoltDB 客户端来对服务进行单元测试。