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