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

Зачем я пишу об этом?

Первая причина - это моя личная мотивация начать-таки наконец писать статьи на Хабре. Говорят, дорога возникает под шагами идущего. И вот я встал на путь менторства и уже веду Youtube канал, где публикую обучающие видео по фреймворку Symfony. Но еще никогда не писал технические статьи.

Вторая причина - этот материал кому-то окажется полезен.

Работая над проектом, у меня возникла задача: возвращать информацию об Exception в формате JSON если клиент указывает поддерживаемый им MIME тип application/json в запросе, используя заголовок Accept.

Простыми словами - если клиенту нужна ошибка в JSON, то дать ему JSON. В других фатальных запросах возвращать стандартную ошибку в формате HTML.

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

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

Есть 2 способа обработки встроенного события exception: через слушатели и подписчики.

Первый способ - EventListener

Создадим класс слушателя:

<?php
declare(strict_types=1);

namespace App\Core\EventListener;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class ExceptionListener
{
    const MIME_JSON = 'application/json';

    public function onKernelException(ExceptionEvent $event): void
    {
        // Получаем MIME тип из заголовка Accept
        $acceptHeader = $event->getRequest()->headers->get('Accept');

        if ($acceptHeader === self::MIME_JSON) {
            $exception = $event->getThrowable();
            $response = new JsonResponse();
            $response->setContent($this->exceptionToJson($exception));

            // HttpException содержит информацию о заголовках и статусе, испольузем это
            if ($exception instanceof HttpExceptionInterface) {
                $response->setStatusCode($exception->getStatusCode());
                $response->headers->replace($exception->getHeaders());
            } else {
                $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
            }

            $event->setResponse($response);
        }
    }

    public function exceptionToJson(\Throwable $exception): string
    {
        return json_encode(
            [
                'message' => $exception->getMessage(),
                'code' => $exception->getCode(),
                'file' => $exception->getFile(),
                'line' => $exception->getLine(),
                'trace' => $exception->getTraceAsString(),
            ]
        );
    }
}

Далее нам необходимо зарегистрировать класс в файле services.yaml с указанием соответствующего тега.

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

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

php bin/console debug:event-dispatcher kernel.exception

Результат:

Как видим, слушатель зарегистрировался успешно
Как видим, слушатель зарегистрировался успешно

Второй способ - Event Subscriber

Добавим класс подписчика

<?php
declare(strict_types=1);

namespace App\Core\EventListener;

use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class BuiltInEventsSubscriber
{
    public function onKernelException(ExceptionEvent $event)
    {
        // Код обработки ошибки можно взять из класса ExceptionListener
    }

    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::EXCEPTION => 'onKernelException',
        ];
    }
}

Если в проекте включена автоматическая регистрация сервисов autoconfigure: true, то дополнительно регистрировать подписчик не нужно. В противном случае пропишем сервис в файле services.yaml

App\Core\EventListener\BuiltInEventsSubscriber:
    tags:
        - { name: kernel.event_subscriber }

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

php bin/console debug:event-dispatcher kernel.exception

Результат:

Тестирование

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

Как выглядит ответ при запросе с указанием заголовка Accept.

Запрос:

curl http://127.0.0.1:888/health-check -H "Accept: application/json"

Ответ:

{
   "message":"Division by zero",
   "code":0,
   "file":"\/var\/www\/src\/Shared\/Infrastructure\/Controller\/HealthCheckAction.php",
   "line":16,
   "trace":"#0 \/var\/www\/vendor\/symfony\/http-kernel\/HttpKernel.php(153): ....... require_once('\/var\/www\/vendor...')\n#6 {main}"
}

Перечень данных возвращаемой ошибки можно изменить под нужды в методе onKernelException.

Рабочий пример можно посмотреть в репозитории, где есть еще много чего интересного :)

Другой обучающий материал по Symfony 6 представлен на моем Youtube канале.

Спасибо за внимание!

Комментарии (5)


  1. misantron
    14.09.2022 15:37

    Кроме красивого отображения ошибки стандартный Symfony обработчик еще логирует информацию об ошибке, в данной версии логирование тоже не помешало бы


    1. gophp Автор
      14.09.2022 15:47

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

      Конечно, в классе App\Core\EventListener\ExceptionListener лучше бы обозначить метод exceptionFormatConverter, и определить его тегом kernel.event_listener, чтобы было нагляднее назначение созданного преобразователя ошибок.

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


      1. misantron
        14.09.2022 16:01

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


  1. BoShurik
    14.09.2022 17:25
    +1

    Если в проекте подключен symfony/serializer, то будет работать из коробки

    composer create-project symfony/skeleton symfony-accept-format
    cd symfony-accept-format
    composer req serializer 
    
    GET http://127.0.0.1:8880/
    Accept: application/json
    
    #{
    #    "type": "https:\/\/tools.ietf.org\/html\/rfc2616#section-10",
    #    "title": "An error occurred",
    #    "status": 500,
    #    "detail": "Internal Server Error"
    #}
    
    ###
    
    GET http://127.0.0.1:8880/
    Accept: text/html
    
    ###
    
    #<body>
    #<div class="container">
    #    <h1>Oops! An Error Occurred</h1>
    #    <h2>The server returned a "500 Internal Server Error".</h2>
    #
    #    <p>
    #        Something is broken. Please let us know what you were doing when this error occurred.
    #        We will fix it as soon as possible. Sorry for any inconvenience caused.
    #    </p>
    #</div>
    #</body>
    #</html>
    


    1. BoShurik
      14.09.2022 17:53
      +1

      Как бонус:

      GET http://127.0.0.1:8880/
      Accept: application/xml
      
      ###
      
      #<?xml version="1.0"?>
      #<response>
      #    <type>https://tools.ietf.org/html/rfc2616#section-10</type>
      #    <title>An error occurred</title>
      #    <status>500</status>
      #    <detail>Internal Server Error</detail>
      #</response>