Шаблон Go-микросервиса для начинающих от .NET разработчика. Часть 2

от автора

Привет, Хабр! В предыдущей статье я поделился своей версией шаблона 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


ссылка на оригинал статьи https://habr.com/ru/articles/820385/