Задача публикации: доступно изложить способ организации иерархии исключений и их обработки в приложении. Без привязки к фреймворкам и конкретной архитектуре. Описываемый способ является де-факто стандартом в сообществе: он используется во многих серьёзных библиотеках и фреймворках. В том числе Zend, Symfony. Не смотря на его логичность и универсальность, формального описания предлагаемого подхода на русском языке я не нашёл. После неоднократного устного изложения концепции коллегам, родилась мысль оформить её в виде публикации на Хабрахабр.
В языке PHP, начиная с 5-ой версии, доступен механизм исключений. В актуальной, 7-ой, версии этот механизм был улучшен и переработан с целью единнобразной обработки разных ошибок при помощи конструкции try{} catch...
В стандартной библиотеке (SPL) PHP предоставляет готовый набор базовых классов и интерфейсов для исключений. В 7-ой версии этот набор был расширен интерфейсом Throwable
. Вот диаграмма всех имеющихся в версии 7 типов (изображение — ссылка):
Для junior-разработчиков, может быть полезным предварительно уяснить все тонкости синтаксиса, логики работы исключений и обработки ошибок в целом. Могу порекоммендовать следующие статьи на русском языке:
- Евгений Пястолов: Стандартные исключения в PHP. Когда какое применить.
- Антон Шевчук (старожил Хабра a.k.a. AntonShevchuk) (перевод Exceptional Code – PART 1): “Исключительный” код – Часть 1
- @kotiara:
- @expolit: Исключения. Где я их использую
Основные аспекты иерархии исключений вашего приложения.
Общий интерфейс
Используйте общий интерфейс (маркер) для всех исключений определяемых в вашем приложении. Это же правило применимо и к отдельным компонентам, модулям, пакетам, т.е. подпространствам имён вашего кода. Например \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.
В бета-тестировании, вы вероятно захотите выводить трейсы всех предыдущих исключений для формирования баг-репортов.
К началу экплуатации вам понадобится единая точка для логирования исключительных ситуаций.
И всё время развития приложения вы будете всего лишь, по мере надобности, добавлять код с логикой обработки, без необходимости внесения изменений в основной код, где исключения генерируются.
aprusov
Но вы же сами себе противоречите, приводя такой пример:
То есть вы перехватываете исключение общего вида (\PDOException) и вводите свое исключение общего вида DataBaseException. Какой в этом смысл? Чем DataBaseException лучше \PDOException? Даже Query из него не вытащить же.
С этим, опять же, можно поспорить. Эта инвестиция может и не окупиться со временем с большой вероятностью, все зависит от проекта. Надо понимать что делаешь и для чего, а не реализовывать шаблоны типа «каждая новая исключительная ситуация должна приводить к созданию нового типа (класса) исключения», мало ли понадобиться в будущем… Для большинства конечных проектов вполне достаточно стандартных исключений.
Reposlav
Смысл в том, что на верхнем уровне нам не интересно, какой драйвер и какая библиотека используется для работы с БД (это может быть PDO или, например, mysqli). Нам важно только, что произошла исключительная ситуация при работе с БД. А чтобы вытащить query, стоит пробрасывать предыдущее исключение в новое.
Обычно либо проект умирает на ранней стадии, либо развивается до очень крупного. Или, третий вариант, проект настолько плохо написан, что его никто не хочет дорабатывать, и он так и остается маленьким и уродливым легаси.
aprusov
Смысл такой архитектуры я прекрасно понимаю, просто пример в статье на мой взгляд не удачный, не очевиден профит подхода.
Кстати, а вы не задумывались, что описанный подход как раз таки уместен в переиспользуемых библиотеках/фреймворках и несколько избыточен для большинства (не говорю про все) конечных проектов?
Вы серьезно? А как же нишевые проекты, коих большинство и которые живут себе припеваючи, покрыты автотестами и вполне себе поддерживаются и развиваются без сферических абстракций в вакууме? Вы думаете если вы напишете идеальный код и напичкаете его точками расширений, то проект из интернет-магазинчика вырастет до уровня Яндекса или Гугла ?)
Ну и в конце концов, проект написанный плохо может быть легко переписан с нуля, если уж выстрелил и даже на другом языке. Примеров масса…
samizdam
Не вижу смысла принципиально отличать код библиотечный и нет. Считаю качества он должен быть одинакового.
Возможность переиспользования, это, имхо не самоцель при написании кода или его собственная характеристика, а скорее следствие того что он качественный, т.е. удачно спроектирован, на должном уровне абстракции.
aprusov
И библиотечный код и код конечного проекта конечно же должен быть одинаково качественны, с этим никто не спорит.
Вопрос в выборе того самого «должного уровня абстракции», о котором вы пишите.
Вы же понимаете, что создатели библиотечного кода решают несколько другие задачи, чем создатели конечного проекта и уровень абcтракции не соизмерим?
Reposlav
Он уместен всегда, поскольку практически не требует усилий и работы мозга. При этом профита дает много.
Можно пример?
Я не совсем верно выразился. Под крупными проектами я подразумевал проекты с большой кодовой базой, а не большой аудиторией.
Интернет-магазины, обычно, либо на SaaS, либо CMS, и поддержка и расширение лежат, в большинстве своем, на разработчиках ядра.
aprusov
Конечно, следование любому паттерну или лучшей практике особо не требует усилий и работы мозга, в этом есть некоторая проблема, я считаю. Работы мозга «писателя» кода действительно особо не требуется, а вот «читатель» потом голову ломать будет, зачем здесь выделен тип исключения если он нигде не используется, что автор имел ввиду, можно ли это выпилить?
Не понял какой пример вы хотите. Из личного опыта? Пожалуйста — проект, в котором я сейчас работаю (maxposter.ru). Нишевой проект, который «заточен» под автосалоны, под капотом довольно сложная бизнес-логика, аналитика, агрегация биллингов разных площадок, всяческие сложные мониторинги и логирование, которые чем-то похожи, но меняются раз в пол года без согласования с нами, и для которых не возможно выделить удачную абстракцию, которая будет сколько-нибудь жизнеспособной и помогла бы быстро решать проблемы. Именно здесь мы в команде пришли к выводу, что DDD не всегда применим, часто надо быть проще и жить будет легче. Проект успешно развивается командой, покрыт тестами, деплоится каждый день «по кнопке», имеет несколько микросервисов написанных на nodejs / golang которые тоже деплоятся «по кнопке». Основное наше конкурентное преимущество — качество и работоспособность сервиса и быстрое изменение кода под сложившуюся ситуацию. Мы с этим успешно справляется и дело тут далеко не в лучших практиках, иерархии типизированных исключений и удачных абстракциях.
А можно вот тоже пример из вашего опыта, когда это выделение типа исключения на каждый чих дает профит и почему это сложнее было сделать методом рефакторинга существующего кода с общим исключением? Я как раз и пытаюсь добиться ответа на вопрос «в чем профит то?»
Reposlav
Проект развивается, кодовая база увеличивается. Я про это и говорил, что проект либо умирает, либо растет. Вы писали, что такие инвестиции могут не окупиться. но они всегда окупаются, когда проект дорастает до определенного уровня.
Пример: исключения логируются. Я знаю, что скрипт падает из-за проблем с внешним сервисом, но не знаю, из-за чего конкретно. Все исключения в скрипте базового типа. Я мог бы легко найти в логе все ошибки, связанные с внешним сервисом, если бы у них был отдельный тип, но увы. Приходится эмпирически искать по сообщению исключения.
Fesor
Если вы довели свой проект до состояния "дешевле переписать чем поддерживать", это не слишком хорошо говорит о качестве решений. Так как "дешевле переписать" это нифига не дешево на самом деле.
aprusov
Что значит "довели"? Просто изначально были выбраны более простые решения и подходы, а потом выяснилось что их не хватает да и вообще, могли упереться в то, что и язык был выбран не удачно.
К примеру, можно взять за основу laravel, и быстро накидать довольно не плохой прототип проекта, используя магию, статические фасады, автоматическую инъекцию сервисов, все что ускорит получение результата, но не является "best practice". А можно долго и нудно конфигурировать Симфони, Доктрину и получить тот же результат, но с лучшими свойствами "качества" но за большее время. Так вот иногда время, которое экономится в ущерб качеству важно, ибо пока вы пишите православный слоистый код, вводите иерархию исключений, конкуренты берут и делают в 2 раза быстрее на коленке, и вы весь свой идеальный код несете на свалку. А конкуренты переписывают все на праволавную Симфони и начинают использовать лучшие практики именно в этот момент
Fesor
это и называется "довести". Обычно симптомы того что что-то не так проявляются намного раньше точки невозврата.
Сомнительно. Вам либо подходит PHP либо нужно писать на Rust/Go. Еще есть нюансы когда нужная часть есть в готовом виде, но это как правило легко пристраивается рядом с проектом. Переписывать весь проект при этом не нужно.
Вообще-то та же Doctrine например как раз таки для выстрой разработки. Можно взять накидать пачку объектов, не особо заморачиваться с оптимизациями выборок, и когда уже будет хоть чуть чуть стабилизировано то что вы делаете с базой — тогда уже браться за оптимизации:
И уж тем более добрых 95% проектов на симфони и доктрин не сильно далеко уходят по качеству от Laravel. Что до автоинджекта сервисов — это как раз таки best practice. Быстро, удобно, не очень явно конечно но в целом норм. А статические фасады не нужны — есть же автоинджект сервисов.
В целом мне как человеку который пишет на симфони уже достаточно давно не очень понятно почему проекты на симфони писать дольше.
я не пишу "православный слоистый код", я просто выбираю ту степень изоляции и декомпозиции которая нужна. И да, это все меняеся с течением жизни проекта.
я вам расскажу историю. Один из моих последних проектов шел с очень жесткими таймлайнами. Проект пишется на симфони и доктрине. Делать "на коленке прототип" который потом выкидывать не вариант, поскольку "сегодня прототип, завтра продакшен". Проект — небольшая ecom система с блокчейнами и прочей хайповой фигней. MVP был реализован за 4 недели. С тех пор прошел почти год, а проект все еще активно развивается на той же кодовой базе что была в самом начале. Да, там есть проблемы, которые по историческим причинам остались, но в целом это все чистят.
Потому я плохо понимаю "почему на Laravel можно сделать быстрее". У меня есть возможность сравнить по цифрам наши проекты на symfony и laravel — разница в скорости разработки в пределах погрешности.
Nahrimet
Слоем приложения. Если вы работаете с репозиторием, то не можете утверждать, что работа ведется при помощи PDO, но точно можете сказать, что работаете с хранилищем данных.
aprusov
DataBaseException судя по названию больше к инфраструктурному слою относится же, а не к слою приложения, разве нет?
Nahrimet
Да, так и есть. И это уже другой вопрос.
Fesor
Да, к инфраструктурному. Но суть в том что драйверы могут быть использованы разные. Вместо примера с базами данных можно рассмотреть какой-либо другой, например какая-либо обертка которая прячет с какой очередью задач мы работаем. Или какой драйвер для обработки картинок мы юзаем (gd, imagemagick, что-то еще через third-party api). Нам возможно нужно отловить и обработать определенные исключения вроде "картинка слишком большая" и нам не хочется ловить исключения драйверов.
aprusov
Я понимаю и осознаю пользу типизированных исключений, я против бездумного следования шаблону «а давайте на каждый чих сделаем новый тип исключений». Я за планомерный рефакторинг: изначально кидаем исключение общего типа, к примеру из SPL, если нужно как-то отдельно обрабатывать, вводим новый тип или маркерный интерфейс для множества ситуаций. Не понимаю, в чем проблема сделать это именно тогда когда понадобилось?
Nahrimet
Исключения, связанные с компонентом, на мой взгляд, должны находиться в нем же. Не понимаю, почему Вы так против этого?
aprusov
Разве я говорил что я против этого, я как раз за, если это действительно зачем-то нужно и этот «компонент» выделили из приложения для переиспользования.
Я высказываю мнение против слепого следования практике «Для каждой ситуации — свой тип» исключения.
Nahrimet
Вы же понимаете, что такие утверждения используются для «снижения порога входа»?
Эти правила лишь обобщения, ведущие примерно к одному результату — улучшению качества кода.
Если проводить аналогию, Вы ведь не будете рассказывать восьмилетнему ребенку, как происходит пиролиз на примере ожога? Почти наверняка Вы объясните, что огонь опасен и, если до него дотронуться, возникнет боль. И это будет ценный совет, хоть он и не раскрывает полной картины.
aprusov
Без понимания «зачем» никакого улучшения качества кода не будет, имхо. Будет только хуже.
Главное чтобы не получилось так, что когда ребенку задашь вопрос «а почему ты именно так сделал, можно же было по-другому?», он не ответил «так правильно.», без аргументов
Fesor
Pain Driven Development — в целом это правильно.
Да, это важно. Ибо люди наслушаются про SRP, поймут неправильно и начинают разводить процедурное болото наплевав на инкапсуляцию. И при этом будут уверены что делают все правильно. Я сталкивался с подобным — это весело)
Fesor
В целом проблем с этим нет. Все зависит от того что вы пишите и какие требования к тому что вы пишите. Если вы пишите библиотеку — то требования к таким вещам будут куда жеще потому что "когда понадобиться" — будет уже поздно либо придется ломать обратную совместимость.
Просто в проектах все уже будет зависить от размеров команды и т.д. Если вы делаете что-то критически важное в контексте проекта, там стоит загнаться. Но это обычно не очень большая часть всего кода проекта.
С другой стороны речь идет про
DatabaseException
, что как бы говорит нам о том что мы имеем дело с какой-то очень важной инфраструктурной шляпой которая будет использоваться в разных контекстах нашего приложения. Такие вот мелочи могут сильно усложнить клиентский код, это то чего мы хотим избежать.samizdam
Предыдущие комментаторы ответили Вам по существу.
Могу повторить суть из резюме поста: да это инвестиция, не самая краткосрочная. Если проект настолько компактный и краткосрочный, жизненный цикл его не подразумевает изменений в контроле над исключениями, вы планируется написать чистовик за одну-две итерации и больше не трогать — она может быть не оправдана.
Как и любая абстракция.
aprusov
Я понял ваше мнение, не буду переубеждать. Просто высказываю альтернативный подход, который тоже работает. Мой опыт привел меня от слоистой архитектуры с преждевременным вводом абстракций к подходу: "напиши просто, насколько это возможно, покрой тестами, чтобы можно было расширить и порефакторить". А после того как я пописал на golang довольно не простые сервисы минималистично и просто, я по другому посмотрел на современные практики в разработке и считаю что некоторые из них монструозны и не всегда уместны.
Fesor
Вопрос не в том "работает" или "не работает". Вопрос в списке плюсов и минусов каждого подхода. Что мы приобретаем с вашим альтернативным решением и чего лишаемся.
Вот только у Go есть своя специфика которая позволяет чуть-чуть по другому вещи писать. И с этим все хорошо. Попробуйте еще на Rust пописать — тоже интересный опыт будет.