Зв'язування та структура пакетів¶
У Go немає фреймворка для впровадження залежностей у широкому вжитку, і він не потрібен. Залежності — це поля структури, які встановлюють конструктори й збирають в одному місці при старті. Дисципліна — не в контейнері, а в тому, куди ви кладете речі.
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, виправлення майже
завжди — третій пакет, що тримає спільну річ:
Зробіть його маленьким — тип, ключ, інтерфейс — без власних залежностей.
Опирайтеся спокусі створити для цього пакет 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 |