Golang 微服務教程

本節對 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 編程》

安裝 gRPC / protobuf

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 架構中用得很多,以方便瀏覽器解析:

{
  "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

開發流程

image-20180512044329199

定義 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

image-20180512010554833

生成協議代碼

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,將成功啓動服務端:

image-20180512051413002

實現客戶端

我們將要託運的貨物信息放到 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

grpc-runing

我們可以新增一個 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,你會獲得一個類似這樣的錯誤信息:

image-20180512020710310

熟悉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,一切應該正常:

image-20180512020218724

最後讓我們更新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,多次運行將看到多個貨物被託運:

viewuploading.4e448015.gif轉存失敗重新上傳取消Jietu20180512-053129-HD

至此,我們使用protobuf和grpc創建了一個微服務以及一個客戶端。

在下一篇文章中,我們將介紹使用go-micro框架,以及創建我們的第二個微服務。同時在下一篇文章中,我們將介紹如何容Docker來容器化我們的微服務。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章