Я пришёл в 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 /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(...)} делает сессию детерминированной.
Как это отлаживать
Протокол, если консоль ведёт себя странно:
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. - Запись прошла, хотя консоль без
-write. Либо startup-параметр не доехал до сервера, либо транзакция перебила дефолт явнымBEGIN READ WRITE. Проверка та же:SHOW default_transaction_read_onlyв той же сессии. Если неon, пулер срезал параметр, и консоль обязана была упасть на старте; проверьте, что fail-fast вообще вызывается. - Символы протухли после рефакторинга. Переименовали поле в
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.
Материалы
- cosy-console: рабочий пример консоли из статьи;
- Yaegi: интерпретатор Go на Go;
- yaegi extract: генератор таблиц символов;
- gore: ещё один REPL для Go;
- док-комментарий ImportUsed: переименование коллизий и запрет вызова после первого Eval;
- golang.org/x/tools/go/packages: загрузка и разбор пакетов;
- startup-параметры в конфиге PgBouncer;
Мой телеграм-канал: @tsymbaldev.