一個基於 Golang 的基本 Go kit 微服務範例

首先

讓我們來建立一個最小、最基本的 Go kit 服務。

你的商業邏輯

你的服務始於你的商業邏輯。在 Go kit 中,我們將一個服務模塊化為一個介面(Interface)。

// StringService 提供了處理字串的相關功能。
type StringService interface {  
    Uppercase(string) (string, error)
    Count(string) int
}

這個介面(Interface)會有一個實作方式。

type stringService struct{}

// Uppercase 能夠將字串給轉為大寫。
func (stringService) Uppercase(s string) (string, error) {  
    if s == "" {
        return "", ErrEmpty
    }
    return strings.ToUpper(s), nil
}

// Count 能夠計算字串的長度。
func (stringService) Count(s string) int {  
    return len(s)
}

// 當輸入字串為空時,ErrEmpty 會被回傳。
var ErrEmpty = errors.New("Empty string")  

請求與回應

在 Go kit 中,訊息的傳遞是基於 RPC 結構。所以我們為每一個成員都定義了一個請求與回應的建構體,他們會被套用在所有輸入與輸出的參數上。

type uppercaseRequest struct {  
    S string `json:"s"`
}

type uppercaseResponse struct {  
    V   string `json:"v"`
    Err string `json:"err,omitempty"` // 錯誤不會被 JSON 化, 所以我們用字串
}

type countRequest struct {  
    S string `json:"s"`
}

type countResponse struct {  
    V int `json:"v"`
}

進入點

Go kit 提供了一個多功能的抽象介面叫做「進入點」。

type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)  

一個進入點呈現了一個獨立的 RPC。我們會撰寫簡單的轉接函式來將服務中的每個方法轉換成進入點。每個轉接函式需要遞入 StringService,接著會回傳一個與其方法相符的進入點。

import (  
    "golang.org/x/net/context"
    "github.com/go-kit/kit/endpoint"
)

func makeUppercaseEndpoint(svc StringService) endpoint.Endpoint {  
    return func(ctx context.Context, request interface{}) (interface{}, error) {
        req := request.(uppercaseRequest)
        v, err := svc.Uppercase(req.S)
        if err != nil {
            return uppercaseResponse{v, err.Error()}, nil
        }
        return uppercaseResponse{v, ""}, nil
    }
}

func makeCountEndpoint(svc StringService) endpoint.Endpoint {  
    return func(ctx context.Context, request interface{}) (interface{}, error) {
        req := request.(countRequest)
        v := svc.Count(req.S)
        return countResponse{v}, nil
    }
}

轉繼站

現在我們需要將你的服務暴露給外面的世界知道,這樣一來才能夠被呼叫。你的團隊可能已經知道該怎麼讓服務互相溝通了。也許是用了 Thrift,或是透過 HTTP 傳送自訂格式的 JSON 。最棒的是 Go kit 支援許多的轉繼方式。

在這個小型的範例服務中,我們會利用 HTTP 傳遞 JSON 資料。Go kit 提供了一個輔助結構體,你能夠在 transport/http 套件中找到。

import (  
    "encoding/json"
    "log"
    "net/http"

    "golang.org/x/net/context"

    httptransport "github.com/go-kit/kit/transport/http"
)

func main() {  
    ctx := context.Background()
    svc := stringService{}

    uppercaseHandler := httptransport.NewServer(
        ctx,
        makeUppercaseEndpoint(svc),
        decodeUppercaseRequest,
        encodeResponse,
    )

    countHandler := httptransport.NewServer(
        ctx,
        makeCountEndpoint(svc),
        decodeCountRequest,
        encodeResponse,
    )

    http.Handle("/uppercase", uppercaseHandler)
    http.Handle("/count", countHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func decodeUppercaseRequest(_ context.Context, r *http.Request) (interface{}, error) {  
    var request uppercaseRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        return nil, err
    }
    return request, nil
}

func decodeCountRequest(_ context.Context, r *http.Request) (interface{}, error) {  
    var request countRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        return nil, err
    }
    return request, nil
}

func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {  
    return json.NewEncoder(w).Encode(response)
}

stringsvc1

到目前為止我們已經完成了叫做 stringsvc1 的微服務範例。

$ go get github.com/go-kit/kit/examples/stringsvc1
$ stringsvc1
$ curl -XPOST -d'{"s":"hello, world"}' localhost:8080/uppercase
{"v":"HELLO, WORLD","err":null}
$ curl -XPOST -d'{"s":"hello, world"}' localhost:8080/count
{"v":12}

中介層

沒有紀錄與效能測量的服務還不能夠算是「正式版」,對吧?

轉繼紀錄

任何需要被記錄的元件都應該把紀錄模塊當作是一個依賴,就像是資料庫連線那樣。所以我們需要在 func main 中建構我們的紀錄器,然後將它傳遞至任何需要被紀錄的元件中。我們永遠都不會在全域範圍(Global Variable)中建構紀錄器。

我們可以直接把紀錄器傳遞給 stringService 來實作,但還有更好的方式。那就是中介層,也稱作裝飾層(Decorator)。中介層接收來自進入點的資訊,然後返回一個進入點。

type Middleware func(Endpoint) Endpoint  

中介層即是在進入點之間的函式,現在我們會建立一個基本的紀錄中介層。

func loggingMiddleware(logger log.Logger) Middleware {  
    return func(next endpoint.Endpoint) endpoint.Endpoint {
        return func(ctx context.Context, request interface{}) (interface{}, error) {
            logger.Log("訊息", "呼叫進入點")
            defer logger.Log("訊息", "進入點被呼叫")
            return next(ctx, request)
        }
    }
}

然後把它與我們的每個處理函式牽連在一起。

logger := log.NewLogfmtLogger(os.Stderr)

svc := stringService{}

var uppercase endpoint.Endpoint  
uppercase = makeUppercaseEndpoint(svc)  
uppercase = loggingMiddleware(log.NewContext(logger).With("method", "uppercase"))(uppercase)

var count endpoint.Endpoint  
count = makeCountEndpoint(svc)  
count = loggingMiddleware(log.NewContext(logger).With("method", "count"))(count)

uppercaseHandler := httptransport.Server(  
    // ...
    uppercase,
    // ...
)

countHandler := httptransport.Server(  
    // ...
    count,
    // ...
)

你之後會發現這種寫法不僅在紀錄上十分有用。許多 Go kit 的模塊都會被實作成一個進入點中介層。

應用程式的紀錄

但如果我們想要紀錄應用程式的主要邏輯、或傳入的參數⋯等,應該怎麼辦?其實我們可以替自己的主邏輯定義一個中介層,然後就會和先前的服務中介層一樣有著相同的效果。自從 StringService 被定義成一個介面之後,我們只需要額外定義一個能包裹著 StringService 的種類來進行紀錄的動作就行了。

type loggingMiddleware struct {  
    logger log.Logger
    next   StringService
}

func (mw loggingMiddleware) Uppercase(s string) (output string, err error) {  
    defer func(begin time.Time) {
        mw.logger.Log(
            "method", "uppercase",
            "input", s,
            "output", output,
            "err", err,
            "took", time.Since(begin),
        )
    }(time.Now())

    output, err = mw.next.Uppercase(s)
    return
}

func (mw loggingMiddleware) Count(s string) (n int) {  
    defer func(begin time.Time) {
        mw.logger.Log(
            "method", "count",
            "input", s,
            "n", n,
            "took", time.Since(begin),
        )
    }(time.Now())

    n = mw.next.Count(s)
    return
}

然後牽掛起來。

import (  
    "os"

    "github.com/go-kit/kit/log"
    httptransport "github.com/go-kit/kit/transport/http"
)

func main() {  
    logger := log.NewLogfmtLogger(os.Stderr)

    var svc StringService
    svc = stringsvc{}
    svc = loggingMiddleware{logger, svc}

    // ...

    uppercaseHandler := httptransport.NewServer(
        // ...
        makeUppercaseEndpoint(svc),
        // ...
    )

    countHandler := httptransport.NewServer(
        // ...
        makeCountEndpoint(svc),
        // ...
    )
}

你可以在轉繼點的邏輯層使用進入點中介層,像是斷路器、速率限制器等。然後在商業邏輯層面則可嘗試套用服務中介層,如紀錄器或測量,不過提到測量⋯⋯

應用程式測量

在 Go kit 中,測量意味著使用 metrics 套件來記錄服務執行時的統計資料。計算工作次數、執行所花費的時間,這些都算在「測量」裡面。

還記得先前的紀錄器中介層嗎?我們可以用相同的概念來實作測量。

type instrumentingMiddleware struct {  
    requestCount   metrics.Counter
    requestLatency metrics.TimeHistogram
    countResult    metrics.Histogram
    next           StringService
}

func (mw instrumentingMiddleware) Uppercase(s string) (output string, err error) {  
    defer func(begin time.Time) {
        methodField := metrics.Field{Key: "method", Value: "uppercase"}
        errorField := metrics.Field{Key: "error", Value: fmt.Sprintf("%v", err)}
        mw.requestCount.With(methodField).With(errorField).Add(1)
        mw.requestLatency.With(methodField).With(errorField).Observe(time.Since(begin))
    }(time.Now())

    output, err = mw.next.Uppercase(s)
    return
}

func (mw instrumentingMiddleware) Count(s string) (n int) {  
    defer func(begin time.Time) {
        methodField := metrics.Field{Key: "method", Value: "count"}
        errorField := metrics.Field{Key: "error", Value: fmt.Sprintf("%v", error(nil))}
        mw.requestCount.With(methodField).With(errorField).Add(1)
        mw.requestLatency.With(methodField).With(errorField).Observe(time.Since(begin))
        mw.countResult.Observe(int64(n))
    }(time.Now())

    n = mw.next.Count(s)
    return
}

然後將此牽入進我們的服務中。

import (  
    stdprometheus "github.com/prometheus/client_golang/prometheus"
    kitprometheus "github.com/go-kit/kit/metrics/prometheus"
    "github.com/go-kit/kit/metrics"
)

func main() {  
    logger := log.NewLogfmtLogger(os.Stderr)

    fieldKeys := []string{"method", "error"}
    requestCount := kitprometheus.NewCounter(stdprometheus.CounterOpts{
        // ...
    }, fieldKeys)
    requestLatency := metrics.NewTimeHistogram(time.Microsecond, kitprometheus.NewSummary(stdprometheus.SummaryOpts{
        // ...
    }, fieldKeys))
    countResult := kitprometheus.NewSummary(stdprometheus.SummaryOpts{
        // ...
    }, []string{}))

    var svc StringService
    svc = stringService{}
    svc = loggingMiddleware{logger, svc}
    svc = instrumentingMiddleware{requestCount, requestLatency, countResult, svc}

    // ...

    http.Handle("/metrics", stdprometheus.Handler())
}

stringsvc2

到目前為止我們已經完成了叫做 stringsvc2 的微服務範例。

$ go get github.com/go-kit/kit/examples/stringsvc2
$ stringsvc2
msg=HTTP addr=:8080  
$ curl -XPOST -d'{"s":"hello, world"}' localhost:8080/uppercase
{"v":"HELLO, WORLD","err":null}
$ curl -XPOST -d'{"s":"hello, world"}' localhost:8080/count
{"v":12}
method=uppercase input="hello, world" output="HELLO, WORLD" err=null took=2.455µs  
method=count input="hello, world" n=12 took=743ns  

呼叫其他服務

照理來說,很少服務是處於一個真空狀態(不依賴其他服務)。通常來說你還是會需要呼叫其他服務。而這就是 Go kit 最能發揮的地方了。我們提供了轉繼點中介層來解決這方面的問題。

如果字串服務要呼叫另一個字串服務,這個時候就會需要代理請求到另一個服務。現在就來實作一個代理中介層並將其稱為 ServiceMiddleware,就像紀錄器或是測量中介層一樣。

// proxymw 實作了 StringService, 將 Uppercase 請求轉發給
// 提供的進入點,然後將接下來的所有請求(如 Count)傳遞給
// 下一個 StringService。
type proxymw struct {  
    ctx       context.Context
    next      StringService     // Serve most requests via this service...
    uppercase endpoint.Endpoint // ...except Uppercase, which gets served by this endpoint
}

客戶端進入點

我們現在有了個長得就像進入點的東西,但這次不太一樣,這個進入點會「觸發」而不是「發送」服務。當我們這麼做的時候會稱其為「客戶端進入點」。欲要觸發這個客戶端進入點,我們只需要做一些基本的溝通轉換。

func (mw proxymw) Uppercase(s string) (string, error) {  
    response, err := mw.uppercase(mw.Context, uppercaseRequest{S: s})
    if err != nil {
        return "", err
    }
    resp := response.(uppercaseResponse)
    if resp.Err != "" {
        return resp.V, errors.New(resp.Err)
    }
    return resp.V, nil
}

現在,我們要建立一個代理中介層能夠將一個網址轉換成進入點。如果我們的 JSON 資料是透過 HTTP 傳遞,那麼 transport/http 套件也許就能派上用場。

import (  
    httptransport "github.com/go-kit/kit/transport/http"
)

func proxyingMiddleware(proxyURL string, ctx context.Context) ServiceMiddleware {  
    return func(next StringService) StringService {
        return proxymw{ctx, next, makeUppercaseEndpoint(ctx, proxyURL)}
    }
}

func makeUppercaseEndpoint(ctx context.Context, proxyURL string) endpoint.Endpoint {  
    return httptransport.NewClient(
        "GET",
        mustParseURL(proxyURL),
        encodeUppercaseRequest,
        decodeUppercaseResponse,
    ).Endpoint()
}

服務探索與負載平衡

只有一個服務是沒什麼問題,但事實並不盡然,我們會有許多服務節點。且我們希望能夠取得這些可用的服務,然後告訴所有主機說這些服務可供使用。如果其中有個節點開始腦殘了,我們希望能夠解決那個問題節點,這樣才不會影響到其他服務。

Go kit 提供了轉接器來應付不同的服務探索系統,並取得最新可用的節點、將其暴露成單個進入點。這些轉接器被稱作「訂閱者(Subscriber)」。

type Subscriber interface {  
    Endpoints() ([]endpoint.Endpoint, error)
}

訂閱者會用一個工廠(Factory)函式來將探索到的節點地址(格式通常是「主機:埠口」)轉換成一個可用的進入點。

type Factory func(instance string) (endpoint.Endpoint, error)  

到目前為止,我們的函式 makeUppercaseEndpoint 是直接透過網址呼叫服務的。為了安全起見,在其中置入些安全性中介層也就變得額外重要,像是:斷路器、速率限制器⋯等。

var e endpoint.Endpoint  
e = makeUppercaseProxy(ctx, instance)  
e = circuitbreaker.Gobreaker(gobreaker.NewCircuitBreaker(gobreaker.Settings{}))(e)  
e = kitratelimit.NewTokenBucketLimiter(jujuratelimit.NewBucketWithRate(float64(maxQPS), int64(maxQPS)))(e)  
}

現在我們會有許多不同節點的進入點,但我們只需要其中一個。這時就需要負載平衡,負載平衡包裹了訂閱者,接著從其中挑選出一個可用的進入點。Go kit 提供了基本的負載平衡服務,如果你想要撰寫一個更進階的功能也會變得簡單些。

type Balancer interface {  
    Endpoint() (endpoint.Endpoint, error)
}

現在我們就有能力選擇該用哪個進入點了。然後我們需要用「重試模塊」將負載平衡包覆著,如此一來就會在請求失敗時不斷地重試直到獲取可用的服務,或是超過重試次數、逾期時間。

func Retry(max int, timeout time.Duration, lb Balancer) endpoint.Endpoint  

是時候將這些串連成我們最終的代理中介層了,為了簡化之後的程序,我們讓使用者能夠在啟動時傳入一個 -proxy_to 標記,用以輸入多個服務的進入點(以逗號分隔)。

func proxyingMiddleware(instances string, ctx context.Context, logger log.Logger) ServiceMiddleware {  
    // 如果節點是空的,那麼就不用代理。
    if instances == "" {
        logger.Log("proxy_to", "none")
        return func(next StringService) StringService { return next }
    }

    // 替客戶端設置一些參數。
    var (
        qps         = 100                    // 超過這個速率會回傳錯誤
        maxAttempts = 3                      // 放棄前的重試次數
        maxTime     = 250 * time.Millisecond // 放棄前的逾期時間
    )

    // 不然的話,就替列表中的每個節點建立一個進入點,然後將其
    // 配給到固定的進入點。在真正的服務中,與其手動配發,
    // 你可能會用上 sd 套件來協助你撰寫服務探索系統。
    var (
        instanceList = split(instances)
        subscriber   sd.FixedSubscriber
    )
    logger.Log("proxy_to", fmt.Sprint(instanceList))
    for _, instance := range instanceList {
        var e endpoint.Endpoint
        e = makeUppercaseProxy(ctx, instance)
        e = circuitbreaker.Gobreaker(gobreaker.NewCircuitBreaker(gobreaker.Settings{}))(e)
        e = kitratelimit.NewTokenBucketLimiter(jujuratelimit.NewBucketWithRate(float64(qps), int64(qps)))(e)
        subscriber = append(subscriber, e)
    }

    // 然後從多個進入點中彙整成
    // 單一個獨立、會自動重試的負載平衡進入點。
    balancer := lb.NewRoundRobin(subscriber)
    retry := lb.Retry(maxAttempts, maxTime, balancer)

    // 最後回傳一個被 proxymw 實作的 ServiceMiddleware。
    return func(next StringService) StringService {
        return proxymw{ctx, next, retry}
    }
}

stringsvc3

到目前為止我們已經完成了叫做 stringsvc3 的微服務範例。

$ go get github.com/go-kit/kit/examples/stringsvc3
$ stringsvc3 -listen=:8001 &
listen=:8001 caller=proxying.go:25 proxy_to=none  
listen=:8001 caller=main.go:72 msg=HTTP addr=:8001  
$ stringsvc3 -listen=:8002 &
listen=:8002 caller=proxying.go:25 proxy_to=none  
listen=:8002 caller=main.go:72 msg=HTTP addr=:8002  
$ stringsvc3 -listen=:8003 &
listen=:8003 caller=proxying.go:25 proxy_to=none  
listen=:8003 caller=main.go:72 msg=HTTP addr=:8003  
$ stringsvc3 -listen=:8080 -proxy=localhost:8001,localhost:8002,localhost:8003
listen=:8080 caller=proxying.go:29 proxy_to="[localhost:8001 localhost:8002 localhost:8003]"  
listen=:8080 caller=main.go:72 msg=HTTP addr=:8080  
$ for s in foo bar baz ; do curl -d"{\"s\":\"$s\"}" localhost:8080/uppercase ; done
{"v":"FOO","err":null}
{"v":"BAR","err":null}
{"v":"BAZ","err":null}
listen=:8001 caller=logging.go:28 method=uppercase input=foo output=FOO err=null took=5.168µs  
listen=:8080 caller=logging.go:28 method=uppercase input=foo output=FOO err=null took=4.39012ms  
listen=:8002 caller=logging.go:28 method=uppercase input=bar output=BAR err=null took=5.445µs  
listen=:8080 caller=logging.go:28 method=uppercase input=bar output=BAR err=null took=2.04831ms  
listen=:8003 caller=logging.go:28 method=uppercase input=baz output=BAZ err=null took=3.285µs  
listen=:8080 caller=logging.go:28 method=uppercase input=baz output=BAZ err=null took=1.388155ms  

進階話題

傳遞內文

照理來說資料會是分散的,例如有個函式處理帳號,另一個函式處理密碼,久而久之可能會導致資料分散不易整理。

此時你可以參考內文物件(Context Object),內文物件帶有這些資訊(帳號、密碼⋯等成一個物件),並在不同的成員中互相傳遞。在我們的範例中,我們還沒有透過商業邏輯來共享內文。但通常會建議這麼做。因為這允許你在商業邏輯與中介層之間傳遞相同的資訊而不會零散,且讓追蹤變得更好掌握。

具體來說,這意味著你的商業邏輯介面會看起來像這樣:

type MyService interface {  
    Foo(context.Context, string, int) (string, error)
    Bar(context.Context, string) error
    Baz(context.Context) (int, error)
}

分散式追蹤

一但你的結構逐漸腫大,追蹤多個服務中的請求也就變得更重要,且有助於你理解問題的核心所在。 查看 tracing 套件 來理解更多相關資訊。

建立客戶端套件

事實上你能夠透過 Go kit 建立一個客戶端套件來連線到你的服務,這能夠讓你從其他的 Go 程式中方便地連入你的服務。基本上你的客戶端套件會是一個透過特殊轉繼點觸發遠端服務節點的服務介面(Interface)實作。看看套件 addsvc/client 或是套件 profilesvc/client 來做為參考。


原文:Go kit - The stringsvc tutorial

The Go Gopher was designed by Renée French and is licensed under the Creative Commons 3.0 Attributions license.