Задача публикации: доступно изложить способ организации иерархии исключений и их обработки в приложении. Без привязки к фреймворкам и конкретной архитектуре. Описываемый способ является де-факто стандартом в сообществе: он используется во многих серьёзных библиотеках и фреймворках. В том числе Zend, Symfony. Не смотря на его логичность и универсальность, формального описания предлагаемого подхода на русском языке я не нашёл. После неоднократного устного изложения концепции коллегам, родилась мысль оформить её в виде публикации на Хабрахабр.


В языке PHP, начиная с 5-ой версии, доступен механизм исключений. В актуальной, 7-ой, версии этот механизм был улучшен и переработан с целью единнобразной обработки разных ошибок при помощи конструкции try{} catch...


В стандартной библиотеке (SPL) PHP предоставляет готовый набор базовых классов и интерфейсов для исключений. В 7-ой версии этот набор был расширен интерфейсом Throwable. Вот диаграмма всех имеющихся в версии 7 типов (изображение — ссылка):


Диаграмма типов исключения в PHP7


Для junior-разработчиков, может быть полезным предварительно уяснить все тонкости синтаксиса, логики работы исключений и обработки ошибок в целом. Могу порекоммендовать следующие статьи на русском языке:



Основные аспекты иерархии исключений вашего приложения.


Общий интерфейс


Используйте общий интерфейс (маркер) для всех исключений определяемых в вашем приложении. Это же правило применимо и к отдельным компонентам, модулям, пакетам, т.е. подпространствам имён вашего кода. Например \YourVendor\YourApp\Exception\ExceptionInterface, либо \YourVendor\YourApp\SomeComponent\Exception\ExceptionInterface, в соответствии с PSR-4.


Пример можно посмотреть в любом компоненте Symfony, Zend, etc.


Для каждой ситуации — свой тип


Каждая новая исключительная ситуация должна приводить к созданию нового типа (класса) исключения. Имя класса должно семантично описывать эту ситуацию. Таким образом код с вызовом конструктора исключения, и его броском, будет: читаем, самодокументирован, прозрачен.


Симметрично и код с отловом исключения, будет явным. Код внутри catch не должен пытаться определить ситуацию по сообщению или другим косвенным признакам.


Расширяйте базовые типы


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


Пример можно посмотреть в любом компоненте Symfony, либо Zend.


Проброс и преобразование в соответствии с уровнем абстракции


Поскольку исключения всплывают по всему стеку вызовов, в приложении может быть несколько мест где они отлавливаются, пропускаются, или преобразуются. В качестве простого примера: стандартное PDOException логично поймать в слое DAL, либо в ORM, и пробросить дальше вместо него собственный DataBaseException, который в свою очередь уже ловить в слое выше, например, контроллере, где преобразовать в HttpException. Последний может быть перехвачен в коде диспетчера, на самом верхнем уровне.


Таким образом, контроллер не знает о существовании PDO — он работает с абстрактным хранилищем.


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


Используете возможности стандартного конструктора


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


namespace Samizdam\HabrahabrExceptionsTutorial\DAL;

class SomeRepository
{
    public function save(Model $model)
    {
        // .....
        try {
            $this->connection->query($data);
        } catch (\PDOException $e) {
            throw new DataBaseException(\i18n('Error on sql query execution: ' . $e->getMessage(), $e->getCode()), $e);
        }
    }
}

// .....

namespace  Samizdam\HabrahabrExceptionsTutorial\SomeModule\Controller;

use Samizdam\HabrahabrExceptionsTutorial\Http\Exception;

class SomeController
{
    public function saveAction()
    {
        // .....
        try {
            $this->repository->save($model);
        } catch (DataBaseException $e) {
            throw new HttpException(\i18n('Database error. '), HttpException::INTERNAL_SERVER_ERROR, $e);
        }
    }
}

// .....

namespace  Samizdam\HabrahabrExceptionsTutorial\Http;

class Dispatcher
{
    public function processRequest()
    {
        // .....
        try {
            $controller->{$action}();
        } catch (HttpException $e) {
            // упрощенно для примера
            http_response_code($e->getCode());
            echo $e->getMessage();
        }
    }
}

Резюме


Зачем построение целой иерархии, с участием интерфейсов, типов, подтипов и весь этот полиморфизм ради обработки ошибок? Каков смысл этой абстракции и чем оправдана её цена?


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


Когда ваши исключения обладают одновременно интерфейсом-маркером, супертипом SPL (как RuntimeException), конкретным типом соответствующим ситуации, вы можете полностью контролировать их обработку и гибко менять её стратегию в будущем. Закладывая на раннем этапе разработки эти абстракции, в будущем, по мере появления и ужесточения требований к обработке ошибок, вы будете иметь в распоряжении инструмент, который поможет эти требования реализовать.


На этапе прототипа достаточно показать надпись "опаньки", для этого достаточно поймать любой Throwable в index.php.


В альфа версии будет не лишним отличать ситуации 401, 404 и 500.


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


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


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

Поделиться с друзьями
-->

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


  1. aprusov
    22.05.2017 14:33
    +2

    Каждая новая исключительная ситуация должна приводить к созданию нового типа (класса) исключения. Имя класса должно семантично описывать эту ситуацию.


    Но вы же сами себе противоречите, приводя такой пример:

            try {
                $this->connection->query($data);
            } catch (\PDOException $e) {
                throw new DataBaseException(\i18n('Error on sql query execution: ' . $e->getMessage(), $e->getCode()), $e);
            }
    


    То есть вы перехватываете исключение общего вида (\PDOException) и вводите свое исключение общего вида DataBaseException. Какой в этом смысл? Чем DataBaseException лучше \PDOException? Даже Query из него не вытащить же.

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

    С этим, опять же, можно поспорить. Эта инвестиция может и не окупиться со временем с большой вероятностью, все зависит от проекта. Надо понимать что делаешь и для чего, а не реализовывать шаблоны типа «каждая новая исключительная ситуация должна приводить к созданию нового типа (класса) исключения», мало ли понадобиться в будущем… Для большинства конечных проектов вполне достаточно стандартных исключений.


    1. Reposlav
      22.05.2017 16:24
      +2

      То есть вы перехватываете исключение общего вида (\PDOException) и вводите свое исключение общего вида DataBaseException. Какой в этом смысл? Чем DataBaseException лучше \PDOException? Даже Query из него не вытащить же.

      Смысл в том, что на верхнем уровне нам не интересно, какой драйвер и какая библиотека используется для работы с БД (это может быть PDO или, например, mysqli). Нам важно только, что произошла исключительная ситуация при работе с БД. А чтобы вытащить query, стоит пробрасывать предыдущее исключение в новое.

      Эта инвестиция может и не окупиться со временем с большой вероятностью, все зависит от проекта

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


      1. aprusov
        22.05.2017 16:49

        Смысл в том, что на верхнем уровне нам не интересно, какой драйвер и какая библиотека используется для работы с БД

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

        Описываемый способ является де-факто стандартом в сообществе: он используется во многих серьёзных библиотеках и фреймворках. В том числе Zend, Symfony.

        Кстати, а вы не задумывались, что описанный подход как раз таки уместен в переиспользуемых библиотеках/фреймворках и несколько избыточен для большинства (не говорю про все) конечных проектов?

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

        Вы серьезно? А как же нишевые проекты, коих большинство и которые живут себе припеваючи, покрыты автотестами и вполне себе поддерживаются и развиваются без сферических абстракций в вакууме? Вы думаете если вы напишете идеальный код и напичкаете его точками расширений, то проект из интернет-магазинчика вырастет до уровня Яндекса или Гугла ?)
        Ну и в конце концов, проект написанный плохо может быть легко переписан с нуля, если уж выстрелил и даже на другом языке. Примеров масса…


        1. samizdam
          22.05.2017 17:07

          Кстати, а вы не задумывались, что описанный подход как раз таки уместен в переиспользуемых библиотеках/фреймворках и несколько избыточен для большинства (не говорю про все) конечных проектов?

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


          1. aprusov
            23.05.2017 09:30

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

            И библиотечный код и код конечного проекта конечно же должен быть одинаково качественны, с этим никто не спорит.
            Вопрос в выборе того самого «должного уровня абстракции», о котором вы пишите.
            Вы же понимаете, что создатели библиотечного кода решают несколько другие задачи, чем создатели конечного проекта и уровень абcтракции не соизмерим?


        1. Reposlav
          22.05.2017 17:18
          +1

          Кстати, а вы не задумывались, что описанный подход как раз таки уместен в переиспользуемых библиотеках/фреймворках и несколько избыточен для большинства (не говорю про все) конечных проектов?

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

          Можно пример?
          Вы думаете если вы напишете идеальный код и напичкаете его точками расширений, то проект из интернет-магазинчика вырастет до уровня Яндекса или Гугла ?)

          Я не совсем верно выразился. Под крупными проектами я подразумевал проекты с большой кодовой базой, а не большой аудиторией.
          Интернет-магазины, обычно, либо на SaaS, либо CMS, и поддержка и расширение лежат, в большинстве своем, на разработчиках ядра.


          1. aprusov
            23.05.2017 11:56

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

            Конечно, следование любому паттерну или лучшей практике особо не требует усилий и работы мозга, в этом есть некоторая проблема, я считаю. Работы мозга «писателя» кода действительно особо не требуется, а вот «читатель» потом голову ломать будет, зачем здесь выделен тип исключения если он нигде не используется, что автор имел ввиду, можно ли это выпилить?

            Можно пример?

            Не понял какой пример вы хотите. Из личного опыта? Пожалуйста — проект, в котором я сейчас работаю (maxposter.ru). Нишевой проект, который «заточен» под автосалоны, под капотом довольно сложная бизнес-логика, аналитика, агрегация биллингов разных площадок, всяческие сложные мониторинги и логирование, которые чем-то похожи, но меняются раз в пол года без согласования с нами, и для которых не возможно выделить удачную абстракцию, которая будет сколько-нибудь жизнеспособной и помогла бы быстро решать проблемы. Именно здесь мы в команде пришли к выводу, что DDD не всегда применим, часто надо быть проще и жить будет легче. Проект успешно развивается командой, покрыт тестами, деплоится каждый день «по кнопке», имеет несколько микросервисов написанных на nodejs / golang которые тоже деплоятся «по кнопке». Основное наше конкурентное преимущество — качество и работоспособность сервиса и быстрое изменение кода под сложившуюся ситуацию. Мы с этим успешно справляется и дело тут далеко не в лучших практиках, иерархии типизированных исключений и удачных абстракциях.

            При этом профита дает много.

            А можно вот тоже пример из вашего опыта, когда это выделение типа исключения на каждый чих дает профит и почему это сложнее было сделать методом рефакторинга существующего кода с общим исключением? Я как раз и пытаюсь добиться ответа на вопрос «в чем профит то?»


            1. Reposlav
              23.05.2017 14:16

              Нишевой проект, который «заточен» под автосалоны

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

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

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


        1. Fesor
          23.05.2017 08:38
          +1

          Ну и в конце концов, проект написанный плохо может быть легко переписан с нуля

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


          1. aprusov
            23.05.2017 09:16

            Что значит "довели"? Просто изначально были выбраны более простые решения и подходы, а потом выяснилось что их не хватает да и вообще, могли упереться в то, что и язык был выбран не удачно.
            К примеру, можно взять за основу laravel, и быстро накидать довольно не плохой прототип проекта, используя магию, статические фасады, автоматическую инъекцию сервисов, все что ускорит получение результата, но не является "best practice". А можно долго и нудно конфигурировать Симфони, Доктрину и получить тот же результат, но с лучшими свойствами "качества" но за большее время. Так вот иногда время, которое экономится в ущерб качеству важно, ибо пока вы пишите православный слоистый код, вводите иерархию исключений, конкуренты берут и делают в 2 раза быстрее на коленке, и вы весь свой идеальный код несете на свалку. А конкуренты переписывают все на праволавную Симфони и начинают использовать лучшие практики именно в этот момент


            1. Fesor
              24.05.2017 09:54
              +2

              Просто изначально были выбраны более простые решения и подходы, а потом выяснилось что их не хватает да и вообще

              это и называется "довести". Обычно симптомы того что что-то не так проявляются намного раньше точки невозврата.


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

              Сомнительно. Вам либо подходит PHP либо нужно писать на Rust/Go. Еще есть нюансы когда нужная часть есть в готовом виде, но это как правило легко пристраивается рядом с проектом. Переписывать весь проект при этом не нужно.


              А можно долго и нудно конфигурировать Симфони, Доктрину и получить тот же результат

              Вообще-то та же Doctrine например как раз таки для выстрой разработки. Можно взять накидать пачку объектов, не особо заморачиваться с оптимизациями выборок, и когда уже будет хоть чуть чуть стабилизировано то что вы делаете с базой — тогда уже браться за оптимизации:


              • сначала оптимизируем выборки, индексы, джойны вместо lazy load
              • multistep hydration что бы ускорить дела
              • для тех у кого доктрина становится узким местом — кастомные гидраторы
              • а потом пилим свой дата мэппер под себя (до этого этапа дорастает очень маленький процент проектов).

              И уж тем более добрых 95% проектов на симфони и доктрин не сильно далеко уходят по качеству от Laravel. Что до автоинджекта сервисов — это как раз таки best practice. Быстро, удобно, не очень явно конечно но в целом норм. А статические фасады не нужны — есть же автоинджект сервисов.


              В целом мне как человеку который пишет на симфони уже достаточно давно не очень понятно почему проекты на симфони писать дольше.


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

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


              конкуренты берут и делают в 2 раза быстрее на коленке, и вы весь свой идеальный код несете на свалку.

              я вам расскажу историю. Один из моих последних проектов шел с очень жесткими таймлайнами. Проект пишется на симфони и доктрине. Делать "на коленке прототип" который потом выкидывать не вариант, поскольку "сегодня прототип, завтра продакшен". Проект — небольшая ecom система с блокчейнами и прочей хайповой фигней. MVP был реализован за 4 недели. С тех пор прошел почти год, а проект все еще активно развивается на той же кодовой базе что была в самом начале. Да, там есть проблемы, которые по историческим причинам остались, но в целом это все чистят.


              Потому я плохо понимаю "почему на Laravel можно сделать быстрее". У меня есть возможность сравнить по цифрам наши проекты на symfony и laravel — разница в скорости разработки в пределах погрешности.


    1. Nahrimet
      22.05.2017 16:49
      +1

      Чем DataBaseException лучше \PDOException?


      Слоем приложения. Если вы работаете с репозиторием, то не можете утверждать, что работа ведется при помощи PDO, но точно можете сказать, что работаете с хранилищем данных.


      1. aprusov
        22.05.2017 16:57

        DataBaseException судя по названию больше к инфраструктурному слою относится же, а не к слою приложения, разве нет?


        1. Nahrimet
          22.05.2017 18:11

          Да, так и есть. И это уже другой вопрос.


        1. Fesor
          23.05.2017 08:41
          +1

          Да, к инфраструктурному. Но суть в том что драйверы могут быть использованы разные. Вместо примера с базами данных можно рассмотреть какой-либо другой, например какая-либо обертка которая прячет с какой очередью задач мы работаем. Или какой драйвер для обработки картинок мы юзаем (gd, imagemagick, что-то еще через third-party api). Нам возможно нужно отловить и обработать определенные исключения вроде "картинка слишком большая" и нам не хочется ловить исключения драйверов.


          1. aprusov
            23.05.2017 08:51

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


            Я понимаю и осознаю пользу типизированных исключений, я против бездумного следования шаблону «а давайте на каждый чих сделаем новый тип исключений». Я за планомерный рефакторинг: изначально кидаем исключение общего типа, к примеру из SPL, если нужно как-то отдельно обрабатывать, вводим новый тип или маркерный интерфейс для множества ситуаций. Не понимаю, в чем проблема сделать это именно тогда когда понадобилось?


            1. Nahrimet
              23.05.2017 11:06

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


              1. aprusov
                23.05.2017 11:24

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

                Разве я говорил что я против этого, я как раз за, если это действительно зачем-то нужно и этот «компонент» выделили из приложения для переиспользования.
                Я высказываю мнение против слепого следования практике «Для каждой ситуации — свой тип» исключения.


                1. Nahrimet
                  23.05.2017 12:05

                  Вы же понимаете, что такие утверждения используются для «снижения порога входа»?
                  Эти правила лишь обобщения, ведущие примерно к одному результату — улучшению качества кода.
                  Если проводить аналогию, Вы ведь не будете рассказывать восьмилетнему ребенку, как происходит пиролиз на примере ожога? Почти наверняка Вы объясните, что огонь опасен и, если до него дотронуться, возникнет боль. И это будет ценный совет, хоть он и не раскрывает полной картины.


                  1. aprusov
                    23.05.2017 12:18

                    Без понимания «зачем» никакого улучшения качества кода не будет, имхо. Будет только хуже.
                    Главное чтобы не получилось так, что когда ребенку задашь вопрос «а почему ты именно так сделал, можно же было по-другому?», он не ответил «так правильно.», без аргументов


                    1. Fesor
                      24.05.2017 10:05

                      Pain Driven Development — в целом это правильно.


                      без аргументов

                      Да, это важно. Ибо люди наслушаются про SRP, поймут неправильно и начинают разводить процедурное болото наплевав на инкапсуляцию. И при этом будут уверены что делают все правильно. Я сталкивался с подобным — это весело)


            1. Fesor
              24.05.2017 10:04

              Не понимаю, в чем проблема сделать это именно тогда когда понадобилось?

              В целом проблем с этим нет. Все зависит от того что вы пишите и какие требования к тому что вы пишите. Если вы пишите библиотеку — то требования к таким вещам будут куда жеще потому что "когда понадобиться" — будет уже поздно либо придется ломать обратную совместимость.


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


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


    1. samizdam
      22.05.2017 17:01

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


      1. aprusov
        23.05.2017 09:01

        Я понял ваше мнение, не буду переубеждать. Просто высказываю альтернативный подход, который тоже работает. Мой опыт привел меня от слоистой архитектуры с преждевременным вводом абстракций к подходу: "напиши просто, насколько это возможно, покрой тестами, чтобы можно было расширить и порефакторить". А после того как я пописал на golang довольно не простые сервисы минималистично и просто, я по другому посмотрел на современные практики в разработке и считаю что некоторые из них монструозны и не всегда уместны.


        1. Fesor
          24.05.2017 10:07

          Просто высказываю альтернативный подход, который тоже работает.

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


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

          Вот только у Go есть своя специфика которая позволяет чуть-чуть по другому вещи писать. И с этим все хорошо. Попробуйте еще на Rust пописать — тоже интересный опыт будет.