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 не будет опубликован. Обязательные поля помечены *