譯文連接:wuYin/blog
原文連接:ewanvalentine.io,翻譯已獲做者 Ewan Valentine 受權。html
本文完整代碼:GitHubgit
在上節中,咱們使用 go-micro 從新實現了微服務並進行了 Docker 化,可是每一個微服務都要單獨維護本身的 Makefile 未免過於繁瑣。本節將學習 docker-compose 來統一管理和部署微服務,引入第三個微服務 user-service 並進行存儲數據。github
到目前爲止,consignment-cli 要託運的貨物數據直接存儲在 consignment-service 管理的內存中,當服務重啓時這些數據將會丟失。爲了便於管理和搜索貨物信息,需將其存儲到數據庫中。golang
能夠爲每一個獨立運行的微服務提供獨立的數據庫,不過由於管理繁瑣少有人這麼作。如何爲不一樣的微服務選擇合適的數據庫,可參考:How to choose a database for your microservicesmongodb
若是對存儲數據的可靠性、一致性要求不那麼高,那 NoSQL 將是很好的選擇,由於它能存儲的數據格式十分靈活,好比經常將數據存爲 JSON 進行處理,在本節中選用性能和生態俱佳的MongoDBdocker
若是要存儲的數據自己就比較完整,數據之間關係也有較強關聯性的話,能夠選用關係型數據庫。事先捋一下要存儲數據的結構,根據業務看一下是讀更多仍是寫更多?高頻查詢的復不復雜?… 鑑於本文的較小的數據量與操做,做者選用了 Postgres,讀者可自行更換爲 MySQL 等。shell
更多參考:如何選擇NoSQL數據庫、梳理關係型數據庫和NoSQL的使用情景數據庫
上節把微服務 Docker 化後,使其運行在輕量級、只包含服務必需依賴的容器中。到目前爲止,要想啓動微服務的容器,均在其 Makefile 中 docker run
的同時設置其環境變量,服務多了之後管理起來十分麻煩。安全
docker-compose 工具能直接用一個 docker-compose.yaml
來編排管理多個容器,同時設置各容器的 metadata 和 run-time 環境(環境變量),文件的 service
配置項來像先前 docker run
命令同樣來啓動容器。舉個例子:session
docker 命令管理容器
$ docker run -p 50052:50051 \ -e MICRO_SERVER_ADDRESS=:50051 \ -e MICRO_REGISTRY=mdns \ vessel-service
等效於 docker-compose 來管理
version: '3.1' vessel-service: build: ./vessel-service ports: - 50052:50051 environment: MICRO_ADRESS: ":50051" MICRO_REGISTRY: "mdns"
想加減和配置微服務,直接修改 docker-compose.yaml,是十分方便的。
針對當前項目,使用 docker-compose 管理 3 個容器,在項目根目錄下新建文件:
# docker-compose.yaml # 一樣遵循嚴格的縮進 version: '3.1' # services 定義容器列表 services: consignment-cli: build: ./consignment-cli environment: MICRO_REGISTRY: "mdns" consignment-service: build: ./consignment-service ports: - 50051:50051 environment: MICRO_ADRESS: ":50051" MICRO_REGISTRY: "mdns" DB_HOST: "datastore:27017" vessel-service: build: ./vessel-service ports: - 50052:50051 environment: MICRO_ADRESS: ":50051" MICRO_REGISTRY: "mdns"
首先,咱們指定了要使用的 docker-compose 的版本是 3.1,而後使用 services
來列出了三個待管理的容器。
每一個微服務都定義了本身容器的名字, build
指定目錄下的 Dockerfile 將會用來編譯鏡像,也能夠直接使用 image
選項直接指向已編譯好的鏡像(後邊會用到);其餘選項則指定了容器的端口映射規則、環境變量等。
可以使用 docker-compose build
來編譯生成三個對應的鏡像;使用 docker-compose run
來運行指定的容器, docker-compose up -d
可在後臺運行;使用 docker stop $(docker ps -aq )
來中止全部正在運行的容器。
使用 docker-compose 的運行效果以下:
到目前爲止,咱們的兩個 protobuf 協議文件,定義了微服務客戶端與服務端數據請求、響應的數據結構。因爲 protobuf 的規範性,也可將其生成的 struct 做爲數據庫表 Model 進行數據操做。這種複用有其侷限性,好比 protobuf 中數據類型必須與數據庫表字段嚴格一致,兩者是高耦合的。不少人並不贊將 protobuf 數據結構做爲數據庫中的表結構:Do you use Protobufs in place of structs ?
通常來講,在表結構變化後與 protobuf 不一致,須要在兩者之間作一層邏輯轉換,處理差別字段:
func (service *Service) (ctx context.Context, req *proto.User, res *proto.Response) error { entity := &models.User{ Name: req.Name. Email: req.Email, Password: req.Password, } err := service.repo.Create(entity) // 無中間轉換層 // err := service.repo.Create(req) ... }
這樣隔離數據庫實體 models 和 proto.* 結構體,彷佛很方便。但當 .proto 中定義 message 各類嵌套時,models 也要對應嵌套,比較麻煩。
上邊隔不隔離由讀者自行決定,就我我的而言,中間用 models 作轉換是不太有必要的,protobuf 已足夠規範,直接使用便可。
回頭看第一個微服務 consignment-service,會發現服務端實現、接口實現等都往 main.go 裏邊塞,功能跑通了,如今要拆分代碼,使項目結構更加清晰,更易維護。
對於熟悉 MVC 開發模式的同窗來講,可能會把代碼按功能拆分到不一樣目錄中,好比:
main.go models/ user.go handlers/ auth.go user.go services/ auth.go
不過這種組織方式並非 Golang 的 style,由於微服務是切割出來獨立的,要作到簡潔明瞭。對於大型 Golang 項目,應該以下組織:
main.go users/ services/ auth.go handlers/ auth.go user.go users/ user.go containers/ services/ manage.go models/ container.go
這種組織方式叫類別(domain)驅動,而不是 MVC 的功能驅動。
因爲微服務的簡潔性,咱們會把該服務相關的代碼全放到一個文件夾下,同時爲每一個文件起一個合適的名字。
在 consignmet-service/ 下建立三個文件:handler.go、datastore.go 和 repository.go
consignmet-service/ ├── Dockerfile ├── Makefile ├── datastore.go # 建立與 MongoDB 的主會話 ├── handler.go # 實現微服務的服務端,處理業務邏輯 ├── main.go # 註冊並啓動服務 ├── proto └── repository.go # 實現數據庫的基本 CURD 操做
package main import "gopkg.in/mgo.v2" // 建立與 MongoDB 交互的主回話 func CreateSession(host string) (*mgo.Session, error) { s, err := mgo.Dial(host) if err != nil { return nil, err } s.SetMode(mgo.Monotonic, true) return s, nil }
鏈接 MongoDB 的代碼夠精簡,傳參是數據庫地址,返回數據庫會話以及可能發生的錯誤,在微服務啓動的時候就會去鏈接數據庫。
如今讓咱們來將 main.go 與數據庫交互的代碼拆解出來,能夠參考註釋加以理解:
package main import (...) const ( DB_NAME = "shippy" CON_COLLECTION = "consignments" ) type Repository interface { Create(*pb.Consignment) error GetAll() ([]*pb.Consignment, error) Close() } type ConsignmentRepository struct { session *mgo.Session } // 接口實現 func (repo *ConsignmentRepository) Create(c *pb.Consignment) error { return repo.collection().Insert(c) } // 獲取所有數據 func (repo *ConsignmentRepository) GetAll() ([]*pb.Consignment, error) { var cons []*pb.Consignment // Find() 通常用來執行查詢,若是想執行 select * 則直接傳入 nil 便可 // 經過 .All() 將查詢結果綁定到 cons 變量上 // 對應的 .One() 則只取第一行記錄 err := repo.collection().Find(nil).All(&cons) return cons, err } // 關閉鏈接 func (repo *ConsignmentRepository) Close() { // Close() 會在每次查詢結束的時候關閉會話 // Mgo 會在啓動的時候生成一個 "主" 會話 // 你可使用 Copy() 直接從主會話複製出新會話來執行,即每一個查詢都會有本身的數據庫會話 // 同時每一個會話都有本身鏈接到數據庫的 socket 及錯誤處理,這麼作既安全又高效 // 若是隻使用一個鏈接到數據庫的主 socket 來執行查詢,那不少請求處理都會阻塞 // Mgo 所以能在不使用鎖的狀況下完美處理併發請求 // 不過弊端就是,每次查詢結束以後,必須確保數據庫會話要手動 Close // 不然將創建過多無用的鏈接,白白浪費數據庫資源 repo.session.Close() } // 返回全部貨物信息 func (repo *ConsignmentRepository) collection() *mgo.Collection { return repo.session.DB(DB_NAME).C(CON_COLLECTION) }
package main import (...) const ( DEFAULT_HOST = "localhost:27017" ) func main() { // 獲取容器設置的數據庫地址環境變量的值 dbHost := os.Getenv("DB_HOST") if dbHost == ""{ dbHost = DEFAULT_HOST } session, err := CreateSession(dbHost) // 建立於 MongoDB 的主會話,需在退出 main() 時候手動釋放鏈接 defer session.Close() if err != nil { log.Fatalf("create session error: %v\n", err) } server := micro.NewService( // 必須和 consignment.proto 中的 package 一致 micro.Name("go.micro.srv.consignment"), micro.Version("latest"), ) // 解析命令行參數 server.Init() // 做爲 vessel-service 的客戶端 vClient := vesselPb.NewVesselServiceClient("go.micro.srv.vessel", server.Client()) // 將 server 做爲微服務的服務端 pb.RegisterShippingServiceHandler(server.Server(), &handler{session, vClient}) if err := server.Run(); err != nil { log.Fatalf("failed to serve: %v", err) } }
將 main.go 中實現微服務服務端 interface 的代碼單獨拆解到 handler.go,實現業務邏輯的處理。
package main import (...) // 微服務服務端 struct handler 必須實現 protobuf 中定義的 rpc 方法 // 實現方法的傳參等可參考生成的 consignment.pb.go type handler struct { session *mgo.Session vesselClient vesselPb.VesselServiceClient } // 從主會話中 Clone() 出新會話處理查詢 func (h *handler)GetRepo()Repository { return &ConsignmentRepository{h.session.Clone()} } func (h *handler)CreateConsignment(ctx context.Context, req *pb.Consignment, resp *pb.Response) error { defer h.GetRepo().Close() // 檢查是否有適合的貨輪 vReq := &vesselPb.Specification{ Capacity: int32(len(req.Containers)), MaxWeight: req.Weight, } vResp, err := h.vesselClient.FindAvailable(context.Background(), vReq) if err != nil { return err } // 貨物被承運 log.Printf("found vessel: %s\n", vResp.Vessel.Name) req.VesselId = vResp.Vessel.Id //consignment, err := h.repo.Create(req) err = h.GetRepo().Create(req) if err != nil { return err } resp.Created = true resp.Consignment = req return nil } func (h *handler)GetConsignments(ctx context.Context, req *pb.GetRequest, resp *pb.Response) error { defer h.GetRepo().Close() consignments, err := h.GetRepo().GetAll() if err != nil { return err } resp.Consignments = consignments return nil }
至此,main.go 拆分完畢,代碼文件分工明確,十分清爽。
在 handler.go 的 GetRepo() 中咱們使用 Clone() 來建立新的數據庫鏈接。
可看到在 main.go 中建立主會話後咱們就再也沒用到它,反而使用 session.Clonse()
來建立新的會話進行查詢處理,能夠看 repository.go 中 Close()
的註釋,若是每次查詢都用主會話,那全部請求都是同一個底層 socket 執行查詢,後邊的請求將會阻塞,不能發揮 Go 天生支持併發的優點。
爲了不請求的阻塞,mgo 庫提供了 Copy()
和 Clone()
函數來建立新會話,兩者在功能上相差無幾,但在細微之處卻有重要的區別。Clone 出來的新會話重用了主會話的 socket,避免了建立 socket 在三次握手時間、資源上的開銷,尤爲適合那些快速寫入的請求。若是進行了複雜查詢、大數據量操做時依舊會阻塞 socket 致使後邊的請求阻塞。Copy 爲會話建立新的 socket,開銷大。
應當根據應用場景不一樣來選擇兩者,本文的查詢既不復雜數據量也不大,就直接複用主會話的 socket 便可。不過用完都要 Close(),謹記。
拆解完 consignment-service/main.go 的代碼,如今用一樣的方式重構 vessel-service
咱們在此添加一個方法:添加新的貨輪,更改 protobuf 文件以下:
syntax = "proto3"; package go.micro.srv.vessel; service VesselService { // 檢查是否有能運送貨物的輪船 rpc FindAvailable (Specification) returns (Response) {} // 建立貨輪 rpc Create(Vessel) returns (Response){} } // ... // 貨輪裝得下的話 // 返回的多條貨輪信息 message Response { Vessel vessel = 1; repeated Vessel vessels = 2; bool created = 3; }
咱們建立了一個 Create()
方法來建立新的貨輪,參數是 Vessel 返回 Response,注意 Response 中添加了 created 字段,標識是否建立成功。使用 make build
生成新的 vessel.pb.go 文件。
以後在對應的 repository.go 和 handler.go 中實現 Create()
// vesell-service/repository.go // 完成與數據庫交互的建立動做 func (repo *VesselRepository) Create(v *pb.Vessel) error { return repo.collection().Insert(v) }
// vesell-service/handler.go func (h *handler) GetRepo() Repository { return &VesselRepository{h.session.Clone()} } // 實現微服務的服務端 func (h *handler) Create(ctx context.Context, req *pb.Vessel, resp *pb.Response) error { defer h.GetRepo().Close() if err := h.GetRepo().Create(req); err != nil { return err } resp.Vessel = req resp.Created = true return nil }
兩個微服務均已重構完畢,是時候在容器中引入 MongoDB 了。在 docker-compose.yaml 添加 datastore 選項:
services: ... datastore: image: mongo ports: - 27017:27017
同時更新兩個微服務的環境變量,增長 DB_HOST: "datastore:27017"
,在這裏咱們使用 datastore 作主機名而不是 localhost,是由於 docker 有內置強大的 DNS 機制。參考:docker內置dnsserver工做機制
修改完畢後的 docker-compose.yaml:
# docker-compose.yaml version: '3.1' services: consigment-cli: build: ./consignment-cli environment: MICRO_REGISTRY: "mdns" consignment-service: build: ./consignment-service ports: - 50051:50051 environment: MICRO_ADRESS: ":50051" MICRO_REGISTRY: "mdns" DB_HOST: "datastore:27017" vessel-service: build: ./vessel-service ports: - 50052:50051 environment: MICRO_ADRESS: ":50051" MICRO_REGISTRY: "mdns" DB_HOST: "datastore:27017" datastore: image: mongo ports: - 27017:27017
修改完代碼需從新 make build
,構建鏡像時需 docker-compose build --no-cache
來所有從新編譯。
如今來建立第三個微服務,在 docker-compose.yaml
中引入 Postgres:
... user-service: build: ./user-service ports: - 50053:50051 environment: MICRO_ADDRESS: ":50051" MICRO_REGISTRY: "mdns" ... database: image: postgres ports: - 5432:5432
在項目根目錄下建立 user-service 目錄,而且像前兩個服務那樣依次建立下列文件:
handler.go, main.go, repository.go, database.go, Dockerfile, Makefile,
建立 proto/user/user.proto 且內容以下:
// user-service/user/user.proto syntax = "proto3"; package go.micro.srv.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; Error errors = 3; } message Error { int32 code = 1; string description = 2; }
確保你的 user-service 有像相似前兩個微服務的 Makefile,使用 make build
來生成 gRPC 代碼。
在 handler.go 實現的服務端代碼中,認證模塊將在下一節使用 JWT 作認證。
// user-service/handler.go package main import ( "context" pb "shippy/user-service/proto/user" ) type handler struct { repo Repository } func (h *handler) Create(ctx context.Context, req *pb.User, resp *pb.Response) error { if err := h.repo.Create(req); err != nil { return nil } resp.User = req return nil } func (h *handler) Get(ctx context.Context, req *pb.User, resp *pb.Response) error { u, err := h.repo.Get(req.Id); if err != nil { return err } resp.User = u return nil } func (h *handler) GetAll(ctx context.Context, req *pb.Request, resp *pb.Response) error { users, err := h.repo.GetAll() if err != nil { return err } resp.Users = users return nil } func (h *handler) Auth(ctx context.Context, req *pb.User, resp *pb.Token) error { _, err := h.repo.GetByEmailAndPassword(req) if err != nil { return err } resp.Token = "`x_2nam" return nil } func (h *handler) ValidateToken(ctx context.Context, req *pb.Token, resp *pb.Token) error { return nil }
package main import ( "github.com/jinzhu/gorm" pb "shippy/user-service/proto/user" ) type Repository interface { Get(id string) (*pb.User, error) GetAll() ([]*pb.User, error) Create(*pb.User) error GetByEmailAndPassword(*pb.User) (*pb.User, error) } type UserRepository struct { db *gorm.DB } func (repo *UserRepository) Get(id string) (*pb.User, error) { var u *pb.User u.Id = id if err := repo.db.First(&u).Error; err != nil { return nil, err } return u, nil } func (repo *UserRepository) GetAll() ([]*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) Create(u *pb.User) error { if err := repo.db.Create(&u).Error; err != nil { return err } return nil } func (repo *UserRepository) GetByEmailAndPassword(u *pb.User) (*pb.User, error) { if err := repo.db.Find(&u).Error; err != nil { return nil, err } return u, nil }
咱們將 ORM 建立的 UUID 字符串修改成一個整數,用來做爲表的主鍵或 ID 是比較安全的。MongoDB 使用了相似的技術,可是 Postgres 須要咱們使用第三方庫手動來生成。在 user-service/proto/user
目錄下建立 extension.go 文件:
package go_micro_srv_user import ( "github.com/jinzhu/gorm" uuid "github.com/satori/go.uuid" "github.com/labstack/gommon/log" ) func (user *User) BeforeCreate(scope *gorm.Scope) error { uuid, err := uuid.NewV4() if err != nil { log.Fatalf("created uuid error: %v\n", err) } return scope.SetColumn("Id", uuid.String()) }
函數 BeforeCreate()
指定了 GORM 庫使用 uuid 做爲 ID 列值。參考:doc.gorm.io/callbacks
Gorm 是一個簡單易用輕量級的 ORM 框架,支持 Postgres, MySQL, Sqlite 等數據庫。
到目前三個微服務涉及到的數據量小、操做也少,用原生 SQL 徹底能夠 hold 住,因此是否是要 ORM 取決於你本身。
類比 consignment-service 的測試,如今建立 user-cli 命令行應用來測試 user-service
在項目根目錄下建立 user-cli 目錄,並建立 cli.go 文件:
package main import ( "log" "os" pb "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() // 建立 user-service 微服務的客戶端 client := pb.NewUserServiceClient("go.micro.srv.user", microclient.DefaultClient) // 設置命令行參數 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", }, ), ) service.Init( micro.Action(func(c *cli.Context) { name := c.String("name") email := c.String("email") password := c.String("password") company := c.String("company") 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: %v", 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) }), ) // 啓動客戶端 if err := service.Run(); err != nil { log.Println(err) } }
在此以前,須要手動拉取 Postgres 鏡像並運行:
$ docker pull postgres $ docker run --name postgres -e POSTGRES_PASSWORD=postgres -d -p 5432:5432 postgres
到目前爲止,咱們建立了三個微服務:consignment-service、vessel-service 和 user-service,它們均使用 go-micro 實現並進行了 Docker 化,使用 docker-compose 進行統一管理。此外,咱們還使用 GORM 庫與 Postgres 數據庫進行交互,並將命令行的數據存儲進去。
上邊的 user-cli 僅是測試使用,明文保存密碼一點也不安全。在本節完成基本功能的基礎上,下節將引入 JWT 作驗證。