Files
dev-conventions/conventions/lang/go/time.md
T
av 787d0bb5ea guide: имя темы и объявленная ось — META-37, META-38
- META-37: тема называется решением и адресатом, а не ролью части проекта;
  логи сервера и браузера — logging и client-logging, а не суффиксная пара
- META-38: ось слоя объявляется ключами lang/stack в шапке, а не выводится
  из пути — переезд файла между директориями иначе молча менял состав копии
  у каждого потребителя; шапки двенадцати конвенций приведены к правилу
- в список проверок добавлены объявление оси, единственность базового слоя
  и совпадение объявленного с директорией
2026-07-26 22:01:38 +03:00

14 KiB
Raw Blame History

topic, prefix, lang, extends
topic prefix lang extends
time GTIM go 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:

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 — валидация зоны отображения загрузчиком конфига.