From 0449337ae734490229d5ee1fb55545423879d2e4 Mon Sep 17 00:00:00 2001 From: liver Date: Mon, 13 Apr 2026 08:14:09 +0300 Subject: [PATCH] import --- .gitignore | 33 +++ README.md | 198 +++++++++++++ Taskfile.yml | 23 ++ cmd/antipattern-broken/main.go | 99 +++++++ cmd/antipattern/main.go | 124 ++++++++ cmd/fx-di-broken/main.go | 115 ++++++++ cmd/fx-di-modules/api.go | 65 ++++ cmd/fx-di-modules/auth_module.go | 46 +++ cmd/fx-di-modules/infra.go | 91 ++++++ cmd/fx-di-modules/interfaces.go | 37 +++ cmd/fx-di-modules/main.go | 37 +++ cmd/fx-di-modules/notification_module.go | 36 +++ cmd/fx-di-modules/user_module.go | 36 +++ cmd/fx-di-shared/interfaces.go | 41 +++ cmd/fx-di-shared/main.go | 79 +++++ cmd/fx-di-shared/providers.go | 175 +++++++++++ cmd/fx-di/main.go | 358 +++++++++++++++++++++++ cmd/manual-di/main.go | 17 ++ cmd/wire-di-broken/main.go | 5 + cmd/wire-di-broken/types.go | 98 +++++++ cmd/wire-di-broken/wire.go | 32 ++ cmd/wire-di/main.go | 28 ++ cmd/wire-di/wire.go | 115 ++++++++ cmd/wire-di/wire_gen.go | 49 ++++ go.mod | 15 + go.sum | 29 ++ internal/api/server.go | 83 ++++++ internal/app/app.go | 63 ++++ internal/app/di.go | 194 ++++++++++++ internal/cache/cache.go | 38 +++ internal/config/config.go | 25 ++ internal/database/database.go | 64 ++++ internal/events/bus.go | 32 ++ internal/repository/notification.go | 26 ++ internal/repository/session.go | 33 +++ internal/repository/user.go | 26 ++ internal/service/auth.go | 66 +++++ internal/service/notification.go | 43 +++ internal/service/user.go | 52 ++++ 39 files changed, 2726 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Taskfile.yml create mode 100644 cmd/antipattern-broken/main.go create mode 100644 cmd/antipattern/main.go create mode 100644 cmd/fx-di-broken/main.go create mode 100644 cmd/fx-di-modules/api.go create mode 100644 cmd/fx-di-modules/auth_module.go create mode 100644 cmd/fx-di-modules/infra.go create mode 100644 cmd/fx-di-modules/interfaces.go create mode 100644 cmd/fx-di-modules/main.go create mode 100644 cmd/fx-di-modules/notification_module.go create mode 100644 cmd/fx-di-modules/user_module.go create mode 100644 cmd/fx-di-shared/interfaces.go create mode 100644 cmd/fx-di-shared/main.go create mode 100644 cmd/fx-di-shared/providers.go create mode 100644 cmd/fx-di/main.go create mode 100644 cmd/manual-di/main.go create mode 100644 cmd/wire-di-broken/main.go create mode 100644 cmd/wire-di-broken/types.go create mode 100644 cmd/wire-di-broken/wire.go create mode 100644 cmd/wire-di/main.go create mode 100644 cmd/wire-di/wire.go create mode 100644 cmd/wire-di/wire_gen.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/server.go create mode 100644 internal/app/app.go create mode 100644 internal/app/di.go create mode 100644 internal/cache/cache.go create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/events/bus.go create mode 100644 internal/repository/notification.go create mode 100644 internal/repository/session.go create mode 100644 internal/repository/user.go create mode 100644 internal/service/auth.go create mode 100644 internal/service/notification.go create mode 100644 internal/service/user.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d296e95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +.idea/ +.vscode/ +bin/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..beb1f49 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +## 👋 Привет! Я Олег Козырев + +Staff Golang Engineer · Ex Ozon, Avito, Tinkoff + +📺 [YouTube](https://www.youtube.com/@olezhek28go) · 💬 [Telegram](https://t.me/olezhek28go) + +--- + +# Dependency Injection в Go: от антипаттернов к элегантным решениям + +Репозиторий с примерами различных подходов к внедрению зависимостей (DI) в Go-приложениях. + +Все примеры используют **одну и ту же бизнес-логику** из `internal/` — меняется только способ сборки зависимостей. + +## Структура проекта + +``` +internal/ +├── config/ — конфигурация приложения (DSN, Redis, HTTP-адрес) +├── database/ — абстракция БД (интерфейс DB + реализация) +├── cache/ — абстракция кэша (интерфейс Cache + Redis-заглушка) +├── events/ — шина событий (Publish/Subscribe) +├── repository/ — репозитории (user, session, notification) +├── service/ — сервисы (auth, user, notification) +├── api/ — HTTP-хендлер и роутинг +└── app/ — сборка приложения + кастомный DI-контейнер + +cmd/ +├── antipattern/ — ручная сборка (проблема) +├── antipattern-broken/ — ручная сборка (сломанная) +├── wire-di/ — Google Wire +├── wire-di-broken/ — Wire + циклические зависимости +├── fx-di/ — Uber Fx (свои интерфейсы у каждого потребителя) +├── fx-di-broken/ — Fx + циклические зависимости +├── fx-di-shared/ — Uber Fx (общие интерфейсы) +├── fx-di-modules/ — Uber Fx (модульная организация) +└── manual-di/ — кастомный lazy-контейнер (решение) +``` + +## Ключевой паттерн: интерфейс определяет потребитель + +Каждый сервис и репозиторий определяет **свой собственный интерфейс** для каждой зависимости — только с теми методами, которые ему нужны. Это Go-идиома и принцип разделения интерфейсов (ISP). + +Например, `UserRepo` определяет интерфейс `UserDB` с нужными ему методами, а `SessionRepo` — свой `SessionDB`. Оба реализуются одним и тем же `*database.db`. + +## Примеры + +### 1. `cmd/antipattern/` — Ручная сборка зависимостей + +Все зависимости создаются вручную в `main()` в строгом порядке. Работает, но: +- порядок инициализации жёстко зафиксирован +- при добавлении зависимости легко ошибиться +- код main() растёт линейно с количеством компонентов + +```bash +go run ./cmd/antipattern/ +``` + +### 2. `cmd/antipattern-broken/` — Сломанная ручная сборка + +Тот же код, но строки переставлены местами. **Компилируется без ошибок**, но падает в рантайме с nil pointer dereference — кэш и сервисы используются до инициализации. + +Показывает, почему ручная сборка хрупкая: компилятор не ловит ошибки порядка инициализации. + +```bash +go run ./cmd/antipattern-broken/ +# curl http://localhost:8080/users/me → panic: nil pointer +# curl http://localhost:8080/health → 200 OK (зависимости не задействованы) +``` + +### 3. `cmd/wire-di/` — Google Wire (кодогенерация) + +Разработчик описывает провайдеры и привязки в `wire.go`, Wire генерирует корректный код инициализации в `wire_gen.go`. + +Решает проблему порядка, но: +- 18 вызовов `wire.Bind()` для маппинга типов → интерфейсы +- eager-инициализация (всё создаётся сразу при старте) +- когда каждый потребитель определяет свой интерфейс, количество Bind растёт быстро + +```bash +task generate-wire # генерация кода +go run ./cmd/wire-di/ +``` + +### 4. `cmd/wire-di-broken/` — Wire и циклические зависимости + +Демонстрирует цикл: `EventBus → NotificationService → UserService → AuthService → EventBus`. + +Wire обнаруживает цикл **на этапе генерации** и отказывается генерировать код. + +```bash +task generate-wire-broken # ошибка: cycle for *EventBus +``` + +### 5. `cmd/fx-di/` — Uber Fx (свои интерфейсы у каждого потребителя) + +Рантайм DI-контейнер на рефлексии. Работает, но когда каждый потребитель определяет свой интерфейс — требует много бойлерплейта: +- `fx.Out`/`fx.In` структуры для каждого провайдера +- именованные зависимости через struct-теги +- ~360 строк кода + +Показывает, что Fx заточен под «один тип — один интерфейс» (как в Java/Spring), и при Go-подходе к интерфейсам становится громоздким. + +```bash +go run ./cmd/fx-di/ +``` + +### 6. `cmd/fx-di-broken/` — Fx и циклические зависимости + +Тот же цикл, что и в `wire-di-broken`, но обнаруживается **в рантайме** при старте приложения. + +```bash +go run ./cmd/fx-di-broken/ # ошибка: cannot depend on the provided type +``` + +### 7. `cmd/fx-di-shared/` — Uber Fx (общие интерфейсы) + +Fx с «Java-style» архитектурой: один общий интерфейс на тип. Код значительно проще (~56 строк), lifecycle-хуки для graceful старта и остановки. + +Показывает, что Fx отлично работает, когда интерфейсы общие, а не определяются каждым потребителем отдельно. + +```bash +go run ./cmd/fx-di-shared/ +``` + +### 8. `cmd/fx-di-modules/` — Uber Fx (модульная организация) + +Продвинутый пример с `fx.Module`: каждый домен (auth, user, notification) — отдельный модуль в отдельном файле. + +Подход для больших команд: каждая команда редактирует только свой модуль, конфликтов в `main.go` нет. + +```bash +go run ./cmd/fx-di-modules/ +``` + +### 9. `cmd/manual-di/` — Кастомный lazy-контейнер (решение) + +Финальное решение: кастомный DI-контейнер из `internal/app/di.go` с ленивой инициализацией. + +- ~17 строк в main, ~50 строк в контейнере +- зависимости создаются при первом обращении (lazy) +- рекурсивное разрешение зависимостей — порядок не важен +- никакой магии, рефлексии и кодогенерации +- ошибки ловятся на этапе компиляции + +```bash +go run ./cmd/manual-di/ +``` + +## Сравнение подходов + +| Пример | Подход | Обнаружение ошибок | Бойлерплейт | Циклы | +|--------|--------|--------------------|-------------|-------| +| `antipattern` | Ручная сборка | Рантайм (nil pointer) | Нет | Не обнаруживает | +| `wire-di` | Кодогенерация | Генерация кода | 18 Bind() | Ловит при генерации | +| `fx-di` | Рефлексия (runtime) | Рантайм (старт) | fx.In/Out структуры | Ловит при старте | +| `fx-di-shared` | Рефлексия (runtime) | Рантайм (старт) | Минимальный | Ловит при старте | +| `fx-di-modules` | Рефлексия (runtime) | Рантайм (старт) | Минимальный | Ловит при старте | +| `manual-di` | Lazy-контейнер | Компиляция | Нет | Решает через lazy | + +## Требования + +- Go 1.26+ +- [Task](https://taskfile.dev/) — таск-раннер (замена Make) + +### Установка Task + +```bash +# macOS +brew install go-task + +# Linux (snap) +sudo snap install task --classic + +# Или через go install +go install github.com/go-task/task/v3/cmd/task@latest +``` + +## Запуск + +```bash +# Установка зависимостей +go mod download + +# Установка Wire (для примеров с кодогенерацией) +task install-wire + +# Запуск любого примера +go run ./cmd/<имя-примера>/ +``` + +### Доступные task-команды + +```bash +task install-wire # установить Wire локально в bin/ +task generate-wire # сгенерировать wire_gen.go для cmd/wire-di/ +task generate-wire-broken # попытка генерации с циклом (упадёт с ошибкой) +``` diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..74ff43d --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,23 @@ +version: '3' + +vars: + LOCAL_BIN: '{{.ROOT_DIR}}/bin' + WIRE_VERSION: v0.7.0 + +tasks: + install-wire: + desc: Установить Wire локально в bin/ + cmds: + - GOBIN={{.LOCAL_BIN}} go install github.com/google/wire/cmd/wire@{{.WIRE_VERSION}} + + generate-wire: + desc: Сгенерировать wire_gen.go + deps: [install-wire] + cmds: + - '{{.LOCAL_BIN}}/wire ./cmd/wire-di/' + + generate-wire-broken: + desc: Попытка сгенерировать Wire с циклической зависимостью (упадёт) + deps: [install-wire] + cmds: + - '{{.LOCAL_BIN}}/wire ./cmd/wire-di-broken/' diff --git a/cmd/antipattern-broken/main.go b/cmd/antipattern-broken/main.go new file mode 100644 index 0000000..93fd345 --- /dev/null +++ b/cmd/antipattern-broken/main.go @@ -0,0 +1,99 @@ +package main + +// Сломанный антипаттерн: тот же код, но строки переставлены. +// +// Сценарий: два разработчика параллельно добавляли сервисы, +// при мерже строки перемешались. Код скомпилировался. +// go build прошёл. CI прошёл (юнит-тесты не трогают main.go). +// Деплой. Первый запрос — паника. +// +// Запустите: go run ./cmd/antipattern-broken/ +// curl http://localhost:8080/health → 200 ok (всё «работает») +// curl http://localhost:8080/users/me → nil pointer dereference (ПАНИКА) +// +// /health не трогает зависимости — поэтому проходит. +// /users/me вызывает userService.GetProfile() → authService.ValidateToken() → nil! + +import ( + "log/slog" + "net/http" + "os" + + "github.com/olezhek28/di-demo/internal/api" + "github.com/olezhek28/di-demo/internal/cache" + "github.com/olezhek28/di-demo/internal/config" + "github.com/olezhek28/di-demo/internal/database" + "github.com/olezhek28/di-demo/internal/events" + "github.com/olezhek28/di-demo/internal/repository" + "github.com/olezhek28/di-demo/internal/service" +) + +func main() { + cfg := config.New() + + // === Инфраструктура === + + db, err := database.New(cfg.DSN) + if err != nil { + slog.Error("не удалось подключиться к БД", "err", err) + os.Exit(1) + } + + // === Репозитории и сервисы === + // После мержа строки перемешались. Всё компилируется — Go проверяет + // только что переменная ОБЪЯВЛЕНА, а не что внутри валидное значение. + + var ( + c cache.Cache + userRepo repository.UserRepo + sessionRepo repository.SessionRepo + notificationRepo repository.NotificationRepo + eventBus events.EventBus + authService service.AuthService + userService service.UserService + notificationService service.NotificationService + ) + + userRepo = repository.NewUserRepo(db) + notificationRepo = repository.NewNotificationRepo(db) + + // ⚠️ ПРОБЛЕМА 1: sessionRepo создаётся ДО cache. + // cache = nil → sessionRepo хранит nil вместо кэша. + sessionRepo = repository.NewSessionRepo(db, c) // c == nil! + + c = cache.New(cfg.RedisAddr) // поздно — sessionRepo уже создан с nil + + eventBus = events.NewEventBus() + + // ⚠️ ПРОБЛЕМА 2: userService создаётся ДО authService. + // authService = nil → userService хранит nil вместо сервиса авторизации. + userService = service.NewUserService(userRepo, authService, eventBus) // authService == nil! + + authService = service.NewAuthService(userRepo, sessionRepo, c, eventBus) // поздно + + notificationService = service.NewNotificationService( + notificationRepo, + userService, + eventBus, + ) + + // === Сервер === + // Всё скомпилировалось. go vet пройдёт. Линтер пройдёт. + // Но внутри userService лежит nil вместо authService. + // Первый запрос, который дёрнет авторизацию — паника. + + handler := api.NewHandler(userService, authService, notificationService) + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: handler.Routes(), + } + + slog.Info("сервер запущен", "addr", cfg.HTTPAddr) + + if err = srv.ListenAndServe(); err != nil { + slog.Error("ошибка сервера", "err", err) + } + + _ = db.Close() + _ = c.Close() +} diff --git a/cmd/antipattern/main.go b/cmd/antipattern/main.go new file mode 100644 index 0000000..c672644 --- /dev/null +++ b/cmd/antipattern/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "log/slog" + "net/http" + "os" + + "github.com/olezhek28/di-demo/internal/api" + "github.com/olezhek28/di-demo/internal/cache" + "github.com/olezhek28/di-demo/internal/config" + "github.com/olezhek28/di-demo/internal/database" + "github.com/olezhek28/di-demo/internal/events" + "github.com/olezhek28/di-demo/internal/repository" + "github.com/olezhek28/di-demo/internal/service" +) + +func main() { + cfg := config.New() + + // === Инфраструктура === + + db, err := database.New(cfg.DSN) + if err != nil { + slog.Error("не удалось подключиться к БД", "err", err) + os.Exit(1) + } + + c := cache.New(cfg.RedisAddr) // ⚠️ если перенести ниже sessionRepo — nil pointer + + // === Репозитории === + + userRepo := repository.NewUserRepo(db) + sessionRepo := repository.NewSessionRepo(db, c) // ⚠️ зависит от db И cache + notificationRepo := repository.NewNotificationRepo(db) // ⚠️ зависит от db + + // === EventBus === + // Пока EventBus — простой брокер без зависимостей, всё нормально. + // Проблемы начнутся когда EventBus понадобится зависимость (см. комментарии ниже). + + eventBus := events.NewEventBus() + + // === Сервисы === + // Тут начинается самое интересное — перекрёстные зависимости. + // Порядок критичен: authService нужен для userService, + // а userService нужен для notificationService. + // Переставь любые два — и привет, nil pointer. + + authService := service.NewAuthService(userRepo, sessionRepo, c, eventBus) // ⚠️ зависит от repos, cache, eventBus + userService := service.NewUserService(userRepo, authService, eventBus) // ⚠️ зависит от authService — порядок! + notificationService := service.NewNotificationService( // ⚠️ зависит от userService — порядок! + notificationRepo, + userService, + eventBus, + ) + + // === Сервер === + + handler := api.NewHandler(userService, authService, notificationService) + srv := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: handler.Routes(), + } + + // ===================================================================== + // === НОВАЯ ЗАДАЧА: EventBus должен сам доставлять уведомления === + // ===================================================================== + // + // Продакт говорит: EventBus должен не просто пересылать события, + // а сам доставлять уведомления. Нужно чтобы EventBus вызывал + // NotificationService напрямую. + // + // Сейчас EventBus — простой брокер: + // + // type EventBus interface { + // Publish(event string) + // Subscribe(event string, handler func()) + // } + // func NewEventBus() EventBus + // + // А нужно так: + // + // type EventBus interface { + // Publish(event string) + // Subscribe(event string, handler func()) + // } + // func NewEventBus(notifications NotificationSender) EventBus + // + // Казалось бы — добавили одно поле. Но теперь зависимости: + // + // EventBus → нужен NotificationService + // NotificationService → нужен UserService + // UserService → нужен AuthService + // AuthService → нужен EventBus ← ЦИКЛ + // + // Куда воткнуть EventBus в этот main.go? + // + // Попытка 1: создать EventBus после notificationService + // eventBus := events.NewEventBus(notificationService) + // ...но authService и userService уже созданы с eventBus выше (строки 48-49). + // Поздно — они уже инициализированы со старым eventBus. + // + // Попытка 2: создать EventBus до сервисов, передать nil + // eventBus := events.NewEventBus(nil) // NotificationService ещё не существует + // ...потом: eventBus.SetNotificationService(notificationService) + // Костыль с мутабельным состоянием. И гонка: кто-то вызовет + // EventBus до SetNotificationService — получит nil pointer. + // + // Попытка 3: переписать всю цепочку + // Разорвать одну из зависимостей, переделать интерфейсы... + // Пол дня работы. И страшно — вдруг сломаю то что уже работает. + // + // Вот она — хрупкость в действии. + // Одна зависимость с одним полем — и вся инициализация встала колом. + // ===================================================================== + + slog.Info("всё хорошо, сервер запускается") + + if err = srv.ListenAndServe(); err != nil { + slog.Error("ошибка сервера", "err", err) + } + + _ = db.Close() + _ = c.Close() +} diff --git a/cmd/fx-di-broken/main.go b/cmd/fx-di-broken/main.go new file mode 100644 index 0000000..ccd326f --- /dev/null +++ b/cmd/fx-di-broken/main.go @@ -0,0 +1,115 @@ +package main + +// Пример циклической зависимости в Fx. +// +// Граф зависимостей замкнулся в кольцо: +// +// NewEventBus(NotificationSender) — EventBus хочет отправлять уведомления +// NewNotificationService(Users, EventSubscriber) — уведомления хотят знать email +// NewUserService(Auth) — пользователи хотят проверять токен +// NewAuthService(EventPublisher) — авторизация хочет публиковать события +// ↑ ↓ +// └──────── EventBus реализует EventPublisher ─────────┘ +// +// Ни один объект нельзя создать первым — каждому нужен ещё не созданный. +// +// Wire ловит такой цикл при кодогенерации (до запуска). +// Fx ловит его в рантайме (при старте приложения). +// Результат одинаковый — приложение не запустится. +// +// Запустите: go run ./cmd/fx-di-broken/ +// Результат: ошибка "In: cannot depend on the provided type" + +import ( + "log/slog" + + "go.uber.org/fx" +) + +// === Интерфейсы === + +type NotificationSender interface { + Send(msg string) +} + +type EventPublisher interface { + Publish(event string) +} + +type EventSubscriber interface { + Subscribe(event string, handler func()) +} + +type Auth interface { + Validate(token string) bool +} + +type Users interface { + GetEmail(userID int) string +} + +// === Реализации === + +type EventBus struct { + notifications NotificationSender // ← вот она, циклическая зависимость +} + +func NewEventBus(notifications NotificationSender) *EventBus { + slog.Info("шина событий создана") + return &EventBus{notifications: notifications} +} + +func (b *EventBus) Publish(event string) {} +func (b *EventBus) Subscribe(event string, handler func()) {} + +type AuthService struct { + events EventPublisher +} + +func NewAuthService(events EventPublisher) *AuthService { + slog.Info("сервис авторизации создан") + return &AuthService{events: events} +} + +func (s *AuthService) Validate(token string) bool { return true } + +type UserService struct { + auth Auth +} + +func NewUserService(auth Auth) *UserService { + slog.Info("сервис пользователей создан") + return &UserService{auth: auth} +} + +func (s *UserService) GetEmail(userID int) string { return "user@example.com" } + +type NotificationService struct { + users Users + events EventSubscriber +} + +func NewNotificationService(users Users, events EventSubscriber) *NotificationService { + slog.Info("сервис уведомлений создан") + return &NotificationService{users: users, events: events} +} + +func (s *NotificationService) Send(msg string) { + slog.Info("уведомление отправлено", "msg", msg) +} + +// === Fx === + +func main() { + fx.New( + fx.Provide( + fx.Annotate(NewEventBus, fx.As(new(EventPublisher)), fx.As(new(EventSubscriber))), + fx.Annotate(NewAuthService, fx.As(new(Auth))), + fx.Annotate(NewUserService, fx.As(new(Users))), + fx.Annotate(NewNotificationService, fx.As(new(NotificationSender))), + ), + fx.Invoke(func(pub EventPublisher) { + slog.Info("всё запустилось") // сюда не дойдём + }), + ).Run() +} diff --git a/cmd/fx-di-modules/api.go b/cmd/fx-di-modules/api.go new file mode 100644 index 0000000..96601ea --- /dev/null +++ b/cmd/fx-di-modules/api.go @@ -0,0 +1,65 @@ +package main + +// API-слой — HTTP-хендлер и сервер. +// Собирает сервисы из всех доменов в единый HTTP-интерфейс. + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/http" + + "go.uber.org/fx" +) + +var APIModule = fx.Module("api", + fx.Provide(newHandler), + fx.Invoke(startHTTPServer), +) + +// --- Handler --- + +type handler struct { + userService UserService + authService AuthService + notificationService NotificationService +} + +func newHandler(userService UserService, authService AuthService, notificationService NotificationService) *handler { + return &handler{userService: userService, authService: authService, notificationService: notificationService} +} + +func (h *handler) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") + }) + return mux +} + +// --- HTTP Server --- + +func startHTTPServer(lc fx.Lifecycle, h *handler) { + srv := &http.Server{ + Addr: ":8080", + Handler: h.routes(), + } + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + ln, err := net.Listen("tcp", srv.Addr) + if err != nil { + return err + } + slog.Info("HTTP-сервер запущен", "addr", srv.Addr) + go srv.Serve(ln) + return nil + }, + OnStop: func(ctx context.Context) error { + slog.Info("HTTP-сервер останавливается...") + return srv.Shutdown(ctx) + }, + }) +} diff --git a/cmd/fx-di-modules/auth_module.go b/cmd/fx-di-modules/auth_module.go new file mode 100644 index 0000000..6de13b7 --- /dev/null +++ b/cmd/fx-di-modules/auth_module.go @@ -0,0 +1,46 @@ +package main + +// Домен авторизации. +// Этим файлом владеет команда авторизации. +// +// Что внутри: сессии, проверка токенов, логин/логаут. +// Что НЕ знает: как устроены уведомления, как устроены профили. +// +// Добавить TokenValidator? Правим ТОЛЬКО этот файл: +// 1. Добавляем тип + конструктор +// 2. Добавляем fx.Provide(newTokenValidator) в AuthModule +// 3. main.go не трогаем — ноль мерж-конфликтов + +import "go.uber.org/fx" + +var AuthModule = fx.Module("auth", + fx.Provide( + newSessionRepo, + newAuthService, + // newTokenValidator, ← команда добавит сюда, не в main.go + ), +) + +// --- SessionRepo --- + +type sessionRepo struct { + db DB + cache Cache +} + +func newSessionRepo(db DB, cache Cache) SessionRepository { + return &sessionRepo{db: db, cache: cache} +} + +// --- AuthService --- + +type authServiceImpl struct { + userRepo UserRepository + sessionRepo SessionRepository + cache Cache + events EventBus +} + +func newAuthService(userRepo UserRepository, sessionRepo SessionRepository, cache Cache, events EventBus) AuthService { + return &authServiceImpl{userRepo: userRepo, sessionRepo: sessionRepo, cache: cache, events: events} +} diff --git a/cmd/fx-di-modules/infra.go b/cmd/fx-di-modules/infra.go new file mode 100644 index 0000000..cd49d40 --- /dev/null +++ b/cmd/fx-di-modules/infra.go @@ -0,0 +1,91 @@ +package main + +// Общая инфраструктура: БД, кэш, шина событий. +// Этим владеет платформенная команда (или DevOps). +// Бизнес-команды используют, но не трогают. + +import ( + "context" + "log/slog" + + "go.uber.org/fx" +) + +var InfraModule = fx.Module("infra", + fx.Provide(newDB, newCache, newEventBus), +) + +// --- DB --- + +type dbImpl struct{ dsn string } + +func (db *dbImpl) Query(query string) error { return nil } +func (db *dbImpl) QueryRow(query string) error { return nil } +func (db *dbImpl) Exec(query string) error { return nil } +func (db *dbImpl) BeginTx() error { return nil } +func (db *dbImpl) BulkInsert(query string, args ...any) error { return nil } +func (db *dbImpl) Close() error { + slog.Info("БД: соединение закрыто") + return nil +} + +func newDB(lc fx.Lifecycle) (DB, error) { + db := &dbImpl{dsn: "postgres://localhost:5432/demo"} + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + slog.Info("БД: подключение установлено", "dsn", db.dsn) + return nil + }, + OnStop: func(ctx context.Context) error { + return db.Close() + }, + }) + return db, nil +} + +// --- Cache --- + +type cacheImpl struct{} + +func (c *cacheImpl) Get(key string) (string, error) { return "", nil } +func (c *cacheImpl) Set(key, value string) error { return nil } +func (c *cacheImpl) Close() error { + slog.Info("Кэш: соединение закрыто") + return nil +} + +func newCache(lc fx.Lifecycle) Cache { + c := &cacheImpl{} + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + slog.Info("Кэш: подключение установлено") + return nil + }, + OnStop: func(ctx context.Context) error { + return c.Close() + }, + }) + return c +} + +// --- EventBus --- + +type eventBusImpl struct{} + +func (b *eventBusImpl) Publish(event string) { slog.Info("событие", "event", event) } +func (b *eventBusImpl) Subscribe(event string, handler func()) { slog.Info("подписка", "event", event) } + +func newEventBus(lc fx.Lifecycle) EventBus { + bus := &eventBusImpl{} + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + slog.Info("EventBus: запущен") + return nil + }, + OnStop: func(ctx context.Context) error { + slog.Info("EventBus: остановлен") + return nil + }, + }) + return bus +} diff --git a/cmd/fx-di-modules/interfaces.go b/cmd/fx-di-modules/interfaces.go new file mode 100644 index 0000000..1a4ebf6 --- /dev/null +++ b/cmd/fx-di-modules/interfaces.go @@ -0,0 +1,37 @@ +package main + +// Общие интерфейсы — один пакет, все потребители импортируют. +// Как в Java/Spring: один UserRepository на весь проект. +// fx.Module работает красиво именно с таким подходом. + +// Инфраструктура + +type DB interface { + Query(query string) error + QueryRow(query string) error + Exec(query string) error + BeginTx() error + BulkInsert(query string, args ...any) error +} + +type Cache interface { + Get(key string) (string, error) + Set(key, value string) error +} + +type EventBus interface { + Publish(event string) + Subscribe(event string, handler func()) +} + +// Репозитории + +type UserRepository interface{} +type SessionRepository interface{} +type NotificationRepository interface{} + +// Сервисы + +type AuthService interface{} +type UserService interface{} +type NotificationService interface{} diff --git a/cmd/fx-di-modules/main.go b/cmd/fx-di-modules/main.go new file mode 100644 index 0000000..f503bbb --- /dev/null +++ b/cmd/fx-di-modules/main.go @@ -0,0 +1,37 @@ +package main + +// fx.Module — модульный монолит с Fx. +// +// Каждый бизнес-домен живёт в своём файле: +// infra.go — БД, кэш, шина событий (общая инфраструктура) +// auth_module.go — авторизация: сессии + проверка токенов +// user_module.go — профили пользователей +// notification_module.go — уведомления +// api.go — HTTP-хендлер и сервер +// +// Зачем это нужно: +// Команда авторизации добавляет TokenValidator → правит ТОЛЬКО auth_module.go. +// main.go не трогает. Конфликтов при мерже с командой уведомлений — ноль. +// +// Без fx.Module все провайдеры в одном main.go. Три команды правят один файл. +// Мерж-конфликты на каждом PR. +// +// Сравните с cmd/fx-di-shared/ — тот же код, но всё в одном файле. + +import "go.uber.org/fx" + +func main() { + fx.New( + // Общая инфраструктура + InfraModule, + + // Бизнес-домены — каждая команда владеет своим файлом. + // Добавить новый домен = новый файл + одна строка здесь. + AuthModule, + UserModule, + NotificationModule, + + // API — собирает сервисы в HTTP-хендлер + APIModule, + ).Run() +} diff --git a/cmd/fx-di-modules/notification_module.go b/cmd/fx-di-modules/notification_module.go new file mode 100644 index 0000000..f8fbc40 --- /dev/null +++ b/cmd/fx-di-modules/notification_module.go @@ -0,0 +1,36 @@ +package main + +// Домен уведомлений. +// Этим файлом владеет команда уведомлений. +// +// Что внутри: отправка уведомлений, шаблоны, каналы доставки. +// Что НЕ знает: как устроена авторизация, как устроены профили внутри. + +import "go.uber.org/fx" + +var NotificationModule = fx.Module("notification", + fx.Provide( + newNotificationRepo, + newNotificationService, + ), +) + +// --- NotificationRepo --- + +type notificationRepo struct{ db DB } + +func newNotificationRepo(db DB) NotificationRepository { + return ¬ificationRepo{db: db} +} + +// --- NotificationService --- + +type notificationServiceImpl struct { + notificationRepo NotificationRepository + userService UserService + events EventBus +} + +func newNotificationService(notificationRepo NotificationRepository, userService UserService, events EventBus) NotificationService { + return ¬ificationServiceImpl{notificationRepo: notificationRepo, userService: userService, events: events} +} diff --git a/cmd/fx-di-modules/user_module.go b/cmd/fx-di-modules/user_module.go new file mode 100644 index 0000000..e35f17e --- /dev/null +++ b/cmd/fx-di-modules/user_module.go @@ -0,0 +1,36 @@ +package main + +// Домен профилей пользователей. +// Этим файлом владеет команда профилей. +// +// Что внутри: пользователи, профили, настройки. +// Что НЕ знает: как устроена авторизация внутри, как устроены уведомления. + +import "go.uber.org/fx" + +var UserModule = fx.Module("user", + fx.Provide( + newUserRepo, + newUserService, + ), +) + +// --- UserRepo --- + +type userRepo struct{ db DB } + +func newUserRepo(db DB) UserRepository { + return &userRepo{db: db} +} + +// --- UserService --- + +type userServiceImpl struct { + userRepo UserRepository + authService AuthService + events EventBus +} + +func newUserService(userRepo UserRepository, authService AuthService, events EventBus) UserService { + return &userServiceImpl{userRepo: userRepo, authService: authService, events: events} +} diff --git a/cmd/fx-di-shared/interfaces.go b/cmd/fx-di-shared/interfaces.go new file mode 100644 index 0000000..669da6e --- /dev/null +++ b/cmd/fx-di-shared/interfaces.go @@ -0,0 +1,41 @@ +package main + +// ===================================================== +// Общие интерфейсы — один пакет, все потребители импортируют. +// Как в Java/Spring: один UserRepository на весь проект. +// +// Каждый интерфейс определён ОДИН РАЗ и переиспользуется всеми. +// В отличие от Go-идиомы, где каждый потребитель определяет свой. +// ===================================================== + +// Инфраструктура + +type DB interface { + Query(query string) error + QueryRow(query string) error + Exec(query string) error + BeginTx() error + BulkInsert(query string, args ...any) error +} + +type Cache interface { + Get(key string) (string, error) + Set(key, value string) error +} + +type EventBus interface { + Publish(event string) + Subscribe(event string, handler func()) +} + +// Репозитории + +type UserRepository interface{} +type SessionRepository interface{} +type NotificationRepository interface{} + +// Сервисы + +type AuthService interface{} +type UserService interface{} +type NotificationService interface{} diff --git a/cmd/fx-di-shared/main.go b/cmd/fx-di-shared/main.go new file mode 100644 index 0000000..55770c5 --- /dev/null +++ b/cmd/fx-di-shared/main.go @@ -0,0 +1,79 @@ +package main + +// Fx с ОБЩИМИ интерфейсами — чистый и простой пример. +// +// Сравните: +// cmd/fx-di/main.go — consumer-defined interfaces (Go-идиома) → 330 строк +// cmd/fx-di-shared/ — shared interfaces (Java-подход) → вот этот файл +// +// Разница — только в подходе к определению интерфейсов. +// Когда один тип → один интерфейс, Fx работает без fx.Out/fx.In/named-тегов. +// +// Это НЕ рекомендация так писать на Go. Это демонстрация того, +// под какую архитектуру Fx был спроектирован. +// +// Lifecycle: +// Fx управляет жизненным циклом компонентов через хуки OnStart/OnStop. +// При запуске — вызывает OnStart в порядке зависимостей (сначала DB, потом сервисы). +// При остановке — вызывает OnStop в обратном порядке (сначала сервер, потом DB). +// Отправьте SIGINT (Ctrl+C) чтобы увидеть порядок остановки в логах. +// +// Хотите увидеть как это масштабируется на несколько команд? +// Смотрите cmd/fx-di-modules/ — тот же код, но каждый домен в своём файле. + +import ( + "context" + "log/slog" + "net" + "net/http" + + "go.uber.org/fx" +) + +func main() { + fx.New( + fx.Provide( + newDB, + newCache, + newEventBus, + newUserRepo, + newSessionRepo, + newNotificationRepo, + newAuthService, + newUserService, + newNotificationService, + newHandler, + ), + + fx.Invoke(startHTTPServer), + + // Вот и всё. 10 провайдеров, 1 invoke. Fx разрулил сам. + // Потому что один тип → один интерфейс → нет неоднозначности. + // + // А lifecycle хуки (OnStart/OnStop) — внутри провайдеров newDB, newCache + // и startHTTPServer. Fx сам вызовет их в правильном порядке. + ).Run() +} + +func startHTTPServer(lc fx.Lifecycle, h *handler) { + srv := &http.Server{ + Addr: ":8080", + Handler: h.routes(), + } + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + ln, err := net.Listen("tcp", srv.Addr) + if err != nil { + return err + } + slog.Info("HTTP-сервер запущен", "addr", srv.Addr) + go srv.Serve(ln) + return nil + }, + OnStop: func(ctx context.Context) error { + slog.Info("HTTP-сервер останавливается...") + return srv.Shutdown(ctx) + }, + }) +} diff --git a/cmd/fx-di-shared/providers.go b/cmd/fx-di-shared/providers.go new file mode 100644 index 0000000..9e0af63 --- /dev/null +++ b/cmd/fx-di-shared/providers.go @@ -0,0 +1,175 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "go.uber.org/fx" +) + +// ===================================================== +// Реализации — конструкторы возвращают интерфейсы. +// Один тип → один интерфейс → Fx разруливает автоматически. +// +// DB и Cache используют fx.Lifecycle для регистрации хуков: +// OnStart — пинг/проверка соединения при запуске +// OnStop — корректное закрытие при остановке +// +// Fx сам вызывает хуки в правильном порядке: +// Старт: DB → Cache → ... → HTTP-сервер +// Остановка: HTTP-сервер → ... → Cache → DB +// ===================================================== + +// --- Инфраструктура --- + +type dbImpl struct{ dsn string } + +func (db *dbImpl) Query(query string) error { return nil } +func (db *dbImpl) QueryRow(query string) error { return nil } +func (db *dbImpl) Exec(query string) error { return nil } +func (db *dbImpl) BeginTx() error { return nil } +func (db *dbImpl) BulkInsert(query string, args ...any) error { return nil } +func (db *dbImpl) Close() error { + slog.Info("БД: соединение закрыто") + return nil +} + +func newDB(lc fx.Lifecycle) (DB, error) { + db := &dbImpl{dsn: "postgres://localhost:5432/demo"} + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + slog.Info("БД: подключение установлено", "dsn", db.dsn) + return nil // здесь был бы db.Ping(ctx) + }, + OnStop: func(ctx context.Context) error { + return db.Close() + }, + }) + + return db, nil +} + +type cacheImpl struct{} + +func (c *cacheImpl) Get(key string) (string, error) { return "", nil } +func (c *cacheImpl) Set(key, value string) error { return nil } +func (c *cacheImpl) Close() error { + slog.Info("Кэш: соединение закрыто") + return nil +} + +func newCache(lc fx.Lifecycle) Cache { + c := &cacheImpl{} + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + slog.Info("Кэш: подключение установлено") + return nil // здесь был бы c.Ping(ctx) + }, + OnStop: func(ctx context.Context) error { + return c.Close() + }, + }) + + return c +} + +type eventBusImpl struct{} + +func (b *eventBusImpl) Publish(event string) { slog.Info("событие", "event", event) } +func (b *eventBusImpl) Subscribe(event string, handler func()) { slog.Info("подписка", "event", event) } + +func newEventBus(lc fx.Lifecycle) EventBus { + bus := &eventBusImpl{} + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + slog.Info("EventBus: запущен") + return nil + }, + OnStop: func(ctx context.Context) error { + slog.Info("EventBus: очередь событий дренирована") + return nil + }, + }) + + return bus +} + +// --- Репозитории --- + +type userRepo struct{ db DB } +type sessionRepo struct { + db DB + cache Cache +} +type notificationRepo struct{ db DB } + +func newUserRepo(db DB) UserRepository { + return &userRepo{db: db} +} + +func newSessionRepo(db DB, cache Cache) SessionRepository { + return &sessionRepo{db: db, cache: cache} +} + +func newNotificationRepo(db DB) NotificationRepository { + return ¬ificationRepo{db: db} +} + +// --- Сервисы --- + +type authServiceImpl struct { + userRepo UserRepository + sessionRepo SessionRepository + cache Cache + events EventBus +} + +func newAuthService(userRepo UserRepository, sessionRepo SessionRepository, cache Cache, events EventBus) AuthService { + return &authServiceImpl{userRepo: userRepo, sessionRepo: sessionRepo, cache: cache, events: events} +} + +type userServiceImpl struct { + userRepo UserRepository + authService AuthService + events EventBus +} + +func newUserService(userRepo UserRepository, authService AuthService, events EventBus) UserService { + return &userServiceImpl{userRepo: userRepo, authService: authService, events: events} +} + +type notificationServiceImpl struct { + notificationRepo NotificationRepository + userService UserService + events EventBus +} + +func newNotificationService(notificationRepo NotificationRepository, userService UserService, events EventBus) NotificationService { + return ¬ificationServiceImpl{notificationRepo: notificationRepo, userService: userService, events: events} +} + +// --- Handler --- + +type handler struct { + userService UserService + authService AuthService + notificationService NotificationService +} + +func newHandler(userService UserService, authService AuthService, notificationService NotificationService) *handler { + return &handler{userService: userService, authService: authService, notificationService: notificationService} +} + +func (h *handler) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") + }) + return mux +} diff --git a/cmd/fx-di/main.go b/cmd/fx-di/main.go new file mode 100644 index 0000000..30a48cf --- /dev/null +++ b/cmd/fx-di/main.go @@ -0,0 +1,358 @@ +package main + +// Это пример инициализации через Uber Fx — runtime DI-контейнер. +// Fx делает всё в рантайме: через рефлексию анализирует сигнатуры конструкторов +// и автоматически собирает граф зависимостей. +// +// Плюсы: +// - Не нужна кодогенерация +// - Lifecycle хуки из коробки +// +// Минусы: +// - Рефлексия: ошибки только в рантайме, а не при компиляции +// - fx.Annotate + fx.As для каждого интерфейса — бойлерплейта не меньше, чем wire.Bind +// - Один конкретный тип → несколько интерфейсов? Нужны обёртки-адаптеры (fx.Out) +// - Свой логгер (zap), свой lifecycle — навязывает архитектуру +// - Магия: непонятно что происходит, пока не запустишь +// +// ===================================================== +// ПОЧЕМУ ЗДЕСЬ СТОЛЬКО БОЙЛЕРПЛЕЙТА? +// ===================================================== +// +// В этом проекте мы определяем интерфейсы по месту использования — +// Go-идиоматичный подход (Interface Segregation Principle): +// +// // service/auth.go — свой интерфейс +// type AuthUserRepository interface { ... } +// +// // service/user.go — свой интерфейс +// type UserRepository interface { ... } +// +// Один repository.UserRepo реализует оба. Fx не умеет это разрулить +// без fx.Out/fx.In структур и named-тегов — отсюда 300+ строк. +// +// Если у вас другой подход — общие интерфейсы в одном пакете: +// +// // shared/interfaces.go +// type UserRepository interface { ... } // один на всех потребителей +// +// То Fx ложится ГОРАЗДО проще: один тип → один интерфейс → нет проблемы. +// Просто fx.Provide(repository.NewUserRepo) — и готово. +// +// Это не плохой подход, просто другой. Java/Spring так и работают. +// Fx по сути портированный Dagger/Spring — он заточен под shared interfaces. +// Если у вас на проекте именно такая архитектура — Fx будет комфортным. +// ===================================================== + +import ( + "log/slog" + "net/http" + + "go.uber.org/fx" + + "github.com/olezhek28/di-demo/internal/api" + "github.com/olezhek28/di-demo/internal/cache" + "github.com/olezhek28/di-demo/internal/config" + "github.com/olezhek28/di-demo/internal/database" + "github.com/olezhek28/di-demo/internal/events" + "github.com/olezhek28/di-demo/internal/repository" + "github.com/olezhek28/di-demo/internal/service" +) + +func main() { + fx.New( + // Конфиг + fx.Provide(config.AppConfig), + + // === Инфраструктура === + // Здесь начинается самое «весёлое». + // + // database.DB реализует 3 интерфейса: + // - repository.UserDB + // - repository.SessionDB + // - repository.NotificationDB + // + // cache.Cache реализует 2 интерфейса: + // - repository.SessionCache + // - service.AuthCache + // + // events.EventBus реализует 3 интерфейса: + // - service.AuthEventPublisher + // - service.UserEventPublisher + // - service.NotificationEventSubscriber + // + // В Fx нельзя просто написать fx.As(new(A), new(B), new(C)). + // Если один провайдер возвращает конкретный тип, а его нужно + // подать как РАЗНЫЕ интерфейсы в разные потребители — нужно + // писать специальные структуры-адаптеры с fx.Out. + // + // Либо — провайдить конкретный тип + отдельные provide-обёртки + // для каждого интерфейса. Оба варианта — бойлерплейт. + // + // Мы используем fx.Out структуры — каноничный подход Fx. + + fx.Provide(provideDB), // → dbOut{UserDB, SessionDB, NotificationDB} + fx.Provide(provideCache), // → cacheOut{SessionCache, AuthCache} + fx.Provide(provideEvents), // → eventsOut{AuthPub, UserPub, NotifSub} + + // === Репозитории === + // Та же история: repository.UserRepo реализует 2 интерфейса (service.AuthUserRepository + // и service.UserRepository), поэтому нужна fx.Out обёртка. + fx.Provide(provideUserRepo), // → userRepoOut{AuthUserRepo, UserRepo} + fx.Provide(provideSessionRepo), // → fx.As(service.AuthSessionRepository) + fx.Provide(provideNotificationRepo), // → fx.As(service.NotificationRepository) + + // === Сервисы === + // И здесь то же самое. + fx.Provide(provideAuthService), // → authServiceOut{UserAuth, APIAuth} + fx.Provide(provideUserService), // → userServiceOut{NotifUser, APIUser} + fx.Provide(provideNotificationService), // → api.NotificationService + + // Handler — тоже нужна обёртка, потому что зависимости named. + fx.Provide(provideHandler), + + // HTTP-сервер — через fx.Invoke. + fx.Invoke(startHTTPServer), + + // Итого: чтобы «автоматически» собрать граф из 12 зависимостей, + // мы написали 7 fx.Out структур + 7 fx.In структур + 11 provide-обёрток. + // Файл ~300 строк для того же результата что 40 строк в DI-контейнере. + // + // А ТЕПЕРЬ ГЛАВНОЕ — как Fx работает внутри? + // + // При вызове fx.New() он: + // 1. Собирает все провайдеры + // 2. Через рефлексию анализирует сигнатуры (кто что принимает, кто что возвращает) + // 3. Строит граф зависимостей + // 4. Делает топологическую сортировку + // 5. Вызывает ВСЕ конструкторы разом, в правильном порядке + // + // То есть инициализация — EAGER, не ленивая. + // Всё создаётся при старте, как в антипаттерне и в Wire. + // Просто порядок вычисляется автоматически через рефлексию. + // + // Что это значит на практике: + // - Ошибки — только в рантайме (забыл провайдер → паника при старте) + // - Циклическая зависимость → Fx упадёт при старте (тот же тупик) + // - Ленивой инициализации нет — всё создаётся сразу + // + // Fx решает ту же задачу что Wire — «не вычисляй порядок руками». + // Но наш DI-контейнер не имеет порядка вообще — каждый геттер + // рекурсивно подтягивает зависимости при первом вызове. + // Нечему ломаться, нечего сортировать. + ).Run() +} + +// ===================================================== +// Обёртки-адаптеры для Fx (fx.Out) +// ===================================================== +// Каждая структура с fx.Out нужна потому что один конкретный тип +// реализует несколько интерфейсов для разных потребителей. +// Без этих структур Fx не знает как разрулить зависимости. + +// --- database.DB → 3 интерфейса --- + +type dbOut struct { + fx.Out + + UserDB repository.UserDB `name:"userDB"` + SessionDB repository.SessionDB `name:"sessionDB"` + NotificationDB repository.NotificationDB `name:"notificationDB"` +} + +func provideDB(cfg *config.Config) (dbOut, error) { + db, err := database.New(cfg.DSN) + if err != nil { + return dbOut{}, err + } + return dbOut{ + UserDB: db, + SessionDB: db, + NotificationDB: db, + }, nil +} + +// --- cache.Cache → 2 интерфейса --- + +type cacheOut struct { + fx.Out + + SessionCache repository.SessionCache `name:"sessionCache"` + AuthCache service.AuthCache `name:"authCache"` +} + +func provideCache(cfg *config.Config) cacheOut { + c := cache.New(cfg.RedisAddr) + return cacheOut{ + SessionCache: c, + AuthCache: c, + } +} + +// --- events.EventBus → 3 интерфейса --- + +type eventsOut struct { + fx.Out + + AuthPublisher service.AuthEventPublisher `name:"authPublisher"` + UserPublisher service.UserEventPublisher `name:"userPublisher"` + NotifSub service.NotificationEventSubscriber `name:"notifSubscriber"` +} + +func provideEvents() eventsOut { + bus := events.NewEventBus() + return eventsOut{ + AuthPublisher: bus, + UserPublisher: bus, + NotifSub: bus, + } +} + +// --- repository.UserRepo → 2 интерфейса --- + +type userRepoOut struct { + fx.Out + + AuthUserRepo service.AuthUserRepository `name:"authUserRepo"` + UserRepo service.UserRepository `name:"userRepo"` +} + +type userRepoIn struct { + fx.In + + DB repository.UserDB `name:"userDB"` +} + +func provideUserRepo(in userRepoIn) userRepoOut { + repo := repository.NewUserRepo(in.DB) + return userRepoOut{ + AuthUserRepo: repo, + UserRepo: repo, + } +} + +// --- repository.SessionRepo → 1 интерфейс --- + +type sessionRepoIn struct { + fx.In + + DB repository.SessionDB `name:"sessionDB"` + Cache repository.SessionCache `name:"sessionCache"` +} + +func provideSessionRepo(in sessionRepoIn) service.AuthSessionRepository { + return repository.NewSessionRepo(in.DB, in.Cache) +} + +// --- repository.NotificationRepo → 1 интерфейс --- + +type notificationRepoIn struct { + fx.In + + DB repository.NotificationDB `name:"notificationDB"` +} + +type notificationRepoOut struct { + fx.Out + + Repo service.NotificationRepository `name:"notifRepo"` +} + +func provideNotificationRepo(in notificationRepoIn) notificationRepoOut { + return notificationRepoOut{ + Repo: repository.NewNotificationRepo(in.DB), + } +} + +// --- service.AuthService → 2 интерфейса --- + +type authServiceOut struct { + fx.Out + + UserAuthService service.UserAuthService `name:"userAuthService"` + APIAuthService api.AuthService `name:"apiAuthService"` +} + +type authServiceIn struct { + fx.In + + UserRepo service.AuthUserRepository `name:"authUserRepo"` + SessionRepo service.AuthSessionRepository + Cache service.AuthCache `name:"authCache"` + Events service.AuthEventPublisher `name:"authPublisher"` +} + +func provideAuthService(in authServiceIn) authServiceOut { + svc := service.NewAuthService(in.UserRepo, in.SessionRepo, in.Cache, in.Events) + return authServiceOut{ + UserAuthService: svc, + APIAuthService: svc, + } +} + +// --- service.UserService → 2 интерфейса --- + +type userServiceOut struct { + fx.Out + + NotifUserService service.NotificationUserService `name:"notifUserService"` + APIUserService api.UserService `name:"apiUserService"` +} + +type userServiceIn struct { + fx.In + + UserRepo service.UserRepository `name:"userRepo"` + AuthService service.UserAuthService `name:"userAuthService"` + Events service.UserEventPublisher `name:"userPublisher"` +} + +func provideUserService(in userServiceIn) userServiceOut { + svc := service.NewUserService(in.UserRepo, in.AuthService, in.Events) + return userServiceOut{ + NotifUserService: svc, + APIUserService: svc, + } +} + +// --- service.NotificationService (fx.Annotate выше, но нужен fx.In для named deps) --- + +type notificationServiceIn struct { + fx.In + + Repo service.NotificationRepository `name:"notifRepo"` + UserSvc service.NotificationUserService `name:"notifUserService"` + Events service.NotificationEventSubscriber `name:"notifSubscriber"` +} + +// Заменяем fx.Annotate на отдельный provide — иначе не прокинуть named зависимости. +func provideNotificationService(in notificationServiceIn) api.NotificationService { + return service.NewNotificationService(in.Repo, in.UserSvc, in.Events) +} + +// --- api.Handler (fx.In для named deps) --- + +type handlerIn struct { + fx.In + + UserService api.UserService `name:"apiUserService"` + AuthService api.AuthService `name:"apiAuthService"` + NotificationService api.NotificationService +} + +func provideHandler(in handlerIn) api.Handler { + return api.NewHandler(in.UserService, in.AuthService, in.NotificationService) +} + +func startHTTPServer(handler api.Handler) { + srv := &http.Server{ + Addr: config.AppConfig().HTTPAddr, + Handler: handler.Routes(), + } + + slog.Info("сервер запущен", "addr", config.AppConfig().HTTPAddr) + + if err := srv.ListenAndServe(); err != nil { + slog.Error("ошибка сервера", "err", err) + } +} diff --git a/cmd/manual-di/main.go b/cmd/manual-di/main.go new file mode 100644 index 0000000..ada54bf --- /dev/null +++ b/cmd/manual-di/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/olezhek28/di-demo/internal/app" +) + +func main() { + a := app.New() + + if err := a.Run(); err != nil { + slog.Error("ошибка приложения", "err", err) + os.Exit(1) + } +} diff --git a/cmd/wire-di-broken/main.go b/cmd/wire-di-broken/main.go new file mode 100644 index 0000000..fa024f4 --- /dev/null +++ b/cmd/wire-di-broken/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + _, _ = InitializeEventBus() +} diff --git a/cmd/wire-di-broken/types.go b/cmd/wire-di-broken/types.go new file mode 100644 index 0000000..20111b6 --- /dev/null +++ b/cmd/wire-di-broken/types.go @@ -0,0 +1,98 @@ +package main + +// Это упрощённый граф зависимостей с циклом. +// Та самая ситуация из антипаттерна: продакт попросил чтобы EventBus +// сам доставлял уведомления через NotificationService. +// +// Цепочка: +// EventBus → NotificationService → UserService → AuthService → EventBus +// ↑ ЦИКЛ +// +// Wire не сможет это сгенерировать. Запустите: +// task generate-wire-broken +// и увидите ошибку. + +import "log/slog" + +// === Интерфейсы (consumer-defined) === + +// NotificationSender — то что EventBus хочет от NotificationService. +type NotificationSender interface { + Send(msg string) +} + +// EventPublisher — то что AuthService хочет от EventBus. +type EventPublisher interface { + Publish(event string) +} + +// EventSubscriber — то что NotificationService хочет от EventBus. +type EventSubscriber interface { + Subscribe(event string, handler func()) +} + +// Auth — то что UserService хочет от AuthService. +type Auth interface { + Validate(token string) bool +} + +// Users — то что NotificationService хочет от UserService. +type Users interface { + GetEmail(userID int) string +} + +// === Реализации === + +// EventBus — шина событий, которая САМА доставляет уведомления. +// Вот эта зависимость на NotificationSender создаёт цикл. +type EventBus struct { + notifications NotificationSender +} + +func NewEventBus(notifications NotificationSender) *EventBus { + slog.Info("шина событий создана") + return &EventBus{notifications: notifications} +} + +func (b *EventBus) Publish(event string) {} +func (b *EventBus) Subscribe(event string, handler func()) {} + +// AuthService — сервис авторизации. Зависит от EventBus для публикации событий. +type AuthService struct { + events EventPublisher +} + +func NewAuthService(events EventPublisher) *AuthService { + slog.Info("сервис авторизации создан") + return &AuthService{events: events} +} + +func (s *AuthService) Validate(token string) bool { return true } + +// UserService — сервис пользователей. Зависит от AuthService. +type UserService struct { + auth Auth +} + +func NewUserService(auth Auth) *UserService { + slog.Info("сервис пользователей создан") + return &UserService{auth: auth} +} + +func (s *UserService) GetEmail(userID int) string { return "user@example.com" } + +// NotificationService — сервис уведомлений. +// Зависит от UserService и EventBus (для подписки на события). +type NotificationService struct { + users Users + events EventSubscriber +} + +func NewNotificationService(users Users, events EventSubscriber) *NotificationService { + slog.Info("сервис уведомлений создан") + return &NotificationService{users: users, events: events} +} + +func (s *NotificationService) Send(msg string) { + slog.Info("уведомление отправлено", "msg", msg) +} diff --git a/cmd/wire-di-broken/wire.go b/cmd/wire-di-broken/wire.go new file mode 100644 index 0000000..bf56d51 --- /dev/null +++ b/cmd/wire-di-broken/wire.go @@ -0,0 +1,32 @@ +//go:build wireinject + +package main + +import "github.com/google/wire" + +// InitializeEventBus — попытка собрать граф с циклической зависимостью. +// +// EventBus → NotificationService → UserService → AuthService → EventBus +// +// Wire сделает топологическую сортировку, обнаружит цикл и упадёт. +// Ленивой инициализации нет — Wire должен знать порядок создания заранее. +// А при цикле порядка не существует. +// +// Запустите: task generate-wire-broken +// Результат: "cycle for *EventBus" +func InitializeEventBus() (*EventBus, error) { + wire.Build( + NewEventBus, + NewAuthService, + NewUserService, + NewNotificationService, + + wire.Bind(new(NotificationSender), new(*NotificationService)), + wire.Bind(new(EventPublisher), new(*EventBus)), + wire.Bind(new(EventSubscriber), new(*EventBus)), + wire.Bind(new(Auth), new(*AuthService)), + wire.Bind(new(Users), new(*UserService)), + ) + + return nil, nil +} diff --git a/cmd/wire-di/main.go b/cmd/wire-di/main.go new file mode 100644 index 0000000..5d58766 --- /dev/null +++ b/cmd/wire-di/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "log/slog" + "net/http" + "os" + + "github.com/olezhek28/di-demo/internal/config" +) + +func main() { + handler, err := InitializeHandler() + if err != nil { + slog.Error("не удалось инициализировать приложение", "err", err) + os.Exit(1) + } + + srv := &http.Server{ + Addr: config.AppConfig().HTTPAddr, + Handler: handler.Routes(), + } + + slog.Info("сервер запущен", "addr", config.AppConfig().HTTPAddr) + + if err = srv.ListenAndServe(); err != nil { + slog.Error("ошибка сервера", "err", err) + } +} diff --git a/cmd/wire-di/wire.go b/cmd/wire-di/wire.go new file mode 100644 index 0000000..d6f99c4 --- /dev/null +++ b/cmd/wire-di/wire.go @@ -0,0 +1,115 @@ +//go:build wireinject + +package main + +// Это файл, который пишет разработчик. Wire анализирует его и генерирует wire_gen.go. +// Build-тег wireinject означает что этот файл НЕ попадает в итоговую сборку — +// вместо него компилируется wire_gen.go. + +import ( + "github.com/google/wire" + "github.com/olezhek28/di-demo/internal/api" + "github.com/olezhek28/di-demo/internal/cache" + "github.com/olezhek28/di-demo/internal/config" + "github.com/olezhek28/di-demo/internal/database" + "github.com/olezhek28/di-demo/internal/events" + "github.com/olezhek28/di-demo/internal/repository" + "github.com/olezhek28/di-demo/internal/service" +) + +// InitializeHandler — инжектор. Wire смотрит на типы и строит граф зависимостей. +// Разработчик перечисляет провайдеры, Wire разбирается кому что нужно. +func InitializeHandler() (api.Handler, error) { + wire.Build( + // Конфиг + config.AppConfig, + + // Инфраструктура — провайдеры-обёртки, потому что конструкторы + // принимают строки (DSN, addr), а Wire различает по типу. + provideDB, + provideCache, + events.NewEventBus, + + // Репозитории + repository.NewUserRepo, + repository.NewSessionRepo, + repository.NewNotificationRepo, + + // Сервисы + service.NewAuthService, + service.NewUserService, + service.NewNotificationService, + + // API + api.NewHandler, + + // А теперь самое «весёлое» — wire.Bind. + // Каждый конструктор принимает интерфейс, а Wire работает с типами. + // Нужно ВРУЧНУЮ указать: какой тип реализует какой интерфейс. + // + // database.DB реализует 3 интерфейса в разных пакетах: + wire.Bind(new(repository.UserDB), new(database.DB)), + wire.Bind(new(repository.SessionDB), new(database.DB)), + wire.Bind(new(repository.NotificationDB), new(database.DB)), + + // cache.Cache реализует 2 интерфейса: + wire.Bind(new(repository.SessionCache), new(cache.Cache)), + wire.Bind(new(service.AuthCache), new(cache.Cache)), + + // events.EventBus реализует 3 интерфейса: + wire.Bind(new(service.AuthEventPublisher), new(events.EventBus)), + wire.Bind(new(service.UserEventPublisher), new(events.EventBus)), + wire.Bind(new(service.NotificationEventSubscriber), new(events.EventBus)), + + // repository.UserRepo реализует 2 интерфейса: + wire.Bind(new(service.AuthUserRepository), new(repository.UserRepo)), + wire.Bind(new(service.UserRepository), new(repository.UserRepo)), + + // Остальные репозитории — по одному интерфейсу: + wire.Bind(new(service.AuthSessionRepository), new(repository.SessionRepo)), + wire.Bind(new(service.NotificationRepository), new(repository.NotificationRepo)), + + // Сервисы тоже реализуют интерфейсы выше по графу: + wire.Bind(new(service.UserAuthService), new(service.AuthService)), + wire.Bind(new(service.NotificationUserService), new(service.UserService)), + wire.Bind(new(api.UserService), new(service.UserService)), + wire.Bind(new(api.AuthService), new(service.AuthService)), + wire.Bind(new(api.NotificationService), new(service.NotificationService)), + ) + + // 10 провайдеров + 18 wire.Bind = 28 строк конфигурации. + // Для 12 зависимостей. И при каждом новом интерфейсе — новый Bind. + // + // А ТЕПЕРЬ ГЛАВНОЕ. + // Откройте wire_gen.go и посмотрите что Wire нагенерировал. + // Это та же самая каша что в cmd/antipattern/main.go: + // + // db, err := provideDB(configConfig) + // userRepo := repository.NewUserRepo(db) + // sessionRepo := repository.NewSessionRepo(db, cache) + // authService := service.NewAuthService(userRepo, sessionRepo, cache, eventBus) + // ... + // + // Последовательная инициализация. Жёсткий порядок. Eager — всё создаётся сразу. + // + // Wire решил ровно одну задачу: тебе не надо самому вычислять порядок строк. + // Он сделал топологическую сортировку за тебя. Всё. + // + // Но хрупкость никуда не делась: + // - Добавил зависимость → правь wire.go + wire.Bind + go generate + // - Циклическая зависимость → Wire упадёт при генерации (тот же тупик) + // - Ленивой инициализации нет — всё создаётся при старте + // + // По сути Wire автоматизирует неправильное решение. + // Вместо того чтобы убрать хрупкость — он генерирует хрупкий код за тебя. + + return nil, nil +} + +func provideDB(cfg *config.Config) (database.DB, error) { + return database.New(cfg.DSN) +} + +func provideCache(cfg *config.Config) cache.Cache { + return cache.New(cfg.RedisAddr) +} diff --git a/cmd/wire-di/wire_gen.go b/cmd/wire-di/wire_gen.go new file mode 100644 index 0000000..6cb7ca5 --- /dev/null +++ b/cmd/wire-di/wire_gen.go @@ -0,0 +1,49 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package main + +import ( + "github.com/olezhek28/di-demo/internal/api" + "github.com/olezhek28/di-demo/internal/cache" + "github.com/olezhek28/di-demo/internal/config" + "github.com/olezhek28/di-demo/internal/database" + "github.com/olezhek28/di-demo/internal/events" + "github.com/olezhek28/di-demo/internal/repository" + "github.com/olezhek28/di-demo/internal/service" +) + +// Injectors from wire.go: + +// InitializeHandler — инжектор. Wire смотрит на типы и строит граф зависимостей. +// Разработчик перечисляет провайдеры, Wire разбирается кому что нужно. +func InitializeHandler() (api.Handler, error) { + configConfig := config.AppConfig() + db, err := provideDB(configConfig) + if err != nil { + return nil, err + } + userRepo := repository.NewUserRepo(db) + cacheCache := provideCache(configConfig) + sessionRepo := repository.NewSessionRepo(db, cacheCache) + eventBus := events.NewEventBus() + authService := service.NewAuthService(userRepo, sessionRepo, cacheCache, eventBus) + userService := service.NewUserService(userRepo, authService, eventBus) + notificationRepo := repository.NewNotificationRepo(db) + notificationService := service.NewNotificationService(notificationRepo, userService, eventBus) + handler := api.NewHandler(userService, authService, notificationService) + return handler, nil +} + +// wire.go: + +func provideDB(cfg *config.Config) (database.DB, error) { + return database.New(cfg.DSN) +} + +func provideCache(cfg *config.Config) cache.Cache { + return cache.New(cfg.RedisAddr) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ecb8ef5 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/olezhek28/di-demo + +go 1.26.0 + +require ( + github.com/google/wire v0.7.0 + go.uber.org/fx v1.24.0 +) + +require ( + go.uber.org/dig v1.19.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff1ead3 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= +golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..4af2c5e --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,83 @@ +package api + +import ( + "fmt" + "log/slog" + "net/http" +) + +// UserService — интерфейс сервиса пользователей для хендлера. +type UserService interface { + GetProfile(token string) string +} + +// AuthService — интерфейс сервиса авторизации для хендлера. +type AuthService interface { + // методы, которые хендлер использует из сервиса авторизации +} + +// NotificationService — интерфейс сервиса уведомлений для хендлера. +type NotificationService interface { + // методы, которые хендлер использует из сервиса уведомлений +} + +// Handler — интерфейс HTTP-обработчика. +// Содержит только хендлеры и роутинг. +// Ничего не знает про http.Server, порт или lifecycle — +// это ответственность app-слоя. +// Структура неэкспортируемая — создать объект можно только через NewHandler. +type Handler interface { + Routes() http.Handler +} + +// handler — обработчик HTTP-запросов. +type handler struct { + userService UserService + authService AuthService + notificationService NotificationService +} + +// NewHandler создаёт обработчик HTTP-запросов. +func NewHandler( + userService UserService, + authService AuthService, + notificationService NotificationService, +) Handler { + return &handler{ + userService: userService, + authService: authService, + notificationService: notificationService, + } +} + +// Routes возвращает маршрутизатор со всеми зарегистрированными хендлерами. +// App-слой использует этот http.Handler при создании http.Server. +func (h *handler) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", h.healthHandler) + mux.HandleFunc("/users/me", h.getUserProfile) + + return mux +} + +func (h *handler) healthHandler(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + + if _, err := fmt.Fprintln(w, "ok"); err != nil { + slog.Error("ошибка записи ответа", "err", err) + } +} + +// getUserProfile — хендлер профиля пользователя. +// Вызывает userService.GetProfile(), который внутри дёрнет authService.ValidateToken(). +// Если authService == nil (как в antipattern-broken) — тут будет nil pointer dereference. +func (h *handler) getUserProfile(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + profile := h.userService.GetProfile(token) + + w.WriteHeader(http.StatusOK) + + if _, err := fmt.Fprintln(w, profile); err != nil { + slog.Error("ошибка записи ответа", "err", err) + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..77cf0eb --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,63 @@ +package app + +import ( + "log/slog" + "net/http" + + "github.com/olezhek28/di-demo/internal/config" +) + +// App — структура приложения. +// Содержит DI-контейнер и HTTP-сервер. +// API-слой отвечает за хендлеры и роутинг, а http.Server живёт здесь — +// это инфраструктура, а не бизнес-логика. +type App struct { + diContainer *diContainer + httpServer *http.Server +} + +// New создаёт приложение и инициализирует все зависимости через DI-контейнер. +// Вся цепочка зависимостей разрешается здесь: от базы данных до HTTP-хендлеров. +// Если какая-то зависимость не создалась — процесс завершится внутри контейнера. +func New() *App { + a := &App{ + diContainer: newDIContainer(), + } + + a.initDeps() + + return a +} + +// initDeps последовательно вызывает функции инициализации. +// Если нужно добавить новый шаг (миграции, метрики и т.д.) — +// просто добавить функцию в слайс inits. +func (a *App) initDeps() { + inits := []func(){ + a.initHTTPServer, + } + + for _, fn := range inits { + fn() + } +} + +// initHTTPServer создаёт HTTP-сервер. +// API-слой (Handler) предоставляет только роутинг — Routes(). +// А http.Server с адресом и конфигом создаётся здесь, в app-слое. +func (a *App) initHTTPServer() { + a.httpServer = &http.Server{ + Addr: config.AppConfig().HTTPAddr, + Handler: a.diContainer.Handler().Routes(), + } +} + +// Run запускает HTTP-сервер. +// +// Сейчас просто запускаем и всё. Перехват сигналов, graceful shutdown, +// закрытие ресурсов в правильном порядке — тема отдельного видео. +func (a *App) Run() error { + slog.Info("сервер запущен", "addr", config.AppConfig().HTTPAddr) + + return a.httpServer.ListenAndServe() +} diff --git a/internal/app/di.go b/internal/app/di.go new file mode 100644 index 0000000..6075076 --- /dev/null +++ b/internal/app/di.go @@ -0,0 +1,194 @@ +package app + +import ( + "log/slog" + "os" + + "github.com/olezhek28/di-demo/internal/api" + "github.com/olezhek28/di-demo/internal/cache" + "github.com/olezhek28/di-demo/internal/config" + "github.com/olezhek28/di-demo/internal/database" + "github.com/olezhek28/di-demo/internal/events" + "github.com/olezhek28/di-demo/internal/repository" + "github.com/olezhek28/di-demo/internal/service" +) + +// diContainer — контейнер зависимостей с ленивой инициализацией. +// +// Принцип работы: +// Все поля начинаются как nil. При первом вызове геттера (например, DB()) +// зависимость создаётся и сохраняется в поле. При повторном вызове — +// возвращается уже созданный экземпляр. Это гарантирует, что каждая +// зависимость существует в единственном экземпляре (singleton в рамках приложения). +// +// Зачем это нужно: +// В отличие от плоского main.go, где порядок строк определяет порядок инициализации, +// здесь порядок определяется самим графом зависимостей. Каждый геттер вызывает +// геттеры своих зависимостей — и они рекурсивно создаются автоматически. +// Не нужно думать «что создать первым» — контейнер разберётся сам. +// +// Как добавить новую зависимость: +// 1. Добавить поле в структуру +// 2. Написать геттер с проверкой на nil +// 3. Вызвать геттер из нужных мест — всё +// Никакой перестановки строк в main.go. +// +// Почему поля — интерфейсы: +// Каждый пакет экспортирует интерфейс (database.DB, cache.Cache и т.д.), +// а конкретная реализация скрыта (неэкспортируемая структура). +// Это гарантирует, что объект можно создать ТОЛЬКО через конструктор (New). +// Нельзя написать &database.db{} — структура не видна за пределами пакета. +// Контейнер зависит от абстракций, а не от конкретных типов. +// +// Почему геттеры не возвращают ошибку: +// Если зависимость не создалась (например, база не подключилась) — +// приложение всё равно не может работать. Нет смысла протаскивать ошибку +// через 5 уровней вызовов — проще сразу завершить процесс. +// Это упрощает код: геттеры становятся однострочными, без if err != nil. +type diContainer struct { + // Инфраструктура — базовые ресурсы, от которых зависит всё остальное. + db database.DB + cache cache.Cache + eventBus events.EventBus + + // Репозитории — слой доступа к данным. Зависят от инфраструктуры. + userRepo repository.UserRepo + sessionRepo repository.SessionRepo + notificationRepo repository.NotificationRepo + + // Сервисы — бизнес-логика. Зависят от репозиториев и друг от друга. + authService service.AuthService + userService service.UserService + notificationService service.NotificationService + + // API — хендлеры и роутинг. Зависит от сервисов. + handler api.Handler +} + +// newDIContainer создаёт новый пустой контейнер. +// Все поля nil — зависимости будут создаваться лениво при первом обращении. +func newDIContainer() *diContainer { + return &diContainer{} +} + +// DB возвращает подключение к базе данных. +// Создаётся один раз при первом вызове. При повторном — возвращается тот же экземпляр. +// Если подключение не удалось — завершаем процесс: без базы приложение бессмысленно. +func (d *diContainer) DB() database.DB { + if d.db == nil { + db, err := database.New(config.AppConfig().DSN) + if err != nil { + slog.Error("не удалось подключиться к БД", "err", err) + os.Exit(1) + } + + d.db = db + } + + return d.db +} + +// EventBus возвращает шину событий. +// Чистый pub/sub брокер без зависимостей — все сервисы зависят от него, а не наоборот. +// В антипаттерне EventBus зависел от NotificationService (тупик с циклом). +// Здесь — EventBus не зависит ни от кого, а сервисы подключаются к нему сами. +func (d *diContainer) EventBus() events.EventBus { + if d.eventBus == nil { + d.eventBus = events.NewEventBus() + } + + return d.eventBus +} + +// Cache возвращает подключение к кэшу. +// Создаётся один раз при первом вызове. +func (d *diContainer) Cache() cache.Cache { + if d.cache == nil { + d.cache = cache.New(config.AppConfig().RedisAddr) + } + + return d.cache +} + +// UserRepo возвращает репозиторий пользователей. +// Зависит от DB — если база ещё не создана, d.DB() создаст её автоматически. +func (d *diContainer) UserRepo() repository.UserRepo { + if d.userRepo == nil { + d.userRepo = repository.NewUserRepo(d.DB()) + } + + return d.userRepo +} + +// SessionRepo возвращает репозиторий сессий. +// Зависит от DB и Cache — оба подтянутся автоматически при первом вызове. +func (d *diContainer) SessionRepo() repository.SessionRepo { + if d.sessionRepo == nil { + d.sessionRepo = repository.NewSessionRepo(d.DB(), d.Cache()) + } + + return d.sessionRepo +} + +// NotificationRepo возвращает репозиторий уведомлений. +// Зависит от DB. +func (d *diContainer) NotificationRepo() repository.NotificationRepo { + if d.notificationRepo == nil { + d.notificationRepo = repository.NewNotificationRepo(d.DB()) + } + + return d.notificationRepo +} + +// AuthService возвращает сервис авторизации. +// Зависит от UserRepo, SessionRepo, Cache и EventBus. +// Все зависимости подтягиваются рекурсивно — если UserRepo ещё не создан, +// он создастся, а для этого создастся DB, и так далее по цепочке. +// EventBus добавился одной строкой — никакой перестановки, никаких тупиков. +func (d *diContainer) AuthService() service.AuthService { + if d.authService == nil { + d.authService = service.NewAuthService(d.UserRepo(), d.SessionRepo(), d.Cache(), d.EventBus()) + } + + return d.authService +} + +// UserService возвращает сервис пользователей. +// Зависит от UserRepo и AuthService — перекрёстная зависимость между сервисами. +// В плоском main.go это создавало проблему с порядком строк. +// Здесь — просто вызываем d.AuthService(), и он рекурсивно создаст всё что нужно. +func (d *diContainer) UserService() service.UserService { + if d.userService == nil { + d.userService = service.NewUserService(d.UserRepo(), d.AuthService(), d.EventBus()) + } + + return d.userService +} + +// NotificationService возвращает сервис уведомлений. +// Зависит от NotificationRepo и UserService. +// UserService → AuthService → UserRepo → DB — вся цепочка разрешится автоматически. +func (d *diContainer) NotificationService() service.NotificationService { + if d.notificationService == nil { + d.notificationService = service.NewNotificationService(d.NotificationRepo(), d.UserService(), d.EventBus()) + } + + return d.notificationService +} + +// Handler возвращает HTTP-хендлер. +// Вершина графа зависимостей — зависит от всех трёх сервисов. +// Один вызов d.Handler() рекурсивно создаст ВСЕ зависимости приложения. +// Хендлер отвечает только за роутинг и обработку запросов. +// http.Server с адресом создаётся в app.go — это инфраструктура, а не API. +func (d *diContainer) Handler() api.Handler { + if d.handler == nil { + d.handler = api.NewHandler( + d.UserService(), + d.AuthService(), + d.NotificationService(), + ) + } + + return d.handler +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..98f625a --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,38 @@ +package cache + +import "log/slog" + +// Cache — интерфейс подключения к кэшу. +// Структура неэкспортируемая — создать объект можно только через New. +type Cache interface { + Get(key string) (string, error) + Set(key, value string) error + Close() error +} + +// redisCache — обёртка над подключением к кэшу. +type redisCache struct { + addr string +} + +// New создаёт подключение к кэшу. +func New(addr string) Cache { + slog.Info("подключились к кэшу", "addr", addr) + return &redisCache{addr: addr} +} + +// Get получает значение по ключу. +func (c *redisCache) Get(key string) (string, error) { + return "", nil +} + +// Set устанавливает значение по ключу. +func (c *redisCache) Set(key, value string) error { + return nil +} + +// Close закрывает подключение к кэшу. +func (c *redisCache) Close() error { + slog.Info("подключение к кэшу закрыто") + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..16a0866 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,25 @@ +package config + +// Config хранит конфигурацию приложения. +type Config struct { + DSN string + RedisAddr string + HTTPAddr string +} + +// appConfig — глобальный экземпляр конфигурации. +var appConfig = &Config{ + DSN: "postgres://localhost:5432/demo", + RedisAddr: "localhost:6379", + HTTPAddr: ":8080", +} + +// AppConfig возвращает конфигурацию приложения. +func AppConfig() *Config { + return appConfig +} + +// New создаёт конфиг с дефолтными значениями. +func New() *Config { + return appConfig +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..69faf9c --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,64 @@ +package database + +import ( + "fmt" + "log/slog" +) + +// DB — интерфейс подключения к базе данных. +// Потребители зависят от этого интерфейса, а не от конкретной реализации. +// Структура db неэкспортируемая — создать объект можно только через New. +type DB interface { + Query(query string) error + QueryRow(query string) error + Exec(query string) error + BeginTx() error + BulkInsert(query string, args ...any) error + Close() error +} + +// db — обёртка над подключением к базе данных. +type db struct { + dsn string +} + +// New создаёт подключение к базе данных. +func New(dsn string) (DB, error) { + if dsn == "" { + return nil, fmt.Errorf("dsn пустой") + } + + slog.Info("подключились к базе данных", "dsn", dsn) + return &db{dsn: dsn}, nil +} + +// Query выполняет запрос на чтение. +func (d *db) Query(query string) error { + return nil +} + +// QueryRow выполняет запрос, возвращающий одну строку. +func (d *db) QueryRow(query string) error { + return nil +} + +// Exec выполняет запрос на запись. +func (d *db) Exec(query string) error { + return nil +} + +// BeginTx начинает транзакцию. +func (d *db) BeginTx() error { + return nil +} + +// BulkInsert выполняет массовую вставку. +func (d *db) BulkInsert(query string, args ...any) error { + return nil +} + +// Close закрывает подключение к базе данных. +func (d *db) Close() error { + slog.Info("подключение к базе данных закрыто") + return nil +} diff --git a/internal/events/bus.go b/internal/events/bus.go new file mode 100644 index 0000000..3979fec --- /dev/null +++ b/internal/events/bus.go @@ -0,0 +1,32 @@ +package events + +import "log/slog" + +// EventBus — интерфейс шины событий приложения. +// Чистый pub/sub брокер без внешних зависимостей. +// Сервисы сами публикуют события и подписываются на них. +// Структура неэкспортируемая — создать объект можно только через NewEventBus. +type EventBus interface { + Publish(event string) + Subscribe(event string, handler func()) +} + +// eventBus — реализация шины событий. +type eventBus struct{} + +// NewEventBus создаёт шину событий. +func NewEventBus() EventBus { + slog.Info("шина событий создана") + + return &eventBus{} +} + +// Publish публикует событие. +func (b *eventBus) Publish(event string) { + slog.Info("событие опубликовано", "event", event) +} + +// Subscribe подписывается на событие. +func (b *eventBus) Subscribe(event string, handler func()) { + slog.Info("подписка на событие", "event", event) +} diff --git a/internal/repository/notification.go b/internal/repository/notification.go new file mode 100644 index 0000000..7d0fbb3 --- /dev/null +++ b/internal/repository/notification.go @@ -0,0 +1,26 @@ +package repository + +import "log/slog" + +// NotificationDB — интерфейс базы данных, который нужен NotificationRepo. +// Включает BulkInsert — для массовой вставки уведомлений. +type NotificationDB interface { + Query(query string) error + Exec(query string) error + BulkInsert(query string, args ...any) error +} + +// NotificationRepo — интерфейс репозитория уведомлений. +// Структура неэкспортируемая — создать объект можно только через NewNotificationRepo. +type NotificationRepo interface{} + +// notificationRepo — репозиторий уведомлений. +type notificationRepo struct { + db NotificationDB +} + +// NewNotificationRepo создаёт репозиторий уведомлений. +func NewNotificationRepo(db NotificationDB) NotificationRepo { + slog.Info("репозиторий уведомлений создан") + return ¬ificationRepo{db: db} +} diff --git a/internal/repository/session.go b/internal/repository/session.go new file mode 100644 index 0000000..4f570bb --- /dev/null +++ b/internal/repository/session.go @@ -0,0 +1,33 @@ +package repository + +import "log/slog" + +// SessionDB — интерфейс базы данных, который нужен SessionRepo. +// Включает BeginTx — для атомарного создания/удаления сессий. +type SessionDB interface { + Query(query string) error + Exec(query string) error + BeginTx() error +} + +// SessionCache — интерфейс кэша, который нужен SessionRepo. +type SessionCache interface { + Get(key string) (string, error) + Set(key, value string) error +} + +// SessionRepo — интерфейс репозитория сессий. +// Структура неэкспортируемая — создать объект можно только через NewSessionRepo. +type SessionRepo interface{} + +// sessionRepo — репозиторий сессий. +type sessionRepo struct { + db SessionDB + cache SessionCache +} + +// NewSessionRepo создаёт репозиторий сессий. +func NewSessionRepo(db SessionDB, cache SessionCache) SessionRepo { + slog.Info("репозиторий сессий создан") + return &sessionRepo{db: db, cache: cache} +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..89af3dc --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,26 @@ +package repository + +import "log/slog" + +// UserDB — интерфейс базы данных, который нужен UserRepo. +// Включает QueryRow — для поиска одного пользователя по ID/email. +type UserDB interface { + Query(query string) error + QueryRow(query string) error + Exec(query string) error +} + +// UserRepo — интерфейс репозитория пользователей. +// Структура неэкспортируемая — создать объект можно только через NewUserRepo. +type UserRepo interface{} + +// userRepo — репозиторий пользователей. +type userRepo struct { + db UserDB +} + +// NewUserRepo создаёт репозиторий пользователей. +func NewUserRepo(db UserDB) UserRepo { + slog.Info("репозиторий пользователей создан") + return &userRepo{db: db} +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..36641a2 --- /dev/null +++ b/internal/service/auth.go @@ -0,0 +1,66 @@ +package service + +import "log/slog" + +// AuthUserRepository — интерфейс репозитория пользователей для AuthService. +type AuthUserRepository interface { + // методы, которые AuthService использует из репозитория пользователей +} + +// AuthSessionRepository — интерфейс репозитория сессий для AuthService. +type AuthSessionRepository interface { + // методы, которые AuthService использует из репозитория сессий +} + +// AuthCache — интерфейс кэша для AuthService (хранит токены). +type AuthCache interface { + Get(key string) (string, error) + Set(key, value string) error +} + +// AuthEventPublisher — интерфейс для публикации событий авторизации. +type AuthEventPublisher interface { + Publish(event string) +} + +// AuthService — интерфейс сервиса авторизации. +// Структура неэкспортируемая — создать объект можно только через NewAuthService. +type AuthService interface { + ValidateToken(token string) bool +} + +// authService — сервис авторизации. +type authService struct { + userRepo AuthUserRepository + sessionRepo AuthSessionRepository + cache AuthCache + events AuthEventPublisher +} + +// NewAuthService создаёт сервис авторизации. +func NewAuthService( + userRepo AuthUserRepository, + sessionRepo AuthSessionRepository, + cache AuthCache, + events AuthEventPublisher, +) AuthService { + slog.Info("сервис авторизации создан") + return &authService{ + userRepo: userRepo, + sessionRepo: sessionRepo, + cache: cache, + events: events, + } +} + +// ValidateToken проверяет токен авторизации. +// Проверяет наличие токена в кэше — именно тут взорвётся nil, +// если кэш не был правильно инициализирован (как в antipattern-broken). +func (s *authService) ValidateToken(token string) bool { + slog.Info("проверяем токен", "token", token) + + // Проверяем токен в кэше. Если cache == nil — паника. + _, err := s.cache.Get(token) + + return err == nil +} diff --git a/internal/service/notification.go b/internal/service/notification.go new file mode 100644 index 0000000..8659068 --- /dev/null +++ b/internal/service/notification.go @@ -0,0 +1,43 @@ +package service + +import "log/slog" + +// NotificationRepository — интерфейс репозитория уведомлений для NotificationService. +type NotificationRepository interface { + // методы, которые NotificationService использует из репозитория уведомлений +} + +// NotificationUserService — интерфейс сервиса пользователей для NotificationService. +type NotificationUserService interface { + // методы, которые NotificationService использует из сервиса пользователей +} + +// NotificationEventSubscriber — интерфейс для подписки на события. +type NotificationEventSubscriber interface { + Subscribe(event string, handler func()) +} + +// NotificationService — интерфейс сервиса уведомлений. +// Структура неэкспортируемая — создать объект можно только через NewNotificationService. +type NotificationService interface{} + +// notificationService — сервис уведомлений. +type notificationService struct { + notificationRepo NotificationRepository + userService NotificationUserService + events NotificationEventSubscriber +} + +// NewNotificationService создаёт сервис уведомлений. +func NewNotificationService( + notificationRepo NotificationRepository, + userService NotificationUserService, + events NotificationEventSubscriber, +) NotificationService { + slog.Info("сервис уведомлений создан") + return ¬ificationService{ + notificationRepo: notificationRepo, + userService: userService, + events: events, + } +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..7c5d7d1 --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,52 @@ +package service + +import "log/slog" + +// UserRepository — интерфейс репозитория пользователей для UserService. +type UserRepository interface { + // методы, которые UserService использует из репозитория пользователей +} + +// UserAuthService — интерфейс сервиса авторизации для UserService. +type UserAuthService interface { + ValidateToken(token string) bool +} + +// UserEventPublisher — интерфейс для публикации событий пользователей. +type UserEventPublisher interface { + Publish(event string) +} + +// UserService — интерфейс сервиса пользователей. +// Структура неэкспортируемая — создать объект можно только через NewUserService. +type UserService interface { + GetProfile(token string) string +} + +// userService — сервис пользователей. +type userService struct { + userRepo UserRepository + authService UserAuthService + events UserEventPublisher +} + +// NewUserService создаёт сервис пользователей. +func NewUserService(userRepo UserRepository, authService UserAuthService, events UserEventPublisher) UserService { + slog.Info("сервис пользователей создан") + return &userService{ + userRepo: userRepo, + authService: authService, + events: events, + } +} + +// GetProfile возвращает профиль текущего пользователя. +// Сначала проверяет токен через authService — и именно тут взорвётся nil, +// если authService не был правильно инициализирован. +func (s *userService) GetProfile(token string) string { + if !s.authService.ValidateToken(token) { + return "unauthorized" + } + + return "user profile data" +}