YourWorldTime

Часовые пояса в коде: практическое руководство

· 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 минут, поэтому читайте величину из самого перехода, а не предполагайте её.

Проверка

Четыре случая ловят большую часть:

  1. Получасовой поясAsia/Kolkata (+05:30). Ловит предположения о целых часах.
  2. Пояс на 45 минутAsia/Kathmandu (+05:45). Ловит предположения о получасах.
  3. Пояс южного полушарияAustralia/Sydney. Ловит «лето — это июнь».
  4. Остров Лорд-Хау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'. Последнее стоит обдумать везде, где молча промахнуться на час хуже, чем выдать ошибку.