Error handling is a very important part of any application and is unfortunately very easy to skip in languagues such as PHP. We have attempted to make Error handling a integral part of Knickers, and here are some of the general precepts.
- All errors (with the exception of things like PHP Fatal errors) can be
represented with a descendant of the Error class.
- We have divided all errors into two general categories:
- "Could not do my job" errors (abbreviated "CDMJ") - These occur when something
happens that prevents you (the currently executing function) from continuing
in the normal fashion. Typical examples range from being passed the wrong type
of object, to a database connection no longer being available.
Only one of these errors can occur in a given function before it must return; they should stop function execution (tho, not necessarily script execution).
- "Invalid Data" errors - These are generated by functions whose purpose is to
validate data. Typical examples include invalid email address, missing required field,
data out-of-range for the given field, etc.
Many of these errors could occur before the function returns.
- "Could not do my job" errors (abbreviated "CDMJ") - These occur when something
happens that prevents you (the currently executing function) from continuing
in the normal fashion. Typical examples range from being passed the wrong type
of object, to a database connection no longer being available.
-
There are two main sub-classes of Error; Error_CDMJ and Error_InvalidData,
which represent the two types above. There are many children of these that represent
more specific conditions, but they all resolve to one of the two.
-
Functions that validate data, and hence throw InvalidData errors, should return FALSE
to alert the caller this happened. Any function that can throw CDMJ Errors should
return the special CDMJ_ERROR constant to alert the caller. In either case, this
should be documented in the function's API.
- "Um, what does it mean to 'throw' an error? And how do I 'throw' errors in PHP4,
which doesn't support throwing exceptions, or try/catch?"
Many languages, including PHP5, have the ability to "throw" errors; that is, besides the normal things that can be returned from a function, its execution can be interupted and a new error object generated that indicates various things about what happened. This normally looks something like:
try { // if this first call "dies", it generates an $errorObject, // and we skip right to the "catch" block, // never making it to the second operation. $widget->doFirstOperation(); $widget->doSecondOperation(); } catch($errorObject) { // do something else based on the information in $errorObject; }
PHP4 does not have this ability, and we wanted to provide some way to do something similar, to the extent that it's possible. Here's the same operation in Knickers:
if($widget->doFirstOperation() == CDMJ_ERROR) { $errorObject = $widget->getCDMJError(); // do something else based on the information in $errorObject; } else { $widget->doSecondOperation(); }
This could have been done by returning the Error object directly, but we decided against that, as it would mean that every time you perform a function call, you have to check to see if the return is an Error object, which requires another function call, and function calls are much more expensive that a simple comparison.
Another question that you might ask is "Why use a special constant rather than just FALSE?" The answer is that we wanted to reserve the booleans for functions that should return booleans; if we used FALSE, all boolean functions would either have to define something else to mean FALSE, or, we have to change what we return just for those boolean functions, which would make the system inconsistent.
So, how do we generate this object? In languages that support Exceptions, there is usually a "throw" method:
// non-PHP4 method function doFirstOperation() { if($doSomethingAndItDies()) throw new Error(...); } // In Knickers, this looks like this: function doFirstOperation() { if($doSomethingAndItDies()) return $this->setCDMJError(new Error_CDMJ(...)); }
- Static functions should still return CDMJ_ERROR or FALSE. They should take an
error variable by reference for passing back of error object(s).
("What's a static function?" A static function is a function in a class that can
be called without creating an object of that class. Since it does not operate on
a specific object instance, we can't set the internal error variables to call later,
which is why passing in an error variable by reference is important.)
// simple example: class SomeClass extends Object { function myStaticFunction($someParam, &$error) {} } $err = ''; if(SomeClass::myStaticFunction('data',$err) == CDMJ_ERROR) { // now $err is the Error object. }
- All Errors have a number of things in common, namely that they:
- Generate a stack trace when created (though this is only displayed under certain
conditions we'll outline later).
- Can have a "cause" error -- that is, another Error that occured that caused the
current Error. This results in a "causal chain" of Error objects.
- Have default Messages. The Message class represents a "souped-up" string which
allows the system to be translated into different languages based on the current
Locale (another class). Messages are gathered from the entire causal chain to
create the default message.
- Can have an "override Message". This is a Message that the calling code can specify
to use something other than the default Message generated by the causal chain.
-
Different information is shown depending on who the current user is;
more specifically, a verbosity level can be set in the Knickers config file,
with the idea being that it is set differently for development vs production
use.
-
The type of information shown also depends on the branch of Error;
because the causal chain information is generally useless (and potentially confusing)
to the End User in the case of CDMJ errors, they have a default override message
that says something to the affect of "We're sorry, something bad happened. Please
contact support" (however, a Developer, with a higher verbosity, will see both this
and the causal chain message). In the case of InvalidData errors, however,
most times the causal chain messages are very useful, and that is what is shown
unless an override is provided.
-
Errors can be set to automatically log various information, and possibly send
out notifications via email as well. (Though not clear at this point if this is
all done through some more general Notification mechanism, or from Error directly).
- Generate a stack trace when created (though this is only displayed under certain
conditions we'll outline later).
"What is this 'verbosity' thing?"
The error verbosity should be defined in your knickers config file if you want to use it. The constant is KNICKERS_ERROR_VERBOSITY_LEVEL. If it is not defined by the time the Error class is loaded, it will be set to 0, which is the value that it should be under normal production conditions.
In your development environment, however, you may want to set it (by defining it in the knickers config file). Currently, only values of 0, 1, or 2 have any meaning. When you call the Error's getExpandedMessageString(), it uses the value to help construct its message.
If an override message was provided, it is always the first part of the result string. If verbosity is 0 (normal production use), the default message from all "causes" is only shown if there is no override. If verbosity is 1, the default message from all "causes" is shown in addition to the override.
Additionally, a call stack/backtrace will be tacked on if the verbosity level is >= the class's showBacktraceLevel.
- Just like everything else in a function's API, it must specify what Errors it
"throws". There is a @throws tag for this purpose.
- While how a function works may change over time, it is *very* important that
its API does *not* change. However, it's OK for a "cause" error to change, as
that is not specified in the API, and the caller should not be relying on causes
to perform actions.
- The type of error thrown for a given condition may change depending on
the function called and its perceived purpose. For example, if a
function whose purpose it is to validate some parameter, it will throw
an InvalidData error if that parameter is found to be syntactically
incorrect; but if that same parameter is passed to a function whose job
is something other than validating that parameter, it will throw an
IllegalArgument error if syntactically incorrect.
- It's the "public" methods job - the one that accepts new data -- to validate it,
unless it is just passing it along (and NOT making it a member variable).
Once something is a member variable (private/protected), it is considered "good".
"What kind of Error should I throw?"
Our policy is that if you are the "root cause" of the Error, that is, you are creating the bottom Error object and not obtaining it from another object, that you should use the most specific Error subclass available (or possibly create one if one doesn't exist).
If you are further up the chain, you should be more general, possibly as general as Error_CDMJ.
-
There are some cases when a function should terminate script execution rather than trying to throw an Error. This is especially evident when it is obvious that continuing would lead to a PHP Fatal error, which cannot be caught and maintain any sense of a call stack. We provide a method for doing so:
function doSomethingOn($object) { if(!is_a($object,'Type_I_Expected')) System::dieGracefully(new Error_IllegalArgument( new MessageDetails(array('EXPECTED' =>'Type_I_Expected', 'RECEIVED' => $object))); $this->myObject = $object; // now considered "good" }
- While it seems tedious, defensive coding is generally the right thing
to do; that is, validating all parameters passed into a public function,
unless that parameter is not going to be used in the current scope and
just passed along to another function.
"But PHP4 doesn't allow you to 'catch' the fact that a Constructor 'failed'..."
As a general rule, you probably don't want to do things in a Constructor that could cause it to fail.
However, this is not always practical; to enforce such a thing would basically mean that constructors would not be used.
There are a few ways to deal with this issue:
- Have the constructor perform a System::dieGracefully()
- If this is not acceptable, you could have a static validation function
that you call prior to calling the constructor to "pre validate" that
the data is good ... but this method seems like it is generally very tedious.
- A better option is to make the constructor "private", and have a
static getNew() function, which also takes an $error variable by reference,
which then allows you to return the new instance and throw an error.
- Alternatively, you could not take any params in the constructor,
and instead have an init() function to pass params in.
- Have the constructor perform a System::dieGracefully()
Examples
class A { /** *Go get some data * *@return mixed data string or CDMJ_ERROR *@throws Error_CDMJ */ function getInfo() { if(($result = $this->dba->execute($query)) === CDMJ_ERROR) return $this->setCDMJError(new Error_CDMJ($this->dba->getCDMJError())); $record = $result->fetchRow(); return $record['SOME_FIELD']; } /** * Typical validation routine * *@param array data fields *@return boolean or CDMJ_ERROR *@throws Error_InvalidData *@throws Error_CDMJ */ function validate($data) { if($data[0] != 'what i expect') $this->addInvalidDataError(new Error_InvalidData()); // assuming that checkSpecificField is also adding an error to our iterator if(($check = $this->checkSpecificField($data[1])) === CDMJ_ERROR) { // BAD: Do *not* just pass along the error directly //return $this->setCDMJError($this->getCDMJError()); // GOOD: Create an Error of your own, to make the message more traceable return $this->setCDMJError(new Error_CDMJ($this->getCDMJError())); } if($data[2] != 'what i expect') $this->addInvalidDataError(new Error_InvalidData()); return ($this->getInvalidDataErrorCount() == 0); } } if(($info = $a->validate($data)) === CDMJ_ERROR) // function couldn't do its job { $errorObject = $a->getCDMJError(); if(is_a('Error_DBA',$errorObject)) { // do something with error } } elseif($info === FALSE) // validation failed (not valid) { $errors = $a->getInvalidDataErrors(); while($errors->hasNext()) { $error = $errors->next(); // do something with the data } } else { // Yeah! All is well }
///// Typical single page $locale = new Locale(); $location = new Location(); /* * Different functions can throw different errors for the same * "condition" depending on what the purpose of that function is. * * Both of the following functions take a $locID paramter. * However, if it is sytactically incorrect, they will throw different Errors * The first will throw an InvalidData, the second an IllegalArgument. */ // returns FALSE if not valid (Error_InvalidData), TRUE if valid, or CDMJ_ERROR $location->isValidLocID($locID); // vs // returns CDMJ_ERROR or void $location->setID($locID); if(($check = $location->checkAndSetIfValid($locID)) === CDMJ_ERROR) { // Something bad happened; we could not do our job. $err = $location->getCDMJError(); $og->setTag('ERROR_MESSAGES', $err->getMessages()); } elseif(!$check) { // loc id invalid... $err = $location->getInvalidDataErrors(); while($err->hasNext()) { $err = $err->next(); $msgs = $err->getMessages(); while($msgs->hasNext()) { $msg = $msgs->next(); $errorMsgs[] = $msg; } } $it = new Iterator_Array($msg); $og->setTag('ERROR_MESSAGES',$it); } else { //data is valid }
//// Example of how errors are "passed up" through your code class A { /** *@throws Error_CDMJ */ function run() { if(($check = $this->B->loadPage($pageName)) === CDMJ_ERROR) return $this->setCDMJError(new Error_CDMJ($this->B->getCDMJError())); else return $check; } } class B { /** *@throws Error_Resource <<< we throw this instead of the more specific errors we receive */ function loadPage($pageName) { if($this->C->loadData('users') == CDMJ_ERROR) { $error = $this->C->getCDMJError(); if(is_a($error,'Error_Resource_NotFound')) { // maybe try a different resource... // all is good return $goodData; } else return $this->setCDMJError( new Error_Resource( new MessageDetails(array('PATH' => 'users')), $error); } // everything was fine return $goodData; } } class C { /** * Should this return Error_CDMJ or something more specific?? *@throws Error_Resource_NotFound *@throws Error_Resource_NotAvailable */ function loadData($type) { // initially when this class is built, it gets it data from flat files $path = '/path/to/'.$type; if(!file_exists($path)) return $this->setCDMJError( new Error_Resource_NotFound( new MessageDetails(array('PATH' => $path))); if(file_is_locked($path)) return $this->setCDMJError( new Error_Resource_NotAvailable (new MessageDetails(array('PATH' => $path))); // .... but at some point in time later, // it is converted to use a database.... // can still throw the same errors, // because they are generic enough. } }
///// for validation, we don't recommend that error messages themselves be used ///// to determine how to programatically respond to an error. If the ///// difference in error types is important, you should be able to call a ///// function to determine what specifically went wrong. class Email { function isValid($email) { if(doesnt_contain_@) $this->addInvalidDataError(new Error_InvalidData( new Message(MSG_EMAIL_BAD_SYNTAX,array('EMAIL' => $email)))); if(domain_is_bad) $this->addInvalidDataError(new Error_InvalidData( new Message(MSG_EMAIL_BAD_DOMAIN,array('EMAIL' => $email)))); return ($this->getInvalidDataErrorCount() == 0); } }