- arch R13: валидное по RFC 3339 значение с чужим офсетом нормализуется при разборе; канонический вид — обязательство писателя, не контракт с партнёром - go R13: исключение регистрируется директивой //nolint на месте вызова, а не exclude-записью в конфиге, которая адресует путём и отвязывается
13 KiB
extends
| extends |
|---|
| arch/time.md |
Время: реализация на Go
Как требования arch/time.md выполняются в Go-коде: откуда берётся
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами.
Форма записи — 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() запрещён линтером, список исключений исчерпывающий
ДОЛЖЕН. Запрет проверяется линтером (в Go — forbidigo); исключений
ровно два, и оба прописаны явно:
| № | Исключение | Почему оно не покрывается R1 |
|---|---|---|
| R3.1 | сама точка store.Now() |
внутри неё вызов time.Now() и происходит |
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) |
Почему. R1 без механической проверки держится на внимании, а
time.Now() пишется рефлекторно — и в новом коде, и в мелкой правке;
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
себе нарушение запрета: непрописанные, они либо роняют линтер, либо будут
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
сама себе.
R13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера
ДОЛЖЕН. Исключение из R3 оформляется как //nolint:forbidigo // <причина>
на строке вызова; exclude-записи в конфигурации линтера для него не
заводятся.
Почему. Запись в конфиге — второй реестр тех же двух мест: она адресует
исключение путём к файлу, отвязывается при переносе кода и продолжает
разрешать time.Now() там, где исключения уже нет, — молча. Директива
переезжает вместе с кодом, и grep по nolint:forbidigo даёт весь список —
та исчерпываемость, которой требует R3, проверяется одной командой. Голый
//nolint без имени правила глушит на строке все проверки сразу, а без
причины неотличим от заглушенного дефекта; обе деградации штатно ловит
nolintlint (require-specific, require-explanation) — стандартный
способ дисциплинировать директивы в golangci-lint.
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 и
обнаруживается не на записи, а на первой сортировке. Само решение
«нормализовать, а не отклонять» — базовое (arch/time.md R13); здесь —
Go-механика, из-за которой его легко нарушить незаметно.
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— валидация зоны отображения загрузчиком конфига.