我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
标签 #教程

为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

PHP进阶:2.PHP中的新特征(1)

这篇文章的最新版本位于 PHP 自 5.2 到 5.6 中新增的功能详解,该页面已停止维护 —— 2015.5.29

截至目前(2014.2), PHP 的最新稳定版本是 PHP5.5, 但有差不多一半的用户仍在使用已经不在维护 [注] 的 PHP5.2, 其余的一半用户在使用 PHP5.3 [注].
因为 PHP 那“集百家之长”的蛋疼语法,加上社区氛围不好,很多人对新版本,新特征并无兴趣。
本文将会介绍自 PHP5.2 起,直至 PHP5.6 中增加的新特征。

  • PHP5.2 以前:autoload, PDO 和 MySQLi, 类型约束
  • PHP5.2:JSON 支持
  • PHP5.3:弃用的功能,匿名函数,新增魔术方法,命名空间,后期静态绑定,Heredoc 和 Nowdoc, const, 三元运算符,Phar
  • PHP5.4:Short Open Tag, 数组简写形式,Traits, 内置 Web 服务器,细节修改
  • PHP5.5:yield, list() 用于 foreach, 细节修改
  • PHP5.6: 常量增强,可变函数参数,命名空间增强

注:已于2011年1月停止支持: http://www.php.net/eol.php
注:http://w3techs.com/technologies/details/pl-php/5/all

PHP5.2以前

(2006前)
顺便介绍一下 PHP5.2 已经出现但值得介绍的特征。

autoload

大家可能都知道 __autoload() 函数,如果定义了该函数,那么当在代码中使用一个未定义的类的时候,该函数就会被调用,你可以在该函数中加载相应的类实现文件,如:

function __autoload($classname)
{
    require_once("{$classname}.php")
}

但该函数已经不被建议使用,原因是一个项目中仅能有一个这样的 __autoload() 函数,因为 PHP 不允许函数重名。但当你使用一些类库的时候,难免会出现多个 autoload 函数的需要,于是 spl_autoload_register() 取而代之:

spl_autoload_register(function($classname)
{
    require_once("{$classname}.php")
});

spl_autoload_register() 会将一个函数注册到 autoload 函数列表中,当出现未定义的类的时候,SPL [注] 会按照注册的倒序逐个调用被注册的 autoload 函数,这意味着你可以使用 spl_autoload_register() 注册多个 autoload 函数.

注:SPL: Standard PHP Library, 标准 PHP 库, 被设计用来解决一些经典问题(如数据结构).

PDO 和 MySQLi

即 PHP Data Object, PHP 数据对象,这是 PHP 的新式数据库访问接口。

按照传统的风格,访问 MySQL 数据库应该是这样子:

// 连接到服务器,选择数据库
$conn = mysql_connect("localhost", "user", "password");
mysql_select_db("database");

// 执行 SQL 查询
$type = $_POST['type'];
$sql = "SELECT * FROM `table` WHERE `type` = {$type}";
$result = mysql_query($sql);

// 打印结果
while($row = mysql_fetch_array($result, MYSQL_ASSOC))
{
    foreach($row as $k => $v)
        print "{$k}: {$v}\n";
}

// 释放结果集,关闭连接
mysql_free_result($result);
mysql_close($conn);

为了能够让代码实现数据库无关,即一段代码同时适用于多种数据库(例如以上代码仅仅适用于MySQL),PHP 官方设计了 PDO.
除此之外,PDO 还提供了更多功能,比如:

  • 面向对象风格的接口
  • SQL预编译(prepare), 占位符语法
  • 更高的执行效率,作为官方推荐,有特别的性能优化
  • 支持大部分SQL数据库,更换数据库无需改动代码

上面的代码用 PDO 实现将会是这样:

// 连接到数据库
$conn = new PDO("mysql:host=localhost;dbname=database", "user", "password");

// 预编译SQL, 绑定参数
$query = $conn->prepare("SELECT * FROM `table` WHERE `type` = :type");
$query->bindParam("type", $_POST['type']);

// 执行查询并打印结果
foreach($query->execute() as $row)
{
    foreach($row as $k => $v)
        print "{$k}: {$v}\n";
}

PDO 是官方推荐的,更为通用的数据库访问方式,如果你没有特殊需求,那么你最好学习和使用 PDO.
但如果你需要使用 MySQL 所特有的高级功能,那么你可能需要尝试一下 MySQLi, 因为 PDO 为了能够同时在多种数据库上使用,不会包含那些 MySQL 独有的功能。

MySQLi 是 MySQL 的增强接口,同时提供面向过程和面向对象接口,也是目前推荐的 MySQL 驱动,旧的C风格 MySQL 接口将会在今后被默认关闭。
MySQLi 的用法和以上两段代码相比,没有太多新概念,在此不再给出示例,可以参见 PHP 官网文档 [注]。

注:http://www.php.net/manual/en/mysqli.quickstart.php

类型约束

通过类型约束可以限制参数的类型,不过这一机制并不完善,目前仅适用于类和 callable(可执行类型) 以及 array(数组), 不适用于 string 和 int.

// 限制第一个参数为 MyClass, 第二个参数为可执行类型,第三个参数为数组
function MyFunction(MyClass $a, callable $b, array $c)
{
    // ...
}

PHP5.2

(2006-2011)

JSON 支持

包括 json_encode(), json_decode() 等函数,JSON 算是在 Web 领域非常常用的数据交换格式,可以被 JS 直接支持,JSON 实际上是 JS 语法的一部分。
JSON 系列函数,可以将 PHP 中的数组结构与 JSON 字符串进行转换:

$array = ["key" => "value", "array" => [1, 2, 3, 4]];
$json = json_encode($array);
echo "{$json}\n";

$object = json_decode($json);
print_r($object);

输出:

{"key":"value","array":[1,2,3,4]}
stdClass Object
(
    [key] => value
    [array] => Array
        (
            [0] => 1
            [1] => 2
            [2] => 3
            [3] => 4
        )
)

值得注意的是 json_decode() 默认会返回一个对象而非数组,如果需要返回数组需要将第二个参数设置为 true.

PHP5.3

(2009-2012)

PHP5.3 算是一个非常大的更新,新增了大量新特征,同时也做了一些不向下兼容的修改。

弃用的功能

以下几个功能被弃用,若在配置文件中启用,则 PHP 会在运行时发出警告。

Register Globals

这是 php.ini 中的一个选项(register_globals), 开启后会将所有表单变量($_GET和$_POST)注册为全局变量.
看下面的例子:

if(isAuth())
    $authorized = true;
if($authorized)
    include("page.php");

这段代码在通过验证时,将 $authorized 设置为 true. 然后根据 $authorized 的值来决定是否显示页面.

但由于并没有事先把 $authorized 初始化为 false, 当 register_globals 打开时,可能访问 /auth.php?authorized=1 来定义该变量值,绕过身份验证。

该特征属于历史遗留问题,在 PHP4.2 中被默认关闭,在 PHP5.4 中被移除。

Magic Quotes

对应 php.ini 中的选项 magic_quotes_gpc, 这个特征同样属于历史遗留问题,已经在 PHP5.4 中移除。

该特征会将所有用户输入进行转义,这看上去不错,在第一章我们提到过要对用户输入进行转义。
但是 PHP 并不知道哪些输入会进入 SQL , 哪些输入会进入 Shell, 哪些输入会被显示为 HTML, 所以很多时候这种转义会引起混乱。

Safe Mode

很多虚拟主机提供商使用 Safe Mode 来隔离多个用户,但 Safe Mode 存在诸多问题,例如某些扩展并不按照 Safe Mode 来进行权限控制。
PHP官方推荐使用操作系统的机制来进行权限隔离,让Web服务器以不同的用户权限来运行PHP解释器,请参见第一章中的最小权限原则.

匿名函数

也叫闭包(Closures), 经常被用来临时性地创建一个无名函数,用于回调函数等用途。

$func = function($arg)
{
    print $arg;
};

$func("Hello World");

以上代码定义了一个匿名函数,并赋值给了 $func.
可以看到定义匿名函数依旧使用 function 关键字,只不过省略了函数名,直接是参数列表。

然后我们又调用了 $func 所储存的匿名函数。

匿名函数还可以用 use 关键字来捕捉外部变量:

function arrayPlus($array, $num)
{
    array_walk($array, function(&$v) use($num){
        $v += $num;
    });
}

上面的代码定义了一个 arrayPlus() 函数(这不是匿名函数), 它会将一个数组($array)中的每一项,加上一个指定的数字($num).

在 arrayPlus() 的实现中,我们使用了 array_walk() 函数,它会为一个数组的每一项执行一个回调函数,即我们定义的匿名函数。
在匿名函数的参数列表后,我们用 use 关键字将匿名函数外的 $num 捕捉到了函数内,以便知道到底应该加上多少。

魔术方法:__invoke(), __callStatic()

PHP 的面向对象体系中,提供了若干“魔术方法”,用于实现类似其他语言中的“重载”,如在访问不存在的属性、方法时触发某个魔术方法。

随着匿名函数的加入,PHP 引入了一个新的魔术方法 __invoke().
该魔术方法会在将一个对象作为函数调用时被调用:

class A
{
    public function __invoke($str)
    {
        print "A::__invoke(): {$str}";
    }
}

$a = new A;
$a("Hello World");

输出毫无疑问是:

A::__invoke(): Hello World

__callStatic() 则会在调用一个不存在的静态方法时被调用。

命名空间

PHP的命名空间有着前无古人后无来者的无比蛋疼的语法:

<?php
// 命名空间的分隔符是反斜杠,该声明语句必须在文件第一行。
// 命名空间中可以包含任意代码,但只有 **类, 函数, 常量** 受命名空间影响。
namespace XXOO\Test;

// 该类的完整限定名是 \XXOO\Test\A , 其中第一个反斜杠表示全局命名空间。
class A{}

// 你还可以在已经文件中定义第二个命名空间,接下来的代码将都位于 \Other\Test2 .
namespace Other\Test2;

// 实例化来自其他命名空间的对象:
$a = new \XXOO\Test\A;
class B{}

// 你还可以用花括号定义第三个命名空间
namespace Other {
    // 实例化来自子命名空间的对象:
    $b = new Test2\B;

    // 导入来自其他命名空间的名称,并重命名,
    // 注意只能导入类,不能用于函数和常量。
    use \XXOO\Test\A as ClassA
}

更多有关命名空间的语法介绍请参见官网 [注].

命名空间时常和 autoload 一同使用,用于自动加载类实现文件:

spl_autoload_register(
    function ($class) {
        spl_autoload(str_replace("\\", "/", $class));
    }
);

当你实例化一个类 \XXOO\Test\A 的时候,这个类的完整限定名会被传递给 autoload 函数,autoload 函数将类名中的命名空间分隔符(反斜杠)替换为斜杠,并包含对应文件。
这样可以实现类定义文件分级储存,按需自动加载。

注:http://www.php.net/manual/zh/language.namespaces.php

后期静态绑定

PHP 的 OPP 机制,具有继承和类似虚函数的功能,例如如下的代码:

class A
{
    public function callFuncXXOO()
    {
        print $this->funcXXOO();
    }

    public function funcXXOO()
    {
        return "A::funcXXOO()";
    }
}

class B extends A
{
    public function funcXXOO()
    {
        return "B::funcXXOO";
    }
}

$b = new B;
$b->callFuncXXOO();

输出是:

B::funcXXOO

可以看到,当在 A 中使用 $this->funcXXOO() 时,体现了“虚函数”的机制,实际调用的是 B::funcXXOO().
然而如果将所有函数都改为静态函数:

class A
{
    static public function callFuncXXOO()
    {
        print self::funcXXOO();
    }

    static public function funcXXOO()
    {
        return "A::funcXXOO()";
    }
}

class B extends A
{
    static public function funcXXOO()
    {
        return "B::funcXXOO";
    }
}

$b = new B;
$b->callFuncXXOO();

情况就没这么乐观了,输出是:

A::funcXXOO()

这是因为 self 的语义本来就是“当前类”,所以 PHP5.3 给 static 关键字赋予了一个新功能:后期静态绑定:

class A
{
    static public function callFuncXXOO()
    {
        print static::funcXXOO();
    }

    // ...
}

// ...

这样就会像预期一样输出了:

B::funcXXOO

Heredoc 和 Nowdoc

PHP5.3 对 Heredoc 以及 Nowdoc 进行了一些改进,它们都用于在 PHP 代码中嵌入大段字符串。

Heredoc 的行为类似于一个双引号字符串:

$name = "MyName";
echo <<< TEXT
My name is "{$name}".
TEXT;

Heredoc 以三个左尖括号开始,后面跟一个标识符(TEXT), 直到一个同样的顶格的标识符(不能缩进)结束。
就像双引号字符串一样,其中可以嵌入变量。

Heredoc 还可以用于函数参数,以及类成员初始化:

var_dump(<<<EOD
Hello World
EOD
);

class A
{
    const xx = <<< EOD
Hello World
EOD;

    public $oo = <<< EOD
Hello World
EOD;
}

Nowdoc 的行为像一个单引号字符串,不能在其中嵌入变量,和 Heredoc 唯一的区别就是,三个左尖括号后的标识符要以单引号括起来:

$name = "MyName";
echo <<< 'TEXT'
My name is "{$name}".
TEXT;

输出:

My name is "{$name}".

用 const 定义常量

PHP5.3 起同时支持在全局命名空间和类中使用 const 定义常量。

旧式风格:

define("XOOO", "Value");

新式风格:

const XXOO = "Value";

const 形式仅适用于常量,不适用于运行时才能求值的表达式:

// 正确
const XXOO = 1234;
// 错误
const XXOO = 2 * 617;

三元运算符简写形式

旧式风格:

echo $a ? $a : "No Value";

可简写成:

echo $a ?: "No Value";

即如果省略三元运算符的第二个部分,会默认用第一个部分代替。

Phar

Phar即PHP Archive, 起初只是Pear中的一个库而已,后来在PHP5.3被重新编写成C扩展并内置到 PHP 中。
Phar用来将多个 .php 脚本打包(也可以打包其他文件)成一个 .phar 的压缩文件(通常是ZIP格式)。
目的在于模仿 Java 的 .jar, 不对,目的是为了让发布PHP应用程序更加方便。同时还提供了数字签名验证等功能。

.phar 文件可以像 .php 文件一样,被PHP引擎解释执行,同时你还可以写出这样的代码来包含(require) .phar 中的代码:

require("xxoo.phar");
require("phar://xxoo.phar/xo/ox.php");

更多信息请参见官网 [注].

注:http://www.php.net/manual/zh/phar.using.intro.php

PHP5.4

(2012-2013)

Short Open Tag

Short Open Tag 自 PHP5.4 起总是可用。
在这里集中讲一下有关 PHP 起止标签的问题。即:

<?php
// Code...
?>

通常就是上面的形式,除此之外还有一种简写形式:

<? /* Code... */ ?>

还可以把

<?php echo $xxoo;?>

简写成:

<?= $xxoo;?>

这种简写形式被称为 Short Open Tag, 在 PHP5.3 起被默认开启,在 PHP5.4 起总是可用。
使用这种简写形式在 HTML 中嵌入 PHP 变量将会非常方便。

对于纯 PHP 文件(如类实现文件), PHP 官方建议顶格写起始标记,同时 省略 结束标记。
这样可以确保整个 PHP 文件都是 PHP 代码,没有任何输出,否则当你包含该文件后,设置 Header 和 Cookie 时会遇到一些麻烦 [注].

注:Header 和 Cookie 必须在输出任何内容之前被发送。

数组简写形式

这是非常方便的一项特征!

// 原来的数组写法
$arr = array("key" => "value", "key2" => "value2");
// 简写形式
$arr = ["key" => "value", "key2" => "value2"];

Traits

所谓Traits就是“构件”,是用来替代继承的一种机制。PHP中无法进行多重继承,但一个类可以包含多个Traits.

// Traits不能被单独实例化,只能被类所包含
trait SayWorld
{
    public function sayHello()
    {
        echo 'World!';
    }
}

class MyHelloWorld
{
    // 将SayWorld中的成员包含进来
    use SayWorld;
}

$xxoo = new MyHelloWorld();
// sayHello() 函数是来自 SayWorld 构件的
$xxoo->sayHello();

Traits还有很多神奇的功能,比如包含多个Traits, 解决冲突,修改访问权限,为函数设置别名等等。
Traits中也同样可以包含Traits. 篇幅有限不能逐个举例,详情参见官网 [注].

注:http://www.php.net/manual/zh/language.oop5.traits.php

内置 Web 服务器

PHP从5.4开始内置一个轻量级的Web服务器,不支持并发,定位是用于开发和调试环境。

在开发环境使用它的确非常方便。

php -S localhost:8000

这样就在当前目录建立起了一个Web服务器,你可以通过 http://localhost:8000/ 来访问。
其中localhost是监听的ip,8000是监听的端口,可以自行修改。

很多应用中,都会进行URL重写,所以PHP提供了一个设置路由脚本的功能:

php -S localhost:8000 index.php

这样一来,所有的请求都会由index.php来处理。

你还可以使用 XDebug 来进行断点调试。

细节修改

PHP5.4 新增了动态访问静态方法的方式:

$func = "funcXXOO";
A::{$func}();

新增在实例化时访问类成员的特征:

(new MyClass)->xxoo();

新增支持对函数返回数组的成员访问解析(这种写法在之前版本是会报错的):

print func()[0];

PHP5.5

(2013起)

yield

yield关键字用于当函数需要返回一个迭代器的时候, 逐个返回值。

function number10()
{
    for($i = 1; $i <= 10; $i += 1)
        yield $i;
}

该函数的返回值是一个数组:

[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

list() 用于 foreach

可以用 list() 在 foreach 中解析嵌套的数组:

$array = [
    [1, 2, 3],
    [4, 5, 6],
];

foreach ($array as list($a, $b, $c))
    echo "{$a} {$b} {$c}\n";

结果:

1 2 3
4 5 6

细节修改

不推荐使用 mysql 函数,推荐使用 PDO 或 MySQLi, 参见前文。
不再支持Windows XP.

可用 MyClass::class 取到一个类的完整限定名(包括命名空间)。

empty() 支持表达式作为参数。

try-catch 结构新增 finally 块。

PHP进阶:1.安全

我决定写一系列教程:PHP进阶——一个论坛系统的实现,这是第一章。

1.0 安全

我们将安全放到了最前边,对于Web应用来说,安全的重要性不言而喻,如果你编写的程序存在漏洞,攻击者将有可能取得服务器的控制权,进行破坏,甚至利用你的站点进一步散播木马。

有些人认为测试性的代码,位于内网的应用,可以暂时不必关心安全,这是非常不负责任的。你很难预见这段代码什么时候会被作为你的应用的一部分,保险的做法是,为你写下的每一段代码负责,通过本章提出的几项原则,这并不麻烦。

  • SQL 注入
  • Shell 注入
  • Eval
  • XSS
  • CSRF
  • 关注官方通告
  • 最小权限原则
  • 中间人攻击
  • Dos 攻击
  • 隐藏信息

SQL 注入

如果你的应用需要将一个包含用户输入的字符串作为代码(如SQL)执行,那么攻击者可以通过构造巧妙的输入,来将额外的代码注入到要执行的字符串中,实现攻击.

下面举一个 SQL 注入的例子, 如果有如下的用户验证的代码:

$result = $db->query("SELECT * FROM `users` WHERE (`name` = '{$_POST['user']}') and (`passwd` = '{$_POST['passwd']}')");
if($result->fetch())
    echo "登录成功";

这段代码从 users 表中查询用户名为 $_POST[&#8216;user&#8217;] 且密码为 $_POST[&#8216;passwd&#8217;]的记录,如存在这样的记录,就认为登录成功。

但攻击者可以通过将 user 和 passwd 分别构造为如下的字符串进行攻击:

admin
1' OR '1'='1

这样一来,实际执行的SQL变成了这个样子:

SELECT * FROM `users` WHERE (`name` = 'admin') and (`passwd` = '1' OR '1'='1')

就可以绕过验证,实现以管理员登录.

产生SQL注入的原因在于单引号在 SQL 中具有特殊含义,单引号标识着一段字符串的开始和结束,如果用户输入中含有单引号,那么该字符串就会提前结束,剩下的部分就会作为SQL命令来解析.

所以,我们有必要对进入 SQL 语句的所有用户输入进行转义,所谓转义就是让代码中具有特殊含义的字符,失去特殊含义,仅仅表示它本身.

具体来说,我们在SQL中需要转义的字符有:

  • 反斜杠 \
  • NUL \0
  • 换行 \n
  • 回车 \r&#8221;
  • 单引号 &#8216;
  • 双引号 &#8220;
  • Ctrl+Z \x1a

还有一些和字符集相关的字符(在特定的字符集中,会有一些同义字).

所幸我们不必自己做这些工作,MySQL提供了一个名为 mysql_real_escape_string() 的函数用于将字符串转义,以安全地嵌入SQL.

而 PDO[1] 里同样有 PDO::quote() 完成同样的工作。

值得注意的是 mysql_real_escape_string() 和 PDO::quote() 的行为有细微的区别,前者只是转义字符串,后者在转义后还会在字符串前后加上单引号。

经过修改的代码会是这样:

$user = $db->quote($_POST['user']);
$passwd = $db->quote($_POST['passwd']);
$result = $db->query("SELECT * FROM `users` WHERE (`name` = {$user}) and (`passwd` = {$passwd})");
if($result->fetch())
    echo "登录成功";

如果攻击者继续构造类似之前的注入字符串,那么数据库会去查询用户名为 admin 且密码为 1' OR '1'='1 的记录,数据库仅仅将这段注入字符串视为它字面的意思,不会作为命令来解析,这样攻击者就不会得逞了.

所以,记住我们的第一个原则:

如果你需要将一段字符串作为代码来执行,那么要对所有进入字符串的用户输入进行转义

用户输入不仅仅在于 $_GET 和 $_POST, 包括HTTP Header, Cookie, 文件都是用户输入。

[1]: PHP Data Object, PHP的新式数据库访问接口, 后文会讲解.

更多SQL注入的例子参见: http://php.net/manual/zh/security.database.sql-injection.php

Shell 注入

如有如下代码:

shell_exec("rm /data/files/{$_POST['file']}");

这段代码会根据用户输入,删除 /data/file/ 下的指定文件.

既然你已经知道了我们的第一个原则,显然我们要对输入进行转义。
于是我们找到了 escapeshellarg(), 它会对进入Shell的参数进行转义,添加空格。

经过修改的代码:

$arg = escapeshellarg("/data/files/{$_POST['file']}");
shell_exec("rm {$arg}");

如果攻击者输入 xxoo;rm -rf /, 那么实际执行的命令是:

rm '/data/files/xxoo;rm -rf /'

可以看到攻击者没有成功。

但是攻击者可以继续尝试输入 ../../etc/passwd, 这下你傻眼了, 因为执行了:

rm '/data/files/../../etc/passwd'

相当于:

rm '/etc/passwd'

删掉了系统的用户数据库.

所以,我们除了要对用户输入进行转义之外,还需要 从逻辑上对输入进行检验

在前面SQL注入的例子中,这种检验是不必要的,因为我们知道数据库里面用户的密码不可能恰巧等于攻击者构造的恶意字符串。

但在这个Shell注入的例子中,我们有这个必要:

if(strstr($_POST['file'], "/") !== false)
{
    echo "文件名非法";
}
else
{
    $arg = escapeshellarg("/data/files/{$_POST['file']}");
    shell_exec("rm {$arg}");
}

这样我们便安全了,如果文件名包含了斜杠,程序会拒绝执行命令.

在Linux下,文件名可以使用除斜杠外的所有字符,如果需要迁移到Windows下,我们还需要做更多工作。

所以请记住我们的第二个原则:

不要相信用户的输入,考虑到每一种可能性

你最好把编写代码的过程,看作“证明这段代码不会出错”的过程。

Eval

包括 PHP 在内的大多数脚本语言都提供了类似 eval 的函数,可以将字符串作为代码来执行。同样,在使用 eval() 时我们也需要考虑注入攻击。

但不同于 SQL 和 Shell, 使用 eval() 的大多数场合都是可以避免的,你需要的可能是匿名函数等机制(后文会介绍), 所以在这里不推荐使用 eval() 函数。

XSS

Cross-site scripting, 跨站脚本攻击,或者你可以认为是 HTML 注入,这种攻击并非针对服务器,而是针对访客。

大多数网页的输出都是 HTML, 这意味着你的 PHP 程序的输出会作为 HTML 代码来显示在用户的浏览器中,而 HTML 中嵌入的 JS 脚本则会被用户的浏览器执行。

例如一个论坛系统显示帖子的代码:

<?php
$post = getPostData($_POST['id']);
?>
<html>
    <head>
        <title><?= $post['title'];?></title>
    </head>
    <body>
        <?= $post['content'];?>
    </body>
</html>

如果有一个帖子的内容是下面这样呢:

<span style="color: red;">帖子内容</span>

显然文字会被显示为红色,这意味着如果帖子中含有 HTML 代码,将会被直接执行,在这个例子中这段 HTML 代码可以认为是无害的,但如果是嵌入一段脚本呢:

<script>alert("帖子内容");</script>

浏览器会执行一段 JS 代码!如果你认为这除了开个玩笑外也是无害的,那么请看下面的输入:

<script>
    $.post("/topic/create/", {"title": "点进来看一看啊~", "content": "<script>" + $("body script:first").html() + "</script>"});
</script>

这段代码会自动发布一个新帖子,其内容就是这段代码本身!这样以来,这个“无害的玩笑”会在整个论坛传播开。

XSS 有持久性和非持久性(反射型)之分,前面介绍的例子属于持久性 XSS, 即已经将攻击代码保存在数据库中,其他人访问这些信息时,HTML代码就会被执行。

而非持久性的意思就是注入代码并未被持久性的保存,而是存在于 URL 中,如:

http://xxoo.xo/show.php?id=<script>alert();</script>

对应的 PHP 代码:

if(!is_numeric($_GET['id']))
    echo "{$_GET['id']} 不是一个数字";

如果你轻易点击了这个连接,那么你将会被执行一段脚本,危险性不再复述。

你想说不会有人点这么显然的链接?其实它可以很隐蔽:

http://xxoo.xo/show.php?id=%3Cscript%3Ealert()%3B%3C%2Fscript%3E

甚至:

http://xxoo.xo/show.php?id=%3C%73%63%72%69%70%74%3E%61%6c%65%72%74%28%29%3B%3C%2F%73%63%72%69%70%74%3E

既然是注入 HTML,那么对策并不复杂:对所有输出到 HTML 的数据进行转义,使用 htmlspecialchars(), 如:

if(!is_numeric($_GET['id']))
{
    $id = htmlspecialchars($_GET['id']);
    echo "{$id} 不是一个数字";
}

好吧,其实前面那么多铺垫这是为了让你了解 XSS 的重要性,当下它们甚至比 SQL 注入更加流行。

CSRF

Cross-site request forgery, 跨域请求伪造。

浏览器本身为 JS 制定的一个“跨域保护”规则,保证了 JS 仅能访问当前站点下的资源。

规则即一个域名下的 JS 无法使用 XHR[4] 访问另一个域名下的资源。

但这个限制并不包括 <img>, <script> 等标记的 src 属性,而且这也不现实,因为一个站点难免引用其他站点的图片等资源。

现在很多站点都使用类似于 /user/logout/ 的链接来实现注销登录的功能。如果有这样一段代码:

<img src="//xxoo.xo/user/logout/" />

那么如果用户访问了这个页面,它在 xxoo.xo 网站就会被注销登录,无论当前站点是不是 xxoo.xo !

这样攻击是非常隐蔽的,因为如果图片加载失败了也只不过是一个小叉而已,还可以通过 CSS 隐藏起来。

上面的例子也许可以认为是无害,因为被注销登录也算不得什么大不了的事情,但如果你继续使用 GET 方式响应非修改性的请求的话,事情就没那么简单了。

记住: 所有修改性的操作都使用POST方式

[2]: XMLHttpRequest, JS 无刷新请求技术的 API 接口。

关注官方通告

有时候,漏洞并不在于我们编写的代码,而在于我们所使用的软件本身,如PHP, 或者Linux, MySQL等等。

虽然这些软件的代码审核和发布过程是非常严谨的,但也难免有漏网之鱼。一些严重的漏洞会在一天之间被传播,利用和修复。

对此我们没有什么对策,毕竟这些软件不是我们自己写的,我们能做的但只有:

  • 使用官方的稳定发行版
  • 及时关注官方通告,安装补丁

在PHP领域,有很多问题是官方在反复提醒,但仍广泛存在的,我们在此指出几点:

关闭 Register Globals

这是 php.ini 中的一个选项(register_globals), 开启后会将所有表单变量($_GET和$_POST)注册为全局变量.

看下面的例子:

if(isAuth())
    $authorized = true;
if($authorized)
    include("page.php");

这段代码在通过验证时,将 $authorized 设置为 true. 然后根据 $authorized 的值来决定是否显示页面.

但由于并没有事先把 $authorized 初始化为 false, 当 register_globals 打开时,可能访问 /auth.php?authorized=1 来定义该变量值,绕过身份验证。

该特征属于历史遗留问题,在 PHP4.2 中被默认关闭,在 PHP5.4 中被移除。

关闭 Magic Quotes

对应 php.ini 中的选项 magic_quotes_gpc, 这个特征同样属于历史遗留问题,已经在 PHP5.4 中移除。

该特征会将所有用户输入进行转义,这看上去不错,我们之前提到过要对用户输入进行转义。

但是 PHP 并不知道哪些输入会进入 SQL , 哪些输入会进入 Shell, 哪些输入会被显示为 HTML, 所以很多时候这种转义会引起混乱。

最小权限原则

让我们试想如果 PHP 出现了一个验证漏洞,导致可以任意执行Shell命令,这将会多么恐怖。事实上这种漏洞在各种服务器软件上时有发生。

虽然我们无法阅读和修改 PHP 的源代码,但我们可以通过给予 PHP 最小的权限,来减少漏洞被利用后的损失。

很多教程在遇到文件权限问题的时候,会很不负责任地让你将文件夹权限设置为 777, 即最宽松权限。

在Linux下,每个文件,每个进程都对应着一个用户,非 root 用户只能控制自己的文件,自己的进程,自己的进程创建的文件也是属于自己的。

每个文件有 9 个权限位,分别对应 自己, 同组用户, 其他用户读取, 修改, 执行 权限

基于这种机制,我们可以将服务器的不同功能划分为不同的用户,实现隔离,即使一个服务被攻击者控制,也不会影响到其他的服务。

事实上各个发行版也是这么做的,Apache2, PHP-FPM[2] 大多是以 www-data 或类似的用户运行的,即使被拿到权限,也只能控制网站的部分,无法执行需要 root 权限的系统级命令。

我们可以进一步对权限进行细分,如果应用不需要写入文件,那么给 Web 目录设置只读权限就可以了。

储存静态资源(图片, CSS, JS等)的目录,不要允许 PHP 执行。

甚至让每一个站点都以单独的用户来运行[3].

如果服务器仅提供 Web 服务,那么防火墙仅开放 80 端口。

对于数据库以及其他服务,我们也需要给予应用最小的权限,为每个应用建立单独的账户,只赋予每个应用读写自己的数据库的权限。

[3]: PHP 在 Apache2 下可能依附于 Apache2进程,在 Nginx 则大多用 PHP-FPM 方式运行。

[4]: Apache2 可使用 apache2-mpm-itk, PHP-FPM 可使用配置文件中的 user 选项。

中间人攻击

在 Web 领域,中间人攻击即服务器和用户之间,存在着一个可以双向篡改数据包的 中间人,它有可能是网络中的一个路由节点,也可能是一个钓鱼网站。

说实话这已经是网络传输层面的问题了,和 PHP 没多大关系,解决中间人攻击的唯一途径就是使用 https 协议,SSL 除了可以防御中间人攻击外,还可以让你的数据以加密的形式传输(HTTP是明文传输的), 目前网络上已经有了一些免费的证书颁发机构(CA), 如 StarSSL.

DoS 攻击

Denial of Service, 阻断服务攻击,通过大量垃圾请求迫使服务器无法工作。

如果你的网站让攻击者无处下手,那么攻击者可能会使出最野蛮的攻击方式,DoS, 比如使用脚本快速访问你的站点,让你的服务器不堪重负。

首先你应当避免你的站点出现性能瓶颈,尽量通过缓存(后文会讲解)来提供内容,减少请求处理时间。

设置 Web 服务器的最大进程数,避免进程过多导致内存不足。

还可以使用如 DDoS deflate[5] 的脚本,自动对攻击 IP 进行封禁,这对单点 DoS 非常有效。

如果攻击者不计成本地使用流量“堵”你的线路,那么你可以选择一些知名的 CDN 服务来过滤请求,隐藏服务器的真实 IP 地址。

隐藏信息

虽然隐藏信息是一种比较消极的防御手段,但也能为攻击者添加一些障碍。

值得隐藏的信息包括:Web服务器版本,脚本引擎(PHP)版本,错误信息等等。

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

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

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