httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree
This commit is contained in:
+33
-21
@@ -63,12 +63,10 @@ func (s Style) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
|
||||
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
|
||||
// словаре нет.
|
||||
func (s Style) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"` + s.String() + `"`), nil
|
||||
}
|
||||
// Собственной сериализации у Style нет намеренно: строку в ответ кладёт
|
||||
// транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ
|
||||
// словаря, а не их видом на проводе; `String()` при этом нужен и логам, и
|
||||
// сообщениям тестов, и проводу — второго словаря заводить незачем.
|
||||
|
||||
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
|
||||
// потому что от них зависят счётчики основания в ответе.
|
||||
@@ -156,6 +154,16 @@ func clipMetric(metric string) string {
|
||||
return metric[:maxMetricInLog] + "…"
|
||||
}
|
||||
|
||||
// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение.
|
||||
//
|
||||
// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они
|
||||
// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет
|
||||
// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты
|
||||
// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена
|
||||
// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить
|
||||
// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел
|
||||
// «Read API», подраздел «Форма провода».
|
||||
|
||||
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
|
||||
// разности были осмысленны: `Hours − Compared` — часы, отброшенные проверкой
|
||||
// пригодности, `Compared − Agreeing − Conflicting` — часы, не сошедшиеся ни с
|
||||
@@ -165,17 +173,21 @@ func clipMetric(metric string) string {
|
||||
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
|
||||
// второго запроса.
|
||||
type Basis struct {
|
||||
Hours int `json:"hours"`
|
||||
Compared int `json:"compared"`
|
||||
Agreeing int `json:"agreeing"`
|
||||
Conflicting int `json:"conflicting"`
|
||||
FirstHour *time.Time `json:"first_hour"`
|
||||
LastHour *time.Time `json:"last_hour"`
|
||||
Hours int
|
||||
Compared int
|
||||
Agreeing int
|
||||
Conflicting int
|
||||
FirstHour *time.Time
|
||||
LastHour *time.Time
|
||||
}
|
||||
|
||||
// Aggregation — род вместе с основанием.
|
||||
//
|
||||
// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта
|
||||
// `aggregation` на проводе объявлена транспортом поимённо и от этого
|
||||
// встраивания не зависит.
|
||||
type Aggregation struct {
|
||||
Style Style `json:"style"`
|
||||
Style Style
|
||||
Basis
|
||||
}
|
||||
|
||||
@@ -186,22 +198,22 @@ type Aggregation struct {
|
||||
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
|
||||
// знает.
|
||||
type LayerRange struct {
|
||||
Layer string `json:"layer"`
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
Points int `json:"points"`
|
||||
Layer string
|
||||
From time.Time
|
||||
To time.Time
|
||||
Points int
|
||||
}
|
||||
|
||||
// Metric — запись каталога.
|
||||
type Metric struct {
|
||||
Metric string `json:"metric"`
|
||||
Metric string
|
||||
// Units — множество различных единиц метрики, отсортированное. Массив, а не
|
||||
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
|
||||
// для обоих случаев честнее строки, которая при расхождении молча выберет
|
||||
// одно из двух.
|
||||
Units []string `json:"units"`
|
||||
Aggregation Aggregation `json:"aggregation"`
|
||||
Layers []LayerRange `json:"layers"`
|
||||
Units []string
|
||||
Aggregation Aggregation
|
||||
Layers []LayerRange
|
||||
}
|
||||
|
||||
// Snapshot — каталог вместе с версией ответа.
|
||||
|
||||
@@ -271,22 +271,25 @@ func TestMeasureПротиворечиеСПеревесомМгновенной
|
||||
}
|
||||
}
|
||||
|
||||
// Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род
|
||||
// уезжает клиенту строкой, которую кладёт `internal/httpapi`, вызывая этот же
|
||||
// `String()`. Поэтому проверяется словарь, а не сериализация — второй словарь на
|
||||
// проводе разошёлся бы с этим молча.
|
||||
//
|
||||
// Незнакомое значение даёт `unknown`, а не пустую строку: клиент не должен
|
||||
// видеть состояние, которого в словаре нет.
|
||||
func TestStyleСловарь(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[catalog.Style]string{
|
||||
catalog.Cumulative: `"cumulative"`,
|
||||
catalog.Instant: `"instant"`,
|
||||
catalog.Unknown: `"unknown"`,
|
||||
catalog.Style(42): `"unknown"`,
|
||||
catalog.Cumulative: "cumulative",
|
||||
catalog.Instant: "instant",
|
||||
catalog.Unknown: "unknown",
|
||||
catalog.Style(42): "unknown",
|
||||
}
|
||||
for style, want := range cases {
|
||||
got, err := json.Marshal(style)
|
||||
if err != nil {
|
||||
t.Fatalf("сериализация %v: %v", style, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
|
||||
if got := style.String(); got != want {
|
||||
t.Errorf("род %d: получили %q, ждали %q", style, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user