PHP编程之高级编程技巧

在PHP开发过程中,还有很多高级的技能。一旦掌握、解锁了这些高级特性,对于理解框架的实现、复杂项目的开发都能提升到一个新的层次和高度。这些高级编程技巧在平时的开发中也许会容易被忽略,但在关键的时刻它能发挥着重大的作用,并能协助你解决各种疑难杂症。
这一节主要讲解三个高级编程技巧,分别是:对运行堆栈的回溯追踪、对错误的捕捉,以及不死的代码。

1 福尔摩斯般的回溯跟踪

喜欢侦探破案推理的同学,大概都有看过柯南、福尔摩斯、神探狄仁杰。他们这些主解都能根据一点点的蛛丝马迹,破解重大的案件,还原整个事实的真相。
在PHP世界中,如果发生灵异事件没有头绪,或者无法进一步排查定位原因时,有两个重要的工具可以进行回溯追踪。这两个工具的效果是类似,但表现形式会有所不同。分别是:
debug_backtrace(),产生一条回溯跟踪
debug_print_backtrace(),打印一条回溯
下面通过一个示例来快速认识这两个强大工具的作用。 创建两个临时的PHP文件,一个是/tmp/test.php入口文件,一个是/tmp/Debug.php类文件。在里面分别放置以下代码:
/tmp/test.php入口文件代码:

<?php
require_once '/tmp/Debug.php';

function test() {
    $obj = new Debug();

    $obj->printBacktrace('first');
    echo PHP_EOL;
    $obj->backtrace('second');
}

test();

/tmp/Debug.php类文件代码:

<?php
class Debug {
    public function printBacktrace($name) {
        debug_print_backtrace();
    }

    public function backtrace($name) {
        var_dump(debug_backtrace());
    }
}

执行/tmp/test.php文件,可以看到以下输出内容。

$ php /tmp/test.php
#0  Debug->printBacktrace(first) called at [/tmp/test.php:7]
#1  test() called at [/tmp/test.php:12]

array(2) {
  [0] =>
  array(7) {
    'file' =>
    string(13) "/tmp/test.php"
    'line' =>
    int(9)
    'function' =>
    string(9) "backtrace"
    'class' =>
    string(5) "Debug"
    'object' =>
    class Debug#1 (0) {
    }
    'type' =>
    string(2) "->"
    'args' =>
    array(1) {
      [0] =>
      string(6) "second"
    }
  }
  [1] =>
  array(4) {
    'file' =>
    string(13) "/tmp/test.php"
    'line' =>
    int(12)
    'function' =>
    string(4) "test"
    'args' =>
    array(0) {
    }
  }
}

第一部分是debug_print_backtrace()函数输出的内容,以字符串的形式输出整个调用链。第二部分是debug_backtrace()函数返回的内容,以数组的形式返回。所谓包含的信息非常丰富,细到哪个PHP文件,哪一行代码,哪一个类,哪一个函数或者哪个方法,调用类型是什么,甚至传递的参数都能一清二楚。
看完了简单的示例。开源框架通常都会使用到这两个工具中的一个,或者两个。其中又数debug_backtrace()函数用得最多。
例如在Yii 2中,有:

$ grep debug_backtrace * -R
vendor/yiisoft/yii2/base/ErrorHandler.php:            $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
vendor/yiisoft/yii2/log/Logger.php:            $ts = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);

在Symfony 4.0中,有:

$ grep debug_backtrace * -R
vendor/symfony/validator/Validator/TraceableValidator.php:        $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7);
vendor/symfony/debug/ErrorHandler.php:                $lightTrace = $this->tracedErrors & $type ? $this->cleanTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3), $type, $file, $line, false) : array();
vendor/symfony/http-kernel/Kernel.php:                $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
vendor/symfony/http-kernel/DataCollector/DumpDataCollector.php:        $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS, 7);

在PhalApi 2.2中,有:

$ grep _backtrace * -R                             
vendor/phalapi/kernal/src/Helper/Tracer.php:        $backTrace = debug_backtrace();

上面这三个开源框架中,都可以搜索得到debug_backtrace()的身影,并且都采摘了与错误处理、调试有关的搜索结果。

2 错误捕捉

这一节是最有意思的章节之一,因为学完这一节,你将能完全明白很多开源框架中友好的错误提示信息是如何产生的。
先来简单梳理一下PHP有哪些错误,它的级别是什么,以及影响是什么。
Fatal Error 致命错误出现致命错误时,会终止PHP的执行。例如调用了一个不存在的方法,或者常见的在NULL对象调用某个方法。对应的错误级别通常是E_ERROR。
Parse Error 语法错误PHP语法错误时会产生此类错误,例如缺少必要的分号,大括号不对齐。对应的错误级别通常是E_PARSE。你也可以使用php -l 命令检测某个PHP文件是否存在语法错误。Warning Error 警告错误这类错误只会给出相关的提示信息,并不会终止执行。常见的有E_WARNING。
Notice Error 通知错误同样地,这类错误只是给出通知错误信息,不会终止执行。例如输出了一个未声明的变量,则产生”PHP Notice: Undefined variable“提示。常用的有E_NOTICE。另外还有一类错误使用提比较少,那就是E_DEPRECATED弃用提示。例如下面代码运行后,会提示”PHP Deprecated: Function call_user_method() is deprecated in ……“。

<?php
error_reporting(E_ALL & E_DEPRECATED);

class A {
    public function test() {}
}

call_user_method('test', new A());

如果需要设置自己的错误处理,可以使用set_error_handler()函数设置用户自定义的错误处理函数。下面举两个开源框架的例子,一个是Symfony框架,另一个是ThinkPHP框架。

Symfony框架 4

首先,使用以下命令快速安装Symfony,并创建自己的项目symfony。

$  composer create-project symfony/website-skeleton symfony

然后启动服务:

$ php bin/console server:start 0.0.0.0:8000

最后,按照Symfony官方文档的提示,编写一个简单的控制器,但是经过我们改装过后的。在LuckyController::number()里面,我们故意使用了一个未声明的变量$number。代码如下:

<?php
// $ vim ./src/Controller/LuckyController.php 
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class LuckyController
{
    /**
     * @Route("/lucky/number", name="app_lucky_number")
     */
    public function number()
    {
        return new Response(
            '<html><body>Lucky number: '.$number.'</body></html>'  // 有问题!使用未声明的变量
        );
    }
}

在浏览器访问:http://localhost:8000/lucky/number,可以看到类似这样的错误提示页面。

图 Symfony的错误提示

在Symfony框架代码中搜索set_error_handler,可以发现有以下相关的搜索结果。

$ grep set_error_handler * -R
vendor/symfony/debug/ErrorHandler.php:        if (null === $prev = set_error_handler(array($handler, 'handleError'))) {
vendor/symfony/debug/ErrorHandler.php:            set_error_handler(array($handler, 'handleError'), $handler->thrownErrors | $handler->loggedErrors);
vendor/symfony/debug/ErrorHandler.php:            $handler = set_error_handler('var_dump');
vendor/symfony/debug/ErrorHandler.php:                    set_error_handler(array($this, 'handleError'), $this->thrownErrors | $this->loggedErrors);
vendor/symfony/debug/ErrorHandler.php:                    set_error_handler(array($this, 'handleError'));

用编辑器打开./vendor/symfony/debug/ErrorHandler.php文件,可以在ErrorHandler::register()内找到使用set_error_handler()函数的身影。为节省篇幅,这里贴出了关键的代码。

<?php
namespace Symfony\Component\Debug;

class ErrorHandler
{
    /**
     * Registers the error handler.
     *
     * @param self|null $handler The handler to register
     * @param bool      $replace Whether to replace or not any existing handler
     *
     * @return self The registered error handler
     */
    public static function register(self $handler = null, $replace = true)
    {
        // ……
        if ($handlerIsNew = null === $handler) {
            $handler = new static();
        }

        if (null === $prev = set_error_handler(array($handler, 'handleError'))) {
            restore_error_handler();
            // Specifying the error types earlier would expose us to https://bugs.php.net/63206
            set_error_handler(array($handler, 'handleError'), $handler->thrownErrors | $handler->loggedErrors);
            $handler->isRoot = true;
        }
        // ……

继续往下看,则可以找到具体处理的函数实现代码。

    /**
     * Handles errors by filtering then logging them according to the configured bit fields.
     */
    public function handleError($type, $message, $file, $line)
    {
        // Level is the current error reporting level to manage silent error.
        // Strong errors are not authorized to be silenced.
        $level = error_reporting() | E_RECOVERABLE_ERROR | E_USER_ERROR | E_DEPRECATED | E_USER_DEPRECATED;
        $log = $this->loggedErrors & $type;
        $throw = $this->thrownErrors & $type & $level;
        $type &= $level | $this->screamedErrors;
        // ……

小结一下,有时研究开源框架是一件很有意思的事情。因为你会发现,其实它们做的事情都是差不多的,但各自又有微妙的区别。一旦你掌握了底层PHP原生态函数的使用,就能够完全明白为什么会这样。从框架提供的友好界面开始,根据关键原生态函数名搜索,找到代码的相关位置,然后展开具体实现的代码版本,你就能在其中找到真相,找到你想了解的东西。

ThinkPHP框架 5.0

再来看一下ThinkPHP框架的错误提示是怎样的。安装ThinkPHP 5.0的命令如下:

$ composer create-project topthink/think=5.0.* tp5  --prefer-dist

下载安装完毕后,配置对应的Nginx就可以正常访问了。
接下来,让我们来搞搞破坏,故意制造一些小问题,让它触发回溯追踪。首先,需要开启应用调试模式,并且开启trace功能。这个调试模式几乎在全部框架中都会有开关设置的,因为在生产环境是不允许暴露任何相关的错误信息,以防被外界利用。修改项目下./application/config.php文件,将里面的app_debug和app_trace都更新为true,即:

    // 应用调试模式
    'app_debug'              => true,
    // 应用Trace
    'app_trace'              => true,

随后,写一行有问题的代码,比如在默认控制器(对应文件是./application/index/controller/Index.php)里输出一个未声明的变量。

<?php
    public function index()
    {
        echo $aa;  // 有问题!输出一个未声明的变量
        return '十年磨一剑 - 为API开发设计的高性能框架';
    }

再次访问首页后,就会出现完整详细的错误信息,其中就包括了整个请求过程中执行的调用栈。效果类似如下:

图 TP6的错误提示

搜索ThinkPHP框架的源代码,可以发现有以下搜索结果:

$ grep set_error * -R
thinkphp/library/think/Error.php:        set_error_handler([__CLASS__, 'appError']);

展开看一下前后的代码,有:

<?php
namespace think;

class Error
{
    /**
     * 注册异常处理
     * @access public
     * @return void
     */
    public static function register()
    {
        error_reporting(E_ALL);
        set_error_handler([__CLASS__, 'appError']);
        // ……
    }

    /**
     * 错误处理
     * @access public
     * @param  integer $errno      错误编号
     * @param  integer $errstr     详细错误信息
     * @param  string  $errfile    出错的文件
     * @param  integer $errline    出错行号
     * @return void
     * @throws ErrorException
     */
    public static function appError($errno, $errstr, $errfile = '', $errline = 0)
    {
        $exception = new ErrorException($errno, $errstr, $errfile, $errline);

        // 符合异常处理的则将错误信息托管至 think\exception\ErrorException
        if (error_reporting() & $errno) {
            throw $exception;
        }

        self::getExceptionHandler()->report($exception);
    }

通过set_error_handler([__CLASS__, 'appError']);
这一行代码,ThinkPHP注册了自己的错误处理机制。所以,前面看到的错误页面,正是think\Error::appError()产生输出的。
比较了国外和国内的开源框架后,也许细心读者已经找到了它们的共同点。基本思路都是先注册自己的错误处理回调函数,再具体实现。这里面也用到了我们前面所说的回调类型。可以说是一环扣一环,环环相扣。在这里,再稍微重复做个小广告:理解回调类型真的很重要。

3 生生不息的代码

在我喜欢的三国杀游戏,有个武将角色周泰,他有个技能是不屈。技能的解释,通俗来说,就是他死了不一定是死。在他死之后还可以在无血状态下再多死几次。我们都戏称杀死已经死去的周泰叫”鞭尸“。
PHP编程也有类似的技能,可以在PHP执行结束后仍然能继续执行一段代码。这时,可以使用register_shutdown_function()函数,一个在各大开源框架都会使用到的PHP原生函数。拥有它,你将能拥有更强大的控制力。它所赋予你的能力,甚至能超乎你的想象。
下面来领略一下。

正常执行完毕

毫无疑问,如果使用register_shutdown_function()函数注册了终止时执行的函数,那么在PHP代码执行结束后就会触发所注册的函数的执行。下面代码演示了这一点。

<?php
class ErrorHandler {
    public static function handleAfterShutdown() {
        echo '这里是终止时将会执行的代码……', PHP_EOL;
    }
}

register_shutdown_function('ErrorHandler::handleAfterShutdown');

$date = date('Y-m-d H:i:s');
echo '现在时间是:', $date, PHP_EOL;

同样地,传递给register_shutdown_function()的第一个参数的类型是回调类型,这里我们的回调函数是ErrorHandler::handleAfterShutdown(),结合前面的知识可以知道,类的静态方法的回调函数表示可以使用字符串表示,格式是:(类名) + (::) + (方法名)。
这段代码的执行结果,会输出类似以下内容:

现在时间是:2018-05-21 22:58:16
这里是终止时将会执行的代码……

可以看到,handleAfterShutdown()里面的代码会在最后执行。

使用die()或exit()主动结束

那么,对于在代码执行过程的中途,如果是开发人员自己手动结束的话,会不会也能触发前面注册的函数执行呢?
答案是可以的。因为虽然是手动通过die()或exit()来主动结束PHP代码的执行,但也属于正常的退出,所以最终还是会执行通过register_shutdown_function()注册的回调函数,是顺理成章的。我们可以在前面的代码最后继续添加以下代码来证明这一点。

// ……
echo '现在时间是:', $date, PHP_EOL;

// 手动终止
die();

$time = time();
echo '当前时间戳是:', $time, PHP_EOL;

继续执行,效果会和前面一样,除了时间显示不同外。

因致命错误导致的终止

刚刚这两种场景都很好理解,但第三种场景可能就要超乎你的想象了。前面有说到,PHP的错误级别中,有Fatal Error 致命错误,比如E_ERROR类型,例如调用一个不存在的方法时就会出现此类错误。那么这时还会继续执行所注册的回调函数吗?
暂时先保留答案,但通过下面的演示代码,我们就不难找到真相了。
调整一下刚才的代码,将die()那一行代码注释掉,并且在下面故意调用一个不存在的方法foo()。然后再次执行。

// 手动终止
// die();

// 故意调用一个不存在的方法
foo();

$time = time();
echo '当前时间戳是:', $time, PHP_EOL;

执行的结果,从前往后,分别输出了现在时间、PHP致命错误的信息,以及最后在handleAfterShutdown()里面的代码!也就是说,即便是出现了致命错误,最终还是会执行所注册的回调函数!

现在时间是:2018-05-21 23:20:50
/path/to/shutdown.php on line 18
PHP Stack trace:
PHP   1. {main}()/path/to/shutdown.php:0
这里是终止时将会执行的代码……

register_shutdown_function()是一个非常有用的工具,通常会与set_exception_handler()一起使用。set_exception_handler()可用于设置用户自定义的异常处理函数。这两个函数,在Yii、Symfony、ThinkPHP等开源框架中都能找到相关的使用代码。大家感兴趣,可以像前面那样自行搜索研究。
最后,为了让代码看起来不那么乱,稍微再浪费一点点纸张把本节示例的完整代码粘贴一下。

<?php
class ErrorHandler {
    public static function handleAfterShutdown() {
        echo '这里是终止时将会执行的代码……', PHP_EOL;
    }
}

register_shutdown_function('ErrorHandler::handleAfterShutdown');

$date = date('Y-m-d H:i:s');
echo '现在时间是:', $date, PHP_EOL;

// 手动终止
// die();

// 故意调用一个不存在的方法
foo();

$time = time();
echo '当前时间戳是:', $time, PHP_EOL;
echo '你不会看到这一句,正如你看不到我在写这书时被多少个蚊子在追杀[捂脸]', PHP_EOL;

注册生生不息的代码这一技巧,既可以用在框架设计中增强对代码的控制强,为开发工程师提供友好的错误提示页面并引导其快速定位问题,还可以用在业务项目中以满足特定场景下的非功能性需求,例如可以将写入到文件的日记,每次纪录时先放入一个PHP数组,最后执行完毕后,再打包一次性写入。

小结

关于PHP原生态的使用,暂时先探讨到这里。这里稍微做一个简单的回顾,以加深对这一章的理解。
很多技术知识点,看似零散,实际是有着微妙的联系,编织起来可构成一张完全的技能网络。正如本章所介绍的空与非空、数组排序、魔术方法、阻塞式调用、回调函数以及回溯追踪等。还是那句话,PHP入门很简单,但要想做到深入和精通,需要持续投入很多的时间和精力。如果将回调函数,以及错误捕捉、不同回调函数的注册等结合起来,你就能明白很多开源框架设计的原理,各自的应用场景,以及最终表现出来的界面背后的运行机制。同时,在本章中,我们也分别探索了Yii、Symfony、ThinkPHP、PhalApi等PHP开源框架在这些方面相关的底层代码。可能一开始还是还难完全吸收,但可以先有一个初步的概念,后面再慢慢消化,逐渐做到融会贯通。PHP原生态蕴藏着巨大的知识宝藏,这里所提及的只是冰山一角。希望能对大家有所帮助,同时激发各位对技术深究的好奇心。
我们讨论了生生不息的代码。其实这里是一语双关。PHP代码执行结束了,并不是真的就结束了,可能还有额外的代码正在开始执行。一个结束,是另一个开始。这也意味着学习PHP也一样,虽然这一章的学习结束了,但对于我,对于大家,对于普遍的开发工程师,学习PHP企业级系统开发,这只是一个开始。后面,我们将转入理论学习与项目实战之间的过滤阶段。如果说理论学习是教学,项目实战是上前线、上战场,那么下一章介绍的PHPUnit单元测试就是岗前练兵、模拟实战。目的是为了让我们在一个更安全的环境下,进行尝试和锻炼。准备好了吗?休息一下,继续前进!

发表评论