Часовые пояса в коде: практическое руководство
· 5 мин чтения
Почти всякая ошибка с часовыми поясами восходит к одному из четырёх промахов. Вот что делать вместо этого — в JavaScript, Python, Java, Go и SQL, с обоснованием, а не только с правилом.
Почти всякая ошибка с часовыми поясами в рабочей системе восходит к одному из четырёх промахов: хранить местное время, хранить смещение вместо пояса, преобразовывать не на том уровне или строить таблицу смещений вручную. Всё дальнейшее вытекает из того, чтобы их избегать.
Правило 1: храните мгновения в UTC
Мгновение — это точка на оси времени. Местное время — это показание часов, которому нужны пояс и правило разрешения неоднозначности, прежде чем оно вообще что-то значит.
-- Хорошо: мгновение
created_at TIMESTAMPTZ NOT NULL
-- Плохо: показание часов без пояса
created_at TIMESTAMP NOT NULL
Тип TIMESTAMPTZ в PostgreSQL не хранит пояс — он приводит значение к UTC при записи и преобразует при чтении. Это ровно то, что нужно для «когда это произошло».
Правило 2: храните идентификатор пояса, а не смещение
Если вам нужно знать где — для повторяющегося события, пользовательской настройки, часов работы заведения, — храните Europe/London, а не +01:00.
Смещение — факт об одном мгновении. Пояс — свод правил, переживающий смену намерений правительства. Британия находится на +00:00 в январе и на +01:00 в июле; сохранить любое из двух и назвать это «поясом пользователя» — значит ошибаться полгода.
event_start_local TIMESTAMP NOT NULL, -- выбранное показание часов
event_time_zone TEXT NOT NULL, -- 'Europe/London'
event_start_utc TIMESTAMPTZ NOT NULL -- разрешённое мгновение, пересчитывается при смене правил
Для повторяющегося события источник истины — местное время и пояс; мгновение в UTC есть производный указатель. Если tzdata меняется — а это случается несколько раз в год, — производный столбец перестраивается, и 09:00 пользователя остаются 09:00.
Правило 3: преобразуйте на краю
Прикладная логика работает мгновениями. Преобразование в удобочитаемое местное время происходит в самый последний момент, на том уровне, который знает, кто читает.
Следствие важнее самого правила: никогда не позволяйте поясу самого сервера просачиваться в поведение. Задайте TZ=UTC в среде развёртывания и считайте ошибкой всякий путь в коде, чей итог зависит от языковых и региональных настроек сервера. Это тот класс отказов, что проявляется, только когда кто-нибудь разворачивает систему во втором регионе.
Правило 4: никогда не стройте таблицу смещений
Всякая среда выполнения несёт базу IANA. Пользуйтесь ею.
JavaScript
// Форматирование в поясе
new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Kathmandu',
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date());
// Смещение без библиотеки: сравнить показание часов пояса с UTC
function offsetMinutes(timeZone, instant) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).formatToParts(new Date(instant));
const get = (type) => Number(parts.find((p) => p.type === type).value);
const asUtc = Date.UTC(
get('year'),
get('month') - 1,
get('day'),
get('hour'),
get('minute'),
get('second'),
);
return (asUtc - Math.floor(instant / 1000) * 1000) / 60000;
}
Задавайте hourCycle: 'h23' явно. Некоторые сборки ICU сообщают полночь как час 24 при en-US, что сдвигает дату на сутки и оказывается по-настоящему изматывающей ошибкой при поиске.
Python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo # стандартная библиотека начиная с 3.9
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("Asia/Kathmandu"))
Никогда не используйте datetime.utcnow(). Он возвращает наивную дату, которая выглядит как UTC, но не несёт пояса, а смешивание её с осведомлёнными датами выбрасывает исключение в самый неподходящий миг. Верный вызов — datetime.now(timezone.utc).
Java
Instant now = Instant.now();
ZonedDateTime local = now.atZone(ZoneId.of("Asia/Kathmandu"));
Библиотека java.time устроена хорошо: Instant — для точек на оси времени, LocalDateTime — для показаний часов без пояса, ZonedDateTime — для того и другого вместе. Система типов принуждает к тому самому различию, на котором это руководство настаивает.
Go
loc, _ := time.LoadLocation("Asia/Kathmandu")
local := time.Now().In(loc)
LoadLocation читает системную tzdata. В пустом контейнере её нет — подключите _ "time/tzdata", чтобы встроить её в исполняемый файл, иначе всякий поиск пояса молча откатится к UTC.
Два дня в году
Перевод вперёд стирает час; перевод назад повторяет один. Выберите правило, применяйте его последовательно и сообщите пользователю, что вы сделали:
- Несуществующее местное время (02:30 в весеннюю дату в США): сдвинуть вперёд на величину провала, так что 02:30 становятся 03:30.
- Неоднозначное местное время (01:30 в осеннюю дату в США): взять первое, более раннее вхождение.
Именно эти правила действуют на этом сайте, и они совпадают со значением по умолчанию disambiguation: 'compatible' из Temporal в ECMAScript — совпадение стоит того, чтобы ваше поведение согласовывалось с платформой.
Провал не всегда равен часу. Остров Лорд-Хау сдвигается на 30 минут, поэтому читайте величину из самого перехода, а не предполагайте её.
Проверка
Четыре случая ловят большую часть:
- Получасовой пояс —
Asia/Kolkata(+05:30). Ловит предположения о целых часах. - Пояс на 45 минут —
Asia/Kathmandu(+05:45). Ловит предположения о получасах. - Пояс южного полушария —
Australia/Sydney. Ловит «лето — это июнь». - Остров Лорд-Хау —
Australia/Lord_Howe. Ловит жёстко заданный шестидесятиминутный сдвиг, и больше ничто его не поймает.
Добавьте дату перевода вперёд и дату перевода назад для каждого пояса, в котором ваши пользователи действительно находятся, и закрепите версию tzdata в сборочной цепочке, чтобы обновление базы не роняло сборку по неверной причине.
Повторяющиеся события — другая задача
Разовое событие есть мгновение. Повторяющееся событие есть правило, и им нужно разное хранение.
«Каждый вторник в 09:00 в Europe/London» — это не «каждые 604800 секунд начиная с этого мгновения». Оба расходятся в тот миг, когда Британия переводит часы: правило удерживает встречу на 09:00 по местному, а промежуток сносит её к 08:00 или 10:00.
Храните правило — местное время, идентификатор пояса, схему повторения — и порождайте из него мгновения. Когда tzdata меняется, перестраивайте порождённые мгновения, а не переносите их. Правило — это то, на что пользователь согласился.
Неудобные случаи вытекают из двух годовых разрывов. Встреча в 02:30 ежедневно обнаружит, что в один весенний день такого времени не существует, а в один осенний день оно случается дважды. Выберите правило, применяйте его последовательно и помните, что разные календарные системы выбрали разные, — оттого одно и то же повторяющееся событие может ровно два дня в году показываться в двух календарях с разницей в час.
Замечание о Temporal
Программный интерфейс Temporal в ECMAScript заменяет Date типами, различающими мгновения, показания часов и датовремя с поясом, в духе java.time. Temporal.ZonedDateTime несёт идентификатор пояса; Temporal.Instant — точка на оси времени; Temporal.PlainDateTime — показание часов без пояса, и система типов не даст их спутать.
Его настройка disambiguation выставляет наружу ровно то правило двух дней в году, к которому это руководство возвращается снова и снова: 'compatible' (по умолчанию — сдвигать вперёд сквозь провалы, из неоднозначной пары брать более раннее), 'earlier', 'later' и 'reject'. Последнее стоит обдумать везде, где молча промахнуться на час хуже, чем выдать ошибку.