1. 程式人生 > >Golang 微服務教程(一)

Golang 微服務教程(一)

原文連結:ewanvalentine.io,翻譯已獲作者 Ewan Valentine 授權。

本節對 gRPC 的使用淺嘗輒止,更多可參考:gRPC 中 Client 與 Server 資料互動的 4 種模式

前言

系列概覽

《Golang 微服務教程》分為 10 篇,總結微服務開發、測試到部署的完整過程。

本節先介紹微服務的基礎概念、術語,再建立我們的第一個微服務 consignment-service 的簡潔版。在接下來的第 2~10 節文章中,我們會陸續建立以下微服務:

  • consignment-service(貨運服務)
  • inventory-service(倉庫服務)
  • user-service(使用者服務)
  • authentication-service(認證服務)
  • role-service (角色服務)
  • vessel-service(貨船服務)

用到的完整技術棧如下:

1
2
3
4
Golang, gRPC, go-micro			// 開發語言及其 RPC 框架
Google Cloud, MongoDB			// 雲平臺與資料儲存
Docker, Kubernetes, Terrafrom  	// 容器化與叢集架構
NATS, CircleCI					// 訊息系統與持續整合

程式碼倉庫

作者程式碼:EwanValentine/shippy,譯者的中文註釋程式碼: wuYin/shippy

每個章節對應倉庫的一個分支,比如本文part1 的程式碼在 feature/part1

開發環境

筆者的開發環境為 macOS,本文中使用了 make 工具來高效編譯,Windows 使用者需 手動安裝

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 / protobuf

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/