Laravel: скаляры в замыканиях маршрутов и контроллерах

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

Привет. Для начала небольшое предисловие и контекст.

Это мой перевод оригинальной заметки “Primitive Types in Controllers and Closure Routes” от Paul Redmond (кеш). Я нашёл её когда пытался разобраться с одной маленькой проблемой при работе с маршрутами и их параметрами в Laravel.

Суть заключена в трёх символах и одном пробеле, а случай не такой уж частый.

В php-проектах, над которыми я работаю, всегда везде стоит:

<?php

declare(strict_types=1);
//...

Это принципиально. Более подробно об этой директиве можно прочесть здесь. Не пожалей времени, это полезная практика, которая пригодится не только в работе, но и для понимания статьи.

У Laravel есть мощный механизм роутинга и инъекции зависимостей. Например, можно добавить в адрес маршрута какой-то (не)обязательный параметр (как часть URL). Если указать то же имя как аргумент метода (action) контроллера, то он будет доступен там сразу благодаря DI. Дальше это будет наглядно продемонстрировано.

В одном из проектов я расширил базовый контроллер, объявив strict_types, и перегрузил метод callAction(), который передаёт управление дочернему контроллеру, пробрасывая все данные из запроса в его метод. В этот экшен одним из аргументов я указал int $id для параметра {id} из URL. С явным скалярным типом. Мне нужно было целое число из URL. Я наивно допускал, что пыха или ларка сами там как-нибудь вжух и разберутся; просто не заострял внимание на этом.

В итоге я пришёл к той же проблеме, которая описана в оригинальной статье. Далее — её перевод и мой небольшой постскриптум.


Вещь, которую я никогда не учитывал, это подсказки примитивных типов в контроллерах Laravel. PHP имеет только четыре скалярных типа: bool, int, float и string. В рамках маршрутизации, скорее всего, вам потребуются только string и int. Тем не менее, в своих контроллерах обычно я не использую подсказки типов.

Недавно я столкнулся с проблемой, связанной с подсказкой типа в экшене, который вызвал TypeError, поэтому я хотел бы продемонстрировать несколько примеров про безопасное использование методов контроллеров с подсказкой типа int.

Присмотритесь к коду экшена ниже и подумайте — значение какого типа будет в переменной $orderId?

Route::get('/order/{order_id}', function ($orderId) {
    return [
        'type' => gettype($orderId),
        'value' => $orderId,
    ];
});

Когда замыкание будет вызвано, в переменной $orderId будет строковое значение. Если мы быстро напишем простой тест, то увидим, что он успешно проходит:

/**
 * A basic test example.
 *
 * @return void
 */
public function test_example()
{
    $response = $this->get('/order/123');
 
    $response->assertJson([
        'type' => 'string',
        'value' => '123',
    ]);
}

Давайте предположим, что мы ожидаем ID заказа целым числом, поэтому дописываем тип аргументу замыкания:

Route::get('/order/{order_id}', function (int $orderId) {
    return [
        'type' => gettype($orderId),
        'value' => $orderId,
    ];
});

Если мы вернёмся к тесту, он завершится с ошибкой:

--- Expected
+++ Actual
@@ @@
 array (
-  'type' => 'string',
-  'value' => '123',
+  'type' => 'integer',
+  'value' => 123,
 )

Технически, мы передаём в замыкание строку "123" и PHP по умолчанию обрабатывает это с помощью приведения типов. Другими словами, PHP пытается преобразовать наше значение из строки в целое число при вызове замыкания.

Хотя этот подход будет работать и гарантировать целочисленный тип, у нас есть другая проблема, которую вы, возможно, заметили: что, если пользователь передаст что-то, что не может быть преобразовано из строки в целое число?

Если мы скорректируем наш тест, мы получим TypeError:

public function test_example()
{
    $this->withoutExceptionHandling();
 
    $response = $this->get('/order/ABC-123');
 
    $response->assertJson([
        'type' => 'integer',
        'value' => 123,
    ]);
}

При выполнении теста будет выдана ошибка:

TypeError: Illuminate\Routing\RouteFileRegistrar::{closure}():
Argument #1 ($orderId) must be of type int, string given, called in .../vendor/laravel/framework/src/Illuminate/Routing/Route.php on line 238

Если мы хотим явно указать целочисленный тип параметра для маршрута, нам следует убедиться, что сам параметр маршрута соответствует регулярному выражению:

Route::get('/order/{order_id}', function (int $orderId) {
    return [
        'type' => gettype($orderId),
        'value' => $orderId,
    ];
})->where('order_id', '[0-9]+');

После добавления этого ограничения в маршруте будут допустимы только числовые значения, поэтому автоматическое приведение типов будет работать как ожидается. Следующий тест чуть более точен, чтобы проверить отработает ли наш маршрут, если передать в наш параметр нечисловое значение:

public function test_example()
{
    $response = $this->get('/order/ABC-123');
 
    $response->assertNotFound();
}

Теперь вы можете расчитывать, что ваши экшены-замыкания в маршрутах будут безопасно приводить числа к int, если вы захотите использовать тайпхинт. Хотя такой вариант использования ограничен, полагаю, изучение этого нюанса может помочь людям, пытающимся ввести типы для параметров маршрутизатора.

Как на это влияет режим строгой типизации?

Хочу подчеркнуть, что declare(strict_types=1); не возымеет эффекта, поскольку код самого фреймворка не использует эту директиву, а приведение типов происходит только в двух местах:

  1. Метод Controller::callAction() в контроллерах
  2. Метод Route::runCallable() на маршрутах с замыканиями

В документации PHP по объявлению типов есть следующее замечание о том, как работает строгая типизация:

Строгая типизация применяется к вызовам функций, сделанным изнутри файла с включённой строгой типизацией, а не к функциям, объявленным в этом файле. Если из файла без включённой строгой типизации вызывается функция, которая была определена в файле со строгой типизацией, то будут использованы его предпочтения по типизации – т.е. правила строгой типизации будут проигнорированы и для значений будет применяться приведение типов.

Альтернативные подходы

Если у вас есть скалярный тип среди параметров маршрута в замыканиях и контроллерах, вы можете его опустить и привести тип вручную:

Route::get(
    '/order/{order_id}',
    function ($orderId, SomeService $someService) {
        // Меняем тип для метода, который явно строго требует целое число
        $result = $someService->someMethod((int) $orderId);
        // ...
    }
);

В большинстве случаев вы будете использовать механизм привязки модели к маршрутам, которые имеют числовой ID в качестве параметра; можно учесть и этот подход, если у вас есть целочисленный параметр маршрута и вы хотите явно указать его как int.


Собственно, статьёй это можно назвать с натяжкой — скорее, очень поверхностная такая SEO-страничка, воды тут как в твоём дипломном. Больше половины из этой инфы я знал по опыту, да и концовка какая-то жёванная: вероятно, автор имел в виду, что при биндинге модели в её нутри данные уже будут в нужных типах.

Однако здесь наглядно видна проблематика и есть над чем ещё подумать. Казалось бы, по отдельности разные вещи вроде понятны, но собрать их в одну кучу банально не додумываешься.

Например, кому-то может быть неочевиден такой факт: если в запросе инт пришёл строкой, то валидатор после валидации по правилу integer тоже вернёт строку, а не число. Валидатор, как ни странно, всего лишь валидирует, а не типизирует. Так же и тут: DI есть DI, но скаляры — будь любезен… Магия Laravel небезгранична.

Я позволю себе расширить список вариантов решения задачи другими, разной степени бредовости:

  • убрать тип у аргумента, как предложил автор, и либо кастовать вручную уже внутри метода, либо брать данные вот так;
  • просто убрать strict_types из базового контроллера (осуждаю, порицаю и смотрю исподлобья);
  • написать мидлварь под каждый уникальный числовой параметр среди всех роутов и привязать к соотв. роутам;
  • написать мидлварь универсальную и использовать рефлексию метода контроллера для определения какие параметры нужно кастануть в инт;
  • использовать пакеты вроде spatie/data-transfer-object или подобные, а лучше даже spatie/laravel-data: он лихом собирает DTO в нужных типах прямо из запроса и умеет много чего ещё — классная штука;
  • использовать Request-объекты (потенциально наименее подходящий вариант, я не проверял, но изучить тоже можно);
  • вкатить @noinspection PhpMissingParamTypeInspection в докблок метода, чтобы phpstorm не ругался.

В целом, это всё не страшно. Просто это надо иметь в виду и использовать тот подход, который не будет противоречить принятому стилю и процессу разработки.

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

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