精子最近在 第一届烧火节 中体验了钻木取火,并制作了第一个 Vlog。
查看源代码

为PHP构建可读的错误报告

当年使用ASP.Net时,每当程序出错,ASP.Net均会显示一个“漂亮”的错误页,列出出错所在行附近的代码,调用栈和一些提示信息等等。

相比之下,PHP默认的报错信息十分简陋,仅仅指明了出错的位置。

本文将介绍通过PHP的错误处理相关函数,构造一个类似的,具有丰富的可利用的信息的错误页。

Asp.Net的错误页

提示:本文示例至少需要PHP5.4, 本文使用了PHP5.4中的数组简写语法,和PHP5.3中的匿名函数语法。

应该说很多PHP框架都提供了类似的功能,本文展现的仅仅是其中一种方式。作者我并没有太多地参考其他PHP框架,仅仅是从官网文档下方的注释中精炼出了一些实用的功能。

错误信息并非任何时候都可以展示的,很多情况下,错误信息中包含了很多敏感的数据,容易被攻击者利用。所以显然我们首先要引入一个“运行级别”的概念,该级别将控制我们的错误页展示错误详细信息的程度。

// 为避免污染全局命名空间,已对所有标识符加 `e` 前缀

/**
 *  运行模式
 *  * debug 调试模式, 会开启详细的日志记录和错误提示
 *  * default 默认模式, 会输出错误提示
 *  * production 生产模式, 不会执行任何额外操作, 最高效地执行代码
 */

const eDebug = 2;
const eDefault = 1;
const eProduction = 0;

const eRunMode = eDefault;

PHP5开始,PHP彻底进入OPP时代,但仍留下了不少历史遗留问题。比如直到目前,PHP的内部错误仍然没有以异常的方式实现,绝大部分的内置函数也没有以异常的方式来报告错误,这种内置的错误报告由error_reporting函数进行控制:

// 默认关掉所有错误提示
error_reporting(0);
// 如果是默认模式,打开致命错误和解析错误的报告
if(eRunMode >= eDefault)
    error_reporting(E_ERROR | E_PARSE);
// 如果是调试模式,打开全部错误提示,并启用修改建议提示
if(eRunMode >= eDebug)
    error_reporting(E_ALL | E_STRICT);

为了能够在出现错误的时候,去除已有的输出,以及追加 HTTP 头,我们要开启缓冲:

ob_start();

然后我们需要向PHP引擎申请通过我们自己的函数来接管错误处理的相关流程,这其中有两个关键函数:

  • set_error_handler
  • set_exception_handler

set_error_handler 会注册一个自定义函数用于接管由PHP产生的错误,set_exception_handler 会注册一个自定义函数用于接管在最外层仍未被处理的异常。

在这里,我们会用 error_handler 函数来将PHP产生的错误统一转换为异常,再通过 exception_handler 来实现错误页的展现。

set_error_handler 所注册的自定义函数会被传递5个参数,分别是错误号,错误描述信息,错误文件名和行号,当前上下文的符号表(当前时刻所有变量的值).

PHP文档中推荐我们在自定义函数中重新抛出 ErrorException 类型的异常,这是一个内置的异常类型,但它并没有提供储存符号表的功能,而错误发生时的符号表却是非常有用的,所以这里我选择了自己构造一个异常类型来代替 ErrorException, 以便能将符号表储存下来。

这个工作很简单:

class ePHPException extends Exception
{
    protected $severity;
    protected $varList;

    public function  __construct($message = "", $code = 0, $severity = 1, $filename = __FILE__, $lineno = __LINE__, Exception $previous = null, $varList = [])
    {
        $this->severity = $severity;
        $this->file = $filename;
        $this->line = $lineno;
        $this->varList = $varList;

        // 调用父类的构造函数
        parent::__construct($message, $code, $previous);
    }

    public function getSeverity()
    {
        return $this->severity;
    }

    public function getVarList()
    {
        return $this->varList;
    }
}

然后我们使用 set_error_handler 注册一个自定义函数将PHP报告的错误转换为抛出 ePHPException 类型的异常.

set_error_handler(function($no, $str, $file, $line, $varList) {
    throw new ePHPException($str, 0, $no, $file, $line, null, $varList);
});

然后我们要重新考虑运行级别的问题,在调试模式和默认模式时,我们打算显示错误信息,而在生产模式下,我们不打算显示任何信息:

if(eRunMode <= eProduction)
{
    set_exception_handler(function(Exception $exception) {
        header("Content-Type: text/plant; charset=UTF-8");

        die(header("HTTP/1.1 500 Internal Server Error"));
    });
}

现在我们开始设计真正的错误处理函数,需要显示的信息包括:异常类型名,描述信息,调用栈,符号表,附近代码等等,这些信息都可以轻松地从异常对象上获取到:

else
{
    set_exception_handler(function(Exception $exception) {
        // 暂时我们只打算以纯文本的形式展示信息
        header("Content-Type: text/plant; charset=UTF-8");

        // 头部
        print sprintf(
            "Exception `%s`: %s\n",
            get_class($exception),
            $exception->getMessage()
        );

        // 运行栈
        print "\n^ Call Stack:\n";
        // 从异常对象获取运行栈
        $trace = $exception->getTrace();
        // 如果是 ePHPException 则去除运行栈的第一项,即 error_handler
        if($exception instanceof ePHPException)
            array_shift($trace);

        // 只有在调试模式才会显示参数的值,其他模式下只显示参数类型
        if(eRunMode < eDebug)
            foreach ($trace as $key => $v)
                $trace[$key]["args"] = array_map("gettype", $trace[$key]["args"]);

        // 用于打印参数的函数
        $printArgs = function($a) use(&$printArgs)
        {
            $result = "";
            foreach($a as $k => $v)
            {
                if(is_array($v))
                    $v = "[" . $printArgs($v) . "]";
                else
                    if(is_string($v))
                        $v = "`{$v}`";
                if(!is_int($k))
                    $v = "`$k` => $v";

                $result .= ($result ? ", {$v}" : $v);
            }
            return $result;
        };

        // 打印运行栈
        foreach ($trace as $k => $v)
            print sprintf(
                "#%s %s%s %s(%s)\n",
                $k,
                isset($v["file"]) ? $v["file"] : "",
                isset($v["line"]) ? "({$v["line"]}):" : "",
                $v["function"],
                $printArgs($v["args"])
            );

        print sprintf(
            "#  {main}\n  thrown in %s on line %s\n\n",
            $exception->getFile(),
            $exception->getLine()
        );

        // 如果当前是调试模式,且异常对象是我们构造的 ePHPException 类型,打印符号表和源代码
        if(eRunMode >= eDebug && $exception instanceof ePHPException)
        {
            // 用于打印符号表的函数
            $printVarList = function($a, $tab=0) use(&$printVarList)
            {
                $tabs = str_repeat("   ", $tab);
                foreach($a as $k => $v)
                    if(is_array($v))
                        if(!$v)
                            print "{$tabs}`{$k}` => []\n";
                        else
                            print "{$tabs}`{$k}` => [\n" . $printVarList($v, $tab+1) . "{$tabs}]\n";
                    else
                        print "{$tabs}`{$k}` => `{$v}`\n";
            };

            print "^ Symbol Table:\n";
            $printVarList($exception->getVarList());

            print "\n^ Code:\n";

            // 显示出错附近行的代码
            $code = file($exception->getFile());
            $s = max($exception->getLine()-6, 0);
            $e = min($exception->getLine()+5, count($code));
            $code = array_slice($code, $s, $e - $s);

            // 为代码添加行号
            $line = $s + 1;
            foreach($code as &$v)
            {
                $l = $line++;
                if(strlen($l) < 4)
                    $l = str_repeat(" ", 4-strlen($l)) . $l;
                if($exception->getLine() == $l)
                    $v = "{$l}->{$v}";
                else
                    $v = "{$l}  {$v}";
            }

            print implode("", $code);
        }

    });
}

下面我们编写一个示例来测试一下:

function throwException()
{
    class MyException extends Exception{}
    throw new MyException("Aal izz well");
}

function call($func, $arg)
{
    $func($arg);
}

// 错误1:
call("array_keys", 1234);
// 错误2:
throwException();

下面是错误1在eDefault下的显示:

Exception `ePHPException`: array_keys() expects parameter 1 to be array, integer given

^ Call Stack:
#0 /var/www/test.php(188): array_keys(integer)
#1 /var/www/test.php(192): call(string, integer)
#  {main}
  thrown /var/www/test.php on line 188

很不错!显示了错误信息和运行栈,运行栈中的参数内容都以类型掩去了。

在 eDebug 下:

Exception `ePHPException`: array_keys() expects parameter 1 to be array, integer given

^ Call Stack:
#0 /var/www/test.php(188): array_keys(1234)
#1 /var/www/test.php(192): call(`array_keys`, 1234)
#  {main}
  thrown in /var/www/test.php on line 188

^ Symbol Table:
`func` => `array_keys`
`arg` => `1234`

^ Code:
 183      throw new MyException;
 184  }
 185  
 186  function call($func, $arg)
 187  {
 188->    $func($arg);
 189  }
 190  
 191  // 错误1:
 192  call("array_keys", 1234);
 193  // 错误2:

也如我们预期一样,在eDebug模式下,在运行栈中会显示参数内容,还会显示符号表和出错附近的代码。

至于eProduction模式,服务器没有返回任何信息,浏览器直接给出了下面的错误提示:

HTTP 错误 500(Internal Server Error):服务器尝试执行请求时遇到了意外情况。

错误2的eDefault模式(其他两个模式不再详细展示):

Exception `MyException`: Aal izz well

^ Call Stack:
#0 /var/www/test.php(194): throwException()
#  {main}
  thrown in /var/www/test.php on line 183

就这样,我们通过百行左右的代码实现了一个具有丰富的可利用信息的PHP错误页,除了我们仅仅是纯文本,已经不逊色于Asp.Net的错误页了。

这将会是非常值得的,会为你的调试工作带来很多方便。

本文全部代码:https://gist.github.com/jybox/5789249

应该说,这个方案是存在硬伤的:PHP并不允许用户使用自定义函数处理致命错误!这包括无法解析的语法,调用未定义的函数等等行为,在发生致命错误时,仍会出现PHP那“丑陋”的错误提示。好在,但凡是致命错误,大都是很容易更正的,反倒是那些乱七八糟的“警告”和“提示”,最难以调试,这也是本文希望解决的问题。

更进一步地,你可以使用PHP的反射机制,获取更多的信息用于调试:http://www.php.net/manual/zh/book.reflection.php

所谓反射,就是在运行时,与PHP的解释器进行交互,获取关于代码的元信息,进行“反向工程”的一种机制,使用反射,你可以深入地挖掘类,函数,扩展的元信息。

比如,获取一个匿名函数的源代码:http://segmentfault.com/q/1010000000160912

撰写评论

精子写了这么多年博客,收到的优秀评论少之又少,在这个属于 SNS 的时代也并不缺少向作者反馈的渠道。因此如果你希望撰写评论,请发邮件至 jysperm@gmail.com 并注明文章标题,我会挑选对读者有价值的评论附加到文章末尾。

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过邮件订阅精子的博客日志、产品和项目的最新动态,精子承诺每一封邮件都会认真撰写(历史邮件),有想和精子说的话也可以直接回复邮件。

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2021-05-27.