Ускоряй! (Accelerate). Авторы этого пособия посвятили целую главу архитектуре приложений и тому, как архитектура влияет на эффективность разработки. И вся эта глава крутится вокруг понятие слабой связанности (loosely coupled).

Зачем нужна архитектура?

Цель использования какой‑либо архитектуры заключается в минимизации избыточного общения между командами. Независимая разработка, без n синков с фронтендерами, девопсерами, дизайнерами, офис‑менеджерами, стажёрами офис‑менеджеров, а также помощниками стажёров офис‑менеджеров в день. Звучит неплохо, не так ли?

Об "Ускоряй!"

Если вам ещё не довелось ознакомиться с «Ускоряй!», возможно, ближайший вечер пятницы — хорошая возможность сделать это, отложив любимый поход в качалку, или не менее любимые посиделки в баре с коллегами. В пособии опирается на научно обоснованные данные, соответственно, и доверия к нему чуть побольше. Изложенный ниже подход, был сформирован под влиянием этого пособия.

Применение связанности (coupling) выглядит полезным, когда дело касается разработки микросервисов, силами нескольких команд программистов. Мы же находим использование слабой связанности в рамках одной команды весма уместным. Также слабая связанность упрощает онбординг, что избавляет команду и новых членов команды от лишней головной боли.

Возможно, многим довелось слышать о концепции «слабая связанность, высокое зацепление» (low coupling, high cohesion), но навряд ли им удавалось достигнуть в полной мере. Хорошая новость в том, что она является одной из плюшек Чистой архитектуры (Clean Architecture).

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

Вот ещё несколько преимуществ применения этого подхода:

  • Стандартная структура позволит легко ориентироваться в проекте

  • В долгосрочной перспективе разработка ускорится

  • Мокать зависимости в юнит‑тестах станет проще

  • Упростится переход от прототипов (например, in‑memory db) на продакшн‑решения (на другой пример, PostgreSQL)

Чистая Архитектура

Придумать заголовок для этого поста было адски сложно, ввиду большого количества разновидностей архитектур. Тут вам и  Чистая архитектура, Луковичная архитектура (Onion Architecture), Гексагональная архитектура (Hexagonal Architecture), и Порты (Ports) с Адаптерами (Adapters). За последние годы я экспериментировал с этими подходами, особенно при работе с Go. Некоторые из них оказались эффективными, от других было пользы, как от КПТ-8. Но в результате я пришёл к своему уникальному подходу, которым хочу поделиться на примере приложения Wild Workouts.

Хочется отметить, что идея не нова. В большей своей мере – это “абстрагирование от деталей имплементации” (abstracting away implementation details). Это дефолтный подход в технологиях, а в особенности в программировании.

Существует и другое понятие для этого -  "разделение ответственности" (separation of concerns). Эта концепция так укоренилась, что проникла на множество уровней в нашей профессии: структуры, пространства имён, модули, пакеты и даже (микро)сервисы. Все эти уровни созданы для того, чтобы удерживать связанные элементы внутри своих границ. Ведь иногда просто кажется очевидным:

  • Оптимизируя SQL-запрос, нет никакого желания рисковать изменением формата отображения.

  • Меняя формат HTTP-ответа, последнее, чем хотелось бы заниматься – это менять схему базы данных.

Что касается подхода к "чистой" архитектуре, описываемого далее, то это симбиоз двух идей: разделение Ports и Adapters, а также контроль за тем, как пакеты ссылаются друг на друга.

Это не просто очередная статья с отрывками кода.

Этот пост является частью большого цикла статей, в которых мы демонстрируем, как создавать приложения на Go, которые легко разрабатывать, поддерживать и с которыми приятно работать в долгосрочной перспективе. Мы делаем это, делясь проверенными методами, основанными на множестве экспериментов с командами, которыми мы руководили, и научных исследованиях (“при съёмках фильма ни одно животное не пострадало”).

Вы можете освоить эти подходы, вместе с нами создавая полноценное веб-приложение на Go - Wild Workouts.

Мы сделали кое-что иначе - добавили некоторые неявные проблемы в первоначальную реализацию Wild Workouts. Вы думаете, мы сошли с ума? Конечно, нет. ???? Такие проблемы типичны для многих проектов на Go. Со временем эти мелкие ошибки становятся критическими и мешают добавлять новые функции.

Одним из ключевых навыков старшего или ведущего разработчика является способность всегда видеть долгосрочные последствия тех или иных решений.

Мы устраним их, во время рефакторинга Wild Workouts. Таким образом, вы сможете быстро освоить методы, которыми мы делимся.

Знаете это чувство, когда после прочтения статьи о каком-то подходе и попытались его реализовать, но столкнулись с тем, что какой-то необходимый нюанс автор просто скипнул? Такой подход к написанию сокращает статью и увеличивает количество просмотров, но это не то, чего мы хотим. Мы создаём контент, который даёт исчерпывающее количество знаний для применения этих подходов на практике. Если вы ещё не читали предыдущие статьи из этой серии, настоятельно рекомендуем это сделать.

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

Вот полный список 14 статей, опубликованных на данный момент.

Полный исходный код Wild Workouts доступен на GitHub. Не забудьте оставить звёздочку нашему проекту! ⭐

Перед тем, как начать

Прежде чем внедрять Чистую архитектуру в Wild Workouts, проект потребовалось немного реорганизовать. Эти изменения основаны на паттернах, о которых велась речь в предыдущих статьях.

Первым шагом стало использование разных моделей для сущностей базы данных и HTTP-ответов. Эти изменения были внесены в сервис users, которые можно найти в предыдущей статье о принципе DRY. Теперь я применил тот же подход в trainer и trainings. Полное описание коммитов можно посмотреть на GitHub.

Второе нововведение базируется на паттерне репозитория (repository pattern), о котором Роберт рассказывал в предыдущей статье. В ходе рефакторинга, код, связанный с базой данных, в trainings, был перенесён в отдельную структуру.

Порты и Адаптеры

Подход "Ports and Adapters" может иметь разные названия, например, "интерфейсы и инфраструктура". Основная идея заключается в том, чтобы явно отделить эти две категории от остального кода вашего приложения.

Мы размещаем код из этих групп в разных пакетах. Мы называем их "уровнями" или "слоями". Обычно мы используем такие слои, как адаптеры, порты, приложение и домен.

Знакомым с Гексагональной архитектурой обязательно к прочтению

Вы можете запутаться в портах и адаптерах. Мы ошиблись и выбрав одни и те же имена для разных понятий (да, они хорошо подходят, но всё же).

  • Наши порты являются основными адаптерами (primary adapters) гексагональной архитектуры.

  • Наши адаптеры являются вторичными адаптерами (secondary adapters) гексагональной архитектуры.

Идея остаётся той же. Нам трудно понять исходное первичное/вторичное наименование, поэтому не стесняйтесь использовать то, что ближе именно вам. Вы можете использовать шлюзы (gateways), точки входа (entry points), интерфейсы (interfaces), инфраструктуру и т. д. Просто расставьте точки над i в команде, об используемой терминологии.

А как насчёт оригинальных портов? Благодаря неявным интерфейсам (implicit interfaces) Go мы не видим смысла сохранять для них отдельный уровень. Мы размещаем интерфейсы рядом с местом их использования (см. ниже).

  • Адаптер – это средство, с помощью которого ваше приложение общается с внешним миром. Вам нужно адаптировать ваши внутренние структуры к тому, что ожидает внешний API. Подумайте о SQL-запросах, клиентах HTTP или gRPC, считывателях и записывателях файлов, издателях сообщений Pub/Sub.

  • Порт – это вход в ваше приложение и единственный способ, как внешний мир может взаимодействовать с ним. Это может быть сервер HTTP или gRPC, команда CLI или подписчик сообщений Pub/Sub.

  • Логика приложения – это тонкий слой, который "склеивает" другие слои. Его также называют "сценариями использования" (или "use cases"). Если вы читаете этот код и не можете определить, какую базу данных он использует или какой URL он вызывает, это хороший знак. Иногда этот слой может быть очень коротким, и это нормально. Воспринимайте его как оркестратора.

  • Если вы также придерживаетесь подхода Domain-Driven Design, вы можете ввести доменный слой, который будет содержать только бизнес-логику.

Зачем разделять приложение на слои?

Если идея разделения на слои для вас до сих пор неясна, давайте рассмотрим пример вашего смартфона. Если задуматься, он использует похожие концепции.

Вы можете управлять своим смартфоном с помощью физических кнопок, сенсорного экрана или голосового помощника. Неважно, нажмёте ли вы кнопку "громче", проведёте пальцем по ползунку звука или скажете "Siri, увеличь громкость" - результат будет одинаковым. Есть несколько точек входа (портов) для логики "изменения громкости".

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

Можете ли вы представить создание мобильного приложения, которое должно знать модель наушников, подключённых к смартфону? Включение SQL-запросов непосредственно в логику приложения аналогично: оно разоблачает детали реализации.

Давайте начнём с рефакторинга слоёв в сервисе trainings service. Пока что наш прекрасный легаси-код выглядит следующим образом:

trainings/
├── firestore.go
├── go.mod
├── go.sum
├── http.go
├── main.go
├── openapi_api.gen.go
└── openapi_types.gen.go

Это несложная часть рефакторинга:

  1. Создайте папки ports, adapters и app .

  2. Разместите файлы по папкам, соответствующим его типу.

trainings/
├── adapters
│   └── firestore.go
├── app
├── go.mod
├── go.sum
├── main.go
└── ports
    ├── http.go
    ├── openapi_api.gen.go
    └── openapi_types.gen.go

Подобные пакеты я ввел в сервис трейнера. На этот раз мы не будем вносить никаких изменений в сервис users. Никакой бизнес-логики там нет, и в целом он крохотный. Как и в случае с любым другим методом, применяйте Чистую архитектуру там, где это имеет смысл.

Кейс использования дополнительных подпапок

Если ваш проект начинает разрастаться, вы можете найти полезным добвать дополнительные уровни подпапок. Например, adapters/hour/mysql_repository.go or ports/http/hour_handler.go.

Вы могли заметить загадочно пустую папку app. В неё мы будем переносить логику из хэндлеров HTTP.

Слой Приложения

Давайте? Давайте. Давайте посмотрим, где живёт логика нашего приложения. Давайте обратим внимание на метод CancelTraining, в сервисе training .

func (h HttpServer) CancelTraining(w http.ResponseWriter, r *http.Request) {
	trainingUUID := r.Context().Value("trainingUUID").(string)

	user, err := auth.UserFromCtx(r.Context())
	if err != nil {
		httperr.Unauthorised("no-user-found", err, w, r)
		return
	}

	err = h.db.CancelTraining(r.Context(), user, trainingUUID)
	if err != nil {
		httperr.InternalError("cannot-update-training", err, w, r)
		return
	}
}

Source code

Этот метод является точкой входа в приложение. Логики здесь не очень много, так что давайте углубимся в метод db.CancelTraining.

Внутри транзакционного запроса в базу данных Firestore содержится много кода, не относящегося к работе с самой Firestore.

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

if training.canBeCancelled() {
	// ...
} else {
	// ...
}

Source code

Одновременная работа с бизнес-логикой (например, когда training можно закенселить) и моделью базы данных замедляет разработку, поскольку код становится трудным для понимания и анализа. Также эту логику будет сложно отлаживать.

Чтобы исправить это, добавим промежуточный тип Trainig на уровень приложения:

type Training struct {
	UUID     string
	UserUUID string
	User     string
	Time  time.Time
	Notes string
	ProposedTime   *time.Time
	MoveProposedBy *string
}

func (t Training) CanBeCancelled() bool {
	return t.Time.Sub(time.Now()) > time.Hour*24
}

func (t Training) MoveRequiresAccept() bool {
	return !t.CanBeCancelled()
}

Source code

Теперь сразу должно быть понятно, когда ```training``` можно кенселить. При этом мы не знаем, формат хранения ```training``` в базе данных или JSON формам для его использования в HTTP API. Это хороший знак.

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

t := TrainingModel{}

if err := doc.DataTo(&t); err != nil {
	return nil, err
}

trainings = append(trainings, app.Training(t))

Source code

Application Service

Теперь создадим  структуру TrainingsService в пакете app, которая будет служить точкой входа для логики приложения trainings.

type TrainingService struct {

}

func (c TrainingService) CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error {

}

Но как же теперь общаться  базой данных? Давайте попробуем повторить то же, что и когда-то в HTTP handler.

Но как же теперь обращаться к базе данных? Давайте попробуем воспроизвести то, что использовалось до сих пор в обработчике HTTP.

type TrainingService struct {
	db adapters.DB
}

func (c TrainingService) CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error {
	return c.db.CancelTraining(ctx, user, trainingUUID)
}

И тут, код не скомпилится. Почему?

import cycle not allowed

package github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings
        imports github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/adapters
        imports github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app
        imports github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/adapters

Делов в том, что сейчас пакеты импортируются друг в друга. Давайте исправим эту проблему.

Принцип инверсии зависимостей

Разделение портов, адаптеров и логики приложения полезно само по себе. Чистая архитектура улучшает его при помощи Принципа инверсии зависимостей (The Dependency Inversion Principle).

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

  • Домен вообще ничего не знает о других слоях. Он содержит чистую бизнес-логику.

  • Приложение может импортировать домен, но ничего не знает о внешних слоях. Оно понятия не имеет, вызывается ли он HTTP-запросом, хэндлером Pub/Sub или командами  CLI.

  • Порты могут импортировать внутренние слои. Порты являются точками входа в приложение, поэтому они часто выполняют службы или команды приложения. Однако они не могут напрямую получить доступ к адаптерам.

  • Адаптеры могут импортировать внутренние слои. Обычно они работают с типами, найденными в слоях Приложение и Домен, например, извлекая их из базы данных.

Опять же, идея не нова. Принцип инверсии зависимостей – это “D” в аббревиатуре SOLID. Думаете это применимо только к ООП? Так сложилось, что интерфейсы Go идеально к нему подходят.

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

Возвращаясь к нашему примеру, как следует обращаться к уровню базы данных?

Поскольку интерфейсы Go не требуют явной реализации, можно определить их рядом с кодом, который в них нуждается.

Итак, application service утверждает: “Мне нужен метод, чтобы кенселить training по UUID. Волнует ли меня реализация? Ни капли, пока метод удовлетворяет интерфейсу”.

type trainingRepository interface {
	CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error
}

type TrainingService struct {
	trainingRepository trainingRepository
}

func (c TrainingService) CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error {
	return c.trainingRepository.CancelTraining(ctx, user, trainingUUID)
}

Source code

Методы базы данных дёргают gRPC клиенты сервисов trainer и users. Здесь им не место, поэтому введём два новых интерфейса, которые будет использовать сервис. 

type userService interface {
	UpdateTrainingBalance(ctx context.Context, userID string, amountChange int) error
}

type trainerService interface {
	ScheduleTraining(ctx context.Context, trainingTime time.Time) error
	CancelTraining(ctx context.Context, trainingTime time.Time) error
}
Нюанс с неймингами

Важно, что “user” и “trainer” в этом контексте не микросервисы, а бизнес-логика приложения. Так уж получилось, что в этом проекте они существуют вместе с микросервисами, названными так же.

Переместим реализацию этих интерфейсов в пакет adapters, как UsersGrpc и TrainerGrpc. И как бонус, конвертация timestamp происходит для application service незаметно.

Извлечение бизнес-логики приложения

Код компилируется, но наш application service мало что умеет. Теперь самое время извлечь логику в нужное место.

Наконец, мы можем использовать update функцию шаблон из статьи "Репозитории" (Repositories), чтобы извлечь логику приложения из репозитория.

func (c TrainingService) CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error {
	return c.repo.CancelTraining(ctx, trainingUUID, func(training Training) error {
		if user.Role != "trainer" && training.UserUUID != user.UUID {
			return errors.Errorf("user '%s' is trying to cancel training of user '%s'", user.UUID, training.UserUUID)
		}

		var trainingBalanceDelta int
		if training.CanBeCancelled() {
			// just give training back
			trainingBalanceDelta = 1
		} else {

			if user.Role == "trainer" {
				// 1 for cancelled training +1 fine for cancelling by trainer less than 24h before training
				trainingBalanceDelta = 2
			} else {
				// fine for cancelling less than 24h before training
				trainingBalanceDelta = 0
			}
		}

		if trainingBalanceDelta != 0 {
			err := c.userService.UpdateTrainingBalance(ctx, training.UserUUID, trainingBalanceDelta)
			if err != nil {
				return errors.Wrap(err, "unable to change trainings balance")
			}
		}

		err := c.trainerService.CancelTraining(ctx, training.Time)
		if err != nil {
			return errors.Wrap(err, "unable to cancel training")
		}

		return nil
	})
}

Source code

Логически этого кода достаточно, для возможности введения Доменного (Domain) уровня в будущем. Пока этого достаточно.

Мы описали процесс рефакторинга для метода CancelTraining. Можете ссылаться это, чтобы увидеть, как были зарефакторены остальные методы.

Внедрение зависимости 

Внедрение зависимости (Dependency Injection) – это процесс предоставления внешней зависимости программному компоненту.

Как объяснить сервису, какой ему нужно использовать адаптер? Для начала определим небольшой конструктор этого сервиса.

func NewTrainingsService(
	repo trainingRepository,
	trainerService trainerService,
	userService userService,
) TrainingService {
	if repo == nil {
		panic("missing trainingRepository")
	}
  
	if trainerService == nil {
		panic("missing trainerService")
	}

	if userService == nil {
		panic("missing userService")
	}

	return TrainingService{
		repo:           repo,
		trainerService: trainerService,
		userService:    userService,
	}
}

Source code

А затем проинициализируем его в main.go

trainingsRepository := adapters.NewTrainingsFirestoreRepository(client)
trainerGrpc := adapters.NewTrainerGrpc(trainerClient)
usersGrpc := adapters.NewUsersGrpc(usersClient)

trainingsService := app.NewTrainingsService(trainingsRepository, trainerGrpc, usersGrpc)

Инициализация зависимостей в main – один из самых простых путей их внедрения. Что ж, можно было бы рассмотреть инициализацию с помощью wire library… в грядущих проектах :)

Добавим тесты

Изначально слои проекта были свалены в одну кучу и мокать зависимости было весьма сложно. Единственным способом тестирования были бы интеграционные тесты, с реальными базами данных и всеми работающими сервисами.

Хотя некоторые сценарии можно покрыть такими тестами, они, это, как правило, долго и неудобно, в сравнении с unit-тестами. После внесения изменений выше, стало возможно покрыть CancelTraining unit-тестами.

Используем стандартный подход Go table-driven тестирования, для повышения читаемости тестов. 

{
	Name:     "return_training_balance_when_trainer_cancels",
	UserRole: "trainer",
	Training: app.Training{
		UserUUID: "trainer-id",
		Time:     time.Now().Add(48 * time.Hour),
	},
	ShouldUpdateBalance:   true,
	ExpectedBalanceChange: 1,
},
{
	Name:     "extra_training_balance_when_trainer_cancels_before_24h",
	UserRole: "trainer",
	Training: app.Training{
		UserUUID: "trainer-id",
		Time:     time.Now().Add(12 * time.Hour),
	},
	ShouldUpdateBalance:   true,
	ExpectedBalanceChange: 2,
},

Source code

В рамках поста библиотеки для создания моков рассмотрены не будут. Читатель может использовать их, но ваши интерфейсы должны быть достаточно маленькими, для эффективного написания моков.

type trainerServiceMock struct {
	trainingsCancelled []time.Time
}

func (t *trainerServiceMock) CancelTraining(ctx context.Context, trainingTime time.Time) error {
	t.trainingsCancelled = append(t.trainingsCancelled, trainingTime)
	return nil
}

Source code

Вы обратили внимание на необычно большое число нереализованных методов в repositoryMock? Это из-за того, что мы используем один training сервис для всех методов, так что необходимо реализовать интерфейс целиком, даже когда тестируете только его.

Мы поправим это в следующем посте на CQRS

А что шаблоны?

Вам может быть интересно, не ввели ли мы слишком много шаблонов? Кодовая база проекта действительно разрослась, но это неплохо само по себе. Это инвестиция в “слабую связанность”, которая окупится по мере роста проекта.

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

Обработка ошибок в приложении

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

if from.After(to) {

	return nil, errors.NewIncorrectInputError("date-from-after-date-to", "Date from after date to")

}

Source code

Ошибка выше переводится в 400 Bad Request HTTP ответа, в ports. Это ответ, который может быть возвращён на фронт и показан юзеру. Это ещё один паттерн, который позволяет избежать утечки особенностей реализации логики приложения (application logic)

Что ещё?

Я рекомендую вам пробежаться по всему коммиту, чтобы увидеть, как я отрефакторил другие части Wild Workout.

Вам может быть интересно, как обеспечить правильное использование слоёв? Есть ли ещё что-то, о чём следует помнить при проверке кода?

К счастью, проверить правильность можно с помощью статического анализа. Вы можете проверить свой проект с помощью линтера go-cleanarch локально или включить его в свой конвейер CI.

С раздельными слоями мы готовы вводить более сложные шаблоны.

В следующий раз Роберт покажет вам, как улучшить проект посредством добавления CQRS.

Если вы хотите почитать больше про Чистую архитектуру, чекайте: Why using Microservices or Monolith can be just a detail?.

Статья является переводом с оригинала: https://threedots.tech/post/introducing-clean-architecture/

Комментарии (1)


  1. mrobespierre
    29.10.2023 05:03
    +4

    Спасибо за перевод. Статья слабовата: автор явно писал на другом ЯП и тащит оттуда плохое, но до некоторых важных моментов (сделать сервис структурой интерфейсов и собрать их в main например) автор уже догадался. Осталось разобраться с неймингом (сейчас с ним швах) и структурой пакетов (на самом деле в Go это одно и то же) и можно будет пользоваться.