Перейти до змісту

Зв'язування та структура пакетів

У Go немає фреймворка для впровадження залежностей у широкому вжитку, і він не потрібен. Залежності — це поля структури, які встановлюють конструктори й збирають в одному місці при старті. Дисципліна — не в контейнері, а в тому, куди ви кладете речі.

type App struct {
    Config Config
    DB     *sql.DB
    Stores *Stores
}

cmd/ та internal/

Два каталоги несуть майже всю домовленість:

myservice/
  cmd/
    api/main.go        ← один бінарник
    migrate/main.go    ← інший
  internal/
    app/               ← кореневий об'єкт
    store/             ← реалізації
    services/          ← контракти
    web/               ← обробники
  go.mod

internal/ забезпечений компілятором: ніщо за межами модуля не може його імпортувати, як описано в статті про спеціальні папки. Кладіть туди все, крім того, що ви маєте намір зробити публічною бібліотекою. Це нічого не коштує й означає, що ви можете рефакторити вільно, нічого не ламаючи нікому.

Кожен каталог під cmd/ — це один package main, що виробляє один бінарник. Тримайте ці файли крихітними.

Тримайте main тонким

main має розбирати прапорці, будувати застосунок, запускати його й обробляти завершення роботи. Більше нічого:

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

func run() error {
    cfg, err := LoadConfig()
    if err != nil {
        return err
    }

    app, err := NewApp(cfg)
    if err != nil {
        return err
    }
    defer app.Close()

    return app.Run()
}

Розділення run() error має значення з причини, яку розкриває стаття про прапорці й середовище: os.Exit пропускає відкладені функції, тож усе в main, що потребує прибирання, має жити у функції, яка повертає значення.

Впровадження через конструктор

Тип оголошує, що йому потрібно, як поля, а конструктор приймає їх:

type Handler struct {
    users  UserStore
    logger *slog.Logger
}

func NewHandler(users UserStore, logger *slog.Logger) *Handler {
    return &Handler{users: users, logger: logger}
}

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

Правило, яке тримає це чесним: беріть найвужчий інтерфейс, який використовуєте. Обробник, якому потрібні два методи, приймає інтерфейс із двома методами, а не весь мішок сховища.

Кореневий об'єкт

Одна структура володіє довгоживучими ресурсами:

type App struct {
    Config Config
    DB     *sql.DB
    Stores *Stores
}

func NewApp(cfg Config) (*App, error) {
    db, err := sql.Open("pgx", cfg.DSN)
    if err != nil {
        return nil, fmt.Errorf("opening database: %w", err)
    }
    if err := db.PingContext(context.Background()); err != nil {
        return nil, fmt.Errorf("database unreachable: %w", err)
    }
    return &App{Config: cfg, DB: db, Stores: NewStores(db)}, nil
}

func (a *App) Close() error { return a.DB.Close() }

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

Контейнер — це нормально; передавати його всюди — ні

Групувати сховища розумно:

type Stores struct {
    Users UserStore
    Posts PostStore
}

func NewStores(db *sql.DB) *Stores {
    return &Stores{Users: &userStore{db: db}, Posts: &postStore{db: db}}
}

Помилка — вручати цю структуру кожному компоненту:

NewHandler(stores)        // що він насправді використовує?
NewHandler(stores.Users)  // два методи, названі явно

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

Опції для необов'язкового

Обов'язкові залежності — параметри. Справді необов'язкові — кеш, рекордер метрик, хук — краще оформити як функціональні опції, патерн зі статті про функції:

type Option func(*Server)

func WithCache(c Cache) Option { return func(s *Server) { s.cache = c } }

func NewServer(store Store, opts ...Option) *Server {
    s := &Server{store: store}
    for _, o := range opts {
        o(s)
    }
    return s
}

Ви також зустрінете ланцюжкові сетери With*, що змінюють і повертають отримувач. Вони добре читаються при старті, але дозволяють недобудованому об'єкту втекти назовні — надавайте перевагу опціям, коли тип має бути валідним у ту саму мить, коли його повернули.

Розриваємо цикл імпортів

Go цілком забороняє цикли імпортів. Коли auth потребує щось із scheduler, а scheduler потребує щось із auth, виправлення майже завжди — третій пакет, що тримає спільну річ:

internal/auth       →  internal/principal  ←  internal/scheduler

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

Інше виправлення часто краще: якщо A потребує функцію з B, хай A оголосить інтерфейс, а тип B йому задовольнить. Тоді залежність вказує лише в один бік.

Плоске краще за глибоке

Один рівень пакетів під internal/ легше проходити, ніж ієрархію. Глибока вкладеність зазвичай породжує цикли й пакети, названі за шарами, а не за речами.

Називайте пакети за тим, що вони містять — store, billing, indexer — а не за тим, чим вони є — models, helpers, utils. Пакет під назвою utils не має межі, тож усе кінець кінцем осідає в ньому.

Пам'ятайте, що назва — частина кожного виклику: store.New, а не store.NewStore, бо store.NewStore затинається.

З досвіду Python: тут немає __init__.py, немає реєстрації під час імпорту, немає модуля settings, імпортованого всюди. Зв'язування явне й перевіряється на етапі компіляції, а еквівалент DI-контейнера — це літерал структури, який можна прочитати.

Швидка довідка

Питання Домовленість
бінарники один каталог на бінарник під cmd/
усе приватне internal/ — забезпечено компілятором
main прапорці, побудова, запуск, завершення; делегувати run() error
залежності параметри конструктора, неекспортовані поля
скільки брати найвужчий інтерфейс, який використовуєте
довгоживучі ресурси одна кореневa структура App із Close
структура-контейнер будувати можна, передавати всюди — ні
необов'язкові співучасники функціональні опції
цикл імпортів винести маленький спільний пакет або інвертувати через інтерфейс
назви пакетів за річчю, ніколи utils

Джерела