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 程式設計》
1 2 |
go get -u google.golang.org/grpc # 安裝 gRPC 框架 go get -u github.com/golang/protobuf/protoc-gen-go # 安裝 Go 版本的 protobuf 編譯器 |
微服務
我們要寫什麼專案?
我們要搭建一個港口的貨物管理平臺。本專案以微服務的架構開發,整體簡單且概念通用。閒話不多說讓我們開始微服務之旅吧。
微服務是什麼?
在傳統的軟體開發中,整個應用的程式碼都組織在一個單一的程式碼庫,一般會有以下拆分程式碼的形式:
- 按照特徵做拆分:如 MVC 模式
- 按照功能做拆分:在更大的專案中可能會將程式碼封裝在處理不同業務的包中,包內部可能會再做拆分
不管怎麼拆分,最終二者的程式碼都會集中在一個庫中進行開發和管理,可參考:谷歌的單一程式碼庫管理
微服務是上述第二種拆分方式的拓展,按功能將程式碼拆分成幾個包,都是可獨立執行的單一程式碼庫。區別如下:
微服務有哪些優勢?
降低複雜性
將整個應用的程式碼按功能對應拆分為小且獨立的微服務程式碼庫,這不禁讓人聯想到 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 資料就使用 description
、weight
等元資料來描述資料本身的意義,在 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 |
開發流程
定義 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
生成協議程式碼
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
,將成功啟動服務端:
實現客戶端
我們將要託運的貨物資訊放到 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
:
我們可以新增一個 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
,你會獲得一個類似這樣的錯誤資訊:
熟悉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
,一切應該正常:
最後讓我們更新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
,多次執行將看到多個貨物被託運:
至此,我們使用protobuf和grpc建立了一個微服務以及一個客戶端。
在下一篇文章中,我們將介紹使用go-micro
框架,以及建立我們的第二個微服務。同時在下一篇文章中,我們將介紹如何容Docker來容器化我們的微服務。
參考文獻:
https://wuyin.io/2018/05/10/microservices-part-1-introduction-and-consignment-service/