Golang 微服务教程(一)

原文连接:ewanvalentine.io,翻译已获做者 Ewan Valentine 受权。html

本节对 gRPC 的使用浅尝辄止,更多可参考:gRPC 中 Client 与 Server 数据交互的 4 种模式linux

前言

系列概览

《Golang 微服务教程》分为 10 篇,总结微服务开发、测试到部署的完整过程。nginx

本节先介绍微服务的基础概念、术语,再建立咱们的第一个微服务 consignment-service 的简洁版。在接下来的第 2~10 节文章中,咱们会陆续建立如下微服务:git

  • consignment-service(货运服务)
  • inventory-service(仓库服务)
  • user-service(用户服务)
  • authentication-service(认证服务)
  • role-service (角色服务)
  • vessel-service(货船服务)

用到的完整技术栈以下:github

1
2
3
4
Golang, gRPC, go-micro			// 开发语言及其 RPC 框架
Google Cloud, MongoDB			// 云平台与数据存储
Docker, Kubernetes, Terrafrom  	// 容器化与集群架构
NATS, CircleCI					// 消息系统与持续集成

代码仓库

做者代码:EwanValentine/shippy,译者的中文注释代码: wuYin/shippygolang

每一个章节对应仓库的一个分支,好比本文part1 的代码在 feature/part1web

开发环境

笔者的开发环境为 macOS,本文中使用了 make 工具来高效编译,Windows 用户需 手动安装shell

1
2
3
4
5
$ go env		
GOARCH="amd64"	# macOS 环境
GOOS="darwin"	# 在第二节使用 Docker 构建 alpine 镜像时需修改成 linux
GOPATH="/Users/wuyin/Go"
GOROOT="/usr/local/go"

准备

掌握 Golang 的基础语法:推荐阅读谢大的《Go Web 编程》编程

安装 gRPC / protobufjson

1
2
go get -u google.golang.org/grpc					# 安装 gRPC 框架
go get -u github.com/golang/protobuf/protoc-gen-go	# 安装 Go 版本的 protobuf 编译器

微服务

咱们要写什么项目?

咱们要搭建一个港口的货物管理平台。本项目以微服务的架构开发,总体简单且概念通用。闲话很少说让咱们开始微服务之旅吧。

微服务是什么?

在传统的软件开发中,整个应用的代码都组织在一个单一的代码库,通常会有如下拆分代码的形式:

  • 按照特征作拆分:如 MVC 模式
  • 按照功能作拆分:在更大的项目中可能会将代码封装在处理不一样业务的包中,包内部可能会再作拆分

无论怎么拆分,最终两者的代码都会集中在一个库中进行开发和管理,可参考:谷歌的单一代码库管理

微服务是上述第二种拆分方式的拓展,按功能将代码拆分红几个包,都是可独立运行的单一代码库。区别以下:

image-20180512033801893

微服务有哪些优点?

下降复杂性

将整个应用的代码按功能对应拆分为小且独立的微服务代码库,这不由让人联想到 Unix 哲学:Do One Thing and Do It Well,在传统单一代码库的应用中,模块之间是紧耦合且边界模糊的,随着产品不断迭代,代码的开发和维护将变得更为复杂,潜在的 bug 和漏洞也会愈来愈多。

提升扩展性

在项目开发中,可能有一部分代码会在多个模块中频繁的被用到,这种复用性很高的模块经常会抽离出来做为公共代码库使用,好比验证模块,当它要扩展功能(添加短信验证码登陆等)时,单一代码库的规模只增不减, 整个应用还需从新部署。在微服务架构中,验证模块可做为单个服务独立出来,能独立运行、测试和部署。

遵循微服务拆分代码的理念,能大大下降模块间的耦合性,横向扩展也会容易许多,正适合当下云计算的高性能、高可用和分布式的开发环境。

Nginx 有一系列文章来探讨微服务的许多概念,可 点此阅读

使用 Golang 的好处?

微服务是一种架构理念而不是具体的框架项目,许多编程语言均可以实现,但有的语言对微服务开发具有天生的优点,Golang 即是其中之一

Golang 自己十分轻量级,运行效率极高,同时对并发编程有着原生的支持,从而能更好的利用多核处理器。内置 net 标准库对网络开发的支持也十分完善。可参考谢大的短文:Go 语言的优点

此外,Golang 社区有一个很棒的开源微服务框架 go-mirco,咱们在下一节会用到。

Protobuf 与 gRPC

在传统应用的单一代码库中,各模块间可直接相互调用函数。但在微服务架构中,因为每一个服务对应的代码库是独立运行的,没法直接调用,彼此间的通讯就是个大问题,解决方案有 2 个:

JSON 或 XML 协议的 API

微服务之间可以使用基于 HTTP 的 JSON 或 XML 协议进行通讯:服务 A 与服务 B 进行通讯前,A 必须把要传递的数据 encode 成 JSON / XML 格式,再以字符串的形式传递给 B,B 接收到数据须要 decode 后才能在代码中使用:

  • 优势:数据易读,使用便捷,是与浏览器交互必选的协议
  • 缺点:在数据量大的状况下 encode、decode 的开销随之变大,多余的字段信息致使传输成本更高

RPC 协议的 API

下边的 JSON 数据就使用 descriptionweight 等元数据来描述数据自己的意义,在 Browser / Server 架构中用得不少,以方便浏览器解析:

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

但在两个微服务之间通讯时,若彼此约定好传输数据的格式,可直接使用二进制数据流进行通讯,再也不须要笨重冗余的元数据。

gRPC 简介

gRPC 是谷歌开源的轻量级 RPC 通讯框架,其中的通讯协议基于二进制数据流,使得 gRPC 具备优异的性能。

gRPC 支持 HTTP 2.0 协议,使用二进制帧进行数据传输,还能够为通讯双方创建持续的双向数据流。可参考:Google HTTP/2 简介

protobuf 做为通讯协议

两个微服务之间经过基于 HTTP 2.0 二进制数据帧通讯,那么如何约定二进制数据的格式呢?答案是使用 gRPC 内置的 protobuf 协议,其 DSL 语法 可清晰定义服务间通讯的数据结构。可参考:gRPC Go: Beyond the basics

consignment-service 微服务开发

通过上边必要的概念解释,如今让咱们开始开发咱们的第一个微服务:consignment-service

项目结构

假设本项目名为 shippy,你须要:

  • 在 $GOPATH 的 src 目录下新建 shippy 项目目录
  • 在项目目录下新建文件 consignment-service/proto/consignment/consignment.proto

为便于教学,我会把本项目的全部微服务的代码统一放在 shippy 目录下,这种项目结构被称为 “mono-repo”,读者也能够按照 “multi-repo” 将各个微服务拆为独立的项目。更多参考 REPO 风格之争:MONO VS MULTI

如今你的项目结构应该以下:

1
2
3
4
5
6
$GOPATH/src
    └── shippy
        └── consignment-service
            └── proto
                └── consignment
                    └── consignment.proto

开发流程

image-20180512044329199

定义 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
29
30
31
32
33
34
// shipper/consignment-service/proto/consignment/consignment.proto

syntax = "proto3";
package go.micro.srv.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;// 新托运的货物
}

语法参考: Protobuf doc

image-20180512010554833

生成协议代码

protoc 编译器使用 grpc 插件编译 .proto 文件

为避免重复的在终端执行编译、运行命令,本项目使用 make 工具,新建 consignment-service/Makefile

1
2
3
4
build:
# 必定要注意 Makefile 中的缩进,不然 make build 可能报错 Nothing to be done for build
# protoc 命令前边是一个 Tab,不是四个或八个空格
	protoc -I. --go_out=plugins=grpc:$(GOPATH)/src/shippy/consignment-service/proto/consignment/consignment.proto

执行 make build,会在 proto/consignment 目录下生成 consignment.pb.go

consignment.proto 与 consignment.pb.go 的对应关系

service:定义了微服务 ShippingService 要暴露为外界调用的函数:CreateConsignment,由 protobuf 编译器的 grpc 插件处理后生成 interface

1
2
3
4
type ShippingServiceClient interface {
	// 托运一批货物
	CreateConsignment(ctx context.Context, in *Consignment, opts ...grpc.CallOption) (*Response, error)
}

message:定义了通讯的数据格式,由 protobuf 编译器处理后生成 struct

1
2
3
4
5
6
7
type Consignment struct {
	Id           string       `protobuf:"bytes,1,opt,name=id" json:"id,omitempty"`
	Description  string       `protobuf:"bytes,2,opt,name=description" json:"description,omitempty"`
	Weight       int32        `protobuf:"varint,3,opt,name=weight" json:"weight,omitempty"`
	Containers   []*Container `protobuf:"bytes,4,rep,name=containers" json:"containers,omitempty"`
    // ...
}

实现服务端

服务端需实现 ShippingServiceClient 接口,建立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
package main

import (
    // 导如 protoc 自动生成的包
	pb "shippy/consignment-service/proto/consignment"
	"context"
	"net"
	"log"
	"google.golang.org/grpc"
)

const (
	PORT = ":50051"
)

//
// 仓库接口
//
type IRepository interface {
	Create(consignment *pb.Consignment) (*pb.Consignment, error) // 存放新货物
}

//
// 咱们存放多批货物的仓库,实现了 IRepository 接口
//
type Repository struct {
	consignments []*pb.Consignment
}

func (repo *Repository) Create(consignment *pb.Consignment) (*pb.Consignment, error) {
	repo.consignments = append(repo.consignments, consignment)
	return consignment, nil
}

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

//
// 定义微服务
//
type service struct {
	repo Repository
}

//
// service 实现 consignment.pb.go 中的 ShippingServiceServer 接口
// 使 service 做为 gRPC 的服务端
//
// 托运新的货物
func (s *service) CreateConsignment(ctx context.Context, req *pb.Consignment) (*pb.Response, error) {
	// 接收承运的货物
	consignment, err := s.repo.Create(req)
	if err != nil {
		return nil, err
	}
	resp := &pb.Response{Created: true, Consignment: consignment}
	return resp, nil
}

func main() {
	listener, err := net.Listen("tcp", PORT)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	log.Printf("listen on: %s\n", PORT)

	server := grpc.NewServer()
	repo := Repository{}

    // 向 rRPC 服务器注册微服务
    // 此时会把咱们本身实现的微服务 service 与协议中的 ShippingServiceServer 绑定
	pb.RegisterShippingServiceServer(server, &service{repo})

	if err := server.Serve(listener); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

上边的代码实现了 consignment-service 微服务所须要的方法,并创建了一个 gRPC 服务器监听 50051 端口。若是你此时运行 go run main.go,将成功启动服务端:

image-20180512051413002

实现客户端

咱们将要托运的货物信息放到 consignment-cli/consignment.json

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

客户端会读取这个 JSON 文件并将该货物托运。在项目目录下新建文件:consingment-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
58
59
60
61
62
63
64
65
package main

import (
	pb "shippy/consignment-service/proto/consignment"
	"io/ioutil"
	"encoding/json"
	"errors"
	"google.golang.org/grpc"
	"log"
	"os"
	"context"
)

const (
	ADDRESS           = "localhost:50051"
	DEFAULT_INFO_FILE = "consignment.json"
)

// 读取 consignment.json 中记录的货物信息
func parseFile(fileName string) (*pb.Consignment, error) {
	data, err := ioutil.ReadFile(fileName)
	if err != nil {
		return nil, err
	}
	var consignment *pb.Consignment
	err = json.Unmarshal(data, &consignment)
	if err != nil {
		return nil, errors.New("consignment.json file content error")
	}
	return consignment, nil
}

func main() {
	// 链接到 gRPC 服务器
	conn, err := grpc.Dial(ADDRESS, grpc.WithInsecure())
	if err != nil {
		log.Fatalf("connect error: %v", err)
	}
	defer conn.Close()

	// 初始化 gRPC 客户端
	client := pb.NewShippingServiceClient(conn)

	// 在命令行中指定新的货物信息 json 文件
	infoFile := DEFAULT_INFO_FILE
	if len(os.Args) > 1 {
		infoFile = os.Args[1]
	}

	// 解析货物信息
	consignment, err := parseFile(infoFile)
	if err != nil {
		log.Fatalf("parse info file error: %v", err)
	}

	// 调用 RPC
	// 将货物存储到咱们本身的仓库里
	resp, err := client.CreateConsignment(context.Background(), consignment)
	if err != nil {
		log.Fatalf("create consignment error: %v", err)
	}

	// 新货物是否托运成功
	log.Printf("created: %t", resp.Created)
}

运行 go run main.go 后再运行 go run cli.go

grpc-runing

咱们能够新增一个 RPC 查看全部被托运的货物,加入一个GetConsignments方法,这样,咱们就能看到全部存在的consignment了:

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
// shipper/consignment-service/proto/consignment/consignment.proto

syntax = "proto3";

package go.micro.srv.consignment;

// 货轮微服务
service ShippingService {
    // 托运一批货物
    rpc CreateConsignment (Consignment) returns (Response) {
    }
    // 查看托运货物的信息
    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;     // 集装箱所属用户的编号
}

// 托运结果
message Response {
    bool created = 1;                       // 托运成功
    Consignment consignment = 2;            // 新托运的货物
    repeated Consignment consignments = 3;  // 目前全部托运的货物
}

// 查看货物信息的请求
// 客户端想要从服务端请求数据,必须有请求格式,哪怕为空
message GetRequest {
}

如今运行make build来得到最新编译后的微服务界面。若是此时你运行go run main.go,你会得到一个相似这样的错误信息:

image-20180512020710310

熟悉Go的你确定知道,你忘记实现一个interface所须要的方法了。让咱们更新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
package main

import (
	pb "shippy/consignment-service/proto/consignment"
	"context"
	"net"
	"log"
	"google.golang.org/grpc"
)

const (
	PORT = ":50051"
)

//
// 仓库接口
//
type IRepository interface {
	Create(consignment *pb.Consignment) (*pb.Consignment, error) // 存放新货物
	GetAll() []*pb.Consignment                                   // 获取仓库中全部的货物
}

//
// 咱们存放多批货物的仓库,实现了 IRepository 接口
//
type Repository struct {
	consignments []*pb.Consignment
}

func (repo *Repository) Create(consignment *pb.Consignment) (*pb.Consignment, error) {
	repo.consignments = append(repo.consignments, consignment)
	return consignment, nil
}

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

//
// 定义微服务
//
type service struct {
	repo Repository
}

//
// 实现 consignment.pb.go 中的 ShippingServiceServer 接口
// 使 service 做为 gRPC 的服务端
//
// 托运新的货物
func (s *service) CreateConsignment(ctx context.Context, req *pb.Consignment) (*pb.Response, error) {
	// 接收承运的货物
	consignment, err := s.repo.Create(req)
	if err != nil {
		return nil, err
	}
	resp := &pb.Response{Created: true, Consignment: consignment}
	return resp, nil
}

// 获取目前全部托运的货物
func (s *service) GetConsignments(ctx context.Context, req *pb.GetRequest) (*pb.Response, error) {
	allConsignments := s.repo.GetAll()
	resp := &pb.Response{Consignments: allConsignments}
	return resp, nil
}

func main() {
	listener, err := net.Listen("tcp", PORT)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	log.Printf("listen on: %s\n", PORT)

	server := grpc.NewServer()
	repo := Repository{}
	pb.RegisterShippingServiceServer(server, &service{repo})

	if err := server.Serve(listener); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

若是如今使用go run main.go,一切应该正常:

image-20180512020218724

最后让咱们更新consignment-cli/cli.go来得到consignment信息:

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

	// 列出目前全部托运的货物
	resp, err = client.GetConsignments(context.Background(), &pb.GetRequest{})
	if err != nil {
		log.Fatalf("failed to list consignments: %v", err)
	}
	for _, c := range resp.Consignments {
		log.Printf("%+v", c)
	}
}

此时再运行go run cli.go,你应该能看到所建立的全部consignment,屡次运行将看到多个货物被托运:

Jietu20180512-053129-HD

至此,咱们使用protobuf和grpc建立了一个微服务以及一个客户端。

在下一篇文章中,咱们将介绍使用go-micro框架,以及建立咱们的第二个微服务。同时在下一篇文章中,咱们将介绍如何容Docker来容器化咱们的微服务。

参考文献:

https://wuyin.io/2018/05/10/microservices-part-1-introduction-and-consignment-service/