остальные конвенции переведены на формальный язык

- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у
  каждого модальность и обязательный блок «Почему»
- классифицирующие места оформлены таблицами, файловый статус снят
  отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
av
2026-07-25 19:17:32 +03:00
parent 7701a28df1
commit 31d0620f55
11 changed files with 2404 additions and 811 deletions
+143 -43
View File
@@ -1,43 +1,105 @@
---
status: рекомендуемая
extends: arch/time.md
---
# Время: реализация на Go
## Единая точка
Как требования `arch/time.md` выполняются в Go-коде: откуда берётся
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами.
Форма записи — `common/language.md`.
- «Сейчас» берём у слоя хранилища — `store.Now()`, а не `time.Now()` по
коду. Ценность точки — **гарантированный UTC и один формат**: `Now()`
возвращает `time.Now().UTC()`, и ни одна ветка кода не может об этом
забыть. Побочно это единственное место, которое придётся превратить в
переменную или поле, если однажды понадобится подменять часы в тестах, —
но само по себе оно тестируемости не даёт.
- Форматирование и разбор — `store.FormatTime` / `store.ParseTime` поверх
`time.RFC3339`.
- Запрет прямого `time.Now()` механизируется `forbidigo`. Исключений
ровно два, и оба обязаны быть прописаны, иначе конвенция противоречит
сама себе: сама точка `Now()` и обёртка измерения длительности (ниже).
## Правила
## Точность и разбор
### R1. «Сейчас» берётся у слоя хранилища
- В БД — **секундная точность**, ширина 20 символов
(`2026-06-28T11:23:45Z`). Она получается сама: layout `time.RFC3339` не
содержит долей секунды, поэтому `Format` их не выведет.
- `time.RFC3339Nano` не используем: он отбрасывает хвостовые нули и ломает
фиксированную ширину.
- `time.Parse(time.RFC3339, …)` принимает и доли, и не-`Z` офсеты, то есть
канонический вид гарантирует **писатель**, а не читатель. Для одного
писателя этого достаточно; чужой вход нормализуем явно.
- В драйвер отдаём строку из `FormatTime`, а не `time.Time`: колонка —
`TEXT`, и промежуточное преобразование драйвером нам не нужно.
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
`time.Now().UTC()`, а не из `time.Now()` по коду.
## Логи
**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна
ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением
всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она
когда-то локальной, и восстановить смещение задним числом не по чему.
`slog` по умолчанию **не даёт UTC**: встроенные хендлеры пишут время в зоне
самого `time.Time`, то есть в локальной зоне процесса, — на ноутбуке
разработчика логи молча поедут в `+03:00`. UTC ставится `ReplaceAttr` по
`slog.TimeKey`:
Тестируемость мотивом **не является**: точка — единственное место, которое
придётся превратить в переменную или поле, если однажды понадобится
подменять часы, но само по себе оно подмены не даёт.
### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
получить строку времени и прочитать её обратно.
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
свойство каждой отдельной строки кода. Фиксированная ширина (R4) и
взаимная обратимость записи и чтения держатся ровно до первого второго
layout — а расхождение проявится не на записи, а при сравнении значений,
записанных разными местами.
### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
**ДОЛЖЕН.** Запрет механизируется `forbidigo`; исключений ровно два, и оба
прописаны явно:
| № | Исключение | Почему оно не покрывается R1 |
|---|---|---|
| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) |
**Почему.** R1 без механической проверки держится на внимании, а
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
себе нарушение запрета: непрописанные, они либо роняют линтер, либо будут
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
сама себе.
### R4. В БД время хранится с секундной точностью, ширина 20 символов
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
лексикографический порядок совпадает с хронологическим только при
одинаковых ширине и форме. Значение с долями секунды сортируется **раньше**
целой секунды того же момента (`.` меньше `Z`), то есть ломаются и
`ORDER BY`, и диапазонные условия — на конкретных данных, а не на всех
сразу.
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
поэтому `Format` их не выведет.
### R5. `time.RFC3339Nano` не используется
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
от значения: соседние записи получают разную ширину, и свойство, на котором
держится R4, исчезает незаметно. Проверка «формат корректен» при этом
проходит — отказывает только порядок.
### R6. Чужой вход нормализуется явно
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
к каноническому виду явно, а не считается каноническим по факту успешного
разбора.
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
**писатель**, а не читатель; пока писатель один, этого достаточно, но
значение из чужой системы, положенное в базу как пришло, нарушает R4 и
обнаруживается не на записи, а на первой сортировке.
### R7. В драйвер передаётся строка, а не `time.Time`
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
драйверу: появляется вторая точка формата вне `FormatTime` (R2), с
собственным layout, который меняется вместе с версией драйвера, а не вместе
с конвенцией.
### R8. Время в логах приводится к UTC через `ReplaceAttr`
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
```go
func utcTime(_ []string, a slog.Attr) slog.Attr {
@@ -48,24 +110,62 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
}
```
`JSONHandler` пишет миллисекунды — три знака, фиксированная ширина. Это
другая точность, чем в БД, и это нормально: ширина фиксируется на носитель
(см. базу).
**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
время в зоне самого `time.Time`, то есть в локальной зоне процесса — на
ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое,
неверная зона выглядит как совершенно валидное время, а записи из разных
мест перестают складываться в одну хронологию с метками хранилища.
## Длительность
### R9. Точность времени в логах отличается от точности в БД
Обёртка измерения — **легитимное исключение из запрета `time.Now()`**, и
без него не обойтись: `store.Now()` приводит время к UTC через `.UTC()`, а
это **срезает монотонную составляющую** `time.Time`. Интервал, посчитанный
по таким меткам, зависит от подводки часов. Поэтому обёртка берёт
`time.Now()` напрямую и считает `time.Since`с локальным `//nolint`.
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
приводится к секундной точности R4.
## Зоны
**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
такая же фиксированная ширина, и свойство, ради которого R4 существует, не
нарушено. Общее у лога и базы одно — зона (R8).
`time/tzdata` импортируется в `main`, зона отображения валидируется
загрузчиком конфига — см. `lang/go/config.md`. Применяется она только в
шаблонах и форматтерах UI; календарные вычисления бизнес-логики берут зону
явно, как описано в базе.
### R10. Обёртка измерения длительности берёт `time.Now()` напрямую
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с
локальным `//nolint`.
**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
меткам, зависит от подводки часов: перевод назад даёт отрицательную
длительность, скачок вперёд — выброс в измерениях, и оба случая
невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается
как недосмотр и его «чинят».
### R11. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** База зон вшивается в бинарь.
**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
в минимальном образе нет: отказ происходит в рантайме, на первой же попытке
применить зону, — то есть после выкладки, а не на сборке. Импорт именно в
`main` держит это решение в одном видимом месте, а не в случайном пакете,
откуда его удаляют при чистке зависимостей.
### R12. Зона отображения применяется только в UI
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях.
**Почему.** Зона отображения — настройка, и её меняют. Протекая в
вычисления и хранение, она делает уже записанные данные зависимыми от
текущего значения настройки: смена зоны задним числом сдвигает границы
суток у того, что давно посчитано и сохранено.
Календарные вычисления бизнес-логики берут зону явно — как описано в
`arch/time.md`.
<!-- local:механизировано -->
<!-- /local -->
## Связано
- `arch/time.md` — базовая конвенция: UTC как формат хранения, явная зона в
календарных вычислениях.
- `lang/go/config.md` — валидация зоны отображения загрузчиком конфига.