From a50c6549a850bb0d11bfc24dc61d216bfc7d4e54 Mon Sep 17 00:00:00 2001 From: Misha Nepryakhin Date: Tue, 6 Apr 2021 16:37:16 +0400 Subject: [PATCH 1/4] [GOT-28] routers refactoring --- internal/app/app.go | 2 +- internal/delivery/amqp_rpc/router.go | 15 ++++----------- internal/delivery/amqp_rpc/translation.go | 14 +++++++++++--- internal/delivery/http/v1/router.go | 12 ++++-------- internal/delivery/http/v1/translation.go | 19 +++++++++++++------ pkg/logger/logger.go | 2 +- 6 files changed, 34 insertions(+), 30 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 54246562..5a768ae7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -43,7 +43,7 @@ func Run(cfg *config.Config) { logger.Fatal(err, "app - Run - rmqServer - server.NewServer") } - // HTTP + // HTTP Server handler := gin.New() v1.NewRouter(handler, translationService) httpServer := httpserver.NewServer(handler, httpserver.Port(cfg.HTTP.Port)) diff --git a/internal/delivery/amqp_rpc/router.go b/internal/delivery/amqp_rpc/router.go index e7c7358d..076b9b88 100644 --- a/internal/delivery/amqp_rpc/router.go +++ b/internal/delivery/amqp_rpc/router.go @@ -5,18 +5,11 @@ import ( "github.com/evrone/go-service-template/pkg/rabbitmq/rmq_rpc/server" ) -type router struct { - translationService service.Translation - routerMap map[string]server.CallHandler -} - func NewRouter(translationService service.Translation) map[string]server.CallHandler { - r := &router{ - translationService: translationService, - routerMap: make(map[string]server.CallHandler), + routes := make(map[string]server.CallHandler) + { + newTranslationRoutes(routes, translationService) } - r.translationRoutes() - - return r.routerMap + return routes } diff --git a/internal/delivery/amqp_rpc/translation.go b/internal/delivery/amqp_rpc/translation.go index b9d1c66a..1b3ba875 100644 --- a/internal/delivery/amqp_rpc/translation.go +++ b/internal/delivery/amqp_rpc/translation.go @@ -5,18 +5,26 @@ import ( "github.com/streadway/amqp" "github.com/evrone/go-service-template/internal/domain" + "github.com/evrone/go-service-template/internal/service" "github.com/evrone/go-service-template/pkg/rabbitmq/rmq_rpc/server" ) -func (r *router) translationRoutes() { - r.routerMap["getHistory"] = r.getHistory() +type translationRoutes struct { + translationService service.Translation +} + +func newTranslationRoutes(routes map[string]server.CallHandler, ts service.Translation) { + r := &translationRoutes{ts} + { + routes["getHistory"] = r.getHistory() + } } type historyResponse struct { History []domain.Translation `json:"history"` } -func (r *router) getHistory() server.CallHandler { +func (r *translationRoutes) getHistory() server.CallHandler { return func(d *amqp.Delivery) (interface{}, error) { translations, err := r.translationService.History() if err != nil { diff --git a/internal/delivery/http/v1/router.go b/internal/delivery/http/v1/router.go index 92fef33e..a018fc70 100644 --- a/internal/delivery/http/v1/router.go +++ b/internal/delivery/http/v1/router.go @@ -20,13 +20,8 @@ import ( // @host localhost:8080 // @BasePath /api/v1 -type router struct { - translationService service.Translation -} - func NewRouter(handler *gin.Engine, translationService service.Translation) { - r := &router{translationService} - + // Options handler.Use(gin.Logger()) handler.Use(gin.Recovery()) @@ -37,8 +32,9 @@ func NewRouter(handler *gin.Engine, translationService service.Translation) { // K8s probe handler.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) }) - api := handler.Group("/api/v1") + // Routers + h := handler.Group("/api/v1") { - r.translationRouts(api) + newTranslationRoutes(h, translationService) } } diff --git a/internal/delivery/http/v1/translation.go b/internal/delivery/http/v1/translation.go index 48711ca5..ac7458aa 100644 --- a/internal/delivery/http/v1/translation.go +++ b/internal/delivery/http/v1/translation.go @@ -6,13 +6,20 @@ import ( "github.com/gin-gonic/gin" "github.com/evrone/go-service-template/internal/domain" + "github.com/evrone/go-service-template/internal/service" ) -func (r *router) translationRouts(api *gin.RouterGroup) { - translation := api.Group("/translation") +type translationRoutes struct { + translationService service.Translation +} + +func newTranslationRoutes(handler *gin.RouterGroup, ts service.Translation) { + r := &translationRoutes{ts} + + h := handler.Group("/translation") { - translation.GET("/history", r.history) - translation.POST("/do-translate", r.doTranslate) + h.GET("/history", r.history) + h.POST("/do-translate", r.doTranslate) } } @@ -29,7 +36,7 @@ type historyResponse struct { // @Success 200 {object} historyResponse // @Failure 400 {object} response // @Router /translation/history [get]. -func (r *router) history(c *gin.Context) { +func (r *translationRoutes) history(c *gin.Context) { translations, err := r.translationService.History() if err != nil { errorResponse(c, http.StatusBadRequest, err, "database problems") @@ -56,7 +63,7 @@ type doTranslateRequest struct { // @Success 200 {object} domain.Translation // @Failure 400 {object} response // @Router /translation/do-translate [post]. -func (r *router) doTranslate(c *gin.Context) { +func (r *translationRoutes) doTranslate(c *gin.Context) { var request doTranslateRequest if err := c.ShouldBindJSON(&request); err != nil { errorResponse(c, http.StatusBadRequest, err, "invalid request body") diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 01da6bd2..450403e7 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -6,7 +6,7 @@ import ( "go.uber.org/zap" ) -var appLogger Logger //nolint:gochecknoglobals // it's necessary +var appLogger Logger //nolint:gochecknoglobals // it's ok func NewAppLogger(zapLogger *ZapLogger, appName, appVersion string) { fields := []Field{ From d1be73290e87d119e282345b84b2cceb6b92f84c Mon Sep 17 00:00:00 2001 From: Ivan Solovev Date: Tue, 6 Apr 2021 16:38:06 +0300 Subject: [PATCH 2/4] Improvement wrap errors --- internal/service/repo/translation_postgres.go | 13 +++++++------ internal/service/translation.go | 9 +++++---- internal/service/webapi/translation_google.go | 5 ++--- pkg/rabbitmq/rmq_rpc/client/client.go | 3 +-- pkg/rabbitmq/rmq_rpc/server/server.go | 3 +-- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/internal/service/repo/translation_postgres.go b/internal/service/repo/translation_postgres.go index 320b53d3..c9ff69c1 100644 --- a/internal/service/repo/translation_postgres.go +++ b/internal/service/repo/translation_postgres.go @@ -2,7 +2,8 @@ package repo import ( "context" - "fmt" + + "github.com/pkg/errors" "github.com/evrone/go-service-template/internal/domain" "github.com/evrone/go-service-template/pkg/postgres" @@ -22,12 +23,12 @@ func (r *TranslationRepo) GetHistory(ctx context.Context) ([]domain.Translation, From("history"). ToSql() if err != nil { - return nil, fmt.Errorf("TranslationRepo - GetHistory - r.Builder: %w", err) + return nil, errors.Wrap(err, "TranslationRepo - GetHistory - r.Builder") } rows, err := r.Pool.Query(ctx, sql) if err != nil { - return nil, fmt.Errorf("TranslationRepo - GetHistory - r.Pool.Query: %w", err) + return nil, errors.Wrap(err, "TranslationRepo - GetHistory - r.Pool.Query") } defer rows.Close() @@ -38,7 +39,7 @@ func (r *TranslationRepo) GetHistory(ctx context.Context) ([]domain.Translation, err = rows.Scan(&e.Source, &e.Destination, &e.Original, &e.Translation) if err != nil { - return nil, fmt.Errorf("TranslationRepo - GetHistory - rows.Scan: %w", err) + return nil, errors.Wrap(err, "TranslationRepo - GetHistory - rows.Scan") } entities = append(entities, e) @@ -54,12 +55,12 @@ func (r *TranslationRepo) Store(ctx context.Context, entity domain.Translation) Values(entity.Source, entity.Destination, entity.Original, entity.Translation). ToSql() if err != nil { - return fmt.Errorf("TranslationRepo - Store - r.Builder: %w", err) + return errors.Wrap(err, "TranslationRepo - Store - r.Builder") } _, err = r.Pool.Exec(ctx, sql, args...) if err != nil { - return fmt.Errorf("TranslationRepo - Store - r.Pool.Exec: %w", err) + return errors.Wrap(err, "TranslationRepo - Store - r.Pool.Exec") } return nil diff --git a/internal/service/translation.go b/internal/service/translation.go index 4ec01930..884ae4ae 100644 --- a/internal/service/translation.go +++ b/internal/service/translation.go @@ -2,7 +2,8 @@ package service import ( "context" - "fmt" + + "github.com/pkg/errors" "github.com/evrone/go-service-template/internal/domain" ) @@ -22,7 +23,7 @@ func NewTranslationService(r TranslationRepo, w TranslationWebAPI) *TranslationS func (s *TranslationService) History() ([]domain.Translation, error) { translations, err := s.repo.GetHistory(context.Background()) if err != nil { - return nil, fmt.Errorf("TranslationService - History - s.repo.GetHistory: %w", err) + return nil, errors.Wrap(err, "TranslationService - History - s.repo.GetHistory") } return translations, nil @@ -31,12 +32,12 @@ func (s *TranslationService) History() ([]domain.Translation, error) { func (s *TranslationService) Translate(translation domain.Translation) (domain.Translation, error) { translation, err := s.webAPI.Translate(translation) if err != nil { - return domain.Translation{}, fmt.Errorf("TranslationService - Translate - s.webAPI.Translate: %w", err) + return domain.Translation{}, errors.Wrap(err, "TranslationService - Translate - s.webAPI.Translate") } err = s.repo.Store(context.Background(), translation) if err != nil { - return domain.Translation{}, fmt.Errorf("TranslationService - Translate - s.repo.Store: %w", err) + return domain.Translation{}, errors.Wrap(err, "TranslationService - Translate - s.repo.Store") } return translation, nil diff --git a/internal/service/webapi/translation_google.go b/internal/service/webapi/translation_google.go index 8b7ee642..17f8736c 100644 --- a/internal/service/webapi/translation_google.go +++ b/internal/service/webapi/translation_google.go @@ -1,9 +1,8 @@ package webapi import ( - "fmt" - translator "github.com/Conight/go-googletrans" + "github.com/pkg/errors" "github.com/evrone/go-service-template/internal/domain" ) @@ -26,7 +25,7 @@ func (t *TranslationWebAPI) Translate(translation domain.Translation) (domain.Tr result, err := trans.Translate(translation.Original, translation.Source, translation.Destination) if err != nil { - return domain.Translation{}, fmt.Errorf("TranslationWebAPI - Translate - trans.Translate: %w", err) + return domain.Translation{}, errors.Wrap(err, "TranslationWebAPI - Translate - trans.Translate") } translation.Translation = result.Text diff --git a/pkg/rabbitmq/rmq_rpc/client/client.go b/pkg/rabbitmq/rmq_rpc/client/client.go index a2e3bee3..8b0d2ee2 100644 --- a/pkg/rabbitmq/rmq_rpc/client/client.go +++ b/pkg/rabbitmq/rmq_rpc/client/client.go @@ -2,7 +2,6 @@ package client import ( "encoding/json" - "fmt" "sync" "time" @@ -232,7 +231,7 @@ func (c *Client) Shutdown() error { err := c.conn.Connection.Close() if err != nil { - return fmt.Errorf("rmq_rpc client - Client - Shutdown - c.Connection.Close: %w", err) + return errors.Wrap(err, "rmq_rpc client - Client - Shutdown - c.Connection.Close") } return nil diff --git a/pkg/rabbitmq/rmq_rpc/server/server.go b/pkg/rabbitmq/rmq_rpc/server/server.go index afcbc2a2..fa13e995 100644 --- a/pkg/rabbitmq/rmq_rpc/server/server.go +++ b/pkg/rabbitmq/rmq_rpc/server/server.go @@ -2,7 +2,6 @@ package server import ( "encoding/json" - "fmt" "time" "github.com/pkg/errors" @@ -148,7 +147,7 @@ func (s *Server) Shutdown() error { err := s.conn.Connection.Close() if err != nil { - return fmt.Errorf("rmq_rpc server - Server - Shutdown - s.Connection.Close: %w", err) + return errors.Wrap(err, "rmq_rpc server - Server - Shutdown - s.Connection.Close") } return nil From f09f79821ad7c36cec518e3d2f893033551ecd1c Mon Sep 17 00:00:00 2001 From: Ivan Solovev Date: Tue, 6 Apr 2021 16:46:45 +0300 Subject: [PATCH 3/4] Fix logger --- pkg/logger/logger.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 450403e7..2f3d0763 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,8 +1,7 @@ package logger import ( - "fmt" - + "github.com/pkg/errors" "go.uber.org/zap" ) @@ -57,14 +56,14 @@ func (l *logger) warn(msg string, fields ...Field) { } func (l *logger) error(err error, msg string, fields ...Field) { - err = fmt.Errorf("%s: %w", msg, err) + err = errors.Wrap(err, msg) fields = append(l.defaultFields, fields...) l.zap.Error(err.Error(), zapFields(fields)...) } func (l *logger) fatal(err error, msg string, fields ...Field) { - err = fmt.Errorf("%s: %w", msg, err) + err = errors.Wrap(err, msg) fields = append(l.defaultFields, fields...) l.zap.Fatal(err.Error(), zapFields(fields)...) // os.Exit() From 45bb16d320198ff9dfdd2b7eeed952515e2b1b74 Mon Sep 17 00:00:00 2001 From: Misha Nepryakhin Date: Tue, 13 Apr 2021 13:30:50 +0400 Subject: [PATCH 4/4] [GOT-28] readme --- README.md | 305 +++++++++++++++------- integration-test/Dockerfile | 4 +- internal/delivery/amqp_rpc/translation.go | 2 +- 3 files changed, 218 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index f1299d52..343c4769 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,27 @@ -Шаблон сервиса на Go -=========================================== -С развитием любого проекта, логика работы приложения усложняется и запутывается. -Разработка новых фич замедляется. Если не планировать архитектуру, довольно быстро -приходит время, когда переписать сервис становится дешевле, чем делать изменения в -существующем. - -Цели: -1. Предоставить готовый шаблон для быстрого развертывания -2. Стандартизировать подходы к организации кода -3. Аккумулировать лучшие практики - -Quick start ----- -Скопировать и переименовать файл `.env.example` в `.env`. +# Go service template +Clean Architecture template for Golang services + +[![Go Report Card](https://goreportcard.com/badge/github.com/evrone/go-service-template)](https://goreportcard.com/report/github.com/evrone/go-service-template) +![CircleCI](https://img.shields.io/circleci/build/github/evrone/go-service-template) +[![License](https://img.shields.io/github/license/evrone/go-service-template.svg)](https://github.com/evrone/go-service-template/blob/master/LICENSE) +[![Release](https://img.shields.io/github/v/release/evrone/go-service-template.svg)](https://github.com/evrone/go-service-template/releases/) + +## Overview +Цель шаблона показать: +- как организовать проект, чтобы он не превратился в spaghetti code +- где хранить бизнес-логику, чтобы она оставалась независимой, чистой и расширяемой +- как не терять контроль при разрастании микросервиса + +Используя принципы Роберта Мартина (aka Uncle Bob). + +## Content +- [Quick start](#quick-start) +- [Project structure](#project-structure) +- [Dependency Injection](#dependency-injection) +- [Clean Architecture](#clean-architecture) + +## Quick start Локальная разработка: ```sh # Postgres, RabbitMQ @@ -28,88 +36,112 @@ $ make run $ make compose-up-integration-test ``` -Clean Architecture ----- -### Главный принцип -Dependency Inversion (тот самый из SOLID) — принцип инверсии зависимостей, играет -ключевую роль в построении архитектуры приложения. Чтобы принцип начал работать, -нам нужно поделить приложение на слои. +## Project structure +### `cmd/app/main.go` +Инициализация конфигурации и логгера. Далее функция _main_ "продолжается" в +`internal/app/app.go`. -![Clean Architecture](docs/img/layers.png) +### `config` +Конфигурация. Сперва читается `config.yml`, далее переменные окружения (перезаписывают +yaml конфиг при совпадении). В `config.go` структуры конфига. Тег `env-required: true` +обязывает указать значение (или в yaml, или в переменных окружения). -Итак, приложение делится на 2 слоя, внутренний и внешний: -1. **Бизнес-логика** (стандартная библиотека Go). -2. **Инструменты** (база данных, HTTP сервер, брокер сообщений, любые другие пакеты -и фреймворки). +Для конфигурирования была выбрана библиотека [cleanenv](https://github.com/ilyakaznacheev/cleanenv), +которая имеет не так много звёзд на GitHub, однако проста и отвечает всем требованиям. -**Внутренний** слой с бизнес-логикой должен быть чистым, то есть: -- Не иметь в себе импортов пакетов из внешнего слоя. -- Не иметь в себе импортов любых других пакетов, кроме стандартной библиотеки. -- Делать обращения к внешнему слою через интерфейс(!). +Чтение конфига из yaml противоречит идеологии 12-ти факторов, однако на практике это более +удобно, чем чтение всего конфига из ENV. Подразумевается что в yaml находятся дефолтные +значения, а в ENV определяются чувствительные к безопасности переменные. -Бизнес-логика ничего не знает о Postgres или RabbitMQ. Бизнес-логика знает -интерфейс для работы с _абстрактной_ базой данных, _абстрактным_ брокером -сообщений или _абстрактным_ web api. +### `docs` +Swagger документация. Авто сгенерированная библиотекой [swag](https://github.com/swaggo/swag). +Править руками не нужно. -**Внешний** слой имеет другие ограничения: -- Все компоненты этого слоя не знают о существовании друг друга. -- Как из одного инструмента вызвать другой? Напрямую никак, только через -внутренний слой логики. -- Обращения к внутреннему слою происходят через интерфейс(!). -- Данные передаются в том формате, который удобен для бизнес-логики. +### `integration-test` +Интеграционные тесты. Запускаются в виде отдельного контейнера, рядом с контейнером +приложения. Rest API удобно тестировать с помощью [go-hit](https://github.com/Eun/go-hit) +(жаль не так популярна, библиотека топчик). -**Например**, вам нужно обратиться из http хендлера к базе данных. И http, и -БД находятся во внешнем слое, значит они ничего не знают друг о друге. Связь -между ними осуществляется через `service` (бизнес-логику): +### `internal/app` +В файле `app.go` всегда одна функция _Run_, которая "продолжает" функцию _main_. + +Здесь происходит создание всех основных объектов. Через конструкторы "New..." происходит +внедрение зависимостей (см. [Dependency Injection](#dependency-injection)). +Этот приём позволяет нам разделить приложение на слои, придерживаясь принципа инверсии +зависимостей (Dependency Inversion). Благодаря этому бизнес-логика становится независимой +от других слоев. + +Далее мы запускаем сервер и ожидаем в _select_ сигналы для graceful завершения. + +Если `app.go` начнет разрастаться, можно разделить его не несколько файлов. + +При большом количестве инъекций, можно использовать [wire](https://github.com/google/wire). + +Файл `migrate.go` используется для авто миграций БД. Он подключается если указан аргумент с +тегом _migrate_. Например: +```sh +$ go run -tags migrate ./cmd/app +``` + +### `internal/delivery` +Слой с обработчиками серверов (контроллеры по MVC). В шаблоне показаны 2 сервера: +- RPC (RabbitMQ в качестве транспорта) +- REST http (Gin фреймворк) + +Роутеры серверов написаны в одном стиле: +- Обработчики группируются по области применения (по общему признаку) +- Для каждой группы создаётся своя структура-роутер, методы которой обрабатывают пути +- В структуру-роутер инжектится структура бизнес-логики, которую будут вызывать обработчики + +#### `internal/delivery/http` +Простое версионирование REST. Для версии v2 потребуется добавить папку `http/v2` с +тем же содержанием. И в файле `internal/app` добавить строку: ``` - HTTP (delivery) > service - service > Postrges (repo) - service < Postrges (repo) - HTTP (delivery) < service +handler := gin.New() +v1.NewRouter(handler, translationService) +v2.NewRouter(handler, translationService) ``` -Стрелочками > и < показано пересечение границ слоев через Интерфейсы. +Вместо Gin можно использовать любой другой http фреймворк или даже стандартную `net/http` +библиотеку. -То же самое на картинке: +В `v1/ruoter.go` и над методами обработчиков присутствуют комментарии для генерации +swagger документации с помощью [swag](https://github.com/swaggo/swag). -![Example](docs/img/example-http-db.png) +### `internal/domain` +Сущности бизнес-логики (модели), их можно использовать в любых слоях. +Так же здесь могут быть методы, например для валидации. -### Терминология Чистой Архитектуры -- **Entities** — объекты, которыми оперирует бизнес-логика. В коде называются -по именам принятым в организации. Находятся в папке `internal/domain`. Domain -намекает на то что мы придерживаемся принципов DDD (domain driven design), это -отчасти так, но без фанатизма. В терминах MVC entity это модели. Удобно когда -модели называются по именам предметной области. +### `internal/service` +Бизнес-логика. +- Методы группируются по области применения (по общему признаку) +- Для каждой группы — своя структура +- В одном файле — одна структура +В структуры бизнес-логики инжектятся (см. [Dependency Injection](#dependency-injection)) +репозитории, webapi, rpc, другие структуры бизнес-логики. -- **Use Cases** — это бизнес-логика, манипулирующая внешними пакетами через -интерфейсы. Находится в папке `internal/service`. Называть бизнес-логику словом -_service_ не очень идеоматично с точки зрения Чистой Архитектуры, но слово -_service_ удобнее использовать для названия пакета, чем _usecase_. +#### `internal/service/repo` +Репозиторий — это абстрактное хранилище (база данных), с которой работает бизнес-логика. -Слой с которым непосредственно взаимодействует бизнес-логика обычно называют -инфраструктурным слоем - _infrastructure_. Это могут быть репозитории -`internal/service/repo`, внешние webapi `internal/service/webapi`, любые pkg -и другие -микросервисы. В шаблоне инфраструктурные пакеты находятся внутри каталога -`internal/service`. +#### `internal/service/webapi` +Это абстрактный web api, с которым работает бизнес-логика. Например, это может быть другой +микросервис, к которому по REST API обращается бизнес-логика. Название пакета меняется в +зависимости от назначения. -Как называть точки входа, вопрос открыт. Варианты: -- delivery -- transport -- controllers -- adaptors -- gateways -- input -- entry_points -- primary +### `pkg/rabbitmq` +RPC паттерн RabbitMQ: +- Внутри RabbitMQ нет никакой маршрутизации +- Используются Exchange fanout, к которому биндится 1 exclusive queue, это самый +производительный конфиг +- Реконнект при потере соединения -### Dependency Injection +## Dependency Injection Для того чтобы убрать зависимость бизнес-логики от внешних пакетов, используется инъекция зависимостей. -Через конструктор NewService мы делаем инъекцию зависимости в структуру бизнес-логики. -Таким образом бизнес-логика становится независимой (и даже переносимой). Мы можем -подменить реализацию интерфейса и при этом не вносить правки в пакет `service`. +Например, через конструктор NewService мы делаем инъекцию зависимости в структуру +бизнес-логики. Таким образом бизнес-логика становится независимой (и переносимой). +Мы можем подменить реализацию интерфейса и при этом не вносить правки в пакет `service`. ```go package service @@ -134,12 +166,102 @@ func (s *Service) Do() { s.repo.Get() } ``` -Это так же позволит нам делать автогенерацию моков (например с помощью библиотеки -_mockery_) и легко писать юнит-тесты. +Это так же позволит нам делать автогенерацию моков (например с помощью [mockery](https://github.com/vektra/mockery)) +и легко писать юнит-тесты. + +> Мы не привязываемся к конкретным реализациям, чтобы всегда иметь возможность +> безболезненно менять один компонент на другой. Если новый компонент будет +> реализовывать интерфейс, в бизнес-логике менять ничего не потребуется. + +## Clean Architecture +### Ключевая идея +Программисты осознают оптимальную архитектуру приложения уже после того как большая часть +кода была написана. + +> Хорошая архитектура позволяет оттягивать принятие решений на как можно позднее время. + +### Главный принцип +Dependency Inversion (тот самый из SOLID) — принцип инверсии зависимостей. Направление +зависимостей идет из внешнего слоя во внутренний. Благодаря чему бизнес-логика и сущности +остаются независимыми от других частей системы. + +Итак, приложение делится на 2 слоя, внутренний и внешний: +1. **Бизнес-логика** (стандартная библиотека Go). +2. **Инструменты** (базы данных, серверы, брокеры сообщений, любые другие пакеты +и фреймворки). + +![Clean Architecture](docs/img/layers.png) + +**Внутренний** слой с бизнес-логикой должен быть чистым, то есть: +- Не иметь в себе импортов пакетов из внешнего слоя. +- Использовать возможности лишь стандартной библиотеки. +- Делать обращения к внешнему слою через интерфейс(!). + +Бизнес-логика ничего не знает о Postgres или конкретном web api. Бизнес-логика имеет +интерфейс для работы с _абстрактной_ базой данных или _абстрактным_ web api. + +**Внешний** слой имеет другие ограничения: +- Все компоненты этого слоя не знают о существовании друг друга. Как из одного инструмента +вызвать другой? Напрямую никак, только через внутренний слой бизнес-логики. +- Все обращения к внутреннему слою происходят через интерфейс(!). +- Данные передаются в том формате, который удобен для бизнес-логики (`internal/domain`). + +**Например**, нужно обратиться из HTTP (контроллера) к базе данных. И HTTP, и +БД находятся во внешнем слое, значит они ничего не знают друг о друге. Связь +между ними осуществляется через `service` (бизнес-логику): +``` + HTTP > service + service > repository (Postgres) + service < repository (Postgres) + HTTP < service +``` +Стрелочками > и < показано пересечение границ слоев через Интерфейсы. + +То же самое на картинке: + +![Example](docs/img/example-http-db.png) + +Или более сложная бизнес-логика: +``` + HTTP > service + service > repository + service < repository + service > webapi + service < webapi + service > RPC + service < RPC + service > repository + service < repository + HTTP < service +``` + +### Терминология Чистой Архитектуры +- **Entities** — структуры, которыми оперирует бизнес-логика. Находятся в папке +`internal/domain`. Domain намекает на то что мы придерживаемся принципов DDD (domain +driven design), это отчасти так, но без фанатизма. В терминах MVC, entities - это модели. + +- **Use Cases** — это бизнес-логика, находится в `internal/service`. Называть бизнес-логику +словом _service_ не очень идеоматично с точки зрения Чистой Архитектуры, но одно слово +_service_ удобнее использовать для названия пакета, чем два: _use case_. + +Слой с которым непосредственно взаимодействует бизнес-логика обычно называют +инфраструктурным слоем - _infrastructure_. Это могут быть репозитории +`internal/service/repo`, внешние webapi `internal/service/webapi`, любые pkg, другие +микросервисы. В шаблоне инфраструктурные пакеты находятся внутри `internal/service`. + +Как называть точки входа, вопрос открыт. Можно выбрать на свой вкус. Варианты: +- delivery (в нашем случае) +- controllers +- transport +- adaptors +- gateways +- entrypoints +- primary +- input ### Дополнительные слои Классический вариант [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) -разрабатывался для построения больших монолитных приложений и имеет 4 слоя абстракций. +разрабатывался для построения больших монолитных приложений и имеет 4 слоя. То есть, в оригинальной версии, внешний слой делится ещё на два, которые так же имеют обратную инверсию зависимостей друг к другу (направленную во внутрь) @@ -149,18 +271,21 @@ _mockery_) и легко писать юнит-тесты. сложной логики. _______________________________ -Сложные инструменты из внешнего слоя рекомендуется делить на дополнительные -слои абстракции. Следует руководствоваться здравым смыслом и добавлять слои -лишь в том случае, если это действительно необходимо. +Сложные инструменты можно делить на дополнительные слои. Однако, следует +руководствоваться здравым смыслом и добавлять слои лишь в том случае, если это +действительно необходимо. ### Альтернативные подходы Кроме Чистой архитектуры есть очень близкие по духу — Луковая архитектура и -Гексагональная (Порты и адаптеры). В основе всех подходов лежит базовый принцип -инверсии зависимостей. Все подходы преследуют цель уменьшить зацепление и -разграничить ответственность. +Гексагональная (Порты и адаптеры). В основе обеих лежит базовый принцип +инверсии зависимостей. _Порты и адаптеры_ очень близка к _Чистой Архитектуре_, различия +в основном в терминологии. + +## Похожие проекты +- [https://github.com/bxcodec/go-clean-arch](https://github.com/bxcodec/go-clean-arch) +- [https://github.com/zhashkevych/courses-backend](https://github.com/zhashkevych/courses-backend) -Полезные ссылки ---------------- -- [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +## Полезные ссылки +- [Статья The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) - [Книга Чистая архитектура](https://www.ozon.ru/context/detail/id/144499396/) - [12 факторов](https://12factor.net/ru/) \ No newline at end of file diff --git a/integration-test/Dockerfile b/integration-test/Dockerfile index c45620a1..85619909 100644 --- a/integration-test/Dockerfile +++ b/integration-test/Dockerfile @@ -4,8 +4,8 @@ COPY go.mod go.sum /modules/ WORKDIR /modules RUN go mod download -# Step 2: Builder -FROM golang:1.16.2-alpine3.13 as builder +# Step 2: Tests +FROM golang:1.16.2-alpine3.13 COPY --from=modules /go/pkg /go/pkg COPY . /app WORKDIR /app diff --git a/internal/delivery/amqp_rpc/translation.go b/internal/delivery/amqp_rpc/translation.go index 1b3ba875..2d6d04b5 100644 --- a/internal/delivery/amqp_rpc/translation.go +++ b/internal/delivery/amqp_rpc/translation.go @@ -28,7 +28,7 @@ func (r *translationRoutes) getHistory() server.CallHandler { return func(d *amqp.Delivery) (interface{}, error) { translations, err := r.translationService.History() if err != nil { - return nil, errors.Wrap(err, "amqp_rpc - router - getHistory - r.translationService.History") + return nil, errors.Wrap(err, "amqp_rpc - translationRoutes - getHistory - r.translationService.History") } response := historyResponse{translations}