Пользовательские часовые пояса

Пользовательские часовые пояса в Laravel

Как работать в одном приложении со множеством часовых поясов, которые выбирают сами пользователи, но при этом в базе данных все временные метки будут храниться в едином 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.