В Yii2 по-умолчанию все Exception обрабатываются, за это отвечает специальный обработчик. Если при обработке запроса возникает нехорошая ситуация (например, пришли некорректные данные от клиента), то можно выбросить исключение. Обработчик сформирует человекообразный ответ.

Интересно, что в таком случае ошибка “Warning: Uncaught exception” в лог ошибок не выводится. Может создаться впечатление, что все исключения перехватываются средствами фреймворка. Но это не так. На наш проект некоторое время назад натравили средство мониторинга (в нашем случае New Relic), которое информацию обо всех выброшенных исключениях отображает в ошибках (именно как “Warning: Uncaught exception”), считает эти исключения необработанными. С этим надо было что-то делать.

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


Почему обработанные исключения считаются не пойманными


В Yii2 обработчик ошибок задается функцией set_exception_handler(). Эта функция определяет обработчик для не пойманных исключений. При этом исключения хоть и обрабатываются, но остаются-таки не пойманными. Чтобы исключения считались пойманными, их все равно надо явно ловить, оборачивая вызовы в try-catch. В каждом экшне каждого контроллера делать этого очень не хотелось. Я считаю удобным иметь единую точку перехвата.

В Yii2, как оказалось, для этого есть готовый вариант — если выбросить исключение yii\base\ExitException (или потомка от него), то такое исключение обрабатывается средствами фреймворка. Для наглядности, вот как это сделано в Application::run():

 public function run()
    {
        try {

            $this->state = self::STATE_BEFORE_REQUEST;
            $this->trigger(self::EVENT_BEFORE_REQUEST);

            $this->state = self::STATE_HANDLING_REQUEST;
            $response = $this->handleRequest($this->getRequest());

            $this->state = self::STATE_AFTER_REQUEST;
            $this->trigger(self::EVENT_AFTER_REQUEST);

            $this->state = self::STATE_SENDING_RESPONSE;
            $response->send();

            $this->state = self::STATE_END;

            return $response->exitStatus;

        } catch (ExitException $e) {

            $this->end($e->statusCode, isset($response) ? $response : null);
            return $e->statusCode;

        }
    }


“Хорошие” и “плохие” исключения


Мне удобно выбрасывать исключения с целью завершения обработки запроса в двух случаях.
  1. Если ничего не сломалось, просто имеет место мелкое недоразумение — пришел кривой веб-запрос на клиент или не нашлось каких-то не особо критичных запрашиваемых данных.
  2. Если что-то сломалось.

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

Для первого случая я создал такой класс, унаследованный от yii\base\ExitException. Чтобы результатом работы скрипта была не пустая страница, прямо в исключении генерируется ответ.

<?php

namespace app\components;

use yii;
use yii\base\ExitException;

/**
 * Исключение, которое будет автоматически обрабатываться на уровне yii\base\Application
 */
class GoodException extends ExitException
{
    /**
     * Конструктор
     * @param string $name Название (выведем в качестве названия страницы)
     * @param string $message Подробное сообщение об ошибке
     * @param int $code Код ошибки
     * @param int $status Статус ответа
     * @param \Exception $previous Предыдущее исключение
     */
    public function __construct($name, $message = null, $code = 0, $status = 500, \Exception $previous = null)
    {
        # Генерируем ответ
        $view = yii::$app->getView();
        $response = yii::$app->getResponse();
        $response->data = $view->renderFile('@app/views/exception.php', [
            'name' => $name,
            'message' => $message,
        ]);

        # Возвратим нужный статус (по-умолчанию отдадим 500-й)
        $response->setStatusCode($status);

        parent::__construct($status, $message, $code, $previous);
    }
}

А также создано еще представление.
<?php

/* @var $this yii\web\View */
/* @var $name string */
/* @var $message string */
/* @var $exception Exception */

use yii\helpers\Html;

$this->title = $name;
?>

<?php $this->beginContent('@app/views/layouts/main.php'); ?>
<div class="site-error">

    <h1><?= Html::encode($this->title) ?></h1>

    <div class="alert alert-danger">
        <?= nl2br(Html::encode($message)) ?>
    </div>

    <p>
        The above error occurred while the Web server was processing your request.
    </p>
    <p>
        Please contact us if you think this is a server error. Thank you.
    </p>

</div>
<?php $this->endContent(); ?>


Итого


Таким образом, чтобы выбросить “культурное” исключение, пишем:
# Выбрасываем исключение, которое будет поймано
throw new GoodException('Проблемка', 'Эта проблема аккуратно обрабатывается');

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

Все остальные исключения, если вы их явно не поймаете, ловиться не будут. И будут попадать в ошибки. Т.е. для второго случая можно писать
throw new yii\base\ErrorException('Эта проблема критичная');

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


  1. zvirusz
    18.08.2015 12:15
    +3

    Т.е. для того, чтобы в NewRelic не было Unhandled Exception вы решили сделать GoodException с вьюхами вместо того, чтобы написать ErrorHandler, в котором указывать, какие ошибки кидать в ньюрелик, а какие — нет?


    1. zvirusz
      18.08.2015 12:21
      +2

      А для того, чтобы юзеру выводился красивый ответ нужно 2 вещи:
      1. Кидать ошибки-наследники UserException
      2. Почитать доку по отлову ошибок — www.yiiframework.com/doc-2.0/guide-runtime-handling-errors.html — там как раз есть раздел «Customizing Error Display»


      1. mnv
        18.08.2015 13:42

        Эта дока про вывод ошибок. Если ошибку не ловить, но аккуратно выводить через ErrorHandler, она так и остается непойманной. Соответственно, можно выбросить «GoodException», если ошибка не критичная. А если с ошибкой надо разбираться, то можно выбросить и не ловить другое исключение — HttpServerException и т.п. ErrorHandler его обработает, но оно будет непойманным.


        1. zvirusz
          18.08.2015 13:50
          +2

          Я даже специально 2 разных комментария оставил — про «непойпанность», и отдельно — про отображение. Почему ответили-то только на один? :)
          Чем вас смущает то, что ошибка «не поймана»? Не нравится, когда в ньюрелике отображается куча 400/404-х? Вот — как раз в ErrorHandler'е есть волшебный метод handleException() внутри которого очень удобно решать — посылать ошибку в ньюрелик, или не нужно.


          1. mnv
            18.08.2015 22:12

            Мы не решаем, что посылать в ньюрелик, а что — нет.
            Все, что мы делаем для ньюрелика — вызываем в index.php

            if (extension_loaded('newrelic')) {
            	newrelic_set_appname('projectname');
            }
            

            А дальше неперехваченные исключения сами попадают в статистику, что нас и смущало


            1. zvirusz
              18.08.2015 22:35

              Вы не подумайте, что я это всё из вредности писал — просто у меня у самого проект с Yii2+newrelic под боком — и никаких не отловленных исключений там нет — мы сами всё в ньюрелик репортим, что нам нужно.

              Оказывается в агенте от 8 июля внесли соответствующие изменения, добавляющие не отловленные исключения. У нас более старая версия.


  1. zvirusz
    19.08.2015 07:33

    Пришёл ответ от техподдержки. Для того, чтобы отключить отлов ошибок — нужно добавить вот такую строку в конфиг php:
    newrelic.special.disable_instrumentation = restore_exception_handler,set_exception_handler


    1. mnv
      19.08.2015 09:00

      Отлов ошибок полезен. Если случился Warning или Notice, то тоже будет не пойманной исключение. Если возникло исключение внутри фреймворка, о котором разработчик не позаботился, то такие события тоже стоит фиксировать в мониторинге. На мой взгляд, наиболее удачный подход — самостоятельно контролировать, что будет ошибкой, а что — нет. При этом все нехорошие события, о которых разработчик не позаботился, автоматически считать ошибками. Это на мой взгляд.


      1. zvirusz
        19.08.2015 09:06

        Дык я не спорю, что он полезен. Все не отловленные ошибки попадают в стандартный Yii'шный ErrorHandler, а дальше уже с ними можно делать всё, что угодно: посылать в ньюрелик, не посылать в ньюрелик, нарисовать юзеру красивую страницу, и т.д.

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


        1. mnv
          19.08.2015 09:25

          Меняю состояние в смысле возвращаю 500 ошибку? На мой взгляд это нормально. В стандартном ErrorHandler происходит примерно то-же самое.

          public function handleException($exception)
              {
                  if ($exception instanceof ExitException) {
                      return;
                  }
          
                  $this->exception = $exception;
          
                  // disable error capturing to avoid recursive errors while handling exceptions
                  $this->unregister();
          
                  // set preventive HTTP status code to 500 in case error handling somehow fails and headers are sent
                  // HTTP exceptions will override this value in renderException()
                  if (PHP_SAPI !== 'cli') {
                      http_response_code(500);
                  }
          ...
          


          1. zvirusz
            19.08.2015 09:30

            Дык разницу-то заметьте — не внутри конструктора Exception'а, а в обработчике ошибок.


            1. mnv
              19.08.2015 09:48

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