- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree
127 lines
11 KiB
Markdown
127 lines
11 KiB
Markdown
# Форма провода принадлежит транспорту, а не домену
|
||
|
||
- **Дата:** 2026-08-04
|
||
- **Источник:** openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md
|
||
|
||
## Решение
|
||
|
||
Публичный контракт читающих маршрутов объявляет транспорт: `internal/httpapi`
|
||
держит собственные типы с `json`-тегами и переводит в них доменное значение
|
||
присваиванием поле в поле. Доменные типы (`internal/catalog` и далее) тегов не
|
||
несут и до сериализации не доезжают. То же правило покрывает тело отказа; MCP
|
||
собственной формы не объявляет.
|
||
|
||
Противоположное решение — **доменные типы объявлены формой провода намеренно** —
|
||
рассмотрено первым как живая и уважаемая практика и отвергнуто по названной
|
||
причине.
|
||
|
||
## Почему
|
||
|
||
Каталог до этого изменения жил вторым способом: `internal/catalog` сам нёс
|
||
`json`-теги и `Style.MarshalJSON`, а транспорт владел только оболочкой
|
||
`{"metrics": …}`. Отсюда три пути смены **публичного** контракта, ни один из
|
||
которых не касается транспорта и все три выглядят как внутренняя правка:
|
||
переименование поля; разъединение встроенного `Basis` (плоскость объекта
|
||
`aggregation` была следствием встраивания); появление внутреннего поля.
|
||
Удерживал контракт один литерал в тесте, и о том, что этот литерал и есть
|
||
контракт, не было сказано нигде.
|
||
|
||
Решение принималось до того, как образец скопируют четыре маршрута и MCP —
|
||
потом это была бы не развилка, а археология.
|
||
|
||
Литература расколота, и обе стороны названы в источнике поимённо: домен = провод
|
||
у Ben Johnson (`benbjohnson/wtf` — доменные типы корневого пакета несут теги
|
||
напрямую) и у Prometheus (`web/api/v1` — конверт свой, полезная нагрузка
|
||
доменная); раздельно у Gitea (`modules/structs` против `models`), Docker
|
||
(`api/types`), go-kit (service → endpoint → transport) и Kubernetes (internal
|
||
против версионированных `k8s.io/api` плюс кодогенерируемая конверсия).
|
||
Ортогональный совет Mat Ryer — объявлять типы ответа рядом с их обработчиком —
|
||
взят вместе с названной им ценой.
|
||
|
||
Развилку решил **факт проекта, а не вкус**. Цитата из источника:
|
||
|
||
> Правило «доменный тип и есть форма провода» ломается на втором же маршруте
|
||
> цели. Провод точек обещан как `{ts, tz_offset, units, values}`
|
||
> (`docs/architecture.md`, раздел «Форма ответа»), а `store.Point` несёт
|
||
> `{Start, End, OffsetSeconds, Raw}` — эти два набора не совпадают **ни одним
|
||
> именем**. Доменный тип формой провода там быть не может даже при желании.
|
||
|
||
Второй факт — внутренний прецедент, и он в ту же сторону:
|
||
|
||
> Хранилище уже применяет ровно предлагаемое решение. `store.Point` не несёт
|
||
> `json`-тегов вовсе; формат сжатого `payload` объявлен **отдельным
|
||
> неэкспортированным** типом `storedPoint`, а `encodePayload` переводит одно в
|
||
> другое **полем в поле**.
|
||
|
||
Плюс `internal/httpapi/ingest.go`, который своим типом ответа владел с самого
|
||
начала. То есть решение **устраняет** второй способ, а не заводит его: каталог
|
||
был отклонением от уже принятого в проекте образца.
|
||
|
||
Отдельная развилка того же изменения — **чем контракт сторожится**, и там тоже
|
||
есть поимённый отказ:
|
||
|
||
> `golang.org/x/exp/apidiff` и `go-apidiff` отвергнуты, и причина измерима: они
|
||
> сравнивают **Go-API** на предмет компилируемости клиентского кода. Смена
|
||
> строки тега (`json:"metric"` → `json:"name"`) при неизменном Go-имени поля для
|
||
> них — не изменение вовсе. То есть ровно тот класс, ради которого заводится
|
||
> сторож, они не видят.
|
||
|
||
Генерация OpenAPI из кода (`swaggo`) отвергнута как сторож по другой причине —
|
||
она фотографирует уже случившееся, — но не как способ **опубликовать** контракт:
|
||
владелец решил в этом же спринте, что источником истины будет рукописная
|
||
OpenAPI-спека. Байтовое утверждение поэтому названо **детектором изменения**, а
|
||
не контрактом.
|
||
|
||
## Последствия
|
||
|
||
- `+` Публичный контракт чтения перестал быть побочным эффектом имён полей
|
||
домена. Переименование поля домена ломает компиляцию перевода — разработчику
|
||
говорят в момент правки; байты ответа при этом те же (проверено: сборка
|
||
базовой ревизии и сборка ветки против одного файла базы дали побайтово
|
||
идентичные 2268 байт).
|
||
- `+` Появился машинный сторож: обход графа типов ответа утверждает, что ни один
|
||
тип домена не достигает сериализации, а требование `json`-тега на каждом
|
||
экспортированном поле транспортной структуры закрывает калитку
|
||
`type pointWire store.Point`. Рядом — заведомо красный случай на 13 позиций,
|
||
потому что проверка, доказывающая отсутствие, зелена и будучи сломанной.
|
||
- `+` Плоскость объекта `aggregation` перестала быть следствием встраивания
|
||
`Basis` в домене и стала записанным решением транспорта.
|
||
- `−` **Цена обратная, и она взята сознательно:** новое поле домена в ответ само
|
||
не попадёт — его обязан перечислить перевод. Поле, не доехавшее до клиента, —
|
||
такой же дефект, как поле, уехавшее случайно, просто другой.
|
||
- `−` Форма объявлена дважды: типы плюс перевод на каждый маршрут.
|
||
- `−` Словарь рода остался в домене (`Style.String()`), и провод зовёт его же.
|
||
Правка `String()` ради читаемости лога изменит тело ответа клиенту. Из двух
|
||
цен взята эта: свой `switch` на проводе сторожил бы лучше, но завёл бы второй
|
||
словарь, который разошёлся бы с первым молча.
|
||
- `−` Сторож остаётся **opt-in**: маршрут, забывший строку в таблице образцов,
|
||
останется без него молча. Развилка вынесена владельцу (см. ниже).
|
||
- `−` Обход слеп к типам, достижимым только через `any`/интерфейс, и к типам
|
||
внешних зависимостей. Слепота названа в источнике и воспроизведена замером,
|
||
а не предположена.
|
||
|
||
## Открыто, решает владелец
|
||
|
||
Записано здесь, а не в файле задачи: файл закрытой задачи удаляется.
|
||
|
||
**Проверять ли полноту таблицы образцов машиной.** Сторож покрывает три типа,
|
||
идущие через `writeJSON` сегодня; впереди четыре маршрута и MCP — четыре шанса
|
||
забыть строку, и забытая строка не отличима от отсутствия проблемы.
|
||
|
||
- **(а)** обход роутера (`chi.Walk`) с утверждением, что число читающих
|
||
маршрутов равно числу строк таблицы. Около 15 строк, забывание краснеет; цена
|
||
— сцепка теста с роутером. **Рекомендация:** это ровно тот класс «проверка
|
||
отсутствия зелена и будучи сломанной», против которого это же изменение завело
|
||
конвенцию заведомо красного случая, — а на полноту таблицы конвенция не
|
||
распространена.
|
||
- **(б)** тестовый hook в `writeJSON`, собирающий типы реально закодированных
|
||
ответов. Ноль мест на новый маршрут, но шов в продакшн-коде.
|
||
- **(в)** оставить на спеке `read-api` и комментарии-образце. Ноль строк сейчас,
|
||
одна молчащая дыра на каждый забытый маршрут.
|
||
|
||
**Что в проекте считается спекой — контракт системы или ещё и дисциплина его
|
||
смены.** Здесь развилка разрешена в сторону «спека нормирует наблюдаемое,
|
||
дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго
|
||
варианта нет предмета для сверки «спека → код». Прецедент задан на четыре
|
||
следующие задачи цели — если владелец решит иначе, переносить придётся их все.
|