Go微服務 - 第八部分 - 使用Viper和Spring Cloud Config進行集中配置

WalkerQiao發表於2018-05-22

第八部分: Go微服務 - 使用Viper和Spring Cloud Config進行集中配置

在第八部分,我們探索Go微服務中使用Spring Cloud Config進行集中配置。

簡介

考慮到微服務畢竟是用來分解應用為獨立軟體片段的,在微服務中集中處理一些東西感覺有些不太搭配。然而我們通常在後面的是程式之間的獨立。微服務的其他操作應該集中處理。例如,日誌應該在你的日誌解決, 比如elk stack中終結, 監控應該納入專用的監控中。 在這部分,我們將使用Spring Cloud Config和git處理外部化和集中配置。

集中處理組成我們應用程式的各種微服務的配置實際上是很自然的事情。 特別是在未知數量底層硬體節點上的容器環境執行的時候,管理配置檔案構建到每個微服務的映像中或放到不同的安裝卷中,很快就會變成真正的難題。有很多行之有效的專案可以幫我們處理這些問題,例如etcd, consul和ZooKeeper。然而,應該注意的是,這些專案提供的不僅僅是配置服務。既然本文聚焦的是整合Go微服務和Spring Cloud/Netflix OSS生態的支援服務, 我們將基於Spring Cloud配置進行集中配置, Spring Cloud Config是一個提供精確配置的專用軟體。

Spring Cloud Config

Spring Cloud生態提供了集中配置的解決方案,也沒有什麼創意,就叫Spring Cloud Config。Spring Cloud Config伺服器可以被視為服務和真正配置之間的代理, 提供了一些非常整潔的特性:

  • 支援多種不同的後端,例如git(預設), 用於etcd、consul和ZooKeeper的檔案系統和外掛。
  • 加密屬性的透明解密。
  • 可插拔安全性。
  • 使用git鉤子/REST API以及Spring Cloud Bus(例如RabbitMQ)的推送機制來將配置檔案中的改變傳播到服務,使得配置的實時更新成為可能。

我的同事Magnus最近的一篇文章對Spring Cloud Config進行特別深入的探討, 見參考連線。在本文中,我們將整合我們的accountservice服務和Spring Cloud Config服務,配置後端使用公開的位於github上的git倉庫, 從倉庫中我們可以獲取配置,解密/加密屬性以及實現實時過載配置。

下面是我們整個解決方案目標的簡單概述:

clipboard.png

概述

既然我們以Swarm模式執行Docker, 我們將繼續以各種方式使用Docker的機制。在Swarm內部,我們應該執行至少一個(可以更多)Spring Cloud Config伺服器。當我們的微服務中的一個啟動的時候,它們要知道:

  • 配置伺服器的邏輯服務名和埠號。也就是說,我們把我們的配置伺服器也部署到Docker Swarm上作為服務,這裡我們稱之為configserver。意味著這是微服務要請求配置的時候唯一需要知道的東西。
  • 它們的名字是什麼, 例如"accountservice"。
  • 它執行在什麼樣的執行配置檔案上,例如"dev", "test", "prod"。 如果你對spring.profiles.active概念比較熟悉的話,這用於Go語言一樣很自然。
  • 如果我們使用git作為後端,並想從特定的分支獲取配置資訊,我們就需要提前知道(可選的)。

鑑於上面四個標準, 請求配置的簡單GET可能看起來像下面的樣子:

resp, err := http.Get("http://configserver:8888/accountservice/dev/P8")

也就是下面的協議:

protocol://url:port/applicationName/profile/branch

在Swarm中搭建一個Spring Cloud配置伺服器

本文程式碼可以從github直接克隆下來: https://github.com/callistaen...

你也可以用其他方式來設定和部署配置伺服器。而我在goblog目錄下面準備了一個support目錄,用於存放https://github.com/callistaen...,裡邊包含了我們後面需要的第三方服務。

一般來說,每個必要的支援元件要麼是簡單的便於構建和部署元件的現成的Dockerfile, 要麼是(java)原始碼和配置(Spring Cloud應用通常是基於Spring Boot的), 這樣我們需要自己使用Gradle構建。(不需要擔心,我們只需要安裝JDK就可以了)。

(這些Spring Cloud應用程式大部分我的同事都已經提前準備好了。具體可以參考Java微服務)

RabbitMQ

什麼情況? 我們不是要安裝Spring Cloud Config伺服器嗎? 好吧,這個依賴的軟體具有一個訊息中間人,可以使用支援RabbitMQ的Spring Cloud Bus來傳播配置改變。

有RabbitMQ是一個很好的事情,不管怎麼說,我們文章後面還會用到它。所以將從RabbitMQ開始,並在我們的Swarm中作為服務來執行。

我已經在/goblog/support/rabbitmq目錄下面準備了一個Dockerfile,可以使用我在Docker Swarm服務中提前準備好的映像。

# use rabbitmq official
FROM rabbitmq

# enable management plugin
RUN rabbitmq-plugins enable --offline rabbitmq_management

# enable mqtt plugin
RUN rabbitmq-plugins enable --offline rabbitmq_mqtt

# expose management port
EXPOSE 15672
EXPOSE 5672

然後我們可以建立一個指令碼檔案, 在需要更新的時候幫我們自動做這些事情。

#!/bin/bash

# RabbitMQ
docker service rm rabbitmq
docker build -t someprefix/rabbitmq support/rabbitmq/
docker service create --name=rabbitmq --replicas=1 --network=my_network -p 1883:1883 -p 5672:5672 -p 15672:15672 someprefix/rabbitmq

(注意,你可能需要給這個指令碼語言新增可執行許可權。)

執行它,等待Docker下載必要的映像,並將它部署到Swarm中。 當它完成的時候,你就可以開啟RabbitMQ管理UI,並且能使用guest/guest來登入進去。

Spring Cloud Config伺服器

在/support/config-server中你會發現一個提前配置好的Spring Boot應用程式,它用於執行配置伺服器。我們會使用一個git倉庫來儲存和訪問我們的yaml檔案儲存的配置。

---
# For deployment in Docker containers
spring:
  profiles: docker
  cloud:
    config:
      server:
        git:
          uri: https://github.com/eriklupander/go-microservice-config.git

# Home-baked keystore for encryption. Of course, a real environment wouldn't expose passwords in a blog...
encrypt:
  key-store:
    location: file:/server.jks
    password: letmein
    alias: goblogkey
    secret: changeme

# Since we're running in Docker Swarm mode, disable Eureka Service Discovery
eureka:
  client:
    enabled: false

# Spring Cloud Config requires rabbitmq, use the service name.
spring.rabbitmq.host: rabbitmq
spring.rabbitmq.port: 5672

上面是配置伺服器的配置檔案。我們可以看到一些東西:

  • 我們告訴config-server到我們指定的URL來獲取配置。
  • 一個金鑰庫,用於加密(自簽名)和解密的金鑰儲存庫。
  • 既然我們是執行在Docker Swarm模式下的,因此eureka的服務發現功能是禁用的。
  • 配置伺服器期望找到一個RabbitMQ, 它的host名為rabbitmq, 埠為5672, host剛好是剛才我們給我們的RabbitMQ服務起的Docker Swarm服務名。

下面是配置伺服器的Dockerfile內容, 相當簡單:

FROM davidcaste/alpine-java-unlimited-jce

EXPOSE 8888

ADD ./build/libs/*.jar app.jar
ADD ./server.jks /

ENTRYPOINT ["java","-Dspring.profiles.active=docker","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

不要介意java.security.egd的東西,這是這個文章系列中我們不需要關心的問題的解決辦法。

這裡有幾點需要注意:

  • 我們使用的映象是基於Alpine Linux的,沒有限制Java的加密擴充套件安裝的。 這是一個必要要求,如果我們想要Spring Cloud Config的加密/解密功能。
  • 容器映象的根目錄中我們加入了在提前準備好的keystore。

編譯keystore

後面我們要使用加密屬性,我們需要為配置伺服器帶一個自簽名證照。(這裡我們需要使用keytool工具。)

在/goblog/support/config-server目錄下面執行下面的命令:

keytool -genkeypair -alias goblogkey -keyalg RSA -dname "CN=Go Blog,OU=Unit,O=Organization,L=City,S=State,C=SE" -keypass changeme -keystore server.jks -storepass letmein -validity 730

keytool是一個金鑰和證照管理工具。它具有很多選項:

  • -certreq: 生成證照請求。
  • -changealias: 更改條目的別名。
  • -delete: 刪除條目。
  • -exportcert: 匯出證照。
  • -genkeypair: 生成金鑰對。
  • -genseckey: 生成金鑰。
  • -gencert: 根據證照請求生成證照。
  • -importcert: 匯入證照或證照鏈。
  • -importpass: 匯入口令。
  • -importkeystore: 從其他金鑰庫匯入一個或所有條目。
  • -keypasswd: 更改條目的金鑰口令。
  • -list: 列出金鑰庫中的條目。
  • -printcert: 列印證照內容。
  • -printcertreq: 列印證照請求的內容。
  • -printcrl: 列印 CRL 檔案的內容。
  • -storepasswd: 更改金鑰庫的儲存口令。

執行完上面命令後在當前目錄下面生成一個server.jks keystore簽名證照。你可以隨意修改任何屬性/密碼, 主要記住相應的更改application.yml就可以了。

...
encrypt:
  key-store:
    location: file:/server.jks
    password: letmein
    alias: goblogkey
    secret: changeme
...

構建部署

是時候構建部署伺服器了。 我們先建立一個shell指令碼來節約我們時間,因為我們可能會需要重複做很多次。 記住 - 你需要Java執行時環境來構建它。 在/goblog目錄,我們建立一個springcloud.sh的指令碼檔案。 我們把所有真正需要構建的東西都放這裡(構建可能需要很長時間):

#!/bin/bash

cd support/config-server
./gradlew build
cd ../..
docker build -t someprefix/configserver support/config-server/
docker service rm configserver
docker service create --replicas 1 --name configserver -p 8888:8888 --network my_network --update-delay 10s --with-registry-auth  --update-parallelism 1 someprefix/configserver

然後執行指令碼,需要修改指令碼的可執行許可權。
等待幾分鐘時間,然後檢查它是否在docker服務中啟動執行了:

> docker service ls

ID                  NAME                MODE                REPLICAS            IMAGE
39d26cc3zeor        rabbitmq            replicated          1/1                 someprefix/rabbitmq
eu00ii1zoe76        viz                 replicated          1/1                 manomarks/visualizer:latest
q36gw6ee6wry        accountservice      replicated          1/1                 someprefix/accountservice
t105u5bw2cld        quotes-service      replicated          1/1                 eriklupander/quotes-service:latest
urrfsu262e9i        dvizz               replicated          1/1                 eriklupander/dvizz:latest
w0jo03yx79mu        configserver        replicated          1/1                 someprefix/configserver

然後可以通過curl來載入accountservice的JSON配置。

> curl http://$ManagerIP:8888/accountservice/dev/master
{"name":"accountservice","profiles":["dev"],"label":"master","version":"b8cfe2779e9604804e625135b96b4724ea378736",
    "propertySources":[
    {"name":"https://github.com/eriklupander/go-microservice-config.git/accountservice-dev.yml",
    "source":
        {"server_port":6767,"server_name":"Accountservice DEV"}
    }]
}

(這裡輸出為了簡潔,我們格式化了的)。實際配置儲存在source屬性中,在那裡包含有所有.yml檔案的屬性值,它們以key-value對的形式出現。載入並解析source屬性到Go語言可用的配置中, 是本文的中介軟體來完成的。

yaml配置檔案

在我們深入到Go程式碼之前,我們先看看https://github.com/eriklupand...:

accountservice-dev.yml
accountservice-test.yml

這兩個檔案目前裡邊的內容都非常少。

server_port: 6767
server_name: Accountservice TEST
the_password: (we'll get back to this one)

這裡我們只配置了我們希望繫結服務的HTTP埠號。真實的服務可能在裡邊設定很多東西。

使用解密/加密

Spring Cloud Config其中一個靈活的地方就是在配置檔案中支援內建支援透明的解密被加密值。例如,可以看看accountservice-test.yml檔案,那裡我們有the_password屬性:

server_port: 6767
server_name: Accountservice TEST
the_password: '{cipher}AQB1BMFCu5UsCcTWUwEQt293nPq0ElEFHHp5B2SZY8m4kUzzqxOFsMXHaH7SThNNjOUDGxRVkpPZEkdgo6aJFSPRzVF04SXOVZ6Rjg6hml1SAkLy/k1R/E0wp0RrgySbgh9nNEbhzqJz8OgaDvRdHO5VxzZGx8uj5KN+x6nrQobbIv6xTyVj9CSqJ/Btf/u1T8/OJ54vHwi5h1gSvdox67teta0vdpin2aSKKZ6w5LyQocRJbONUuHyP5roCONw0pklP+2zhrMCy0mXhCJSnjoHvqazmPRUkyGcjcY3LHjd39S2eoyDmyz944TKheI6rWtCfozLcIr/wAZwOTD5sIuA9q8a9nG2GppclGK7X649aYQynL+RUy1q7T7FbW/TzSBg='

使用字串{cipher}作為解密字首,我們的Spring Cloud配置伺服器將在傳遞結果給伺服器之前,知道如何自動為我們解密值。在所有配置都正確的執行例項中,curl請求REST API來獲取這個配置將返回:

...
      "source": {
        "server_port": 6767,
        "server_name": "Accountservice TEST",
        "the_password": "password"
....

相當靈活吧, 對吧?the_password屬性可以在公網伺服器和Spring Cloud伺服器(它可能在不安全環境或內部伺服器外部可見的任何環境都不可用。)中用儲存明文加密的字串(如果你相信加密演算法和簽名金鑰的完整性)透明解密這個屬性為真正的password。

當然,你需要使用相同的key作為Spring Cloud Config的解密key來解密,有些事情可以通過配置伺服器的HTTP API來完成。

curl http://$ManagerIP:8888/encrypt -d 'password'
AQClKEMzqsGiVpKx+Vx6vz+7ww00n... (rest omitted for brevity)

Viper

我們的基於Go的配置框架選擇的是Viper。 Viper具有很好的API可以用, 並且很方便擴充套件, 並且不會妨礙我們正常的應用程式碼。雖然Viper不願生的支援從Spring Cloud配置伺服器載入配置, 但是我們可以寫一小片程式碼可以幫我們做到這點。 Viper也可以處理很多種檔案型別作為配置源 - 例如json, yaml, 普通屬性檔案。 Viper可以為我們從OS讀取環境變數, 相當整潔。 一旦初始化併產生後,我們的配置總是可以使用各種的viper.Get函式獲取來使用,確實很方便。

還記得在本文開頭的圖片嗎? 好吧,如果不記得了, 我們再重複一遍:

clipboard.png

我們將讓微服務啟動的時候發起一個HTTP請求, 獲取JSON響應的source部分,並將它們放到Viper中,這樣我們就可以在那裡獲取我們的web伺服器的埠號了。 讓我們開始吧。

載入配置

正如使用curl的已展示示例,我們可以對配置伺服器進行簡單HTTP請求,那裡我們只需要知道名字和我們的profile即可。 我們將新增一些解析flag的功能到我們的accountservice main.go, 因此在啟動的時候,我們可以指定一個環境profile,也可以指定到配置伺服器的可選的URI。

var appName = "accountservice"

// Init function, runs before main()
func init() {
    // Read command line flags
    profile := flag.String("profile", "test", "Environment profile, something similar to spring profiles")
    configServerUrl := flag.String("configServerUrl", "http://configserver:8888", "Address to config server")
    configBranch := flag.String("configBranch", "master", "git branch to fetch configuration from")
    flag.Parse()
    
    // Pass the flag values into viper.
    viper.Set("profile", *profile)
    viper.Set("configServerUrl", *configServerUrl)
    viper.Set("configBranch", *configBranch)
}

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

    // NEW - load the config
    config.LoadConfigurationFromBranch(
        viper.GetString("configServerUrl"),
        appName,
        viper.GetString("profile"),
        viper.GetString("configBranch"))
    initializeBoltClient()
    service.StartWebServer(viper.GetString("server_port"))    // NEW, use port from loaded config 
}

init函式比較簡單,就是從命令列引數解析flag引數值,然後設定到viper中。 在main函式中,呼叫config.LoadConfigurationFromBranch, 從遠端git倉庫載入配置。這裡config.LoadConfigurationFromBranch是在goblog/common/config/loader.go中定義的:

package config

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"

    "github.com/Sirupsen/logrus"
    "github.com/spf13/viper"
)

// LoadConfigurationFromBranch loads config from for example http://configserver:8888/accountservice/test/P8
func LoadConfigurationFromBranch(configServerURL string, appName string, profile string, branch string) {
    url := fmt.Sprintf("%s/%s/%s/%s", configServerURL, appName, profile, branch)
    logrus.Printf("Loading config from %s\n", url)
    body, err := fetchConfiguration(url)
    if err != nil {
        logrus.Errorf("Couldn't load configuration, cannot start. Terminating. Error: %v", err.Error())
        panic("Couldn't load configuration, cannot start. Terminating. Error: " + err.Error())
    }
    parseConfiguration(body)
}

func fetchConfiguration(url string) ([]byte, error) {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered in f", r)
        }
    }()
    logrus.Printf("Getting config from %v\n", url)
    resp, err := http.Get(url)
    if err != nil || resp.StatusCode != 200 {
        logrus.Errorf("Couldn't load configuration, cannot start. Terminating. Error: %v", err.Error())
        panic("Couldn't load configuration, cannot start. Terminating. Error: " + err.Error())
    }
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        panic("Error reading configuration: " + err.Error())
    }
    return body, err
}

func parseConfiguration(body []byte) {
    var cloudConfig springCloudConfig
    err := json.Unmarshal(body, &cloudConfig)
    if err != nil {
        panic("Cannot parse configuration, message: " + err.Error())
    }

    for key, value := range cloudConfig.PropertySources[0].Source {
        viper.Set(key, value)
        logrus.Printf("Loading config property %v => %v\n", key, value)
    }
    if viper.IsSet("server_name") {
        logrus.Printf("Successfully loaded configuration for service %s\n", viper.GetString("server_name"))
    }
}

type springCloudConfig struct {
    Name            string           `json:"name"`
    Profiles        []string         `json:"profiles"`
    Label           string           `json:"label"`
    Version         string           `json:"version"`
    PropertySources []propertySource `json:"propertySources"`
}

type propertySource struct {
    Name   string                 `json:"name"`
    Source map[string]interface{} `json:"source"`
}

本程式碼引入了三個包logrus, viper和amqp。因為我沒有使用deps之類的包管理工具,因此我們在安裝logrus和viper包的時候,這兩個包也有依賴的第三方包,我們手工進行一些go get:

mkdir -p $GOPATH/src/golang.org/x
cd !$

git clone https://github.com/golang/text.git
git clone https://github.com/golang/sys.git


logrus go get問題
git clone https://github.com/golang/crypto.git

loadConfigurationFromBranch函式根據提供的引數獲取配置並解析配置到viper中。

基本上來說就是我們發起一個帶有appName, profile, git branch引數的HTTP GET請求到配置伺服器, 然後解碼響應JSON到在同一檔案中宣告的springCloudConfig結構體中。最後我們簡單迭代cloudConfig.PropertySources[0]的所有key-value對, 並將它們分別放入viper, 這樣我們可以隨處都可以使用viper.GetString(key)或其他的Viper提供的其他Get方法來獲取它們。

注意,如果我們連線配置伺服器或解析響應發生錯誤的話,就會panic()整個微服務, 這樣就會kill掉它。Docker Swarm將檢測這個並嘗試在數秒之內部署一個新的例項。 擁有這樣行為的典型原因在於叢集冷啟動的時候,基於Go的微服務要比基於Sping Boot的配置伺服器啟動要快得多。讓Swarm嘗試幾次,事情會自己解決掉的。

我們吧實際工作分割到一個公共函式和一些包級別的函式單元,主要是便於單元測試。 單元測試檢查,以便我們能將JSON轉換為實際的viper屬性,看起來想GoConvey樣式的測試:

func TestParseConfiguration(t *testing.T) {
    Convey("Given a JSON configuration response body", t, func() {
        var body = `{"name":"accountservice-dev","profiles":["dev"],"label":null,"version":null,"propertySources":[{"name":"file:/config-repo/accountservice-dev.yml","source":{"server_port":6767"}}]}`

        Convey("When parsed", func() {
            parseConfiguration([]byte(body))

            Convey("Then Viper should have been populated with values from Source", func() {
                So(viper.GetString("server_port"), ShouldEqual, "6767")
            })
        })
    })
}

然後在goblog/accountservice目錄執行測試: go test ./...

更新Dockerfile

鑑於我們是從外部源載入配置,我們的服務需要一個查詢的線索。 這可以在容器和服務啟動的時候,通過使用flag作為命令列引數來執行。

FROM iron/base
EXPOSE 6767

ADD accountservice-linux-amd64 /
ADD healthchecker-linux-amd64 /

HEALTHCHECK --interval=3s --timeout=3s CMD ["./healthchecker-linux-amd64", "-port=6767"] || exit 1
ENTRYPOINT ["./accountservice-linux-amd64", "-configServerUrl=http://configserver:8888", "-profile=test", "-configBranch=P8"]

ENTRYPOINT現在提供了一些值,使得它可以到達配置,這樣可以載入配置。

放入Swarm

你可能已經注意到我們不再使用6767埠號作為埠號的硬編碼了, 也就是:

service.StartWebServer(viper.GetString("server_port"))

使用copyall.sh指令碼重新構建並部署更新後的accountservice到Docker Swarm中。

所有事情都完成的時候,服務依然如本部落格系列那樣執行,例外的是它實際上是從外部和集中化配置伺服器拿的埠號,而不是硬編碼到編譯二進位制檔案的埠號。

我們可以看看我們的accountservice的日誌:

docker logs -f [containerid]
Starting accountservice
Loading config from http://configserver:8888/accountservice/test/P8
Loading config property the_password => password
Loading config property server_port => 6767
Loading config property server_name => Accountservice TEST
Successfully loaded configuration for service Accountservice TEST

這裡我們又get新技能了,使用docker logs可以檢視具體容器的日誌:

Usage:    docker logs [OPTIONS] CONTAINER

Fetch the logs of a container

Options:
      --details        Show extra details provided to logs
  -f, --follow         Follow log output
      --since string   Show logs since timestamp (e.g. 2013-01-02T13:23:37) or relative (e.g. 42m for 42 minutes)
      --tail string    Number of lines to show from the end of the logs (default "all")
  -t, --timestamps     Show timestamps
      --until string   Show logs before a timestamp (e.g. 2013-01-02T13:23:37) or relative (e.g. 42m for 42 minutes)

docker logs支援查詢某個時間點前後的日誌。

實際上列印配置值是錯誤的做法,這裡我們只是出於學習目的作出的輸出。這裡我們使用logrus來列印日誌。

實時配置更新

1. 哦,我們用於某種目的的外部伺服器的URL是否改變了呢?
2. 該死,怎麼沒有人告訴我!

假設我們很多人都遇到下面情況, 我們需要重建整個應用或至少重啟來更新一些無效或改變的配置值。Spring Cloud具有重新整理域的概念,其中bean可以實時更新,使用配置修改通過git commit hook傳播。

下圖提供了一個如何推送到git倉庫,傳播到我們Go微服務的概覽:

clipboard.png

在本文中,我們使用的是github倉庫,它完全不知道如何執行post-commit hook操作到我的筆記本的Spring Cloud Server, 因此我們將模擬一個提交掛鉤使用Spring Cloud伺服器的內建/監控端點來推送。

curl -H "X-Github-Event: push" -H "Content-Type: application/json" -X POST -d '{"commits": [{"modified": ["accountservice.yml"]}],"name":"some name..."}' -ki http://$ManagerIP:8888/monitor

Spring Cloud伺服器將知道使用這個POST做什麼,並在RabbitMQ(由Spring Cloud Bus抽象出來的)的交換上傳送一個RefreshRemoteApplicationEvent。如果在成功引導了Spring Cloud Config之後,看看RabbitMQ的管理介面,應該建立了exchange。

clipboard.png

exchange和傳統的訊息控制例如publisher, consumer, queue的區別是什麼?

Publisher -> Exchange -> (Routing) -> Queue -> Consumer

也就是訊息被髮布到exchange, 然後基於路由規則和可能註冊了消費者的捆綁將訊息副本分佈到queue。

因此為了消費RefreshRemoteApplicationEvent訊息(我更喜歡呼叫它們的refresh tokens), 所有我們需要做的是確保我們的Go服務在springCloudBus exchange上監聽這樣的訊息, 如果我們的目標應用執行了配置過載。 下面我們來實現它。

Go語言中使用AMQP協議來消費訊息

RabbitMQ中間人可以通過使用AMQP協議來訪問。我們將使用一個叫做streadway/amqp的Go版本的AMQP客戶端。 大部分AMQP/RabbitMQ管道程式碼都應該使用一些可複用工具,可能我們稍後會重構它。 基於這個例子的管道程式碼是來自streadway/amqp倉庫的:

// This example declares a durable Exchange, an ephemeral (auto-delete) Queue,
// binds the Queue to the Exchange with a binding key, and consumes every
// message published to that Exchange with that routing key.
//
package main

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

var (
    uri          = flag.String("uri", "amqp://guest:guest@localhost:5672/", "AMQP URI")
    exchange     = flag.String("exchange", "test-exchange", "Durable, non-auto-deleted AMQP exchange name")
    exchangeType = flag.String("exchange-type", "direct", "Exchange type - direct|fanout|topic|x-custom")
    queue        = flag.String("queue", "test-queue", "Ephemeral AMQP queue name")
    bindingKey   = flag.String("key", "test-key", "AMQP binding key")
    consumerTag  = flag.String("consumer-tag", "simple-consumer", "AMQP consumer tag (should not be blank)")
    lifetime     = flag.Duration("lifetime", 5*time.Second, "lifetime of process before shutdown (0s=infinite)")
)

func init() {
    flag.Parse()
}

func main() {
    c, err := NewConsumer(*uri, *exchange, *exchangeType, *queue, *bindingKey, *consumerTag)
    if err != nil {
        log.Fatalf("%s", err)
    }

    if *lifetime > 0 {
        log.Printf("running for %s", *lifetime)
        time.Sleep(*lifetime)
    } else {
        log.Printf("running forever")
        select {}
    }

    log.Printf("shutting down")

    if err := c.Shutdown(); err != nil {
        log.Fatalf("error during shutdown: %s", err)
    }
}

type Consumer struct {
    conn    *amqp.Connection
    channel *amqp.Channel
    tag     string
    done    chan error
}

func NewConsumer(amqpURI, exchange, exchangeType, queueName, key, ctag string) (*Consumer, error) {
    c := &Consumer{
        conn:    nil,
        channel: nil,
        tag:     ctag,
        done:    make(chan error),
    }

    var err error

    log.Printf("dialing %q", amqpURI)
    c.conn, err = amqp.Dial(amqpURI)
    if err != nil {
        return nil, fmt.Errorf("Dial: %s", err)
    }

    go func() {
        fmt.Printf("closing: %s", <-c.conn.NotifyClose(make(chan *amqp.Error)))
    }()

    log.Printf("got Connection, getting Channel")
    c.channel, err = c.conn.Channel()
    if err != nil {
        return nil, fmt.Errorf("Channel: %s", err)
    }

    log.Printf("got Channel, declaring Exchange (%q)", exchange)
    if err = c.channel.ExchangeDeclare(
        exchange,     // name of the exchange
        exchangeType, // type
        true,         // durable
        false,        // delete when complete
        false,        // internal
        false,        // noWait
        nil,          // arguments
    ); err != nil {
        return nil, fmt.Errorf("Exchange Declare: %s", err)
    }

    log.Printf("declared Exchange, declaring Queue %q", queueName)
    queue, err := c.channel.QueueDeclare(
        queueName, // name of the queue
        true,      // durable
        false,     // delete when unused
        false,     // exclusive
        false,     // noWait
        nil,       // arguments
    )
    if err != nil {
        return nil, fmt.Errorf("Queue Declare: %s", err)
    }

    log.Printf("declared Queue (%q %d messages, %d consumers), binding to Exchange (key %q)",
        queue.Name, queue.Messages, queue.Consumers, key)

    if err = c.channel.QueueBind(
        queue.Name, // name of the queue
        key,        // bindingKey
        exchange,   // sourceExchange
        false,      // noWait
        nil,        // arguments
    ); err != nil {
        return nil, fmt.Errorf("Queue Bind: %s", err)
    }

    log.Printf("Queue bound to Exchange, starting Consume (consumer tag %q)", c.tag)
    deliveries, err := c.channel.Consume(
        queue.Name, // name
        c.tag,      // consumerTag,
        false,      // noAck
        false,      // exclusive
        false,      // noLocal
        false,      // noWait
        nil,        // arguments
    )
    if err != nil {
        return nil, fmt.Errorf("Queue Consume: %s", err)
    }

    go handle(deliveries, c.done)

    return c, nil
}

func (c *Consumer) Shutdown() error {
    // will close() the deliveries channel
    if err := c.channel.Cancel(c.tag, true); err != nil {
        return fmt.Errorf("Consumer cancel failed: %s", err)
    }

    if err := c.conn.Close(); err != nil {
        return fmt.Errorf("AMQP connection close error: %s", err)
    }

    defer log.Printf("AMQP shutdown OK")

    // wait for handle() to exit
    return <-c.done
}

func handle(deliveries <-chan amqp.Delivery, done chan error) {
    for d := range deliveries {
        log.Printf(
            "got %dB delivery: [%v] %q",
            len(d.Body),
            d.DeliveryTag,
            d.Body,
        )
        d.Ack(false)
    }
    log.Printf("handle: deliveries channel closed")
    done <- nil
}

載goblog/accountservice/main.go main函式中新增新行, 為我們啟動一個AMQP消費者:

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

    config.LoadConfigurationFromBranch(
            viper.GetString("configServerUrl"),
            appName,
            viper.GetString("profile"),
            viper.GetString("configBranch"))
    initializeBoltClient()
    
    // NEW
    go config.StartListener(appName, viper.GetString("amqp_server_url"), viper.GetString("config_event_bus"))   
    service.StartWebServer(viper.GetString("server_port"))
}

注意上面的StartListener的兩個引數伺服器url和事件bus兩個屬性,它們是在下面的檔案中定義的:

server_port: 6767
server_name: Accountservice TEST
the_password: '{cipher}AQB1BMFC....'
amqp_server_url: amqp://guest:guest@rabbitmq:5672/
config_event_bus: springCloudBus
func StartListener(appName string, amqpServer string, exchangeName string) {
    err := NewConsumer(amqpServer, exchangeName, "topic", "config-event-queue", exchangeName, appName)
    if err != nil {
        log.Fatalf("%s", err)
    }

    log.Printf("running forever")
    select {}   // Yet another way to stop a Goroutine from finishing...
}

NewConsumer是樣板程式碼的實際位置,這裡先忽略過它,直接看看實際處理進來請求的程式碼:

func handleRefreshEvent(body []byte, consumerTag string) {
    updateToken := &UpdateToken{}
    err := json.Unmarshal(body, updateToken)
    if err != nil {
        log.Printf("Problem parsing UpdateToken: %v", err.Error())
    } else {
        if strings.Contains(updateToken.DestinationService, consumerTag) {
            log.Println("Reloading Viper config from Spring Cloud Config server")

            // Consumertag is same as application name.
            LoadConfigurationFromBranch(
                viper.GetString("configServerUrl"),
                consumerTag,
                viper.GetString("profile"),
                viper.GetString("configBranch"))
        }
    }
}

// {"type":"RefreshRemoteApplicationEvent","timestamp":1494514362123,"originService":"config-server:docker:8888","destinationService":"xxxaccoun:**","id":"53e61c71-cbae-4b6d-84bb-d0dcc0aeb4dc"}
type UpdateToken struct {
    Type string `json:"type"`
    Timestamp int `json:"timestamp"`
    OriginService string `json:"originService"`
    DestinationService string `json:"destinationService"`
    Id string `json:"id"`
}

這個程式碼嘗試解析到達的訊息為UpdateToken結構體,並且如果destinationService匹配我們的consumerTag(也就是 appName accountservice), 我們就呼叫同樣的最初服務啟動時呼叫的LoadConfigurationFromBranch函式。

請注意在實際場景中,NewConsumer函式和一般的訊息處理程式碼將需要更多的錯誤處理、確保只處理恰當的訊息等等工作。

單元測試

讓我們為handleRefreshEvent()函式寫一個單元測試。 建立一個新的測試檔案:

var SERVICE_NAME = "accountservice"

func TestHandleRefreshEvent(t *testing.T) {
    // Configure initial viper values
    viper.Set("configServerUrl", "http://configserver:8888")
    viper.Set("profile", "test")
    viper.Set("configBranch", "master")

    // Mock the expected outgoing request for new config
    defer gock.Off()
    gock.New("http://configserver:8888").
        Get("/accountservice/test/master").
        Reply(200).
        BodyString(`{"name":"accountservice-test","profiles":["test"],"label":null,"version":null,"propertySources":[{"name":"file:/config-repo/accountservice-test.yml","source":{"server_port":6767,"server_name":"Accountservice RELOADED"}}]}`)

Convey("Given a refresh event received, targeting our application", t, func() {
        var body = `{"type":"RefreshRemoteApplicationEvent","timestamp":1494514362123,"originService":"config-server:docker:8888","destinationService":"accountservice:**","id":"53e61c71-cbae-4b6d-84bb-d0dcc0aeb4dc"}
`
        Convey("When handled", func() {
            handleRefreshEvent([]byte(body), SERVICE_NAME)

            Convey("Then Viper should have been re-populated with values from Source", func() {
                So(viper.GetString("server_name"), ShouldEqual, "Accountservice RELOADED")
            })
        })
    })
}

我希望BDD樣式的GoConvey傳達(雙關語!)測試如何工作。 注意我們如何使用gock來攔截對外的請求新配置的HTTP請求,以及我們預先產生的帶有一些初始值的viper。

執行它

是時候測試了。 重新使用copyall.sh指令碼部署服務。

檢查accountservice的日誌:

> docker logs -f [containerid]
Starting accountservice
... [truncated for brevity] ...
Successfully loaded configuration for service Accountservice TEST    <-- LOOK HERE!!!!
... [truncated for brevity] ...
2017/05/12 12:06:36 dialing amqp://guest:guest@rabbitmq:5672/
2017/05/12 12:06:36 got Connection, getting Channel
2017/05/12 12:06:36 got Channel, declaring Exchange (springCloudBus)
2017/05/12 12:06:36 declared Exchange, declaring Queue (config-event-queue)
2017/05/12 12:06:36 declared Queue (0 messages, 0 consumers), binding to Exchange (key 'springCloudBus')
2017/05/12 12:06:36 Queue bound to Exchange, starting Consume (consumer tag 'accountservice')
2017/05/12 12:06:36 running forever

現在對accountservice-test.yml和service name進行修改,並使用前面展示的使用monitor API POST來偽造一個提交hook:

我修改了accountservice-test.yml檔案和它的service name屬性,從accountservice TEST到Temporary test string, 然後推送改變。

接著,我們使用curl來讓我們的Spring Cloud Config伺服器知道這些更新:

> curl -H "X-Github-Event: push" -H "Content-Type: application/json" -X POST -d '{"commits": [{"modified": ["accountservice.yml"]}],"name":"what is this?"}' -ki http://192.168.99.100:8888/monitor

如果所有都正常工作,就會觸發一個refresh token從Config伺服器,我們的accountservice就會撿起它. 再次檢查下log:

> docker logs -f [containerid]
2017/05/12 12:13:22 got 195B consumer: [accountservice] delivery: [1] routingkey: [springCloudBus] {"type":"RefreshRemoteApplicationEvent","timestamp":1494591202057,"originService":"config-server:docker:8888","destinationService":"accountservice:**","id":"1f421f58-cdd6-44c8-b5c4-fbf1e2839baa"}
2017/05/12 12:13:22 Reloading Viper config from Spring Cloud Config server
Loading config from http://configserver:8888/accountservice/test/P8
Loading config property server_port => 6767
Loading config property server_name => Temporary test string!
Loading config property amqp_server_url => amqp://guest:guest@rabbitmq:5672/
Loading config property config_event_bus => springCloudBus
Loading config property the_password => password
Successfully loaded configuration for service Temporary test string!      <-- LOOK HERE!!!!

正如你所見的,最後一行列印了"Successfully loaded configuration for service Temporary test string!", 原始碼如下:

if viper.IsSet("server_name") {
    fmt.Printf("Successfully loaded configuration for service %s\n", viper.GetString("server_name"))
} 

也就是說,我們已經動態修改了的之前儲存在Viper中的屬性值, 而沒告訴我們的服務!這是真正的酷!

重要提示: 雖然動態更新屬性是非常酷的,但是它本身不會更新這些東西,比如我們執行伺服器的埠,池中已存在連線物件, 或RabbitMQ中間人的活動連線。 這些型別的已執行東西需要花費一些時間來使用新的配置來重啟, 這些內容超出了本文的範圍。

Footprint及效能

在啟動時新增配置載入不應該影響執行時效能, 事實上它確實不影響。每秒1千個請求和之前具有同樣的吞吐,CPU和記憶體使用。相信我的話或者你自己試試。我們將在第一次啟動後快速檢視記憶體使用情況:

CONTAINER                                    CPU %               MEM USAGE / LIMIT     MEM %               NET I/O             BLOCK I/O           PIDS
accountservice.1.pi7wt0wmh2quwm8kcw4e82ay4   0.02%               4.102MiB / 1.955GiB   0.20%               18.8kB / 16.5kB     0B / 1.92MB         6
configserver.1.3joav3m6we6oimg28879gii79     0.13%               568.7MiB / 1.955GiB   28.41%              171kB / 130kB       72.9MB / 225kB      50
rabbitmq.1.kfmtsqp5fnw576btraq19qel9         0.19%               125.5MiB / 1.955GiB   6.27%               6.2MB / 5.18MB      31MB / 414kB        75
quotes-service.1.q81deqxl50n3xmj0gw29mp7jy   0.05%               340.1MiB / 1.955GiB   16.99%              2.97kB / 0B         48.1MB / 0B         30

甚至和AMQP、Viper作為配置框架的整合,我們最初執行資訊大概4MB左右。我們的Spring Boot實現的配置伺服器使用了超過500MB的記憶體,而RabbitMQ(我認為是用Erlang寫的)使用125MB。

我可以肯定的是,我們可以使用一些標準的JVM -xmx引數可以讓配置伺服器的尺寸下降到256MB初始化堆尺寸,但是它絕對是需要大量RAM的。然而,在生產環境中我希望執行2個配置伺服器,而非幾十個或幾百個。 當談及支援服務,從Spring Cloud生態,記憶體使用並不是什麼大事,因為我們通常不會有這種服務的多餘一個或幾個例項。

備忘

// 查詢需要加入的swarm的token, 需要在Leader中查詢。
docker swarm join-token -q worker

// 以worker節點的形式加入Swarm
docker swarm join --token tokenstring worker ip:port
docker swarm join --token tokenstring manager ip:port

// ssh到具體的機器
docker-machine ssh docker-name # swarm-manager-1

總結

在本文中我們部署了一個Spring Cloud配置伺服器,和它的RabbitMQ依賴到我們的Swarm中。然後我們寫了一些Go程式碼,使用一些簡單的HTTP, JSON和Viper框架從配置伺服器啟動時載入配置並填充到Viper中,方便我們整個微服務程式碼方便使用。

在下一節中,我們會繼續探索AMQP和RabbitMQ, 深入更多細節,看看我們自己如何傳送一些訊息。

參考連線

相關文章