Розробка серверних Go-застосунків. Частина 1: структура пакетів

Привіт, спільното! Мене звати Дмитро, я співпрацюю з Wizer Inc. у ролі Backend Lead. За свій професійний шлях у сфері розробки ПЗ довжиною у 9 років я розробляв декілька великих продуктів, зокрема для компаній із S&P Global 100. Останні 5 років пишу код переважно на мові програмування Go.

Дана стаття має стати першою із серії публікацій, де я планую поділитися своїм досвідом розробки серверних застосунків. Насамперед у цій пропоную структуру побудови пакетів, яка може стати в нагоді як новачкам, так і досвідченим фахівцям при проєктуванні середніх та великих проєктів з багатим API та підтримкою декількох варіантів міжсервісної коммунікації.

Для початку хочу зауважити, що окрім побудови основної логіки застосунку, яка безпосередньо спрямована на досягнення мети проєкту, технічні команди також докладають зусилля щодо:

  • адаптації процесів розробки під мінливість бізнесових вимог;
  • мінімізації витрат на розробку і доставку нових функціональностей, їх подальшу підтримку;
  • організації планомірної доставки за рахунок впровадження автоматичного контролю якості;
  • зменшення зусиль, необхідних для інтеграції нових розробників в команду;
  • забезпечення масштабованості та продуктивності застосунку в умовах зростаючого навантаження.

На сьогодні є безліч різних архітектурних паттернів, які допомогають у вирішенні вищевказаних питань і які, в свою чергу, впливають на внутрішню структуру проєкту. В цій статті я розглядатиму саме розміщення файлів та директорій, яке добре комбінується з практиками предметно-орієнтованого проєктування (Domain-Driven Design, DDD) та гексагональної архітектури (Hexagonal Architecture / Ports and Adapters Architecture).

Розпочнемо з основної директорії. Базова структура виглядає наступним чином:

.
├── api                     # визначення API (Protobuf, OpenAPI та інше).
├── bin                     # необов'язково - скомпільовані файли.
├── build                   # інструкції для збірки та запуску Docker образів.
├── cmd                     # необов'язково - вхідні точки для запуску застосунку.
├── config                  # файл(и) конфігурації (YAML, JSON і т.д.).
├── internal                # основна, предметна логіка застосунку.
├── pkg                     # код, що не відноститься до предметної логіки застосунку, але може бути використаний у багатьох місцях.
├── scripts                 # необов'язково - сценарії для автоматизації процесів розробки.
├── tests                   # автоматизовані інтеграційні та e2e тести.
├── vendor                  # необов'язково - копії зовнішніх пакетів.
├── .gitignore              # визначення файлів, які не слід відслідковувати в  Git.
├── .gitmodules             # необов'язково - налаштування Git підмодулів. 
├── go.work                 # необов'язково - налаштування робочих просторів Go.
├── LICENSE                 # необов'язково - файл ліцензії.
├── main.go                 # необов'язково - вхідна точка для запуску застосунку (якщо не використовується пакет cmd).
└── README.md               # необов'язково - текстовий файл який представляє та описує проєкт. 

Крім вищевказаних файлів до цієї директорії також доцільно покласти файли, що допоможуть організувати спрощений запуск сценаріїв — такі як Makefile, Taskfile тощо.

api — як взаємодіяти зі застосунком

Тут розміщуємо як YAML/JSON файли для опису HTTP ендпоїнтів, так і protobuf файли, що потрібні для організації комунікації за допомогою gRPC чи можуть стати в нагоді для обміну повідомленнями через NATS чи Kafka. Конкретний перелік піддиректорій залежитиме від технологій та форматів передачі даних, який використовується на проєкті.

build — збираємо все до купи

Dockerfile, docker-compose.yaml та інші файли, які потрібні для збірки застосунку та підготовки його до деплою, можна залишити у директорії build. Вона ж стане у нагоді, якщо потрібно підготувати залежності (як то база даних чи брокер повідомлень) для запуску тестів, а тест-контейнери чи їх аналоги на проєкті не використовуються.

bin — скомпільовані файли

Наявність цієї директорії залежить від прийнятих у команді підходів до локальної розробки та розгортання застосунку. У ній можуть знаходитися скомпільовані файли як самого застосунку (або декількох залежно від обраної стратегії розробки програмного забезпечення, наприклад монорепозиторію), так і сторонніх інструментів, що необхідні для автоматизації процесів розробки: генератори коду і OpenAPI документації, звітів за результатами перевірки якості коду, лінтери тощо.

cmd — точки входу

Створення директорії cmd найбільш доцільно для монорепозиторіїв. У такому разі пакети у ній міститимуть main.go файли, а за необхідності — файли налаштування запуску застосунку.

.  
├── ...  
├── cmd  
│   ├── users
│   │   └── main.go         # вхідна точка для сервісу роботи з користувачами.  
│   ├── order  
│   │   └── main.go         # вхідна точка для сервісу роботи із замовленнями.  
│   └── ...
└── ...

config — конфігурації застосунку

Місце для розміщення файлів конфігурації. Найбільш вдалим варіантом, на мою думку, є YAML / JSON файл, який містить у собі змінні. Бібліотеки на кшталт spf13/viper дозволяють парсити такі файли, підставляючи необхідні значення зі змінних оточення.

app:
    version: {APP_VERSION}
log:
    level: {LOG_LEVEL}
server:
    http: {LOG_LEVEL}

Вибір конкретного конфігу можна зробити через аргументи застосунку. Це дозволить легко завантажувати різні конфігурації залежно від контексту використання застосунку. Особливо зручно при запуску на локальному оточенні розробника, де замість змінних можна одразу проставити конкретні значення.

./bin/app -c ./config/local.yaml

internal — серце застосунку

В мові Go internal називають пакет, який не підлягає імпорту. У ньому доцільно розмістити основну логіку застосунку.

app — пакет, що містить код, необхідний для запуску застосунку: підключення до баз даних, файлових сховищ, сторонніх сервісів, брокерів повідомлень тощо. Може містити наступні директорії:

  • conn — підключення до сторонніх сервісів і БД.
  • core — DI та запуск серверів.
  • observe — налаштування та запуск логування, метрик та трейсингу.
  • events — реєстрація підписників на події у застосунку (паттерн Спостерігач / Observer) .
.  
├── ... 
├── internal  
│   ├── app                 # налаштування запуску застосунку.
│   │   ├── conn            # підключення.
│   │   ├── core            # DI, запуск серверів.
│   │   ├── observe         # логування трейсинг, метрики.
│   │   └── events          # робота з подіями в застосунку.
│   ├── service             # логіка, яка не відноситься до предметної області. 
│   ├── README.md           # необов'язково - корисна для розробників пакету. інформація.
│   └── ...
└── ...

При побудові пакетів для роботи з предметною областю застосунку варто розглянути два найбільш поширені варіанти його побудови:

  1. Один застосунок — одна предметна область. Наприклад: робота з користувачами (users).
  2. Один застосунок — декілька предметних областей. Наприклад: робота з клієнтами (tenants), користувачами клієнтів (users) + партнерка (partnership). В такому випадку підійде структура притаманна для модульних монолітів.

Для першого варіанту підійде простіша структура:

.  
├── ...  
├── internal  
│   ├── inbound             # обробка вхідних даних.
│   ├── model               # сутності предметної логіки застосунку. 
│   ├── outbound            # запити та повідомлення до сторонніх систем.
│   ├── service             # логіка що не відноситься до предметної області. 
│   ├── usecase             # використання логіки сутностей предметної області.
│   ├── vo                  # об'єкти-значення.
│   ├── README.md           # необов'язково - корисна для розробкиків пакету інформація.
│   └── ... 
└── ...

Ще більш спрощений варіант — взагалі відмовитись від директорії internal. Тоді загальна структура основної директорії проєкту виглядатиме наступним чином:

.
├── api
├── app               # налаштування запуску застосунку.
├── bin
├── build
├── config
├── inbound           # обробка вхідних даних.
├── outbound          # запити та повідомлення до сторонніх систем.
├── model             # сутності предметної логіки застосунку (Entity / Aggregate).
├── vo                # об'єкти-значення.
├── pkg
├── service           # логіка що не відноситься до предметної області.
├── scripts
├── tests
├── usecase           # використання логіки сутностей предметної області.
├── vendor
├── .gitignore
├── .gitmodules
├── go.work
├── LICENSE
├── main.go
└── README.md

Для варіанту з декількома предметними областями підійде наступна структура:

.  
├── ...  
├── internal  
│   ├── app                 # налаштування запуску застосунку, впровадження залежностей (Dependency Injection).
│   ├──          # предметна область 1 застосунку.
│   │   ├── model           # сутності предметної логіки застосунку. 
│   │   ├── inbound         # обробка вхідних даних. 
│   │   ├── outbound        # запити та повідомлення до сторонніх систем. 
│   │   ├── vo              # об'єкти-значення. 
│   │   ├── usecase         # використання логіки сутностей предметної області.
│   │   └── README.md       # необов'язково - корисна для розробкиків пакету інформація.
│   ├──          # предметна область 2 застосунку.
│   │   ├── model
│   │   ├── inbound
│   │   ├── outbound
│   │   ├── usecase         # використання логіки сутностей предметної області.
│   │   ├── vo
│   │   └── README.md
│   ├──          # предметна область N застосунку.
│   │   ├── model
│   │   ├── inbound
│   │   ├── outbound
│   │   ├── usecase         # використання логіки сутностей предметної області.
│   │   ├── vo
│   │   └── README.md
│   ├── dto.go
│   ├── service             # логіка, яка не відноситься до предметної області. 
│   └── ...
└── ...

model — ядро предметної області, пакет, де знаходяться усі предметні сутності. Важливою вимогою для гнучкої побудови застосунку із даним підходом є уникання в коді цього пакету та його підпакетів імпорту інших модулів даного застосунку (крім хіба що з pkg). Таким чином, пакет model (як і його підпакети) не залежитиме від будь-яких пакетів з inbound, outbound, app та їх підпакетів тощо. Це можна розглядати як виконання принципу інверсії залежностей (Dependency Inversion Principle) щодо того, що модулі верхніх рівнів не мають залежати від модулів нижніх рівнів. Також це допоможе уникнути циклічного імпорту.

service — логіка, що не відноситься до предметної області, а по своїй природі тяготіє до так званого Infrastructure Services і корисна лише в рамках даного застосунку:

  • генерація URL;
  • журналювання аудиту;
  • підготовка до збору аналітичної інформації;
  • тощо. Ідея виокремлення цієї директорії у тому, щоб функціональність її пакетів могли використовувати інші — без необхідності дублювання коду. На відміну від usecase, де логіка сфокусована на роботі з предметними сутностями, у service функції та методи працюють із типами з цього пакету або з об’єктами передачі даних. Останні можна використовувати для передачі даних із пакетів предметної області до функцій і методів у service й розміщувати їх у файлі internal/dto.go.

inbound — обробка вхідних даних

.  
├── ...  
├── inbound  
│   ├── http                    # обробка HTTP запитів.
│   │   ├── dto
│   │   │   ├── requests.go
│   │   │   └── responses.go
│   │   ├── handler_1.go
│   │   ├── handler_2.go
│   │   ├── handler_N.go
│   │   └── routes.go
│   ├── websocket               # комунікація з клієнтом через WebSocket.
│   ├── nats                    # обробка повідомлень із NATS.
│   ├── kafka                   # обробка повідомлень із Kafka.
│   ├── grpc                    # gRPC сервер.
│   └── ...
└── ...

Розбиття на декілька обробників запитів чи повідомлень (handlers) допомагає уникнути необхдіності впровадження (ін’єкції) зайвих залежностей при написанні інтеграційних тестів. Розділяти можна відповідно до типу сутностей або проєкцій (Projections / Read Models), з якими вони працюють.

Також можна винести їх в окремі пакети разом з відповідними об’єктами передачі даних (Data Transfer Object). Це дещо ускладнить загальну структуру та вкладеність пакетів, але значно спростить іменування типів у цих пакетах.

.  
├── ...  
├── inbound  
│   ├── http                
│   │   ├── settings
│   │   │   ├── handler.go
│   │   │   ├── requests.go
│   │   │   └── responses.go
│   │   ├── user
│   │   │   ├── handler.go
│   │   │   ├── requests.go
│   │   │   └── responses.go
│   │   ├── sso
│   │   │   ├── handler.go
│   │   │   ├── requests.go
│   │   │   └── responses.go
│   │   └── routes.go
│   └── ...
└── ...

outbound — робота зі сторонніми системами. В данному випадку вже застосунок виступає ініціатором з’єднання з іншими системами.

.  
├── ...  
├── outbound  
│   ├── http                    # виконання HTTP запитів.
│   │   ├── service_1
│   │   │   ├── client.go
│   │   │   ├── requests.go
│   │   │   └── responses.go
│   │   ├── service_2
│   │   │   ├── client.go
│   │   │   ├── requests.go
│   │   │   └── responses.go
│   │   └── service_N
│   │       ├── client.go
│   │       ├── requests.go
│   │       └── responses.go
│   ├── postgres               # запити до PostgreSQL.
│   │   ├── aggregate_1
│   │   │   ├── dto.go
│   │   │   ├── read.go
│   │   │   └── write.go
│   │   ├── aggregate_2
│   │   │   ├── dto.go
│   │   │   ├── read.go
│   │   │   └── write.go
│   │   └── aggregate_N
│   │   │   ├── dto.go
│   │   │   ├── read.go
│   │   │   └── write.go
│   │   └── projections
│   │       ├── dto.go
│   │       └── read.go
│   ├── redis                  # запити до Redis.
│   ├── kafka                  # відправка повідомлень у Kafka.
│   ├── nats                   # відправка повідомлень у NATS.
│   ├── grpc                   # gRPC клієнт.
│   └── ...
└── ...

README.md може містити інформацію для розробників, що стосується даного пакету: гайди, стандарти іменування та імпорту пакетів, вимоги щодо покриття тестами, форматування тощо.

Випадки, коли представлена структура буде не найкращим вибором:

  • застосунки з малою кодовою базою;
  • команди з високою плинністю кадрів;
  • POC, MVP і у вас немає проєкту з даною структурою, щоб взяти його за основу;
  • проєкти з жорсткими термінами доставки, де швидкість розробки критичніша за довгострокову підтримуваність;
  • мікросервіси з вузькою, чітко обмеженою функціональністю;
  • прості CRUD-застосунки без складної бізнес-логіки.

pkg — пропонуємо для експорту

Тут можна розмістити типи і їх методи та функції, які можуть бути перевикористані у багатьох місцях, наприклад:

  • дедуплікація елементів у слайсі;
  • пагінація і сортування;
  • робота з часом та моки для цього;
  • генерація унікальних токенів заданої довжини;
  • тощо.

Особливо корисним він буде у разі використання робочих просторів Go (Go workspaces).

Враховуючи специфіку даного пакету, загальна рекомендація щодо його написання — це добре покриття тестами. За допомогою юніт тестів не має бути складно досягти відмітки у 100%. Відмінністю від inbound/service є незалежність функціональності від конретного застосунку.

scripts — автоматизація запуску

Тут можна розмістити, наприклад, shell-скрипти як для запуску тестів, так і для компіляції застосунку. Як і у випадку зі build, наповнення директорії бажано узгоджувати зі спеціалістами за напрямком DevOps.

tests — інтеграційні тести

Тема написання та структурування інтеграційних тестів заслуговує окремої статті. Якщо ж коротко: під кожен окремий варіант комунікації із застосунком — окремий пакет з тестами.

.  
├── ...  
├── tests  
│   ├── user_create
│   │   ├── success
│   │   │   ├── request.json
│   │   │   └── response.json
│   │   ├── error_email_taken
│   │   │   ├── request.json
│   │   │   └── response.json
│   │   ├── ...
│   │   ├── setup.go
│   │   └── user_create_test.go
│   ├── user_delete
│   │   ├── success
│   │   │   ├── request.json
│   │   │   └── response.json
│   │   ├── error_not_found
│   │   │   ├── request.json
│   │   │   └── response.json
│   │   ├── ...
│   │   ├── setup.go
│   │   └── user_delete_test.go
│   └── ...
└── ...

vendor — копії зовнішніх пакетів

Наявність цієї директорії залежить від обраної стратегії керування залежностями. Найчастіше необхідність включення локальних копій у проєкт продиктована вимогами безпеки або особливостями налаштування процесів CI/CD.

Варто пам’ятати, що будь-яка структура пакетів Go не є догмою — це скоріше відправна точка, яку можна адаптувати під специфіку конкретного проєкту та потреби команди. Ключ до успіху — збалансувати складність архітектури з потребами бізнесу та можливостями команди на поточному етапі розвитку проєкту. Найкраща структура — це та, яка очевидна для всієї команди і не заважає швидкій доставці якісного продукту.

Сподіваюсь, було корисно та цікаво. Дякую за увагу!

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось26
До обраногоВ обраному18
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Чому не раст?

Логічне запитання коли бачиш цю чехарду з папками)

Логічно поставити булоб питання як розбиратися з messup залежностями вбудованої модулізації/пакетизацїї у відповідних мовах (rust friendly waving to node/python/perl)

як розбиратися з messup залежностями

Ніяк. Прийняти та нести цей хрест.

ну чехарда з папками — то віжн автора, так шо це субьективно, не всюди так (окрім internal, наприклад, або pkg)

internal, pkg та cmd також. К автору взагалі нема претензій. Тут справа в існуванні таких неоднозначностей.

ээ.. internal, pgk — неоднозначності? а в чому неоднозначність?) internal та pkg взагалі «стандарт» мови, бо гошка не дасть заімпортити код з інтерналу в паблік лібах.

Який він стандарт коли можливо класти пакеті в internal, а можливо і ні? А можливо в pkg або в корінь модуля і ніякої різниці. А коли вирішив щось перенести в internal/pkg, то привіт messup з путями.

бо гошка не дасть заімпортити код з інтерналу в паблік лібах.

Це більш схоже на костиль, а не на стандарт.

Який він стандарт коли можливо класти пакеті в internal, а можливо і ні?

ну як це, це ж як Private в Java — хочешь юзай, хочешь ні

messup з путями.

нууу, якшо ти кудись шось перекладаєш/міняєш, то це завжди месап незалежно від мови?)

Це більш схоже на костиль, а не на стандарт.

це як? це ж на рівні компілятора. В якому місці це костиль?) Це звичайна інкапсуляція

нууу, якшо ти кудись шось перекладаєш/міняєш, то це завжди месап незалежно від мови?)

Тому про Rust і почали говорити, тому що там нема такої маячні.

по той же причине почему не похапе, нода, джава и тд

От якби хтось написав статтю чому Rust краще за Go то було би корисно.
Я ще не пробував щоьс робити на Раст але про нього багато говорять. Він в тренді. Може варто перейти

не пробував щось робити на Раст

спробувати щось зробити невеличке (навіть дуже невеличке) для отримання власних вражень завжди можна, з відносно/умовно «нью-сі» які все ще досі в пошуку своєї ніши чи починають її шукати, то rust zig odin (з останнім має буде найлегше)

говорять

пропорційно тому скільки вкладають в щось, зараз схоже менше стали акцентуватися у пошуках нью-сі (одна з причин мабуть тому що вкладаються в ai) — відповідно і тренди виглядають чи міняються

а навіщо веб-сервер на расті робити?
а ще потім підтримувати і розвивати
задачі де доцільно використання Rust а не Goland зустрічаються менше 0.01% і уж точно не в малому та середньому бізнесі.
тоді вже давайте за С++ поговоримо — теж гарний вибір для написання сервера на ньому

Дякую за статтю, коли плануєте наступну частину ?

Поки пишу на Node.js (Typescript/Javascript). Go здається потроху набирає популярніть і створюється попит. Інколи помічаю Node.js + Go запити, тож думаю зануритися в світ Go лишнім не буде.

Влітку. Вона в роботі, пишу як є вільний час.

Дякую за статтю, коли плануєте наступну частину ?

Вже опублікована Частина 2: проєктування предметної області

Виглядає як трошки поглиблена, в сторону DDD, версія Golang Standards.

Дійсно, в огляді Golang Standards автори зазначають, що це -

звід паттернів програмування в екосистемі Go, які склалися історично.

Тому, на мою думку, є висока ймовірність, що структура багатьох проєктів на Go буде подібною до описаної там.

Додатково було б корисно додати пару рядків про workspaces чи vendoring.

Дякую за влучний коментар, додам.

Підписатись на коментарі