- META-37: тема называется решением и адресатом, а не ролью части проекта; логи сервера и браузера — logging и client-logging, а не суффиксная пара - META-38: ось слоя объявляется ключами lang/stack в шапке, а не выводится из пути — переезд файла между директориями иначе молча менял состав копии у каждого потребителя; шапки двенадцати конвенций приведены к правилу - в список проверок добавлены объявление оси, единственность базового слоя и совпадение объявленного с директорией
14 KiB
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— валидация зоны отображения загрузчиком конфига.