Microservices in Golang - Part3

内容提要:docker-compose & 持久化

原作者:Ewan Valentine

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

引言

系列的第二章节中,我们开始使用Docker对服务进行容器化,并使用go-micro替代了gRPC,并且引入了vessel服务。本章节中我们将会使用docker-compose,它能够使我们在本地运行多个服务变得更加简单,以及,我们将介绍几个不同的数据库,并且这次将迎来服务组合中的第三位成员。

UPDATE 2020/05/02

开始之前

请先安装 docker-compose: https://docs.docker.com/compose/install/


数据库

直到目前为止,我们的数据依然还是放在内存当中,而并没有使用数据库,当容器重启的时候,这些数据就会丢失。所以是时候选择一个方法,对数据进行持久化、存储以便查询了。

微服务美妙的地方就在于,你可以为不同的服务选择不同类型的数据库,当然你并不是非得这么做,事实上对于小型团队来说,维护多个数据库要比一个麻烦的多。但在某些场景之下,我们的服务中的数据,可能并不是很适合用当前的数据库来处理,所以这时,微服务的特性就提供了无限的可能。由于你的关注点是相互独立的,因此微服务使得工作变得更简单。

SQL 还是 NoSQL ?

如何为你的服务选择一个“正确的数据库“并不是本文的重点,你可以参考这篇文章,所以我们不会在这个问题上过多纠结。简单来说,如果你的数据十分松散并且一致性要求不高,那么一个NoSQL类型的数据库会是一个好的选择,这种类型的数据库更加灵活,非常适合搭配JSON使用。我们会使用MongoDB作为我们的NoSQL数据库,因为它性能足够好,且使用广泛、有非常棒的社区支持。

当然了,如果你的数据定义很严格,且关联性更强,那么使用传统的rdbms或关系型数据库会更好。在关系型数据库的选择上,并没有什么硬性规定。但在选择数据库之前,还请务必研究下你的数据结构,考虑你的服务使读取更多还是写入更多?查询的复杂度如何?根据这些再来找一个合适的数据库。我将会选择Postgres作为我们的关系型数据库,没别的原因,只是因为我比较熟,而且它性能也不错。你完全可以用MySQL、MariaDB或者其他数据库。

如果你想避免自己维护数据库,那么Amazon和Google对于这两种数据库类型也都有一些出色的云端解决方案 (比较推荐这样做)。 另一个不错的选择是compose,它对多种数据库都有良好的支持,并且有完全托管的、可扩展的实例,同时它会根据你的服务选择服务商以减少网络延迟。

Amazon:
RDBMS: https://aws.amazon.com/rds/
NoSQL: https://aws.amazon.com/dynamodb/

Google:
RDBMS: https://cloud.google.com/spanner/
NoSQL: https://cloud.google.com/datastore/


docker-compose

上期我们已经了解过 Docker了, 它能让我们的微服务运行在一个轻量的、拥有独立运行时和依赖的容器中。但你们大概也感觉到了,为每一个微服务都写一个 Makefile 是在是太麻烦了!不妨来看看 docker-compose 把,看它是如何解决这个麻烦的!Docker-compose 允许我们通过一个 yaml 文件配置一系列的容器,并且你可以为每一个容器提供它们运行时 (runtime) 的元数据 (metadata)。用 docker-compose 来运行容器类似于使用我们先前使用的 docker 命令。Docker-compose或多或少地用到我们已经在使用的docker命令。 例如:

docker命令:

1
$ docker run -p 50052:50051 -e MICRO_SERVER_ADDRESS=:50051 -e MICRO_REGISTRY=mdns vessel-service

转成docker-compose:

1
2
3
4
5
6
7
8
9
version: '3.1'

services:
vessel-service:
build: ./vessel-service
ports:
- 50052:50051
environment:
MICRO_SERVER_ADDRESS: ":50051"

非常简单!

那我们的策略就是为每个服务都创建一个docker-compose文件,且这些服务都共享一个网络,这样它们就能作为不同的项目相互通信了。


容器编排

使用 docker-compose 进行容器编排

接下来让我们在所有项目的根目录创建一个 $ touch docker-compose.yml ,在其中加入我们的服务:

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
# docker-compose.yml
version: '3.5'

services:
consignment-service:
restart: always # ensures the container will restart on crash
container_name: "consignment-service"
build: ./consignment-service
ports:
- 50051 # exposing this port on the docker network only, not host
links:
- datastore
depends_on:
- datastore
networks:
- backend-tier
- consignment-tier
environment:
DB_HOST: "mongodb://datastore:27017"
MICRO_ADDRESS: ":50051"

datastore:
image: mongo:latest
container_name: "datastore"
environment:
- MONGO_DATA_DIR=/data/db
- MONGO_LOG_DIR=/dev/null
volumes:
- ./data/db:/data/db # ensures data persistence between restarting
networks:
- consignment-tier
ports:
- 27017
command: mongod --logpath=/dev/null

networks:
consignment-tier:
name: consignment-tier
backend-tier:
name: backend-tier

编者注:该文件后文中会有改动,不以此为准!

文件开头中,我们定义了docker-compose的版本,之后则是一系列服务。还有其他的一些根级别的定义,例如网络和卷,但是我们现在仅关注服务相关的配置。

首先我们定义了我们的服务,包括环境变量还有其他的一些参数,之后我们定义了我们的数据库,使用的是mongodb官方镜像。

每个服务都以它的名字进行声明,之后是它们的编译路径,这是一个相对路径,路径中应该包含一个Dockerfile。通过后者,docker-compose可以构建服务的镜像,你也可以使用一个预先构建好的镜像放在 image 参数中 (我们之后会采取这种方式)。服务声明中还需要包括 端口映射 环境变量.

之后只需使用 $ dokcer-compose build 就可以构建你的docker-compose栈,然后使用 $ docker-compose run 运行,使用 $ docker-compose up -d 将容器放到后台运行。在任何端点你都可以使用 $docker ps 查看正在运行的容器,最后,你还可以使用 $ docker stop $(docker ps -qa) 停止所有的容器 (别轻易这么干)。

最终我们创建了两个网络,一个是用于整个后端系统,另一个用于本地范围的服务。bakend-tier 网络使我们的服务能够连接到 consignment-service ,在服务和数据库之间,我们会创建一个私有网络用于通信。你并不一定非得这么做,你完全可以把这些都放到同一层网络中,但是实践中考虑网络安全是一件好事事。


运行一下

接下来让我们运行一下CLI工具来检查 docker-compose 能否正常工作。使用 $ docker-compose build && docker-compose run 来运行我们的程序,可以看到cli成功输出就再好不过了。

consignment-service 重构

现在我们需要来连接我们的第一个服务了—— consignment-service. 我感觉我们得先进行一些清理工作,之前我们把所有业务逻辑一股脑儿全部放到了 main.go 中,虽说我们写的是”微服务“,但是也没有理由写得”一团“遭。所以接下来要在 consignment-service/ 目录中创建 handler.go datastore.go repository.go。注意到我是在服务的根目录中创建这些文件而不是将它们放在新路径的 package 中,这对一个“微服务”其实已经足够了。

一篇关于组织Go代码仓库结构的文章,其实按照golang项目的风格,并不是很适合采用MVC架构,尤其是小型的golang项目,这种情况下,项目结构更多的是采用领域驱动 (Domain Driven),而不是功能驱动。


另外需要注意一下,Golang并不建议将项目根目录中的文件作为包导入到main函数中,所以在项目编译的时候,我们需要将新增的三个文件也一起编译,在Dockerfile中对应处加入:

1
RUN CGO_ENABLED=0 GOOS=linux go build  -o consignment-service -a -installsuffix cgo main.go repository.go handler.go datastore.go

repository.go 与MongoDB交互

好吧,接下来可以开始删除 main.go 里面 repository 部分的代码,真正的开始使用mongoDB数据库了。

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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// consignment-service/repository.go
package main

import (
"context"
pb "github.com/<YourUsername>/shippy-service-consignment/proto/consignment"
"go.mongodb.org/mongo-driver/mongo"
)

type Consignment struct {
ID string `json:"id"`
Weight int32 `json:"weight"`
Description string `json:"description"`
Containers Containers `json:"containers"`
VesselID string `json:"vessel_id"`
}

type Container struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
UserID string `json:"user_id"`
}

type Containers []*Container

func MarshalContainerCollection(containers []*pb.Container) []*Container {
collection := make([]*Container, 0)
for _, container := range containers {
collection = append(collection, MarshalContainer(container))
}
return collection
}

func UnmarshalContainerCollection(containers []*Container) []*pb.Container {
collection := make([]*pb.Container, 0)
for _, container := range containers {
collection = append(collection, UnmarshalContainer(container))
}
return collection
}

func UnmarshalConsignmentCollection(consignments []*Consignment) []*pb.Consignment {
collection := make([]*pb.Consignment, 0)
for _, consignment := range consignments {
collection = append(collection, UnmarshalConsignment(consignment))
}
return collection
}

func UnmarshalContainer(container *Container) *pb.Container {
return &pb.Container{
Id: container.ID,
CustomerId: container.CustomerID,
UserId: container.UserID,
}
}

func MarshalContainer(container *pb.Container) *Container {
return &Container{
ID: container.Id,
CustomerID: container.CustomerId,
UserID: container.UserId,
}
}

// Marshal an input consignment type to a consignment model
func MarshalConsignment(consignment *pb.Consignment) *Consignment {
containers := MarshalContainerCollection(consignment.Containers)
return &Consignment{
ID: consignment.Id,
Weight: consignment.Weight,
Description: consignment.Description,
Containers: containers,
VesselID: consignment.VesselId,
}
}

func UnmarshalConsignment(consignment *Consignment) *pb.Consignment {
return &pb.Consignment{
Id: consignment.ID,
Weight: consignment.Weight,
Description: consignment.Description,
Containers: UnmarshalContainerCollection(consignment.Containers),
VesselId: consignment.VesselID,
}
}

type repository interface {
Create(ctx context.Context, consignment *Consignment) error
GetAll(ctx context.Context) ([]*Consignment, error)
}

// MongoRepository implementation
type MongoRepository struct {
collection *mongo.Collection
}

// Create -
func (repository *MongoRepository) Create(ctx context.Context, consignment *Consignment) error {
_, err := repository.collection.InsertOne(ctx, consignment)
return err
}

// GetAll -
func (repository *MongoRepository) GetAll(ctx context.Context) ([]*Consignment, error) {
cur, err := repository.collection.Find(ctx, nil, nil)
var consignments []*Consignment
for cur.Next(ctx) {
var consignment *Consignment
if err := cur.Decode(&consignment); err != nil {
return nil, err
}
consignments = append(consignments, consignment)
}
return consignments, err
}

这就是与MongoDB数据库交互的代码了,这里用到了 marshallingunmarshalling 函数,它们主要用于 “protobuf 声明生成的数据结构” 与代码内置的数据模型之间的转换。理论上你可以使用生成的数据结构直接作为你的数据模型,但从软件设计的角度来看,我们并不推荐这种行为,因为这会造成数据模型和交付层之间的耦合度过高。更好的方法是在软件的各个功能结构之间保持隔离,看起来似乎会造成一些额外的开销,但为了保证软件的可扩展性,这种操作是非常必要的。


datastore.go 创建连接

*编写用于创建 “会话/连接” *的代码,更新 consignment-service/datastore.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
// consignment-service/datastore.go
package main

import (
"context"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"time"
)

// CreateClient -
func CreateClient(ctx context.Context, uri string, retry int32) (*mongo.Client, error) {
conn, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err := conn.Ping(ctx, nil); err != nil {
if retry >= 3 {
return nil, err
}
retry = retry + 1
time.Sleep(time.Second * 2)
return CreateClient(ctx, uri, retry)
}

return conn, err
}

在这里我们首先使用url字符串创建了一个连接,接下来通过 ping 一下连接,确认一下与数据库的连接是否正常。我们设置了一些基本的“重试”逻辑:如果连接失败,就再试一次,当重试次数超过了三次,就会报一个错误,并等待处理。


重构 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
// consignment-service/main.go
package main

import (
"context"
"fmt"
pb "github.com/fusidic/consignment-service/proto/consignment"
vesselProto "github.com/fusidic/vessel-service/proto/vessel"
"github.com/micro/go-micro"
"log"
"os"
)

const (
defaultHost = "datastore:27017"
)

func main() {
// Set-up micro instance
srv := micro.NewService(
micro.Name("shippy.service.consignment"),
)

srv.Init()

uri := os.Getenv("DB_HOST")
if uri == "" {
uri = defaultHost
}

client, err := CreateClient(context.Background(), uri, 0)
if err != nil {
log.Panic(err)
}
defer client.Disconnect(context.Background())

consignmentCollection := client.Database("shippy").Collection("consignments")

repository := &MongoRepository{consignmentCollection}
vesselClient := vesselProto.NewVesselServiceClient("shippy.service.client", srv.Client())
h := &handler{repository, vesselClient}

// Register handlers
pb.RegisterShippingServiceHandler(srv.Server(), h)

// Run the server
if err := srv.Run(); err != nil {
fmt.Println(err)
}
}

handler.go 处理服务请求

最后一件事就是将我们响应gRPC调用的处理函数移到 handler.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-service/handler.go
package main

import (
"context"
"github.com/pkg/errors"
pb "github.com/<YourUsername>/shippy-service-consignment/proto/consignment"
vesselProto "github.com/<YourUsername>/shippy-service-vessel/proto/vessel"
)

type handler struct {
repository
vesselClient vesselProto.VesselServiceClient
}

// 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 *handler) CreateConsignment(ctx context.Context, req *pb.Consignment, res *pb.Response) error {

// Here we call a client instance of our vessel service with our consignment weight,
// and the amount of containers as the capacity value
vesselResponse, err := s.vesselClient.FindAvailable(ctx, &vesselProto.Specification{
MaxWeight: req.Weight,
Capacity: int32(len(req.Containers)),
})
if vesselResponse == nil {
return errors.New("error fetching vessel, returned nil")
}

if err != nil {
return err
}

// We set the VesselId as the vessel we got back from our
// vessel service
req.VesselId = vesselResponse.Vessel.Id

// Save our consignment
if err = s.repository.Create(ctx, MarshalConsignment(req)); err != nil {
return err
}

res.Created = true
res.Consignment = req
return nil
}

// GetConsignments -
func (s *handler) GetConsignments(ctx context.Context, req *pb.GetRequest, res *pb.Response) error {
consignments, err := s.repository.GetAll(ctx)
if err != nil {
return err
}
res.Consignments = UnmarshalConsignmentCollection(consignments)
return nil
}

vessel-service 重构

完成这些之后,对 vessel-service 也进行这样的重构,篇幅有限,我不会在这篇文章中将 vessel 部分完整写出来,相信到了这个时候,你应该已经有能力完成这件事了。(也可以偷偷看一眼 my repository )

哦,对了,我们还需要在 vessel-service 里面加入一个新的方法,让我们能够创建新的 vessel. 首先,还是先更新 protobuf 声明:

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
syntax = "proto3";

package vessel;

service VesselService {
rpc FindAvailable(Specification) returns (Response) {}
rpc Create(Vessel) returns (Response) {}
}

message Vessel {
string id = 1;
int32 capacity = 2;
int32 max_weight = 3;
string name = 4;
bool available = 5;
string owner_id = 6;
}

message Specification {
int32 capacity = 1;
int32 max_weight = 2;
}

message Response {
Vessel vessel = 1;
repeated Vessel vessels = 2;
bool created = 3;
}

新增货轮 Create()

其中我们在gRPC服务中创建了一个 Create 方法,该方法接受一个 vessel 参数,返回一个通用Response。同时我们也在Response中加入了一个布尔值 created。重新生成一下,现在我们要做的就是添加一个处理程序 vessel-service/handler.go 和一个新的repository中的方法:

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
// vessel-service/handler.go
package main

import (
"context"
pb "github.com/<YourUsername>/shippy-service-vessel/proto/vessel"
)

type handler struct {
repository
}

// FindAvailable vessels
func (s *handler) FindAvailable(ctx context.Context, req *pb.Specification, res *pb.Response) error {

// Find the next available vessel
vessel, err := s.repository.FindAvailable(ctx, MarshalSpecification(req))
if err != nil {
return err
}

// Set the vessel as part of the response message type
res.Vessel = UnmarshalVessel(vessel)
return nil
}

// Create a new vessel
func (s *handler) Create(ctx context.Context, req *pb.Vessel, res *pb.Response) error {
if err := s.repository.Create(ctx, MarshalVessel(req)); err != nil {
return err
}
res.Vessel = req
return nil
}

repository.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
// vessel-service/repository.go
package main

import (
"context"
pb "github.com/EwanValentine/shippy-service-vessel/proto/vessel"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)

type repository interface {
FindAvailable(ctx context.Context, spec *Specification) (*Vessel, error)
Create(ctx context.Context, vessel *Vessel) error
}

type VesselRepository struct {
collection *mongo.Collection
}

type Specification struct {
Capacity int32
MaxWeight int32
}

func MarshalSpecification(spec *pb.Specification) *Specification {
return &Specification{
Capacity: spec.Capacity,
MaxWeight: spec.MaxWeight,
}
}

func UnmarshalSpecification(spec *Specification) *pb.Specification {
return &pb.Specification{
Capacity: spec.Capacity,
MaxWeight: spec.MaxWeight,
}
}

func MarshalVessel(vessel *pb.Vessel) *Vessel {
return &Vessel{
ID: vessel.Id,
Capacity: vessel.Capacity,
MaxWeight: vessel.MaxWeight,
Name: vessel.Name,
Available: vessel.Available,
OwnerID: vessel.OwnerId,
}
}

func UnmarshalVessel(vessel *Vessel) *pb.Vessel {
return &pb.Vessel{
Id: vessel.ID,
Capacity: vessel.Capacity,
MaxWeight: vessel.MaxWeight,
Name: vessel.Name,
Available: vessel.Available,
OwnerId: vessel.OwnerID,
}
}

type Vessel struct {
ID string
Capacity int32
Name string
Available bool
OwnerID string
MaxWeight int32
}

// FindAvailable - 根据规格清单检查船只,从货船列表中找到一个容量和载重量都符合标准的船
func (repository *VesselRepository) FindAvailable(ctx context.Context, spec *Specification) (*Vessel, error) {
filter := bson.D{{
"capacity",
bson.D{{
"$lte",
spec.Capacity,
}, {
"$lte",
spec.MaxWeight,
}},
}}
vessel := &Vessel{}
if err := repository.collection.FindOne(ctx, filter).Decode(vessel); err != nil {
return nil, err
}
return vessel, nil
}

// Create a new vessel
func (repository *VesselRepository) Create(ctx context.Context, vessel *Vessel) error {
_, err := repository.collection.InsertOne(ctx, vessel)
return err
}

终于我们可以创建 vessel 了!我已经更新了主函数,调用 Create() 方法保存数据,看这里


经过一些努力,我们终于用上了 MongoDB,在我们尝试运行之前,需要更新一下 docker-compose 文件

编者注:由于作者的文章经过几个版本更新,其实前文已经添加了 datastore 部分。

datastore 的环境变量中,使用 DB_HOST: "mongodb://datastore:27017"注意我们现在使用 datastore 作为主机名,而不是 localhost ,这是由于 docker-compose 能够只能处理内部的DNS请求。

*更新后的 docker-compose.yml : *

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
version: '3.7'

services:

consignment-service:
build: ./consignment-service
links:
- datastore
depends_on:
- datastore
ports:
- 50051:50051
networks:
- backend-tier
- consignment-tier
environment:
MICRO_ADDRESS: ":50051"
MICRO_REGISTRY: "mdns"
DB_HOST: "mongodb://datastore:27017"

vessel-service:
build: ./vessel-service
ports:
- 50052:50051
links:
- datastore
depends_on:
- datastore
networks:
- backend-tier
- vessel-tier
environment:
MICRO_ADDRESS: ":50052"
MICRO_REGISTRY: "mdns"
DB_HOST: "mongodb://datastore:27017"

user-service:
build: ./user-service
links:
- database
depends_on:
- database
ports:
- 50053:50051
networks:
- backend-tier
- user-tier
environment:
MICRO_ADDRESS: ":50053"
MICRO_REGISTRY: "mdns"
DB_NAME: "postgres"
DB_HOST: "database"
DB_PORT: "5432"
DB_USER: "postgres"
DB_PASSWORD: "postgres"

user-cli:
build: ./user-cli
environment:
MICRO_REGISTRY: "mdns"
networks:
- user-tier
- backend-tier


datastore:
image: mongo
networks:
- vessel-tier
- consignment-tier
ports:
- 27017:27017

database:
image: postgres
environment:
POSTGRES_USER: 'postgres'
POSTGRES_PASSWORD: 'postgres'
POSTGRESS_DB: 'postgres'
networks:
- user-tier
ports:
- 5432:5432

networks:
consignment-tier:
name: consignment-tier
user-tier:
name: user-tier
vessel-tier:
name: vessel-tier
backend-tier:
name: backend-tier

$ docker-compose build && docker-compose up注意:由于Docker的缓存逻辑,所以有时你需要使用无缓存编译 --no-cache ,这样使你的最新改动生效。


User service

第三个服务,首先在 docker-compose.yml 里加上 user-service 的内容(顺便加上 Postgres 数据库):

1
2
3
4
5
6
7
8
9
10
11
12
13
...
user-service:
build: ./shippy-service-user
ports:
- 50053:50051
environment:
MICRO_ADDRESS: ":50051"

...
database:
image: postgres
ports:
- 5432:5432

现在再新建 user-service 的路径 (根目录下) ,就像之前的操作一样,并在新目录中创建

handler.go main.go repository.go database.go Dockerfile Makefile proto/user/user.proto


user.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
syntax = "proto3";

package user;

service UserService {
rpc Create(User) returns (Response) {}
rpc Get(User) returns (Response) {}
rpc GetAll(Request) returns (Response) {}
rpc Auth(User) returns (Token) {}
rpc ValidateToken(Token) returns (Token) {}
}

message User {
string id = 1;
string name = 2;
string company = 3;
string email = 4;
string password = 5;
}

message Request {}

message Response {
User user = 1;
repeated User users = 2;
repeated Error errors = 3;
}

message Token {
string token = 1;
bool valid = 2;
repeated Error errors = 3;
}

message Error {
int32 code = 1;
string description = 2;
}

Makefile

类似之前的,相信你可以写出来。使用 $ make build 生成 gRPC 代码。


handler.go

在先前的服务中,我们已经创建了一些代码来连接gRPC中的方法。 这篇文章,我们只实现 user.proto 三种方法中的一部分 —— 我们只希望能够创建和获取用户。 在本系列的下一篇文章中,我们将研究身份验证和JWT。 因此,我们暂时将保留所有与 token(令牌) 相关的内容。 handler.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
// user-service/handler.go
package main

import (
pb "github.com/fusidic/user-service/proto/user"
"golang.org/x/net/context"
)

type service struct {
repo Repository
// 下一章会使用JWT认证
// tokenService Authable
}

func (srv *service) Get(ctx context.Context, req *pb.User, res *pb.Response) error {
user, err := srv.repo.Get(ctx, req.Id)
if err != nil {
return err
}
res.User = user
return nil
}

func (srv *service) GetAll(ctx context.Context, req *pb.Request, res *pb.Response) error {
users, err := srv.repo.GetAll(ctx)
if err != nil {
return err
}
res.Users = users
return nil
}

func (srv *service) Auth(ctx context.Context, req *pb.User, res *pb.Token) error {
_, err := srv.repo.GetByEmailAndPassword(req)
if err != nil {
return err
}
res.Token = "testingabc"
return nil
}

func (srv *service) Create(ctx context.Context, req *pb.User, res *pb.Response) error {
if err := srv.repo.Create(req); err != nil {
return err
}
res.User = req
return nil
}

func (srv *service) ValidateToken(ctx context.Context, req *pb.Token, res *pb.Token) error {
return nil
}

repository.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
// user-service/repository.go
package main

import (
pb "github.com/fusidic/user-service/proto/user"
"github.com/jinzhu/gorm"
"golang.org/x/net/context"
)

// Repository ...
type Repository interface {
GetAll(ctx context.Context) ([]*pb.User, error)
Get(ctx context.Context, id string) (*pb.User, error)
Create(user *pb.User) error
GetByEmailAndPassword(user *pb.User) (*pb.User, error)
}

// UserRepository ...
type UserRepository struct {
db *gorm.DB
}

// GetAll ...
func (repo *UserRepository) GetAll(ctx context.Context) ([]*pb.User, error) {
var users []*pb.User
if err := repo.db.Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}

func (repo *UserRepository) Get(ctx context.Context, id string) (*pb.User, error) {
var user *pb.User
user.Id = id
if err := repo.db.First(&user).Error; err != nil {
return nil, err
}
return user, nil
}

// GetByEmailAndPassword ...
func (repo *UserRepository) GetByEmailAndPassword(user *pb.User) (*pb.User, error) {
if err := repo.db.First(&user).Error; err != nil {
return nil, err
}
return user, nil
}

// Create ...
func (repo *UserRepository) Create(user *pb.User) error {
if err := repo.db.Create(user).Error; err != nil {
return err
}
return nil
}

extension.go

我们也需要修改一下 ORM 的行为,需要在 ORM 创建的时候生成一个 UUID ,而不是使用内置的ID。以防你不知道,这里简单介绍下 UUID:UUID 是随机生成的一个集合,其元素都是用 ’-‘ 串联的字符串,被用于当作ID或者主键,相比自增的ID,UUID更加安全,因为它能够防止人们猜到或者追踪到你的 API 端点。MongoDB 中用到了UUID的一个差异版本,而在 Postgres 中我们需要告知其启用 UUID。所以在 user-service/proto/user 中,需要创建一个 extensions.go :

1
2
3
4
5
6
7
8
9
10
11
package user

import (
"github.com/jinzhu/gorm"
"github.com/satori/go.uuid"
)

func (model *User) BeforeCreate(scope *gorm.Scope) error {
uuid := uuid.NewV4()
return scope.SetColumn("Id", uuid.String())
}

这个程序深入到了 GORM 的生命周期中,在每个实体创建之前,给它生成一个 UUID。

你可能会注意到,不像是 MongoDB,我们在这里不需要处理任何关于连接的操作。原生的 SQL/postgres 驱动有着和 MongoDB 不一样的行为模式,这次我们不需要去管这些事情。现在让我们稍微了解下用到的 gorm 库。


UPDATE 2020/04/20

译者注:

更多请参考 https://github.com/EwanValentine/shippy/blob/tutorial-3/user-service/

Gorm - Go + ORM

Gorm 是一个非常轻量的对象关系映射 (ORM, Object-Relational Mapping),非常适合 Postgers、MySQL、Sqlite等类型的数据库。他可以生成数据库样式,并很轻松地使用、管理这些样式。

话虽如此,但是作为微服务,数据结构更小、结构更简单,所以也不一定需要用到任何形式的 ORM。

我们需要测试一下“添加用户”的功能,所以需要创建另一个CLI工具 user-cli ,和之前的 consignment-cli 类似:

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
// user-cli/main.go
package main
import (
"log"
"os"
pb "github.com/EwanValentine/shippy/user-service/proto/user"
microclient "github.com/micro/go-micro/client"
"github.com/micro/go-micro/cmd"
"golang.org/x/net/context"
"github.com/micro/cli"
"github.com/micro/go-micro"
)
func main() {
cmd.Init()
// Create new greeter client
client := pb.NewUserServiceClient("user", microclient.DefaultClient)

// Define our flags
service := micro.NewService(
micro.Flags(
cli.StringFlag{
Name: "name",
Usage: "You full name",
},
cli.StringFlag{
Name: "email",
Usage: "Your email",
},
cli.StringFlag{
Name: "password",
Usage: "Your password",
},
cli.StringFlag{
Name: "company",
Usage: "Your company",
},
),
)

// Start as service
service.Init(
micro.Action(func(c *cli.Context) {
name := c.String("name")
email := c.String("email")
password := c.String("password")
company := c.String("company")
// Call our user service
r, err := client.Create(context.TODO(), &pb.User{
Name: name,
Email: email,
Password: password,
Company: company,
})
if err != nil {
log.Fatalf("Could not create: %v", err)
}
log.Printf("Created: %s", r.User.Id)
getAll, err := client.GetAll(context.Background(), &pb.Request{})
if err != nil {
log.Fatalf("Could not list users: %v", err)
}
for _, v := range getAll.Users {
log.Println(v)
}
os.Exit(0)
}),
)
// Run the server
if err := service.Run(); err != nil {
log.Println(err)
}
}

运行一下:

1
2
3
4
5
$ docker-compose run user-cli command \
--name="Ewan Valentine" \
--email="ewan.valentine89@gmail.com" \
--password="Testing123" \
--company="BBC"

应该能看到被创建的用户了!

使用 $ docker-compose run database bash 可以进入容器 shell 中查看数据库:

1
2
3
4
5
6
7
$ docker-compose run database bash
database# psql --host=database --username=postgres --dbname=postgres
Password for user unicorn_user:
psql (12.0 (Debian 12.0-2.pgdg100+1))
Type "help" for help.

postgres=# select * from users;

创建的过程不是很安全,因为我们是明文保存密码,下一章中我们会看一下微服务中的认证鉴权 JWT 该怎么做。


总结一下,经过第三篇文章的学习,我们创建了一个新的微服务和CLI工具,并且我们还使用到了两种数据库技术来保存我们的数据。这节涉及到的内容比较多,如果你觉得讲的内容太多太快,我在这里报以歉意。请在项目仓库中向我提出建议,或者给我反馈!


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

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


UPDATE 2020/05/01

Troubleshoot

由于作者文章是两年前所写,因此有许多内容在当前已经有所变化,也导致程序出现了一些问题,以下为译者对一些问题的修复。

  • 运行 consignment-service 时报错:
1
2
3
I | server selection error: server selection timeout, current topology: { Type: Unknown, Servers: [{ Addr: datastore:27017, Type: Unknown, State: Connected, Average RTT: 0, Last error: connection() : dial tcp: lookup datastore on 127.0.0.11:53: no such host }, ] }

panic: server selection error: server selection timeout, current topology: { Type: Unknown, Servers: [{ Addr: datastore:27017, Type: Unknown, State: Connected, Average RTT: 0, Last error: connection() : dial tcp: lookup datastore on 127.0.0.11:53: no such host }, ] }

显然 consignment 服务没法与 datastore 建立连接,这是由于两个服务容器未互相关联引起的,可以在 consignment-service 中加入 depends_on: datastore 这样一条属性。


  • 空引用问题,运行 consignment-service 等的报错信息:
1
2
3
4
5
6
7
8
9
10
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x2d0 pc=0xf1059c]

goroutine 1 [running]:
go.mongodb.org/mongo-driver/mongo.(*Client).Ping(0x0, 0x1469ec0, 0xc000128000, 0x0, 0x1, 0x0)
/go/pkg/mod/go.mongodb.org/mongo-driver@v1.3.2/mongo/client.go:231 +0x21c
main.CreateClient(0x1469ec0, 0xc000128000, 0xc000040068, 0xf, 0xc000000000, 0xf39b27, 0x12243ac, 0x1d)
/app/datastore.go:15 +0x119
main.main()
/app/main.go:35 +0x133

可以看到,问题是出现在 CreateClient 函数中,这是由于未正确定义 DB_HOST 地址引起的,应该使用 DB_HOST: "mongodb://datastore:27017"


  • networks 网络配置错误:
1
2
ERROR: The Compose file './docker-compose.yml' is invalid because:
networks.backend-tier value Additional properties are not allowed ('name' was unexpected)

由于 name 是在 docker-compose 3.5版本之后加入的功能,因此如果出现这样的错误,需要更新一下当前 docker-compose 的版本 (注意正在运行的服务可能会受到影响) 。

  1. 下载当前版本 (1.25.5) 的 docker-compose:

    1
    $ sudo curl -L "https://github.com/docker/compose/releases/download/1.25.5/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

    也可以通过替换 1.25.5来指定 docker-compose 的版本

  2. 赋予可执行权限:

    1
    $ sudo chmod +x /usr/local/bin/docker-compose
  3. 建立链接:

    1
    $ sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose
  4. 查看版本:

    1
    $ docker-compose --version

  • 运行 user-service 报错: Could not connect to DB: dial tcp: lookup database on 127.0.0.11:53: no such host,原因可能是为将 user-servicedatabase 定义到同一网络中,也可能是由于 Postgres://database 没有正确的初始化的缘故:

    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
    database:
    image: postgres
    # 注意对数据库进行初始化
    environment:
    POSTGRES_USER: 'postgres'
    POSTGRES_PASSWORD: 'postgres'
    POSTGRESS_DB: 'postgres'
    networks:
    - user-tier
    ports:
    - 5432:5432

    user-service:
    build: ./user-service
    links:
    - database
    depends_on:
    - database
    ports:
    - 50053:50051
    networks:
    - backend-tier
    - user-tier
    environment:
    MICRO_ADDRESS: ":50053"
    MICRO_REGISTRY: "mdns"
    DB_NAME: "postgres"
    DB_HOST: "database"
    DB_PORT: "5432"
    DB_USER: "postgres"
    DB_PASSWORD: "postgres"

  • user-cli 中捕获错误

    1
    2020-05-02 14:53:09.088479 I | Could not create: {"id":"go.micro.client","code":408,"detail":"call timeout: context deadline exceeded","status":"Request Timeout"}

    docker-compose down 关闭所有服务,移除容器、网络等之后,重新运行服务,解决了这个问题


  • micro/cmd flag 参数始终无法传入,待解决

    1
    2
    3
    4
    5
    6
    7
    root@left3:~/workspace/go docker-compose run user-cli command --name test --email test@test.com --password testword --company TEST.Inc
    2020-05-03 01:29:41.721090 I | unlucky :( name:blank email:test@blank password:blank company:BLANK
    2020-05-03 01:29:41.831990 I | Created: f7ba601a-2703-488d-a2b6-d2b91c7b3793
    2020-05-03 01:29:41.834034 I | id:"ae063eb1-5900-4a7b-8632-c190f105a213"
    2020-05-03 01:29:41.834081 I | id:"d9e1dcd4-208b-4a74-ad4c-284b6a8ae59b" name:"blank" company:"BLANK" email:"test@blank" password:"blank"
    2020-05-03 01:29:41.834102 I | id:"8519d1a4-7b7e-4e9b-9ee5-d821ca126bb3" name:"blank" company:"BLANK" email:"test@blank" password:"blank"
    2020-05-03 01:29:41.834121 I | id:"a2398cf2-b0ba-4a22-bb3d-8cbb6335cd6a" name:"blank" company:"BLANK" email:"test@blank" password:"blank"

    无奈只能加入了一个判断,手动给属性赋了值。