原文: PHP Best Practices-A short, practical guide for common and confusing PHP tasks
译者:youngsterxyf
最后修订日期&维护者
本文档最后审阅于2013年3月8日。最后修改于2013年5月8日。
由我,Alex Cabal,维护该文档。我编写PHP程序已有很长一段时间了,当前我 经营着Scribophile,由认真作家组成的一个在线写作团体, Writerfolio,为自由职业者提供的一个易用写作工具集,以及 Standard Ebooks,一个图文并茂、无数字版权管理的公共领域电子书出版商。 有时我是个为吸引我的项目或客户而工作的自由职业者。
如果你认为我在某些事情上能够帮到你,或者对本文档有点建议或纠正存在的错误,请给我写封邮件。
简介
PHP是一门复杂的语言,经过多年折腾,使其不同版本之间高度不一致,有时还有些bug。 每个版本都有自己独有的特性、多余和怪异之处,也很难跟踪哪个版本有哪些问题。这也就 很好理解为什么有时它会遭到那么多的厌恶。
尽管如此,如今它还是Web开发方面最流行的语言。因其悠久的历史,对于实现密码哈希和 数据库访问诸如此类的基本任务你能够找到很多教程。但问题在于,5个教程,你就很有可能 找到5种完全不同的完成任务的方式,那么哪种是“正确”的方式呢?其他方式有难以捉摸的bug 或者陷阱?确实很难搞明白,所以你经常要在互联网上反复查找尝试确认正确的答案。
这也是PHP编程新手频繁地因为丑陋、过时、或不安全的代码而遭到责备的原因之一。如果 Google搜索的第一个结果是一篇4年前的文章,讲述一种5年前的方法,那么PHP新手们也就 很难改变经常遭受责备的现状。
本文档通过为PHP中常见的令人困惑的问题和任务编辑组织一系列被认为最佳实践的基本做法, 来尝试解决上述问题。若一个低层次的任务在PHP中有多种令人困惑的实现方式,本文也会涵盖。
是什么
这是一份指南,在PHP程序员遇到一些常见低层次任务但不明确最佳做法(由于PHP可能提供 了多种解决方案)之时,为其建议最佳实践。例如:连接数据库是一个常见任务,PHP中提供了 大量可行的方案,但并不是所有的都是好的做法,因此,本文也会包含该问题。
本文包含的是一系列简短的、入门性质的方案。涉及的示例在基本设定下就能够运行起来, 你研究一下应该就能把它们变为对你有用的东西。
本文将指出一些我们认为是PHP中最新最好的东西。然而,这意味如果你在使用老版本的PHP, 一些用来实现这些解决方案的特性对你并不可用。
这份文档会一直更新,我会尽我最大努力保持该文档与PHP的发展同步。
不是什么
本文档不是一份PHP教程。你应该在别处学习语言基础和语法。
它也不是一份针对web应用常见问题,如cookie存储、缓存、编程风格、文档等的指南。
它也不是一个安全指南。当本文档触碰到一些安全相关的问题时,也是希望你自己做些研究来 确保你的PHP应用的安全问题。你的代码造成的问题应该都是自己的过错。
该文档也并不是在主张一种特定的编程风格、模式或者框架。
也不是在主张一种特定的方式来完成高层次任务如用户注册、登录系统等。本文档只限于 PHP的悠久历史所造成的一些易混淆或不明确的低层次任务。
它不是一个一劳永逸的解决方案,也不是一个唯一的方案。下面要讲述的一些方法对于你的 特定场景来说也许并不是最好的,存在很多不同的方式来达到同样的目的。特别是,高负载web 应用也许能从更加难懂的方案中获益更多。
我们在使用哪个版本的PHP?
带Suhosin-Patch的PHP 5.3.10-1ubuntu3.6,安装在Ubuntu 12.04 LTS上。
PHP是Web世界里的百年老龟,它的壳上铭刻着一段丰富、复杂、而粗糙的历史。在一个共享 主机的环境里,它的配置可能会限制你能做的事情。
为了保持清晰地叙述,我们将仅针对一个版本的PHP进行讲述。在2013年4月30日时,该版本 为PHP 5.3.10-1ubuntu3.6 with Suhosin-Patch。若你在Ubuntu 12.04 LTS服务器 上使用apt-get进行安装的就是该版本的PHP。
你也许发现这些方案中的一些在其他或者更老版本的PHP上也能工作。如果是这样的话,就由 你来研究在这些更老版本上潜在的难以捉摸的bug或安全问题。
存储密码
使用phpass库来哈希和比较密码
经phpass 0.3测试
在存入数据库之前进行哈希保护用户密码的标准方式。许多常用的哈希算法如md5,甚至是sha1 对于密码存储都是不安全的,因为骇客能够使用那些算法轻而易举地破解密码。
对密码进行哈希最安全的方法是使用bcrypt算法。开源的phpass库以一个易于使用的类来提供 该功能。
示例
<?php
// Include the phpass library
require_once('phpass-03/PasswordHash.php')
// Initialize the hasher without portable hashes (this is more secure)
$hasher = new PasswordHash(8, false);
// Hash the password. $hashedPassword will be a 60-character string.
$hashedPassword = $hasher->HashPassword('my super cool password');
// You can now safely store the contents of $hashedPassword in your database!
// Check if a user has provided the correct password by comparing what they
// typed with our hash
$hasher->CheckPassword('the wrong password', $hashedPassword); // false
$hasher->CheckPassword('my super cool password', $hashedPassword); // true
?>
陷阱
- 许多资源可能推荐你在哈希之前对你的密码“加盐”。想法很好,但phpass在HashPassword()函数中已经对你的密码“加盐”了,这意味着你不需要自己“加盐”。
进一步阅读
连接并查询MySQL数据库
使用PDO及其预处理语句功能。
在PHP中,有很多方式来连接到一个MySQL数据库。PDO(PHP数据对象)是其中最新且最健壮的一种。PDO跨多种不同类型数据库有一个一致的接口,使用面向对象的方式,支持更多的新数据库支持的特性。
你应该使用PDO的预处理语句函数来帮助防范SQL注入攻击。使用函数bindValue来确保你的SQL免于一级SQL注入攻击。(虽然并不是100%安全的,查看进一步阅读获取更多细节。)在以前,这必须使用一些“魔术引号(magic quotes)”函数的组合来实现。PDO使得那堆东西不再需要。
示例
<?php
try{
// Create a new connection.
// You'll probably want to replace hostname with localhost in the first parameter.
// The PDO options we pass do the following:
// \PDO::ATTR_ERRMODE enables exceptions for errors. This is optional but can be handy.
// \PDO::ATTR_PERSISTENT disables persistent connections, which can cause concurrency issues in certain cases. See "Gotchas".
// \PDO::MYSQL_ATTR_INIT_COMMAND alerts the connection that we'll be passing UTF-8 data.
// This may not be required depending on your configuration, but it'll save you headaches down the road
// if you're trying to store Unicode strings in your database. See "Gotchas".
$link = new \PDO( 'mysql:host=your-hostname;dbname=your-db',
'your-username',
'your-password',
array(
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_PERSISTENT => false,
\PDO::MYSQL_ATTR_INIT_COMMAND => 'set names utf8mb4'
)
);
$handle = $link->prepare('select Username from Users where UserId = ? or Username = ? limit ?');
// PHP bug: if you don't specify PDO::PARAM_INT, PDO may enclose the argument in quotes.
// This can mess up some MySQL queries that don't expect integers to be quoted.
// See: https://bugs.php.net/bug.php?id=44639
// If you're not sure whether the value you're passing is an integer, use the is_int() function.
$handle->bindValue(1, 100, PDO::PARAM_INT);
$handle->bindValue(2, 'Bilbo Baggins');
$handle->bindValue(3, 5, PDO::PARAM_INT);
$handle->execute();
// Using the fetchAll() method might be too resource-heavy if you're selecting a truly massive amount of rows.
// If that's the case, you can use the fetch() method and loop through each result row one by one.
// You can also return arrays and other things instead of objects. See the PDO documentation for details.
$result = $handle->fetchAll(\PDO::FETCH_OBJ);
foreach($result as $row){
print($row->Username);
}
}
catch(\PDOException $ex){
print($ex->getMessage());
}
?>
陷阱
- 当绑定整型变量时,如果不传递PDO::PARAM_INT参数有事可能会导致PDO对数据加引号。这会 搞坏特定的MySQL查询。查看该bug报告。
- 未使用
set names utf8mb4
作为首个查询,可能会导致Unicode数据错误地存储进数据库,这依赖于你的配置。如果你 绝对有把握你的Unicode编码数据不会出问题,那你可以不管这个。 - 启用持久连接可能会导致怪异的并发相关的问题。这不是一个PHP的问题,而是一个应用层面 的问题。只要你仔细考虑了后果,持久连接一般会是安全的。查看Stack Overfilow这个问题。
-
即使你使用了
set names utf8mb4
,你也得确认实际的数据库表使用的是utf8mb4字符集! -
可以在单个execute()调用中执行多条SQL语句。只需使用分号分隔语句,但注意这个bug,在该文档所针对的PHP版本中还没修复。
进一步阅读
- PHP手册:PDO
- 为什么你应该使用PHP的PDO访问数据库
- Stack Overflow: PHP PDO vs 普通的mysql_connect
- Stack Overflow: PDO预处理语句足以防范SQL注入吗?
- Stack Overflow: 在MySQL中使用SET NAMES utf8?
PHP标签
使用 <?php ?>
。
有几种不同的方式用来区分PHP程序块:<?php ?>
, <?= ?>
, <? ?>
, 以及<%
%>
。对于打字来说,更短的标签更方便些,但唯一一种在所有PHP服务器上都一定能工作的标签
是<?php
?>
。若你计划将你的PHP应用部署到一台上面的PHP配置你无法控制的服务器上,那么你应始终使用
<?php ?>
。
若你仅仅是为自己编码,也能控制你将使用的PHP配置,你可能觉得短标签更方便些。但记住
<? ?>
可能会和XML声明冲突,并且<? ?>
实际上是ASP的风格。
无论你选择哪一种,确保一致。
陷阱
- 在一个纯PHP文件(例如,仅包含一个类定义的文件)中包含一个关闭
?>
标签时,确保其后 不会跟着任何换行。当PHP解析器安全地吃进跟在关闭标签之后的单个换行符时,任何其他的换行 都可能被输出到浏览器,如果之后要输出某些HTTP头,那么可能会造成混淆。 - 编写Web应用时,确保在关闭
?>
标签与html的<!doctype>
标签之间不会留下换行。正确的HTML 文件中,<!doctype>
标签必须是文件中的第一样东西---在其之前的任何空格或换行都会使其 无效。
进一步阅读
自动加载类
使用spl_autoload_register()来注册你的自动加载函数。
PHP提供了若干方式来自动加载包含还未加载的类的文件。老的方法是使用名为__autoload()
魔术全局函数。然而你一次仅能定义一个__autoload()
函数,因此如果你的程序
包含一个也使用了__autoload()
函数的库,就会发生冲突。
处理这个问题的正确方法是唯一地命名你的自动加载函数,然后使用spl_autoload_register()
函数
来注册它。该函数允许定义多个__autoload()
这样的函数,因此你不必担心其他代码的__autoload()
函数。
示例
<?php
// First, define your auto-load function
function MyAutoload($className){
include_once($className . '.php');
}
// Next, register it with PHP
spl_autoload_register('MyAutoload');
// Try it out!
// Since we haven't included a file defining the MyClass object, our
// auto-loader will kick in and include MyClass.php.
// For this example, assume the MyClass class is defined in the MyClass.php
// file.
$var = new MyClass();
?>
进一步阅读
从性能角度来看单引号和双引号
其实并不重要。
已有很多人花费很多笔墨来讨论是使用单引号('
)还是双引号("
)来定义字符串。
单引号字符串不会被解析,因此放入字符串的任何东西都会以原样显示。双引号字符串会被解析,
字符串中的任何PHP变量都会被求值。另外,转义字符如换行符\n
和制表符\t
在单引号字符串中
不会被求值,但在双引号字符串中会被求值。
由于双引号字符串在程序运行时要求值,从而理论上使用单引号字符串能提高性能,因为PHP 不会对单引号字符串求值。这对于一定规模的应用来说也许确实如此,但对于现实中一般的应用来说, 区别非常小以至于根本不用在意。因此对于普通应用,你选择哪种字符串并不重要。对于负载 极其高的应用来说,是有点作用的。根据你的应用的需要来做选择,但无论你选择什么,请保持一致。
进一步阅读
- PHP手册:字符串
- PHP基准(向下滚动到引号类型(Quote Types))
- Stack Overflow: PHP中单引号字符串相比双引号字符串有性能优势么?
define() vs. const
使用define(),除非考虑到可读性、类常量、或关注微优化
习惯上,在PHP中是使用define()函数来定义常量。但从某个时候开始,PHP中也能够使用const 关键字来声明常量了。那么当定义常量时,该使用哪种方式呢?
答案在于这两种方法之间的区别。
- define()在执行期定义常量,而const在编译期定义常量。这样const就有轻微的速度优势, 但不值得考虑这个问题,除非你在构建大规模的软件。
- define()将常量放入全局作用域,虽然你可以在常量名中包含命名空间。这意味着你不能 使用define()定义类常量。
- define()允许你在常量名和常量值中使用表达式,而const则都不允许。这使得define() 更加灵活。
- define()可以在if()代码块中调用,但const不行。
示例
<?php
// Let's see how the two methods treat namespaces
namespace MiddleEarth\Creatures\Dwarves;
const GIMLI_ID = 1;
define('MiddleEarth\Creatures\Elves\LEGOLAS_ID', 2);
echo(\MiddleEarth\Creatures\Dwarves\GIMLI_ID); // 1
echo(\MiddleEarth\Creatures\Elves\LEGOLAS_ID); // 2; note that we used define()
// Now let's declare some bit-shifted constants representing ways to enter Mordor.
define('TRANSPORT_METHOD_SNEAKING', 1 << 0); // OK!
const TRANSPORT_METHOD_WALKING = 1 << 1; //Compile error! const can't use expressions as values
// Next, conditional constants.
define('HOBBITS_FRODO_ID', 1);
if($isGoingToMordor){
define('TRANSPORT_METHOD', TRANSPORT_METHOD_SNEAKING); // OK!
const PARTY_LEADER_ID = HOBBITS_FRODO_ID // Compile error: const can't be used in an if block
}
// Finally, class constants
class OneRing{
const MELTING_POINT_DEGREES = 1000000; // OK!
define('SHOW_ELVISH_DEGREES', 200); // Compile error: can't use define() within a class
}
?>
因为define()更加灵活,你应该使用它以避免一些令人头疼的事情,除非你明确地需要类 常量。使用const通常会产生更加可读的代码,但是以牺牲灵活性为代价的。
无论你选择哪一种,请保持一致。
进一步阅读
缓存PHP opcode
使用APC
在一个标准的PHP环境中,每次访问PHP脚本时,脚本都会被编译然后执行。一次又一次地花费 时间编译相同的脚本对于大型站点会造成性能问题。
解决方案是采用一个opcode缓存。opcode缓存是一个能够记下每个脚本经过编译的版本,这样 服务器就不需要浪费时间一次又一次地编译了。通常这些opcode缓存系统也能智能地检测到 一个脚本是否发生改变,因此当你升级PHP源码时,并不需要手动清空缓存。
有几个PHP opcode缓存可用,其中值得关注的有eaccelerator, xcache,以及APC。 APC是PHP项目官方支持的,最为活跃,也最容易安装。它也提供一个可选的类memcached 的持久化键-值对存储,因此你应使用它。
安装APC
在Ubuntu 12.04上你可以通过在终端中执行以下命令来安装APC:
user@localhost: sudo apt-get install php-apc
除此之外,不需要进一步的配置。
将APC作为一个持久化键-值存储系统来使用
APC也提供了对于你的脚本透明的类似于memcached的功能。与使用memcached相比一个大的优势是 APC是集成到PHP核心的,因此你不需要在服务器上维护另一个运行的部件,并且PHP开发者在APC 上的工作很活跃。但从另一方面来说,APC并不是一个分布式缓存,如果你需要这个特性,你就 必须使用memcached了。
示例
<?php
// Store some values in the APC cache. We can optionally pass a time-to-live,
// but in this example the values will live forever until they're garbage-collected by APC.
apc_store('username-1532', 'Frodo Baggins');
apc_store('username-958', 'Aragorn');
apc_store('username-6389', 'Gandalf');
// After storing these values, any PHP script can access them, no matter when it's run!
$value = apc_fetch('username-958', $success);
if($success === true)
print($value); // Aragorn
$value = apc_fetch('username-1', $success); // $success will be set to boolean false, because this key doesn't exist.
if($success !== true) // Note the !==, this checks for true boolean false, not "falsey" values like 0 or empty string.
print('Key not found');
apc_delete('username-958'); // This key will no longer be available.
?>
陷阱
- 如果你使用的不是PHP-FPM(例如你在 使用mod_php 或mod_fastcgi),那么 每个PHP进程都会有自己独有的APC实例,包括键-值存储。若你不注意,这可能会在你的应用 代码中造成同步问题。
进一步阅读
PHP与Memcached
若你需要一个分布式缓存,那就使用Memcached客户端库。否则,使用APC。
缓存系统通常能够提升应用的性能。Memcached是一个受欢迎的选择,它能配合许多语言使用, 包括PHP。
然而,从一个PHP脚本中访问一个Memcached服务器,你有两个不同且命名很愚蠢的客户端库选择项: Memcache和Memcached。 它们是两个名字几乎相同的不同库,两者都可用于访问一个Memcached实例。
事实证明,Memcached库对于Memcached协议的实现最好,包含了一些Mmecache库没有的有用的特性, 并且看起来Memcached库的开发也最为活跃。
然而,如果不需要访问来自一组分布式服务器的一个Memcached实例,那就使用APC。 APC得到PHP项目的支持,具备很多和Memcached相同的功能,并且能够用作opcode缓存,这能提高PHP脚本的性能。
安装Memcached客户端库
在安装Memcached服务器之后,需要安装Memcached客户端库。没有该库,PHP脚本就没法与 Memcached服务器通信。
在Ubuntu 12.04上,你可以使用如下命令来安装Memcached客户端库:
user@localhost: sudo apt-get install php5-memcached
使用APC作为替代
查看opcode缓存一节阅读更多与使用APC作为 Memcached替代方案相关的信息。
进一步阅读
- PHP手册:Memcached
- PHP手册:APC
- Stack Overflow: PHP中使用Memcache vs. Memcached
- Stack Overflow: Memcached vs APC,我该选择哪一个?
PHP与正则表达式
使用 PCRE ( preg_*
) 家族函数
PHP有两种使用不同的方式来使用正则表达式:PCRE(Perl兼容表示法,preg_*
)函数
和POSIX(POSIX扩展表示法,ereg_*
)
函数。
每个函数家族各自使用一种风格稍微不同的正则表达式。幸运的是,POSIX家族函数从PHP
5.3.0开始就被弃用了。因此,你绝不应该使用POSIX家族函数编写新的代码。始终使用
PRCE家族函数,即preg_*
函数。
进一步阅读
配置Web服务器提供PHP服务
使用PHP-FPM
有多种方式来配置一个web服务器以提供PHP服务。传统(并且糟糕的)的方式是使用Apache的 mod_php。Mod_php将PHP 绑定到Apache自身,但是Apache对于该模块功能的管理工作非常糟糕。一旦遇到较大的流量, 就会遭受严重的内存问题。
后来两个新的可选项很快流行起来:mod_fastcgi 和mod_fcgid。两者均保持一定数量的PHP执行进程, Apache将请求发送到这些端口来处理PHP的执行。由于这些库限制了存活的PHP进程的数量, 从而大大减少了内存使用而没有影响性能。
一些聪明的人创建一个fastcgi的实现,专门为真正与PHP工作良好而设计,他们称之为 PHP-FPM。PHP 5.3.0之前,为安装它, 你得跨越许多障碍,但幸运的是,PHP 5.3.3的核心包含了PHP-FPM,因此在Ubuntu 12.04上安装它非常方便。
如下示例是针对Apache 2.2.22的,但PHP-FPM也能用于其他web服务器如Nginx。
安装PHP-FPM和Apache
在Ubuntu 12.04上你可以使用如下命令安装PHP-FPM和Apache:
user@localhost: sudo apt-get install apache2-mpm-worker
libapache2-mod-fastcgi php5-fpm
user@localhost: sudo a2enmod actions alias fastcgi
注意我们必须使用apache2-mpm-worker,而不是apache2-mpm-prefork或apache2-mpm-threaded。
接下来配置Aapache虚拟主机将PHP请求路由到PHP-FPM进程。将如下配置语句放入Apache 配置文件(在Ubuntu 12.04上默认配置文件是/etc/apache2/sites-available/default)。
<VirtualHost *:80>
AddHandler php5-fcgi .php
Action php5-fcgi /php5-fcgi
Alias /php5-fcgi /usr/lib/cgi-bin/php5-fcgi
FastCgiExternalServer /usr/lib/cgi-bin/php5-fcgi -host 127.0.0.1:9000 -idle-timeout 120 -pass-header Authorization
</VirtualHost>
最后,重启Apache和FPM进程:
user@localhost: sudo service apache2 restart && sudo service php5-fpm
restart
进一步阅读
发送邮件
使用PHPMailer
经PHPMailer 5.1测试
PHP提供了一个mail()函数,看起来很简单易用。 不幸的是,与PHP中的很多东西一样,它的简单性是个幻象,因其虚假的表面使用它会导致 严重的安全问题。
Email是一组网络协议,比PHP的历史还曲折。完全可以说发送邮件中的陷阱与PHP的mail() 函数一样多,这个可能会令你有点“不寒而栗”吧。
PHPMailer是一个流行而 成熟的开源库,为安全地发送邮件提供一个易用的接口。它关注可能陷阱,这样你可以专注 于更重要的事情。
示例
<?php
// Include the PHPMailer library
require_once('phpmailer-5.1/class.phpmailer.php');
// Passing 'true' enables exceptions. This is optional and defaults to false.
$mailer = new PHPMailer(true);
// Send a mail from Bilbo Baggins to Gandalf the Grey
// Set up to, from, and the message body. The body doesn't have to be HTML;
// check the PHPMailer documentation for details.
$mailer->Sender = 'bbaggins@example.com';
$mailer->AddReplyTo('bbaggins@example.com', 'Bilbo Baggins');
$mailer->SetFrom('bbaggins@example.com', 'Bilbo Baggins');
$mailer->AddAddress('gandalf@example.com');
$mailer->Subject = 'The finest weed in the South Farthing';
$mailer->MsgHTML('<p>You really must try it, Gandalf!</p><p>-Bilbo</p>');
// Set up our connection information.
$mailer->IsSMTP();
$mailer->SMTPAuth = true;
$mailer->SMTPSecure = 'ssl';
$mailer->Port = 465;
$mailer->Host = 'my smpt host';
$mailer->Username = 'my smtp username';
$mailer->Password = 'my smtp password';
// All done!
$mailer->Send();
?>
验证邮件地址
使用filter_var()函数
Web应用可能需要做的一件常见任务是检测用户是否输入了一个有效的邮件地址。毫无疑问
你可以在网上找到一些声称可以解决该问题的复杂的正则表达式,但是最简单的方法是使用
PHP的内建filter_var()
函数。
示例
<?php
filter_var('sgamgee@example.com', FILTER_VALIDATE_EMAIL);
//Returns "sgamgee@example.com". This is a valid email address.
filter_var('sauron@mordor', FILTER_VALIDATE_EMAIL);
// Returns boolean false! This is *not* a valid email address.
?>
进一步阅读
净化HTML输入和输出
对于简单的数据净化,使用htmlentities()函数, 复杂的数据净化则使用HTML Purifier库
经HTML Purifier 4.4.0测试
在任何web应用中展示用户输出时,首先对其进行“净化”去除任何潜在危险的HTML是非常必要的。 一个恶意的用户可以制作某些HTML,若被你的web应用直接输出,对查看它的人来说会很危险。
虽然可以尝试使用正则表达式来净化HTML,但不要这样做。HTML是一种复杂的语言,试图 使用正则表达式来净化HTML几乎总是失败的。
你可能会找到建议你使用strip_tags()
函数的观点。虽然strip_tags()从技术上来说是安全的,但如果输入的不合法的HTML(比如,
没有结束标签),它就成了一个“愚蠢”的函数,可能会去除比你期望的更多的内容。由于非技术用户
在通信中经常使用<
和>
字符,strip_tags()
也就不是一个好的选择了。
如果阅读了验证邮件地址一节, 你也许也会考虑使用filter_var() 函数。然而filter_var()函数在遇到断行时会出现问题, 并且需要不直观的配置以接近htmlentities()函数的效果, 因此也不是一个好的选择。
对于简单需求的净化
如果你的web应用仅需要完全地转义(因此可以无害地呈现,但不是完全去除)HTML,则使用 PHP的内建htmlentities()函数。 这个函数要比HTML Purifier快得多,因此它不对HTML做任何验证---仅转义所有东西。
htmlentities()不同于类似功能的函数htmlspecialchars(), 它会编码所有适用的HTML实体,而不仅仅是一个小的子集。
示例
<?php
// Oh no! The user has submitted malicious HTML, and we have to display it in our web app!
$evilHtml = '<div onclick="xss();">Mua-ha-ha! Twiddling my evil mustache...</div>';
// Use the ENT_QUOTES flag to make sure both single and double quotes are escaped.
// Use the UTF-8 character encoding if you've stored the text as UTF-8 (as you should have).
// See the UTF-8 section in this document for more details.
$safeHtml = htmlentities($evilHtml, ENT_QUOTES, 'UTF-8');
// $safeHtml is now fully escaped HTML. You can output $safeHtml to your users without fear!
?>
对于复杂需求的净化
对于很多web应用来说,简单地转义HTML是不够的。你可能想完全去除任何HTML,或者允许 一小部分子集的HTML存在。若是如此,则使用HTML Purifier 库。
HTML Purifier是一个经过充分测试但效率比较低的库。这就是为什么如果你的需求并不复杂 就应使用htmlentities(),因为 它的效率要快得多。
HTML Purifier相比strip_tags() 是有优势的,因为它在净化HTML之前会对其校验。这意味着如果用户输入无效HTML,HTML Purifier相比strip_tags()更能保留HTML的原意。HTML Purifier高度可定制,允许你为HTML的一个子集建立白名单来允许这个HTML子集的实体存在 输出中。
但其缺点就是相当的慢,它要求一些设置,在一个共享主机的环境里可能是不可行的。其文档 通常也复杂而不易理解。以下示例是一个基本的使用配置。查看文档 阅读HTML Purifier提供的更多更高级的特性。
示例
<?php
// Include the HTML Purifier library
require_once('htmlpurifier-4.4.0/HTMLPurifier.auto.php');
// Oh no! The user has submitted malicious HTML, and we have to display it in our web app!
$evilHtml = '<div onclick="xss();">Mua-ha-ha! Twiddling my evil mustache...</div>';
// Set up the HTML Purifier object with the default configuration.
$purifier = new HTMLPurifier(HTMLPurifier_Config::createDefault());
$safeHtml = $purifier->purify($evilHtml);
// $safeHtml is now sanitized. You can output $safeHtml to your users without fear!
?>
陷阱
- 以错误的字符编码使用htmlentities()会造成意想不到的输出。在调用该函数时始终确认 指定了一种字符编码,并且该编码与将被净化的字符串的编码相匹配。更多细节请查看 UTF-8一节。
- 使用htmlentities()时,始终包含ENT_QUOTES和字符编码参数。默认情况下,htmlentities() 不会对单引号编码。多愚蠢的默认做法!
- HTML Purifier对于复杂的HTML效率极其的低。可以考虑设置一个缓存方案如APC来保存经过净化的结果 以备后用。
进一步阅读
- PHP HTML净化工具对比
- Stack Overflow: 使用strip_tags()来防止XSS?
- Stack Overflow: PHP中净化用户输入的最佳方法是什么?
- Stack Overflow: 断行时的FILTER_SANITIZE_SPECIAL_CHARS问题
PHP与UTF-8
没有一行式解决方案。小心、注意细节,以及一致性。
PHP中的UTF-8糟透了。原谅我的用词。
目前PHP在低层次上还不支持Unicode。有几种方式可以确保UTF-8字符串能够被正确处理, 但并不容易,需要深入到web应用的所有层面,从HTML,到SQL,到PHP。我们旨在提供一个简洁、 实用的概述。
PHP层面的UTF-8
基本的字符串操作,如串接
两个字符串、将字符串赋给变量,并不需要任何针对UTF-8的特殊东西。然而,多数
字符串函数,如strpos()
和strlen,就需要特殊的考虑。这些
函数都有一个对应的mb_*
函数:例如,mb_strpos()
和mb_strlen()。这些对应的函数
统称为多字节字符串函数。这些多字节字符串
函数是专门为操作Unicode字符串而设计的。
当你操作Unicode字符串时,必须使用mb_*
函数。例如,如果你使用substr()
操作一个UTF-8字符串,其结果就很可能包含一些乱码。正确的函数应该是对应的多字节函数,
mb_substr()。
难的是始终记得使用mb_*
函数。即使你仅一次忘了,你的Unicode字符串在接下来的处理中
就可能产生乱码。
并不是所有的字符串函数都有一个对应的mb_*
。如果不存在你想要的那一个,那你就只能
自认倒霉了。
此外,在每个PHP脚本的顶部(或者在全局包含脚本的顶部)你都应使用 mb_internal_encoding 函数,如果你的脚本会输出到浏览器,那么还得紧跟其后加个mb_http_output() 函数。在每个脚本中显式地定义字符串的编码在以后能为你减少很多令人头疼的事情。
最后,许多操作字符串的PHP函数都有一个可选参数让你指定字符编码。若有该选项, 你应 始终显式地指明UTF-8编码。例如,htmlentities() 就有一个字符编码方式选项,在处理这样的字符串时应始终指定UTF-8。
MySQL层面的UTF-8
如果你的PHP脚本会访问MySQL,即使你遵从了前述的注意事项,你的字符串也有可能在数据库 中存储为非UTF-8字符串。
确保从PHP到MySQL的字符串为UTF-8编码的,确保你的数据库以及数据表均设置为utf8mb4字符集, 并且在你的数据库中执行任何其他查询之前先执行MySQL查询`set names utf8mb4`。这是至关重要的。示例 请查看连接并查询MySQL数据库一节内容。
注意你必须使用`utf8mb4`字符集来获得完整的UTF-8支持,而不是`utf8`字符集!原因 请查看进一步阅读。
浏览器层面的UTF-8
使用mb_http_output()函数
来确保你的PHP脚本输出UTF-8字符串到浏览器。并且在HTML页面的<head>
标签块中包含
字符集<meta>
标签块。
示例
<?php
// Tell PHP that we're using UTF-8 strings until the end of the script
mb_internal_encoding('UTF-8');
// Tell PHP that we'll be outputting UTF-8 to the browser
mb_http_output('UTF-8');
// Our UTF-8 test string
$string = 'Aš galiu valgyti stiklą ir jis manęs nežeidžia';
// Transform the string in some way with a multibyte function
$string = mb_substr($string, 0, 10);
// Connect to a database to store the transformed string
// See the PDO example in this document for more information
// Note the `set names utf8mb4` commmand!
$link = new \PDO( 'mysql:host=your-hostname;dbname=your-db',
'your-username',
'your-password',
array(
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_PERSISTENT => false,
\PDO::MYSQL_ATTR_INIT_COMMAND => 'set names utf8mb4'
)
);
// Store our transformed string as UTF-8 in our database
// Assume our DB and tables are in the utf8mb4 character set and collation
$handle = $link->prepare('insert into Sentences (Id, Body) values (?, ?)');
$handle->bindValue(1, 1, PDO::PARAM_INT);
$handle->bindValue(2, $string);
$handle->execute();
// Retrieve the string we just stored to prove it was stored correctly
$handle = $link->prepare('select * from Sentences where Id = ?');
$handle->bindValue(1, 1, PDO::PARAM_INT);
$handle->execute();
// Store the result into an object that we'll output later in our HTML
$result = $handle->fetchAll(\PDO::FETCH_OBJ);
?><!doctype html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>UTF-8 test page</title>
</head>
<body>
<?php
foreach($result as $row){
print($row->Body); // This should correctly output our transformed UTF-8 string to the browser
}
?>
</body>
</html>
进一步阅读
- PHP手册:多字节字符串函数
- PHP UTF-8备忘单
- Stack Overflow: 什么因素致使PHP不兼容Unicode?
- Stack Overflow: PHP与MySQL之间国际化字符串的最佳实践
- 怎样在MySQL数据库中完整支持Unicode
处理日期和时间
使用DateTime类。
在PHP糟糕的老时光里,我们必须使用date(), gmdate(), date_timezone_set(), strtotime()等等令人迷惑的 组合来处理日期和时间。悲哀的是现在你仍旧会找到很多在线教程在讲述这些不易使用的老式函数。
幸运的是,我们正在讨论的PHP版本包含友好得多的DateTime类。 该类封装了老式日期函数所有功能,甚至更多,在一个易于使用的类中,并且使得时区转换更加容易。 在PHP中始终使用DateTime类来创建,比较,改变以及展示日期。
示例
<?php
// Construct a new UTC date. Always specify UTC unless you really know what you're doing!
$date = new DateTime('2011-05-04 05:00:00', new DateTimeZone('UTC'));
// Add ten days to our initial date
$date->add(new DateInterval('P10D'));
echo($date->format('Y-m-d h:i:s')); // 2011-05-14 05:00:00
// Sadly we don't have a Middle Earth timezone
// Convert our UTC date to the PST (or PDT, depending) time zone
$date->setTimezone(new DateTimeZone('America/Los_Angeles'));
// Note that if you run this line yourself, it might differ by an hour depending on daylight savings
echo($date->format('Y-m-d h:i:s')); // 2011-05-13 10:00:00
$later = new DateTime('2012-05-20', new DateTimeZone('UTC'));
// Compare two dates
if($date < $later)
echo('Yup, you can compare dates using these easy operators!');
// Find the difference between two dates
$difference = $date->diff($later);
echo('The 2nd date is ' . $difference['days'] . ' later than 1st date.');
?>
陷阱
- 如果你不指定一个时区,DateTime::__construct() 就会将生成日期的时区设置为正在运行的计算机的时区。之后,这会导致大量令人头疼的事情。 在创建新日期时始终指定UTC时区,除非你确实清楚自己在做的事情。
- 如果你在DateTime::__construct()中使用Unix时间戳,那么时区将始终设置为UTC而不管 第二个参数你指定了什么。
- 向DateTime::__construct()传递零值日期(如:“0000-00-00”,常见MySQL生成该值作为 DateTime类型数据列的默认值)会产生一个无意义的日期,而不是“0000-00-00”。
- 在32位系统上使用DateTime::getTimestamp() 不会产生代表2038年之后日期的时间戳。64位系统则没有问题。
进一步阅读
检测一个值是否为null或false
使用===操作符来检测null和布尔false值。
PHP宽松的类型系统提供了许多不同的方法来检测一个变量的值。然而这也造成了很多问题。
使用==
来检测一个值是否为null或false,如果该值实际上是一个空字符串或0,也会误报
为false。isset是检测一个变量是否有值,
而不是检测该值是否为null或false,因此在这里使用是不恰当的。
is_null()函数能准确地检测一个值
是否为null,is_bool可以检测一个值
是否是布尔值(比如false),但存在一个更好的选择:===
操作符。===
检测两个值是否同一,
这不同于PHP宽松类型世界里的相等。它也比is_null()和is_bool()要快一些,并且有些人
认为这比使用函数来做比较更干净些。
示例
<?php
$x = 0;
$y = null;
// Is $x null?
if($x == null)
print('Oops! $x is 0, not null!');
// Is $y null?
if(is_null($y))
print('Great, but could be faster.');
if($y === null)
print('Perfect!');
// Does the string abc contain the character a?
if(strpos('abc', 'a'))
// GOTCHA! strpos returns 0, indicating it wishes to return the position of the first character.
// But PHP interpretes 0 as false, so we never reach this print statement!
print('Found it!');
//Solution: use !== (the opposite of ===) to see if strpos() returns 0, or boolean false.
if(strpos('abc', 'a') !== false)
print('Found it for real this time!');
?>
陷阱
- 测试一个返回0或布尔false的函数的返回值时,如strpos(),始终使用
===
和!==
,否则 你就会碰到问题。
进一步阅读
建议与指正
感谢阅读!如果你有些地方还不太理解,很正常,PHP是复杂的,并且充斥着陷阱。因为我也 只是一个人,所以本文档中难免存在错误。
如果你想为本文档贡献建议或纠正错误之处,请使用最后修订日期&维护者 一节中的信息联系我。