- 31 регион `<!-- local:имя -->` в двенадцати файлах удалён, а не перенесён: локальное принадлежит копии и живёт ниже маркера `<!-- conv:local -->` (META-22), так что наполнять регионы в каноне нечем - вместе с ними ушли два опустевших раздела «Связано» — в arch и ansible слоях app-directories канонических ссылок нет, а пустой заголовок ничего не адресует; CLAUDE.md уточнён: раздел заводят, когда ссылки есть - форма проверена скриптом: у всех правил модальность и «Почему», префиксы сходятся с реестром, дыр в нумерации нет
191 lines
13 KiB
Markdown
191 lines
13 KiB
Markdown
---
|
||
prefix: GTIM
|
||
extends: arch/time.md
|
||
---
|
||
|
||
# Время: реализация на Go
|
||
|
||
Как требования базового слоя выполняются в Go-коде: откуда берётся «сейчас»,
|
||
в каком виде время попадает в базу и в логи, что делать с зонами.
|
||
|
||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка
|
||
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и
|
||
только тогда, когда написаны заглавными.
|
||
|
||
## Правила
|
||
|
||
### GTIM-1. «Сейчас» берётся у слоя хранилища
|
||
|
||
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
|
||
`time.Now().UTC()`, а не из `time.Now()` по коду.
|
||
|
||
**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна
|
||
ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением
|
||
всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она
|
||
когда-то локальной, и восстановить смещение задним числом не по чему.
|
||
|
||
Тестируемость мотивом **не является**: точка — единственное место, которое
|
||
придётся превратить в переменную или поле, если однажды понадобится
|
||
подменять часы, но само по себе оно подмены не даёт.
|
||
|
||
### GTIM-2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
|
||
|
||
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
|
||
получить строку времени и прочитать её обратно.
|
||
|
||
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
|
||
свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
|
||
взаимная обратимость записи и чтения держатся ровно до первого второго
|
||
layout — а расхождение проявится не на записи, а при сравнении значений,
|
||
записанных разными местами.
|
||
|
||
### GTIM-3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
|
||
|
||
**ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений
|
||
ровно два, и оба прописаны явно:
|
||
|
||
| № | Исключение | Почему оно не покрывается GTIM-1 |
|
||
|---|---|---|
|
||
| GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
|
||
| GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
|
||
|
||
**Почему.** GTIM-1 без механической проверки держится на внимании, а
|
||
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
|
||
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
|
||
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
|
||
себе нарушение запрета: непрописанные, они либо роняют линтер, либо будут
|
||
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
|
||
сама себе.
|
||
|
||
### GTIM-13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера
|
||
|
||
**ДОЛЖЕН.** Исключение из GTIM-3 оформляется как
|
||
`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
|
||
конфигурации линтера для него не заводятся.
|
||
|
||
**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует
|
||
исключение путём к файлу, отвязывается при переносе кода и продолжает
|
||
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
|
||
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
|
||
та исчерпываемость, которой требует GTIM-3, проверяется одной командой. Голый
|
||
`//nolint` без имени правила глушит на строке все проверки сразу, а без
|
||
причины неотличим от заглушенного дефекта; обе деградации штатно ловит
|
||
`nolintlint` (`require-specific`, `require-explanation`) — стандартный
|
||
способ дисциплинировать директивы в golangci-lint.
|
||
|
||
### GTIM-4. В БД время хранится с секундной точностью, ширина 20 символов
|
||
|
||
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
|
||
|
||
**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
|
||
лексикографический порядок совпадает с хронологическим только при
|
||
одинаковых ширине и форме. Значение с долями секунды сортируется **раньше**
|
||
целой секунды того же момента (`.` меньше `Z`), то есть ломаются и
|
||
`ORDER BY`, и диапазонные условия — на конкретных данных, а не на всех
|
||
сразу.
|
||
|
||
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
|
||
поэтому `Format` их не выведет.
|
||
|
||
### GTIM-5. `time.RFC3339Nano` не используется
|
||
|
||
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
|
||
|
||
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
|
||
от значения: соседние записи получают разную ширину, и свойство, на котором
|
||
держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
|
||
проходит — отказывает только порядок.
|
||
|
||
### GTIM-6. Чужой вход нормализуется явно
|
||
|
||
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
|
||
к каноническому виду явно, а не считается каноническим по факту успешного
|
||
разбора.
|
||
|
||
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
|
||
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
|
||
**писатель**, а не читатель; пока писатель один, этого достаточно, но
|
||
значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
|
||
обнаруживается не на записи, а на первой сортировке. Само решение
|
||
«нормализовать, а не отклонять» — базовое (`TIME-13`); здесь —
|
||
Go-механика, из-за которой его легко нарушить незаметно.
|
||
|
||
### GTIM-7. В драйвер передаётся строка, а не `time.Time`
|
||
|
||
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
|
||
|
||
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
|
||
драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
|
||
собственным layout, который меняется вместе с версией драйвера, а не вместе
|
||
с конвенцией.
|
||
|
||
### GTIM-8. Время в логах приводится к UTC через `ReplaceAttr`
|
||
|
||
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
|
||
|
||
```go
|
||
func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||
if a.Key == slog.TimeKey {
|
||
a.Value = slog.TimeValue(a.Value.Time().UTC())
|
||
}
|
||
return a
|
||
}
|
||
```
|
||
|
||
**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
|
||
время в зоне самого `time.Time`, то есть в локальной зоне процесса — на
|
||
ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое,
|
||
неверная зона выглядит как совершенно валидное время, а записи из разных
|
||
мест перестают складываться в одну хронологию с метками хранилища.
|
||
|
||
### GTIM-9. Точность времени в логах отличается от точности в БД
|
||
|
||
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
|
||
приводится к секундной точности GTIM-4.
|
||
|
||
**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
|
||
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
|
||
такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
|
||
нарушено. Общее у лога и базы одно — зона (GTIM-8).
|
||
|
||
### GTIM-10. Обёртка измерения длительности берёт `time.Now()` напрямую
|
||
|
||
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с
|
||
локальным `//nolint`.
|
||
|
||
**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это
|
||
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
|
||
меткам, зависит от подводки часов: перевод назад даёт отрицательную
|
||
длительность, скачок вперёд — выброс в измерениях, и оба случая
|
||
невоспроизводимы. Разрешение записано явно, иначе исключение GTIM-3.2 читается
|
||
как недосмотр и его «чинят».
|
||
|
||
### GTIM-11. `time/tzdata` импортируется в `main`
|
||
|
||
**ДОЛЖЕН.** База зон вшивается в бинарь.
|
||
|
||
**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
|
||
в минимальном образе нет: отказ происходит в рантайме, на первой же попытке
|
||
применить зону, — то есть после выкладки, а не на сборке. Импорт именно в
|
||
`main` держит это решение в одном видимом месте, а не в случайном пакете,
|
||
откуда его удаляют при чистке зависимостей.
|
||
|
||
### GTIM-12. Зона отображения применяется только в UI
|
||
|
||
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
|
||
представления, но не в хранимых значениях и не в вычислениях.
|
||
|
||
**Почему.** Зона отображения — настройка, и её меняют. Протекая в
|
||
вычисления и хранение, она делает уже записанные данные зависимыми от
|
||
текущего значения настройки: смена зоны задним числом сдвигает границы
|
||
суток у того, что давно посчитано и сохранено.
|
||
|
||
Календарные вычисления бизнес-логики берут зону явно — как описано в
|
||
базовом слое.
|
||
|
||
## Связано
|
||
|
||
- базовый слой — UTC как формат хранения, нормализация чужого входа, явная
|
||
зона в календарных вычислениях.
|
||
- конвенция `config` — валидация зоны отображения загрузчиком конфига.
|