Go微服務 - 第九部分 - 使用RabbitMQ和AMQP進行訊息傳遞

WalkerQiao發表於2018-05-22

第九部分: Go語言微服務系列 - 使用RabbitMQ和AMQP進行訊息傳遞

本文我們將通過RabbitMQ和AMQP協議在Go微服務之間進行訊息傳遞。

簡介

微服務是將應用程式的業務領域分離成具有清晰分離域的邊界上下文,執行程式分離,其中任何跨域邊界的持久關係必須依賴最終一致性,而不是類似於ACID事務或外來鍵約束。其中很多概念都來自域驅動設計,或受其啟發。領域驅動設計是另外一個很大的話題,足以用一個文章系列來介紹。

在我們Go語言微服務部落格系列的上下文和微服務大體架構中,實現服務間的鬆耦合的一種模式是使用訊息傳遞來進行服務間通訊,不需要嚴格的請求/響應訊息交換或類似的訊息交換。也就是說,使用訊息傳遞只是便於服務間鬆耦合的眾多策略中的一種。

在Spring Cloud中,RabbitMQ似乎是選擇的訊息中間人(代理), 特別是因為在第八部分中我們看到的,Spring Cloud Config伺服器具有RabbitMQ執行時依賴。

本文中,將會讓accountservice服務每當讀取特殊賬號物件時,就在RabbitMQ exchange上放一條訊息。這個訊息會被一個我們本文所實現的全新微服務消費。我們也將處理Go程式碼在多微服務間的複用問題,將多服務複用程式碼放在common類庫中,這樣每個微服務都可以import它。

還記得我們在第一部分中的系統景觀的圖片嗎? 下面是在本部分完成之後看起來的樣子:

clipboard.png

  • 實現集中配置服務
  • 實現服務間通訊的訊息傳遞
  • 實現兩個微服務accountservice和vipservice

依然還有很多元素尚未實現。 不要擔心,我們慢慢都會做到的。

原始碼

這一部分有很多原始碼,本文不會包含所有程式碼。 要檢視完整程式碼,可克隆並切換到P9分支,或者直接檢視https://github.com/callistaen...

傳送訊息

我們將實現一個簡單的虛構(make-believe)用例: 當特定VIP賬號在讀取accountservice服務時,我們希望通知一個vip offer服務,在某些情況下,它將為賬戶持有人產生"offer"。在適當設計的領域模型中,賬戶物件和VIP offer物件時兩個獨立領域,它們應該儘可能少的互相瞭解。

clipboard.png

換言之,accountservice不能直接訪問VIP服務的儲存。這個例子中,我們通過RabbitMQ傳遞一個訊息給vipservice, 完全將業務邏輯和持久化都委託給vipservice。

我們將使用AMQP協議做所有通訊,這個協議是面向互操作性訊息傳遞的ISO標準應用程式層協議。我們的選擇使用的Go類庫是streadway/amqp, 類似在第八部分中我們消費配置更新時候使用的。

讓我們重複在AMQP中exchange和publisher, consumer和queue之間的關係:
clipboard.png

也就是說訊息被髮布到exchange, 然後將訊息副本基於路由規則和可能已經註冊消費者的繫結分佈到queue。在quora.com網站上的這個帖子對這個話題進行了很好的解釋。

Thread vs Post: 在論壇中,常用Thread和Post代指某些東西。但是這兩者有什麼區別呢?
通俗的講Thread就是論壇中最初發起的某個主題的話題, 包含很多Post(A thread is a group of posts on a single topic.)。中文社群通常所謂的樓主發的第一個東西。 而Post則是對樓主最初發的內容做的回覆或跟帖。
參考連結: https://www.drupal.org/projec...

為什麼RabbitMQ有Queue,還要有Exchange?

現實中的(Quora中的答案)例子:

假設你在Apple商店裡邊,先要買耳機。 店裡就會有人過來問你:"需要什麼?" 你告訴他你需要買耳機,然後他就把你帶到他的同事的櫃檯前的排隊佇列之後等待。因為很多其他人也在買東西,銷售員正在處理佇列前面的那個消費者。 如果這個時候,另外一個人進店了,剛才招呼你的人會同樣詢問對方需要什麼幫助。剛進來的人需要修下手機,被找呼的人帶到了另外一個修理手機的櫃檯等待了。

這個例子中問你需要什麼的人就是exchange, 他會根據需要把你路由到恰當的佇列中排隊等待。在佇列的後面有很多員工,也就是對應佇列的worker, 或者消費者。一次處理一個請求,基於先進先出的原則。也可能會根據最先到的人做一個簡單輪詢。

如果店裡沒有導流的服務員,那麼你就需要來回在每個櫃檯前來回問是否能幫到你,直到找到你需要辦理業務的櫃檯後開始排隊。

當然,導航蘋果商店的工作不復雜,但在應用程式中,你可能有很多佇列,服務不同型別的請求,基於路由和繫結具有交換路由訊息的鍵來說非常有幫助。 釋出者只需要關心新增正確的路由密匙,而消費者只需要關心用正確的繫結密匙建立正確的佇列,就可以做到"我對這些訊息感興趣。"

訊息傳遞程式碼

既然我們需要在accountservice和vipservice中使用訊息傳遞程式碼和從Spring Cloud Config伺服器上載入配置的程式碼,我們可以建立可共享的庫。

我們在goblog目錄下面建立一個common目錄來儲存我們可複用的東西:

mkdir -p common/messaging
mkdir -p common/config

我們將所有AMQP相關的程式碼放在messaging目錄,配置相關的放在config目錄。這樣你可以把之前的goblog/accountservice/config中的程式碼移到common/config目錄中,並相應的修改import語句中的程式碼位置。可以看看已完成程式碼看它是如何支援的。

訊息傳遞程式碼在單獨檔案中封裝起來,裡邊定義了我們應用將用於連線、釋出和訂閱的介面以及具體實現。老實說,對於使用streadway/amqp的AMQP訊息傳遞來說有很多樣板程式碼,因此無需在意程式碼的實現細節。

在common/messaging/下面建立一個messagingclient.go檔案:

package messaging

import (
    "github.com/streadway/amqp"
    "fmt"
    "log"
)

// Defines our interface for connecting and consuming messages.
type IMessagingClient interface {
    ConnectToBroker(connectionString string)
    Publish(msg []byte, exchangeName string, exchangeType string) error
    PublishOnQueue(msg []byte, queueName string) error
    Subscribe(exchangeName string, exchangeType string, consumerName string, handlerFunc func(amqp.Delivery)) error
    SubscribeToQueue(queueName string, consumerName string, handlerFunc func(amqp.Delivery)) error
    Close()
}

// Real implementation, encapsulates a pointer to an amqp.Connection
type MessagingClient struct {
    conn *amqp.Connection
}

上面程式碼片段,定義了messaging的介面。 這就是accountservice和vipservice需要訊息傳遞的時候需要使用它們進行處理的,希望能從很多複雜的東西里邊抽象出來。注意我已經選擇兩種變體"Product"和"Consume"來使用topics和direct/queue訊息模式。

接下來,我們定義了一個儲存amqp.Connection指標的結構體,我們會將必要的方法繫結到它上面(隱式的,因為Go語言中都是這樣乾的), 這樣就實現了我們宣告的介面。

func (m *MessagingClient) ConnectToBroker(connectionString string) {
    if connectionString == "" {
        panic("Cannot initialize connection to broker, connectionString not set. Have you initialized?")
    }

    var err error
    m.conn, err = amqp.Dial(fmt.Sprintf("%s/", connectionString))
    if err != nil {
        panic("Failed to connect to AMQP compatible broker at: " + connectionString)
    }
}

func (m *MessagingClient) PublishOnQueue(body []byte, queueName string) error {
    if m.conn == nil {
            panic("Tried to send message before connection was initialized. Don't do that.")
    }
    ch, err := m.conn.Channel()      // Get a channel from the connection
    defer ch.Close()

    queue, err := ch.QueueDeclare(// Declare a queue that will be created if not exists with some args
        queueName, // our queue name
        false, // durable
        false, // delete when unused
        false, // exclusive
        false, // no-wait
        nil, // arguments
    )

    // Publishes a message onto the queue.
    err = ch.Publish(
        "", // exchange
        queue.Name, // routing key
        false, // mandatory
        false, // immediate
        amqp.Publishing{
            ContentType: "application/json",
            Body:        body, // Our JSON body as []byte
        })
    fmt.Printf("A message was sent to queue %v: %v", queueName, body)
    return err
}

ConnectToBroker中展示了我們如何獲取連線指標的,例如amqp.Dial方法。如果我們沒有配置或者無法連線我們的broker, 會panic我們的微服務,容器編排會嘗試使用新例項重新嘗試。 傳入的連線字串就像這樣:

amqp://guest:guest@rabbitmq:5672/

注意我們現在使用的是Docker Swarm模式下的RabbitMQ broker的服務名。

PublishOnQueue()函式相當長,它或多或少是從官方例子派生過來的,這裡我對其進行了簡化,帶比較少的引數。要釋出訊息到命名佇列,我們需要傳入的引數有:

  • body: 以位元組陣列形式傳入。 可以是JSON,XML或一些二進位制。
  • queueName: 要傳送訊息到的目標佇列名字。

要了解更多exchange的詳情,可以參考RabbitMQ的官方文件

PublishOnQueue()方法樣本程式碼使用的很重,但是很容易理解。宣告佇列(如果不存在就建立它), 然後釋出我們的[]byte訊息到它裡邊。釋出訊息到命名exchange更加複雜,它需要樣板程式碼首先宣告一個exchange,一個佇列,然後實現將它們繫結一起的程式碼。 詳細請檢視完整程式碼

繼續,實際使用我們MessagingClient的是在goblog/accountservice/service/handlers.go中,因此我們新增一個欄位,並硬編碼檢查是否為VIP, 然後如果請求賬號id是10000的話,我們就傳送一個訊息傳遞。

var DBClient dbclient.IBoltClient
var MessagingClient messaging.IMessagingClient  // 新增新行
var isHealthy = true

func GetAccount(w http.ResponseWriter, r *http.Request) {
    // Read the 'accountId' path parameter from the mux map
    var accountId = mux.Vars(r)["accountId"]

    // Read the account struct BoltDB
    account, err := DBClient.QueryAccount(accountId)
    account.ServedBy = util.GetIP()

    // If err, return a 404
    if err != nil {
        fmt.Println("Some error occured serving " + accountId + ": " + err.Error())
        w.WriteHeader(http.StatusNotFound)
        return
    }

    notifyVIP(account)   // 新增新行 同時傳送VIP通知。

    // NEW call the quotes-service
    quote, err := getQuote()
    if err == nil {
        account.Quote = quote
    }

    // If found, marshal into JSON, write headers and content
    data, _ := json.Marshal(account)
    writeJsonResponse(w, http.StatusOK, data)
}

// If our hard-coded "VIP" account, spawn a goroutine to send a message.
func notifyVIP(account model.Account) {
    if account.Id == "10000" {
        go func(account model.Account) {
            vipNotification := model.VipNotification{AccountId: account.Id, ReadAt: time.Now().UTC().String()}
            data, _ := json.Marshal(vipNotification)
            fmt.Printf("Notifying VIP account %v\n", account.Id)
            err := MessagingClient.PublishOnQueue(data, "vip_queue")
            if err != nil {
                fmt.Println(err.Error())
            }
        }(account)
    }
}

藉此機會,我們展示呼叫新goroutine的內聯匿名函式, 也就是說使用了go關鍵詞的。既然我們沒有什麼理由在傳送訊息傳遞的時候需要阻塞執行HTTP處理的主goroutine, 那麼這種情況就是使用goroutine實現並行的最佳時機。

main.go檔案也需要更新一點程式碼以便可以在啟動的時候使用載入的並注入到Viper中的配置來初始化AMQ連線。

...
func main() {
    fmt.Printf("Starting %v\n", appName)

    config.LoadConfigurationFromBranch(
        viper.GetString("configServerUrl"),
        appName,
        viper.GetString("profile"),
        viper.GetString("configBranch"))
    initializeBoltClient()
    initializeMessaging()     // 新增行,初始化訊息傳遞
    handleSigterm(func() {
        service.MessagingClient.Close()
    })
    service.StartWebServer(viper.GetString("server_port"))
}

func initializeMessaging() {
    if !viper.IsSet("amqp_server_url") {
        panic("No 'amqp_server_url' set in configuration, cannot start")
    }

    service.MessagingClient = &messaging.MessagingClient{}
    service.MessagingClient.ConnectToBroker(viper.GetString("amqp_server_url"))
    service.MessagingClient.Subscribe(viper.GetString("config_event_bus"), "topic", appName, config.HandleRefreshEvent)
}
...

沒有什麼大不了的東西 - 我們建立一個空的MessagingClient例項並將其地址賦值給service.MessagingClient, 然後使用配置amqp_server_url來呼叫ConnectToBroker方法。如果配置中沒有broker_url,我們就panic()退出,因為我們不希望在甚至都沒有可能連線到broker的情況下執行服務。

如果成功的連線到broker, 那麼我們就呼叫Subscribe方法來訂閱由配置指定的topic。

更新配置

我們在我們的.yml配置檔案中新增amqp_broker_url屬性到第八部分中的配置檔案中,這些東西已經沒有人管了。

broker_url: amqp://guest:guest@192.168.99.100:5672 _(dev)_   

broker_url: amqp://guest:guest@rabbitmq:5672 _(test)_

注意test profile, 我們使用的是Swarm服務名"rabbitmq", 而不是我筆記本上看到的Swarm的網路IP地址。(你實際的IP地址可能會變化,192.168.99.100似乎是執行Docker Toolbox的標準IP)。

配置檔案中使用明文的使用者名稱和密碼是不推薦的,在現實生活中,我們一般會使用第八部分中看到的Spring Cloud Config伺服器內建的加密特性。

單元測試

當然,我們應該至少編寫一個單元測試,確保我們handlers.go中的GetAccount函式當某人請求神奇的並非常特殊的賬號標識為10000的賬號時嘗試傳送一個訊息。為此,我們需要模擬IMessagingClient和handlers_test.go中新增新的測試用例實現。讓我們開始模擬吧。 這次我們將使用第三方工具mockery來產生IMessagingClient介面的實現:(記住在命令列執行這些命令的時候使用恰當的GOPATH設定)。

> go get github.com/vektra/mockery/.../
> cd $GOPATH/src/github.com/callistaenterprise/goblog/common/messaging
> ./$GOPATH/bin/mockery -all -output .
  Generating mock for: IMessagingClient

我們現在在當前目錄有一個IMessagingClient.go模擬檔案。 我不太喜歡這樣的檔名字,不喜歡駝峰,所以我將它重新命名為一個明顯的東西,它模擬並遵循本部落格系列中檔名的約定。

mv IMessagingClient.go mockmessagingclient.go

可能需要調整一般檔案中的import語句,刪除import別名。 除了那些,我們使用一個黑盒方式來達到這個特殊模擬 - 僅假設它在我們開始寫測試的時候會工作。

請隨意檢查生成的模擬實現的原始碼,它非常類似我們之前第四部分中手工寫的東西。

切到handlers_test.go,我們新增一個新的測試用例:

// declare mock types to make test code a bit more readable
var anyString = mock.AnythingOfType("string")
var anyByteArray = mock.AnythingOfType("[]uint8")  // == []byte

func TestNotificationIsSentForVIPAccount(t *testing.T) {
    // Set up the DB client mock
    mockRepo.On("QueryAccount", "10000").Return(model.Account{Id:"10000", Name:"Person_10000"}, nil)
    DBClient = mockRepo

    mockMessagingClient.On("PublishOnQueue", anyByteArray, anyString).Return(nil)
    MessagingClient = mockMessagingClient

    Convey("Given a HTTP req for a VIP account", t, func() {
        req := httptest.NewRequest("GET", "/accounts/10000", nil)
        resp := httptest.NewRecorder()
        Convey("When the request is handled by the Router", func() {
            NewRouter().ServeHTTP(resp, req)
            Convey("Then the response should be a 200 and the MessageClient should have been invoked", func() {
                So(resp.Code, ShouldEqual, 200)
                time.Sleep(time.Millisecond * 10)    // Sleep since the Assert below occurs in goroutine
                So(mockMessagingClient.AssertNumberOfCalls(t, "PublishOnQueue", 1), ShouldBeTrue)
            })
    })})
}

可以檢視註釋瞭解詳情。我不喜歡在斷言呼叫數之前人為新增10毫秒睡眠,但由於模擬是在goroutine中呼叫,和主執行緒是獨立的,我們需要允許它有一些時間來完成。 希望在涉及到有goroutine或者channel的時候,有更好的單元測試方式。

我承認,模擬這種方式比使用類似Mockito的東西更冗餘, 當寫Java應用的單元測試的時候。不過,我認為可讀性和易讀性還是不錯的。

確保測試通過:

go test ./...

執行

如果你還沒有做的話,先執行springcloud.sh指令碼更新配置伺服器。 然後,執行copyall.sh並等幾秒鐘更新accountservice。我們將使用curl來獲取我們特殊的賬號:

> curl http://$ManagerIP:6767/accounts/10000
{"id":"10000","name":"Person_0","servedBy":"10.255.0.11"}

如果所有進行順利的話,我們可以開啟RabbitMQ管理控制檯,並看我們是否在名為vipQueue的佇列上獲得了一個訊息。

clipboard.png

在上面截圖最底下,我們看到vipQueue有一個訊息。如果我們使用RabbitMQ管理控制檯的Get Message功能, 我們會看到下面的訊息:

clipboard.png

在Go語言中實現消費者 - vipservice

最後,是時候從頭開始寫一個全新的微服務了, 我們需要用它來展示如何從RabbitMQ消費訊息。我們將確保應用在前面內容中學到的模式包括:

  • HTTP伺服器
  • 健康檢查
  • 集中化配置管理
  • 訊息傳遞碼複用

如果你已經切出P9分支的程式碼了,那麼在你goblog目錄下面就已經有了vipservice了。
我不會一行行過每個程式碼檔案的內容,因為有些和accountservice裡邊的重複了。相反我將聚焦在剛才傳送訊息的消費方面。需要注意一些事情:

  • 在config-repo倉庫新增了兩個新的.yml檔案,vipservice-dev.yml和vipservice-test.yml。
  • copyall.sh指令碼更新了,讓它同時構建和部署accountservice和vipservice。

消費訊息

我們會使用common/messaging的SubscribeToQueue函式,例如:

SubscribeToQueue(queueName string, consumerName string, handlerFunc func(amqp.Delivery)) error

這裡我們應該提供的最重要的是:

  • 佇列的名字(例如: vip_queue)。
  • 消費者名字(我們是誰)。
  • 處理器函式,它將使用一個amqp.Delivery引數來呼叫 - 和第八部分中我們消費配置更新非常類似。

實際上將我們的回撥函式繫結到佇列的SubscribeToQueue實現的實現並不奇怪,如果我們需要了解細節,可以查閱原始碼

繼續快速看看vipservice的入口檔案main.go, 看看我們如何設定的:

package main

import (
    "flag"
    "fmt"
    "github.com/callistaenterprise/goblog/common/config"
    "github.com/callistaenterprise/goblog/common/messaging"
    "github.com/callistaenterprise/goblog/vipservice/service"
    "github.com/spf13/viper"
    "github.com/streadway/amqp"
    "os"
    "os/signal"
    "syscall"
)

var appName = "vipservice"

var messagingClient messaging.IMessagingClient

func init() {
    configServerUrl := flag.String("configServerUrl", "http://configserver:8888", "Address to config server")
    profile := flag.String("profile", "test", "Environment profile, something similar to spring profiles")
    configBranch := flag.String("configBranch", "master", "git branch to fetch configuration from")
    flag.Parse()

    viper.Set("profile", *profile)
    viper.Set("configServerUrl", *configServerUrl)
    viper.Set("configBranch", *configBranch)
}

func main() {
    fmt.Println("Starting " + appName + "...")

    config.LoadConfigurationFromBranch(viper.GetString("configServerUrl"), appName, viper.GetString("profile"), viper.GetString("configBranch"))
    initializeMessaging()

    // Makes sure connection is closed when service exits.
    handleSigterm(func() {
        if messagingClient != nil {
            messagingClient.Close()
        }
    })
    service.StartWebServer(viper.GetString("server_port"))
}

func onMessage(delivery amqp.Delivery) {
    fmt.Printf("Got a message: %v\n", string(delivery.Body))
}

func initializeMessaging() {
    if !viper.IsSet("amqp_server_url") {
        panic("No 'broker_url' set in configuration, cannot start")
    }
    messagingClient = &messaging.MessagingClient{}
    messagingClient.ConnectToBroker(viper.GetString("amqp_server_url"))

    // Call the subscribe method with queue name and callback function
    err := messagingClient.SubscribeToQueue("vip_queue", appName, onMessage)
    failOnError(err, "Could not start subscribe to vip_queue")

    err = messagingClient.Subscribe(viper.GetString("config_event_bus"), "topic", appName, config.HandleRefreshEvent)
    failOnError(err, "Could not start subscribe to "+viper.GetString("config_event_bus")+" topic")
}

// Handles Ctrl+C or most other means of "controlled" shutdown gracefully. Invokes the supplied func before exiting.
func handleSigterm(handleExit func()) {
    c := make(chan os.Signal, 1)
    signal.Notify(c, os.Interrupt)
    signal.Notify(c, syscall.SIGTERM)
    go func() {
        <-c
        handleExit()
        os.Exit(1)
    }()
}

func failOnError(err error, msg string) {
    if err != nil {
        fmt.Printf("%s: %s", msg, err)
        panic(fmt.Sprintf("%s: %s", msg, err))
    }
}

看起來和accountservice非常相似,對不對? 我們可能會重複如何安裝和啟動我們新增的每個微服務的基本知識。

onMessage函式在這裡僅僅列印我們接到的vip訊息的body。如果我們需要實現更多虛構的用例,它會呼叫一些花哨的邏輯來確定賬號持有人是否有資格獲得"超級可怕的購買我們所有東西(TM)"的offer, 並且可能寫一個offer給"VIP offer資料庫"。你可以隨意實現並提交一個PR。

沒有什麼可補充的。除了這個片段,當我們按下Ctrl + C或者當Swarm認為是時候殺死服務例項:

func handleSigterm(handleExit func()) {
    c := make(chan os.Signal, 1)
    signal.Notify(c, os.Interrupt)
    signal.Notify(c, syscall.SIGTERM)
    go func() {
        <-c
        handleExit()
        os.Exit(1)
    }()
}

不是最容易讀的程式碼片段,它所做的就是註冊通道c作為os.Interrupt和syscall的監聽器。SIGTERM和goroutine會阻塞在c上的訊息監聽,知道接收到這兩種訊號。 這樣就使得我們非常肯定我們提供的handleExit()函式在微服務被殺掉的時候都會被呼叫。怎麼確定? Ctrl + C或docker swarm擴充套件也工作良好。kill也一樣。 kill -9不會。 因此請求不要使用kill -9停止,除非你必須要這樣做。

它將呼叫我們在IMessageConsumer介面中宣告的Close()函式, 它實現的時候確保AMQP連線被正確關閉。

部署執行

我們對copyall.sh內容進行了修改:

#!/bin/bash

export GOOS=linux
export CGO_ENABLED=0

cd accountservice;go get;go build -o accountservice-linux-amd64;echo built `pwd`;cd ..
cd healthchecker;go get;go build -o healthchecker-linux-amd64;echo built `pwd`;cd ..
cd vipservice;go get;go build -o vipservice-linux-amd64;echo built `pwd`;cd ..

export GOOS=darwin

cp healthchecker/healthchecker-linux-amd64 accountservice/
cp healthchecker/healthchecker-linux-amd64 vipservice/

docker build -t someprefix/accountservice accountservice/
docker service rm accountservice
docker service create --name=accountservice --replicas=1 --network=my_network -p=6767:6767 someprefix/accountservice

docker build -t someprefix/vipservice vipservice/
docker service rm vipservice
docker service create --name=vipservice --replicas=1 --network=my_network someprefix/vipservice

執行這個指令碼,等待幾秒鐘,讓服務重新構建部署完成。然後檢視:

> docker service ls
ID            NAME            REPLICAS  IMAGE
kpb1j3mus3tn  accountservice  1/1       someprefix/accountservice
n9xr7wm86do1  configserver    1/1       someprefix/configserver
r6bhneq2u89c  rabbitmq        1/1       someprefix/rabbitmq
sy4t9cbf4upl  vipservice      1/1       someprefix/vipservice
u1qcvxm2iqlr  viz             1/1       manomarks/visualizer:latest

或者可以使用dvizz Docker Swarm服務呈現來檢視:

clipboard.png

檢查日誌

既然docker service logs特性已經在1.13.0中被標記為試驗階段,我們依然可以使用前面的方式來檢視vipservice的日誌。首先,執行docker ps找出容器id:

> docker ps
CONTAINER ID        IMAGE
a39e6eca83b3        someprefix/vipservice:latest
b66584ae73ba        someprefix/accountservice:latest
d0074e1553c7        someprefix/configserver:latest

然後使用vipservice的容器id來檢視日誌:

> docker logs -f a39e6eca83b3
Starting vipservice...
2017/06/06 19:27:22 Declaring Queue ()
2017/06/06 19:27:22 declared Exchange, declaring Queue ()
2017/06/06 19:27:22 declared Queue (0 messages, 0 consumers), binding to Exchange (key 'springCloudBus')
Starting HTTP service at 6868

然後另外開啟一個視窗,執行下面的請求:

> curl http://$ManagerIP:6767/accounts/10000

然後你就會在剛才日誌裡邊看到多了下面一行資訊:

Got a message: {"accountId":"10000","readAt":"2017-02-15 20:06:27.033757223 +0000 UTC"}

也就是說我們的vipservice成功的消費了從accountservice釋出的訊息。

Work佇列

跨越服務的多個例項的分散式work模式是利用了work佇列的概念。每個vip訊息應該只能被單個vipservice例項處理。

clipboard.png

因此讓我們看看當我們將vipservice規模擴大到2個的時候會發生什麼:

> docker service scale vipservice=2

數秒之後新的例項就可以使用了。既然我們使用的是AMQP中的direct/queue方式,我們希望有輪詢的行為。使用curl觸發四個VIP賬戶查詢。

> curl http://$ManagerIP:6767/accounts/10000
> curl http://$ManagerIP:6767/accounts/10000
> curl http://$ManagerIP:6767/accounts/10000
> curl http://$ManagerIP:6767/accounts/10000

然後在看看日誌:

> docker logs -f a39e6eca83b3
Got a message: {"accountId":"10000","readAt":"2017-02-15 20:06:27.033757223 +0000 UTC"}
Got a message: {"accountId":"10000","readAt":"2017-02-15 20:06:29.073682324 +0000 UTC"}

正如我們預料的,我們看到第一個例項處理了四條訊息中的兩條。如果我們對其他的vipservice進行docker logs查詢,我們會看到其他的訊息在它們裡邊消費了。非常滿意。

佔用空間和效能

這次不會做效能測試,在傳送和接受一些訊息後,快速檢視記憶體使用就足夠了:

CONTAINER                                    CPU %               MEM USAGE / LIMIT
vipservice.1.tt47bgnmhef82ajyd9s5hvzs1       0.00%               1.859MiB / 1.955GiB
accountservice.1.w3l6okdqbqnqz62tg618szsoj   0.00%               3.434MiB / 1.955GiB
rabbitmq.1.i2ixydimyleow0yivaw39xbom         0.51%               129.9MiB / 1.955GiB

上買呢在服務了一些請求後得到的資訊。新的vipservice和accountservice一樣不是很複雜,因此和預料的一樣啟動的時候佔用的記憶體非常小。

總結

本文可能是這個系列目前最長的一篇文章了!我們完成了:

  • 更深入的測試了RabbitMQ和AMQP的機制。
  • 新增了全新的微服務vipservice。
  • 將訊息傳遞和配置程式碼放到可複用的子專案中。
  • 使用AMQP協議釋出/訂閱訊息。
  • 使用mockery產生模擬程式碼。

在第十部分,我們將做一些輕量的但在現實世界非常重要的模型 - 使用Logrus, Docker GELF日誌驅動記錄結構化日誌以及將日誌發不到Laas提供者商。

中英文對照表

  • 領域驅動設計: Domain-driven Design(DDD).

參考連結

相關文章