Хочу разобраться как делать правильно.
Сейчас у меня в коде обработчик ошибок (ErrorHandler) обрабатывает как внутренние ошибки скрипта, так и исключения, которые я сам создаю. Выглядит этот так:
class ErrorHandler
{
public static function register()
{
ini_set('display_errors', 'on');
error_reporting(E_ALL);
set_error_handler(get_class() . "::showError");
register_shutdown_function(get_class() . "::catch_fatal_error");
ob_start();
}
/**
* @param int $errno
* @param string $errstr
* @param string $file
* @param int $line
* @param string $header
*/
public static function showError($errno, $errstr, $file, $line, $header = "HTTP/1.0 404 Not Found")
{/*здесь отправляю ответ об ошибке*/}
public static function catch_fatal_error()
{
/*здесь отправляю ответ о критической ошибке
при помощи все того же метода self::showError*/
}
}
Весь код приложения заключен в блок try{} и исключение обрабатывается при помощи все того же метода:
try{
//Здесь весь код моего приложения
//в нем встречаются такие выбросы ошибок:
throw new \Exception("Сообщение об ошибке.");
}
catch(\Exception $e){
//обрабатывается исключение при помощи все того же метода класса ErrorHandler
ErrorHandler::showError($e->getCode(), $e->getMessage(), $e->getFile(), $e->getLine());
}
Вопросы:
Можно ли так делать?
Какой наиболее приемлемый вариант работы с ошибками и исключениями, если мой неверный?
Вопрос возник потому, что мой знакомый, которого я считаю более продвинутым чем я, говорит, что так как у меня делать нельзя. Но я не могу понять почему. Что не так?
Некоторые важные моменты, которые я понял:
Когда возникает фатальная ошибка, то она автоматически попадает в
буфер вывода. Это означает, что мы можем сразу его удалять и НЕ создавать предварительно вручную функцией ob_start();, если, конечно, хотим обрабатывать фатальные ошибки тоже. Что не рекомендуется делать.
Директива ini_set('display_errors', 'on'); в моем коде лишняя, т.к. я сам решаю, что делать с ошибками и мне не нужно сообщать php, что ему делать с ними. Я уже сам решу. А остальным пусть занимаются базовые настройки сервера.
Отправлять ответ пользователю внутри обработчика ошибок допустимо. Т.к. другие механизмы ответа, предусмотренные приложением, могут быть недоступны как раз из-за этих ошибок. Встроенный обработчик именно это делает - он прерывает в месте ошибки ход программы и отправляет ответ пользователю.
Ответ
В целом ваша механика отлова ошибок верная, потому мне не очень понятна критика вашего товарища. Было бы здорово взглянуть на его решение.
Теперь по существу. Собственно, класс обработчик ошибок:
class ErrorHandler
{
protected $format = '{{message}} {{class}}::{{method}} {{file}} on line {{line}}';
/**
* @var HandlerInterface
*/
protected $displayHandler;
/**
* @var integer the size of the reserved memory. A portion of memory is pre-allocated so that
* when an out-of-memory issue occurs, the error handler is able to handle the error with
* the help of this reserved memory. If you set this value to be 0, no memory will be reserved.
* Defaults to 256KB.
*/
protected $memoryReserveSize = 262144;
/**
* @var string Used to reserve memory for fatal error handler.
*/
private $_memoryReserve;
/**
* Register this error handler.
*/
public function register()
{
// Catch errors
set_error_handler([$this, 'handleError']);
if ($this->memoryReserveSize > 0) {
$this->_memoryReserve = str_repeat('x', $this->memoryReserveSize);
}
// Start buffer
ob_start();
// Catch fatal errors
register_shutdown_function([$this, 'handleShutdown']);
}
/**
* Unregisters this error handler by restoring the PHP error handlers.
*/
public function unregister()
{
restore_error_handler();
}
/**
* Error handler.
*
* @param int $code
* @param string $msg
* @param string $file
* @param int $line
* @return bool
* @throws \ErrorException
*/
public function handleError($code, $msg, $file, $line)
{
if (~error_reporting() & $code) {
return false;
}
switch ($code) {
case E_USER_WARNING:
case E_WARNING:
$exception = new \ErrorException("[E_WARNING] {$msg}", Log::WARNING, $code, $file, $line);
break;
case E_USER_NOTICE:
case E_NOTICE:
case E_STRICT:
$exception = new \ErrorException("[E_NOTICE] {$msg}", Log::NOTICE, $code, $file, $line);
break;
case E_RECOVERABLE_ERROR:
$exception = new \ErrorException("[E_CATCHABLE] {$msg}", Log::ERROR, $code, $file, $line);
break;
default:
$exception = new \ErrorException("[E_UNKNOWN] {$msg}", Log::CRITICAL, $code, $file, $line);
}
throw $exception;
}
/**
* Fatal handler.
*
* @return void
*/
public function handleShutdown()
{
unset($this->_memoryReserve);
$error = error_get_last();
if (
isset($error['type']) &&
($error['type'] == E_ERROR ||
$error['type'] == E_PARSE ||
$error['type'] == E_COMPILE_ERROR ||
$error['type'] == E_CORE_ERROR)
) {
$type = "";
switch ($error['type']) {
case E_ERROR:
$type = '[E_ERROR]';
break;
case E_PARSE:
$type = '[E_PARSE]';
break;
case E_COMPILE_ERROR:
$type = '[E_COMPILE_ERROR]';
break;
case E_CORE_ERROR:
$type = '[E_CORE_ERROR]';
break;
}
$exception = new \ErrorException("$type {$error['message']}", Log::CRITICAL, $error['type'], $error['file'], $error['line']);
if (APP_LOG) {
Log::log(Log::CRITICAL, $this->convertExceptionToString($exception));
}
$this->display($exception);
} else {
if (ob_get_length() !== false) {
// Display buffer, complete work buffer
ob_end_flush();
}
}
}
/**
* Sets a display handler.
* @param HandlerInterface $handler
*/
public function setDisplayHandler(HandlerInterface $handler)
{
$this->displayHandler = $handler;
}
/**
* Sets a format message log.
* @param string $format
*/
public function setFormat($format)
{
$this->format = $format;
}
/**
* Sets a size memory.
* @param int $size
*/
public function setMemoryReserve($size)
{
$this->memoryReserveSize = $size;
}
/**
* @param \Exception $exception
*/
public function display(\Exception $exception)
{
// display Whoops
if (APP_DEBUG === true) {
if (!isset($this->displayHandler)) {
$this->displayHandler = new PrettyPageHandler();
}
$run = new Run();
$run->pushHandler($this->displayHandler);
$run->handleException($exception);
return;
}
die('This site is temporarily unavailable. Please, visit the page later.');
}
/**
* Converts an exception into a simple string.
*
* @param \Exception $exception the exception being converted
* @return string the string representation of the exception.
*/
public function convertExceptionToString(\Exception $exception)
{
$trace = $exception->getTrace();
$placeholders = [
'{{class}}' => isset($trace[0]['class']) ? $trace[0]['class'] : '',
'{{method}}' => isset($trace[0]['function']) ? $trace[0]['function'] : '',
'{{message}}' => $exception->getMessage(),
'{{file}}' => $exception->getFile(),
'{{line}}' => $exception->getLine(),
];
return strtr($this->format, $placeholders);
}
}
Класс может быть и статическим (т.е. содержать статические методы и свойства). Не имеет значения.
За отображение исключений и ошибок в красивом интерфейсе отвечает вполне известная библиотека Whoops, а логирование производится с помощью Monolog (здесь представлен лёгенький wrapper над ней)
Существуют различные нюансы, к примеру, для отлова фатала связанного с переполнением памяти (Allowed memory size of...), необходимо зарезервировать некий объём. Опытным путём был рассчитан объём в 256KB. Об этом я узнал на одной из конференции от Александра samdark Макарова (евангелист и мэйнтейнер фреймворка yii). Собственно, в обработчике фреймворка подобный хак имеется. Если проанализировать код обработчика yii2, то можно заментить другие нюансы связанные, к примеру, с обработкой ошибок/исключений HHVM.
В единой точке входна в ваше приложение (index.php) указываем следующее:
defined('APP_DEBUG') or define('APP_DEBUG', true);
defined('APP_LOG') or define('APP_LOG', true);
$errorHandler = new ErrorHandler;
$errorHandler->register();
try {
// ... bootstrap вашего приложения
} catch (\Exception $e) {
if (APP_LOG) {
$msg = $errorHandler->convertExceptionToString($e);
Log::log($e->getCode() ? : Log::CRITICAL, $msg);
}
$errorHandler->display($e);
}
В самом приложение можно ловить локальные исключения и делать с ними всё что угодно. К примеру, ловить и тут же логировать:
try {
// ... некая локальная логика приложения
} catch (\Exception $e) {
Log::log(Log::ERROR, (new ErrorHandler())->convertExceptionToString($e));
}
Так или иначе, любое выбрашенное исключение будет отловлено в index.php и в зависимости от заданных констант (APP_DEBUG и APP_LOG) представленно в красивом интерфейсе и добавлено в лог. Естественно на продакшене стоит обязательно отключить режим дебага.
По поводу обработчиков Whoops:
Зная в каком формате (content type) вам необходимо отдать данные пользователю, можно выбрать подходящий обработчик.
К примеру, проверка является ли запрос ajax-запросом
if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {
$errorHandler->setDisplayHandler(new JsonResponseHandler());
}
Дебаг-информация будет представлена в виде json-а, что удобно для просмотра через консоль браузера.
В идеале необходимо иметь HTTP прослойку (классы Response/Request) и всё это детектить на уровне роутинга или фильтров/поведений контроллера - ContentNegotiator или что-то подобное.
P.S. Посмотрите видео холивара про оператор подавления ошибок @ с devconf. Очень занимательно.
UPDATE
Я лично в проектах стараюсь возвращать верные http-статусы. Вам никто не мешает выдать пользовотелю специально оформленную 404-страницу с аналогичным статусом.
if (headers_sent()) { // проверка: не отправлены ли уже заголовоки
return;
}
$version = isset($_SERVER['SERVER_PROTOCOL']) && $_SERVER['SERVER_PROTOCOL'] === 'HTTP/1.0' ? '1.0' : '1.1'
$statusCode = 404;
$statusText = 'Not Found';
header("HTTP/$version $statusCode $statusText");
Как я уже отмечал ранее, лучше всего использовать класс-прослойку Response над нативными функциями.
С роутингом этого добиться досточно просто:
$route = new Route();
$route->get('/items/{id:\d+}/', ['
amespace\ItemsController', 'actionOne']);
$route->post('/items/', ['
amespace\ItemsController', 'actionCreate']);
//... другие правила
$route->any('*', ['
amespace\ItemsController', 'actionNotFound']);
// т.е. если вышеприведенные правила не выполнились, то выполнится последнее правило.
Для ajax-запрос, достаточно в ответе указать заголовок без тела:
$route->any('*', function(Route $route) {
$route->response->status404();
return $route->response;
});
403 - если доступ к ресурсу (по какому-то URL-у) запрещен для неавторизованного пользователя или пользователя с иной ролью/правами, к примеру, не имеющего права администратора. Посмотрите RBAC. В yii подобный механизм реализован через поведения/фильтры к экшенам контроллера.
201 - ресурс успешно создан. К примеру, можно выдавать, когда зарегистрирован пользователь, создан комментарий или пост.
204 - запрос к БД завершился успешно, но данные по какой-то причине отсутствуют. К примеру, открылся новый раздел на сайте, но статьи для него ещё не написаны.
422 я использую, если не прошла валидация данных, к примеру формы. В данном случае, через $statusText (см. выше), можно дать пояснение: Validation failure
429 - классический rate limiter. Чаще всего применятеся для REST API. При достижении определённого лимита на количтво запросов выдавать данный статус.
302 - при редиректе. К примеру, если данные пришедшие от формы верны и запись в БД успешно произведена, то проивзодится редирект на стороннюю страницу, либо рефреш текущий страницы с данным статус.
500 - если вы могли заметить, то в ErrorHandler имеется die('This site is temporarily unavailable. Please, visit the page later.');. Вы можете вместо этой "пресной" записи выдать пользователю статичную страницу-заглушку предварительно указав http-статус 500.
die(file_get_contents('/path/to/stub.html'));
Для реализации своего RESTful API указание верныx http-методов, статусов, заголовков (почитайте про HATEOAS) является обязательных.
Полный список статусов
Таким образом, в режиме дебага у вас будет выводится Whoops со стек-трейсом, а на продакшене (боевом сервере) пользователю будет показана заглушка. Логировать или нет ошибки/исключения на ваше усмотрение. К примеру, во многих фреймворках по умолчанию производлится "информационное" логирование (level Log::INFO), т.е. каждое соединие с БД, успешность транзакций, авторизация пользователя и т.д., что хорошенько может так забить ваш накопитель на сервере. Необходимо повышать уровень логирования ошибок до Log::WARNING или Log::ERROR, а также использоватать штатные утилиты по ротации логов. К примеру, в Linux это logrotate
Коль я затронул тут множество тем, то позвольте прорекламировать мои open source библиотеки:
Rock Route - роутинг с гибкими правилами, группировкой (разделение правил на пространства/модули для, к примеру, ajax, backend/админка и т.д., как в laravel) и поддержкой REST. Алгоритм реализации облегчённых regexp-паттернов был взят из библиотеки FastRoute Никиты Попова (входит в core team PHP). Имеется подробная документация.
Rock Response - форк yii2 response, который полностью отделён от фреймворка. Документации, к сожалению нет, но существует официальная документации yii. Правда, тоже пока короткая.
Как мы знаем yii2 является монолитным фреймворком.
Rock Request - аналогичный форк, но с моей библиотекой фильтрации данный
Все указанные библиотеки идут без лишних зависимостей - всё только самое необходимое для их работы.