Files
dev-conventions/conventions/lang/go/time.md
T
av 682fa075bb снятое правило остаётся заглушкой, нумерация сплошная
- META-31 и META-32: номера идут без пропусков, ссылка обязана разрешаться;
  обе проверки стали механическими — данных со стороны языка им хватает
- заведена метка СНЯТО: заголовок и номер снятого правила сохраняются, норму
  с обоснованием заменяет блок с датой и причиной, отдельный реестр снятых
  номеров не нужен
- META-9, META-16 и META-26 переписаны из таблицы «Освободившиеся номера» в
  заглушки; три висячие ссылки, тянувшиеся с утра, закрылись
2026-07-26 15:59:22 +03:00

192 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
topic: time
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
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях.
**ПОЧЕМУ.** Зона отображения — настройка, и её меняют. Протекая в
вычисления и хранение, она делает уже записанные данные зависимыми от
текущего значения настройки: смена зоны задним числом сдвигает границы
суток у того, что давно посчитано и сохранено.
Явную зону в календарных вычислениях требует TIME-12 — это его правило, а не
второе такое же здесь.
## Связано
- базовый слой — UTC как формат хранения, нормализация чужого входа, явная
зона в календарных вычислениях.
- конвенция `config` — валидация зоны отображения загрузчиком конфига.