Laravel: FormRequest

13.01.2021 в 17:26
3929
+5

В этом обзоре мы рассмотрим один из самых, на мой взгляд, ужасно реализованных и в то же время один из самых, по мнению разработчиков на Laravel, полезных компонентов фреймворка — FormRequest. Этот компонент позволяет вам описывать правила валидации текущего запроса в наследниках класса FormRequest, которые будут автоматически провалидированы фреймворком после того, как вы заинжектите один из таких классов в метод (экшен) вашего контроллера. Вдобавок он позволяет описать правила авторизации для доступа к текущему обработчику в методе authorize.

У этой реализации есть как пользовательские проблемы (проблемы разработчиков, использующих фреймворк), так и проблемы самой организации (архитектуры, реализации) компонента.

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

Во-вторых, он заставляет вас создавать целый класс, который будет использован только для валидации входящего запроса (я надеюсь, вы не передаете реквесты дальше контроллеров?), что само по себе доставляет неудобства. Возможно, в одном случае из ста вы будете переиспользовать такой реквест, однако это слабое преимущество перед основным недостатком — бойлерплейтом.

Но эти все проблемы меркнут после того, как вы посмотрите на код. Давайте это сделаем.

Для начала я объясню на пальцах, как Ларавел вообще автоматически валидирует такие реквесты. В контейнере фреймворка есть возможность подписать замыкания на различные события при резолвинге зависимостей, например, resolving, afterResolving и другие. Таким образом, один из вендорных провайдеров подписывается на событие резолвинга FormRequest или его наследников и заполняет его текущим реквестом, сетит в него контейнер и так называемый Redirector класс, которые отвечает за различные возможности редиректа:

$this->app->resolving(FormRequest::class, function ($request, $app) {
   $request = FormRequest::createFrom($app['request'], $request);

   $request->setContainer($app)->setRedirector($app->make(Redirector::class));
});

Кстати, еще один недостаток в этом случае уже контейнера фреймворка — это то, что такие подписчики не позволяют вернуть вам объект, созданный вами, вы можете только заполнить руками тот объект, что к вам пришел. Такое возможно, поскольку в php объекты по умолчанию передаются по ссылке, поэтому и после исполнения замыкания ваши изменения будут доступны коду. На мой взгляд, это очень неудобно, поскольку лишает вас возможности создавать объекты самому, используя различные фабрики или нормалайзеры, вы вынуждены будете добавлять методы для заполнения ваших объектов в данном случае. Давайте приведу наглядный пример:

  1. Если бы контейнер позволял возвращать объекты (как в Symfony аргумент-резолверы, например):
final class UserCreateDto implements Dto // в этом случае это интерфейс-маркер
{
     // some fields
}

$this->app->resolving(Dto::class, function ($dto, $app) {
    return $app->get(DenormalizerFactory::class)->denormalize(
               $app->get(Request::class)->all(),
               get_class($dto)
     );
});
  1. Как это работает сейчас:
abstract class Dto // в этом случае это объект, который возьмет часть похожего поведения на себя
{
    public function fillFromArray(array $data)
    {
        foreach($data as $property => $value) {
           if (property_exists($this, $property) {
               $this->$property => $value;
           }
        }
    }
}

final class UserCreateDto extends Dto
{
     // some fields
}

$this->app->resolving(Dto::class, function ($dto, $app) {
      $dto->fillFromArray($app->get(Request::class)->all());
});

Таким образом, мы вынуждены добавлять какие-то методы или заполнять через публичные свойства данные объектов самостоятельно вместо того, чтобы отдать это на откуп всяким фабрикам и нормалайзерам.

Мы немного отвлеклись, вернемся к нашим форм-реквестам. Итак, мы заполнили наш объект, дали ему контейнер и редиректор (чувствуете, как завоняло нарушением SRP?), теперь нам нужно его провалидировать. Это происходит тоже с помощью замыкания в этом же провайдере:

$this->app->afterResolving(ValidatesWhenResolved::class, function ($resolved) {
       $resolved->validateResolved();
});

Оказывается, FormRequest реализует какой-то интерфейс ValidatesWhenResolved. Интерфейс достаточно простой и имеет всего один метод:

interface ValidatesWhenResolved
{
    /**
     * Validate the given class instance.
     *
     * @return void
     */
    public function validateResolved();
}

Сразу скажу, это пример плохого интерфейса. Во-первых, непонятно, что должен делать класс, его реализующий, а что делают те классы, что уже реализовали его? У него нет аргументов, даже возвращающее значение не проставлено (не берем во внимание phpdoc). Во-вторых, такой интерфейс будет работать только с контейнером ларавеля, так как такой интерфейс дает возможность иметь невалидные объекты, раз для их валидации необходимо дернуть метод. А ведь можно и не дернуть, да? Ну хорошо, я придираюсь, интерфейс плюс-минус ничего. Но чему нет и не может быть оправданий, это реализации этого интерфейса, смотрим:

public function validateResolved()
{
    $this->prepareForValidation();

     if (! $this->passesAuthorization()) {
         $this->failedAuthorization();
     }

     $instance = $this->getValidatorInstance();

     if ($instance->fails()) {
         $this->failedValidation($instance);
     }

     $this->passedValidation();
}

Этот код находится в трейте, а не внутри класса FormRequest, к этому претензий нет, это вполне в духе ларавеля. Если играть по его правилам, то к этому можно привыкнуть. На первый взгляд кажется, что с реализацией проблем нет. Давайте посмотрим на весь трейт:

trait ValidatesWhenResolvedTrait
{
    /**
     * Validate the class instance.
     *
     * @return void
     */
    public function validateResolved()
    {
        $this->prepareForValidation();

        if (! $this->passesAuthorization()) {
            $this->failedAuthorization();
        }

        $instance = $this->getValidatorInstance();

        if ($instance->fails()) {
            $this->failedValidation($instance);
        }

        $this->passedValidation();
    }

    /**
     * Prepare the data for validation.
     *
     * @return void
     */
    protected function prepareForValidation()
    {
        //
    }

    /**
     * Get the validator instance for the request.
     *
     * @return \Illuminate\Validation\Validator
     */
    protected function getValidatorInstance()
    {
        return $this->validator();
    }

    /**
     * Handle a passed validation attempt.
     *
     * @return void
     */
    protected function passedValidation()
    {
        //
    }

    /**
     * Handle a failed validation attempt.
     *
     * @param  \Illuminate\Validation\Validator  $validator
     * @return void
     *
     * @throws \Illuminate\Validation\ValidationException
     */
    protected function failedValidation(Validator $validator)
    {
        throw new ValidationException($validator);
    }

    /**
     * Determine if the request passes the authorization check.
     *
     * @return bool
     */
    protected function passesAuthorization()
    {
        if (method_exists($this, 'authorize')) {
            return $this->authorize();
        }

        return true;
    }

    /**
     * Handle a failed authorization attempt.
     *
     * @return void
     *
     * @throws \Illuminate\Validation\UnauthorizedException
     */
    protected function failedAuthorization()
    {
        throw new UnauthorizedException;
    }
} 

Почему многие не любят трейты? Потому что они не привязаны к типу. Чем может являться $this внутри трейта? Особенно, если этот $this обращается к несуществующим внутри трейта методам или, прости Господи, свойствам? Непонятно. Поскольку это трейт, его можно подключить в любой класс, но не в любом классе он будет работать. Плюс-минус нормальными являются те трейты, что не берут на себя большие возможности и валидны абсолютно всегда. Пример валидного трейта:

trait LoggerAwareTrait
{
     protected $logger;

     public function setLogger(LoggerInterface $logger)
     {
         $this->logger = $logger;
     }
}

Этот трейт не обращается к несуществующим свойствам или методам, поэтому он будет работать в любом классе. Давайте разберем с этой позиции трейт ValidatesWhenResolvedTrait:

public function validateResolved()
    {
        $this->prepareForValidation();

        if (! $this->passesAuthorization()) {
            $this->failedAuthorization();
        }

        $instance = $this->getValidatorInstance();

        if ($instance->fails()) {
            $this->failedValidation($instance);
        }

        $this->passedValidation();
    }

Метод prepareForValidation определен внутри трейта, с этим все в порядке. На этот раз закроем глаза на странность метода, который непонятно, что должен делать, пока не обратишься к документации. Методы passesAuthorization и failedAuthorization так же определены внутри трейта, поэтому трейт можно спокойно подключить в любой класс (например, в свою реализацию FormRequest) и он будет работать. Дальше мы достаем какой-то инстанс валидатора. Метод getValidatorInstance тоже определен внутри трейта, однако... он обращается к какому-то validator методу:

protected function getValidatorInstance()
{
   return $this->validator();
}

Но этого метода нет ни в интерфейсе, ни внутри трейта, ни даже в FormRequest. Зачем он нужен? Если вы попробуете реализовать интерфейс и подключить данный трейт, то закономерно получите ошибку:

Call to undefined method App\Http\ValidatedRequest::validator()

Де-факто вендорный код не работает, а де-юре авторам кода надо дать подзатыльник за такие кульбиты с кодом во фреймворке, с которым работают миллионы разработчиков. Оправдывать такое решение тем, что, мол, разработчик увидит ошибку и поправит, разработчик должен заглянуть в код перед использованием или этот код только для внутреннего использования (он не помечен аннотацией @internal), не надо, код сломан. Чтобы его починить, достаточно включить голову и сделать метод getValidatorInstance() абстрактным, тогда будет понятно, что необходимо реализовать, чтобы код работал. Хотя признаться, этого недостаточно. Все остальные методы-заглушки также необходимо реализовать, чтобы код в принципе делал свою задачу, а не просто существовал. Теперь задайтесь вопросом, какой смысл существования такого бездарного и неработающего трейта? Он подключается только в одном классе, он не работает, он абсолютно зависимый от конкретной ситуации и конкретной реализации класса, так зачем его переиспользовать? Очевидно, это синдром любви к трейтам, а не какая-то задумка авторов.

Если вы задаетесь вопросом, а почему работает FormRequest, то все просто — он переопределяет метод getValidatorInstance, поэтому метод validator() не вызывается. Для усиления эмоционального возмущения повторюсь, метод validator вообще никому неизвестен и никем не используется, это из головы придуманное название, о котором каким-то чудесным образом (методом проб и ошибок, проще говоря) должен будет узнать разработчик, которому, не дай бог, предстоит работать с этим кодом.

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

protected function convertValidationExceptionToResponse(ValidationException $e, $request)
{
   if ($e->response) {
       return $e->response;
   }

   return $request->expectsJson()
             ? $this->invalidJson($request, $e)
             : $this->invalid($request, $e);
}

Таким нехитрым и безобразным способом работает FormRequest в Laravel.

loader
13.01.2021 в 17:26
3929
+5
Логические задачи с собеседований