Как работать в одном приложении со множеством часовых поясов, которые выбирают сами пользователи, но при этом в базе данных все временные метки будут храниться в едином UTC-формате, либо в часовом поясе заданном по умолчанию в приложении, что позволит нам отображать одну и ту же информацию по-разному без нарушения ее целостности.
Часовые пояса в PHP и MySQL
Заданный часовой пояс будет использоваться для PHP-функций date
и datetime
, и это важно, поскольку он влияет не только на вычисления даты и времени, например, на разницу между указанной датой и текущим моментом, но и на то, как метки времени будут храниться в базе данных.
Часовой пояс в PHP задается в файле php.ini
, например:
[Date] date.timezone = "Europe/Moscow"
Также его можно задать на системном уровне:
date_default_timezone_set ('Europe/Moscow');
Все часовые пояса, которые поддерживает PHP, можно увидеть здесь.
В MySQL дефолтный часовой пояс задается глобально в файле /etc/mysql/my.cnf
, через указание GMT-разницы ((Greenwich Mean Time, Среднее время по Гринвичу)) желаемой зоны:
[mysqld] default-time-zone = "+03:00"
Также можно задать глобальную переменную:
sudo mysql -e "SET GLOBAL time_zone = '+03:00';"
Если мы используем PDO, то можем задать часовой пояс для каждого соединения, указая GMT-разницу:
$conn->exec("SET time_zone='+03:00';");
Часовые пояса в Laravel
В Laravel по умолчанию часовой пояс установлен на UTC (Coordinated Universal Time, Всемирное координированное время), хотя можно задать другой, изменив значение timezone
в config/app.php
. Получить доступ к этому значению можно через config('app.timezone')
.
Если ваше приложение будет использоваться пользователями с разными часовыми поясами, то рекомендуется использовать UTC в качестве общесистемного часового пояса, чтобы все даты и временные метки хранились в базе данных как UTC, независимо от часового пояса пользователя (тем более, что он может измениться, например, если пользователь путешествует и зашел из другой страны), а затем конвертировать временные метки в часовой пояс пользователя, чтобы он видел их правильно.
Существуют разные подходы к решению этой проблемы, в этой статье мы попытаемся охватить различные глобальные параметры, чтобы пользователь мог задать свой часовой пояс и увидеть дату и время в соответствии со своими предпочтениями.
Поле timezone в таблице пользователей
Поскольку каждый пользователь может задать свой часовой пояс, то нам нужно дополнительное поле в таблице пользователей для хранения этой информации.
php artisan make:migration add_timezone_field_to_users_table --table=users
Миграция:
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; class AddTimezoneFieldToUsersTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::table('users', function (Blueprint $table) { $table->string('timezone', 40)->nullable(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::table('users', function (Blueprint $table) { $table->dropColumn('timezone'); }); } }
При этом подходе мы задаем поле как nullable
, так как нет нужды хранить ненужную информацию, если пользователь использует UTC или дефолтный часовой пояс. Хранить нужно только отличающиеся часовые пояса, если пользователь их задаст.
Добавление Аксессора и Мутатора для часового пояса
После добавления timezone
в $fillable[]
в модели User
, мы зададим аксессор и мутатор для установки и получения этого атрибута.
Поскольку, при этом подходе, поле часового пояса может быть нулевым (например, если оно равно системному часовому поясу, или если мы добавляем эту функцию в уже существующую систему, где у пользователей пока нет часового пояса), то сделаем аксессор, который, если часовой пояс пользователя пуст, выдает дефолтный часовой пояс.
В пользовательской модели зададим:
public function getTimeZoneAttribute ($value): string { return $value == config('app.timezone') || empty($value) ? config('app.timezone') : $value; }
Так как мы не будем хранить в базе данных часовые пояса аналогичные дефолтному, то добавим Мутатор, который, если часовой пояс, выбранный пользователем, равен дефолтному, то он не будет сохранен в базе данных.
public function setTimeZoneAttribute($value) { $this->attributes['timezone'] = $value == config('app.timezone') || is_null($value) ? null : $value; }
Хелпер Timezone
Напишем несколько хелперов, которые можно будет использовать многократно.
Существует много способов подключения хелперов, например создать app/Helpers/Helpers.php
со следующей структурой:
namespace App\Helpers; use Illuminate\Support\Facades\App; class Helpers { //... }
И добавить в файл composer.json
ссылку на него:
"files": [ "app/Helpers/Helpers.php" ]
Также добавим в файл config/app.php
в $aliases[]
ссылки на этот класс:
'aliases' => [ //.. 'Helpers' => App\Helpers\Helpers::class, ],
В Helpers.php
добавим два новых хелпера для часовых поясов:
static public function getTimeZoneList() { return \Cache::rememberForever('timezones_list_collection', function () { $timestamp = time(); foreach (timezone_identifiers_list(\DateTimeZone::ALL) as $key => $value) { date_default_timezone_set($value); $timezone[$value] = $value . ' (UTC ' . date('P', $timestamp) . ')'; } return collect($timezone)->sortKeys(); }); }
Этот метод создаст упорядоченную коллекцию с доступными часовыми поясами (используемыми в качестве ключа) вместе со смещением по Гринвичу, как показано на изображении ниже. Поскольку эта информация не будет меняться, то мы бессрочно закэшируем её.
Мы можем заменить этот метод упрощенной версией и использовать встроенную PHP-функцию timezone_identifiers_list()
, которая вернет массив со всеми поддерживаемыми часовыми поясами.
Также добавим метод getUserTimeZone()
, который попытается получить часовой пояс, заданный пользователем, если таковой существует, а в противном случае он вернет дефолтный часовой пояс.
Здесь мы будем использовать Laravel-хелпер optional()
, чтобы избежать ошибок, если пользователь не залогинен.
static public function getUserTimeZone() { return optional(auth()->user())->timezone ?? config('app.timezone'); }
Выбор часового пояса пользователем
Используя ранее созданный хелпер, добавим форму, в которой пользователь может задавать свой часовой пояс. Например:
<div class="col-md-4"> <label class="required" for="timezone">{{ __('TimeZone') }}</label> <select class="form-control" name="timezone" id="timezone"> @foreach(Helpers::getTimeZoneList() as $timezone => $timezone_gmt_diff) <option value="{{ $timezone }}" {{ ( $timezone === old('timezone', $user->timezone)) ? 'selected' : '' }}> {{ $timezone_gmt_diff }} </option> @endforeach </select> </div>
В Laravel есть правило проверки timezone
, поэтому мы можем легко валидировать введенное значение:
'timezone' => ['required', 'timezone']
Получение часового пояса пользователя
Если мы хотим предварительно задать часовой пояс пользователя в форме или добавить его в скрытом поле в процессе регистрации, то можно получить текущий часовой пояс пользователя из js-библиотеки moment
.
Для этого нам нужны библиотеки moment
и moment-timezone
. Будем использовать метод moment.tz.guess()
:
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.33/moment-timezone-with-data.min.js"></script> <script> $(document).ready(function() { console.log(moment.tz.guess()); }); </script>
Либо можно получить эту информацию и без использования внешних библиотек, но такой код может не заработать на старых браузерах:
Intl.DateTimeFormat().resolvedOptions().timeZone;
Использование часового пояса пользователя в контроллерах и blade-шаблонах
Существуют различные варианты преобразования дат и временных меток с использованием часового пояса пользователя.
Если раздел требует, чтобы пользователь был авторизован, то мы можем использовать, например:
{{ now( \Auth::user()->timezone ) }}
Или хелпер, чтобы учесть неавторизованные случаи и возвращать дефолтный часовой пояс:
{{ now( Helpers::getUserTimeZone() ) }}
Чтобы отпарсить временную метку с использованием часового пояса пользователя:
Carbon::parse( $model->timestamp_field )->setTimezone( Helpers::getUserTimeZone() );
Сохранение временных меток в базе данных в формате UTC
Обычно пользователь добавляет временные метки, выбирая дату и время из javascript-библиотеки, используя свой собственный часовой пояс, поэтому мы должны преобразовать эту дату в UTC, чтобы сохранить ее в нашей базе данных.
Предположим, что пользователь может добавлять элементы и задавать их дату создания, а часовой пояс отличается от дефолтного часового пояса. Один из способов преобразования такой даты перед сохранением:
$request->merge([ 'created_at' => Carbon::parse( $request->input('created_at'), Helpers::getUserTimeZone() ) ->setTimeZone( config('app.timezone') ) ->format( 'Y-m-d H:i:s' ), ]);
Таким образом, мы указываем парсеру, что часовой пояс данной временной метки имеет тип, и что мы преобразуем его в UTC или дефолтный часовой пояс нашего приложения.
Важно, чтобы это преобразование выполнялось после валидации, поскольку в противном случае, если проверка не удалась, то при вызове old()
в шаблоне, пользователь увидит дату в формате UTC, а не в своем часовом поясе.
Пользователь имеет возможность редактировать этот элемент, поэтому сделаем, чтобы он отображался в его часовом поясе — преобразуем его перед отправкой в шаблон:
$model->created_at = Carbon::parse( $model->created_at )->setTimezone( Helpers::getUserTimeZone() );
Аксессоры часового пояса пользовательской модели
Если мы хотим, чтобы временные метки времени по умолчанию возвращались в часовом поясе пользователя (или дефолтном, если пользователь не залогинен или не установил свой часовой пояс), то можна задать аксессоры в модели.
public function getCreatedAtAttribute($value): Carbon { return Carbon::parse($value)->timezone(Helpers::getUserTimeZone()); } public function getUpdatedAtAttribute($value): Carbon { return Carbon::parse($value)->timezone(Helpers::getUserTimeZone()); }
Blade-компонент для дат с часовым поясом пользователя
Чтобы упростить преобразование временных меток с часовым поясом пользователя в Blade, мы можем создать отдельный компонент.
php artisan make:component DateTimeZone --inline
Используем --inline
, чтобы избежать создания шаблона, поскольку для упрощения этого компонента в нём нет необходимости. Преобразованная дата будет возвращена непосредственно из метода render()
класса app/View/Components/DateTimeZone.php
.
Код этого файла:
namespace App\View\Components; use App\Helpers\Helpers; use Carbon\Carbon; use Illuminate\View\Component; class DateTimeZone extends Component { public Carbon $date; public mixed $format; /** * Create a new component instance. * * @return void */ public function __construct(Carbon $date, $format = null) { $this->date = $date->setTimezone(Helpers::getUserTimeZone()); $this->format = $format; } protected function format() { return $this->format ?? 'Y-m-d H:i:s'; } /** * Get the view / contents that represent the component. * * @return \Illuminate\Contracts\View\View|\Closure|string */ public function render() { return $this->date->format($this->format()); } }
$format
задан как mixed
, поскольку может иметь тип как string
так и null
, если отсутствует. Снова будем использовать хелпер getUserTimeZone()
, чтобы получить, либо часовой пояс, заданный пользователем, либо дефолтный.
В Blade можем использовать этот компонент, передавая ему временную метку для преобразования и, необязательно, указывая выходной формат, который по умолчанию будет, если этот параметр не указан, Ymd H: i: s
.
<x-date-time-zone :date="$model->created_at" /> <x-date-time-zone :date="$model->created_at" format="d-m-Y H:i:s" /> <x-date-time-zone :date="$model->created_at" format="d/m/Y<\b\r>H:i" />
Нерекомендуемый подход
Некоторые, при работе с несколькими часовыми поясами в Laravel, создают мидлвар, в котором дефолтный часовой пояс задается на основе часового пояса пользователя с помощью команды date_default_timezone_set()
.
Этого делать не рекомендуется, поскольку таким образом часовой пояс всей системы изменяется для каждого пользователя и в базе данных будут сохраняться временные метки с разными часовыми поясами.
Автор: Daniel Ignacio Fernández
Перевод: Алексей Широков
Наш Телеграм-канал — следите за новостями о Laravel.