Go 现实世界项目的设计模式(十三)
当涉及到我们的服务如何相互通信以及客户端如何与服务通信时,有许多选项,Go kit 并不关心(更确切地说,它并不介意——它足够关心,以至于提供了许多流行机制的实现)。实际上,我们能够为我们的用户提供多个选项,并让他们决定他们想要使用哪一个。我们将添加对熟悉的 JSON over HTTP 的支持,但我们也将引入一个新的 API 技术选择。gRPC,即 Google 的远程过程调用,是一个开源机制,
原文:
zh.annas-archive.org/md5/ed73371609da148c0306f0e1d33f99c6
译者:飞龙
第十章. 使用 Go kit 框架的 Go 微服务
微服务是离散的组件,它们协同工作,为更大的应用程序提供功能性和业务逻辑,通常通过网络协议(如 HTTP/2 或某些其他二进制传输)进行通信,并分布到许多物理机器上。每个组件与其他组件隔离,它们接受定义良好的输入并产生定义良好的输出。同一服务的多个实例可以在多个服务器上运行,并且可以在它们之间进行负载均衡。如果设计得当,单个实例的失败不会导致整个系统崩溃,并且在运行时可以启动新的实例以帮助处理负载峰值。
Go kit(参考gokit.io
)是由 Peter Bourgon(Twitter 上的@peterbourgon
)创立的,用于构建具有微服务架构的应用程序的分布式编程工具包,目前由一群 Gophers 在开源社区中维护。它旨在解决构建此类系统时许多基础(有时可能有些枯燥)方面的问题,同时鼓励良好的设计模式,让您能够专注于构成您产品或服务的业务逻辑。
Go kit 并不试图从头解决每个问题;相反,它集成了许多流行的相关服务来解决SOA(面向服务的架构)问题,例如服务发现、度量、监控、日志记录、负载均衡、断路器以及许多其他正确运行大规模微服务的重要方面。当我们使用 Go kit 手动构建服务时,您会注意到我们将编写大量的模板或框架代码,以便使一切正常工作。
对于小型产品和服务,以及小型开发团队,您可能会决定直接暴露一个简单的 JSON 端点更容易,但 Go kit 在大型团队中表现尤为出色,用于构建具有许多不同服务的大量系统,每个服务在架构中运行数十或数百次。具有一致的日志记录、仪表化、分布式跟踪,并且每个组件都与下一个相似,这意味着运行和维护此类系统变得显著更容易。
“Go kit 的最终目的是在服务内部鼓励良好的设计实践:SOLID 设计、领域驱动设计或六边形架构等。它并不是教条地遵循任何一种,而是试图使良好的设计/软件工程变得可行。” ——Peter Bourgon
在本章中,我们将构建一些解决各种安全挑战的微服务(在一个名为vault
的项目中)——在此基础上,我们可以构建更多的功能。业务逻辑将保持非常简单,这样我们就可以专注于学习构建微服务系统的原则。
注意
作为技术选择,有一些 Go kit 的替代方案;它们大多数有类似的方法,但优先级、语法和模式不同。在开始项目之前,请确保您已经考虑了其他选项,但本章中学到的原则将适用于所有情况。
具体来说,在本章中,您将学习:
-
如何使用 Go kit 手动编码一个微服务
-
gRPC 是什么以及如何使用它来构建服务器和客户端
-
如何使用 Google 的协议缓冲区和相关工具以高效二进制格式描述服务和进行通信
-
Go kit 中的端点如何允许我们编写单个服务实现,并通过多种传输协议将其公开
-
Go kit 包含的子包如何帮助我们解决许多常见问题
-
中间件如何让我们在不触及实现本身的情况下包装端点以适应其行为
-
如何将方法调用描述为请求和响应消息
-
如何对我们的服务进行速率限制以保护免受流量激增的影响
-
一些其他的 Go 语言惯用技巧和技巧
本章中的一些代码行跨越了许多行;它们是以溢出的内容在下一行右对齐的方式编写的,如下例所示:
func veryLongFunctionWithLotsOfArguments(one string, two int, three
http.Handler, four string) (bool, error) {
log.Println("first line of the function")
}
前面的代码片段中的前三行应该写在一行中。不用担心;Go 编译器会足够友好地指出您是否出错。
介绍 gRPC
当涉及到我们的服务如何相互通信以及客户端如何与服务通信时,有许多选项,Go kit 并不关心(更确切地说,它并不介意——它足够关心,以至于提供了许多流行机制的实现)。实际上,我们能够为我们的用户提供多个选项,并让他们决定他们想要使用哪一个。我们将添加对熟悉的 JSON over HTTP 的支持,但我们也将引入一个新的 API 技术选择。
gRPC,即 Google 的远程过程调用,是一个开源机制,用于通过网络调用远程运行的代码。它使用 HTTP/2 进行传输,并使用协议缓冲区来表示构成服务和消息的数据。
RPC 服务与 RESTful 网络服务不同,因为您不是使用定义良好的 HTTP 标准来更改数据(就像您使用 REST 那样——使用POST
创建某物,使用PUT
更新某物,使用DELETE
删除某物等),而是触发一个远程函数或方法,传递预期的参数,并得到一个或多个数据响应。
为了突出差异,想象一下我们正在创建一个新用户。在一个 RESTful 的世界里,我们可以发出如下请求:
POST /users
{
"name": "Mat",
"twitter": "@matryer"
}
我们可能会得到如下响应:
201 Created
{
"id": 1,
"name": "Mat",
"twitter": "@matryer"
}
RESTful 调用表示对资源状态的查询或更改。在 RPC 世界中,我们会使用生成的代码来代替,以便进行二进制序列化的过程调用,这些调用在 Go 中感觉更像正常的方法或函数。
与 RESTful 服务和 gPRC 服务之间唯一的另一个关键区别是,gPRC 而不是 JSON 或 XML,使用一种称为协议缓冲区的特殊格式。
协议缓冲区
协议缓冲区(在代码中称为protobuf
)是一种非常小且编码和解码非常快的二进制序列化格式。您使用声明性迷你语言以抽象方式描述数据结构,并生成源代码(多种语言),以便用户轻松读写数据。
你可以把协议缓冲区看作是 XML 的现代替代品,只不过数据结构的定义与内容分开,而内容是以二进制格式而不是文本格式。
当你查看真实示例时,可以清楚地看到其好处。如果我们想在 XML 中表示一个有名字的人,我们可以这样写:
<person>
<name>MAT</name>
</person>
这大约占用 30 个字节(不包括空白)。让我们看看它在 JSON 中的样子:
{"name":"MAT"}
现在我们已经缩减到 14 个字节,但结构仍然内嵌在内容中(名称字段与值一起展开)。
在协议缓冲区中,等效内容只需 5 个字节。以下表格显示了每个字节,以及 XML 和 JSON 表示的前五个字节,以供比较。描述行解释了内容行中字节的含义:
字节 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
内容 | 0a | 03 | 4d | 61 | 72 |
描述 | 类型(字符串) | 长度(3) | M | A | T |
XML | < | p | e | r | s |
JSON | { | " | n | a | m |
结构定义存在于一个特殊的.proto
文件中,与数据分开。
仍然有许多情况下,XML 或 JSON 比协议缓冲区更适合,而在决定使用的数据格式时,文件大小并不是唯一的衡量标准,但对于固定模式结构和远程过程调用,或者对于真正大规模运行的应用程序,它是一个因合理原因而流行的选择。
安装协议缓冲区
有一些工具可以编译并生成协议缓冲区的源代码,您可以从项目的 GitHub 主页github.com/google/protobuf/releases
获取。一旦下载了文件,解压它并将 bin 文件夹中的protoc
文件放置在您的机器上的一个适当文件夹中:一个在您的$PATH
环境变量中提到的文件夹。
一旦 protoc 命令就绪,我们需要添加一个插件,这将允许我们使用 Go 代码。在终端中执行以下命令:
go get -u github.com/golang/protobuf/{proto,protoc-gen-go}
这将安装两个我们将要使用的包。
协议缓冲区语言
为了定义我们的数据结构,我们将使用协议缓冲区语言的第三个版本,称为proto3
。
在你的$GOPATH
中创建一个名为vault
的新文件夹,并在其中创建一个名为pb
的子文件夹。pb
包将存放我们的协议缓冲区定义和生成的源代码。
我们将定义一个名为Vault
的服务,它有两个方法,Hash
和Validate
:
方法 | 描述 |
---|---|
Hash |
为给定的密码生成一个安全的哈希值。可以存储哈希值而不是存储明文密码。 |
Validate |
给定一个密码和之前生成的哈希值,Validate 方法将检查密码是否正确。 |
每个服务调用都有一个请求和响应对,我们也将定义这些。在pb
中,将以下代码插入一个名为vault.proto
的新文件中:
syntax = "proto3";
package pb;
service Vault {
rpc Hash(HashRequest) returns (HashResponse) {}
rpc Validate(ValidateRequest) returns (ValidateResponse) {}
}
message HashRequest {
string password = 1;
}
message HashResponse {
string hash = 1;
string err = 2;
}
message ValidateRequest {
string password = 1;
string hash = 2;
}
message ValidateResponse {
bool valid = 1;
}
提示
为了节省纸张,已经删除了垂直空白,但如果你认为在各个块之间添加空格可以提高可读性,你可以这样做。
我们在文件中首先指定的是使用proto3
语法,以及生成源代码的包名为pb
。
service
块定义了Vault
以及在其下方定义的两个方法——HashRequest
、HashResponse
、ValidateRequest
和ValidateResponse
消息。服务块中以rpc
开头的行表示我们的服务由两个远程过程调用组成:Hash
和Validate
。
消息内部字段采用以下格式:
type name = position;
type
是一个描述标量值类型的字符串,例如string
、bool
、double
、float
、int32
、int64
等。name
是一个人类可读的字符串,用于描述字段,例如hash
和password
。位置是一个整数,表示该字段在数据流中的位置。这很重要,因为内容是字节流,将内容与定义对齐对于能够使用该格式至关重要。此外,如果我们稍后添加(甚至重命名)字段(协议缓冲区的一个关键设计特性),我们可以在不破坏期望以特定顺序包含某些字段的组件的情况下这样做;它们将继续无改动地工作,忽略新数据,并透明地传递它。
提示
有关支持类型的完整列表以及对该语言的深入探讨,请查看developers.google.com/protocol-buffers/docs/proto3
上的文档。
注意,每个方法调用都有一个相关的请求和响应对。这些是当远程方法被调用时通过网络发送的消息。
由于哈希方法只接受一个密码字符串参数,因此HashRequest
对象包含一个密码字符串字段。像正常的 Go 函数一样,响应可能包含一个错误,这就是为什么HashResponse
和ValidateResponse
都有两个字段。在 proto3 中,没有像 Go 中那样的专用error
接口,所以我们打算将错误转换为字符串。
生成 Go 代码
Go 无法理解 proto3 代码,但幸运的是,我们之前安装的协议缓冲编译器和 Go 插件可以将它翻译成 Go 可以理解的东西:Go 代码。
在终端中,导航到pb
文件夹,并运行以下命令:
protoc vault.proto --go_out=plugins=grpc:.
这将生成一个名为vault.pb.go
的新文件。打开该文件并检查其内容。它为我们做了很多工作,包括定义消息,甚至为我们创建了VaultClient
和VaultServer
类型,这将允许我们分别消费和公开服务。
提示
如果你对生成的其余代码(文件描述符看起来特别有趣)感兴趣,你可以自由地解码。现在,我们将相信它工作正常,并使用pb
包来构建我们的服务实现。
构建服务
最后,无论我们的架构中正在进行什么其他黑暗魔法,它最终都会归结为调用某个 Go 方法,执行一些工作,并返回一个结果。所以接下来我们要做的是定义和实现 Vault 服务本身。
在vault
文件夹内,向一个新创建的service.go
文件中添加以下代码:
// Service provides password hashing capabilities.
type Service interface {
Hash(ctx context.Context, password string) (string,
error)
Validate(ctx context.Context, password, hash string)
(bool, error)
}
此接口定义了服务。
提示
你可能会认为VaultService
比仅仅Service
更好,但请记住,由于这是一个 Go 包,它将在外部被视为vault.Service
,这听起来很顺耳。
我们定义了两个方法:Hash
和Validate
。每个方法都将context.Context
作为第一个参数,然后是正常的string
参数。响应也是正常的 Go 类型:string
、bool
和error
。
提示
一些库可能仍然需要旧的上下文依赖项,即golang.org/x/net/context
,而不是 Go 1.7 首次提供的context
包。注意错误关于混合使用的问题,并确保你导入的是正确的。
设计微服务的一部分是注意状态存储的位置。即使你将在单个文件中实现服务的各种方法,并且可以访问全局变量,你也绝不应该使用它们来存储每个请求或甚至每个服务的状态。重要的是要记住,每个服务可能会在多个物理机器上多次运行,每个机器都无法访问其他机器的全局变量。
在这个精神下,我们将使用一个空的struct
来实现我们的服务,这实际上是一个整洁的 Go 惯用技巧,可以将方法组合在一起,以便在不存储对象本身中的任何状态的情况下实现接口。向service.go
添加以下struct
:
type vaultService struct{}
提示
如果实现确实需要任何依赖项(例如数据库连接或配置对象),你可以将它们存储在结构体中,并在函数体中使用方法接收器。
从测试开始
在可能的情况下,首先编写测试代码有许多优点,通常最终会提高代码的质量和可维护性。我们将编写一个单元测试,该测试将使用我们新的服务来散列并验证密码。
创建一个名为service_test.go
的新文件,并添加以下代码:
package vault
import (
"testing"
"golang.org/x/net/context"
)
func TestHasherService(t *testing.T) {
srv := NewService()
ctx := context.Background()
h, err := srv.Hash(ctx, "password")
if err != nil {
t.Errorf("Hash: %s", err)
}
ok, err := srv.Validate(ctx, "password", h)
if err != nil {
t.Errorf("Valid: %s", err)
}
if !ok {
t.Error("expected true from Valid")
}
ok, err = srv.Validate(ctx, "wrong password", h)
if err != nil {
t.Errorf("Valid: %s", err)
}
if ok {
t.Error("expected false from Valid")
}
}
我们将通过NewService
方法创建一个新的服务,然后使用它来调用Hash
和Validate
方法。我们甚至测试了一个不愉快的案例,即我们输入了错误的密码,并确保Validate
返回false
——否则,它将非常不安全。
Go 语言中的构造函数
在其他面向对象的语言中,构造函数是一种特殊的函数,用于创建类的实例。它执行任何初始化并接受所需的参数,例如依赖项等。在这些语言中,通常只有一种创建对象的方式,但它往往具有奇怪的语法或依赖于命名约定(例如,函数名称与类名相同)。
Go 语言没有构造函数;它更简单,只有函数,并且由于函数可以返回参数,构造函数将只是一个全局函数,它返回一个可用的结构体实例。Go 语言的简单哲学驱使语言设计者做出这类决策;而不是强迫人们学习关于构建对象的新概念,开发者只需要学习函数的工作方式,他们就可以使用函数构建构造函数。
即使我们在构建一个对象的过程中没有进行任何特殊的工作(例如初始化字段、验证依赖关系等),有时添加一个构建函数也是值得的。在我们的情况下,我们不想通过暴露vaultService
类型来膨胀 API,因为我们已经暴露了我们的Service
接口类型,并且将其隐藏在构造函数中是一种实现这一点的不错方式。
在vaultService
结构定义下方,添加NewService
函数:
// NewService makes a new Service.
func NewService() Service {
return vaultService{}
}
这不仅阻止了我们暴露内部结构,而且如果将来我们需要对vaultService
进行更多工作以准备其使用,我们也可以在不更改 API 的情况下完成,因此不需要我们的包的用户在他们的端进行任何更改,这对于 API 设计来说是一个巨大的胜利。
使用 bcrypt 散列和验证密码
我们将在服务中实现的第一个方法是Hash
。它将接受一个密码并生成一个散列。然后可以将生成的散列(以及密码)传递给稍后要调用的Validate
方法,该方法将确认或否认密码是否正确。
小贴士
要了解更多关于在应用程序中正确存储密码的方法,请查看 Coda Hale 关于该主题的博客文章,链接为codahale.com/how-to-safely-store-a-password/
。
我们服务的目的是确保密码永远不会需要存储在数据库中,因为如果有人能够未经授权访问数据库,那将是一个安全风险。相反,您可以生成一个单向哈希(无法解码),它可以安全地存储,并且当用户尝试进行身份验证时,您可以执行检查以查看密码是否生成相同的哈希。如果哈希匹配,则密码相同;否则,它们不相同。
bcrypt
包提供了以安全可靠的方式为我们完成这项工作的方法。
向 service.go
添加 Hash
方法:
func (vaultService) Hash(ctx context.Context, password
string) (string, error) {
hash, err :=
bcrypt.GenerateFromPassword([]byte(password),
bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
确保您导入适当的 bcrypt
包(尝试 golang.org/x/crypto/bcrypt
)。我们本质上是在包装 GenerateFromPassword
函数以生成哈希,然后在没有错误发生的情况下返回它。
注意,Hash
方法中的接收器只是 (vaultService)
;我们没有捕获变量,因为我们无法在空 struct
上存储状态。
接下来,让我们添加 Validate
方法:
func (vaultService) Validate(ctx context.Context,
password, hash string) (bool, error) {
err := bcrypt.CompareHashAndPassword([]byte(hash),
[]byte(password))
if err != nil {
return false, nil
}
return true, nil
}
与 Hash
类似,我们正在调用 bcrypt.CompareHashAndPassword
以安全方式确定密码是否正确。如果返回错误,则表示有问题,我们返回 false
表示。否则,当密码有效时,我们返回 true
。
使用请求和响应模拟方法调用
由于我们的服务将通过各种传输协议公开,我们需要一种方式来模拟服务内外部的请求和响应。我们将通过为服务将接受或返回的每种消息类型添加一个 struct
来实现这一点。
为了让某人能够调用 Hash
方法并接收哈希密码作为响应,我们需要将以下两个结构添加到 service.go
:
type hashRequest struct {
Password string `json:"password"`
}
type hashResponse struct {
Hash string `json:"hash"`
Err string `json:"err,omitempty"`
}
hashRequest
类型包含一个字段,即密码,而 hashResponse
包含生成的哈希以及一个 Err
字符串字段,以防出现错误。
小贴士
要模拟远程方法调用,您实际上是为传入参数创建一个 struct
,并为返回参数创建一个 struct
。
在继续之前,看看您是否可以为 Validate
方法模拟相同的请求/响应对。查看 Service
接口中的签名,检查它接受的参数,并考虑它需要做出什么样的响应。
我们将添加一个辅助方法(类型为 Go kit 的 http.DecodeRequestFunc
),它将能够将 http.Request
的 JSON 主体解码到 service.go
:
func decodeHashRequest(ctx context.Context, r
*http.Request) (interface{}, error) {
var req hashRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return nil, err
}
return req, nil
}
decodeHashRequest
的签名由 Go kit 决定,因为它将稍后代表我们解码 HTTP 请求。在这个函数中,我们只是使用 json.Decoder
将 JSON 解码到我们的 hashRequest
类型中。
接下来,我们将为 Validate
方法添加请求和响应结构以及一个解码辅助函数:
type validateRequest struct {
Password string `json:"password"`
Hash string `json:"hash"`
}
type validateResponse struct {
Valid bool `json:"valid"`
Err string `json:"err,omitempty"`
}
func decodeValidateRequest(ctx context.Context,
r *http.Request) (interface{}, error) {
var req validateRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
return nil, err
}
return req, nil
}
在这里,validateRequest
结构体同时接受 Password
和 Hash
字符串,因为签名有两个输入参数,并返回一个包含名为 Valid
或 Err
的 bool
数据类型的响应。
我们需要做的最后一件事是编码响应。在这种情况下,我们可以编写一个单独的方法来编码 hashResponse
和 validateResponse
对象。
将以下代码添加到 service.go
中:
func encodeResponse(ctx context.Context,
w http.ResponseWriter, response interface{})
error {
return json.NewEncoder(w).Encode(response)
}
我们的 encodeResponse
方法只是让 json.Encoder
帮我们完成工作。再次注意,签名是通用的,因为 response
类型是 interface{}
;这是因为它是 Go kit 用于解码到 http.ResponseWriter
的机制。
Go kit 中的端点
端点是 Go kit 中的一个特殊函数类型,它代表单个 RPC 方法。定义在 endpoint
包中:
type Endpoint func(ctx context.Context, request
interface{})
(response interface{}, err error)
端点函数接受 context.Context
和 request
,并返回 response
或 error
。request
和 response
类型是 interface{}
,这告诉我们,在构建端点时,处理实际类型的责任在于实现代码。
端点很强大,因为,就像 http.Handler
(和 http.HandlerFunc
)一样,你可以用通用中间件包装它们,以解决在构建微服务时出现的各种常见问题:日志记录、跟踪、速率限制、错误处理等等。
Go kit 解决了在多种协议上传输的问题,并使用端点作为从它们的代码跳转到我们的代码的通用方式。例如,gRPC 服务器将在端口上监听,并在接收到适当的消息时调用相应的 Endpoint
函数。多亏了 Go kit,这一切对我们来说都是透明的,因为我们只需要用 Go 代码处理我们的 Service
接口。
为服务方法创建端点
为了将我们的服务方法转换为 endpoint.Endpoint
函数,我们将编写一个处理传入的 hashRequest
、调用 Hash
服务方法,并根据响应构建和返回适当的 hashResponse
对象的函数。
将 MakeHashEndpoint
函数添加到 service.go
中:
func MakeHashEndpoint(srv Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{})
(interface{}, error) {
req := request.(hashRequest)
v, err := srv.Hash(ctx, req.Password)
if err != nil {
return hashResponse{v, err.Error()}, nil
}
return hashResponse{v, ""}, nil
}
}
这个函数接受 Service
作为参数,这意味着我们可以从我们的 Service
接口的任何实现中生成端点。然后我们使用类型断言来指定请求参数实际上应该是 hashRequest
类型。我们调用 Hash
方法,传入上下文和 Password
,这些是从 hashRequest
获取的。如果一切顺利,我们使用从 Hash
方法返回的值构建 hashResponse
并返回它。
让我们对 Validate
方法也做同样的事情:
func MakeValidateEndpoint(srv Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{})
(interface{}, error) {
req := request.(validateRequest)
v, err := srv.Validate(ctx, req.Password, req.Hash)
if err != nil {
return validateResponse{false, err.Error()}, nil
}
return validateResponse{v, ""}, nil
}
}
这里,我们做的是同样的事情:获取请求并使用它来调用方法,然后再构建响应。请注意,我们从不会从 Endpoint
函数返回错误。
不同的错误级别
在 Go kit 中,主要有两种错误类型:传输错误(网络故障、超时、断开连接等)和业务逻辑错误(请求和响应的基础设施执行成功,但逻辑或数据中存在问题)。
如果Hash
方法返回错误,我们不会将其作为第二个参数返回;相反,我们将构建hashResponse
,其中包含错误字符串(可通过Error
方法访问)。这是因为从端点返回的错误旨在指示传输错误,也许 Go kit 将通过某些中间件配置为重试调用几次。如果我们的服务方法返回错误,则被视为业务逻辑错误,并且对于相同的输入可能会始终返回相同的错误,因此不值得重试。这就是为什么我们将错误包装到响应中,并将其返回给客户端,以便他们可以处理它。
将端点包装到服务实现中
在 Go kit 中处理端点时,另一个非常有用的技巧是编写我们vault.Service
接口的实现,它只是对底层端点进行必要的调用。
将以下结构体添加到service.go
中:
type Endpoints struct {
HashEndpoint endpoint.Endpoint
ValidateEndpoint endpoint.Endpoint
}
为了实现vault.Service
接口,我们将在我们的Endpoints
结构体中添加两个方法,这些方法将构建一个请求对象,发送请求,并将生成的响应对象解析为要返回的正常参数。
添加以下Hash
方法:
func (e Endpoints) Hash(ctx context.Context, password
string) (string, error) {
req := hashRequest{Password: password}
resp, err := e.HashEndpoint(ctx, req)
if err != nil {
return "", err
}
hashResp := resp.(hashResponse)
if hashResp.Err != "" {
return "", errors.New(hashResp.Err)
}
return hashResp.Hash, nil
}
我们使用hashRequest
调用HashEndpoint
,我们使用密码参数在将一般响应缓存到hashResponse
并从中返回哈希值或错误之前创建它。
我们将对Validate
方法做同样的事情:
func (e Endpoints) Validate(ctx context.Context, password,
hash string) (bool, error) {
req := validateRequest{Password: password, Hash: hash}
resp, err := e.ValidateEndpoint(ctx, req)
if err != nil {
return false, err
}
validateResp := resp.(validateResponse)
if validateResp.Err != "" {
return false, errors.New(validateResp.Err)
}
return validateResp.Valid, nil
}
这两个方法将使我们能够将我们创建的端点视为正常的 Go 方法;这对于我们在本章后面实际消费服务时非常有用。
Go kit 中的 HTTP 服务器
当我们为我们的端点创建一个 HTTP 服务器以进行哈希和验证时,Go kit 的真实价值才显现出来。
创建一个名为server_http.go
的新文件,并添加以下代码:
package vault
import (
"net/http"
httptransport "github.com/go-kit/kit/transport/http"
"golang.org/x/net/context"
)
func NewHTTPServer(ctx context.Context, endpoints
Endpoints) http.Handler {
m := http.NewServeMux()
m.Handle("/hash", httptransport.NewServer(
ctx,
endpoints.HashEndpoint,
decodeHashRequest,
encodeResponse,
))
m.Handle("/validate", httptransport.NewServer(
ctx,
endpoints.ValidateEndpoint,
decodeValidateRequest,
encodeResponse,
))
return m
}
我们正在导入github.com/go-kit/kit/transport/http
包,并且(由于我们还在导入net/http
包)告诉 Go 我们将显式地引用此包为httptransport
。
我们正在使用标准库中的 NewServeMux
函数来构建 http.Handler
接口,并进行简单的路由,将 /hash
和 /validate
路径映射。我们获取 Endpoints
对象,因为我们想让我们的 HTTP 服务器提供这些端点,包括我们稍后可能添加的任何中间件。调用 httptransport.NewServer
是让 Go kit 为每个端点提供 HTTP 处理器的方法。像大多数函数一样,我们传入 context.Context
作为第一个参数,这将形成每个请求的基本上下文。我们还传入端点以及我们之前编写的解码和编码函数,以便服务器知道如何反序列化和序列化 JSON 消息。
Go kit 中的 gRPC 服务器
使用 Go kit 添加 gRPC 服务器几乎和添加 JSON/HTTP 服务器一样简单,就像我们在上一节中所做的那样。在我们的生成代码(在 pb
文件夹中),我们得到了以下 pb.VaultServer
类型:
type VaultServer interface {
Hash(context.Context, *HashRequest)
(*HashResponse, error)
Validate(context.Context, *ValidateRequest)
(*ValidateResponse, error)
}
这个类型与我们自己的 Service
接口非常相似,只是它接受生成请求和响应类而不是原始参数。
我们将首先定义一个将实现前面接口的类型。将以下代码添加到一个名为 server_grpc.go
的新文件中:
package vault
import (
"golang.org/x/net/context"
grpctransport "github.com/go-kit/kit/transport/grpc"
)
type grpcServer struct {
hash grpctransport.Handler
validate grpctransport.Handler
}
func (s *grpcServer) Hash(ctx context.Context,
r *pb.HashRequest) (*pb.HashResponse, error) {
_, resp, err := s.hash.ServeGRPC(ctx, r)
if err != nil {
return nil, err
}
return resp.(*pb.HashResponse), nil
}
func (s *grpcServer) Validate(ctx context.Context,
r *pb.ValidateRequest) (*pb.ValidateResponse, error) {
_, resp, err := s.validate.ServeGRPC(ctx, r)
if err != nil {
return nil, err
}
return resp.(*pb.ValidateResponse), nil
}
注意,你需要导入 github.com/go-kit/kit/transport/grpc
作为 grpctransport
,以及生成的 pb
包。
grpcServer
结构体包含每个服务端点的字段,这次是 grpctransport.Handler
类型。然后,我们实现接口的方法,在适当的处理器上调用 ServeGRPC
方法。这个方法实际上会通过首先解码请求,调用适当的端点函数,获取响应,然后编码并发送回请求客户端来处理请求。
从协议缓冲类型转换为我们的类型
你会注意到我们正在使用 pb
包中的请求和响应对象,但请记住,我们自己的端点使用我们在 service.go
中早期添加的结构。我们将需要一个针对每种类型的方法来将其转换为我们的类型。
小贴士
接下来会有很多重复的输入;如果你愿意,可以从 GitHub 仓库 github.com/matryer/goblueprints
复制粘贴以节省你的手指。我们正在手动编码,因为这很重要,要理解构成服务的所有部分。
在 server_grpc.go
中添加以下函数:
func EncodeGRPCHashRequest(ctx context.Context,
r interface{}) (interface{}, error) {
req := r.(hashRequest)
return &pb.HashRequest{Password: req.Password}, nil
}
这个函数是 Go kit 定义的 EncodeRequestFunc
函数,它用于将我们的 hashRequest
类型转换为可以与客户端通信的协议缓冲类型。它使用 interface{}
类型,因为它很通用,但在这个案例中,我们可以确信类型,因此我们将传入的请求转换为 hashRequest
(我们的类型)然后使用适当的字段构建一个新的 pb.HashRequest
对象。
我们将为此进行编码和解码请求和响应,包括 hash 和 validate 端点的编码和解码。将以下代码添加到server_grpc.go
中:
func DecodeGRPCHashRequest(ctx context.Context,
r interface{}) (interface{}, error) {
req := r.(*pb.HashRequest)
return hashRequest{Password: req.Password}, nil
}
func EncodeGRPCHashResponse(ctx context.Context,
r interface{}) (interface{}, error) {
res := r.(hashResponse)
return &pb.HashResponse{Hash: res.Hash, Err: res.Err},
nil
}
func DecodeGRPCHashResponse(ctx context.Context,
r interface{}) (interface{}, error) {
res := r.(*pb.HashResponse)
return hashResponse{Hash: res.Hash, Err: res.Err}, nil
}
func EncodeGRPCValidateRequest(ctx context.Context,
r interface{}) (interface{}, error) {
req := r.(validateRequest)
return &pb.ValidateRequest{Password: req.Password,
Hash: req.Hash}, nil
}
func DecodeGRPCValidateRequest(ctx context.Context,
r interface{}) (interface{}, error) {
req := r.(*pb.ValidateRequest)
return validateRequest{Password: req.Password,
Hash: req.Hash}, nil
}
func EncodeGRPCValidateResponse(ctx context.Context,
r interface{}) (interface{}, error) {
res := r.(validateResponse)
return &pb.ValidateResponse{Valid: res.Valid}, nil
}
func DecodeGRPCValidateResponse(ctx context.Context,
r interface{}) (interface{}, error) {
res := r.(*pb.ValidateResponse)
return validateResponse{Valid: res.Valid}, nil
}
如您所见,为了使事物正常工作,需要进行大量的模板代码编写。
小贴士
代码生成(此处未涉及)在这里会有很大的应用,因为代码非常可预测且具有自我相似性。
为了使我们的 gRPC 服务器正常工作,最后要做的事情是提供一个辅助函数来创建我们的grpcServer
结构体实例。在grpcServer
结构体下面,添加以下代码:
func NewGRPCServer(ctx context.Context, endpoints
Endpoints) pb.VaultServer {
return &grpcServer{
hash: grpctransport.NewServer(
ctx,
endpoints.HashEndpoint,
DecodeGRPCHashRequest,
EncodeGRPCHashResponse,
),
validate: grpctransport.NewServer(
ctx,
endpoints.ValidateEndpoint,
DecodeGRPCValidateRequest,
EncodeGRPCValidateResponse,
),
}
}
就像我们的 HTTP 服务器一样,我们接收一个基本上下文和通过 gRPC 服务器公开的实际Endpoints
实现。我们创建并返回一个新的grpcServer
类型实例,通过调用grpctransport.NewServer
来设置hash
和validate
的处理程序。我们使用我们的endpoint.Endpoint
函数来处理服务,并告诉服务使用我们哪些编码/解码函数来处理每种情况。
创建服务器命令
到目前为止,我们所有的服务代码都位于vault
包内部。我们现在将使用这个包来创建一个新的工具,以暴露服务器功能。
在vault
中创建一个新的文件夹名为cmd
,并在其中创建另一个名为vaultd
的文件夹。我们将把命令代码放在vaultd
文件夹中,因为尽管代码将在main
包中,但工具的名称默认为vaultd
。如果我们只是将命令放在cmd
文件夹中,工具将被构建成一个名为cmd
的二进制文件,这会很令人困惑。
注意
在 Go 项目中,如果包的主要用途是导入到其他程序中(如 Go kit),则根级文件应组成包,并将具有适当的包名(不是main
)。如果主要目的是命令行工具,如 Drop 命令(github.com/matryer/drop
),则根文件将在main
包中。
这种做法的合理性在于可用性;当导入一个包时,您希望用户必须输入的字符串尽可能短。同样,当使用go install
时,您希望路径既短又简洁。
我们将要构建的工具(后缀为d
,表示它是守护程序或后台任务)将启动我们的 gRPC 和 JSON/HTTP 服务器。每个服务器将在自己的 goroutine 中运行,我们将捕获来自服务器的任何终止信号或错误,这将导致我们的程序终止。
在 Go kit 中,主函数最终会变得相当大,这是有意为之;有一个函数包含您整个微服务的全部内容;从那里,您可以深入了解细节,但它提供了每个组件的直观视图。
我们将在vaultd
文件夹中的新main.go
文件中逐步构建main
函数,首先是一个相当大的导入列表:
import (
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"your/path/to/vault"
"your/path/to/vault/pb"
"golang.org/x/net/context"
"google.golang.org/grpc"
)
应将 your/path/to
前缀替换为从 $GOPATH
到你的项目的实际路由。请注意上下文导入;在 Go kit 转移到 Go 1.7 的时候,你可能只需要输入 context 而不是这里列出的导入。最后,Google 的 grpc
包为我们提供了在网络上公开 gRPC 功能所需的一切。
现在,我们将组合我们的 main
函数;记住,从这一部分开始的全部内容都放在 main
函数体内:
func main() {
var (
httpAddr = flag.String("http", ":8080",
"http listen address")
gRPCAddr = flag.String("grpc", ":8081",
"gRPC listen address")
)
flag.Parse()
ctx := context.Background()
srv := vault.NewService()
errChan := make(chan error)
我们使用标志来允许操作团队决定在网络上公开服务时将监听哪些端点,但为 JSON/HTTP 服务器提供合理的默认值 :8080
,为 gRPC 服务器提供 :8081
。
然后,我们使用 context.Background()
函数创建一个新的上下文,该函数返回一个非空、空的上下文,没有指定取消或截止日期,也不包含任何值,非常适合我们所有服务的基上下文。请求和中间件可以自由地从该上下文中创建新的上下文对象,以便添加请求范围的数据或截止日期。
接下来,我们使用 NewService
构造函数为我们创建一个新的 Service
类型,并创建一个零缓冲通道,该通道可以接收错误(如果发生错误)。
我们现在将添加代码来捕获终止信号(如 Ctrl + C)并将错误发送到 errChan
:
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
errChan <- fmt.Errorf("%s", <-c)
}()
在这里,在一个新的 goroutine 中,我们要求 signal.Notify
通知我们何时接收到 SIGINT
或 SIGTERM
信号。当这种情况发生时,信号将通过 c
通道发送,此时我们将它格式化为字符串(调用其 String()
方法),并将其转换为错误,然后将其发送到 errChan
,从而导致程序终止。
使用 Go kit 端点
是时候创建一个我们可以传递给服务器的端点实例了。将以下代码添加到主函数体中:
hashEndpoint := vault.MakeHashEndpoint(srv)
validateEndpoint := vault.MakeValidateEndpoint(srv)
endpoints := vault.Endpoints{
HashEndpoint: hashEndpoint,
ValidateEndpoint: validateEndpoint,
}
我们将字段分配给端点辅助函数的输出,对于哈希和验证方法都是如此。我们为两者传递相同的服务,因此 endpoints
变量实际上是我们 srv
服务的包装器。
小贴士
你可能会想通过完全删除变量的赋值来整理这段代码,直接将辅助函数的返回值设置到结构体初始化的字段中,但当我们稍后添加中间件时,你会感谢这种方法的。
我们现在可以使用这些端点来启动我们的 JSON/HTTP 和 gRPC 服务器。
运行 HTTP 服务器
现在,我们将添加一个 goroutine 到主函数体中,用于创建和运行 JSON/HTTP 服务器:
// HTTP transport
go func() {
log.Println("http:", *httpAddr)
handler := vault.NewHTTPServer(ctx, endpoints)
errChan <- http.ListenAndServe(*httpAddr, handler)
}()
Go kit 在我们的包代码中已经为我们完成了所有繁重的工作,所以我们只需调用 NewHTTPServer
函数,传递背景上下文和希望公开的服务端点,然后在调用标准库的 http.ListenAndServe
之前,该函数在指定的 httpAddr
中公开处理器功能。如果发生错误,我们将它发送到错误通道。
运行 gRPC 服务器
为了运行 gRPC 服务器,还需要做一些额外的工作,但仍然相当简单。我们必须创建一个低级别的 TCP 网络监听器,并在其上提供 gRPC 服务器。将以下代码添加到主函数主体中:
go func() {
listener, err := net.Listen("tcp", *gRPCAddr)
if err != nil {
errChan <- err
return
}
log.Println("grpc:", *gRPCAddr)
handler := vault.NewGRPCServer(ctx, endpoints)
gRPCServer := grpc.NewServer()
pb.RegisterVaultServer(gRPCServer, handler)
errChan <- gRPCServer.Serve(listener)
}()
我们在指定的 gRPCAddr
端点创建 TCP 监听器,并将任何错误发送到 errChan
错误通道。我们使用 vault.NewGRPCServer
创建处理器,再次传递背景上下文和我们公开的 Endpoints
实例。
提示
注意到 JSON/HTTP 服务器和 gRPC 服务器实际上公开的是相同的服务——字面上是同一个实例。
然后,我们使用 Google 的 grpc
包创建一个新的 gRPC 服务器,并通过 RegisterVaultServer
函数使用我们自己的生成的 pb
包进行注册。
注意
RegisterVaultService
函数只是在我们自己的 grpcServer
上调用 RegisterService
,但隐藏了自动生成的服务描述的内部细节。如果你查看 vault.pb.go
并搜索 RegisterVaultServer
函数,你会看到它引用了类似 &_Vault_serviceDesc
的内容,这是服务的描述。你可以随意挖掘生成的代码;元数据特别有趣,但本书不涉及这部分内容。
然后,我们要求服务器自己 Serve
,如果发生错误,将错误信息发送到同一个错误通道。
提示
这章不涉及,但建议每个服务都应提供传输层安全性(TLS),特别是处理密码的服务。
防止主函数立即终止
如果我们在这里关闭了主函数,它将立即退出并终止所有服务器。这是因为我们正在做的所有防止这种情况发生的事情都在它自己的 goroutine 中。为了防止这种情况,我们需要一种方法在函数末尾阻塞,等待程序收到终止信号。
由于我们使用 errChan
错误通道来处理错误,这是一个完美的候选者。我们可以监听这个通道,在没有任何内容发送下来时,它会阻塞并允许其他 goroutine 执行它们的工作。如果出现问题(或收到终止信号),<-errChan
调用将解除阻塞并退出,所有 goroutine 都将停止。
在主函数的底部,添加最后的语句和结束块:
log.Fatalln(<-errChan)
}
当发生错误时,我们只是记录它并以非零代码退出。
通过 HTTP 消费服务
现在我们已经连接好了一切,我们可以使用 curl
命令或任何允许我们发送 JSON/HTTP 请求的工具来测试 HTTP 服务器。
在终端中,让我们首先运行我们的服务器。转到 vault/cmd/vaultd
文件夹并启动程序:
go run main.go
服务器启动后,你将看到如下内容:
http: :8080
grpc: :8081
现在,打开另一个终端,使用 curl
发出以下 HTTP 请求:
curl -XPOST -d '{"password":"hernandez"}'
http://localhost:8080/hash
我们正在向散列端点发送一个包含我们想要散列的密码的 JSON 体的 POST 请求。然后,我们得到如下内容:
{"hash":"$2a$10$IXYT10DuK3Hu.
NZQsyNafF1tyxe5QkYZKM5by/5Ren"}
小贴士
在这个例子中的散列值不会与你的匹配——有多个可接受的散列值,而且无法知道你会得到哪一个。确保复制并粘贴你的实际散列值(双引号内的所有内容)。
生成的散列值是我们根据指定的密码存储在数据存储中的值。然后,当用户再次尝试登录时,我们将使用他们输入的密码以及这个散列值向验证端点发送请求:
curl -XPOST -d
'{"password":"hernandez",
"hash":"PASTE_YOUR_HASH_HERE"}'
http://localhost:8080/validate
通过复制和粘贴正确的散列值并输入相同的 hernandez
密码来发送此请求,你将看到以下结果:
{"valid":true}
现在,更改密码(这相当于用户输入错误)并将看到以下内容:
{"valid":false}
你可以看到,我们 vault 服务的 JSON/HTTP 微服务暴露是完整且正在工作的。
接下来,我们将探讨如何消费 gRPC 版本。
构建 gRPC 客户端
与 JSON/HTTP 服务不同,gRPC 服务并不容易供人类交互。它们实际上是作为机器到机器的协议而设计的,因此如果我们想使用它们,我们必须编写一个程序。
为了帮助我们做到这一点,我们首先将在我们的 vault 服务内部添加一个新的包,名为 vault/client/grpc
。它将提供一个对象,该对象在从 Google 的 grpc
包获取的 gRPC 客户端连接对象的基础上执行适当的调用、编码和解码,所有这些都在我们自己的 vault.Service
接口背后隐藏。因此,我们将能够将这个对象用作我们接口的另一个实现。
在 vault 中创建新的文件夹,以便你有 vault/client/grpc
的路径。如果你愿意,可以想象添加其他客户端,因此这似乎是一个建立良好模式的合适选择。
将以下代码添加到一个新的 client.go
文件中:
func New(conn *grpc.ClientConn) vault.Service {
var hashEndpoint = grpctransport.NewClient(
conn, "Vault", "Hash",
vault.EncodeGRPCHashRequest,
vault.DecodeGRPCHashResponse,
pb.HashResponse{},
).Endpoint()
var validateEndpoint = grpctransport.NewClient(
conn, "Vault", "Validate",
vault.EncodeGRPCValidateRequest,
vault.DecodeGRPCValidateResponse,
pb.ValidateResponse{},
).Endpoint()
return vault.Endpoints{
HashEndpoint: hashEndpoint,
ValidateEndpoint: validateEndpoint,
}
}
grpctransport
包引用的是 github.com/go-kit/kit/transport/grpc
。现在这可能会让你感到熟悉;我们正在根据指定的连接创建两个新的端点,这次明确指定了 Vault
服务名称和端点名称 Hash
和 Validate
。我们从前端的 vault 包中传递适当的编码器和解码器以及空响应对象,然后将它们都包装在我们的 vault.Endpoints
结构中,这是我们添加的结构——它实现了 vault.Service
接口,该接口为我们触发了指定的端点。
消费服务的命令行工具
在本节中,我们将编写一个命令行工具(或 CLI-命令行界面),它将允许我们通过 gRPC 协议与我们的服务进行通信。如果我们用 Go 编写另一个服务,我们将以与编写 CLI 工具时相同的方式使用 vault 客户端包。
我们的工具将允许你在命令行中以流畅的方式访问服务,通过用空格分隔命令和参数,这样我们就可以像这样哈希密码:
vaultcli hash MyPassword
我们将能够使用如下哈希来验证密码:
vaultcli hash MyPassword HASH_GOES_HERE
在cmd
文件夹中,创建一个名为vaultcli
的新文件夹。添加一个main.go
文件并插入以下主函数:
func main() {
var (
grpcAddr = flag.String("addr", ":8081",
"gRPC address")
)
flag.Parse()
ctx := context.Background()
conn, err := grpc.Dial(*grpcAddr, grpc.WithInsecure(),
grpc.WithTimeout(1*time.Second))
if err != nil {
log.Fatalln("gRPC dial:", err)
}
defer conn.Close()
vaultService := grpcclient.New(conn)
args := flag.Args()
var cmd string
cmd, args = pop(args)
switch cmd {
case "hash":
var password string
password, args = pop(args)
hash(ctx, vaultService, password)
case "validate":
var password, hash string
password, args = pop(args)
hash, args = pop(args)
validate(ctx, vaultService, password, hash)
default:
log.Fatalln("unknown command", cmd)
}
}
确保你将vault/client/grpc
包导入为grpcclient
,将google.golang.org/grpc
导入为grpc
。你还需要导入vault
包。
在调用 gRPC 端点以建立连接之前,我们像往常一样解析标志并获取背景上下文。如果一切顺利,我们将延迟关闭连接并使用该连接创建我们的 vault 服务客户端。记住,这个对象实现了我们的vault.Service
接口,因此我们可以像调用正常 Go 方法一样调用这些方法,而无需担心通信是通过网络协议进行的。
然后,我们开始解析命令行参数,以决定采取哪种执行流程。
在 CLI 中解析参数
在命令行工具中解析参数非常常见,在 Go 中有一个整洁的惯用方法来做这件事。所有参数都可通过os.Args
切片获得,或者如果你使用标志,则通过flags.Args()
方法(该方法获取不带标志的参数)。我们想要从切片(从开始处)移除每个参数并按顺序消费它们,这将帮助我们决定通过程序采取哪种执行流程。我们将添加一个名为pop
的辅助函数,它将返回第一个项目,并返回移除了第一个项目的切片。
我们将编写一个快速单元测试来确保我们的pop
函数按预期工作。如果你想要尝试自己编写pop
函数,那么一旦测试就绪,你应该这样做。记住,你可以通过在终端中导航到相应的文件夹并执行以下命令来运行测试:
go test
在vaultcli
内部创建一个名为main_test.go
的新文件,并添加以下测试函数:
func TestPop(t *testing.T) {
args := []string{"one", "two", "three"}
var s string
s, args = pop(args)
if s != "one" {
t.Errorf("unexpected "%s"", s)
}
s, args = pop(args)
if s != "two" {
t.Errorf("unexpected "%s"", s)
}
s, args = pop(args)
if s != "three" {
t.Errorf("unexpected "%s"", s)
}
s, args = pop(args)
if s != "" {
t.Errorf("unexpected "%s"", s)
}
}
我们期望每次调用pop
都会返回切片中的下一个项目,并且在切片为空时返回空参数。
在main.go
的底部添加pop
函数:
func pop(s []string) (string, []string) {
if len(s) == 0 {
return "", s
}
return s[0], s[1:]
}
通过提取 case 体来保持良好的视线
我们唯一剩下要做的事情是实现前面 switch 语句中提到的哈希和验证方法。
我们本可以将此代码嵌入到 switch 语句本身中,但这样会使主函数难以阅读,并且会隐藏不同缩进级别上的 happy path 执行,这是我们应尽量避免的。
相反,将 switch 语句中的情况跳转到专用函数中是一个好习惯,该函数接受它需要的任何参数。在主函数下方,添加以下哈希和验证函数:
func hash(ctx context.Context, service vault.Service,
password string) {
h, err := service.Hash(ctx, password)
if err != nil {
log.Fatalln(err.Error())
}
fmt.Println(h)
}
func validate(ctx context.Context, service vault.Service,
password, hash string) {
valid, err := service.Validate(ctx, password, hash)
if err != nil {
log.Fatalln(err.Error())
}
if !valid {
fmt.Println("invalid")
os.Exit(1)
}
fmt.Println("valid")
}
这些函数只是简单地调用服务上的相应方法,并根据结果将结果记录或打印到控制台。如果验证方法返回 false,程序将以退出代码 1 退出,因为非零值表示错误。
从 Go 源代码安装工具
要安装此工具,我们只需在终端中导航到vaultcli
文件夹,并输入以下命令:
go install
假设没有错误,该包将被构建并部署到$GOPATH/bin
文件夹,该文件夹应该已经列在你的$PATH
环境变量中。这意味着工具已经准备好像终端中的正常命令一样使用。
部署的二进制文件名称将与文件夹名称匹配,这就是为什么即使在只构建单个命令的情况下,我们也在cmd
文件夹内有一个额外的文件夹。
一旦安装了命令,我们就可以使用它来测试 gRPC 服务器。
前往cmd/vaultd
并启动服务器(如果它还没有运行),只需输入以下命令:
go run main.go
在另一个终端中,通过输入以下命令来哈希密码:
vaultcli hash blanca
注意,哈希值被返回。现在让我们验证这个哈希值:
vaultcli validate blanca PASTE_HASH_HERE
小贴士
哈希值可能包含会干扰您的终端的特殊字符,因此如果需要,您应该用引号转义字符串。
在 Mac 上,用$'PASTE_HASH_HERE'
格式化参数以正确转义它。
在 Windows 上,尝试用感叹号包围参数:!PASTE_HASH_HERE!
。
如果您输入了正确的密码,您会注意到您看到了单词valid
;否则,您会看到invalid
。
使用服务中间件进行速率限制
现在我们已经构建了一个完整的服务,我们将看到如何轻松地向我们的端点添加中间件,以扩展服务而不需要触及实际的实现。
在现实世界的服务中,限制它将尝试处理的请求数量是合理的,这样服务就不会过载。这可能发生在进程需要的内存比可用内存多的情况下,或者如果我们注意到性能下降,那么它可能消耗了太多的 CPU。在微服务架构中,解决这些问题的策略是添加另一个节点并分散负载,这意味着我们希望每个单独的实例都受到速率限制。
由于我们提供了客户端,我们应该在那里添加速率限制,这将防止过多的请求进入网络。但是,如果许多客户端同时尝试访问相同的服务,添加到服务器上的速率限制也是合理的。幸运的是,Go kit 中的端点既用于客户端也用于服务器,因此我们可以使用相同的代码在这两个地方添加中间件。
我们将添加一个基于 Token Bucket 的速率限制器,你可以在 en.wikipedia.org/wiki/Token_bucket
上了解更多信息。Juju 团队已经编写了一个 Go 实现供我们使用,通过导入 github.com/juju/ratelimit
,Go kit 也为这个实现提供了中间件,这将为我们节省大量时间和精力。
通用思路是我们有一个令牌桶,每个请求都需要一个令牌来完成其工作。如果没有令牌在桶中,我们就达到了限制,请求无法完成。桶在特定的时间间隔内填充。
导入 github.com/juju/ratelimit
并在我们创建 hashEndpoint
之前插入以下代码:
rlbucket := ratelimit.NewBucket(1*time.Second, 5)
NewBucket
函数创建一个新的速率限制桶,以每秒一个令牌的速度填充,最多五个令牌。这些数字对我们来说相当愚蠢,但我们希望在开发过程中能够手动达到我们的限制。
由于 Go kit 的 ratelimit
包与 Juju 的包同名,我们需要用不同的名称来导入它:
import ratelimitkit "github.com/go-kit/kit/ratelimit"
Go kit 中的中间件
Go kit 中的端点中间件通过 endpoint.Middleware
函数类型指定:
type Middleware func(Endpoint) Endpoint
一段中间件仅仅是一个接收 Endpoint
并返回 Endpoint
的函数。记住,Endpoint
也是一个函数:
type Endpoint func(ctx context.Context, request
interface{}) (response interface{}, err error)
这有点令人困惑,但它们与为我们 http.HandlerFunc
构建的包装器相同。一个中间件函数返回一个 Endpoint
函数,它在调用被包装的 Endpoint
之前和/或之后执行某些操作。传递给返回 Middleware
的函数的参数被闭包,这意味着它们可以通过闭包(无需在其他地方存储状态)在内部代码中使用。
我们将使用 Go kit 的 ratelimit
包中的 NewTokenBucketLimiter
中间件,如果我们查看代码,我们将看到它如何使用闭包并返回函数来在传递执行给 next
端点之前调用令牌桶的 TakeAvailable
方法:
func NewTokenBucketLimiter(tb *ratelimit.Bucket)
endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, request interface{})
(interface{}, error) {
if tb.TakeAvailable(1) == 0 {
return nil, ErrLimited
}
return next(ctx, request)
}
}
}
在 Go kit 中出现了一种模式,即先获取端点,然后立即在其后放置所有中间件适配器。返回的函数在调用时接收端点,并且相同的变量会被覆盖为结果。
对于一个简单的例子,考虑以下代码:
e := getEndpoint(srv)
{
e = getSomeMiddleware()(e)
e = getLoggingMiddleware(logger)(e)
e = getAnotherMiddleware(something)(e)
}
现在,我们将为此端点执行此操作;更新主函数内的代码以添加速率限制中间件:
hashEndpoint := vault.MakeHashEndpoint(srv)
{
hashEndpoint = ratelimitkit.NewTokenBucketLimiter
(rlbucket)(hashEndpoint)
}
validateEndpoint := vault.MakeValidateEndpoint(srv)
{
validateEndpoint = ratelimitkit.NewTokenBucketLimiter
(rlbucket)(validateEndpoint)
}
endpoints := vault.Endpoints{
HashEndpoint: hashEndpoint,
ValidateEndpoint: validateEndpoint,
}
这里没有太多需要更改的;我们只是在将 hashEndpoint
和 validateEndpoint
变量分配给 vault.Endpoints
结构体之前更新它们。
手动测试速率限制器
为了查看我们的速率限制器是否工作,并且由于我们设置了如此低的阈值,我们可以仅使用我们的命令行工具进行测试。
首先,通过在运行服务器的终端窗口中按Ctrl + C来重启服务器(以便运行新代码)。这个信号将被我们的代码捕获,并将错误发送到errChan
,导致程序退出。一旦它已经终止,重新启动它:
go run main.go
现在,在另一个窗口中,让我们来哈希一些密码:
vaultcli hash bourgon
重复这个命令几次——在大多数终端中,你可以按上箭头键并回车。你会注意到前几个请求是成功的,因为它们在限制范围内,但如果你稍微激进一些,在一秒内发出超过五个请求,你会注意到我们得到了错误:
$ vaultcli hash bourgon
$2a$10$q3NTkjG0YFZhTG6gBU2WpenFmNzdN74oX0MDSTryiAqRXJ7RVw9sy
$ vaultcli hash bourgon
$2a$10$CdEEtxSDUyJEIFaykbMMl.EikxvV5921gs/./7If6VOdh2x0Q1oLW
$ vaultcli hash bourgon
$2a$10$1DSqQJJGCmVOptwIx6rrSOZwLlOhjHNC83OPVE8SdQ9q73Li5x2le
$ vaultcli hash bourgon
Invoke: rpc error: code = 2 desc = rate limit exceeded
$ vaultcli hash bourgon
Invoke: rpc error: code = 2 desc = rate limit exceeded
$ vaultcli hash bourgon
Invoke: rpc error: code = 2 desc = rate limit exceeded
$ vaultcli hash bourgon
$2a$10$kriTDXdyT6J4IrqZLwgBde663nLhoG3innhCNuf8H2nHf7kxnmSza
这表明我们的速率限制器正在工作。我们会在令牌桶重新填满之前看到错误,然后我们的请求再次得到满足。
优雅的速率限制
与其返回错误(这通常是一个相当严厉的回应),我们可能更希望服务器只是保留我们的请求,并在能够处理时完成它——这就是所谓的节流。对于这种情况,Go kit 提供了NewTokenBucketThrottler
中间件。
更新中间件代码,使用这个中间件函数:
hashEndpoint := vault.MakeHashEndpoint(srv)
{
hashEndpoint = ratelimitkit.NewTokenBucketThrottler(rlbucket,
time.Sleep)(hashEndpoint)
}
validateEndpoint := vault.MakeValidateEndpoint(srv)
{
validateEndpoint = ratelimitkit.NewTokenBucketThrottler(rlbucket,
time.Sleep)(validateEndpoint)
}
endpoints := vault.Endpoints{
HashEndpoint: hashEndpoint,
ValidateEndpoint: validateEndpoint,
}
NewTokenBucketThrottler
的第一个参数与之前的端点相同,但现在我们添加了一个time.Sleep
的第二个参数。
注意
Go kit 允许我们通过指定在需要延迟时应该发生什么来定制行为。在我们的例子中,我们传递了time.Sleep
,这是一个将请求执行暂停指定时间的函数。如果你想做不同的事情,可以在这里编写自己的函数,但现在这个方法就足够了。
现在重复之前的测试,但这次,请注意我们永远不会得到错误——相反,终端会在请求可以满足之前挂起一秒钟。
摘要
通过构建一个真实的微服务示例,我们在本章中涵盖了大量的内容。没有代码生成,涉及的工作量很大,但对于大型团队和大型微服务架构来说,这些投资是值得的,因为你可以构建出构成系统的自相似、离散组件。
我们学习了 gRPC 和协议缓冲如何为我们提供客户端和服务器之间的高效传输通信。使用proto3
语言,我们定义了我们的服务,包括消息,并使用工具生成了一个 Go 包,为我们提供了客户端和服务器代码。
我们探讨了 Go kit 的基本原理以及我们如何使用端点来描述我们的服务方法。当涉及到构建 HTTP 和 gRPC 服务器时,我们让 Go kit 为我们做繁重的工作,通过利用项目中包含的包。我们看到了中间件函数如何让我们轻松地将端点适应,例如,限制服务器需要处理的流量量。
我们还学习了 Go 语言中的构造函数,这是一种解析传入命令行参数的巧妙技巧,以及如何使用bcrypt
包来哈希和验证密码,这是一个明智的方法,可以帮助我们避免存储密码。
构建微服务还有很多内容,建议您访问 Go kit 网站gokit.io
或加入 gophers.slack.com 上的#go-kit
频道进行更多了解。
现在我们已经构建了我们的 Vault 服务,我们需要考虑我们的选项以便将其部署到野外。在下一章中,我们将我们的微服务打包成 Docker 容器,并部署到 Digital Ocean 的云平台。
第十一章。使用 Docker 部署 Go 应用程序
Docker 是一个开源生态系统(技术和一系列相关服务),它允许您将应用程序打包到简单、轻量级且可移植的容器中;它们将在任何环境中以相同的方式运行。考虑到我们的开发环境(可能是一个 Mac)与生产环境(如 Linux 服务器甚至云服务)不同,以及我们可能希望部署相同应用程序的大量不同位置,这非常有用。
大多数云平台已经支持 Docker,这使得它成为将我们的应用程序部署到野外的绝佳选择。
在第九章, 为 Google App Engine 构建问答应用程序中,我们构建了一个适用于 Google App Engine 的应用程序。如果我们决定在另一个平台上运行我们的应用程序,即使忘记了我们对 Google Cloud Datastore 的使用,我们也需要对我们的代码进行重大修改。以在 Docker 容器内部署应用程序为目的构建应用程序,为我们提供了额外的灵活性。
注意
您知道 Docker 本身是用 Go 编写的吗?通过浏览github.com/docker/docker
的源代码来亲自看看吧。
在本章中,您将学习:
-
如何编写简单的 Dockerfile 来描述应用程序
-
如何使用
docker
命令构建容器 -
如何在本地运行 Docker 容器并终止它们
-
如何将 Docker 容器部署到 Digital Ocean
-
如何使用 Digital Ocean 中的功能启动已预配置 Docker 的实例
我们将把在第十章 使用 Go kit 框架的 Go 微服务 中创建的 Vault 服务放入 Docker 镜像,并将其部署到云中。
在本地使用 Docker
在我们能够将代码部署到云之前,我们必须使用开发机器上的 Docker 工具构建并推送镜像到 Docker Hub。
安装 Docker 工具
为了构建和运行容器,您需要在您的开发机器上安装 Docker。请访问www.docker.com/products/docker
并下载适合您电脑的相应安装程序。
Docker 及其生态系统正在快速发展,因此确保您与最新版本保持同步是个好主意。同样,本章中的一些细节可能会发生变化;如果您遇到困难,请访问项目主页github.com/matryer/goblueprints
以获取一些有用的提示。
Dockerfile
Docker 镜像就像一个迷你虚拟机。它包含运行应用程序所需的一切:代码将运行的操作系统,我们代码可能需要的任何依赖项(例如,在我们的 Vault 服务中是 Go kit),以及我们应用程序本身的二进制文件。
一个镜像是通过Dockerfile
描述的;一个包含一系列特殊命令的文本文件,这些命令指导 Docker 如何构建镜像。它们通常基于另一个容器,这样可以节省您构建和运行 Go 应用程序所需的一切。
在代码中第十章的vault
文件夹内,添加一个名为Dockerfile
的文件(注意,此文件名没有扩展名),包含以下代码:
FROM scratch
MAINTAINER Your Name <your@email.address>
ADD vaultd vaultd
EXPOSE 8080 8081
ENTRYPOINT ["/vaultd"]
Dockerfile
文件中的每一行代表在构建镜像时运行的不同命令。以下表格描述了我们使用的每个命令:
Command | 描述 |
---|---|
FROM |
此镜像将基于的镜像名称。单词,如 scratch,代表托管在 Docker Hub 上的官方 Docker 镜像。有关关于 scratch 镜像的更多信息,请参阅https://hub.docker.com/_/scratch/ 。 |
ADD |
将文件复制到容器中。我们正在复制我们的vaultd 二进制文件,并将其命名为vaultd 。 |
EXPOSE |
公开端口号列表;在我们的案例中,Vault 服务绑定到:8080 和:8081 。 |
ENTRYPOINT |
当容器在我们的情况下执行时运行的二进制文件,即vaultd 二进制文件,它将由之前的 go install 调用放置在那里。 |
MAINTAINER |
维护 Docker 镜像的人的姓名和电子邮件地址。 |
注意
要获取支持的命令的完整列表,请查阅在线 Docker 文档:docs.docker.com/engine/reference/builder/#dockerfile-reference
。
为不同架构构建 Go 二进制文件
Go 支持交叉编译,这是一种机制,我们可以在一台机器(比如我们的 Mac)上为目标操作系统(如 Linux 或 Windows)和架构构建二进制文件。Docker 容器是基于 Linux 的;因此,为了提供一个可以在该环境中运行的二进制文件,我们必须首先构建一个。
在终端中,导航到 vault 文件夹并运行以下命令:
CGO_ENABLED=0 GOOS=linux go build -a ./cmd/vaultd/
我们在这里实际上是在调用 go build,但增加了一些额外的部分来控制构建过程。CGO_ENABLED
和GOOS
是 go build 会注意到的环境变量,-a
是一个标志,./cmd/vaultd/
是我们想要构建的命令的位置(在我们的案例中,是我们在上一章中构建的vaultd
命令)。
-
CGO_ENABLED=0
表示我们不希望启用 cgo。由于我们没有绑定任何 C 依赖项,我们可以通过禁用此功能来减小构建的大小。 -
GOOS
是 Go 操作系统的缩写,允许我们指定我们正在针对哪个操作系统,在我们的例子中,是 Linux。要查看完整的选项列表,可以直接访问 Go 源代码,通过访问github.com/golang/go/blob/master/src/go/build/syslist.go
。
一段时间后,你会注意到出现了一个新的二进制文件,名为vaultd
。如果你使用的是非 Linux 机器,你将无法直接执行这个文件,但不用担心;它将在我们的 Docker 容器中正常运行。
构建 Docker 镜像
要构建镜像,在终端中导航到Dockerfile
并运行以下命令:
docker build -t vaultd
我们使用docker
命令来构建镜像。最后的点表示我们想要从当前目录构建 Dockerfile。-t
标志指定我们想要给我们的镜像命名为vaultd
。这将允许我们通过名称而不是 Docker 分配给它的哈希值来引用它。
如果你第一次使用 Docker,特别是使用scratch
基础镜像,那么根据你的网络连接,从 Docker Hub 下载所需的依赖项将需要一些时间。一旦完成,你将看到类似以下输出的内容:
Step 1 : FROM scratch
--->
Step 2 : MAINTAINER Your Name <your@email.address>
---> Using cache
---> a8667f8f0881
Step 3 : ADD vaultd vaultd
---> 0561c999c1e3
Removing intermediate container 4b75fde507df
Step 4 : EXPOSE 8080 8081
---> Running in 8f169f5b3b44
---> 1d7758c20b3a
Removing intermediate container 8f169f5b3b44
Step 5 : ENTRYPOINT /vaultd
---> Running in b5d55d6429be
---> b7178985dddf
Removing intermediate container b5d55d6429be
Successfully built b7178985dddf
对于每个命令,都会创建一个新的镜像(你可以在过程中看到中间容器被销毁),直到我们得到最终的镜像。
由于我们在本地机器上构建二进制文件并将其复制到容器中(使用ADD
命令),我们的 Docker 镜像最终只有大约 7 MB:考虑到它包含了运行服务所需的所有内容,这相当小。
在本地运行 Docker 镜像
现在我们已经构建了镜像,我们可以通过以下命令来测试它:
docker run -p 6060:8080 -p 6061:8081 --name localtest --rm vaultd
docker run
命令将启动vaultd
镜像的一个实例。
-p
标志指定了一对要公开的端口,第一个值是主机端口,第二个值(冒号之后)是镜像内的端口。在我们的例子中,我们表示我们想要将端口8080
公开到端口6060
,端口8081
通过端口6061
公开。
我们使用--name
标志给运行实例命名为localtest
,这将帮助我们识别它,当我们检查和停止它时。--rm
标志表示我们希望在停止后删除镜像。
如果成功,你会注意到 Vault 服务确实已经开始,因为它在告诉我们它绑定到的端口:
2016/09/20 15:56:17 grpc: :8081
2016/09/20 15:56:17 http: :8080
小贴士
这些是内部端口;记住,我们已经将这些映射到不同的外部端口。这看起来可能有些混乱,但最终却非常强大,因为负责启动服务实例的人可以决定哪些端口适合他们的环境,而 Vault 服务本身则无需担心这一点。
要查看这个运行状态,打开另一个终端并使用curl
命令访问我们的密码散列服务的 JSON 端点:
curl -XPOST -d '{"password":"monkey"}' localhost:6060/hash
你将看到类似运行服务输出的内容:
{"hash":"$2a$0$wk4qc74ougOkbkt/TWuRQHSg03i1ataNupbDADBwpe"}
检查 Docker 进程
要查看正在运行的 Docker 实例,我们可以使用docker ps
命令。在终端中,输入以下内容:
docker ps
你将看到一个文本表格,概述以下属性:
CONTAINER ID | 0b5e35dca7cc |
---|---|
IMAGE | vaultd |
COMMAND | /bin/sh -c /go/bin/vaultd |
CREATED | 3 seconds ago |
STATUS | Up 2 seconds |
PORTS | 0.0.0.0:6060->8080/tcp, 0.0.0.0:6061->8081/tcp |
NAMES | localtest |
详细信息显示了我们刚刚启动的镜像的高级概述。请注意,PORTS部分显示了外部到内部的映射。
停止 Docker 实例
我们习惯于在运行代码的窗口中按Ctrl + C来停止它,但由于它是在容器中运行的,所以这不会起作用。相反,我们需要使用docker stop
命令。
由于我们给我们的实例命名为localtest
,我们可以在一个可用的终端窗口中输入以下内容来停止它:
docker stop localtest
几分钟后,你会注意到运行镜像的终端现在已经返回到提示符。
部署 Docker 镜像
现在我们已经将 Vault 服务封装在一个 Docker 容器中,我们将对它做一些有用的事情。
我们将要做的第一件事是将这个镜像推送到 Docker Hub,这样其他人就可以启动自己的实例,甚至基于它构建新的镜像。
部署到 Docker Hub
访问 Docker Hub hub.docker.com
,点击右上角的登录链接,然后点击创建账户来创建一个账户。当然,如果你已经有了账户,只需登录即可。
现在,在终端中,你将通过运行 Docker 的login
命令来使用此账户进行认证:
docker login -u **USERNAME** -p **PASSWORD** https://index.docker.io/v1/
小贴士
如果你看到类似WARNING: Error loading config, permission denied
的错误,那么请尝试使用带有sudo
命令前缀的命令再次执行。这一点适用于从现在开始的所有 Docker 命令,因为我们正在使用一个受保护的配置。
确保将USERNAME
和PASSWORD
替换为你刚刚创建的账户的实际用户名和密码。
如果成功,你将看到“登录成功”。
接下来,回到网页浏览器中,点击创建仓库并创建一个名为vault
的新仓库。这个镜像的实际名称将是USERNAME/vault
,因此我们需要在本地重新构建镜像以匹配这个名称。
小贴士
注意,为了公开使用,我们称镜像为vault
而不是vaultd
。这是一个故意的区别,以确保我们处理的是正确的镜像,但这对用户来说也是一个更好的名称。
在终端中,使用正确的名称构建新的存储库:
docker build -t USERNAME/vault
这将再次构建镜像,这次使用适当的名称。要将镜像部署到 Docker Hub,我们使用 Docker 的push
命令:
docker push USERNAME/vault
经过一段时间,镜像及其依赖项将被推送到 Docker Hub:
f477b97e9e48: Pushed
384c907d1173: Pushed
80168d020f50: Pushed
0ceba54dae47: Pushed
4d7388e75674: Pushed
f042db76c15c: Pushing [====> ] 21.08 MB/243.6 MB
d15a527c2ee1: Pushing [=====> ] 15.77 MB/134 MB
751f5d9ad6db: Pushing [======> ] 16.49 MB/122.6 MB
17587239b3df: Pushing [===================>] 17.01 MB/44.31 MB
9e63c5bce458: Pushing [==================> ] 65.58 MB/125.1 MB
现在转到 Docker Hub 查看您镜像的详细信息,或者查看hub.docker.com/r/matryer/vault/
的示例。
部署到 Digital Ocean
Digital Ocean 是一家提供具有竞争力的价格来托管虚拟机的云服务提供商。它使得部署和提供 Docker 镜像变得非常容易。在本节中,我们将部署一个 droplet(Digital Ocean 对单个机器的术语),在云中运行我们的 docker 化 Vault 服务。
具体来说,以下是将 Docker 镜像部署到 Digital Ocean 的步骤:
-
创建 droplet。
-
通过基于 Web 的控制台访问它。
-
拉取
USERNAME/vault
容器。 -
运行容器。
-
通过
curl
命令远程访问我们的托管 Vault 服务。
Digital Ocean 是一个平台即服务(PaaS)架构,因此用户体验可能会不时发生变化,所以这里描述的精确流程在您执行这些任务时可能并不完全准确。通常,通过查看选项,您将能够找出如何进行操作,但已经包括了截图以帮助您。
本节还假设您已启用创建 droplets 可能需要的任何计费。
创建 droplet
通过浏览器访问www.digitalocean.com
注册或登录到 Digital Ocean。请确保您使用真实的电子邮件地址,因为这将是他们发送您创建的 droplet 的 root 密码的地方。
如果您没有其他 droplets,您将看到一个空白屏幕。点击创建 Droplet:
https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/go-dsn-ptn-rlwd-proj/img/00070.jpeg
在一键应用标签页中,查找最新的 Docker 选项;在撰写本文时,它是Docker 1.12.1 on 16.04,这意味着 Docker 版本 1.12.1 正在 Ubuntu 16.04 上运行。
滚动页面选择剩余的选项,包括选择大小(目前最小的尺寸即可)和位置(选择离您最近的地理位置)。现在我们不会添加额外的服务(如卷、网络或备份),只需进行简单的 droplet。
给您的 droplet 起一个有意义的名称可能是个好主意,这样以后就更容易找到,比如vault-service-1
或类似名称;现在这并不重要:
https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/go-dsn-ptn-rlwd-proj/img/00071.jpeg
小贴士
你可以选择添加 SSH 密钥以增加额外的安全性,但为了简单起见,我们将继续不添加它。对于生产环境,建议你始终这样做。
在页面底部,点击创建:
https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/go-dsn-ptn-rlwd-proj/img/00072.jpeg
访问 droplet 的控制台
一旦你的 droplet 创建完成,从Droplets列表中选择它,并查找控制台选项(它可能被写成Access console
)。
几分钟后,你将看到一个基于 Web 的终端。这就是我们将如何控制 droplet,但首先,我们必须登录:
https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/go-dsn-ptn-rlwd-proj/img/00073.jpeg
输入登录用户名为root
,并检查你的电子邮件以获取 Digital Ocean 发送给你的 root 密码。在撰写本文时,你不能复制粘贴,所以请准备好尽可能准确地输入一个长字符串。
小贴士
密码可能是一个小写十六进制字符串,这将帮助你了解哪些字符可能出现。例如,所有看起来像O的字符可能都是零,而1不太可能是I或L。
第一次登录后,你将被要求更改密码,这需要再次输入生成的长密码!有时安全性会如此不方便。
拉取 Docker 镜像
由于我们选择了 Docker 应用作为我们的 droplet 的起点,Digital Ocean 已经友好地配置了 Docker,使其已经在我们的实例中运行,因此我们可以直接使用docker
命令来完成设置。
在基于 Web 的终端中,使用以下命令拉取你的容器,记得将USERNAME
替换为你的 Docker Hub 用户名:
docker pull USERNAME/vault
小贴士
如果由于任何原因,这对你不起作用,你可以尝试使用作者放置在那里的 Docker 镜像,通过输入以下命令:docker pull matryer/vault
Docker 将会去拉取它运行我们之前创建的镜像所需的所有内容:
https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/go-dsn-ptn-rlwd-proj/img/00074.jpeg
在云中运行 Docker 镜像
一旦镜像及其依赖项成功下载,我们就可以使用docker run
命令来运行它,这次使用-d
标志来指定我们希望它作为后台守护进程运行。在基于 Web 的终端中,输入以下命令:
docker run -d -p 6060:8080 -p 6061:8081 --name vault USERNAME/vault
这与之前我们运行的命令类似,但这次我们给它命名为 vault,并且省略了--rm
标志,因为它与后台守护进程模式不兼容(并且没有意义)。
包含我们的 Vault 服务的 Docker 镜像将开始运行,现在已准备好测试。
访问云中的 Docker 镜像
现在,我们的 Docker 镜像已经在 Digital Ocean 平台上运行的 droplet 中运行,我们可以开始使用它了。
在 Digital Ocean 的 Web 控制面板中,选择Droplets并查找我们刚刚创建的那个。我们需要知道 IP 地址,以便我们可以远程访问服务。一旦你找到了 droplet 的 IP 地址,点击它以复制它。
在你的电脑上打开本地终端(不要使用基于网页的终端)并使用 curl
命令(或等效命令)执行以下请求:
curl -XPOST -d '{"password":"Monkey"}' http://IPADDRESS:6060/hash
记得将 IPADDRESS
替换为你从 Digital Ocean 的网页控制面板中复制的实际 IP 地址。
当你收到以下类似响应时,你会注意到你已经成功管理访问了我们的 Vault 服务的 JSON/HTTP 端点:
{"hash":"$2a$10$eGFGRZ2zMfsXss.6CgK6/N7TsmF.6MAv6i7Km4AHC"}
看看你是否可以修改 curl
命令,使用 /validate
端点验证提供的哈希值。
摘要
在本章中,我们使用 Docker 在 Digital Ocean 的云平台上构建和部署了 Vault Go 应用程序。
在安装 Docker 工具后,我们看到了如何轻松地将我们的 Go 应用程序打包成 Docker 镜像并推送到 Docker Hub。我们使用他们提供的有用的 Docker 应用程序创建了 Digital Ocean 的 Droplet,并通过基于网页的控制台进行控制。一旦进入,我们就能从 Docker Hub 拉取我们的 Docker 镜像并在 Droplet 中运行它。
使用 Droplet 的公网 IP,我们能够远程访问 Vault 服务器的 JSON/HTTP 端点以哈希和验证密码。
附录 附录。稳定 Go 环境的良好实践
编写 Go 代码是一种有趣且愉快的体验,编译时错误不再是痛苦,而是引导你编写健壮、高质量的代码。然而,时不时地,你将遇到一些环境问题,这些问题开始妨碍你的工作流程。虽然你通常可以通过一些搜索和微调来解决这些问题,但正确设置你的开发环境在很大程度上可以减少问题,让你能够专注于构建有用的应用程序。
在本章中,我们将从头开始在新的机器上安装 Go,并讨论我们的一些环境选项及其可能对未来产生的影响。我们还将考虑协作如何影响我们的决策,以及开源我们的包可能产生的影响。
具体来说,我们将:
-
在你的开发机上安装 Go
-
了解
GOPATH
环境变量的用途,并讨论其合理的使用方法 -
了解 Go 工具及其使用方法,以保持我们代码的高质量
-
学习如何使用工具自动管理我们的导入
-
考虑到我们的
.go
文件的 保存 操作,以及我们如何将 Go 工具集成到日常开发中 -
查看一些流行的代码编辑器选项来编写 Go 代码
安装 Go
安装 Go 的最佳方式是使用网络上可用的众多安装程序之一,请访问 golang.org/dl/
。访问 Go 网站,点击 下载,然后查找适合你电脑的最新 1.x 版本。页面顶部的 特色下载 部分包含指向最受欢迎版本的链接,所以你的版本可能就在这个列表中。
本书中的代码已经使用 Go 1.7 进行了测试,但任何 1.x 版本都将工作。对于 Go 的未来版本(2.0 及更高版本),你可能需要调整代码,因为主要版本发布可能包含破坏性更改。
配置 Go
Go 现已安装,但为了使用工具,我们必须确保它已正确配置。为了使调用工具更简单,我们需要将我们的 go/bin
路径添加到 PATH
环境变量中。
注意
在 Unix 系统上,你应该将 export PATH=$PATH:/opt/go/bin
(确保它是你安装 Go 时选择的路径)添加到你的 .bashrc
文件中。
在 Windows 上,打开 系统属性(尝试右键单击 我的电脑),然后在 高级 选项卡中点击 环境变量 按钮,并使用 UI 确保路径变量包含你的 go/bin
文件夹路径。
在终端中(你可能需要重启终端以使更改生效),你可以通过打印 PATH
变量的值来确保这已经生效:
echo $PATH
确保打印的值包含正确的 go/bin
文件夹路径;例如,在我的机器上它打印如下:
/usr/local/bin:/usr/bin:/bin:/opt/go/bin
注意
路径之间的冒号(在 Windows 上是分号)表示 PATH
变量实际上是一个文件夹列表,而不仅仅是一个文件夹。这表明当你输入终端中的命令时,将搜索每个包含的文件夹。
现在,我们可以确保我们刚刚创建的 Go 构建可以成功运行:
go version
以这种方式执行 go
命令(可以在你的 go/bin
位置找到)将为我们打印出当前版本。例如,对于 Go 1.77.1,你应该看到以下类似的内容:
go version go1.77.1 darwin/amd64
正确设置 GOPATH
GOPATH
是另一个指向文件夹的环境变量(如上一节中的 PATH
),用于指定 Go 源代码和编译的二进制包的位置。在你的 Go 程序中使用 import
命令会导致编译器在 GOPATH
位置查找你引用的包。当使用 go get
和其他命令时,项目会被下载到 GOPATH
文件夹中。
虽然 GOPATH
位置可以包含一系列由冒号分隔的文件夹,例如 PATH
,并且你可以根据你正在工作的项目为 GOPATH
设置不同的值,但强烈建议你为所有内容使用单个 GOPATH
位置,这是我们假设你在本书的项目中会这样做。
创建一个名为 go
的新文件夹,这次在 Users
文件夹中,可能在 Work
子文件夹中。这将是我们 GOPATH
的目标,所有第三方代码和二进制文件都将在这里结束,我们也将在这里编写我们的 Go 程序和包。使用你在上一节中设置 PATH
环境变量时使用的相同技术,将 GOPATH
变量设置为新的 go
文件夹。让我们打开一个终端并使用新安装的命令之一为我们获取第三方包:
go get github.com/matryer/silk
获取 silk
库实际上会导致创建以下文件夹结构:$GOPATH/src/github.com/matryer/silk
。你可以看到路径段在 Go 组织事物的方式中非常重要,这有助于命名空间项目并保持它们的独特性。例如,如果你创建了一个名为 silk
的自己的包,你不会将其保存在 matryer
的 GitHub 仓库中,所以路径就会不同。
当我们在本书中创建项目时,你应该考虑一个合理的 GOPATH
根目录。例如,我使用了 github.com/matryer/goblueprints
,如果你去获取它,你实际上会在你的 GOPATH
文件夹中获得这本书所有源代码的完整副本!
Go 工具
Go 核心团队早期做出的一个决定是,所有 Go 代码都应该对说 Go 语的每个人来说都熟悉且明显,而不是每个代码库都需要额外的学习才能让新程序员理解它或对其进行工作。当你考虑到开源项目时,这是一个特别合理的做法,其中一些项目有数百名贡献者来来去去。
有许多工具可以帮助我们达到 Go 核心团队设定的高标准,我们将在本节中查看一些工具的实际应用。
在你的 GOPATH
位置,创建一个名为 tooling
的新文件夹,并创建一个包含以下代码的新 main.go
文件:
package main
import (
"fmt"
)
func main() {
return
var name string
name = "Mat"
fmt.Println("Hello ", name)
}
紧凑的空间和缺乏缩进是有意为之的,因为我们将要查看 Go 附带的一个非常酷的实用工具。
在终端中,导航到你的新文件夹并运行以下命令:
go fmt -w
注意
在 2014 年科罗拉多州丹佛的 Gophercon 大会上,大多数人了解到,与其将这个小三元组读作 format 或 f, m, t,实际上它是作为一个单词来发音的。现在试着对自己说:fhumt;看来,计算机程序员们如果不互相说一种外星语就已经够奇怪的了!
你会注意到这个小小的工具实际上调整了我们的代码文件,以确保我们的程序布局(或格式)符合 Go 标准。新版本更容易阅读:
package main
import (
"fmt"
)
func main() {
return
var name string
name = "Mat"
fmt.Println("Hello ", name)
}
go fmt
命令关注缩进、代码块、不必要的空白、不必要的额外换行符等等。以这种方式格式化你的代码是一种很好的实践,以确保你的 Go 代码看起来像其他所有 Go 代码。
接下来,我们将审查我们的程序,以确保我们没有犯任何错误或可能让用户感到困惑的决定;我们可以使用另一个免费获得的神器来自动完成这项工作:
go vet
我们的小程序输出显示了一个明显且令人瞩目的错误:
main.go:10: unreachable code
exit status 1
我们在函数顶部调用 return
,然后尝试做其他事情。go vet
工具注意到了这一点,并指出我们在文件中有不可达的代码。
go vet
不仅能捕捉到这种愚蠢的错误,它还会寻找你程序中更微妙的问题,这些问题将指导你编写尽可能好的 Go 代码。要查看 vet 工具将报告的最新列表,请查看golang.org/cmd/vet/
上的文档。
我们将要使用的最后一个工具叫做goimports
,它是由 Brad Fitzpatrick 编写的,用于自动修复(添加或删除)Go 文件的import
语句。在 Go 中,导入一个包而不使用它是错误的,显然,尝试使用未导入的包也不会工作。goimports
工具将根据我们的代码文件内容自动重写我们的import
语句。首先,让我们使用这个熟悉的命令来安装goimports
:
go get golang.org/x/tools/cmd/goimports
更新你的程序,导入一些我们不会使用的包,并移除fmt
包:
import (
"net/http"
"sync"
)
当我们通过调用go run main.go
来尝试运行我们的程序时,我们会看到一些错误:
./main.go:4: imported and not used: "net/http"
./main.go:5: imported and not used: "sync"
./main.go:13: undefined: fmt
这些错误告诉我们,我们导入了未使用的包,缺少了fmt
包,并且为了继续,我们需要进行修正。这就是goimports
发挥作用的地方:
goimports -w *.go
我们使用带有-w
写入标志的goimports
命令,这将节省我们修正所有以.go
结尾的文件的麻烦。
现在查看你的main.go
文件,注意net/http
和sync
包已经被移除,而fmt
包已经被放回。
你可能会认为切换到终端运行这些命令比手动操作花费的时间更多,在大多数情况下你可能是对的,这就是为什么强烈建议你将 Go 工具与你的文本编辑器集成。
清理、构建和保存时运行测试
由于 Go 核心团队为我们提供了像fmt
、vet
、test
和goimports
这样出色的工具,我们将探讨一种已被证明极其有用的开发实践。每次我们保存.go
文件时,我们都希望自动执行以下任务:
-
使用
goimports
和fmt
修复我们的导入并格式化代码。 -
检查代码中的任何错误,并立即告诉我们。
-
尝试构建当前包并输出任何构建错误。
-
如果构建成功,运行包的测试并输出任何失败。
由于 Go 代码编译速度非常快(Rob Pike 曾经实际上说过它并不快,但并不像其他所有东西那样慢),我们每次保存文件时都可以舒适地构建整个包。这也适用于运行测试以帮助我们进行 TDD 风格开发的情况,体验非常棒。每次我们对代码进行更改时,我们都可以立即看到是否破坏了某些内容,或者对我们的项目其他部分产生了意外影响。我们将不再看到包导入错误,因为我们的import
语句已经为我们修正,而且我们的代码将直接在我们的眼前正确格式化。
一些编辑器可能不支持在特定事件(如保存文件)响应下运行代码,这给您留下了两个选择:您可以选择切换到更好的编辑器,或者您可以编写自己的脚本文件,该文件会在文件系统更改时运行。后者超出了本书的范围;相反,我们将专注于如何在几个流行的编辑器中实现此功能。
集成开发环境
集成开发环境(IDEs)本质上是一些具有额外功能,使编写代码和构建软件更简单的文本编辑器。具有特殊意义的文本,如字符串字面量、类型、函数名等,通常通过语法高亮以不同的颜色显示,或者您在键入时可能会获得自动完成选项。一些编辑器甚至会在您执行代码之前指出代码中的错误。
有许多选项可供选择,大多数情况下,这取决于个人喜好,但我们将探讨一些更受欢迎的选择以及如何设置它们以构建 Go 项目。
最受欢迎的编辑器包括以下几种:
-
Sublime Text 3
-
Visual Studio Code
-
Atom
-
Vim(带 vim-go)
您可以在github.com/golang/go/wiki/IDEsAndTextEditorPlugins
查看一个完整的精选选项列表。
在本节中,我们将探讨 Sublime Text 3 和 Visual Studio Code。
Sublime Text 3
Sublime Text 3 是一个优秀的编辑器,可以用于编写在 OS X、Linux 和 Windows 上运行的 Go 代码,它拥有极其强大的扩展模型,这使得它易于定制和扩展。您可以从www.sublimetext.com/
下载 Sublime Text,并在决定是否购买之前免费试用。
感谢DisposaBoy(请参阅github.com/DisposaBoy
),已经有一个针对 Go 的 Sublime 扩展包,实际上为我们提供了许多 Go 程序员实际上错过的丰富功能和力量。我们将安装这个GoSublime
包,然后在此基础上添加我们想要的保存功能。
在我们能够安装 GoSublime
之前,我们需要将 Package Control 安装到 Sublime Text 中。访问 sublime.wbond.net/
并点击 安装 链接,获取安装 Package Control 的说明。在撰写本文时,这只是一个复制单行命令(尽管很长)并将其粘贴到 Sublime 控制台中的简单过程,控制台可以通过从菜单中选择 视图 | 显示控制台 来打开。
完成这些后,按 shift + command + P 并输入 Package Control: Install Package
,当你选择了选项后按 return。经过短暂的延迟(Package Control 正在更新其列表),将出现一个框,允许你通过输入、选择并按 return 来搜索和安装 GoSublime。如果一切顺利,GoSublime 将被安装,编写 Go 代码将变得容易得多。
小贴士
现在你已经安装了 GoSublime,你可以通过按 command + ., command + 2(同时按住命令键和点号,然后按住命令键和数字 2)来打开一个包含包详细信息的简短帮助文件。
在保存时需要一些额外帮助的话,请按 command + ., command + 5 打开 GoSublime 设置,并在对象中添加以下条目:
"on_save": [
{
"cmd": "gs9o_open",
"args": {
"run": ["sh", "go build . errors && go test -i && go test &&
go vet && golint"],
"focus_view": false
}
}
]
小贴士
注意,设置文件实际上是一个 JSON 对象,所以确保你在不损坏文件的情况下添加 on_save
属性。例如,如果你在前后都有属性,确保适当的逗号已经放置好。
之前的设置将告诉 Sublime Text 在保存文件时查找代码错误、安装测试依赖项、运行测试和审查代码。保存设置文件(暂时不要关闭它),让我们看看这个功能是如何实际应用的。
从菜单中选择 选择文件 | 打开… 并选择一个文件夹现在打开,让我们打开我们的 tooling
文件夹。Sublime Text 的简单用户界面清楚地表明,我们目前项目中的文件只有一个:main.go
。点击文件并添加一些额外的换行符,并添加和删除一些缩进。然后,从菜单中选择 文件 | 保存,或者按 command + S。请注意,代码会立即被清理,并且如果你没有从 main.go
中移除放置不当的返回语句,你会注意到控制台已经出现并报告了问题,这是由于 go vet 的功劳:
main.go:8: unreachable code
按住 command + shift 并在控制台中的不可达代码行上双击,将打开文件并将光标跳转到相关的行。当你继续编写 Go 代码时,你可以看到这个功能将多么有用。
如果你向文件中添加了不需要的导入,你将注意到在使用 on_save
时你会被告知问题,但它并没有自动修复。这是因为我们还需要进行另一个调整。在添加 on_save
属性的相同设置文件中,添加以下属性:
"fmt_cmd": ["goimports"]
这告诉 GoSublime 使用 goimports
命令而不是 go fmt
。再次保存此文件,然后返回 main.go
。再次将 net/http
添加到导入中,删除 fmt
导入,并保存文件。请注意,已删除未使用的包,并将 fmt
再次放回。
Visual Studio Code
在最佳 Go IDE 竞选中出现的一个惊喜是微软的 Visual Studio Code,可在 code.visualstudio.com
免费获得。
一旦您从网站上下载它,打开一个 Go 文件(任何以 .go
扩展名结尾的文件),请注意 Visual Studio Code 会询问您是否希望安装推荐的插件以使处理 Go 文件更容易:
https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/go-dsn-ptn-rlwd-proj/img/00075.jpeg
点击 显示推荐 并点击建议的 Go 插件旁边的 安装:
https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/go-dsn-ptn-rlwd-proj/img/00076.jpeg
它可能会要求您重新启动 Visual Studio Code 以启用插件,并且它还可能要求您安装一些额外的命令:
https://github.com/OpenDocCN/freelearn-golang-zh/raw/master/docs/go-dsn-ptn-rlwd-proj/img/00077.jpeg
点击 安装所有 以安装所有依赖项,确保在启动其他安装过程之前等待之前的安装过程完成。不久后,您会注意到安装了一些工具。
在 Visual Studio Code 中编写一些混乱的代码(或从 github.com/matryer/goblueprints/blob/master/appendixA/messycode/main.go
复制粘贴一些)并保存。您会注意到导入已修复,代码已按照 Go 标准格式化。
您可以利用更多功能,但在这里我们不会进一步探讨。
摘要
在本附录中,我们安装了 Go,现在准备好开始构建真实的项目。我们了解了 GOPATH
环境变量,并发现了一个常见做法,即对所有项目保持一个值。这种方法大大简化了在 Go 项目上的工作,否则您可能会继续遇到棘手的失败。
我们发现了 Go 工具集如何真正帮助我们产生高质量、符合社区标准的代码,任何其他程序员都可以轻松地拾起并在此基础上进行工作,无需额外的学习。更重要的是,我们探讨了自动化这些工具的使用意味着我们可以真正专注于编写应用程序和解决问题,这正是开发者真正想做的事情。
我们查看了一些代码编辑器或 IDE 的选项,并看到了如何轻松添加插件或扩展,以使编写 Go 代码更容易。
参考文献列表
这条学习路径是为您准备的,以帮助您使用最先进的技术和技巧在 Go 中构建生产就绪的解决方案。它包括以下 Packt 产品:
-
学习 Go 编程,弗拉基米尔·维维安
-
Go 设计模式,马里奥·卡斯特罗·孔特拉斯
-
Go 编程蓝图 - 第二版,马特·瑞尔
更多推荐
所有评论(0)