open atlas
↑ К треку
Go с нуля до senior GO · 05 · 03

Дизайн пакетов: имена как API, internal/, направление зависимостей и цикл, блокирующий хотфикс

Границы пакетов как дизайн API: имя пакета — префикс каждого экспорта, internal/ как инструмент видимости, принимай интерфейсы и возвращай структуры, почему гниют util/common и как давление циклических импортов выдаёт неверное направление зависимостей — с кейсом рефакторинга.

GO Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Инцидент почти закончился. Возвраты проводились дважды, причиной был двухстрочный guard, отсутствующий в billing, и дежурный инженер набрал фикс за одиннадцать минут. Потом упала сборка: import cycle not allowed: billing -> customer -> billing. Цикла не было в диффе — он два года лежал взведённым: пакет models, который импортировал каждый сервис, пакет util с девяноста одной экспортированной функцией и недавняя «маленькая» правка, заставившая customer напрямую звать биллинговый хелпер. Компилятор просто ни разу не проходил этот путь, пока хотфикс не тронул оба пакета. Распутывание графа до компилируемого состояния заняло пятьдесят минут; разбор инцидента посвятил структуре пакетов больше времени, чем возвратам. Раскладку никто не проектировал — она наросла, по одному удобному импорту за раз, пока архитектурная диаграмма, в которую все верили, и граф импортов, который проверяет компилятор, не стали двумя разными документами.

К концу этого урока ты сможешь распознать скрытый цикл до того, как он взведётся, и провести четырёхшаговый рефакторинг, который разомкнёт его без изменения поведения.

Имя пакета — первое слово вашего API

Каждый экспортированный идентификатор читается вместе с именем пакета: http.Server, json.Marshal, time.Duration. Это делает имя пакета частью API и диктует два правила. Первое: называйте пакеты по тому, что они дают, а не что содержат: billing, retry, pdf — существительное, описывающее способность. Имена вроде models, helpers, types, interfaces, common описывают разновидность кода, а значит, рано или поздно туда подходит всё в кодовой базе — так пакет превращается в гравитационный колодец, который импортируют все и который импортирует всех; models из Hook взвёл цикл ровно потому, что его типы были нужны и billing, и customer. Второе: проектируйте идентификаторы так, чтобы они читались с префиксом: http.Server, а не http.HTTPServer; billing.Invoice, а не billing.BillingInvoice. Заикание — не косметика: это признак, что автор придумывал идентификатор в отрыве от единственного способа, которым вызывающие будут его видеть. Тест здорового пакета: можете ли вы сформулировать его назначение одним предложением, не сводящимся к «разные штуки, нужные нескольким сервисам»? util проваливает тест по построению — и операционно тоже: мешок из девяноста одной функции несёт объединение зависимостей всех своих функций, так что импорт ради util.Clamp тянет AWS SDK, который кому-то понадобился для util.UploadJSON.

internal/ и направление зависимостей

Go даёт ровно один инструмент видимости выше уровня идентификаторов: пакет под директорией internal/ импортируется только кодом, укоренённым в родителе internal/. svc/internal/auth импортируем чем угодно под svc/ — и ничем снаружи; это проверяет компилятор, а не конвенция или бдительность ревью. Используйте его по умолчанию: каждый пакет рождается в internal/ и выносится наружу, только когда внешний потребитель это заслужил, — потому что всё вне internal/ в опубликованном модуле — это API, который вы поддерживаете вечно. Более глубокая дисциплина, которой служит internal/, — направление зависимостей. Решите, какие пакеты — ядро домена (billing, customer — чистая логика, почти ничего не импортирует), а какие — края (HTTP-хендлеры, консьюмеры очередей, адаптеры хранилищ), и пусть каждая стрелка импорта показывает от края к ядру, никогда обратно. Доменный пакет, импортирующий net/http ради ошибки в форме хендлера или читающий конфиг-структуру из транспортного слоя, развернул стрелку — а развёрнутые стрелки и есть то, чем взводятся циклы: каждая безобидна, пока вторая развёрнутая стрелка не замкнёт петлю.

Викторина

В модуле есть svc/internal/auth. Какой код может его импортировать?

Принимай интерфейсы, возвращай структуры

Интерфейсы Go удовлетворяются неявно, и одно это свойство решает, где интерфейсы должны объявляться: в пакете, который их потребляет, а не в том, который реализует. billing нужны данные клиентов — значит, billing объявляет двухметодный CustomerSource, который ему реально нужен; пакет customer удовлетворяет его, не импортируя billing и даже не зная о его существовании. Сравните с рефлексом в духе Java — пакет interfaces, импортируемый обеими сторонами, — который воссоздаёт проблему гравитационного колодца и привязывает каждого реализатора к толстому спекулятивному контракту. Дополняющее правило — возвращать конкретные структуры: конструктор, возвращающий *Client, позволяет добавлять методы вечно, никого не ломая, тогда как возврат интерфейса замораживает множество методов (каждое добавление ломает сторонние реализации) и прячет документацию типа. Вместе два правила держат стрелки в правильную сторону, а контракты — минимальными:

// Пакет billing — потребитель объявляет контракт, который ему нужен.
type CustomerSource interface {
	Customer(ctx context.Context, id string) (Customer, error)
}

// Customer — собственный взгляд billing: три поля, которые он использует,
// а не 40-полевая структура пакета customer.
type Customer struct {
	ID      string
	Country string
	VATID   string
}

func NewInvoicer(src CustomerSource) *Invoicer { // принимаем интерфейс…
	return &Invoicer{src: src}                   // …возвращаем конкретную структуру
}

// Пакет customer никогда не импортирует billing. Слой сборки (cmd/serve)
// импортирует оба и адаптирует: billing.NewInvoicer(customeradapter.New(repo)).

Обратите внимание на размер интерфейса: один метод. Интерфейсы на стороне потребителя остаются маленькими, потому что кодируют нужды одного потребителя; интерфейсы на стороне провайдера распухают, потому что спекулируют о нуждах всех.

Кейс: распутываем billing ⇄ customer

Вернёмся к графу из Hook. В репозитории были models (все общие структуры), util (девяносто одна функция) и сервисные пакеты, импортирующие оба плюс друг друга. Рефакторинг, который это вылечил, прошёл в четыре хода, ни один из которых не менял поведение. Один: растворить models — каждая структура переехала в пакет, владеющий её жизненным циклом (Invoice в billing, Customer в customer); пакетам, которым нужны чужие данные, достались собственные узкие view-структуры или список параметров, потому что общий тип — это связанность, а большинство «общих» типов использовались ради двух полей. Два: разорвать цикл интерфейсом на стороне потребителя — вызов из customer в биллинговый хелпер стал реализацией billing.CustomerSource, которую соединяет слой сборки, развернув стрелку обратно. Три: растворить util — каждая функция переехала к своему единственному вызывающему (у шестидесяти двух из девяноста одной он был ровно один), по-настоящему общие сложились в настоящие пакеты с настоящими именами: retry, clock, money. Четыре: огородить дерево через internal/ — всё, что не потребляется другим сервисом, уехало под него, и следующий удобный-но-неверный импорт стал ошибкой компиляции, а не спором на ревью. Вместе эти четыре хода образуют механическую последовательность: растворить гравитационные колодцы, перевернуть одну циклическую стрелку через интерфейс, рассеять утилиты по владельцам и огородить результат. Ни один из них не требует переписывания и ни один не меняет наблюдаемое поведение — они лишь перекладывают ответственности в пакеты, которые должны ими владеть. Метрика успеха была не про элегантность: упала амплификация изменений — обзор git log показал, что медианный PR раньше трогал 3,4 пакета, после — 1,6. Давление цикла — тот запах, на который надо реагировать рано: в момент, когда вам хочется импорта, который компилятор отказывается принять, дизайн сообщает, что ответственность живёт не в том месте, — ошибка цикла всегда симптом, а не болезнь.

Викторина

billing нужна одна функция из customer, но импорт создаёт billing -> customer -> billing. Какой фикс структурно верен?

Вспомните перед уходом
  1. 01
    Почему пакеты models/util/common надёжно гниют, и какой рецепт растворения был в кейсе?
  2. 02
    Объясни принимай-интерфейсы-возвращай-структуры: где объявляется интерфейс, почему это возможно благодаря неявному удовлетворению, и чем вреден возврат интерфейса из конструктора.
Итог

Дизайн пакетов в Go — это дизайн API, потому что язык даёт пакетам зубы: имя префиксует каждый экспорт, internal/ — видимость, проверяемая компилятором, а циклы импортов отвергаются наотрез. Называйте пакет по способности, которую он даёт, — billing, retry, money — и проектируйте идентификаторы читаться с префиксом (http.Server, никогда http.HTTPServer). Имена-разновидности-кода (models, util, common, helpers) принимают всё подряд, поэтому вырастают в гравитационные колодцы с объединением чужих зависимостей, импортируемые всеми и в итоге импортирующие обратно — так хотфикс из Hook встретил import cycle not allowed на одиннадцатой минуте инцидента. Пакеты по умолчанию кладите в internal/, где компилятор ограничивает импорт деревом с корнем в родителе, и выносите в публичное только то, что заслужили внешние потребители. Стрелки зависимостей — в одну сторону, от края к домену: хендлеры и адаптеры хранилищ импортируют billing; billing не импортирует ни тех ни других. Когда домену нужен соратник, он объявляет маленький интерфейс на своей стороне — неявное удовлетворение означает, что реализатор об этом не знает, — а слой сборки в cmd/ адаптирует их друг к другу; конструкторы принимают эти интерфейсы и возвращают конкретные структуры, чтобы тип мог наращивать методы, никого не ломая. Рецепт из кейса: растворить пакеты общих типов по пакетам-владельцам с узкими view-структурами, развернуть циклическую стрелку интерфейсом потребителя, рассеять util по вызывающим, огородить internal/ — и мерить успех амплификацией изменений, а не эстетикой. Давление цикла — самое дешёвое дизайн-ревью из возможных; ошибка компиляции — симптом, развёрнутая стрелка — болезнь. Теперь, когда встретишь пакет models или util, перевалившийся за тридцать экспортов, — считай, что нашёл взведённую гранату: замерь амплификацию изменений, найди развёрнутые стрелки и начни с наименьшего интерфейса, размыкающего петлю.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.