Начало: Дата и время в программировании. Часть 1 — история вопроса

Дата и время в PHP

В PHP для работы с датами существует отличный класс DateTime. Конструктор этого класса принимает два параметра: первый — строковое значение с датой, второй — экземпляр класса DateTimeZone с нужной временной зоной. Если строка с датой содержит информацию о временной зоне или смещении, то второй параметр игнорируется. Если строковое значение не может быть преобразовано в дату, вызывается исключение класса Exception.

$utc  = new DateTimeZone('UTC');
$date = new DateTime('2012-03-16 17:00:00', $utc);

// Такие значения также допустимы:
$date = new DateTime('Last Thursday', $utc);

В случае если второй параметр не передан (он не является необходимым), а в строке с датой не содержится никакой информации о временной зоне или смещении, временная зона определяется автоматически по следующему принципу:

  1. Значение, установленное функцией date_default_timezone_set().
  2. До PHP 5.4: значение переменной окружения TZ.
  3. Значение ini опции date.timezone.
  4. До PHP 5.4: запрос информации у операционной системы (если это поддерживается ОС) с использованием алгоритма для определения названия временной зоны. На этом этапе выдается предупреждение.
  5. Берется временная зона UTC.

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

Ну что же, экземпляр класса DateTime у нас есть, теперь пора разобраться, какие операции над датой есть в наличии. Речь пойдет о PHP 5.3 и выше. Допустим, мы получили дату со стороннего сервиса и хотим отобразить ее в своей временной зоне. Для этого воспользуемся методом-мутатором setTimezone() для установки нужной временной зоны, после чего выведем дату с помощью метода format():

$date = new DateTime($data['date_created']);
$kiev = new DateTimeZone('Europe/Kiev');

$date->setTimezone($kiev);

echo $date->format('j.m.Y H:i');
Мутатор — функция или метод, меняющий состояние исходного объекта (переменной) вместо создания копии с новым значением. Хороший пример мутатора — функции array_push() и array_pop(), которые добавляют и удаляют значения прямо в переданном массиве. Обратным примером будет функция str_replace(), которая возвращает новую строку (после произведения замены), но исходную строку не изменяет.

Метод setTimezone() принимает только экземпляры класса DateTimeZone. Конструктор класса DateTimeZone, в свою очередь, принимает строковое обозначение временной зоны, например Europe/Paris или UTC. Если переданное значение неверно, будет вызвано исключение класса Exception. С полным списком поддерживаемых временных зон можно ознакомиться здесь. Метод format() использует такой-же формат, что и функция date().

Нередко возникает задача отсчитать от даты определенный временной интервал. Для этого можно использовать методы-мутаторы modify(), add() и sub(). Метод modify() принимает строку с относительным значением:

$date = new DateTime('2012-03-16 17:00:00 UTC');

$date->modify('-1 hour'); // либо '1 hour ago'

echo $date->format('Y-m-d H:i:s'); // выведет "2012-03-16 16:00:00"

В работе этого метода есть один нюанс: если добавляются месяцы, то PHP устанавливает новое числовое значение месяца. Если в результате такой операции числовое значение дня не будет соответствовать допустимому количеству дней, то к дате будет добавлен еще один месяц, а количество дней в нем будет вычтено из числового значения дня:

$date = new DateTime('2000-12-31');

echo $date->modify('+1 month')->format('Y-m-d'); // выведет "2001-01-31"
echo $date->modify('+1 month')->format('Y-m-d'); // выведет "2001-03-03"
/* Далее можно добавлять по одному месяцу сколько угодно,
   такого перелистывания больше не произойдет, ведь третье число есть 
   в каждом месяце */

Методы add() и sub() принимают экземпляры класса DateInterval. Класс DateInterval представляет собой относительный отрезок времени и упрощает выражение этого отрезка в понятных человеку величинах. Подробнее об этом я расскажу чуть ниже. Метод add() добавляет к текущей дате интервал времени, а метод sub() этот интервал вычитает:

$date = new DateTime('2012-03-16 17:00:00 UTC');
$day  = DateInterval::createFromDateString('1 day');

echo $date->add($day)->format('Y-m-d H:i:s'); // выведет "2012-03-17 17:00:00"
echo $date->sub($day)->format('Y-m-d H:i:s'); // выведет "2012-03-16 17:00:00"

На работу этих методов распространяется ранее описанный нюанс с добавлением месяцев.

В классе DateTime есть возможность вычислять интервал времени между двумя датами. Для этого используется метод diff(). В результате его работы мы получаем экземпляр класса DateInterval. Допустим, у нас есть дата сообщения в твиттере, и мы хотим вывести, сколько времени прошло с момента его публикации:

$date     = new DateTime($tweet->date_created);
$now      = new DateTime('now');
$interval = $now->diff($date);

echo $interval->format('%d days, %h hours, %s seconds ago'); // выведет нечто вроде "23 days, 12 hours, 25 seconds ago"

Метод diff() принимает второй параметр — булевое значение. Если этот параметр имеет значение true, то интервал времени вычисляется по модулю: не имеет значения, какая из дат позднее, интервал всегда будет положительным. Подробнее о формате метода format() класса DateInterval можно узнать из документации.

Естественно, я рассказал не обо всех методах для работы с датами, я лишь привел самые, на мой взгляд, полезные из них.

Дата и время в MySQL

В MySQL есть целый набор типов данных для хранения даты и времени, но я рассмотрю лишь два: DATETIME и TIMESTAMP. Первый тип данных хранит дату и время без привязки к временной зоне — эту информацию тип DATETIME вообще не хранит. А вот тип TIMESTAMP использует временную зону UTC для хранения. При сохранении значения, MySQL преобразует временную зону в UTC, а при выборке преобразует временную зону обратно в серверную. Это может привести к некорректным данным, если временная зона сервера изменится. Поэтому я рекомендую вам устанавливать используемую временную зону для каждого подключения к MySQL либо использовать тип DATETIME. Мой личный выбор — второй вариант.

Работа с типом DATETIME в MySQL ничем не отличается от работы со строкой (помимо вполне конкретного формата строки, конечно же):

-- Создаем таблицу с полем типа DATETIME:
CREATE TABLE `test` (`date` datetime);

-- Вставляем данные в таблицу:
INSERT INTO `test` SET `date` = '2012-03-16 17:00:00';

-- Выбираем данные из таблицы:
SELECT * FROM `test` WHERE `date` = '2012-03-16 17:00:00';

Как видим, все элементарно.

Високосные секунды в PHP и MySQL

В прошлой статье я писал о високосных секундах, периодически добавляемых ко времени UTC (и всех производных временных зон). На работу PHP они не влияют никоим образом, так как «под капотом» PHP работает с датами как со значениями timestamp, которые, как я уже писал, не учитывают високосных секунд:

$f = 'Y-m-d H:i:s (U)';

echo date_create('2008-31-12 23:59:59 UTC')->format($f), "\n";
echo date_create('2008-31-12 23:59:60 UTC')->format($f), "\n";
echo date_create('2009-01-01 00:00:00 UTC')->format($f), "\n";
echo date_create('2009-01-01 00:00:01 UTC')->format($f);

Результатом выполнения этого кода будет следующее (в скобках выводится значение в формате unix timestamp):

2008-12-31 23:59:59 (1230767999)
2009-01-01 00:00:00 (1230768000)
2009-01-01 00:00:00 (1230768000)
2009-01-01 00:00:01 (1230768001)

А вот в MySQL, при определенных конфигурациях сервера и самого MySQL, при работе с функциями UNIX_TIMESTAMP() и FROM_UNIXTIME(), а также с типом TIMESTAMP високосные секунды добавляются к значениям. В связи с этим, значения timestamp, полученные из MySQL и PHP в одно и то же время, могут отличаться:

$result = $db->query("SELECT UNIX_TIMESTAMP('2010-10-20 00:00:00 UTC')");

echo $result[0], "\n";
echo date_create('2010-10-20 00:00:00 UTC')->format('U');

При условии, что в переменной $db у нас установленное подключение к базе данных, а временной зоной MySQL по умолчанию является UTC, данный код выведет следующее:

1287532824
1287532800

Имеем разницу в 24 секунды. Именно столько високосных секунд было добавлено до 20 октября 2010 года. Есть несколько решений этой проблемы. Первое и самое простое — не работать с типом TIMESTAMP в MySQL. Кроме того, можно отключить високосные секунды в таблице конфигурации timezone в MySQL:

UPDATE mysql.time_zone SET Use_leap_seconds = 'N';

Теперь проблем с високосными секундами возникать не должно.

Сохранение и загрузка данных из MySQL

На мой взгляд, наиболее оптимальный подход к хранению дат в MySQL — хранить их в полях с типом DATETIME. В связи с тем, что этот тип не хранит информацию о временных зонах, я перед сохранением устанавливаю всем датам временную зону UTC, а после загрузки — перевожу их обратно к локальной временной зоне. Для упрощения этого процесса можно создать несколько вспомогательных методов. Заодно напишем метод для упрощения установки временных зон:

class MyDateTime extends DateTime
{
    public function setTimezoneFromString($timezone)
    {
        $tz = new DateTimeZone($timezone);
        return $this->setTimezone($tz);
    }

    public function formatDB()
    {
        $this->setTimezoneFromString('UTC');
        return $this->format('Y-m-d H:i:s');
    }

    public static function createFromDB($datetime)
    {
        $date = new DateTime($datetime . ' UTC');
        $date->setTimezoneFromString(date_default_timezone_get());
        return $date;
    }
}

Приведение возвращаемых родными методами значений типа DateTime к нашему новому классу оставляю на совести читателя. Теперь работать с хранением дат в MySQL стало проще:

// Сохраняем значение в БД
$query = $db->prepare('INSERT INTO `test` SET `date` = ":date"');
$query->execute(array(
    ':date' => $date->formatDB()
));

$query = $db->prepare('SELECT * FROM `test` WHERE `date` = ":date"');
$query->execute(array(
    ':date' => $date->formatDB()
));

$result = $query->fetch(PDO::FETCH_ASSOC);
$result['date'] = MyDateTime::createFromDB($result['date']);

Стоит помнить, что конвертировать нужно абсолютно все даты, даже те, по которым мы фильтруем данные таблицы при выборке. А счастливые обладатели ORM и ActiveRecord могут вынести приведение временных зон в соответствующие классы, избавив себя от необходимости делать это каждый раз вручную.

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

Заключение

Проделанная работа позволяет нам не заботиться о временных зонах, тонкостях работы и геополитической ситуации в мире. Мы даем пользователям возможность получать информацию о времени автоматически в зависимости от их местонахождения. Конечно, еще есть куда расти: не рассмотренными остались вопросы локализации названий месяцев и формата самих дат, но это уже выходит за рамки темы статьи. А в следующий раз я расскажу о нюансах работы с датами с использованием javascript в браузере пользователя.