JavaRush /Курси /Go SELF /internal/ — як обмежу...

internal/ — як обмежувати імпорти

Go SELF
Рівень 28 , Лекція 1
Відкрита

1. Чому правило «не експортуй зайвого» інколи не рятує

Якщо ви вже почали ділити проєкт на пакети, то досить швидко з’являється спокуса: «А давайте я імпортуватиму отой зручний хелпер — він же експортований і так гарно лежить». І начебто нічого страшного: код же компілюється. Але за кілька тижнів виявляється, що половина проєкту знає про деталі іншої половини, а будь-яка правка запускає ефект доміно.

Проблема в тому, що експортовані й неекспортовані елементи розв’язують лише питання доступу до імен, але не розв’язують питання доступу до самого пакета. Якщо пакет доступний для імпорту, то рано чи пізно хтось — навіть ви за три дні — потягне звідти «тимчасову штуку, поки швидко». А потім це «поки швидко» стає архітектурою.

І от тут з’являється internal/: це спосіб сказати інструментам Go: «цей пакет — внутрішній, імпортувати його ззовні не можна, навіть якщо дуже хочеться».

Що таке internal/ і яке правило перевіряє Go

Суть механізму проста й водночас дуже «по-Go»: жодних анотацій, конфігів і магічних ключових слів. Є лише структура каталогів і правило, яке перевіряє go tool.

Go підтримує «internal packages» починаючи з Go 1.4: якщо пакет лежить у директорії internal (або всередині неї), то його не можна імпортувати з коду, який знаходиться поза дозволеним піддеревом. Правило описано прямо в документації: пакет виду .../a/b/c/internal/d/e/f можна імпортувати лише кодом усередині дерева .../a/b/c.

Те саме пояснює довідка go help packages: «код у директорії internal імпортуємо лише з дерева, коренем якого є батько internal».

Пояснімо це не на абстрактних a/b/c, а на людському прикладі. Уявімо структуру:

example.com/myapp/
  app/
    internal/
      validate/
  cmd/
    myapp/
  domain/

Якщо у нас є пакет example.com/myapp/app/internal/validate, то імпортувати його мають право лише пакети всередині example.com/myapp/app/..., тому що батько internal — це app. Пакети з cmd/... або domain/... — уже «ззовні», їм не можна.

Це важливо: internal/ не робить пакет «приватним за іменами», він робить його приватним за місцем у дереві.

2. Мапа доступу: хто може імпортувати кого

Зараз буде мінітаблиця, щоб не доводилося тримати все в голові (вона й так зайнята тим, щоб не забути поставити , у import).

Припустімо, у нас є такий пакет: example.com/myapp/app/internal/validate

Тоді обмеження будуть приблизно такі:

Пакет-імпортер Чи можна імпортувати app/internal/validate? Чому
example.com/myapp/app
так він усередині дерева app/...
example.com/myapp/app/usecase
так він усередині дерева app/...
example.com/myapp/cmd/myapp
ні cmd/... не всередині app/...
example.com/myapp/domain
ні domain/... не всередині app/...

Ця проста таблиця — уже половина успіху під час проєктування: перш ніж створювати internal, запитайте себе: «Хто має право це імпортувати?».

3. Де розміщувати internal/: усередині шару чи біля кореня проєкту

Коли ви вперше дізнаєтеся про internal/, виникає бажання зробити так:

internal/
  everything/

Тобто сховати туди взагалі все, що «не хочеться показувати». Це не завжди погано, але часто призводить до дивного ефекту: ви створили «псевдомоноліт» усередині проєкту, де все дружить із усім, бо internal стоїть занадто високо.

Тут доречно розрізняти два сценарії.

Перший сценарій — внутрішня частина конкретного шару. Тоді internal живе всередині шару, наприклад app/internal/.... Це означає: «внутрішні речі доступні лише сценарному шару та його підпакетам». Це чудовий спосіб не дати CLI/HTTP-шару лізти в чужу внутрішню логіку.

Другий сценарій — внутрішня частина всього проєкту (модуля). Тоді internal кладуть близько до кореня проєкту: internal/.... У цьому випадку майже всі пакети всередині проєкту зможуть його імпортувати, але зовнішні проєкти — ні. Це зручно для спільних утиліт, які не є публічною бібліотекою.

Жоден із варіантів не є «правильнішим» за замовчуванням. Правильний — той, що відповідає межі відповідальності. І так, це той випадок, коли архітектура буквально проступає в структурі папок.

4. Ховаємо валідацію в app/internal/validate

Продовжімо наш умовний todo-проєкт. У нас уже є:

  • domain.Task і конструктор domain.NewTask(...),
  • сценарний шар app, який оркеструє створення та збереження,
  • адаптер сховища (наприклад, пам’ять).

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

Було: допоміжна функція усередині app

Почнімо з простого варіанта:

// app/validate.go
package app

import "errors"

var ErrBadTitle = errors.New("некоректний заголовок")

func validateTitle(title string) error {
	if title == "" {
		return ErrBadTitle
	}
	return nil
}

Тут усе начебто нормально, але в цієї схеми є підводний камінь: щойно у вас з’являться підпакети app/..., хтось почне копіювати або тягнути ці хелпери туди-сюди, і скоро буде «валідація в трьох місцях, але по-різному».

Стало: переносимо в app/internal/validate

Тепер зробімо так, щоб лише сценарний шар міг користуватися цією утилітою.

// app/internal/validate/title.go
package validate

import "errors"

var ErrBadTitle = errors.New("некоректний заголовок")

func Title(s string) error {
	if s == "" {
		return ErrBadTitle
	}
	return nil
}

Зверніть увагу: пакет називається validate, а не internalinternal це ім’я папки, а не ім’я пакета. Ім’я пакета має бути нормальним, щоб імпорт виглядав читабельно.

Використовуємо всередині app

Тепер у app ми імпортуємо внутрішній пакет:

// app/add_task.go
package app

import (
	"context"

	"example.com/myapp/app/internal/validate"
)

func AddTask(ctx context.Context, store TaskStore, title string) error {
	if err := validate.Title(title); err != nil {
		return err
	}
	return nil
}

Тут приклад короткий і не робить реального збереження (ми його вже писали раніше), але сенс видно: validate став внутрішньою деталлю app.

5. Перевіряємо обмеження: use of internal package not allowed

Зараз буде момент, який багато хто запам’ятовує найкраще: ви не домовляєтеся з командою, ви домовляєтеся з компілятором (ну гаразд, із go tool, але звучить менш епічно).

Уявімо, що в cmd/myapp/main.go хтось вирішив повторно використати валідацію, бо «ну а що такого».

// cmd/myapp/main.go
package main

import (
	"example.com/myapp/app/internal/validate"
)

func main() {
	_ = validate.Title("hello")
}

І от тут go вам чесно скаже: не можна. Це рівно те, чого ми й добивалися: CLI не має лізти в внутрішню частину сценарного шару.

Формулювання правила в офіційній довідці go help packages якраз про це: імпорт пакета з internal допустимий лише всередині дерева батька internal.

6. Корисні нюанси й коли internal/ доречний

Кілька internal/ в одному проєкті — це нормально

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

Наприклад, така структура цілком має сенс:

example.com/myapp/
  internal/
    buildinfo/         (внутрішня частина всього проєкту)
  app/
    internal/
      validate/        (внутрішня частина app-шару)
  adapters/
    memstore/
      internal/
        debug/         (внутрішня частина конкретного адаптера)

Логіка тут така: internal/buildinfo доступний усім пакетам проєкту, але не зовнішнім. app/internal/validate доступний лише всередині app/.... А adapters/memstore/internal/debug доступний лише всередині adapters/memstore/....

І це дуже приємна штука: ви можете тримати проєкт охайним, не перетворюючи корінь на «звалище утиліт», але й не розкриваючи зайвого.

Де internal/ особливо доречний

Зараз важливо не «зробити internal заради internal», а застосовувати його там, де він справді знижує зв’язність і захищає архітектуру.

Хороший кандидат — внутрішні реалізації, які часто змінюються. Наприклад, ви хочете мати в app кілька маленьких помічників: нормалізацію тексту, парсинг якихось вхідних рядків, підготовку повідомлень, просту валідацію. Якщо ці речі імпортуватиме зовнішній шар, ви перестанете вільно їх змінювати.

Ще один кандидат — речі, які виглядають надто спокусливо для повторного використання, але не повинні повторно використовуватися. Типовий приклад: який-небудь internal/sqlutil або internal/httpjson. На перших порах здається класним тягнути це всюди. Потім виявляється, що ви випадково зробили «спільний шар», від якого тепер залежать усі, і будь-яка зміна потребує синхронного виправлення половини проєкту.

А от поганий кандидат — справжній контракт. Якщо інший шар має користуватися чимось, то це має бути в публічному API: або в domain, або в app, або в окремому пакеті, призначеному для використання. internal не має бути способом «сховати те, що потрібно всім», інакше ви постійно боротиметеся з власними межами.

internal/ — це про імпорт, а не про експорт

Якщо ви звикли думати про публічне й приватне через великі та малі літери, то internal/ спочатку сприймається дивно: «А чому я можу написати Validate.Title, але не можу імпортувати пакет?».

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

Це означає, що всередині internal ви можете мати експортовані ідентифікатори — і це нормально. Вони просто експортовані лише для «своїх», тобто для тих, хто має право імпортувати цей пакет.

7. Типові помилки під час роботи з internal/

Помилка № 1: покласти internal/ занадто високо й отримати «спільне звалище».
Якщо ви створюєте internal у корені проєкту й починаєте складати туди все підряд, то формально ви «сховали від зовнішнього світу», але всередині проєкту зробили гігантський спільний пакетний суп. У якийсь момент майже кожен пакет почне імпортувати internal/..., і він стане прихованим монолітом. Лікується це не забороною, а дизайном: тримайте internal ближче до того шару, чиї внутрішні речі ви захищаєте.

Помилка № 2: покласти internal/ занадто низько й випадково заборонити «своїм» користуватися потрібним кодом.
Іноді роблять app/usecase/internal/..., а потім дивуються, чому app/service не може імпортувати. Причина проста: межа рахується за батьком конкретної папки internal. Якщо вам потрібно, щоб увесь app/... бачив внутрішні речі, то internal має лежати на рівні app/internal/..., а не глибше.

Помилка № 3: використовувати internal/ як заміну нормального контракту.
Якщо пакет потрібен кільком шарам, то він не «внутрішній», а спільний. Ховати його в internal — це як сховати спільний чайник у шафу й видавати ключ за розкладом: технічно можна, але команда почне ненавидіти і шафу, і чай. Краще підняти контракт у правильний шар (часто це domain або публічний пакет app).

Помилка № 4: намагатися «обійти» заборону копіюванням коду або дивними шляхами імпорту.
Заборона існує не для того, щоб ускладнити вам життя, а щоб показати: ви намагаєтеся протягнути залежність через неправильну межу. Якщо дуже хочеться імпортувати app/internal/... із cmd/..., зазвичай це сигнал: або в cmd не має бути цієї логіки, або ця логіка має бути оформлена як публічна частина app (через функцію чи метод), або винесена в інший пакет за відповідальністю.

Помилка № 5: забути, що помилка буде на рівні імпорту, а не «в рантаймі».
internal/ хороший тим, що ламає збірку одразу. Але новачки інколи сприймають це як «ой, Go дивний». Насправді він просто чесно застосовує правило з документації: імпорт .../internal/... дозволений лише з певного піддерева.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ