Go-kitのサンプル読んでみた

KiKUCHi
26 min readAug 20, 2019

はじめに

今回Go-kitのサンプルを読んでみようと思ったモチベーションは、Go言語でなにか作ってみたいなーと思っていて、マイクロサービスにも興味があったので、Go言語でマイクロサービスを扱うツールであるGo-kitを使ってなんか作ってみようと思い立ったところからです。

まずは、Go-kitのサンプルを読んでみて、どんな特徴があるのか、どうやって実装するのかを、備忘録としてまとめておこうと思います。

進め方としては、Go-kitリポジトリに含まれているstringsvcというサンプルを見ていこうと思います。stringsvcは1, 2, 3と徐々にマイクロサービス的なGo-kitの使い方にしていくサンプルになります。

https://gokit.io/examples/stringsvc.html

Go-kit

Go-kitは、公式リポジトリに以下の記述があるように、Go言語でマイクロサービスを構築するためのツールです。

Go kit is a programming toolkit for building microservices (or elegant monoliths) in Go.

最小限のGo-kitサンプル

まずは、最小限のGo-kitサンプルであるstringsvc1から見てみます。

stringsvc1は、渡された文字列を大文字化して返すuppercaseというエンドポイントと、countという渡された文字列の長さを返すエンドポイントを、main.goのみに記述していくサンプルになります。

どのように動作するかを確認したい場合は、go getでサンプルを取得すれば実行することができます(stringsvc2,3についても同様です)。

$ go get github.com/go-kit/kit/examples/stringsvc1
$ stringsvc1

ビジネスロジックの作成

まずは、文字列を大文字化する処理と、文字列の長さを返す処理から作成していきます。

stringServiceという型と、UppercaseとCountというメソッドを持つStringServiceインターフェースを定義します。

type stringService struct{}type StringService interface {
Uppercase(string) (string, error)
Count(string) int
}

そしてビジネスロジックを追加します。

import (
"context"
"errors"
"strings"
)

type stringService struct{}
// 渡された文字列を大文字化して返すメソッド。
func (stringService) Uppercase(s string) (string, error) {
if s == "" {
return "", ErrEmpty
}
// 文字列を大文字化する。
return strings.ToUpper(s), nil
}
// 渡された文字列の長さを返すメソッド。
func (stringService) Count(s string) int {
return len(s)
}

// 渡された文字列が空だった場合に返すエラー
var ErrEmpty = errors.New("Empty string")

大文字化にはstringsパッケージのToUpper()を使用しています。渡された文字列が空だった場合には、空であることを表すエラーを返すようにしています。

リクエストとレスポンスの作成

リクエストとレスポンスの型を作成していきます。

// 大文字化のリクエスト
type uppercaseRequest struct {
S string `json:"s"`
}

// 大文字化のレスポンス
type uppercaseResponse struct {
V string `json:"v"`
Err string `json:"err,omitempty"`
}

// 文字数カウントのリクエスト
type countRequest struct {
S string `json:"s"`
}

// 文字数カウントのレスポンス
type countResponse struct {
V int `json:"v"`
}

Go-kitは、RPCがプライマリメッセージパターンなので、RPCの形ですべてのインターフェースがモデル化されます。

uppercaseResponseのErrの値についているomitemptyは、errの値が空の際に、レスポンスから省略するためについています。

エンドポイントの作成

次に、エンドポイントの作成を行います。今回は、uppercaseとcountを作成します。

エンドポイントは、Go-kit内に以下のように定義されています。

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

ビジネスロジックをエンドポイントに変換するためのアダプターを作成します。

import (
"context"
"github.com/go-kit/kit/endpoint"
)

// 大文字化のエンドポイント
func makeUppercaseEndpoint(svc StringService) endpoint.Endpoint {
return func(_ 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(_ context.Context, request interface{}) (interface{}, error) {
req := request.(countRequest)
v := svc.Count(req.S)
return countResponse{v}, nil
}
}

各アダプターには、StringServiceを渡します。そこから、エンドポイントを生成するために関数を呼び出し、返り値とします。エンドポイント生成関数内では、requestパラメータを各リクエストにキャストし、ビジネスロジックを呼び出し、各レスポンスのインスタンスを生成します。

トランスポートの設定

最後に、作成したサービスを外部に公開する必要があるため、トランスポートを設定します。

今回の例では、HTTPを使用して外部に公開します。そのため、uppercaseとcountのハンドラーを生成し、サーバーを起動します。

ハンドラーの作成には、エンドポイントの他にリクエストをデコードするための処理と、レスポンスをjsonにエンコードするための処理を渡すひつようがあるため、その処理も定義しています。

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

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

func main() {
svc := stringService{}

// 大文字化する処理のハンドラー
uppercaseHandler := httptransport.NewServer(
makeUppercaseEndpoint(svc),
decodeUppercaseRequest,
encodeResponse,
)

// 文字数をカウントする処理のハンドラー
countHandler := httptransport.NewServer(
makeCountEndpoint(svc),
decodeCountRequest,
encodeResponse,
)

// ハンドラーセットする。
http.Handle("/uppercase", uppercaseHandler)
http.Handle("/count", countHandler)
// 8080ポートでサーバーを起動する。
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)
}

ビルドし実行する

あとはビルドし実行することで、localhost:8080の/uppercaseと/countへリクエストすることで、処理を実行することができます。

$ go build stringsvc1
$ stringsvc1

ミドルウェアの追加(ログ出力処理の追加)

つぎは、先程作成したstringsvc1にログ出力するための処理をミドルウェアとして追加していきます。

ファイルの分離

まず、先程のサンプルでは、main.goにすべての処理を記述しているので、関心事ごとにファイルを分割していきます。

まず、ビジネスロジックは、servicesファイルに移動させます。

type stringService struct{}type StringService interface {
Uppercase(string) (string, error)
Count(string) int
}
func (stringService) Uppercase(s string) (string, error) {
// ...
}
func (stringService) Count(s string) int {
// ...
}

var ErrEmpty = errors.New("Empty string")

次にトランスポートに関する処理をtransportsファイルに移動させます。

func makeUppercaseEndpoint
func makeCountEndpoint
func decodeUppercaseRequest
func decodeCountRequest
func encodeResponse
type uppercaseRequest
type uppercaseResponse
type countRequest
type countResponse

これでmain.goには、main()のハンドラーの生成と、サーバーの起動処理のみが残ることになり、それぞれの関心事でファイルを分けることができます。

トランスポートのログ出力

まず、トランスポートのログ出力処理から追加していきます。

それぞれのトランスポートでログを出力するためにはloggerを各コンポーネントに渡す必要があります。そのため、main関数内で、loggerのインスタンスを生成し、それを各コンポーネントに渡すようにします。

loggerをstringServiceの引数に直接渡すという方法もあるのですが、Go-kitでは、Middlewareという方法があります。Middlewareは、エンドポイントを取得し、エンドポイントを返す関数です。このMiddlewareをstringServiceを呼ぶ間に挟むことで、ビジネスロジックを呼ぶ前に様々な処理を行うことができるようになります。

StringServiceは、インターフェースとして定義しているため、StringServiceをラップする新しい型を定義することで、ログ出力をするための処理を追加することができます。

loggingファイルを新たに作成し、以下の処理を追加します。

// 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())

// StringServiceのUppercaseを呼ぶ。
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())

// StringServiceのCountを呼ぶ。
n = mw.next.Count(s)
return
}

そして、main関数内で、ログ出力用のMiddlewareに差し替えます。

import (
"os"

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

func main() {
// loggerを生成する。
logger := log.NewLogfmtLogger(os.Stderr)

var svc StringService
svc = stringService{}
// loggingファイルに作成したlogintMiddlewareにする。
svc = loggingMiddleware{logger, svc}

// ...

// それぞれのハンドラーをログ出力用のものに置き換える。
uppercaseHandler := httptransport.NewServer(
// ...
makeUppercaseEndpoint(svc),
// ...
)

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

この時点で実行してみると、各エンドポイントにリクエストを投げるとログが出力されます。これで、ログを出力するという目的を達成することができました。

統計情報を出力する

ログの出力をしましたが、動作全体に関する統計情報を出力したい場合、Go-kitに含まれるmetricsパッケージを使用することで、出力させることができます。

instrumentingファイルを新たに作成し、以下の内容を追加します。

// 統計情報を取得するための型を定義する。
type instrumentingMiddleware struct {
requestCount metrics.Counter
requestLatency metrics.Histogram
countResult metrics.Histogram
next StringService
}

func (mw instrumentingMiddleware) Uppercase(s string) (output string, err error) {
defer func(begin time.Time) {
lvs := []string{"method", "uppercase", "error", fmt.Sprint(err != nil)}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
}(time.Now())

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

func (mw instrumentingMiddleware) Count(s string) (n int) {
defer func(begin time.Time) {
lvs := []string{"method", "count", "error", "false"}
mw.requestCount.With(lvs...).Add(1)
mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds())
mw.countResult.Observe(float64(n))
}(time.Now())

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

そしてmain.go内で、統計情報を取得するための内容を追加します。

        fieldKeys := []string{"method", "error"}
// リクエストされた回数
requestCount := kitprometheus.NewCounterFrom(stdprometheus.CounterOpts{
Namespace: "my_group",
Subsystem: "string_service",
Name: "request_count",
Help: "Number of requests received.",
}, fieldKeys)
// 各リクエストの合計レイテンシ
requestLatency := kitprometheus.NewSummaryFrom(stdprometheus.SummaryOpts{
Namespace: "my_group",
Subsystem: "string_service",
Name: "request_latency_microseconds",
Help: "Total duration of requests in microseconds.",
}, fieldKeys)
// countメソッドの結果の合計
countResult := kitprometheus.NewSummaryFrom(stdprometheus.SummaryOpts{
Namespace: "my_group",
Subsystem: "string_service",
Name: "count_result",
Help: "The result of each count method.",
}, []string{}) // no fields here

var svc StringService
svc = stringService{}
svc = loggingMiddleware{logger, svc}
svc = instrumentingMiddleware{requestCount, requestLatency, countResult, svc}
...
// metricsで統計情報を取得できるようハンドラーをセットする。
http.Handle("/metrics", promhttp.Handler())

そして、実行すると、/metricsにアクセスすると動作している間の統計情報を取得することができます。各エンドポイントにリクエストしてみて、統計情報が変わるか確認してみてください。

別のサービスを呼び出す

マイクロサービスでは、各サービスが同じ空間内にあることは、まれです。そのため、別のサービスを呼び出す処理を追加する必要があります。この処理は、Go-kitが得意とするところで、複雑な処理を実装する際に発生する多くの問題を解決するトランスポートミドルウェアの仕組みを持っています。

今回は、文字列を大文字化する仕組みを別のサービスとして呼び出してみます。実際の処理としては、リクエストを別のサービスにプロキシします。プロキシミドルウェアをServiceMiddlewareとして実装します。

まずは、proxyingファイルを新たに作成し、StringServiceを継承したproxymwという型を新たに定義します。

type proxymw struct {
next StringService
uppercase endpoint.Endpoint
}

このproxymwは、countなどの処理は、従来どおりStringService内の処理を呼びます。ですが、大文字化する処理は、endpoint型のuppercaseを呼び出します。

クライアントエンドポイント

この場合は、リクエストを処理するのではなく、サービスを呼び出します。これをクライアントエンドポイントと呼びます。クライアントエンドポイントを呼び出すには、簡単な変換を行う必要があります。

まずは、別のサービスにプロキシするためのmiddlewareを作成します。

func (mw proxymw) Uppercase(s string) (string, error) {
response, err := mw.uppercase(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
}

今までの実装と違う点は、proxymwのuppercaseを呼び出している点です。uppercaseからのレスポンスを、uppercaseResponseとして返しています。

次に、プロキシミドルウェアを構築するためにプロキシURL文字列をエンドポイントに変換します。

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

func proxyingMiddleware(proxyURL string) ServiceMiddleware {
return func(next StringService) StringService {
return proxymw{next, makeUppercaseProxy(proxyURL)}
}
}

func makeUppercaseProxy(proxyURL string) endpoint.Endpoint {
return httptransport.NewClient(
"GET",
mustParseURL(proxyURL),
encodeUppercaseRequest,
decodeUppercaseResponse,
).Endpoint()
}

Go-kitに含まれるhttpのNewClientを使用することで、エンドポイントに変換しています。

ロードバランサー

サービスが一つしかない場合は、問題ないのですが、実際には、もっと多くのサービスインスタンスを利用することがあります。そのため、なんらかのサービス検出メカニズムを使用して、それらに負荷を分散させることができます。そして、それらのサービスが不適切に動作している場合、サービス全体の信頼性に影響を与えることなく対処することができます。

Go-kitには、個々のエンドポイントのインスタンスを取得するための様々なサービス検出システムへのアダプターを提供しています。これらのアダプターは、subscribersと呼ばれます。

subscribersは、ファクトリー関数を使用して、検出された各インスタンス文字列を使用可能なエンドポイントに変換します。インスタンス文字列は、通常host:portの形です。

エンドポイントのセットができたら、ロードバランサーは多数のエンドポイントから1つのエンドポイントを選択する必要があります。Go-kitには、基本的なロードバランサーがすでに用意されています。また、Retryストラテジーを使用することで、再試行回数とタイムアウトに達するまで、失敗したリクエストを再試行することができます。

では、プロキシミドルウェアに接続するための例を見てみます。

func proxyingMiddleware(instances string, 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 // タイムアップまでの時間
)

var (
// インスタンスリスト
       instanceList = split(instances)
// sdパッケージに含まれるサブスクライバー
subscriber sd.FixedSubscriber
)
logger.Log("proxy_to", fmt.Sprint(instanceList))
for _, instance := range instanceList {
var e endpoint.Endpoint
e = makeUppercaseProxy(instance)
e = circuitbreaker.Gobreaker(gobreaker.NewCircuitBreaker(gobreaker.Settings{}))(e)
e = kitratelimit.NewTokenBucketLimiter(jujuratelimit.NewBucketWithRate(float64(qps), int64(qps)))(e)
subscriber = append(subscriber, e)
}

// ロードバランサーとRetryストラテジーを設定する。
balancer := lb.NewRoundRobin(subscriber)
retry := lb.Retry(maxAttempts, maxTime, balancer)

// proxymwをStringServiceとして返す。
return func(next StringService) StringService {
return proxymw{next, retry}
}
}

これでuppercaseサービスを複数実行し、uppercaseにリクエストすると、それぞれのサービスインスタンスにロードバランシングします。

$ 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

まとめ

とりあえず、Go-kitの初歩的なサンプルを確認してみました。これで、基礎部分だけであれば十分なので、次は実際に自分で作ってみようと思います。stringsvc以外にもサンプルはあるので、詰まったらそれらの確認もしてみようと思います。

--

--