Привет. Заметка по следам предыдущего материала: Даты, деньги и enum.
Однажды, пока я работал над проектом, меня осенило. Даты-то у меня везде разные!
Во-первых, система, которую я разрабатываю, зависит от стороннего API. Оттуда даты приходят в чистом UTC, что хорошо. Время моего сервера — московское (UTC+3), допустим это окей, судить не берусь. А время в конфигах лары — Asia/Irkutsk (UTC+8). Вот здрасьте. Надо решать проблему.
Лара отдаёт мускулю даты (тип timestamp) как они есть и мускуль их такими пишет в БД. В этих timestamp-ах не указан часовой пояс, что хорошо для нас — это даёт гибкость. Время сервера никак на ни на что это не влияет, так что этим можем пренебречь. Уже легче.
Теперь нужно понять что в каком порядке корректировать. План таков:
- Создать миграцию, которая добавит новое поле в таблицу юзеров. Это будет поле timezone типа varchar(64). Туда мы будем писать идентификатор таймзоны, и этой длины хватит за глаза.
- Эта же миграция должна взять существующие метки времени из всех таблиц и перевести их из Asia/Irkutsk в UTC.
- Поменять в config/app.php значение параметра timezone с Asia/Irkutsk на UTC. Лара должна понимать, что все временные метки уже существуют и впредь должны существовать точно в UTC.
- В веб-морду, на страницу настроек пользователя, нужно добавить селект с выбором их персонального часового пояса. Поскольку проект нацелен на Россию, то есть смысл выбирать часовые пояса только из российских.
- Отображать все метки времени на сайте с учётом часового пояса пользователя.
Дальше я сильно глаголить не стану, но покажу главные фишки, которые я реализовал для решения задачи.
Миграция
Первый и второй шаг связаны с одной миграцией:
./asrtisan make:migration AddTimezoneColumnToUsers
Я покажу примерное её содержание:
/**
* Class AddTimezoneColumnToUsers
*/
class AddTimezoneColumnToUsers extends Migration
{
/**
* Запускает миграцию
*
* @return void
*/
public function up()
{
// Добавляем пользователям поле для хранения их персонального часового пояса
Schema::table('users', function (Blueprint $table) {
$table->string('timezone', 64)
->comment('часовой пояс')
// юзерам обязательно надо поставить какую-то таймзону
->default('Europe/Moscow')
->after('name');
});
// Переводим все метки времени в UTC
$this->convertUsersTimestamps();
// и т. п...
}
/**
* Конвертирует метки времени у пользователей:
* - дата подтверждения почты (если есть)
* - дата создания
* - дата обновления
*
* @param string $to_timezone Часовой пояс, в который нужно перевести метки времени
*/
private function convertUsersTimestamps(string $to_timezone = 'UTC')
{
$collection = DB::table('users')->select(['id', 'email_verified_at', 'created_at', 'updated_at'])->get();
foreach ($collection as $data) {
$newdata['email_verified_at'] = $data->email_verified_at
? Carbon::parse($data->email_verified_at)->setTimezone($to_timezone)
: null;
$newdata['created_at'] = Carbon::parse($data->created_at)->setTimezone($to_timezone);
$newdata['updated_at'] = Carbon::parse($data->updated_at)->setTimezone($to_timezone);
DB::table('users')->where('id','=', $data->id)->update($newdata);
}
}
/**
* Откатывает миграцию
*
* @return void
*/
public function down()
{
// Переводим все метки времени в наш часовой пояс
$this->convertUsersTimestamps('Asia/Irkutsk');
// и т. п...
// Удаляем у пользователей поле для хранения их персонального часового пояса
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('timezone');
});
}
}
В сущности всё просто. Взяли из таблицы метки, сдвинули в другую таймзону, записали.
Конфиг
Ещё проще. Открываем config/app.php:
//...
'timezone' => 'UTC',
//...
Вывод, выбор и сохранение таймзоны
Нужно добавить куда-нибудь функцию, которая будет отдавать готовый список российских часовых поясов. Её можно вкорячить в нужный контроллер или куда угодно. Она у меня вот такая:
/**
* Возвращает массив российских часовых поясов, где:
* ключ - смещение от UTC
* значение - массив с названиями поясов (англ)
*
* @return array
*/
function get_ru_timezones()
{
$arr = [];
foreach (\DateTimeZone::listIdentifiers(\DateTimeZone::PER_COUNTRY, 'RU') as $string) {
$tz = Carbon::now($string)->getTimezone();
//$arr[] = 'UTC'.$tz->toOffsetName().' '.$tz->toRegionName();
$arr[$tz->toOffsetName()][] = $tz->toRegionName();
}
ksort($arr);
return $arr;
}
В нужном виде (в моём случае это страница настроек профиля пользователя) добавляем селект для выбора, примерно так:
<label for="timezone" class="col-md-4 col-form-label text-md-right">
Часовой пояс
</label>
<div class="col-md-6">
<select
id="timezone"
name="timezone"
class="custom-select @error('timezone') is-invalid @enderror">
<option value="UTC" @if(old('timezone', user()->timezone) == 'UTC') selected @endif>
UTC
</option>
@foreach($timezones as $offset => $timezone_arr)
@foreach($timezone_arr as $timezone)
<option value="{{ $timezone }}" @if(old('timezone', user()->timezone) == $timezone) selected @endif>
UTC{{$offset}} - {{ $timezone }}
</option>
@endforeach
@endforeach
</select>
</div>
Осталось в контроллере, который будет принимать данные из этой формы, задать указанную таймзону пользователю:
//...
Auth::user()->timezone = $request->timezone;
Auth::user()->save();
//...
Вывод времени с учётом таймзоны пользователя
Для этого я создал трейт, который мы должен прицепить ко всем моделям, имющим временные метки. В нём я реализовал два метода для полей created_at и updated_at — чаще всего они есть у моделей, а если и нет, то всё равно вернётся null.
<?php
namespace App\Traits;
/**
* Трейт, предназаченный для моделей, для получения дефолтных
* меток времени уже с готовым смещением на часовой пояс пользователя
*
* @package App\Traits
*/
trait MustReturnLocalTimestamps
{
/**
* Мутатор, возвращающий метку времени создания сущности
* со смещением на часовой пояс пользователя
*
* @return mixed
*/
public function getCreatedAtLocalAttribute()
{
if ($this->created_at) {
return $this->created_at->timezone(Auth::user()->timezone);
}
return null;
}
/**
* Мутатор, возвращающий метку времени обновления сущности
* со смещением на часовой пояс пользователя
*
* @return mixed
*/
public function getUpdatedAtLocalAttribute()
{
if ($this->updated_at) {
return $this->updated_at->timezone(Auth::user()->timezone);
}
return null;
}
}
Чтобы использовать этот трейт, нужно:
- Сохранить его в app/Traits
- Указать его в use класса нужной модели, например, так:
//...
class User extends Authenticatable implements MustVerifyEmail
{
use /*...*/, App\Traits\MustReturnLocalTimestamps;
А использовать так:
// blade
Время создания (UTC): {{ $somemodel->created_at }}
Время создания {{ Auth::user()->timezone }}: {{ $somemodel->created_at_local }}
// php
$utc_time = $somemodel->created_at;
$local_time = $somemodel->created_at_local;
То есть в атрибуте created_at всё ещё хранится исходное значение из БД в UTC, при необходимости можно работать и с ним. А атрибуты _local — только для чтения.
Всё
Можно коммитить, слать на сервер и проводить миграцию. В самом простом случае это будет примерно выглядеть так:
cd MySuperProject
git pull
./artisan down --message="Проводятся технические работы"
./artisan migrate
# внести правку в config/app.php вручную либо через sed
./artisan up
Может быть код и не топчик, но цель поста — указать направление вашей мысли и донести идею как можно решить аналогичную задачу на вашем проекте.