Привет, Хабр! В предыдущей статье я поделился своей версией шаблона 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)
YegorP
28.07.2024 15:29+3Если микросервис в вашей системе нужно кодить на определённом языке и тем более по определённому шаблону, то это не микросервис.
change_my_mind.jpg
ItwithMisha Автор
28.07.2024 15:29А что у вас есть микросервис?
Есть определенные договоренности внутри компании или команды, как писать код, что и как использовать или нет, что в этом плохого? Например, когда проекты от одной команды передают другой и получается каждый будет передавать свой поток сознания?
И в данном случае шаблон выступает, просто руководством или примером для тех, кто не видел и решает поизучать вопрос.YegorP
28.07.2024 15:29+1Микросервис это узкоспециализированный модуль, который пришлось разработать и запустить отдельно от других модулей.
Реализация микросервисов по шаблону решает некоторые вторичные проблемы (передача знаний и тому подобное), но уводит от решения первичных, ради которых вся эта хурма и затевалась.
И вообще. Если вы можете зафигачить всю систему однородными микросервисами, то обычным модульным монолитом вы её зафигачите быстрее/дешевле минимум в π раз.
Шаблоны в микросервисной архитектуре нужны между сервисами (интерфейсы и потоки данных), а не для каждого из них.
ItwithMisha Автор
28.07.2024 15:29Не вижу связи между шаблоном построения конкретного сервиса с точки зрения организации кода и интерфейсов и потоков данных. При наличии первого, автоматически не отменяется второе.
А про модульный монолит тоже странно, если просто микросервисы деплоятся из одного репозитория, то это не модульный монолит, а если вы имеете ввиду, что хранить все в одном проекте, то это странная идея и несет большое количество проблем, ведь микросервисов в компании может быть много
Marsezi
28.07.2024 15:29А на сколько верна практика что покупка книги находится в сущностях-контролере-урле 'book', а не выделяется в отдельный микросервис который занимается продажами расчетами оплатой?
ItwithMisha Автор
28.07.2024 15:29Смотря какое решение мы собираешься реализовывать.
В данном случае как тестовое задание не вижу смысле прям в отдельный сервис выносить.
А по хорошему на микросервисы разбивают по разным причинам, если нет потребности и имеется, только домен с которым мы работаем, как например небольшой магазин книг, не вижу смысла делать отдельно.
johhy13
ИМХО, плохо использовать принципы и подходы с других языков в Go (накидал 3 пункта, хотя там их намного больше)
1. Использовать контейнеры и что-то на интерфейсах (магия), трудно будет отлаживать
go.uber.org/fx
2.Суперинтерфейсы типа IBookRepository и нейминг не по рекомендации
3.Экспортируемые структуры для сервисов и хранение в ней контекста
PS: Если честно, то кровь из глаз, когда на такой проект смотришь. Поизучайте проекты на go которые соответствуют https://github.com/golang-standards/project-layout
ItwithMisha Автор
На счет использование fx, согласен, но думаю имеет место быть для небольших сервисов, хотя, тогда можно и вручную прокинуть. Так или иначе DI может решать свои проблемы, как и в других языках. Думаю это холиварный вопрос и мне не хватит экспертизы, чтобы в нем настаивать)
Тут я видимо назвал, как бы в c#, вырвалось)
Такое просто видел в различных проектах на github и посчитал нормальным, я так полагаю лучше контекст передавать именно в метод?
Вообще по структуре, когда на Go пробуешь писать, после более c# или java, то сложно вообще понять структуру проекта go или как-то ее объяснить, потому что она не выглядит хорошей или правильной. По-этому и хочется принести, что-то с собой из других языков)
makarychev_13
Она не выглядит как в С# или Java. По вашим примерам кода видно, что вы совсем не ознакомились с идиоматичным способом написания кода на go, а просто тупо скопировали подходы из других языков