Чистая архитектура на Go

“Set squares and a protractor on an architectural drawing” by Sergey Zolkin on Unsplash
“Set squares and a protractor on an architectural drawing” by Sergey Zolkin on Unsplash
Оглавление

О том, что такое чистая архитектура можно почитать в оригинале, а также в переводе. Кроме того на том же хабре есть переводы статей о переложении этого подхода на Go: раз, два и три. Но все это разбивается о реальную жизнь, транзакции, переиспользование кода, протомонолит и прочие проблемы. Но обо всём по порядку.

TL;DR

Вся статья в одной картинке

Что же такое чистая архитектура?

Роберт Мартин предлагает ввести 4 круга абстракций, что иллюстрирует следующая картинка:

Оригинальная архитектура, предложенная Р. Мартином

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

Сценарии (в оригинале Use cases) это самая интересная часть. Бизнес-логика сервиса. Любое взаимодействие с сервисом это сценарий.

Интерфейсы-адапторы — вот тут отчего-то «всё смешалось в доме Облонских». В один слой попадают как внешние зависимости нашего сервиса, так и его собственный интерфейс.

И последний неожиданный слой это Драйвера. Почему они заслужили целого слоя вопрос отдельный, почему это самый внешний слой и эти драйвера должны знать про все внутренности нашего приложения тоже совершенно непонятно.

Терминология

Сущности будут появляться практически на всех слоях нашего приложения, поэтому для самого внутреннего слоя будем использовать более привычное слово Модель. Сценарии назовём Действиями. Также давайте сразу узаконим название Адаптер не в смысле шаблона проектирования, а в виде обёртки над внешней системой. То есть по факту адаптер скорее является фасадом с точки зрения шаблонов проектирования. Ну, и, конечно, адаптер это своего рода прокси, не хранящее состояние или подключение. Для хранения подключения будем использовать собственно слово Подключение. Наконец добавим новую абстракцию: Сервис, которая и будет связывать все эти подключения, адаптеры, модели и действия, а заодно реализовывать некий внешний интерфейс. Ну, а теперь подробнее про каждую абстракцию.

Модель

Как уже было сказано выше, модели это абстрактные объекты нашей системы. Они не имеют знаний о том откуда и каким образом их создали, а также куда и каким образом отправят. Здесь должны устанавливаться связи между ними, а также те методы, которые не зависят от внешних систем. Например, модель пользователя может иметь метод формирования ФИО, но с одной оговоркой, формат должен прийти извне, ведь настройки формирования ФИО зависят от настроек системы, а модель пользователя о них не имеет ни малешйего представления.

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

У модели не должно быть никаких методов или аннотаций, относящихся к её хранению в базе данных или (де-)сериализации. Я ведь предупреждал, что придётся писать много кода? Так вот, модели надо будет сериализовать, используя промежуточные объекты. Используя другие промежуточные объекты, модели надо будет класть в базу данных. И так далее. По сути, использование самой модели для этих целей делает систему менее гибкой, ведь при формировании промежуточного объекта, мы можем воспользоваться методами (геттерами и сеттерами) модели наравне со свойствами, а также определять ту часть модели, которую нужно сериализовать.

Подключение

Подключения ещё одна абстракция, независящая от других уровней нашей системы. По сути и чаще всего это просто открытый TCP-socket. Подключение может содержать логику переподключения или балансировки, но знаний о бизнес-логике приложения оно не имеет.

Иногда необходимо обернуть часть логики транзакцией, при этом не хочется выставлять наружу кишки реализации, ведь если мы изменим базу данных, нам придётся менять все места с транзакциями. Для изоляции транзакции воспользуемся контекстом вызова. По сути нам необходимо реализовать 2 метода:

package connection

import (
	"context"
	"database/sql"

	"github.com/pkg/errors"
)

// PG is a connection wrapper with transaction decorators
type PG struct {
	sql.DB
}

type ctxTxKey struct{}

// WithTx execute callback with transaction in context
func (pg *PG) WithTx(ctx context.Context, fn func(ctx context.Context) error) (err error) {
	tx, alreadyHasTx := ctx.Value(ctxTxKey{}).(*sql.Tx)
	if !alreadyHasTx {
		tx, err = pg.BeginTx(ctx, nil)
		if err != nil {

			return errors.WithStack(err)
		}
		ctx = context.WithValue(ctx, ctxTxKey{}, tx)
	}

	err = errors.WithStack(fn(ctx))

	if alreadyHasTx {

		return err
	}
	if err == nil {

		return errors.WithStack(tx.Commit())
	}

	tx.Rollback()

	return err
}

// ExtractTx from context or create new
func (pg *PG) ExtractTx(ctx context.Context, fn func(context.Context, *sql.Tx) error) error {

	return pg.WithTx(ctx, func(ctx context.Context) error {
		tx := ctx.Value(ctxTxKey{}).(*sql.Tx)

		return errors.WithStack(fn(ctx, tx))
	})
}

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

Адаптер

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

На уровне адаптеров появляются промежуточные сущности для сохранения и извлечения моделей из баз данных, очередей или других сервисов. Ну, и соответственно перекладывание туда-обратно. Зато отсутствие нагромождений структурных тэгов, а также методов Marshal…, Unmarshal…, Scan и Value.

Адаптеры удобно группировать по сущностям и обязанностям, например, репозитории сущностей (UserRepository) или подписчик на очередь событий (EventNotifier). По сути это должны быть обёртки над подключениями, такие, что имея подключение, можно легко сконструировать новый адаптер.

Действие

Действие это самая сложная часть нашей системы. Это средоточие бизнес-логики нашего сервиса. Всё что делает наш сервис должно проходить через действие.

Действие, конечно, знает всё про модели, ведь оно оперирует именно ими. А вот адаптеры оно знает только по интерфейсам. Конечно, знание непосредственно адаптеров не приведёт к циклическим импортам, однако, сделает невозможным юнит-тестирование, а также это ненужное повышение связности.

Первое правило, которого необходимо придерживаться:

1 действие = 1 задача

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

Каждому по потребностям

Действие должно требовать на уровне своего конструктора все необходимые ему адаптеры и сервисы по интерфейсу. При этом интерфейсы адаптеров должны лежать рядом с действиями, а имплементации в пакете адаптеров. Это не обязательно, но для гарантии того, что действие создаётся через конструктор, я предпочитаю объявлять действие тоже как интерфейс, а имплементацию делать приватной.

package action

import (
	"context"

	"github.com/vporoshok/bfapp/internal/model"
)

// TxManager implement transaction decorator
type TxConnection interface {
	WithTx(context.Context, func(context.Context) error) error
}

// UserRepository provide methods to CRUD users in db
type UserRepository interface {
	Save(context.Context, *model.User) error
	GetByID(context.Context, int) (*model.User, error)
}

// GroupRepository provides methods to CRUD groups in db
type GroupRepository interface {
	Save(context.Context, *model.Group) error
	GetByID(context.Context, int) (*model.Group, error)
}

// EventBus provide methods to event mediator bus
type EventBus interface {
	Send(context.Context, *model.Event) error
}

package action

import (
	"context"

	"github.com/vporoshok/bfapp/internal/model"
)

// CreateUser with group and event notification
type CreateUser interface {
	Do(ctx context.Context, email, password string) (*model.User, error)
}

type createUser struct {
	txConnection    CreateUserTxConnection
	userRepository  CreateUserUserRepository
	groupRepository CreateUserGroupRepository
	eventBus        CreateUserEventBus
}

// NewCreateUser is a constructor
func NewCreateUser(txConnection TxConnection, userRepository UserRepository, groupRepository GroupRepository, eventBus EventBus) CreateUser {

	return &createUser{
		txConnection,
		userRepository,
		groupRepository,
		eventBus,
	}
}

func (cu *createUser) Do(ctx context.Context, email string, password string) (*model.User, error) {
	// Тут собственно вся логика создания
}

На первый взгляд может показаться монструозно, зато такое действие легко тестировать и переиспользовать. Действия могут использовать друг друга.

Сервисы

На этом уровне у меня тоже происходит смешение понятий, однако, не таких уж и далёких.

Итак, в первую очередь сервисы это объекты, оркестрирующие подключения, адаптеры, действия и модели. То есть именно сервис на своём уровне хранит указатели на подключения. На основе этих подключений создаёт адаптеры, на основе адаптеров создаёт действия и выполняет. Также сервис предоставляет внешний интерфейс, например, HTTP или gRPC. Кроме того, сервис имеет сущности запросов и ответов, преобразовывает их в модели и обратно, сериализуя и десериализуя, а также валидируя. По факту это самая рутинная часть кода.

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

— Но подожди, — воскликните вы, — а как же общее состояние? Кеш, локи и прочее?

Ваша правда, порой приходится иметь общее состояние между различными интерфейсами в рамках одного приложения. Ок, тут у нас появляется новая сущность: Состояние. По всем характеристикам состояние ведёт себя как подключение, вплоть до того, что возможно над ним нужно писать адаптер. Таким образом это общее состояние превращается в некий абстрактный сервис со своим интерфейсом. Например, сервис открытых клиентских подключений или сервис запланированных задач.

Протомонолит

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

Могут ли быть эти два контекста разнесены в разные сервисы или нет?

Если не могут, например, ни жить ни быть нужна сквозная транзакция (может быть всё-таки не нужна?), то делать нечего, надо объединять эти контексты в один или дублировать часть функционала между ними.

Если же контексты можно оставить слабосвязанными, тогда делаем над нашим пакетом ещё один сервис. Внутренний сервис, который может быть подключен как зависимость другого контекста. То есть не должно быть никаких перекрёстных импортов между контекстами. Контекст отдаёт наружу 2 сервиса: для внешнего API и для внутреннего использования. На уровне объединяющего в монолит сервиса внутренний сервис передаётся в инициализацию другим сервисам по интерфейсу, на манер подключения. Таким образом в дальнейшем мы сможем отделить любой из контекстов в микросервис, оставив на его месте только клиент, и всё продолжить работать.

На почитать

PS. Так уж получилось, что сегодня не про ангуляр, но накопилось.

Update 19.05.18

В первоначальной версии статьи я предлагал называть действия контроллерами, что не очень стыковалось с тем фактом, что они должны имплементировать только один метод Do. Так что переименовал их в действия.

Также я предлагал создавать интерфейсы адаптеров для каждого действия (отсюда и Каждому по потребностям). Однако, это хорошо работает не во всех языках, в том же Python такой фокус не пройдёт. Так что это требование ослабилось до объявления интерфейсов в пакете действий (нагло взято из луковой архитектуры)