七、使用 PHP 管理文件
PHP 有大量用于服务器文件系统的函数,但是找到合适的函数并不容易。这一章从混乱中切入,向您展示这些函数的一些实际用途,例如在没有数据库的情况下读写文本文件来存储少量信息。循环在检查文件系统的内容中起着重要的作用,所以您还将探索一些标准的 PHP 库(SPL)迭代器,这些迭代器旨在提高循环的效率。
除了打开本地文件,PHP 还可以读取其他服务器上的公共文件,比如新闻提要。新闻提要通常被格式化为 XML(可扩展标记语言)。在过去,从 XML 文件中提取信息是一个曲折的过程,但是名字非常贴切的 SimpleXML 使 PHP 变得很容易。在本章中,您将了解如何创建一个列出文件夹中所有图像的下拉菜单,如何创建一个从文件夹中选择特定类型文件的功能,如何从另一个服务器获取实时新闻,以及如何提示访问者下载图像或 PDF 文件,而不是在浏览器中打开它。作为奖励,您将学习如何更改从另一个网站检索的日期的时区。
本章涵盖以下主题:
-
读取和写入文件
-
列出文件夹的内容
-
用
SplFileInfo
类检查文件 -
用 SPL 迭代器控制循环
-
使用 SimpleXML 从 XML 文件中提取信息
-
消费 RSS 源
-
创建下载链接
检查 PHP 是否可以打开文件
本章中的许多 PHP 解决方案都涉及到打开文件进行读写,所以确保在本地测试环境和远程服务器上设置正确的权限是很重要的。PHP 能够在任何地方读写文件,只要它有正确的权限并且知道在哪里可以找到文件。因此,为了安全起见,您应该将计划读写的文件存储在 web 服务器根目录之外(通常称为htdocs
、public_html
或www
)。这可以防止未经授权的人阅读您的文件,或者更糟的是,篡改其内容。
大多数托管公司使用 Linux 或 Unix 服务器,这些服务器对文件和目录的所有权有严格的规定。检查在 web 服务器根目录之外存储文件的目录的权限是否已设置为 644(这允许所有者读取和写入该目录;所有其他用户只能读取)。如果你仍然得到许可被拒绝的警告,咨询你的托管公司。如果您被告知将任何设置提升到 7,请注意这将允许执行脚本,这可能会被恶意攻击者利用。
Tip
如果你不能访问网站根目录以外的目录,我建议你换一家托管公司。由网站维护者以外的人上传到网站的文件在被包含在网页中之前应该被检查。将它们存储在公众视野之外可以降低任何安全风险。
在服务器根目录外创建一个文件夹,以便在 Windows 上进行本地测试
对于下面的练习,我建议你在 c 盘的顶层创建一个名为private
的文件夹。在 Windows 上没有权限问题,所以这就是你需要做的。
在服务器根目录外创建一个文件夹,以便在 macOS 上进行本地测试
Mac 用户可能需要做更多的准备,因为文件权限类似于 Linux。在你的主文件夹中创建一个名为private
的文件夹,并按照 PHP 解决方案 7-1 中的说明进行操作。
如果一切顺利,你不需要做任何额外的事情。但是,如果您收到 PHP“未能打开流”的警告,请像这样更改private
文件夹的权限:
-
在 Mac Finder 中选择
private
,选择文件➤获取信息(Cmd+I)打开其信息面板。 -
在“共享与权限”中,单击右下方的挂锁图标解锁设置,然后将所有人的设置从只读更改为读写,如以下截图所示:
- 再次单击挂锁图标以保存新设置并关闭信息面板。现在你应该能够使用
private
文件夹继续本章的剩余部分。
影响文件访问的配置设置
托管公司可以通过php.ini
对文件访问进行进一步限制。要找出施加了什么限制,请在您的网站上运行phpinfo()
并检查核心部分的设置。表 7-1 列出了您需要检查的设置。除非您运行自己的服务器,否则通常无法控制这些设置。
表 7-1
影响文件访问的 PHP 配置设置
|管理的
|
缺省值
|
描述
|
| — | — | — |
| allow_url_fopen
| 在 | 允许 PHP 脚本打开互联网上的公共文件 |
| allow_url_include
| 离开 | 控制包含远程文件的能力 |
表 7-1 中的设置都通过 URL 控制对文件的访问(与本地文件系统相反)。第一个是allow_url_fopen
,允许您读取远程文件,但不能将它们包含在脚本中。这通常是安全的,因此默认情况下启用它。
另一方面,allow_url_include
允许您在脚本中直接包含远程文件。这是一个主要的安全风险,所以默认情况下allow_url_include
是禁用的。
Tip
如果你的托管公司已经禁用了allow_url_fopen
,要求将其启用。否则,你将无法使用 PHP 解决方案 7-5。但是不要把名字搞混了:allow_url_include
在托管环境中应该总是关闭的。即使在您的网站上禁用了allow_url_fopen
,您仍然可以使用客户端 URL 库(cURL)访问有用的外部数据源,比如新闻提要和公共 XML 文档。详见 www.php.net/manual/en/book.curl.php
。
读取和写入文件
读写文件的能力有广泛的应用。例如,您可以打开另一个网站上的文件,将内容读入服务器内存,使用字符串和 XML 操作函数提取信息,然后将结果写入本地文件。您也可以在自己的服务器上查询数据库,并将数据输出为文本或 CSV(逗号分隔值)文件。您甚至可以生成开放文档格式或 Microsoft Excel 电子表格格式的文件。但首先,我们来看看基本操作。
在单个操作中读取文件
PHP 有三个函数可以在一次操作中读取文本文件的内容:
-
readfile() 打开一个文件,直接输出其内容。
-
file_get_contents() 将文件的全部内容读入一个字符串,但不生成直接输出。
-
file() 将每一行读入一个数组。
PHP 解决方案 7-1:获取文本文件的内容
这个 PHP 解决方案展示了使用readfile()
、file_get_contents()
和file()
访问文件内容的区别。
-
将
ch07
文件夹中的sonnet.txt
复制到你的private
文件夹中。这是一个包含莎士比亚十四行诗 116 的文本文件。 -
在 php8sols 站点根目录下创建一个名为
filesystem
的新文件夹,然后在新文件夹下创建一个名为get_contents.php
的 php 文件。将以下代码插入 PHP 块中(ch07
文件夹中的get_contents_01.php
显示了嵌入在网页中的代码,但是您可以只使用 PHP 代码进行测试):
readfile('C:/private/sonnet.txt');
如果您使用的是 Mac,请使用您自己的 Mac 用户名修改路径名,如下所示:
readfile('/Users/username/private/sonnet.txt');
如果您在 Linux 或远程服务器上进行测试,请相应地修改路径名。
Note
为简洁起见,本章中的其余示例只显示了 Windows 路径名。
- 保存
get_contents.php
并在浏览器中查看。您应该会看到类似下面的截图。浏览器会忽略原始文本中的换行符,并将莎士比亚的十四行诗显示为实心块:
Tip
如果您看到错误消息,请检查您键入的代码是否正确,以及在 Mac 或 Linux 上是否设置了正确的文件和文件夹权限。
-
PHP 有一个名为
nl2br()
的函数,将换行符转换为<br/>
标签(尾部斜杠是为了与 XHTML 兼容,在 HTML5 中有效)。把get_contents.php
里的代码改成这样(在get_contents_02.php
里): -
保存
get_contents.php
并在浏览器中重新加载。输出仍然是一个完整的文本块。当您像这样将一个函数作为参数传递给另一个函数时,内部函数的结果通常会传递给外部函数,在一个表达式中执行这两个操作。因此,您可能希望文件的内容在浏览器中显示之前被传递给nl2br()
。然而,readfile()
会立即输出文件的内容。当它完成的时候,已经没有什么可以让nl2br()
插入<br/>
标签了。文本已经在浏览器中。注意当两个函数像这样嵌套时,首先执行内部函数,然后外部函数处理结果。但是内部函数的返回值需要作为外部函数的参数有意义。
readfile()
的返回值是从文件中读取的字节数。即使您在行首添加了echo
,您得到的也只是添加到文本末尾的 594。在这种情况下,嵌套函数不起作用,但它通常是一种非常有用的技术,避免了在用另一个函数处理内部函数的结果之前将它存储在变量中的需要。 -
代替
readfile()
,你需要使用file_get_contents()
来将换行符转换成<br/>
标签。readfile()
只是输出文件的内容,file_get_contents()
将文件的内容作为一个字符串返回。由你决定如何处理它。像这样修改代码(或使用get_contents_03.php
):
nl2br(readfile('C:/private/sonnet.txt'));
- 在浏览器中重新加载页面。十四行诗的每一行现在都自成一行:
echo nl2br(file_get_contents('C:/private/sonnet.txt'));
-
file_get_contents()
的优点是你可以将文件内容赋给一个变量,并在决定如何处理它之前以某种方式处理它。像这样修改get_contents.php
中的代码(或者使用get_contents_04.php
,并将页面加载到浏览器中:$sonnet = file_get_contents('C:/private/sonnet.txt'); // replace new lines with spaces $words = str_replace("\r\n", ' ', $sonnet); // split into an array of words $words = explode(' ', $words); // extract the first nine array elements $first_line = array_slice($words, 0, 9); // join the first nine elements and display echo implode(' ', $first_line);
这将把sonnet.txt
的内容存储在一个名为$sonnet
的变量中,该变量被传递给str_replace()
,后者用空格替换回车符和换行符,并将结果存储为$words
。
Note
关于"\r\n"
的解释,参见第四章中的“在双引号内使用转义序列”。文本文件是在 Windows 中创建的,所以换行符由回车和换行符表示。在 macOS 和 Linux 上创建的文件只使用一个换行符("\n"
)。
然后$words
被传递给explode()
函数。这个名字令人担忧的函数“拆开”一个字符串,并将其转换为一个数组,使用第一个参数来确定在哪里断开字符串。在这种情况下,使用了一个空格,因此文本文件的内容被分成一个单词数组。
然后将单词数组传递给array_slice()
函数,该函数从第二个参数指定的位置开始从数组中取出一部分。第三个参数指定切片的长度。PHP 从 0 开始对数组计数,因此提取前九个单词。
最后,implode()
执行与explode()
相反的操作,连接数组的元素,并在每个元素之间插入第一个参数。结果由echo
显示,产生如下:
该脚本现在只显示第一行,而不是显示文件的全部内容。完整的字符串仍然存储在$sonnet
中。
-
然而,如果您想单独处理每一行,使用
file()
更简单,它将文件的每一行读入一个数组。为了显示sonnet.txt
的第一行,前面的代码可以简化成这样(参见get_contents_05.php
):$sonnet = file('C:/private/sonnet.txt'); echo $sonnet[0];
-
事实上,如果您不需要完整的数组,您可以使用一种称为数组解引用的技术直接访问一行,方法是在调用函数后在方括号中添加它的索引号。以下代码显示十四行诗的第 11 行(见
get_contents_06.php
):
echo file('C:/private/sonnet.txt')[10];
在我们刚刚探索的三个函数中,readfile()
只是读取一个文件的内容,并将其直接转储到输出中。您不能操作文件内容或从中提取信息。然而,readfile()
的一个实际用途是强制下载一个文件,你将在本章后面看到。
另外两个函数file_get_contents()
和file()
,允许您捕获变量中的内容,以便重新格式化或提取信息。唯一的区别是,file_get_contents()
将内容读入单个字符串,而file()
生成一个数组,其中每个元素对应文件中的一行。
Tip
file()
函数在每个数组元素的末尾保留换行符。如果想去掉换行符,将常量FILE_IGNORE_NEW_LINES
作为第二个参数传递给函数。您也可以使用FILE_SKIP_EMPTY_LINES
作为第二个参数来跳过空行。要删除换行符,跳过空行,用竖线分隔两个常量,像这样:FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES
。
虽然我们只对本地文本文件测试了file_get_contents()
和file()
,但是它们也可以从其他域的公共文件中获取内容。这使得它们对于访问其他网页上的信息非常有用,尽管提取信息通常需要对字符串函数和文档对象模型或 DOM 所描述的文档的逻辑结构有扎实的理解(参见 www.w3.org/TR/WD-DOM/introduction.html
)。
file_get_contents()
和file()
的缺点是将整个文件读入内存。对于非常大的文件,最好使用一次只处理文件一部分的函数。我们接下来会看这些。
为读/写操作打开和关闭文件
到目前为止,我们所看到的函数都是一次完成的。然而,PHP 也有一组函数,允许你打开一个文件,读它和/或写它,然后关闭文件。该文件可以在本地文件系统上,也可以是不同域上的公开文件。
以下是用于此类操作的最重要的功能:
-
fopen()
:打开一个文件 -
读取文件的内容,通常一次一行
-
fgetcsv()
:从 CSV 文件中获取当前行,并将其转换为数组 -
fread()
:读取指定数量的文件 -
fwrite()
:写入文件 -
feof()
:判断是否已经到达文件的末尾 -
rewind()
:将内部指针移回文件顶部 -
fseek()
:将内部指针移动到文件中的特定位置 -
fclose()
:关闭文件
第一个是fopen()
,它提供了一个令人困惑的选项来选择文件打开后如何使用:fopen()
有一个只读模式、四个只写模式和五个读/写模式。之所以有这么多,是因为它们让您可以控制是覆盖现有内容还是添加新内容。在其他时候,如果文件不存在,您可能希望 PHP 创建一个文件。
每种模式都决定了打开文件时放置内部指针的位置。这就像文字处理器中的光标:当你调用fread()
或fwrite()
时,PHP 从指针碰巧所在的地方开始读取或写入。
表格 7-2 指导您完成所有选项。
表 7-2
fopen()使用的读/写模式
|类型
|
方式
|
描述
|
| — | — | — |
| 只读 | r
| 最初放在文件开头的内部指针。 |
| 只写 | w
| 写入前删除现有数据。创建一个不存在的文件。 |
| | a
| 追加模式。在末尾添加了新数据。创建一个不存在的文件。 |
| | c
| 现有内容被保留,但是内部指针被放在文件的开头。创建一个不存在的文件。 |
| | x
| 仅在文件不存在时创建文件。如果已经有同名文件,则失败。 |
| 读/写 | r+
| 读/写操作可以按任意顺序进行,并且从内部指针所在的位置开始。指针最初放在文件的开头。文件必须已经存在,操作才能成功。 |
| w+
| 现有数据已删除。数据可以在写入后读回。创建一个不存在的文件。 |
| a+
| 打开一个文件,准备在文件末尾添加新数据。还允许在内部指针移动后回读数据。创建一个不存在的文件。 |
| c+
| 保留现有内容,并将内部指针放在文件的开头。如果文件不存在,则创建一个新文件。 |
| x+
| 创建新文件,但如果同名文件已经存在,则创建失败。数据可以在写入后读回。 |
选择了错误的模式,你可能最终会删除有价值的数据。您还需要注意内部指针的位置。如果指针在文件的末尾,而你试图读取内容,你最终会一无所获。另一方面,如果指针位于文件的开头,并且您开始写入,您将覆盖等量的现有数据。本章后面的“移动内部指针”对此有更详细的解释。
通过传递以下两个参数来使用fopen()
:
-
您要打开的文件的路径或 URL(如果文件在不同的域中)
-
包含表 7-2 中所列模式之一的字符串
fopen()
函数返回一个对打开文件的引用,该引用可用于其他读/写函数。这是打开文本文件进行阅读的方式:
$file = fopen('C:/private/sonnet.txt', 'r');
此后,将$file
作为参数传递给其他函数,比如fgets()
和fclose()
。通过一些实际的演示,事情会变得更清楚。您可能会发现使用ch07
文件夹中的文件比自己构建文件更容易。我将快速浏览每个模式。
Note
Mac 和 Linux 用户需要调整示例文件中private
文件夹的路径,以匹配他们的设置。
用 fopen()读取文件
文件fopen_read.php
包含以下代码:
// store the pathname of the file
$filename = 'C:/private/sonnet.txt';
// open the file in read-only mode
$file = fopen($filename, 'r');
// read the file and store its contents
$contents = fread($file, filesize($filename));
// close the file
fclose($file);
// display the contents with <br/> tags
echo nl2br($contents);
如果将它加载到浏览器中,您应该会看到以下输出:
结果与在get_contents_03.php
中使用file_get_contents()
相同。与file_get_contents()
不同,函数fread()
需要知道要读取多少文件。您需要提供第二个参数来指示字节数。例如,如果您只需要一个非常大的文件中的前 100 个左右的字符,这可能会很有用。但是,如果您想要整个文件,您需要将文件的路径名传递给filesize()
以获得正确的数字。
用fopen()
读取文件内容的另一种方法是使用fgets()
,它一次检索一行。这意味着您需要结合使用while
循环和feof()
来读取文件的末尾。fopen_readloop.php
中的代码是这样的:
$filename = 'C:/private/sonnet.txt';
// open the file in read-only mode
$file = fopen($filename, 'r');
// create variable to store the contents
$contents = ";
// loop through each line until end of file
while (!feof($file)) {
// retrieve next line, and add to $contents
$contents .= fgets($file);
}
// close the file
fclose($file);
// display the contents
echo nl2br($contents);
while
循环使用fgets()
一次一行地检索文件的内容——!feof($file)
等同于说“直到$file
结束”——并将它们存储在$contents
中。
使用fgets()
与使用file()
函数非常相似,因为它一次处理一行。不同的是,一旦你找到了你要找的信息,你就可以用fgets()
打破这个循环。如果您正在处理一个非常大的文件,这是一个显著的优势。file()
函数将整个文件加载到一个数组中,消耗内存。
PHP 解决方案 7-2:从 CSV 文件中提取数据
文本文件可以用作平面文件数据库,其中每条记录都存储在一行中,每个字段之间用逗号、制表符或其他分隔符分隔。这种类型的文件称为 CSV 文件。通常,CSV 代表逗号分隔的值,但是当使用制表符或不同的分隔符时,它也可以表示字符分隔的值。这个 PHP 解决方案展示了如何使用fopen()
和fgetcsv()
将 CSV 文件中的值提取到多维关联数组中。
- 将
ch07
文件夹中的weather.csv
复制到你的private
文件夹中。该文件包含以下逗号分隔值的数据:
city,temp
London,11
Paris,10
Rome,12
Berlin,8
Athens,19
第一行由文件其余部分的数据标题组成。共有五行数据,每行包含一个城市的名称和一个温度。
Caution
将数据存储为逗号分隔的值时,逗号后面不应有空格。如果添加空格,它将被视为数据字段的第一个字符。CSV 文件中的每一行都必须有相同数量的项目。
-
在
filesystem
文件夹中创建一个名为getcsv.php
的文件,使用fopen()
以读取模式打开weather.csv
: -
使用
fgetcsv()
从文件中提取第一行作为数组,然后将它赋给一个名为$titles
的变量:$titles = fgetcsv($file);
这会将
$titles
创建为一个数组,其中包含第一行(city 和 temp)的值。fgetcsv()
函数需要一个参数,即您打开的文件的引用。它还接受多达四个可选参数:-
线的最大长度:默认值为 0,表示无限制。
-
字段之间的分隔符:默认为逗号。
-
包围字符:如果字段包含分隔符作为数据的一部分,它们必须用引号括起来。双引号是默认设置。
-
转义符:默认为反斜杠。
-
$file = fopen('C:/private/weather.csv', 'r');
我们使用的 CSV 文件不需要设置任何可选参数。
-
在下一行,为将从 CSV 数据中提取的值初始化一个空数组:
-
从一行中提取值后,
fgetcsv()
移动到下一行。要从文件中获取剩余的数据,您需要创建一个循环。添加以下代码:while (!(feof($file)) { $data = fgetcsv($file); $cities[] = array_combine($titles, $data); }
$cities = [];
循环内部的代码将 CSV 文件的当前行作为数组分配给 d a t a ,然后使用 ‘ a r r a y c o m b i n e ( ) ‘ 函数生成一个关联数组,该数组被添加到 ‘ data,然后使用`array_combine()`函数生成一个关联数组,该数组被添加到` data,然后使用‘arraycombine()‘函数生成一个关联数组,该数组被添加到‘cities`数组中。这个函数需要两个参数,这两个参数都必须是元素个数相同的数组。这两个数组被合并,从第一个参数中提取结果关联数组的键,从第二个参数中提取值。
- 关闭 CSV 文件:
图 7-1
CSV 数据已被转换为多维关联数组
-
要检查结果,使用
print_r()
。用<pre>
标记包围它,使输出更容易阅读:echo '<pre>'; print_r($cities); echo '</pre>';
-
保存
getcsv.php
并将其载入浏览器。您应该会看到如图 7-1 所示的结果。
fclose($file);
-
这与
weather.csv
配合得很好,但是脚本可以做得更健壮。如果fgetcsv()
遇到一个空行,它将返回一个包含单个null
元素的数组,该数组在作为参数传递给array_combine()
时会产生一个错误。通过添加以粗体突出显示的条件语句来修改while
循环:while (!feof($file)) { $data = fgetcsv($file); if (empty($data[0])) { continue; } $cities[] = array_combine($titles, $data); }
如果 fgetcsv()遇到一个空行,它将返回一个包含单个 null 元素的数组。条件语句使用empty()
函数测试$data 数组中的第一个元素,如果变量不存在或等于false
,则返回 true。如果有一个空行,continue
关键字返回到循环的顶部,而不执行下一行。
您可以对照ch07
文件夹中的getcsv.php
来检查您的代码。
CSV FILES CREATED ON MACOS
PHP 经常很难检测在 Mac 操作系统上创建的 CSV 文件的行尾。如果fgetcsv()
无法从 CSV 文件中正确提取数据,请在脚本顶部添加以下代码行:
ini_set('auto_detect_line_endings', true);
这对性能的影响微乎其微,因此只有当 Mac 行尾导致 CSV 文件出现问题时,才应该使用它。
用 fopen()替换内容
第一种只写模式(w
)删除文件中的任何现有内容,因此对于需要频繁更新的文件非常有用。您可以用fopen_write.php
测试w
模式,它在DOCTYPE
声明上面有以下 PHP 代码:
<?php
// if the form has been submitted, process the input text
if (isset($_POST['putContents'])) {
// open the file in write-only mode
$file = fopen('C:/private/write.txt', 'w');
// write the contents
fwrite($file, $_POST['contents']);
// close the file
fclose($file);
}
?>
当页面中的表单被提交时,这段代码将把$_POST['contents'
的值写到一个名为write.txt
的文件中。fwrite()
函数有两个参数:文件的引用和你想写入的内容。
Note
你可能会遇到fputs()
而不是fwrite()
。这两个功能是相同的:fputs()
是fwrite()
的同义词。
如果您将fopen_write.php
加载到浏览器中,请在文本区域中键入一些内容,然后单击写入文件。PHP 创建write.txt
并将您输入的内容插入文本区域。因为这只是一个演示,所以我省略了任何检查来确保文件被成功写入。打开write.txt
来验证你的文本已经被插入。现在,在文本区域输入不同的内容,然后再次提交表单。从write.txt
中删除原始内容,并用新文本替换。
用 fopen()追加内容
append 模式不仅在末尾添加新内容,保留任何现有内容,而且如果文件不存在,它还可以创建一个新文件。fopen_append.php
中的代码看起来像这样:
// open the file in append mode
$file = fopen('C:/private/append.txt', 'a');
// write the contents followed by a new line
fwrite($file, $_POST['contents'] . PHP_EOL);
// close the file
fclose($file);
请注意,我在$_POST['contents']
后面连接了PHP_EOL
。这是一个 PHP 常量,表示使用操作系统的正确字符的新行。在 Windows 上,它插入一个回车和换行符,但是在 Mac 和 Linux 上只有一个换行符。
如果您将fopen_append.php
加载到浏览器中,键入一些文本,然后提交表单。它在私有文件夹中创建一个名为 append.txt 的文件,并插入您的文本。键入其他内容并再次提交表单;新文本应添加到先前文本的末尾,如下面的屏幕截图所示:
我们将在第十一章回到追加模式。
写入前锁定文件
在c
模式下使用fopen()
的目的是让你有机会在修改文件之前用flock()
锁定文件。
flock()
函数有两个参数:文件引用和一个指定锁应该如何操作的常量。有三种类型的操作:
-
LOCK_SH
获取共享锁进行读取。 -
获得一个写操作的独占锁。
-
LOCK_UN
解除锁定。
要在写入文件之前锁定文件,请在c
模式下打开文件并立即调用flock()
,如下所示:
// open the file in c mode
$file = fopen('C:/private/lock.txt', 'c');
// acquire an exclusive lock
flock($file, LOCK_EX);
这将打开文件,如果文件不存在,则创建它,并将内部指针放在文件的开头。这意味着您需要将指针移动到文件的末尾或删除现有内容,然后才能使用fwrite()
开始写入。
要将指针移动到文件的末尾,使用fseek()
函数,如下所示:
// move to end of file
fseek($file, 0, SEEK_END);
或者,通过调用ftruncate()
删除现有内容:
// delete the existing contents
ftruncate($file, 0);
在您完成写入文件后,您必须在调用fclose()
之前手动解锁它:
// unlock the file before closing
flock($file, LOCK_UN);
fclose($file);
Caution
如果您在关闭文件之前忘记解锁该文件,即使您自己可以打开它,其他用户和进程仍会锁定该文件。
防止覆盖现有文件
与其他写入模式不同,x
模式不会打开现有文件。它只创建一个准备写入的新文件。如果同名文件已经存在,fopen()
返回false
,防止您覆盖它。fopen_exclusive.php
中的处理代码是这样的:
// create a file ready for writing only if it doesn't already exist
// error control operator prevents error message from being displayed
if ($file = @ fopen('C:/private/once_only.txt', 'x')) {
// write the contents
fwrite($file, $_POST['contents']);
// close the file
fclose($file);
} else {
$error = 'File already exists, and cannot be overwritten.';
}
试图以x
模式写入现有文件会产生 PHP 警告和致命错误。将写和关闭操作包装在条件语句中消除了致命错误,但是fopen()
仍然会生成警告。fopen()
前面的错误控制操作符(@
)抑制警告。
将fopen_exclusive.php
加载到浏览器中,键入一些文本,然后单击写入文件。内容应该写入目标文件夹中的once_only.txt
。
如果您再次尝试,储存在$error
中的信息会显示在表单上方。
用 fopen()组合读/写操作
通过在前面的任何模式后添加一个加号(+
),文件被打开以进行读写。您可以按任意顺序执行任意数量的读取或写入操作,直到文件关闭。组合模式之间的区别如下:
-
r+
:文件必须已经存在;不会自动创建新的。内部指针放在开头,准备读取现有内容。 -
w+
:已有内容被删除,所以第一次打开文件时没有可读取的内容。 -
a+
:文件打开时,内部指针在末尾,准备追加新的素材,所以指针需要移回,才能读取任何内容。 -
c+
:文件以内部指针开头打开。 -
总是创建一个新文件,所以当文件第一次打开时没有什么可读的。
用fread()
或fgets()
读,用fwrite()
写,和以前一模一样。重要的是理解内部指针的位置。
移动内部指针
读取和写入操作总是从内部指针所在的地方开始,所以通常你希望它在文件的开头读取,在文件的结尾写入。
要将指针移到开头,将文件引用传递给rewind()
,如下所示:
rewind($file);
要将指针移动到文件的末尾,像这样使用fseek()
:
fseek($file, 0, SEEK_END);
您也可以使用fseek()
将内部指针移动到特定位置或相对于其当前位置。详见 www.php.net/manual/en/function.fseek
。
Tip
在追加模式(a
或a+
)下,无论指针的当前位置如何,内容总是被写到文件的末尾。
探索文件系统
PHP 的文件系统函数也可以打开目录(文件夹)并检查其内容。从 web 开发人员的角度来看,文件系统功能的实际用途包括构建显示文件夹内容的下拉菜单,以及创建提示用户下载文件(如图像或 PDF 文档)的脚本。
用 scandir()检查文件夹
函数的作用是:返回一个由指定文件夹中的文件和文件夹组成的数组。只需将文件夹(目录)的路径名作为字符串传递给scandir()
,并将结果存储在一个变量中,如下所示:
$files = scandir('../images');
您可以通过使用print_r()
显示数组的内容来检查结果,如下图所示(代码在ch07
文件夹中的scandir.php
):
由scandir()
返回的数组不仅仅包含文件。前两项称为点文件,代表当前文件夹和父文件夹。最后一项是一个名为thumbs
的文件夹。
该数组只包含每个项目的名称。如果你想要更多关于文件夹内容的信息,最好使用FilesystemIterator
类。
使用文件系统生成器检查文件夹的内容
FilesystemIterator
类可以让你遍历一个目录或文件夹的内容。它是标准 PHP 库(SPL)的一部分,是 PHP 的核心部分。SPL 的主要特性之一是一组专门的迭代器,这些迭代器可以用很少的代码创建复杂的循环。
因为它是一个类,所以您用关键字new
实例化一个FilesystemIterator
对象,并将您想要检查的文件夹的路径传递给构造函数,如下所示:
$files = new FilesystemIterator('../images');
与scandir()
不同,它不返回文件名数组,所以不能使用print_r()
来显示其内容。相反,它会创建一个对象,让您可以访问文件夹中的所有内容。要显示文件名,使用一个像这样的foreach
循环(代码在ch07
文件夹的iterator_01.php
中):
$files = new FilesystemIterator('../images');
foreach ($files as $file) {
echo $file . '<br>';
}
这会产生以下结果:
可以对该输出进行以下观察:
-
省略了表示当前文件夹和父文件夹的点文件。
-
显示的值代表文件的相对路径,而不仅仅是文件名。
-
因为截图是在 Windows 上拍摄的,所以在相对路径中使用了反斜杠。
在大多数情况下,反斜杠不重要,因为 PHP 接受 Windows 路径中的正斜杠或反斜杠。但是,如果您想从FilesystemIterator
的输出中生成 URL,可以选择使用 Unix 风格的路径。设置选项的一种方法是将一个常量作为第二个参数传递给FilesystemIterator()
,就像这样(参见iterator_02.php
):
$files = new FilesystemIterator('../images', FilesystemIterator::UNIX_PATHS);
或者,您可以像这样调用FilesystemIterator
对象上的setFlags()
方法(see iterator_03.php
):
$files = new FilesystemIterator('../images');
$files->setFlags(FilesystemIterator::UNIX_PATHS);
两者都产生如下屏幕截图所示的输出:
当然,这在 macOS 或 Linux 上不会有什么不同,但是设置这个选项会使您的代码更具可移植性。
Tip
SPL 类使用的常量都是类常量。它们总是以类名和范围解析操作符(两个冒号)为前缀。像这样冗长的名字使得使用带有 PHP 代码提示和代码补全的编辑程序非常值得。
虽然能够显示文件夹内容的相对路径很有用,但是使用FilesystemIterator
类的真正价值在于每次循环运行时,它都可以让您访问一个SplFileInfo
对象。SplFileInfo
类有近 30 种方法可以用来提取关于文件和文件夹的有用信息。表 7-3 列出了一些最有用的SplFileInfo
方法。
表 7-3
可通过 SplFileInfo 方法访问的文件信息
|方法
|
返回
|
| — | — |
| getFilename()
| 文件的名称 |
| getPath()
| 当前对象的相对路径减去文件名,或者如果当前对象是文件夹,则减去文件夹名 |
| getPathName()
| 当前对象的相对路径,包括文件名或文件夹名,具体取决于当前类型 |
| getRealPath()
| 当前对象的完整路径,包括文件名(如果适用) |
| getSize()
| 文件或文件夹的大小,以字节为单位 |
| isDir()
| 如果当前对象是文件夹(目录),则为 True |
| isFile()
| 如果当前对象是文件,则为 True |
| isReadable()
| 如果当前对象可读,则为 True |
| isWritable()
| 如果当前对象可写,则为 True |
要访问子文件夹的内容,请使用RecursiveDirectoryIterator
类。这深入到了文件夹结构的每一层,但是你需要把它和名字奇怪的RecursiveIteratorIterator
结合起来使用,就像这样(代码在iterator_04.php
):
$files = new RecursiveDirectoryIterator('../images');
$files->setFlags(RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($files);
foreach ($files as $file) {
echo $file->getRealPath() . '<br>';
}
Note
默认情况下,RecursiveDirectoryIterator
包括代表当前和父文件夹的点文件。要排除它们,需要将类的SKIP_DOTS
常量作为第二个参数传递给构造函数方法,或者使用setFlags()
方法。
如下面的截图所示,RecursiveDirectoryIterator
检查所有子文件夹的内容,显示thumbs
文件夹的内容,只需一次操作:
如果您只想查找特定类型的文件,该怎么办?提示另一个迭代器…
使用 RegexIterator 限制文件类型
RegexIterator
充当另一个迭代器的包装器,使用正则表达式(regex)作为搜索模式过滤其内容。假设您想在ch07
文件夹中找到文本和 CSV 文件。用于搜索.txt
和.csv
文件扩展名的正则表达式如下所示:
'/\.(?:txt|csv)$/i'
这个正则表达式以不区分大小写的方式匹配这两个文件扩展名。iterator_05.php
中的代码看起来像这样:
$files = new FilesystemIterator('.');
$files = new RegexIterator($files, '/\.(?:txt|csv)$/i');
foreach ($files as $file) {
echo $file->getFilename() . '<br>';
}
传递给FilesystemIterator
构造函数的点告诉它检查当前文件夹。然后原始的$files
对象作为第一个参数传递给RegexIterator
构造函数,正则表达式作为第二个参数,过滤后的集合被重新分配给$files
。在foreach
循环中,getFilename()
方法检索文件的名称。结果是这样的:
现在只列出了文本和 CSV 文件。所有的 PHP 文件都被忽略了。
我想到了这个阶段,你可能会想知道这是否能有任何实际用途。让我们在一个文件夹中构建一个图像下拉菜单。
PHP 解决方案 7-3:建立一个文件下拉菜单
使用数据库时,您通常需要特定文件夹中的图像或其他文件的列表。例如,您可能希望将图片与产品详细信息页面相关联。虽然您可以在文本字段中键入图像的名称,但是您需要确保图像在那里,并且拼写正确。通过自动构建下拉菜单,让 PHP 来完成这项艰巨的工作。它总是最新的,而且没有拼错名字的危险。
-
在
filesystem
文件夹中创建一个名为imagelist.php
的 PHP 页面。或者,使用ch07
文件夹中的imagelist_01.php
。 -
在
imagelist.php
中创建一个表单,插入一个只有一个<option>
的<select>
元素,就像这样(代码已经在imagelist_01.php
中了):<form method="post"> <select name="pix" id="pix"> <option value="">Select an image</option> </select> </form>
-
这个
<option>
是下拉菜单中唯一的静态元素。 -
按如下形式修改
<select>
元素:<select name="pix" id="pix"> <option value="">Select an image</option> <?php $files = new FilesystemIterator('../images'); $images = new RegexIterator($files, '/\.(?:jpg|png|gif|webp)$/i'); foreach ($images as $image) { $filename = $image->getFilename(); ?> <option value="<?= $filename ?>"><?= $filename ?></option> <?php } ?> </select>
确保images
文件夹的路径对于您站点的文件夹结构是正确的。用作RegexIterator
构造函数第二个参数的正则表达式匹配不区分大小写的文件,文件扩展名为.jpg
、.png
、.gif
和.webp
。
foreach
循环简单地获取当前图像的文件名,并将其插入到<option>
元素中。
保存imagelist.php
并将其加载到浏览器中。你应该会看到一个下拉菜单,列出了你的images
文件夹中的所有图片,如图 7-2 所示。
图 7-2
PHP 轻松地在特定文件夹中创建一个图片下拉菜单
当合并到在线表单中时,所选图像的文件名出现在$_POST
数组中,并由<select>
元素的name
属性标识——在本例中为$_POST['pix']
。仅此而已!
您可以将您的代码与ch07
文件夹中的imagelist_02.php
进行比较。
PHP 解决方案 7-4:创建一个通用文件选择器
之前的 PHP 解决方案依赖于对正则表达式的理解。使其适应其他文件扩展名并不困难,但是您需要小心,不要意外删除了一个重要的字符。除非正则表达式是您的专长,否则将代码包装在一个函数中可能更容易,该函数可用于检查特定文件夹并创建特定类型的文件名数组。例如,您可能想要创建一个 PDF 文档文件名数组或一个包含 PDF 和 Word 文档的数组。这是你怎么做的。
-
在
filesystem
文件夹中创建一个名为buildlist.php
的新文件。该文件将只包含 PHP 代码,所以删除任何由你的编辑程序插入的 HTML。 -
将以下代码添加到文件中:
function buildFileList(string $dir, string|array $extensions) { if (!is_dir($dir) && !is_readable($dir)) { return false; } else { if (is_array($extensions)) { $extensions = implode('|', $extensions); } } }
-
这定义了一个名为
buildFileList()
的函数,它有两个参数:-
$dir
:您要从中获取文件名列表的文件夹的路径。这必须是一个字符串。 -
$extensions
:函数签名使用了联合类型声明,这是 PHP 8 的新功能。它指定参数可以是字符串或数组。这应该是单个文件扩展名或文件扩展名数组。为了保持代码简单,扩展名不应包含前导句点。
-
该函数首先检查$dir
是否是一个文件夹并且可读。如果不是,函数返回false
,不再执行代码。
如果$dir
没问题,则执行else
块。它也以检查$extensions
是否是一个数组的条件语句开始。如果是,它被传递给implode()
, 用每个数组元素之间的竖线(|
)连接数组元素。正则表达式中使用竖线来表示可选值。假设下面的数组作为第二个参数传递给函数:
['jpg', 'png', 'gif']
条件语句将其转换为jpg|png|gif
。所以这个寻找jpg
或者png
或者gif
。但是,如果参数是字符串,它将保持不变。
-
现在可以构建正则表达式搜索模式,并将两个参数传递给
FilesystemIterator
和RegexIterator
,如下所示:function buildFileList(string $dir, string|array $extensions) { if (!is_dir($dir) && !is_readable($dir)) { return false; } else { if (is_array($extensions)) { $extensions = implode('|', $extensions); } $pattern = "/\.(?:{$extensions})$/i"; $folder = new FilesystemIterator($dir); $files = new RegexIterator($folder, $pattern); } }
regex 模式是使用双引号中的字符串和花括号中的$extensions
构建的,以确保 PHP 引擎正确解释它。复制代码时要小心。它不太容易读懂。
-
代码的最后一部分提取文件名来构建一个数组,数组被排序然后返回。完成的函数定义如下所示:
function buildFileList(string $dir, string|array $extensions) { if (!is_dir($dir) && !is_readable($dir)) { return false; } else { if (is_array($extensions)) { $extensions = implode('|', $extensions); } $pattern = "/\.(?:{$extensions})$/i"; $folder = new FilesystemIterator($dir); $files = new RegexIterator($folder, $pattern); $filenames = []; foreach ($files as $file) { $filenames[] = $file->getFilename(); } natcasesort($filenames); return $filenames; } }
这将初始化一个数组,并使用一个foreach
循环通过getFilename()
方法给它分配文件名。最后,数组被传递给natcasesort()
,它以自然的、不区分大小写的顺序对数组进行排序。“自然”的意思是包含数字的字符串的排序方式与人的排序方式相同。例如,计算机通常将img12.jpg
排在img2.jpg
之前,因为 12 中的 1 小于 2。使用natcasesort()
导致img2.jpg
在img12.jpg
之前。
- 若要使用该函数,请将文件夹路径和要查找的文件的文件扩展名用作参数。例如,您可以从这样的文件夹中获取所有 Word 和 PDF 文档:
$docs = buildFileList('folder_name', ['doc', 'docx', 'pdf']);
然后,您可以在 foreach 循环中遍历$docs
来构建一个select
列表的option
元素,方法与 PHP Solution 7–3 的第 3 步相同。
buildFileList()
功能的代码在ch07
文件夹的buildlist.php
中。
访问远程文件
在本地计算机或您自己的网站上读取、写入和检查文件非常有用。但是allow_url_fopen
也让你可以在互联网的任何地方获得公开的文件。您可以读取内容,将其保存到一个变量中,并在将它合并到您自己的页面或将信息保存到数据库之前用 PHP 函数对其进行操作。
一个警告:当从远程资源中提取材料以包含在您自己的页面中时,存在安全风险。例如,远程页面可能包含嵌入在<script>
标签或超链接中的恶意脚本。即使远程页面以已知的格式从可信的来源提供数据——比如来自 Amazon.com 数据库的产品细节、来自政府气象办公室的天气信息,或者来自报纸或广播公司的新闻提要——你也应该总是通过将它传递给htmlentities()
来净化内容(参见 PHP 解决方案 6-3)。除了将双引号转换为"
,htmlentities()
还将<
转换为& lt;
,将>
转换为& gt;
。这会以纯文本显示标签,而不是将其视为 HTML。
如果你想允许一些 HTML 标签,使用strip_tags()
函数代替。如果您将一个字符串传递给strip_tags()
,它将返回去掉所有 HTML 标签和注释的字符串。它还删除了 PHP 标签。第二个可选参数是您想要保留的标签列表。例如,下面的代码去掉了除段落、一级和二级标题之外的所有标签:
$stripped = strip_tags($original, '<p><h1><h2>');
消费新闻和其他 RSS 源
一些最有用的远程信息源来自 RSS 提要,您可能希望将它们合并到您的站点中。RSS 代表真正简单的联合,是 XML 的一种方言。XML 和 HTML 的相似之处在于它使用标签来标记内容。XML 标签不是定义段落、标题和图像,而是用来以可预测的层次结构组织数据。XML 是用纯文本编写的,所以它经常被用来在可能运行不同操作系统的计算机之间共享信息。
图 7-3 显示了 RSS 2.0 提要的典型结构。整个文档被包装在一对<rss>
标签中。这是根元素,类似于网页的<html>
标签。文档的其余部分被包装在一对<channel>
标签中,它们总是包含以下三个描述 RSS 提要的元素:<title>
、<description>
和<link>
。
图 7-3
RSS 提要的主要内容在 item 元素中
除了这三个必需的元素之外,<channel>
还可以包含许多其他元素,但是有趣的内容可以在<item>
元素中找到。对于新闻提要,这是可以找到单个新闻条目的地方。如果你正在查看一个博客的 RSS 提要,那么<item>
元素通常包含博客文章的摘要。
每个<item>
元素可以包含几个元素,但是图 7-3 中显示的元素是最常见的,通常也是最有趣的:
-
<title>
:项目的标题 -
<link>
:项目的 URL -
<pubDate>
:出版日期 -
<description>
:项目摘要
这种可预测的格式使得使用 SimpleXML 提取信息变得容易。
Note
你可以在 www.rssboard.org/rss-specification
找到完整的 RSS 规范。与大多数技术规范不同,它是用通俗易懂的语言编写的,易于阅读。
使用 SimpleXML
只要您知道 XML 文档的结构,SimpleXML 就像它在 tin 上所说的那样:它使得从 XML 中提取信息变得简单。第一步是将 XML 文档的 URL 传递给simplexml_load_file()
。还可以通过将路径作为参数传递来加载本地 XML 文件。例如,它从 BBC 获得世界新闻提要:
$feed = simplexml_load_file('http://feeds.bbci.co.uk/news/world/rss.xml');
这创建了一个SimpleXMLElement
类的实例。提要中的所有元素现在都可以通过使用元素名称作为$feed
对象的属性来访问。使用 RSS 提要,<item>
元素可以作为$feed->channel->item
被访问。
要显示每个<item>
的<title>
,创建一个foreach
循环,如下所示:
foreach ($feed->channel->item as $item) {
echo $item->title . '<br>';
}
如果你与图 7-3 比较,你可以看到你通过用->
操作符链接元素名来访问元素,直到你到达目标。由于有多个<item>
元素,您需要使用一个循环来进一步挖掘。或者,使用数组符号,如下所示:
$feed->channel->item[2]->title
这将获得第三个<item>
元素的<title>
。除非您只想要一个特定的值,否则使用循环会更简单。
了解了背景之后,让我们使用 SimpleXML 来显示新闻提要的内容。
PHP 解决方案 7-5:使用 RSS 新闻提要
这个 PHP 解决方案展示了如何使用 SimpleXML 从实时新闻提要中提取信息,然后将其显示在 web 页面上。它还展示了如何将<pubDate>
元素格式化为更加用户友好的格式,以及如何使用LimitIterator
类限制显示的项目数量。
图 7-4
新闻提要包含大量项目
-
在
filesystem
文件夹中创建一个名为newsfeed.php
的新页面。这个页面将包含 PHP 和 HTML 的混合。 -
这个 PHP 解决方案选择的新闻提要是 BBC 世界新闻。使用大多数新闻源的一个条件是你要知道来源。因此,在页面顶部添加格式为
<h1>
标题的最新 BBC 新闻。请注意在您自己的网站上使用 BBC 新闻源的条款和条件,请参见
www.bbc.co.uk/news/10628494#mysite
and
www.bbc.co.uk/usingthebbc/terms/can-i-share-things-from-the-bbc/
。 -
在标题下创建一个 PHP 块,并添加以下代码来加载提要:
$url = 'https://feeds.bbci.co.uk/news/world/rss.xml'; $feed = simplexml_load_file($url);
-
使用
foreach
循环访问<item>
元素并显示每个元素的<title>
:foreach ($feed->channel->item as $item) { echo htmlentities($item->title) . '<br>'; }
-
保存
newsfeed.php
并在浏览器中加载页面。您应该会看到一长串类似于图 7-4 的新闻条目。 -
正常的提要通常包含 30 个或更多的条目。对于一个新闻网站来说这很好,但是你可能希望在你自己的网站中有一个较短的选择。使用另一个 SPL 迭代器选择特定范围的项目。像这样修改代码:
$url = 'http://feeds.bbci.co.uk/news/world/rss.xml'; $feed = simplexml_load_file($url, 'SimpleXMLIterator'); $filtered = new LimitIterator($feed->channel->item, 0 , 4); foreach ($filtered as $item) { echo htmlentities($item->title) . '<br>'; }
要在 SPL 迭代器中使用 SimpleXML,您需要提供SimpleXMLIterator
类名作为simplexml_load_file()
的第二个参数。然后,您可以将想要影响的 SimpleXML 元素传递给迭代器构造函数。
在这种情况下,$feed->channel->item
被传递给LimitIterator
构造函数。LimitIterator
有三个参数:想要限制的对象、起点(从 0 开始计数)和想要循环运行的次数。这段代码从第一项开始,并将项数限制为四。
foreach
循环现在遍历$filtered
结果。如果你再次测试这个页面,你会看到只有四个标题,如图 7-5 所示。如果头条的选择和以前不一样,不要惊讶。BBC 新闻网站每分钟都在更新。
图 7-5
LimitIterator
限制显示的项目数量
-
现在您已经限制了条目的数量,修改
foreach
循环以将<title>
元素包装在到原始文章的链接中,然后显示<pubDate>
和<description>
条目。该循环如下所示:foreach ($filtered as $item) { ?> <h2><a href="<?= htmlentities($item->link) ?>"> <?= htmlentities($item->title)?></a></h2> <p class="datetime"><?= htmlentities($item->pubDate) ?></p> <p><?= htmlentities($item->description) ?></p> <?php } ?>
-
保存页面并再次测试。这些链接直接把你带到 BBC 网站上的相关新闻报道。新闻提要现在可以使用了,但是
<pubDate>
格式遵循 RSS 规范中规定的格式,如下面的截图所示:
-
为了以更加用户友好的方式格式化日期和时间,将
$item->pubDate
传递给DateTime
类构造函数,然后使用DateTime format()
方法来显示它。更改foreach
循环中的代码,如下所示:<p class="datetime"><?php $date = new DateTime($item->pubDate); echo $date->format('M j, Y, g:ia'); ?></p>
这将日期重新格式化如下:
神秘的 PHP 日期格式字符串在第十六章中有解释。
-
那看起来好多了,但是时间还是用 GMT(伦敦时间)表示。如果你网站的大多数访问者住在美国东海岸,你可能想显示当地时间。这对于一个
DateTime
对象来说没有问题。使用setTimezone()
方法更改为纽约时间。你甚至可以自动显示 EDT(东部夏令时)或 EST(东部标准时间),这取决于夏令时是否有效。像这样修改代码:<p class="datetime"><?php $date = new DateTime($item->pubDate); $date->setTimezone(new DateTimeZone('America/New_York')); $offset = $date->getOffset(); $timezone = ($offset == -14400) ? ' EDT' : ' EST'; echo $date->format('M j, Y, g:ia') . $timezone; ?></p>
要创建一个DateTimeZone
对象,将在 www.php.net/manual/en/timezones.php
列出的时区之一作为参数传递给它。这是唯一需要DateTimeZone
对象的地方,所以它被直接创建为setTimezone()
方法的参数。
没有专门的方法告诉您夏令时是否在运行,但是getOffset()
方法返回时间与协调世界时(UTC)的偏差秒数。下面一行决定是显示 EDT 还是 EST:
$timezone = ($offset == -14400) ? ' EDT' : ' EST';
这使用了带有三元运算符的值$offset
。在夏季,纽约比世界协调时晚 4 小时(14440 秒)。因此,如果$offset
为 14400,则条件等同于true
,EDT 被分配给$timezone
。否则,使用 EST。
最后,$timezone
的值被连接到格式化的时间。用于$timezone
的字符串有一个前导空格来分隔时区和时间。当页面被加载时,时间被调整到美国东海岸,如下所示:
图 7-6
实时新闻提要只需要十几行 PHP 代码
- 所有的网页现在需要的是用 CSS 来美化。图 7-6 显示了在
styles
文件夹中使用newsfeed.css
样式的新闻提要。
虽然我在这个 PHP 解决方案中使用了 BBC 新闻提要,但它应该可以与任何 RSS 2.0 提要一起工作。比如可以用 http://rss.cnn.com/rss/edition.rss
局部尝试一下。在公共网站上使用 CNN 新闻需要获得 CNN 的许可。在将提要合并到网站之前,一定要检查版权所有者的条款和条件。
创建下载链接
在线论坛中经常出现的一个问题是“我如何创建一个链接到一个图片(或 PDF 文件)来提示用户下载它?”快速的解决方法是将文件转换成压缩格式,比如 ZIP。这通常会导致较小的下载量,但缺点是没有经验的用户可能不知道如何解压缩文件,或者他们可能使用的是不包含解压缩功能的旧操作系统。使用 PHP 文件系统函数,很容易创建一个链接,自动提示用户下载原始格式的文件。
PHP 解决方案 7-6:提示用户下载图像
这个 PHP 解决方案发送必要的 HTTP 头,并使用readfile()
以二进制流的形式输出文件内容,迫使浏览器下载它。
-
在
filesystem
文件夹中创建一个名为download.php
的 PHP 文件。下一步将给出完整的列表。你也可以在ch07
文件夹的download.php
中找到。 -
删除脚本编辑器创建的任何默认代码,并插入以下代码:
<?php
// define error page
$error = 'http://localhost/php8sols/error.php';
// define the path to the download folder
$filepath = 'C:/xampp/htdocs/php8sols/img/';
$getfile = NULL;
// block any attempt to explore the filesystem
if (isset($_GET['file']) && basename($_GET['file']) == $_GET['file']) {
$getfile = $_GET['file'];
} else {
header("Location: $error");
exit;
}
if ($getfile) {
$path = $filepath . $getfile;
// check that it exists and is readable
if (file_exists($path) && is_readable($path)) {
// send the appropriate headers
header('Content-Type: application/octet-stream');
header('Content-Length: '. filesize($path));
header('Content-Disposition: attachment; filename=' . $getfile);
header('Content-Transfer-Encoding: binary');
// output the file content
readfile($path);
} else {
header("Location: $error");
}
}
在这个脚本中,您需要修改的只有两行以粗体突出显示。第一个定义了$error
,一个包含错误页面 URL 的变量。需要修改的第二行定义了存储下载文件的文件夹的路径。
该脚本的工作方式是从附加到 URL 的查询字符串中获取要下载的文件的名称,并将其保存为$getfile
。因为查询字符串很容易被篡改,$getfile
最初被设置为NULL
。如果做不到这一点,就可能让恶意用户访问服务器上的任何文件。
开始条件语句使用basename()
来确保攻击者不能从文件结构的另一部分请求文件,比如存储密码的文件。正如在第五章中解释的那样,basename()
提取路径的文件名部分,所以如果basename($_GET['file'])
不同于$_GET['file']
,你知道有人试图探测你的服务器。然后,您可以通过使用header()
函数将用户重定向到错误页面来阻止脚本继续运行。
在检查请求的文件存在并且可读之后,脚本发送适当的 HTTP 头,并使用readfile()
将文件发送到输出缓冲区。如果找不到该文件,用户将被重定向到错误页面。
-
通过创建另一个页面来测试脚本;给
download.php
添加几个链接。在每个链接的末尾添加一个查询字符串,后跟要下载的文件的名称。您将在ch07
文件夹中找到一个名为getdownloads.php
的页面,其中包含以下两个链接: -
单击其中一个链接。根据您的浏览器设置,该文件将被下载到您的默认下载文件夹,或者会出现一个对话框,询问您如何处理该文件。
<p><a href="download.php?file=fountains.jpg">Download fountains image</a></p>
<p><a href="download.php?file=monk.jpg">Download monk image</a></p>
我已经用图像文件演示了download.php
,但是它可以用于任何类型的文件,因为头文件以二进制流的形式发送文件。
Caution
这个脚本依靠header()
向浏览器发送适当的 HTTP 头。确保开始的 PHP 标签前面没有新行或空白是至关重要的。如果你删除了所有的空格,仍然得到一个错误信息“头已经发送”,你的编辑器可能在文件的开头插入了不可见的控制字符。一些编辑程序会插入字节顺序标记(BOM ),这已知会导致header()
函数出现问题。检查您的程序首选项,以确保取消选择插入 BOM 表的选项。
第三章回顾
文件系统函数并不特别难使用,但是有许多微妙之处可以将看似简单的任务变成复杂的任务。检查您是否拥有正确的权限非常重要。即使在您自己的网站中处理文件,PHP 也需要权限来访问您想要读取或写入文件的任何文件夹。
SPL FilesystemIterator
和RecursiveDirectoryIterator
类使得检查文件夹的内容变得容易。与SplFileInfo
方法和RegexIterator
结合使用,您可以在文件夹或文件夹层次结构中快速找到特定类型的文件。
当处理远程数据源时,您需要检查allow_url_fopen
没有被禁用。远程数据源最常见的用途之一是从 RSS 新闻提要或 XML 文档中提取信息,多亏了 SimpleXML,这项任务只需要几行代码。
在本书的后面,我们将把本章中的一些 PHP 解决方案应用到处理图像和构建一个简单的用户认证系统的实际应用中。
八、使用数组
数组是 PHP 中最通用的数据类型之一。有 80 多个核心函数专门用于处理存储在数组中的数据,这一事实反映了它们的重要性。它们通常可以分类为修改、排序、比较和从数组中提取信息。本章并不试图涵盖所有这些。它主要关注一些更有趣和有用的数组操作应用。
本章涵盖
-
了解修改数组内容的各种方法
-
合并数组
-
将数组转换为符合语法的字符串
-
寻找一个数组的所有排列
-
排序数组
-
从多维数组自动生成嵌套的 HTML 列表
-
从 JSON 中提取数据
-
将数组元素赋给变量
-
用 splat 操作符解包数组
修改数组元素
PHP 新手在尝试修改数组中的每个元素时,经常会感到很困惑。比方说,您想要对一个数字数组中的每个元素执行计算。最简单的方法似乎是使用一个循环,在循环内执行计算,然后将结果重新分配给当前元素,如下所示:
$numbers = [2, 4, 7];
foreach ($numbers as $number) {
$number *= 2;
}
看起来好像它应该工作;但事实并非如此。$numbers
数组中的值保持不变。发生这种情况是因为 PHP 在一个循环中对数组的一个副本进行操作。当循环结束时,副本被丢弃,计算结果也随之丢弃。要更改原始数组,需要通过引用将每个元素的值传递到循环中。
PHP 解决方案 8-1:用循环修改数组元素
这个 PHP 解决方案展示了如何使用一个foreach
循环来修改数组中的每个元素。索引数组和关联数组的技术是相似的。
-
打开
ch08
文件夹中的modify_01.php
。它包含前一节中的代码,后跟一对<pre>
标记之间的print_r($numbers);
。 -
将页面加载到浏览器中,以验证$ numbers 数组中的值没有改变,如下面的屏幕截图所示:
-
通过引用将每个数组元素的值传递给循环,方法是在循环声明中的临时变量前加上一个&符号,如下所示:
-
当循环结束时,临时变量仍将包含最后一个数组元素的重新计算值。为了避免以后意外更改值,建议在循环后取消设置临时变量,如下所示:
foreach ($numbers as &$number) { $number *= 2; } unset($number);
-
保存文件并将其加载到浏览器中,以测试修改后的代码(在
modify_02.php
中)。数组中的每个数字都应该是双精度的,如下图所示:
foreach ($numbers as &$number) {
-
要修改关联数组的值,需要为键和值声明临时变量;但是只有值应该通过引用传递。以下代码在
modify_03.php
中:$book = [ 'author' => 'David Powers', 'title' => 'PHP 8 Solutions' ]; foreach ($book as $key => &$value) { $book[$key] = strtoupper($value); } unset($value);
这会产生以下输出:
- 然而,假设您想要修改数组键。合乎逻辑的方法是在键前面加一个&符号,通过引用传递它,如下所示:
foreach ($book as &$key => $value) {
但是,如果您尝试这样做,将会触发致命错误。数组键不能通过引用传递。只有数组值可以。
-
要修改关联数组的每个键,只需在循环内部修改它,就像在循环外部一样。以下代码在
modify_04.php
中:foreach ($book as $key => $value) { $book[ucfirst($key)] = $value; }
它产生以下输出:
-
如前面的屏幕截图所示,原始密钥与修改后的密钥一起保留。如果您只想要修改过的键,您需要像这样在循环中取消原始键的设置(代码在
modify_05.php
中):foreach ($book as $key => $value) { $book[ucfirst($key)] = $value; unset($book[$key]); }
这仅保留每个密钥的修改版本。
Tip
如果您想将数组键转换成大写或小写,简单的方法是使用下面 PHP 解决方案中描述的array_change_key_case()
函数。
PHP 解决方案 8-2:用 array_walk()修改数组元素
使用循环修改数组元素的另一种方法是使用array_walk()
函数,它对数组的每个元素应用一个回调函数。回调可以是一个匿名函数、一个箭头函数(参见第四章中的“使用箭头函数的简明匿名语法”),或者一个已定义函数的名称。默认情况下,array_walk()
向回调传递两个参数:元素的值和键— ,顺序是。也可以使用可选的第三个参数。这个 PHP 解决方案探索了使用array_walk()
的各种方式。
-
ch08
文件夹中array_walk_01.php
的主代码如下所示:$numbers = [2, 4, 7]; array_walk($numbers, fn (&$val) => $val *= 2);
array_walk()
的第一个参数是回调函数将应用到的数组。第二个参数是回调,在本例中是一个箭头函数。与foreach
循环一样,值需要通过引用传递,因此回调函数的第一个参数前面有一个&符号。
这个例子修改了一个索引数组,所以不需要将数组键作为第二个参数传递给回调函数。
像这样应用array_walk()
会产生与前面的 PHP 解决方案中的modify_02.php
相同的结果:$numbers
数组中的每个值都加倍。
-
当对关联数组使用 array_walk()时,如果只想修改值,就不需要将数组键作为参数传递给回调函数。array_walk_02.php 中的代码使用箭头函数将每个数组元素的值转换为大写字符串,如下所示:
$book = [ 'author' => 'David Powers', 'title' => 'PHP 8 Solutions' ]; array_walk($book, fn (&$val) => $val = strtoupper($val));
这产生了与前面 PHP 解决方案中的modify_03.php
相同的输出。
-
除了将匿名或箭头函数作为第二个参数传递给
array_walk()
,您还可以将已定义函数的名称作为字符串传递,如下所示(代码在array_walk_03.php
):array_walk($book, 'output'); function output (&$val) { return $val = strtoupper($val); }
这将产生与前面示例相同的输出。如果函数定义在同一个文件中,它是在调用array_walk()
之前还是之后都没关系。但是,如果定义在一个外部文件中,那么在调用array_walk()
之前必须包含该文件。
-
传递给
array_walk()
的回调函数最多可以有三个参数。第二个参数必须是数组键,而最后一个参数可以是您想要使用的任何其他值。当使用第三个参数时,它也作为第三个参数传递给array_walk()
。下面在array_walk_04.php
的例子演示了它的用法:array_walk($book, 'output', 'is'); function output (&$val, $key, $verb) { return $val = "The $key of this book $verb $val."; }
这会产生以下输出:
-
使用
array_walk()
,你不能修改数组键。如果你只是想把所有的键都改成大写或小写,使用array_change_key_case()
。默认情况下,它将键转换为小写。与array_walk()
不同,它不修改原始数组。它返回一个带有修改过的键的新数组,所以您需要将结果赋给一个变量。在array_change_key_case_01.php
中,数组键已经被赋予了一个初始的 cap。以下代码将密钥转换为小写,并将结果重新分配给$book
:$book = [ 'Author' => 'David Powers', 'Title' => 'PHP 8 Solutions' ]; $book = array_change_key_case($book);
-
要将密钥转换为大写,将 PHP 常量
CASE_UPPER
作为第二个参数传递给array_change_key_case()
,如下所示(代码在array_change_key_case_02.php
中):
$book = array_change_key_case($book, CASE_UPPER);
PHP 解决方案 8-3:用 array_map()修改数组元素
通过引用一个foreach
循环或array_walk()
来传递数组值会修改原始数组。往往,这就是你想要的。但是,如果你想保留原来的数组,可以考虑使用array_map()
。这将对每个数组元素应用一个回调函数,并返回一个包含已修改元素的新数组。array_map()的第一个参数是回调函数,可以是匿名函数、箭头函数或已定义函数的名称。第二个参数是要修改其元素的数组。
如果回调使用多个参数,那么每个参数的值必须以数组的形式传递给array_map()
,传递顺序与回调所需的顺序相同。即使您想在随后的参数中每次都使用相同的值,也必须将它作为一个数组传递给array_map()
,该数组的元素数量与被修改的数组相同。
对于关联数组,array_map()
只在回调使用单个参数时保留键。如果将多个参数传递给回调函数,array_map()
将返回一个索引数组。
-
array_map_01.php
中的代码展示了一个简单的例子,使用箭头回调函数使用array_map()
将数组中的数字加倍。代码如下所示:$numbers = [2, 4, 7]; $doubled = array_map(fn ($num) => $num * 2}, $numbers); echo '<pre>'; print_r($numbers); print_r($doubled); echo '</pre>';
如下图所示,原始$numbers
数组中的值没有改变。$doubled
数组包含回调返回的结果。
-
array_map_02.php
中的下一个例子使用一个已定义的函数来修改一个关联数组:$book = [ 'author' => 'David Powers', 'title' => 'PHP 8 Solutions' ]; $modified = array_map('modify', $book); function modify($val) { return strtoupper($val); } echo '<pre>'; print_r($book); print_r($modified); echo '</pre>';
如下面的屏幕截图所示,数组键保留在修改后的数组中:
-
array_map_03.php
中的代码已被修改,以演示如何向回调函数传递多个参数:$descriptions = ['British', 'the fifth edition']; $modified = array_map('modify', $book, $descriptions); function modify($val, $description) { return "$val is $description."; }
第二个参数$description
被添加到modify()
函数中。作为参数传递给回调的值存储在一个名为$descriptions
的数组中,该数组作为第三个参数传递给array_map()
。这会产生以下结果:
请注意,修改后的数组中没有保留数组键。向回调传递多个参数会产生一个索引数组。
-
传递给
array_map()
的第三个和后续参数必须包含与被修改的数组相同数量的元素。array_map_04.php
中的代码显示了如果一个参数包含的元素太少会发生什么。看起来是这样的:$descriptions = ['British', 'the fifth edition']; $label = ['Description']; $modified = array_map('modify', $book, $descriptions, $label); function modify($val, $description, $label) { return "$label: $val is $description."; }
在$label
数组中只有一个元素;但是如下图所示,这不会导致相同的值被重用。
当作为参数传递给array_map()
的数组的元素比第一个数组(被修改的那个)少时,较短的数组用空元素填充。因此,修改后的数组中的第二个元素省略了标签;但是 PHP 不会触发错误。
合并数组
PHP 提供了几种不同的方法来组合两个或多个数组的元素;但是它们并不总是产生相同的结果。理解每种方法的工作原理将会避免错误和混乱。
使用数组联合运算符
合并数组最简单的方法是使用数组联合操作符,一个加号(+
)。然而,结果可能不是你所期望的。在ch08
文件夹的merge_01.php
中的代码演示了当你在两个索引数组上使用数组联合操作符时会发生什么:
$first = ['PHP', 'JavaScript'];
$second = ['Java', 'R', 'Python'];
$languages = $first + $second;
echo '<pre>';
print_r($languages);
echo '</pre>';
运行该脚本会产生以下输出:
结果数组只包含三个元素,而不是五个元素。这是因为数组联合运算符不会将第二个数组连接到第一个数组的末尾。对于索引数组,它忽略第二个数组中与第一个数组中的元素具有相同索引的元素。在这个例子中,第二个数组中的 Java 和 R 与 PHP 和 JavaScript 具有相同的索引(0 和 1),所以它们被忽略了。只有 Python 有一个第一个数组中不存在的索引(2),所以它被添加到合并后的数组中。
数组联合运算符对关联数组的处理方式类似。merge_02.php
中的代码包含两个关联数组,如下所示:
$first = ['PHP' => 'Rasmus Lerdorf', 'JavaScript' => 'Brendan Eich'];
$second = ['Java' => 'James Gosling', 'R' => 'Ross Ihaka', 'Python' => 'Guido van Rossum'];
$lead_developers = $first + $second;
两个数组都包含一组唯一的键,因此生成的数组包含每个元素及其相关的键,如下面的屏幕截图所示:
然而,当存在重复的键时,数组联合操作符忽略第二个数组中的元素,如merge_03.php
中的代码所示:
$first = ['PHP' => 'Rasmus Lerdorf', 'JavaScript' => 'Brendan Eich', 'R' => 'Robert Gentleman'];
$second = ['Java' => 'James Gosling', 'R' => 'Ross Ihaka', 'Python' => 'Guido van Rossum'];
$lead_developers = $first + $second;
如下面的屏幕截图所示,只有 Robert Gentleman 被认为是 R. Ross Ihaka 的首席开发人员。第二个数组中的 Ihaka 被忽略,因为他共享一个重复的密钥。
忽略重复的索引或键并不总是您想要的,所以 PHP 提供了几个函数来产生所有元素的完全合并数组。
使用 array_merge()和 array_merge_recursive()
函数array_merge()
和array_merge_recursive()
连接两个或多个数组来创建一个新数组。它们之间的区别在于处理关联数组中重复值的方式。
对于索引数组,array_merge()
自动对每个元素的索引重新编号,并包含每个值,包括重复值。这由merge_04.php
中的以下代码演示:
$first = ['PHP', 'JavaScript', 'R'];
$second = ['Java', 'R', 'Python', 'PHP'];
$languages = array_merge($first, $second);
如下面的屏幕截图所示,索引是连续编号的,重复的值(PHP 和 R)保留在结果数组中:
对于关联数组,array_merge()
的行为取决于重复数组键的存在。当没有重复时,array_merge()
以与使用数组联合操作符完全相同的方式连接关联数组。你可以通过运行merge_05.php
中的代码来验证。
但是,重复键的存在会导致只保留最后一个重复值。这由merge_06.php
中的以下代码演示:
$first = ['PHP' => 'Rasmus Lerdorf', 'JavaScript' => 'Brendan Eich', 'R' => 'Robert Gentleman'];
$second = ['Java' => 'James Gosling', 'R' => 'Ross Ihaka', 'Python' => 'Guido van Rossum'];
$lead_developers = array_merge($first, $second);
如下面的屏幕截图所示,第二个数组(Ross Ihaka)中的 R 值覆盖了第一个数组(Robert Gentleman)中的值:
Caution
数组合并的顺序不同于数组联合运算符。数组 union 操作符保留第一个重复值,而array_merge()
保留最后一个重复值。
要保留重复键的值,需要使用array_merge_recursive()
。merge_07.php
中的代码合并了相同的数组,如下所示:
$lead_developers = array_merge_recursive($first, $second);
如下图所示,重复键的值被合并到一个索引子数组中:
Robert Gentleman 的名字作为$lead_developers['R'][0]
存储在新数组中。
Note
数组联合运算符、array_merge()
和array_merge_recursive()
可以用于两个以上的数组。关于重复键和值的规则是相同的。使用array_merge()
,总是最后一个副本被保存下来。
将两个索引数组合并成一个关联数组
array_combine()
函数合并两个索引数组来创建一个关联数组,第一个数组用于键,第二个数组用于值。两个数组必须有相同数量的值。否则,该函数返回 false 并触发警告。
下面的简单例子展示了它是如何工作的:
$colors = ['red', 'amber', 'green'];
$actions = ['stop', 'caution', 'go'];
$signals = array_combine($colors, $actions);
// $signals is ['red' => 'stop', 'amber' => 'caution', 'green' => 'go']
Tip
关于array_combine()
的实际用法,请参见“PHP 解决方案 7-2:从 CSV 文件中提取数据”。
比较数组
表 8-1 列出了 PHP 核心函数,可以用来寻找数组的不同或交集。表中的所有函数都接受两个或更多的数组作为参数。在回调函数执行比较的情况下,回调应该是传递给函数的最后一个参数。
表 8-1
比较数组的 PHP 函数
|功能
|
描述
|
| — | — |
| array_diff()
| 将第一个数组与一个或多个其他数组进行比较。返回第一个数组中不存在于其他数组中的值的数组。 |
| array_diff_assoc()
| 与array_diff()
类似,但在比较中同时使用数组键和值。 |
| array_diff_key()
| 与array_diff()
类似,但比较的是键而不是值。 |
| array_diff_uassoc()
| 与array_diff_assoc()
相同,但使用用户提供的回调函数来比较按键。 |
| array_diff_ukey()
| 与array_diff_key()
相同,但使用用户提供的回调函数来比较按键。 |
| array_intersect()
| 比较两个或多个数组。返回一个数组,该数组包含第一个数组中出现在所有其他数组中的所有值。密钥被保留。 |
| array_intersect_assoc()
| 类似于array_intersect()
,但是在比较中同时使用数组键和值。 |
| array_intersect_key()
| 返回一个数组,该数组包含第一个数组中的所有条目,这些条目的键在所有其他数组中都存在。 |
| array_intersect_uassoc()
| 与array_intersect_assoc()
相同,但使用用户提供的回调函数来比较按键。 |
| array_intersect_ukey()
| 与array_intersect_key()
相同,但使用用户提供的回调函数来比较按键。 |
我不会深入每个函数的细节,但是让我们来看看通过比较下面两个带有array_diff_assoc()
和array_diff_key()
的数组返回的不同结果:
$first = [
'PHP' => 'Rasmus Lerdorf',
'JavaScript' => 'Brendan Eich',
'R' => 'Robert Gentleman'];
$second = [
'Java' => 'James Gosling',
'R' => 'Ross Ihaka',
'Python' => 'Guido van Rossum'];
$diff = array_diff_assoc($first, $second); // $diff is the same as $first
array_diff_assoc()
(参见ch08
文件夹中的array_diff_assoc.php
)检查键和值,返回存在于第一个数组中但不存在于其他数组中的元素数组。在本例中,返回第一个数组中的所有三个元素,即使两个数组都包含 R 作为键。这是因为分配给 R 的值是不同的。
$diff = array_diff_key($first, $second);
// $diff is ['PHP' => 'Rasmus Lerdorf','JavaScript' => 'Brendan Eich']
然而,array_diff_key()
(见ch08
文件夹中的array_diff_key.php
)只检查键,忽略值。因此,它返回第一个数组的前两个元素,但不返回第三个元素,因为 R 在第二个数组中作为键存在。分配给 R 的值不同这一事实无关紧要。
ch08
文件夹包含表 8-1 中其他功能的简单示例,并附有简要说明。*_uassoc()
和*_ukey()
版本需要一个回调函数作为最终参数来比较每个元素的键。回调必须接受两个参数,如果第一个参数分别小于、等于或大于第二个参数,则返回小于、等于或大于零的整数。ch08
文件夹中的示例使用内置的 PHP strcasecmp()
函数来执行不区分大小写的比较,如果两个字符串被认为相等,则返回0
。
Tip
比较两个值最有效的方法是使用宇宙飞船运算符。你会在本章后面的“PHP 解决方案 8-5:用飞船操作符自定义排序”中看到一个例子。
删除重复元素
要从单个数组中删除重复的元素,使用array_unique()
,它接受一个输入数组并返回一个删除了重复值的新数组。在ch08
文件夹的unique_01.php
中的代码包含以下简单的例子:
$original = ['John', 'john', 'Elton John', 'John', 'Elton John', 42, "42"];
$unique = array_unique($original);
print_r($unique);
这将产生如下屏幕截图所示的输出:
默认情况下,array_unique()
将每个值转换为一个字符串,并执行严格的比较。结果,“john”和“John”都被保留,因为比较区分大小写。因为整数被转换为字符串,所以$original
数组中的最后两项被认为是重复的。如截图所示,保留了原来的键,表示第四个和第五个元素已被删除。
array_unique()
函数也适用于关联数组。unique_02.php
中的例子如下所示:
$tracks = [
'The Beatles' => 'With a Little Help from my Friends',
'Joe Cocker' => 'With A Little Help From My Friends',
'Wet Wet Wet' => 'With a Little Help from my Friends',
'Paul McCartney' => 'Yesterday'
];
$unique = array_unique($tracks);
echo '<pre>';
print_r($unique);
echo '</pre>';
这会产生以下输出:
字符串比较区分大小写,因此 Wet Wet Wet 被排除在外。
PHP 解决方案 8-4:用逗号连接数组
内置的 PHP implode()
函数用用户提供的字符串连接数组的所有元素。这个 PHP 解决方案通过在最后一个元素前插入“and”来增强输出。它提供了限制元素数量的选项,用“and one other”或“and others”替换多余的值
-
打开
ch08
文件夹中的commas_01.php
。它包含一系列索引数组,包含 20 世纪 60 年代和 70 年代的 0 到 5 个录音艺术家的名字。最后一行使用implode()
用逗号连接最后一个数组:$too_many = ['Dave Dee', 'Dozy', 'Beaky', 'Mick', 'Tich']; echo implode(', ', $too_many);
-
将脚本加载到浏览器中。如下面的屏幕截图所示,最终名称前没有“and”时,输出看起来很笨拙:
- 删除最后一行,开始定义一个函数,如下所示:
function with_commas(array $array, int $max = 4) { }
函数签名有两个参数:$array
和$max
。The
类型声明指定第一个必须是一个数组,第二个必须是一个整数,所以如果有任何其他类型的数据传递给它,该函数将触发一个错误。$max
设置待连接元素的最大数量。它有一个默认值4
,所以它是一个可选参数。
-
在函数内部,我们可以使用一个
match
表达式(参见第四章中的“为决策链使用匹配表达式”)来决定如何根据数组中元素的数量来处理输出:$length = count($array); $result = match ($length) { 0 => '', 1 => array_pop($array), 2 => implode(' and ', $array), default => implode(', ', array_slice($array, 0, $length -1)) . ' and ' . array_pop($array) }; return $result;
首先,我们使用count($array)
来确定数组中元素的数量,并将值赋给$length
。然后将它作为参数传递给match
表达式,该表达式将返回值存储为$result
。
如果数组不包含任何元素,则返回一个空字符串。如果只有一个,数组被传递给array_pop()
函数。我们需要这样做,因为函数应该返回一个准备显示的字符串。如果你只是返回$array
,它仍然是一个不能用echo
或print
显示的数组。array_pop()
函数移除数组中的最后一个元素并返回它。
如果数组中有两个元素,数组被传递给implode()
函数,字符串“and”两边用空格包围。
默认操作使用implode()
连接数组中除最后一个元素之外的所有元素,用逗号后跟一个空格。传递给implode()
的第二个参数使用array_slice()
函数来选择所需的元素。array_slice()
函数有三个参数:要从中提取元素的数组、要从中开始的元素的索引以及要提取的元素的数量。数组是从零开始计数的,所以从数组的开头开始,提取$length–1
元素。然后,在返回结果之前,最后一个元素的值(再次使用array_pop()
)被连接到以“and”开头的逗号分隔的字符串。
Caution
这个脚本至少需要 PHP 8。对于旧版本的 PHP,你需要使用我的 PHP 7 解决方案中描述的 switch 语句。
-
保存脚本,并依次用每个测试数组测试它。例如:
-
这以合乎语法的方式用逗号连接数组元素:
echo with_commas($fab_four);
- 让我们修复数组元素数量超过$max 的情况,从多一个开始。在 default 之前插入以下代码:
$max + 1 =>implode(', ', array_slice($array, 0, $max)) . ' and one other';
这将把array_slice($array, 0, $max)
作为第二个参数传递给implode()
。然后,在返回结果之前,将字符串“and one other”连接到结果上。
- 保存脚本并再次测试。如果你用
$fab_four
测试它,你会得到和前面截图一样的结果。现在用$too_many
试试会产生以下结果:
-
超过
$max
的多个元素以类似方式处理。然而,match
表达式需要稍微不同的方法来处理比较。不要将$length
作为参数传递给match()
,而是需要传递true
并对每种情况进行比较。像这样修改匹配表达式:$result = match (true) { $length === 0 => '', $length === 1 => array_pop($array), $length === 2 => implode(' and ', $array), $length === $max + 1 => implode(', ', array_slice($array, 0, $max)) . ' and one other', $length > $max + 1 => implode(', ', array_slice($array, 0, $max)) . ' and others', default => implode(', ', array_slice($array, 0, $length -1)) . ' and ' . array_pop($array) };
-
保存脚本并再次运行。用
$too_many
,结果不变。但是,将第二个参数with_commas()
改为一个较小的数字,如下所示:
echo with_commas($too_many, 3);
这将输出更改如下:
- 您可以在
ch08
文件夹中用commas_02.php
检查完成的代码。
排序数组
表 8-2 列出了许多用于排序数组的内置 PHP 函数。
表 8-2
数组排序函数
|功能
|
描述
|
| — | — |
| sort()
| 按升序排序(从低到高) |
| rsort()
| 按降序排序(从最高到最低) |
| asort()
| 按值升序排序,保持键值关系 |
| arsort()
| 按值降序排序,保持键值关系 |
| ksort()
| 按键升序排序,保持键与值的关系 |
| krsort()
| 按键降序排序,保持键与值的关系 |
| natsort()
| 以“自然顺序”按值排序,维护值关系的键 |
| natcasesort()
| 以不区分大小写的“自然顺序”按值排序,保持键值关系 |
| usort()
| 使用回调比较函数按值排序 |
| uasort()
| 使用回调比较函数按值排序,保持键与值的关系 |
| uksort()
| 使用回调比较函数按键排序,保持键与值的关系 |
| array_multisort()
| 对多个或多维数组进行排序 |
表 8-2 中的所有函数影响原始数组,根据操作是否成功,只返回true
或false
。前六个函数(包括krsort()
)可以将表 8-3 中列出的 PHP 常量作为可选的第二个参数来修改排序顺序。
表 8-3
修改排序顺序的常数
|常数
|
描述
|
| — | — |
| SORT_REGULAR
| 比较项目而不改变其类型(默认) |
| SORT_NUMERIC
| 将项目作为数字进行比较 |
| SORT_STRING
| 将项目作为字符串进行比较 |
| SORT_LOCALE_STRING
| 基于当前区域设置比较项目 |
| SORT_NATURAL
| 以“自然顺序”比较项目 |
| SORT_FLAG_CASE
| 可以与使用竖线(|
)的SORT_STRING
或SORT_NATURAL
结合使用,对字符串进行不区分大小写的排序 |
以“自然顺序”对值进行排序的两个函数和常量以与人类相同的方式对包含数字的字符串进行排序。在ch08
文件夹的natsort.php
中有一个例子,用sort()
和natsort()
对下面的数组进行排序:
$images = ['image10.jpg', 'image9.jpg', 'image2.jpg'];
下面的屏幕截图显示了不同的结果:
有了sort()
,顺序不仅违反直觉,而且索引也被重新编号。有了natsort()
,顺序更加人性化,原来的索引都保留了下来。
Tip
natsort()
和natcasesort()
函数没有逆序的等价函数,但是您可以将结果传递给内置的array_reverse()
函数。这将返回一个新数组,其中的元素以相反的顺序排列,不进行排序。与表 8-2 中的功能不同,原始数组不变。关联数组键被保留,但索引数组被重新编号。为了防止索引数组被重新编号,传递布尔值true
作为第二个(可选)参数。
在usort()
、uasort()
和uksort()
中使用的回调比较函数必须接受两个参数,如果第一个参数分别小于、等于或大于第二个参数,则返回一个小于、等于或大于零的整数。PHP 解决方案 8-5 展示了如何用飞船操作符来做这件事。
PHP 解决方案 8-5:用飞船操作符自定义排序
表 8-2 中的前八个排序函数在处理大多数排序操作时表现出色。然而,它们不能涵盖所有场景。这时定制排序函数就派上用场了。这个 PHP 解决方案展示了飞船操作员如何简化定制排序*。*
-
打开
ch08
文件夹中的spaceship_01.php
。它包含以下音乐播放列表的多维数组和一个将它显示为无序列表的循环:$playlist = [ ['artist' => 'Jethro Tull', 'track' => 'Locomotive Breath'], ['artist' => 'Dire Straits', 'track' => 'Telegraph Road'], ['artist' => 'Mumford and Sons', 'track' => 'Broad-Shouldered Beasts'], ['artist' => 'Ed Sheeran', 'track' => 'Nancy Mulligan'], ['artist' => 'Dire Straits', 'track' => 'Sultans of Swing'], ['artist' => 'Jethro Tull', 'track' => 'Aqualung'], ['artist' => 'Mumford and Sons', 'track' => 'Thistles and Weeds'], ['artist' => 'Ed Sheeran', 'track' => 'Eraser'] ]; echo '<ul>'; foreach ($playlist as $item) { echo "<li>{$item['artist']}: {$item['track']}</li>"; } echo '</ul>';
-
在循环之前插入一行,使用
asort()
对数组进行排序:
图 8-1
asort()
函数使得对多维关联数组中的值进行排序变得简单
- 保存文件,并将其加载到浏览器中。如图 8-1 所示,
asort()
不仅按字母顺序对艺人进行了排序;与每个艺术家相关的曲目也是按字母顺序排列的。
asort($playlist);
- 但是,假设您想按曲目名称的字母顺序对播放列表进行排序。为此,您需要一个自定义排序。用以下代码替换您在步骤 2 中插入的代码行:
usort($playlist, fn ($a, $b) => $a['track'] <=> $b['track']);
这使用了带有箭头回调函数的usort()
函数。回调函数的两个参数($a
和$b
)表示您想要比较的两个数组元素。该函数使用宇宙飞船运算符将当前 track 元素的值与下一个元素的值进行比较,根据左边的操作数是小于、等于还是大于右边的操作数,分别返回小于、等于或大于零的整数。
- 要使自定排序的结果看起来更清楚,请交换每个列表项中显示的艺术家和曲目的顺序:
图 8-2
播放列表现在已经按曲目名称的字母顺序排序
- 保存文件并在浏览器中重新加载。轨道现在按字母顺序列出(见图 8-2 )。
echo "<li>{$item['track']}: {$item['artist']}</li>";
-
要颠倒自定义排序的顺序,请交换 spaceship 运算符两边的操作数顺序:
-
您可以对照
ch08
文件夹中的spaceship_02.php
来检查您的代码。
usort($playlist, fn ($a, $b) => $b['track'] <=> $a['track']);
使用 array_multisort()进行复杂排序
array_multisort()
功能有两个目的,即:
-
要对希望保持同步的多个数组进行排序
-
按一个或多个维度对多维数组进行排序
multisort_01.php
中的代码包含了一个在重新排序时需要保持同步的数组的例子。$states
数组按字母顺序列出各州,而$population
数组包含按相同顺序列出的每个州的人口:
$states = ['Arizona', 'California', 'Colorado', 'Florida', 'Maryland', 'New York', 'Vermont'];
$population = [7_151_502, 39_538_223, 5_773_714, 21_538_187, 6_177_224, 20_201_249, 643_077];
然后循环显示每个州的名称及其人口:
echo '<ul>';
for ($i = 0, $len = count($states); $i < $len; $i++) {
echo "<li>$states[$i]: $population[$i]</li>";
}
echo '</ul>';
图 8-3 显示了输出。
图 8-3
尽管各州和人口数字在不同的数组中,但它们的顺序是正确的
Note
PHP 引擎在执行脚本时去掉了$population
数组中的下划线。PHP 7.4 引入了在整数中使用下划线以提高可读性。
但是,如果您希望按升序或降序对人口数据进行重新排序,两个数组需要保持同步。
multisort_02.php
中的代码显示了如何使用array_multisort()
完成这一任务:
array_multisort($population, SORT_ASC, $states);
array_multisort()
的第一个参数是要首先排序的数组。它后面可以跟两个可选参数:使用常量SORT_ASC
或SORT_DESC
分别表示升序或降序的排序方向,以及使用表 8-3 中列出的常量之一的排序类型。剩下的参数是您希望与第一个数组同步排序的其他数组。每个后续数组后面还可以跟有排序方向和类型的可选参数。
在这个例子中,$population
数组按升序排序,而$states
数组与其同步重新排序。如图 8-4 所示,人口数据和州名之间的正确关系得以保持。
图 8-4
人口数字现在按升序排列,保留正确的州名
下一个 PHP 解决方案展示了一个使用array_multisort()
按照多维度对多维数组重新排序的例子。
PHP 解决方案 8-6:用 array_multisort()对多维数组排序
在前面的 PHP 解决方案中,我们使用了 spaceship 操作符,通过比较分配给单个键的值,对多维数组进行自定义排序。在这个解决方案中,我们将使用array_multisort()
来执行更复杂的排序操作。
-
multisort_03.php
中的代码包含 PHP 解决方案 8-5 中的$playlist
多维数组的更新版本。每个子阵列都添加了一个评级键,如下所示:$playlist = [ ['artist' => 'Jethro Tull', 'track' => 'Locomotive Breath', 'rating' => 8], ['artist' => 'Dire Straits', 'track' => 'Telegraph Road', 'rating' => 7], ['artist' => 'Mumford and Sons', 'track' => 'Broad-Shouldered Beasts', 'rating' => 9], ['artist' => 'Ed Sheeran', 'track' => 'Nancy Mulligan', 'rating' => 10], ['artist' => 'Dire Straits', 'track' => 'Sultans of Swing', 'rating' => 9], ['artist' => 'Jethro Tull', 'track' => 'Aqualung', 'rating' => 10], ['artist' => 'Mumford and Sons', 'track' => 'Thistles and Weeds', 'rating' => 6], ['artist' => 'Ed Sheeran', 'track' => 'Eraser', 'rating' => 8] ];
-
正如前面的解决方案所演示的,使用
usort()
和飞船操作符可以很容易地按照轨道的字母顺序对数组进行排序。我们也可以通过评级对数组进行排序;但是根据评级和跟踪进行分类需要不同的方法。
根据多个标准对多维数组进行排序的第一步是将待排序的值提取到单独的数组中。使用array_column()
函数很容易做到这一点,该函数有两个参数:顶级数组和要从每个子数组中提取的键。在$playlist
数组后添加以下代码(在multisort_04.php
):
图 8-5
排序所需的值已经提取到单独的索引数组中
- 保存文件并在浏览器中测试。如图 8-5 所示,多维数组中的值被提取到两个索引数组中。
$tracks = array_column($playlist, 'track');
$ratings = array_column($playlist, 'rating');
print_r($tracks);
print_r($ratings);
-
我们不再需要检查
$tracks
和$ratings
数组的内容,所以注释掉或删除这两个对print_r()
的调用。 -
我们现在可以使用
array_multisort()
对多维数组进行排序。传递给函数的参数顺序决定了分配给最终排序的优先级。我希望播放列表按收视率降序排序,然后按曲目的字母顺序。所以第一个参数需要是$ratings
数组,后面是排序方向;然后是$tracks
数组,接着是排序方向;最后,$playlist
,多维数组。
将以下代码添加到脚本的底部:
-
多维数组现在已经从最高评级到最低评级进行了重新排序,同等评级的曲目按字母顺序排列。我们可以通过像这样遍历
$playlist
数组来验证这一点(代码在multisort_05.php
中):echo '<ul>'; foreach ($playlist as $item) { echo "<li>{$item['rating']} {$item['track']} by {$item['artist']}</li>"; } echo '</ul>';
array_multisort($ratings, SORT_DESC, $tracks, SORT_ASC, $playlist);
图 8-6 显示了它工作的证据。
图 8-6
多维数组已按多个标准排序
Note
在前面的 PHP 解决方案中,array_column()与关联子数组一起使用,所以第二个参数是一个字符串,包含我们想要提取的值的键。该函数还能够从索引子数组中提取值。只需传递想要提取的值的索引作为第二个参数。在下一章的“PHP 解决方案 9-6:修改类以处理多次上传”中你会看到一个实际的例子。
PHP 解决方案 8-7:寻找一个数组的所有排列
这个 PHP 解决方案改编自 Python。它使用递归生成器中的array_slice()
和array_merge()
函数(参见第四章中的“生成器:一种特殊类型的不断给出的函数”)来分离数组并以不同的顺序合并元素。它是递归的,因为生成器会反复调用自己,直到到达要处理的元素的末尾。
-
生成器的定义是这样的(代码在
ch08
文件夹的permutations.php
):function permutations(array $elements) { $len = count($elements); if ($len <= 1) { yield $elements; } else { foreach(permutations(array_slice($elements, 1)) as $permutation) { foreach(range(0, $len - 1) as $i) { yield array_merge( array_slice($permutation, 0, $i), [$elements[0]], array_slice($permutation, $i) ); } } } }
从第 7 行开始的foreach
循环使用array_slice()
函数递归调用生成器,提取传递给它的数组中除第一个元素之外的所有元素。当我们在“PHP 解决方案 8-4:用逗号连接一个数组”中使用array_slice()
时,我们给它传递了三个参数:数组、开始元素的索引和要提取的元素数量。在这种情况下,只使用前两个参数。当array_slice()
的最后一个参数被省略时,它返回从数组的起点到结尾的所有元素。因此,如果字母ABC
作为数组传递给它,array_slice($elements, 1)
返回BC
,这在循环内部被称为$permutation
。
嵌套的foreach
循环使用range()
函数创建一个从 0 到$elements
数组长度减 1 的数字数组。每次循环运行时,生成器使用array_merge()
和array_slice()
的组合产生一个重新排序的数组。循环第一次运行时,计数器$i
为0
,因此array_slice($permutation, 0, 0)
不会从BC
中提取任何内容。$elements[0]
是A
,array_slice($permutation, 0)
是BC
。结果,原始数组ABC
被生成。
下一次循环运行时,$i
为1
,于是从$permutation
中提取出B
,$elements[0]
仍为A
,array_slice($permutation, 1)
为C
,产生BAC
,以此类推。
-
要使用
permutations()
生成器,将一个索引数组作为参数传递给它,并将生成器分配给一个变量,如下所示: -
然后,您可以使用一个带有生成器的
foreach
循环来获得数组的所有排列(代码在permutations.php
中):foreach ($perms as $perm) { echo implode(' ', $perm) . '<br>'; }
$perms = permutations(['A', 'B', 'C']);
这将显示 ABC 的所有排列,如以下屏幕截图所示:
处理数组数据
在这一节中,我们将研究两种处理存储在数组中的数据的 PHP 解决方案:从多维关联数组中自动构建 HTML 嵌套列表,以及从 JSON 提要中提取数据。
PHP 解决方案 8-8:自动构建嵌套列表
这个 PHP 解决方案重新访问了标准 PHP 库(SPL)中的RecursiveIteratorIterator
,我们在第七章的“用 FilesystemIterator 检查文件夹的内容”中使用过它来挖掘文件系统。像RecursiveIteratorIterator
这样的类的一个有用的特性是你可以通过扩展它们来适应你自己的需要。当您扩展一个类时,子类——通常被称为子类——继承其父类的所有公共和受保护的方法和属性。您可以添加新的方法和属性,或者通过重写父类的方法来更改它们的工作方式。RecursiveIteratorIterator
公开了几个公共方法,可以重写这些方法,以便在多维关联数组上循环时在数组键和值之间注入 HTML 标记。
Note
类可以将方法和属性声明为公共的、受保护的或私有的。Public 意味着可以在类定义之外访问它们。受保护意味着它们只能在类定义或子类中被访问。Private 意味着它们只能在类定义中访问,而不能在子类中访问。
在构建 PHP 脚本之前,让我们检查一下 HTML 中嵌套列表的结构。下图显示了一个简单的嵌套列表:
HTML 代码如下所示:
<ul>
<li>Label 1
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</li>
</ul>
需要注意的重要一点是,缩进列表嵌套在顶级列表项中。标签 1 的结束标记位于嵌套列表的结束标记之后。手工编写 HTML 嵌套列表容易出错,因为很难跟踪列表项的打开和关闭位置。当用 PHP 自动化嵌套列表时,我们需要记住这种结构。
-
在
ch08
文件夹中创建一个名为ListBuilder.php
的文件。如果你只是想研究完整的代码,它在ListBuilder_end.php
中,带有完整的注释。 -
定义一个名为
ListBuilder
的类来扩展RecursiveIteratorIterator
,并为要处理的数组和输出 HTML 创建两个受保护的属性:class ListBuilder extends RecursiveIteratorIterator { protected $array; protected $output = ''; }
-
大多数类都有一个构造函数方法来初始化它们并接受任何参数。
ListBuilder
类需要将一个数组作为它的参数,并准备使用它。将以下代码添加到类定义中(所有的ListBuilder
代码需要放在步骤 2 中代码的右花括号之前):public function __construct(array $array) { $this->array = new RecursiveArrayIterator($array); // Call the RecursiveIteratorIterator parent constructor parent::__construct($this->array, parent::SELF_FIRST); }
所有类的构造函数方法的名称都是相同的,并且以两个下划线开头。这个构造函数只有一个参数:将被转换成嵌套无序列表的数组。
要使用带有 SPL 迭代器的数组,必须先将其转换为迭代器,因此构造函数中的第一行创建了一个新的RecursiveArrayIterator
实例,并将其赋给ListBuilder
的$array
属性。
因为我们覆盖了RecursiveIteratorIterator
构造函数,所以我们需要调用父构造函数,并将$array
属性作为第一个参数传递给它。调用parent::SELF_FIRST
作为第二个参数可以访问正在处理的数组的键和值。如果没有第二个参数,我们就无法访问密钥。
Tip
在第九章和第十章中你会学到更多关于类和扩展类的知识。
-
一个 HTML 无序列表以开始和结束的
<ul>
标签开始和结束。RecursiveIteratorIterator
有在循环开始和结束时自动调用的公共方法,所以我们可以覆盖它们,用如下组合连接操作符向$output
属性添加必要的标记:public function beginIteration() { $this->output .= '<ul>'; } public function endIteration() { $this->output .= '</ul>'; }
-
在每个子数组的开头和结尾也自动调用两个公共方法。我们可以使用这些来插入嵌套列表的开始标签
<ul>
,并关闭嵌套列表及其父列表项:public function beginChildren() { $this->output .= '<ul>'; } public function endChildren() { $this->output .= '</ul></li>'; }
-
为了处理每个数组元素,我们可以覆盖自动调用的
nextElement()
公共方法…是的,你已经猜到了。这稍微复杂一些,因为我们需要检查当前元素是否有子数组。如果有,我们需要添加一个开始标签和子数组的键。否则,我们需要在一对<li>
标签之间添加当前值,如下所示:public function nextElement() { // Check whether there's a subarray if (parent::callHasChildren()) { // Display the subarray's key $this->output .= '<li>' . self::key(); } else { // Display the current array element $this->output .= '<li>' . self::current() . '</li>'; } }
这些代码的大部分是不言自明的。该条件调用父级的—换句话说,RecursiveIteratorIterator
的— callHasChildren()
方法。如果当前元素有子元素,即子数组,则返回 true。如果有,开始的<li>
标签被连接到$output
属性上,后面跟着self::key()
。这将调用从RecursiveIteratorIterator
继承而来的ListBuilder
的key()
方法来获取当前键的值。没有结束的</li>
标签,因为直到子数组被处理后才会添加。
如果当前元素没有任何子元素,则执行else
子句。它调用current()
方法来获取当前元素的值,该元素夹在一对<li>
标记之间。
-
为了显示嵌套列表,我们需要迭代数组并返回
$output
属性。我们可以用神奇的__toString()
方法。这样定义它:public function __toString() { // Generate the list $this->run(); return $this->output; }
-
要完成
ListBuilder
类,如下定义run()
方法:protected function run() { self::beginIteration(); while (self::valid()) { self::next(); } self::endIteration(); }
这只是调用了从RecursiveIteratorIterator
继承的四个方法。他们调用beginIteration()
,然后通过while
循环运行数组,并结束迭代。
-
要测试
ListBuilder
,打开ch08
文件夹中的multidimensional_01.php
。它包含一个名为$wines
的多维关联数组。包含ListBuilder
定义,然后通过添加以下代码生成输出并显示(完整的代码在multidimensional_02.php
):require './ListBuilder.php'; echo new ListBuilder($wines);
图 8-7 显示了结果。
图 8-7
ListBuilder 扩展了 RecursiveIteratorIterator,从多维关联数组中自动构建嵌套列表
PHP 解决方案 8-9:从 JSON 中提取数据
在前一章中,我们使用了SimpleXML
来消化一个 RSS 新闻提要。RSS 和其他形式的 XML 分发数据的缺点是,用于包装数据的标签使数据变得冗长。JavaScript Object Notation (JSON)越来越多地被用于在线分发数据,因为它更简洁。虽然简洁的格式使得 JSON 下载速度更快,消耗的带宽更少,但缺点是不容易阅读。
这个 PHP 解决方案从旧金山开放数据( https://datasf.org/opendata/
)访问一个 JSON 提要,将其转换为一个数组,构建数据的多维关联数组,然后过滤它以提取所需的信息。这听起来像是很多艰苦的工作,但它涉及的代码相对较少。
-
这个 PHP 解决方案的 JSON 数据源在
ch08/data
文件夹的film_locations.json
中。或者,您可以从https://data.sfgov.org/api/views/yitu-d5am/rows.json?accessType=DOWNLOAD
获得最新版本。如果您访问在线版本,请将其作为一个.json
文件保存在本地硬盘上,以避免不断访问远程提要。 -
这些数据由旧金山电影委员会收集的电影拍摄地的数据组成。使用 JSON 的挑战之一是定位您想要的信息,因为没有通用的命名约定。虽然这个提要被格式化为单独的行和缩进,但是 JSON 经常没有空格以使它更紧凑。将其转换为多维关联数组简化了识别过程。在
ch08
文件夹中创建一个名为json.php
的 PHP 文件,并添加以下代码(在json_01.php
):$json = file_get_contents('./data/film_locations.json'); $data = json_decode($json, true); echo '<pre>'; print_r($data); echo '</pre>';
这使用file_get_contents()
从数据文件中获取原始 JSON,将其转换为多维关联数组,然后显示它。将true
作为第二个参数传递给json_decode()
会将 JSON 对象转换成 PHP 关联数组。
图 8-8
将 JSON 提要转换成关联数组简化了数据位置的识别
- 保存文件并在浏览器中运行脚本。这个
$data
数组非常庞大。它包含了 3400 多部电影的细节。在print_r()
周围包裹<pre>
标签使得检查结构以识别感兴趣的数据位于何处变得容易。如图 8-8 所示,顶层数组称为meta
。嵌套在里面的是一个名为view
的子数组,它又包含一个名为columns
的子数组。
columns
子数组包含一个索引数组;在第一个元素中还有另一个数组,它有一个名为name
的键。当你进一步向下滚动找到一个名为data
的数组时,这一点的重要性就变得很明显了(见图 8-9 )。
图 8-9
为了紧凑,电影数据存储在索引数组中
Tip
因为 JSON 文件太大了,所以使用浏览器的 Find 实用程序来搜索[数据]。
所有有趣的信息都存储在这里。它包含一个有 3400 多个元素的索引子数组,每个元素包含另一个有 19 个元素的索引数组。数据被映射到图 8-8 中标识的名称数组,而不是数千次重复列名。为了提取我们想要的信息,有必要为这个data
数组中的每部电影构建一个关联数组。
- 我们可以使用在“PHP 解决方案 8-6:用
array_multisort()
排序多维数组”中遇到的array_column()
函数来获得列名然而,name
元素被深埋在顶层数组中,该数组在步骤 2 中被存储为$data
。图 8-8 中的缩进有助于找到作为第一个参数传递的正确子数组。将以下代码添加到脚本中(在json_02.php
):
图 8-10
列名标识为每个电影位置存储的信息
- 使用
print_r()
检查是否提取了正确的值,如图 8-10 所示。
$col_names = array_column($data['meta']['view']['columns'], 'name');
-
现在我们有了列名,我们可以循环通过
data
子数组,使用array_combine()
将每个元素转换成关联数组。将以下代码添加到脚本中:$locations = []; foreach ($data['data'] as $datum) { $locations[] = array_combine($col_names, $datum); }
这会将$locations
初始化为一个空数组,然后遍历data
子数组,将$col_names
和当前数组的值传递给array_combine()
。这导致相关的列名被指定为每个值的键。data
(见图 8-9 的压痕水平表示data
子阵列与meta
在同一深度(见图 8-8 )。
- 现在包含一个关联数组的数组,每个数组包含 JSON 提要中列出的 3400 多个电影位置的详细信息。为了定位特定的信息,我们可以使用
array_filter()
函数,它将一个数组和一个回调函数作为参数,并返回一个新的过滤结果数组。
回调函数接受一个参数,即过滤器正在检查的当前元素。这意味着过滤标准需要在回调中硬编码。为了使回调更具适应性,我将使用一个能够从全局范围继承变量的 arrow 函数。如下定义搜索词和回调函数:
$search = 'Pier 7';
$getLocation = fn($location) => str_contains($location['Locations'], $search);
arrow 函数被分配给一个变量。它采用一个参数$location
,表示当前数组元素。回调函数使用str_contains()
函数对当前数组的Locations
元素中的搜索词执行区分大小写的搜索,这是 PHP 8 的新功能。如果找到搜索词,结果将是true
。
图 8-11
这些信息是从 JSON 提要的 3400 多个条目中筛选出来的
-
我们现在可以过滤
$locations
数组,并像这样显示结果(完成的代码在json_03.php
中):$filtered = array_filter($locations, $getLocation); echo '<ul>'; foreach ($filtered as $item) { echo "<li>{$item['Title']} ({$item['Release Year']}) filmed at {$item['Locations']}</li>"; } echo '</ul>';
-
保存脚本并在浏览器中测试。您应该会看到如图 8-11 所示的结果。
-
有些电影在 JSON 文件中不止列出一次。要删除重复项,请通过创建一个空数组并修改
foreach
循环来修改代码,如下所示:$duplicates = []; foreach ($filtered as $item) { if (in_array($item['Title'], $duplicates)) continue; echo "<li>{$item['Title']} ({$item['Release Year']}) filmed at {$item['Locations']}</li>"; $duplicates[] = $item['Title']; }
循环内的条件语句使用in_array()
函数检查$
item['Title']
是否在$duplicates
数组中。如果是,函数返回true
,continue
关键字跳过循环的当前迭代。显示结果后,$item['Title']
被添加到$duplicates
数组中。更新后的代码在json_04.php
中。
-
再次运行脚本。这一次,重复项被省略。
-
将
$search
的值更改为三藩市其他地点的名称,如普雷斯迪奥或阿卡特兹,以查看在那里拍摄的电影的名称。
自动将数组元素赋给变量
毫无疑问,关联数组非常有用,但是它们的缺点是键入和嵌入双引号字符串很费力。因此,通常将关联数组元素赋给简单变量,如下所示:
$name = $_POST['name'];
$email = $_POST['email'];
$message = $_POST['message'];
但是,有一些方法可以简化这个过程,如下面几节所述。
使用 extract()函数
在最基本的形式中,extract()
函数根据相关键的名称自动将关联数组的值赋给变量。换句话说,你可以通过简单地这样做获得与前面三行代码相同的结果:
extract($_POST);
Caution
使用extract()
处理来自用户输入的未过滤数据,比如$_POST
或$_GET
数组,被认为是一个主要的安全风险。恶意攻击者可能会尝试注入变量来覆盖您已经定义的值。
以最简单的形式使用,extract()
函数是一个钝工具。除非您确切地知道哪些键在关联数组中,否则您将冒覆盖现有变量的风险。为了解决这个问题,该函数可以采用两个可选参数:一个是八个 PHP 常量中的一个,用于确定在命名冲突的情况下应该做什么;另一个是一个字符串,用于作为变量名的前缀。您可以在 www.php.net/manual/en/function.extract.php
的在线文档中找到这些选项的详细信息。
虽然可选参数改进了extract()
的行为,但是使用它们的需要降低了函数提供的便利性。extract()
还有另一个缺点:它不能处理变量名中包含无效字符的键。例如,下面是一个完全有效的关联数组:
$author = ['first name' => 'David', 'last name' => 'Powers'];
即使键包含空格,$author['first name']
和$author['last name']
也是有效的。然而,将$author
数组传递给extract()
不会导致变量被创建。
这些限制大大降低了extract()
的价值。
使用列表()
虽然括号使list()
看起来像一个函数,但从技术上讲,它不是;这是一种 PHP 语言结构,它在一次操作中将一个变量列表赋给一个值数组。它从 PHP 4 开始就可用了,但在 PHP 7.1 中已经得到了相当大的增强。
在 PHP 7.1 之前,list()
只能处理索引数组。按照变量名在数组中出现的顺序,列出要为其分配数组值的变量名。以下list_01.php
中的例子展示了它是如何工作的:
$person = ['David', 'Powers', 'London'];
list($first_name, $last_name, $city) = $person;
// Displays "David Powers lives in London."
echo "$first_name $last_name lives in $city.";
在 PHP 7.1 及更高版本中,list()
也可以和关联数组一起使用。语法类似于创建文字关联数组的语法。使用双箭头运算符将关联数组键赋给一个变量。因为每个数组键都标识其关联值,所以它们不需要按照数组中的相同顺序列出,也不需要使用所有键,如list_02.php
中的示例所示:
$person = [
'first name' => 'David',
'last name' => 'Powers',
'city' => 'London',
'country' => 'the UK'];
list('country' => $country,
'last name' => $surname,
'first name' => $name) = $person;
// Displays "David Powers lives in the UK."
echo "$name $surname lives in $country.";
对 list()使用数组速记语法
PHP 7.1 中的另一个增强是对list()
使用数组速记语法。前两个例子中变量的赋值可以简化成这样(完整代码在list_03.php
和list_04.php
中):
[$first_name, $last_name, $city] = $person;
['country' => $country, 'last name' => $surname, 'first name' => $name] = $person;
PHP 解决方案 8-10:使用生成器处理 CSV 文件
这个 PHP 解决方案修改了“PHP 解决方案 7-2:从 CSV 文件中提取数据”中的脚本,使用一个生成器来处理 CSV 文件,并用list()
数组速记将每行生成的数组值赋给变量。
-
打开
ch08
文件夹中的csv_processor.php
。它包含了一个名为csv_processor()
的发生器的如下定义:// generator that yields each line of a CSV file as an array function csv_processor($csv_file) { if (@!$file = fopen($csv_file, 'r')) { echo "Can't open $csv_file."; return; } while (($data = fgetcsv($file)) !== false) { yield $data; } fclose($file); }
生成器接受一个参数,即 CSV 文件的名称。它使用第七章中描述的文件操作功能以读取模式打开文件。如果文件无法打开,错误控制操作符(@
)会抑制任何 PHP 错误消息,显示一条自定义消息,然后返回,防止进一步尝试处理该文件。
假设文件被成功打开,while
循环一次传递一行给fgetcsv()
函数,后者将数据作为生成器生成的数组返回。当循环结束时,文件被关闭。
这是一个方便的实用函数,可以用来处理任何 CSV 文件。
-
在
ch08
文件夹中创建一个名为csv_list.php
的文件,并包含csv_processor.php
: -
在
ch08/data
文件夹中,scores.csv
包含以下以逗号分隔值存储的数据:Home team,Home score,Away team,Away score Arsenal,2,Newcastle United,0 Tottenham Hotspur,2,Crystal Palace,0 Watford,4,Fulham,1 Manchester City,2,Cardiff City,0 Southampton,1,Liverpool,3 Wolverhampton Wanderers,2,Manchester United,1
-
通过创建如下所示的
csv_processor()
生成器实例,将数据加载到 CSV 文件中:
require_once './csv_processor.php';
图 8-12
生成器处理 CSV 文件的每一行,包括列标题
-
使用发电机最简单的方法是使用
foreach
回路。每次循环运行时,生成器都会将 CSV 文件的当前行作为索引数组生成。使用list()
数组简写将数组值赋给变量,然后用echo
显示它们,如下所示:foreach ($scores as $score) { [$home, $hscore, $away, $ascore] = $score; echo "$home $hscore:$ascore $away<br>"; }
-
保存文件,并通过将脚本加载到浏览器中来运行脚本。或者,使用
ch08
文件夹中的csv_list_01.php
。如图 8-12 所示,输出包括 CSV 文件中的列标题行。
$scores = csv_processor('./data/scores.csv');
-
使用
foreach
循环的问题是它处理 CSV 文件中的每一行。我们可以在每次循环运行时递增一个计数器,并用它来跳过带有关键字continue
的第一行。但是,生成器有内置的方法,允许我们遍历要生成的值并检索当前值。编辑步骤 5 中的代码,如下所示(更改以粗体突出显示):$scores->next(); while ($scores->valid()) { [$home, $hscore, $away, $ascore] = $scores->current(); echo "$home $hscore:$ascore $away<br>"; $scores->next(); }
修改后的代码使用了调用生成器的valid()
方法的while
循环,而不是foreach
循环。只要至少还有一个值需要生成器生成,就会返回true
。因此,这具有在被处理的 CSV 文件中的每一行上循环的效果。
为了跳过第一行,在循环开始之前调用next()
方法。顾名思义,这会将生成器移动到下一个可用值。在循环内部,current()
方法返回当前值,next()
方法移动到下一个值,为循环再次运行做好准备。
图 8-13
在迭代剩余的值之前,已经跳过了第一行
- 保存文件并再次运行脚本(代码在
csv_list_02.php
中)。这次只显示分数,如图 8-13 所示。
用 Splat 运算符从数组中解包参数
第四章中简要介绍的 splat 运算符(...
)有两个作用,即:
-
当在函数定义中使用时,它将多个参数转换成可以在函数内部使用的数组。
-
当调用一个函数时,它解包一个参数数组,把它们当作单独传递给一个函数。
下面的 PHP 解决方案展示了一个简单的例子,展示了它的实用价值。
PHP 解决方案 8-11:用 Splat 操作符处理 CSV 文件
fgetcsv()
函数将 CSV 文件中的数据作为索引数组返回。这个 PHP 解决方案展示了如何使用 splat 操作符将数组直接传递给需要多个参数的函数,而无需分隔各个元素。它还使用了前面 PHP 解决方案中描述的csv_processor()
生成器。
-
在
ch08
文件夹中创建一个名为csv_splat.php
的文件,并包含csv_processor.php
: -
在
ch08/data
文件夹中,weather.csv
包含以下数据:
require_once './csv_processor.php';
City,temp
London,11
Paris,10
Rome,12
Berlin,8
Athens,19
温度以摄氏度为单位。为了那些相信水在 32 度而不是 0 度结冰的人的利益,我们需要以一种用户友好的方式处理这些数据。
- 在
csv_splat.php
中,添加如下函数定义(代码在 csv_splat_01.php 中):
function display_temp($city, $temp) {
$tempF = round($temp/5*9+32);
return "$city: $temp°C ($tempF°F)";
}
该函数有两个参数:城市名和温度。使用标准公式(除以 5,乘以 9,然后加上 32)将温度转换为华氏温度,并四舍五入为最接近的整数。
然后,该函数返回一个字符串,该字符串由城市名称和以摄氏度表示的温度组成,后跟括号中的华氏温度。
-
包含数据的 CSV 文件以一行列标题开始,因此我们需要使用与上一个解决方案相同的技术跳过第一行。将数据加载到
csv_processor()
生成器中,并像这样跳过第一行: -
使用一个
while
循环,通过display_temp()
函数和 splat 运算符处理剩余的数据行,如下所示:
$cities = csv_processor('./data/weather.csv');
$cities->next();
while ($cities->valid()) {
echo display_temp(...$cities->current()) . '<br>';
$cities->next();
}
和前面的解决方案一样,生成器的current()
方法以数组的形式返回当前数据行。但是,这一次,splat 操作符没有将每个数组元素分配给一个变量,而是将数组解包,并将值作为参数按照它们在数组中出现的顺序进行分配。
如果您觉得这段代码难以理解,可以先将current()
方法的返回值赋给一个变量,如下所示:
$data = $cities->current();
echo display_temp(...$data) . '<br>';
在作为参数传递给函数的数组前面加上 splat 操作符的效果与此完全相同(代码在csv_splat_02.php
中):
[$city, $temp] = $cities->current();
echo display_temp($city, $temp) . '<br>';
图 8-14 显示了使用任一技术的结果。
图 8-14
每个数据数组都通过 splat 操作符直接传递给函数进行了处理
使用 splat 操作符来解包参数数组具有简洁的优点,但是较短的代码并不总是可读性最好的,当您没有得到预期的结果时,这会使调试变得困难。我个人认为,将数组元素赋给变量,然后将其作为参数显式传递是一种更安全的方法。但是即使你不使用特定的技术,如果你需要使用其他人的代码,理解它是如何工作的也是有用的。
第三章回顾
使用数组是 PHP 中最常见的任务之一,尤其是在使用数据库时。数据库查询的几乎所有结果都以关联数组的形式返回,因此理解如何处理它们非常重要。在这一章中,我们已经学习了修改数组,合并数组,排序和提取数据。关于在循环中使用数组,要记住的要点是 PHP 总是在数组的副本上工作,除非您通过引用将值传递到循环中。相比之下,对数组排序的函数在原始数组上工作。
你可以在 PHP 在线文档 www.php.net/manual/en/ref.array.php
中找到所有数组相关函数的全部细节。这一章展示了使用其中大约一半的实际例子,帮助你成为 PHP 中处理数组的专家。