{wcademy}

Как написать микросервис на Go kit?

May 04, 2020

Что такое микросервисы?

В последнее время, большую популярность получила микросервисная архитектура. Это когда мы разбиваем наш проект на множество отдельных небольших компонентов («микросервисов») вместо того, чтобы всё писать в одном большом (так называемом «монолите»). Они деплоятся и обновляются отдельно, общаются друг с другом либо синхронно, с помощью http или grpc, либо асинхронно — через очереди сообщений. Микросервисы любят за то, что они облегают создание больших проектов. Разные микросервисы могут разрабатываться разными командами, могут независимо масштабироваться, их можно писать на разных языках программирования (выбрать наиболее подходящий инструмент), использовать разные базы данных и т. д. и т. п. Минусы тоже, конечно, есть, кто без них. Но во время написания этой статьи это почти стандартный подход для написания бэкэнда.

Что такое Go kit?

Go kit — набор библиотек, облегчающий написание микросервисов на Go, в котором заранее решены множество проблем, ждущие любого, кто будет создавать распределённую систему с нуля.

Что мы будем делать?

Мы напишем очень простой микросервис, который будет возвращать и валидировать даты. Основная цель — понять, как работает Go kit, ничего больше. Это легко можно реализовать и без Go kit, но мы же учимся?

У нашего микросервиса будут следующие эндпоинты:

  • GET /status — обязательный эндпоинт для любого приложения, будет возвращать статус 200 и сообщение ок, если микросервис жив и работает
  • GET /get — будет возвращать текущую дату = POST /validate — будет принимать дату в формате dd.mm.yyy и проверять её валидность

Давайте начнём.

Для этого туториала у вас должен быть установлен Go. Также вам, по меньшей мере, нужно знать его основы.

Микросервис дат

Отлично, давайте начнём с создания новой папки с названием dateservice. Это имя также будет названием нашего пакета.

> mkdir -p dateservice/{cmd/dateservice,pkg}
> cd dateservice
> go mod init dateservice
go: creating new go.mod: module dateservice

На данный момент проект должен выглядеть так:

> tree
├── cmd
│   └── dateservice
├── go.mod
└── pkg

3 directories, 1 file

Начнём с создания файла pkg/service.go и опишем интерфейс нашего будущего сервиса.

package pkg

import (
	"context"
	"time"
)

type Service interface {
	Status(ctx context.Context) (string, error)
	Get(ctx context.Context) (string, error)
	Validate(ctx context.Context, date string) (bool, error)
}

type dateService struct{}

func NewService() Service {
	return dateService{}
}

func (dateService) Status(ctx context.Context) (string, error) {
	return "ok", nil
}

func (dateService) Get(ctx context.Context) (string, error) {
	now := time.Now()
	return now.Format("02.01.2006"), nil
}

func (dateService) Validate(ctx context.Context, date string) (bool, error) {
	_, err := time.Parse("02.01.2006", date)
	if err != nil {
		return false, err
	}

	return true, nil
}

В новом типе dateService (пустой структуре) мы группируем методы микросервиса, а также «прячем» детали его реализации от внешнего мира, так как он всего лишь реализует созданный ранее интерфейс.

NewService можно считать конструктором нашего «объекта». Мы вызовем его, чтобы создать сервис и спрятать внутреннюю логику его работы (как делают все хорошие программисты).

Напишем тест

TDD, все дела. Если на код не написан тест, этот код нельзя считать написанным. К тому же тесты — отличный способ документировать код, чтобы показать, как его нужно использовать. Создадим pkg/service_test.go:

package pkg

import (
	"context"
	"testing"
	"time"
)

func Test_dateService_Get(t *testing.T) {
	type args struct {
		ctx context.Context
	}
	tests := []struct {
		name    string
		args    args
		want    string
		wantErr bool
	}{
		{
			"today",
			args{context.Background()},
			time.Now().Format("02.01.2006"),
			false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			da := dateService{}
			got, err := da.Get(tt.args.ctx)
			if (err != nil) != tt.wantErr {
				t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("Get() got = %v, want %v", got, tt.want)
			}
		})
	}
}

func Test_dateService_Status(t *testing.T) {
	type args struct {
		ctx context.Context
	}
	tests := []struct {
		name    string
		args    args
		want    string
		wantErr bool
	}{
		{
			"ok",
			args{context.Background()},
			"ok",
			false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			da := dateService{}
			got, err := da.Status(tt.args.ctx)
			if (err != nil) != tt.wantErr {
				t.Errorf("Status() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("Status() got = %v, want %v", got, tt.want)
			}
		})
	}
}

func Test_dateService_Validate(t *testing.T) {
	type args struct {
		ctx  context.Context
		date string
	}
	tests := []struct {
		name    string
		args    args
		want    bool
		wantErr bool
	}{
		{
			"valid date",
			args{
				context.Background(),
				"31.12.2019",
			},
			true,
			false,
		},
		{
			"invalid date",
			args{
				context.Background(),
				"31.31.2019",
			},
			false,
			true,
		},
		{
			"invalid, USA formatted date",
			args{
				context.Background(),
				"12.31.2019",
			},
			false,
			true,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			da := dateService{}
			got, err := da.Validate(tt.args.ctx, tt.args.date)
			if (err != nil) != tt.wantErr {
				t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("Validate() got = %v, want %v", got, tt.want)
			}
		})
	}
}

И теперь у нас есть то, что можно запустить:

go test ./...
ok dateservice 0.058s

И да, всё работает. Только после запуска тестов мы можем говорить об этом хотя бы с какой-то вероятностью.

Транспорты

Наш сервис будет взаимодействовать с окружающим миром с помощью HTTP. Начнём с описания запросов и ответов сервиса. Создадим файл pkg/transport.go в той же папке.

package pkg

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

// Сначала описываем "модели" запросов и ответов нашего сервиса

type getRequest struct{}

type getResponse struct {
	Date string `json:"date"`
	Err  error  `json:"err,omitempty"`
}

type validateRequest struct {
	Date string `json:"date"`
}

type validateResponse struct {
	Valid bool  `json:"valid"`
	Err   error `json:"err,omitempty"`
}

type statusRequest struct{}

type statusResponse struct {
	Status string `json:"status"`
}

// Затем описываем "декодеры" для входящих запросов

func decodeGetRequest(
	_ context.Context,
	_ *http.Request,
) (interface{}, error) {
	return getRequest{}, nil
}

func decodeValidateRequest(
	_ context.Context,
	r *http.Request,
) (interface{}, error) {
	var req validateRequest

	err := json.NewDecoder(r.Body).Decode(&req)

	if err != nil {
		return nil, err
	}

	return req, nil
}

func decodeStatusRequest(
	_ context.Context,
	_ *http.Request,
) (interface{}, error) {
	return statusRequest{}, nil
}

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

Кода многовато, но, думаю, комменты помогут разобраться. В первой части файла мы описываем формат запрос и ответов сервиса и их сериализацию/десериализацию. statusRequest и getRequest пустые, сервису не нужно передавать никаких данных в запросах. В validationRequest есть поле date, в котором мы будем передавать дату для валидации.

Ответы сервиса тоже весьма простые.

Во второй части мы описываем “декодеры” для входящих запросов, говорящие сервису как он должен понимать входящие запросы и корректно преобразовать их в модели запросов.

И, наконец, мы описываем сериализатор для ответа, который представляет собой просто JSON-сериализатор. Получаем объект — возвращаем JSON.

С транспортами — всё, идём дальше к эндпоинтам.

Эндпоинты

Создадим новый файл pkg/endpoint.go. В этом файле будет определены эндпоинты, которые будут направлять запросы от клиента к внутренностям сервиса.

package pkg

import (
	"context"
	"errors"

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

var (
	errUnexpected = errors.New("unexpected error")
)

func MakeStatusEndpoint(srv Service) endpoint.Endpoint {
	return func(
		ctx context.Context,
		request interface{},
	) (interface{}, error) {
		res, err := srv.Status(ctx)
		if err != nil {
			return statusResponse{res}, err
		}

		return statusResponse{res}, nil
	}
}

func MakeGetEndpoint(srv Service) endpoint.Endpoint {
	return func(
		ctx context.Context,
		request interface{},
	) (interface{}, error) {
		res, err := srv.Get(ctx)
		if err != nil {
			return getResponse{res, err}, err
		}

		return getResponse{Date: res}, nil
	}
}

func MakeValidateEndpoint(srv Service) endpoint.Endpoint {
	return func(
		ctx context.Context,
		request interface{},
	) (interface{}, error) {
		req, ok := request.(validateRequest)
		if !ok {
			return validateResponse{Err: errUnexpected}, errUnexpected
		}

		res, err := srv.Validate(ctx, req.Date)
		if err != nil {
			return validateResponse{res, err}, nil
		}

		return validateResponse{Valid: res}, nil
	}
}

type Endpoints struct {
	StatusEndpoint   endpoint.Endpoint
	GetEndpoint      endpoint.Endpoint
	ValidateEndpoint endpoint.Endpoint
}

func (e Endpoints) Get(ctx context.Context) (string, error) {
	req := getRequest{}

	resp, err := e.GetEndpoint(ctx, req)
	if err != nil {
		return "", err
	}

	getResp, ok := resp.(getResponse)
	if !ok {
		return "", errUnexpected
	}

	if getResp.Err != nil {
		return "", getResp.Err
	}

	return getResp.Date, nil
}

func (e Endpoints) Status(ctx context.Context) (string, error) {
	req := statusRequest{}

	resp, err := e.StatusEndpoint(ctx, req)
	if err != nil {
		return "", err
	}

	statusResp, ok := resp.(statusResponse)
	if !ok {
		return "", errUnexpected
	}

	return statusResp.Status, nil
}

func (e Endpoints) Validate(ctx context.Context, date string) (bool, error) {
	req := validateRequest{Date: date}

	resp, err := e.ValidateEndpoint(ctx, req)
	if err != nil {
		return false, err
	}

	validateResp, ok := resp.(validateResponse)
	if !ok {
		return false, errUnexpected
	}

	if validateResp.Err != nil {
		return false, validateResp.Err
	}

	return validateResp.Valid, nil
}

Теперь немного поподробнее. Мы описали все эндпоинты (Status(), Get() и Validate()) в структуре Endpoints. Они соответствуют методам сервиса. Также с помощью функций Make... мы описали реализацию методов Endpoints. Каждая из этих функций получает сервис, преобразует request к ожидаемому типу, и использует его для вызова метода сервиса.

Сервер

Теперь нам нужно написать http-сервер. Можно использовать и встроенный, но для простоты воспользуемся github.com/julienschmidt/httprouter. Новый файл pkg/server.go:

package pkg

import (
	"net/http"

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

func NewHTTPServer(endpoints Endpoints) *http.ServeMux {
	router := http.NewServeMux()

	// создадим простой middleware
	// он будет устанавливать для всех запросов,
	// зарегистрированных через него, тип ответа "application/json"
	handle := func(pattern string, handler http.Handler) {
		router.Handle(pattern, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
			writer.Header().Add("Content-Type", "application/json; charset=utf-8")
			handler.ServeHTTP(writer, request)
		}))
	}

	handle(
		"/status",
		httptransport.NewServer(
			endpoints.StatusEndpoint,
			decodeStatusRequest,
			encodeResponse,
		),
	)

	handle(
		"/get",
		httptransport.NewServer(
			endpoints.GetEndpoint,
			decodeGetRequest,
			encodeResponse,
		),
	)

	handle(
		"/validate",
		httptransport.NewServer(
			endpoints.ValidateEndpoint,
			decodeValidateRequest,
			encodeResponse,
		),
	)

	return router
}

httptransport.NewServer собирает эндпоинт, сериализатор и десериализатор. Сам Endpoints мы инициализируем чуть позже, в main.go. Также обратите внимание на небольшой хэлпер handle, который выставляет заголовок с типом ответа сервера.

Последний штрих

И осталось немного, завершающие штрихи. У нас уже есть http-сервер, нам нужно только его запустить. Для этого создадим файл cmd/dateservice/main.go:

package main

import (
	"flag"
	"log"
	"net/http"

	"dateservice/pkg"
)

func main() {
	var (
		httpAddr = flag.String("http", ":8080", "http listen address")
	)

	flag.Parse()

	srv := pkg.NewService()

	// создаем Endpoints
	endpoints := pkg.Endpoints{
		GetEndpoint:      pkg.MakeGetEndpoint(srv),
		StatusEndpoint:   pkg.MakeStatusEndpoint(srv),
		ValidateEndpoint: pkg.MakeValidateEndpoint(srv),
	}

	handler := pkg.NewHTTPServer(endpoints)

	log.Printf("dateservice is running on %s\n", *httpAddr)

	if err := http.ListenAndServe(*httpAddr, handler); err != nil {
		log.Println(err)
	}
}

Мы создали пакет main, импортировали всё, что нам нужно. С помощью пакета flag сделали порт, который будет слушать сервер, конфигурируемым (с классическим 8080, по умолчанию). Создали endpoints, инициализировав его методы актуальными функциями. И запустили сервер, передав ему эндпоинты.

И запускаем

Если мы сделали всё правильно, оно должно заработать. Запускаем

> go run cmd/dateservice/main.go
2020/05/04 01:31:32 dateservice is running on :8080

из папки с проектом, а теперь давайте тестировать:

> http localhost:8080/status
> HTTP/1.1 200 OK
> Content-Length: 16
> Content-Type: application/json; charset=utf-8
> Date: Sun, 03 May 2020 21:34:24 GMT

{
"status": "ok"
}

> http localhost:8080/get
> HTTP/1.1 200 OK
> Content-Length: 22
> Content-Type: application/json; charset=utf-8
> Date: Sun, 03 May 2020 21:34:36 GMT

{
"date": "04.05.2020"
}

> http POST localhost:8080/validate date=12.11.2020
> HTTP/1.1 200 OK
> Content-Length: 15
> Content-Type: application/json; charset=utf-8
> Date: Sun, 03 May 2020 21:34:50 GMT

{
"valid": true
}

> http POST localhost:8080/validate date=12.30.2020
> HTTP/1.1 200 OK
> Content-Length: 138
> Content-Type: application/json; charset=utf-8
> Date: Sun, 03 May 2020 21:35:01 GMT

{
"err": {
"Layout": "02.01.2006",
"LayoutElem": "01",
"Message": ": month out of range",
"Value": "12.30.2020",
"ValueElem": ".2020"
},
"valid": false
}

У меня всё работает, надеюсь, у вас тоже. Если что, полный код можно найти в репозитории.

В заключение

Мы создали с нуля микросервис на Go kit. Несмотря на то, что он очень простой, это отличный путь, чтобы начать использовать GO kit. Мы начали с того, что определили в сервисе функции, которые будет выполнять микросервис. Затем определили формат запросов/ответов в транспорте. Создали эндпоинты. И, наконец, сконфигурировали веб-сервер, чтобы тот вызывал эндпоинты.

🚀  Если узнал из статьи что-то полезное, ставь лайк и подписывайся на наш канал в Телеграм или группу ВК. Обсудить статью можно в нашем уютном чатике 😏

© 2019 - 2022, {wcademy}