Потребность во временной локализации продукта возникает, когда продукт вырастает до таких масштабов, при которых необходима работа в разных временных зонах (очевидность). Хочется описать вариант простой идеи решения этого кейса.
Предыстория такова: разрабатывали нишевую CRM/ERP-систему, а потом нам сказали, что буквально завтра с этой системой будут работать по франшизе от Владивостока до Калининграда. К сожалению, изначально такой сценарий продуман не был, и мы начали изучать, как сделать это сделать с минимальными затратами и максимальным удобством.
Итого, укрупненно получилось три задачи: как мы выводим данные и как вводим, а между ними задача как все это храним. Поскольку время, как известно, относительно в прямом и переносном смысле, было решено хранить время как раньше по Москве UTC+3, но обрабатывать его на входе и выходе (и везде иметь в виду, что точка отсчета — UTC+3). Конечно, мы понимали, что есть и другие решения в этом и других направлениях. Можно преобразовать все существующие записи к UTC+-0, а также использовать специализированные типы в СУБД, которые хранят временную зону, можно самим написать этот кастомный тип, если вдруг база не в полной мере поддерживает такие фичи. Но руководствуясь принципом простоты, пошли по предложенному пути, тем более, он, на первый взгляд, существенно ничем не проигрывает остальным, и логика по определению нужной временной зоны была довольно проста.
После того как точкой отсчета стала Москва, добавили настраеваемый параметр временной зоны каждому пользователю, а также — в ряд связанных сущностей (организация, город, заявки, сделки и т.д.). После чего можно было однозначно устанавливать, в каком временном поясе пользователь или сущность, с которой он работает. Логика там стандартная и точно часто специфичная для проектов. Обернули эту логику в сервис и получали временную зону, где нужно
$localizationService->getTimezone();
Решение по локализации дат в шаблонах было следующим: при инициализации Twig расширений меняли временную зону на нужную:
function __construct(Environment $twig, LocalizationService $localizationService) {
$twig->getExtension('Twig_Extension_Core')->setTimezone($localizationService->getTimezone());
}
Наша ситуация осложнялась еще тем, что после вывода любой даты-времени необходимо делать приписку «01.01.2020 12:30 (Москва)», чтобы, например, в условной заявке/задаче/сделке, которая привязана к часовому поясу, выводилась информация о часовом поясе. Из практических соображений это нужно, чтобы единый колл-центр мог комфортно работать с разными временными зонами в рамках задачи/заявки/сделки.
Вся логика по определению приоритета временных зон была зашита в вышеупомянутый getTimezone.
Далее столкнулись с тем, что если делать свой twig-фильтр или -функцию, то необходимо будет изменять кучу шаблонов, а этого хотелось избежать. Поэтому, немного приценившись, мы решили переопределить стандартный twig-фильтр date
...
new TwigFilter('date', [$this, 'date'], ['needs_environment' => true]),
...
function date(Twig_Environment $env, $date, $format = null, $timezone = null)
{
$appendix = '';
if (format && strpos($format, 'H:i') !== false)
$appendix = ' ('.DateTimeFunctions::getRussianAbbrev($this->localizationService->getTimezone()).')';
...
// стандартный код фильтра date записывающийся в $result
...
return $result.$appendix;
}
Также раз мы заняли стандартный фильтр, старую версию определили заново:
...
new TwigFilter('native_date', [$this, 'nativeDate'], [ 'needs_environment' => true]),
...
public function nativeDate(Twig_Environment $env, $date, $format = null, $timezone = null)
{
// стандартный код фильтра date
}
Стандартный код фильтра date можно найти в /twig/twig/lib/Twig/Extension/Core::twig_date_format_filter. Хотя на самом деле в большинстве случаев сгодится простейший, не сильно отличающийся вариант:
$date->setTimeZone($timezone)
$result = $date->format($format);
Конечно, также можно сделать форк или переопределить более существенную часть Twig, но если функционал стандартного фильтра устраивает, то можно просто вынести его отдельно и ничего не потерять.
Осталось решить проблему ввода даты-времени. Один из вариантов решения:
private function getOffsetHours()
{
if (!$this->isInit)
$this->init();
$local = new DateTime('now', new DateTimeZone($this->getTimezone()));
$user = new DateTime('now');
$localOffset = $local->getOffset() / 3600;
$globalOffset = $user->getOffset() / 3600;
$diff = $globalOffset - $localOffset;
return $diff;
}
public function toGlobalTime(DateTimeInterface $dateTime): DateTimeInterface
{
if (!$this->isInit)
$this->init();
$offsetHours = $this->getOffsetHours();
if ($offsetHours > 0) {
return $dateTime->modify('+ '.$offsetHours.' hours');
} else if($offsetHours < 0){
return $dateTime->modify($offsetHours.' hours');
}
return $dateTime;
}
После чего вызывать перед сохранением даты-времени, например, в слушателях. Нам этот вариант подошел, так как в основном время и дата в проекте фиксируются по определенным событиям, а не вводятся вручную. Для другого крайнего случая, где постоянно вводится время через формы, возможно, решение будет неоптимальным.
В качестве бонуса. В проекте для вывода таблиц используется Omines datatables-bundle. Там решение оказалось еще проще. Вместо DateTimeColumn для локализации использовался:
class CustomDateTimeColumn extends DateTimeColumn
{
private $localizationService;
private $timeZone;
public function __construct(LocalizationService $localizationService)
{
$this->localizationService = $localizationService;
$this->timeZone = $localizationService->getTimezoneObject();
}
public function normalize($value)
{
$value->setTimeZone($this->timeZone);
return parent::normalize($value);
}
}
Спасибо за потраченное время. Если кто-то поможет улучшить базовые вещи решения, то буду весьма благодарен. Речь идет про базовые, так как понятно, что код вакуумный и в реальности имеет куда больший DI и всякие плюшки для внутреннего пользования в проекте.
Резюмируя. Представлена идея простого решения по быстрой временной локализации проекта. От версий не зависит или если зависит, то слабо. Это решение успешно перекочевало из Symfony 4.2 в 5.
Автор: prinkov