Golang 微服務教程(三)

譯文連接:wuYin/blog
原文連接:ewanvalentine.io,翻譯已獲做者 Ewan Valentine 受權。html

本文完整代碼:GitHubgit

在上節中,咱們使用 go-micro 從新實現了微服務並進行了 Docker 化,可是每一個微服務都要單獨維護本身的 Makefile 未免過於繁瑣。本節將學習 docker-compose 來統一管理和部署微服務,引入第三個微服務 user-service 並進行存儲數據。github

MongoDB 與 Postgres

微服務的數據存儲

到目前爲止,consignment-cli 要託運的貨物數據直接存儲在 consignment-service 管理的內存中,當服務重啓時這些數據將會丟失。爲了便於管理和搜索貨物信息,需將其存儲到數據庫中。golang

能夠爲每一個獨立運行的微服務提供獨立的數據庫,不過由於管理繁瑣少有人這麼作。如何爲不一樣的微服務選擇合適的數據庫,可參考:How to choose a database for your microservicesmongodb

選擇關係型數據庫與 NoSQL

若是對存儲數據的可靠性、一致性要求不那麼高,那 NoSQL 將是很好的選擇,由於它能存儲的數據格式十分靈活,好比經常將數據存爲 JSON 進行處理,在本節中選用性能和生態俱佳的MongoDBdocker

若是要存儲的數據自己就比較完整,數據之間關係也有較強關聯性的話,能夠選用關係型數據庫。事先捋一下要存儲數據的結構,根據業務看一下是讀更多仍是寫更多?高頻查詢的復不復雜?… 鑑於本文的較小的數據量與操做,做者選用了 Postgres,讀者可自行更換爲 MySQL 等。shell

更多參考:如何選擇NoSQL數據庫梳理關係型數據庫和NoSQL的使用情景數據庫

docker-compose

引入緣由

上節把微服務 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 編排容器

編排當前項目的容器

針對當前項目,使用 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 的運行效果以下:

3.1

Protobuf 與數據庫操做

複用及其侷限性

到目前爲止,咱們的兩個 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 重構

回頭看第一個微服務 consignment-service,會發現服務端實現、接口實現等都往 main.go 裏邊塞,功能跑通了,如今要拆分代碼,使項目結構更加清晰,更易維護。

MVC 代碼結構

對於熟悉 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 的功能驅動。

consignment-service 的重構

因爲微服務的簡潔性,咱們會把該服務相關的代碼全放到一個文件夾下,同時爲每一個文件起一個合適的名字。

在 consignmet-service/ 下建立三個文件:handler.go、datastore.go 和 repository.go

consignmet-service/ 
    ├── Dockerfile
    ├── Makefile
    ├── datastore.go    # 建立與 MongoDB 的主會話
    ├── handler.go        # 實現微服務的服務端,處理業務邏輯
    ├── main.go            # 註冊並啓動服務
    ├── proto
    └── repository.go    # 實現數據庫的基本 CURD 操做

負責鏈接 MongoDB 的 datastore.go

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 的代碼夠精簡,傳參是數據庫地址,返回數據庫會話以及可能發生的錯誤,在微服務啓動的時候就會去鏈接數據庫。

負責與 MongoDB 交互的 repository.go

如今讓咱們來將 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)
}

拆分後的 main.go

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)
    }
}

實現服務端的 handler.go

將 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 拆分完畢,代碼文件分工明確,十分清爽。

mgo 庫的 Copy() 與 Clone()

在 handler.go 的 GetRepo() 中咱們使用 Clone() 來建立新的數據庫鏈接。

可看到在 main.go 中建立主會話後咱們就再也沒用到它,反而使用 session.Clonse() 來建立新的會話進行查詢處理,能夠看 repository.go 中 Close() 的註釋,若是每次查詢都用主會話,那全部請求都是同一個底層 socket 執行查詢,後邊的請求將會阻塞,不能發揮 Go 天生支持併發的優點。

爲了不請求的阻塞,mgo 庫提供了 Copy()Clone() 函數來建立新會話,兩者在功能上相差無幾,但在細微之處卻有重要的區別。Clone 出來的新會話重用了主會話的 socket,避免了建立 socket 在三次握手時間、資源上的開銷,尤爲適合那些快速寫入的請求。若是進行了複雜查詢、大數據量操做時依舊會阻塞 socket 致使後邊的請求阻塞。Copy 爲會話建立新的 socket,開銷大。

應當根據應用場景不一樣來選擇兩者,本文的查詢既不復雜數據量也不大,就直接複用主會話的 socket 便可。不過用完都要 Close(),謹記。

vessel-service 重構

拆解完 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

兩個微服務均已重構完畢,是時候在容器中引入 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 來所有從新編譯。

user-service

引入 Postgres

如今來建立第三個微服務,在 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,

定義 protobuf 文件

建立 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

在 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
}

實現數據庫交互的 repository.go

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
}

使用 UUID

咱們將 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

Gorm 是一個簡單易用輕量級的 ORM 框架,支持  Postgres, MySQL, Sqlite 等數據庫。

到目前三個微服務涉及到的數據量小、操做也少,用原生 SQL 徹底能夠 hold 住,因此是否是要 ORM 取決於你本身。

user-cli

類比 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)
    }
}

測試

運行成功

3.3

在此以前,須要手動拉取 Postgres 鏡像並運行:

$ docker pull postgres
$ docker run --name postgres -e POSTGRES_PASSWORD=postgres -d -p 5432:5432 postgres

用戶數據建立並存儲成功:

image-20180526203001681

總結

到目前爲止,咱們建立了三個微服務:consignment-service、vessel-service 和 user-service,它們均使用 go-micro 實現並進行了 Docker 化,使用 docker-compose 進行統一管理。此外,咱們還使用 GORM 庫與 Postgres 數據庫進行交互,並將命令行的數據存儲進去。

上邊的 user-cli 僅是測試使用,明文保存密碼一點也不安全。在本節完成基本功能的基礎上,下節將引入 JWT 作驗證。

相關文章
相關標籤/搜索