Files
dev-conventions/lang/go/time.md
T
av 31d0620f55 остальные конвенции переведены на формальный язык
- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у
  каждого модальность и обязательный блок «Почему»
- классифицирующие места оформлены таблицами, файловый статус снят
  отовсюду, локальные регионы сохранены под прежними именами
2026-07-25 19:17:32 +03:00

11 KiB
Raw Blame History

extends
extends
arch/time.md

Время: реализация на Go

Как требования arch/time.md выполняются в Go-коде: откуда берётся «сейчас», в каком виде время попадает в базу и в логи, что делать с зонами. Форма записи — common/language.md.

Правила

R1. «Сейчас» берётся у слоя хранилища

ДОЛЖЕН. Текущее время приходит из store.Now(), возвращающего time.Now().UTC(), а не из time.Now() по коду.

Почему. Единая точка даёт гарантированный UTC и один формат: ни одна ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением всех записей — по метке 2026-06-28T11:23:45Z уже не видно, была ли она когда-то локальной, и восстановить смещение задним числом не по чему.

Тестируемость мотивом не является: точка — единственное место, которое придётся превратить в переменную или поле, если однажды понадобится подменять часы, но само по себе оно подмены не даёт.

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:

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. Умолчание тихое, неверная зона выглядит как совершенно валидное время, а записи из разных мест перестают складываться в одну хронологию с метками хранилища.

R9. Точность времени в логах отличается от точности в БД

ДОПУСКАЕТСЯ. JSONHandler пишет миллисекунды — три знака, и это не приводится к секундной точности R4.

Почему. Явное разрешение нужно, чтобы R4 не читался как требование одной точности везде. Ширина фиксируется на носитель: три знака в логе — такая же фиксированная ширина, и свойство, ради которого R4 существует, не нарушено. Общее у лога и базы одно — зона (R8).

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.

Связано

  • arch/time.md — базовая конвенция: UTC как формат хранения, явная зона в календарных вычислениях.
  • lang/go/config.md — валидация зоны отображения загрузчиком конфига.