Как стать автором
Обновить

Пишем расширяемые сервисы на Symfony. От простого к сложному

Уровень сложностиСредний

Привет. Сегодня мы с вами посмотрим на один из примеров как можно писать расширяемый код. Работать будем с фреймворком Symfony. Для примера мы реализуем с вами небольшой обработчик ошибок, который должен будет возвращать нам DTO после того как произойдет какой-нибудь эксепшен.

Сначала мы напишем с вами плохой и труднорасширяемый пример реализации обработчика, а потом приведем его в порядок.

У меня уже установлен фреймворк Symfony версии 7.2. Данный подход будет работать и на более ранних версиях фреймворка(могут быть незначительные отличия в импортируемых класса атрибутов, но принцип работы от этого не меняется)

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

ExceptionListener.php

namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\ExceptionEvent;

class ExceptionListener
{
    /**
     * @param ExceptionEvent $event
     */
    public function onKernelException(ExceptionEvent $event): void
    {
    }
}

services.yaml

    App\EventListener\ExceptionListener:
        tags:
            - { name: kernel.event_listener, event: kernel.exception }

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

ExceptionDto.php

namespace App\Dto;

readonly class ExceptionDto
{
    /**
     * @param string $message
     * @param int $code
     */
    public function __construct(
        private string $message,
        private int $code
    ) {
    }

    /**
     * @return string
     */
    public function getMessage(): string
    {
        return $this->message;
    }

    /**
     * @return int
     */
    public function getCode(): int
    {
        return $this->code;
    }
}

Далее создаем сервис в котором будем работать с исключениями. Напишем код самым примитивным и не расширяемым способом

ExceptionService.php

namespace App\Service;

use App\Dto\ExceptionDto;

class ExceptionService
{
    /**
     * @param \Throwable $exception
     *
     * @return ExceptionDto
     */
    public function handleException(\Throwable $exception): ExceptionDto
    {
        $dto = new ExceptionDto();

        if($exception instanceof HttpException){
            $dto->message = $exception->getMessage();
            $dto->exceptionClass = $exception::class;
        } else{
            $dto->message = 'Unhandled exception';
        }
        return $dto;
    }
}

В слушателе ExceptionListener вызовем наш сервис

namespace App\EventListener;

use App\Service\ExceptionService;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;

readonly class ExceptionListener
{
    /**
     * @param ExceptionService $exceptionService
     */
    public function __construct(
        private ExceptionService $exceptionService,
    ) {
    }

    /**
     * @param ExceptionEvent $event
     */
    public function onKernelException(ExceptionEvent $event): ExceptionEvent
    {
        dd($this->exceptionService->handleException($event->getThrowable()));
    }
}

Для проверки работоспособности мы можем перейти на 404 страницу(В таком случае фреймворк выдаст нам HttpException) или в коде отдать стандартный Exception. Все работает, но код очень сложно расширять и поддерживать, на сервисе обработчика слишком много ответственностей. Такой код ревью точно не пройдет)


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

HandlingExceptionInterface.php

namespace App\Service\ErrorHandler;

use App\Dto\ExceptionDto;

interface HandlingExceptionInterface
{
    /**
     * @param \Throwable $exception
     * @return ExceptionDto
     */
    public function handlingException(
        \Throwable $exception
    ): ExceptionDto;
}

Дальше мы уже можем перенести логику обработки наших ошибок в отдельные классы

HttpExceptionHandler.php

namespace App\Service\ErrorHandler;

use App\Dto\ExceptionDto;
use Symfony\Component\HttpKernel\Exception\HttpException;

class HttpExceptionHandler implements ErrorHandlerInterface
{
    /**
     * @param \Throwable $exception
     * @return ExceptionDto
     */
    public function handlingException(\Throwable $exception,): ExceptionDto
    {
        return new ExceptionDto(
            $exception->getMessage(),
            $exception->getCode(),
        );
    }
}

DefaultExceptionHandler.php

namespace App\Service\ErrorHandler;

use App\Dto\ExceptionDto;

class DefaultExceptionHandler implements HandlingExceptionInterface
{
    private const DEFAULT_MESSAGE = 'Unhandled exception';

    /**
     * @param \Throwable $exception
     * @return ExceptionDto
     */
    public function handlingException(\Throwable $exception,): ExceptionDto
    {
        return new ExceptionDto(
            self::DEFAULT_MESSAGE,
            $exception->getCode()
        );
    }
}

Дорабатываем наш ExceptionService

namespace App\Service;

use App\Dto\ExceptionDto;

class ExceptionService
{
    /**
     * @param \Throwable $exception
     *
     * @return ExceptionDto
     */
    public function handleException(\Throwable $exception): ExceptionDto
    {
        if($exception instanceof HttpException){
            return (new HttpExceptionHandler())->handlingException($exception);
        } 

        return (new DefaultExceptionHandler())->handlingException($exception);
    }
}

Наши доработки не изменили результат работы сервиса, но он стал выглядеть лучше. Мы переложили логику формирования нашего DTO на отдельный класс. Но расширять наш сервис всё еще сложно. При появлении нового обработчика нам нужно заходить и писать новое условие. Но и такой код вряд ли пройдет ревью)


Давайте доработаем код так, чтобы конкретный хэнделр решал нужно ему обрабатывать данный эксепшен или нет.

Создадим еще один интерфейс ErrorHandlerInterface и унаследуем его от ранее созданного HandlingExceptionInterface.

namespace App\Service\ErrorHandler;

interface ErrorHandlerInterface extends HandlingExceptionInterface
{
    /**
     * @param \Throwable $exception
     * @return bool
     */
    public function isApply(\Throwable $exception): bool;
}

Мы разделили интерфейсы, так как в дефолтном хендлере нам не нужен метод isApply.

Дорабатываем код нашего HttpExceptionHandler

use App\Dto\ExceptionDto;
use Symfony\Component\HttpKernel\Exception\HttpException;

class HttpExceptionHandler implements ErrorHandlerInterface
{
    /**
     * @param \Throwable $exception
     * @return ExceptionDto
     */
    public function handlingException(\Throwable $exception): ExceptionDto
    {
        return new ExceptionDto(
            $exception->getMessage(),
            $exception->getCode(),
        );
    }

    /**
     * @param \Throwable $exception
     * @return bool
     */
    public function isApply(\Throwable $exception): bool
    {
        return $exception instanceof HttpException;
    }
}

Наши обработчики готовы. Теперь давайте перепишем сервис ExceptionService. Вынесем все имеющиеся у нас классы хендлеров в поле класса и доработаем код.

namespace App\Service;

use App\Dto\ExceptionDto;
use App\Service\ErrorHandler\DefaultExceptionHandler;
use App\Service\ErrorHandler\ErrorHandlerInterface;
use App\Service\ErrorHandler\HttpExceptionHandler;

class ExceptionService
{
    /**
     * @var array|string[]
     */
    private array $errorHandlers = [
        HttpExceptionHandler::class,
    ];

    /**
     * @param \Throwable $exception
     *
     * @return ExceptionDto
     */
    public function handleException(\Throwable $exception): ExceptionDto
    {
        /** @var ErrorHandlerInterface $errorHandler */
        foreach ($this->errorHandlers as $errorHandler) {
            $errorHandler = new $errorHandler();
            if ($errorHandler->isApply($exception)) {
                return $errorHandler->handlingException($exception);
            }
        }
        $defaultErrorHandler = new DefaultExceptionHandler();

        return $defaultErrorHandler->handlingException(
            $exception
        );
    }
}

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

На данном этапе мы можем расширять наш сервис путем добавления новых обработчиков в константу. Но и такой код ревью может не пройти, поэтому идем дальше)


При помощи фреймворка мы можем повесить теги на определенные классы и в нужный нам момент Symfony соберем их со всего приложения. В нашем случае теги мы будем вешать на наши обработчики. Способов реализовать это много. Но мы сделаем всё через файл Kernel.php. Для начала нам нужно придумать тег. Пропишем его как константу в нашем интерфейсе.

namespace App\Service\ErrorHandler;

interface ErrorHandlerInterface extends HandlingExceptionInterface
{
    public const TAG = 'errorHandlerTag';

    /**
     * @param \Throwable $exception
     * @return bool
     */
    public function isApply(\Throwable $exception): bool;
}

В файле Kernel.php нам необходимо переопределить метод build и добавить ко всем классам, которые имплементируют наш интерфейс тег:

namespace App;

use App\Service\ErrorHandler\ErrorHandlerInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    /**
     * @param ContainerBuilder $container
     * @return void
     */
    protected function build(ContainerBuilder $container): void
    {
        parent::build($container);

        $container->registerForAutoconfiguration(ErrorHandlerInterface::class)
            ->addTag(ErrorHandlerInterface::TAG);
    }
}

Перейдем к нашему ExceptionService. Далее нужно получить собранные хендлеры от фреймворка. В конструкторе нашего сервиса принимаем итератор и отмечаем его атрибутом. Именно сюда Симфони заавтовайрит итератор с нашими обработчиками. Также мы заменим в цикле константу с нашими хендлерами на полученный в конструкторе итератор:

namespace App\Service;

use App\Dto\ExceptionDto;
use App\Service\ErrorHandler\DefaultExceptionHandler;
use App\Service\ErrorHandler\ErrorHandlerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

readonly class ExceptionService
{
    /**
     * @param iterable $errorHandlerList
     */
    public function __construct(
        #[AutowireIterator(ErrorHandlerInterface::TAG)] private iterable $errorHandlerList
    ) {
    }

    /**
     * @param \Throwable $exception
     *
     * @return ExceptionDto
     */
    public function handleException(\Throwable $exception): ExceptionDto
    {
        /** @var ErrorHandlerInterface $errorHandler */
        foreach ($this->errorHandlerList as $errorHandler) {
            $errorHandler = new $errorHandler();
            if ($errorHandler->isApply($exception)) {
                return $errorHandler->handlingException($exception);
            }
        }
        $defaultErrorHandler = new DefaultExceptionHandler();

        return $defaultErrorHandler->handlingException(
            $exception,
        );
    }
}

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

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

  1. Создать класс обработчика

  2. Имплементировать в него интерфейс

  3. Реализовать методы

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