Продовый REPL: Yaegi поверх composition root
- суббота, 3 октября 2026 г. в 00:00:08
Я пришёл в Go из Ruby, и первое время больше всего не хватало одного инструмента, который в Rails есть из коробки: консоли, где любую непонятную ситуацию на проде можно посмотреть руками. bundle exec rails c, Order.find(42), и перед тобой не строка в таблице, а объект приложения со всеми его методами. В моих 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. Вопрос: чем это собрать.
Гугление «go repl» даёт двух кандидатов: gore и Yaegi. С первого взгляда оба «REPL для Go», но они решают разные задачи, и разница решающая.
gore это обычный Go-REPL: запускается, ждёт выражений, выполняет их через go run. Отсюда два свойства, каждое из которых влияет на нашу задачу:
свойство 1: каждое выражение компилируется заново (медленно, и это признают сами авторы) свойство 2: нужен Go toolchain, а на проде его нет и не должно быть
Но главное даже не это: запустив gore в каталоге проекта, вы получаете только право написать:
:import cosy-console/internal/domain/orders
А дальше тупик: юзкейс не создашь, ему нужен *gorm.DB, пул соединений, конфиг из переменных окружения, клиенты внешних сервисов. Всё это в вашем приложении собирается через composition root, единственное место, где зависимости создаются и связываются друг с другом, но gore про него ничего не знает. Получится локальная песочница, которую до прода надо ещё дотянуть.
Ключевое слово: встраивается. У 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 это v0.16.1, апрель 2024. Issues в трекере появляются, а релизов давно нет. Для нас это приемлемо, так как Yaegi инструмент, он не влияет на бизнес-логику, и если понадобится, мы его заменим. Важно, что именно здесь интерпретируется: не бизнес-логика, а один вызов, App.Orders.Usecases.Order.Get(Ctx, id), и сам метод при этом остаётся обычным скомпилированным кодом.
REPL запускает человек, пользовательский трафик через него не идёт. Интерпретатор может упасть, и пострадает сессия, но не процесс API. С этим ограничением Yaegi для консоли пригоден.
Второе решение архитектурное, и здесь легко попасть в частое заблуждение.
Частая ошибка: сделать REPL HTTP-эндпоинтом или встроить его в главный процесс, чтобы не собирать отдельный бинарник. Что с этим не так:
Вариант | Что не так |
|---|---|
REPL внутри http-сервера | интерпретатор с состоянием в памяти процесса: общий пул соединений, общая память, и всё, что уронит консоль, может уронить API |
HTTP-эндпоинт /console | это remote code execution (RCE) по HTTP; любую защиту обойдёт тот, кто найдёт эндпоинт |
REPL как эндпоинт с auth | всё ещё RCE, просто с паролем |
Мы хотим работать так же удобно, как в Rails. При этом rails c не подключается к работающему серверу: он поднимает свой процесс и загружает то же окружение (модели, конфиг, БД и так далее). Память у него своя, а конфиг и данные те же, что у прода: нагрузка и блокировки в базе общие, но процесс API от консоли не зависит.
Повторяем эту модель один в один:
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, поэтому:
подключение к БД настроено идентично;
в консоли доступно то же, что и в приложении: один и тот же граф контейнера. HTTP-слоя в этом графе у нас нет, роутер и хендлеры живут снаружи контейнера, и консоли они не нужны.
В Docker добавляются две строки: собрать и скопировать бинарник в тот же образ. Никаких отдельных контейнеров и образов:
RUN go build -o bin/console ./cmd/console ... COPY --from=builder /app/bin/console /app/console
Бинарник просто лежит в образе рядом с API. Вызывают его изнутри контейнера. Доступ снаружи к нему закрыт.
Локально проверить можно так:
docker compose exec api /app/console
Теперь тонкое место: какие имена видны в сессии. Мы перебрали три варианта, прежде чем нашли правильный.
Попытка 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.Order.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 это контекст сессии для методов, которые его требуют. Рассмотрим что представляет собой App:
App (*app.Container) |-- Orders | |-- Usecases.Order.Get(ctx, id) -> (models.Order, error) | .Cancel(ctx, id) | \-- Repos.OrderRepo.Get(ctx, id) | .Update(ctx, id, orders.UpdateAttrs) |-- Catalog | |-- Usecases.Item.List(ctx, catalog.ItemFilter) | \-- Repos.ItemRepo.Get(ctx, id) \-- Shared |-- DB *gorm.DB \-- Tx менеджер транзакций: InTransaction(ctx, fn)
Идентификаторы в проекте это uuid.UUID, поэтому id в примерах ниже разобранный uuid, а не число. Доступ идёт через навигацию по этой структуре:
> App.Orders.Usecases.Order.Get(Ctx, id) > App.Catalog.Repos.ItemRepo.Get(Ctx, itemID) > App.Shared.Tx.InTransaction(Ctx, func(...) error { ... })
Здесь reflect в коде только потому, что это API Yaegi. Никакой магии определения зависимостей: что лежит в контейнере, то и доступно. Добавил юзкейс в composition root, он появился в консоли автоматически. HTTP-хендлеры в контейнер не входят. Консоль про них не знает и не должна: ей нужны данные и бизнес-правила, а не роутер.
Рассмотрим устройство Yaegi. Начнем с терминов:
Символы это мапа «имя -> reflect.Value» внутри интерпретатора. Через неё Yaegi видит скомпилированный мир.
Регистрация (i.Use) это запись пакета в эту мапу под ключом-путём. Зарегистрировать мало: имя в сессии появится только после биндинга.
Биндинг (i.ImportUsed) это объявление имён зарегистрированных пакетов в скоупе сессии, как будто в невидимом начале сессии выполнены все импорты.
extract это генератор таблицы символов: читает скомпилированный пакет и пишет Go-файл, который заполняет Symbols при init().
App в сессии есть, методы вызываются, но первый же вызов спотыкается об аргумент:
> App.Orders.Usecases.Order.Get(Ctx, uuid.MustParse("...b2")) 1:28: undefined: uuid
Чтобы прочитать заказ по id, нужен тип uuid.UUID, а uuid для интерпретатора несуществующий пакет. То же со структурами проекта:
func (r *OrderRepo) Update( ctx context.Context, id uuid.UUID, attrs orders.UpdateAttrs, ) (models.Order, error)
выражение orders.UpdateAttrs{Status: &status} надо сначала скомпилировать, и orders интерпретатор тоже не знает:
> status := "x" > App.Orders.Repos.OrderRepo.Update(Ctx, id, orders.UpdateAttrs{Status: &status}) 1:28: undefined type
Yaegi не умеет «просто импортировать» скомпилированный пакет: у интерпретатора нет его типов в своей таблице. Для этого существует yaegi extract, генератор таблиц символов.
go run github.com/traefik/yaegi/cmd/yaegi extract -name symbols cosy-console/internal/domain/orders
создаёт файл:
// Code generated by 'yaegi extract cosy-console/internal/domain/orders'. DO NOT EDIT. func init() { Symbols["cosy-console/internal/domain/orders/orders"] = map[string]reflect.Value{ "ErrNotFound": reflect.ValueOf(&orders.ErrNotFound).Elem(), "UpdateAttrs": reflect.ValueOf((*orders.UpdateAttrs)(nil)), "CreationParams": reflect.ValueOf((*orders.CreationParams)(nil)), ... } }
Комментарий в первой строке говорит сам за себя: файл сгенерирован, править его руками не нужно.
Вторая ошибка: прогнать extract по всему подряд, включая gorm.io/gorm:
github_com-google-uuid.go <- 70 строк gorm_io-gorm.go <- 900 строк: gorm.Open, gorm.DB, clause... cosy-console-internal-domain-orders.go <- 30 строк cosy-console-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, id) > o.Status // работает, хотя пакет models нигде не зарегистрирован
Список входных типов мы не угадывали, а посчитали скриптом. Он берёт Container, проходит по всем его экспортируемым методам и собирает типы аргументов, а если аргумент это структура, спускается по её полям и собирает типы оттуда же. Код читает пакет golang.org/x/tools/go/packages: в стандартной библиотеке его нет, он живёт в отдельном модуле. Ответ вышел компактным:
Пакет | Зачем в REPL |
|---|---|
cosy-console/internal/domain/orders + models |
|
cosy-console/internal/domain/catalog | фильтры поиска, пагинация-опции |
github.com/google/uuid |
|
cosy-console/pkg/pagination | опции |
Итого пять пакетов вместо «всего»: orders и его models, catalog, uuid, pagination. Проверка «чего не хватает» ничего не стоит: запустили REPL, попытались сконструировать, и если получили undefined, пакет надо добавить в список. Лишнее ищется с другой стороны: пакет, который ни разу не понадобился в качестве входного аргумента, из символов убирается.
Нюанс по uuid: extract даёт весь пакет, вместе со служебным (ClockSequence, SetRand, NodeID), а приложению нужен ограниченный набор. Мы оставили минимальный набор: Parse, MustParse, Must, New, Nil, UUID.
extract утилита самого Yaegi. Интерпретатор не умеет «просто импортировать» скомпилированный пакет: у него нет типов из вашего бинарника в собственной таблице. extract это мост: он читает пакет и генерирует мапу имя -> reflect.Value для всех его экспортируемых сущностей. Руками такую мапу тоже пишут, ниже мы так регистрируем App, Ctx и урезанный uuid, но для доменного пакета это сотни строк. Что попало в мапу, то можно конструировать и вызывать из REPL.
Как запустить. extract живёт в том же Go-модуле, что и Yaegi. Флаги, которые имеют значение: -name задаёт имя словаря (у нас symbols), а -include и -exclude фильтруют по regexp, если нужен только кусок пакета.
Что получается. Файл в текущей директории, по одному на пакет; имя файла строится из import path. Запускаете из internal/console/symbols/, и файлы ложатся сразу куда надо:
$ cd internal/console/symbols && go run ... extract -name symbols \ cosy-console/internal/domain/orders cosy-console/internal/domain/orders/models cosy-console-internal-domain-orders.go <- extract домена cosy-console-internal-domain-orders-models.go <- extract его models
Внутри каждого файла тот же словарь под init(), что показан выше.
Проверка, что ничего не забыто. Сгенерированные файлы сами по себе ничего не гарантируют: пакет мог не попасть в список или потеряться на коллизии имён. Проверяется это разовым прогоном выражения в собранной консоли, по одному типу из каждого пакета символов: /app/console -e 'orders.OrderFilter{Status: "paid"}.Status' должен напечатать значение, а не undefined.
Синхронизация одной командой. Символы это слепок кода, и он отстаёт от каждой правки сигнатур: добавили поле в 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 \ cosy-console/internal/domain/$$domain cosy-console/internal/domain/$$domain/models && \ mv -f cosy-console-internal-domain-$$domain.go $$domain.go && \ mv -f cosy-console-internal-domain-$$domain-models.go $${domain}_models.go ); \ echo "==> $$domain: written $$domain.go + $${domain}_models.go"; \ done
Три решения, зашитых в таргет.
Первое: extract вызывается с двумя путями: сам домен и его models, так устроен наш проект. extract не умеет ...-паттерны, а Go-пакет не включает поддиректории, поэтому одного пути мало: params извлеклись бы, а модели нет. Модели нужны не для чтения, а для конструирования: у Create(ctx, order models.Order) модель это аргумент, и без регистрации models.Order{...} в сессии даёт undefined type.
Второе: extract запускается внутри internal/console/symbols/: он пишет файлы в текущую директорию, и так они попадают сразу куда надо, минуя корень репозитория. Два сгенерированных файла переименовываются в <domain>.go и <domain>_models.go: по файлу на пакет, с честным DO NOT EDIT и родными ключами extract’а.
Третье: mv -f. Файла нет, он создастся; файл есть, он перезапишется целиком. Никакого ручного редактирования сгенерированного: изменили UpdateAttrs, запустили make console-symbols DOMAIN=orders, коммит; имена в сессии домен подхватит сам, потому что их выводит Exports(), а не файл.
Символы нужны, чтобы собрать значение с нуля. Всё, что composition root уже создал, приезжает в сессию внутри App, и регистрировать его не надо. У консоли два канала доставки:
канал 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, и метод находится по типу самого значения. Значение несёт тип с собой. Поэтому без единой строки регистрации работают и юзкейсы, и репозитории, и App.Shared.DB: всё это создано до старта REPL.
Если их всё-таки извлечь и вызвать, order_repo.New(db) в REPL даёт вторые экземпляры репозиториев в обход контейнера: другая конфигурация, другой пул, никакого отношения к тому, чем живёт приложение. И цепочка потянет *gorm.DB в символы, со сценарием «открыть своё соединение», который мы запретили парой разделов выше.
Теперь пакеты зарегистрированы. Но этого мало: после i.Use(...) имена пакетов в сессии всё ещё не видны, и каждая сессия начиналась бы с десятка импортов:
> import "fmt" > import "time" > import orders "cosy-console/internal/domain/orders" > ...10 строк, только потом первый полезный вызов
Yaegi закрывает это одним вызовом:
i.ImportUsed()
ImportUsed берёт каждый зарегистрированный через Use пакет и объявляет его имя в глобальном скоупе, как будто в невидимом начале сессии выполнены все импорты сразу:
Use(...) = «интерпретатор, запомни эти пакеты» (регистрация) ImportUsed() = «и покажи их имена без import'ов» (биндинг имён) > fmt.Sprintf("%d", 7) <- fmt доступен > orders.UpdateAttrs{} <- и orders тоже
ImportUsed закрыл все обычные пакеты, но одну строку в стартовом коде пришлось оставить, import . "console". Почему:
Шаг 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, и в сессии появляется пакет с этим именем, то есть ровно тот префикс, от которого мы уходим:
> console.App.Orders.Usecases.Order.Get(Ctx, id) <- после ImportUsed работает так > App.Orders.Usecases.Order.Get(Ctx, id) <- undefined: App
Dot-import это отдельная операция, и ImportUsed её не выполняет. Поэтому её исполняет сама консоль, одной строкой Eval:
i.ImportUsed() if _, err := i.Eval(`import . "console"`); err != nil { ... }
Порядок обязателен. Док-комментарий ImportUsed говорит прямо: не вызывать дважды и не вызывать после первого Eval, потому что метод может переименовывать пакеты.
Переименование происходит на коллизии имён. В stdlib на имя rand претендуют сразу два пакета, math/rand и crypto/rand, поэтому ImportUsed даёт каждому своё: math_rand и crypto_rand. В сессии это выглядит так:
> math_rand.Intn(1) 0 > rand.Intn(1) 1:28: undefined: rand
На пакетах приложения этот механизм не спасает, их коллизии мы разводим сами в Exports(), дальше будет отдельный разбор.
Главное, что переименование не добавляет имя, а подменяет: старое имя ImportUsed из скоупа удаляет. Пока в стартовом коде одна строка Eval, набор имён от порядка не меняется, мы проверяли оба варианта. Но чем больше строк исполняется при старте, тем больше кода успевает отработать до переименования, и соблюдать правило дешевле, чем каждый раз проверять заново.
Результат: ImportUsed биндит пакеты, import . распаковывает два значения, и сессия стартует с пустой строкой ввода.
После переезда на ImportUsed тесты показали картину, которая не складывалась:
> orders.UpdateAttrs{} <- РАБОТАЕТ > 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 у нас есть в обоих доменах:
Ключ |
| Итог |
|---|---|---|
github.com/google/uuid/uuid |
| совпало с именем пакета |
cosy-console/pkg/pagination/pagination |
| совпало |
cosy-console/internal/domain/orders/orders |
| совпало |
cosy-console/internal/domain/orders/models/models |
| коллизия |
cosy-console/internal/domain/catalog/models/models |
| то же имя второй раз |
Два пакета претендуют на одно имя models. Казалось бы, Yaegi должен решить эту проблему: док-комментарий ImportUsed обещает переименование в духе math_rand/crypto_rand. Читаем код и видим, почему на наших путях это не срабатывает:
// Handle collision by renaming old and new entries. name2 := key2name(fixKey(sym.typ.path)) sc.sym[name2] = sym if name2 != name { delete(sc.sym, name) // простое имя при этом исчезает }
fixKey заменяет подчёркиванием последний слэш пути. В stdlib пути короткие, и всё получается: math/rand становится math_rand, такое имя можно набрать. У нас путь глубокий, cosy-console/internal/domain/orders/models становится cosy-console/internal/domain/orders_models, а это не идентификатор, набрать его в сессии нельзя. Простое models при этом уже удалено.
Проверили на чистом эксперименте: после коллизии недоступны оба пакета, нет ни models, ни orders_models, ни чего-либо ещё. Кто из них занял бы имя, если бы его освободили, решает порядок итерации по map, то есть случай.
Регистрировать пакеты надо не под реальными путями, а под ключами вида console/<имя>/<имя>, где последний сегмент и есть имя, которое получит сессия. Сам extract так не умеет: ключ он строит жёстко, как <import path>/<имя пакета>, и ни один его флаг на это не влияет. Значит, ключи надо подменить шагом после генерации. Вот какими они должны стать:
ключ от extract под каким ключом регистрируем cosy-console/internal/domain/orders/orders console/orders/orders cosy-console/internal/domain/orders/models/models console/orders_models/orders_models cosy-console/internal/domain/catalog/models/models console/catalog_models/catalog_models
Сегмент console/ в этих ключах ничего не значит: ключ обязан выглядеть как путь, поэтому перед именем нужен хоть какой-то сегмент. Yaegi смотрит только на последний сегмент, так что в сессии будет orders, а не console.orders. С псевдопакетом console/console, через который приезжают App и Ctx, эти ключи не связаны: там console было настоящим именем пакета, здесь это просто начало подменённого ключа.
Заодно разные имена (orders_models, catalog_models) в REPL читаются лучше, чем одно многозначное models.
Теперь разбираемся, как это сделать правильно. Сгенерированные файлы не трогаем вовсе: правки исчезнут при первой же регенерации, и коллизия вернётся. Первая версия решения держала рукописную мапу «имя в сессии -> extract-ключ» для конфликтующих пакетов, но это оказался всё тот же ручной список: добавил домен с models, не забудь строку. Финальная версия убрала список вовсе: имя каждого пакета выводится из самого ключа. Чтобы было видно, как эти файлы находят друг друга, покажу обе половины целиком. Сгенерированная:
// internal/console/symbols/orders.go // Code generated by 'yaegi extract cosy-console/internal/domain/orders'. DO NOT EDIT. package symbols func init() { Symbols["cosy-console/internal/domain/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:
ключ в | выведенное имя | ключ в | имя в сессии |
|---|---|---|---|
cosy-console/internal/domain/orders/orders |
|
|
|
cosy-console/internal/domain/orders/models/models |
|
|
|
cosy-console/internal/domain/catalog/catalog |
|
|
|
cosy-console/internal/domain/catalog/models/models |
|
|
|
cosy-console/pkg/pagination/pagination |
|
|
|
github.com/google/uuid/uuid |
|
|
|
Где происходит слияние. Нигде явно: обе половины пишут и читают одну переменную пакета. symbols.go объявляет Symbols пустой, init() каждого сгенерированного файла кладёт туда свою таблицу под реальным путём, и к моменту вызова Exports в мапе лежат все домены разом. Отдельного шага «собрать всё вместе» нет, его делает сам язык: файлы одного пакета видят одну переменную, поэтому сгенерированные файлы не импортируют symbols.go и не знают друг о друге. Ровно поэтому регенерация любого файла ничего не ломает: имена в сессии выводятся из того, что extract уже сгенерировал, а не из списка, который кто-то должен поддерживать.
Дубликат имени это ошибка. В стоковом ImportUsed коллизию решает порядок итерации по map, то есть случай. Здесь пересечение имён возможно ровно одно: два одинаковых пакета без зарегистрированных родителей (два голых models). Для этого случая owners возвращает ошибку со всеми виновниками: консоль падает на старте и говорит, какие ключи столкнулись, а не молча выкидывает половину таблицы. Проверять дубликаты руками или надеяться на компилятор не нужно.
Многострочность работает. Это неочевидно, и потому стоит сказать прямо: стоковый REPL буферизует незавершённое выражение и ждёт продолжения. Пока скобка не закрыта, ввод можно продолжать сколько угодно строк, хоть функцию целиком:
> err := App.Shared.Tx.InTransaction(Ctx, func(ctx context.Context) error { > order, err := App.Orders.Repos.OrderRepo.Get(ctx, id) > 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.Tx.InTransaction(Ctx, func(ctx context.Context) error { > order, err := App.Orders.Repos.OrderRepo.Get(ctx, id) <- то же «> », хотя > 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, id, attrs) <- хочется так > err {0 00000000-... {false 0} ...} <- а это Order, не error
Сама ошибка при этом потеряна. В err лежит заказ, а упал вызов или нет, по нему не понять: у неудачного вызова там будет просто нулевая структура. Пишите оба имени, res, err := ....
Собираем всё вместе: три части в репозитории (пакет консоли, символы, entry point) и две строки в Dockerfile. Вспомогательное (runEval, Options, importPaths, parentPackage) оставлено за кадром, целиком оно есть в cosy-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 } // Ошибку i.REPL() отбрасываем сознательно: это ошибка последнего // выражения, REPL её уже напечатал в stderr, и чистый выход по Ctrl+D // после опечатки не должен валить процесс. func runREPL(ctx context.Context, i *interp.Interpreter) error { done := make(chan struct{}) go func() { defer close(done) _, _ = i.REPL() }() select { case <-done: return nil case <-ctx.Done(): 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), }, } }
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().
// 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 запрещает INSERT, UPDATE, DELETE, DDL и SELECT INTO. Это не договорённость «не запускайте 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, id, attrs) > err ERROR: cannot execute UPDATE in a read-only transaction (SQLSTATE 25006)
Но это предохранитель, а не граница. default_transaction_read_only задаёт только значение по умолчанию, и транзакция может его перебить: SET TRANSACTION READ WRITE внутри App.Shared.DB.Begin() снова разрешает запись. От промаха это защищает полностью, от намерения не защищает вовсе. Настоящую границу ставят права роли (гранты только на 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, стоило бы данных. Поэтому безопасное идёт по умолчанию, а опасное включается явно.
Протокол, если консоль ведёт себя странно:
undefined type при конструировании доменного типа. Пакет не зарегистрирован или зарегистрирован под ключом, последний сегмент которого не равен имени пакета. Смотрите ключи в symbols/ и перегенерируйте extract: make console-symbols DOMAIN=<домен>, файлы перезаписываются на месте (процедура в практикуме extract).
Пакет зарегистрирован, но имени в сессии нет. Совпали последние сегменты ключей, и после коллизии недоступны оба имени; лечит это Exports() (см. раздел про ImportUsed). Быстрая проверка на чужом наборе символов: возьмите rand из stdlib, и если он тоже undefined, вы наткнулись на ту же мину crypto/rand против math/rand.
Каждый вызов умирает с context canceled. Контекст, переданный в Run, отменяется SIGINT (NotifyContext(..., os.Interrupt) в main). Диагноз одной строкой: Ctx.Err(). SIGINT принадлежит REPL, main слушает только SIGTERM.
Символы протухли после рефакторинга. Переименовали поле в UpdateAttrs, а файл символов не перегенерили. Это не молчит: undefined возникает ровно на том типе, который менялся.
Отдельный бинарник с тем же composition root: конфиг и данные общие с API, процесс свой. Транспорт в контейнер не входит, консоли он не нужен.
В сессию уходят два имени, App и Ctx. Что лежит в контейнере, то и доступно, без рефлексии по полям.
Символы нужны только входным типам, выходные читаются без регистрации. Список пакетов считается от Container, а не угадывается.
SIGINT принадлежит REPL, main() слушает только SIGTERM. Иначе первое Ctrl+C убивает экспортированный Ctx, и сессия становится зомби.
Read-only по умолчанию стоит в параметрах подключения, а SHOW на старте проверяет, что он применился: пулер умеет срезать параметр, и тогда консоль поднялась бы без защиты. Это предохранитель от промаха, а не граница; границу ставят права роли.
Итоговая сессия на проде выглядит так:
$ /app/console > id := uuid.MustParse("00000000-0000-0000-0000-0000000000b2") > o, err := App.Orders.Repos.OrderRepo.Get(Ctx, id) > o.Status paid > o.TotalCents 1290 > res, err := App.Orders.Usecases.Order.Cancel(Ctx, id) <- операция приложения, а не SELECT > err ERROR: cannot execute UPDATE in a read-only transaction (SQLSTATE 25006) <- и это тоже правильное поведение
Ровно то, за чем мы шли из Rails.
Мой телеграм-канал: @tsymbaldev.