Мы попросили спикера интенсива «Чистая архитектура приложения на Go» Николая Колядко поделиться полезными материалами по этой теме. Статья заботливо подготовлена на основе перевода части книги Go With The Domain, а именно — главы 9 «Чистая архитектура» за авторством Miłosz Smółka.

Введение

Авторы книги Accelerate посвятили целую главу архитектуре ПО и ее влиянию на эффективность разработки. Здесь часто упоминается создание «слабо связанных» приложений.

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

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

Хотя связанность (coupling) в основном относится к разработке микросервисов среди нескольких команд, слабосвязанная архитектура может принести немалую пользу и для работы внутри одной команды. Соблюдение стандартов архитектуры дает возможность выполнять параллельную разработку и помогает онбордить новых сотрудников.

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

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

У этого подхода есть и другие преимущества:

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

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

  • подстановка моковых данных становится тривиальной для юнит тестов;

  • простой переход от прототипов к правильным решениям (например, смена хранилища в памяти на базу данных SQL).

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

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

Мы пытались использовать эти паттерны в Go идиоматически в течение последних нескольких лет по следующей схеме: применение подхода на практике — неудача — корректировка — новая попытка.

В итоге у нас родилось сочетание приведенных выше идей. Мы не всегда строго следовали исходным паттернам, однако обнаружили, что такое неплохо работает в Go. Я покажу этот подход на примере рефакторинга проекта Wild Workouts.

Хочу отметить, что идея совсем не новая. Большая часть нашего подхода состоит в абстрагировании деталей реализации — стандарте технологии, особенно в ПО.

Другое его название — «принцип разделения ответственности». Эта концепция настолько старая, что существует на нескольких уровнях. Существуют структуры, пространства имен, модули, пакеты и даже (микро)сервисы. Все предназначено для того, чтобы удерживать связанные вещи в границах. Иногда это кажется простым здравым смыслом:

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

  • Если нужно изменить формат ответа HTTP, нежелательно менять схему базы данных.

Наш подход к чистой архитектуре заключается в сочетании двух идей: разделении портов и адаптеров + ограничении того, как структуры кода ссылаются друг на друга.

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

Для демонстрации чистой архитектуры на примере Wild Workouts был выполнен небольшой рефакторинг проекта на основе паттернов, о которых рассказано в предыдущих главах книги.

Первое изменение заключается в использовании отдельных моделей для сущностей базы данных и ответов HTTP. Изменения, внесенные в службу пользователей (users), можно увидеть в главе 5 «Когда следует избегать DRY». Сейчас тот же паттерн применяется в trainer и trainings. См. полный коммит на GitHub.

Второе изменение следует паттерну репозитория, который представлен в главе 7 «Паттерн репозитория». Мой рефакторинг переместил код, связанный с базой данных в trainings, в отдельную структуру.

Разделение портов и адаптеров

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

Берем код из этих групп и размещаем его в разных пакетах. Назовем их «слоями». Слои, которые обычно используются, — это адаптеры, порты, приложение и домен.

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

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

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

  • Если вы также применяете DDD (глава 6 «Упрощенное DDD»), вы можете ввести уровень предметной области, который содержит только бизнес-логику.

Примечание. Если идея разделения слоев все еще не ясна, взгляните на свой смартфон. Если подумать, он использует аналогичные концепции.

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

Есть несколько точек входа (портов) в логику «изменения громкости».

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

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

Давайте начнем рефакторинг, добавив слои в сервис trainings. Пока проект выглядит так:

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

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

Рис. 9.1: Слои чистой архитектуры
Рис. 9.1: Слои чистой архитектуры

1. Создайте порты, адаптеры и каталоги приложений.
2. Переместите каждый файл в соответствующий каталог.
trainings/
adapters
firestore.go
app
go.mod
go.sum
main.go
ports
http.go
openapi_api.gen.go
openapi_types.gen.go

Схожие пакеты были добавлены в сервис trainer. На этот раз мы не будем вносить никаких изменений в службу пользователей (users). В ней нет логики приложения, да и вообще она крошечная. Как и в случае с любой технологией, применяйте чистую архитектуру там, где это имеет смысл.

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

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

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

Давайте узнаем, где живет логика приложения. Обратите внимание на метод CancelTraining в сервисе обучения.

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
  }
}

Источник: http.go на GitHub

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

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

Что еще хуже, реальная логика приложения внутри этого метода использует модель базы данных (TrainingModel) для принятия решений:

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

Источник: firestore.go на GitHub

Соединение бизнес-правил (например, отмены обучения) с моделью базы данных замедляет разработку, поскольку код становится трудным для понимания и осмысления. Вдобавок такую логику сложно протестировать.

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

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()
}

Источник: training.go на GitHub

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

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

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

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

Источник: trainings_firestore_repository.go на GitHub

Служба приложения

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

type TrainingService struct {
}

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

Каким образом вызывать базу данных теперь? Попробуем воспроизвести то, что до сих пор использовалось в обработчике 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

Необходимо решить, как слои должны ссылаться друг на друга.

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

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

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

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

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

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

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

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

Принцип инверсии зависимостей решает вопрос о том, как пакеты должны ссылаться друг на друга. Лучший способ достичь этого редко бывает очевиден, особенно в Go, где циклы импорта запрещены. Возможно, именно поэтому некоторые разработчики утверждают, что лучше избегать «вложенности» и держать весь код в одном пакете. Но пакеты существуют по определенной причине, и причина эта — принцип разделения ответственности.

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

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

Сервис приложения определяет: «Мне нужен способ отменить обучение с заданным 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)
}

Источник: training_service.go на GitHub

Метод базы данных вызывает gRPC-клиентов служб пользователей (users) и trainer. Это неподходящее место, поэтому мы вводим два новых интерфейса, которые будут использованы сервисом.

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
}

Источник: training_service.go на GitHub

Примечание. Обратите внимание, что «пользователь» (user) и «тренер» (trainer) в данном контексте — не микросервисы, а (бизнес) концепции приложения. Просто в этом проекте они живут в рамках одноименных микросервисов.

Мы перемещаем реализацию этих интерфейсов в адаптеры как UsersGrpc и TrainerGrpc. В качестве бонуса преобразование метки времени теперь также происходит там, незаметно для сервиса приложения.

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

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

Наконец-то мы можем использовать паттерн «Метод обновления» из главы 7 «Паттерн репозитория», чтобы извлечь логику приложения из репозитория.

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
  })
}

Источник: training_service.go на GitHub

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

Процесс описан только для метода CancelTraining. Обратитесь к полному diff, чтобы увидеть рефакторинг остальных методов.

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

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

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,
  }
}

Источник: training_service.go на GitHub

Затем внедряем адаптер в main.go.

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

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

Источник: main.go на GitHub

Использование функции main — самый простой способ внедрить зависимости. По мере усложнения проекта в следующих главах будет рассмотрена библиотека wire.

Добавление тестов

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

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

Здесь использовался стандартный табличный подход Go к тестам, чтобы все случаи было легко читать и понимать.

{
  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,
},

Источник: training_service_test.go на GitHub

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

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
}

Источник: training_service_test.go на GitHub

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

Исправим это в главе 10 «Основы CQRS».

Что насчет шаблонного кода (boilerplate)?

Вам может быть интересно, не ввели ли мы слишком много паттернов. Проект действительно вырос по количеству строк кода, но само по себе это не наносит никакого вреда. Это инвестиции в слабую связанность, которые окупятся по мере роста проекта (глава 5: «Когда следует избегать DRY»).

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

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

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

if from.After(to) {
  return nil, errors.NewIncorrectInputError("date-from-after-date-to", "Date from after date to")
}

Источник: hour_service.go на GitHub

Вышеупомянутая ошибка приводит к ответу HTTP 401 Bad Request в портах. Она включает в себя параметры URL, которые можно обработать на стороне фронтенда и показать пользователю. Это еще один способ избежать утечки деталей реализации в логику приложения.

Что еще?

Рекомендуем прочитать полный commit, чтобы увидеть рефакторинг других частей проекта Wild Workouts.

Как обеспечить правильное использование слоев? Это еще одна вещь, о которой нужно помнить при осуществлении код-ревью?

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

Разделив слои, мы готовы ввести более сложные паттерны.

Хотите узнать больше о чистой архитектуре? Рекомендуем прочитать эту статью.


15-16 октября (сб и вс) мы в Слёрме проведем интенсив «Чистая архитектура приложения на Go» во второй раз.

Мы ждем Junior-разработчиков на Go и опытных разработчиков, которые переходят на Go с других языков. Будем активно учиться 2 дня: программа состоит из 4 часов теории и 8 часов практики. К концу обучения вы создадите сервис по работе с контактами и возможностью их группировки.

Занять место можно здесь: https://slurm.club/3SO4wRe

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


  1. tessob
    06.10.2022 12:30
    +1

    httperr.Unauthorised("no-user-found", err, w, r)

    За такие дегенеративные сообщения об ошибках надо выкидывать из профессии!


    1. Druj
      06.10.2022 13:38
      +2

      Покажите пожалуйста как надо, не хочу быть выкинутым.


      1. tessob
        06.10.2022 16:23
        +4

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


  1. wilcot
    07.10.2022 00:19

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


    1. AngusMetall
      07.10.2022 13:17

      Странный опыт у вас, у меня вот обратный. Метод на 1000 строк будет всегда меньше чем выделение этого ада в разные классы. Более того, довольно часто приходится писать всякий скаффолд, чисто ради того, что бы к примеру разделить ответственности или зависимости. Но это конечно если переписывается конкретный кусок. В общем приложение скорее всего станет меньше, потому что код можно будет легче переиспользовать. Да и скаффолд я не считаю увеличением кода, как правило он легко автоматизируется. В любом случае я лучше напишу 200 строк бездумного кода, чем 100 строк транзакшн скрипта, который умеет всё.


      1. wilcot
        08.10.2022 02:52

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