InquirerSouthern Leyte seeks Maasin-Clark flight as airport rehab advancesPunchNSCDC arrests ex-AEDC worker, others over N350m cable theftוואלה32 רקטות מוכנות לשיגור: צה"ל איתר בדרום לבנון - טרם הפסקת האשDaily MaverickWHAT’S COOKING: A trio of venison recipes from the heart of the KarooESPN59 points for the Bears? 41 for the Ravens? Let's size up four NFL offenses that erupted in Week 1Bollywood HungamaEXCLUSIVE: Himesh Reshammiya reunites with Vikram Bhatt after 13 years for 1920: Cold Winter; horror flick to be shot in MussoorieRTP DesportoI Liga. Braga Vence Estoril por 1-0 com Golo Decisivo de Jonas WindThe Jerusalem PostRussian frigate fires flares at NATO member Denmark's military helicopter in international watersInquirer Entertainment‘Forgotten Island’ introduces underrepresented Filipino culture to the worldThe South AfricanUnited Rugby Championship: All player moves, transfers for SA teamsBBC News BrasilAO VIVO: Caso Moraes-Vorcaro é analisado em sessão plenária pelo STF; acompanheUOLGoverno acredita que decisão firme do STF sobre Moraes reduz margem para intervenção dos EUA
The Daily Newsstand · Free, Always
Tuesday, September 15, 2026

Падающая сборка, и это я её просил

Translate

Два пула. Соединения к базе понадобились в двух местах, и в каждом честно вызвали NewPool(). Компилируется, работает, тесты зелёные. Обнаруживается по графику числа коннектов через месяц, когда база начинает упираться в лимит.

Не тот кэш. Есть два Cache — общий и локальный для тяжёлых выборок. В один сервис передали не тот. Типы совпадают, компилятор доволен, поведение правдоподобное: просто иногда данные свежее, чем должны быть, а иногда наоборот.

Разъехавшиеся ветки. Сборка в main() разветвлена по флагу. В одной ветке компонент собрали, в другой забыли — потому что ветки правили в разное время и разные люди. Фича включена, в конфиге всё верно, а обслуживать её некому.

«Надо было руками» — здесь не ответ. Всё перечисленное написано руками. Забыть аргумент конструктора при ручной сборке нельзя, это правда — не скомпилируется. Но никто и не забывает аргумент. Ошибаются в том, какой экземпляр передать, сколько их создать и что делать в соседней ветке, а на это у компилятора нет ни одной зацепки: типы-то верные.

Рантайм-контейнеры — fx, samber/do, моя собственная первая версия — часть этого ловят: отсутствующую зависимость видно при старте, и это лучше, чем в проде. Взамен они приносят свой класс отказов, тот самый nil pointer dereference на ручке, которую забыли протестировать, и проверяются всё равно запуском.

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

В первой статье я рассказывал, как перенёс разбор запроса из рантайма в компиляцию, во второй — как документация перестала быть вторым источником правды и стала выводом из первого. Здесь третий инструмент из той же серии, и по времени он был первым. Называется он diogen: compile-time DI для Go, без рефлексии. Читает объявления, строит граф и печатает обычный Go-код — внедрение зависимостей целиком уезжает в компиляцию, рантайм-контейнера после него не остаётся.

Чем плохи обычные ответы: руками, fx, wire

Руками в main(). Честный способ: всё видно, никакой магии, и аргументы конструкторов действительно проверяет компилятор. Я так писал несколько лет.

Он не падает — он тихо расходится. Три истории из начала статьи ровно про это: на сотне компонентов функция сборки перестаёт читаться целиком, ветки правятся по отдельности, а «этот кэш или тот» становится вопросом внимательности. Отказ не исчезает, он просто перестаёт быть заметным.

Контейнер в рантайме (fx, samber/do). Отсутствующая зависимость переезжает со «случайного запроса» на «старт процесса», и это большой шаг. Но проверка по-прежнему делается запуском, через рефлексию, на CI её надо специально организовать, — а «не тот экземпляр» и «два пула» он не ловит вообще, потому что с его точки зрения всё зарегистрировано верно.

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

Связывание — это не код

Связывание — это объявление. «Кэш реализуется редисом», «этому сервису нужен репозиторий пользователей» — утверждения, а не действия. У них нет исполнения, они просто либо согласуются между собой, либо нет.

А согласованность объявлений умеет проверять компилятор. Значит вопрос не в том, как поудобнее собрать граф в рантайме, а в том, почему проверка вообще отложена до рантайма.

И тут видно, чего в существующих моделях не хватает. Они описывают наличие: какой тип нужен и кто его производит. Три истории из начала не про наличие, они про идентичность — который из двух одинаковых по типу, при каком условии он вообще создаётся, кому предназначен. Ответы на это есть и там, но за пределами объявления: у fx строковым именем, которое не проверяет никто, у wire отдельным типом-обёрткой, заведённой ради контейнера. То есть либо строка, либо правка в вашем доменном коде, сделанная, чтобы удовлетворить сборщик.

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

Дальше — как это выглядит.

Что пишется руками

func setup() {
    di.Wire[Cache](NewRedisCache)
    di.Wire[UserRepository](postgres.NewUserRepository)
    di.Define(NewUserService)
}

di.Wire[T](конструктор) — «T реализуется вот этим». di.Define(конструктор) — то же, но под собственным типом, без интерфейса. Аргументы конструкторов никто не перечисляет: они и так объявлены в их сигнатурах.

Лежит это в обычном файле обычного пакета — импорт, типы, конструкторы и setup() рядом, — а запускается директивой оттуда же:

//go:generate diogen -output=internal/di/gen.go $GOFILE

Саму setup() никто не вызывает, ни здесь, ни где-либо ещё: она существует затем, чтобы её прочитал генератор.

Пакет di при этом — заглушки. Ни одна из этих функций ничего не делает и в рантайме не вызывается: они существуют, чтобы разметка была обычным Go, который проверяет компилятор, а генератор мог прочитать её как AST с полной информацией о типах.

Это, кстати, важнее, чем кажется. Опечатались в имени конструктора — ошибка компиляции, а не отсутствующая регистрация. Переименовали тип — не собирается разметка, а не «в контейнере не нашлось Cache».

И раньше компилятора это видит IDE. Разметка — обычный Go с настоящими типами, поэтому в ней работает всё штатное: автодополнение имён конструкторов, переход к определению, переименование типа сразу по всем регистрациям, поиск использований. Опечатка подчёркивается красным в редакторе — не после go generate, не после go build, а пока вы её печатаете. Для DSL в YAML или в строковых ключах не работает ничего из перечисленного.

Что получается

go generate ./...

Рядом появляется файл с контейнером. Вот он для случая посложнее — с условием, и это настоящий вывод из тестового набора. Разметка:

func setup() {
    if env := os.Getenv("CACHE_TYPE"); env == "redis" {
        di.Wire[Cache](NewRedisCache)
    } else {
        di.Wire[Cache](NewMemoryCache)
    }
}

Сгенерированное:

func InitAppWith(replacements Replacements) (_ *Application, err error) {
    var (
        container = &Application{}

        env_1     = os.Getenv("CACHE_TYPE")
        c4e7fd691 = env_1 == "redis"
    )

    container.conditionalCache = replacements.ConditionalCache

    if container.conditionalCache == nil && c4e7fd691 {
        s := NewRedisCache()
        container.conditionalCache = s
    }

    if container.conditionalCache == nil && !c4e7fd691 {
        s := NewMemoryCache()
        container.conditionalCache = s
    }

    if v, ok := container.conditionalCache.(io.Closer); ok {
        container.closers = append(container.closers, v)
    }

    return container, nil
}

Первая строка тела — replacements. Это второй вход в приложение, из которого получается компонентный тест на настоящем графе и без инфраструктуры; про него отдельный раздел ниже.

Три места в этом выводе стоит разобрать отдельно: в каждом я выбирал форму, а не записывал единственно возможную.

Условие вынесено в переменную. os.Getenv вызывается один раз, результат складывается в env_1, а само сравнение — в c4e7fd691. Если то же условие охраняет пятнадцать компонентов, оно всё равно вычисляется однажды. Имя, разумеется, машинное: это хеш от текста выражения, и человеку его читать не надо.

Обе реализации в файле, каждая под своим условием. Не «выбор при сборке» и не «выбор рефлексией при старте», а обычный if в обычном Go. Компилятор видит оба конструктора и проверяет оба.

io.Closer собирается автоматически. Что реализует — попадёт в список, и Shutdown() закроет всё в обратном порядке создания. Это ровно тот код, который пишут руками и в котором путают порядок.

Коллекции, которые никто не собирал

Ещё одна опция разметки, и она оказалась самой используемой. Помечаем реализации тегом, потребитель забирает их списком:

di.Wire[Handler](NewA, di.Tag("handler"))
di.Wire[Handler](NewB, di.Tag("handler"))
di.Define(NewRouter, di.Args(map[string]any{
    "handlers": di.Tags[Handler]("handler"),
}))

В контейнере из этого выходит буквально литерал среза:

s := NewRouter([]Handler{container.tagsHandlerFromNewA, container.tagsHandlerFromNewB})

Так собираются middleware, интерцепторы и команды: инструмент регистрирует себя тегом, а сервис ничего про него не знает. Обратите внимание на имена полей — tagsHandlerFromNewA. Двух регистраций одного интерфейса типом не различить, поэтому в имя добавляется конструктор.

Одну вещь про теги надо сказать прямо: это множество, а не список, и так и задумано. Порядок среза стабилен между генерациями, но контрактом не является — между пакетами он идёт по порядку blank-импортов, а их сортирует gofmt, так что попытка упорядочить цепочку перестановкой строк молча отменится при сохранении.

Это не мой недосмотр: у fx value groups устроены так же и по той же причине. Как только состав коллекции собирается из пакетов, которые друг о друге не знают, порядок перестаёт быть чьим-либо решением.

Где порядок важен — цепочка middleware, стек интерцепторов, — собирайте руками через di.Args, чтобы он лежал в одном читаемом месте:

di.Define(NewServer, di.Args(map[string]any{
    "chain": []mw.Middleware{mw.Tracing, mw.Errmap, mw.Auth},
}))

Значение из di.Args генератор переносит в контейнер выражением, дословно, и сам добавляет нужный импорт. Поэтому ссылки внутри должны быть квалифицированными: контейнер обычно живёт в отдельном пакете, и голое Tracing из пакета разметки там уже ничего не значит.

Две первые истории

Вернусь к началу. «Два пула» и «не тот кэш» — обе про экземпляры, и обе закрываются двумя опциями разметки.

Один экземпляр под несколькими интерфейсами — di.Alias. Компонент реализует два интерфейса, потребители просят каждый свой, объект нужен один:

di.Define(NewAuthService, di.Alias[Authenticator](), di.Alias[Authorizer]())
di.Define(NewUserController)   // ему нужен Authenticator
di.Define(NewAdminController)  // ему нужен Authorizer

В контейнере:

if container.aliasAuthenticator == nil {
    s := NewAuthService()
    container.aliasAuthenticator = s
    container.aliasAuthorizer = s
}

Конструктор вызван один раз, полей два, s в обоих одно и то же. Это и есть ответ на «два пула»: не «не забудьте переиспользовать», а «второго экземпляра тут взяться неоткуда». А зарегистрировать тот же конструктор второй раз генератор не даст: два провайдера на один слот — это ошибка ambiguous providers из следующего раздела, и в ней перечислены оба места регистрации с номерами строк.

Кому какой экземпляр — di.For. Две реализации одного интерфейса, и одна из них предназначена конкретному потребителю:

di.Wire[UserRepo](NewDefaultRepo)
di.Wire[UserRepo](NewAdminRepo, di.For[AdminService]())
di.Define(NewAdminService)
di.Define(NewProfileService)

В контейнере два поля, и раздаются они не по внимательности:

s := NewAdminService(container.for_contextualUserRepoForFor_contextualAdminService)
...
s := NewProfileService(container.for_contextualUserRepo)

Имена полей несут префикс пакета, поэтому в тестовом пакете for_contextual они такие длинные; смысловая часть — суффикс For….

Но главное здесь не di.For, а то, что без него не собирается. Два провайдера одного слота, чьи условия не исключают друг друга, — ошибка генерации, и одна из подсказок в ней ровно про di.For. То есть «в системе два Cache» перестаёт быть фоновым фактом, о котором надо помнить: генератор требует сказать, чем они различаются, — условием, di.For или приоритетом. «Не тот кэш» не в том, что человек невнимателен, а в том, что раньше его никто не спрашивал.

Обе истории от этого меняют вид: не «будьте аккуратнее», а «скажите, что вы имели в виду, иначе не соберётся». Третья, как выяснится, того же вида — но до неё я дойду через два раздела.

Вот здесь wire и не подошёл

Обещанные три причины. Всё, что выше, компилятор проверяет и у wire — он от Google, появился сильно раньше, и начинал я именно с него.

Первая причина — условия. У нас на одной кодовой базе живут две площадки маркетплейса: кодовая база одна, приложение одно, а PROJECT=gws и PROJECT=dl поднимают его в разных режимах — разные наборы сервисов, местами разные реализации одних и тех же интерфейсов. Отсюда, кстати, и третья история из начала статьи: ветки правятся по отдельности именно потому, что их две.

У wire нет понятия рантайм-условия. Выразить это означало бы отдельный инжектор на каждую комбинацию флагов — а комбинаций больше, чем площадок.

Вторая причина — blank-импорты. Фреймворковый пакет привозит собственную разметку, а сервис подписывается одной строкой:

import (
    _ "gitlab.com/gorib/waffle/config/service/sql"
    _ "gitlab.com/gorib/waffle/config/tools/http"
)

Генератор читает AST импортированного пакета и вливает его регистрации в контейнер. В wire потребитель обязан назвать каждый provider set, который берёт, — значит добавление инструмента правится и в инструменте, и в каждом сервисе, который его использует. При двух десятках сервисов это ощутимо.

Третья причина мельче, но именно она в своё время меня и остановила: закрытие ресурсов. У wire cleanup есть, и порядок он держит сам — но это часть подписи провайдера: чтобы компонент закрывался, конструктор должен возвращать (T, func(), error). То есть на каждый закрываемый компонент правится конструктор, и правится ровно затем, чтобы удовлетворить контейнер. Здесь вместо этого проверяется тип готового значения — реализует io.Closer, попадёт в список — и конструкторы не знают, что живут в контейнере. На 1246 регистрациях разница между «ничего не трогаем» и «дописываем возврат в каждый второй конструктор» решает.

Все три об одном и том же: то, чего модель не выражает, доплачивается кодом — отдельным инжектором на комбинацию, перечислением наборов у потребителя, возвратом в подписи конструктора.

Все три возражения — про мой проект: именно они меня тогда и остановили. Разница в возможностях шире — из показанного выше di.For в wire не выражается, а коллекций по тегу там нет вовсе, они есть у fx. Зато di.Alias отличием не является: wire.Bind делает ровно то же.

Чего генератор делать не станет

Вот на чём перенос отказа окупается: каждая ошибка ниже — проблема, которая раньше находилась запуском.

Сообщения ниже настоящие, но я обрезал в них пути: в жизни там полные import path типов и абсолютные пути файлов.

Зависимости нет:

graph error: dependency for db (DB) in Greeter is not found

Цикл:

graph error: circular dependency: A → B → A

Провайдеров несколько, и их условия не исключают друг друга:

resolve providers: ambiguous providers for Cache:
  NewMemoryCache (setup.go:21), NewRedisCache (setup.go:22)
  — pick one or distinguish with di.For / mutually exclusive conditions

Обратите внимание на оговорку про условия: два провайдера одного слота — норма, если их условия исключают друг друга. if/else — очевидный случай, но два отдельных if с enabled() и !enabled() тоже считаются исключающими. Ошибка только когда они могут сработать оба.

Одна регистрация написана дважды, и обе живы:

NewTracing (setup.go:21) is registered twice and both registrations are built:
  they would share the container field interceptorFromNewTracing and the second
  would overwrite the first — register it once, or give the second registration
  its own constructor

Тегированную регистрацию просят как обычную зависимость:

graph error: dependency for h (Handler) in *Audit is provided only by tagged
  registrations (NewA (setup.go:29), NewB (setup.go:30)): a di.Tag registration
  is a collection member — collect them with di.Tags, or drop the tag to wire it
  directly

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

Все эти сообщения приходят от go generate, то есть до коммита.

Третья история, и как я её нашёл

Провайдер под условием, потребитель без условия:

if os.Getenv("FEATURE") == "on" {
    di.Wire[Cache](NewCache)
}
di.Define(NewService) // а ему Cache нужен всегда

Собирается, и в контейнере NewService(container.cache) вызывается безусловно. При выключенной фиче — с nil. Ровно тот класс, за который отвечает генератор: компилируется и неверно.

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

if os.Getenv("CACHE_TYPE") == "redis" {
    di.Wire[Cache](NewRedisCache)
} else {
    di.Panic(errors.New("CACHE_TYPE must be redis"))
}

Пока пишешь так, дырки нет — она есть у того, кто так не пишет. А тут я, набирая пример для этой самой статьи, отвлёкся на середине, вернулся к файлу и else дописывать не стал. Запустил генератор — он молча отдал контейнер. Удивился: по логике всего остального он был обязан отказаться. Полез проверять — отказываться он не умел вообще.

Теперь умеет, и первым, кому отказал, был тот самый недописанный файл:

dependency for cache (Cache) in NewService (setup.go:25) is unset when
  os.Getenv("FEATURE") == "on" is false: the registrations that fill it are all
  conditional and the branches do not cover each other — add an else branch, an
  unconditional provider, or di.Panic to declare the gap

Проверка не гадает. Каждое условие — один булев атом, гард регистрации — конъюнкция атомов, и поле покрыто, если дизъюнкция гардов его провайдеров выполняется везде, где выполняется гард потребителя. Атомов вокруг одного поля единицы, так что это перебор присваиваний, а не SMT. Закрыть ветку можно тремя способами: else, безусловный провайдер (включая di.Fallback) или та самая di.Panic — паники выполняются раньше всех конструкторов, поэтому с nil ничего собрано не будет.

Попутно выяснилось, что комплементарность генератор до этого видел только у пары if/else — сравнивались узлы AST. Два отдельных if enabled() и if !enabled() он считал независимыми и падал на них с ambiguous providers, хотя они исчерпывающие. Теперь сравнение по каноническому литералу: !x снимается, != сворачивается в ==. Одной ложной ошибкой меньше.

Чего проверка не делает — не рассуждает о значениях за условиями:

if mode == "a" { di.Wire[Cache](NewA) }
if mode == "b" { di.Wire[Cache](NewB) }

Два независимых булева, и генератор сообщит о дырке. Это не ложное срабатывание: mode == "c" действительно оставляет nil. Закрывается всё тем же else или паникой.

Подмена, которую проверяет компилятор

Компонентному тесту обычно нужен настоящий граф и два-три фейка. Для этого генерируется вторая точка входа:

func InitApp() (*Application, error)                      // = InitAppWith(Replacements{})
func InitAppWith(replacements Replacements) (*Application, error)

Replacements — структура с полем на каждый подменяемый слот. Подставили значение — конструктор, который его построил бы, не вызывается вовсе:

app, err := diogen.InitAppWith(diogen.Replacements{
    PostgresUserRepository: fakeUserRepository{},
})

Проверяет это компилятор. Тип поля — тот интерфейс, под которым значение зарегистрировано, поэтому фейк, у которого пропал метод, не соберётся на строке, где его подставляют. Ни одного приведения типа в этом механизме нет, и это его единственный смысл: подмена, которая может упасть в рантайме, для теста бесполезна.

Что это заменяет. Обычный способ собрать такой тест — второй граф: отдельный набор провайдеров или отдельная функция сборки, где вместо базы стоит фейк. Он тоже проверяется компилятором, но его надо поддерживать, и расходится он молча: продакшен-граф поехал, тестовый остался прежним, тест зеленеет на схеме, которой больше нет. Здесь второго графа нет — берётся production-сборка, и в ней называются слоты.

Отсюда два следствия, которых у отдельного тестового графа не бывает. Подмена выигрывает у условий: поле заполняется до того, как пробуются ветки, и каждая ветка сторожится тем, что оно ещё пусто, — фейк доедет до потребителя независимо от того, какую ветку выбрала бы разметка. И закрывается значение на своём месте в графе: шов стоит там же, где стояли регистрации, поэтому подменённое, зависящее от настоящих, закроется раньше них, как того требует Shutdown.

Замеры, раз уж речь про них: на 25 сервисах двух платформ (1246 регистраций) шов получают 91% регистраций, не считая теговых. Остальные 9% выставляют наружу конкретный тип вместо интерфейса — и это лечится разметкой, а не генератором.

И конфигурация исчезает вместе с ней

У шва есть второе свойство, и оно важнее первого: вместе с конструктором пропадает и конфигурация, которую он читал.

Заметьте, где стоит выражение из di.Args. Разметка:

di.Wire[Db](NewDb, di.Args(map[string]any{
    "dsn": os.Getenv("DB_DSN"),
}))
di.Define(NewRepo)

Контейнер — здесь я укоротил имена полей, префикс пакета к делу не относится:

container.db = replacements.Db

if container.db == nil {
    s := NewDb(os.Getenv("DB_DSN"))
    container.db = s
}

os.Getenvвнутри охраняемого блока. Значит подмена снимает не только вызов конструктора, но и чтение его переменных: подставили Replacements{Db: fake} — и DB_DSN никому не нужен. Не «нужен, но пустой», а не читается вовсе.

Значит компонентный тест пишется без единого t.Setenv:

app, err := diogen.InitAppWith(diogen.Replacements{
    Db:    fakeDb{},
    Cache: fakeCache{},
})

Подставили фейки на листья — и конфигурация настоящей базы никого не интересует, потому что читать её некому. Ни фиктивных DSN в тесте, ни переменных в пайплайне.

Заодно это отвечает на вопрос, который у Replacements возникает сразу: зачем шов, если у меня нет компонентных тестов. Затем, что это не тестовый механизм, а второй вход в приложение, и воспользоваться им можно из двух мест — из теста или из отдельного бинарника. Генерация документации оказалась первым применением, до которого я додумался не сразу.

И там же обнаружилась приятная деталь. Тест, который пишет две спецификации, табличный — по строке на роутер. А роутеров два потому, что они разъехались по отдельным полям контейнера через di.For[CustomerCommand]() и di.For[AdminCommand](): та же опция, которой выше раздавался net.Addr. Деление документации выпало из разметки само, фильтровать маршруты не пришлось.

Цена одна, и она про точку входа: сгенерированный InitDi() зовёт InitApp() без подмен, так что докозапуску нужен свой вариант с InitAppWith. Дописывается он, впрочем, в рукописный адаптер рядом с контейнером, а не в сам контейнер.

Чего здесь нет

Частичной сборки одного контейнера. InitAppWith собирает всё или падает, попросить «только эту ветку графа» нельзя.

Зато можно иметь несколько контейнеров. Генератор читает пакет, в котором лежит входной файл, — значит два di.go в разных пакетах, каждый со своей директивой, дают два независимых Application со своими InitApp и Shutdown. Общее кладётся в модуль, который оба подключают blank-импортом:

config/
├── shared/    // регистрации, нужные обоим
├── api/       // свой контейнер
└── worker/    // свой контейнер

Это и есть ответ на «а если у меня два бинарника с разными графами»: не резать один граф, а объявить два.

Обрезки под подменой. Подменили компонент — его собственные зависимости всё равно сконструируются. Граф статический, обрезки по достижимости в рантайме нет: подменив репозиторий, вы получите фейк в контейнере и настоящий пул соединений рядом с ним. Исключение одно — коллекция по тегу: она подменяется целиком, и конструкторы её членов не вызываются, если те больше никуда не идут.

Меня это не беспокоит, и вот почему. Конструктор, который при вызове куда-то подключается, ломается и без всякого DI: его нельзя позвать в тесте, нельзя позвать дважды и нельзя позвать, пока сети нет. Поэтому у меня конструкторы ничего не делают — вот весь newPgx из SQL-слоя:

func newPgx(dsn string) Db {
    return &pgxDb{
        dsn:    dsn,
        driver: driver.NewPostgres(),
    }
}

Пул поднимает отдельный Connect(ctx), который зовёт жизненный цикл приложения. Отсюда и берётся дешёвый докозапуск из раздела выше: подменять надо листья, а не тех, кто их использует, и тогда обрезка не нужна вовсе.

Но это соглашение, а не гарантия. Генератор не умеет проверить, что ваш конструктор лёгкий, и тяжёлый вызовет не поморщившись. Если он у вас тяжёлый — ставьте шов на него самого, потому что больше его никто не остановит.

Аргументов у точки входа. InitApp() не принимает ничего — всё, что попадает в граф, это выражение, которое генератор впишет в код. Конфигурацию это не ограничивает, но задаёт форму: её передают выражением в di.Args, а не провайдером. Разница принципиальная — выражение попадает внутрь охраняемого блока и исчезает вместе с подменённым компонентом, как в разделе выше, а провайдер это обычный узел, он строится всегда. Остаётся узкое: значение, которое процесс не может получить сам, и два контейнера, различающиеся таким значением в рантайме — wire для этого зовут дважды с разными аргументами, здесь два контейнера объявляются статически.

Шва у конкретных типов. di.Define(NewServer) выставляет *Server, и никакой фейк не может быть *Server. Хотите подменяемости — объявите интерфейс.

И общее: это ещё один кодогенератор в сборке. Шаг go generate, файлы в репозитории, инструмент, который надо уметь. Времени в этом списке нет: на втором по размеру нашем сервисе полный прогон занимает секунду на тёплом кэше сборки — вся тяжесть в разборе пакетов, и он параллельный. Если у вас двадцать компонентов и одна конфигурация, всё это не стоит своих денег.

Что общего у трёх инструментов

Три инструмента, три разные задачи — и один и тот же ход.

Разбор запроса был кодом, стал объявлением, и опечатка в теге превратилась в ошибку сборки. Документация была вторым источником правды, стала выводом из первого, и расхождение превратилось в ошибку согласования. Связывание было последовательностью действий в main(), стало набором утверждений, и забытая зависимость превратилась в ошибку генерации.

Каждый раз одно и то же: найти в коде место, где на самом деле написано объявление, и перестать исполнять его. Дальше проверку берёт на себя тот, кто и должен, — компилятор.

Это не про Go и не про кодогенерацию. Это про то, что отказ стоит тем дешевле, чем раньше он случился, и что у большинства рантайм-проверок есть версия, которая случается раньше. Иногда её видно сразу, иногда — только когда садишься объяснять свой код чужому человеку.

За три статьи я так нашёл четыре собственных недоделки, и самую крупную — в этой, про которую вы прочитали выше. Рекомендую способ.

Есть и четвёртый инструмент, и он в этой компании белая ворона: пишет файл один раз и больше никогда в него не заглядывает — ровно наоборот к трём предыдущим.

Код: gitlab.com/gorib/di, генератор diogen. Контейнеры в листингах — дословный вывод генератора для случаев из testdata/cases/ того же репозитория; в сообщениях об ошибках обрезаны пути, всё остальное как есть. Можно сверить, а не поверить.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.