本節對 gRPC 的使用淺嘗輒止,更多可參考:gRPC 中 Client 與 Server 數據交互的 4 種模式
前言
系列概覽
《Golang 微服務教程》分爲 10 篇,總結微服務開發、測試到部署的完整過程。
本節先介紹微服務的基礎概念、術語,再創建我們的第一個微服務 consignment-service 的簡潔版。在接下來的第 2~10 節文章中,我們會陸續創建以下微服務:
- consignment-service(貨運服務)
- inventory-service(倉庫服務)
- user-service(用戶服務)
- authentication-service(認證服務)
- role-service (角色服務)
- vessel-service(貨船服務)
用到的完整技術棧如下:
Golang, gRPC, go-micro // 開發語言及其 RPC 框架
Google Cloud, MongoDB // 雲平臺與數據存儲
Docker, Kubernetes, Terrafrom // 容器化與集羣架構
NATS, CircleCI // 消息系統與持續集成
代碼倉庫
作者代碼:EwanValentine/shippy,譯者的中文註釋代碼: wuYin/shippy
每個章節對應倉庫的一個分支,比如本文part1 的代碼在 feature/part1
開發環境
筆者的開發環境爲 macOS,本文中使用了 make 工具來高效編譯,Windows 用戶需 手動安裝
$ go env
GOARCH="amd64" # macOS 環境
GOOS="darwin" # 在第二節使用 Docker 構建 alpine 鏡像時需修改爲 linux
GOPATH="/Users/wuyin/Go"
GOROOT="/usr/local/go"
準備
掌握 Golang 的基礎語法:推薦閱讀謝大的《Go Web 編程》
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 架構中用得很多,以方便瀏覽器解析:
{
"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
現在你的項目結構應該如下:
$GOPATH/src
└── shippy
└── consignment-service
└── proto
└── consignment
└── consignment.proto
開發流程
定義 protobuf 通信協議文件
// 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
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
type ShippingServiceClient interface {
// 託運一批貨物
CreateConsignment(ctx context.Context, in *Consignment, opts ...grpc.CallOption) (*Response, error)
}
message:定義了通信的數據格式,由 protobuf 編譯器處理後生成 struct
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
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
:
{
"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
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
了:
// 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
:
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
信息:
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來容器化我們的微服務。