Gracefully Handling Catastrophic Disaster with Zend Framework
Posted by John Kleijn • Saturday, May 24. 2008 • Category: Zend FrameworkEven in the best of applications, errors will occur. Zend Framework has a great feature to deal with uncaught exceptions: the error handler front controller plugin, or Zend_Controller_Plugin_ErrorHandler.
In this post a quick example (or maybe not so quick – if you're in a hurry, come back later) of how to implement a relatively simple 'error controller' using Zend Framework.
First of all, you need to ensure that the front controller catches the exceptions and forwards them to the error controller we'll write.
Zend_Controller_Front::getInstance()
->throwExceptions(false)
On a development machine, you probably want to keep throwExceptions set to true. The ErrorHandler plugin is enabled by default.
By default, the ErrorHandler plugin will try to dispatch to ErrorController:errorAction(), in the default module. You can override this default by invoking the explicit methods setErrorHandlerModule(), setErrorHandlerController() and setErrorHandlerAction(), or using setErrorHandler(), which takes an associative array, like so:
Zend_Controller_Front::getInstance()
->getPlugin('Zend_Controller_Plugin_ErrorHandler')
->setErrorHandler(
array(
'module' => 'somemodule',
'controller' => 'somecontroller',
'action' => 'someaction'
)
);
We're just going to use the defaults for this example.
class ErrorController extends Zend_Controller_Action
{
protected $_defaultMessages = array(
'ERROR_404' => 'This action is not implemented',
'ERROR_500' => 'Application error'
);
public function errorAction()
{
$errors = $this->_getParam('error_handler');
switch ($errors->type)
{
case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_CONTROLLER:
case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_ACTION:
$this->getResponse()->setRawHeader('HTTP/1.1 404 Not Found');
$this->view->errorMessage = $this->_defaultMessages['ERROR_404'];
break;
default:
$this->view->errorMessage = $this->_defaultMessages['ERROR_500'];
break;
}
}
}
Note that the error action gets a parameter passed called 'error_handler', which also contains the exception thrown. In this simple example, we just assign some default messages to the template. We do not use setRawHeader to set a HTTP 500 status code, because certain web servers, most notably one by a specific vendor not be named, do not handle the output when a status 500 header is set. Instead it just displays the default 'Internal Server Error' page. You know what I'm talking about.
Ok, now we're going to try something a little more fancy. We're going to use a simple class extending Zend_Db_Table to fetch messages from the database. Of course, when our 'Table Data Gateway' fails, we're screwed unless we catch the exception and display a default message anyway. Maybe in future we want to fetch the messages from a different type of store, so we encapsulate the Table Data Gateway, and create a Proxy, that will also make for easy access to the 'system wide' messages using a Singleton. It's a mouth full, but it is in fact stunningly simple.
Knowing this, we first create the interface to fetch and manipulate messages from store.
interface System_Messages_Store_Interface
{
/**
* Get a message by key
*
* @param string $msgKey
*
* @return string
*/
public function getMsg($msgKey);
/**
* Check if a message is stored
*
* @param string $msgKey
*
* @return bool
*/
public function msgExists($msgKey);
/**
* Create a new message
*
* @param string $key
* @param string $text
*/
public function createMsg($key, $text);
/**
* Update message text
*
* @param string $key
* @param string $text
*/
public function updateMsg($key, $text);
}
Now we create a basic Proxy/Singleton serving as a system messages gateway. Usually, I'm a bit wary of Singletons, but since this is indeed intended as a globally available object, guess I can live with it. You can't beat the simplicity of calling something like:
System_Messages::get('booboo');
Beware of Singletonitus though.
Here we go:
class System_Messages
{
/**
* Single instance
*
* @var System_Messages
*/
private static $_instance;
/**
* Loaded messages
*
* @var array
*/
private $_messages = array();
/**
* The storage gateway
*
* @var System_Messages_Store_Interface
*/
private $_store;
/**
* Constructor
*
* @param System_Messages_Store_Interface $store
*
*/
private function __construct(System_Messages_Store_Interface $store)
{
$this->_store = $store;
}
/**
* Get the single instance
*
* @param System_Messages_Store_Interface $store
*
* @return System_Messages
*/
public static function getInstance(System_Messages_Store_Interface $store = null)
{
if(self::$_instance == null)
{
self::$_instance = new System_Messages($store);
return self::$_instance;
}
else
{
if($store !== null)
{
throw new System_Messages_Exception('Cannot handle $store, already instantiated.');
}
return self::$_instance;
}
}
/**
* Static convenience method
*
* @param string $msgKey
*
* @return string
*/
public static function get($msgKey)
{
return self::getInstance()->getMsg($msgKey);
}
/**
* Static convenience method
*
* @param string $msgKey
*
* @return bool
*/
public static function exists($msgKey)
{
return self::getInstance()->msgExists($msgKey);
}
/**
* Get a message by key
*
* @param string $msgKey
*
* @return string
*/
public function getMsg($msgKey)
{
if(isset($this->_messages[$msgKey]))
{
return $this->_messages[$msgKey];
}
return $this->getStore()->getMsg($msgKey);
}
/**
* Check if a message is stored
*
* @param string $msgKey
*
* @return bool
*/
public function msgExists($msgKey)
{
if(isset($this->_messages[$msgKey]))
{
return true;
}
return $this->getStore()->msgExists($msgKey);
}
/**
* Create a new message
*
* @param string $key
* @param string $text
*/
public function createMsg($key, $text)
{
$this->_assertValidKeyFormat($key);
$this->getStore()->createMsg($key, $text);
$this->_messages[$key] = $text;
}
/**
* Update message text
*
* @param string $key
* @param string $text
*/
public function updateMsg($key, $text)
{
$this->_assertValidKeyFormat($key);
$this->getStore()->update($text, $key);
$this->_messages[$key] = $text;
}
/**
* Get the storage engine
*
* @return System_Messages_Store_Interface
*/
public function getStore()
{
return $this->_store;
}
/**
* Assert that the format of the key is valid
*
* @param string $key
* @throws System_Messages_Exception
*/
protected function _assertValidKeyFormat($key)
{
$validator = new Zend_Validate_Regex('/^[A-Z_]*$/');
if(! $validator->isValid($key))
{
throw new System_Messages_Exception("Key '$key' is invalid.");
}
}
/**
* Assert that the message text is valid
*
* @param string $text
* @throws System_Messages_Exception
*/
protected function _assertValidText($text)
{
$validator = new Zend_Validate_StringLength(3, 255);
if(! $validator->isValid($text))
{
throw new System_Messages_Exception("Text '$text' is invalid, string length must be between 3 and 255.");
}
}
/**
* Singletons may not be cloned
*
*/
private function __clone()
{}
}
Note that I'm also using an internal stack to prevent multiple trips to the store. Whatever the store, we can safely assume that using the stack is quicker than loading the persistent copy.
This is a really simple class that just uses a simple key => message system. You may want to add multilingual support for something like this.
Ok, cool. Now we need to actually get and manipulate messages. In comes our Table Data Gateway.
class Default_MessagesStore extends Zend_Db_Table_Abstract implements System_Messages_Store_Interface
{
/**
* Name of the target table
*
* @var string
*/
protected $_name = 'messages';
/**
* Default statement (select)
*
* @var Zend_Db_Select
*/
private $_defaultStmt;
/**
* Get a message by key
*
* @param string $msgKey
*
* @return string
*/
public function getMsg($msgKey)
{
$row = $this->getRow($msgKey);
return $row !== null ? $row['text'] : null;
}
/**
* Check if a message is stored
*
* @param string $msgKey
*
* @return bool
*/
public function msgExists($msgKey)
{
$row = $this->getRow($msgKey);
return $row !== null;
}
/**
* Create a new message
*
* @param string $key
* @param string $text
*/
public function createMsg($key, $text)
{
$this->insert(array(
'msgkey' => $key , 'text' => $text , 'created' => new Zend_Db_Expr('NOW()')
));
}
/**
* Update message text
*
* @param string $key
* @param string $text
*/
public function updateMsg($key, $text)
{
$this->update(array(
'text' => $text
), $this->getAdapter()->quoteInto('msgkey = ?', $key));
}
/**
* Get a row from the table
*
* @param string $msgKey
*
* @return array
*/
public function getRow($msgKey)
{
return $this->getAdapter()->fetchRow($this->_getDefaultStatement(), array(
$msgKey
));
}
/**
* Get the default select statement
*
* @return Zend_Db_Select
*/
protected function _getDefaultStatement()
{
if($this->_defaultStmt == null)
{
$this->_defaultStmt = $this->select()->where('msgkey = ?');
}
return $this->_defaultStmt;
}
}
The name of the class already hints at the fact that this class is not in the 'System_Messages' 'namespace' (can't wait for real namespaces in PHP 5.3). Because something like a Table Data Gateway is dependent on the database schema, it is not something you want in your library. Hence this application of the 'Separated Interface' pattern.
Now we've got everything to be lazy and simply call System_Message::get() and such throughout our application. Back to the error controller.
We're going to change the errorAction to fetch messages from a new method, and we're going to place the call to System_Messages in a try block. Otherwise we might get screwed over by our good intentions
class ErrorController extends Zend_Controller_Action
{
protected $_defaultMessages = array(
'ERROR_404' => 'This action is not implemented' , 'ERROR_500' => 'Application error'
);
/**
* Error dispatching action
*
*/
public function errorAction()
{
$errors = $this->_getParam('error_handler');
switch($errors->type)
{
case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_CONTROLLER:
case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_ACTION:
$this->getResponse()->setRawHeader('HTTP/1.1 404 Not Found');
$this->view->errorMessage = $this->_getMessage('ERROR_404');
break;
default:
$this->view->errorMessage = $this->_getMessage('ERROR_500');
}
}
/**
* Get a message, either from System_Messages or from the defaults array
*
* @param string $key
* @return string
*/
protected function _getMessage($key)
{
try
{
if(System_Messages::exists($key))
{
return System_Messages::get($key);
}
}
catch (Exception $e)
{}
return $this->_defaultMessages[$key];
}
}
This pretty much covers our bases. But, we can now only use the database to store two different messages. That's a bit meager, so let's try an addition to the _getMessage() method.
Basically, an exception has three properties that could be utilized to present useful information to the end-user.
The exception message
Technically, you could use the original exception message as a key to a user-friendly message. This allows a very fine grained customization of the error output, without the hassle of managing exception codes, but also gets your database a bit dirty.
The exception code
Using exception codes for mapping works best when explicitly declaring the codes and messages in the exception class. An example follows shortly.
The exception type
The type of the exception CAN be a useful indicator of what happened. It can also lead to useless messages depending on the type of exception used. For example a 'OutOfBoundsException' can never map to a useful message for the end-user. Generally there are two main types of exceptions, three if you count combinations. Exceptions that indicate WHAT happened (InvalidArgument, IndexOutOfBounds), and those that indicate WHERE it happened (System_Messages_Exception in our example above, as well the Zend Framework exceptions). A System_Messages_Exception could map to a reasonably useful message saying something like “An error occurred trying to access system messages”. Just an example.
You can choose any combination of these properties to map to a message. I use a combination of the exception type and a message code. The exception type is only used to make the message code unique. To make this work, you have to create messages for everything that could possibly go wrong (not beforehand naturally).
Here's the exception base class I use:
/**
* Standard Backbone Exception class
*
* Provides basic message templating using vsprintf().
*
* Has it's own __toString() method, which proxies to Exception::__toString(),
* unless override is enabled using Backbone_Exception::overrideToString()
*
*/
class Backbone_Exception extends Exception
{
/**
* Default message index.
*
*/
const MSG_DEFAULT = 0x00000000;
/**
* Array of available default messages.
*
* @var array
*/
protected $messages = array(
self::MSG_DEFAULT => 'An unknown error occurred.'
);
/**
* Indicicate wheter to override __toString()
*
* @var bool
*/
private static $_overrideToString = false;
/**
* Message arguments
*
* @var array
*/
protected $_messageArgs = array();
/**
* Create a new exception.
*
* You may use a format string (using %, as used by printf())
* and add an arbitrary number of arguments to be formatted.
*
* @param string $message
*/
public function __construct($message = null)
{
if($message === null)
{
$message = $this->messages[self::MSG_DEFAULT];
$this->_setCode(self::MSG_DEFAULT);
}
elseif(is_int($message))
{
$this->_setCode($message);
if(! isset($this->messages[$message]))
{
$message = $this->messages[self::MSG_DEFAULT];
}
else
{
$message = $this->messages[$message];
}
}
if(func_num_args() > 1)
{
$this->_messageArgs = func_get_args();
array_shift($this->_messageArgs);
$message = $this->parseMessageText($message);
}
parent::__construct($message);
}
/**
* Parses the exceptions' message arguments into
* the given string using vsprintf.
*
* @param string $string
* @return string
*/
public function parseMessageText($string)
{
return vsprintf($message, $this->_messageArgs);
}
/**
* Set the exception creation line
*
* @param string|int $line
*
* @return Backbone_Exception
*/
protected function _setLine($line)
{
$this->line = $line;
return $this;
}
/**
* Set the exception creation code
*
* @param string|int $line
*
* @return Backbone_Exception
*/
protected function _setCode($code)
{
$this->code = $code;
return $this;
}
/**
* Set the exception creation file
*
* @param string|int $line
*
* @return Backbone_Exception
*/
protected function _setFile($file)
{
$this->file = $file;
return $this;
}
/**
* Create an instance of Backbone_Exception using a different
* type of Exception.
*
* @param Exception $e
*
* @return Backbone_Exception
*/
public static function factory(Exception $e)
{
if($e instanceof Backbone_Exception)
{
return $e;
}
$BackboneException = new Backbone_Exception($e->getMessage());
$BackboneException->_setCode($e->getCode())->_setLine($e->getLine())->_setFile($e->getFile());
return $BackboneException;
}
/**
* Indicicate wheter to override __toString()
*
* @var bool
*/
public static function overrideToString($bool = true)
{
self::$_overrideToString = $bool;
}
/**
* Get a string representation of the exception.
*
* @return string
*/
public function __toString()
{
if(self::$_overrideToString)
{
return get_class($this) . " [code {$this->code}]: {$this->getMessage()}\\n\\nBacktrace:\\n{$this->getTraceAsString()}";
}
return parent::__toString();
}
}
Then every typed exception extends this class, declaring it's own messages. For example:
/**
* Default file system exceptions
*
*/
class System_Messages_Exception extends Backbone_Exception
{
const MSG_DEFAULT = 0x00000000;
const MSG_INSTANTIATED = 0x00000001;
const MSG_INVALIDTEXT = 0x00000002;
const MSG_INVALIDKEY = 0x00000003;
protected $messages = array(
self::MSG_DEFAULT => 'Unknown exception accessing system messages' , self::MSG_INSTANTIATED => 'Cannot handle $store, already instantiated.' , self::MSG_INVALIDTEXT => 'Text "%s" is invalid, string length must be between %d and %d.' , self::MSG_INVALIDKEY => 'Key "%s" is invalid.'
);
}
The exceptions in our System_Messages class would change accordingly, like this:
throw new System_Messages_Exception(System_Messages_Exception::MSG_INSTANTIATED);
Because the base class has a method that allows us to have the message arguments parsed into our own string, this setup is extremely useful for making user friendly messages.
Again, back to to the error controller.
Every message we throw now has it's own unique identifier, that we can use as a message key. Some simple adjustments have us all set:
For 'non-404' exceptions, we pass the exception as an argument to _getMessage()
$this->_getMessage('ERROR_500', $errors->exception);
We mend _getMessage() to support the new message keying and message templating:
/**
* Get a message, either from System_Messages or from the defaults array
*
* @param string $key
* @param Exception $e
*
* @return string
*/
protected function _getMessage($key, Exception $e = null)
{
if($e !== null)
{
try {
$key = get_class($e) . ':' . $e->getCode();
if(System_Messages::exists($key))
{
$msg = System_Messages::get($key);
}
if($e instanceof Backbone_Exception)
{
$msg = $e->parseMessageText($msg);
}
return $msg;
}
catch (Exception $e)
{}
}
return $this->_defaultMessages[$key];
}
Note that $e->getCode() will return an integer, not the class constant name.
Of course there is a little more to exception handling than just displaying some text. Even though the exception is caught, you still want it in your error log. And while we're at it, we'll also send an email to the server administrator.
class ErrorController extends Zend_Controller_Action
{
protected $_defaultMessages = array(
'ERROR_404' => 'This action is not implemented' , 'ERROR_500' => 'Application error'
);
/**
* Error dispatching action
*
*/
public function errorAction()
{
try
{
$errors = $this->_getParam('error_handler');
$e = $errors->exception;
$logString = $e->getMessage() . PHP_EOL . $e->getTraceAsString();
$this->_dispatchToLog($logString);
switch($errors->type)
{
case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_CONTROLLER:
case Zend_Controller_Plugin_ErrorHandler::EXCEPTION_NO_ACTION:
$this->getResponse()->setRawHeader('HTTP/1.1 404 Not Found');
$this->view->errorMessage = $this->_getMessage('ERROR_404');
break;
default:
$this->view->errorMessage = $this->_getMessage('ERROR_500', $e);
$this->_notifyAdmin($logString);
break;
}
}
catch (Exception $e)
{
trigger_error('Unknown failure trying to dispatch failure message, contents: ' . $logString, E_USER_WARNING);
}
}
/**
* Log the exception in the error log
*
* @param string $logString
*/
protected function _dispatchToLog($logString)
{
try
{
$log = new Zend_Log(new Zend_Log_Writer_Stream(ini_get('error_log')));
$log->info($logString);
}
catch (Zend_Log_Exception $e)
{
trigger_error('Logging failure, contents: ' . $logString . ' failure: ' . $e->getMessage(), E_USER_WARNING);
}
}
/**
* Notify the server admin by sending an email to $_SERVER['SERVER_ADMIN']
*
* @param string $logString
*/
protected function _notifyAdmin($logString)
{
try
{
$mail = new Zend_Mail();
$mail->setBodyText($logString)
->setBodyHtml("<h1>{$e->getMessage()}</h1>$logString")
->setFrom('noreply@' . $_SERVER['HTTP_HOST'], 'Error Handler')
->addTo($_SERVER['SERVER_ADMIN'], 'Admin')
->setSubject($_SERVER['HTTP_HOST'] . ' : ' . $e->getMessage())
->send();
}
catch (Zend_Mail_Exception $e)
{
trigger_error('Failure trying to mail error to server admin: ' . $e->getMessage(), E_USER_WARNING);
}
}
/**
* Get a message, either from System_Messages or from the defaults array
*
* @param string $key
* @param Exception $e
*
* @return string
*/
protected function _getMessage($key, Exception $e = null)
{
if($e !== null)
{
try
{
$key = get_class($e) . ':' . $e->getCode();
if(System_Messages::exists($key))
{
$msg = System_Messages::get($key);
}
if($e instanceof Backbone_Exception)
{
$msg = $e->parseMessageText($msg);
}
return $msg;
}
catch (Exception $e)
{}
}
return $this->_defaultMessages[$key];
}
}
You'll notice the large number of try blocks and fall backs on trigger_error. Overkill? I don't think so. As long as you have error reporting at a minimum of E_ALL and log_errors turned on (which you really always should have), all hell can break loose; you will know what happened.
In fact, to ensure that you even know THAT something happened (without checking the error log every single day), it is a good idea to create a cronjob that checks the mtime of the error log and emails any new errors.
Catastrophic failure is always lurking around the corner, be ready for it.



ShareThis