Как стать автором
Обновить

Еще один вариант структуры go-приложения

Уровень сложностиСредний
Время на прочтение14 мин
Количество просмотров4K

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

  • архитектура слоев;

  • предметно-ориентированное проектирование (DDD);

  • разделение команд и запросов (CQS);

  • архитектура портов и адаптеров.

Также будет затронута тема именования файлов .go и вопросы связности (low coupling/high cohesion).

Приведенный выше перечень подходов часто можно встретить в проектах на Java или C#. Точнее говоря, эти подходы предназначены для задач, которые традиционно решаются с использованием этих языков. Однако бывают случаи, когда подобные задачи необходимо решать на Go. Надеюсь, эта статья поможет начинающим разработчикам Go с выбором подходящей структуры проекта. Возможно, и опытные разработчики найдут в ней что-то полезное.

Всё, что вы прочитаете далее, — лишь субъективное видение автора и, разумеется, не претендует на звание "шаблон проекта".

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

Декомпозиция пакета internal с помощью архитектурных слоев

Пакет internal будет присутствовать в повествовании, поскольку автор статьи в свое время вдохновился https://github.com/golang-standards/project-layout.

Самое простое, с чего можно начать - это создать в пакете internal еще три пакета, соответствующие архитектурным слоям:

  • internal/domain - содержит модели предметной области и обеспечивает выполнение инвариантных правил (слой предметной области, domain layer), в этом пакете будем искать компоненты с помощью DDD;

  • internal/app - отвечает за координацию приложения и внешних систем (слой приложения, application layer), в этом пакете будем искать компоненты с помощью CQS;

  • internal/infra - содержит код адаптеров внешних систем (слой инфраструктуры, infrastructure layer) в этом пакете будем искать компоненты с помощью ports and adapters.

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

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

В demo-приложении действуют следующие правила зависимостей (dependency rules), помогающие провести границы между слоями и изолировать код одного слоя от кода другого слоя.

Архитектурные слои
Архитектурные слои

Если стрелка направлена из слоя приложения в слой предметной области, то в коде допустим импорт типов из пакета internal/domain в пакет internal/app. Импорт типов из пакета internal/infra в пакет internal/domain запрещен, поскольку такой стрелки нет на рисунке.

Декомпозиция пакета domain с помощью предметных подобластей

Обычно приложения создаются с целью автоматизировать некоторые процессы. Предметная область приложения - это процессы, которые приложение должно автоматизировать. Часто предметную область приложения можно разбить на предметные подобласти. Этим фактом и воспользуемся для декомпозиции пакета internal/domain.

В demo-приложении автоматизируются выдуманные процессы. Познакомимся с ними.

Пусть в некотором вымышленном городе есть организация, которая занимается составлением списков дел (todo list). В данной организации над составлением списков дел трудятся редакторы. Редакторы составляют самые разнообразные списки дел, например, список ингридиентов для яблочного пирога. Списками дел пользуются жители города. Они приходят к редакторам и переписывают себе на листок бумаги список ингридиентов, затем идут в магазины, приобретают нужные ингридиенты и вычеркивают их карандашом по мере совершения покупок. Нам как разработчикам требуется автоматизировать работу городской организации и ее клиентов.

Основной моделью предметной является список дел. Существуют две категории людей, которые работают со списками дел:

  • редакторы;

  • пользователи.

Предметные подобласти
Предметные подобласти

Редакторы выбирают заголовок списка и добавляют туда пункты. Пользователи же только читают придуманные редакторами заголовок и список пунктов. Пользователи могут вычеркивать пункты из списка, чего не могут делать редакторы. Таким образом, редакторы и пользователи смотрят на один и тот же список по-разному. Сначала список существует в контексте редактирования, а затем этот же список существует в контексте использования.

Создадим в пакете internal/domain еще два пакета:

  • internal/domain/editing - предметная подобласть редактирования списков дел;

  • internal/domain/using - предметная подобласть использования списков дел.

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

Строительные блоки слоя предметной области

В качестве строительных блоков слоя предметной области выберем модели данных предметной области и сервисные функции, также известные как сервисы предметной области. В качестве примера моделей данных предметной подобласти рассмотрим модели из пакета internal/domain/using.

Файлы в пакете using
Файлы в пакете using

Выделим категории моделей:

  • объект-значение (value object);

  • сущность (entity);

  • агрегат (aggregate).

Краткое описание данных категорий:

  • сущность - это структура go, часто соответствующая строке в таблице БД;

  • объект-значение - это структура go, часто соответствующая полю в таблице БД;

  • агрегат - это кластер сущностей и объектов-значений.

Более подробно описывать не буду, потому что мы договорились, что DDD будет без фанатизма.

Каждый файл пакета internal/domain/using содержит по одной модели. По названию файла можно определить, какая модель в нем объявлена.

Когда требуется выполнить операцию с более чем одним агрегатом имеет смысл применить сервис предметной области. Сервис предметной области - это функция, привязанная к go-структуре. Поля структуры ссылаются на типы go, которые необходимы для выполнения задач сервиса. От этих типов зависит успешное выполнение сервисной функции. Будем называть такие типы зависимостями сервиса.

Сервисная функция TakeTodoListService
Сервисная функция TakeTodoListService

Поскольку сервис предметной области в demo-приложении импортирует типы из пакетов /internal/domain/editing и /internal/domain/using, он не объявлен ни в одном из этих пакетов. Вместо этого используется специальный пакет для размещения сервисов /internal/domain/services. Файл с сервисом предметной области TakeTodoListService называется take_todo_list.go. Название пакета говорит о том, что это сервис предметной области, а название файла говорит о том, что этот сервис делает.

Границы слоя предметной области

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

Вот несколько инвариантных правил demo-приложения:

  • изначально список дел создается со статусом "Черновик";

  • нельзя опубликовать пустой список дел;

  • нельзя добавить более десяти пунктов в список дел;

  • нельзя использовать список дел со статусом "Черновик".

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

Функция-конструктор и инвариантное правило
Функция-конструктор и инвариантное правило

Инвариантные правила могут соблюдаться в функциях сервиса предметной области.

Сервис предметной области и инвариантное правило
Сервис предметной области и инвариантное правило

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

Функция агрегата и инвариантное правило
Функция агрегата и инвариантное правило

Непосредственным клиентом слоя предметной области является слой приложения. Слой предметной области предоставляет слою приложения свой API - набор функций, которые будут вызывать функции из слоя приложения. В API слоя предметной области входят:

  • экспортируемые функции-конструкторы агрегатов;

  • экспортируемые функции агрегатов;

  • экспортируемые функции сервисов предметной области;

  • экспортируемые интерфейсы, объявленные в слое предметной области.

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

Предостережение: неправильно разделение предметной области на подобласти может оказать медвежью услугу и усилить запутанность кода во всем приложении. Поэтому при отсутствии опыта в DDD я рекомендую отложить этот способ декомпозиции до лучших времен и не делить пакет /internal/domain, ведь далее мы рассмотрим более очевидные и вместе с тем довольно эффективные способы декомпозиции слоя приложения и слоя инфраструктуры.

Декомпозиция пакета app с помощью команд и запросов

В соответствии с CQS (command-query separation) структуры данных, которые внешние системы отправляют для обработки в приложение, делятся на два типа:

  • команды (commands);

  • запросы (queries).

Commands меняют состояние приложения, которое обычно хранится в БД. Queries не меняют состояние приложения, а считывают его и возвращают внешней системе. На основании данных, полученных в результате выполнения queries, внешняя система принимает решение о том, какую команду отправить в приложение следующей.

Разделение команд и запросов
Разделение команд и запросов

Строительные блоки слоя приложения

Для слоя приложения выберем такие строительные блоки:

  • функции, выполняющие обработку commands и queries;

  • модели commands и результатов commands (dto, data transfer objects);

  • модели queries и результатов queries (тоже dto).

Обработчики команд и запросов имеют те же анатомические особенности, что и сервисы предметной области - состоят из функции-обработчика и структуры с зависимостями.

Обработчик команд
Обработчик команд

Модели команд и запросов можно объявить явно как структуры.

Модель результата запроса
Модель результата запроса

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

Обработчик запроса без DTO
Обработчик запроса без DTO

Границы слоя приложения

В demo-приложении обработчики команд и запросов выполняют следующие функции:

  • определяют границы транзакций;

  • конвертируют входные структуры данных в модели предметной области и наоборот.

В обработке commands всегда задействован слой предметной области, потому что выполнение команды ведет к изменению состояния БД. Слой предметной области с помощью инвариантных правил гарантирует то, что состояние БД после выполнения команды останется согласованным. Поэтому важной зоной ответственности слоя приложения в целом и обработчиков команд в частности является определение границы транзакций.

Обработка команды: демонстрация транзакции
Обработка команды: демонстрация транзакции

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

Обработка команды: демонстрация конвертеров
Обработка команды: демонстрация конвертеров

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

Структура пакета app
Структура пакета app

Разделение слоя приложения на commands и queries кажется простой задачей. Чтобы не запутаться удобно сопоставлять commands с методами http POST, PUT, PATCH и DELETE. Метод GET соответствует queries.

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

Декомпозиция пакета infra с помощью входных и выходных адаптеров

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

Входные адаптеры

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

  • /internal/infra/in/http

  • /internal/infra/in/grpc

  • /internal/infra/in/graphql

  • /internal/infra/in/tcp

  • /internal/infra/in/kafkaconsumer

Пусть приложение использует два http сервера: один для обработки запросов к application layer, а другой для обработки запросов технического характера (/ready, /health, /metrics и т.д.). Тогда имеет смысл использовать два пакета:

  • /internal/infra/in/http

  • /internal/infra/in/httptech

Аналогично можно разделить любой пакет входных адаптеров общей технологии.

Выходные адаптеры

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

  • /internal/infra/out/http

  • /internal/infra/out/grpc

  • /internal/infra/out/redis

  • /internal/infra/out/postgres

  • /internal/infra/out/kafkaproducer

Пусть приложение инициирует запросы к сервисам auth и notify с помощью протокола http. Тогда имеет смысл использовать два пакета:

  • /internal/infra/out/httpauth

  • /internal/infra/out/httpnotify

Аналогично можно разделить любой пакет выходных адаптеров общей технологии.

Концепция входных и выходных адаптеров
Концепция входных и выходных адаптеров

Строительные блоки слоя инфраструктуры

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

В demo-приложении эти строительные блоки похожи на те, что используются в слое приложения: обработкой http-запросов занимаются функции-обработчики, тела запроса и ответа моделируется с помощью dto. Обычно dto в слое инфраструктуры проектируются для нужд сериализации/десериализации в структуры данных, пригодных для передачи по сети - в demo-приложении это json-теги. Поэтому всегда стоит разделять dto слоя приложения и dto слоя инфраструктуры.

Обработчик http-запроса
Обработчик http-запроса

Границы слоя инфраструктуры

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

И конечно же нужно выделить в слое инфраструктуры входные и выходные адаптеры. Провести границы этих компонентов легко. Если речь идет о входном http адаптере, то соответствующем пакете будет храниться код, связанный с обработкой http запросов. Если речь идет о выходном адаптере postgres, то в соответствующем пакете будет сосредоточен sql-код. И так далее.

Утилиты и общий код

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

В таких ситуациях я предлагаю определиться с тем, насколько такой код зависит от разрабатываемого приложения. Для этого можно провести мысленный эксперимент: если этот код можно выложить в публичный репозиторий, то он не зависим от приложения. Зависимый от приложения код будем хранить либо в пакете слоя (internal/domain, internal/app, internal/infra) либо в специальном "приближенном" к слоям пакете internal/shared. Название можно придумать любое. Независимый код будем хранить в пакете internal/pkg, который предлагает использовать для этой цели Standard go project layout.

Алрогитм выбора места для общего кода и утилит
Алрогитм выбора места для общего кода и утилит

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

В пакете internal/shared можно хранить, например, строковые константы, используемые во всех слоях или единый перечень ошибок (var Err...), используемых повсеместно в приложении:

  • internal/shared/strconst

  • internal/shared/errors

В internal/pkg можно поместить middleware fiber с вашей собственной реализацией CORS. Напротив, если в middleware fiber обрабатываются ошибки из пакета internal/domain, то такой код нужно оставить в слое инфраструктуры. В демо-приложении в internal/pkg размещен пакет pgxtx, который отвечает за передачу транзакции БД в context.Context. Однако содержимое pgxtx можно было бы оставить и в слое инфраструктуры, поскольку эта функциональность используется только там.

А вот другой пример. Требуется расширить стандартную библиотеку go одной функцией. Этот код полностью независим от разрабатываемого приложения и потенциально может быть использован в любом слое. Такой код хорошо будет чувствовать себя в internal/pkg.

Пример функции общего назначения
Пример функции общего назначения
Пакеты для общего кода и архитектурные слои
Пакеты для общего кода и архитектурные слои

В целом разделение на internal/shared и internal/pkg не несет сверхвысокой выгоды, однако когда кода много, этот способ декомпозиции будет не лишним.

Анализ связности поддоменов

Если вы уже заглянули в исходный код demo-приложения, то, вероятно, обратили внимание на заметное влияние поддоменов на структуру пакетов. Хотя выделение поддоменов — во многом инженерное искусство, я все же внесу немного формализма. Давайте попробуем оценить, насколько поддомены editing и using независимы и как сильно они связаны между собой.

Концепция модульности
Концепция модульности

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

Граф - это абстрактная модель. Чтобы воспользоваться этой моделью нужно определиться с тем, что считать вершинами, а что - ребрами. Для анализа связности поддоменов за вершину графа примем сущность (entity). В demo-приложении четыре сущности:

  • e_list (editable todo list)

  • e_list_item (editable todo list item)

  • u_list (usable todo list)

  • u_list_item (usable todo list item)

Будем считать, что между парой вершин есть ребро, если соответствующая пара сущностей необходима для выполнения command или query. Рассмотрим пример с TakeTodoListCommandHandler. В этом случае из БД извлекаются экземпляры сущностей e_list и e_list_item, а затем создаются экземпляры сущностей u_list и u_list_item. Поскольку все указанные сущности используются совместно, граф, описывающий TakeTodoListCommandHandler, является полносвязным.

Граф TakeTodoListCommandHandler
Граф TakeTodoListCommandHandler

Аналогичным образом все остальные command handlers и query handlers можно представить как полносвязные графы, если они используют более одной сущности. Далее все полученные полносвязные графы объединяем в один общий взвешенный граф, где вес ребра соответствует количеству раз, которое это ребро встречается в исходных графах. В построенном взвешенном графе сообщества вершин определим с помощью Лувенского метода.

Автоматизированную версия вышеописанной процедуры вы можете найти в файле scripts/analyze_decomposition.py.

Сообщества сущностей
Сообщества сущностей

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

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

Заключение

Кому-то вышеописанная структура проекта может показаться избыточной, ведь часто перед разработчиком встает вопрос о выборе структуры, когда кода еще крайне мало или его вообще нет. Я предпочитаю начинать проект, используя минимально затратные инструменты. Например, создать три пакета domain, app и infra и использовать их по назначению очень просто и к тому же крайне полезно, если проект будет стремительно обрастать функциональностью в будущем.

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

Весьма просто на уровне приложения в пакете app использовать структуры с одной основной функцией (как CommandHandler и QueryHandler), вместо структур со словом Service в названии и 10-20 привязанными функциями. Сервисная функция - это гибкое решение, маловероятно, что в ней нарушится принцип единственной ответственности, ведь она всего одна в структуре. К тому же для тестирования такой функции понадобится инициализировать зависимости только этой функции, а не все зависимости десятков функций гигантского Service.

HttpRequest > Command > DomainModel > CommandResult > HttpResponse
HttpRequest > Query > (БД) > QueryResult > HttpResponse

Каждый раз, когда так много dto кажется мне избыточным, я вспоминаю, как много раз жалел о том что во всех слоях у меня была одна структура, которая проверяла инвариантные правила, отображалась в json и в поля таблицы БД.

Надеюсь, что статья вам оказалась немного полезной. Спасибо за то, что ее прочитали!

Список литературы

https://vaughnvernon.com

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

https://threedots.tech/post

https://martinfowler.com/bliki/CommandQuerySeparation.html

https://codesoapbox.dev/ports-adapters-aka-hexagonal-architecture-explained

https://arxiv.org/abs/0803.0476

Теги:
Хабы:
+6
Комментарии13

Публикации

Работа

Go разработчик
87 вакансий

Ближайшие события