Microservices in Golang - Part1

原作者:Ewan Valentine

原文链接:https://ewanvalentine.io/microservices-in-golang-part-1/

引言

《Go微服务》系列文章包括十个部分,这是系列的第一篇。本系列中将会用到protobufgRPC作为服务间的通讯协议。我花了很长时间找到了这个简洁明了的解决方案,所以我想将我所了解的有关微服务项目创建、测试以及微服务端到端部署的知识分享给刚接触这个领域的新手们。

在教程中,我们将介绍一些基本概念,术语,并以最原始的形式创建我们的第一个微服务。

在整个系列中,我们将会创建以下服务:

  • consighments
  • users
  • authentication
  • vessels

我们需要用到的技术栈包括:golang, mongodb, grpc, docker, Google Cloud, Kubernetes(容器编排), NATS(消息中间件), CircleCI, Terraform(实例操作工具,代替kubectl), go-micro.

UPDATE 2020/05/21

  • 感谢 @lihao 指出 go mod 使用上的问题

源码仓库:

  1. Consignment Service
  2. Consignment CLI

先决条件

  • 了解Golang及其生态
  • 安装 gRPC / protobuf - see here
  • 安装 Golang - see here
  • 安装如下go仓库:
1
2
$ go get -u google.golang.org/grpc
$ go get -u github.com/golang/protobuf/protoc-gen-go

我们要做什么?

我们也许会构建一个你能想到最简单的微服务实例,一个码头集装箱管理平台!对于微服务来说,构建一个博客未免有点杀鸡用牛刀,所以我想用一个项目既能展现微服务的低耦合,又具有一定的复杂度。这听起来像是一个有趣的挑战!

接下来就是正文内容了。


什么是微服务?

在传统整体应用 (Monolith Application) 中,所有的功能结构都被写入到了一个单体应用中。有时它们会被按照类型来进行分组,比如controllers, entities, factories等,其他时候,大型应用可能按照关注点或者按照功能来进行划分,所以我们可能会有一个 auth 包,一个 friends 包,以及一个 articles 包。它们各自都有自己的 factories, services, repositories, models 等,但最终它们都将会被组织到同一个代码库中。

微服务的理念就是在第二种方法上继续延伸:我们依然按照关注点或者功能拆分,并且这些包各自都是一个可以独立运行的代码库。


为什么用微服务?

复杂度 - 将功能划分成微服务使你可以将整个项目划分成更小的代码块,正如早期unix开发哲学——“做好一件事”。整体应用的发展趋势使功能结合得愈加紧密,且使得关注的边界变得模糊。随着复杂度的提升,出现漏洞的风险越来越高,集成的难度也越来越大。

拓展性 - 在单体应用中,某些代码的使用频率可能远高于其他部分,当需要扩展的时候,我们只能将整个代码库全部进行水平扩展,所以假设当你的认证服务访问频率非常高的时候,你需要将整个代码库都复制到新的服务器上来应对认证访问的需求。

微服务的理念,这种松耦合的理念允许你分别扩展单个服务,这意味着更高效的水平扩展。这点在多核、多区域云计算中能够发挥相当好的效果。

Nginx在微服务方面有很多非常好的文章,please give this a read.


为什么用Golang?

尽管微服务支持几乎所有的语言,但毕竟微服务只是一种概念而非具体的框架或者工具。一些语言对微服务有着更好的支持,而Golang就是其中之一。

Golang是一个轻量化、快捷的语言,同时它对并发提供了很好的支持,非常适合运行在多台多核机器上,能够充分发挥它们的性能。

Go语言同时包含了强大的标准库,对web服务有着良好的支持。

最后,Go语言有个强力的微服务框架 - go-micro,用它!


protobuf gRPC

protobuf gRPC 简述

由于微服务被拆分为不同的代码库,一个很重要的问题——如何在这些微服务之间进行通信。在单体应用中通讯并不是一个问题,因为你可以在代码库中直接调用其他代码。然而微服务并不能这样做,所以需要这些相互独立的应用需要一个相互通信的方法,延迟越低越好。

当然可以使用传统的REST方式,诸如基于http的JSON、XML。但是这个方法存在一个问题:服务A将数据编码成JSON/XML,并将字符串发送给服务B,B再将其解码需要付出一定的代价,这在一定规模的系统中有潜在的开销问题。尽管我们在同浏览器交互时必须采用这种方法,但是服务之间的通信用有更好的方法。

那就是 gRPC,gRPC是Google发行的一个基于二进制的轻量通讯协议。它本身包含很多东西,让我们来对它进行一些剖析。gRPC使用二进制作为其核心编码方式,以使用JSON的RESTful为例,我们通过http协议以字符串的形式发送数据,字符串中包含大量元数据,用来描述其编码格式、长度、内容的格式以及其他重要信息。这就是一台服务器向一个传统浏览器客户端发送其所需数据的方式。对于两个服务之间的通信来说,我们并不需要这么多东西,所以我们可以直接用二进制,这更轻量。gRPC使用新的HTTP 2.0规范,该规范允许使用二进制数据,它甚至允许双向流式传输,这非常酷!HTTP 2是gRPC运作的基础,关于HTTP 2.0,更多内容请看Google的 这篇文章.


但我们该怎么利用二进制数据呢?gRPC 有一个内置的数据交换协议名为 protobufProtobuf 允许我们使用对开发者更有好的格式定义一个微服务的接口。

那就让我们来定义我们第一个服务吧,在你仓库的根目录中创建 consignment-service/proto/consignment/consignment.proto ,在初期我会将我们所有的服务都放在一个仓库里面,是不是感觉还是整体仓库的结构?这是为了让教学更简单。关于是否使用单体仓库有很多争论,我们这里不去涉及,你当然也可以把服务和组件放在不同的仓库中。

译者注:

如果你能独立解决一些代码引用的问题,建议你还是将每个服务分别放到各自的仓库中,这样便于进行包的管理。

另外,其实在第四章中,作者还是把服务拆分到多个仓库了。

这里有一篇关于gRPC的文章我非常推荐。


定义 proto 文件

在刚才创建的 consignment.proto 中,加入一下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// consignment-service/proto/consignment/consignment.proto
syntax = "proto3";

package consignment;

service ShippingService {
rpc CreateConsignment(Consignment) returns (Response) {}
}

message Consignment {
string id = 1;
string description = 2;
int32 weight = 3;
repeated Container containers = 4;
string vessel_id = 5;
}

message Container {
string id = 1;
string customer_id = 2;
string origin = 3;
string user_id = 4;
}

message Response {
bool created = 1;
Consignment consignment = 2;
}

尽管这只是一个非常基础的例子,仍然有一些值得注意的地方。首先,你要定义你的service,其中包含你希望暴露给其他服务的接口,其次你需要定义message的类型,这其实就是你的数据结构。Protobuf是静态类型,你可以自定义类型,就像我们在Container中定义的那样,message就是我们定义的类型。

这里需要用的的库有两个,messages由protobuf处理,我们定义的service则由gRPC protobuf插件处理,该插件编译我们的定义,生成相应的代码,同诸如service这些类型进行交互。

生成代码

protobuf的定义之后还需要通过命令行工具(CLI)生成支持二进制数据和函数的接口代码。在consignment-service/路径下运行:

1
2
$ protoc -I. --go_out=plugins=grpc:. \
proto/consignment/consignment.proto

这段命令会调用protoc库,负责将protobuf定义编译成代码。我们还指定使用grpc插件,同时构建了上下文和生成路径。

译者注:如果正使用go module,请先安装protobuf:$ sudo apt install protobuf-compiler

protoc库 $ go get -u github.com/golang/protobuf/protoc-gen-go

运行这行命令,你可以在proto/consignment/路径下发现新生成的代码,这个代码由gRPC/protobuf库自动生成,允许我们的代码使用protobuf定义生成的接口(interface)。


译者注:

可以参照 Makefile 文件,在路径下使用 make build 使操作更加简洁。

在生成的consignment.pb.go中,可以看到Consignment Container Response 的实体,均对各个属性自动生成了相应的get方法。此外还有service对应的实体ShippingServiceServer :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ShippingServiceServer is the server API for ShippingService service.
type ShippingServiceServer interface {
CreateConsignment(context.Context, *Consignment) (*Response, error)
}

// UnimplementedShippingServiceServer can be embedded to have forward compatible implementations.
type UnimplementedShippingServiceServer struct {
}

func (*UnimplementedShippingServiceServer) CreateConsignment(ctx context.Context, req *Consignment) (*Response, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateConsignment not implemented")
}

func RegisterShippingServiceServer(s *grpc.Server, srv ShippingServiceServer) {
s.RegisterService(&_ShippingService_serviceDesc, srv)
}

代码中定义了ShippingServiceServer的接口以及需要实现的方法 CreateConsignment(context.Context, *Consignment) ,这个方法需要用户自行实现


实现接口

完成这些设置之后,创建 consignment-service/main.go 来实现这个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// consignment-service/main.go
package main

import (
"context"
"log"
"net"
"sync"

// 导入生成的consignment.pb.go,注意修改导入地址
pb "github.com/<YourUserName>/shippy-service-consignment/proto/consignment"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)

const (
port = ":50051"
)

type repository interface {
Create(*pb.Consignment) (*pb.Consignment, error)
}

// Repository - 模拟仓库, 用来模拟数据库,之后会用一个真的实现
type Repository struct {
mu sync.RWMutex
consignments []*pb.Consignment
}

// Create a new consignment
func (repo *Repository) Create(consignment *pb.Consignment) (*pb.Consignment, error) {
repo.mu.Lock()
updated := append(repo.consignments, consignment)
repo.consignments = updated
repo.mu.Unlock()
return consignment, nil
}

// Service 需要实现protobuf.service中的所有定义,可以直接在pb.go中查找需要实现
// 的方法以及函数签名。
type service struct {
repo repository
}

// CreateConsignment - 在service中我们只创建了一个方法,即Create方法,需要
// context和request作为参数,pb.Consignment由gRPC服务器处理并返回
func (s *service) CreateConsignment(ctx context.Context, req *pb.Consignment) (*pb.Response, error) {

// Save our consignment
consignment, err := s.repo.Create(req)
if err != nil {
return nil, err
}

// Return matching the `Response` message we created in our
// protobuf definition.
return &pb.Response{Created: true, Consignment: consignment}, nil
}

func main() {

repo := &Repository{}

// Set-up our gRPC server.
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()

// Register 在gRPC服务器上注册微服务,将我们的实现代码和自动生成的接口
// 连接起来
pb.RegisterShippingServiceServer(s, &service{repo})

// Register reflection service on gRPC server.
reflection.Register(s)

log.Println("Running on port:", port)
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

请仔细阅读代码中的注释。以上内容主要实现了 protobuf 中定义接口的方法,并在50051端口创建了一个 gRPC 服务器。接下来我们就有一个功能完整的gRPC服务了,你可以使用 go run main.go 运行,但是现在还无法直接调用这个服务,需要创建一个客户端(更准确一点,一个 CLI)来查看它的运行情况。

译者注:

repository中我们定义了接口,在结构体Repository中我们实现了 Create() 方法,之后结构体 service 中调用了接口中的方法,而直到 main() 中的这一步,我们才讲方法实现和接口进行绑定。(知道需要用的时候才bind)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
...

s := grpc.NewServer()

// Register our service with the gRPC server, this will tie our
// implementation into the auto-generated interface code for our
// protobuf definition.
// 这里通过&service{repo} 将实现了方法的&Repository{}传入了service
// 将方法与对象进行了绑定
pb.RegisterShippingServiceServer(s, &service{repo})

...
}

接下来使用 go mod 来初始化你的项目:

注:go mod 需要 go version 1.11及以上

1
2
$ go mod init github.com/<YourUserName>/shippy-service-consignment
$ go get -u

译者注:

为了使用自己的 consignment.pb.go ,你需要将仓库上传到github中

使用本地依赖替代 consignment.pb.go 方便测试:

方法一

main.go 中的包导入可以先改为本地路径:

1
2
3
4
import (
...
pb "shippy-service-consignment/proto/consignment"
)

方法二(不推荐)

如果想利用 replace 使用本地包替换调用远程参考的包,需要在 proto/consignment 下使用 go mod init

再在 shippy-service-consignment/go.mod 中加入:

1
replace github.com/fusidic/go-microsvc/consignment/proto/consignment => /root/workspace/go/go-microsvc/consignment-service/proto/consignment/consignment.pb.go

可以看到,这种方法非常不优雅,需要分别建立 go mod,不推荐使用

方法三

尽早拆分程序,微服务就要有微服务的样子。


接下来创建一个 CLI (Command Line Interface), 它会从 JSON 文件中读取 consignment (货运) 信息,并与 gRPC 服务器交互。

接下来在根目录创建文件夹 mkdir consignment-cli ,并在其中创建一个 cli.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// consignment-cli/main.go
package main

import (
"encoding/json"
"io/ioutil"
"log"
"os"

"context"

pb "github.com/<YourUserName>/shippy-service-consignment/proto/consignment"
"google.golang.org/grpc"
)

const (
address = "localhost:50051"
defaultFilename = "consignment.json"
)

func parseFile(file string) (*pb.Consignment, error) {
var consignment *pb.Consignment
data, err := ioutil.ReadFile(file)
if err != nil {
return nil, err
}
json.Unmarshal(data, &consignment)
return consignment, err
}

func main() {
// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure())
if err != nil {
log.Fatalf("Did not connect: %v", err)
}
defer conn.Close()
client := pb.NewShippingServiceClient(conn)

// Contact the server and print out its response.
file := defaultFilename
if len(os.Args) > 1 {
file = os.Args[1]
}

consignment, err := parseFile(file)

if err != nil {
log.Fatalf("Could not parse file: %v", err)
}

r, err := client.CreateConsignment(context.Background(), consignment)
if err != nil {
log.Fatalf("Could not greet: %v", err)
}
log.Printf("Created: %t", r.Created)
}

创建一个货运 JSON 文件:

1
2
3
4
5
6
7
8
{
"description": "This is a test consignment",
"weight": 550,
"containers": [
{ "customer_id": "cust001", "user_id": "user001", "origin": "Manchester, United Kingdom" }
],
"vessel_id": "vessel001"
}

现在如果你在 consignment-service 中使用 $ go run main.go ,并创建一个新的终端,在 consignment-cli 中运行另外一个 $ go run cli.go ,此时你可以看见一条消息:Created: true

我们该如何确认真的完成创建了呢?让我们更新一下我们的微服务,加入一个 GetConsignments 方法,如此一来我们就可以看见我们创建的 consignments 了。


首先我们需要更新我们的 proto 定义(已在注释中标明)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// consignment-service/proto/consignment/consignment.proto
syntax = "proto3";

package consignment;

service ShippingService {
rpc CreateConsignment(Consignment) returns (Response) {}

// Created a new method
rpc GetConsignments(GetRequest) returns (Response) {}
}

message Consignment {
string id = 1;
string description = 2;
int32 weight = 3;
repeated Container containers = 4;
string vessel_id = 5;
}

message Container {
string id = 1;
string customer_id = 2;
string origin = 3;
string user_id = 4;
}

// Created a blank get request
message GetRequest {}

message Response {
bool created = 1;
Consignment consignment = 2;

// Added a pluralised consignment to our generic response message
// 在通用的回复消息中加入了包含多个值的Consignment
repeated Consignment consignments = 3;
}

接下来就需要创建 GetConsignments 方法了,我们同样也需要新建一个 GetRequest ,目前暂时还不包括任何内容。同时我们也需要在 response 消息中加入 consignments 字段,你可能会注意到此处类型前的关键词 repeated ,这是为了将该类型作为数组来处理。

之后需要用之前提到的命令重新构建proto定义,我可能会看到类似这样的错误:*service does not implement consignment.ShippingServiceServer (missing GetConsignments method).

这是由于我们gRPC中的方法是基于定义的接口构建的,而此处我们需要实现新的接口。

更新 consignment-service/main.go 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package main

import (
"context"
"log"
"net"
"sync"

pb "github.com/<YourUsername>/shippy-service-consignment/proto/consignment"
"google.golang.org/grpc"
)

const (
port = ":50051"
)

type repository interface {
Create(*pb.Consignment) (*pb.Consignment, error)
GetAll() []*pb.Consignment
}

// Repository - Dummy repository, this simulates the use of a datastore
// of some kind. We'll replace this with a real implementation later on.
type Repository struct {
mu sync.RWMutex
consignments []*pb.Consignment
}

// Create a new consignment
func (repo *Repository) Create(consignment *pb.Consignment) (*pb.Consignment, error) {
repo.mu.Lock()
updated := append(repo.consignments, consignment)
repo.consignments = updated
repo.mu.Unlock()
return consignment, nil
}

// GetAll consignments
func (repo *Repository) GetAll() []*pb.Consignment {
return repo.consignments
}

// Service should implement all of the methods to satisfy the service
// we defined in our protobuf definition. You can check the interface
// in the generated code itself for the exact method signatures etc
// to give you a better idea.
type service struct {
repo repository
}

// CreateConsignment - we created just one method on our service,
// which is a create method, which takes a context and a request as an
// argument, these are handled by the gRPC server.
func (s *service) CreateConsignment(ctx context.Context, req *pb.Consignment) (*pb.Response, error) {

// Save our consignment
consignment, err := s.repo.Create(req)
if err != nil {
return nil, err
}

// Return matching the `Response` message we created in our
// protobuf definition.
return &pb.Response{Created: true, Consignment: consignment}, nil
}

// GetConsignments 这就是我们现在需要增加的方法实现了
func (s *service) GetConsignments(ctx context.Context, req *pb.GetRequest) (*pb.Response, error) {
consignments := s.repo.GetAll()
return &pb.Response{Consignments: consignments}, nil
}

func main() {

repo := &Repository{}

// Set-up our gRPC server.
lis, err := net.Listen("tcp", port)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()

// Register our service with the gRPC server, this will tie our
// implementation into the auto-generated interface code for our
// protobuf definition.
// 这里通过&service{repo} 将实现了方法的&Repository{}传入了service
// 将方法与对象进行了绑定
pb.RegisterShippingServiceServer(s, &service{repo})

log.Println("Running on port:", port)
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

以上,我们已经实现了 GetConsignments 方法,更新 git 仓库、实现 proto 定义的接口之后,如果再运行 $ go run main.go,应该可以正常工作了。

接下来更新我们的cli工具 consignment-cli/cli.go ,在 main() 末尾添加:

1
2
3
4
5
6
7
8
9
10
11
func main() {
...

getAll, err := client.GetConsignments(context.Background(), &pb.GetRequest{})
if err != nil {
log.Fatalf("Could not list consignments: %v", err)
}
for _, v := range getAll.Consignments {
log.Println(v)
}
}

在主函数的最下面,我们增加“Created: success“消息的输出,重新运行 $ go run cli.go ,创建 consignment之后,再调用 GetConsignments 你就可以看见创建的consignments的列表。

注:为简洁起见,有时我可能会编辑以前用…编写过的代码,以表示未对之前的代码进行任何更改,但添加或添加了其他行。


好了,到这你应该已经成功的创建了一个微服务并创建了一个客户端通过gRPC与之交互了。

下一章节我们将介绍集成框架 go-micro,这是一个基于gRPC创建的微服务框架,而且我们将会创建我们的第二个服务,并使用Docker来容器化我们的服务。

关于这篇文章,如果你发现了错误或者有反馈,please drop me an email.

教程的仓库:

  1. Consignment Service
  2. Consignment CLI

觉得这系列文章对您有帮助,可以请原作者喝杯咖啡,Cheers! https://monzo.me/ewanvalentine.

或者通过 Patreon 来支持原作者。


译者仓库:

https://github.com/fusidic/go-microsvc/

更多请看

Article and newsletters
https://www.nginx.com/blog/introduction-to-microservices/
https://martinfowler.com/articles/microservices.html
https://www.microservices.com/talks/
https://medium.facilelogin.com/ten-talks-on-microservices-you-cannot-miss-at-any-cost-7bbe5ab7f43f#.ui0748oat
https://microserviceweekly.com/

Books
https://www.amazon.co.uk/Building-Microservices-Sam-Newman/dp/1491950358
https://www.amazon.co.uk/Devops-Handbook-World-Class-Reliability-Organizations/dp/1942788002
https://www.amazon.co.uk/Phoenix-Project-DevOps-Helping-Business/dp/0988262509

Podcasts
https://softwareengineeringdaily.com/tag/microservices/
https://martinfowler.com/tags/podcast.html
https://www.infoq.com/microservices/podcasts/