Я пришёл в 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. Вопрос: чем это собрать.

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

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

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

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

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

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

:import cosy-console/internal/domain/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.Order.Get(Ctx, id), и сам метод при этом остаётся обычным скомпилированным кодом.

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, поэтому:

  • подключение к БД настроено идентично;

  • в консоли доступно то же, что и в приложении: один и тот же граф контейнера. 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

Что экспортировать в 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.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-хендлеры в контейнер не входят. Консоль про них не знает и не должна: ей нужны данные и бизнес-правила, а не роутер.

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

Рассмотрим устройство Yaegi. Начнем с терминов:

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

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

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

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

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

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, генератор таблиц символов.

Что генерирует 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

UpdateAttrs, CreationParams; модель это аргумент Create(ctx, order)

cosy-console/internal/domain/catalog

фильтры поиска, пагинация-опции

github.com/google/uuid

uuid.UUID в сигнатурах: uuid.Parse(...), uuid.Nil

cosy-console/pkg/pagination

опции WithPage(1)

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

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

yaegi extract

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(), а не файл.

Почему всё внутри App доступно без символов

Символы нужны, чтобы собрать значение с нуля. Всё, что 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 в символы, со сценарием «открыть своё соединение», который мы запретили парой разделов выше.

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

Теперь пакеты зарегистрированы. Но этого мало: после 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: имя пакета = последний сегмент ключа

После переезда на 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 у нас есть в обоих доменах:

Ключ

path.Base

Итог

github.com/google/uuid/uuid

uuid

совпало с именем пакета

cosy-console/pkg/pagination/pagination

pagination

совпало

cosy-console/internal/domain/orders/orders

orders

совпало

cosy-console/internal/domain/orders/models/models

models

коллизия

cosy-console/internal/domain/catalog/models/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:

ключ в Symbols (от extract)

выведенное имя

ключ в Exports()

имя в сессии

cosy-console/internal/domain/orders/orders

orders

console/orders/orders

orders

cosy-console/internal/domain/orders/models/models

orders_models (родитель orders)

console/orders_models/orders_models

orders_models

cosy-console/internal/domain/catalog/catalog

catalog

console/catalog/catalog

catalog

cosy-console/internal/domain/catalog/models/models

catalog_models (родитель catalog)

console/catalog_models/catalog_models

catalog_models

cosy-console/pkg/pagination/pagination

pagination

console/pagination/pagination

pagination

github.com/google/uuid/uuid

uuid

console/uuid/uuid

uuid

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

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

Акт 4: грабли самого REPL

Многострочность работает. Это неочевидно, и потому стоит сказать прямо: стоковый 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.

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
}

// Ошибку 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),
        },
    }
}

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. Это не договорённость «не запускайте 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, стоило бы данных. Поэтому безопасное идёт по умолчанию, а опасное включается явно.

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

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

  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. Символы протухли после рефакторинга. Переименовали поле в 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.