Сучасна диджитал-освіта для дітей — безоплатне заняття в GoITeens ×
Mazda CX 30
×

Працюємо з датою та часом правильно

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Мене звати Юрій Івон, я співпрацюю з компанією EPAM як Senior Solution Architect. Хотів би поділитись досвідом на тему роботи з датою та часом.

Більшість проєктів стикаються з проблемами, викликаними неправильною обробкою дати та часу. Навіть якщо система працює в одному часовому поясі, перехід на літній час може принести неприємні сюрпризи. Не дивлячись на це, далеко не всі розробники приділяють достатньо уваги цій проблемі, тому що вона виглядає тривіальною. На жаль, практика показує, що це не так.

Ось перелік типів пов’язаних з датою та часом, які ви можете зустріти в роботі:

  • Дата з часом — «кров для аналізу була зібрана 23 липня 2022 року в 14:26:43».
  • Дата без часу — «прем’єра фільму в США відбудеться 28 жовтня 2022 року».
  • Проміжок часу — «експорт каталогу тривав 3 хвилини 12 секунд».
  • Розклад — «імпорт зі старої системи повинен відбуватися кожен робочий день о восьмій ранку».

Розглянемо кожен з випадків, також враховуючи загальні рекомендації. В кого немає часу та бажання читати всі деталі, можуть відразу перейти до висновків.

Дата з часом

Припустимо, що кров для аналізу була зібрана працівником лабораторії в часовому поясі +2, тоді як її центральний офіс знаходиться в +1. Вказане в прикладі значення було записано лабораторією. Виникає питання — який час повинен побачити центральний офіс? Очевидно, програмне забезпечення центрального офісу повинно показати 13:26:43 в цьому випадку (мінус година), тому що подія збору крові відбулась саме в цей момент в часовому поясі центрального офісу.

Нам треба гарантувати, що наша система працює саме так, тобто показує мітки часу відповідно до часового поясу клієнта. Щоб краще зрозуміти як ми можемо надійно реалізувати таку поведінку, розглянемо приклад послідовності дій, коли мітка часу визначається на клієнті, а потім йде через усі рівні програмного забезпечення в обох напрямках.

  1. Мітка часу створюється на стороні клієнта. Припустімо, це буде 3 жовтня 2022 року 15:13:36. Клієнт знаходиться в часовому поясі +2.
  2. Значення перетворюється в текстовий формат і передається серверу — «2022-10-03T15:13:36+02:00».
  3. Сервер десеріалізує часову мітку в об’єкт дати та часу, приводячи його до свого часового поясу. Якщо серверу відповідає пояс +1, об’єкт буде містити 3 жовтня 2022 року 14:13:36.
  4. Сервер записує це значення в базу даних. Припустімо, що база не зберігає часовий пояс для часових міток. В нашому прикладі, 3 жовтня 2022 року 14:13:36 буде збережено без інформації про пояс.
  5. Сервер читає значення з бази даних і створює об’єкт дати та часу зі значенням 3 жовтня 2022 року 14:13:36. Оскільки сервер працює в +1, значення буде трактуватись як часова мітка в цьому часовому поясі.
  6. Значення перетворюється в текстовий формат і передається клієнту — «2022-10-03T14:13:36+01:00».
  7. Клієнт десеріалізує часову мітку в об’єкт дати та часу, приводячи його до свого часового поясу. Якщо це GMT −5, значення показане користувачу повинно дорівнювати 3 жовтня 2022 року 08:13:36.

Все виглядає логічним та цілісним, але подивімось, що може піти не так. Насправді проблеми можуть виникнути на будь-якому кроці:

  • Часова мітка на клієнті може бути створена без часового поясу. Наприклад, DateTime в .NET з DateTimeKind.Unspecified.
  • Механізм серіалізації може використовувати формат, який не включає зсув часового поясу.
  • Зсув часового поясу може ігноруватись при десеріалізації, особливо в самописних серіалізаторах.
  • При читанні з бази даних об’єкт дати та часу може бути створений без часового поясу. Наприклад, DateTime в .NET з DateTimeKind.Unspecified. Щобільше, це звичайна ситуація в .NET при читанні типів, які не підтримують часовий пояс.
  • Якщо сервери доданків, що працюють з однією базою даних, належать до різних часових поясів, то в даних буде дика плутанина. Часова мітка, записана сервером А та прочитана сервером Б, буде відрізнятись від того ж значення, записаного Б та прочитаним А.
  • Переміщення серверів доданків з одного часового поясу в інший призведе до некоректної інтерпретації вже збережених даних.

Найбільшою проблемою описаного вище підходу є конверсія значень дати та часу в часовий пояс сервера. Це ще може спрацювати, якщо в ньому немає переходу на літній час. В протилежному випадку, рано чи пізно все зламається.

Країни можуть змінювати свої правила переходу на літній час, і ці зміни повинні бути включені в оновлення систем заздалегідь. Мені доводилося зустрічати декілька ситуацій на практиці, коли цей механізм не працював коректно, і немає гарантії, що це не повториться знову.

Беручи до уваги всі ці міркування, можна сформулювати найпростіший та найнадійніший спосіб передачі та збереження міток часу: значення дати та часу мають бути приведеними до UTC як на сервері, так і в базі даних.

Розглянемо, що це нам дає:

  • Під час посилання даних серверу, клієнт має передати зсув часового поясу для кожної часової мітки, щоб сервер міг привести ці значення до UTC. Як варіант, клієнт сам може зробити приведення, але перший варіант більш гнучкий. При отриманні даних назад, клієнт може легко перевести часові мітки до локального поясу, знаючи, що вони завжди в UTC.
  • UTC не має переходу на літній час, тому і не буде проблем, пов’язаних з цим переходом.
  • Не потрібне приведення з урахуванням часового поясу при читанні даних з бази. Достатньо переконатися, що прочитане значення часової мітки розглядається бекендом як UTC. Наприклад, в .NET це можна зробити встановленням властивості DateTimeKind в DateTimeKind.Utc.
  • Різниця часового поясу між різними серверами доданків, як і переміщення серверів між часовими поясами, ніяк не вплинуть на коректність даних.

Для реалізації цих правил треба забезпечити три речі:

  • Механізм серіалізації/десеріалізації повинен коректно переводити UTC значення дати та часу в локальний часовий пояс та навпаки.
  • Десеріалізатор на стороні сервера повинен створювати об’єкти дати та часу з часовим поясом UTC.
  • Механізм доступу до даних на стороні сервера повинен створювати об’єкти дати та часу з часовим поясом UTC. Іноді це досягається без змін в коді, а просто встановленням системного часового поясу всіх серверів в UTC. Проте краще писати такий код, якому це буде непотрібно: в .NET слідкувати, щоб всі екземпляри DateTime мали DateTimeKind.Utc, в Java — триматись якомога далі від LocalDateTime і слідкувати, щоб замість нього використовувався Instant або типи з підтримкою часового поясу тощо.

Цей підхід трохи змінює картинку, яку ми раніше бачили:

Але майте на увазі, що описані вище ідеї добре працюють тільки при двох умовах:

  • Немає вимоги показувати значення часових міток в тому часовому поясі, в контексті якого вони були створені. Протилежну ситуацію можна зустріти в авіаквитках, де часи вильоту та прильоту мають бути вказані згідно поясів відповідних аеропортів. Або якщо один і той же сервер друкує інвойси для різних країн, на кожному інвойсі дата та час мають бути в поясі відповідного регіону, а не бути приведеними до серверного.
  • Всі мітки часу — абсолютні, тобто вказують на один момент часу. Наприклад, «останній запуск Space Shuttle відбувся 8 липня 2011 року в 15:29:04 UTC» або «мітинг почнеться сьогодні о сімнадцятій згідно з часовим поясом EST». Але іноді можуть знадобитися «відносні» мітки часу, такі як «Шоу буде транслюватись між 10 та 11 ранку в усіх країнах зі списку». Тут виходить, що трансляція — не одна подія, а декілька, які можуть відбуватись в різні проміжки часу на абсолютній шкалі через різницю в часових поясах.

У випадках, коли порушується перша умова, проблему можна вирішити використанням типів даних з підтримкою часового поясу як на сервері, так і в базі даних. Якщо ваша база не підтримує такі типи, просто додайте ще одне поле для збереження зсуву поясу для кожної мітки часу.

Порушення другої умови складніше. Уявімо, що такі відносні мітки часу треба зберігати тільки для відображення і немає потреби у визначенні відповідних абсолютних значень. В цьому випадку достатньо уникати перетворень дати та часу. Наприклад, користувач встановив початок трансляції для всіх локацій в 10 листопада 2022 року 10:00:00, і це значення буде передано, збережено і відображено як є. Але в системі може бути планувальник, який має запускати якусь автоматизацію перед кожною трансляцією (відправка повідомлень, перевірка даних тощо). Такий планувальник потребує знання зсуву часового поясу для кожної локації, а це число може змінюватися з часом. Є різні варіанти, як можна підтримувати це знання, але я б розглядав глобальні джерела, такі як Google Maps Time Zone API.

Як можна побачити, немає комплексного підходу, який покрив би 100% випадків. Досить часто «збереження в UTC» буде достатньо, але краще знати про виняткові випадки, коли він не працює і є потреба в складнішому рішенні.

Дата без часу

Ми розібрались, що робити з мітками часу, але як бути з датами без часу? Візьмемо приклад з початку статті: «Прем’єра фільму в США відбудеться 28 жовтня 2022 року». Що буде, якщо ми використаємо ті самі типи даних і той самий механізм, що і для дати з часом?

Не всі платформи, мови та бази даних мають спеціалізовані типи для зберігання дати без часу. Наприклад, в .NET його DateOnly з’явився тільки в .NET 6. Якщо ми вкажемо тільки дату при створенні екземпляра DateTime або аналогічних типів, все одно в ньому буде значення часу, яке дорівнює 00:00:00. Якщо «28 жовтня 00:00:00» з поясу −4 перевести в −5, отримаємо «27 жовтня 23:00:00». Для наведеного вище прикладу це означало б, що фільм вийде 28 жовтня в одному часовому поясі, а 27 жовтня — в іншому, що є нісенітницею, оскільки суперечить початковому твердженню. Загальне правило для «чистих» дат досить просте — такі значення не можна конвертувати на жодному етапі читання та запису.

Є декілька способів уникнути конвертації:

  • Якщо платформа підтримує тип «тільки дата», треба його використовувати.
  • Зберігати, обробляти та передавати значення як ISO date string («2022-10-28») і форматувати його на клієнті залежно від поточних регіональних налаштувань. З точки зору бази даних тут не повинно бути жодних проблем, оскільки такий формат зберігає правильний порядок сортування.
  • Майже такий самий як і попередній, але зі зберіганням в полі типу «тільки дата», якщо база даних його підтримує.

Майте на увазі, що не всі властивості, які виглядають як дата без часу, насправді не мають часу. Наприклад, візуальний елемент діапазону дат, який часто використовується для фільтрації об’єктів, зазвичай неявно має часову складову — «від дати А 00:00:00 до дати Б 23:59:59», тому такі значення слід розглядати як мітки часу та обробляти відповідним чином.

Проміжок часу

Збереження і обробка проміжків часу дуже проста — його значення не залежить від часового поясу, тому тут немає якихось специфічних рекомендацій. Ви можете зберігати та передавати їх як кількість одиниць часу (ціле число або з плаваючою комою, в залежності від необхідної точності). Якщо потрібна секундна точність — як кількість секунд, якщо мілісекундна — як кількість мілісекунд і т. д.

Але розрахунок проміжку часу може мати деякі підводні камені. Скажімо, у нас є типовий код C#, який обчислює інтервал між двома подіями:

DateTime start = DateTime.Now; 
//... 
DateTime end = DateTime.Now; 
double hours = (end - start).TotalHours;

Може здатися, що проблем немає, але це не так. По-перше, ви можете зіткнутися з деякими проблемами з модульним тестуванням, але ми поговоримо про це пізніше. По-друге, тут можуть бути проблеми з розрахунком проміжку. Уявімо, що початок був у зимовий час, а кінець — у літній (наприклад, ми вимірюємо робочий час, а наші працівники мають нічні зміни).

Припустімо, що цей код виконується в часовому поясі, де літній час почався вночі 27 березня 2022 року.

DateTime start = DateTime.Parse("2022-03-26T20:00:15+02"); 
DateTime end = DateTime.Parse("2022-03-27T05:00:15+03"); 
double hours = (end - start).TotalHours;

Цей приклад дає 9 годин, але між подіями пройшло лише 8. Ви можете легко довести це, змінивши код наступним чином:

DateTime start = DateTime.Parse("2022-03-26T20:00:15+02")
    .ToUniversalTime(); 
DateTime end = DateTime.Parse("2022-03-27T05:00:15+03")
    .ToUniversalTime(); 
double hours = (end - start).TotalHours;

Тут ми можемо зробити важливий висновок — будь-які арифметичні операції з мітками часу повинні виконуватися або в значеннях, приведених до UTC, або з використанням типів, що підтримують часовий пояс. Тобто, початковий приклад можна легко виправити, змінивши DateTime.Now на DateTime.UtcNow.

Цей нюанс не залежить від конкретної платформи або мови програмування. Ось еквівалентний код Java, який має ту саму проблему:

LocalDateTime start = LocalDateTime.now();
//...
LocalDateTime end = LocalDateTime.now();
long hours = ChronoUnit.HOURS.between(start, end);

Його також можна легко виправити. Наприклад, замінивши LocalDateTime на Instant, OffsetDateTime або ZonedDateTime.

Розклад

Розклад — складніша річ. Стандартні бібліотеки не мають універсального типу, який би дозволяв зберігати розклади. Однак ця функція досить часто потрібна, тому ви можете легко знайти готове рішення. Хорошим прикладом є вираз cron, який використовується іншими службами та бібліотеками, такими як Quartz. Він покриває майже всі потреби в плануванні, навіть такі випадки, як «друга п’ятниця кожного місяця».

У більшості випадків розробляти власний планувальник немає сенсу, тому що на ринку вже є чимало перевірених в боях рішень. Але якщо з будь-якої причини виникне необхідність створити власну реалізацію, принаймні формат розкладу можна запозичити з cron.

Загальні рекомендації

По-перше, щодо статичних членів класу для отримання поточного часу — DateTime.UtcNow, ZonedDateTime.now() тощо. Як я вже говорив раніше, їх безпосереднє використання в коді може ускладнити модульне тестування, оскільки неможливо буде замінити поточний час без спеціальних mock-фреймворків. Таким чином, якщо ви плануєте проводити модульне тестування свого коду, краще подбати про цей аспект. Цю проблему можна вирішити принаймні двома способами:

  • Зробити новий інтерфейс «DateTimeProvider» з єдиним методом, що повертає поточний час. Потім інжектати цей інтерфейс у всі класи, яким потрібно отримати поточний час. В модульних тестах можна буде підміняти його реалізацію, коли це потрібно. Цей підхід є найбільш гнучким.
  • Зробити новий статичний клас з методом, що повертає поточний час і способом заміни реалізації цього методу. Наприклад, в C# він може надавати властивість UtcNow і метод SetUtcNowImplementation(Func<DateTime> impl). Використання статичного члена для отримання поточного часу зменшує кількість залежностей в конструкторах класів програми, але «статика» не ідеальна з точки зору ООП. Якщо з будь-якої причини попередній варіант вам не підходить, можна розглянути цей.

Ще одна проблема, яку необхідно вирішити під час переходу на власні «провайдери поточного часу», полягає в тому, щоб ніхто не використовував стандартні класи з тією ж метою. Це можна легко вирішити за допомогою сучасних інструментів якості коду і зводиться до простого пошуку «небажаного» підрядка у всіх файлах коду за виключенням того, що містить реалізацію провайдера.

Другий нюанс отримання поточного часу — клієнту не можна довіряти. Поточний час на клієнтських пристроях може істотно відрізнятися від реального. І якщо є логіка, що покладається на це, така різниця може все зламати. Усі місця, де потрібен поточний час, повинні бути виконані на стороні сервера, якщо це можливо.

Ще одна річ, яку я хотів би згадати — це стандарт ISO 8601, який описує формат дати та часу для обміну даними. Серіалізовані представлення дати й часу мають відповідати цьому стандарту, щоб уникнути проблем із сумісністю. На практиці розробники рідко впроваджують власну серіалізацію дати та часу, тому стандарт здебільшого корисний в інформаційних цілях.

Висновки

  • Якщо вам не треба показувати значення міток часу в тому часовому поясі, в контексті якого вони були створені, майте їх приведеними до часового поясу UTC як на сервері, так і в базі даних.
  • Якщо ви не впевнені, що серверний код усюди явно використовує значення дати та часу в UTC, можете встановити системний часовий пояс на всіх серверах в UTC. У цьому випадку, навіть якщо подекуди все ще залучений часовий пояс сервера, все буде узгоджено.
  • Якщо вам треба зберегти оригінальний часовий пояс для деяких міток часу, використовуйте типи, що його підтримують як на сервері, так і в базі даних. Якщо база даних не має таких типів, додайте поле для зсуву часового поясу «поряд» з кожним полем мітки часу, для яких вам треба зберігати це знання.
  • Чітко розрізняйте мітки часу (дати з часом) і чисті дати (дати без часу). Чисті дати ні в якому разі не можна конвертувати між часовими поясами. Також пам’ятайте, що не все, що виглядає як дата без часу, насправді не має часу. Наприклад, діапазон дат у критеріях пошуку зазвичай неявно має часову складову.
  • Будь-які арифметичні операції з мітками часу повинні виконуватися або в значеннях, приведених до UTC, або з використанням типів, що підтримують часовий пояс.
  • Для реалізації планувальників краще використовувати готові рішення. Якщо вам треба обробляти розклади на низькому рівні, подивіться на вирази cron як хороший приклад формату опису розкладів.
  • Переконайтеся, що поточний час можна підміняти в модульних тестах.
  • По можливості отримуйте поточний час на сервері. Ніколи не довіряйте клієнту у визначенні поточного часу.

Це переклад моєї статті з blog.devgenius.io.

👍ПодобаєтьсяСподобалось21
До обраногоВ обраному9
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Було якось дві цікаві проблеми з датами:
1. Маніпуляції з датами (типу парсингу збереженої без таймзони з приведенням до поточної зони) можуть призводити до виникнення «неможливих» дат (наприклад, протягом години DST на яку вперед переводять годинник). Колись через це у клієнта сервер лежав годину в ніч переводу часу.
2. Парсинг текстових дат — досить затратний процес. І в системі з архітектурою накшталт CQRS це може бути постійне значне навантаження CPU серверів (в нас піднімалось до 30-50%, доки це не помітили і мігрували на long).

в любом случае всегда использую хранение времени только по UTC и никогда не было проблем. Создаёте собственно проблемы какие-то на пустом месте

Вы статью прочитали? Если нет — то хотя бы выводы прочитайте. Какие проблемы я по-вашему создаю на ровном месте?

я не про вас. Про тех, кто не использует UTC

Ок. Но стоит отметить, что вся история с UTC справедлива для прошедших событий. Для запланированных все сложнее. Я вот сейчас понимаю что недостаточно детально этот момент рассмотрел в статье.

Хочу лише додати шо в останіх версіях зявився тип TimeSpan який являє собою проміжок часу. Наприклад проміжок 5 годин це new TimeSpan(5,0,0). Можливо цей тип може бути в нагоді.

В останніх версіях чого саме? Наприклад, в .NET цей тип був завжди.

То я переплутав з DateOnly і TimeOnly які з’явились в .Net 6

так, DateOnly — потенціально дуже корисна штука. Чекаю нагоди випробувати в бойових умовах

Дякую за статтю. Англійську версію не робили? Колегам-іноземцям знадобилося б

Так, технічні статті я спочатку пишу англійською. В кінці цієї статті є посилання — blog.devgenius.io/...​he-right-way-5a6b9afa3437

Всё слишком сложно. На самом деле на стороне бекенда удобнее всего всюду работать со временем по гринвичу в UNIX-формате. А отображение в локальном формате производится только на стороне клиента.

Во-первых, это одна из первых рекомендаций в статье (если исключить упомянутый вами Unix формат, с использованием которого я не согласен), во-вторых, этот подход не решает все проблемы. Почему — в статье написано.

Так, я згадував цей момент в статті, але треба було чіткіше прописати. Що майже з будь-яким майбутнім часом ми не можемо з повною впевненістю знати коли це станеться по шкалі UTC. І що при плануванні майбутніх подій треба значення часу перераховувати для кожної таймзони, де подія повинна відбутися.

Погана порада — треба зберігати ідентифікатор таймзони. типу US\Pacific ітд
Бо тільки цей ідентифікатор містить повну інформацію про правила зміни часу.

Ця порада не в контексті майбутнього часу. Для минулого немає сенсу зберігати код таймзони, окрім екзотичних випадків. Пізніше пропишу це більш явно в статті.

Додам: переводячи час з однієї тайм-зони до іншої, чи з UTC в Local — не можна просто додавати Offset (як зазвичай роблять у Java Script). Тому шо одна і та сама тайм-зона може додавати на час більше чи менше через «літній час». Тобто навіть якщо у вас мітинг з 10 до 12 то:
— У день переходу він може бути від 1 до 3х годин завдовшки.
— Дату і час початку мітингу ви отримаєте додавши до UTC +2 години, а дату і час кінця — +1 чи +3 години. Тобто тайм-зона фактично залежить ще й від самого значення дати!
— Сьогодні, завтра, через 2 дні — усе це дуже ненадійні поняття, якщо враховувати таймзони. Для когось сьогодні тільки починається — для іньшого ця дата добігає кіньця, для третього ще не наступила. Якщо людина подорожує — то рахувати дні взагалі недоречно (пригадайте «Навкруги світу за 80 днів»).

Есть опыт работы с datetimeoffset ? Интересно послушать на чем там можно обжечься.

Если речь идет про DateTimeOffset как тип .NET, то с ним все в порядке. Нюансы начинаются при его в маппинге в базу. Если база нормально поддерживает такой тип (например, datetimeoffset в SQL Server), то каких-то сильно больших неожиданностей быть не должно. Но есть неудобства:
— Оверхед по размеру (и это очевидно не только про хранение, но про на чтение/запись данных)
— При отладке и просмотре «сырых» значений из базы неудобно сравнивать часовые метки созданные в разных часовых поясах.
Поэтому, если приложению не важен исходный часовой пояс значений даты/времени, можно немного сэкономить.

А вот если база данных не имеет соответствующего типа, могут быть нюансы. Например, в Cosmos DB даты хранят либо ISO строках, либо в числовом представлении. Если «в лоб» сериализовать .NET DateTimeOffset в строку, то по такому полю нельзя будет сортировать на уровне базы. «2022-11-15T11:00:00+03» было позже «2022-11-15T12:00:00+05», хотя порядок сортировки этих значений как строк будет противоположным. В таких случаях DateTimeOffset из .NET все равно придется как-то конвертировать в удобный для базы формат и обратно. Но, опять же, если исходный часовой пояс важен, без этого не обойтись.

Еще раз напомню, что все эти рассуждения полностью валидны для прошедших событий. Для запланированного времени есть больше нюансов, которые и в статье, и в комментариях упоминались.

Підписатись на коментарі