Напрямок залежностей¶
Який пакет може імпортувати який — це те архітектурне рішення, яке важко скасувати. Go робить частину цього механічною: цикли імпортів — це помилка компіляції — але решта є правилом, яке доводиться тримати свідомо.
І веб-шар, і шар зберігання залежать від контрактів. Контракти не залежать ні від чого.
Спрямовуйте залежності на абстракції¶
Наївне шарування змушує обробник імпортувати пакет сховища напряму. Це
прив'язує ваш HTTP-шар до database/sql, і кожен тест обробника
потребує базу даних.
Інвертуючи це: середній пакет оголошує інтерфейси й доменні типи, і обидві сторони залежать від нього.
// пакет services — контракт. Жодних імпортів бази даних узагалі.
type UserStore interface {
ByID(ctx context.Context, id int64) (User, error)
}
type User struct {
ID int64
Name string
}
var ErrUserNotFound = errors.New("user not found")
// пакет storage — реалізація
type userStore struct{ db *sql.DB }
func (s userStore) ByID(ctx context.Context, id int64) (services.User, error) { ... }
web ніколи не імпортує storage. Тільки код старту, який уже знає
все, з'єднує одне з іншим.
Де живе інтерфейс¶
Звичайна порада Go — "оголошуйте інтерфейс там, де він споживається", і для одноразового співучасника це правильно — маленький неекспортований інтерфейс поруч з обробником, що його використовує.
Центральний пакет контрактів — це варіант, що масштабується: одне місце, яке перелічує кожну операцію зберігання, наявну в застосунку, тож межа видима, а не розкидана. Компроміс у тому, що пакет росте, і інтерфейс у ньому може накопичити методи, які не потрібні жодному окремому споживачу.
Обидва варіанти законні. Що незаконне — оголошувати інтерфейс у пакеті реалізації — це спрямовує залежність не в той бік і зводить нанівець усю вправу.
Тримайте дескриптор бази даних подалі від верхніх шарів¶
Конкретне правило, що з цього випливає: *sql.DB має з'являтися рівно
у двох місцях — у коді, що його відкриває, і в коді, що виконує
запити.
Якщо обробник може назвати *sql.DB, він може виконати запит, і рано
чи пізно виконає. Тоді сховище перестає бути єдиним шляхом до даних, і
на питання "де записується ця таблиця" відповіді немає.
Те саме стосується пакета контрактів. Якщо services імпортує
database/sql, щоб описати тип повернення, абстракція протекла, і
кожен споживач успадковує залежність.
Помилки теж перетинають межу¶
Контракт — це не лише сигнатури методів. Просочування sql.ErrNoRows
угору прив'язує виклики до рушія зберігання так само надійно, як і
просочування дескриптора. Перекладайте на межі, як показує стаття про
патерн репозиторію:
if errors.Is(err, sql.ErrNoRows) {
return services.User{}, fmt.Errorf("id %d: %w", id, services.ErrUserNotFound)
}
Сигнальна помилка (sentinel error) належить пакету контрактів, тож виклики залежать від контракту і на щасливому, і на нещасливому шляху.
Напрямок важливіший за назви¶
Папки під назвами handlers, services і repositories — це не
архітектура. Пакет services, що імпортує storage, має ту саму
зв'язність, що й обробник, який робить це напряму, лише з додатковою
непрямістю.
Питайте натомість:
- Якби я видалив пакет сховища, чи скомпілювалась би решта проти контрактів? Мала б.
- Чи можу я протестувати бізнес-логіку без жодної бази даних? Маєте змогти.
- Чи щось вище сховища імпортує
database/sql? Не має.
Три відповіді так/ні кращі за будь-яку домовленість про папки.
Цикли — це симптом, не хвороба¶
Цикл імпортів — це спосіб Go сказати, що два пакети насправді є одним, або що щось спільне потребує винесення. Виправлення, у порядку переваги:
- Інвертуйте через інтерфейс. Якщо
Aпотребує функцію зB, аBпотребує тип зA, хайAоголосить інтерфейс, який йому потрібен, аBйому задовольнить. Тепер залежність вказує лише в один бік. - Винесіть спільну річ у маленький третій пакет без власних залежностей — тип, ключ, сигнальну помилку.
- Об'єднайте їх, якщо це справді одна турбота, розділена з косметичних причин.
Чого не варто робити — додавати interface{} чи колбек лише для того,
щоб обійти компілятор. Цикл — це інформація; прийміть її.
Забезпечення правила¶
Проза в README не переживає зіткнення з дедлайном. Правила тут — жодного
*sql.DB вище сховища, жодних імпортів бази даних у пакеті контрактів —
механічно перевірні, і лінтер може провалити збірку на них. Це
конфігурація стороннього інструменту, тож вона живе поруч з іншими
зовнішніми бібліотеками. Рішення про дизайн — ця стаття; забезпечення —
конфігураційний файл.
З досвіду Python: та сама ідея інверсії залежностей, мінус абстрактні базові класи й реєстрація. Компілятор забезпечує ациклічність, чого Python не робить, тож цикл — це жорстка помилка, а не тонкий баг у порядку імпортів.
Швидка довідка¶
| Питання | Відповідь |
|---|---|
| де живуть інтерфейси | на боці споживача або в центральному пакеті контрактів |
| де вони не мають жити | у пакеті реалізації |
що може називати *sql.DB |
код, що його відкриває, і сховища |
| що імпортує пакет контрактів | не database/sql, не сховища |
| помилки зберігання | перекладати на доменні сигнальні помилки на межі |
| що означає цикл імпортів | інвертувати через інтерфейс або винести маленький пакет |
| справжня перевірка | чи компілюється й тестується бізнес-логіка без бази даних |