JavaRush /Курсы /Go SELF /internal/ — как огра...

internal/ — как ограничивать импорты

Go SELF
28 уровень , 1 лекция
Открыта

1. Почему «не экспортируй лишнего» иногда не спасает

Если вы уже начали делить проект на пакеты, то довольно быстро появляется соблазн: «а давайте я импортну вот тот удобный helper, он же экспортирован и так красиво лежит». И вроде бы ничего страшного, код же компилируется. Но через пару недель обнаруживается, что половина проекта знает про детали другой половины, а любая правка вызывает эффект домино.

Проблема в том, что экспорт/неэкспорт решает только вопрос доступа к именам, но не решает вопрос доступа к самому пакету. Если пакет доступен для импорта, то рано или поздно кто-нибудь (включая вас через три дня) потянет оттуда «временную штуку, пока быстро». А потом это «пока быстро» становится архитектурой.

И вот тут появляется 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, спросите себя: «кто должен иметь право это импортировать?».

4. Где размещать internal/: внутри слоя или у корня проекта

Когда вы впервые узнаёте про internal/, возникает желание сделать так:

internal/
  everything/

То есть спрятать туда вообще всё, что “не хочется светить”. Это не всегда плохо, но часто приводит к странному эффекту: вы создали “псевдо-монолит” внутри проекта, где всё дружит со всем, потому что internal стоит слишком высоко.

Тут полезно держать два сценария.

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

Второй сценарий — внутренности всего проекта (модуля). Тогда internal кладут близко к корню проекта: internal/.... В этом случае почти все пакеты внутри проекта смогут его импортировать, но внешние проекты — нет. Это удобно для общих утилит, которые не являются публичной библиотекой.

Ни один из вариантов не «правильнее» по умолчанию. Правильный — тот, который соответствует границе ответственности. И да, это тот случай, когда архитектура проявляется в структуре папок буквально.

5. Прячем валидацию в app/internal/validate

Давайте продолжим наше мини-приложение задач (условный todo). У нас уже есть:

  • domain.Task и конструктор domain.NewTask(...),
  • сценарный слой app, который оркестрирует создание и сохранение,
  • адаптер хранилища (например, память).

Пусть в пакете app у нас есть логика проверки заголовка. Сначала она могла быть просто функцией в app, но сейчас мы сделаем шаг к более строгой границе: валидация — это деталь реализации сценариев, а не то, что должен импортировать кто попало.

Было: helper внутри app

Начнём с примитивного варианта:

// app/validate.go
package app

import "errors"

var ErrBadTitle = errors.New("bad title")

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

Здесь всё вроде нормально, но у этой схемы есть подводный камень: как только у вас появятся подпакеты app/..., кто-то начнёт копировать/таскать эти helpers туда-сюда, и скоро будет «валидация в трёх местах, но по-разному».

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

Теперь сделаем так, чтобы только сценарный слой мог пользоваться этой утилитой.

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

import "errors"

var ErrBadTitle = errors.New("bad title")

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.

6. Проверяем ограничение: 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.

7. Полезные нюансы и когда 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/....

И это очень приятная штука: вы можете держать проект аккуратным, не превращая корень в «помойку util-ов», но и не раскрывая лишнего.

Где internal/ особенно уместен

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

Хороший кандидат — “внутренние реализации”, которые часто меняются. Например, вы хотите иметь в app несколько маленьких помощников: нормализация текста, парсинг каких-то входных строк, подготовка сообщений, простая валидация. Если эти вещи импортнёт внешний слой, вы перестанете свободно их менять.

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

А вот плохой кандидат — “настоящий контракт”. Если другой слой должен пользоваться чем-то, то это должно быть в публичном API: либо в domain, либо в app, либо в отдельном пакете, предназначенном для использования. internal не должен быть способом «спрятать то, что нужно всем», иначе вы будете постоянно бороться с собственными границами.

internal/ — это про импорт, а не про экспорт

Если вы привыкли думать “публичное/приватное” через заглавные/строчные буквы, то internal/ сначала ощущается странно: «а почему я могу написать Validate.Title, но не могу импортировать пакет?».

Потому что механизм работает на уровне возможности импорта, а не на уровне имён внутри пакета. Это прямо подчёркивается в описании механизма: go проверяет путь импорта, и если в нём есть элемент internal, то проверяет, находится ли импортирующий код внутри нужного поддерева.

Это значит, что внутри internal вы можете иметь экспортируемые идентификаторы (и это нормально). Они просто экспортируемы только для “своих”, то есть для тех, кто имеет право импортировать этот пакет.

8. Типичные ошибки при работе с 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/... разрешён только из определённого поддерева.

1
Задача
Go SELF, 28 уровень, 1 лекция
Недоступна
Внутреннее приветствие
Внутреннее приветствие
1
Задача
Go SELF, 28 уровень, 1 лекция
Недоступна
Проверка заголовка
Проверка заголовка
1
Задача
Go SELF, 28 уровень, 1 лекция
Недоступна
Каноническое имя
Каноническое имя
1
Задача
Go SELF, 28 уровень, 1 лекция
Недоступна
Скрытая статистика
Скрытая статистика
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ