发布于 2026-01-06 0 阅读
0

使用 Go kit 在 Go 中实现微服务

使用 Go kit 在 Go 中实现微服务

在《微服务模式:Java示例》一书的其中一章中,作者提到了“微服务底盘”模式:

使用微服务底盘框架构建微服务,该框架可处理横切关注点,例如异常跟踪、日志记录、健康检查、外部化配置和分布式跟踪。

他更进一步,举例说明了在 Java 和 Go 中实现这些概念的框架:

经过一番研究,我选择了 Go 套件,因为它是最受欢迎的套件之一,更新速度也很快,而且我喜欢它提出的架构。

建筑学

服务

服务

服务是实现所有业务逻辑的地方。在 Go Kit 中,服务通常被建模为接口,而这些接口的实现则包含业务逻辑。Go Kit 服务应力求遵循整洁架构或六边形架构。也就是说,业务逻辑不应该了解传输域的概念:你的服务不应该知道任何关于 HTTP 标头或 gRPC 错误代码的信息。

端点

端点

端点就像控制器上的动作/处理程序;安全性和反脆弱性逻辑就存在于其中。如果您实现了两种传输方式(HTTP 和 gRPC),则可能需要两种方法向同一个端点发送请求。

运输

运输

传输域绑定到具体的传输协议,例如 HTTP 或 gRPC。在微服务可以支持一种或多种传输协议的情况下,这非常强大;你可以在单个微服务中同时支持传统的 HTTP API 和较新的 RPC 服务。

例子

让我们用这种架构创建一个微服务示例。目录结构如下所示:

例子

服务

本示例中的服务层代码非常简单:



package user

import (
    "auth/security"
    "context"
    "errors"
)

type Service interface {
    ValidateUser(ctx context.Context, mail, password string) (string, error)
    ValidateToken(ctx context.Context, token string) (string, error)
}

var (
    ErrInvalidUser  = errors.New("Invalid user")
    ErrInvalidToken = errors.New("Invalid token")
)

type service struct{}

func NewService() *service {
    return &service{}
}

func (s *service) ValidateUser(ctx context.Context, email, password string) (string, error) {
    //@TODO create validation rules, using databases or something else
    if email == "eminetto@gmail.com" && password != "1234567" {
        return "nil", ErrInvalidUser
    }
    token, err := security.NewToken(email)
    if err != nil {
        return "", err
    }
    return token, nil
}

func (s *service) ValidateToken(ctx context.Context, token string) (string, error) {
    t, err := security.ParseToken(token)
    if err != nil {
        return "", ErrInvalidToken
    }
    tData, err := security.GetClaims(t)
    if err != nil {
        return "", ErrInvalidToken
    }
    return tData["email"].(string), nil
}


Enter fullscreen mode Exit fullscreen mode

正如 Go 工具包文档所建议的,第一步是interface为我们的服务创建一个接口,该接口将用于实现我们的业务逻辑。很快,当我们在应用程序中添加日志记录和指标监控功能时,创建接口的这一决定就会显得非常有用。

由于服务层测试只包含业务规则,因此也非常简单:



package user_test

import (
    "auth/user"
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestValidateUser(t *testing.T) {
    service := user.NewService()
    t.Run("invalid user", func(t *testing.T) {
        _, err := service.ValidateUser(context.Background(), "eminetto@gmail.com", "invalid")
        assert.NotNil(t, err)
        assert.Equal(t, "Invalid user", err.Error())
    })
    t.Run("valid user", func(t *testing.T) {
        token, err := service.ValidateUser(context.Background(), "eminetto@gmail.com", "1234567")
        assert.Nil(t, err)
        assert.NotEmpty(t, token)
    })
}



Enter fullscreen mode Exit fullscreen mode

端点

现在我们将把函数暴露给外部。在这个例子中,两个函数都可以从外部访问,因此我们将创建两个端点。但这并非总是如此。根据具体情况,您可以只暴露部分函数,​​而将其他函数保留在服务层内部访问。



package user

import (
    "context"

    "github.com/go-kit/kit/endpoint"
)

//definition of endpoint input and output structures 
type validateUserRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
}

type validateUserResponse struct {
    Token string `json:"token,omitempty"`
    Err   string `json:"err,omitempty"` // errors don't JSON-marshal, so we use a string
}

//the endpoint will receive a request, convert to the desired
//format, invoke the service and return the response structure 
func makeValidateUserEndpoint(svc Service) endpoint.Endpoint {
    return func(ctx context.Context, request interface{}) (interface{}, error) {
        req := request.(validateUserRequest)
        token, err := svc.ValidateUser(ctx, req.Email, req.Password)
        if err != nil {
            return validateUserResponse{"", err.Error()}, err
        }
        return validateUserResponse{token, ""}, err
    }
}

//definition of endpoint input and output structures 
type validateTokenRequest struct {
    Token string `json:"token"`
}

type validateTokenResponse struct {
    Email string `json:"email,omitempty"`
    Err   string `json:"err,omitempty"`
}

//the endpoint will receive a request, convert to the desired
//format, invoke the service and return the response structure 
func makeValidateTokenEndpoint(svc Service) endpoint.Endpoint {
    return func(ctx context.Context, request interface{}) (interface{}, error) {
        req := request.(validateTokenRequest)
        email, err := svc.ValidateToken(ctx, req.Token)
        if err != nil {
            return validateTokenResponse{"", err.Error()}, err
        }
        return validateTokenResponse{email, ""}, err
    }
}



Enter fullscreen mode Exit fullscreen mode

端点的作用是接收请求,将其转换为预期的结构体,调用服务层,并返回另一个结构体。端点层对上层一无所知,因为无论端点是通过 HTTP、gRPC 还是其他传输方式被调用,都无关紧要。

由于其结构简单,测试这一层也同样容易实现:



package user

import (
    "context"
    "testing"
)

func TestMakeValidateUserEndpoint(t *testing.T) {
    s := NewService()
    endpoint := makeValidateUserEndpoint(s)
    t.Run("valid user", func(t *testing.T) {
        req := validateUserRequest{
            Email:    "eminetto@gmail.com",
            Password: "1234567",
        }
        _, err := endpoint(context.Background(), req)
        if err != nil {
            t.Errorf("expected %v received %v", nil, err)
        }
    })
    t.Run("invalid user", func(t *testing.T) {
        req := validateUserRequest{
            Email:    "eminetto@gmail.com",
            Password: "123456",
        }
        _, err := endpoint(context.Background(), req)
        if err == nil {
            t.Errorf("expected %v received %v", ErrInvalidUser, err)
        }
    })
}



Enter fullscreen mode Exit fullscreen mode

可以通过将服务的使用替换为实现相同Service接口的模拟服务来改进此测试,从而使测试更加高效。

运输

在这一层,我们可以实现多种协议,例如 HTTP、gRPC、AMPQ、NATS 等。在本例中,我们将以 HTTP API 的形式公开我们的端点。因此,我们将创建以下文件transpor_http.go



package user

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

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

func NewHttpServer(svc Service, logger log.Logger) *mux.Router {
    //options provided by the Go kit to facilitate error control 
    options := []httptransport.ServerOption{
        httptransport.ServerErrorLogger(logger),
        httptransport.ServerErrorEncoder(encodeErrorResponse),
    }
    //definition of a handler 
    validateUserHandler := httptransport.NewServer(
        makeValidateUserEndpoint(svc), //use the endpoint
        decodeValidateUserRequest, //converts the parameters received via the request body into the struct expected by the endpoint 
        encodeResponse, //converts the struct returned by the endpoint to a json response 
        options...,
    )

    validateTokenHandler := httptransport.NewServer(
        makeValidateTokenEndpoint(svc),
        decodeValidateTokenRequest,
        encodeResponse,
        options...,
    )
    r := mux.NewRouter() //I'm using Gorilla Mux, but it could be any other library, or even the stdlib 
    r.Methods("POST").Path("/v1/auth").Handler(validateUserHandler)
    r.Methods("POST").Path("/v1/validate-token").Handler(validateTokenHandler)
    return r
}

func encodeErrorResponse(_ context.Context, err error, w http.ResponseWriter) {
    if err == nil {
        panic("encodeError with nil error")
    }
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(codeFrom(err))
    json.NewEncoder(w).Encode(map[string]interface{}{
        "error": err.Error(),
    })
}

func codeFrom(err error) int {
    switch err {
    case ErrInvalidUser:
        return http.StatusNotFound
    case ErrInvalidToken:
        return http.StatusUnauthorized
    default:
        return http.StatusInternalServerError
    }
}

//converts the parameters received via the request body into the struct expected by the endpoint 
func decodeValidateUserRequest(ctx context.Context, r *http.Request) (interface{}, error) {
    var request validateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        return nil, err
    }
    return request, nil
}

//converts the parameters received via the request body into the struct expected by the endpoint 
func decodeValidateTokenRequest(ctx context.Context, r *http.Request) (interface{}, error) {
    var request validateTokenRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        return nil, err
    }
    return request, nil
}

//converts the struct returned by the endpoint to a json response
func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
    return json.NewEncoder(w).Encode(response)
}



Enter fullscreen mode Exit fullscreen mode

这段代码看起来像是一系列设置,用于指示每个 API 地址将使用哪个端点。我尝试在代码注释中描述其行为。这一层的测试代码如下:



package user

import (
    "net/http"
    "net/http/httptest"
    "os"
    "strings"
    "testing"

    "github.com/go-kit/kit/log"
)

func TestHTTP(t *testing.T) {
    var logger log.Logger
    logger = log.NewLogfmtLogger(os.Stderr)
    logger = log.With(logger, "listen", "8081", "caller", log.DefaultCaller)
    s := NewService()
    r := NewHttpServer(s, logger)
    srv := httptest.NewServer(r)

    for _, testcase := range []struct {
        method, url, body string
        want              int
    }{
        {"POST", srv.URL + "/v1/auth", `{"email": "eminetto@gmail.com", "password":"1234567"}`, http.StatusOK},
        {"GET", srv.URL + "/v1/auth", `{"email": "eminetto@gmail.com", "password":"1234567"}`, http.StatusMethodNotAllowed},
        {"POST", srv.URL + "/v1/auth", `{"email": "eminetto@gmail.com", "password":"invalid"}`, http.StatusNotFound},
        {"POST", srv.URL + "/v1/validate-token", `{"token": "invalid"}`, http.StatusUnauthorized},
    } {
        req, _ := http.NewRequest(testcase.method, testcase.url, strings.NewReader(testcase.body))
        resp, _ := http.DefaultClient.Do(req)
        if testcase.want != resp.StatusCode {
            t.Errorf("%s %s %s: want %d have %d", testcase.method, testcase.url, testcase.body, testcase.want, resp.StatusCode)
        }

    }
}



Enter fullscreen mode Exit fullscreen mode

就像测试端点层一样,我们可以使用服务的模拟来改进此测试。

主要的

在这个main.go文件中,我们将使用所有图层:



package main

import (
    "auth/user"
    "net/http"
    "os"

    "github.com/go-kit/kit/log"
)

func main() {

    var logger log.Logger
    logger = log.NewLogfmtLogger(os.Stderr)
    logger = log.With(logger, "listen", "8081", "caller", log.DefaultCaller)

    svc := user.NewLoggingMiddleware(logger, user.NewService())
    r := user.NewHttpServer(svc, logger)
    logger.Log("msg", "HTTP", "addr", "8081")
    logger.Log("err", http.ListenAndServe(":8081", r))
}


Enter fullscreen mode Exit fullscreen mode

这里我们可以看到为服务创建接口的另一个优势。该user.NewHttpServer函数需要一个实现了该接口的对象作为第一个参数Service。该user.NewLoggingMiddleware函数会创建一个实现了该接口的结构体,并将我们原始的服务包含在其中。该文件的代码logging.go如下所示:



package user

import (
    "context"
    "time"

    "github.com/go-kit/kit/log"
)

func NewLoggingMiddleware(logger log.Logger, next Service) logmw {
    return logmw{logger, next}
}

type logmw struct {
    logger log.Logger
    Service
}

func (mw logmw) ValidateUser(ctx context.Context, email, password string) (token string, err error) {
    defer func(begin time.Time) {
        _ = mw.logger.Log(
            "method", "validateUser",
            "input", email,
            "err", err,
            "took", time.Since(begin),
        )
    }(time.Now())

    token, err = mw.Service.ValidateUser(ctx, email, password)
    return
}

func (mw logmw) ValidateToken(ctx context.Context, token string) (email string, err error) {
    defer func(begin time.Time) {
        _ = mw.logger.Log(
            "method", "validateToken",
            "input", token,
            "err", err,
            "took", time.Since(begin),
        )
    }(time.Now())

    email, err = mw.Service.ValidateToken(ctx, token)
    return
}



Enter fullscreen mode Exit fullscreen mode

它实现了接口的所有功能,并增加了在调用实际服务代码之前记录每次函数调用的功能。同样的方法也可用于实现指标管理、限制 API 访问等。官方教程中提供了一些示例

如果我们的微服务需要以更多格式(例如 gRPC 或 NATS)交付逻辑,我们只需在传输层实现这些代码,并指定将使用哪些端点即可。这为功能扩展提供了极大的灵活性,而不会增加复杂性。

在这篇文章中,我更多地关注了 Go 工具包提供的架构,但在官方文档中,你可以看到它提供的其他chassis功能,例如身份验证、熔断器、日志、指标、速率限制、服务发现、跟踪等。

我喜欢它的架构和功能,我相信它能以快速、简洁、高效的方式创建服务。

本示例的代码位于此存储库中

文章来源:https://dev.to/eminetto/microservices-in-go-using-the-go-kit-jjf