Я пришёл в Go из Ruby, и первое время больше всего не хватало одной привычки, которую в Rails не замечаешь, пока она есть: любую непонятную ситуацию на проде можно посмотреть руками. bundle exec rails c, Order.find(42), и перед тобой не строка в таблице, а объект приложения со всеми его методами и бизнес-смыслом.

Сейчас я пишу на Go в продакшене, и инструментов вокруг стало больше: структурные логи, метрики, трейсы, тесты на каждый слой. Но все они отвечают на вопрос «что произошло», а консоль отвечала на другой: «что приложение видит прямо сейчас, если спросить его напрямую». В Go-проекте на этот вопрос остаётся psql, и то если он добавлен в образ.

Далее: выбор между gore и Yaegi, почему «экспортировать сервисы по одному» это ловушка, почему пакеты в REPL оказываются undefined при правильной регистрации, как сделать так, чтобы консолью нельзя было случайно снести БД в продакшене. Код, на который опирается статья, целиком лежит в cosy-console, листинги ниже это фрагменты оттуда.

Проблема

Поддержка продакшена без консоли выглядит так. Прилетает тикет «у заказа 42 странный статус». Дальше:

1. подключиться к БД
2. вспомнить, в какой таблице статус, а в какой его история
3. psql $POSTGRES_URL
4. SELECT ... JOIN ... WHERE order_id = 42
5. увидеть цифры без бизнес-контекста: что значит status = 7?
6. пойти читать код, чтобы расшифровать
7. при необходимости «посмотреть глазами юзкейс»: писать временный
   HTTP-эндпоинт или повторять состояние прода на локальном стенде,
   что само по себе не всегда просто

В Rails их закрывает одна команда:

$ bundle exec rails c
order = Order.find(42)
order.status_label           # понятно что за статус
PaymentsService.retry(order) # запуск действия из консоли

Хочется того же в Go. Вопрос: чем это собрать.

Акт 1: выбор инструмента

Гугление «go repl» даёт двух кандидатов: gore и Yaegi. С первого взгляда оба «REPL для Go», но они решают разные задачи, и разница решающая.

gore: про язык, а не про приложение

gore это обычный Go-REPL: запускается, ждёт выражений, выполняет их через go run. Отсюда два свойства, каждое из которых влияет на нашу задачу:

свойство 1: каждое выражение компилируется заново (медленно, и это
            признают сами авторы)
свойство 2: нужен Go toolchain, а на проде его нет и не должно быть

Но главное даже не это: запустив gore в каталоге проекта, вы получаете только право написать:

:import myapp/internal/orders

А дальше тупик: юзкейс не создашь, ему нужен *gorm.DB, пул соединений, конфиг из переменных окружения, клиенты внешних сервисов. Всё это в вашем приложении собирается через composition root, единственное место, где зависимости создаются и связываются друг с другом, но gore про него ничего не знает. Получится локальная песочница, которую до прода надо ещё дотянуть.

Yaegi: интерпретатор, который встраивается в приложение

Ключевое слово: встраивается. У Yaegi есть API:

i := interp.New(interp.Options{...})
i.Use(stdlib.Symbols) // зарегистрировать пакеты
i.Eval(`1 + 1`)       // выполнить код
i.REPL()              // интерактивная сессия

Главная для нас возможность это передача скомпилированных значений внутрь интерпретатора. В REPL можно отдать *gorm.DB, юзкейс, конфиг, и интерпретируемый код будет вызывать их как обычные значения.

gore:                       Yaegi:

REPL --go run--> код        процесс приложения
                              |
нет доступа к приложению    composition root (db, usecases, http-clients)
                              |
                              v
                            Yaegi-интерпретатор --> REPL вызывает
                                                    скомпилированные объекты

Важное уточнение про Yaegi, прежде чем продолжить

Последний релиз Yaegi это v0.16.1, апрель 2024. В проекте issues появляются, а релизов нет давно. Для нас это приемлемо, так как Yaegi инструмент, он не влияет на бизнес-логику, и если понадобится, мы его заменим.

Важно, что именно здесь интерпретируется: не бизнес-логика, а один вызов, App.Orders.Usecases.Get(Ctx, 42), и сам метод при этом остаётся обычным скомпилированным кодом.

Через REPL не идёт пользовательский трафик: его запускает человек. Интерпретатор может упасть, и пострадает сессия, но не процесс API. С этим ограничением Yaegi для консоли пригоден.

Акт 2: консоль живёт в процессе, а не в эндпоинте

Второе решение архитектурное, и оно спотыкается о частое заблуждение.

Сразу отбрасываем идею о кнопке в админке

Соблазн: сделать REPL HTTP-эндпоинтом или встроить в главный процесс. Тогда не нужен отдельный бинарник. Рассмотрим:

Вариант Что не так
REPL внутри http-сервера интерпретатор с состоянием в памяти процесса: общий пул соединений, общая память, и всё, что уронит консоль, может уронить API
HTTP-эндпоинт /console это remote code execution (RCE) по HTTP; любую защиту обойдёт тот, кто найдёт эндпоинт
REPL как эндпоинт с auth всё ещё RCE, просто с паролем

Мы хотим работать так же удобно, как в Rails. При этом rails c не подключается к работающему серверу: он поднимает свой процесс и загружает то же окружение (модели, конфиг, БД и так далее). Память у него своя, общее с продом конфиг и данные: нагрузка и блокировки в базе общие, а процесс API от консоли не зависит.

Отдельный процесс + общий composition root

Повторяем эту модель один в один:

                       config + logger
                              |
                    composition root (Container)
                              |
            +-----------------+------------------+
            v                 v                  v
        cmd/api           cmd/worker        cmd/console   <- новый бинарник
            |                 |                  |
          Http server      воркеры           Yaegi REPL
            |                 |                  |
            +-------- скомпилированные юзкейсы --+
                              |
                    PostgreSQL / Redis / Kafka
            (одни и те же, через тот же продакшен конфиг)

cmd/console запускает тот же composition root, что и API, поэтому:

  • подключение к БД настроено идентично;
  • в консоли нет ничего, чего нет в приложении, и наоборот.

В Docker добавляются две строки: собрать и скопировать бинарник в тот же образ. Никаких отдельных контейнеров и образов:

RUN go build -o bin/console ./cmd/console
...
COPY --from=builder /app/bin/console /app/console

Отдельной точки входа у консоли нет: бинарник просто лежит в образе рядом с API. Вызывают его изнутри контейнера, локально, через docker exec. Ни порта, ни эндпоинта консоль не открывает, подключаться к ней снаружи не к чему.

Локально проверить можно так:

docker exec -it myapp /app/console

Что экспортировать в REPL: три попытки сделать правильно

Теперь тонкое место: какие имена видны в сессии. Мы перебрали три варианта, прежде чем нашли правильный.

Попытка 1: экспортировать всё по отдельности.

i.Use(interp.Exports{"console/console": {
    "DB":      reflect.ValueOf(db),
    "Orders":  reflect.ValueOf(ordersUseCase),
    "Catalog": reflect.ValueOf(catalogUseCase),
    ...        // 30 строк сегодня, 60 после пары спринтов
}})

Работает, но список растёт с каждым доменом, имена живут в двух местах (контейнер + экспорт), а про забытый сервис узнаёшь в середине увлекательного разбора инцидента.

Попытка 2: автоэкспорт рефлексией по контейнеру.

// пройтись reflect'ом по полям Container и зарегистрировать каждое
v := reflect.ValueOf(app).Elem()
for i := 0; i < v.NumField(); i++ { ... }

Магия, и она опасна. Во-первых, работает не всегда: интерфейсное поле, встраиваемая структура, коллизия имён, на каждой из этих форм рефлексия спотыкается по-своему. Во-вторых, и это хуже, имена в сессии зависят от того, как структура контейнера выглядит в момент запуска.

Рефакторишь контейнер, переименовал поле, перенёс юзкейс между доменами, и всё собирается, все тесты зелёные: тесты про консоль не знают. А в сессии тем временем поменялись имена: привычное App.Orders.Usecases.Get(...) теперь undefined. Узнаёт об этом не автор рефакторинга, а инженер на проде.

Для инструмента, которым чинят прод, «молча» это худшее слово.

Попытка 3: весь контейнер одним значением.

func exports(ctx context.Context, container *app.Container) interp.Exports {
    return interp.Exports{
        "console/console": {
            "App": reflect.ValueOf(container),
            "Ctx": reflect.ValueOf(ctx),
        },
    }
}

App это весь composition root как есть, Ctx это контекст сессии для методов, которые его требуют (в entry point он отменяется по SIGTERM, см. ниже). Дальше в статье App выглядит так:

App (*app.Container)
 |-- Orders
 |    |-- Usecases.Get(ctx, id)        -> (models.Order, error)
 |    \-- Repos.OrderRepo.Get(ctx, id)
 |                     .Update(ctx, id, orders.UpdateAttrs)
 |-- Catalog
 |    |-- Usecases.Search(ctx, catalog.Filter)
 |    \-- Repos.ItemRepo.GetByOrderID(ctx, orderID)
 \-- Shared
      |-- DB   *gorm.DB
      \-- TM   менеджер транзакций: InTransaction(ctx, fn)

Доступ идёт через навигацию по этой структуре:

> App.Orders.Usecases.Get(Ctx, 42)
> App.Catalog.Repos.ItemRepo.GetByOrderID(Ctx, 42)
> App.Shared.TM.InTransaction(Ctx, func(...) error { ... })

Здесь reflect в коде только потому, что это API Yaegi. Никакой магии определения зависимостей: что лежит в контейнере, то и доступно. Добавил юзкейс в composition root, он появился в консоли автоматически.

Последний штрих этой попытки: HTTP-хендлеры в контейнер не входят (транспорт живёт уровнем выше, в cmd/api). Консоль про них не знает и не должна: ей нужны данные и бизнес-правила, а не роутер.

Акт 3: символы, или почему undefined type при правильном коде

У интерпретатора есть ограничение, о которое мы ударились первым же вечером. Четыре термина, которые понадобятся дальше:

Символы это мапа «имя → reflect.Value» внутри интерпретатора. Через неё Yaegi видит скомпилированный мир.

Регистрация (i.Use) это положить пакет в эту мапу под ключом-путём. Зарегистрировать мало: имя в сессии появится только после биндинга.

Биндинг (i.ImportUsed) это объявить имена зарегистрированных пакетов в скоупе сессии, как будто в невидимом начале сессии выполнены все импорты.

extract это генератор таблицы символов: читает скомпилированный пакет и пишет Go-файл, который заполняет Symbols при init().

Проблема: REPL не может сконструировать аргумент

Вызов с простыми аргументами работает сразу:

> App.Orders.Usecases.Get(Ctx, 42)      <- int64, отлично

Но половина методов принимает типы проекта:

func (r *OrderRepo) Update(
    ctx context.Context, id int64, attrs orders.UpdateAttrs,
) (models.Order, error)

выражение orders.UpdateAttrs{Status: "paid"} надо сначала скомпилировать, а orders для интерпретатора несуществующий пакет:

> App.Orders.Repos.OrderRepo.Update(Ctx, 42, orders.UpdateAttrs{Status: "x"})
1:58: undefined type

Yaegi не умеет «просто импортировать» скомпилированный пакет: у интерпретатора нет его типов в своей таблице. Для этого существует yaegi extract, генератор таблиц символов.

Что генерирует extract (и что с этим делать)

go run github.com/traefik/yaegi/cmd/yaegi extract -name symbols myapp/internal/orders

создаёт файл:

// Code generated by 'yaegi extract myapp/internal/orders'. DO NOT EDIT.

func init() {
    Symbols["myapp/internal/orders/orders"] = map[string]reflect.Value{
        "ErrNotFound":  reflect.ValueOf(&orders.ErrNotFound).Elem(),
        "UpdateAttrs":  reflect.ValueOf((*orders.UpdateAttrs)(nil)),
        "CreateParams": reflect.ValueOf((*orders.CreateParams)(nil)),
        ...
    }
}

Когда открываешь такой файл первый раз, реакция у нас была дословной: «что это вообще такое». Спокойно: это не код, который читают. Это мапа «имя → reflect.Value», и её генерирует extract. Доменные файлы выходят компактными, десятки строк; цифры становятся трёхзначными, только если extract прогнать по инфраструктуре, и вот это как раз следующий соблазн.

Второй соблазн: прогнать extract по всему подряд, включая gorm.io/gorm:

github_com-google-uuid.go        <- 70 строк
gorm_io-gorm.go                  <- 900 строк: gorm.Open, gorm.DB, clause...
myapp-internal-domain-orders.go  <- 30 строк
myapp-internal-domain-catalog.go <- 30 строк
...

Так делать не надо. Таблица gorm-символов нужна для сценария «пользователь REPL сам делает import "gorm.io/gorm" и открывает свои соединения». В нашем случае соединение уже открыто composition root’ом, с продакшен конфигом. В REPL оно приезжает внутри контейнера, как App.Shared.DB. Открывать из интерпретатора вторые, не настроенные соединения незачем. gorm-файл мы удалили, а правило сформулировали так: символы нужны входным типам, а не инфраструктуре.

Как понять, что генерировать: считаем входные типы

Надо понять какие пакеты нужны. Интуиция подсказывает «все домены и все модели», и интуиция ошибается. Правильная постановка вопроса:

какие пакеты должен уметь КОНСТРУИРОВАТЬ пользователь REPL,
чтобы вызвать метод контейнера?

Типы делятся по месту в сигнатуре:

                 +-- параметры метода --> ВХОД: пользователь строит значение
метод контейнера +                        -> пакет нужен в символах
                 +-- возвращаемое ------> ВЫХОД: пользователь только читает
                                          -> пакет НЕ нужен

С возвращаемыми значениями всё делает сама Yaegi: значение, вернувшееся из скомпилированного метода, несёт свой тип в себе, и поля у него читаются без всякой регистрации:

> o, err := App.Orders.Repos.OrderRepo.Get(Ctx, 42)
> o.Status // работает, хотя пакет models нигде не зарегистрирован

Список входных типов мы не угадывали, а посчитали скриптом на packages.Load из golang.org/x/tools/go/packages (в стандартной библиотеке этого пакета нет, он живёт в модуле golang.org/x/tools): от Container по всем экспортируемым методам, рекурсивно по полям структур-аргументов. Ответ вышел компактным:

Пакет Зачем в REPL
myapp/internal/orders + models UpdateAttrs, CreateParams; модель это аргумент Create(ctx, order, ...)
myapp/internal/catalog фильтры поиска, пагинация-опции
github.com/google/uuid uuid.UUID в сигнатурах: uuid.Parse(...), uuid.Nil
myapp/pkg/pagination опции WithPage(1)

Итого пять пакетов вместо «всего»: orders и его models, catalog, uuid, pagination. Проверка «чего не хватает» ничего не стоит: запустили REPL, попытались сконструировать, и если получили undefined, пакет надо добавить в список. Лишнее ищется с другой стороны: пакет, который ни разу не понадобился в качестве входного аргумента, из символов убирается.

Нюанс по uuid: extract даёт весь пакет (часовые последовательности, пулы энтропии, ридеры), а приложению нужна горстка имён. Мы оставили минимальный набор: Parse, MustParse, Must, New, NewV7, Nil, UUID.

yaegi extract: практикум

Что это. CLI-утилита самого Yaegi. Интерпретатор не умеет «просто импортировать» скомпилированный пакет: у него нет типов из вашего бинарника в собственной таблице. extract это штатный мост: он читает пакет и генерирует мапу имя → reflect.Value для всех его экспортируемых сущностей. Руками такую мапу тоже пишут, ниже мы так регистрируем App, Ctx и урезанный uuid, но для доменного пакета это сотни строк. Что попало в мапу, то можно конструировать и вызывать из REPL.

Как запустить. extract живёт в том же Go-модуле, что и yaegi (github.com/traefik/yaegi/cmd/yaegi), а yaegi уже в зависимостях, раз консоль собирается. Поэтому отдельной установки нет: go run сам собирает утилиту из модульного кеша и сразу запускает ту же команду, с которой мы начали.

Флаги, которые имеют значение: -name задаёт имя пакета-словаря (у нас symbols), а -include и -exclude фильтруют по regexp, если нужен только кусок пакета.

Что получается. Файл в текущей директории, по одному на пакет; имя файла строится из import path. Запускаете из internal/console/symbols/, и файлы ложатся сразу куда надо:

$ cd internal/console/symbols && go run ... extract -name symbols \
      myapp/internal/orders myapp/internal/orders/models

myapp-internal-orders.go        <- extract домена
myapp-internal-orders-models.go <- extract его models

Внутри каждого файла тот же словарь под init(), что показан выше.

Проверка, что ничего не забыто. extract сгенерировал файлы, но как узнать, что в REPL теперь работает всё нужное? Проверочный прогон в eval-режиме:

./console -e 'orders.UpdateAttrs{Status: "paid"}.Status'
./console -e 'uuid.Parse("00000000-0000-0000-0000-000000000001").String()'
./console -e 'pagination.Settings{Page: 1}.Page'

Каждая команда должна напечатать значение без undefined. Мы закрепили эти проверки тестами пакета console, таблично, по типу из каждого пакета символов: конструирование attrs, models, uuid, pagination. Тогда забытая регенерация символов ловится CI, а не ночной диагностикой на проде.

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

# make console-symbols DOMAIN=orders         (один домен)
# make console-symbols DOMAIN=orders,catalog (несколько через запятую)
# make console-symbols                       (все домены разом)
DOMAINS ?= orders catalog

console-symbols:
	@set -e; for domain in $$(echo $(if $(DOMAIN),$(DOMAIN),$(DOMAINS)) | tr ',' ' '); do \
		echo "==> $$domain: extracting domain + models"; \
		( cd internal/console/symbols && \
			go run github.com/traefik/yaegi/cmd/yaegi extract -name symbols \
				myapp/internal/$$domain myapp/internal/$$domain/models && \
			mv -f myapp-internal-$$domain.go $$domain.go && \
			mv -f myapp-internal-$$domain-models.go $${domain}_models.go ); \
		echo "==> $$domain: written $$domain.go + $${domain}_models.go"; \
	done

Три решения, зашитых в таргет.

Первое: extract вызывается с двумя путями: сам домен и его models, так устроен наш проект. Extract не умеет ...-паттерны, а Go-пакет не включает поддиректории, поэтому одного пути мало: params извлеклись бы, а модели нет. Модели нужны не для чтения (output-тип приезжает в самом значении), а для конструирования: у Create(ctx, user models.User, ...) модель это аргумент, и без регистрации models.User{...} в сессии даёт undefined type.

Второе: extract запускается внутри internal/console/symbols/: он пишет файлы в текущую директорию, и так они попадают сразу куда надо, минуя корень репозитория. Два сгенерированных файла переименовываются в <domain>.go и <domain>_models.go: по файлу на пакет, с честным DO NOT EDIT и родными ключами extract’а.

Третье: mv -f используется для merge. Если файла нет, то создастся, иначе перезапишется. Никакого ручного редактирования сгенерированного: изменили UpdateAttrs, запустили make console-symbols DOMAIN=orders, коммит; сессионные имена домен подхватит сам, потому что их выводит Exports(), а не файл.

Почему репозитории вызываются без символов

Вопрос, который возникает сразу после «а что генерировать»: репозиториев же в таблице символов нет, как тогда работает App.Orders.Repos.OrderRepo.Get(...)?

Ответ: у консоли два канала доставки, и это половина дизайна:

канал 1: symbols           ТИПЫ И ФУНКЦИИ по имени пакета
                           то, что пользователь КОНСТРУИРУЕТ сам:
                           orders.UpdateAttrs{...}, uuid.Parse(...)

канал 2: App               ГРАФ контейнера целиком
                           то, что пользователь только ВЫЗЫВАЕТ:
                           App.Orders.Repos.OrderRepo.Get(...)

Символы отвечают на вопрос «как создать значение в REPL?». App отвечает на вопрос «как дотянуться до уже созданного». Репозитории создаёт composition root задолго до REPL, и в сессию они появляются внутри App.

Механика Yaegi: встретив App.Orders.Repos.OrderRepo.Get(...), интерпретатор не ищет пакет по имени, он идёт по полям App через reflection, и метод находится по типу самого значения. Значение несёт тип с собой.

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

А если бы мы извлекли? order_repo.New(db) в REPL это вторые экземпляры репозиториев в обход контейнера: другая конфигурация, другой пул, никакого отношения к тому, чем живёт приложение. И цепочка потянула бы *gorm.DB в символы, со сценарием «открыть своё соединение», который мы запретили парой разделов выше.

Импорты без импортов: ImportUsed

Теперь пакеты зарегистрированы. Но этого мало: после i.Use(...) имена пакетов в сессии всё ещё не видны, и каждая сессия начиналась бы с десятка импортов:

> import "fmt"
> import "time"
> import orders "myapp/internal/orders"
> ...10 строк, только потом первый полезный вызов

Мы сначала написали preamble: большой import-блок, исполняемый при старте. Работало, но тянуло за собой вопрос «а если юзеру нужен пакет, которого нет в preamble?». На него был красивый ответ внутри самого Yaegi:

i.ImportUsed()

Этим же приёмом пользуется CLI-REPL самого yaegi. ImportUsed берёт каждый зарегистрированный через Use пакет и объявляет его имя в глобальном скоупе, как будто в невидимом начале сессии выполнены все импорты сразу:

Use(...)      = «интерпретатор, запомни эти пакеты»     (регистрация)
ImportUsed()  = «и покажи их имена без import'ов»       (биндинг имён)

> fmt.Sprintf("%d", 7)        <- fmt доступен
> orders.UpdateAttrs{}         <- и orders тоже

Последняя строка preamble: почему import . "console"

ImportUsed закрыл все обычные пакеты, но одну строку preamble пришлось оставить. Почему:

Шаг 1: интерпретатор принимает только пакеты. Мы хотим отдать в сессию два значения, контейнер и контекст, но отдельное значение Yaegi не примет: словарь «имя → значение» обязан лежать под каким-то ключом-путём. Поэтому App и Ctx завёрнуты в «псевдопакет», словарь под выдуманным путём console/console. Настоящего пакета с таким именем нет, для интерпретатора он выглядит как пакет console с двумя переменными внутри.

Шаг 2: без импорта имена недоступны. Псевдопакет это такой же пакет: его имена в сессии появляются только после импорта. Если делать через импорт:

> import "console"
> console.App.Orders...         <- работает, но таскать префикс с каждым вызовом

Шаг 3: точка убирает префикс. В Go есть dot-import, import . "pkg" объявляет имена пакета прямо в текущем скоупе, без префикса:

import . "console"
// App и Ctx доступны напрямую:
// > App.Orders...

Почему ImportUsed не сделал это сам? Он умеет только обычный импорт, а имя для сессии берёт из ключа регистрации: всё, что после последнего слэша. Ключ у нас console/console, последний слэш отрезает console, и префикс возвращается. Dot-import это отдельная операция языка, и ImportUsed её не выполняет. Поэтому её исполняем одной строкой Eval, как будто пользователь сам набрал её при старте сессии:

i.ImportUsed()
if _, err := i.Eval(`import . "console"`); err != nil { ... }

Порядок обязателен. Док-комментарий ImportUsed запрещает звать его дважды и после первого Eval, потому что он может переименовывать пакеты. Поэтому сначала ImportUsed, потом наша строка с dot-import.

Результат: ImportUsed биндит пакеты, import . распаковывает два значения, и сессия стартует с пустой строкой ввода.

Грабли ImportUsed: имя пакета = последний сегмент ключа

После переезда на ImportUsed тесты показали картину, которая не складывалась:

> orders.UpdateAttrs{Status: "paid"}   <- РАБОТАЕТ
> pagination.Settings{Page: 1}         <- РАБОТАЕТ
> uuid.Parse("...")                    <- РАБОТАЕТ
> models.Order{}                       <- undefined type
> models.Item{}                        <- undefined type

Пакеты зарегистрированы одинаково, extract отработал по всем, почему же домены видны, а модели нет? Находим ответ в исходнике ImportUsed:

for k := range interp.binPkg {
    name := path.Base(k)        // имя в скоупе = ПОСЛЕДНИЙ сегмент ключа
    ...
}

Смотрим на ключи. extract строит ключ как путь + имя пакета, поэтому у доменов последний сегмент всегда совпадает с именем. А вот пакет models у нас есть в обоих доменах:

Ключ path.Base Итог
github.com/google/uuid/uuid uuid совпало с именем пакета
myapp/pkg/pagination/pagination pagination совпало
myapp/internal/orders/orders orders совпало
myapp/internal/orders/models/models models коллизия
myapp/internal/catalog/models/models models то же имя второй раз

Два пакета претендуют на одно имя models. Казалось бы, Yaegi должен решить эту проблему: doc-комментарий ImportUsed обещает переименование коллизий в духе crypto_rand/math_rand. Читаем код дальше, и он обещает больше, чем делает:

// Handle collision by renaming old and new entries.
name2 := key2name(fixKey(sym.typ.path))
sc.sym[name2] = sym

key2name дописывает к имени "/_.go", и новое имя превращается в myapp/internal/orders/models_models/_.go. Строку со слэшем и точкой невозможно набрать как Go-идентификатор. Мы проверили в чистом эксперименте: после коллизии недоступны оба пакета, нет ни models, ни orders_models, ни чего-либо ещё. Кто из них занял бы имя, если бы его освободили, решает порядок итерации по map, от запуска к запуску.

Лечение: регистрировать пакеты не под реальными путями, а под выдуманными ключами вида console/<имя>/<имя>, где последний сегмент и есть имя, которое получит сессия. Сам extract так не умеет: ключ он строит жёстко, как <import path>/<имя пакета>, и ни один его флаг на это не влияет. Значит, ключи надо подменить шагом после генерации. Вот какими они должны стать:

ключ от extract                        под каким ключом регистрируем
myapp/internal/orders/orders           console/orders/orders
myapp/internal/orders/models/models    console/orders_models/orders_models
myapp/internal/catalog/models/models   console/catalog_models/catalog_models

Заодно разные имена (orders_models, catalog_models) в REPL читаются лучше, чем одно многозначное models.

Теперь разбираемся как это сделать правильно. Сгенерированные файлы не трогаем вовсе: правка руками исчезнет при первой же регенерации, соответственно коллизия вернётся. Первая версия решения держала рукописную мапу «сессионный ключ → extract-ключ» для конфликтующих пакетов, но это оказался всё тот же ручной список: добавил домен с models, не забудь строку. Финальная версия убрала список вовсе: сессионное имя каждого пакета выводится из самого ключа. Чтобы было видно, как эти файлы находят друг друга, вот обе половины целиком. Сгенерированная:

// internal/console/symbols/orders.go
// Code generated by 'yaegi extract myapp/internal/orders'. DO NOT EDIT.

package symbols

func init() {
    Symbols["myapp/internal/orders/orders"] = map[string]reflect.Value{
        "UpdateAttrs": reflect.ValueOf((*orders.UpdateAttrs)(nil)),
        ...
    }
}

И в том же пакете объявляем функцию Exports:

// internal/console/symbols/symbols.go
package symbols

// Symbols это общая мапа пакета. Объявлена пустой, а наполняют её init()
// сгенерированных файлов, каждый под своим реальным import path.
var Symbols = interp.Exports{}

// Exports отдаёт все сгенерированные таблицы под сессионными ключами.
// Сессионное имя выводится из ключа: у пакета верхнего уровня это имя
// самого пакета, у вложенного в зарегистрированный <родитель>_<имя>.
func Exports() (interp.Exports, error) {
    paths := importPaths() // пути всех зарегистрированных пакетов
    exports := interp.Exports{}
    owners := map[string]string{}

    for key, syms := range Symbols {
        name := path.Base(key) // "orders", "models", ...

        // вложен в зарегистрированный домен -> префикс родителя
        if parent, ok := parentPackage(importPath(key), paths); ok {
            name = path.Base(parent) + "_" + name // "orders_models"
        }

        // имя не вывелось уникально: называем обоих виновников
        if prev, dup := owners[name]; dup {
            return nil, fmt.Errorf(
                "session name %q claimed by both %s and %s", name, prev, key)
        }

        owners[name] = key
        exports["console/"+name+"/"+name] = syms
    }

    return exports, nil
}

// importPath отрезает последний сегмент: extract регистрирует пакет как
// path.Join(importPath, packageName).
func importPath(key string) string {
    return strings.TrimSuffix(key, "/"+path.Base(key))
}

func importPaths() []string { /* пути из всех ключей Symbols */ }

// parentPackage ищет ближайший зарегистрированный пакет, содержащий
// данный путь как подкаталог: .../orders/models вложен в .../orders.
func parentPackage(pkgPath string, paths []string) (string, bool) { /* ... */ }

Что в итоге лежит в exports и уходит в i.Use:

ключ в Symbols (от extract) выведенное имя ключ в Exports() имя в сессии
myapp/internal/orders/orders orders console/orders/orders orders
myapp/internal/orders/models/models orders_models (родитель orders) console/orders_models/orders_models orders_models
myapp/internal/catalog/catalog catalog console/catalog/catalog catalog
myapp/internal/catalog/models/models catalog_models (родитель catalog) console/catalog_models/catalog_models catalog_models
myapp/pkg/pagination/pagination pagination console/pagination/pagination pagination
github.com/google/uuid/uuid uuid console/uuid/uuid uuid

Где происходит слияние. Нигде явно: обе половины пишут и читают одну переменную пакета. symbols.go объявляет Symbols пустой, init() каждого сгенерированного файла кладёт туда свою таблицу под реальным путём, и к моменту вызова Exports в мапе лежат все домены разом. Отдельного шага «собрать всё вместе» нет, его делает сам Go: файлы одного пакета видят одну переменную, поэтому сгенерированные файлы не импортируют symbols.go и не знают друг о друге. Ровно поэтому регенерация любого файла ничего не ломает: сессионные имена выводятся из того, что extract уже сгенерировал, а не из списка, который кто-то должен поддерживать.

Дубликат имени это ошибка, а не лотерея. В стоковом ImportUsed коллизию решает порядок итерации по map, то есть случай. Здесь пересечение имён возможно ровно одно: два одинаковых пакета без зарегистрированных родителей (два голых models). Для этого случая owners возвращает ошибку со всеми виновниками: консоль падает на старте и говорит, какие ключи столкнулись, а не молча выкидывает половину таблицы. Проверять дубликаты руками или надеяться на компилятор не нужно.

Грабли самого REPL: перенос после точки, Ctrl+C и первый return

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

> err := App.Shared.TM.InTransaction(Ctx, func(ctx context.Context) error {
> order, err := App.Orders.Repos.OrderRepo.Get(ctx, 42)
> if err != nil {
> return err
> }
> fmt.Println(order.Status)
> return nil
> })

В середине незавершённого ввода обычно REPL’ы подсказывают, что ввод ещё не закончен, меняют приглашение. Так делает psql: пока запрос не закрыт точкой с запятой, он ждёт продолжения и говорит об этом:

shop=# SELECT id,
shop-#        name          <- приглашение сменилось на «-#»: ввод продолжается
shop-# FROM orders;

У Yaegi этой подсказки нет, и это не дизайн, а ограничение. Приглашение у него одно на все случаи:

> err := App.Shared.TM.InTransaction(Ctx, func(ctx context.Context) error {
> order, err := App.Orders.Repos.OrderRepo.Get(ctx, 42)   <- то же «> », хотя
> return err                                              ввод продолжается
> })

После каждого Enter REPL пробует исполнить накопленное. Парсер споткнулся на конце ввода, значит выражение не дописано: строка молча уходит в буфер, и следующий Enter повторяет попытку уже с ней. Отдельного состояния «жду продолжения» внутри нет, есть только неудавшаяся попытка исполнить, поэтому и показывать в приглашении нечего. Почему так: REPL в Yaegi это вспомогательная утилита, а не продукт; полноценный цикл ввода с отслеживанием состояния это отдельный парс-цикл, которого в коде нет.

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

Исключение это перенос после точки, разорвать цепочку вызовов нельзя:

> s := fmt.
> Sprintf("%d", 7)
> s
3:1: expected selector or type assertion, found '}'    <- в вводе вообще нет '}'
1:28: undefined: Sprintf
1:28: undefined: s

Правило: не разрывать цепочку вызовов после точки, пишите fmt.Sprintf(...) целиком или переносите после запятой в аргументах.

У Ctrl+C должен быть один обработчик. Стоковый REPL сам ловит SIGINT и отменяет текущее выражение, и это удобно: промпт возвращается, сессия живёт. Наш первый main() при этом следовал привычному паттерну любого сервиса: signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM). Первое же Ctrl+C ловили оба обработчика: REPL отменял выражение, а NotifyContext отменял контекст, который мы экспортировали в сессию как Ctx.

Результат это сессия-зомби: промпт отвечает, переменные живут, но каждый вызов App.*.*(Ctx, ...) умирает с context canceled, и так до перезапуска. Проверяется одной строкой: Ctx.Err() вместо <nil> возвращает context canceled.

И одна оговорка про само Ctrl+C: отменяется исполнение интерпретатора, а уже начавшийся вызов скомпилированного кода доработает до конца. Промпт вернётся сразу, а запрос в базе прервёт только statement_timeout или отмена контекста, который в этот вызов передали.

Фикс: развести сигналы. SIGINT остаётся у REPL, им человек за терминалом отменяет текущее выражение, а main() слушает только SIGTERM. Из сигнатуры Run это не видно, поэтому требование простое и его стоит держать в голове: контекст, который уходит в сессию, не должен умирать от SIGINT.

err := f() молча берёт первое значение. Update возвращает два значения, (models.Order, error). В обычном коде err := Update(...) не соберётся, компилятор скажет assignment mismatch: 1 variable but ... returns 2 values. Yaegi такое принимает и кладёт в err первое возвращённое значение, то есть заказ:

> err := App.Orders.Repos.OrderRepo.Update(Ctx, 42, attrs)   <- хочется так
> err
{0 00000000-... {false 0} ...}                      <- а это Order, не error

Сама ошибка при этом потеряна: в err лежит заказ, а упал вызов или нет, по нему не понять, у неудачного вызова там будет просто нулевая структура. Пишите оба имени, res, err := ....

Рабочее решение

Собираем всё вместе: три файла в репозитории и две строки в Dockerfile. Вспомогательное (runEval, Options, importPaths, parentPackage) оставлено за кадром, целиком оно есть в cosy-console.

1. Пакет console: сборка интерпретатора и два имени

// internal/console/console.go
func Run(ctx context.Context, container *app.Container, opts Options) error {
    i, err := newInterpreter(ctx, container, opts)
    if err != nil {
        return err
    }

    if opts.Eval != "" {  // одноразовый режим: -e 'выражение'
        return runEval(ctx, i, opts.Eval, opts.Stdout)
    }

    return runREPL(ctx, i)
}

// newInterpreter собирает интерпретатор: stdlib, символы приложения,
// псевдопакет с App и Ctx, затем биндинг имён.
func newInterpreter(
    ctx context.Context, container *app.Container, opts Options,
) (*interp.Interpreter, error) {
    i := interp.New(interp.Options{
        Stdin:  opts.Stdin,   // nil = потоки процесса; удобно для тестов
        Stdout: opts.Stdout,
        Stderr: opts.Stderr,
    })

    if err := i.Use(stdlib.Symbols); err != nil {
        return nil, fmt.Errorf("use stdlib symbols: %w", err)
    }

    symbolsExports, err := symbols.Exports()
    if err != nil {
        return nil, fmt.Errorf("build console symbols: %w", err)
    }
    if err := i.Use(symbolsExports); err != nil {
        return nil, fmt.Errorf("use app symbols: %w", err)
    }
    if err := i.Use(exports(ctx, container)); err != nil {
        return nil, fmt.Errorf("use console exports: %w", err)
    }

    // Объявляет имена всех зарегистрированных пакетов, как это делает
    // CLI-REPL самого yaegi. Строго до первого Eval: ImportUsed может
    // переименовывать пакеты.
    i.ImportUsed()

    // Единственная обязательная строка преамбулы: App и Ctx лежат в
    // псевдопакете, dot-import кладёт оба имени в скоуп сессии.
    if _, err := i.Eval(`import . "console"`); err != nil {
        return nil, fmt.Errorf("import console: %w", err)
    }

    return i, nil
}

func runREPL(ctx context.Context, i *interp.Interpreter) error {
    errc := make(chan error, 1)
    go func() {
        _, err := i.REPL()
        errc <- err
    }()

    select {
    case err := <-errc:
        return err
    case <-ctx.Done():
        // горутина остаётся на чтении stdin до выхода процесса:
        // прервать чтение терминала нечем, а процесс сейчас завершится
        return ctx.Err()
    }
}

func exports(ctx context.Context, container *app.Container) interp.Exports {
    return interp.Exports{
        "console/console": {
            "App": reflect.ValueOf(container),
            "Ctx": reflect.ValueOf(ctx),
        },
    }
}

2. Символы: посчитанный список, по файлу на пакет

internal/console/symbols/
  orders.go           <- extract домена (params, фильтры, errors)
  orders_models.go    <- extract его models (модели-аргументы методов)
  catalog.go          <- то же для catalog
  catalog_models.go
  pagination.go
  uuid.go             <- рукописный минимум вместо полного extract
  symbols.go          <- var Symbols + Exports(): имена сессии из ключей

Процедура регенерации собрана в make console-symbols DOMAIN=<домен> (см. практикум extract выше): extract пишет файлы сразу сюда, по одному на пакет. Ключи в сгенерированных файлах не правятся: имена выводит Exports().

3. Entry point: флаги, сигналы и read-only по умолчанию

// cmd/console/main.go
func main() {
    write := flag.Bool("write", false,
        "allow writes; by default the session is read-only")
    eval := flag.String("e", "", "evaluate a single statement and exit")
    flag.Parse()

    cfg, err := config.New()
    if err != nil {
        log.Fatalf("could not start console, %v", err)
    }

    l, err := logger.New(&cfg)
    if err != nil {
        log.Fatalf("could not create logger, %v", err)
    }

    // SIGINT не слушаем: он принадлежит REPL (отмена текущего выражения).
    // Слушали бы, и первое Ctrl+C отменяло бы экспортированный Ctx.
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM)
    defer cancel()

    var opts []app.Option
    if !*write {
        opts = append(opts, app.WithReadOnlyDB()) // защита по умолчанию
    }

    container := app.NewContainer(&cfg, l, opts...)
    if err := console.Run(ctx, container, console.Options{Eval: *eval}); err != nil {
        if errors.Is(err, context.Canceled) {
            return // SIGTERM: чистый выход
        }
        log.Fatalf("console exited with error: %v", err)
    }
}

Как включается read-only и как убедиться, что он доехал

Включённый read-only запрещает INSERT, UPDATE, DELETE, DDL и SELECT INTO для постоянных таблиц: во временные Postgres писать разрешает, это read-only высокого уровня, а не запрет любой записи на диск. Это не договорённость «не запускайте console с -write», которую легко забыть, а отказ сервера: запись не пройдёт, даже если её попросят. Включается он не в строке подключения, а в коде, после её разбора. Приём тот же, которым мы фиксировали таймзону в статье про три источника времени:

pgxConfig, err := pgx.ParseConfig(dsn)
if err != nil {
    log.Fatalf("failed to parse postgres config: %v", err)
}
pgxConfig.RuntimeParams["TimeZone"] = "UTC"
if options.readOnly {
    pgxConfig.RuntimeParams["default_transaction_read_only"] = "on"
}

Это настройка сессии, а не наша проверка: параметр уезжает в каждое соединение пула, и отказ приходит от сервера:

> res, err := App.Orders.Repos.OrderRepo.Update(Ctx, 42, attrs)
> err
ERROR: cannot execute UPDATE in a read-only transaction (SQLSTATE 25006)

Но это предохранитель, а не граница. default_transaction_read_only это обычный параметр сессии категории USERSET: он задаёт значение по умолчанию, а транзакция может его перебить. Любой, кто возьмёт одно соединение и перебьёт этот дефолт на нём (tx := App.Shared.DB.Begin(), затем tx.Exec("SET TRANSACTION READ WRITE")), снова получит запись. Делать это надо именно на закреплённой транзакции: одиночный Exec("BEGIN READ WRITE") поверх пула уедет в одно соединение, а следующий запрос может попасть в другое. От промаха это защищает полностью, от намерения не защищает вовсе. Настоящую границу ставят права роли (гранты только на SELECT) или подключение к реплике, а не параметр сессии.

Есть и вторая оговорка: параметр может вообще не доехать до сервера. Чтобы соединение через PgBouncer поднималось, незнакомые ему startup-параметры дописывают в ignore_startup_parameters, и дальше параметр не отклоняется, а молча выбрасывается: консоль работает, только без защиты. Свежие сборки PgBouncer с PostgreSQL 14 и новее пропускают его сами. Но зависеть от версии пулера мы не хотим. Поэтому используем fail-fast:

func validateReadOnly(sqlDB *sql.DB) {
    var value string
    if err := sqlDB.QueryRow("SHOW default_transaction_read_only").Scan(&value); err != nil {
        log.Fatalf("failed to read default_transaction_read_only: %v", err)
    }
    if !strings.EqualFold(strings.TrimSpace(value), "on") {
        log.Fatalf("default_transaction_read_only is %q, expected on: "+
            "the read-only startup parameter was overridden or stripped "+
            "(check the connection string and pooler config)", value)
    }
}

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

Полная цепочка защиты:

make console / /app/console        (без флага)
        |
        v
flag -write = false ---> app.WithReadOnlyDB() ---> postgres.WithReadOnly()
        |
        v
RuntimeParams["default_transaction_read_only"] = "on"     <- выставляем параметр
        |
        v
validateReadOnly: SHOW ... ---> не "on" ---> log.Fatalf   <- проверяем доставку
        |
        v
REPL ---> UPDATE ... ---> сервер: SQLSTATE 25006           <- исполняет сервер

Эта цепочка закрывает только базу. Если приложение работает с Kafka, Redis или другим внешним API, у каждого из них своя запись. Самое дешёвое это вообще не поднимать клиента на запись, тем же app.Option, что и для базы. Если клиент нужен, ограничение ставится правами на стороне сервиса: в Redis это отдельный пользователь, которому разрешены только читающие команды, в Kafka это права на топики без операции Write.

Дефолт выбран не случайно. Забыть -write стоит секунды: первый же UPDATE упадёт. Забыть обратный флаг, -read-only, стоило бы данных. Поэтому безопасное идёт по умолчанию, а опасное включается явно.

Тесты

Слои, по конвенции проекта:

  • юнит консоли: eval-режим против zero-value контейнера, где App и Ctx видны в сессии, fmt доступен без импорта, orders.UpdateAttrs{} конструируется, uuid.Parse работает, невалидный код даёт ошибку;
  • контракт read-only (против реальной БД): с опцией SELECT проходит, а UPDATE/DELETE/INSERT/DDL дают read-only transaction; без опции DDL работает, то есть защита не протекает в обычные подключения;
  • REPL-режим: скриптированный stdin (переменные живут между строками, ошибка рантайма не убивает сессию, чистый EOF после опечатки не валит процесс, многострочные конструкции буферизуются);
  • контекст: отмена ctx возвращает управление из REPL (io.Pipe вместо stdin, как терминал, EOF не даёт), а eval с блокирующимся выражением (<-make(chan int)) отменяется с context.Canceled.

Юнит-тесты REPL-инфраструктуры не требуют ни БД, ни Кафки: интерпретатор поднимается за миллисекунды, а Options{Stdin: strings.NewReader(...)} делает сессию детерминированной.

Как это отлаживать

Протокол, если консоль ведёт себя странно:

  1. undefined type при конструировании доменного типа. Пакет не зарегистрирован или зарегистрирован под ключом, последний сегмент которого не равен имени пакета. Смотрите ключи в symbols/ и перегенерируйте extract: make console-symbols DOMAIN=<домен>, файлы перезаписываются на месте (процедура в практикуме extract).
  2. Пакет зарегистрирован, но имени в сессии нет. Совпали последние сегменты ключей, и после коллизии недоступны оба имени; лечит это Exports() (см. раздел про ImportUsed). Быстрая проверка на чужом наборе символов: возьмите rand из stdlib, и если он тоже undefined, вы наткнулись на ту же мину crypto/rand против math/rand.
  3. Каждый вызов умирает с context canceled. Контекст, переданный в Run, отменяется SIGINT (NotifyContext(..., os.Interrupt) в main). Диагноз одной строкой: Ctx.Err(). SIGINT принадлежит REPL, main слушает только SIGTERM.
  4. Запись прошла, хотя консоль без -write. Либо startup-параметр не доехал до сервера, либо транзакция перебила дефолт явным BEGIN READ WRITE. Проверка та же: SHOW default_transaction_read_only в той же сессии. Если не on, пулер срезал параметр, и консоль обязана была упасть на старте; проверьте, что fail-fast вообще вызывается.
  5. Символы протухли после рефакторинга. Переименовали поле в UpdateAttrs, а файл символов не перегенерили. Это не молчит: undefined возникает ровно на том типе, который менялся, а регенерация занимает минуту.

Выводы

  • cmd/console поднимает тот же composition root, а общее с API конфиг и данные, но не процесс.

  • В REPL содержит App целиком и Ctx. Явная проводка без рефлексии: что в контейнере, то и в консоли. Хендлеры в контейнер не входят, транспорт консоли не касается.

  • Символы нужны только входным типам; выходные читаются без регистрации. Набор пакетов считается скриптом от Container, а не угадывается.

  • У консоли два канала доставки: символы для того, что пользователь конструирует сам (params, uuid, pagination-опции), и граф App для того, что composition root уже создал.

  • ImportUsed() убирает импорты из сессии, но связывает пакет по последнему сегменту ключа регистрации, а не по имени пакета. Два models в разных доменах выбивают из скоупа оба имени, и обещанный ренейм не спасает. Лечение это сессионные ключи console/<name>/<name>, которые выводит Exports(): сгенерированные файлы при этом не правятся.

  • Сигналы терминала надо развести. SIGINT это инструмент REPL (отмена выражения, сессия жива), SIGTERM это завершение процесса. Main-контекст, слушающий оба, превращает консоль в зомби с мёртвым Ctx.

  • Read-only по умолчанию живёт в startup-параметрах подключения (default_transaction_read_only=on) с fail-fast проверкой доставки: пулер может срезать параметр, и единственный способ узнать это спросить сервер через SHOW. Дефолт безопасный, запись включается явным -write. Это предохранитель от промаха: явный BEGIN READ WRITE его снимает, и там, где нужна настоящая граница, её ставят права роли.

  • Через REPL не идёт пользовательский трафик: интерпретатор может падать, версия может протухнуть, бизнес-логика при этом остаётся скомпилированным, протестированным кодом того же бинарника. Это и делает конструкцию приемлемой для прода.

Итоговая сессия на проде выглядит так:

$ ./app/console
> o, err := App.Orders.Repos.OrderRepo.Get(Ctx, 42)
> o.Status
7

> o, err := App.Orders.Usecases.Get(Ctx, 42)
> o.StatusLabel()                      <- бизнес-контекст применён
"оплачен, ждёт сборки"

> res, err := App.Orders.Repos.OrderRepo.Update(Ctx, 42, attrs)
> err
ERROR: cannot execute UPDATE in a read-only transaction (SQLSTATE 25006)
                                       <- и это тоже правильное поведение

Ровно то, за чем мы шли из Rails.

Материалы

Мой телеграм-канал: @tsymbaldev.