Laravel 5.8: настроить часовые пояса для пользователей

Этот пост был опубликован мной более года назад. Информация, описанная ниже, уже могла потерять актуальность, но всё ещё может быть полезна.

Привет. Заметка по следам предыдущего материала: Даты, деньги и enum.

Однажды, пока я работал над проектом, меня осенило. Даты-то у меня везде разные!

Во-первых, система, которую я разрабатываю, зависит от стороннего API. Оттуда даты приходят в чистом UTC, что хорошо. Время моего сервера — московское (UTC+3), допустим это окей, судить не берусь. А время в конфигах лары — Asia/Irkutsk (UTC+8). Вот здрасьте. Надо решать проблему.

Лара отдаёт мускулю даты (тип timestamp) как они есть и мускуль их такими пишет в БД. В этих timestamp-ах не указан часовой пояс, что хорошо для нас — это даёт гибкость. Время сервера никак на ни на что это не влияет, так что этим можем пренебречь. Уже легче.

Теперь нужно понять что в каком порядке корректировать. План таков:

  1. Создать миграцию, которая добавит новое поле в таблицу юзеров. Это будет поле timezone типа varchar(64). Туда мы будем писать идентификатор таймзоны, и этой длины хватит за глаза.
  2. Эта же миграция должна взять существующие метки времени из всех таблиц и перевести их из Asia/Irkutsk в UTC.
  3. Поменять в config/app.php значение параметра timezone с Asia/Irkutsk на UTC. Лара должна понимать, что все временные метки уже существуют и впредь должны существовать точно в UTC.
  4. В веб-морду, на страницу настроек пользователя, нужно добавить селект с выбором их персонального часового пояса. Поскольку проект нацелен на Россию, то есть смысл выбирать часовые пояса только из российских.
  5. Отображать все метки времени на сайте с учётом часового пояса пользователя.

Дальше я сильно глаголить не стану, но покажу главные фишки, которые я реализовал для решения задачи.

Миграция

Первый и второй шаг связаны с одной миграцией:

./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;
    }
}

Чтобы использовать этот трейт, нужно:

  1. Сохранить его в app/Traits
  2. Указать его в 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

Может быть код и не топчик, но цель поста — указать направление вашей мысли и донести идею как можно решить аналогичную задачу на вашем проекте.

Бонус.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *