PHP MySQL 入门教程(六)
十五、处理文件上传
大多数人都知道 Web 的 HTTP 协议主要涉及从服务器到用户浏览器的网页传输。然而,实际上可以通过 HTTP 传输任何类型的文件,包括图像、Microsoft Office 文档、pdf、可执行文件、MPEGs、ZIP 文件和各种其他文件类型。尽管 FTP 在历史上一直是将文件上传到服务器的标准方式,但通过基于 web 的界面进行文件传输正变得越来越普遍。在这一章中,你将了解 PHP 的文件上传处理能力,包括以下主题:
-
PHP 的文件上传配置指令
-
PHP 的
$_FILES超全局数组,用于处理文件上传数据 -
PHP 内置的文件上传函数:
is_uploaded_file()和move_uploaded_file() -
查看上传脚本返回的可能错误消息
本章提供了几个真实世界的例子,为您提供了关于这个主题的适用见解。
通过 HTTP 上传文件
1995 年 11 月,当施乐公司的 Ernesto Nebel 和 Larry Masinter 在 RFC 1867 中提出了这样做的标准化方法时,通过 web 浏览器上传文件的方式被正式确定下来,“以 HTML(https://www.ietf.org/rfc/rfc1867.txt)形式上传文件”。这份备忘录为对 HTML 进行必要的添加以允许文件上传奠定了基础(随后并入 HTML 3.0),也为一种新的互联网媒体类型multipart/form-data提供了规范。这种新的媒体类型很受欢迎,因为用于编码“普通”表单值的标准类型application/x-www-form-urlencoded被认为效率太低,无法处理可能通过这种表单界面上传的大量二进制数据。下面是一个文件上传表单的例子,相应的输出截图如图 15-1 所示:
图 15-1
包含文件输入类型标签的 HTML 表单
<form action="uploadmanager.html" enctype="multipart/form-data" method="post">
<label form="name">Name:</label><br>
<input type="text" name="name" value=""><br>
<label form="email">Email:</label><br>
<input type="text" name="email" value=""><br>
<label form="homework">Class notes:</label>
<input type="file" name="homework" value=""><br>
<input type="submit" name="submit" value="Submit Homework">
</form>
理解这种形式只能提供部分期望的结果;虽然file输入类型和其他与上传相关的属性标准化了文件通过 HTML 页面发送到服务器的方式,但是没有任何功能可以确定文件到达服务器后会发生什么。上传文件的接收和后续处理是上传处理器的一个功能,使用一些服务器进程或有能力的服务器端语言(如 Perl、Java 或 PHP)创建。本章的其余部分将专门介绍上传过程的这一方面。
用 PHP 上传文件
通过 PHP 成功地管理文件上传是各种配置指令、$_FILES超全局和适当编码的 web 表单之间合作的结果。在接下来的几节中,将介绍所有这三个主题,并以一些示例结束。
PHP 的文件上传/资源指令
有几个配置指令可以用来微调 PHP 的文件上传功能。这些指令决定是否启用 PHP 的文件上传支持,以及允许的最大可上传文件大小、允许的最大脚本内存分配和各种其他重要的资源基准。
file_uploads = 开|关
范围:PHP_INI_SYSTEM;默认值:On
file_uploads指令决定服务器上的 PHP 脚本是否可以接受文件上传。
max_input_time = 整数
范围:PHP_INI_ALL;默认值:-1
指令决定了 PHP 脚本在注册致命错误之前尝试解析输入的最大时间,以秒为单位。如果时间是从开始执行开始计算,而不是从输入可用的时间开始计算,则默认值-1 表示时间不受限制。这是相关的,因为特别大的文件可能需要一些时间来上传,超过了该指令设置的时间限制。请注意,如果您创建了处理大型文档或高分辨率照片的上传功能,您可能需要相应地增加该指令设置的限制。
max_file_uploads = 整数
范围:PHP_INI_SYSTEM;默认值:20
max_file_uploads指令设置了可以同时上传的文件数量上限。
memory_limit = 整数
范围:PHP_INI_ALL;默认值:16M
memory_limit指令以兆字节为单位设置了一个脚本可以分配的最大允许内存量(该值以字节为单位提供,但是您可以通过添加 k、M 或 G 来表示千字节、兆字节和千兆字节。)当你在上传文件的时候,PHP 会分配内存来保存 POST 数据的内容。内存限制应设置为大于post_max_size的值。使用它来防止失控的脚本独占服务器内存,甚至在某些情况下使服务器崩溃。
post_max_size = 整数
范围:PHP_INI_PERDIR;默认值:8M
post_max_size对通过 POST 方法提交的数据大小设置了上限。因为文件是使用 POST 上传的,所以在处理较大的文件时,您可能需要使用upload_max_filesize向上调整该设置。这个post_max_size至少应该和upload_max_filesize一样大。
上传 _ 最大文件大小= 整数
范围:PHP_INI_PERDIR;默认值:2M
upload_max_filesize指令决定上传文件的最大大小。此限制适用于单个文件。如果您通过单个 post 请求上传多个文件,此值将设置每个文件的最大大小。这个指令应该比post_max_size小,因为它只适用于通过file输入类型传递的信息,而不是通过 POST 实例传递的所有信息。比如memory_limit。
上传 _ 临时目录= 字符串
范围:PHP_INI_SYSTEM;默认值:NULL
因为上传的文件必须在对该文件的后续处理可以开始之前成功地传输到服务器,所以必须为这些文件指定一个分类暂存区,在将它们移动到最终位置之前,可以将它们临时放置在该暂存区中。这个暂存位置是使用upload_tmp_dir指令指定的。例如,假设您想将上传的文件临时存储在/tmp/phpuploads/目录中。您将使用以下内容:
upload_tmp_dir = "/tmp/phpuploads/"
请记住,该目录必须是拥有服务器进程的用户可写的。因此,如果用户nobody拥有 Apache 进程,那么用户nobody应该成为临时上传目录的所有者或者拥有该目录的组的成员。如果不这样做,用户nobody将无法将文件写入目录(除非对目录分配了全局写入权限)。如果upload_tmp_dir未定义或设置为空,将使用系统定义的 tmp 目录。在大多数 Linux 系统上,这将是/tmp。
$_ FILES 数组
超级全局存储了与通过 PHP 脚本上传到服务器的文件相关的各种信息。在这个数组中总共有五个可用的项目,这里将对每个项目进行介绍。
注意
本节介绍的每个数组元素都引用了用户文件。该术语只是分配给文件上传表单元素的名称的占位符,与用户硬盘上的文件名无关。您可能会根据您选择的名称分配来更改此名称。
-
这个数组值提供了与上传结果相关的重要信息。总共有五个可能的返回值:一个表示成功的结果,另外四个表示尝试中出现的特定错误。“上传错误消息”一节介绍了每个返回值的名称和含义。
-
$_FILES['userfile']['name']:该变量指定了文件的原始名称,包括在客户端机器上声明的扩展名。因此,如果你浏览到一个名为vacation.png的文件并通过表单上传,这个变量将被赋值为vacation.png。 -
$_FILES['userfile']['size']:该变量指定从客户端机器上传的文件的大小,以字节为单位。例如,在vacation.png文件的情况下,这个变量可能被赋予一个值,比如 5253,或者大约 5KB。 -
$_FILES['userfile']['tmp_name']:该变量指定文件上传到服务器后分配给文件的临时名称。当文件保存到临时目录(由 PHP 指令upload_tmp_dir指定)时,这个值由 PHP 自动生成。 -
$_FILES['userfile']['type']:该变量指定从客户端机器上传的文件的 MIME 类型。因此,在vacation.png图像文件的情况下,这个变量将被赋予值image/png。如果上传了 PDF,将分配值application/pdf。因为这个变量有时会产生意想不到的结果,所以您应该在脚本中明确地验证它。
PHP 的文件上传功能
除了通过 PHP 的文件系统库提供的大量文件处理函数(更多信息见第十章),PHP 还提供了两个专门用于帮助文件上传过程的函数,is_uploaded_file()和move_uploaded_file()。
确定文件是否已上传
is_uploaded_file()函数确定输入参数filename指定的文件是否使用POST方法上传。其原型如下:
boolean is_uploaded_file(string filename)
此功能旨在防止潜在的攻击者通过所讨论的脚本操纵不用于交互的文件。该函数检查文件是否是通过 HTTP POST 上传的,而不仅仅是系统上的任何文件。以下示例显示了在将上传的文件移动到其最终位置之前,如何进行简单的检查。
<?php
if (is_uploaded_file($_FILES['classnotes']['tmp_name'])) {
copy($_FILES['classnotes']['tmp_name'],
"/www/htdocs/classnotes/".$_FILES['classnotes']['name']);
} else {
echo "<p>Potential script abuse attempt detected.</p>";
}
?>
移动上传的文件
move_uploaded_file()功能提供了一种将上传文件从临时目录移动到最终位置的便捷方法。其原型如下:
boolean move_uploaded_file(string filename, string destination)
虽然copy()工作得同样好,但是move_uploaded_file()提供了一个额外的特性:它将检查以确保由filename输入参数表示的文件实际上是通过 PHP 的 HTTP POST上传机制上传的。如果文件尚未上传,移动将失败,并将返回一个假值。因此,你可以放弃使用is_uploaded_file()作为使用move_uploaded_file()的先决条件。
使用move_uploaded_file()很简单。考虑这样一个场景,您希望将上传的课堂笔记文件移动到目录/www/htdocs/classnotes/中,同时保留客户端上指定的文件名:
move_uploaded_file($_FILES['classnotes']['tmp_name'],
"/www/htdocs/classnotes/".$_FILES['classnotes']['name']);
当然,在文件被移动后,您可以将它重命名为您想要的任何名称。但是,在第一个(源)参数中正确引用文件的临时名称是很重要的。
上传错误消息
像任何其他涉及用户交互的应用组件一样,您需要一种方法来评估结果,成功与否。你如何确定文件上传过程是成功的?如果在上传过程中出现问题,您如何知道是什么导致了错误?令人高兴的是,在$_FILES['userfile']['error']中提供了足够的信息来确定结果(以及错误的原因):
-
UPLOAD_ERR_OK:如果上传成功,则返回值0。 -
UPLOAD_ERR_INI_SIZE:如果试图上传的文件大小超过了upload_max_filesize指令指定的值,则返回值1。 -
UPLOAD_ERR_FORM_SIZE:如果试图上传一个文件,其大小超过了max_file_size指令的值,则返回值2,该指令可以嵌入到 HTML 表单中
注意
因为max_file_size指令嵌入在 HTML 表单中,所以它很容易被有野心的攻击者修改。因此,总是使用 PHP 的服务器端设置(upload_max_filesize、post_max_filesize)来确保不会超过这样的预定绝对值。
-
UPLOAD_ERR_PARTIAL:如果文件没有完全上传,则返回值3。如果网络错误导致上传过程中断,可能会发生这种情况。 -
UPLOAD_ERR_NO_FILE:如果用户提交表单时没有指定上传文件,则返回值4。 -
UPLOAD_ERR_NO_TMP_DIR:如果临时目录不存在,则返回值 6。 -
UPLOAD_ERR_CANT_WRITE:如果文件无法写入磁盘,则返回值 7。 -
UPLOAD_ERR_EXTENSION:如果某个已安装的 PHP 扩展导致上传停止,则返回值 8。
一个简单的例子
清单 15-1 ( uploadmanager.php)实现了贯穿本章的课堂笔记示例。为了形式化这个场景,假设一位教授邀请学生在他的网站上发布课堂笔记,这个想法是每个人都可以从这样的合作努力中获得一些东西。当然,尽管如此,还是应该在应该得到学分的地方给予学分,所以每个上传的文件都应该重新命名,以包括学生的姓氏。此外,只接受 PDF 文件。
<form action="listing15-1.php" enctype="multipart/form-data" method="post">
<label form="email">Email:</label><br>
<input type="text" name="email" value=""><br>
<label form="lastname">Last Name:</label><br>
<input type="text" name="lastname" value=""><br>
<label form="classnotes">Class notes:</label><br>
<input type="file" name="classnotes" value=""><br>
<input type="submit" name="submit" value="Submit Notes">
</form>
<?php
// Set a constant
define ("FILEREPOSITORY","/var/www/5e/15/classnotes");
// Make sure that the file was POSTed.
If ($_FILES['classnotes']['error'] == UPLOAD_ERR_OK) {
if (is_uploaded_file($_FILES['classnotes']['tmp_name'])) {
// Was the file a PDF?
if ($_FILES['classnotes']['type'] != "application/pdf") {
echo "<p>Class notes must be uploaded in PDF format.</p>";
} else {
// Move uploaded file to final destination.
$result = move_uploaded_file($_FILES['classnotes']['tmp_name'],
FILEREPOSITORY . $_POST['lastname'] . '_' . $_FILES['classnotes']['name']);
if ($result == 1) echo "<p>File successfully uploaded.</p>";
else echo "<p>There was a problem uploading the file.</p>";
}
}
}
else {
echo "<p>There was a problem with the upload. Error code {$_FILES['classnotes']['error']}</p>”;
}
?>
Listing 15-1A Simple File-Upload Example
警告
请记住,文件是在 web 服务器守护进程所有者的伪装下上传和移动的。如果没有为该用户的临时上传目录和最终目录目标分配足够的权限,将导致无法正确执行文件上传过程。
尽管手动创建自己的文件上传机制很容易,但是HTTP_Upload PEAR 包确实让这项任务变得很简单。
摘要
通过网络传输文件消除了防火墙、FTP 服务器和客户端带来的诸多不便。不需要额外的应用,安全性可以在 web 应用中进行管理。它还增强了应用轻松操作和发布非传统文件的能力。在这一章中,你知道了在你的 PHP 应用中添加这样的功能是多么容易。除了提供 PHP 文件上传特性的全面概述之外,还讨论了几个实际例子。
下一章将详细介绍通过会话处理跟踪用户这一非常有用的 Web 开发主题。
十六、网络编程
你可能会翻到这一章,想知道 PHP 在网络方面能提供什么。毕竟,网络任务不是很大程度上属于系统管理常用的语言吗,比如 Perl 或 Python?虽然这种刻板印象可能曾经描绘了一幅相当准确的画面,但如今,将网络功能整合到 web 应用中已经司空见惯。事实上,基于 web 的应用经常被用来监控甚至维护网络基础设施。此外,使用 PHP 的命令行版本。使用最喜欢的语言和所有可用的库来编写系统管理的高级脚本是非常容易的。PHP 开发人员总是热衷于承认不断增长的用户需求,他们已经集成了一系列令人印象深刻的特定于网络的功能。
本章分为几节,涵盖以下主题:
-
DNS、服务器和服务 : PHP 提供了多种功能,能够检索关于网络内部、DNS、协议和互联网寻址方案的信息。本节将介绍这些函数,并提供几个使用示例。
-
用 PHP 发送电子邮件:通过 web 应用发送电子邮件无疑是目前你能找到的最常见的功能之一,而且理由充分。电子邮件仍然是互联网的杀手级应用,为交流和维护重要的数据和信息提供了一种非常有效的手段。本节解释了如何通过 PHP 脚本轻松发送消息。此外,您将了解如何使用 PHPMailer 库来简化更复杂的电子邮件发送,例如涉及多个收件人、HTML 格式和包含附件的发送。
-
常见网络任务:在本节中,您将学习如何使用 PHP 来模拟通常由命令行工具执行的任务,包括 ping 网络地址、跟踪网络连接、扫描服务器的开放端口等等。
DNS、服务和服务器
如今,对网络问题进行调查或故障排除通常需要收集与受影响的客户端、服务器和网络内部相关的各种信息,如协议、域名解析和 IP 寻址方案。PHP 提供了许多函数来检索关于每个主题的大量信息,本节将介绍每一个函数。
域名服务器(Domain Name Server)
域名系统(DNS)允许您使用域名(如 example.com)代替相应的 IP 地址,如 192.0.34.166。域名及其互补的 IP 地址存储在散布在全球各地的域名服务器上。通常,一个域有多种类型的相关记录,一种将 IP 地址映射到该域的特定主机名,另一种用于定向电子邮件,还有一种用于域名别名。网络管理员和开发人员经常需要了解给定域的各种 DNS 记录。本节介绍了许多标准的 PHP 函数,这些函数能够挖掘大量关于 DNS 记录的信息。
检查 DNS 记录是否存在
checkdnsrr()函数检查 DNS 记录的存在。其原型如下:
int checkdnsrr(string host [, string type])
根据提供的host值和可选的 DNS 资源记录type检查 DNS 记录,如果找到任何记录,则返回TRUE,否则返回FALSE。可能的记录类型包括以下几种:
-
A : IPv4 地址记录。负责主机名到 IPv4 地址的转换。
-
AAAA : IPv6 地址记录。负责主机名到 IPv6 地址的转换。
-
A6 : IPv6 地址记录。用于表示 IPv6 地址。旨在取代目前用于 IPv6 映射的 AAAA 记录。
-
ANY :查找任何类型的记录。
-
CNAME :规范名称记录。将别名映射到真实域名。
-
MX :邮件交换记录。确定主机邮件服务器的名称和相对首选项。这是默认设置。
-
NAPTR :命名机构指针。允许不符合 DNS 的名称,使用正则表达式重写规则将它们解析到新域。例如,NAPTR 可能用于维护遗留(前 DNS)服务。
-
NS :名称服务器记录。确定主机的名称服务器。
-
PTR :指针记录。将 IP 地址映射到主机。
-
SOA :权限记录开始。为主机设置全局参数。
-
SRV :服务记录。表示所提供域的各种服务的位置。
-
TXT :文本记录。存储有关主机的其他未格式化信息,如 SPF 记录。
举个例子。假设您想要验证域名 example.com 是否有相应的 DNS 记录:
<?php
$domain = "example.com";
$recordexists = checkdnsrr($domain, "ANY");
if ($recordexists)
echo "The domain '$domain' has a DNS record!";
else
echo "The domain '$domain' does not appear to have a DNS record!";
?>
这将返回以下内容:
The domain 'example.com' exists
您还可以使用此函数来验证所提供的邮件地址的域是否存在:
<?php
$email = "ceo@example.com";
$domain = explode("@",$email);
$valid = checkdnsrr($domain[1], "MX");
if($valid)
echo "The domain has an MX record!";
else
echo "Cannot locate MX record for $domain[1]!";
?>
这将返回以下内容:
Cannot locate MX record for example.com!
将记录类型更改为“A”将导致脚本返回有效的响应。这是因为 example.com 域有一个有效的 A 记录,但没有有效的 MX(邮件交换)记录。请记住,这不是请求验证 MX 记录的存在。有时,网络管理员采用其他配置方法来允许在不使用 MX 记录的情况下进行邮件解析(因为 MX 记录不是强制性的)。为了谨慎起见,只需检查域是否存在,而不需要特别要求验证 MX 记录是否存在。
此外,这并不能验证电子邮件地址是否真的存在。做出这一决定的唯一确定方法是向用户发送一封电子邮件,并要求他通过单击一次性 URL 来验证地址。你可以在第十四章中了解更多关于一次性 URL 的信息。
正在检索 DNS 资源记录
函数的作用是:返回一个数组,该数组包含与特定域相关的各种 DNS 资源记录。其原型如下:
array dns_get_record(string hostname [, int type [, array &authns, array &addtl]])
默认情况下,dns_get_record()返回它可以找到的特定于所提供的域的所有记录(hostname);但是,您可以通过指定类型来简化检索过程,类型的名称必须以 DNS 开头。该功能支持与checkdnsrr()一起介绍的所有类型,以及稍后将介绍的其他类型。最后,如果您正在寻找这个主机名的 DNS 描述的完整描述,您可以通过引用传递authns和addtl参数,它们指定与权威名称服务器和附加记录相关的信息也应该被返回。
假设提供的hostname有效并且存在,对dns_get_record()的调用至少返回四个属性:
-
host:指定所有其他属性对应的 DNS 名称空间的名称。 -
class:仅返回类 Internet 的记录,因此该属性始终为IN。 -
type:决定记录类型。根据返回的类型,其他属性也可能可用。 -
ttl:计算记录的原始生存时间减去自查询权威名称服务器以来经过的时间。
除了在checkdnsrr()一节中介绍的类型外,dns_get_record() :还可以使用以下域记录类型
-
DNS_ALL:检索所有可用的记录,甚至是那些在使用特定操作系统的识别功能时可能无法识别的记录。当您希望绝对确定所有可用记录都已被检索时,请使用此选项。 -
DNS_ANY:检索特定操作系统识别的所有记录。 -
DNS_HINFO:指定主机的操作系统和计算机类型。请记住,这些信息不是必需的。 -
DNS_NS:确定名称服务器是否是给定域的权威答案,或者该职责是否最终委托给另一个服务器。
请记住,类型名必须总是以DNS_开头。例如,假设您想了解有关 example.com 域的更多信息:
<?php
$result = dns_get_record("example.com");
print_r($result);
?>
返回信息的示例如下:
Array
(
[0] => Array
(
[host] => example.com
[class] => IN
[ttl] => 3600
[type] => SOA
[mname] => sns.dns.icann.org
[rname] => noc.dns.icann.org
[serial] => 2018013021
[refresh] => 7200
[retry] => 3600
[expire] => 1209600
[minimum-ttl] => 3600
)
[1] => Array
(
[host] => example.com
[class] => IN
[ttl] => 25742
[type] => NS
[target] => a.iana-servers.net
)
[2] => Array
(
[host] => example.com
[class] => IN
[ttl] => 25742
[type] => NS
[target] => b.iana-servers.net
)
[3] => Array
(
[host] => example.com
[class] => IN
[ttl] => 25742
[type] => AAAA
[ipv6] => 2606:2800:220:1:248:1893:25c8:1946
)
[4] => Array
(
[host] => example.com
[class] => IN
[ttl] => 25742
[type] => A
[ip] => 93.184.216.34
)
[5] => Array
(
[host] => example.com
[class] => IN
[ttl] => 60
[type] => TXT
[txt] => v=spf1 -all
[entries] => Array
(
[0] => v=spf1 -all
)
)
[6] => Array
(
[host] => example.com
[class] => IN
[ttl] => 60
[type] => TXT
[txt] => $Id: example.com 4415 2015-08-24 20:12:23Z davids $
[entries] => Array
(
[0] => $Id: example.com 4415 2015-08-24 20:12:23Z davids $
)
)
)
如果您只对地址记录感兴趣,您可以执行以下命令:
<?php
$result = dns_get_record("example.com", DNS_A);
print_r($result);
?>
这将返回以下内容:
Array (
[0] => Array (
[host] => example.com
[type] => A
[ip] => 192.0.32.10
[class] => IN
[ttl] => 169679 )
)
正在检索 MX 记录
getmxrr()函数检索由hostname指定的域的 MX 记录。其原型如下:
boolean getmxrr(string hostname, array &mxhosts [, array &weight])
由hostname指定的主机的 MX 记录被添加到由mxhosts指定的数组中。如果提供了可选的输入参数weight,相应的权重值将放在那里;这些是指分配给记录所标识的每台服务器的命中率。下面是一个例子:
<?php
getmxrr("wjgilmore.com", $mxhosts);
print_r($mxhosts);
?>
这将返回以下输出:
Array ( [0] => aspmx.l.google.com)
服务
虽然我们经常在广义上使用互联网这个词,指的是聊天、阅读或下载某个游戏的最新版本,但我们实际上指的是一个或几个互联网服务,它们共同定义了这个交流平台。这些服务的例子包括 HTTP、HTTPS、FTP、POP3、IMAP 和 SSH。由于各种原因(这方面的解释超出了本书的范围),每个服务通常都在一个特定的通信端口上运行。例如,HTTP 的默认端口是 80,SSH 的默认端口是 22。如今,对网络各级防火墙的广泛需求使得这方面的知识变得非常重要。两个 PHP 函数,getservbyname()和getservbyport() ,可用于了解更多关于服务及其相应端口号的信息。
检索服务的端口号
函数的作用是:返回指定服务的端口号。其原型如下:
int getservbyname(string service, string protocol)
对应于service的服务必须使用与在/etc/services文件或 C:\ Windows \ System32 \ drivers \ etc(在 Windows 系统上)中找到的相同名称来指定。protocol参数指定您是指这个服务的tcp还是udp组件。考虑一个例子:
<?php
echo "HTTP's default port number is: ".getservbyname("http", "tcp");
?>
这将返回以下内容:
HTTP's default port number is: 80
检索端口号的服务名
getservbyport()函数返回与所提供的端口号相对应的服务名。其原型如下:
string getservbyport(int port, string protocol)
protocol参数指定您是指服务的tcp还是udp组件。考虑一个例子:
<?php
echo "Port 80's default service is: ".getservbyport(80, "tcp");
?>
这将返回以下内容:
Port 80's default service is: www
建立套接字连接
在今天的网络环境中,您经常想要查询本地和远程的服务。这通常是通过与该服务建立套接字连接来实现的。本节演示了如何使用fsockopen()功能来实现这一点。其原型如下:
resource fsockopen(string target, int port [, int errno [, string errstring
[, float timeout]]])
fsockopen()函数建立到端口上target指定的资源的连接,返回错误信息给可选参数errno和errstring。可选参数timeout设置了一个时间限制,以秒为单位,该函数在失败前将尝试建立连接多长时间。
第一个示例显示了如何使用fsockopen()建立到 www.example.com 的端口 80 连接,以及如何输出索引页面:
<?php
// Establish a port 80 connection with www.example.com
$http = fsockopen("www.example.com",80);
// Send a request to the server
$req = "GET / HTTP/1.1\r\n";
$req .= "Host: www.example.com\r\n";
$req .= "Connection: Close\r\n\r\n";
fputs($http, $req);
// Output the request results
while(!feof($http)) {
echo fgets($http, 1024);
}
// Close the connection
fclose($http);
?>
这将返回以下输出:
HTTP/1.1 200 OK
Cache-Control: max-age=604800
Content-Type: text/html
Date: Sun, 25 Feb 2018 23:12:08 GMT
Etag: "1541025663+gzip+ident"
Expires: Sun, 04 Mar 2018 23:12:08 GMT
Last-Modified: Fri, 09 Aug 2013 23:54:35 GMT
Server: ECS (sea/5557)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 1270
Connection: close
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 50px;
background-color: #fff;
border-radius: 1em;
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
body {
background-color: #fff;
}
div {
width: auto;
margin: 0 auto;
border-radius: 0;
padding: 1em;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is established to be used for illustrative examples in documents
. You may use this
domain in examples without prior coordination or asking for permission.</p>
<p><a href="http://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>
输出显示了来自服务器的完整响应(头和主体)。使用 PHP 通过基于 HTTP 的服务检索内容可以通过对file_get_contents() which only returns the body part的单个函数调用来完成,但是对于遵循 PHP 不知道的协议的其他服务,必须使用 socket 函数并手动构建支持,如上例所示。
第二个例子,如清单 16-1 所示,展示了如何使用fsockopen()构建一个基本的端口扫描器。
<?php
// Give the script enough time to complete the task
ini_set("max_execution_time", 120);
// Define scan range
$rangeStart = 0;
$rangeStop = 1024;
// Which server to scan?
$target = "localhost";
// Build an array of port values
$range =range($rangeStart, $rangeStop);
echo "<p>Scan results for $target</p>";
// Execute the scan
foreach ($range as $port) {
$result = @fsockopen($target, $port,$errno,$errstr,1);
if ($result) echo "<p>Socket open at port $port</p>";
}
?>
Listing 16-1Creating a Port Scanner with fsockopen()
使用此脚本扫描我的本地计算机会产生以下输出:
Scan results for localhost
Socket open at port 22
Socket open at port 80
Socket open at port 631
请注意,运行远程计算机的扫描很可能会导致防火墙阻止请求。
完成同样任务的一个更为懒惰的方法是使用一个程序执行命令,比如system()和精彩的免费软件包 Nmap ( https://nmap.org/ )。“常见网络任务”一节演示了这种方法。
邮件
PHP 强大的邮件功能非常有用,而且许多 web 应用都需要它,因此这一部分很可能是本章中最受欢迎的部分之一,如果不是整本书的话。在本节中,您将学习如何使用 PHP 流行的mail()函数发送电子邮件,包括如何控制标题、包含附件以及执行其他常见的任务。
本节介绍了相关的配置指令,描述了 PHP 的mail()函数,并以几个例子强调了该函数的多种用法。
配置指令
有五个配置指令与 PHP 的mail()函数相关。请密切注意这些描述,因为每个描述都是特定于平台的。
SMTP = 字符串
范围:PHP_INI_ALL;默认值:localhost
指令为 PHP 的 Windows 平台版本的邮件功能设置邮件传输代理(MTA)。请注意,这仅与 Windows 平台相关,因为该功能的 Unix 平台实现实际上只是该操作系统邮件功能的包装。相反,Windows 实现依赖于由该指令定义的到本地或远程 MTA 的套接字连接。
sendmail_from = 字符串
范围:PHP_INI_ALL;默认值:NULL
sendmail_from指令设置消息头的From字段和返回路径。
sendmail_path = 字符串
范围:PHP_INI_SYSTEM;默认值:默认的 sendmail 路径
如果 sendmail 二进制文件不在系统路径中,或者如果您想向二进制文件传递额外的参数,那么sendmail_path指令会设置它的路径。默认情况下,这被设置为以下内容:
sendmail -t -i
请记住,该指令仅适用于 Unix 平台。Windows 依赖于与 smtp_port 端口上的 SMTP 指令指定的 SMTP 服务器建立套接字连接。
smtp_port = 整数
范围:PHP_INI_ALL;默认值:25
ort 指令设置用于连接 SMTP 指令指定的服务器的端口。
mail . force _ extra _ parameters =string
范围:PHP_INI_SYSTEM;默认值:NULL
您可以使用mail.force_extra_parameters指令将附加标志传递给 sendmail 二进制文件。注意,这里传递的任何参数都将替换那些通过mail()函数的addl_params参数传递的参数。
使用 PHP 脚本发送电子邮件
使用mail()函数,可以通过 PHP 脚本以极其简单的方式发送电子邮件。其原型如下:
boolean mail(string to, string subject, string message [, string addl_headers [, string addl_params]])
mail()功能可以向一个或多个收件人发送带有主题和信息的电子邮件。您可以使用 addl_header s参数定制许多电子邮件属性;您甚至可以通过addl_params参数传递额外的标志来修改您的 SMTP 服务器的行为。请注意,该函数不验证 addl_headers 参数的内容。添加多个换行符会破坏电子邮件。确保只添加有效的标题。
在 Unix 平台上,PHP 的mail()函数依赖于 sendmail MTA。如果你使用另一种 MTA(例如 qmail),你需要使用 MTA 的 sendmail 包装器。PHP 的 Windows 函数实现依赖于建立一个套接字连接到一个由配置指令SMTP指定的 MTA,在前一节中已经介绍过。
本节的剩余部分将通过大量的例子来突出这个简单而强大的函数的许多功能。
发送纯文本电子邮件
使用mail()函数发送最简单的电子邮件很简单,除了第四个参数(允许您识别发件人)之外,只使用三个必需的参数就可以完成。这里有一个例子:
<?php
mail("test@example.com", "This is a subject", "This is the mail body",
"From:admin@example.com\r\n");
?>
请特别注意如何设置发件人地址,包括\r\n(回车加换行符)字符。忽略以这种方式格式化地址会产生意想不到的结果或导致功能完全失败。
利用 PHPMailer
虽然可以使用mail()函数来执行更复杂的操作,比如发送给多个收件人、用 HTML 格式的电子邮件骚扰用户或者包含附件,但是这样做可能是一个繁琐且容易出错的过程。然而,PHPMailer 库( https://github.com/PHPMailer/PHPMailer )让这样的任务变得轻而易举。
安装 PHPMailer
安装这个库很容易,它是通过前面描述的 composer 工具来完成的。将以下行添加到 composer.json 文件中,并在该目录中运行 composer update:
"phpmailer/phpmailer": "~6.0"
您也可以运行以下命令行来安装文件:
composer require phpmailer/phpmailer
这将在您的本地供应商文件夹中安装文件,并且这些文件可以使用。instalPackage 操作的输出:1 次安装,11 次更新,0 次删除
- Updating symfony/polyfill-mbstring (v1.6.0 => v1.7.0): Downloading (100%)
- Updating symfony/translation (v3.4.1 => v4.0.4): Downloading (100%)
- Updating php-http/discovery (1.3.0 => 1.4.0): Downloading (100%)
- Updating symfony/event-dispatcher (v2.8.32 => v2.8.34): Downloading (100%)
- Installing phpmailer/phpmailer (v6.0.3): Downloading (100%)
- Updating geoip/geoip dev-master (1f94041 => b82fe29): Checking out b82fe29281
- Updating nesbot/carbon dev-master (926aee5 => b1ab4a1): Checking out b1ab4a10fc
- Updating ezyang/htmlpurifier dev-master (5988f29 => c1167ed): Checking out c1167edbf1
- Updating guzzlehttp/guzzle dev-master (501c7c2 => 748d67e): Checking out 748d67e23a
- Updating paypal/rest-api-sdk-php dev-master (81c2c17 => 219390b): Checking out 219390b793
- Updating piwik/device-detector dev-master (caf2d15 => 319d108): Checking out 319d108899
- Updating twilio/sdk dev-master (e9bc80c => d33971d): Checking out d33971d26a
phpmailer/phpmailer suggests installing league/oauth2-google (Needed for Google XOAUTH2 authentication)
phpmailer/phpmailer suggests installing hayageek/oauth2-yahoo (Needed for Yahoo XOAUTH2 authentication)
phpmailer/phpmailer suggests installing stevenmaguire/oauth2-microsoft (Needed for Microsoft XOAUTH2 authentication)lation will look similar to this:
用 PHPMailer 发送电子邮件
使用 PHPMailer 类需要使用两个名称空间,然后包含作曲者的 autoload.php 脚本。任何使用此功能的脚本都应该在顶部包含以下几行:
<?php
// Import PHPMailer classes into the global namespace
// These must be at the top of your script, not inside a function
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
//Load composer's autoloader
require 'vendor/autoload.php';
发送电子邮件的过程从 PHPMailer 类的安装开始:
$mail = new PHPMailer(true); // True indicates that exceptions are used.
使用$mail 对象,您现在可以添加发件人地址、一个或多个收件人、指定 SMTP 主机。等等。
如果您的 web 服务器可以在未经身份验证的情况下访问本地主机上的 SMTP 服务器,您可以使用如下简单脚本来发送电子邮件:
<?php
// Import PHPMailer classes into the global namespace
// These must be at the top of your script, not inside a function
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
//Load composer's autoloader
require 'autoload.php';
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = "localhost";
$mail->setFrom('from@mywebsite.com', 'Web Site');
$mail->addAddress('user@customer.com');
$mail->Subject = 'Thank you for the order';
$mail->Body = "Your package will ship out asap!";
$mail->send();
?>
为了将邮件发送给多个收件人,您可以为每个收件人一直调用addAddress()方法。该对象还支持addCC()和addBCC()方法。
如果您的邮件服务器需要身份验证,您可以使用以下行来调整配置:
$mail->isSMTP(); // Set mailer to use SMTP
$mail->Host = 'smtp1.example.com;smtp2.example.com'; // Specify main and backup SMTP servers
$mail->SMTPAuth = true; // Enable SMTP authentication
$mail->Username = 'user@example.com'; // SMTP username
$mail->Password = 'secret'; // SMTP password
$mail->SMTPSecure = 'tls'; // Enable TLS encryption, `ssl` also accepted
$mail->Port = 587; // TCP port to connect to
到目前为止,电子邮件只包含纯文本。为了将它更改为包含 HTML 内容,您需要使用参数 true 调用 isHTML()方法。
$mail->isHTML(true);
请注意,可以为 Body 属性指定一个 HTML 字符串,最好也为 AltBody 属性指定一个值。如果电子邮件在不能呈现 HTML 消息的客户端中呈现,AltBody 属性将是用户将看到的版本。
最后,添加附件也很简单。方法addAttachment()使用完整路径的文件名,并将文件附加到消息中。多次调用addAttachment()将允许附加多个文件。请注意,一些邮件系统会限制电子邮件的总大小,甚至会过滤掉带有可执行文件或其他已知携带恶意软件的文件类型的电子邮件。包含用户可以下载文件的链接可能更简单。
常见网络任务
尽管各种命令行应用早已能够执行本节中演示的联网任务,但是提供一种通过 Web 执行这些任务的方法肯定是有用的。虽然命令行的功能更强大、更灵活,但是通过 Web 查看这些信息有时更方便。不管是什么原因,您都可以很好地利用本节中的一些应用。
注意
本节中的几个例子使用了system()函数。该功能在第十章中介绍。
Pinging 服务器
验证服务器的连通性是一项常见的管理任务。以下示例向您展示了如何使用 PHP 实现这一点:
<?php
// Which server to ping?
$server = "www.example.com";
// Ping the server how many times?
$count = 3;
// Perform the task
echo "<pre>";
system("ping -c {$count} {$server}");
echo "</pre>";
?>
前面的代码应该相当简单。在 ping 请求中使用固定数量的计数将导致 ping 命令在达到该数量时终止,然后输出将返回给 PHP 并传递回客户端。
示例输出如下:
PING www.example.com (93.184.216.34) 56(84) bytes of data.
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=60 time=0.798 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=2 ttl=60 time=0.846 ms
64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=3 ttl=60 time=0.828 ms
--- www.example.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2027ms
rtt min/avg/max/mdev = 0.798/0.824/0.846/0.019 ms
PHP 的程序执行功能非常强大,因为它们允许您利用安装在服务器上的任何程序,只要这些程序被分配了适当的权限。
创建端口扫描器
本章前面对fsockopen()的介绍伴随着如何创建端口扫描器的演示。然而,与本节中介绍的许多任务一样,使用 PHP 的一个程序执行函数可以更容易地完成这些任务。下面的例子使用了 PHP 的system()函数和 Nmap(网络映射器)工具:
<?php
$target = "localhost";
echo "<pre>";
system("nmap {$target}");
echo "</pre>";
?>
示例输出的一个片段如下:
Starting Nmap 6.40 ( http://nmap.org ) at 2018-02-25 19:00 PST
Nmap scan report for localhost (127.0.0.1)
Host is up (0.00042s latency).
Other addresses for localhost (not scanned): 127.0.0.1
Not shown: 991 closed ports
PORT STATE SERVICE
22/tcp open ssh
25/tcp open smtp
53/tcp open domain
80/tcp open http
443/tcp open https
3306/tcp open mysql
5432/tcp open postgresql
8080/tcp open http-proxy
9000/tcp open cslistener
Nmap done: 1 IP address (1 host up) scanned in 0.06 seconds
列出的端口号表示 web 服务器可以访问主机上的哪些内容。防火墙可能会阻止从互联网访问这些端口。
创建子网转换器
您可能曾经绞尽脑汁试图找出一些模糊的网络配置问题。最常见的是,这种灾难的罪魁祸首似乎集中在有故障或未插好的网络电缆上。第二个最常见的问题可能是在计算必要的基本网络要素时出现的错误:IP 地址、子网掩码、广播地址、网络地址等等。为了解决这个问题,可以引入一些 PHP 函数和位运算来帮你进行计算。当提供一个 IP 地址和一个位掩码时,清单 16-2 计算其中的几个部分。
<form action="listing16-2.php" method="post">
<p>
IP Address:<br />
<input type="text" name="ip[]" size="3" maxlength="3" value="" />.
<input type="text" name="ip[]" size="3" maxlength="3" value="" />.
<input type="text" name="ip[]" size="3" maxlength="3" value="" />.
<input type="text" name="ip[]" size="3" maxlength="3" value="" />
</p>
<p>
Subnet Mask:<br />
<input type="text" name="sm[]" size="3" maxlength="3" value="" />.
<input type="text" name="sm[]" size="3" maxlength="3" value="" />.
<input type="text" name="sm[]" size="3" maxlength="3" value="" />.
<input type="text" name="sm[]" size="3" maxlength="3" value="" />
</p>
<input type="submit" name="submit" value="Calculate" />
</form>
<?php
if (isset($_POST['submit'])) {
// Concatenate the IP form components and convert to IPv4 format
$ip = implode('.', $_POST['ip']);
$ip = ip2long($ip);
// Concatenate the netmask form components and convert to IPv4 format
$netmask = implode('.', $_POST['sm']);
$netmask = ip2long($netmask);
// Calculate the network address
$na = ($ip & $netmask);
// Calculate the broadcast address
$ba = $na | (~$netmask);
// Number of hosts
$h = ip2long(long2ip($ba)) - ip2long(long2ip($na));
// Convert the addresses back to the dot-format representation and display
echo "Addressing Information: <br />";
echo "<ul>";
echo "<li>IP Address: ". long2ip($ip)."</li>";
echo "<li>Subnet Mask: ". long2ip($netmask)."</li>";
echo "<li>Network Address: ". long2ip($na)."</li>";
echo "<li>Broadcast Address: ". long2ip($ba)."</li>";
echo "<li>Total Available Hosts: ".($h - 1)."</li>";
echo "<li>Host Range: ". long2ip($na + 1)." - ".
long2ip($ba - 1)."</li>";
echo "</ul>";
}
?>
Listing 16-2A Subnet Converter
举个例子。如果您提供 192.168.1.101 作为 IP 地址,255.255.255.0 作为子网掩码,您应该会看到如图 16-1 所示的输出。
图 16-1
计算网络寻址
摘要
PHP 的许多网络功能不会很快取代那些已经在命令行或其他成熟的客户端上提供的工具。尽管如此,随着 PHP 的命令行功能越来越受欢迎,您很可能会很快发现本章中介绍的一些内容的用处,如果没有其他用处的话,也许是电子邮件发送功能。
下一章介绍会话功能。会话用于存储请求之间的数据。
十七、会话处理器
虽然 PHP 的会话处理功能从 4.0 版本开始就有了,但它仍然是最酷、讨论最多的特性之一。在本章中,您将学习以下内容:
-
为什么会话处理是必要且有用的
-
如何配置 PHP 以最有效地使用该特性
-
如何创建和销毁会话,以及管理会话变量
-
为什么您可能考虑在数据库中管理会话数据,以及如何做到这一点
什么是会话处理?
超文本传输协议(HTTP)定义了用于通过万维网传输文本、图形、视频和所有其他数据的规则。这是一个无状态的协议,意味着每个请求都是在不知道任何先前或未来请求的情况下被处理的。虽然 HTTP 的简单性是其无处不在的重要原因,但它的无状态本质长期以来一直是希望创建复杂的基于 web 的应用的开发人员的一个问题,这些应用必须适应用户特定的行为和偏好。为了解决这个问题,在客户机器上存储信息的做法,也就是通常所说的 cookies ,很快获得了认可,缓解了这个难题。然而,对 cookie 大小、允许的 cookie 数量的限制,以及围绕其实现的各种其他不便和安全问题,促使开发人员设计了另一种解决方案:会话处理。
会话处理本质上是解决这个无状态问题的一个聪明的方法。这是通过为每个站点访问者分配一个唯一的标识属性(称为会话 ID (SID ))来实现的,然后将该 SID 与任何数量的其他数据相关联,无论是每月访问次数、最喜欢的背景颜色还是中间名,只要是您能想到的。会话 ID 作为 cookie 存储在浏览器中,并自动包含在对服务器的每个后续请求中。这样服务器就可以跟踪访问者在网站上做了什么。在基本配置中,会话 ID 是文件系统中某个文件的索引,该文件保存了用户的所有保存信息。由于会话 ID 存储在 cookie 中,因此访问者必须在浏览器中启用 cookie 功能,网站才能正常工作。许多国家要求网站所有者显示一条消息,通知访问者 cookies 被使用,即使它仅用于会话跟踪。
会话处理过程
在大多数情况下,开发人员在开始使用会话处理流程时不需要做太多工作。使用标准配置,您需要做的就是在脚本开始时调用session_start()函数,然后将输出发送到客户端。该函数将检测是否已经定义了会话 cookie。如果没有定义,它会在响应中添加一个 cookie 头。如果定义了 cookie,PHP 将寻找相关的会话文件,并使用它来填充$_SESSION超级全局变量。如果您查看会话文件,您会看到该用户先前请求的$_SESSION变量中内容的序列化副本。
谈到 PHP 如何使用会话,有许多配置选项。在接下来的小节中,您将了解负责执行这个过程的配置指令和函数。
配置指令
将近 30 条配置指令负责调整 PHP 的会话处理行为。因为这些指令中有许多在决定这种行为中起着如此重要的作用,所以您应该花一些时间来熟悉这些指令及其可能的设置。大多数初学者不必改变任何默认设置。
管理会话存储媒体
session.save_handler指令决定如何存储会话信息。其原型如下:
session.save_handler = files|mm|redis|sqlite|user
只有files和user选项可以在不安装额外 PHP 扩展的情况下使用。
会话数据至少可以以五种方式存储:在平面文件中(files)、在易失性存储器中(mm)、使用 Redis 服务器( https://redis.io )、使用 SQLite 数据库(sqlite)或通过用户定义的函数(user)。虽然默认设置files对于许多网站来说已经足够了,但是请记住,对于活跃的网站,会话存储文件的数量在给定的时间段内可能会达到数千个,甚至数十万个。
易失性内存选项对于管理会话数据是最快的,但也是最易变的,因为数据存储在 RAM 中。要使用该选项,您需要从 https://www.ossp.org/pkg/lib/mm/ 下载并安装 mm 库。除非您对以这种方式管理会话可能产生的各种问题了如指掌,否则我建议您选择另一个选项。
Redis 选项的工作方式类似于内存解决方案,但是 Redis 服务器支持磁盘持久性,并且它可以安装在不同的服务器上,从而允许在负载平衡环境中的多个 web 服务器之间共享会话数据。Redis 服务器可以从 http://redis.io 下载。还需要可以从 https://github.com/nicolasff/phpredis 下载的 Redis 扩展。一些 Linux 发行版允许您用软件包管理器安装这些元素。
sqlite选项利用 SQLite 扩展,使用这个轻量级数据库透明地管理会话信息。第五个选项user,虽然配置起来最复杂,但也是最灵活和最强大的,因为可以创建定制的处理器来将信息存储在开发人员希望的任何媒体中。在本章的后面,您将学习如何使用该选项在 MySQL 数据库中存储会话数据。
设置会话文件路径
如果session.save_handler被设置为files存储选项,那么必须设置session.save_path指令以识别存储目录。它的原型是这样的:
session.save_path = string
默认情况下,没有定义该指令,除非提供值,否则系统将使用/tmp 作为会话文件的位置。如果您使用的是files选项,那么您需要在php.ini文件中启用它,并选择一个合适的存储目录。请记住,这不应该设置为位于服务器文档根目录中的目录,因为信息很容易通过浏览器泄露。此外,该目录必须可由服务器守护程序写入。
自动启用会话
默认情况下,只有通过调用函数session_start()(本章稍后介绍)页面才会启用会话。但是,如果您计划在整个站点使用会话,您可以通过将session.auto_start设置为1来放弃使用该功能。其原型如下:
session.auto_start = 0 | 1
启用该指令的一个缺点是,如果您想在会话变量中存储对象,您需要使用auto_prepend_file指令加载它们的类定义。这样做当然会导致额外的开销,因为即使在应用中没有使用这些类的情况下,它们也会被加载。
设置会话名称
默认情况下,PHP 将使用会话名PHPSESSID。但是,您可以使用 session.name 指令将其更改为您想要的任何名称。其原型如下:
session.name = string
选择 Cookies 或 URL 重写
如果您希望在用户多次访问该站点时维护用户会话,您应该使用 cookie,以便稍后可以检索 SID。您可以使用session.use_cookies选择这种方法。将此指令设置为1(默认值)会导致使用 cookies 进行 SID 传播;将其设置为0会导致使用 URL 重写。使用 URL 重写可以将会话 ID 作为 URL 的一部分来查看。这是一个潜在的安全风险,允许访问该 URL 的不同用户使用相同的会话 ID 访问该站点。session.ude_cookies指令有两个可能的值:
session.use_cookies = 0 | 1
请记住,当session.use_cookies启用时,不需要显式调用 cookie 设置函数(例如,通过 PHP 的set_cookie()),因为这将由会话库自动处理。如果您选择 cookies 作为跟踪用户 SID 的方法,那么您必须考虑其他几个指令,下面将介绍它们。
出于安全原因,建议您为 cookie 处理配置一些额外的选项。这将有助于防止 cookie 劫持。
session.use_only_cookies = 0 | 1
设置session.use_only_cookies = 1将阻止用户在 querystring 中将 cookie 作为参数传递。只有当会话 id 作为 cookie 从浏览器传递过来时,服务器才会接受它。此外,大多数现代浏览器允许将 cookies 定义为“仅 http”这样做可以防止 JavaScript 访问 cookie。它由指令session.cookie_httponly:控制
session.cookie_httponly = 0 | 1
最后,可以防止 cookie 设置在不安全的连接上。如果使用了安全 SSL 连接,设置session.cookie_secure = 1只会将 cookie 发送到浏览器。
session.cookie_secure = 0 | 1
设置会话 Cookie 生存期
session.cookie_lifetime指令决定了会话 cookie 的有效期。其原型如下:
session.cookie_lifetime = integer
生命周期是以秒为单位指定的,所以如果 cookie 应该存活 1 小时,那么这个指令应该设置为3600。如果该指令设置为0(默认值),cookie 将一直存在,直到浏览器重新启动。cookie 生存期表示 cookie 的生存期。每次用户发送请求时,PHP 都会发出一个具有相同生存期的更新 cookie。如果用户等待的时间超过了请求之间的生存期,浏览器将不再在请求中包含 cookie,并且它将看起来像是站点的新访问者。
设置会话 Cookie 的有效 URL 路径
指令session.cookie_path决定了 cookie 被认为有效的路径。cookie 对该路径下的所有子目录也有效。其原型如下:
session.cookie_path = string
例如,如果设置为/(默认值),那么 cookie 将对整个网站有效。将它设置为/books意味着 cookie 只有在从 http://www.example.com/books/ 路径中被调用时才有效。
设置会话 Cookie 的有效域
指令session.cookie_domain确定 cookie 对哪个域有效。忽略设置此 cookie 将导致 cookie 的域被设置为生成它的服务器的主机名。其原型如下:
session.cookie_domain = string
下面的例子说明了它的用法:
session.cookie_domain = www.example.com
如果您想为站点子域名提供一个会话,比如说customers.example.com、intranet.example.com和 www.example.com ,可以这样设置这个指令:
session.cookie_domain = .example.com
设置缓存方向
使用缓存来加速网页加载是一种常见的做法。缓存可以由浏览器、代理服务器或 web 服务器来完成。如果您提供的页面包含特定于用户的内容,您不希望这些内容被缓存在代理服务器中,并被请求同一页面的不同用户获取。session.cache_limiter指令修改这些页面的缓存相关头,提供关于缓存偏好的指令。其原型如下:
session.cache_limiter = string
有五个值可用:
-
none:该设置禁止传输任何缓存控制头以及启用会话的页面。 -
nocache:这是默认设置。此设置确保在提供可能缓存的版本之前,首先将每个请求发送到原始服务器,以确认页面未发生更改。 -
private:将缓存的文档指定为私有意味着该文档将只对原始用户可用,指示代理不缓存页面,因此不与其他用户共享。 -
private_no_expire:private名称的这种变化导致没有文档到期日期被发送到浏览器。其他方面与private设置相同,这是为各种浏览器添加的一个解决方法,当缓存设置为private时,这些浏览器会被发送的Expire头弄糊涂。 -
这个设置认为所有的文档都是可缓存的,由于性能的提高,它对于站点的非敏感区域是一个有用的选择。
为启用会话的页面设置缓存过期时间
session.cache_expire指令决定了在创建新页面之前,缓存的会话页面可用的秒数(默认为 180 秒)。其原型如下:
session.cache_expire = integer
如果session.cache_limiter被设置为nocache,该指令被忽略。
设置会话生存期
session.gc_maxlifetime指令确定会话数据被认为有效的持续时间,以秒为单位(默认为1440)。当会话数据超过指定的生命周期时,它将不再被读入$_SESSION变量,内容将被“垃圾收集”或从系统中删除。其原型如下:
session.gc_maxlifetime = integer
一旦达到此限制,会话信息将被销毁,以便回收系统资源。另外,查看session.gc_divisor和session.gc_probability指令,了解关于调整会话垃圾收集特性的更多信息。
使用会话
本节介绍了许多关键的会话处理任务,并一路展示了相关的会话功能。其中一些任务包括会话的创建和销毁、SID 的指定和检索以及会话变量的存储和检索。这一介绍为下一节奠定了基础,在下一节中,将提供几个实际的会话处理示例。
开始会话
请记住,HTTP 对用户的过去和将来的情况都是漠不关心的。因此,对于每个请求,您需要显式地启动并随后恢复会话。这两项任务都是使用session_start()功能完成的。它的原型是这样的:
Boolean session_start()
如果没有找到 SID,执行session_start()将创建一个新会话,或者如果 SID 存在,则继续当前会话。您可以通过如下方式调用该函数:
session_start([ array $options = array() ]);
一个让许多刚接触session_start()函数的人感到困惑的重要问题是,这个函数究竟可以在哪里被调用。在任何其他输出被发送到浏览器之前,忽略执行它将导致生成错误消息(headers already sent)。
您可以通过启用配置指令session.auto_start来完全消除该功能的执行。但是,请记住,这将为每个启用 PHP 的页面启动或恢复一个会话,而且还会带来其他副作用,例如,如果您希望将对象信息存储在一个会话变量中,就需要加载类定义。
可选参数$options是在 PHP 7.0 中引入的,允许开发人员通过传递选项的关联数组来覆盖 php.ini 中配置的任何指令。除了标准参数,还可以指定 read_and_close 选项。当设置为TRUE时,该功能将读取会话文件的内容并立即关闭它,防止文件更新。这可以用在高流量的网站上,那里的会话被很多页面读取,但只有少数页面更新。
销毁会话
虽然您可以配置 PHP 的会话处理指令来根据到期时间或垃圾收集概率自动销毁会话,但有时您自己手动取消会话也是有用的。例如,您可能希望允许用户手动注销您的站点。当用户点击适当的链接时,您可以从内存中清除会话变量,甚至从存储器中完全清除会话,分别通过session_unset()和session_destroy()函数来完成。
session_unset()函数删除当前会话中存储的所有会话变量,有效地将会话重置到创建时的状态(没有注册会话变量)。它的原型是这样的:
void session_unset()
虽然执行session_unset()确实会删除当前会话中存储的所有会话变量,但它不会从存储机制中完全删除会话。如果想彻底销毁会话,需要使用函数session_destroy(),通过从存储机制中删除会话使当前会话失效。请记住,这将而不是破坏用户浏览器上的任何 cookies。它的原型是这样的:
Boolean session_destroy()
如果您对在会话结束后使用 cookie 不感兴趣,只需在php.ini文件中将session.cookie_lifetime设置为0(其默认值)。
设置和检索会话 ID
请记住,SID 将所有会话数据绑定到特定用户。尽管 PHP 会自动创建和传播 SID,但有时您可能希望手动设置或检索它。函数session_id()能够执行这两项任务。它的原型是这样的:
string session_id([string sid])
函数session_id()可以设置和获取 SID。如果没有参数,函数session_id()返回当前的 SID。如果包含可选的 SID 参数,当前 SID 将被替换为该值。下面是一个例子:
<?php
session_start();
echo "Your session identification number is " . session_id();
?>
这将产生类似于以下内容的输出:
Your session identification number is 967d992a949114ee9832f1c11c
如果您想要创建自定义会话处理器,支持的字符仅限于字母数字字符、逗号和减号。
创建和删除会话变量
会话变量用于管理用户从一个页面到下一个页面的数据。然而,现在的首选方法是简单地设置和删除这些变量,就像其他任何变量一样,只是您需要在$_SESSION超全局的上下文中引用它。例如,假设您想要设置一个名为username的会话变量:
<?php
session_start();
$_SESSION['username'] = "Jason";
printf("Your username is %s.", $_SESSION['username']);
?>
这将返回以下内容:
Your username is Jason.
要删除变量,可以使用unset()功能:
<?php
session_start();
$_SESSION['username'] = "Jason";
printf("Your username is: %s <br />", $_SESSION['username']);
unset($_SESSION['username']);
printf("Username now set to: %s", $_SESSION['username']);
?>
这将返回:
Your username is: Jason
Username now set to:
警告
您可能会遇到引用函数的session_register()和session_unregister()的旧的学习资源和新闻组讨论,它们曾经分别是创建和销毁会话变量的推荐方法。但是,因为这些函数依赖于一个名为register_globals的配置指令,这个指令在 PHP 4.2.0 中被默认禁用,在 PHP 5.4.0 中被完全删除,所以您应该使用本节中描述的变量赋值和删除方法。
编码和解码会话数据
不管存储介质是什么,PHP 都以由单个字符串组成的标准化格式存储会话数据。例如,由两个变量(username和loggedon))组成的会话的内容显示在这里:
username|s:5:"jason";loggedon|s:20:"Feb 16 2011 22:32:29";
每个会话变量引用由分号分隔,由三部分组成:名称、长度和值。一般语法如下:
name|s:length:"value";
幸运的是,PHP 自动处理会话编码和解码。但是,有时您可能希望手动执行这些任务。为此,有两个函数可用:session_encode()和session_decode()。
编码会话数据
session_encode()提供了一种将所有会话变量手动编码成一个字符串的便捷方法。其原型如下:
string session_encode()
当您希望轻松地将用户的会话信息存储在数据库中以及进行调试时,该函数特别有用,它为您提供了一种查看会话内容的简单方法。例如,假设一个包含该用户 SID 的 cookie 存储在他的计算机上。当用户请求包含以下清单的页面时,将从 cookie 中检索用户 ID。然后,该值被指定为 SID。创建某些会话变量并赋予它们值,然后使用session_encode()对所有这些信息进行编码,准备将其插入数据库,如下所示:
<?php
// Initiate session and create a few session variables
session_start();
// Set a few session variables.
$_SESSION['username'] = "jason";
$_SESSION['loggedon'] = date("M d Y H:i:s");
// Encode all session data into a single string and return the result
$sessionVars = session_encode();
echo $sessionVars;
?>
这将返回:
username|s:5:"jason";loggedon|s:20:"Feb 16 2011 22:32:29";
请记住,session_encode()将对该用户可用的所有会话变量进行编码,而不仅仅是那些在执行session_encode()的特定脚本中注册的变量。
您也可以使用seraialize()函数来获得类似的结果,但是默认情况下session_encode()函数将使用与serialize()函数不同的内部序列化格式。
解码会话数据
编码的会话数据可以用session_decode()解码。它的原型是这样的:
Boolean session_decode(string session_data)
输入参数session_data表示会话变量的编码字符串。该函数将对变量进行解码,将它们返回到原始格式,如果成功,则返回 TRUE,否则返回 FALSE。继续前面的例子,假设一些会话数据被编码并存储在数据库中,即 SID 和变量$_SESSION['username']和$_SESSION['loggedon']。在下面的脚本中,从表中检索数据并解码:
<?php
session_start();
$sid = session_id();
// Encoded data retrieved from database looks like this:
// $sessionVars = username|s:5:"jason";loggedon|s:20:"Feb 16 2011 22:32:29";
session_decode($sessionVars);
echo "User ".$_SESSION['username']." logged on at ".$_SESSION['loggedon'].".";
?>
这将返回:
User jason logged on at Feb 16 2011 22:55:22.
如果您想将会话数据存储在数据库中,有一种更有效的方法,即定义自定义会话处理器,并将这些处理器直接绑定到 PHP 的 API 中。本章稍后将对此进行演示。
重新生成会话 id
一种称为会话固定的攻击涉及攻击者以某种方式获得一个没有怀疑的用户的 SID,然后使用它来冒充该用户,以便获得对潜在敏感信息的访问。您可以通过在维护特定于会话的数据的同时在每个请求上重新生成会话 ID 来最小化这种风险。PHP 提供了一个名为session_regenerate_id()的便利函数,它将用一个新的 ID 替换现有的 ID。其原型如下:
Boolean session_regenerate_id([boolean delete_old_session])
可选的delete_old_session参数决定了当重新生成会话 ID 时,旧的会话文件是否也将被删除。如果设置为 false 或未通过,旧的会话文件将会留在系统中,攻击者仍然可以使用这些数据。最好的选择是总是传递 true,以确保在创建新的会话 id 后删除旧的数据。
使用这个函数有一些开销,因为必须生成一个新的会话文件并更新会话 cookie。
实际会话处理示例
既然您已经熟悉了使会话处理工作的基本函数,那么您就可以考虑一些真实的例子了。第一个示例展示了如何创建一个自动验证返回注册用户的机制。第二个示例演示了如何使用会话变量为用户提供最近查看的文档的索引。这两个例子都很常见,鉴于它们明显的实用性,这并不奇怪。令人惊讶的是,你可以轻而易举地创建它们。
注意
如果您不熟悉 MySQL 数据库,并且对下面例子中的语法感到困惑,可以考虑复习第二十二章中的内容。
自动登录回访用户
一旦用户登录,通常通过提供唯一的用户名和密码组合,允许用户稍后返回站点而不必重复该过程通常是很方便的。您可以使用会话、几个会话变量和一个 MySQL 表轻松实现这一点。虽然有很多方法可以实现这个特性,但是检查现有的会话变量(即$username)就足够了。如果该变量存在,用户可以自动登录到该站点。如果没有,将显示一个登录表单。
注意
默认情况下,session.cookie_lifetime配置指令被设置为0,这意味着如果浏览器重新启动,cookie 将不会持续。因此,您应该将该值更改为适当的秒数,以便使会话持续一段时间。
清单 17-1 中显示了 MySQL 表users。
CREATE TABLE users (
id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
first_name VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
password VARCHAR(32) NOT NULL,
PRIMARY KEY(id)
);
Listing 17-1The users Table
如果没有找到有效的会话,用于向用户显示登录表单的代码片段(login.html)如下所示:
<p>
<form method="post" action="<?php echo $_SERVER['PHP_SELF']; ?>">
Username:<br><input type="text" name="username" size="10"><br>
Password:<br><input type="password" name="pswd" SIZE="10"><br>
<input type="submit" value="Login">
</form>
</p>
最后,用于管理自动登录过程的逻辑如下:
<?php
session_start();
// Has a session been initiated previously?
if (! isset($_SESSION['username'])) {
// If no previous session, has the user submitted the form?
if (isset($_POST['username']))
{
$db = new mysqli("localhost", "webuser", "secret", "corporate");
$stmt = $db->prepare("SELECT first_name FROM users WHERE username = ? and password = ?");
$stmt->bind_param('ss', $_POST['username'], $_POST['password]);
$stmt->execute();
$stmt->store_result();
if ($stmt->num_rows == 1)
{
$stmt->bind_result($firstName);
$stmt->fetch();
$_SESSION['first_name'] = $firstName;
header("Location: http://www.example.com/");
}
} else {
require_once('login.html');
}
} else {
echo "You are already logged into the site.";
}
?>
当用户被各种可以想象的在线服务的用户名和密码淹没的时候,从检查电子邮件到库图书续借到查看银行账户,在情况允许时提供自动登录功能肯定会受到用户的欢迎。
上面的例子需要一个名为 users 的表,其中包含列的username和password。正如在第十四章中所讨论的,你不应该用明文存储密码。相反,您应该使用哈希,因为如果攻击者获得了数据库的访问权限,就不会获得实际的密码。
生成最近查看的文档索引
有多少次你回到一个网站,想知道在哪里可以找到你忘记加书签的 PHP 教程?如果网站能够记住你读了哪些文章,并在你需要的时候给你一个列表,这不是很好吗?这个例子演示了这样一个特性。
这个解决方案出乎意料的简单,却很有效。要记住给定用户阅读了哪些文档,可以要求用户和每个文档都用唯一的标识符来标识。对于用户来说,SID 满足这个要求。可以按照您希望的任何方式来标识文档,但是本例使用文章的标题和 URL,并假设该信息来自名为articles的数据库表中存储的数据,如下所示:
CREATE TABLE articles (
id INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(50),
content MEDIUMTEXT NOT NULL,
PRIMARY KEY(id)
);
唯一需要的任务是将文章标识符存储在会话变量中,这将在下面实现:
<?php
// Start session
session_start();
// Connect to server and select database
$db = new mysqli("localhost", "webuser", "secret", "corporate");
// User wants to view an article, retrieve it from database
$stmt = $db->prepare("SELECT id, title, content FROM articles WHERE id = ?");
$stmt->bind_param('i', $_GET['id']);
$stmt->execute();
$stmt->store_result();
if ($stmt->num_rows == 1)
{
$stmt->bind_result($id, $title, $content);
#stmt->fetch();
}
// Add article title and link to list
$articleLink = "<a href='article.php?id={$id}'>{$title}</a>";
if (! in_array($articleLink, $_SESSION['articles']))
$_SESSION['articles'][] = $articleLink;
// Display the article
echo "<p>$title</p><p>$content</p>";
// Output list of requested articles
echo "<p>Recently Viewed Articles</p>";
echo "<ul>";
foreach($_SESSION['articles'] as $doc) {
echo "<li>$doc</li>";
}
echo "</ul>";
?>
样本输出如图 17-1 所示。
图 17-1
跟踪用户查看的文档
创建自定义会话处理器
用户定义的会话处理器提供了四种存储方法中最大程度的灵活性。实现定制的会话处理器非常容易——只需遵循几个步骤。首先,您需要定制六个任务(定义如下)用于您的自定义存储位置。此外,无论您的特定实现是否使用参数,都必须遵循每个函数的参数定义。本节概述了这六个功能的目的和结构。此外,它还引入了session_set_save_handler(),这个函数用于神奇地将 PHP 的会话处理器行为转换成您的自定义处理器函数所定义的行为。最后,本节以演示这一伟大特性结束,提供了一个基于 MySQL 的实现。您可以立即将这个库合并到您自己的应用中,使用 MySQL 表作为会话信息的主要存储位置。
-
session_open($session_save_path, $session_name):此函数初始化会话过程中可能用到的任何元素。两个输入参数$session_save_path和$session_name指的是在php.ini文件中找到的同名配置指令。PHP 的get_cfg_var()函数用于在后面的例子中检索这些配置值。 -
session_close():这个函数的操作很像一个典型的处理函数,关闭任何由session_open().初始化的打开的资源。正如你所看到的,这个函数没有输入参数。请记住,这不会破坏会话。这就是在列表末尾介绍的session_destroy()的工作。 -
session_read($sessionID):该功能从存储介质中读取会话数据。输入参数$sessionID指的是 SID,它将用于标识为这个特定客户端存储的数据。 -
session_write($sessionID, $value):该功能将会话数据写入存储介质。输入参数$sessionID是变量名,输入参数$value是会话数据。 -
这个函数可能是你在脚本中调用的最后一个函数。它会破坏会话和所有相关的会话变量。输入参数
$sessionID指的是当前打开的会话中的 SID。 -
session_garbage_collect($lifetime):此功能有效删除所有已过期的会话。输入参数$lifetime指的是在php.ini文件中找到的会话配置指令session.gc_maxlifetime。
将自定义会话函数绑定到 PHP 的逻辑中
在定义了六个自定义处理函数之后,必须将它们绑定到 PHP 的会话处理逻辑中。这是通过将它们的名字传递给函数session_set_save_handler()来实现的。请记住,这些名称可以是您选择的任何名称,但是它们必须接受正确数量和类型的参数,如前一节所述,并且必须按以下顺序传递给session_set_save_handler()函数:打开、关闭、读取、写入、销毁和垃圾收集。描述如何调用此函数的示例如下:
session_set_save_handler("session_open", "session_close", "session_read",
"session_write", "session_destroy",
"session_garbage_collect");
使用定制的基于 MySQL 的会话处理器
在部署基于 MySQL 的处理器之前,您必须完成两项任务:
-
创建将用于存储会话数据的数据库和表。
-
创建六个自定义处理函数。
下面的 MySQL 表sessioninfo将用于存储会话数据。出于这个例子的目的,假设这个表是在数据库sessions中找到的,尽管您可以将这个表放在您希望的地方。
CREATE TABLE sessioninfo (
sid VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
expiration TIMESTAMP NOT NULL,
PRIMARY KEY(sid)
);
清单 17-2 提供了自定义 MySQL 会话函数。请注意,它定义了每个必需的处理器,确保将适当数量的参数传递给每个处理器,而不管这些参数是否在函数中实际使用。该示例使用函数session_set_save_handler()来定义实现所有函数所需的六个回调函数。每个函数都可以用一个字符串形式的函数名或一个带两个参数的数组来标识。第一个是对对象的引用,第二个是对给定操作调用的方法的名称。因为本例中的会话处理器是用一个类定义的,所以每个函数名都用一个数组指定。
<?php
class MySQLiSessionHandler {
private $_dbLink;
private $_sessionName;
private $_sessionTable;
CONST SESS_EXPIRE = 3600;
public function __construct($host, $user, $pswd, $db, $sessionName, $sessionTable)
{
// Create a connection to the database
$this->_dbLink = new mysqli($host, $user, $pswd, $db);
$this->_sessionName = $sessionName;
$this->_sessionTable = $sessionTable;
// Set the handlers for open, close, read, write, destroy and garbage collection
.
session_set_save_handler(
array($this, "session_open"),
array($this, "session_close"),
array($this, "session_read"),
array($this, "session_write"),
array($this, "session_destroy"),
array($this, "session_gc")
);
session_start();
}
function session_open($session_path, $session_name) {
$this->_sessionName = $session_name;
return true;
}
function session_close() {
return 1;
}
function session_write($SID, $value) {
$stmt = $this->_dbLink->prepare("
INSERT INTO {$this->_sessionTable}
(sid, value) VALUES (?, ?) ON DUPLICATE KEY
UPDATE value = ?, expiration = NULL");
$stmt->bind_param('sss', $SID, $value, $value);
$stmt->execute();
session_write_close();
}
function session_read($SID) {
// create a SQL statement that selects the value for the cussent session ID and validates that it is not expired
.
$stmt = $this->_dbLink->prepare(
"SELECT value FROM {$this->_sessionTable}
WHERE sid = ? AND
UNIX_TIMESTAMP(expiration) + " .
self::SESS_EXPIRE . " > UNIX_TIMESTAMP(NOW())"
);
$stmt->bind_param('s', $SID);
if ($stmt->execute())
{
$stmt->bind_result($value);
$stmt->fetch();
if (! empty($value))
{
return $value;
}
}
}
public function session_destroy($SID) {
// Delete the record for the session id provided
$stmt = $this->_dbLink->prepare("DELETE FROM {$this->_sessionTable} WHERE SID = ?");
$stmt->bind_param('s', $SID);
$stmt->execute();
}
public function session_gc($lifetime) {
// Delete records that are expired.
$stmt = $this->_dbLink->prepare("DELETE FROM {$this->_sessionTable}
WHERE UNIX_TIMESTAMP(expiration) < " . UNIX_TIMESTAMP(NOW()) - self::SESS_EXPIRE);
$stmt->execute();
}
}
Listing 17-2The MySQL Session Storage Handler
要使用该类,只需将它包含在您的脚本中,实例化该对象,并分配您的会话变量:
require "mysqlisession.php";
$sess = new MySQLiSessionHandler("localhost", "root", "jason",
"chapter17", "default", "sessioninfo");
$_SESSION['name'] = "Jason";
执行完这个脚本后,使用mysql客户端查看一下sessioninfo表的内容:
mysql> select * from sessioninfo;
+-------------------------------------+---------------+-------------------+
| SID | expiration | value |
+-------------------------------------+---------------+-------------------+
| f3c57873f2f0654fe7d09e15a0554f08 | 1068488659 | name|s:5:"Jason"; |
+-------------------------------------+---------------+-------------------+
1 row in set (0.00 sec)
正如所料,已经插入了一行,将 SID 映射到会话变量"Jason."。该信息被设置为在创建后 1440 秒过期;该值的计算方法是确定 Unix 纪元后的当前秒数,然后加上 1,440。注意,虽然 1,440 是默认的到期设置,如在php.ini文件中所定义的,但是您可以将这个值更改为您认为合适的值。
请注意,这不是实现这些适用于 MySQL 的过程的唯一方法。你可以随意修改这个库。
摘要
本章涵盖了 PHP 会话处理能力的全部。您了解了许多用于定义这种行为的配置指令,以及将这种功能整合到应用中的最常用函数。本章以 PHP 用户定义的会话处理器的真实例子结束,向您展示了如何将 MySQL 表转换成会话存储介质。
下一章将讨论另一个高级但非常有用的主题:web 服务。它还将介绍如何使用标准 web 技术与服务和 API 进行交互。
十八、Web 服务
Web 技术已经发生了很大的变化,从 1994 年创建第一个浏览器时引入的静态 HTML 页面,到由 PHP 等编程语言支持的更动态的内容,再到当前的情况:提供服务并与 web 服务的使用轻松集成。有许多可用的协议和格式,其中许多都受原生 PHP 或 PHP 扩展的支持。
可扩展标记语言(XML)和 JavaScript 对象表示法(JSON)是交换信息的两种常见格式。XML 通常与简单对象访问协议(SOAP)一起使用,SOAP 是一种轻量级且灵活的协议,用于在系统之间交换信息。它使定义和验证请求和响应以及通过 Web 服务描述语言(WSDL)中的结构化文档公开 API 端点成为可能。SOAP 标准仍然被许多公司和系统广泛使用和支持,但是与 JSON 标准相比,它的使用通常要复杂一些。
JSON 易于阅读和以编程方式创建,并且它受到浏览器等前端工具和许多用于在互联网上构建应用和服务的编程语言(包括 PHP)的支持。除了使用 JSON 格式在 Web 上请求和检索信息之外,还经常使用表述性状态转移(REST)架构或 RESTful web 服务,利用 HTTP 协议的无状态特性在多个系统之间交换信息。
今天可用的许多 web 服务都支持 XML 和 JSON 作为响应格式,但是现在大多数都默认使用 JSON;当添加新服务时,只支持 JSON 的情况并不少见。
为什么选择 Web 服务?
为了吸引浏览者到一个网站,你必须提供尽可能多的相关内容。这包括提供根据访问者位置定制的天气服务、通过 OAuth 协议进行访问管理(如第十四章所述)或访问云中的存储和计算资源。关键是利用外部服务的工作,要么是免费的,要么是付费的。亚马逊(Amazon)、微软(Microsoft)和谷歌(Google Cloud)等公司提供了一长串服务,让开发者的生活变得更加轻松。
当一个 web 服务或 API 被暴露给工作时,它提供了所谓的端点,也就是用来访问 API 的 URL。因为它基于 HTTP 协议,所以可以将参数传递给这样的 API。这可以是查询字符串参数的形式,就像从浏览器地址栏(GET request)中知道的那样,或者是 POST 请求,API 将返回一个响应。根据服务的不同,响应可以是 HTTP 协议支持的任何内容(文本、图像、二进制内容等)。).许多 web 服务提供商还发布了 PHP(和其他语言)的软件开发工具包(SDK ),这使得开发人员可以更容易地将服务集成到 web 应用中。脸书有一个用于认证服务的 SDK,亚马逊提供了一个用于简单存储服务(S3)和许多其他服务的 PHP SDK。这些 SDK 通常很容易用 composer 工具( https://composer.org )安装。
API 入门
为了使用以 JSON 格式返回数据的 API,或者如果您创建自己的以 JSON 格式返回数据给请求者的 RESTful APIs,您将需要一种创建内容的方法。JSON 是一种非常像 PHP 的数组结构的对象格式。PHP 提供了两个函数,使得从 JSON 编码的字符串到 PHP 变量的来回转换变得非常容易。这些功能被称为json_encode()和json_decode()。在最简单的形式中,这两个函数都可以使用单个参数,如下例所示。
<?php
$a = ['apple', 'orange', 'pineapple', 'pear'];
header('Content-Type: application/json');
echo json_encode($a);
该示例将产生以下输出。
["apple","orange","pineapple","pear"]
header 语句用于告诉请求者期望响应中包含什么内容。如果您使用的是 PHP 的 CLI 版本,这条语句不会有任何视觉效果,因为命令行上不会返回任何头;但是,如果您使用 web 服务器返回结果,您将获得标题,客户端可以相应地采取行动。
同样,我们可以将 JSON 字符串转换成 PHP 变量,如下例所示:
<?php
$json ='["apple","orange","pineapple","pear"]';
print_r(json_decode($json));
这将把字符串变成一个 PHP 数组。
Array
(
[0] => apple
[1] => orange
[2] => pineapple
[3] => pear
)
将硬编码的字符串值转换成数组没有太大的价值,除非您想用这种方式将 PHP 变量以字符串格式存储在数据库或文件系统中。为了从 API 调用中检索响应,我们需要一个可以执行 API 调用的函数。您可以使用 PHP 中的套接字函数来编写打开连接、发送请求、读取响应和关闭连接的所有逻辑;但是在大多数情况下这是不必要的,因为函数file_get_contents()处理硬盘上的本地文件以及通过 HTTP 协议访问的远程文件,并且它在一个动作中完成所有这些事情。
为了说明使用 JSON 的 web 服务的简单本质,我们将查看 OpenWeatherMap。对于中等数量的 API 调用(每分钟多达 60 次)来说,这是一项免费服务,但是对于大量的请求,它们也支持付费服务。为了使用该服务,您必须请求一个 API 密钥(APPID)。这是用于识别您的网站和跟踪使用情况的标识符。( https://openweathermap.org/appid )。当您创建了一个 API 密钥后,您就可以开始使用该服务了。首先,您必须构建一个查询字符串,该字符串结合了一个基本 API URL 和您想要传递给 API 的参数。对于 OpenWeatherMap,可以根据城市名称、邮政编码和坐标请求当前天气或天气预报。下一个示例显示了如何检索邮政编码为 98109(华盛顿州西雅图市)的当前天气。
<?php
$OpenWeather = ['api_key' => '<API KEY>'];
$zip = "98109";
$base_url = "https://api.openweathermap.org/data/2.5";
$weather_url = "/weather?zip=" . $zip;
$api_key = "&appid={$OpenWeather['api_key']}";
$api_url = $base_url . $weather_url . $api_key;
$weather = json_decode(file_get_contents($api_url));
print_r($weather);
stdClass Object
(
[coord] => stdClass Object
(
[lon] => -122.36
[lat] => 47.62
)
[weather] => Array
(
[0] => stdClass Object
(
[id] => 803
[main] => Clouds
[description] => broken clouds
[icon] => 04d
)
)
[base] => stations
[main] => stdClass Object
(
[temp] => 281.64
[pressure] => 1011
[humidity] => 75
[temp_min] => 280.15
[temp_max] => 283.15
)
[visibility] => 16093
[wind] => stdClass Object
(
[speed] => 4.1
[deg] => 320
)
[clouds] => stdClass Object
(
[all] => 75
)
[dt] => 1523817120
[sys] => stdClass Object
(
[type] => 1
[id] => 2931
[message] => 0.0105
[country] => US
[sunrise] => 1523798332
[sunset] => 1523847628
)
[id] => 420040070
[name] => Seattle
[cod] => 200
)
响应以对象的形式显示了许多关于位置和天气的不同参数。因此,要从响应中获得温度,可以使用$weather->Main->temp。请注意,温度以开尔文(K)标度给出,需要转换为摄氏度或华氏度。如果希望数据以数组而不是对象的形式返回,可以将 true 作为第二个参数传递给json_decode()函数。在这种情况下,您将访问作为$weather['main']['temp']的温度数据。
通过从一个名为weather的 API 切换到forecast ,,可以检索到未来五天的天气预报,每三小时一次。
<?php
$OpenWeather = ['api_key' => '<API KEY>'];
$zip = "98109";
$base_url = "https://api.openweathermap.org/data/2.5";
$weather_url = "/forecast?zip=" . $zip;
$api_key = "&appid={$OpenWeather['api_key']}";
$api_url = $base_url . $weather_url . $api_key;
$weather = json_decode(file_get_contents($api_url));
print_r($weather);
这会产生更长的输出。下面的例子只显示了第一行数据。
stdClass Object
(
[cod] => 200
[message] => 0.0047
[cnt] => 39
[list] => Array
(
[0] => stdClass Object
(
[dt] => 1523847600
[main] => stdClass Object
(
[temp] => 280.33
[temp_min] => 278.816
[temp_max] => 280.33
[pressure] => 1006.85
[sea_level] => 1017.61
[grnd_level] => 1006.85
[humidity] => 100
[temp_kf] => 1.52
)
[weather] => Array
(
[0] => stdClass Object
(
[id] => 501
[main] => Rain
[description] => moderate rain
[icon] => 10n
)
)
[clouds] => stdClass Object
(
[all] => 92
)
[wind] => stdClass Object
(
[speed] => 1.71
[deg] => 350.002
)
[rain] => stdClass Object
(
[3h] => 3.0138
)
[sys] => stdClass Object
(
[pod] => n
)
[dt_txt] => 2018-04-16 03:00:00
)
… There are 30 rows of data …
)
[city] => stdClass Object
(
[id] => 420040070
[name] => Seattle
[coord] => stdClass Object
(
[lat] => 47.6223
[lon] => -122.3558
)
[country] => US
)
)
应用编程接口安全性
在上一节中,我们使用 OpenWeatherMap APIs 演示了如何以简单明了的方式与 RESTful APIs 进行交互。所需要的只是一个 API 键,用于服务器识别请求者并跟踪使用情况。在这种情况下,信息只向一个方向流动:从服务器到客户机。在其他情况下,数据将在两个方向上流动,有必要使 API 更加安全,以防止任何有权访问 GET 或 POST URL 的人与端点进行交互。第一步是确保与服务器的连接是安全的。如今,大多数流量都应该在服务器上安装 TLS/SSL 证书,并且应该使用 https://而不是 http://进行访问。然而,这只能保护被发送的数据,而不能确保发送者就是他/她所声称的那个人。
为了增加额外的安全层,通常的做法是在服务器和客户机之间交换一个“秘密”。机密永远不会与请求中交换的任何参数一起传递,但它用于创建哈希形式的签名,该签名可以在服务器上根据请求中包含的参数、有关如何创建签名的知识以及机密的服务器副本重新创建。
以这种方式创建签名的一个标准是亚马逊 AWS HMAC-SHA256 签名( https://docs.aws.amazon.com/AWSECommerceService/latest/DG/HMACSignatures.html ),但有许多方法可以实现这一点。生成这个秘密可能是一个麻烦的任务,但是 PHP 提供了一个函数使它变得简单一些。它被称为 hash_hmac(),其原型如下:
hash_hmac(string $algo, string $data, string $key [, bool $raw_output])
第一个参数$algos用于选择要使用的哈希算法。可以通过调用hash_hmac_algos()函数找到允许的值。创建供 AWS 使用的 HMAC 散列是通过 sha256 算法完成的。
第二个参数$data 是应该被散列的输入。为了与 AWS 一起使用,这应该是传递给 API 的所有参数的键/值对的列表,不包括签名值。准备好参数字符串后,值应该按字节值排序,每个键/值对应该用&分隔。当 API 被调用时,参数的顺序并不重要;但是在生成散列时,客户机和服务器使用相同的参数顺序来生成用于比较的签名是很重要的。否则,API 调用将失败。下面是字符串外观的示例:
AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&AssociateTag=mytag-20&ItemId=0679722769&Operation=ItemLookup&ResponseGroup=Images%2CItemAttributes%2COffers%2CReviews&Service=AWSECommerceService&Timestamp=2014-08-18T12%3A00%3A00Z&Version=2013-08-01
注意,还提供了时间戳。这是 AWS 服务的要求。
第三个参数$key 是与 API 提供者交换的秘密,第四个参数用于控制输出的返回方式。设置为 true 将返回二进制数据,设置为 false 将返回十六进制字符串。
为了与 AWS 和其他服务提供者一起使用,字符串应该在添加到参数列表之前进行 base64 编码。下面的例子展示了这是如何工作的。
<?php
$url = "http://webservices.amazon.com/onca/xml";
$param = "AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&AssociateTag=mytag-20&ItemId=0679722769&Operation=ItemLookup&ResponseGroup=Images%2CItemAttributes%2COffers%2CReviews&Service=AWSECommerceService&Timestamp=2014-08-18T12%3A00%3A00Z&Version=2013-08-01";
$data = " GET
webservices.amazon.com
/onca/xml
" . $param;
$key = "1234567890";
$Signature = base64_encode(hash_hmac("sha256", $param, $key, true));
$request = $url . "?" . $param . "&Signature=" . $Signature;
echo $request;
注意,签名的是整个 HTTP Get 请求,包括 HTTP 动词、主机名、位置以及参数列表。脚本的输出应该如下所示:
http://webservices.amazon.com/onca/xml?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&AssociateTag=mytag-20&ItemId=0679722769&Operation=ItemLookup&ResponseGroup=Images%2CItemAttributes%2COffers%2CReviews&Service=AWSECommerceService&Timestamp=2014-08-18T12%3A00%3A00Z&Version=2013-08-01&Signature=j7bZM0LXZ9eXeZruTqWm2DIvDYVUU3wxPPpp+iXxzQc=
本例中的时间戳看起来很旧,但这是为了与 AWS 文档中的示例相匹配。因为签名与文档中生成的签名相同,所以我们可以使用它来验证代码是否生成了正确的签名。当您创建代码来使用 API 时,您将需要一个当前的时间戳。
在上面的例子中,签名被作为一个额外的参数添加到查询字符串中。其他服务要求将信息作为标头包含在内,在某些情况下,您应该提供一个标头值,其中包含用于签名的标头值的名称和顺序,并提供第二个标头,其中包含实际签名。使用头值可以防止从浏览器访问 API,因为没有办法添加头。这可以被视为一个额外的安全层。
创建一个 API
使用服务提供商提供的 API 通常是一个很好的起点,但是当您开发自己的 web 应用时,您可能希望公开自己的 API,以允许其他站点或应用与您的服务集成。如果您想公开一个不需要任何身份验证就可以使用的 API,只需创建一个 PHP 脚本,以您想要的格式和标题返回请求的数据。不需要特别的技巧。您可能希望将 api 分离到不同的主机(或虚拟主机)中,如 api.mysite.com,或者将它们放在一个名为 API 或 service 的特殊文件夹中,这样就可以使用 https://mysite.com/api/api_name.php?param1=abc 来访问它们。移除。php 部分的 URL 可以通过实现 URL 重写来完成。
构建 API 通常从创建一个没有身份验证和访问控制的简单版本开始。这使得调试和修改变得容易,但是一旦开始向用户开放 API,就必须增加安全性,以防止未经授权的插入、删除或更新。
添加身份验证的第一步是决定当用户与服务交互时如何识别用户。这可能是用户定义的字符串、电子邮件地址或其他独特的信息。对于 AWS 和许多其他服务,这是由服务提供商生成的字符串。它对每个用户都是唯一的,因此它可以是数据库中自动生成的记录 id。同样,你需要某种形式的密钥或秘密。这可以是任意长度的字符串。在您的用户中它不必是唯一的,但是它应该只被服务器和客户机知道,因此被称为 secret。
下一步是定义如何在每个请求中生成签名并传递给服务器。您可以使用与上一节描述的 AWS HMAC-SHA256 方法相同的结构,也可以创建自己的结构。文档对于这一步很重要。定义签名方法后,您可以开始在服务器上编写函数来创建签名,并在调用 API 时根据用户提供的签名来验证它。为您的用户提供示例代码或 SDK 可能是一个好主意,这样可以让他们更容易地与您的服务集成,并且可以让您更容易地进行测试和调试。
验证签名只是操作的一部分。使用应用 id,您必须找到用户的秘密,以便执行验证。这可能涉及数据库查找。您还应该考虑在允许脚本继续执行之前,验证用户是否有权执行所请求的操作(插入、更新或删除)。如果出现错误,您需要向调用者返回一些可以处理的细节。就像一个有效的请求可能导致一个内容类型被设置为application/json,的 JSON 响应一样,您可以使用内容类型application/problem+json来指示出错了。在这两种情况下,响应文档都是 JSON 格式的,但是这两种类型的响应都由 content-type 头明确标识。
一个简单的 web 服务可以是日志服务,其中多个服务器可以使用一个公共 API 来记录事件。这将是一个服务于单一目的的简单服务,它可以有一个简单的接口,使集成多个网站或其他应用变得容易。日志服务的基本构造块包括防止未授权访问的身份验证、接收日志消息的 API 和检索事件的 API。这可以实现为一个具有三个方法的类,如下一个框架示例所示。
<?php
class logService {
private function authenticate() {
}
public function addEvent() {
}
public function getEvents() {
}
};
authenticate()函数应该能够验证请求,并找到调用客户端创建散列所使用的秘密。为了简单起见,我们可以创建一个简单的协议,其中只有应用 id 和时间戳被散列来创建签名。
private function authenticate () {
if (empty($_GET['AppId']) || empty($_GET['Timestamp']) || empty($_GET['Signature'])) {
return false;
}
else {
$Secret = null;
// Replace with a lookup of the secret based on the AppId.
if ($_GET['AppId'] == 'MyApplication') {
$Secret = '1234567890';
}
If ($Secret) {
$params = "AppId={$_GET['AppId']}&Timestamp={$_GET['Timestamp']}";
$Signature = base64_encode(hash_hmac("sha256", $param, $Secret, true));
if ($Signature == $_GET['Signature']) {
return $_GET['AppId'];
}
else {
return false;
}
}
}
}
authenticates()函数首先检查三个必需的参数是否传递给了请求。否则,该函数将返回 false。然后进行查找以查看 AppId 是否是有效的 Id,并且找到相关联的$Secret。这通常是某种类型的数据库查找,但是为了简单起见,它用硬编码的值来表示。最后,根据 AppId 和时间戳计算签名,并与请求提供的签名进行比较。
接下来我们可以处理 a ddEvent()函数。在基本示例中,该函数将使用请求者提供的消息创建一个条目,并将一行添加到与 AppId 同名的日志文件中。扩展该函数来处理额外的参数(如严重性或日志中可能有用的其他值)相对简单。该函数还将添加一个时间戳和调用 API 的客户机的 IP 地址。
public function addEvent() {
if ($filename = $this->authenticate()) {
$entry = gmdate('Y/m/d H:i:s') . ' ' . $_SERVER['REMOTE_ADDR'] . ' ' . $_GET['Msg']);
file_put_contents('/log/' . $filename .'.log', $entry . "\n", FILE_APPEND);
header('Content-Type: application/json');
echo json_encode(true);
}
else {
header('Content-Type: application/problem+json');
echo json_encode(false);
}
}
addEvent()函数首先使用authenticate()方法来验证请求者。如果验证成功,该函数将向应用的日志文件中写入一个条目,并返回 true。如果验证失败,将返回一个错误。
以类似的方式,我们可以实现检索日志的功能。为了简单起见,函数 getEvents 将检索整个日志,但是它可以被优化为包含一个日期参数,以便只检索日志的一部分。
public function getEvents() {
if ($filename = $this->authenticate()) {
header('Content-Type: text/plain');
readfile('/log/' . $filename .'.log');
}
else {
header('Content-Type: application/problem+json');
echo json_encode(false);
}
}
getEvents()函数将执行与 addEvent()相同的验证,如果验证成功,它将读取整个日志文件并将其返回给请求者。
现在我们已经设计好了整个类,我们可以创建用于添加条目或请求内容的脚本。第一个脚本名为 add_event.php,它将创建一个 logService 类的对象,并使用 addEvent()方法在日志中创建一个条目。
<?php
// add_event.php
require "log_service.php";
$log = new logService();
$log->addEvent();
第二个脚本名为 get_events.php,也将实例化 logService 类并调用 getEvents()方法。
<?php
// get_events.php
require "log_service.php";
$log = new logService();
$log->getEvents();
为了完整起见,下面是 log_service.php 脚本的完整列表,它定义了日志服务的类。
<?php
class logService {
private function authenticate() {
if (empty($_GET['AppId']) || empty($_GET['Timestamp']) || empty($_GET['Signature'])) {
return false;
}
else {
$Secret = null;
// Replace with a lookup of the secret based on the AppId.
if ($_GET['AppId'] == 'MyApplication') {
$Secret = '1234567890';
}
If ($Secret) {
$params = "AppId={$_GET['AppId']}&Timestamp={$_GET['Timestamp']}";
$Signature = base64_encode(hash_hmac("sha256", $params, $Secret, true));
If ($Signature == $_GET['Signature']) {
return $_GET['AppId'];
}
else {
return false;
}
}
}
}
public function addEvent() {
if ($filename = $this->authenticate()) {
$entry = gmdate('Y/m/d H:i:s') . ' ' . $_SERVER['REMOTE_ADDR'] . ' ' . $_GET['Msg'];
file_put_contents('/log/' . $filename .'.log', $entry . "\n", FILE_APPEND);
header('Content-Type: application/json');
echo json_encode(true);
}
else {
header('Content-Type: application/problem+json');
echo json_encode(false);
}
}
public function getEvents() {
if ($filename = $this->authenticate()) {
header('Content-Type: text/plain');
readfile('/log/' . $filename .'.log');
}
else {
header('Content-Type: application/problem+json');
echo json_encode(false);
}
}
};
现在只需要一个 PHP 脚本来调用这两个 API。在这两种情况下,我们都需要生成一个与logService()类的签名相匹配的签名。
<?php
$AppId = 'MyApplication';
$Secret = '1234567890';
$url = 'https://logservice.com/api/add_event.php';
$Timestamp = time();
$Msg = 'Testing of the logging Web Service';
$params = "AppId={$AppId}&Timestamp={$Timestamp}";
$Signature = base64_encode(hash_hmac("sha256", $params, $Secret, true));
$QueryString = $params . '&Msg=' . urlencode($Msg) . '&Signature=' . urlencode($Signature);
echo file_get_contents($url . '?' . $QueryString);
在同一台服务器或远程服务器上执行该脚本将产生输出 true,并且日志文件将添加一个如下所示的条目:
2018/04/18 04:27:18 10.10.10.10 Testing of the logging Web Service
同样,可以创建一个脚本来检索该应用的日志文件。该脚本可能如下所示:
<?php
$AppId = 'MyApplication';
$Secret = '1234567890';
$url = 'https://logservice.com/api/get_events.php';
$Timestamp = time();
$params = "AppId={$AppId}&Timestamp={$Timestamp}";
$Signature = base64_encode(hash_hmac("sha256", $params, $Secret, true));
$QueryString = $params . '&Signature=' . urlencode($Signature);
echo file_get_contents($url . '?' . $QueryString);
这将产生类似如下的输出:
2018/04/18 04:27:18 10.10.10.10 Testing of the logging Web Service
2018/04/18 04:30:37 10.10.10.10 Testing of the logging Web Service
2018/04/18 04:30:39 10.10.10.10 Testing of the logging Web Service
摘要
本章讨论了 web 服务和使用 web 服务时最常见的两种技术,JSON 格式和 RESTful API 结构。您了解了如何与第三方提供的服务进行交互,如何使用 AWS HMAC 签名,以及如何创建简单的日志服务。
下一章将介绍另一个与安全性相关的高级特性:安全 PHP 编程。这包括软件漏洞以及如何处理用户提供的数据。