- PVSM.RU - https://www.pvsm.ru -
Практически все проекты сталкиваются с проблемами, вызванными неправильной обработкой и хранением даты и времени. Даже если проект используется в одном часовом поясе, все равно после перехода на зимнее/летнее время можно получить неприятные неожиданности. При этом мало кто озадачивается реализацией правильного механизма со старта, потому что кажется, что с этим проблем быть не может, так как все тривиально. К сожалению, в последствии реальность показывает, что это не так.
Логически можно выделить следующие типы значений, относящиеся к дате и времени:
Рассмотрим каждый пункт по отдельности, не забывая об общих рекомендациях [5].
Допустим, лаборатория, которая собрала материал для анализа находится в часовом поясе +2, а центральный филиал, в котором следят за своевременным выполнением анализов — в поясе +1. Время, приведенное в примере, было отмечено при сборе материала первой лабораторией. Возникает вопрос — какую цифру времени должен увидеть центральный офис? Очевидно, что программное обеспечение центрального офиса должно показывать 15 января 2014 года 12:17:15 — на час меньше, так как по их часам событие произошло именно в этот момент.
Рассмотрим одну из возможных цепочек действий, через которую проходят данные с клиента на сервер и обратно, позволяющую всегда корректно отображать дату/время согласно текущему часовому поясу клиента:
Вроде бы все целостно, но давайте подумаем, что в этом процессе может пойти не так. На самом деле, проблемы здесь могут случиться почти на каждом шаге.
Но самый серьезный недостаток в описанной выше цепочке — это использование локального часового пояса на сервере. Если в нем нет перехода на летнее/зимнее время, то никаких дополнительных проблем не будет. А вот в противном случае можно получить массу неприятных неожиданностей.
Правила перевода на летнее/зимнее время — вещь, строго говоря, переменная. Разные страны могут иногда менять свои правила, и эти изменения должны быть заблаговременно заложены в обновления системы. На практике неоднократно встречались ситуации некорректной работы этого механизма, которые по итогу решались установкой хотфиксов либо операционной системы, либо используемых сторонних библиотек. Вероятность повторения тех же проблем — не нулевая, поэтому лучше иметь способ гарантированно их избежать.
Учитывая описанные выше соображения, сформулируем как можно более надежный и простой подход к передаче и хранению времени: за пределами клиента все значения должны быть приведены к часовому поясу UTC.
Рассмотрим, что нам дает такое правило:
Для реализации такого правила достаточно позаботиться о трех вещах:
Описанные выше соображения и рекомендации отлично работают при сочетании двух условий:
Для случаев, где нарушается первое условие, задачу можно решить использованием типов данных, содержащих часовой пояс – как на сервере, так и в базе данных. Ниже приведен небольшой перечень примеров для разных платформ и СУБД.
.NET | DateTimeOffset |
Java | org.joda.time.DateTime, java.time.ZonedDateTime |
MS SQL | datetimeoffset |
Oracle, PostgreSQL | TIMESTAMP WITH TIME ZONE |
MySQL | — |
Нарушение второго условия — более сложный случай. Если это «относительное» время нужно хранить просто для отображения, и нет задачи определить «абсолютный» момент времени, когда событие наступило или наступит для заданного часового пояса — достаточно просто запретить преобразование времени. Например, пользователь ввел начало передачи для всех филиалов телекомпании 25 марта 2016 года в 9:00, и оно в таком виде будет передаваться, храниться и отображаться. Но может случиться, что какой-то планировщик должен автоматически выполнить специальные действия за час до начала каждой передачи (разослать уведомления или проверить наличие каких-то данных в базе телекомпании). Надежная реализация такого планировщика является нетривиальной задачей. Допустим, планировщик осведомлен о том, в каком часовом поясе находится каждый из филиалов. И одна из стран, где есть филиал, через некоторое время решает сменить часовой пояс. Случай не настолько редкий, как может показаться — за этот и два предыдущих года я насчитал больше 10 подобных событий (http://www.timeanddate.com/news/time/ [6]). Получается, что либо пользователи должны поддерживать привязки к часовым поясам в актуальном состоянии, либо планировщик должен в автоматизированном режиме брать эту информацию из глобальных источников типа Google Maps Time Zone API. Я не берусь предложить универсальное решение для подобных случаев, просто отмечу, что такие ситуации требуют серьезной проработки.
Как видно из вышесказанного, не существует единого подхода, покрывающего 100% случаев. Поэтому сперва нужно четко понять из требований, какие из упомянутых выше ситуаций будут в вашей системе. С большой вероятностью, все ограничится первым предложенным подходом с хранением в UTC. Ну а описанные исключительные ситуации его не отменяют, а просто добавляют другие решения для частных случаев.
Допустим, с правильным отображением даты и времени с учетом часового пояса клиента разобрались. Перейдем к датам без времени и примеру, указанному для этого случая в начале — «новый контракт вступает в силу 2 февраля 2016 года». Что будет, если для таких значений использовать те же типы и тот же механизм, что и для «обычных» даты с временем?
Не во всех платформах, языках и СУБД есть типы, хранящие только дату. Например, в .NET есть только тип DateTime, отдельного «просто Date» нет. Даже если при создании такого объекта была указана только дата, время все равно присутствует, и оно равно 00:00:00. Если мы значение «2 февраля 2016 00:00:00» из пояса со смещением +2 переведем в +1, то получим «1 февраля 2016 23:00:00». Для указанного выше примера это будет равносильно тому, что в одном часовом поясе новый контракт начнет действовать 2 февраля, а в другом — 1 февраля. С юридической точки зрения это абсурд и так, конечно же, быть не должно. Общее правило для «чисто» дат предельно простое — такие значения не должны преобразовываться ни на одном шаге сохранения и чтения.
Есть несколько способов избежать преобразование для дат:
Можно, конечно, попытаться привести контрпример и сказать, что контракт имеет смысл только в пределах страны, в которой он заключен, страна находится в одном часовом поясе, и поэтому можно однозначно определить момент вступления его в силу. Но даже в этом случае пользователям из других часовых поясов не будет интересно, в какой момент по их локальному времени произойдет это событие. А даже если бы была нужда показывать этот момент времени, то отображать пришлось бы не только дату, но и время, что противоречит исходному условию.
С хранением и обработкой временных интервалов все просто: их величина не зависит от часового пояса, поэтому никаких особых рекомендаций здесь нет. Их можно хранить и передавать как целочисленное количество единиц времени. Если важна секундная точность — то как количество секунд, если миллисекундная — то как количество миллисекунд и т.д.
А вот вычисление интервала может иметь подводные камни. Предположим, у нас есть типовой код на C#, который считает интервал времени между двумя событиями:
DateTime start = DateTime.Now;
//...
DateTime end = DateTime.Now;
double hours = (end - start).TotalHours;
На первый взгляд, никаких проблем здесь нет, но это не так. Во-первых, могут возникнуть проблемы с юнит-тестированием такого кода, но об этом мы поговорим чуть позже. Во-вторых, давайте представим, что начальный момент времени пришелся на зимнее время, а конечный — на летнее (например, таким образом замеряется количество рабочих часов, а у работников есть ночная смена).
Предположим, код работает в часовом поясе, в котором переход на летнее время в 2016 году происходит в ночь 27 марта, и смоделируем описанную выше ситуацию:
DateTime start = DateTime.Parse("2016-03-26T20:00:15+02");
DateTime end = DateTime.Parse("2016-03-27T05:00:15+03");
double hours = (end - start).TotalHours;
Этот код даст в результате 9 часов, хотя фактически между этими моментами прошло 8 часов. В этом легко убедиться, изменив код вот таким образом:
DateTime start = DateTime.Parse("2016-03-26T20:00:15+02").ToUniversalTime();
DateTime end = DateTime.Parse("2016-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);
Исправляется он также легко — например, использованием ZonedDateTime вместо LocalDateTime.
Расписание запланированных событий – более сложная ситуация. Универсального типа, позволяющего хранить расписания, в стандартных библиотеках нет. Но такая задача возникает не так уж редко, поэтому готовые решения можно найти без проблем. Хорошим примером является формат планировщика cron, который в том или ином виде используется другими решениями, например, Quartz: http://quartz-scheduler.org/api/2.2.0/org/quartz/CronExpression.html [7]. Он покрывает практически все нужды составления расписаний, включая варианты типа «вторая пятница месяца».
В большинстве случаев писать свой планировщик не имеет смысла, так как существуют гибкие проверенные временем решения, но если по какой-то причине есть надобность в создании своего механизма, то как минимум формат расписания можно позаимствовать у cron.
Помимо описанных выше рекомендаций, посвященных хранению и обработке разнотипных значений времени, есть еще несколько других, о которых тоже хотелось бы сказать.
Во-первых, по поводу использования статических членов класса для получения текущего времени — DateTime.UtcNow, ZonedDateTime.now() и т.д. Как и было сказано, использование их напрямую в коде может серьезно усложнить юнит-тестирование, так как без специальных мок фреймворков подменить текущее время не получится. Поэтому, если вы планируете писать юнит тесты, следует позаботиться о том, чтобы реализацию таких методов можно было подменить. Для решения этой задачи есть как минимум два способа:
Дополнительная проблема, которую следует решить при переходе на свою реализацию провайдера текущего времени — это контроль за тем, чтобы никто «по старинке» не продолжил использовать стандартные классы. Эту задачу легко решить в большинстве систем контроля качества кода. По сути она сводится к поиску «нежелательной» подстроки во всех файлах за исключением того, где объявлена реализация «по умолчанию».
Второй нюанс с получением текущего времени — это то, что клиенту доверять нельзя. Текущее время на компьютерах пользователей может очень сильно отличаться от реального, и если есть логика, завязанная на него, то эта разница может все испортить. Все места, где есть необходимость получать текущее время, должны по возможности выполняться на стороне сервера. И, как уже было сказано ранее, все арифметические операции с временем должны производиться либо в UTC значениях, либо с использованием типов, хранящих смещение часового пояса.
И еще одна вещь, которую хотелось упомянуть — это стандарт ISO 8601 [8], описывающий формат даты и времени для обмена информацией. В частности, строковое представление даты и времени, используемое при сериализации, должно соответствовать этому стандарту для предотвращения потенциальных проблем с совместимостью. На практике крайне редко приходится самому реализовывать форматирование, поэтому сам стандарт может быть полезен в основном в ознакомительных целях.
Автор: YuriyIvon
Источник [9]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/razrabotka/114385
Ссылки в тексте:
[1] Дата и время: #1
[2] Дата без времени: #2
[3] Временной интервал: #3
[4] Расписание запланированных событий: #4
[5] общих рекомендациях: #5
[6] http://www.timeanddate.com/news/time/: http://www.timeanddate.com/news/time/
[7] http://quartz-scheduler.org/api/2.2.0/org/quartz/CronExpression.html: http://quartz-scheduler.org/api/2.2.0/org/quartz/CronExpression.html
[8] ISO 8601: https://en.wikipedia.org/wiki/ISO_8601
[9] Источник: https://habrahabr.ru/post/278527/
Нажмите здесь для печати.