Привет, Хабр! В предыдущей статье я поделился своей версией шаблона Go-микросервиса для начинающих, чтобы помочь тем, кто только начинает знакомиться с языком и еще не полностью его освоил. В этом продолжении я хочу подойти к задаче более серьезно и создать полностью функциональный сервис с необходимой инфраструктурой, которую мы развернем в Docker. Кроме того, я планирую внести изменения в структуру проекта, учитывая замечания из комментариев и анализа кода других проектов.

Содержание

Создание шаблона решил представить в виде небольшого проекта книжного магазина для простоты понимания. Как это будет выглядеть и что мы будем реализовывать, я отразил на схеме, прикрепленной ниже:

Визуальная схема, того, что мы хотим сделать
Визуальная схема, того, что мы хотим сделать

Теперь переходим от описания того, что мы хотим реализовать, к тому, как будет выглядеть наш микросервис.

Начнем с того, как будет взаимодействовать клиент с нашим книжным магазином. Для взаимодействия мы выбираем REST API, которое будет включать в себя следующие запросы:

  • GET /api/v1/books - Получение списка книг клиентом, доступных для продажи.

  • POST /api/v1/books - Добавление новой книги в наш магазин.

  • POST /api/v1/books/buy - Приобретение книги содержащейся в нашем магазине

В своей архитектуре микросервиса я решил отказаться от классического сервисного подхода в пользу CQRS с использованием библиотеки MediatR, где в качестве контрактов API будут выступать команды (Command) и запросы (Query). Такое решение очень хорошо ложится на микросервисы в .NET, и я думаю, что в Go это тоже не вызовет особых трудностей, так как на GitHub я видел множество проектов, работающих на такой основе.

Имитировать поставку книг мы будем с помощью фоновой задачи, запущенной вместе с HTTP-сервером. Эта задача будет отправлять новые книги в Kafka, а наш consumer будет сохранять их в нашем магазине. Этот элемент я добавил для того, чтобы показать пример взаимодействия с Kafka, так что претензии по поводу целесообразности использования Kafka здесь не принимаются.

Общая схема работы нашего магазина
Общая схема работы нашего магазина

Тесты, которые я приготовил для данного проекта, являются компонентными. Это своего рода аналог end-to-end тестов, но всё необходимое окружение для приложения поднимается в контейнерах, имитируя работу настоящего API. Ознакомиться с тем, что такое компонентные тесты, можно в следующей статье.

Однако они будут в продолжении данного шаблона и в следующей отдельной статье, также как и часть с kafka.

Начинаем

Посмотрев различные реальные проекты на GitHub, я решил немного отойти от той структуры, которую описывал в предыдущей статье. Я долго обдумывал это и пришел к следующему решению.

Для начала создадим проект и добавим в него первый каталог cmd, в который поместим наш основной файл main.go. Аналогом в последних версиях .NET выступает файл Program.cs, в котором происходит конфигурация нашего сервера.

Конфигурация нашего сервиса будет происходить с помощью библиотеки "go.uber.org/fx", поэтому откроем консоль и выполним следующую команду:

go get "go.uber.org/fx"

Заполняем main.go следующим образом.

package main

import "go.uber.org/fx"

func main() {
	fx.New(
		fx.Options(
			fx.Provide(),
		),
	).Run()
}

Uber FX представляет собой обёртку, в которой мы регистрируем все необходимые зависимости и также запускаем задачи на обработку.

Для большего понимания покажу, как бы это выглядело на .NET:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.Run();

Нам нужно сконфигурировать наш HTTP-сервер, который будет обрабатывать входящие запросы. Для этих целей я решил использовать библиотеку Echo.

go get "github.com/labstack/echo/v4"
go get "github.com/labstack/gommon/log"

Переходим в каталог, где будут лежать наши общие для сервисов пакеты pkg, и создаём каталог http, в котором создаём каталог server с файлом echo_server.go.

package echoserver

import (
	"context"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
	"time"
)

const (
	MaxHeaderBytes = 1 << 20
	ReadTimeout    = 15 * time.Second
	WriteTimeout   = 15 * time.Second
)

type EchoConfig struct {
	Port                string   `mapstructure:"port" validate:"required"`
	Development         bool     `mapstructure:"development"`
	BasePath            string   `mapstructure:"basePath" validate:"required"`
	DebugErrorsResponse bool     `mapstructure:"debugErrorsResponse"`
	IgnoreLogUrls       []string `mapstructure:"ignoreLogUrls"`
	Timeout             int      `mapstructure:"timeout"`
	Host                string   `mapstructure:"host"`
}

func NewEchoServer() *echo.Echo {
	e := echo.New()
	return e
}

// RunHttpServer - запустить наш HTTP-сервер
func RunHttpServer(ctx context.Context, echo *echo.Echo, cfg *EchoConfig) error {
	echo.Server.ReadTimeout = ReadTimeout
	echo.Server.WriteTimeout = WriteTimeout
	echo.Server.MaxHeaderBytes = MaxHeaderBytes

	go func() {
		for {
			select {
			case <-ctx.Done():
				log.Infof("Сервер завершает свою работу. HTTP POST: {%s}", cfg.Port)
				err := echo.Shutdown(ctx)
				if err != nil {
					log.Errorf("(ОТКЛЮЧЕНИЕ СЕРВЕРА) ошибка: {%v}", err)
					return
				}
				return
			}
		}
	}()

	err := echo.Start(cfg.Port)

	return err
}

В данном коде содержится непосредственно конфигурация нашего сервера и функция для непосредственно запуска.

Также нам необходимо создать файл context_provider.go в каталоге http, который будет останавливать работу сервера и отменять операции. Аналогом в .NET выступает CancellationToken.

package http

import (
	"context"
	"github.com/labstack/gommon/log"
	"os"
	"os/signal"
	"syscall"
)

// NewContext - создать новый контекст приложения. Context - является аналогом CancellationToken
func NewContext() context.Context {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
	go func() {
		for {
			select {
			case <-ctx.Done():
				log.Info("context is canceled!")
				cancel()
				return
			}
		}
	}()
	return ctx
}

Как это будет выглядеть:

Добавления конфигурации приложения

Далее для конфигурирования нашего сервиса необходимо подгружать основные настройки из конфигурационных файлов в формате JSON.

Библиотека, которая работает с конфигурация Viper, по-этому для начала подключим ее в проект.

go get "github.com/spf13/viper"

Теперь в корне проекта создаем папку config с файлами config.go , config.development.json

Содержимое config.go

package config

import (
	"fmt"
	"github.com/pkg/errors"
	"github.com/spf13/viper"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"os"
)

type Config struct {
	ServiceName string                 `mapstructure:"serviceName"`
	Echo        *echoserver.EchoConfig `mapstructure:"echo"`
}

func NewConfig() (*Config, *echoserver.EchoConfig, error) {
	env := os.Getenv("APP_ENV")
	if env == "" {
		env = "development"
	}

	cfg := &Config{}

	viper.SetConfigName(fmt.Sprintf("config.%s", env))
	viper.AddConfigPath("./config/")
	viper.SetConfigType("json")

	if err := viper.ReadInConfig(); err != nil {
		return nil, nil, errors.Wrap(err, "viper.ReadInConfig")
	}

	if err := viper.Unmarshal(cfg); err != nil {
		return nil, nil, errors.Wrap(err, "viper.Unmarshal")
	}

	return cfg, cfg.Echo, nil
}

Содержимое config.development.json

{
  "serviceName": "book_service",
  "deliveryType": "http",
  "context": {
    "timeout": 20
  },
  "echo": {
    "port": ":5000",
    "development": true,
    "timeout": 30,
    "basePath": "/api/v1",
    "host": "http://localhost",
    "debugHeaders": true,
    "httpClientDebug": true,
    "debugErrorsResponse": true,
    "ignoreLogUrls": [
      "metrics"
    ]
  }
}
как будет выглядеть по структуре
как будет выглядеть по структуре

Теперь возвращаемся в main.go, чтобы подключить в DI нашу конфигурацию:

package main

import (
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				echoserver.NewEchoServer,
			),
		),
	).Run()
}

Настройка сервера

Теперь для реализации первой части нашего микросервиса, необходимо сконфигурировать общий файл сервера, в котором будут запускаться echo_server и в будущем воркер, который будет отправлять новые книги со склада, через kafka.

Первая часть нашей реализации
Первая часть нашей реализации

По-этому идем и в корневой папке создаем каталог server с файлом server.go

package server

import (
	"context"
	"github.com/labstack/echo/v4"
	"github.com/pkg/errors"
	"go-template-microservice-v2/config"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go.uber.org/fx"
	"log"
	"net/http"
)

// RunServers - запустить все сервера
func RunServers(lc fx.Lifecycle, ctx context.Context, e *echo.Echo, cfg *config.Config) error {
	lc.Append(fx.Hook{
		OnStart: func(_ context.Context) error {
			log.Println("Starting server")

			// Запустить HTTP - сервер
			go func() {
				if err := echoserver.RunHttpServer(ctx, e, cfg.Echo); !errors.Is(err, http.ErrServerClosed) {
					log.Fatalf("error running http server: %v", err)
				}
			}()

			e.GET("/", func(c echo.Context) error {
				return c.String(http.StatusOK, cfg.ServiceName)
			})

			return nil
		},
		OnStop: func(_ context.Context) error {
			log.Println("all servers shutdown gracefully...")
			return nil
		},
	})

	return nil
}
как выглядит каталог
как выглядит каталог

Также на будущее устанавливаем пакет "github.com/go-playground/validator"

go get "github.com/go-playground/validator"

И возвращаемся в main.go для подключения нашего сервера.

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(server.RunServers),
		),
	).Run()
}

Запускаем и проверяем.

Наш сервер запустился, теперь мы можешь начать реализацию нашей схемы, но для начала мы подключим базу данных.

Подключение и конфигурация базы данных

Для начала подключить библиотеку для работы с гуидами и также скачиваем ORM gorm и дополнительные драйвера для подключения postgresql.

go get "github.com/satori/go.uuid"
go get "github.com/cenkalti/backoff/v4"
go get "github.com/uptrace/bun/driver/pgdriver"
go get "gorm.io/driver/postgres"
go get "gorm.io/gorm"

Отправляемся в каталог pkg в которой создаем папку gorm_pg , так-как совместно с ORM мы будет использовать базу данных postgresql, а в этом каталоге мы создаем файл pg_gorm.go

Сам файл с настройками подключение и накатыванием миграций будет выглядеть следующим образом.

package gormpg

import (
	"database/sql"
	"fmt"
	"github.com/cenkalti/backoff/v4"
	"github.com/pkg/errors"
	"github.com/uptrace/bun/driver/pgdriver"
	gorm_postgres "gorm.io/driver/postgres"
	"gorm.io/gorm"
	"time"
)

// PgConfig - конфигурация для соединения с Postgresql
type PgConfig struct {
	Host     string `mapstructure:"host"`
	Port     int    `mapstructure:"port"`
	User     string `mapstructure:"user"`
	DBName   string `mapstructure:"dbName"`
	SSLMode  bool   `mapstructure:"sslMode"`
	Password string `mapstructure:"password"`
}

// PgGorm - модель базы данных
type PgGorm struct {
	DB     *gorm.DB
	Config *PgConfig
}

func NewPgGorm(config *PgConfig) (*PgGorm, error) {
	err := createDatabaseIfNotExists(config)

	if err != nil {
		panic(err)
		return nil, err
	}

	connectionString := getConnectionString(config, config.DBName)

	bo := backoff.NewExponentialBackOff()
	bo.MaxElapsedTime = 10 * time.Second
	maxRetries := 5

	var gormDb *gorm.DB

	err = backoff.Retry(func() error {
		gormDb, err = gorm.Open(gorm_postgres.Open(connectionString), &gorm.Config{})

		if err != nil {
			return errors.Errorf("failed to connect postgres: %v and connection information: %s", err, connectionString)
		}

		return nil
	}, backoff.WithMaxRetries(bo, uint64(maxRetries-1)))

	return &PgGorm{DB: gormDb, Config: config}, err
}

func Migrate(gorm *gorm.DB, types ...interface{}) error {

	for _, t := range types {
		err := gorm.AutoMigrate(t)
		if err != nil {
			return err
		}
	}
	return nil
}

func createDatabaseIfNotExists(config *PgConfig) error {

	connectionString := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
		config.User,
		config.Password,
		config.Host,
		config.Port,
		"postgres",
	)

	pgSqlDb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(connectionString)))

	var exists int

	selectDbQueryString := fmt.Sprintf("SELECT 1 FROM  pg_catalog.pg_database WHERE datname='%s'", config.DBName)

	rows, err := pgSqlDb.Query(selectDbQueryString)
	if err != nil {
		return err
	}

	if rows.Next() {
		err = rows.Scan(&exists)
		if err != nil {
			return err
		}
	}

	if exists == 1 {
		return nil
	}

	createDbQueryString := fmt.Sprintf("CREATE DATABASE %s", config.DBName)

	_, err = pgSqlDb.Exec(createDbQueryString)
	if err != nil {
		return err
	}

	defer func(pgSqlDb *sql.DB) {
		err := pgSqlDb.Close()
		if err != nil {
			panic(err)
		}
	}(pgSqlDb)

	return nil
}

func getConnectionString(config *PgConfig, dbName string) string {
	return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s",
		config.Host,
		config.Port,
		config.User,
		dbName,
		config.Password,
	)
}
Как будет выглядеть в структуре
Как будет выглядеть в структуре

Теперь поправим наши конфигурационные файлы с учетом добавленного.

package config

import (
	"fmt"
	"github.com/pkg/errors"
	"github.com/spf13/viper"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"os"
)

type Config struct {
	ServiceName string                 `mapstructure:"serviceName"`
	Echo        *echoserver.EchoConfig `mapstructure:"echo"`
	PgConfig    *gormpg.PgConfig       `mapstructure:"pgConfig"`
}

func NewConfig() (*Config, *echoserver.EchoConfig, *gormpg.PgConfig, error) {
	env := os.Getenv("APP_ENV")
	if env == "" {
		env = "development"
	}

	cfg := &Config{}

	viper.SetConfigName(fmt.Sprintf("config.%s", env))
	viper.AddConfigPath("./config/")
	viper.SetConfigType("json")

	if err := viper.ReadInConfig(); err != nil {
		return nil, nil, nil, errors.Wrap(err, "viper.ReadInConfig")
	}

	if err := viper.Unmarshal(cfg); err != nil {
		return nil, nil, nil, errors.Wrap(err, "viper.Unmarshal")
	}

	return cfg, cfg.Echo, cfg.PgConfig, nil
}
{
  "serviceName": "book_service",
  "deliveryType": "http",
  "context": {
    "timeout": 20
  },
  "echo": {
    "port": ":5000",
    "development": true,
    "timeout": 30,
    "basePath": "/api/v1",
    "host": "http://localhost",
    "debugHeaders": true,
    "httpClientDebug": true,
    "debugErrorsResponse": true,
    "ignoreLogUrls": [
      "metrics"
    ]
  },
  "PgConfig": {
    "Host": "localhost",
    "Port": 5432,
    "User": "tgbotchecker",
    "DbName": "tgbotchecker",
    "SSLMode": false,
    "Password": "tgbotchecker"
  }
}

Далее подключаем зависимости в main.go

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				gormpg.NewPgGorm,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(server.RunServers),
		),
	).Run()
}

Для поднятие нашей базы данных в докере в корневом каталоге создадим папку deployments в которой создадим docker-compose.yml, который мы сможем вызывать использовав команду в консоли из каталога с файлом docker-compose up.

version: "3.9"
services:
  postgres:
    image: postgres
    environment:
      POSTGRES_DB: "tgbotchecker"
      POSTGRES_USER: "tgbotchecker"
      POSTGRES_PASSWORD: "tgbotchecker"
    ports:
      - "5432:5432"
    volumes:
      - ./data:/var/lib/postgresql/data

Создадим сущности БД и репозитории

Отправляемся в корневой каталог и создадим папку internal в которой создадим каталог data и в нем создадим каталог entities и в нем создадим файл book_entity.go

package entities

import (
	uuid "github.com/satori/go.uuid"
)

// BookEntity model
type BookEntity struct {
	Id      uuid.UUID `json:"id" gorm:"primaryKey"`
	Name    string    `json:"name"`
	Author  string    `json:"author"`
	Price   float64   `json:"price"`
	Enabled bool      `json:"enabled"`
}

// CreateBookEntity создать модель
func CreateBookEntity(name string, author string, price float64) BookEntity {
	return BookEntity{
		Name:    name,
		Author:  author,
		Price:   price,
		Id:      uuid.NewV4(),
		Enabled: true,
	}
}

Теперь для создании миграций в файле main.go необходимо зарегистрировать нашу сущность.

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/internal/data/entities"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				gormpg.NewPgGorm,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(server.RunServers),
			fx.Invoke(
				func(sql *gormpg.PgGorm) error {
					return gormpg.Migrate(sql.DB, &entities.BookEntity{})
				}),
		),
	).Run()
}

Теперь в папке data создадим 2 каталога contracts и repositories. В каталоге contracts будет интерфейс абстракция для нашего репозитория book_repository.go , а в каталоге repository будет лежать непосредственно реализация для postgresql pg_book_repository.go

package contracts

import (
	uuid "github.com/satori/go.uuid"
	"go-template-microservice-v2/internal/data/entities"
)

type IBookRepository interface {
	AddBook(bookEntity entities.BookEntity) error
	GetBook(id uuid.UUID) (*entities.BookEntity, error)
	GetAllBook() ([]*entities.BookEntity, error)
	UpdateBook(bookEntity entities.BookEntity) error
}
package repositories

import (
	"fmt"
	"github.com/pkg/errors"
	uuid "github.com/satori/go.uuid"
	"go-template-microservice-v2/internal/data/contracts"
	"go-template-microservice-v2/internal/data/entities"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
)

type PgBookRepository struct {
	PgGorm *gormpg.PgGorm
}

func NewPgBookRepository(pgGorm *gormpg.PgGorm) contracts.IBookRepository {
	return &PgBookRepository{PgGorm: pgGorm}
}

func (p PgBookRepository) AddBook(bookEntity entities.BookEntity) error {
	err := p.PgGorm.DB.Create(bookEntity).Error
	if err != nil {
		return errors.Wrap(err, "error in the inserting book into the database.")
	}

	return nil
}

func (p PgBookRepository) GetBook(id uuid.UUID) (*entities.BookEntity, error) {
	var book entities.BookEntity

	if err := p.PgGorm.DB.First(&book, id).Error; err != nil {
		return nil, errors.Wrap(err, fmt.Sprintf("can't find the book with id %s into the database.", id))
	}

	return &book, nil
}

func (p PgBookRepository) GetAllBook() ([]*entities.BookEntity, error) {
	var books []*entities.BookEntity

	if err := p.PgGorm.DB.Find(&books).Error; err != nil {
		return nil, errors.Wrap(err, fmt.Sprintf("can't find the books into the database."))
	}

	return books, nil
}

func (p PgBookRepository) UpdateBook(bookEntity entities.BookEntity) error {
	err := p.PgGorm.DB.Save(bookEntity).Error
	if err != nil {
		return errors.Wrap(err, "error in the inserting book into the database.")
	}

	return nil
}
структура
структура

Теперь зарегистрируем в DI наш репозиторий в main.go

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/internal/data/entities"
	"go-template-microservice-v2/internal/data/repositories"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				gormpg.NewPgGorm,
				repositories.NewPgBookRepository,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(server.RunServers),
			fx.Invoke(
				func(sql *gormpg.PgGorm) error {
					return gormpg.Migrate(sql.DB, &entities.BookEntity{})
				}),
		),
	).Run()
}

Реализация команд медиатора

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

go get "github.com/mehdihadeli/go-mediatr"

Команды в нашем приложении также будут выполнять роль контрактов для запросов и ответов.

Для нашей реализации понадобятся следующие команды: добавление новой книги, покупка книги, а также запрос на получение списка всех книг.

Поэтому идем в каталог internal и создаем каталог features , в котором создаем 3 каталога add_book , buy_book , get_all_books .

Для начала будем работать с каталогом add_book . В котором мы создадим каталог commands в котором создадим файлы: add_book_command.go , add_book_handler.go , add_book_response.go.

package commands

// AddBookCommand - модель добавления книги в каталог
type AddBookCommand struct {
	Name   string  `json:"name"   validate:"required"`
	Author string  `json:"author" validate:"required"`
	Price  float64 `json:"price"  validate:"required"`
}
package commands

import (
	"context"
	"go-template-microservice-v2/internal/data/contracts"
	"go-template-microservice-v2/internal/data/entities"
)

// AddBookHandler - хендлер для команды AddUserRequestCommand
type AddBookHandler struct {
	Repository contracts.IBookRepository
	Ctx        context.Context
}

// NewAddBookHandler - DI
func NewAddBookHandler(
	repository contracts.IBookRepository,
	ctx context.Context) *AddBookHandler {
	return &AddBookHandler{Repository: repository, Ctx: ctx}
}

// Handle - выполнить
func (handler *AddBookHandler) Handle(ctx context.Context, command *AddBookCommand) (*AddBookResponse, error) {
	bookEntity := entities.CreateBookEntity(
		command.Name,
		command.Author,
		command.Price)

	err := handler.Repository.AddBook(bookEntity)
	if err != nil {
		return nil, err
	}

	return &AddBookResponse{BookId: bookEntity.Id}, nil
}
package commands

import uuid "github.com/satori/go.uuid"

type AddBookResponse struct {
	BookId uuid.UUID `json:"book_id"`
}

Теперь создадим аналог нашего контроллера в каталоге add_book сделаем каталог endpoints в котором создадим файл add_book_endpoints

package endpoints

import (
	"context"
	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
	"github.com/mehdihadeli/go-mediatr"
	"github.com/pkg/errors"
	"go-template-microservice-v2/internal/features/add_book/commands"
	"net/http"
)

// MapRoute - настройка маршрутизации
func MapRoute(validator *validator.Validate, echo *echo.Echo, ctx context.Context) {
	group := echo.Group("/api/v1/books")
	group.POST("", addBook(validator, ctx))
}

// AddBook
// @Tags        Book
// @Summary     Add Book
// @Description Add new Book in catalogue
// @Accept      json
// @Produce     json
// @Param       AddBookCommand body commands.AddBookCommand true "Book data"
// @Success     200  {object} commands.AddBookResponse
// @Security -
// @Router      /api/v1/books [post]
func addBook(validator *validator.Validate, ctx context.Context) echo.HandlerFunc {
	return func(c echo.Context) error {
		request := &commands.AddBookCommand{}

		if err := c.Bind(request); err != nil {
			badRequestErr := errors.Wrap(err, "[addBookEndpoint_handler.Bind] error in the binding request")
			log.Error(badRequestErr)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		if err := validator.StructCtx(ctx, request); err != nil {
			validationErr := errors.Wrap(err, "[addBook_handler.StructCtx] command validation failed")
			log.Error(validationErr)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		result, err := mediatr.Send[*commands.AddBookCommand, *commands.AddBookResponse](ctx, request)

		if err != nil {
			log.Errorf("(Handle) id: {%s}, err: {%v}", request.Name, err)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		log.Infof("(auto added) id: {%s}", result.BookId)
		return c.JSON(http.StatusCreated, result)
	}
}

Переходим к следующему каталогу get_all_books и создаем в нем каталог queries в котором создаем get_all_books_query , get_all_books_handler , get_all_books_response

package queries

type GetAllBooksQuery struct{}
package queries

import (
	"context"
	"go-template-microservice-v2/internal/data/contracts"
)

type GetAllBooksHandler struct {
	Repository contracts.IBookRepository
	Ctx        context.Context
}

// NewGetAllBooksHandler - DI
func NewGetAllBooksHandler(
	repository contracts.IBookRepository,
	ctx context.Context) *GetAllBooksHandler {
	return &GetAllBooksHandler{Repository: repository, Ctx: ctx}
}

// Handle - выполнить
func (handler *GetAllBooksHandler) Handle(ctx context.Context, command *GetAllBooksQuery) (*GetAllBooksResponse, error) {
	getAllBooksResponse := &GetAllBooksResponse{
		Books: make([]GetAllBooksResponseItem, 0),
	}

	result, err := handler.Repository.GetAllBook()
	if err != nil {
		return nil, err
	}

	for _, element := range result {
		getAllBooksResponse.Books = append(getAllBooksResponse.Books, GetAllBooksResponseItem{
			Id:      element.Id,
			Name:    element.Name,
			Author:  element.Author,
			Price:   element.Price,
			Enabled: element.Enabled,
		})
	}

	return getAllBooksResponse, nil
}
package queries

import uuid "github.com/satori/go.uuid"

type GetAllBooksResponse struct {
	Books []GetAllBooksResponseItem `json:"books,omitempty"`
}

type GetAllBooksResponseItem struct {
	Id      uuid.UUID `json:"id"`
	Name    string    `json:"name"`
	Author  string    `json:"author"`
	Price   float64   `json:"price"`
	Enabled bool      `json:"enabled"`
}

И теперь по аналогии создадим каталог endpoints с файлом get_all_books_endpoints.go

package endpoints

import (
	"context"
	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
	"github.com/mehdihadeli/go-mediatr"
	"go-template-microservice-v2/internal/features/get_all_books/queries"
	"net/http"
)

// MapRoute - настройка маршрутизации
func MapRoute(validator *validator.Validate, echo *echo.Echo, ctx context.Context) {
	group := echo.Group("/api/v1/books")
	group.GET("", getAllBooks(validator, ctx))
}

// AddBook
// @Tags        Book
// @Summary     Get All Books
// @Description Get All Books from catalogue
// @Accept      json
// @Produce     json
// @Param       GetAllBooksQuery body queries.GetAllBooksQuery true "Book data"
// @Success     200  {object} queries.GetAllBooksResponse
// @Security -
// @Router      /api/v1/books [get]
func getAllBooks(validator *validator.Validate, ctx context.Context) echo.HandlerFunc {
	return func(c echo.Context) error {
		query := queries.GetAllBooksQuery{}

		result, err := mediatr.Send[*queries.GetAllBooksQuery, *queries.GetAllBooksResponse](ctx, &query)

		if err != nil {
			log.Errorf("(Handle) err: {%v}", err)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}
		
		return c.JSON(http.StatusCreated, result)
	}
}

Осталось реализовать последнюю команду и можно будет приступать к регистрации медиаторов.

Переходим к следующему каталогу buy_book и создаем в нем каталог commands в котором создаем buy_book_commands, buy_book_handler, buy_book_response.

package commands

import uuid "github.com/satori/go.uuid"

// BuyBookCommand - модель добавления книги в каталог
type BuyBookCommand struct {
	BookId uuid.UUID `json:"BookId"   validate:"required"`
}
package commands

import (
	"context"
	"go-template-microservice-v2/internal/data/contracts"
)

// BuyBookHandler - хендлер для команды AddUserRequestCommand
type BuyBookHandler struct {
	Repository contracts.IBookRepository
	Ctx        context.Context
}

// NewBuyBookHandler - DI
func NewBuyBookHandler(
	repository contracts.IBookRepository,
	ctx context.Context) *BuyBookHandler {
	return &BuyBookHandler{Repository: repository, Ctx: ctx}
}

// Handle - выполнить
func (handler *BuyBookHandler) Handle(ctx context.Context, command *BuyBookCommand) (*BuyBookResponse, error) {
	book, err := handler.Repository.GetBook(command.BookId)

	if err != nil {
		return nil, err
	}

	book.Enabled = false

	err = handler.Repository.UpdateBook(*book)
	if err != nil {
		return nil, err
	}

	return &BuyBookResponse{Result: book.Enabled}, nil
}
package commands

type BuyBookResponse struct {
	Result bool `json:"result"`
}
package endpoints

import (
	"context"
	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
	"github.com/mehdihadeli/go-mediatr"
	"github.com/pkg/errors"
	"go-template-microservice-v2/internal/features/buy_book/commands"
	"net/http"
)

// MapRoute - настройка маршрутизации
func MapRoute(validator *validator.Validate, echo *echo.Echo, ctx context.Context) {
	group := echo.Group("/api/v1/books/buy")
	group.POST("", buyBook(validator, ctx))
}

// AddBook
// @Tags        Book
// @Summary     Buy Book
// @Description Buy Book in catalogue
// @Accept      json
// @Produce     json
// @Param       BuyBookCommand body commands.BuyBookCommand true "Book data"
// @Success     200  {object} commands.BuyBookResponse
// @Security -
// @Router      /api/v1/books/buy [post]
func buyBook(validator *validator.Validate, ctx context.Context) echo.HandlerFunc {
	return func(c echo.Context) error {
		request := &commands.BuyBookCommand{}

		if err := c.Bind(request); err != nil {
			badRequestErr := errors.Wrap(err, "[addBookEndpoint_handler.Bind] error in the binding request")
			log.Error(badRequestErr)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		if err := validator.StructCtx(ctx, request); err != nil {
			validationErr := errors.Wrap(err, "[addBook_handler.StructCtx] command validation failed")
			log.Error(validationErr)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		result, err := mediatr.Send[*commands.BuyBookCommand, *commands.BuyBookResponse](ctx, request)

		if err != nil {
			log.Errorf("(Handle) err: {%v}", err)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		log.Infof("(auto added) id: {%s}", result.Result)
		return c.JSON(http.StatusCreated, result)
	}
}

Регистрация роутов и команд медиатора.

Для регистрации маршрутизации и команд медиатор необходимо в каталоге internal создать каталог configurations в котором создать 2 файла endpoints_configurations и mediator_configurations .

package configurations

import (
	"context"
	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	addBookEndpoints "go-template-microservice-v2/internal/features/add_book/endpoints"
	buyBookEndpoints "go-template-microservice-v2/internal/features/buy_book/endpoints"
	getAllBooksEndpoints "go-template-microservice-v2/internal/features/get_all_books/endpoints"
)

// ConfigEndpoints - конфигурирование ендпоинтов нашего API
func ConfigEndpoints(validator *validator.Validate, echo *echo.Echo, ctx context.Context) {
	addBookEndpoints.MapRoute(validator, echo, ctx)
	buyBookEndpoints.MapRoute(validator, echo, ctx)
	getAllBooksEndpoints.MapRoute(validator, echo, ctx)
}
package configurations

import (
	"context"
	"github.com/mehdihadeli/go-mediatr"
	"go-template-microservice-v2/internal/data/contracts"
	addBookCommand "go-template-microservice-v2/internal/features/add_book/commands"
	buyBookCommand "go-template-microservice-v2/internal/features/buy_book/commands"
	getAllBooksQueries "go-template-microservice-v2/internal/features/get_all_books/queries"
)

// ConfigMediator - DI
func ConfigMediator(
	ctx context.Context,
	repository contracts.IBookRepository) (err error) {

	err = mediatr.RegisterRequestHandler[
		*addBookCommand.AddBookCommand,
		*addBookCommand.AddBookResponse](addBookCommand.NewAddBookHandler(repository, ctx))

	err = mediatr.RegisterRequestHandler[
		*buyBookCommand.BuyBookCommand,
		*buyBookCommand.BuyBookResponse](buyBookCommand.NewBuyBookHandler(repository, ctx))

	err = mediatr.RegisterRequestHandler[
		*getAllBooksQueries.GetAllBooksQuery,
		*getAllBooksQueries.GetAllBooksResponse](getAllBooksQueries.NewGetAllBooksHandler(repository, ctx))

	if err != nil {
		return err
	}

	return nil
}

Теперь осталось все это зарегистрировать в DI в файле main.go

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/internal/configurations"
	"go-template-microservice-v2/internal/data/entities"
	"go-template-microservice-v2/internal/data/repositories"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				gormpg.NewPgGorm,
				repositories.NewPgBookRepository,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(configurations.ConfigEndpoints),
			fx.Invoke(configurations.ConfigMediator),
			fx.Invoke(server.RunServers),
			fx.Invoke(
				func(sql *gormpg.PgGorm) error {
					return gormpg.Migrate(sql.DB, &entities.BookEntity{})
				}),
		),
	).Run()
}

Проверяем, что у нас все запустилось

Проверяем в БД, что табличка по миграции создалась

Проверяем запрос

Проверяем валидацию price

Проверяем добавилась ли книга

Отлично у нас есть работающий микросервис!

Заключение

В данной статье я решил поделиться и познакомить со структурой проекта микросервиса, где у нас присутствовала валидация входных запросов, контракты заменены на команды и запросы медиатора, была подключена база данных, настроен DI и конфигурация, также настроены эндпоинты, как аналоги контроллеров.

Спасибо за внимание! В следующей статье я планирую подключить kafka и уже начать писать компонентные тесты на данный функционал, а также подключить swagger.

Ссылка на репозиторий: https://github.com/ItWithMisha/go-template-microservice-v2

Комментарии (9)


  1. johhy13
    28.07.2024 15:29
    +5

    ИМХО, плохо использовать принципы и подходы с других языков в Go (накидал 3 пункта, хотя там их намного больше)
    1. Использовать контейнеры и что-то на интерфейсах (магия), трудно будет отлаживать
    go.uber.org/fx
    2.Суперинтерфейсы типа IBookRepository и нейминг не по рекомендации
    3.Экспортируемые структуры для сервисов и хранение в ней контекста

    type AddBookHandler struct {
    	Repository contracts.IBookRepository
    	Ctx        context.Context
    }


    PS: Если честно, то кровь из глаз, когда на такой проект смотришь. Поизучайте проекты на go которые соответствуют https://github.com/golang-standards/project-layout


    1. ItwithMisha Автор
      28.07.2024 15:29

      1. На счет использование fx, согласен, но думаю имеет место быть для небольших сервисов, хотя, тогда можно и вручную прокинуть. Так или иначе DI может решать свои проблемы, как и в других языках. Думаю это холиварный вопрос и мне не хватит экспертизы, чтобы в нем настаивать)

      2. Тут я видимо назвал, как бы в c#, вырвалось)

      3. Такое просто видел в различных проектах на github и посчитал нормальным, я так полагаю лучше контекст передавать именно в метод?

        Вообще по структуре, когда на Go пробуешь писать, после более c# или java, то сложно вообще понять структуру проекта go или как-то ее объяснить, потому что она не выглядит хорошей или правильной. По-этому и хочется принести, что-то с собой из других языков)


      1. makarychev_13
        28.07.2024 15:29

        потому что она не выглядит хорошей или правильной

        Она не выглядит как в С# или Java. По вашим примерам кода видно, что вы совсем не ознакомились с идиоматичным способом написания кода на go, а просто тупо скопировали подходы из других языков


  1. YegorP
    28.07.2024 15:29
    +3

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

    change_my_mind.jpg


    1. ItwithMisha Автор
      28.07.2024 15:29

      А что у вас есть микросервис?

      Есть определенные договоренности внутри компании или команды, как писать код, что и как использовать или нет, что в этом плохого? Например, когда проекты от одной команды передают другой и получается каждый будет передавать свой поток сознания?

      И в данном случае шаблон выступает, просто руководством или примером для тех, кто не видел и решает поизучать вопрос.


      1. YegorP
        28.07.2024 15:29
        +1

        Микросервис это узкоспециализированный модуль, который пришлось разработать и запустить отдельно от других модулей.

        Реализация микросервисов по шаблону решает некоторые вторичные проблемы (передача знаний и тому подобное), но уводит от решения первичных, ради которых вся эта хурма и затевалась.

        И вообще. Если вы можете зафигачить всю систему однородными микросервисами, то обычным модульным монолитом вы её зафигачите быстрее/дешевле минимум в π раз.

        Шаблоны в микросервисной архитектуре нужны между сервисами (интерфейсы и потоки данных), а не для каждого из них.


        1. ItwithMisha Автор
          28.07.2024 15:29

          Не вижу связи между шаблоном построения конкретного сервиса с точки зрения организации кода и интерфейсов и потоков данных. При наличии первого, автоматически не отменяется второе.

          А про модульный монолит тоже странно, если просто микросервисы деплоятся из одного репозитория, то это не модульный монолит, а если вы имеете ввиду, что хранить все в одном проекте, то это странная идея и несет большое количество проблем, ведь микросервисов в компании может быть много


  1. Marsezi
    28.07.2024 15:29

    А на сколько верна практика что покупка книги находится в сущностях-контролере-урле 'book', а не выделяется в отдельный микросервис который занимается продажами расчетами оплатой?


    1. ItwithMisha Автор
      28.07.2024 15:29

      Смотря какое решение мы собираешься реализовывать.

      В данном случае как тестовое задание не вижу смысле прям в отдельный сервис выносить.

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