PHP8-解决方案-全-

PHP8 解决方案(全)

原文:PHP 8 Solutions

协议:CC BY-NC-SA 4.0

一、PHP 8 是什么?

2020 年 11 月下旬发布的 PHP 8 是最流行的编程语言之一的重大更新。根据网络技术调查( https://w3techs.com/technologies/details/pl-php/all/all ),使用服务器端语言的网站中,超过五分之四部署了 PHP。尽管 PHP 很受欢迎,但也有很多批评者,主要是因为这种语言在早期的发展方式。这导致相关函数的名称和参数的顺序有时不一致。而且它的一些特性对没有经验的人来说会带来安全风险。自 2012 年以来,为改善语言所做的共同努力已经消除了大部分问题。

PHP 现在是一种成熟、强大的语言,已经成为创建动态网站最广泛使用的技术。它被大型企业使用,包括 Wikipedia、Mailchimp 和 Tumblr,也支持流行的 WordPress、Drupal 和 Joomla 内容管理系统。PHP 通过以下方式赋予网站生命:

  • 将您网站的反馈直接发送到您的邮箱

  • 通过网页上传文件

  • 从较大的图像生成缩略图

  • 读取和写入文件

  • 动态显示和更新信息

  • 使用数据库来显示和存储信息

  • 使网站可搜索

  • 还有更多…

通过阅读这本书,你将能够做到这一切。PHP 不仅简单易学;它是平台中立的,所以相同的代码可以在 Windows、macOS 和 Linux 上运行。你需要用 PHP 开发的所有软件都是开源免费的。

在本章中,您将了解以下内容:

  • PHP 是如何发展成为动态网站最广泛使用的技术的

  • PHP 如何使网页动态化

  • 学习 PHP 有多难或多容易

  • PHP 是否安全

  • PHP 8 的新特性

  • 写 PHP 需要什么软件

PHP 是如何发展的

PHP 始于 1995 年,当时的野心并不大。最初叫个人主页工具(PHP Tools)。其主要目标之一是通过从在线表格中收集信息并将其显示在网页上来创建一个留言簿。不到三年,它就决定从名字中去掉个人主页,因为它听起来像是为业余爱好者准备的,而且与后来添加的一系列复杂功能不相称。这就留下了首字母 PHP 应该代表什么的问题。最终决定称之为 PHP 超文本预处理器;但是大多数人都简单的叫它 PHP。

这些年来,PHP 一直在发展,不断增加新的特性。这种语言最吸引人的地方之一是它忠实于自己的根源。尽管它支持复杂的面向对象编程,但您可以在不深入复杂理论的情况下开始使用它。PHP 的创始人拉斯马斯·勒德尔夫曾将其描述为“一种对程序员非常友好的脚本语言,适合没有编程经验的人以及需要快速完成工作的经验丰富的 web 开发人员。”您可以立即开始编写有用的脚本,同时确信您正在使用一种能够开发工业级应用的技术。

Note

本书中的大部分代码都使用了 PHP 8 的新特性。它不能保证在旧版本的 PHP 上工作。

PHP 如何使页面动态化

PHP 最初被设计成嵌入在网页的 HTML 中,这也是它经常被使用的方式。例如,要在版权声明中显示当前年份,您可以将其放在页脚中:

<p>&copy; <?php echo date('Y'); ?> PHP 8 Solutions</p>

在支持 PHP 的 web 服务器上,<?php?>标记之间的代码被自动处理,并显示年份,如下所示:

这只是一个小例子,但它说明了使用 PHP 的一些优点:

  • 新年午夜钟声敲响时,年份会自动更新。

  • 日期是由 web 服务器计算的,因此如果用户电脑中的时钟设置不正确,也不会受到影响。然而,正如您稍后将了解到的,PHP 遵循服务器的时区;但是这可以通过编程来调整。

虽然像这样在 HTML 中嵌入 PHP 代码很方便,但是这是重复的,而且会导致错误。它还会使您的网页难以维护,尤其是当您开始使用更复杂的 PHP 代码时。因此,通常的做法是将大量动态代码存储在单独的文件中,然后使用 PHP 从不同的组件构建页面。单独的文件——或通常所说的包含文件——可以只包含 PHP、HTML 或两者的混合。

举个简单的例子,你可以把网站的导航菜单放在一个包含文件中,用 PHP 把它包含在每个页面中。每当需要更改菜单时,只需编辑包含文件,更改会自动反映在包含菜单的每个页面中。想象一下,对于一个有几十个页面的网站来说,这能节省多少时间!

对于普通的 HTML 页面,内容是由 web 开发人员在设计时确定的,并上传到 web 服务器。当有人访问页面时,web 服务器只需发送 HTML 和其他资产,如图像和样式表。这是一个简单的事务—请求来自浏览器,固定内容由服务器发回。当你用 PHP 创建网页时,更多的事情会发生。图 1-1 显示了发生的情况。

img/332054_5_En_1_Fig1_HTML.png

图 1-1

web 服务器动态构建每个 PHP 页面来响应请求

当一个 PHP 驱动的网站被访问时,它会启动以下事件序列:

  1. 浏览器向 web 服务器发送请求。

  2. web 服务器将请求传递给嵌入在服务器中的 PHP 引擎。

  3. PHP 引擎处理请求页面中的代码。在许多情况下,它还可能在构建页面之前查询数据库。

  4. 服务器将完成的页面发送回浏览器。

这个过程通常只需要几分之一秒,所以 PHP 网站的访问者不太可能注意到任何延迟。因为每个页面都是单独构建的,所以 PHP 站点可以响应用户输入,在用户登录时显示不同的内容,或者显示数据库搜索的结果。

创建独立思考的页面

PHP 是一种服务器端语言。PHP 代码保留在 web 服务器上。经过处理后,服务器只发送脚本的输出。通常情况下,这是 HTML,但 PHP 也可以用于生成其他 web 语言,如 JSON (JavaScript 对象表示法)或 XML(可扩展标记语言)。

PHP 允许你在你的网页中引入基于选择的逻辑。有些决策是使用 PHP 从服务器收集的信息做出的:日期、时间、星期几、页面 URL 中的信息等等。如果是星期三,它会显示星期三的电视时间表。在其他时候,决策是基于用户输入的,这是 PHP 从在线表单中提取的。如果你注册了一个网站,它会显示个性化信息——诸如此类。

PHP 使用和学习有多难?

PHP 不是火箭科学,但是不要期望在 5 分钟内成为专家。也许对新来者来说最大的震惊是 PHP 对错误的容忍度远远低于浏览器对 HTML 的容忍度。如果在 HTML 中省略了结束标记,大多数浏览器仍然会呈现页面。如果您在 PHP 中省略了右引号、分号或大括号,您将得到如图 1-2 所示的错误信息。这影响了所有的编程语言,比如 JavaScript 和 C#,而不仅仅是 PHP。

img/332054_5_En_1_Fig2_HTML.jpg

图 1-2

像 PHP 这样的服务器端语言不能容忍大多数编码错误

如果你使用可视化设计工具,却从来不看底层代码,那么是时候重新考虑你的方法了。将 PHP 与结构不良的 HTML 混合在一起很可能会导致问题。PHP 使用循环来执行重复的任务,比如显示数据库搜索的结果。一个循环重复相同的代码段——通常是 PHP 和 HTML 的混合——直到所有结果都显示出来。如果你把循环放在错误的地方,或者你的 HTML 结构不良,你的页面很可能会像纸牌搭的房子一样倒塌。

如果你还没有这样做的习惯,使用万维网联盟(W3C)的 Nu HTML 检查器( https://validator.w3.org/nu/ )来检查你的页面是个好主意。

Note

W3C 是开发 HTML 和 CSS 等标准以确保网络长期发展的国际组织。它是由万维网的发明者蒂姆·伯纳斯·李领导的。要了解 W3C,请参见 www.w3.org/Consortium/mission

我可以复制并粘贴代码吗?

抄这本书里的代码没有错。这就是它存在的目的。我已经把这本书组织成了一系列实践项目。我解释了代码的用途以及它为什么会在那里。即使您不完全理解它是如何工作的,这也应该给了您足够的信心,让您知道代码的哪些部分适合您自己的需要,哪些部分最好不要去管。但是要想从这本书中获得最大收益,你需要开始尝试,然后想出自己的解决方案。

PHP 有数以千计的内置函数,可以执行各种任务,比如将文本转换为大写,从全尺寸图像生成缩略图,或者连接到数据库。真正的力量来自于以不同的方式组合这些函数,并添加您自己的条件逻辑。

PHP 有多安全?

PHP 就像你家里的电或菜刀:处理得当,非常安全;不负责任的处理,会造成很大的伤害。这本书第一版的灵感之一是大量攻击,这些攻击利用了电子邮件脚本中的漏洞,将网站变成了垃圾邮件中继站。解决方案很简单,你将在第六章中了解到,但即使多年后,我仍然看到有人使用同样不安全的技术,将他们的网站暴露在攻击之下。

PHP 并非不安全,也不需要每个人都成为安全专家才能使用。重要的是理解 PHP 安全的基本原理:在处理用户输入之前总是检查它。你会发现这是本书的永恒主题。大多数安全风险都可以不费吹灰之力消除。

保护自己的最好方法是理解你正在使用的代码。

PHP 8 有什么新特性?

PHP 8 中的新特性和变化非常广泛;但最重要的是采用了实时(JIT)编译器。这改变了 PHP 代码转换成服务器可以理解的机器码的方式。顾名思义,JIT 旨在提高性能。JIT 提案的作者之一 Zeev Surasky 制作了一个短片( https://youtu.be/dWH65pmnsrI )来展示 JIT 能够带来的巨大改进。然而,这样的速度提升只会影响处理器密集型计算——至少目前是这样。WordPress 在 PHP 8 上的运行速度并不比在 PHP 7 上快,这也是它在速度上超越之前版本的地方。

PHP 8 中的许多新特性旨在使代码更加简洁高效。例如,如果您只想更改一个函数的多个参数中的一个,那么使用命名参数就不需要重复这些参数的默认值。构造函数属性提升极大地简化了类定义中的属性声明,通常会将行数减少三分之一。新的 nullsafe 操作符同样减少了调用方法或获取非 null 表达式结果的属性所需的代码量。这些和其他新特性的细节在第 3 和 4 章中介绍。

将现有代码迁移到新版本 PHP 的一个重要考虑是,不兼容的更改是否会破坏您的应用。如果你一直遵循推荐的最佳实践,你不太可能有问题。但是,您应该了解一些重要的变化,如下所示:

  • 错误控制操作符(@)将不再沉默致命错误。

  • 使用两个等号(==)的数字和非数字字符串之间的非严格比较现在将数字转换为字符串并比较字符串。这意味着以前等同于true的一些比较现在是false

  • 与类同名的方法不再被解释为构造函数。你必须用__construct()来代替。

  • 您不能再定义不区分大小写的常量。

  • match现在是保留关键字。

  • #[不再被认为是注释的开始,因为这个语法被用于一个叫做属性的新特性。这只影响#后的左方括号。

Note

参见 www.php.net/manual/en/migration80.incompatible.php 获得 PHP 8 中向后不兼容的变化的完整列表。

写 PHP 需要什么软件?

严格来说,你不需要任何特殊的软件来编写 PHP 脚本。PHP 代码是纯文本,可以在任何文本编辑器中创建,例如 Windows 上的记事本或 macOS 上的 TextEdit。话虽如此,如果你使用一个具有加速开发过程的特性的程序,你的生活会变得容易得多。有很多可用的——既有免费的,也有付费的。

选择 PHP 编辑器时要注意什么

如果你的代码中有一个错误,你的页面可能永远不会到达浏览器,你只会看到一个错误信息。您应该选择具有以下功能的脚本编辑器:

  • PHP 语法检查:这曾经只能在昂贵的专用程序中找到,但是现在它是几个免费程序的一个特性。语法检查器会在您键入代码时监控代码并突出显示错误,从而节省大量时间并减少挫败感。

  • PHP 语法着色:代码根据所扮演的角色用不同的颜色突出显示。如果你的代码是一种意想不到的颜色,这肯定是你犯了一个错误。

  • PHP 代码提示:PHP 有如此多的内置函数,以至于即使对于一个有经验的用户来说,也很难记住如何使用它们。许多脚本编辑器会自动显示工具提示,提醒您某段代码是如何工作的。

  • 线路编号:快速找到特定线路,使故障排除变得更加简单。

  • 一个“平衡大括号”特征:圆括号(())、方括号([])和花括号({})必须总是成对出现。很容易忘记关闭一对。所有好的脚本编辑器都有助于找到匹配的圆括号、方括号或大括号。

您正在使用的构建网页的程序可能已经具备了这些功能中的部分或全部。即使您不打算进行大量的 PHP 开发,如果您的 web 开发程序不支持语法检查,您也应该考虑使用专用的脚本编辑器。下面的专用脚本编辑器拥有所有的基本特性,比如语法检查和代码提示。这不是一个详尽的列表,而是基于个人经验的列表:

  • PhpStorm ( www.jetbrains.com/phpstorm/ ):虽然这是一个专用的 PHP 编辑程序,但它对 HTML、CSS、JavaScript 都有极好的支持。这是我最喜欢的用 PHP 开发的程序。它是按年订阅出售的。如果您在至少 12 个月后取消,您将获得旧版本的永久许可证。

  • Visual Studio Code(https://code.visualstudio.com/):微软的一款优秀的代码编辑器,不仅可以在 Windows 上运行,也可以在 macOS 和 Linux 上运行。它是免费的,并且内置了对 PHP 的支持。

  • Sublime Text ( www.sublimetext.com/ ):如果你是 Sublime Text 的粉丝,这里有 PHP 语法着色、语法检查和文档化的插件。免费评估,但你应该购买相对便宜的许可证继续使用。

  • Zend Studio ( www.zend.com/products/zend-studio ):由 Zend 创建的强大的专用 PHP 编辑器,该公司由 PHP 开发的主要贡献者运营。它可以在 Windows、macOS 和 Linux 上运行。不同的价格适用于个人和商业用途。

  • Eclipse PHP 开发工具(PDT) ( https://projects.eclipse.org/projects/tools.pdt ):类似 Zend Studio 但优势是免费。它运行在 Eclipse 上,Eclipse 是支持多种计算机语言的开源 IDE。如果您已经将 Eclipse 用于其他语言,您应该会发现它相对容易使用。PDT 在 Windows、macOS 和 Linux 上运行。

所以让我们继续吧…

这一章仅仅提供了一个 PHP 可以做些什么来给你的网站添加动态特性的简要概述,以及你需要什么样的软件来这样做。使用 PHP 的第一步是建立一个测试环境。下一章将介绍你在 Windows 和 macOS 上需要的东西。

二、准备使用 PHP

既然您已经决定使用 PHP 来丰富您的网页,那么您需要确保您已经拥有了继续阅读本书其余部分所需的一切。虽然您可以在远程服务器上测试所有内容,但是在本地计算机上测试 PHP 页面通常更方便。你需要安装的一切都是免费的。在这一章中,我将解释 Windows 和 macOS 的各种选项。必要的组件通常默认安装在 Linux 上。

本章涵盖

  • 检查你的网站是否支持 PHP

  • 在 Windows 和 macOS 中使用现成的包创建本地测试设置

  • 决定存储 PHP 文件的位置

  • 检查本地和远程服务器上的 PHP 配置

检查你的网站是否支持 PHP

要想知道你的网站是否支持 PHP,最简单的方法就是询问你的托管公司。另一种方法是上传一个 PHP 页面到你的网站,看看它是否有效。即使您知道您的站点支持 PHP,也要做以下测试来确认运行的是哪个版本:

  1. 打开脚本编辑器,在空白页中键入以下代码:

  2. 将文件另存为phpversion.php。确保你的操作系统没有在.php后面添加.txt文件扩展名是很重要的。如果您在 Mac 上使用“文本编辑”,请确定它没有以 RTF 格式存储文件。如果你完全不确定,使用本书随附文件中的ch02文件夹中的phpversion.php

  3. 像上传 HTML 页面一样上传phpversion.php到你的网站,然后在浏览器中输入 URL。假设您将文件上传到站点的顶层,URL 将类似于 www.example.com/phpversion.php

<?php echo phpversion();

如果您看到屏幕上显示一个由三部分组成的数字,如 8.0.3,那么您就成功了:PHP 已启用。这个数字告诉你你的服务器上运行的是哪个版本的 PHP。

  1. 如果您收到类似“解析错误”的消息,这意味着 PHP 是受支持的,但您在文件中键入代码时出错了。请使用ch02文件夹中的版本。

  2. 如果只是看到原代码,说明不支持 PHP。

Caution

本书中的代码使用了 PHP 8 的新特性。如果您的 web 服务器运行的是旧版本的 PHP,本书中描述的许多技术将无法工作。

决定在哪里测试你的页面

与普通网页不同,你不能在 Mac 上的 Windows 文件资源管理器或 Finder 中双击 PHP 页面,然后在浏览器中查看它们。它们需要通过支持 PHP 的网络服务器进行解析或处理。如果你的托管公司支持 PHP,你可以上传你的文件到你的网站并在那里测试。但是,每次进行更改时,您都需要上传文件。在早期,您会发现由于代码中的一个小错误,您不得不经常这样做。随着你越来越有经验,你仍然需要经常上传文件,因为你会想尝试不同的想法。

使用本地测试环境是用 PHP 开发的最有效的方式。本章的其余部分将向你展示如何做到这一点,包括对 Windows 和 macOS 的说明。

本地测试环境需要什么

要在本地计算机上测试 PHP 页面,您需要安装以下软件:

  • 网络服务器,它是一个显示网页的软件,而不是一台独立的计算机

  • 服务器端编程语言(Professional Hypertext Preprocessor 的缩写)

  • 一个 MySQL 或 MariaDB 数据库和 phpMyAdmin,一个用于管理数据库的基于 web 的前端

Tip

MariaDB ( https://mariadb.org/ )是一个由社区开发的替代 MySQL 的插件。本书中的代码完全兼容 MySQL 和 MariaDB。

你需要的所有软件都是免费的。对你来说,唯一的代价是下载必要的文件所花费的时间,当然,还有确保一切设置正确的时间。在大多数情况下,您应该在不到一个小时的时间内启动并运行,可能更短。只要你有至少 1 GB 的空闲磁盘空间,你应该能够在你的计算机上安装所有的软件——即使是一个普通的软件。

Tip

如果您的本地计算机上已经有了 PHP 8 测试环境,就没有必要重新安装。只需查看本章末尾的“检查 PHP 设置”一节。

设置测试环境最简单的方法是使用一个包,在一个操作中安装 Apache、PHP、MySQL(或 MariaDB)和 phpMyAdmin。在我的电脑上,我用 XAMPP 操作系统( www.apachefriends.org/index.html )和 MAMP 操作系统( www.mamp.info/en/ )。还提供其他软件包;你选择哪一个都没关系。

在 Windows 上设置

在继续之前,请确保您以管理员身份登录。

让窗口显示文件扩展名

默认情况下,大多数 Windows 计算机隐藏常见的三或四个字母的文件扩展名,如.doc.html,所以你在对话框和 Windows 文件资源管理器中看到的都是thisfile,而不是thisfile.docthisfile.html

使用以下说明在 Windows 10 中启用文件扩展名显示:

  1. 打开文件资源管理器(Windows 键+ E)。

  2. 选择“查看”以展开文件资源管理器窗口顶部的功能区。

  3. 选择“文件扩展名”复选框。

显示文件扩展名更加安全——您可以判断病毒作者是否在看似无害的文档中附加了.exe.scr可执行文件。

选择 Web 服务器

大多数 PHP 安装运行在 Apache web 服务器上。两者都是开源的,可以很好地协同工作。但是,Windows 有自己的 web 服务器,Internet 信息服务(IIS),它也支持 PHP。微软与 PHP 开发团队密切合作,将 PHP 在 IIS 上的性能提高到与 Apache 大致相同的水平。那么应该选择哪个呢?

除非您需要 ASP 或 ASP.NET 的 IIS,否则我建议您使用 XAMPP 或其他一体化软件包安装 Apache,如下一节所述。如果需要使用 IIS,可以从 https://php.iis.net/ 安装 PHP。

在 Windows 上安装一体化软件包

有两个流行的 Windows 软件包可以在一次操作中在你的电脑上安装 Apache、PHP、MySQL 或 MariaDB、phpMyAdmin 和其他几个工具:XAMPP ( www.apachefriends.org/index.html )和 EasyPHP ( www.easyphp.org )。安装过程通常只需几分钟。安装完软件包后,您可能需要更改一些设置,这将在本章后面解释。

在印刷书籍的生命周期中,版本很容易改变,所以我不会描述安装过程。每个包装的网站上都有说明。

在 macOS 上设置

Apache web 服务器和 PHP 预装在 macOS 上,但默认情况下它们是不启用的。我建议您不要使用预装的版本,而是使用 MAMP,它可以在一次操作中安装 Apache、PHP、MySQL、phpMyAdmin 和其他几个工具。

为了避免与预装的 Apache 和 PHP 版本冲突,MAMP 将所有应用放在硬盘上的专用文件夹中。如果您决定不再需要电脑上的 MAMP,只需将 MAMP 文件夹拖到废纸篓,就可以轻松卸载所有内容。

安装 MAMP

开始之前,请确定您已使用管理权限登录到电脑:

  1. 前往 www.mamp.info/en/downloads/ ,选择 MAMP & MAMP PRO 的链接。这会下载一个磁盘映像,其中包含免费和付费版本的 MAMP。

  2. 下载完成后,启动磁盘映像。您将看到一份许可协议。您必须点按“同意”以继续装载磁盘映像。

  3. 按照屏幕上的说明进行操作。

  4. 验证 MAMP 已安装在您的应用文件夹中。

Note

MAMP 自动将免费版和付费版安装在分别名为MAMPMAMP PRO的文件夹中。付费版本使得配置 PHP 和使用虚拟主机更加容易,但是免费版本已经足够了,尤其是对于初学者。如果你想删除MAMP PRO文件夹,不要把它拖到垃圾桶里。打开文件夹,双击MAMP PRO卸载图标。付费版本需要这两个文件夹。

测试和配置 MAMP

默认情况下,MAMP 为 Apache 和 MySQL 使用非标准端口。除非您使用 Apache 和 MySQL 的多个安装,否则请按照以下步骤更改端口设置:

img/332054_5_En_2_Fig1_HTML.jpg

图 2-1

在 MAMP 控制面板中选择 PHP 版本

  1. 双击应用/MAMP 中的 MAMP 图标。如果您看到一个面板,邀请您了解有关标准视图的更多信息,这是仅在付费版本中新增的功能。要防止每次启动 MAMP 时都显示该面板,请取消选择面板左下角的复选框。然后单击左上角的关闭按钮关闭面板。

  2. 在 MAMP 控制面板中,将 PHP 版本下拉菜单设置为 8.0.2 或更高版本(参见图 2-1 )。如果你得到一个警告,说你的站点在 PHP 8 中可能没有预期的行为,点击 OK。通过选中复选框,可以防止此警告再次出现。

img/332054_5_En_2_Fig2_HTML.jpg

图 2-2

更改 Apache 和 MySQL 端口

  1. 单击 MAMP 控制面板右上角的开始图标。您的默认浏览器最终会启动并向您显示 MAMP 欢迎页面。

  2. 如果您的浏览器没有自动启动,请单击 MAMP 控制面板顶部的 WebStart 图标。

  3. 检查浏览器地址栏中的 URL。从localhost:8888开始。:8888表示 Apache 正在监听非标准端口 8888 上的请求。

  4. 最小化浏览器,然后单击 MAMP 控制面板左上角的首选项图标。

  5. 在打开的面板顶部选择端口。显示 Apache 和 MySQL 运行在端口 8888 和 8889 上(见图 2-2 )。

  6. 如图 2-2 所示,点击 80 & 3306 按钮,将 web 和 MySQL 端口更改为标准值:Apache 为 80,MySQL 为 3306。

  7. 当提示重新启动服务器时,点按“好”并输入您的 Mac 密码。

    提示如果任何其他程序正在使用端口 80,Apache 将不会重新启动。如果您找不到阻止 Apache 使用端口 80 的原因,请打开 MAMP 偏好设置面板,然后单击 MAMP 默认按钮。然后再次单击确定。

  8. 当服务器再次启动时,单击 MAMP 控制面板中的 WebStart 按钮,将欢迎页面加载到浏览器中。这一次,URL 不应该有一个冒号,后跟一个出现在localhost后面的数字,因为 Apache 现在正在监听默认端口。

在哪里可以找到你的 PHP 文件(Windows 和 Mac)

您需要在 web 服务器可以处理文件的位置创建文件。通常,这意味着文件应该位于服务器的文档根目录或文档根目录的子文件夹中。对于最常见的设置,文档根目录的默认位置如下:

  • XAMPP : C:\xampp\htdocs

  • EasyPHP : C:\EasyPHP\www

  • IISC:\inetpub\wwwroot

  • MAMP : /Applications/MAMP/htdocs

要查看 PHP 页面,您需要使用 URL 将其加载到浏览器中。在您的本地测试环境中,web 服务器的文档根的 URL 是http://localhost/

Caution

如果您需要将 MAMP 重置回其默认端口,您将需要使用http://localhost:8888而不是http://localhost

如果您将这本书的文件存储在名为php8sols的文档根目录的子文件夹中,URL 是http://localhost/php8sols/,后跟文件夹(如果有)和文件的名称。

Tip

如果您对http://localhost/有疑问,请使用http://127.0.0.1/127.0.0.1是所有计算机用来指代本地机器的环回 IP 地址。

检查你的 PHP 设置

安装 PHP 后,最好检查一下它的配置设置。除了核心特性,PHP 还有大量可选的扩展。一体化软件包安装了这本书所需的所有扩展。但是,一些基本配置设置可能会略有不同。为避免意外问题,请调整您的配置,以匹配以下页面中推荐的设置。

用 phpinfo()显示服务器配置

PHP 有一个内置命令phpinfo(),显示 PHP 在服务器上如何配置的细节。由phpinfo()产生的大量细节可能让人感觉像是大量信息过载,但是它对于确定为什么某些东西在您的本地计算机上完美地工作而在您的实时网站上却不工作是无价的。问题通常在于远程服务器禁用了某个功能或者没有安装可选的扩展。

一体式软件包使运行phpinfo()变得简单:

  • XAMPP :点击 XAMPP 控制面板中的 Apache 管理按钮,在浏览器中启动 XAMPP 欢迎页面。然后点击页面顶部的 PHPInfo 按钮。

  • MAMP:向下滚动到 MAMP 欢迎页面的 PHP 部分,点击 phpinfo 链接。

或者,创建一个简单的测试文件,并按照以下说明将其加载到您的浏览器中:

  1. 确保 Apache 或 IIS 正在本地计算机上运行。

  2. 在脚本编辑器中键入以下内容:

<?php phpinfo();

文件里应该没别的了。

img/332054_5_En_2_Fig3_HTML.jpg

图 2-3

运行phpinfo()命令显示 PHP 配置的全部细节。

  1. 将文件保存为服务器文档根目录中的phpinfo.php(参见本章前面的“在哪里定位您的 PHP 文件(Windows 和 Mac)”。

    小心确保你的编辑器没有在.php后添加.txt.rtf扩展名。

  2. 在浏览器地址栏中键入http://localhost/phpinfo.php,然后按回车键。

  3. 您应该会看到一个类似于图 2-3 的页面,显示 PHP 的版本,后面是您的 PHP 配置的详细信息。

  4. 记下加载的配置文件项目的值。这告诉你在哪里可以找到php.ini,你需要编辑这个文本文件来改变 PHP 中的大多数设置。

  5. 向下滚动到标记为 Core 的部分,将设置与表 2-1 中推荐的设置进行比较。记下任何差异,以便您可以按照本章后面的描述进行更改。

    表 2-1

    推荐的 PHP 配置设置

    |

    管理的

    |

    本地值

    |

    评论

    |
    | --- | --- | --- |
    | display_errors | 在 | 对于调试脚本中的错误至关重要。如果设置为 Off,错误会导致完全空白的屏幕,让您对可能的原因一无所知。 |
    | error_reporting | Thirty-two thousand seven hundred and sixty-seven | 这将错误报告设置为最高级别。 |
    | file_uploads | 在 | 允许您使用 PHP 将文件上传到网站。 |
    | log_errors | 离开 | 当display_errors设置为 On 时,您不需要用错误日志来填充硬盘。 |

  6. 配置页面的其余部分显示了启用了哪些 PHP 扩展。虽然页面看起来会一直延续下去,但是扩展都是按字母顺序排列的。要使用这本书,请确保启用了以下扩展:

    • gd :使 PHP 能够生成和修改图像和字体。

      注意如果您的测试环境运行在 Windows 上,并且没有列出gd扩展,您可以按照下一节中的说明轻松打开它。

    • MySQL:连接到 MySQL/MariaDB。(请注意“I”,它代表“改进的”。从 PHP 7 开始,不再支持旧的mysql版本。)

    • PDO:为数据库提供软件中立的支持(可选)。

    • pdo_mysql :连接 MySQL/MariaDB 的替代方法(可选)。

    • 会话:会话维护与用户相关的信息,并用于用户认证等。

您还应该在远程服务器上运行phpinfo()来检查哪些特性被启用了。如果不支持列出的扩展名,当您将文件上传到网站时,本书中的某些代码将无法工作。如果PDOpdo_mysql没有列出,可以用mysqli代替。

Caution

phpinfo()显示的输出揭示了大量信息,恶意黑客可以利用这些信息来攻击您的网站。检查配置后,请务必从远程服务器上删除该文件。

如果您的设置中有任何设置与这些建议不同,您将需要编辑 PHP 配置文件php.ini,如下一节所述。

编辑 php.ini

PHP 配置文件php.ini是一个非常长的文件,这往往会让编程新手感到紧张,但没什么好担心的。它是用纯文本编写的,其长度的一个原因是它包含大量解释各种选项的注释。也就是说,在编辑php.ini之前做个备份是个好主意,以防你出错。

如何打开php.ini取决于您的操作系统和 PHP 的安装方式:

  • 如果您在 Windows 上使用了一体化软件包,例如 XAMPP,请在 Windows 文件资源管理器中双击php.ini。该文件将在记事本中自动打开。

  • 如果您在 IIS 上安装了 PHP,php.ini通常位于 Program Files 的子文件夹中。虽然您可以通过双击打开php.ini,但是您将无法保存您所做的任何更改。相反,右键单击记事本并选择以管理员身份运行。在记事本中,选择文件➤打开,并设置选项显示所有文件().导航到php.ini所在的文件夹,选择文件,点击打开。

  • 在 macOS 上,使用纯文本编辑器打开php.ini。如果您使用“文本编辑”,请确定它将文件存储为纯文本格式,而不是多信息文本格式。

以分号(;)开头的行是注释。除了在 Windows 上打开gd扩展名,需要编辑的行不用分号开头。

使用您的文本编辑器的查找功能找到您需要更改的指令,以匹配表 2-1 中的建议。大多数指令前面都有一个或多个应该如何设置的示例。确保你没有错误地编辑其中一个被评论的例子。

对于使用OnOff的指令,只需将值更改为推荐值。例如,如果需要打开错误消息的显示,请编辑这一行

display_errors = Off

通过将其更改为:

display_errors = On

要设置错误报告的级别,您需要使用 PHP 常量,这些常量以大写形式编写,并且区分大小写。该指令应该如下所示:

error_reporting = E_ALL

如果您的测试环境运行在 Windows 上,并且在运行phpinfo()时没有列出gd扩展,那么在php.ini中找到下面一行:

;extension=gd

删除该行开头的分号。

Note

在 macOS 和 Linux 上,PHP 通常需要在启用 gd 扩展的情况下进行编译,所以这个简单的修复不起作用。查看您下载 PHP 安装的网站,了解任何可用的选项。

编辑php.ini后,保存文件,然后重启 Apache 或 IIS,使更改生效。如果 web 服务器无法启动,请检查服务器的错误日志文件。可以在以下位置找到它:

  • XAMPP :在 XAMPP 控制面板,点击 Apache 旁边的日志按钮,然后选择 Apache (error.log)。

  • MAMP :在/Applications/MAMP/logs 中,双击 apache_error.log 在控制台中打开。

  • EasyPHP :右击系统托盘中的 EasyPHP 图标,选择日志文件➤ Apache。

  • IIS :日志文件的默认位置是 C:\inetpub\logs。

错误日志中的最新条目应该会告诉您是什么阻止了服务器重新启动。使用该信息来更正您对php.ini所做的更改。如果这不起作用,感谢你在编辑之前备份了php.ini。重新开始,仔细检查你的编辑。

下一步是什么?

现在,您已经有了一个 PHP 测试平台,毫无疑问,您已经迫不及待了。我最不想做的事情就是挫伤任何热情,但是在实际的网站中使用 PHP 之前,您应该对这门语言的规则有一个基本的了解。所以,在开始做这些有趣的事情之前,请阅读下一章,它解释了如何编写 PHP 脚本。即使你有丰富的 PHP 经验,也一定要查看 PHP 8 中的变化部分。

三、如何编写 PHP 脚本

这一章提供了 PHP 工作原理的快速概述,并给出了基本规则。它主要针对那些以前没有 PHP 或编码经验的读者。即使你以前使用过 PHP,也要检查一下主要的标题,看看这一章包含了什么,并复习一下你不太清楚的方面的知识。

本章涵盖

  • 理解 PHP 的结构

  • 在网页中嵌入 PHP

  • 将数据存储在变量和数组中

  • 让 PHP 做决定

  • 循环重复的任务

  • 使用预设任务的功能

  • 显示 PHP 输出

  • 理解 PHP 错误消息

PHP:大图

乍一看,PHP 代码可能看起来很吓人,但是一旦你理解了基础,你会发现结构非常简单。如果你使用过其他计算机语言,比如 JavaScript 或 jQuery,你会发现它们有很多共同点。

每个 PHP 页面必须具有以下内容:

  • 正确的文件扩展名,通常是.php

  • 每个 PHP 代码块周围的 PHP 标记(如果文件只包含 PHP 代码,通常省略结束 PHP 标记)

典型的 PHP 页面将使用以下部分或全部元素:

  • 变量作为未知或变化值的占位符

  • 用于保存多个值的数组

  • 做出决策的条件语句

  • 执行重复任务的循环

  • 执行预设任务的功能或对象

让我们依次快速浏览一下,从文件名和开始、结束标记开始。

告诉服务器处理 PHP

PHP 是一种服务器端语言。web 服务器——通常是 Apache——处理您的 PHP 代码,并只将结果(通常是 HTML 格式)发送给浏览器。因为所有的操作都在服务器上进行,所以您需要告诉它您的页面包含 PHP 代码。这包括两个简单的步骤:

  • 给每个页面一个 PHP 文件扩展名;默认为.php。只有当你的主机公司明确要求你使用不同的扩展名时,才使用不同的扩展名。

  • 用 PHP 标签识别所有的 PHP 代码。

开始标签是<?php,结束标签是?>。如果你把标签和周围的代码放在同一行,那么在开始标签之前或者结束标签之后不需要有空格,但是在开始标签中的php之后必须有一个空格,就像这样:

<p>This is HTML with embedded PHP<?php //some PHP code ?>.</p>

当插入多行 PHP 代码时,为了清晰起见,最好将开始和结束标记放在不同的行上:

<?php
// some PHP code
// more PHP code
?>

你可能会遇到<?作为开始标签的另一个简短版本。但是,<?并未在所有服务器上启用。坚持使用保证有效的<?php

当一个文件只包含 PHP 代码时,强烈建议省略结束 PHP 标签。这避免了使用包含文件时的潜在问题(参见第五章)。

Note

为了节省空间,本书中的大多数例子都省略了 PHP 标签。当您编写自己的脚本或将 PHP 嵌入到网页中时,您必须始终使用它们。

在网页中嵌入 PHP

PHP 可以作为一种嵌入式语言。这意味着您可以在普通网页中插入 PHP 代码块。当有人访问您的站点并请求一个 PHP 页面时,服务器会将其发送到 PHP 引擎,该引擎会从上到下读取页面,寻找 PHP 标签。HTML 和 JavaScript 原封不动地通过,但是每当 PHP 引擎遇到一个<?php标签时,它就开始处理您的代码,并继续处理,直到到达结束的?>标签(或者如果 PHP 代码后面没有任何内容,则到达脚本的结尾)。如果 PHP 产生了任何输出,它就在那个点被插入。

Tip

一个页面可以有多个 PHP 代码块,但是它们不能嵌套在一起。

图 3-1 显示了嵌入在普通网页中的一段 PHP 代码,以及它通过 PHP 引擎后在浏览器和页面源代码视图中的样子。该代码计算当前年份,检查它是否不同于固定年份(在图左侧代码的第 26 行用$startYear表示),并在版权声明中显示适当的年份范围。从图右下方的页面源代码视图中可以看到,发送到浏览器的内容中没有 PHP 的痕迹。

img/332054_5_En_3_Fig1_HTML.jpg

图 3-1

PHP 代码保留在服务器上;只有输出被发送到浏览器

Tip

PHP 并不总是为浏览器产生直接输出。例如,它可以在发送电子邮件或将信息插入数据库之前检查表单输入的内容。因此,一些代码块被放在主 HTML 代码的上面或下面,或者放在外部文件中。但是,产生直接输出的代码会出现在您希望显示输出的地方。

将 PHP 存储在外部文件中

除了在 HTML 中嵌入 PHP,通常的做法是将常用代码存储在单独的文件中。当一个文件只包含 PHP 代码时,开始的<?php标签是强制的,但是结束的?>标签是可选的。事实上,推荐的做法是省去结束的 PHP 标签。然而,如果外部文件在 PHP 代码后包含 HTML,你必须使用结束?>标签。

使用变量来表示变化的值

图 3-1 中的代码可能看起来像是一种非常冗长的显示一年或一系列年份的方式。但是从长远来看,PHP 解决方案可以节省您的时间。你不需要每年更新版权声明,PHP 代码会自动更新。你写一次代码就忘了。更重要的是,正如你将在第五章中看到的,如果你将代码存储在一个外部文件中,对外部文件的任何更改都会反映在你网站的每一页上。

这种自动显示年份的能力依赖于 PHP 的两个关键方面:变量函数。顾名思义,函数做事情;它们执行预设的任务,例如获取当前日期并将其转换为人类可读的形式。稍后我将介绍函数,所以让我们先研究变量。图 3-1 中的脚本包含两个变量:$startYear$thisYear

Tip

一个变量仅仅是你给一个可能改变或者你事先不知道的东西起的名字。PHP 中的变量总是以$(一个美元符号)开头。

我们在日常生活中无时无刻不在使用变量,而不去思考它。当你第一次见到某人时,你可以问“你叫什么名字?”这个人是叫汤姆、迪克还是哈里特并不重要;“名”字不变。同样,你的银行账户也是,钱一直在进进出出(看起来大部分是出了),但是如图 3-2 所示,无论你是穷困潦倒还是腰缠万贯都没关系。可用的金额总是被称为余额。

img/332054_5_En_3_Fig2_HTML.jpg

图 3-2

你的银行对账单上的余额是一个变量的日常例子——标签保持不变,尽管值可能每天都在变化

所以“名字”和“余额”是日常变量。只要在它们前面放一个美元符号,就有了两个现成的 PHP 变量,就像这样:

$name
$balance

很简单。

命名变量

只要记住以下规则,您可以随意命名变量:

  • 变量总是以美元符号($)开头。

  • 有效字符包括字母、数字和下划线。

  • 美元符号后的第一个字符必须是字母或下划线(_)。

  • 除下划线外,不允许有空格或标点符号。

  • 变量名区分大小写:$startYear$startyear不一样。

给变量命名时,选择能告诉你它的用途的东西。到目前为止,你所看到的变量——$startYear$thisYear$name$balance——就是很好的例子。在组合单词时,最好将第二个或后续单词的第一个字母大写(有时称为 camel case )。或者,您可以使用下划线($start_year$this_year等)。).

Tip

西欧语言中常用的重音字符在变量中是有效的。比如$prénom$förnamn都是可以接受的。在实践中,您也可以在变量名中使用其他字母,如西里尔字母和非字母文字,如日本汉字;但是在撰写本文时,这种使用还没有文档记录,所以我建议坚持前面的规则。

不要试图通过使用非常短的变量来节省时间。使用$sy$ty$n$b而不是更具描述性的会让代码更难理解——这也让代码更难写。更重要的是,这使得错误更难发现。和往常一样,规则也有例外。按照惯例,$i$j$k经常用于记录循环运行的次数,而$e$t用于错误检查。在本章的后面你会看到这些例子。

Caution

虽然你在变量名的选择上有相当大的自由度,但是不能用$this,因为它在 PHP 面向对象编程中有特殊的含义。建议避免 www.php.net/manual/en/reserved.php 中列出的任何关键词。

给变量赋值

变量从各种来源获取值,包括:

  • 通过在线表单的用户输入

  • 一个数据库

  • 外部源,如新闻源或 XML 文件

  • 计算的结果

  • PHP 代码中的直接赋值

无论该值来自何处,通常都会被赋予一个等号(=),如下所示:

$variable = value;

变量在等号的左边,值在右边。因为等号赋值,所以称之为赋值运算符

Caution

从小对等号的熟悉,让我们很难走出认为它表示“等于”的习惯。然而,PHP 使用两个等号(==)来表示相等。这是初学者犯错误的主要原因,而且有时也会让更有经验的开发人员犯错误。===之间的区别将在本章后面详细介绍。

以分号结束命令

PHP 被写成一系列的命令或语句。每个语句通常告诉 PHP 引擎执行一个操作,并且它后面必须总是跟一个分号,就像这样:

<?php
do this;
now do something else;
?>

与所有规则一样,有一个例外:您可以省略代码块中最后一条语句后的分号。然而,不要这样做,除非使用短的echo标签,如本章后面所述。与 JavaScript 不同,PHP 不会假定如果你省略分号,行尾就应该有分号。这有一个很好的副作用:你可以将长语句分散在几行中,以便于阅读。PHP 和 HTML 一样,忽略代码中的空白。相反,它依靠分号来指示一个命令的结束位置和下一个命令的开始位置。

Tip

缺少分号会让你的脚本嘎然而止。

注释脚本

PHP 将所有东西都视为要执行的语句,除非您将一段代码标记为注释。以下三个原因解释了您为什么想要这样做:

  • 插入脚本功能的提醒

  • 为以后添加的代码插入占位符

  • 暂时禁用一段代码

当您对脚本记忆犹新时,似乎没有必要插入任何不会被处理的内容。然而,如果您需要在几个月后修改脚本,您会发现注释比试图单独遵循代码更容易阅读。当你在团队中工作时,评论也是至关重要的。他们帮助你的同事理解代码的意图。

在测试过程中,阻止一行代码,甚至整个部分运行通常是有用的。PHP 忽略任何标记为注释的东西,所以这是一种打开和关闭代码的有用方法。

添加注释有三种方式:两种用于单行注释,一种用于跨越多行的注释。

单行注释

最常见的单行注释以两个正斜杠开头,如下所示:

// this is a comment and will be ignored by the PHP engine

PHP 忽略从双斜线到行尾的所有内容,所以您也可以在代码旁边放置注释(但只能放在右边):

$startYear = 2018; // this is a valid comment

注释不是 PHP 语句,所以它们不以分号结尾。但是不要忘记 PHP 语句末尾的分号,它和注释在同一行。

另一种样式使用散列或井号(#),如下所示:

# this is another type of comment that will be ignored by the PHP engine
$startYear = 2018; # this also works as a comment

这种注释风格通常表示较长脚本的各个部分,如下所示:

##################
## Menu section ##
##################

Caution

PHP 8 使用#[作为一个名为属性的新特性的开始语法,该特性为类、函数和一些其他特性的声明提供元数据(参见 www.php.net/manual/en/language.attributes.overview.php )。如果你在#后面使用一个方括号作为注释,PHP 8 将会产生一个解析错误。

多行注释

对于跨越几行的注释,使用与级联样式表(CSS)和 JavaScript 相同的样式。/**/之间的任何内容都被视为注释,如下所示:

/* This is a comment that stretches
   over several lines. It uses the same
   beginning and end markers as in CSS. */

多行注释在测试或故障排除时特别有用,因为它们可以用来禁用脚本的长部分,而无需删除它们。

Tip

好的注释和精心选择的变量名使代码更容易理解和维护。

使用数组存储多个值

PHP 允许你在一个特殊类型的变量中存储多个值,这个变量被称为一个数组。一种简单的思考数组的方式是,它们就像一个购物清单。虽然每个项目可能不同,但您可以用一个名称来统称它们。图 3-3 展示了这个概念:变量$shoppingList指的是所有五个项目——酒、鱼、面包、葡萄和奶酪。

img/332054_5_En_3_Fig3_HTML.jpg

图 3-3

数组是存储多个项目的变量,就像购物清单一样

单个项目——或数组元素——通过紧跟在变量名后面的方括号中的数字来标识。PHP 自动分配编号,但是需要注意的是编号总是从 0 开始。所以数组中的第一项,在我们的例子中是葡萄酒,被称为$shoppingList[0],而不是$shoppingList[1]。虽然有五个项目,但最后一个(奶酪)是$shoppingList[4]。这个数字被称为数组索引,这种类型的数组被称为索引数组

Caution

PHP 8 改变了自动编号的工作方式。如果你创建一个数组,第一个键是负数(如第四章所述),后续的键将在前一个数上加 1。在 PHP 8 之前,负数之后的后续键总是从零开始。

PHP 使用另一种类型的数组,其中的关键字是一个单词(或者是字母和数字的任意组合)。例如,包含这本书的详细信息的数组可能如下所示:

$book['title'] = 'PHP 8 Solutions: Dynamic Web Design and Development Made Easy';
$book['author'] = 'David Powers';
$book['publisher'] = 'Apress';

这种类型的数组称为关联数组。注意数组键是用引号括起来的(单引号还是双引号,无所谓)。

数组是 PHP 重要而有用的一部分。你会经常用到它们,从第五章开始,你将在一个数组中存储图像的细节,以在网页上显示一个随机的图像。当您在一系列数组中获取搜索结果时,数组也广泛用于数据库。

Note

你可以在第四章中学习创建数组的各种方法。

PHP 内置的超级全局数组

PHP 有几个内置的数组,可以自动填充有用的信息。它们被称为超全局数组,通常以美元符号后跟下划线开始。唯一的例外是$GLOBALS,它包含对脚本的全局作用域中所有变量的引用(参见第四章中的“变量作用域:作为黑盒的功能”了解作用域的描述)。

你会经常看到的两个超级全局变量是$_POST$_GET。它们包含分别通过超文本传输协议(HTTP) postget方法从表单传递的信息。超全局变量都是关联数组,$_POST$_GET的键是从 URL 末尾的查询字符串中的表单元素或变量的名称自动派生出来的。

假设您在一个表单中有一个名为“address”的文本输入字段;当表单通过post方法提交时,PHP 自动创建一个名为$_POST['address']的数组元素,或者如果使用get方法,PHP 自动创建一个名为$_GET['address']的数组元素。如图 3-4 所示,$_POST['address']包含了访问者在文本字段中输入的任何值,使你能够在屏幕上显示它,将其插入数据库,发送到你的电子邮箱,或者做任何你想做的事情。

img/332054_5_En_3_Fig4_HTML.jpg

图 3-4

您可以通过$_POST数组检索用户输入的值,该数组是在使用post方法提交表单时自动创建的

当您通过电子邮件将在线反馈表的内容发送到您的收件箱时,您将使用第六章中的$_POST数组。你将在本书中使用的其他超全局数组是$_SERVER,在第 5 、 14 和 15 中从 web 服务器获取信息,在第 8 和$_SESSION章中上传文件到你的网站,在第 11 和 19 中创建一个简单的登录系统。

Caution

不要忘记 PHP 中的变量名是区分大小写的。所有超全局数组名都是大写的。比如$_Post或者$_Get就不行。

了解何时使用引号

如果你仔细观察图 3-1 中的 PHP 代码块,你会注意到赋给第一个变量的值没有用引号括起来。看起来是这样的:

$startYear = 2021;

然而“使用数组存储多个值”中的所有例子都使用了引号,就像这样:

$book['title'] = 'PHP 8 Solutions: Dynamic Web Design and Development Made Easy';

简单的规则如下:

  • 数字:无引号

  • 文本:需要引号

一般来说,不管是用单引号还是双引号括住文本还是用字符串括住文本,都没有关系,因为文本是在 PHP 和其他计算机语言中调用的。情况有点复杂,如第四章中所解释的,因为 PHP 引擎处理单引号和双引号的方式有细微的差别。

Note

“字符串”这个词是从计算机和数学科学中借来的,它的意思是一系列简单的对象——在这里是文本中的字符。

引号必须总是成对出现,所以在单引号字符串中包含撇号或者在双引号字符串中包含双引号时需要小心。检查以下代码行:

$book['description'] = 'This is David's latest book on PHP.';

乍一看,似乎没什么毛病。然而,PHP 引擎看到的东西与人眼不同,如图 3-5 所示。

img/332054_5_En_3_Fig5_HTML.jpg

图 3-5

单引号字符串中的撇号会使 PHP 引擎混淆

有两种方法可以解决这个问题:

  • 如果文本包含撇号,请使用双引号。

  • 在撇号前加一个反斜杠(这被称为转义)。

因此,以下两种情况都是可以接受的:

$book['description'] = "This is David's latest book on PHP.";
$book['description'] = 'This is David\'s latest book on PHP.';

这同样适用于双引号字符串中的双引号(尽管规则相反)。以下代码导致了一个问题:

$play = "Shakespeare's "Macbeth"";

在这种情况下,撇号没问题,因为它与双引号不冲突,但是 Macbeth 前面的左引号使字符串过早结束。要解决该问题,以下任一方法都是可以接受的:

$play = 'Shakespeare\'s "Macbeth"';
$play = "Shakespeare's \"Macbeth\"";

在第一个示例中,整个字符串都用单引号括起来。这绕过了麦克白的双引号问题,但是引入了对 的莎士比亚的 中的撇号进行转义的需要。撇号在双引号字符串中没有问题,但是麦克白两边的双引号都需要转义。所以,总结一下

  • 单引号和撇号在双引号字符串中是可以的。

  • 双引号可以出现在单引号字符串中。

  • 其他任何内容都必须用反斜杠进行转义。

Tip

大多数情况下使用单引号,保留双引号用于有特殊含义的情况,如第四章所述。

特殊情况:真、假和空

尽管文本应该用引号括起来,但是三个关键字——truefalsenull——永远不应该用引号括起来,除非您想将它们视为字符串。前两个意味着你所期望的;null表示“不存在的价值”

Note

Truefalse被称为布尔值。它们是以 19 世纪数学家乔治·布尔的名字命名的,他的逻辑运算系统成为许多现代计算的基础。

正如下一节所解释的,PHP 根据某个东西是等于true还是false来做出决定。给false加上引号会产生意想不到的后果。考虑以下代码:

$OK = 'false';

这与你可能期望的正好相反:它使$OK成为真实!为什么呢?因为false周围的引号把它变成了一个字符串,而 PHP 把字符串当成了true。(在第四章的“PHP 真相”中有更详细的解释。)

关键字truefalsenull不区分大小写。下面的例子都是有效的:

$OK = TRUE;
$OK = tRuE;
$OK = true;

因此,概括一下,PHP 将truefalsenull视为特例:

  • 不要用引号将它们括起来。

  • 它们不区分大小写。

做决定

决定,决定,决定…生活充满了决定。PHP 也是。它们使它能够根据情况改变输出。PHP 中的决策使用条件语句。这些用法中最常见的是if,它严格遵循正常语言的结构。在现实生活中,你可能会面临以下决定(诚然,在英国不太常见):如果天气热,我会去海滩。

在 PHP 伪代码中,相同的决定如下所示:

if (the weather's hot) {
    I'll go to the beach;
}

被测试的条件放在括号内,结果动作放在花括号内。这是基本的决策模式:

if (condition is true) {
    // code to be executed if condition is true
}

Tip

条件语句是控制结构,后面没有分号。花括号将一个或多个单独的语句放在一起,这些语句将作为一个组来执行。

如果条件是true,花括号内的代码只在执行。如果是false,PHP 将忽略括号之间的所有内容,并继续下一段代码。PHP 如何确定条件是true还是false将在下一节描述。

有时,if语句就是您所需要的,但是如果条件不满足,您通常希望调用默认操作。为此,使用else,就像这样:

if (condition is true) {
    // code to be executed if condition is true
} else {
    // default code to run if condition is false
}

如果你想要更多的选择,你可以像这样添加更多的条件语句:

if (condition is true) {
    // code to be executed if condition is true
} else {
    // default code to run if condition is false
}
if (second condition is true) {
    // code to be executed if second condition is true
} else {
    // default code to run if second condition is false
}

在这种情况下,两个条件语句都将运行。如果您只想执行一个代码块,可以像这样使用elseif:

if (condition is true) {
    // code to be executed if first condition is true
} elseif (second condition is true) {
    // code to be executed if first condition fails
    // but second condition is true
} else {
    // default code if both conditions are false
}

您可以在条件语句中使用任意多的elseif子句。只执行第一个等于真的条件;所有其他的都将被忽略,即使它们也是真的。这意味着你需要按照你希望它们被评估的优先顺序来构建条件语句。这是严格的先来先服务的等级制度。

Note

虽然elseif通常被写成一个单词,但是你也可以把else if作为单独的单词。

进行比较

条件语句只对一件事感兴趣:被测试的条件是否等于true。如果不是true,那一定是false。没有折衷或可能的余地。条件通常取决于两个值的比较。这个比那个大吗?它们都一样吗?诸如此类。

为了测试相等性,PHP 使用两个等号(==),如下所示:

if ($status == 'administrator') {
    // send to admin page
} else {
    // refuse entry to admin area
}

Caution

在第一行使用一个等号($status = 'administrator')向所有人打开你网站的管理区。为什么呢?因为这样会自动将$status的值设置为administrator;它不会比较这两个值。这是一个常见的错误,但可能会带来灾难性的后果。要比较值,必须使用两个等号。一个更强大的比较运算符使用三个等号;在第四章中有描述。

使用小于(<)和大于(>)的数学符号进行数值比较。假设您在允许文件上传到您的服务器之前检查文件的大小。您可以将最大大小设置为 50 KB,如下所示(1 千字节= 1024 字节):

if ($bytes > 51200) {
    // display error message and abandon upload
} else {
    // continue upload
}

Note

第四章描述了如何同时测试多个条件。

为了清晰起见,使用缩进和空白

缩进代码有助于将语句放在逻辑组中,从而更容易理解脚本的流程。PHP 忽略代码中的任何空白,所以你可以采用任何你喜欢的风格。保持一致,这样你就能发现任何看起来不合适的地方。

大多数人发现缩进四到五个空格有助于提高代码的可读性。也许风格上最大的区别在于花括号的位置。通常将左大括号放在与前面代码相同的行上,右大括号放在代码块后的新行上,如下所示:

if ($bytes > 51200) {
    // display error message and abandon upload
} else {
    // continue upload
}

然而,其他人更喜欢这种风格:

if ($bytes > 51200)
{
    // display error message and abandon upload
}
else
{
    // continue upload
}

风格并不重要。重要的是你的代码是一致的,易读的。

对重复性任务使用循环

循环可以节省大量时间,因为它们一遍又一遍地执行相同的任务,而只涉及很少的代码。它们经常用于数组和数据库结果。您可以逐一查看每个项目,查找匹配项或执行特定任务。循环在与条件语句结合使用时特别强大,允许您在一次扫描中对大量数据选择性地执行操作。在真实的环境中使用循环可以更好地理解它们。所有循环结构的细节和示例在第四章中。

使用预设任务的功能

函数做事情……很多事情,令人难以置信的是在 PHP 中。典型的 PHP 设置允许您访问数千个内置函数。您只需要使用少数几种,但是知道 PHP 是一种全功能语言是令人放心的。

您将在本书中使用的函数确实非常有用,例如获取图像的高度和宽度,从现有图像创建缩略图,查询数据库,发送电子邮件,等等。您可以在 PHP 代码中识别函数,因为它们后面总是跟有一对括号。有时候,括号是空的,就像你在上一章的phpversion.php中使用的phpversion()的情况。不过,括号中通常包含变量、数字或字符串,如图 3-1 中的这行代码:

$thisYear = date('Y');

这段代码计算当前年份,并将其存储在变量$thisYear中。它通过向内置的 PHP 函数date()提供字符串'Y'来工作。像这样在括号之间放置一个值被称为向函数传递一个参数。该函数获取参数中的值,并对其进行处理以产生(或返回)结果。例如,如果您将字符串'M'作为参数传递给date()而不是'Y',它将返回三个字母缩写形式的当前月份(例如,三月、四月、五月)。如下例所示,通过将函数的结果赋给适当命名的变量,可以捕获函数的结果:

$thisMonth = date('M');

Note

第十六章深入介绍了 PHP 如何处理日期和时间。

有些函数有多个参数。出现这种情况时,请在括号内用逗号分隔参数,如下所示:

$mailSent = mail($to, $subject, $message);

不需要天才就能发现,这将发送一封电子邮件到第一个参数中存储的地址,主题行存储在第二个参数中,消息存储在第三个参数中。大多数函数都返回值,所以结果存储在变量$mailSent(本例中为true或 f alse,取决于成功或失败)。你会在第六章中看到这个函数是如何工作的。

Tip

您经常会遇到术语“参数”而不是“参数”从技术上讲,parameter 指的是函数定义中使用的变量,而 argument 指的是传递给函数的实际值。实际上,这两个术语往往可以互换使用。

似乎所有的内置函数还不够,PHP 允许您构建自己的自定义函数,如下一章所述。即使你不喜欢创建自己的函数,在本书中你也会用到一些我自己做的函数。您可以像使用内置函数一样使用它们。

显示 PHP 输出

除非你能在你的网页上显示结果,否则在幕后进行的所有这些魔术没有多大意义。在 PHP 中实现这一点的两种主要方式是使用echoprint。这两者之间有一些细微的差别,但它们是如此的细微,以至于你可以把echoprint视为相同。我更喜欢echo,原因很简单,因为这样可以少打一个字母。

可以将echo与变量、数字、字符串一起使用;简单地把它放在你想展示的东西前面,就像这样:

$name = 'David';
echo $name;   // displays David
echo 5;       // displays 5
echo 'David'; // displays David

对变量使用echoprint时,变量必须只包含一个值。您不能使用它们来显示数组或数据库结果的内容。这就是循环如此有用的地方:在循环中使用echoprint来单独显示每个元素。在本书的其余部分,您将会看到大量这样的例子。

您可能会看到使用圆括号将echoprint括起来的脚本,如下所示:

echo('David'); // displays David

括号没什么区别。除非你喜欢为了打字而打字,否则就不要用它们。

使用短回显标签

当您想要显示单个变量或表达式的值(除此之外别无其他)时,您可以使用简短的echo标记,它由一个左尖括号、一个问号和等号组成,如下所示:

<p>My name is <?= $name ?>.</p>

这会产生与以下内容相同的输出:

<p>My name is <?php echo $name ?>.</p>

因为它是echo的简写,所以没有其他代码可以在同一个 PHP 块中,但是当在网页中嵌入数据库结果时,它特别有用。不用说,在使用这个快捷方式之前,变量的值必须在前面的 PHP 块中设置。

Tip

因为没有其他代码可以在同一个 PHP 块中,所以在使用短的echo标记时,通常会省略结束 PHP 标记前的分号。

将字符串连接在一起

虽然许多其他计算机语言使用加号(+)来连接文本(字符串),但是 PHP 使用句点、圆点或句号(.),如下所示:

$firstName = 'David';
$lastName = 'Powers';
echo $firstName.$lastName; // displays DavidPowers

正如最后一行代码的注释所示,当两个字符串像这样连接在一起时,PHP 不会在它们之间留下任何空隙。不要误以为在句号后面加个空格就行了。不会的。你可以在周期的任何一边放尽可能多的空间;结果总是一样的,因为 PHP 会忽略代码中的空格。事实上,为了可读性,建议在句号的两边留一个空格。

要在最终输出中显示一个空格,必须在其中一个字符串中包含一个空格,或者将空格作为单独的字符串插入,如下所示:

echo $firstName . ' ' . $lastName; // displays David Powers

Tip

句点——或者说串联操作符,给它一个正确的名字——可能很难在其他代码中找到。确保编辑器中的字体足够大,可以看到句号和逗号之间的区别。

使用数字

PHP 可以处理很多数字,从简单的加法到复杂的数学。下一章包含了可以在 PHP 中使用的算术运算符的细节。在这个阶段,你需要记住的是,除了小数点之外,数字不能包含任何标点符号。这必须是一个点或句号。在许多欧洲国家,逗号是不允许使用的。类似地,如果您使用逗号或空格作为千位分隔符,PHP 将会阻塞(尽管从 PHP 7.4.0 开始,为了可读性,您可以使用下划线——PHP 在处理代码时会去掉下划线)。

理解 PHP 错误消息

错误消息是生活中不幸的事实,所以你需要理解它们试图告诉你什么。下图显示了一条典型的错误消息:

img/332054_5_En_3_Figa_HTML.jpg

PHP 错误消息报告 PHP 发现问题的行。大多数新人——很自然地——认为这是他们犯错的地方。错了…

大多数时候,PHP 会告诉你发生了意想不到的事情。换句话说,错误在于先于那一点。前面的错误消息意味着 PHP 发现了一个不应该存在的echo命令。

不要担心echo命令可能出了什么问题(可能没什么问题),开始向后工作,寻找任何遗漏的东西,可能是前一行的分号或右引号。

有时,消息会在脚本的最后一行报告错误。这通常意味着你已经在页面的某个地方省略了右花括号。

这些是主要的错误类别,按重要性降序排列如下:

  • 致命错误:错误之前的任何 HTML 输出都将被显示,但是一旦遇到错误——顾名思义——其他的都将被彻底杀死。致命错误通常是由引用不存在的文件或函数引起的。

  • 解析错误:这意味着你的代码语法中有错误,比如不匹配的引号,或者缺少分号或右括号。它停止脚本运行,甚至不允许显示任何 HTML 输出。

  • 警告:警告表示严重的问题,比如缺少包含文件。(包含文件是第五章的主题。)但是,该错误通常不会严重到阻止脚本的其余部分被执行。

  • Deprecated :警告你 PHP 未来版本中计划删除的特性。如果您看到这种类型的错误消息,您应该认真考虑更新您的脚本,因为如果您的服务器升级,它可能会突然停止工作。

  • Strict :这种类型的错误消息警告您使用不被认为是良好实践的技术。

  • 注意:这是对相对较小问题的建议,比如使用未声明的变量。虽然这种类型的错误不会阻止页面的显示(并且您可以关闭通知的显示),但是您应该总是尝试消除它们。任何错误都是对您输出的威胁。

为什么我的页面是空白的?

当许多初学者将一个 PHP 页面加载到浏览器中时,却什么也看不到,这让他们摸不着头脑。没有错误信息,只有一个空白页。当有一个解析错误——换句话说,代码中的一个错误——并且php.ini中的display_errors指令被关闭时,就会发生这种情况。

如果您遵循了前一章中的建议,那么应该在您的本地测试环境中启用display_errors。但是,大多数托管公司都关闭了display_errors。这有利于安全,但会使远程服务器上的故障排除变得困难。除了解析错误之外,缺少包含文件通常会导致空白页。

通过在页面顶部添加以下代码,可以打开单个脚本的错误显示:

ini_set('display_errors', '1');

将这段代码放在开始的 PHP 标签后的第一行,或者如果 PHP 在页面下方,则放在页面顶部的一个单独的 PHP 块中。当您上传页面并刷新浏览器时,您应该会看到 PHP 生成的任何错误消息。

如果在添加这行代码后,您仍然看到一个空白页,这意味着您的语法有错误。在打开display_errors的情况下本地测试页面,找出导致问题的原因。

Caution

更正错误后,删除显示错误的代码。如果在以后的阶段脚本中出现了其他问题,您不希望在您的 web 站点上暴露潜在的漏洞。

PHP 快速检查表

这一章包含了很多信息,但是希望它已经给了你一个 PHP 如何工作的广泛概述。以下是一些要点的提示:

  • 总是给 PHP 页面正确的文件扩展名,通常是.php

  • 将 PHP 代码放在正确的标签之间:<?php?>

  • 避免开头标签的缩写形式:<?。用<?php比较靠谱。

  • 在只包含 PHP 代码的文件中省略结束 PHP 标记。

  • PHP 变量以$开头,后跟一个字母或下划线字符。

  • 选择有意义的变量名,并记住它们区分大小写。

  • 使用注释来提醒您脚本的作用。

  • 数字不需要引号,但是字符串(文本)需要。

  • 小数点是数字中唯一允许的标点符号。

  • 您可以在字符串周围使用单引号或双引号,但外部的一对必须匹配。

  • 使用反斜杠对字符串中相同类型的引号进行转义。

  • 若要将相关项存储在一起,请使用数组。

  • 使用条件语句,如ifif . . . else,进行决策。

  • 循环简化了重复性任务。

  • 函数执行预设任务。

  • echoprint显示 PHP 输出。

  • 对于大多数错误信息,从指示的位置向后操作

  • 保持微笑——记住 PHP 是而不是难的。

下一章将填充一些必要的细节,你可以在阅读本书时参考。

四、PHP 快速参考

前一章为初学者提供了 PHP 的鸟瞰图,而这一章则深入细节。不要试图一口气读完。当您需要了解如何做特定的事情时,比如构建一个数组或使用一个循环来重复一个动作,就可以使用它。下面几节没有涵盖 PHP 的每个方面,但是它们将有助于扩展您对本书其余部分的理解。

本章涵盖

  • 理解 PHP 中的数据类型

  • 使用算术运算符进行计算

  • 理解 PHP 如何处理字符串中的变量

  • 创建索引和关联数组

  • 理解 PHP 认为什么是真什么是假

  • 用比较来做决定

  • 在循环中重复执行相同的代码

  • 用函数模块化代码

  • 使用生成器生成一系列值

  • 理解类和对象

  • 动态创建新变量

在现有网站中使用 PHP

PHP 代码通常只在使用.php文件扩展名的页面中处理。虽然您可以在同一个网站中混合使用.html.php页面,但是最好只使用.php,即使不是每个页面都包含动态特性。这让你可以灵活地将 PHP 添加到页面中,而不会破坏现有的链接或丢失搜索引擎排名。

PHP 中的数据类型

PHP 是众所周知的弱类型语言。实际上,这意味着,与其他一些计算机语言(如 Java 或 C#)不同,PHP 不关心在变量中存储什么类型的数据。

大多数情况下,这非常方便,尽管您需要小心用户输入,因为来自在线表单的数据总是以文本形式传输。仔细检查用户输入是后面章节的主题之一。

尽管 PHP 是弱类型的,但它使用以下数据类型:

  • 整数:这是一个整数,比如 1,25,42,或者 2006。整数不能包含逗号作为千位分隔符。但是,从 PHP 7.4.0 开始,为了提高可读性,可以在数字之间使用下划线,例如 1_234_567。PHP 引擎会自动删除下划线。

  • 浮点数:包含小数点的数字,如 9.99、98.6 或 2.1。PHP 不支持使用逗号作为小数点,这在许多欧洲国家是很常见的。您必须使用句号。和整数一样,从 PHP 7.4.0 开始,浮点数可以包含下划线作为千位分隔符。(这种类型也被称为浮子。)

Caution

以前导零开头的整数被视为八进制数。例如,08 会产生一个解析错误,因为它不是一个有效的八进制数。另一方面,在浮点数中使用前导零没有问题,例如 0.8。

  • String :字符串是任意长度的文本。它可以短到零个字符(空字符串),并且在 64 位版本上没有上限。在实践中,其他考虑因素,比如可用内存或通过表单传递值,都会施加限制。

  • 布尔:这个类型只有两个值,truefalse。然而,PHP 将其他值视为隐式真或假。请参阅本章后面的“PHP 的真相”。

  • 数组:数组是一个能够存储多个值的变量,尽管它可能根本不包含任何值(空数组)。数组可以保存任何数据类型,包括其他数组。一个数组的数组叫做多维数组

  • 对象:对象是一种复杂的数据类型,能够存储和操作值。请参阅本章后面的“理解 PHP 类和对象”。

  • Resource :当 PHP 连接到一个外部数据源时,比如一个文件或数据库,它将一个对它的引用存储为一个资源。

  • Null :这是一种特殊的数据类型,表示变量值不存在。

Note

PHP 在线文档列出了另外两种描述结构行为而不是数据类型的类型。一个 iterable 是一个结构,比如一个数组或生成器,它可以在一个循环中使用,通常在每次循环运行时提取或生成一个序列中的下一个值。一个可调用的是被另一个函数调用的函数。

PHP 弱类型的一个重要副作用是,如果您用引号将整数或浮点数括起来,PHP 会自动将其从字符串转换为数字,从而允许您执行计算,而无需任何特殊处理。这可能会产生意想不到的后果。当 PHP 看到加号(+)时,它假设您想要执行加法,因此它尝试将字符串转换为整数或浮点数,如下例所示(代码在ch04文件夹的data_conversion_01.php中):

$fruit = '2 apples ';
$veg = '2 carrots';
echo $fruit + $veg;  // displays 4

PHP 看到$fruit$veg都以一个数字开始,所以它提取这个数字并忽略其余的。

Caution

尽管自动转换有效,但 PHP 8 会生成关于“非数值”的警告消息。引号中的数字本身没有问题。

但是,如果字符串不是以数字开头,PHP 8 会触发致命的 TypeError,因为+不能用于组合两个字符串,如下例所示(代码在data_conversion_02.php):

$fruit = '2 apples ';
$veg = 'and 2 carrots';
echo $fruit + $veg;  // displays warning about "non-numeric value" followed by fatal error

检查变量的数据类型

测试脚本时,检查变量的数据类型通常很有用。这有助于解释为什么脚本会产生意想不到的结果。要检查变量的数据类型和内容,只需将它传递给var_dump()函数,如下所示:

var_dump($variable_to_test);

使用本章文件中的data_tests.php查看var_dump()为不同类型的数据生成的输出。只需更改最后一行括号中的变量名称。

显式更改变量的数据类型

大多数时候,PHP 会自动将变量的数据类型转换为适合当前上下文的类型。这就是所谓的式杂耍。然而,有时有必要使用转换操作符显式地改变数据类型。表 4-1 列出了 PHP 中最常用的造型运算符。

表 4-1

常用的 PHP 强制转换运算符

|

铸造操作员

|

可供选择的事物

|

操作

|
| --- | --- | --- |
| (array) |   | 强制转换为数组 |
| (bool) | (boolean) | 强制转换为布尔值 |
| (float) | (double), (real) | 强制转换为浮点数 |
| (int) | (integer) | 转换为整数 |
| (string) |   | 转换为字符串 |

要转换变量的数据类型,请在它前面加上适当的转换运算符,如下所示:

$input = 'coffee';
$drinks = (array) $input;

这将把$input的值作为一个数组分配给$drinks,包含字符串'coffee'作为其唯一的元素。当函数需要数组而不是字符串作为参数时,像这样将字符串转换为数组会很有用。在这个例子中,$input的数据类型仍然是字符串。要使造型永久,请将造型值重新分配给原始变量,如下所示:

$input = (array) $input;

检查变量是否已经定义

条件语句中最常见的测试之一是检查变量是否已定义。简单地将变量传递给isset()函数,如下所示:

if (isset($name)) {
    //do something if $name has been defined
} else {
    //do something else, such as give $name a default value
}

Tip

请参阅本章后面的“使用空值合并运算符设置默认值”,以了解为尚未定义的变量赋值的更简单的方法。

用 PHP 做计算

PHP 可以执行各种各样的计算,从简单的算术到复杂的数学。本章只讲述标准算术运算符。PHP 支持的数学函数和常数详见 www.php.net/manual/en/book.math.php

Note

一个常数代表一个不能改变的固定值。所有 PHP 预定义的常量都是大写的。与变量不同,它们不以美元符号开头。例如,π (pi)的常数是M_PI。您可以在 www.php.net/manual/en/reserved.constants.php 找到完整列表。

算术运算符

标准的算术运算符都按照您预期的方式工作,尽管其中一些看起来与您在学校学到的略有不同。例如,星号(*)用作乘法符号,正斜杠(/)用于表示除法。表 4-2 展示了标准算术运算符如何工作的例子。为了展示它们的效果,$x被设定为 20。

表 4-2

PHP 中的算术运算符

|

操作

|

操作员

|

例子

|

结果

|
| --- | --- | --- | --- |
| 添加 | + | $x + 10 | 30 |
| 减法 | - | $x - 10 | 10 |
| 增加 | * | $x * 10 | 200 |
| 分开 | / | $x / 10 | 2 |
| 以…为模 | % | $x % 3 | 2 |
| 增量(加 1) | ++ | $x++ | 21 |
| 减量(减 1) | -- | $x-- | 19 |
| 幂运算 | ** | $x**3 | 8000 |

模运算符通过在处理前去除小数部分,将两个数字转换为整数,并返回除法的余数,如下所示:

5 % 2.5    // result is 1, not 0 (the decimal fraction is stripped from 2.5)
10 % 2     // result is 0

模运算对于计算一个数是奇数还是偶数很有用。$number % 2总是产生 0 或 1。如果结果为 0,则没有余数,所以数字是偶数。

使用递增和递减运算符

递增(++)和递减(--)运算符可以出现在变量之前或之后。它们的位置对计算有重要影响。

当运算符出现在变量之前时,在执行任何进一步的计算之前会加上或减去 1,如下例所示:

$x = 5;
$y = 6;
--$x * ++$y // result is 28 (4 * 7)

他们来了之后,先进行主计算,然后要么加 1,要么减 1,像这样:

$x = 5;
$y = 6;
$x-- * $y++ // result is 30 (5 * 6), but $x is now 4, and $y is 7

确定计算顺序

PHP 中的计算遵循与标准算术相同的优先级规则。表 4-3 按优先级顺序列出了算术运算符,优先级最高的在顶部。

表 4-3

算术运算符的优先级

|

|

经营者

|

规则

|
| --- | --- | --- |
| 圆括号 | () | 首先计算括号内的运算。如果这些表达式是嵌套的,则最里面的表达式最先被计算。 |
| 幂运算 | ** |   |
| 递增/递减 | ++ -- |   |
| 乘法和除法 | * / % | 如果表达式包含两个或更多这样的运算符,则从左到右计算它们。 |
| 加法和减法 | + - | 如果表达式包含两个或更多这样的运算符,则从左到右计算它们。 |

结合计算和赋值

PHP 提供了一种在变量上执行计算并通过组合赋值操作符将结果重新分配给变量的简便方法。主要的列于表 4-4 中。

表 4-4

PHP 中使用的组合算术赋值运算符

|

操作员

|

例子

|

等于

|
| --- | --- | --- |
| += | $a += $b | $a = $a + $b |
| -= | $a -= $b | $a = $a - $b |
| *= | $a *= $b | $a = $a * $b |
| /= | $a /= $b | $a = $a / $b |
| %= | $a %= $b | $a = $a % $b |
| **= | $a **= $b | $a = $a ** $b |

添加到现有字符串

同样方便的简写允许您通过组合句点和等号向现有字符串的末尾添加新内容,如下所示:

$hamlet = 'To be';
$hamlet .= ' or not to be';

请注意,您需要在附加文本的开头创建一个空格,除非您希望两个字符串不间断地运行。这种简写被称为组合 串联运算符,在组合许多字符串时非常有用,例如在构建电子邮件消息的内容或循环数据库搜索的结果时。

Tip

在复制代码时,等号前面的句点很容易被忽略。当你看到同一个变量在一系列语句的开头重复出现时,这通常是一个明确的信号,表明你需要单独使用.=而不是=。但是,在使用组合串联运算符之前,变量必须已经存在。如果你试图用.=初始化一个变量,它会产生一个关于未定义变量的警告。

你曾经想知道的关于报价的一切,以及更多

计算机总是将第一个匹配的引号作为字符串的结尾。由于字符串可能包含撇号,单引号和双引号的组合是不够的。此外,PHP 在双引号内对变量和转义序列(某些字符前面有反斜杠)进行了特殊处理。在接下来的几页中,我将解开这个谜团,为你解释清楚。

PHP 如何处理字符串中的变量

选择使用双引号还是单引号可能看起来只是个人偏好的问题,但是 PHP 处理它们的方式有一个重要的区别:

  • 单引号之间的任何内容都被视为文本。

  • 双引号作为处理变量和特殊字符的信号,称为转义序列

在下面的示例中,$name被赋值,然后在单引号字符串中使用。所以$name被当作普通文本对待(代码在quotes_01.php):

$name = 'Dolly';
echo 'Hello, $name';  // Hello, $name

如果将第二行的单引号替换为双引号(参见quotes_02.php),则会处理$name,其值会显示在屏幕上:

$name = 'Dolly';
echo "Hello, $name";  // Hello, Dolly

Note

在这两个示例中,第一行的字符串都在单引号中。导致变量被处理的原因是它嵌入在双引号字符串中,而不是它最初是如何获得值的。

在双引号中使用转义序列

双引号还有另一个重要的作用:它们以一种特殊的方式处理转义序列。所有的转义序列都是在字符前加一个反斜杠形成的。表 4-5 列出了 PHP 支持的主要转义序列。

表 4-5

主要的 PHP 转义序列

|

转义序列

|

用双引号字符串表示的字符

|
| --- | --- |
| \" | 双引号 |
| \n | 换行 |
| \r | 回车 |
| \t | 标签 |
| \\ | 反斜线符号 |
| \$ | 美元符 |

Caution

除了\\,表 4-5 中列出的转义序列只在双引号字符串中有效。在单引号字符串中,它们被视为文字反斜杠后跟第二个字符。字符串末尾的反斜杠总是需要转义。否则,它将被解释为对下面的引号进行转义。

在字符串中嵌入关联数组元素

双引号字符串中的关联数组元素有一个令人讨厌的“陷阱”。下面一行代码试图从名为$book的关联数组中嵌入几个元素:

echo "$book['title'] was written by $book['author'].";

看起来还行。数组元素的键使用单引号,所以不会出现引号不匹配的情况。然而,如果你将quotes_03.php加载到浏览器中,你会得到这个神秘的错误信息:

img/332054_5_En_4_Figa_HTML.jpg

解决方案是将关联数组元素用花括号括起来,就像这样(见quotes_04.php):

echo "{$book['title']} was written by {$book['author']}.";

这些值现在可以正确显示,如下面的屏幕截图所示:

img/332054_5_En_4_Figb_HTML.jpg

索引数组元素,比如$shoppingList[2],不需要这种特殊处理,因为数组索引是一个数字,没有用引号括起来。

避免使用 Heredoc 语法对引号进行转义

使用反斜杠来转义一两个引号并不是很大的负担,但是我经常看到反斜杠似乎不受控制的代码示例。PHP heredoc 语法提供了一种相对简单的将文本赋给变量的方法,不需要任何特殊的引号处理。

Note

“heredoc”这个名字来源于 here-document,这是 Unix 和 Perl 编程中使用的一种技术,用于向命令传递大量文本。

使用 heredoc 将字符串赋给变量包括以下步骤:

  1. 键入赋值运算符,后跟<<<和一个标识符。标识符可以是字母、数字和下划线的任意组合,但不能以数字开头。稍后使用相同的组合来标识 heredoc 的结尾。

  2. 在新的一行开始字符串。它可以包括单引号和双引号。任何变量都将以与双引号字符串中相同的方式处理。

  3. 将标识符放在字符串末尾后的新行上。为了确保 heredoc 在 PHP 的所有版本中都能工作,标识符必须位于行首;除了最后一个分号,其他任何东西都不应该在同一行。

Note

在 PHP 8 中,结束标识符可以缩进。

实践中看到就轻松很多了。以下简单示例可在本章文件的heredoc.php中找到:

$fish = 'whiting';
$book['title'] = 'Alice in Wonderland';
$mockTurtle = <<< Gryphon
"Oh, you sing," said the Gryphon. "I've forgotten the words."
So they began solemnly dancing round and round Alice, every now and then treading on her toes when they passed too close, and waving their fore-paws to mark the time, while the Mock Turtle sang this, very slowly and sadly:—
"Will you walk a little faster?" said a $fish to a snail.
"There's a porpoise close behind us, and he's treading on my tail."
(from {$book['title']})
Gryphon;
echo $mockTurtle;

在本例中,Gryphon是标识符。字符串从下一行开始,双引号被视为字符串的一部分。所有内容都包括在内,直到新行开始处的标识符。然而,在 heredoc 的主体中重复的标识符被视为文本的一部分。如下图所示,Gryphon 的第一个实例被视为字符串的一部分,因为它不在新行的开头。此外,heredoc 显示双引号并处理$fish$book['title']变量:

img/332054_5_En_4_Figc_HTML.jpg

Caution

尽管 heredoc 语法避免了对引号的转义,但是关联数组元素$book['title']仍然需要用大括号括起来,如前一节所述。或者,在双引号字符串或 heredoc 中使用它之前,将其赋给一个更简单的变量。

为了在不使用 heredoc 语法的情况下达到相同的效果,您需要添加双引号并像这样对它们进行转义:

$mockTurtle = "\"Oh, you sing,\" said the Gryphon. \"I've forgotten the words.\" So they began solemnly dancing round and round Alice, every now and then treading on her toes when they passed too close, and waving their fore-paws to mark the time, while the Mock Turtle sang this, very slowly and sadly:— \"Will you walk a little faster?\" said a $fish to a snail. \"There's a porpoise close behind us, and he's treading on my tail.\" (from {$book['title']})";

当您有一个长字符串和/或许多引号时,heredoc 语法主要是有价值的。如果您想将一个 XML 文档或一段很长的 HTML 赋给一个变量,这也很有用。

创建数组

有两种类型的数组:索引数组,它使用数字来标识每个元素;关联数组,它使用字符串。您可以通过直接为每个元素赋值来构建这两种类型。例如,$book关联数组可以这样定义:

$book['title'] = 'PHP 8 Solutions: Dynamic Web Design and Development Made Easy';
$book['author'] = 'David Powers';
$book['publisher'] = 'Apress';

要直接构建索引数组,请使用数字而不是字符串作为数组键。默认情况下,索引数组从 0 开始编号,因此要构建上一章图 3-3 中描述的$shoppingList数组,您应该像这样声明它:

$shoppingList[0] = 'wine';
$shoppingList[1] = 'fish';
$shoppingList[2] = 'bread';
$shoppingList[3] = 'grapes';
$shoppingList[4] = 'cheese';

尽管这两种方法都是创建数组的非常有效的方法,但是还有更短的方法。

构建索引数组

最快的方法是使用简写语法,这与 JavaScript 中的数组文字相同。您可以通过在一对方括号之间括起逗号分隔的值列表来创建数组,如下所示:

$shoppingList = ['wine', 'fish', 'bread', 'grapes', 'cheese'];

Caution

逗号必须在引号之外,这与美国印刷惯例不同。为了便于阅读,我在每个逗号后面插入了一个空格,但这不是必须的。

另一种方法是将逗号分隔的列表传递给array(),如下所示:

$shoppingList = array('wine', 'fish', 'bread', 'grapes', 'cheese');

PHP 自动对每个数组元素进行编号,从 0 开始,所以这两种方法创建的是同一个数组,就好像您对它们分别进行了编号一样。

要在数组末尾添加新元素,请使用一对空方括号,如下所示:

$shoppingList[] = 'coffee';

PHP 使用下一个可用的数字,所以这变成了$shoppingList[5]

Note

在 PHP 8 之前,只有当现有数组中的最后一个数字为正数时,添加到索引数组中的项才会取下一个可用的数字。如果最后一个数字是负数,则新的加法被设置为 0。现在,如果最后一个数字是负数,新的索引将增加 1。例如,如果最后一个索引是–4,那么下一个索引将是–3。

构建关联数组

关联数组使用=>操作符(等号后面跟一个大于号)给每个数组键赋值。使用速记方括号语法,结构如下所示:

$arrayName = ['key1' => 'element1', 'key2' => 'element2'];

使用array()获得相同的结果:

$arrayName = array('key1' => 'element1', 'key2' => 'element2');

所以这是构建$book数组的简化方法:

$book = [
    'title'           => 'PHP 8 Solutions: Dynamic Web Design and Development Made Easy',
    'author'      => 'David Powers',
    'publisher'  => 'Apress'
];

不一定要把开始和结束括号放在不同的行上,也不一定要像我所做的那样对齐=>操作符,但是这使得代码更容易阅读和维护。

Tip

速记语法和array()都允许在最后一个数组元素后面有一个逗号。这同样适用于索引数组和关联数组。

创建空数组

您可能希望创建空数组有两个原因,如下所示:

  • 创建(或初始化)一个数组,这样它就可以在一个循环中添加元素了

  • 清除现有数组中的所有元素

要创建空数组,只需使用一对空方括号:

$shoppingList = [];

或者,使用圆括号之间不带任何内容的array(),如下所示:

$shoppingList = array();

$shoppingList数组现在不包含任何元素。如果您使用$shoppingList[]添加一个新的,它将自动从 0 开始重新编号。

多维数组

数组元素可以存储任何数据类型,包括其他数组。您可以用几本书的详细信息创建一个数组的数组,换句话说,一个多维数组,如下所示(使用速记语法):

$books = [
    [
        'title'     => 'PHP 8 Solutions: Dynamic Web Design and Development Made Easy',
        'author'    => 'David Powers'
    ],
    [
        'title'     => 'PHP 8 Revealed',
        'author'    => 'Gunnard Engebreth'
    ]
];

此示例显示了嵌套在索引数组中的关联数组,但是多维数组可以嵌套任何一种类型。要引用特定元素,请使用两个数组的键,例如:

$books[1]['author']  // value is 'Gunnard Engebreth'

使用多维数组并不像看起来那么困难。秘诀是使用一个循环来到达嵌套数组。然后,您可以像处理普通数组一样处理它。这是您处理数据库搜索结果的方式,它通常包含在多维数组中。

使用 print_r()检查数组

像这样将数组传递给print_r(),以在测试期间检查其内容(参见inspect_array.php):

print_r($books);

通常,切换到源代码视图来检查细节会有所帮助,因为浏览器会忽略底层输出中的缩进:

img/332054_5_En_4_Figd_HTML.jpg

Tip

始终使用print_r()来检查阵列。echoprint不起作用。要在网页上显示数组的内容,使用一个foreach循环,如本章后面所述。

PHP 的真相

PHP 条件语句中的决策是基于互斥的布尔值truefalse。如果条件等于true,则执行条件块中的代码。如果false,则忽略。条件是true还是false通过以下方式之一确定:

  • 显式设置为布尔值之一的变量

  • PHP 将一个值隐式解释为truefalse

  • 两个非布尔值的比较

显式布尔值

如果一个变量被赋值为truefalse并在条件语句中使用,则决定基于该值。关键字truefalse不区分大小写,不能用引号括起来,例如:

$ok = false;
if ($ok) {
    // do something
}

条件语句里面的代码不会被执行,因为$okfalse

隐式布尔值(“真”和“假”)值

使用隐式布尔值提供了一种方便的速记方法,尽管它有一个缺点——至少对初学者来说——就是不够清晰。隐式布尔值——或者有时被称为“真”和“假”值——依赖于 PHP 对其视为false的相对狭窄的定义,即:

  • 不区分大小写的关键字 false 和 null

  • 整数(0)、浮点数(0.0)或字符串('0'"0")形式的零

  • 空字符串(单引号或双引号,中间没有空格)

  • 空数组

  • 从空标记创建的 SimpleXML 对象

其他都是true

Tip

这解释了为什么 PHP 将"false"(在引号中)解释为true。这是一个字符串,所有的字符串——除了一个空字符串——都是true。还要注意,–1被认为是true,就像任何其他非零数字一样。

通过比较两个值做出决策

许多true/false决策是基于使用比较运算符对两个值进行的比较。表 4-6 列出了 PHP 中使用的比较运算符。

表 4-6

用于决策的 PHP 比较运算符

|

标志

|

名字

|

例子

|

结果

|
| --- | --- | --- | --- |
| == | 平等 | $a == $b | 如果$a$b相等,则返回true;否则,返回false. |
| != | 不平等 | $a != $b | 如果$a$b不同,则返回true;否则,返回false. |
| === | 同一的 | $a === $b | 确定$a$b是否相同。它们不仅必须具有相同的值,而且必须具有相同的数据类型(例如,都是整数)。 |
| !== | 不相同 | $a !== $b | 确定$a$b是否不相同(根据与前一个运算符相同的标准)。 |
| > | 大于 | $a > $b | 如果$a大于$b.,则返回true |
| >= | 大于或等于 | $a >= $b | 如果$a大于或等于$b.,则返回true |
| < | 不到 | $a < $b | 如果$a小于$b.,则返回true |
| <= | 小于或等于 | $a <= $b | 如果$a小于或等于$b.,则返回true |
| <=> | 宇宙飞船 | $a <=> $b | 如果$a小于$b,则返回一个小于零的整数;如果$a大于$b,则返回一个大于零的整数;如果$a$b等于.,则返回零 |

正如你将在第九章中看到的,飞船操作符对于定制排序很有用。它的名字来自 Perl 书籍的作者,也是操作符的发源地。他认为这比经常提到“小于等于或大于运算符”要容易得多。

重要的是要记住,一个等号只分配一个值。比较两个值时,使用相等运算符()、相同运算符(=)或它们的负等效运算符(!=而且!==).

Caution

PHP 8 改变了等号运算符(==)比较数字和字符串的方式,将数字转换成字符串,并测试它们是否相同。在以前的版本中,比较是通过将字符串转换为数字以相反的方式进行的。这导致一些边缘情况现在返回到它们先前返回的true的地方false。详见 www.php.net/manual/en/migration80.incompatible.php

测试多个条件

通常,比较两个值是不够的。PHP 允许你设置一系列条件,使用逻辑操作符来指定是全部还是部分需要满足。

PHP 中最重要的逻辑运算符在表 4-7 中列出。逻辑 Not 运算符适用于单个条件,而不是一系列条件。

表 4-7

PHP 中用于决策的主要逻辑运算符

|

标志

|

名字

|

例子

|

结果

|
| --- | --- | --- | --- |
| && | 和 | $a && $b | 如果$a$b都是true,则等同于true |
| &#124;&#124; | 或者 | $a &#124;&#124; $b | 如果$a$btrue,则等同于true;否则,false |
| ! | 不 | !$a | 如果$a而不是 true,则等同于true |

从技术上讲,可以测试的条件数量没有限制。从左到右依次考虑每个条件,一旦达到定义点,就不再进行进一步的测试。使用&&时,每个条件都必须满足,因此一旦其中一个变成false,测试就停止。同样,当使用||时,只需要满足一个条件,因此一旦其中一个变成true,测试就停止:

$a = 10;
$b = 25;
if ($a > 5 && $b > 20) // returns true
if ($a > 5 || $b > 30) // returns true, $b never tested

总是设计测试来提供最快的结果。如果所有条件都必须满足,首先评估最有可能失败的条件。如果只需要满足一个条件,首先评估最有可能成功的一个。如果需要将一组条件视为一组,请将它们括在括号中,如下所示:

if (($a > 5 && $a < 8) || ($b > 20 && $b < 40))

Note

PHP 也用AND代替&&,用OR代替||。然而,ANDOR的优先级要低得多,这可能会导致意想不到的结果。为了避免问题,建议坚持使用&&||

将 switch 语句用于决策链

switch陈述为决策提供了if . . . else的替代方案。基本结构是这样的:

switch(variable being tested) {
    case value1:
        statements to be executed
        break;
    case value2:
        statements to be executed
        break;
    default:
        statements to be executed
}

case关键字表示传递给switch()的变量的可能匹配值。每个可选值必须以case开头,后跟一个冒号。当匹配成功时,执行每一行后续代码,直到遇到breakreturn关键字,此时switch语句结束。下面是一个简单的例子:

switch($myVar) {
    case 1:
        echo '$myVar is 1';
        break;
    case 'apple':
    case 'orange':
        echo '$myVar is a fruit';
        break;
    default:
        echo '$myVar is neither 1 nor a fruit';
}

关于switch的注意要点如下:

  • case关键字后面的表达式通常是数字或字符串。不能使用数组或对象这样的复杂数据类型。

  • 要使用带有case的比较运算符,您必须重复被测试的表达式。例如,case > 100:不会工作,但是case $myVar > 100:会。在第八章的“PHP 解决方案 8-4:用逗号连接数组”中有一个这种情况的实际例子。

  • 每一个后续的案件也会被执行,除非你用break或者return结束一个案件。

  • 您可以将case关键字的几个实例组合在一起,对它们应用相同的代码块。因此,在前面的例子中,如果$myVar是“apple”或“orange”,下面的行将被执行。

  • 如果没有匹配,则执行任何跟在关键字default后面的语句。如果没有设置默认值,那么switch语句会自动退出,并继续执行下一个代码块。

对决策链使用匹配表达式

PHP 8 引入了将一个值与多个选项进行比较的match表达式。它类似于switch,但有一些重要的区别。基本语法如下所示:

$return_value = match($value) {
    single_conditional_expression => return_expression,
    conditional_expression1, conditional_expression2 => return_expression,
} ;

上一节中的开关示例将改写如下:

$result = match ($myVar) {
    1 => '$myVar is 1',
    'apple', 'orange' => '$myVar is a fruit',
    default => '$myVar is neither 1 nor a fruit'
};
echo $result;

这不仅比 switch 更简洁;还有其他重大差异,即:

  • match返回一个值。不能用echoprint直接输出数值。

  • 在右花括号后面必须有一个分号。

  • match使用标识运算符(===)执行严格的比较,而switch使用宽松的相等运算符(==)。这意味着被比较的值必须与条件表达式的类型相同。在前面的示例中,如果传递给 match 的值是字符串“1”(在引号中),则它不会与第一个条件表达式中的数字 1 匹配。

  • 如果没有匹配的,PHP 抛出一个UnhandledMatchError。您可以通过在末尾设置默认值来避免这种情况。

  • 没必要用break。一旦找到匹配项,match表达式就停止计算条件表达式。

您还可以通过将true作为参数来使用match表达式测试不相同的条件。以下示例通过在每个条件表达式中重复一个值,将该值与一个整数范围进行比较:

$age = 23;

$result = match (true) {
    $age >= 65 => 'senior',
    $age >= 25 => 'adult',
    $age >= 18 => 'young adult',
    default => 'child',
};  // $result is 'young adult'

Note

所有在 www.php.net/manual/en/control-structures.match.php 的例子在最后一个表达式后面都有一个尾随逗号。这在实践中是可选的。

使用三元运算符

三元运算符 ( ?:)是一种表示条件语句的简写方法。它的名字来源于它通常使用三个操作数。基本语法如下所示:

condition ? value if true : value if false;

下面是一个使用中的例子:

$age = 17;
$fareType = $age >= 16 ? 'adult' : 'child';

第二行测试$age的值。如果大于等于 16,$fareType设置为adult;否则,$fareType被设置为child。使用if . . . else的等效代码如下所示:

if ($age >= 16) {
    $fareType = 'adult';
} else {
    $fareType = 'child';
}

您可以省略问号和冒号之间的值。如果条件为真,这会将条件值赋给变量。前面的示例可以改写如下:

$age = 17;
$adult = $age >= 16 ?: false; // $adult is true

在这种情况下,问号前的表达式是一个比较,所以它只能等同于truefalse。但是,如果问号前的表达式为“truthy”(隐式 true),则返回值本身。例如:

$age = 17;
$years = $age ?: 'unknown';  // $years is 17

前面例子的问题是,如果用作条件的变量还没有定义,就会产生一个错误。一个更好的解决方案是使用零合并操作符,如下一节所述。

Caution

不建议链接或嵌套三元表达式,因为代码可能难以理解,结果也难以预测。PHP 8 要求嵌套的三元表达式用圆括号括起来,以指示它们的求值顺序。否则会产生致命错误。

使用空合并运算符设置默认值

当另一个变量——比如一个包含来自在线表单的用户输入的变量——没有被定义时,空合并操作符是一种为变量分配默认值的便捷方式。运算符由两个问号(??)组成,用法如下:

$greeting = $_GET['name'] ?? 'guest';

这试图将$greeting的值设置为$_GET['name']中存储的任何值。但是如果$_GET['name']没有被定义——换句话说,它是空的——则使用?? ( 'guest')之后的值。零合并运算符可以像这样链接:

$greeting = $_GET['name'] ?? $nonexistent ?? $undefined ?? 'guest';

PHP 依次测试每个值,并将第一个非空值赋给变量。

Caution

空合并操作符只拒绝空值——换句话说,不存在的变量或者被显式设置为 的变量。在前面的例子中,如果提交的表单没有在名为name的字段中输入值,那么$_GET['name']将被设置为空字符串。虽然 PHP 将此视为false,但它不是null。因此,$greeting将被设置为空字符串。

用循环重复执行代码

一个循环是一段重复的代码,直到满足某个条件。通常通过设置一个计算迭代次数的变量来控制循环。通过每次递增变量,当变量达到一个预置数时,循环停止。循环也是通过遍历数组的每一项来控制的。当没有更多的项目要处理时,循环停止。循环经常包含条件语句,所以尽管它们在结构上非常简单,但它们可以用来创建以复杂方式处理数据的代码。

使用 while 和 do 循环。。。正在…

最简单的循环称为while循环。它的基本结构是这样的:

while (condition is true) {
    do something
}

以下代码在浏览器中显示从 1 到 100 的每个数字(您可以在本章文件的while.php中测试它)。它首先将变量($i)设置为 1,然后使用变量作为计数器来控制循环,并在屏幕上显示当前数字:

$i = 1;  // set counter
while ($i <= 100) {
    echo "$i<br>";
    $i++; // increase counter by 1
}

Tip

在前一章中,我警告过不要使用带有神秘名称的变量。但是,使用$i作为计数器是一种常见的约定。如果$i已经被使用,通常的做法是使用$j$k作为计数器。

while循环的一个变体使用关键字do并遵循以下基本模式:

do {
    code to be executed
} while (condition to be tested);

不同之处在于,do 块中的代码至少执行一次,即使条件从不为真。以下代码(在dowhile.php中)显示一次$i的值,即使它大于在该条件下测试的最大值:

$i = 1000;
do {
    echo "$i<br>";
    $i++; // increase counter by 1
} while ($i <= 100);

危险在于忘记设置一个结束循环的条件,或者设置一个不可能的条件。这就是所谓的无限循环,它会冻结你的电脑或者导致浏览器崩溃。

多功能 for 循环

for循环不太容易产生无限循环,因为循环的所有条件都在第一行声明。for循环使用以下基本模式:

for (initialize loop; condition; code to run after each iteration) {
    code to be executed
}

以下代码的输出与前面的while循环相同,显示从 1 到 100 的每个数字(见forloop.php):

for ($i = 1; $i <= 100; $i++) {
    echo "$i<br>";
}

括号内的三个表达式控制循环的动作(注意,它们是用分号分隔的,而不是逗号):

  • 第一个表达式在循环开始前执行。在这种情况下,它将计数器变量$i的初始值设置为 1。

  • 第二个表达式设置了确定循环应该运行多长时间的条件。这可以是固定的数字、变量或计算值的表达式。

  • 第三个表达式在循环的每次迭代结束时执行。在这种情况下,它将$i增加 1,但是没有什么可以阻止您使用更大的增量。例如,在这个例子中用$i+=10替换$i++将会显示 1、11、21、31 等等。

注意for 循环开始处括号内的第一个和第三个表达式可以包含多个用逗号分隔的语句。例如,循环可能使用两个独立递增或递减的计数器。

用 foreach 遍历数组和对象

PHP 中循环的最后一种类型与数组、对象和生成器一起使用(参见本章后面的“生成器:一种特殊类型的不断给出的函数”)。它有两种形式,都使用临时变量来处理每个元素。如果您只需要对元素的值做一些事情,foreach循环采用以下形式:

foreach (variable_name as element) {
    do something with element
}

下面的示例遍历$shoppingList数组并显示每个项目的名称(代码在foreach_01.php中):

$shoppingList = ['wine', 'fish', 'bread', 'grapes', 'cheese'];
foreach ($shoppingList as $item) {
    echo $item.'<br>';
}

Caution

foreach关键字必须而不是foreach之间有一个空格。

虽然前面的例子使用了一个索引数组,但是您也可以使用带有关联数组的基本形式的foreach循环来访问每个元素的值。

另一种形式的foreach循环提供了对每个元素的键和值的访问。它的形式略有不同:

foreach (variable_name as key => value) {
    do something with key and value
}

下一个例子使用了本章前面“创建数组”一节中的$book关联数组,并将每个元素的键和值合并到一个简单的字符串中,如下面的屏幕截图所示(参见foreach_02.php):

foreach ($book as $key => $value) {
    echo "$key: $value<br>";
}

img/332054_5_En_4_Fige_HTML.jpg

Note

除了数组,foreach循环的主要用途是与迭代器生成器一起使用。你将在第 8 和 9 章看到如何使用迭代器和生成器。

打破循环

要在满足特定条件时提前结束循环,请在条件语句中插入关键字break。脚本一遇到break,就退出循环。

要在满足特定条件时跳过循环中的代码,请使用continue关键字。它没有退出,而是立即返回到循环的顶部(忽略循环体中跟在continue后面的代码)并处理下一个元素。例如,下面的循环从一个条件开始,如果$photo没有值,则跳过当前元素(如果变量不存在或为假,则empty()函数返回true):

foreach ($photos as $photo) {
    if (empty($photo)) continue;
    // code to display a photo
}

用函数模块化代码

除了大量的内置函数,PHP 还允许您创建自己的函数。您只需编写一次代码,而不需要在任何需要的地方重新键入。如果函数中的代码有问题,您可以只在一个地方更新它,而不是搜索整个站点。

用 PHP 构建自己的函数很容易。您只需将一段代码放在一对花括号中,并使用function关键字来命名新函数。函数名后面总是跟着一对括号。下面的例子展示了一个定制函数的基本结构(参见本章文件中的functions_01.php):

function sayHi() {
    echo 'Hi!';
}

简单地将sayHi();放入 PHP 代码块中会导致 Hi!显示在屏幕上。这种类型的功能就像无人机:它总是执行相同的操作。为了让函数对环境做出响应,您需要将值作为参数传递给它们。

向函数传递值

假设您想修改sayHi()函数来显示某人的名字。您可以通过在函数声明的括号之间插入一个变量来实现这一点(从技术上来说,这被称为在函数签名中插入一个参数)。然后在函数内部使用同一个变量来存储传递给函数的任何值。functions_02.php中的修改版看起来是这样的:

function sayHi($name) {
    echo "Hi, $name!";
}

您现在可以在页面中使用这个函数来显示传递给sayHi()的任何变量或文字字符串的值。例如,如果你有一个在线表单,将某人的名字保存在一个名为$visitor的变量中,马克访问你的网站,你可以通过将sayHi($visitor);放在你的页面中,给他如下图所示的那种个人问候。

img/332054_5_En_4_Figf_HTML.jpg

PHP 弱类型的一个缺点是,如果 Mark 不合作,他可能会在表单中键入 5 而不是他的名字,这样就不会出现您所期望的击掌。

img/332054_5_En_4_Figg_HTML.jpg

Tip

在任何关键情况下使用用户输入之前,一定要检查它。随着这本书的深入,你会学到如何做到这一点。

要向函数传递多个参数,请在函数签名中用逗号分隔变量(参数)。

设置参数的默认值

要为传递给函数的参数设置默认值,在函数签名中为变量赋值,如下所示(见functions_04.php):

function sayHi($name = 'bashful') {
    echo "Hi, $name!";
}

这使得参数是可选的,允许您像这样调用函数:

sayHi();

以下截图显示了结果:

img/332054_5_En_4_Figh_HTML.jpg

但是,您仍然可以向该函数传递一个不同的值来代替默认值。

Tip

可选参数必须始终位于必需参数之后的函数签名的末尾。这个不行:function sayHi($name = 'bashful', $title);。这个会:function sayHi($title, $name = 'bashful');

可变范围:充当黑盒

函数创建了一个独立的环境,有点像黑匣子。正常情况下,函数内部发生的事情对脚本的其余部分没有影响,除非它返回一个值,如下一节所述。函数内部的变量仍然是函数专有的。这个例子应该说明这一点(见functions_05.php):

function doubleIt($number) {
    $number *= 2;
    echo 'Inside the function, $number is ' . $number . '<br>';  // number is doubled
}
$number = 4;
doubleIt($number);
echo 'Outside the function $number is still ' . $number;   // not doubled

前四行定义了一个名为doubleIt()的函数,它接受一个数字,将它加倍,并显示在屏幕上。脚本的其余部分将值 4 赋给$number。然后它将$number作为参数传递给doubleIt()。该功能处理$number并显示 8。功能结束后,echo在屏幕上显示$number。这次是 4 而不是 8,如下图所示:

img/332054_5_En_4_Figi_HTML.jpg

这表明主脚本中的$number与函数中同名的变量完全无关。这就是所谓的变量的范围。即使变量的值在函数内部发生了变化,在函数外部同名的变量也不会受到影响,除非变量的值通过引用传递给函数,如本章后面所述。

Tip

尽可能避免在脚本的其余部分使用与函数内部相同的变量名。它使您的代码更容易理解和调试。

PHP 超全局变量( www.php.net/manual/en/language.variables.superglobals.php ),如$_POST``$_GET,不受变量作用域的影响。它们总是可用的,这就是它们被称为超级全球的原因。

从函数返回值

有多种方法可以让函数改变作为参数传递给它的变量值,但最重要的方法是使用return关键字,并将结果赋给同一个变量或另一个变量。这可以通过如下修改doubleIt()函数来演示(代码在functions_06.php):

function doubleIt($number) {
    return $number *= 2;
}
$num = 4;
$doubled = doubleIt($num);
echo '$num is: ' . $num . '<br>';  // remains unchanged
echo '$doubled is: ' . $doubled;   // original number doubled

img/332054_5_En_4_Figj_HTML.jpg

这一次,我为变量使用了不同的名称,以避免混淆。我还将doubleIt($num)的结果赋给了一个新变量。这样做的好处是原始值和计算结果现在都是可用的。您不会总是希望保持原始值,但它有时会非常有用。

Tip

函数并不总是需要返回值。关键字return可以单独用来停止任何进一步的处理。

生成器:一种特殊类型的函数,它不断地给出

当一个函数遇到return时,它立即终止并返回值或不返回值。生成器是创建简单迭代器的特殊函数,用于在循环中产生一系列值。他们不使用关键字return,而是使用yield。这使得生成器一次产生一个值,跟踪序列中的下一个值,直到它被再次调用或用完所有值。

生成器可以使用内部循环来生成它所产生的值,或者它可以有一系列的yield语句。generator.php中的简单例子使用了这两种技术:

function counter($num) {
    $i = 1;
    while ($i < $num) {
        yield $i++;
    }
    yield $i;
    yield $i + 10;
    yield $i + 20;
}

counter()生成器接受一个参数$num。它将计数器$i初始化为 1,然后使用一个循环,当$i小于$num时继续运行。循环产生$i,并将其递增 1。在循环结束后,一系列的yield语句产生另外三个值。

通过将生成器赋给变量来初始化生成器后,可以在 foreach 循环中使用它,如下所示:

$numbers = counter(5);
foreach ($numbers as $number) {
    echo $number . ' ';
}

这将产生一系列数字,如下面的屏幕截图所示:

img/332054_5_En_4_Figk_HTML.jpg

对于这个简单的例子,创建一个值数组并在循环中直接使用它会更简单。生成器的主要优点是,对于大量的值,它们使用的内存比数组少得多。第九章给出了一个生成器处理文件内容的实例。

通过引用传递:更改参数的值

尽管函数通常不改变作为参数传递给它们的变量值,但有时您确实想改变原始值而不是捕获返回值。为此,在定义函数时,在要更改的参数前加一个&符号,如下所示:

function doubleIt(&$number) {
    $number *= 2;
}

注意,这个版本的doubleIt()函数不echo计算$number的值,也不返回计算的值。因为圆括号之间的参数以&为前缀,所以作为参数传递给函数的变量的原始值将会改变。这就是所谓的通过引用

以下代码(可在functions_07.php中找到)演示了这种效果:

$num = 4;
echo '$num is: ' . $num . '<br>';
doubleIt($num);
echo '$num is now: ' . $num;

img/332054_5_En_4_Figl_HTML.jpg

&符号仅在函数定义中使用,而不是在调用函数时使用。

一般来说,使用函数来更改作为参数传递给它们的变量的原始值并不是一个好主意,因为如果在脚本的其他地方使用该变量,可能会产生意想不到的后果。然而,有些情况下这样做很有意义。例如,内置的数组排序函数使用按引用传递来影响原始数组。

Note

对象总是通过引用传递,即使函数定义没有在参数前加上&符号。这也适用于迭代器和生成器,它们实现内置的 PHP 类。

接受可变数量参数的函数

名字有点不雅的 splat 操作符允许你定义一个接受任意数量参数的函数(技术上称为变量函数)。它由函数签名中最后一个(或唯一一个)参数前的三个点或句点组成。splat 运算符将传递给函数的值转换成一个数组,然后可以在函数内部使用该数组。functions_08.php中的代码包含以下简单的例子:

function addEm(...$nums) {
    return array_sum($nums);
}
$total = addEm(1, 2, 3, 4, 5);
echo '$total is ' . $total;

传递给函数的逗号分隔的数字被转换成一个数组,然后传递给内置的array_sum()函数,该函数将数组中的所有值相加。以下屏幕截图显示了输出:

img/332054_5_En_4_Figm_HTML.jpg

自动解包传递给函数的数组

当 splat 运算符位于作为参数传递给函数的数组之前时,它会产生相反的效果:它会对数组进行解包,以便每个元素都被视为一个单独的参数。以下 unpack.php 的例子说明了它是如何运作的:

function add ($a, $b) {
    return $a + $b;
}
$nums = [1,2,4,7,9];
echo 'The result is ' . add(...$nums);

add()函数期望两个独立的值,并将它们相加。$nums是五个整数的数组。当 splat 操作符前面的数组被传递给函数时,前两个元素被自动提取并相加,然后返回结果。多余的参数被忽略,产生如下屏幕截图所示的结果:

img/332054_5_En_4_Fign_HTML.jpg

Caution

尽管多余的元素会被忽略,但数组必须至少包含函数期望的那么多的值。

可选地指定数据类型

随着 PHP 的成熟,许多开发人员寻求对函数接受和返回的数据类型的更大控制。这在社区中引发了激烈的争论,因为 PHP 薄弱的数据类型是其成功的主要原因之一——不必担心数据类型使这门语言对初学者来说更容易学习。折衷的办法是引入可选的类型声明

要指定参数必须是特定类型,请在函数签名中的参数前加上表 4-8 中列出的类型之一。

表 4-8

类型声明

|

类型

|

描述

|
| --- | --- |
| Class/interface name | 必须是给定类或接口的实例 |
| self | 必须是同一类的实例 |
| parent | 必须是当前类的父类的实例 |
| array | 必须是数组 |
| callable | 必须是有效的可调用函数 |
| bool | 必须是布尔值 |
| float | 必须是浮点数 |
| int | 必须是整数 |
| string | 必须是字符串 |
| iterable | 必须是数组或实现Traversable接口 |
| object | 必须是一个对象 |
| mixed | 可以是任何值 |

Note

表 4–8 中的前三个类型声明仅用于类,这将在本章后面的“理解 PHP 类和对象”中描述。一个接口指定了一个类必须实现哪些方法。

类、接口、数组、可调用函数和对象的类型声明通过在使用不同类型时引发错误来强制使用正确的数据类型。然而,boolfloatintstring类型声明的行为不同。它们不会抛出错误,而是悄悄地将参数转换为指定的数据类型。functions_09.php中的代码修改了本章前面“从函数返回值”中的doubleIt()函数,添加了如下类型声明:

function doubleIt(int $number) {
    return $number *= 2;
}

下面的屏幕截图显示了当传递给函数的值是 4.9 时会发生什么:

img/332054_5_En_4_Figo_HTML.jpg

该数字在被处理之前被转换成整数。甚至没有四舍五入到最接近的整数。小数部分被简单地去掉了。

Tip

您可以通过在每个脚本中启用严格类型来更改boolfloatintstring类型声明的行为。但是,严格类型的实现可能会令人困惑。我个人建议仅对类、接口和数组使用类型声明,除非您有意要将提交的值转换为指定的类型。在 www.php.net/manual/en/functions.arguments.php 可以学习如何在 PHP 文档中启用严格类型。

您还可以指定函数返回的数据类型。可用类型与表 4-8 中所列的相同,但增加了void。返回类型声明由函数签名中右括号和左花括号之间的冒号和 type 组成。functions_10.php中的例子像这样修改了doubleIt()函数:

function doubleIt(int $number) : float {
    return $number *= 2;
}

我特意选择了这个不合逻辑的例子来演示将float设置为返回类型会悄悄地将函数返回的值转换为浮点数。但是它不重写参数的类型声明。将 4.9 作为参数传递给函数仍然返回 8;但是var_dump()揭示了 PHP 将其作为浮点数处理,如下截图所示:

img/332054_5_En_4_Figp_HTML.jpg

使用boolintstring作为返回类型声明也会执行静默数据类型转换,除非启用了严格类型。如果函数返回错误的数据类型,其他返回类型声明会抛出错误。

指定多种数据类型

以前的 PHP 版本只允许声明一种数据类型。PHP 8 现在允许联合类型,这允许您在一个声明中组合两个或多个数据类型。简单地用一个垂直管道将类型分开,就像这样:

string|array

它接受字符串或数组。联合类型也可以用于返回类型声明。

如果你想指定null也是可以接受的,有两种方法。对于联合类型,将null声明为如下类型之一:

string|array|null

如果您指定值可以是单一类型或null,请在类型声明前加上一个问号,如下所示:

?string

这相当于以下联合类型:

string|null

You不能使用null作为独立类型。它只能是指定类型的替代。

Note

本书中的代码只在真正有好处的情况下才使用类型声明,例如,检查是否将正确的数据类型传递给了函数。

使用命名参数

通常,所有参数都需要按照函数签名中参数的顺序传递给函数,除非它们是可选的。但是,如果函数有几个可选参数,而您只需要更改其中一个选项,这可能会很不方便。PHP 8 通过引入命名参数解决了这个问题,它允许你以任何顺序向函数提交值。

一个有用的命名参数的例子是内置函数htmlentities(),它接受一个字符串并转换所有具有 HTML 实体等价物的字符(比如用& amp;替换&)。函数签名如下所示:

htmlentities ( string $string , int $flags = ENT_COMPAT , string|null $encoding = null , bool $double_encode = true ) : string

最后三个参数有默认值,所以是可选的。最后一个参数转换所有内容,包括现有的 HTML 实体。例如,如果您的字符串包含一个已经被转换成 HTML 实体的&符号,如下所示

Fish &​amp; Chips

当您将它传递给htmlentities()时,默认是再次转换&符号,产生

Fish &​amp;​amp; Chips

为了防止这种情况,您需要将最后一个参数设置为false。在以前的 PHP 版本中,这需要像这样设置所有四个参数:

$output = htmlentities($myText, ENT_COMPAT, null, false);

命名参数是通过在值前面加上参数名称(减去前导$)后跟一个冒号来传递的,代码简化为:

$output = htmlentities($myText, double_encode: false) ;

哪里可以找到定制的函数

如果你的定制函数在被使用的同一个页面上,那么你在哪里声明这个函数并不重要;可以是使用前也可以是使用后。然而,将函数存储在一起是一个好主意,无论是在页面的顶部还是底部。这使得它们更容易找到和维护。

在多个页面中使用的函数最好存储在每个页面包含的外部文件中。包含带有includerequire的外部文件将在下一章详细介绍。当函数在外部文件中时,您必须在调用其任何函数之前包含外部文件

创建匿名函数

匿名函数允许创建没有指定名称的函数。它们在你需要一个只使用一次的函数的情况下很有用,比如一个回调函数

Tip

回调函数是作为参数传递给另一个函数的函数,然后在外部函数中调用该函数来完成某种例程或操作。你会在第八章中看到匿名回调的实际例子。

匿名函数的基本语法与普通函数相同,只是它没有名字。如果您将它作为参数传递给另一个函数,它看起来像这样:

function ($arguments) {
    // body of function
}

举个简单的例子,这个匿名函数将一个数字加倍,并返回结果:

function ($num) {
    return $num * 2;
}

您也可以像这样给变量分配一个匿名函数:

$anon = function ($arguments) {
    // body of function
};

Caution

右花括号后面必须有一个分号,因为这是一个将函数赋给变量的语句。

当函数体包含大量代码,如果直接作为参数传递给另一个函数会使代码难以阅读时,将函数赋给变量非常有用。当作为回调参数传递时,单独传递变量。在任何其他上下文中,通过在变量后附加一对括号来调用匿名函数,并像这样传递参数:

$anon($arguments);

如果您想将值从父作用域传递给匿名函数,可以使用如下的use结构:

function ($arguments) use ($fromParentScope) {
    // body of function
    // do something with $fromParentScope
}

通过use构造传递的值可以被匿名函数修改,方法是在参数前面加上一个&,与本章前面的“通过引用传递:更改参数的值”中描述的方式相同。

使用箭头函数的简洁匿名语法

如果匿名函数节省了输入,箭头函数节省更多。语法如下所示:

fn ($arguments) => expression

上一节中的数字加倍匿名函数可以重写为箭头函数,如下所示:

fn ($num) => $num * 2

function关键字被缩短为fn。没有花括号;并且省略了return关键字。您也可以将箭头函数分配给变量,如下所示:

$anon = fn ($num) => $num * 2;

箭头函数可以自动访问父作用域中的变量。在下面的示例中,arrow 函数将父范围中的\(y 值添加到参数\)x 中:

$y = 3;
$anon = fn ($x) => $x + $y;
echo $anon(5);  // displays 8

但是,箭头函数不能修改父作用域中的值。以下内容没有影响:

$y = 3;
$anon = fn () => $y++;
echo $anon();  // displays 3; the value of $y is not changed

要从父作用域中更改一个值,您需要一个匿名函数的更详细的语法,并通过引用前面一节中描述的use构造来传递值。

理解 PHP 类和对象

类是面向对象编程 (OOP)的基本构建模块,面向对象编程是一种旨在使代码可重用且更易于维护的编程方法。PHP 对 OOP 有广泛的支持,新特性经常以面向对象的方式实现。

对象是一种复杂的数据类型,可以存储和操作值。一个是定义一个对象特性的代码,可以看作是制作对象的蓝图。

使用 PHP 内置类

在 PHP 的许多内置类中,两个特别有趣的是处理日期和时区的DateTimeDateTimeZone类。要创建一个对象,可以使用new关键字和类名,如下所示:

$now = new DateTime();

这创建了一个DateTime类的实例,并将其存储在一个名为$nowDateTime对象中,该对象不仅知道其创建的日期和时间,还知道 web 服务器使用的时区。大多数类都有属性和方法,就像变量和函数一样,只是它们与类的特定实例相关。例如,您可以使用DateTime类的方法来更改某些值,比如月、年或时区。DateTime 对象还能够执行日期计算,这在使用普通函数时要复杂得多。

您可以使用->操作符(一个连字符后跟一个大于号)来访问对象的属性和方法。要重置DateTime对象的时区,将DateTimeZone对象作为参数传递给setTimezone()方法,如下所示:

$westcoast = new DateTimeZone('America/Los_Angeles');
$now->setTimezone($westcoast);

这会将$now更改为洛杉矶的当前日期和时间,而不管 web 服务器位于何处,并自动根据夏令时进行调整。

使用->操作符以同样的方式访问对象的属性:

$someObject->propertyName

构建自定义类

你可以用 PHP 定义自己的类,就像定义一个函数一样。不同之处在于,一个类通常包含一组设计用来协同工作的函数(称为方法)和变量(称为属性)。一个类中的每个函数通常应该专注于一个任务。代码也应该是通用的,所以它不依赖于特定的网页。您还可以创建子类(也称为子类)来添加或修改现有类的功能。

定义 PHP 类很简单。您使用class关键字,后跟类名,然后将该类的所有代码放在一对花括号中。按照惯例,类名以大写字母开头,类存储在与类同名的单独文件中。您不能使用 www.php.net/manual/en/reserved.php 中列出的任何保留字作为类的名称。

大多数类都有一个构造函数,用于在创建对象的新实例时初始化任何属性。基本的构造函数语法如下所示:

__construct($arguments) {
    // initialization of object
}

Caution

PHP 8 不再将与类同名的方法视为构造函数。你必须使用__construct()。注意,它以两个下划线开始,而不是一个。

访问类中的方法和属性

PHP 类使用保留变量$this来引用对象的当前实例。要调用类定义中的一个类方法,请使用箭头运算符,如下所示:

$this->myMethod();

类似地,通过使用箭头运算符访问属性并赋值来设置属性的值,如下所示:

$this->myProperty = 4;

设置类方法、属性和常数的可见性

类定义可以通过在声明前添加以下关键字之一来设置方法、属性和常数的可见性:

  • 这使得它在任何地方都是可见的,包括在类定义之外,允许你调用一个方法,访问或者改变一个属性的值,或者使用一个常量的值。

  • protected:这限制了对类定义内部或父类或子类的访问。

  • private:这限制了对定义类的访问。

声明属性时,必须定义它的可见性,后面可以选择数据类型。声明方法和常量的可见性是可选的。没有任何显式可见性的方法和常量被视为public

通常的做法是在类定义的顶部声明属性。如果为属性指定默认值,则该值必须是实际值,而不是从另一个变量派生的表达式的结果。更改默认值的一种方法是将一个参数传递给构造函数,并将其重新分配给属性,如下所示:

class MyClass {
    protected int myValue = 42;

    public function __construct(int $value) {
        $this->myValue = $value;
        // other initialization code
    }
}

使用构造函数属性提升

PHP 8 引入了一种声明和设置属性值的简写语法。当构造函数参数包含可见性修饰符时,PHP 将其解释为对象属性和构造函数参数,并将参数值赋给属性。这避免了单独声明属性的需要。因此,上一节中的示例可以简化如下:

class MyClass {
    public function __construct(protected int myValue = 42) {
        // other initialization code
    }
}

如果没有其他需要初始化的,构造函数方法可以是空的。

声明和使用类常量

要创建一个类常量,在类定义中使用const关键字声明它,可以选择在它前面加上一个可见性声明。例如,这将 42 设置为只能在子类或父类中访问的常量:

protected const ULTIMATE_ANSWER = 42;

通常的惯例是常量名称全部大写,以提醒常量的值不能更改。

Note

虽然常量的值不能在类内部或由类的实例更改,但它可以由子类重新定义。

要在类或子类中访问常量的值,可以使用 self 或 parent 数据类型,后跟范围解析运算符(一对冒号),如下所示:

self::ULTIMATE_ANSWER
parent::ULTIMATE_ANSWER

如果类常量已被显式公开或定义时没有可见性声明,则可以使用范围解析运算符通过类的实例在类定义外部访问其值。例如,它访问一个名为$myObject的对象的类常量的值:

$myObject::ULTIMATE_ANSWER

使用命名空间避免命名冲突

一旦你开始使用别人(包括本书中的人)创建的脚本和类,就会有多个类同名的危险。PHP 通过使用名称空间将相关的类、函数和常数分组来解决这个问题。

一种常见的策略是将类定义存储在描述其功能的文件夹结构中,并根据域名或公司名为顶级文件夹指定一个唯一的名称。名称空间可以有子级别,因此文件夹结构被复制为由反斜杠分隔的子名称空间。命名空间也是单独声明的,允许您使用简单的类名。

例如,在第九章中,你将创建一个名为Upload的类。为了避免命名冲突,它将被创建在一个名为Php8Solutions\File的名称空间中。

使用关键字namespace在文件的顶部声明一个名称空间,后跟如下名称空间:

namespace Php8Solutions\File;

Caution

PHP 在所有操作系统上都使用反斜杠作为名称空间分隔符。不要试图在 Linux 或 macOS 上将其改为正斜杠。

因此,在这个名称空间中,名为Upload的类的完全限定名是Php8Solutions\File\Upload

导入命名空间类

为了避免每次引用命名空间类时都必须使用完全限定名,可以在脚本的开头用关键字use导入类,如下所示:

use Php8Solutions\File\Upload;

Caution

关键字use必须在脚本的顶层声明。它不能嵌套在条件语句中。

然后,您可以将该类称为Upload,而不是使用完全限定名。事实上,您可以使用关键字as为导入的类分配一个别名,如下所示:

use Php8Solutions\File\Upload as FileUploader;

该类可以被称为FileUploader。使用别名主要在大型应用中有用,在这些应用中,来自不同框架的两个类具有相同的名称。用use关键字导入一个类只是声明您想使用一个名字更短的类。你仍然需要包含类定义(包含外部文件是第五章的主题)。

Note

本章只讲述了在 PHP 中使用类和对象的基本知识。更多详情,请查阅 www.php.net/manual/en/language.oop5.php 的文档。

处理错误和异常

从 PHP 7 开始,大多数错误都是通过抛出一个异常来报告的——或者生成一个特殊类型的对象,该对象包含导致错误的原因以及错误出现的位置的详细信息。如果您使用过 PHP 以前的版本,您可能会注意到的唯一区别是错误消息的措辞或错误类型不同。但是,由内部错误(比如解析错误或缺少包含文件)引发的异常和由脚本引发的异常之间有细微的区别。

当 PHP 由于内部错误抛出异常时,它会立即暂停脚本。如果您按照测试环境中的建议打开了错误消息的显示,PHP 会显示一条消息,指出发生了什么。有时这些消息可能很难解释,所以捕捉异常通常是个好主意。您可以通过将主脚本封装在一个名为try的块中,并将错误处理代码放在一个catch块中,如下所示:

try {
    // main script goes here
} catch (Throwable $t) {
    echo $t->getMessage();
}

Tip

catch块中的Throwable类型声明涵盖了内部错误和脚本抛出的异常(用户异常)。

这将产生一条错误消息,通常比某些错误产生的冗长消息更容易理解。

您可以使用关键字throw抛出自定义异常,如下所示:

if (error occurs) {
    throw new Exception('Houston, we have a problem.');
}

括号内的字符串用作错误消息,可以在catch块中捕获。

Caution

错误消息对于帮助您解决开发过程中的问题至关重要。但是,当您在活动的网站上部署脚本时,它们可能会泄露对恶意攻击者有用的信息。上线时,用中性消息替换catch块中的错误消息。或者,使用catch块将访问者重定向到错误页面。

动态创建新变量

PHP 支持创建所谓的变量。那不是印刷错误。变量变量创建一个新的变量,它的名字来源于一个现有的变量。以下示例显示了其工作原理(参见variable_variables.php):

$location = 'city';

前面的语句将字符串“city”赋给一个名为$location的变量。你可以通过使用两个美元符号来创建一个变量,就像这样:

$$location = 'London';

变量 variable 以原始变量的值作为其名称。换句话说,$$location$city相同:

echo $city; // London

您将在第六章的邮件处理脚本中看到这种技术的实际例子。

Tip

为了表明这个双$是有意的,你可以用花括号将变量括起来,这样来创建变量变量:${$location}。大括号是可选的,但是使代码更容易阅读。

现在谈谈解决方案

前四章是关于理论的——很重要,但没什么意思。本书的其余部分涉及到实际问题:让 PHP 解决现实世界的问题。所以,事不宜迟,让我们继续讨论 PHP 8 解决方案。

五、通过以下内容减轻您的工作量

将一个文件的内容包含在另一个文件中的能力是 PHP 最强大的特性之一。这也是最容易实现的方法之一。这意味着代码可以合并到多个页面中——例如,公共元素,如页眉、页脚或导航菜单。PHP 将内容合并到服务器上的每个页面,允许您通过编辑和上传单个文件来更新菜单或其他公共元素——节省了大量时间。

在学习本章的过程中,您将了解 PHP includes 是如何工作的,PHP 在哪里寻找包含文件,以及当找不到包含文件时如何防止出现错误消息。您还将学习用 PHP 做一些很酷的事情,比如创建一个随机图像生成器。

本章涵盖以下主题:

  • 了解不同的包含命令

  • 告诉 PHP 在哪里可以找到你的包含文件

  • 对公共页面元素使用 PHP 包含

  • 保护包含文件中的敏感信息

  • 自动化“你在这里”菜单链接

  • 从文件名生成页面标题

  • 自动更新版权声明

  • 显示带有标题的随机图像

  • 处理包含文件的错误

  • 更改您的 web 服务器的include_path

包括来自外部文件的代码

包含其他文件代码的能力是 PHP 的核心部分。所有需要做的就是使用 PHP 的 include 命令,并告诉服务器在哪里可以找到该文件。

PHP 包含命令简介

PHP 有四个命令可以用来包含来自外部文件的代码,即:

  • include

  • include_once

  • require

  • require_once

都做基本相同的事情,为什么有四个?根本的区别在于,include试图继续处理您的脚本,即使它找不到指定的文件,而require在强制意义上使用:如果文件丢失,PHP 引擎停止处理并抛出致命错误。实际上,这意味着如果你的页面在没有外部文件的情况下仍然可用,你应该使用include。如果页面依赖于外部文件,使用require

另外两个命令include_oncerequire_once防止同一个文件在一个页面中被多次包含。试图在脚本中多次定义函数或类会触发致命错误。因此include_oncerequire_once确保函数和类只定义一次,即使脚本试图多次包含外部文件,如果命令在条件语句中就可能发生这种情况。

Tip

如果有疑问,总是使用require,除了定义函数和类的文件,这时你应该使用require_once。即使找不到外部文件,依赖于您的脚本仍然可以工作也会使您面临安全风险。

PHP 在哪里寻找包含文件

要包含一个外部文件,请使用四个 include 命令中的一个,后跟用引号括起来的文件路径(单引号或双引号,无所谓)。文件路径可以是绝对路径,也可以是相对于当前文档的路径。例如,只要目标文件存在,以下任何一项都将有效:

require 'includes/menu.php';
require 'C:/xampp/htdocs/php8sols/includes/menu.php';
require '/Applications/MAMP/htdocs/php8sols/includes/menu.php';

Note

PHP 接受包含命令的 Windows 文件路径中的正斜杠。

您可以选择在 include 命令中使用圆括号,这样下面的命令也可以使用:

require('includes/menu.php');
require('C:/xampp/htdocs/php8sols/includes/menu.php');
require('/Applications/MAMP/htdocs/php8sols/includes/menu.php');

当使用相对文件路径时,建议使用./表示路径从当前文件夹开始。因此,像这样重写第一个例子更有效:

require './includes/menu.php'; // path begins in current folder

不起作用的是使用相对于站点根目录的文件路径,如下所示:

require '/includes/menu.php'; // THIS WILL NOT WORK

这是行不通的,因为 PHP include 命令将前导正斜杠解释为硬盘的根目录。换句话说,PHP 将其视为绝对路径,而不是相对于站点根目录的路径。PHP 还查看 PHP 配置中定义的include_path。我将在这一章的后面回到这个主题。在此之前,让我们把 PHP 包含实际使用。

PHP 解决方案 5-1:移动菜单和页脚以包含文件

图 5-1 展示了一个页面的四个元素是如何从包含文件的 PHP 小魔术中获益的。

img/332054_5_En_5_Fig1_HTML.jpg

图 5-1

识别静态网页中可以用 PHP 改进的元素

菜单和页脚出现在 Japan Journey 网站的每个页面上,所以它们是包含文件的主要候选对象。清单 5-1 显示了页面主体的代码,菜单和页脚以粗体突出显示。(导航菜单中的第二个链接故意不同于图 5-1 。你以后会改的。)

  1. ch05文件夹中的index_01.php复制到php8sols站点根目录,并重命名为index.php。如果你正在使用一个提供更新页面链接的程序,不要更新它们。下载文件中的相关链接是正确的。通过将index.php加载到浏览器中,检查 CSS 和图像是否正常显示。它看起来应该和图 5-1 一样。

  2. blog.phpgallery.phpcontact.phpch05文件夹复制到您的站点根文件夹。这些页面还不能在浏览器中正确显示,因为还没有创建必要的包含文件。这很快就会改变。

  3. index.php中,高亮显示清单 5-1 中粗体显示的<nav>元素,然后剪切(Ctrl+X/Cmd+X)到你的电脑剪贴板。

  4. 在站点根目录下创建一个名为includes的新文件夹。然后在刚刚创建的文件夹中创建一个名为menu.php的文件。删除编辑程序插入的任何代码;该文件必须完全为空。

  5. 将剪贴板中的代码粘贴(Ctrl+V/Cmd+V)到menu.php并保存文件。menu.php的内容应该是这样的:

<header>
    <h1>Japan Journey</h1>
</header>
<div id="wrapper">
    <nav>
        <ul>
            <li><a href="index.php" id="here">Home</a></li>
            <li><a href="blog.php">Journal</a></li>
            <li><a href="gallery.php">Gallery</a></li>
            <li><a href="contact.php">Contact</a></li>
        </ul>
    </nav>
    <main>
        <h2>A journey through Japan with PHP</h2>
        <p>One of the benefits of using PHP . . .</p>
        <figure>
            <img src="img/water_basin.jpg" alt="Maiko&​mdash;trainee geishas in Kyoto"
            width="340" height="205" class="picBorder">
            <figcaption>Maiko&​mdash;trainee geishas in Kyoto</figcaption>
        </figure>
        <p>Ut enim ad minim veniam, quis nostrud . . .</p>
        <p>Eu fugiat nulla pariatur. Ut labore et dolore . . .</p>
        <p>Sed do eiusmod tempor incididunt ullamco . . .</p>
    </main>
    <footer>
        <p>&​copy; 2006&​ndash;2021 David Powers</p>
    </footer>
</div>

Listing 5-1The static version of index.php

<nav>
    <ul>
        <li><a href="index.php" id="here">Home</a></li>
        <li><a href="blog.php">Journal</a></li>
        <li><a href="gallery.php">Gallery</a></li>
        <li><a href="contact.php">Contact</a></li>
    </ul>
</nav>

不要担心你的新文件没有DOCTYPE声明或者任何<html><head>或者<body>标签。包含该文件内容的其他页面将提供这些元素。

  1. 打开index.php并在nav无序列表留下的空间插入以下内容:
<?php require './includes/menu.php'; ?>

这使用了一个到menu.php的文档相对路径。在路径的开头使用./会更有效,因为它明确指出路径从当前文件夹开始。

Tip

我使用require命令,因为导航菜单是关键任务。没有它,就无法浏览网站。

  1. 保存index.php并将页面加载到浏览器中。看起来应该和以前一模一样。尽管菜单和页面的其余部分来自不同的文件,但 PHP 在将任何输出发送到浏览器之前会将它们合并。

    注意不要忘记 PHP 代码需要由 web 服务器处理。如果您已经将文件存储在服务器文档根目录下名为php8sols的子文件夹中,您应该使用 URL http://localhost/php8sols/index.php来访问index.php。如果你需要找到服务器文件根的帮助,请参见第二章中的“在哪里定位你的 PHP 文件(Windows 和 Mac)”。

  2. footer做同样的操作。剪切清单 5-1 中粗体突出显示的行,粘贴到includes文件夹中一个名为footer.php的空白文件中。然后插入命令,将新文件包含在<footer>留下的间隙中:

<?php include './includes/footer.php'; ?>

这一次,我使用了include而不是require<footer>是页面的重要组成部分,但是如果找不到包含文件,站点仍然可用。

Caution

如果包含文件丢失,例如,如果您不小心删除了它,您应该总是替换它或删除 include 命令。不要相信include即使找不到外部文件也会尝试处理页面的其余部分。总是在你意识到问题的时候马上解决它们。

  1. 保存所有页面并在浏览器中重新加载index.php。同样,它应该看起来与原始页面相同。如果您导航到站点中的其他页面,菜单和页脚应该出现在每个页面上。包含文件中的代码现在服务于所有页面。

  2. 为了证明菜单是从单个文件中提取的,更改menu.php中日志链接的文本,如下所示:

  3. 保存menu.php并重新加载站点。这种变化反映在所有页面上。你可以对照ch05文件夹中的index_02.phpmenu_01.phpfooter_01.php来检查你的代码。

<li><a href="blog.php">Blog</a></li>

如图 5-2 所示,有问题。指示您所在页面的样式不会改变(它由<a>标签中的here ID 控制)。

img/332054_5_En_5_Fig2_HTML.jpg

图 5-2

当前页面指示器仍然指向主页

用 PHP 条件逻辑很容易解决这个问题。在此之前,让我们检查一下 web 服务器和 PHP 引擎是如何处理包含文件的。

为 Includes 选择正确的文件扩展名

当 PHP 引擎遇到 include 命令时,它会在外部文件的开头停止处理 PHP,并在结尾继续处理。这就是包含文件只包含原始 HTML 的原因。如果您希望外部文件使用 PHP 代码,那么代码必须包含在 PHP 标签中。因为外部文件是作为包含它的 PHP 文件的一部分来处理的,所以包含文件可以有任何文件扩展名。

一些开发人员使用.inc作为文件扩展名,以表明该文件将被包含在另一个文件中。然而,大多数服务器将.inc文件视为纯文本。如果文件包含敏感信息,如数据库的用户名和密码,这会带来安全风险。如果该文件存储在您网站的根文件夹中,任何发现该文件名称的人只需在浏览器地址栏中键入 URL,浏览器就会欣然显示您所有的秘密细节!

另一方面,任何带有.php扩展名的文件在发送到浏览器之前都会自动发送到 PHP 引擎进行解析。只要你的秘密信息在 PHP 代码块中,在扩展名为.php的文件中,它就不会被暴露。这就是为什么一些开发者使用.inc.php作为 PHP 包含的双重扩展。.inc部分提醒您这是一个包含文件,但是服务器只对末尾的.php感兴趣,它确保所有 PHP 代码都被正确解析。

在很长一段时间里,我遵循对包含文件使用.inc.php的惯例。但是由于我将所有包含文件存储在一个名为includes的单独文件夹中,我认为双扩展名是多余的。我现在用的只是.php

您选择哪种命名约定取决于您,但是单独使用.inc是最不安全的。

PHP 解决方案 5-2:测试包含的安全性

这个解决方案演示了使用.inc.php(或.inc.php)作为包含文件的文件扩展名之间的区别。使用上一节中的index.phpmenu.php。或者,使用ch05文件夹中的index_02.phpmenu_01.php。如果您使用下载文件,请在使用前删除文件名中的_02_01

  1. menu.php重命名为menu.inc,并相应地编辑index.php以包含它:

  2. index.php载入浏览器。你应该看不出有什么不同。

  3. 修改menu.inc中的代码,将密码存储在 PHP 变量中,如下所示:

<?php require './includes/menu.inc'; ?>

img/332054_5_En_5_Fig3_HTML.jpg

图 5-3

PHP 代码没有输出,所以只有 HTML 被发送到浏览器

  1. 重新加载页面。如图 5-3 所示,密码仍然隐藏在源代码中。虽然 include 文件没有.php文件扩展名,但是它的内容已经与index.php合并,所以 PHP 代码被处理。
<ul>
    <li><a href="index.php" id="here">Home</a></li>
    <?php $password = 'topSecret'; ?>
    <li><a href="blog.php">Blog</a></li>
    <li><a href="gallery.php">Gallery</a></li>
    <li><a href="contact.php">Contact</a></li>
</ul>

img/332054_5_En_5_Fig4_HTML.jpg

图 5-4

在浏览器中直接加载menu.inc会暴露 PHP 代码

  1. 现在直接在浏览器中加载menu.inc。图 5-4 显示了发生的情况。

服务器和浏览器都不知道如何处理一个.inc文件,所以所有的内容都显示在屏幕上:原始 HTML,你的密码,所有的一切。

  1. 将包含文件的名称更改为menu.inc.php,并通过在上一步中使用的 URL 末尾添加.php将其直接加载到您的浏览器中。这一次,您应该会看到一个无序的链接列表。检查浏览器的源代码视图。PHP 没有公开。

  2. 将名称改回menu.php,通过直接在浏览器中加载并再次查看源代码来测试包含文件。

  3. 删除您在步骤 3 中添加到menu.php的密码 PHP 代码,并将index.php中的 include 命令改回其原始设置,如下所示:

<?php require './includes/menu.php'; ?>

PHP 解决方案 5-3:自动显示当前页面

让我们解决菜单不显示当前页面的问题。解决方案包括使用 PHP 找出当前页面的文件名,然后使用条件语句在相应的<a>标签中插入一个 ID。

继续使用相同的文件。或者,使用ch05文件夹中的index_02.phpcontact.phpgallery.phpblog.phpmenu_01.phpfooter_01.php,确保删除任何文件名中的_01_02

  1. 打开menu.php。代码目前如下所示:
<nav>
    <ul>
        <li><a href="index.php" id="here">Home</a></li>
        <li><a href="blog.php">Blog</a></li>
        <li><a href="gallery.php">Gallery</a></li>
        <li><a href="contact.php">Contact</a></li>
    </ul>
</nav>

指示当前页面的样式由第 3 行突出显示的id="here"控制。如果当前页面是blog.php,你需要 PHP 将id="here"插入到blog.php <a>标签中,如果页面是gallery.php,插入到gallery.php <a>标签中,如果页面是contact.php,插入到contact.php <a>标签中。

希望你现在已经得到了提示——你需要在每个<a>标签中有一个if语句(参见第三章中的“做决定”)。第 3 行需要看起来像这样:

<li><a href="index.php" <?php if ($currentPage == 'index.php') {
    echo 'id="here"'; } ?>>Home</a></li>

其他链接应该以类似的方式进行修改。但是$currentPage是怎么得到它的值的呢?你需要找出当前页面的文件名。

  1. 暂时把menu.php放在一边,创建一个名为get_filename.php的新 PHP 页面。插入以下代码(或者,使用ch05文件夹中的get_filename.php):

  2. 保存get_filename.php并在浏览器中查看。在 Windows 系统上,您应该会看到类似下面的屏幕截图:(在ch05文件夹中的版本包含这一步和下一步的代码,以及指示哪个是哪个的文本。)

<? php echo $_SERVER['SCRIPT_FILENAME'];

img/332054_5_En_5_Figa_HTML.jpg

在 macOS 上,您应该会看到类似这样的内容:

img/332054_5_En_5_Figb_HTML.jpg

来自 PHP 的一个内置超全局数组,它总是给你当前页面的绝对文件路径。您现在需要的是提取文件名的方法。

  1. 像这样修改上一步中的代码:

  2. 保存get_filename.php并点击浏览器中的重新加载按钮。您现在应该只看到文件名:get_filename.php

<?php echo basename($_SERVER['SCRIPT_FILENAME']);

内置的 PHP 函数basename()将文件路径作为参数,并提取文件名。这就是找到当前页面文件名的方法。

  1. 像这样修改menu.php中的代码(更改以粗体突出显示):
<?php $currentPage = basename($_SERVER['SCRIPT_FILENAME']); ?>
<nav>
    <ul>
        <li><a href="index.php" <?php if ($currentPage == 'index.php') {
            echo 'id="here"';} ?>>Home</a></li>
        <li><a href="blog.php" <?php if ($currentPage == 'blog.php') {
            echo 'id="here"';} ?>>Blog</a></li>
        <li><a href="gallery.php" <?php if ($currentPage == 'gallery.php') {
            echo 'id="here"';} ?>>Gallery</a></li>
        <li><a href="contact.php" <?php if ($currentPage == 'contact.php') {
            echo 'id="here"';} ?>>Contact</a></li>
    </ul>
</nav>

Tip

我用双引号将here括起来,所以我用单引号将字符串'id="here"'括起来。它比"id=\"here\""更容易阅读。

img/332054_5_En_5_Fig5_HTML.jpg

图 5-5

包含文件中的条件代码为每个页面生成不同的输出

  1. 保存menu.php并将index.php加载到浏览器中。菜单看起来应该和以前没有什么不同。使用菜单导航到其他页面。这一次,如图 5-5 所示,当前页面旁边的边框应该是白色的,表示你在站点中的位置。如果您在浏览器中检查页面的源代码视图,您会看到here ID 已经被自动插入到正确的链接中。

  2. 如有必要,将您的代码与ch05文件夹中的menu_02.php进行比较。

PHP 解决方案 5-4:根据文件名自动生成页面标题

这个解决方案使用basename()来提取文件名,然后使用 PHP 字符串函数来格式化名称,以便插入到<title>标签中。它只对告诉你一些关于页面内容的文件名有效,但无论如何这是一个好的实践。

  1. 创建一个名为title.php的新 PHP 文件,并将其保存在includes文件夹中。

  2. 去掉脚本编辑器插入的任何代码,并键入以下代码:

    <?php $title = basename($_SERVER['SCRIPT_FILENAME'], '.php');
    
    

    提示不要在结尾添加结束 PHP 标签。当在同一个文件中 PHP 代码后面没有任何东西时,它是可选的。省略结束标记有助于避免包含文件的一个常见错误,即“标题已发送”你将在 PHP 解决方案 5-9 中了解更多关于这个错误的信息。

PHP 解决方案 5-3 中使用的basename()函数有一个可选的第二个参数:一个包含文件扩展名的字符串,前面有一个前导句点。添加第二个参数会删除文件名的扩展名。所以这段代码提取文件名,去掉扩展名.php,并将结果赋给一个名为$title的变量。

  1. 通过在DOCTYPE上方键入以下内容,打开contact.php并包含title.php:
<?php include './includes/title.php'; ?>

Note

通常情况下,在网页中的DOCTYPE声明之前不应该有任何内容。然而,如果 PHP 代码不向浏览器发送任何输出,这就不适用于 PHP 代码。title.php中的代码只给$title赋值,所以DOCTYPE声明仍然是浏览器看到的第一个输出。

  1. 像这样修改<title>标签:
<title>Japan Journey <?= $title ?></title>

注意在开始的简写 PHP 标签前有一个空格。没有它,$title的值将与“Journey”相冲突。

img/332054_5_En_5_Fig6_HTML.jpg

图 5-6

提取文件名后,您可以动态生成页面标题

  1. 保存两个页面并将contact.php加载到浏览器中。没有扩展名.php的文件名被添加到浏览器标签中,如图 5-6 所示。

  2. 如果你喜欢用首字母大写来表示从文件名中派生出来的那部分标题呢?PHP 有一个名为ucfirst()的简洁的小函数,它就是这样做的(uc代表“大写”)。向步骤 2 中的代码添加另一行,如下所示:

<?php
$title = basename($_SERVER['SCRIPT_FILENAME'], '.php');
$title = ucfirst($title);

如果您是编程新手,这可能看起来令人困惑,所以让我们来看看这里发生了什么。PHP 标签后的第一行代码获取文件名,去掉末尾的.php,并将其存储为$title。下一行将$title的值传递给ucfirst()以大写第一个字母,并将结果存储回$title。所以,如果文件名是contact.php,那么$title开始是contact,但是到了下一行的末尾,就变成了Contact

Tip

您可以通过将两行合并成一行来缩短代码,如下所示:

$title = ucfirst(basename($_SERVER['SCRIPT_FILENAME'], '.php'));

当您像这样嵌套函数时,PHP 首先处理最内层的函数,并将结果传递给外层的函数。它使你的代码更短,但是不容易阅读。

  1. 这种技术的一个缺点是文件名只由一个单词组成——至少应该是这样。URL 中不允许有空格,这也是为什么有些网页设计软件或者浏览器会用%20代替空格,在一个 URL 中显得很难看,很不专业。您可以通过使用下划线来解决这个问题。

    contact.php的文件名改为contact_us.php

  2. 像这样修改title.php中的代码:

<?php
$title = basename($_SERVER['SCRIPT_FILENAME'], '.php');
$title = str_replace('_', ' ', $title);
$title = ucwords($title);

中间一行使用一个名为str_replace()的函数来查找每一个下划线并用一个空格替换它。该函数有三个参数:要替换的字符、替换字符和要更改的字符串。

Tip

您也可以使用str_replace()删除字符,方法是使用一个空字符串(一对中间没有任何内容的引号)作为第二个参数。这将第一个参数中的字符串替换为空,实际上是删除了它。

最后一行代码没有使用ucfirst(),而是使用了相关的函数ucwords(),它给每个单词一个初始的大写字母。

img/332054_5_En_5_Fig7_HTML.jpg

图 5-7

下划线被去掉了,两个单词的首字母都被大写

  1. 保存title.php并将重命名的contact_us.php加载到浏览器中。图 5-7 显示了结果。

img/332054_5_En_5_Fig8_HTML.jpg

图 5-8

从 index.php 生成页面标题产生了不令人满意的结果

  1. 将文件名改回contact.php,并将文件重新加载到浏览器中。title.php中的脚本仍然有效。没有下划线可以替换,所以str_replace()保持$title的值不变,而ucwords()将第一个字母转换成大写,即使只有一个单词。

  2. index.phpblog.phpgallery.php重复步骤 3 和 4。

  3. 日本之旅网站的主页名为index.php。如图 5-8 所示,将当前的解决方案应用到这个页面似乎不太合适。

有两种解决方案:要么不要对这样的页面应用这种技术,要么使用条件语句(一个if语句)来处理特殊情况。例如,要显示 Home 而不是 Index,修改title.php中的代码如下:

<?php
$title = basename($_SERVER['SCRIPT_FILENAME'], '.php');
$title = str_replace('_', ' ', $title);
if ($title == 'index') {
    $title = 'home';
}
$title = ucwords($title);

条件语句的第一行使用两个等号来检查$title的值。下面一行使用一个等号将新值赋给$title。如果页面被称为除了index.php之外的任何东西,花括号内的行被忽略,$title保持其原始值。

Tip

PHP 是区分大小写的,所以这个解决方案只有在“index”全小写的情况下才有效。要进行不区分大小写的比较,请将前面代码的第四行更改如下:

if (strtolower($title) == 'index') {

函数strtolower()将一个字符串 ing 转换成小写——因此得名——并且经常用于进行不区分大小写的比较。到小写的转换不是永久的,因为strtolower($title)没有赋给变量;它只是用来做比较的。为了使更改永久,您需要将结果赋回一个变量,就像在最后一行,当ucwords($title)被赋回$title时。

要将字符串转换为大写,请使用strtoupper()

img/332054_5_En_5_Fig9_HTML.jpg

图 5-9

条件语句将 index.php 上的标题更改为 Home

  1. 保存title.php并将index.php重新加载到浏览器中。页面标题现在看起来更加自然,如图 5-9 所示。

  2. 导航回contact.php,您将看到页面标题仍然是从页面名称中正确派生出来的。

    您可以对照title.phpch05文件夹中index_03.phpblog_02.phpgallery_02.phpcontact_02.php中其他文件的更新版本来检查您的代码。

Caution

绝大多数 PHP 网站都托管在 Linux 服务器上,这些服务器将文件名和目录(文件夹)名区分大小写。但是,在 Windows 或 macOS 上进行本地开发时,文件名和文件夹名是以不区分大小写的方式处理的。为了避免在实时服务器上部署文件时路径被破坏,我建议在命名文件和文件夹时只使用小写字母。如果要混合使用大写和小写,请确保拼写一致。

PHP 解决方案 5-5:处理缺失变量

在许多情况下,预期值会丢失。例如,您可能拼错了变量名,表单中可能没有提交值,或者缺少包含文件。因此,在尝试使用外部来源的值之前,最好先检查它是否存在。在这个解决方案中,您将使用两种不同的方法来解决这个问题。

img/332054_5_En_5_Fig10_HTML.jpg

图 5-10

拼写错误的变量会在浏览器选项卡中生成警告

  1. 继续使用与上一个解决方案中相同的文件。或者,将index_03.phpblog_02.phpgallery_02.phpcontact_02.phpch05文件夹复制到您的站点根目录。还要确保title.phpmenu_02.phpfooter_01.phpincludes文件夹中。如果使用ch05文件夹中的文件,删除每个文件名中的下划线和数字。

  2. index.php中,将<title>标签中变量的第一个字母大写,将其从$title改为$Title。PHP 变量是区分大小写的,所以这不再是指由title.php生成的值。

  3. 保存文件,并将index.php加载到浏览器中。右键单击查看源代码。如果您将error_reporting设置为第二章中推荐的水平,您应该会看到如图 5-10 所示的结果。browser 选项卡包含来自 PHP 关于未定义变量的警告的原始 HTML。

Note

PHP 8 通过生成警告而不是通知,将未定义的变量视为比以前版本更严重的错误,这是最低的错误级别。

  1. 零合并操作符(参见第四章中的“用零合并操作符设置默认值”)无缝处理这种情况。像这样更改<title>标签中的 PHP 块:

  2. 保存并重新加载页面。浏览器选项卡现在应该如下所示:

<?= $Title ?? 'default' ?>

img/332054_5_En_5_Figc_HTML.jpg

忽略未定义的变量,显示 null 合并运算符后的值,而不生成错误通知。

  1. 删除引号之间的文本,留下空字符串,如下所示:

  2. 保存并重新加载页面。这一次,浏览器选项卡只显示 HTML 中的文本。空字符串只是隐藏错误提示。

  3. 通过将第一个字母小写来更正变量名,并再次测试页面。它现在看起来和之前的 PHP 解决方案的结尾一样(见图 5-9 )。

  4. 当变量不存在时,零合并操作符可以很好地设置默认值;但是如果你想修改一个变量,你就不能使用它。在这种情况下,您需要使用isset()函数来测试变量的存在。

<?= $Title ?? " ?>

打开blog.php并像这样更改<title>标签:

<title>Japan Journey<?php if (isset($title)) {echo "&​mdash;{$title}";}
    ?></title>

请注意,HTML 文本和开始的 PHP 标记之间的空格已经被删除。此外,开始的 PHP 标签不再是简写的,因为 PHP 块包含一个条件语句;它不仅仅是显示一个值。

如果变量存在,isset()函数返回true。因此,如果已经定义了$title,那么echo会显示一个双引号字符串,其中包含一个长破折号(& mdash;是 HTML 字符实体),后跟$title的值。我用花括号将变量括起来,因为实体和$title之间没有空格。这是可选的,但是它使代码更容易阅读。

Tip

如果值是一个空字符串(一对中间没有空格的引号),isset()函数返回true。它检查一个变量已经被定义并且不是null。使用empty()检查空字符串或零值。下一章的 PHP 解决方案 6–2 解释了如何检查一个字符串不仅仅由空格字符组成。

  1. 保存blog.php并在浏览器中测试。浏览器选项卡应如下所示:

img/332054_5_En_5_Figd_HTML.jpg

因为$title有一个值,isset()返回true并显示前面有一个长破折号的值。

  1. 尝试一个未定义的变量,比如$Title。条件语句中的代码将被忽略,而不会触发错误通知。

  2. 使用isset()或零合并操作符来保护gallery.phpcontact.php避免在<title>标签中使用未定义的变量。

    您可以对照ch05文件夹中的index_04.phpblog_03.phpgallery_03.phpcontact_03.php来检查您的代码。

创建内容不断变化的页面

到目前为止,您已经使用 PHP 根据页面的文件名生成了不同的输出。接下来的两个解决方案生成独立于文件名变化的内容:一个在 1 月 1 日自动更新年份的版权声明和一个随机图像生成器。

PHP 解决方案 5-6:自动更新版权声明

footer.php中的版权声明只包含静态 HTML。这个 PHP 解决方案展示了如何使用date()函数自动生成当前年份。该代码还指定了版权的第一年,并使用条件语句来确定当前年份是否不同。如果是,则显示两个年份。

继续使用 PHP 解决方案 5-5 中的文件。或者,使用ch05文件夹中的index_04.phpfooter_01.php,并删除文件名中的数字。如果使用ch05文件夹中的文件,确保在includes文件夹中有title.phpmenu.php的副本。

  1. 打开footer.php。它包含以下 HTML:
<footer>
    <p>&​copy; 2006&​ndash;2021 David Powers</p>
</footer>

使用包含文件的好处是,您可以通过更改这一个文件来更新整个站点的版权声明。但是,自动增加年份会更有效。

  1. PHP date()函数巧妙地解决了这个问题。像这样更改段落中的代码:
<p>&​copy; 2006&​ndash;<?php echo date('Y'); ?> David Powers</p>

这将替换第二个日期,并使用四位数显示当前年份。确保将大写的 Y 作为参数传递给date()

Note

第十六章中的表 16-4 列出了可以传递给date()函数来显示日期部分的最常用字符,如月、星期几等等。

  1. 保存footer.php并将index.php加载到浏览器中。页面底部的版权声明看起来应该和以前一样——当然,除非你在 2022 年或更晚的时候阅读这篇文章,在这种情况下,将显示当前年份。

  2. 像大多数版权声明一样,这涵盖了一系列的年份,表明了一个网站首次推出的时间。因为第一次约会已经过去了,所以可以硬编码。但是,如果你正在创建一个新的网站,你只需要今年。直到 1 月 1 日才需要年份范围。

    要显示一系列年份,您需要知道起始年份和当前年份。如果两个年份相同,则只显示当前年份;如果它们不一样,用中间的破折号显示它们。这是一个简单的情况。像这样更改footer.php中段落的代码:

<?php
$startYear = 2006;
$thisYear = date('Y');
if ($startYear == $thisYear) {
    $output = $startYear;
} else {
    $output = "{$startYear}&ndash;{$thisYear}";
}
?> <p>&​copy; <?= $​output ?> David Powers</p>

就像在 PHP 解决方案 5-5 中一样,我在else子句中的变量周围使用了花括号,因为它们在不包含空格的双引号字符串中。

  1. 保存footer.php并在浏览器中重新加载index.php。版权声明看起来应该和以前一样。

  2. 将传递给date()函数的参数改为小写的 y ,如下所示:

  3. 保存footer.php并点击浏览器中的重新加载按钮。第二年仅使用最后两位数字显示,如下面的屏幕截图所示:

$thisYear = date('y');

img/332054_5_En_5_Fige_HTML.jpg

Tip

这应该提醒我们 PHP 中区分大小写的重要性。大写的 Y 和小写的 ydate()函数产生不同的结果。忘记区分大小写是 PHP 中最常见的错误原因之一。

  1. 将传递给date()的参数改回大写的 Y 。将$startYear的值设置为当前年份,并重新加载页面。这一次,您应该只看到当前显示的年份。

你现在有一个完全自动化的版权声明。完成的代码在ch05文件夹的footer_02.php中。

PHP 解决方案 5-7:显示随机图像

显示一个随机图像所需要的只是一个可用图像的列表,存储在一个索引数组中(参见第四章中的“创建数组”)。因为索引数组从 0 开始编号,所以您可以通过生成一个介于 0 和小于数组长度的 1 之间的随机数来选择其中一个图像。所有这些都是通过几行代码完成的…

继续使用相同的文件。或者,使用ch05文件夹中的index_04.php,并将其重命名为index.php。由于index_04.php使用了title.phpmenu.phpfooter.php,请确保这三个文件都在您的includes文件夹中。图像已经在images文件夹中。

  1. includes文件夹中创建一个空白的 PHP 页面,并将其命名为random_image.php。插入以下代码(也在ch05文件夹的random_image_01.php中):
<?php
$images = ['kinkakuji', 'maiko', 'maiko_phone', 'monk', 'fountains',
    'ryoanji', 'menu', 'basin'];
$i = random_int(0, count($images)-1);
$selectedImage = "img/{$images[$i]}.jpg";

这是完整的脚本:一个图像名称数组减去.jpg文件扩展名(没有必要重复共享信息——它们都是 JPEG ),一个随机数生成器,以及一个为所选文件构建正确路径名的字符串。

要生成一个范围内的随机数,将最小和最大数作为参数传递给random_int()函数。因为数组中有八个图像,所以需要一个介于 0 和 7 之间的数字。简单的方法是使用random_int(0, 7)——简单,但效率低。每次更改$images数组时,都需要计算它包含多少个元素,并更改传递给random_int()的最大数量。

count()函数让 PHP 为您做这件事要容易得多,它计算数组中元素的数量。您需要一个比数组中元素数少一的数字,所以传递给random_int()的第二个参数变成了count($images)-1,结果存储在$i中。

随机数用在最后一行,为选定的文件建立正确的路径名。变量$images[$i]嵌入在一个双引号字符串中,没有空格将其与周围的字符分开,所以它被括在花括号中。数组从 0 开始,所以如果随机数是 1,$selectedImage就是img/maiko.jpg

如果您是 PHP 新手,您可能会发现很难理解这样的代码:

$i = random_int(0, count($images)-1);

所发生的是传递给random_int()的第二个参数是一个表达式而不是一个数字。如果这样能让你更容易理解,就像这样重写代码:

  1. 打开index.php并将random_image.php包含在与title.php相同的代码块中,如下所示:
$numImages = count($images); // $numImages is 8
$max = $numImages – 1;       // $max is 7
$i = random_int(0, $max);    // $i = random_int(0, 7)

<?php include './includes/title.php';
include './includes/random_image.php'; ?>

因为random_image.php没有向浏览器发送任何直接输出,所以把它放在DOCTYPE上面是安全的。

  1. index.php中向下滚动,找到在 figure 元素中显示图像的代码。看起来是这样的:

  2. 不使用img/maiko.jpg作为固定图像,而是用$selectedImage代替。所有的图像都有不同的尺寸,所以删除widthheight属性,使用一个通用的alt属性。同时删除figcaption元素中的文本。步骤 3 中的代码现在应该如下所示:

<figure>
    <img src="img/maiko.jpg" alt="Maiko&​mdash;trainee geishas in Kyoto"
        width="340" height="205" class="picBorder">
    <figcaption>Maiko&​mdash;trainee geishas in Kyoto</figcaption>
</figure>

<figure>
    <img src="<?= $selectedImage ?>" alt="Random image" class="picBorder">
    <figcaption></figcaption>
</figure>

Note

PHP 块只显示一个值,所以您可以使用短的echo标记<?=

img/332054_5_En_5_Fig11_HTML.jpg

图 5-11

将图像文件名存储在索引数组中可以很容易地显示随机图像

  1. 保存random_image.phpindex.php,然后将index.php加载到浏览器中。现在应该随机选择图像。单击浏览器中的重新加载按钮;你应该会看到各种各样的图像,如图 5-11 所示。

你可以对照ch05文件夹中的index_05.phprandom_image_01.php来检查你的代码。

这是显示随机图像的一种简单而有效的方式,但是如果能够动态地设置不同大小图像的宽度和高度,并添加一个标题来描述图像,效果会更好。

PHP 解决方案 5-8:给随机图像添加标题

这个解决方案使用一个多维数组——或数组的数组——来存储每个图像的文件名和标题。如果你觉得多维数组的概念很难用抽象的术语来理解,那就把它想象成一个大盒子,里面有很多信封,每个信封里面都有一张图片和它的标题。盒子是顶层数组,里面的封套是子数组。

这些图像大小不同,但是 PHP 方便地提供了一个名为getimagesize()的函数。猜猜它是做什么的。

这个 PHP 解决方案建立在前一个的基础上,所以继续使用相同的文件。

  1. 打开random_image.php,按如下方式更改代码:
<?php
$images = [
    ['file'    => 'kinkakuji',
     'caption' => 'The Golden Pavilion in Kyoto'],
    ['file'    => 'maiko',
     'caption' => 'Maiko&​mdash;trainee geishas in Kyoto'],
    ['file'    => 'maiko_phone',
     'caption' => 'Every maiko should have one&​mdash;a mobile, of course'],
    ['file'    => 'monk',
     'caption' => 'Monk begging for alms in Kyoto'],
    ['file'    => 'fountains',
     'caption' => 'Fountains in central Tokyo'],
    ['file'    => 'ryoanji',
     'caption' => 'Autumn leaves at Ryoanji temple, Kyoto'],
    ['file'    => 'menu',
     'caption' => 'Menu outside restaurant in Pontocho, Kyoto'],
    ['file'    => 'basin',
     'caption' => 'Water basin at Ryoanji temple, Kyoto']
];
$i = random_int(0, count($images)-1);
$selectedImage = "img/{$images[$i]['file']}.jpg";
$caption = $images[$i]['caption'];

Caution

你需要小心代码。每个子数组都用一对方括号括起来,后跟一个逗号,用于将它与下一个子数组隔开。如果您按如下所示对齐数组键和值,您会发现构建和维护多维数组会更容易。

尽管代码看起来很复杂,但它是一个普通的索引数组,包含八个条目,每个条目都是一个关联数组,包含对'file''caption'的定义。多维数组的定义形成了一个语句,所以在第 19 行之前没有分号。该行的右括号与第 2 行的左括号相匹配。

用于选择图像的变量也需要改变,因为$images[$i]不再包含字符串,而是一个数组。要获得图像的正确文件名,您需要使用$images[$i]['file']。所选图像的标题包含在$images[$i]['caption']中,并存储在一个较短的变量中。

  1. 您现在需要修改index.php中的代码来显示标题,如下所示:

img/332054_5_En_5_Fig12_HTML.jpg

图 5-12

长标题突出于图像之外,并使其向左移动过远

  1. 保存index.phprandom_image.php并将index.php载入浏览器。大多数图像看起来都不错,但是在拿着手机的见习艺妓图像的右边有一个难看的缺口,如图 5-12 所示。
<figure>
    <img src="<?= $selectedImage ?>" alt="Random image" class="picBorder">
    <figcaption><?= $caption ?></figcaption>
</figure>

  1. random_image.php的末尾添加以下代码:
if (file_exists($selectedImage) && is_readable($selectedImage)) {
    $imageSize = getimagesize($selectedImage);
}

if语句使用了两个函数,file_exists()is_readable(),以确保$selectedImage不仅存在,而且可以访问(它可能被破坏或具有错误的权限)。这些函数返回布尔值(truefalse),所以它们可以直接用作条件语句的一部分。

if语句中的一行代码使用了函数getimagesize(),该函数返回一组关于图像的信息,这些信息存储为$imageSize。你将在第十章中了解更多关于getimagesize()的内容。目前,您对以下两条信息感兴趣:

  1. 首先,我们来修复一下<img>标签中的代码。像这样改变它:
  • $imageSize[0]:图像的宽度,以像素为单位

  • $imageSize[3]:一个包含图像高度和宽度的字符串,被格式化以包含在<img>标签中

<img src="<?= $selectedImage ?>" alt="Random image" class="picBorder"
    <?= $imageSize[3] ?>>

这将在<img>标签中插入正确的widthheight属性。

  1. 虽然这设置了图像的尺寸,但是您仍然需要控制标题的宽度。您不能在外部样式表中使用 PHP,但是没有什么可以阻止您在index.php<head>中创建一个<style>块。在结束的</head>标签之前插入以下代码。
<?php if (isset($imageSize)) { ?>
<style>
figcaption {
     width: <?= $imageSize[0] ?>px;
}
</style>
<?php } ?>

这段代码只有短短的七行,但是它是 PHP 和 HTML 的奇怪组合。让我们从第一行和最后一行开始。如果去掉 PHP 标签,用一个注释替换 HTML <style>块,结果是:

if (isset($imageSize)) {
  // do something if $imageSize has been set
}

换句话说,如果变量$imageSize没有被设置(定义), PHP 引擎会忽略花括号之间的所有内容。大括号之间的代码大部分是 HTML 和 CSS,这没关系。如果没有设置【the PHP 引擎会跳到右括号,中间的代码不会发送到浏览器。

Tip

许多没有经验的 PHP 程序员错误地认为他们需要使用echoprint在条件语句中创建 HTML 输出。只要左大括号和右大括号匹配,就可以使用 PHP 像这样隐藏或显示 HTML 的各个部分。这比一直使用echo要整洁得多,涉及的输入也少得多。

如果已经设置了$imageSize,则创建<style>块,并使用$imageSize[0]为包含标题的段落设置正确的宽度。

img/332054_5_En_5_Fig13_HTML.jpg

图 5-13

通过创建与图像大小直接相关的样式规则来消除难看的间隙

  1. 保存random_image.phpindex.php,然后将index.php重新加载到浏览器中。点击重新加载按钮,直到出现拿着手机的见习艺妓的图像。这一次,它应该看起来像图 5-13 。如果您查看浏览器的源代码,样式规则将使用正确的图像宽度。

Note

如果标题仍然突出,确保结束 PHP 标签和<style>块中的px之间没有间隙。CSS 不允许值和度量单位之间有空格。

img/332054_5_En_5_Fig14_HTML.jpg

图 5-14

包含文件中的错误会破坏页面的外观

  1. random_image.php中的代码和您刚刚插入的代码可以防止所选图像找不到时出现错误,但是显示图像的代码没有类似的检查。暂时更改其中一幅图像的名称,可以是在random_image.php文件夹中,也可以是在images文件夹中。多次重装index.php。最终,你应该会看到两个如图 5-14 所示的警告。不仅$imageSize 未定义(因此为空);您正试图访问空对象上的数组偏移量(索引)。看起来非常不专业。

  2. 只有当选定的图像既存在又可读时,random_image.php底部的条件语句才会设置$imageSize,所以如果已经设置了$imageSize,您就知道所有系统都运行了。在index.php中显示图像的图形元素周围添加条件语句的开始和结束块,如下所示:

<?php if (isset($imageSize)) { ?>
<figure>
     <img src="<?= $selectedImage ?>" alt="Random image" class="picBorder"
         <?= $imageSize[3] ?>>
     <figcaption><?= $caption ?></figcaption>
</figure>
<?php } ?>

现有的图像将正常显示,但您可以避免在文件丢失或损坏的情况下出现任何令人尴尬的错误消息,这看起来更专业。不要忘记恢复您在上一步中更改的图像的名称。

你可以对照ch05文件夹中的index_06.phprandom_image_02.php来检查你的代码。

防止包含文件出错

使用服务器端技术(如 PHP)的页面会处理大量的未知情况,因此明智的做法是编写防御性代码,在使用它们之前检查值。本节描述了您可以采取的措施,以防止和解决包含文件的错误。

检查变量的存在

从 PHP 解决方案 5-5 和 5-8 中可以吸取的教训是,你应该总是使用空合并操作符来设置默认值,或者使用isset()来验证来自包含文件的变量的存在,并将任何依赖代码包装在条件语句中。您也可以使用带有逻辑Not运算符的isset()(参见第四章中的表 4-7 )来指定默认值,如下所示:

if (!isset($someVariable)) {
    $someVariable = default value;
}

您可能会在许多脚本中遇到这种设置默认值的结构,因为空合并操作符从 PHP 7 开始才可用。没有一个比另一个更好;但是零合并操作符使得代码更短。

检查函数或类是否已经定义

包含文件经常用于定义自定义函数或类。试图使用尚未定义的函数或类会触发致命错误。要检查函数是否已经定义,将函数名作为字符串传递给function_exists()。将函数名传递给function_exists()时,省略函数名末尾的括号。例如,您检查一个名为doubleIt()的函数是否被定义成这样:

if (function_exists('doubleIt')) {
    // use doubleIt()
}

要检查一个类是否已经被定义,以同样的方式使用class_exists(),传递一个包含类名的字符串作为参数:

if (class_exists('MyClass')) {
    // use MyClass
}

假设您想要使用函数或类,如果函数或类尚未定义,更实用的方法是使用条件语句来包含定义文件。例如,doubleIt()的定义在一个名为utilities.php的文件中:

if (!function_exists('doubleIt')) {
    require_once './includes/utilities.php';
}

抑制实时网站上的错误消息

假设您的包含文件在远程服务器上正常工作,前面几节中概述的措施可能就是您需要的所有错误检查。但是,如果您的远程服务器显示错误消息,您应该采取措施抑制它们。以下技术隐藏所有错误信息,而不仅仅是那些与包含文件相关的错误信息。

使用错误控制运算符

一种相当粗糙的技术是使用 PHP 错误控制操作符 ( @),它抑制与使用它的行相关的错误消息。您可以将@放在行首,或者直接放在您认为可能会产生错误的函数或命令的前面,如下所示:

@ include './includes/random_image.php';

Caution

这不适用于requirerequire_once,因为试图用这些命令加载丢失或损坏的文件会产生致命错误。在 PHP 8 中,错误控制操作符不再抑制致命的错误消息。

错误控制操作符的问题在于它隐藏了错误,而不是解决它们。它只有一个字符,所以很容易忘记你用过它。因此,您可能会浪费大量时间在脚本的错误部分查找错误。如果您使用错误控制操作符,那么在对问题进行故障诊断时,您应该首先删除@标记。

另一个缺点是,您需要在可能生成错误消息的每一行使用错误控制操作符,因为它只影响当前行。

关闭 PHP 配置中的显示错误

在实时网站中抑制错误消息的一个更好的方法是在 web 服务器的配置中关闭display_errors指令。最有效的方法是编辑php.ini,如果你的主机公司让你控制它的设置。找到display_errors指令,将On改为Off

如果你不能控制php.ini,许多主机公司允许你使用一个叫做.htaccess.user.ini的文件来改变有限范围的配置设置。文件的选择取决于 PHP 在服务器上的安装方式,所以请咨询您的托管公司以确定使用哪一种。

如果您的服务器支持.htaccess文件,将以下命令添加到服务器根文件夹中的.htaccess文件:

php_flag display_errors Off

在一个.user.ini文件中,命令很简单:

display_errors Off

.htaccess.user.ini都是纯文本文件。像php.ini一样,每个命令应该在一个单独的行上。如果该文件在您的远程服务器上不存在,您可以简单地在文本编辑器中创建它。确保您的编辑器不会自动在文件名末尾添加.txt。然后将文件上传到您网站的服务器根文件夹。

Tip

默认情况下,macOS 会隐藏名称以点开头的文件。在 macOS Sierra 和更高版本中,使用键盘快捷键 Cmd+Shift+。(点)来打开和关闭隐藏文件的显示。

关闭单个文件中的 display_errors

如果您无法控制服务器配置,可以通过在任何脚本的顶部添加以下行来防止显示错误消息:

<?php ini_set('display_errors', '0'); ?>

PHP 解决方案 5-9:找不到包含文件时重定向

到目前为止,所有建议的技术都只是在找不到包含文件的情况下抑制错误消息。如果一个页面没有包含文件就没有意义,那么当包含文件丢失时,您应该将用户重定向到一个错误页面。

一种方法是抛出异常,如下所示:

$file = './includes/menu.php';
if (file_exists($file) && is_readable($file)) {
    include $file;
} else {
    throw new Exception("$file can't be found");
}

当使用可能抛出异常的代码时,您需要将其包装在一个try块中,并创建一个catch块来处理异常(参见第四章中的“处理错误和异常”)。这个 PHP 解决方案展示了如何做到这一点,如果找不到包含文件,使用catch块将用户重定向到不同的页面。

如果你已经彻底地设计和测试了你的站点,这种技术在大多数使用包含文件的页面上是不必要的。然而,下面的 PHP 解决方案绝不是毫无意义的练习。它演示了 PHP 的几个重要特性:如何抛出和捕捉异常,以及如何重定向到另一个页面。正如您将从下面的说明中看到的,重定向并不总是简单明了的。这个 PHP 解决方案展示了如何克服最常见的问题。

继续使用 PHP 解决方案 5-8 中的index.php。或者,使用ch05文件夹中的index_06.php

img/332054_5_En_5_Fig15_HTML.jpg

图 5-15

如果输出已经发送到浏览器,则header()功能不起作用

  1. error.phpch05文件夹复制到站点根目录。如果您的编辑程序提示您更新页面中的链接,请不要这样做。这是一个静态页面,包含一个一般性错误消息,并链接回其他页面。

  2. 在编辑程序中打开index.php。导航菜单是最不可缺少的包含文件,所以像这样编辑index.php中的require命令:

    $file = './includes/menu.php';
    if (file_exists($file) && is_readable($file)) {
        require $file;
    } else {
        throw new Exception("$file can't be found");
    }
    
    

    提示像这样将包含文件的路径存储在一个变量中,可以避免重新键入四次,减少了拼写错误的可能性。

  3. 要将用户重定向到另一个页面,请使用header()功能。除非有语法错误,否则 PHP 引擎通常从顶部开始处理页面,输出 HTML,直到出现问题。这意味着当 PHP 引擎得到这段代码时,输出已经开始了。为了防止这种情况发生,在产生任何输出之前启动try模块。(这在一些设置中不起作用,但是请耐心等待,因为它演示了一个重要的观点。)

    滚动到页面顶部,编辑开始的 PHP 代码块,如下所示:

    <?php try {
        include './includes/title.php';
        include './includes/random_image.php'; ?>
    
    

    这将打开try块。

  4. 向下滚动到页面底部,在结束的</html>标记后添加以下代码:

    <?php } catch (Exception $e) {
        header('Location: http://localhost/php8sols/error.php');
    } ?>
    
    

    这将关闭try块并创建一个catch块来处理异常。catch块中的代码使用header()将用户重定向到error.php

    header()函数向浏览器发送一个 HTTP 头。它接受一个字符串作为参数,该字符串包含由冒号分隔的头和值。在这种情况下,它使用Location头将浏览器重定向到冒号后面的 URL 所指定的页面。如有必要,调整 URL 以匹配您自己的设置。

  5. 保存index.php并在浏览器中测试页面。它应该正常显示。

  6. 将您在步骤 2 中创建的变量$file的值改为指向一个不存在的包含文件,比如men.php

  7. 保存index.php并在浏览器中重新加载。如果您在测试环境中使用 XAMPP 或最新版本的 MAMP,您可能会被正确地重定向到error.php。不过,在一些设置中,你可能会看到图 5-15 中的信息。

图 5-15 中的错误信息可能是导致更多头部撞到键盘的原因。(我也带着伤疤。)如前所述,如果输出已经发送到浏览器,则不能使用header()功能。发生了什么事?

答案就在错误消息中,但并不明显。它说错误发生在第 55 行,这是调用header()函数的地方。您真正需要知道的是输出是在哪里生成的。这些信息埋藏在这里:

(output started at C:\xampp\htdocs\php8sols\index.php:5)

冒号后的数字 5 是行号。那么index.php的第 5 行是什么?从下面的截图可以看出,第 5 行输出了 HTML DOCTYPE 声明。

img/332054_5_En_5_Figf_HTML.jpg

因为到目前为止代码中没有错误,PHP 引擎已经输出了 HTML。一旦发生这种情况,header()就不能重定向页面,除非输出存储在一个缓冲区(web 服务器的内存)中。

Note

在许多设置中不会出现此错误消息的原因是,输出缓冲通常设置为 4096,这意味着在 HTTP 头发送到浏览器之前,有 4 KB 的输出存储在缓冲区中。尽管这很有用,但它给了您一种错误的安全感,因为您的远程服务器上可能没有启用输出缓冲。所以,即使你被正确地重定向,也要继续读下去。

img/332054_5_En_5_Fig16_HTML.jpg

图 5-16

缓冲输出使浏览器能够重定向到错误页面

  1. 编辑index.php顶部的代码块,如下所示:

    <?php ob_start();
    try {
        include './includes/title.php';
        include './includes/random_image.php'; ?>
    
    

    ob_start()函数打开输出缓冲,防止任何输出在header()函数被调用之前被发送到浏览器。

  2. PHP 引擎会在脚本结束时自动刷新缓冲区,但最好是显式地这样做。编辑页面底部的 PHP 代码块,如下所示:

    <?php } catch (Exception $e) {
        ob_end_clean();
        header('Location: http://localhost/php8sols/error.php');
    }
    ob_end_flush();
    ?>
    
    

    这里增加了两个不同的功能。当重定向到另一个页面时,您不希望 HTML 存储在缓冲区中。因此,在catch块中,调用ob_end_clean(),关闭缓冲区并丢弃其内容。

    然而,如果没有抛出异常,您希望显示缓冲区的内容,所以在页面的末尾在trycatch块之后调用ob_end_flush()。这将刷新缓冲区的内容,并将其发送到浏览器。

  3. 保存index.php并在浏览器中重新加载。这一次,您应该被重定向到错误页面,如图 5-16 所示,不管您的服务器配置中是否启用了缓冲。

  4. $file的值改回./includes/menu.php并保存index.php。当您点击错误页面上的主页链接时,index.php应正常显示。

    您可以将您的代码与ch05文件夹中的index_07.php进行比较。

为什么我不能使用 PHP 包含的站点根目录相对链接?

你可以也可以不可以。为了清楚起见,我将首先解释相对于文档的链接和相对于站点根的链接之间的区别。

文档相关链接

大多数 web 创作工具都指定了其他文件的路径,例如相对于当前文档的样式表、图像和其他网页。如果目标页面在同一个文件夹中,则只使用文件名。如果它比当前页面高一级,文件名前面会加上../。这就是所谓的文档相对路径或链接。如果你的网站有很多层次的文件夹,这种类型的链接可能很难理解——至少对人来说是这样。

相对于网站根目录的链接

网页中使用的另一种链接总是以正斜杠开头,这是站点根目录的简写。一个站点根目录相对路径的优点是当前页面在站点层次结构中有多深并不重要。开头的斜杠保证浏览器将从站点的顶层开始查看。虽然站点根目录相对链接更容易阅读,但是 PHP 包含命令不能正确解释它们。您必须使用文档相对路径或绝对路径,或者在您的include_path指令中指定includes文件夹(请参阅本章后面的“调整您的 include_path”)。

Note

只有当前导斜杠代表站点根时,PHP include 命令才能定位外部文件。Linux 和 macOS 上的绝对路径也以正斜杠开头。绝对路径是明确的,所以它们没有问题。

通过将超全局变量$_SERVER['DOCUMENT_ROOT']连接到路径的开头,可以将站点根目录相对路径转换为绝对路径,如下所示:

require $_SERVER['DOCUMENT_ROOT'] . '/includes/filename.php';

大多数服务器都支持$_SERVER['DOCUMENT_ROOT'],但是请检查远程服务器上phpinfo()显示的配置细节底部的 PHP 变量部分以确保这一点。

包含文件中的链接

这是容易让很多人困惑的一点。PHP 和浏览器对以正斜杠开头的路径有不同的解释。因此,尽管你不能使用相对于站点根目录的链接来包含一个文件,但是包含文件中的链接通常应该是相对于站点根目录的。这是因为包含文件可以包含在站点层次结构的任何级别,所以当文件包含在不同级别时,文档相关链接会断开。

Note

menu.php中的导航菜单使用相对于文档的链接,而不是相对于站点根目录的链接。它们被故意保留成那样,因为除非你创建了一个虚拟主机,否则站点根目录是localhost,而不是php8sols。这是在 web 服务器文档根目录的子文件夹中测试站点的一个缺点。本书通篇使用的 Japan Journey 站点只有一个级别,因此文档相关的链接是有效的。当开发一个使用多层文件夹的站点时,在包含文件中使用站点根目录相对链接,并考虑设置一个虚拟主机进行测试。

选择包含文件的位置

PHP 包含文件的一个有用特性是它们可以位于任何地方,只要带有包含命令的页面知道在哪里可以找到它们。包含文件甚至不需要在您的 web 服务器根目录中。这意味着您可以保护包含敏感信息(如密码)的 include 文件,这些文件位于无法通过浏览器访问的私有目录(文件夹)中。

Tip

如果你的托管公司在你的服务器根目录之外提供了一个存储区域,你应该认真考虑在那里放置一些(如果不是全部)你的包含文件。

安全注意事项包括

包含文件是 PHP 的一个非常强大的特性。随之而来的是安全风险。只要外部文件是可访问的,PHP 就会包含它并将任何代码合并到主脚本中。从技术上讲,包含文件甚至可以在不同的服务器上。然而,这被认为是一种安全风险,配置指令allow_url_include在默认情况下是禁用的,因此不可能包含来自不同服务器的文件,除非您完全控制您服务器的配置。与include_path不同的是,allow_url_include指令不能被覆盖,除非是服务器管理员。

即使您自己控制两台服务器,也不应该包含来自不同服务器的文件。攻击者有可能伪造地址并试图在您的站点上执行恶意脚本。

永远不要包含可以被公众上传或覆盖的文件。

Note

本章的其余部分相当专业。它们主要作为参考提供。请随意跳过它们。

调整您的包含路径

include 命令需要相对路径或绝对路径。如果两者都没有给出,PHP 会自动查找 PHP 配置中指定的include_path。将包含文件放在 web 服务器的include_path中指定的文件夹中的好处是,您不需要担心获得正确的相对或绝对路径。你只需要文件名。如果你使用了大量的 includes 或者你有一个几层的站点层次结构,这是非常有用的。有三种方法可以改变include_path:

  • 编辑 php.ini中的值:如果你的托管公司允许你访问php.ini,这是添加自定义包含文件夹的最佳方式。

  • 使用 .htaccess.user.ini:如果你的托管公司允许用.htaccess.user.ini文件修改配置,这是一个不错的选择。

  • 使用 set_include_path():仅在前面的选项不可用时使用,因为它只影响当前文件的include_path

当您运行phpinfo()时,web 服务器的include_path值在配置细节的核心部分列出。它通常以句点开始,表示当前文件夹,后跟要搜索的每个文件夹的绝对路径。在 Linux 和 macOS 上,每个路径由冒号分隔。在 Windows 上,分隔符是分号。在 Linux 或 Mac 服务器上,您现有的include_path指令可能如下所示:

.:/php/PEAR

在 Windows 服务器上,对等用法如下所示:

.;C:\php\PEAR

在 php.ini 或. user.ini 中编辑 include_path

php.ini中,找到include_path指令。要在您自己的站点中添加一个名为includes的文件夹,请在现有值的末尾添加一个冒号或分号,后跟includes文件夹的绝对路径。

在 Linux 或 Mac 服务器上,使用如下冒号:

include_path=".:/php/PEAR:/home/mysite/includes"

在 Windows 服务器上,使用分号:

include_path=".;C:\php\PEAR;C:\sites\mysite\includes"

对于.user.ini文件,命令是相同的。. user.ini中的值覆盖了默认值,所以确保从phpinfo()中复制了现有的值,并向其中添加了新的路径。

使用。htaccess 来更改包含路径

在一个.htaccess文件中的值覆盖了缺省值,所以从phpinfo()复制现有的值并添加新的路径。在 Linux 或 Mac 服务器上,该值应该如下所示:

php_value include_path ".:/php/PEAR:/home/mysite/includes"

除了用分号分隔路径之外,该命令在 Windows 上是相同的:

php_value include_path ".;C:\php\PEAR;C:\sites\mysite\includes"

Caution

.htaccess中,不要在include_path和路径名列表之间插入等号。

使用 set_include_path()

虽然set_include_path()只影响当前页面,但是您可以轻松地创建一个代码片段,并将其粘贴到您想要使用它的页面中。PHP 还使得获取现有的include_path并以平台中立的方式将其与新的结合起来变得容易。

将新路径存储在变量中,然后将其与现有值合并,如下所示:

$includes_folder = '/home/mysite/includes';
set_include_path(get_include_path() . PATH_SEPARATOR . $includes_folder);

看起来好像有三个参数被传递给了set_include_path(),但实际上只有一个;这三个元素由串联运算符(句点)连接,而不是逗号:

  • get_include_path()获取已有的include_path

  • PATH_SEPARATOR是一个 PHP 常量,根据操作系统自动插入冒号或分号。

  • $includes_folder添加新路径。

这种方法的问题是,新的includes文件夹的路径在您的远程和本地测试服务器上并不相同。您可以使用条件语句来解决这个问题。超全局变量$_SERVER['HTTP_HOST']包含网站的域名。如果您的域是 www.example.com ,您可以为每个服务器设置正确的路径,如下所示:

if ($_SERVER['HTTP_HOST'] == 'www.example.com') {
    $includes_folder = '/home/example/includes';
} else {
    $includes_folder = 'C:/xampp/htdocs/php8sols/includes';
}
set_include_path(get_include_path() . PATH_SEPARATOR . $includes_folder);

对于不使用很多包含文件的小型网站来说,使用set_include_path()可能不值得。但是,您可能会发现它在更复杂的项目中很有用。

嵌套包含文件

一旦一个文件包含在另一个文件中,相对路径就从父文件开始计算,而不是从包含的文件开始计算。这给需要包含另一个外部文件的外部文件中的函数或类定义带来了问题。

如果两个外部文件在同一个文件夹中,您可以包含一个只有文件名的嵌套文件,如下所示:

require_once 'Thumbnail.php';

在这种情况下,相对路径应该是而不是./开始,因为./意味着“从这个文件夹开始”对于包含文件,“此文件夹”是指父文件的文件夹,而不是包含文件的文件夹,从而导致嵌套文件的路径不正确。

当包含文件在不同的文件夹中时,您可以使用 PHP 常量__DIR__构建目标文件的绝对路径。该常量返回包含文件目录(文件夹)的绝对路径,不带尾随斜杠。连接__DIR__、正斜杠和文档相对路径将相对路径转换为绝对路径。例如,假设这是从一个包含文件到另一个包含文件的相对路径:

'../File/Upload.php'

你把它转换成这样的绝对路径:

__DIR__ . '/../File/Upload.php'

在文档相对路径的开头添加正斜杠的效果是查找包含文件的父文件夹,然后向上一级查找正确的路径。

在第十章中你会看到一个这样的例子,一个包含文件需要包含另一个在不同文件夹中的文件。

第三章回顾

本章让你一头扎进了 PHP 的世界,使用了包含、数组和多维数组。它向您展示了如何提取当前页面的名称,显示随机图像,以及获取图像的尺寸。您还学习了如何抛出和捕获异常,以及如何重定向到不同的页面。有很多东西需要吸收,所以如果第一次没有全部吸收也不用担心。你使用 PHP 越多,你对基本技术就越熟悉。在下一章中,您将学习 PHP 如何处理来自在线表单的输入,并将使用这些知识将来自网站的反馈发送到您的电子邮件收件箱。

六、赋予表单以生命

表单是使用 PHP 的核心。您使用表单登录受限页面、注册新用户、向在线商店下订单、在数据库中输入和更新信息、发送反馈……等等。所有这些用法背后都有相同的原则,所以你从本章学到的知识在大多数 PHP 应用中都有实用价值。为了演示如何处理表单中的信息,我将向您展示如何收集网站访问者的反馈并将其发送到您的邮箱。

不幸的是,用户输入可能会使您的站点遭受恶意攻击。在接受表单之前检查从表单提交的数据是很重要的。尽管 HTML5 表单元素在现代浏览器中认证用户输入,但您仍然需要检查服务器上的数据。HTML5 验证有助于合法用户避免提交有错误的表单,但是恶意用户可以很容易地避开在浏览器中执行的检查。服务器端验证不是可选的,而是必不可少的。本章中的 PHP 解决方案向你展示了如何过滤或阻止任何可疑或危险的东西。没有一个在线应用是完全防黑客的,但是要让除了最坚定的掠夺者之外的所有人远离黑客并不需要太多的努力。如果表单不完整或发现错误,最好保留用户输入并重新显示。

这些解决方案构建了一个完整的邮件处理脚本,可以在不同的表单中重用,因此按顺序阅读它们非常重要。

在本章中,您将了解以下内容:

  • 了解用户输入是如何从在线表单传输的

  • 显示错误而不丢失用户输入

  • 认证用户输入

  • 通过电子邮件发送用户输入

PHP 如何从表单中收集信息

尽管 HTML 包含了构建表单所需的所有标签,但它没有提供任何提交表单时处理表单的方法。为此,你需要一个服务器端的解决方案,比如 PHP。

日本之旅网站包含一份简单的反馈表(见图 6-1 )。其他元素—如单选按钮、复选框和下拉菜单—将在以后添加。

img/332054_5_En_6_Fig1_HTML.jpg

图 6-1

处理反馈表单是 PHP 最常见的用途之一

首先,让我们看看表单的 HTML 代码(它在ch06文件夹的contact_01.php中):

<form method="post" action="">
    <p>
        <label for="name">Name:</label>
        <input name="name" id="name" type="text">
    </p>
    <p>
        <label for="email">Email:</label>
        <input name="email" id="email" type="text">
    </p>
    <p>
        <label for="comments">Comments:</label>
        <textarea name="comments" id="comments"></textarea>
    </p>
    <p>
        <input name="send" type="submit" value="Send message">
    </p>
</form>

前两个<input>标签和<textarea>标签包含设置为相同值的nameid属性。这种重复的原因是可访问性。HTML 使用id属性将<label>元素与正确的<input>元素关联起来。然而,表单处理脚本依赖于name属性。因此,尽管id属性在 Submit 按钮中是可选的,但是对于想要处理的每个表单元素,您必须使用name属性。

Note

表单输入元素的name属性通常不应该包含空格。如果您想要组合多个单词,请用下划线将它们连接起来(如果您留下任何空格,PHP 会自动这样做)。因为本章后面开发的脚本将name属性转换为 PHP 变量,所以不要在 PHP 变量名称中使用连字符或任何其他无效字符。

另外两件需要注意的事情是开始的<form>标签中的methodaction属性。method属性决定了表单如何发送数据。可以设置为postgetaction属性告诉浏览器在单击提交按钮时将数据发送到哪里进行处理。如果该值为空,就像这里一样,页面会尝试自己处理表单。但是,空的 action 属性在 HTML5 中是无效的,因此需要解决这个问题。

Note

我有意避免使用任何新的 HTML5 表单特性,比如type="email"required属性。这使得测试 PHP 服务器端验证脚本变得更加容易。测试后,您可以更新表单以使用 HTML5 验证功能。浏览器中的验证主要是为了避免用户提交不完整的信息,所以它是可选的。永远不要跳过服务器端验证。

理解 post 和 get 之间的区别

演示postget方法之间的区别的最好方法是用一个真实的表单。如果您完成了上一章,您可以继续使用相同的文件。

否则,ch06文件夹包含日本旅程网站的一整套文件,其中包含第五章的所有代码。将contact_01.php复制到站点根目录,并将其重命名为contact.php。同时将ch06/includes文件夹中的footer.phpmenu.phptitle.php复制到站点根目录下的includes文件夹中。

  1. 找到contact.php中的开始<form>标签,将method属性的值从post更改为get,如下所示:

  2. 保存contact.php并在浏览器中加载页面。在表单中键入您的姓名、电子邮件地址和一条短信。然后单击发送消息。

<form method="get" action="">

img/332054_5_En_6_Figa_HTML.jpg

  1. 查看浏览器地址栏。您应该会看到附加在 URL 末尾的表单内容,如下所示:

img/332054_5_En_6_Figb_HTML.jpg

如果你把网址拆开,看起来是这样的:

http://localhost/php8sols/contact.php
?name=David
&email=david%40example.com
&comments=Greetings+%3A-%29
&send=Send+message

表单提交的数据已经作为一个以问号开头的查询字符串附加到基本 URL 上。来自每个字段和提交按钮的值由表单元素的name属性标识,后跟一个等号和提交的数据。来自每个输入元素的数据由一个&符号(&)分隔。URL 不能包含空格或某些字符(如感叹号或笑脸),因此浏览器用+替换空格,并将其他字符编码为十六进制值,这一过程称为 URL 编码(完整的值列表请参见 www.degraeve.com/reference/urlencoding.php )。

  1. 回到contact.php的代码,把method改回post,像这样:

  2. 保存contact.php并在浏览器中重新加载页面,确保从 URL 的末尾清除查询字符串。键入另一条消息,然后单击发送消息。您的消息应该会消失,但不会发生其他事情。它没有丢失,但是你还没有做任何事情来处理它。

  3. contact.php中,在结束</form>标签的正下方添加以下代码:

<form method="post" action="">

<pre>
<?php if ($_POST) { print_r($_POST); } ?>
</pre>

如果已经发送了任何post数据,这将显示$_POST超全局数组的内容。如第四章所述,print_r()函数允许你检查数组的内容;<pre>标签只是让输出更容易阅读。

  1. 保存页面并单击浏览器中的刷新按钮。您可能会看到类似下面的警告。这告诉你数据会被重发,这正是你想要的。确认您要再次发送信息。

img/332054_5_En_6_Figc_HTML.jpg

img/332054_5_En_6_Fig2_HTML.jpg

图 6-2

$_ POST 数组使用表单的名称属性来标识每个数据元素

  1. 步骤 6 中的代码现在应该在表单下方显示您的消息内容,如图 6-2 所示。一切都存储在 PHP 的超级全局数组之一$_POST中,其中包含使用post方法发送的数据。每个表单元素的name属性被用作数组键,这使得检索内容变得很容易。

正如您刚才看到的,get方法发送附加到 URL 上的数据,而post方法发送带有 HTTP 头的数据,因此它是隐藏的。一些浏览器将 URL 的最大长度限制在 2000 个字符左右,因此get方法只能用于少量数据。post方法可用于更大量的数据。默认情况下,PHP 允许高达 8 MB 的post数据,尽管托管公司可能会设置不同的限制。

然而,这两种方法之间最重要的区别是它们的预期用途。get方法被设计用于无论请求多少次都不会导致服务器发生变化的请求。因此,它主要用于数据库搜索;给你的搜索结果做书签很有用,因为所有的搜索标准都在 URL 中。另一方面,post方法是为导致服务器发生变化的请求而设计的。所以它被用来插入、更新或删除数据库中的记录,上传文件或发送电子邮件。

我们将在本书的后面回到get方法。这一章集中在post方法和它相关的超级全局数组$_POST

用 PHP 超级全局变量获取表单数据

$_POST超全局数组包含使用post方法发送的数据。毫不奇怪,get方法发送的数据在$_GET数组中。

要访问表单提交的值,只需将表单元素的name属性放在$_POST$_GET后面的方括号中,这取决于表单的method属性。因此,如果通过post方法发送,则email变为$_POST['email'],如果通过get方法发送,则变为$_GET['email']。么事儿啦在那里。

您可能会遇到使用$_REQUEST的脚本,这避免了区分$_POST$_GET的需要。不太安全。你应该总是知道用户信息来自哪里。$_REQUEST还包括 cookie 的值,所以您不知道您处理的是 post 方法提交的值,还是通过 URL 传输或由 cookie 注入的值。始终使用$_POST$_GET

你可能会遇到使用$HTTP_POST_VARS$HTTP_GET_VARS的旧脚本,它们与$_POST$_GET的意思相同。这些都是过时的,已经从 PHP 8 中删除了。

处理和认证用户输入

本章的最终目的是通过电子邮件将contact.php表格中的输入发送到您的收件箱。使用 PHP mail()函数相对简单。它至少需要三个参数:电子邮件要发送到的地址、包含主题行的字符串和包含邮件正文的字符串。通过将输入字段的内容连接成一个字符串来构建消息体。

大多数互联网服务提供商(ISP)实施的安全措施使得在本地测试环境中测试mail()功能变得非常困难。PHP 解决方案 6-2 到 6-5 没有直接使用mail(),而是专注于认证用户输入以确保必填字段被填写并显示错误消息。实施这些措施使您的在线表单更加用户友好和安全。

使用 JavaScript 或 HTML5 表单元素和属性来检查用户输入被称为客户端验证,因为它发生在用户的计算机(或客户端)上。它很有用,因为它几乎是即时的,可以提醒用户有问题,而无需与服务器进行不必要的往返。然而,客户端验证很容易回避。恶意用户只需从自定义脚本提交数据,您的检查就会变得毫无用处。用 PHP 检查用户输入也很重要。

Tip

客户端验证本身是不够的。总是使用 PHP 的服务器端验证来验证来自外部数据源的数据。

创建可重用的脚本

为多个网站重用同一个脚本(可能只需少量编辑)可以节省大量时间。然而,将输入数据发送到一个单独的文件进行处理,很难在不丢失用户输入的情况下提醒用户错误。为了解决这个问题,本章采用的方法是使用所谓的自处理表单

提交表单时,页面会重新加载,条件语句会运行处理脚本。如果服务器端验证检测到错误,表单可以重新显示错误消息,同时保留用户的输入。特定于表单的脚本部分将嵌入到 DOCTYPE 声明之上。通用的、可重用的部分将位于一个单独的文件中,该文件可以包含在任何需要电子邮件处理脚本的页面中。

PHP 解决方案 6-1:防止自处理表单中的跨站脚本

当提交数据时,将开始表单标签的action属性留空或完全忽略它会重新加载表单。但是,空的action属性在 HTML5 中是无效的。PHP 有一个非常方便的超级全局变量($_SERVER['PHP_SELF']),它包含当前文件的站点根目录相对路径。将它设置为action属性的值会自动为自处理表单插入正确的值——但是单独使用它会将您的站点暴露给被称为跨站脚本 (XSS)的恶意攻击。这个 PHP 解决方案解释了风险,并展示了如何安全地使用$_SERVER['PHP_SELF']

img/332054_5_En_6_Fig3_HTML.jpg

图 6-3

畸形链接嵌入了 XSS 攻击

  1. ch06文件夹中的bad_link.php加载到浏览器中。它在同一个文件夹中包含一个到form.php的链接;但是底层 HTML 中的链接已经被故意弄成畸形,以模拟 XSS 攻击。

    注意,这个 PHP 解决方案的练习文件中的链接假设它们位于本地主机服务器根目录下的一个名为php8sols/ch06的文件夹中。如有必要,调整它们以符合您的测试设置。

  2. 单击该链接。在大多数浏览器中,您应该会看到如图 6-3 所示的 JavaScript 警告对话框。

Note

谷歌 Chrome 和微软 Edge 曾用 XSS 过滤器阻止可疑攻击。但是,它已经被删除,取而代之的是使用内容安全策略(CSP)。有关 CSP 的详细信息,请参见 https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

  1. 消除 JavaScript 警告,并右键单击以查看页面源代码。第 10 行应该类似如下:

img/332054_5_En_6_Figd_HTML.jpg

bad_link.php中的错误链接在开始的<form>标签后立即向页面中注入了一段 JavaScript 代码。在这种情况下,这是一个无害的 JavaScript 警报;但在真正的 XSS 攻击中,它可能会试图窃取 cookies 或其他个人信息。这种攻击是无声的,让用户不知道发生了什么,除非他们注意到浏览器地址栏中的脚本。

这是因为form.php使用$_SERVER['PHP_SELF']来生成action属性的值。畸形链接在action属性中插入页面位置,关闭开始表单标签,然后注入<script>标签,该标签在页面加载时立即执行。

  1. 抵消这种类型的 XSS 攻击的一个简单方法是像这样将$_SERVER['PHP_SELF']传递给htmlentities()函数:
<form method="post"  action="<?= htmlentities($_SERVER['PHP_SELF']) ?>">

这将把<script>标签的尖括号转换成它们的 HTML 实体等价物,防止脚本被执行。虽然它可以工作,但它会在浏览器地址栏中留下格式错误的 URL,这可能会导致用户质疑您的站点的安全性。我认为更好的解决方案是在检测到 XSS 病毒时将用户重定向到错误页面。

  1. form.php中,在 DOCTYPE 声明上方创建一个 PHP 块,并使用当前文件的站点根目录相对路径定义一个变量,如下所示:

  2. 现在比较一下$currentPage$_SERVER['PHP_SELF']的值。如果它们不相同,使用header()函数将用户重定向到一个错误页面并立即退出脚本,如下所示:

<?php
$currentPage = '/php8sols/ch06/form.php';
?>
<!doctype html>

if ($currentPage !== $_SERVER['PHP_SELF']) {
    header('Location: http://localhost/php8sols/ch06/missing.php');
    exit;
}

Caution

传递给header()函数的位置必须是完全合格的 URL。如果使用与文档相关的链接,目标将附加到格式错误的链接上,从而阻止页面被成功重定向。

  1. 使用$currentPage作为开始表单标签中action属性的值:

  2. 保存form.php,返回bad_link.php,再次点击链接。这次你应该被直接带到missing.php

  3. 直接在浏览器中加载form.php。它应该像预期的那样加载和工作。

<form method="post"  action="<?= $currentPage ?>">

完成的版本在ch06文件夹的form_end.php中。如果你只是想测试脚本,名为bad_link_end.php的文件链接到完成的版本。

这种技术比简单地将$_SERVER['PHP_SELF']传递给htmlentities()函数涉及更多的代码;但是它有一个优点,如果用户通过一个恶意链接访问您的表单,它可以将用户无缝地引导到一个错误页面。显然,错误页面应该链接回主菜单。

PHP 解决方案 6-2:确保必填字段不为空

当必填字段为空时,您将无法获得所需的信息,并且用户可能永远得不到回复,尤其是当联系方式被省略时。

继续使用本章前面的“理解 post 和 get 之间的区别”中的文件。或者,使用ch06文件夹中的contact_02.php,并从文件名中删除_02

  1. 处理脚本使用两个名为$errors$missing的数组来存储错误的详细信息和尚未填写的必填字段。这些数组将用于控制表单标签旁边的错误消息的显示。页面第一次加载时不会有任何错误,所以在contact.php顶部的 PHP 代码块中将$errors$missing初始化为空数组,就像这样:

  2. 只有在表单提交后,电子邮件处理脚本才会运行。使用条件语句检查超全局变量$_SERVER['REQUEST_METHOD']的值。如果是 POST(全部大写),您知道表单已经使用post方法提交了。将粗体突出显示的代码添加到页面顶部的 PHP 块中。

<?php
include './includes/title.php';
$errors = [];
$missing = [];
?>

<?php
include './includes/title.php';
$errors = [];
$missing = [];
// check if the form has been submitted
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    // email processing script
}
?>

Tip

检查$_SERVER['REQUEST_METHOD']的值是否为 POST 是一个通用条件,可以用于任何表单,不管 Submit 按钮的名称是什么。

  1. 虽然您现在还不会发送电子邮件,但是可以定义两个变量来存储电子邮件的目的地址和主题行。以下代码位于您在上一步中创建的条件语句中:

  2. 接下来,创建两个数组:一个列出表单中每个字段的name属性,另一个列出所有必需的字段。为了便于演示,将email字段设为可选,这样就只需要namecomments字段。将以下代码添加到条件块中,紧接在定义主题行的代码之后:

if ( $_SERVER['REQUEST_METHOD'] == 'POST') {
    // email processing script
    $to = 'david@example.com'; // use your own email address
    $subject = 'Feedback from Japan Journey';
}

    $subject = 'Feedback from Japan Journey';
    // list expected fields
    $expected = ['name', 'email', 'comments'];
    // set required fields
    $required = ['name', 'comments'];
}

Tip

$expected数组是为了防止攻击者将其他变量注入到$_POST数组中,试图覆盖您的默认值。通过只处理那些您期望的变量,您的表单更加安全。任何虚假值都将被忽略。

  1. 下一部分代码不是特定于这个表单的,所以它应该放在一个外部文件中,可以包含在任何电子邮件处理脚本中。在includes文件夹中创建一个名为processmail.php的新 PHP 文件。然后将它包含在contact.php中,紧跟在您在上一步中输入的代码之后,就像这样:

  2. processmail.php中的代码首先检查$_POST变量中已经留空的必填字段。去掉由你的编辑器插入的任何默认代码,并将下面的代码添加到processmail.php:

    $required = ['name', 'comments'];
    require './includes/processmail.php';
}

<?php
foreach ($_POST as $key => $value) {
    // strip whitespace from $value if not an array
    if (!is_array($value)) {
        $value = trim($value);
    }
    if (!in_array($key, $expected)) {
        // ignore the value, it's not in $expected
        continue;
    }
    if (in_array($key, $required) && empty($value)) {
        // required value is missing
        $missing[] = $key;
        $$key = "";
        continue;
    }
    $$key = $value;
}

这个foreach循环处理$_POST数组,从文本字段中去除前导和尾随空格,并将字段内容赋给一个具有简化名称的变量。结果,$_POST['email']变成了$email等等。它还检查必填字段是否为空,并将它们添加到$missing数组中,将相关变量设置为空字符串。

$_POST数组是一个关联数组,所以循环将当前元素的键和值分别分配给$key$value。通过使用带有逻辑 Not 运算符(!)的is_array()函数,循环开始检查当前值是否不是一个数组。如果不是这样,trim()函数将去掉前导和尾随的空白,并将其重新分配给$value。删除开头和结尾的空白可以防止任何人多次按空格键来避免填写必填字段。

Note

该表单目前只有文本输入字段,但以后会扩展到包含以数组形式提交数据的<select>和复选框元素。有必要检查当前元素的值是否是一个数组,因为将数组传递给trim()函数会触发错误。

下一个条件语句检查当前键是否不在$expected数组中。如果不是,关键字continue会强制循环停止处理当前元素,转到下一个元素。所以任何不在$expected数组中的东西都会被忽略。

接下来,我们检查当前数组键是否在$required数组中,以及它是否没有值。如果条件返回 true,那么这个键被添加到$missing数组中,并且基于这个键的名称的一个变量被动态创建,它的值被设置为一个空字符串。注意$$key在下面一行中以两个美元符号开始:

$$key = "";

这意味着它是一个可变变量(参见第四章中的“动态创建新变量”)。所以,如果$key的值是“姓名”,$$key就变成了$name

再次,continue将循环移动到下一个元素。

但是,如果我们一直到循环的最后一行,我们知道我们正在处理一个需要处理的元素,所以基于键名的变量是动态创建的,当前值被赋给它。

  1. 保存processmail.php。稍后您将向它添加更多的代码,但是现在让我们转向contact.php的主体。开始表单标记中的 action 属性为空。为了进行本地测试,只需将其值设置为当前页面的名称:

  2. 如果缺少任何东西,您需要显示一个警告。在页面内容顶部的<h2>标题和第一段之间添加一个条件语句,如下所示:

<form method="post" action="contact.php">

<h2>Contact us</h2>
<?php if ($missing || $errors) { ?>
<p class="warning">Please fix the item(s) indicated.</p>
<?php } ?>
<p>Ut enim ad minim veniam . . . </p>

这将检查在步骤 1 中初始化为空数组的$missing$errors。正如在第四章的“PHP 的真相”中所解释的,空数组被视为false,所以当页面第一次加载时,条件语句中的段落不会显示。但是,如果提交表单时某个必填字段还没有填写,它的名称将被添加到$missing数组中。至少包含一个元素的数组被视为true||表示“或”,因此如果必填字段留空或发现错误,将显示该警告段落。($errors数组在 PHP 解决方案 6-4 中发挥作用。)

  1. 为了确保到目前为止还能工作,保存contact.php并在浏览器中正常加载(不要点击刷新按钮)。不会显示警告消息。单击发送消息,不填写任何字段。您现在应该会看到关于缺少项目的消息,如下面的屏幕截图所示:

img/332054_5_En_6_Fige_HTML.jpg

  1. 要在每个缺少的必填字段旁边显示合适的消息,使用 PHP 条件语句在<label>标记中插入一个<span>,如下所示:
<label for="name">Name:
<?php if (in_array('name', $missing)) { ?>
    <span class="warning">Please enter your name</span>
<?php } ?>
</label>

该条件使用in_array()函数来检查$missing数组是否包含值name。如果是,则显示<span>$missing在脚本顶部被定义为一个空数组,所以当页面第一次加载时,跨度不会显示。

  1. emailcomments字段插入类似的警告,如下所示:
    <label for="email">Email:
    <?php if (in_array('email', $missing)) { ?>
        <span class="warning">Please enter your email address</span>
    <?php } ?>
    </label>
    <input name="email" id="email" type="text">
</p>
<p>
    <label for="comments">Comments:
    <?php if (in_array('comments', $missing)) { ?>
        <span class="warning">Please enter your comments</span>
    <?php } ?>
    </label>

PHP 代码是相同的,除了您在$missing数组中寻找的值。它与表单元素的name属性相同。

img/332054_5_En_6_Fig4_HTML.jpg

图 6-4

通过认证用户输入,您可以显示关于必填字段的警告

  1. 保存contact.php并再次测试页面,首先在任何字段中不输入任何内容。表单标签应该如图 6-4 所示。

虽然您为email字段向<label>添加了一个警告,但是它没有显示出来,因为email还没有被添加到$required数组中。因此,它不会被processmail.php中的代码添加到$missing数组中。

  1. email添加到contact.php顶部代码块中的$required数组中,如下所示:

  2. 再次单击发送消息,不填写任何字段。这一次,您将在每个标签旁边看到一条警告消息。

  3. 在“名称”栏中键入您的姓名。在电子邮件和评论字段中,只需按几次空格键,然后单击发送消息。名称字段旁边的警告消息会消失,但其他两条警告消息会保留。processmail.php中的代码去除了文本字段中的空白,因此它拒绝通过输入一系列空格来绕过必填字段的尝试。

$required = ['name', 'comments', 'email'];

如果你有任何问题,将你的代码与ch06文件夹中的contact_03.phpincludes/processmail_01.php进行比较。

要更改必填字段,只需更改$required数组中的名称,并在表单内适当输入元素的<label>标记中添加一个合适的警告。这很容易做到,因为您总是使用表单输入元素的name属性。

当表单不完整时保留用户输入

假设你花了 10 分钟填写一张表格。单击 Submit 按钮,返回的响应是缺少一个必填字段。如果你不得不重新填写每一个字段,那就太令人恼火了。因为每个字段的内容都在$_POST数组中,所以当出现错误时很容易重新显示它。

PHP 解决方案 6-3:创建粘性表单域

这个 PHP 解决方案展示了如何使用条件语句从$_POST数组中提取用户的输入,并在文本输入字段和文本区域中重新显示。

像以前一样继续处理相同的文件。或者,使用ch06文件夹中的contact_03.phpincludes/processmail_01.php

  1. 当页面第一次加载时,您不希望任何内容出现在输入字段中,但是如果一个必填字段丢失或出现错误,您确实希望重新显示内容。这就是关键:如果$missing$errors数组包含任何值,那么每个字段的内容都应该重新显示。您使用<input>标签的value属性为文本输入字段设置了默认文本,因此将name<input>标签修改如下:

    <input name="name" id="name" type="text"
    <?php if ($missing || $errors) {
        echo 'value="' . htmlentities($name) . '"';
    } ?>>
    
    

    花括号内的行包含引号和句点的组合,这可能会让您感到困惑。首先要意识到的是只有一个分号——就在末尾——所以echo命令适用于整行。正如在第三章中所解释的,一个句点被称为连接操作符,它连接字符串和变量。您可以将该行的其余部分分为三个部分,如下所示:

    • 'value="' .

    • htmlentities($name)

    • . '"'

第一部分将value="输出为文本,并使用连接操作符将其连接到下一部分,下一部分将$name传递给一个名为htmlentities()的函数。我稍后将解释为什么这是必要的,但是第三部分再次使用连接操作符来连接最终输出,它只包含一个双引号。因此,如果$missing$errors包含任何值,而$_POST['name']包含Joe,那么在<input>标记中就会出现这个:

<input name="name" id="name" type="text" value="Joe">

$name变量包含通过$_POST数组传输的原始用户输入。你在 PHP 解决方案 6-2 的processmail.php中创建的foreach循环处理$_POST数组并将每个元素赋给一个同名的变量。这允许您简单地以$name的形式访问$_POST['name']

那么我们为什么需要htmlentities()函数呢?正如函数名所示,它将某些字符转换成等价的 HTML 字符实体。你现在关心的是双引号。假设埃里克·克拉普顿决定通过表单发送反馈。如果您单独使用$name,图 6-5 显示了当一个必填字段被省略并且您没有使用htmlentities()时会发生什么。

img/332054_5_En_6_Fig5_HTML.jpg

图 6-5

在重新显示表单域之前,需要对引号进行特殊处理

然而,将$_POST数组元素的内容传递给htmlentities(),会将字符串中间的双引号转换为&quot;。并且,如图 6-6 所示,内容不再被截断。

img/332054_5_En_6_Fig6_HTML.jpg

图 6-6

在显示之前将值传递给 htmlentities()解决了这个问题

这个比较酷的是人物实体&quot;重新提交表单时,会转换回双引号。因此,在发送电子邮件之前,无需进一步转换。

Note

如果htmlentities()破坏了你的文本,你可以在 PHP 8 中使用命名参数直接设置编码(参见第四章中的“使用命名参数”)。例如,要将编码设置为简体中文,请使用htmlentities($name, encoding: 'GB2312')

  1. 用同样的方式编辑email字段,用$email代替$name

  2. 因为<textarea>标签没有value属性,所以对comments文本区域的处理需要稍有不同。您必须将 PHP 块放在文本区域的开始和结束标记之间,就像这样(新代码以粗体显示):

<textarea name="comments" id="comments"><?php
  if ($missing || $errors) {
      echo htmlentities($comments);
  } ?></textarea>

将 PHP 的开始和结束标记放在正对着<textarea>标记的位置是很重要的。如果不这样做,就会在文本区域中出现不需要的空白。

  1. 保存contact.php并在浏览器中测试页面。如果省略了任何必填字段,表单将显示原始内容以及任何错误消息。

你可以用ch06文件夹中的contact_04.php来检查你的代码。

Caution

使用这种技术可以防止表单的重置按钮重置 PHP 脚本更改过的任何字段,因为它显式地设置了每个字段的 value 属性。

过滤掉潜在的攻击

一个被称为邮件头注入的特别令人讨厌的漏洞试图将在线表单转化为垃圾邮件中继。攻击者试图欺骗您的脚本,将带有副本的 HTML 电子邮件发送给许多人。如果您将未过滤的用户输入合并到可以作为第四个参数传递给mail()函数的附加头中,这是可能的。添加用户的电子邮件地址作为Reply-To头是很常见的。如果您在提交的值中检测到空格、换行符、回车符或任何字符串“Content-Type:"、“Cc:”或“Bcc:”时,您就是攻击的目标,因此您应该阻止该消息。

PHP 解决方案 6-4:阻止包含可疑内容的电子邮件地址

这个 PHP 解决方案检查用户的电子邮件地址输入是否有可疑内容。如果检测到,布尔变量被设置为true。这将在以后被用来阻止电子邮件被发送。

继续使用与以前相同的页面。或者,使用ch06文件夹中的contact_04.phpincludes/processmail_01.php

  1. 为了检测可疑短语,我们将使用搜索模式或正则表达式。在现有的foreach循环之前的processmail.php顶部添加以下代码:
// pattern to locate suspect phrases
$pattern = '/[\s\r\n]|Content-Type:|Bcc:|Cc:/i';
foreach ($_POST as $key => $value) {

分配给$pattern的字符串将用于执行不区分大小写的搜索:空格、回车、换行符、" Content-Type:"、" Bcc:"或" cc:"。它是以一种叫做 Perl 兼容正则表达式(PCRE)的格式编写的。搜索模式包含在一对正斜杠中,最后一个斜杠后的i使模式不区分大小写。

Tip

正则表达式是匹配文本模式的非常强大的工具。诚然,它们不容易学;但是如果你真的想使用 PHP 和 JavaScript 之类的编程语言,这是一项必不可少的技能。看看 rg Krause 的介绍正则表达式(a press,2017,ISBN 978-1-4842-2508-0)。它主要面向 JavaScript 开发人员,但是 JavaScript 和 PHP 在实现上只有很小的区别。基本语法是相同的。

  1. 您现在可以使用存储在$pattern中的 PCRE 来检测提交的电子邮件地址中任何可疑的用户输入。在步骤 1 中的$pattern变量后立即添加以下代码:
// check the submitted email address
$suspect = preg_match($pattern,  $_POST['email']);

preg_match()函数将作为第一个参数传递的正则表达式与第二个参数中的值进行比较,在本例中是来自 Email 字段的值。如果找到匹配,它将返回true。所以,如果任何可疑的内容被发现,$suspect将是真实的。但是如果没有匹配的话,那就是false

  1. 如果在电子邮件地址中检测到可疑内容,那么进一步处理$_POST数组就没有意义了。将处理$_POST变量的代码包装在一个条件语句中,如下所示:
if (!$suspect) {
    foreach ($_POST as $key => $value) {
        // strip whitespace from $value if not an array
        if (!is_array($value)) {
           $value = trim($value);
        }
        if (!in_array($key, $expected)) {
            // ignore the value, it's not in $expected
            continue;
        }
        if (in_array($key, $required) && empty($value)) {
            // required value is missing
            $missing[] = $key;
            $$key = "";
            continue;
        }
    $$key = $value;
    }
}

只有当$suspect不是true时,才会处理$_POST数组中的变量。

不要忘记用额外的花括号来结束条件语句。

  1. 编辑contact.php<h2>标题后的 PHP 块,在表单上方添加一条新的警告消息,如下所示:
<h2>Contact Us</h2>
<?php if ($_POST && $suspect) { ?>
    <p class="warning">Sorry, your mail could not be sent.
    Please try later.</p>
<?php } elseif ($missing || $errors) { ?>
  <p class="warning">Please fix the item(s) indicated.</p>
<?php } ?>

这设置了一个新的条件,该条件通过被首先考虑而优先于原始警告消息。它检查$_POST数组是否包含任何元素——换句话说,表单已经提交——以及$suspect是否为true。这一警告的语气刻意保持中立。激怒攻击者毫无意义。

  1. 保存contact.php并通过在电子邮件字段中键入任何可疑内容来测试表单。您应该会看到新的警告消息,但是您的输入不会被保存。

你可以对照ch06文件夹中的contact_05.phpincludes/processmail_02.php来检查你的代码。

发送电子邮件

在继续之前,有必要解释一下 PHP mail()函数是如何工作的,因为它将帮助您理解处理脚本的其余部分。

PHP mail()函数最多接受五个参数,都是字符串,如下所示:

  • 收件人的地址

  • 主题行

  • 邮件正文

  • 其他电子邮件标题列表(可选)

  • 附加参数(可选)

第一个参数中的电子邮件地址可以是下列格式之一:

'user@example.com'
'Some Guy <user2@example.com>'

要发送到多个地址,请使用逗号分隔的字符串,如下所示:

'user@example.com, another@example.com, Some Guy <user2@example.com>'

消息正文必须显示为单个字符串。这意味着您需要从$_POST数组中提取输入数据并格式化消息,添加标签来标识每个字段。默认情况下,mail()函数只支持纯文本。新行必须同时使用回车和换行符。还建议将行的长度限制在 78 个字符以内。虽然听起来很复杂,但是你可以用大约 20 行 PHP 代码自动构建消息体,正如你将在 PHP 解决方案 6-6 中看到的。添加其他电子邮件标题将在下一节详细介绍。

许多托管公司现在把第五个论点作为一个要求。它确保电子邮件是由可信用户发送的,通常由您自己的电子邮件地址加上前缀-f(中间没有空格)组成,全部用引号括起来。检查你的托管公司的说明,看看这是否是必需的,以及它应该采取的确切格式。

Caution

永远不要将用户输入合并到mail()函数的第五个参数中,因为它可以用来在 web 服务器上执行任意脚本。

安全使用附加电子邮件标题

你可以在 www.faqs.org/rfcs/rfc2076 找到电子邮件标题的完整列表,但是一些最著名和最有用的标题可以让你发送电子邮件的副本到其他地址(抄送和密件抄送)或者改变编码。除了最后一个标题,每个新标题都必须在一个单独的行上,以回车和换行符结束。在 PHP 的旧版本中,这意味着在双引号字符串中使用\r\n转义序列(参见第四章中的表 4-5 )。

Tip

从 PHP 7.2 开始,附加头的格式化是自动处理的。简单地定义一个关联数组,使用标题名作为每个元素的键,然后将该数组作为第四个参数传递给mail()函数。

默认情况下,mail()使用 Latin1 (ISO-8859-1)编码,它不支持重音字符。如今,网页编辑经常使用 Unicode (UTF-8 ),它支持大多数书面语言,包括欧洲语言中常用的重音符号,以及非字母文字,如中文和日文。为了确保电子邮件不会乱码,使用Content-Type头将编码设置为 UTF-8,如下所示:

$headers['Content-Type'] = 'text/plain; charset=utf-8';

您还需要将 UTF-8 作为charset属性添加到 web 页面的<head>中的<meta>标签中,如下所示:

<meta charset="utf-8">

假设您想将副本发送到其他部门,再将副本发送到另一个您不想让其他人看到的地址。由mail()发送的电子邮件通常被识别为来自nobody@yourdomain(或者分配给网络服务器的任何用户名),所以添加一个更用户友好的“发件人”地址是一个好主意。这就是你如何建立那些额外的头:

$headers['From'] = 'Japan Journey<feedback@example.com>';
$headers['Cc'] = 'sales@example.com, finance@example.com';
$headers['Bcc'] = 'secretplanning@example.com';

在定义了您想要使用的标题数组之后,您将该数组传递给mail(),就像这样(假设目的地址、主题和消息体已经存储在变量中):

$mailSent = mail($to, $subject, $message, $headers);

像这样硬编码的附加头不会带来安全风险,但是来自用户输入的任何内容在使用之前都必须经过过滤。最大的危险来自一个要求输入用户电子邮件地址的文本字段。一种广泛使用的技术是将用户的电子邮件地址合并到一个FromReply-To标题中,这使得你可以通过点击你的电子邮件程序中的回复按钮来直接回复收到的消息。这非常方便,但是攻击者经常试图在电子邮件输入字段中装入大量伪造的标题。以前的 PHP 解决方案消除了攻击者最常用的头,但是我们需要在将电子邮件地址合并到附加头之前进一步检查它。

Caution

尽管电子邮件字段是攻击者的主要目标,但是如果您允许用户更改值,目标地址和主题行都很容易受到攻击。用户输入应该总是被认为是可疑的。始终对目的地址和主题行进行硬编码。或者,提供一个可接受值的下拉菜单,并根据相同值的数组检查提交的值。

PHP 解决方案 6-5:添加标题和自动回复地址

这个 PHP 解决方案为电子邮件添加了三个标题:FromContent-Type(将编码设置为 UTF-8)和Reply-To。在将用户的电子邮件地址添加到最终的头之前,它使用一个内置的 PHP 过滤器来验证提交的值是否符合有效电子邮件地址的格式。

继续使用与以前相同的页面。或者,使用ch06文件夹中的contact_05.phpincludes/processmail_02.php

  1. 标题通常特定于特定的网站或页面,所以FromContent-Type标题将被添加到contact.php的脚本中。将以下代码添加到页面顶部的 PHP 块中,刚好在包含processmail.php之前:

  2. 验证电子邮件地址的目的是确保其格式有效,但是该字段可能为空,因为您决定不要求它,或者因为用户简单地忽略了它。如果该字段是必填的但为空,它将被添加到$missing数组中,并显示您在 PHP 解决方案 6-2 中添加的警告。如果字段不为空,但是输入无效,则需要显示不同的消息。

$required = ['name', 'comments', 'email'];
// create additional headers
$headers['From'] = 'Japan Journey<feedback@example.com>';
$headers['Content-Type'] = 'text/plain; charset=utf-8';
require './includes/processmail.php';

切换到processmail.php并将这段代码添加到脚本的底部:

// validate the user's email
if (!$suspect && !empty($email)) {
    $validemail = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
    if ($validemail) {
        $headers['Reply-To'] = $validemail;
    } else {
        $errors['email'] = true;
    }
}

首先检查是否没有发现可疑内容,并且email字段不为空。这两个条件前面都有逻辑非运算符,所以如果$suspectempty($email)都是false,它们就返回true。您在 PHP 解决方案 6-2 中添加的foreach循环将$_POST数组中所有期望的元素分配给更简单的变量,因此$email包含与$_POST['email']相同的值。

下一行使用filter_input()来验证电子邮件地址。第一个参数是 PHP 常量INPUT_POST,它指定值必须在$_POST数组中。第二个参数是您想要测试的元素的名称。最后一个参数是另一个 PHP 常量,它指定您要检查元素是否符合电子邮件的有效格式。

如果有效的话,filter_input()函数返回被测试的值。否则返回false。因此,如果用户提交的值看起来像一个有效的电子邮件地址,$validemail包含该地址。如果格式无效,$validemailfalseFILTER_VALIDATE_EMAIL常量只接受单个电子邮件地址,因此任何插入多个电子邮件地址的尝试都将被拒绝。

Note

FILTER_VALIDATE_EMAIL检查格式,而不是地址是否真实。

如果$validemail不是false,合并到Reply-To邮件头中是安全的。但是如果$validemailfalse,那么$errors['email']被添加到$errors数组中。

  1. 您现在需要修改contact.phpemail字段的<label>,如下所示:
<label for="email">Email:
<?php if (in_array('email', $missing)) { ?>
    <span class="warning">Please enter your email address</span>
<?php } elseif (isset($errors['email'])) { ?>
    <span class="warning">Invalid email address</span>
<?php } ?>
</label>

这将在第一个条件语句中添加一个elseif子句,如果电子邮件地址验证失败,将显示不同的警告。

  1. 保存contact.php并通过将所有字段留空并点击发送消息来测试表单。您将看到最初的错误消息。通过在“电子邮件”栏中输入非电子邮件地址的值或输入两个电子邮件地址来再次测试。您应该会看到无效消息。

    注意:如果在测试更新的脚本时,在多个电子邮件地址之间的逗号后面加一个空格,您将不会看到新的错误消息,因为 PHP 解决方案 6–4 中的正则表达式拒绝包含空格的电子邮件字段中的值。无论哪种方式,企图攻击被挫败。

你可以用ch06文件夹中的contact_06.phpincludes/processmail_03.php来检查你的代码。

PHP 解决方案 6-6:构建消息体并发送邮件

许多 PHP 教程展示了如何像这样手动构建消息体:

$message = "Name: $name\r\n\r\n";
$message .= "Email: $email\r\n\r\n";
$message .= "Comments: $comments";

这将添加标签来标识输入来自哪个字段,并在每个标签之间插入两个回车符和换行符。这对于少量的字段来说很好,但是对于更多的字段来说很快就变得乏味了。只要给表单字段赋予有意义的name属性,就可以用一个foreach循环自动构建消息体,这是这个 PHP 解决方案中采用的方法。

像以前一样继续处理相同的文件。或者,使用ch06文件夹中的contact_06.phpincludes/processmail_03.php

  1. processmail.php中的脚本底部添加以下代码:
$mailSent = false;

这将初始化一个变量,以便在邮件发送后重定向到感谢页面。它需要被设置为false,直到你知道mail()功能已经成功。

  1. 现在添加构建消息的代码,紧跟在:
// go ahead only if not suspect, all required fields OK, and no errors
if (!$suspect && !$missing && !$errors) {
    // initialize the $message variable
    $message = '';
    // loop through the $expected array
    foreach($expected as $item) {
        // assign the value of the current item to $val
        if (isset($$item) && !empty($$item)) {
            $val = $$item;
        } else {
            // if it has no value, assign 'Not selected'
            $val = 'Not selected';
        }
        // if an array, expand as comma-separated string
        if (is_array($val)) {
            $val = implode(', ', $val);
        }
        // replace underscores in the label with spaces
        $item = str_replace('_', ' ', $item);
        // add label and value to the message body
        $message .= ucfirst($item).": $val\r\n\r\n";
    }
    // limit line length to 70 characters
    $message = wordwrap($message, 70);
    $mailSent = true;
}

这段代码首先检查$suspect$missing$errors都是false。如果是,它通过遍历$expected数组构建消息体,将结果作为一系列标签/值对存储在$message中。

其工作原理的关键在于下面的条件语句:

if (isset($$item) && !empty($$item)) {
    $val = $$item;
}

这是使用可变变量的另一个例子(参见第四章中的“动态创建新变量”)。每次循环运行时,$item包含$expected数组中当前元素的值。第一个元素是name,所以$$item动态创建一个名为$name的变量。实际上,条件语句变成了这样:

if (isset($name) && !empty($name)) {
    $val = $name;
}

在下一次传递中,$$item创建一个名为$email的变量,依此类推。PHP 解决方案 6–2 将每个表单字段中提交的数据分配给一个简单的变量,因此这个条件语句将当前字段中的数据分配给一个临时变量$val

Caution

该脚本仅从$expected数组中的项目构建消息正文。您必须在$expected数组中列出所有表单字段的名称,它才能工作。

如果未指定为必填的字段留空,其值将设置为“未选择”该代码还处理来自多项选择元素的值,例如复选框组和<select>列表,它们作为$_POST数组的子数组传输。implode()函数将子数组转换成逗号分隔的字符串。第一个参数是要在每个数组元素之间插入的字符串。第二个参数是要处理的数组。

每个标签都是从$expected数组的当前元素中输入字段的name属性派生而来的。str_replace()的第一个参数是下划线。如果在name属性中发现了一个下划线,它将被第二个参数替换,第二个参数是一个由单个空格组成的字符串。然后第一个字母被ucfirst()设为大写。注意str_replace()的第三个参数是$item(带单美元符号),所以这次是普通变量,不是变量变量。它包含来自$expected数组的当前值。来自当前元素的数据后跟两个回车符和换行符,然后连接到标签。

将标签和字段数据组合成一个字符串后,wordwrap()函数将行长度限制为 70 个字符。

仍然需要添加发送电子邮件的代码,但是出于测试目的,$mailSent被设置为true

  1. 保存processmail.php。在contact.php的底部找到这个代码块:
<pre>
<?php if ($_POST) {print_r($_POST);} ?>
</pre>
Change it to this:
<pre>
<?php if ($_POST && $mailSent) {
    echo htmlentities($message);
    foreach ($headers as $key => $value) {
        echo htmlentities("$key: $value") . '<br>';
    }
} ?>
</pre>

这将检查表单是否已提交,邮件是否已准备好发送。然后显示$message$headers数组中的值。所有的值都被传递给htmlentities(),以确保它们在浏览器中正确显示。

img/332054_5_En_6_Fig7_HTML.jpg

图 6-7

验证邮件正文和邮件头的格式是否正确

  1. 保存contact.php,并通过输入您的姓名、电子邮件地址和简短评论来测试表单。当你点击发送消息时,你应该看到消息正文和标题显示在页面的底部,如图 6-7 所示。

假设邮件正文和标题正确显示在页面底部,您就可以添加发送电子邮件的代码了。如有必要,对照ch06文件夹中的contact_07.phpincludes/processmail_04.php检查您的代码。

  1. processmail.php中,添加发送邮件的代码。找到以下行:
$mailSent = true;
Change it to this:
$mailSent = mail($to, $subject, $message, $headers);
if (!$mailSent) {
    $errors['mailfail'] = true;
}

这会将目的地址、主题行、消息正文和标题传递给mail()函数,如果成功地将电子邮件传递给 web 服务器的邮件传输代理(MTA ),该函数将返回true。如果失败,$mailSent被设置为false,条件语句向$errors数组添加一个元素,允许您在表单重新显示时保留用户的输入。

  1. 在位于contact.php顶部的 PHP 块中,在包含processmail.php的命令之后立即添加以下条件语句:
    require './includes/processmail.php';
    if ($mailSent) {
        header('Location: http://www.example.com/thank_you.php');
        exit;
    }
}
?>

您需要在您的远程服务器上测试这一点,所以用您自己的域名替换 www.example.com 。这将检查$mailSent是否为true。如果是,header()功能重定向到thank_you.php,一个确认消息已经发送的页面。下一行中的exit命令确保脚本在页面被重定向后终止。

ch06文件夹中有一份thank_you.php的副本。

  1. 如果$mailSentfalse,则contact.php重新显示;您需要警告用户消息无法发送。编辑<h2>标题后的条件语句,如下所示:
<h2>Contact Us </h2>
<?php if (($_POST && $suspect) || ($_POST && isset($errors['mailfail']))) { ?>
    <p class="warning">Sorry, your mail could not be sent. . . .

原始条件和新条件都被括在括号中,因此每一对都被单独考虑。如果表单已提交且发现可疑短语如果表单已提交且$errors['mailfail']已设置,则显示消息未发送的警告。

  1. 删除在contact.php底部显示消息正文和标题的代码块(包括<pre>标签)。

  2. 在本地测试可能会显示感谢页面,但电子邮件不会到达。这是因为大多数测试环境没有 MTA。即使您设置了一个,大多数邮件服务器也会拒绝来自无法识别的来源的邮件。将contact.php和所有相关文件,包括processmail.phpthank_you.php上传到你的远程服务器,并在那里测试联系方式。不要忘记processmail.php需要在一个名为includes的子文件夹中。

你可以用ch06文件夹中的contact_08.phpincludes/processmail_05.php来检查你的代码。

邮件故障排除( )

重要的是要明白mail()不是一个电子邮件程序。一旦将地址、主题、消息和头传递给 MTA,PHP 的责任就结束了。它无法知道电子邮件是否被发送到了预定的目的地。通常情况下,电子邮件会瞬间到达,但是网络堵塞会延迟几个小时甚至几天。

Tip

在远程服务器上测试这个脚本时,最有可能导致失败的原因之一是 PHP 的版本。如果您的远程服务器运行的是 PHP 7.1 或更早版本,那么$headers数组将需要被转换成一个字符串,在每个头之间有一个回车符和换行符。使用带有“r\n”(双引号)的implode()作为第一个参数。

如果您在从contact.php发送邮件后被重定向到感谢页面,但您的收件箱中没有收到任何邮件,请检查以下内容:

  • 邮件被垃圾邮件过滤器拦截了吗?

  • 您是否检查过存储在$to中的目的地地址?尝试另一个电子邮件地址,看看是否有所不同。

  • 你在From头中使用了真正的地址吗?使用假的或无效的地址很可能导致邮件被拒绝。请使用与您的 web 服务器属于同一域的有效地址。

  • 请咨询您的托管公司,看看第五个参数mail()是否是必需的。如果是这样,它通常应该是一个由-f后跟您的电子邮件地址组成的字符串。比如david@example.com变成了'-fdavid@example.com'

如果您仍然没有收到来自 contact.php 的消息,请使用以下脚本创建一个文件:

<?php
ini_set('display_errors', '1');
$mailSent = mail('you@example.com', 'PHP mail test', 'This is a test email');
if ($mailSent) {
    echo 'Mail sent';
} else {
    echo 'Failed';
}

用您自己的电子邮件地址替换you@example.com。将文件上传到您的网站,并将页面加载到浏览器中。

如果您看到一条关于没有From头的错误消息,添加一个头作为mail()函数的第四个参数,如下所示:

$mailSent = mail('you@example.com', 'PHP mail test', 'This is a test email',
'From: me@example.com');

在第一个参数中使用与目的地址不同的地址通常是个好主意。

如果你的主机公司需要第五个参数,调整代码如下:

$mailSent = mail('you@example.com', 'PHP mail test', 'This is a test email', null,
'-fme@example.com');

使用第五个参数通常取代了提供一个From头的需要,所以使用null(不带引号)作为第四个参数表明它没有值。

如果你看到“邮件已发送”但没有邮件到达,或者在尝试了所有五个参数后,你看到“失败”,请咨询你的托管公司以获得建议。

如果您收到的测试邮件来自这个脚本,而不是来自contact.php,这意味着您在代码中犯了一个错误,或者您忘记上传processmail.php。临时打开错误显示,如“为什么我的页面是空白的?”在第三章中,要检查contact.php是否能够找到processmail.php

Tip

我在英国的一所大学教书,不明白为什么学生的邮件没有被投递,尽管他们的代码是完美的。原来 It 部门已经禁用了 Sendmail(MTA ),以防止服务器被用来发送垃圾邮件!

处理多选表单元素

contact.php中的表格仅使用文本输入字段和文本区。要成功使用表单,您还需要知道如何处理多选元素,即:

  • 单选按钮

  • 检查框

  • 下拉选项菜单

  • 多项选择列表

它们背后的原理与您一直在处理的文本输入字段相同:表单元素的name属性被用作$_POST数组中的键。但是,有一些重要的区别:

  • 复选框组和多选列表将选定的值存储为一个数组,因此您需要在这些类型的输入的name属性的末尾添加一对空的方括号。例如,对于一个名为interests的复选框组,每个<input>标签中的name属性应该是name="interests[]"。如果省略方括号,只有最后选择的项目通过$_POST阵列传输。

  • 复选框组或多选列表中所选项的值作为$_POST数组的子数组传输。PHP 解决方案 6-6 中的代码自动将这些子数组转换为逗号分隔的字符串。但是,当将表单用于其他目的时,您需要从子数组中提取值。您将在后面的章节中看到如何做到这一点。

  • 如果没有选择值,单选按钮、复选框和多选列表不会包含在$_POST数组中。因此,在处理表单时尝试访问它们的值之前,使用isset()检查它们的存在是至关重要的。

本章剩余的 PHP 解决方案展示了如何处理多选表单元素。我不想详细介绍每一步,我只想强调重点。在阅读本章剩余部分时,请记住以下几点:

  • 处理这些元素依赖于processmail.php中的代码。

  • 您必须将每个元素的name属性添加到$expected数组中,以便将其添加到消息体中。

  • 要使一个字段成为必填字段,将其属性添加到数组$required中。

  • 如果不需要的字段留空,processmail.php中的代码将其值设置为“未选择”

图 6-8 显示了添加到原始设计中的各种类型输入的contact.php

img/332054_5_En_6_Fig8_HTML.jpg

图 6-8

带有多项选择表单元素示例的反馈表单

Tip

HTML5 表单输入元素都使用name属性,并将值作为文本或$_POST数组的子数组发送,因此您应该能够相应地修改代码。

PHP 解决方案 6-7:处理单选按钮组

单选按钮组只允许您选择一个值。尽管在 HTML 标记中设置默认值很常见,但这不是必须的。这个 PHP 解决方案展示了如何处理这两种情况。

  1. 处理单选按钮的简单方法是将其中一个设为默认按钮。单选按钮组总是包含在$_POST数组中,因为总是选择一个值。

具有默认值的单选按钮组的代码如下所示(name属性和 PHP 代码以粗体突出显示):

<fieldset id="subscribe">
    <h2>Subscribe to newsletter?</h2>
    <p>
    <input name="subscribe" type="radio" value="Yes" id="subscribe-yes"
    <?php
    if ($_POST && $_POST['subscribe'] == 'Yes') {
        echo 'checked';
    } ?>>
    <label for="subscribe-yes">Yes</label>
    <input name="subscribe" type="radio" value="No" id="subscribe-no"
    <?php
    if (!$_POST || $_POST['subscribe'] == 'No') {
       echo 'checked';
    } ?>>
    <label for="subscribe-no">No</label>
    </p>
</fieldset>

单选按钮组的所有成员共享相同的name属性。因为只能选择一个值,所以name属性的不是以一对空括号结束。

与 Yes 按钮相关的条件语句检查$_POST以查看表单是否已经提交。如果有,并且$_POST['subscribe']的值为“是”,那么checked属性将被添加到<input>标签中。

在 No 按钮中,条件语句使用|| (or)。第一个条件是!$_POST,当表单还没有提交时是true。如果是true,当页面第一次加载时,checked属性被添加为默认值。如果false,则表示表单已经提交,因此检查$_POST['subscribe']的值。

  1. 当单选按钮没有默认值时,它不包含在$_POST数组中,所以构建$missing数组的processmail.php中的循环不会检测到它。为了确保单选按钮元素包含在$_POST数组中,您需要在表单提交后测试它是否存在。如果没有包含它,您需要将其值设置为空字符串,如下所示:

  2. 如果单选按钮组是必需的但未被选中,则需要在表单重新加载时显示一条错误消息。您还需要更改<input>标签中的条件语句来反映不同的行为。

$required = ['name', 'comments', 'email', 'subscribe'];
// set default values for variables that might not exist
if (!isset($_POST['subscribe'])) {
    $_POST['subscribe'] = '';
}

下面的清单显示了来自contact_09.phpsubscribe单选按钮组,所有 PHP 代码都以粗体突出显示:

<fieldset id="subscribe">
    <h2>Subscribe to newsletter?
    <?php if (in_array('subscribe', $missing)) { ?>
    <span class="warning">Please make a selection</span>
    <?php } ?>
    </h2>
    <p>
    <input name="subscribe" type="radio" value="Yes" id="subscribe-yes"
    <?php
    if ($_POST && $_POST['subscribe'] == 'Yes') {
        echo 'checked';
    } ?>>
    <label for="subscribe-yes">Yes</label>
    <input name="subscribe" type="radio" value="No" id="subscribe-no"
    <?php
    if ($_POST && $_POST['subscribe'] == 'No') {
        echo 'checked';
    } ?>>
    <label for="subscribe-no">No</label>
    </p>
</fieldset>

控制<h2>标签中警告消息的条件语句使用了与文本输入字段相同的技术。如果单选按钮组是必填项并且在$missing数组中,则显示该消息。

两个单选按钮中围绕checked属性的条件语句是相同的。它检查表单是否已经提交,只有当$_POST['subscribe']中的值匹配时才显示选中的属性。

PHP 解决方案 6-8:处理复选框组

复选框可以单独使用,也可以成组使用。处理它们的方法略有不同。这个 PHP 解决方案展示了如何处理名为interests的复选框组。PHP 解决方案 6-11 解释了如何处理单个复选框。

当作为一个组使用时,组中的所有复选框共享同一个name属性,该属性需要以一对空的方括号结束,以便 PHP 将选择的值作为数组传输。为了识别哪些复选框被选中,每个复选框都需要一个惟一的value属性。

如果没有选择任何项目,复选框组不包含在$_POST数组中。表单提交后,您需要检查$_POST数组,看它是否包含复选框组的子数组。如果没有,您需要创建一个空的子数组作为processmail.php中脚本的默认值。

  1. 为了节省空间,只显示该组的前两个复选框。代码的name属性和 PHP 部分以粗体突出显示:
<fieldset id="interests">
<h2>Interests in Japan</h2>
<div>
    <p>
        <input type="checkbox" name="interests[]" value="Anime/manga"
        id="anime"
        <?php
        if ($_POST && in_array('Anime/manga', $_POST['interests'])) {
            echo 'checked';
        } ?>>
        <label for="anime">Anime/manga</label>
    </p>
    <p>
        <input type="checkbox" name="interests[]" value="Arts & crafts"
        id="art"
        <?php
        if ($_POST && in_array('Arts & crafts', $_POST['interests'])) {
            echo 'checked';
        } ?>>
        <label for="art">Arts & crafts</label>
    </p>
. . .
</div>
</fieldset>

每个复选框共享相同的name属性,该属性以一对空方括号结束,因此数据被视为数组。如果省略括号,$_POST['interests']只包含选中的第一个复选框的值。另外,如果没有选择复选框,$_POST['interests']也不会存在。您将在下一步中解决这个问题。

Note

虽然对于多重选择,必须将括号添加到name属性中,但是所选值的子数组在$_POST['interests']中,而不是在$_POST['interests[]']中。

每个复选框元素中的 PHP 代码执行与单选按钮组中相同的角色,将checked属性包装在一个条件语句中。第一个条件检查表单是否已经提交。第二个条件使用in_array()函数来检查与该复选框相关联的value是否在$_POST['interests']子数组中。如果是,则意味着该复选框已被选中。

  1. 提交表单后,您需要检查是否存在$_POST['interests']。如果还没有设置,您必须创建一个空数组作为缺省值,以便脚本的其余部分进行处理。代码遵循与单选按钮组相同的模式:

  2. 要设置所需复选框的最小数量,使用count()功能确认从表单传输的值的数量。如果少于所需的最小值,将该组添加到$errors数组,如下所示:

$required = ['name', 'comments', 'email', 'subscribe', 'interests'];
// set default values for variables that might not exist
if (!isset($_POST['subscribe'])) {
    $_POST['subscribe'] = '';
}
if (!isset($_POST['interests'])) {
    $_POST['interests'] = [];
}

if (!isset($_POST['interests'])) {
    $_POST['interests'] = [];
}
// minimum number of required check boxes
$minCheckboxes = 2;
if (count($_POST['interests']) < $minCheckboxes) {
    $errors['interests'] = true;
}

count()函数返回数组中元素的数量,所以如果选择的复选框少于两个,就会创建$errors['interests']。你可能想知道为什么我用了一个变量,而不是像这样的数字:

if (count($_POST['interests']) < 2) {

这当然是可行的,而且涉及的输入更少,但是$minCheckboxes可以在错误消息中重用。将数字存储在变量中意味着这种情况和错误消息总是保持同步。

  1. 表单正文中的错误消息如下所示:
<h2>Interests in Japan
<?php if (isset($errors['interests'])) { ?>
    <span class="warning">Please select at least <?= $minCheckboxes ?></span>
<?php } ?>
</h2>

PHP 解决方案 6-9:使用下拉选项菜单

<select>标签创建的下拉选项菜单类似于单选按钮组,因为它们通常只允许用户从几个选项中选择一个。它们的不同之处在于下拉菜单中总是有一个项目被选中,即使它只是邀请用户选择其他项目的第一个项目。因此,$_POST数组总是包含一个引用<select>菜单的元素,而单选按钮组被忽略,除非预设了默认值。

  1. 下面的代码显示了contact_09.php中下拉菜单的前两项,PHP 代码以粗体突出显示。与所有多选元素一样,PHP 代码包装了指示选择了哪个项目的属性。尽管这个属性在单选按钮和复选框中都被称为checked,但在<select>菜单和列表中它被称为selected。如果提交的表单缺少必需的项目,使用正确的属性重新显示选择是很重要的。当页面第一次加载时,$_POST数组不包含任何元素,所以您可以通过测试!$_POST来选择第一个<option>。一旦表单被提交,$_POST数组总是包含一个下拉菜单中的元素,所以您不需要测试它是否存在:

  2. 尽管下拉菜单中的某个选项总是处于选中状态,但您可能希望强制用户进行非默认选择。为此,将<select>菜单的name属性添加到$required数组,然后将默认选项的value属性和$_POST数组元素设置为空字符串,如下所示:

<p>
    <label for="howhear">How did you hear of Japan Journey?</label>
    <select name="howhear" id="howhear">
        <option value="No reply"
        <?php
        if (!$_POST || $_POST['howhear'] == 'No reply') {
            echo 'selected';
        } ?>>Select one</option>
        <option value="Apress"
        <?php
        if (isset($_POST && $_POST['howhear'] == 'Apress') {
            echo 'selected';
        } ?>>Apress</option>
    . . .
    </select>
</p>

<option value=""
<?php
if (!$_POST || $_POST['howhear'] == '') {
    echo 'selected';
} ?>>Select one</option>

<option>标签中不需要value属性,但是如果省略它,表单会使用开始和结束标签之间的文本作为选择值。因此,有必要将value属性显式设置为空字符串。否则,“选择一个”作为所选值传输。

  1. 如果没有进行选择,则显示警告消息的代码遵循一种熟悉的模式:
<label for="select">How did you hear of Japan Journey?
<?php if (in_array('howhear', $missing)) { ?>
    <span class="warning">Please make a selection</span>
<?php } ?>
</label>

PHP 解决方案 6-10:处理多项选择列表

多选列表类似于复选框组:它们允许用户选择零个或多个项目,因此结果存储在一个数组中。如果没有选择任何项目,多选列表就不会包含在$_POST数组中,所以需要像添加复选框组一样添加一个空的子数组。

  1. 下面的代码显示了contact_09.php中多选列表的前两项,用粗体突出显示了name属性和 PHP 代码。附加到name属性的方括号确保它将结果存储为一个数组。该代码的工作方式与 PHP 解决方案 6-8 中的复选框组相同:

  2. 在处理消息的代码中,以与复选框数组相同的方式为多选列表设置默认值:

<p>
    <label for="characteristics">What characteristics do you associate with
    Japan?</label>
    <select name="characteristics[]" size="6" multiple="multiple"
    id="characteristics">
        <option value="Dynamic"
        <?php
        if ($_POST && in_array('Dynamic', $_POST['characteristics'])) {
            echo 'selected';
        } ?>>Dynamic</option>
        <option value="Honest"
        <?php
        if ($_POST && in_array('Honest', $_POST['characteristics'])) {
            echo 'selected';
        } ?>>Honest</option>
. . .
    </select>
</p>

  1. 要制作所需的多选列表并设置最小选择数,使用 PHP 解决方案 6-8 中用于复选框组的相同技术。
if (!isset($_POST['interests'])) {
  $_POST['interests'] = [];
}
if (!isset($_POST['characteristics'])) {
  $_POST['characteristics'] = [];
}

PHP 解决方案 6-11:处理单个复选框

处理单个复选框的方式与复选框组略有不同。对于一个单独的复选框,您不需要将方括号附加到name属性,因为它不需要作为数组来处理。另外,value属性是可选的。如果不设置value属性,则复选框被选中时默认为“开”。但是,如果复选框没有被选中,它的名字就不会包含在$_POST数组中,所以您需要测试它是否存在。

这个 PHP 解决方案展示了如何添加一个复选框来确认站点的条款已经被接受。它假设需要选中该复选框。

  1. 这段代码显示了单个复选框,用粗体突出显示了name属性和 PHP 代码。
<p>
    <input type="checkbox" name="terms" value="accepted" id="terms"
    <?php
    if ($_POST && !isset($errors['terms'])) {
        echo 'checked';
    } ?>>
    <label for="terms">I accept the terms of using this website
    <?php if (isset($errors['terms'])) { ?>
        <span class="warning">Please select the check box</span>
    <?php } ?></label>
</p>

只有当$_POST数组包含值并且$errors['terms']没有被设置时,<input>元素中的 PHP 块才会插入checked属性。这可以确保在首次加载页面时不会选中该复选框。如果用户在没有确认接受条款的情况下提交表单,它也会保持未选中状态。

如果设置了$errors['terms'],第二个 PHP 块会在标签旁边显示一条错误消息。

  1. 除了向$expected$required数组添加项之外,还需要为$_POST['terms']设置一个默认值;然后在提交表单时处理数据的代码中设置$errors['terms']:
if (!isset($_POST['characteristics'])) {
    $_POST['characteristics'] = [];
}
if (!isset($_POST['terms'])) {
    $_POST['terms'] = '';
    $errors['terms'] = true;
}

只有在复选框是必需的情况下,才需要创建$errors['terms']。对于可选的复选框,如果它不包含在$_POST数组中,只需将值设置为空字符串。

第三章回顾

构建processmail.php已经做了很多工作,但是这个脚本的美妙之处在于它可以与任何表单一起工作。唯一需要更改的部分是$expected$required数组以及特定于表单的细节,比如目标地址、标题和多选元素的默认值,如果没有选择值,这些内容将不会包含在$_POST数组中。

我避免谈论 HTML 电子邮件,因为mail()函数处理它的能力很差。位于 www.php.net/manual/en/book.mail.php 的 PHP 在线手册展示了一种通过添加额外标题来发送 HTML 邮件的方法。然而,人们普遍认为 HTML 邮件应该包含不接受 HTML 的电子邮件程序的替代文本版本。如果想发送 HTML 邮件或附件,试试 PHPMailer ( https://github.com/PHPMailer/PHPMailer/ )。

正如你将在后面的章节中看到的,在线表单是你用 PHP 做的所有事情的核心。它们是浏览器和网络服务器之间的网关。你会一次又一次地回到你在本章中学到的技术。

七、使用 PHP 管理文件

PHP 有大量用于服务器文件系统的函数,但是找到合适的函数并不容易。这一章从混乱中切入,向您展示这些函数的一些实际用途,例如在没有数据库的情况下读写文本文件来存储少量信息。循环在检查文件系统的内容中起着重要的作用,所以您还将探索一些标准的 PHP 库(SPL)迭代器,这些迭代器旨在提高循环的效率。

除了打开本地文件,PHP 还可以读取其他服务器上的公共文件,比如新闻提要。新闻提要通常被格式化为 XML(可扩展标记语言)。在过去,从 XML 文件中提取信息是一个曲折的过程,但是名字非常贴切的 SimpleXML 使 PHP 变得很容易。在本章中,您将了解如何创建一个列出文件夹中所有图像的下拉菜单,如何创建一个从文件夹中选择特定类型文件的功能,如何从另一个服务器获取实时新闻,以及如何提示访问者下载图像或 PDF 文件,而不是在浏览器中打开它。作为奖励,您将学习如何更改从另一个网站检索的日期的时区。

本章涵盖以下主题:

  • 读取和写入文件

  • 列出文件夹的内容

  • SplFileInfo类检查文件

  • 用 SPL 迭代器控制循环

  • 使用 SimpleXML 从 XML 文件中提取信息

  • 消费 RSS 源

  • 创建下载链接

检查 PHP 是否可以打开文件

本章中的许多 PHP 解决方案都涉及到打开文件进行读写,所以确保在本地测试环境和远程服务器上设置正确的权限是很重要的。PHP 能够在任何地方读写文件,只要它有正确的权限并且知道在哪里可以找到文件。因此,为了安全起见,您应该将计划读写的文件存储在 web 服务器根目录之外(通常称为htdocspublic_htmlwww)。这可以防止未经授权的人阅读您的文件,或者更糟的是,篡改其内容。

大多数托管公司使用 Linux 或 Unix 服务器,这些服务器对文件和目录的所有权有严格的规定。检查在 web 服务器根目录之外存储文件的目录的权限是否已设置为 644(这允许所有者读取和写入该目录;所有其他用户只能读取)。如果你仍然得到许可被拒绝的警告,咨询你的托管公司。如果您被告知将任何设置提升到 7,请注意这将允许执行脚本,这可能会被恶意攻击者利用。

Tip

如果你不能访问网站根目录以外的目录,我建议你换一家托管公司。由网站维护者以外的人上传到网站的文件在被包含在网页中之前应该被检查。将它们存储在公众视野之外可以降低任何安全风险。

在服务器根目录外创建一个文件夹,以便在 Windows 上进行本地测试

对于下面的练习,我建议你在 c 盘的顶层创建一个名为private的文件夹。在 Windows 上没有权限问题,所以这就是你需要做的。

在服务器根目录外创建一个文件夹,以便在 macOS 上进行本地测试

Mac 用户可能需要做更多的准备,因为文件权限类似于 Linux。在你的主文件夹中创建一个名为private的文件夹,并按照 PHP 解决方案 7-1 中的说明进行操作。

如果一切顺利,你不需要做任何额外的事情。但是,如果您收到 PHP“未能打开流”的警告,请像这样更改private文件夹的权限:

  1. 在 Mac Finder 中选择private,选择文件➤获取信息(Cmd+I)打开其信息面板。

  2. 在“共享与权限”中,单击右下方的挂锁图标解锁设置,然后将所有人的设置从只读更改为读写,如以下截图所示:

img/332054_5_En_7_Figa_HTML.jpg

  1. 再次单击挂锁图标以保存新设置并关闭信息面板。现在你应该能够使用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()访问文件内容的区别。

  1. ch07文件夹中的sonnet.txt复制到你的private文件夹中。这是一个包含莎士比亚十四行诗 116 的文本文件。

  2. 在 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 路径名。

  1. 保存get_contents.php并在浏览器中查看。您应该会看到类似下面的截图。浏览器会忽略原始文本中的换行符,并将莎士比亚的十四行诗显示为实心块:

img/332054_5_En_7_Figb_HTML.jpg

Tip

如果您看到错误消息,请检查您键入的代码是否正确,以及在 Mac 或 Linux 上是否设置了正确的文件和文件夹权限。

  1. PHP 有一个名为nl2br()的函数,将换行符转换为<br/>标签(尾部斜杠是为了与 XHTML 兼容,在 HTML5 中有效)。把get_contents.php里的代码改成这样(在get_contents_02.php里):

  2. 保存get_contents.php并在浏览器中重新加载。输出仍然是一个完整的文本块。当您像这样将一个函数作为参数传递给另一个函数时,内部函数的结果通常会传递给外部函数,在一个表达式中执行这两个操作。因此,您可能希望文件的内容在浏览器中显示之前被传递给nl2br()。然而,readfile()会立即输出文件的内容。当它完成的时候,已经没有什么可以让nl2br()插入<br/>标签了。文本已经在浏览器中。

    注意当两个函数像这样嵌套时,首先执行内部函数,然后外部函数处理结果。但是内部函数的返回值需要作为外部函数的参数有意义。readfile()的返回值是从文件中读取的字节数。即使您在行首添加了echo,您得到的也只是添加到文本末尾的 594。在这种情况下,嵌套函数不起作用,但它通常是一种非常有用的技术,避免了在用另一个函数处理内部函数的结果之前将它存储在变量中的需要。

  3. 代替readfile(),你需要使用file_get_contents()来将换行符转换成<br/>标签。readfile()只是输出文件的内容,file_get_contents()将文件的内容作为一个字符串返回。由你决定如何处理它。像这样修改代码(或使用get_contents_03.php):

nl2br(readfile('C:/private/sonnet.txt'));

  1. 在浏览器中重新加载页面。十四行诗的每一行现在都自成一行:
echo nl2br(file_get_contents('C:/private/sonnet.txt'));

img/332054_5_En_7_Figc_HTML.jpg

  1. 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显示,产生如下:

img/332054_5_En_7_Figd_HTML.jpg

该脚本现在只显示第一行,而不是显示文件的全部内容。完整的字符串仍然存储在$sonnet中。

  1. 然而,如果您想单独处理每一行,使用file()更简单,它将文件的每一行读入一个数组。为了显示sonnet.txt的第一行,前面的代码可以简化成这样(参见get_contents_05.php):

    $sonnet = file('C:/private/sonnet.txt');
    echo $sonnet[0];
    
    
  2. 事实上,如果您不需要完整的数组,您可以使用一种称为数组解引用的技术直接访问一行,方法是在调用函数后在方括号中添加它的索引号。以下代码显示十四行诗的第 11 行(见get_contents_06.php):

echo file('C:/private/sonnet.txt')[10];

img/332054_5_En_7_Fige_HTML.jpg

在我们刚刚探索的三个函数中,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);

如果将它加载到浏览器中,您应该会看到以下输出:

img/332054_5_En_7_Figf_HTML.jpg

结果与在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 文件中的值提取到多维关联数组中。

  1. ch07文件夹中的weather.csv复制到你的private文件夹中。该文件包含以下逗号分隔值的数据:
city,temp
London,11
Paris,10
Rome,12
Berlin,8
Athens,19

第一行由文件其余部分的数据标题组成。共有五行数据,每行包含一个城市的名称和一个温度。

Caution

将数据存储为逗号分隔的值时,逗号后面不应有空格。如果添加空格,它将被视为数据字段的第一个字符。CSV 文件中的每一行都必须有相同数量的项目。

  1. filesystem文件夹中创建一个名为getcsv.php的文件,使用fopen()以读取模式打开weather.csv:

  2. 使用fgetcsv()从文件中提取第一行作为数组,然后将它赋给一个名为$titles的变量:

    $titles = fgetcsv($file);

    这会将$titles创建为一个数组,其中包含第一行(city 和 temp)的值。

    fgetcsv()函数需要一个参数,即您打开的文件的引用。它还接受多达四个可选参数:

    • 线的最大长度:默认值为 0,表示无限制。

    • 字段之间的分隔符:默认为逗号。

    • 包围字符:如果字段包含分隔符作为数据的一部分,它们必须用引号括起来。双引号是默认设置。

    • 转义符:默认为反斜杠。

$file = fopen('C:/private/weather.csv', 'r');

我们使用的 CSV 文件不需要设置任何可选参数。

  1. 在下一行,为将从 CSV 数据中提取的值初始化一个空数组:

  2. 从一行中提取值后,fgetcsv()移动到下一行。要从文件中获取剩余的数据,您需要创建一个循环。添加以下代码:

    while (!(feof($file)) {
        $data = fgetcsv($file);
        $cities[] = array_combine($titles, $data);
    }
    
    
$cities = [];

循环内部的代码将 CSV 文件的当前行作为数组分配给\(data,然后使用`array_combine()`函数生成一个关联数组,该数组被添加到`\)cities`数组中。这个函数需要两个参数,这两个参数都必须是元素个数相同的数组。这两个数组被合并,从第一个参数中提取结果关联数组的键,从第二个参数中提取值。

  1. 关闭 CSV 文件:

img/332054_5_En_7_Fig1_HTML.jpg

图 7-1

CSV 数据已被转换为多维关联数组

  1. 要检查结果,使用print_r()。用<pre>标记包围它,使输出更容易阅读:

    echo '<pre>';
    print_r($cities);
    echo '</pre>';
    
    
  2. 保存getcsv.php并将其载入浏览器。您应该会看到如图 7-1 所示的结果。

fclose($file);

  1. 这与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 的文件,并插入您的文本。键入其他内容并再次提交表单;新文本应添加到先前文本的末尾,如下面的屏幕截图所示:

img/332054_5_En_7_Figg_HTML.jpg

我们将在第十一章回到追加模式。

写入前锁定文件

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

在追加模式(aa+)下,无论指针的当前位置如何,内容总是被写到文件的末尾。

探索文件系统

PHP 的文件系统函数也可以打开目录(文件夹)并检查其内容。从 web 开发人员的角度来看,文件系统功能的实际用途包括构建显示文件夹内容的下拉菜单,以及创建提示用户下载文件(如图像或 PDF 文档)的脚本。

用 scandir()检查文件夹

函数的作用是:返回一个由指定文件夹中的文件和文件夹组成的数组。只需将文件夹(目录)的路径名作为字符串传递给scandir(),并将结果存储在一个变量中,如下所示:

$files = scandir('../images');

您可以通过使用print_r()显示数组的内容来检查结果,如下图所示(代码在ch07文件夹中的scandir.php):

img/332054_5_En_7_Figh_HTML.jpg

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>';
}

这会产生以下结果:

img/332054_5_En_7_Figi_HTML.jpg

可以对该输出进行以下观察:

  • 省略了表示当前文件夹和父文件夹的点文件。

  • 显示的值代表文件的相对路径,而不仅仅是文件名。

  • 因为截图是在 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);

两者都产生如下屏幕截图所示的输出:

img/332054_5_En_7_Figj_HTML.jpg

当然,这在 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文件夹的内容,只需一次操作:

img/332054_5_En_7_Figk_HTML.jpg

如果您只想查找特定类型的文件,该怎么办?提示另一个迭代器…

使用 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()方法检索文件的名称。结果是这样的:

img/332054_5_En_7_Figl_HTML.jpg

现在只列出了文本和 CSV 文件。所有的 PHP 文件都被忽略了。

我想到了这个阶段,你可能会想知道这是否能有任何实际用途。让我们在一个文件夹中构建一个图像下拉菜单。

PHP 解决方案 7-3:建立一个文件下拉菜单

使用数据库时,您通常需要特定文件夹中的图像或其他文件的列表。例如,您可能希望将图片与产品详细信息页面相关联。虽然您可以在文本字段中键入图像的名称,但是您需要确保图像在那里,并且拼写正确。通过自动构建下拉菜单,让 PHP 来完成这项艰巨的工作。它总是最新的,而且没有拼错名字的危险。

  1. filesystem文件夹中创建一个名为imagelist.php的 PHP 页面。或者,使用ch07文件夹中的imagelist_01.php

  2. imagelist.php中创建一个表单,插入一个只有一个<option><select>元素,就像这样(代码已经在imagelist_01.php中了):

    <form method="post">
        <select name="pix" id="pix">
            <option value="">Select an image</option>
        </select>
    </form>
    
    
  3. 这个<option>是下拉菜单中唯一的静态元素。

  4. 按如下形式修改<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 所示。

img/332054_5_En_7_Fig2_HTML.jpg

图 7-2

PHP 轻松地在特定文件夹中创建一个图片下拉菜单

当合并到在线表单中时,所选图像的文件名出现在$_POST数组中,并由<select>元素的name属性标识——在本例中为$_POST['pix']。仅此而已!

您可以将您的代码与ch07文件夹中的imagelist_02.php进行比较。

PHP 解决方案 7-4:创建一个通用文件选择器

之前的 PHP 解决方案依赖于对正则表达式的理解。使其适应其他文件扩展名并不困难,但是您需要小心,不要意外删除了一个重要的字符。除非正则表达式是您的专长,否则将代码包装在一个函数中可能更容易,该函数可用于检查特定文件夹并创建特定类型的文件名数组。例如,您可能想要创建一个 PDF 文档文件名数组或一个包含 PDF 和 Word 文档的数组。这是你怎么做的。

  1. filesystem文件夹中创建一个名为buildlist.php的新文件。该文件将只包含 PHP 代码,所以删除任何由你的编辑程序插入的 HTML。

  2. 将以下代码添加到文件中:

    function buildFileList(string $dir, string|array $extensions) {
        if (!is_dir($dir) && !is_readable($dir)) {
            return false;
        } else {
            if (is_array($extensions)) {
                $extensions = implode('|', $extensions);
            }
         }
    }
    
    
  3. 这定义了一个名为buildFileList()的函数,它有两个参数:

    • $dir:您要从中获取文件名列表的文件夹的路径。这必须是一个字符串。

    • $extensions:函数签名使用了联合类型声明,这是 PHP 8 的新功能。它指定参数可以是字符串或数组。这应该是单个文件扩展名或文件扩展名数组。为了保持代码简单,扩展名不应包含前导句点。

该函数首先检查$dir是否是一个文件夹并且可读。如果不是,函数返回false,不再执行代码。

如果$dir没问题,则执行else块。它也以检查$extensions是否是一个数组的条件语句开始。如果是,它被传递给implode(), 用每个数组元素之间的竖线(|)连接数组元素。正则表达式中使用竖线来表示可选值。假设下面的数组作为第二个参数传递给函数:

['jpg', 'png', 'gif']

条件语句将其转换为jpg|png|gif。所以这个寻找jpg或者png或者gif。但是,如果参数是字符串,它将保持不变。

  1. 现在可以构建正则表达式搜索模式,并将两个参数传递给FilesystemIteratorRegexIterator,如下所示:

    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 引擎正确解释它。复制代码时要小心。它不太容易读懂。

  1. 代码的最后一部分提取文件名来构建一个数组,数组被排序然后返回。完成的函数定义如下所示:

    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.jpgimg12.jpg之前。

  1. 若要使用该函数,请将文件夹路径和要查找的文件的文件扩展名用作参数。例如,您可以从这样的文件夹中获取所有 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)。除了将双引号转换为&quot;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>

img/332054_5_En_7_Fig3_HTML.jpg

图 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类限制显示的项目数量。

img/332054_5_En_7_Fig4_HTML.jpg

图 7-4

新闻提要包含大量项目

  1. filesystem文件夹中创建一个名为newsfeed.php的新页面。这个页面将包含 PHP 和 HTML 的混合。

  2. 这个 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/

  3. 在标题下创建一个 PHP 块,并添加以下代码来加载提要:

    $url = 'https://feeds.bbci.co.uk/news/world/rss.xml';
    $feed = simplexml_load_file($url);
    
    
  4. 使用foreach循环访问<item>元素并显示每个元素的<title>:

    foreach ($feed->channel->item as $item) {
        echo htmlentities($item->title) . '<br>';
    }
    
    
  5. 保存newsfeed.php并在浏览器中加载页面。您应该会看到一长串类似于图 7-4 的新闻条目。

  6. 正常的提要通常包含 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 新闻网站每分钟都在更新。

img/332054_5_En_7_Fig5_HTML.jpg

图 7-5

LimitIterator限制显示的项目数量

  1. 现在您已经限制了条目的数量,修改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 } ?>
    
    
  2. 保存页面并再次测试。这些链接直接把你带到 BBC 网站上的相关新闻报道。新闻提要现在可以使用了,但是<pubDate>格式遵循 RSS 规范中规定的格式,如下面的截图所示:

img/332054_5_En_7_Figm_HTML.jpg

  1. 为了以更加用户友好的方式格式化日期和时间,将$item->pubDate传递给DateTime类构造函数,然后使用DateTime format()方法来显示它。更改foreach循环中的代码,如下所示:

    <p class="datetime"><?php $date = new DateTime($item->pubDate);
    echo $date->format('M j, Y, g:ia'); ?></p>
    
    

这将日期重新格式化如下:

img/332054_5_En_7_Fign_HTML.jpg

神秘的 PHP 日期格式字符串在第十六章中有解释。

  1. 那看起来好多了,但是时间还是用 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的字符串有一个前导空格来分隔时区和时间。当页面被加载时,时间被调整到美国东海岸,如下所示:

img/332054_5_En_7_Figo_HTML.jpg

img/332054_5_En_7_Fig6_HTML.jpg

图 7-6

实时新闻提要只需要十几行 PHP 代码

  1. 所有的网页现在需要的是用 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()以二进制流的形式输出文件内容,迫使浏览器下载它。

  1. filesystem文件夹中创建一个名为download.php的 PHP 文件。下一步将给出完整的列表。你也可以在ch07文件夹的download.php中找到。

  2. 删除脚本编辑器创建的任何默认代码,并插入以下代码:

<?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()将文件发送到输出缓冲区。如果找不到该文件,用户将被重定向到错误页面。

  1. 通过创建另一个页面来测试脚本;给download.php添加几个链接。在每个链接的末尾添加一个查询字符串,后跟要下载的文件的名称。您将在ch07文件夹中找到一个名为getdownloads.php的页面,其中包含以下两个链接:

  2. 单击其中一个链接。根据您的浏览器设置,该文件将被下载到您的默认下载文件夹,或者会出现一个对话框,询问您如何处理该文件。

<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 FilesystemIteratorRecursiveDirectoryIterator类使得检查文件夹的内容变得容易。与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循环来修改数组中的每个元素。索引数组和关联数组的技术是相似的。

  1. 打开ch08文件夹中的modify_01.php。它包含前一节中的代码,后跟一对<pre>标记之间的print_r($numbers);

  2. 将页面加载到浏览器中,以验证$ numbers 数组中的值没有改变,如下面的屏幕截图所示:

img/332054_5_En_8_Figa_HTML.jpg

  1. 通过引用将每个数组元素的值传递给循环,方法是在循环声明中的临时变量前加上一个&符号,如下所示:

  2. 当循环结束时,临时变量仍将包含最后一个数组元素的重新计算值。为了避免以后意外更改值,建议在循环后取消设置临时变量,如下所示:

    foreach ($numbers as &$number) {
        $number *= 2;
    }
    unset($number);
    
    
  3. 保存文件并将其加载到浏览器中,以测试修改后的代码(在modify_02.php中)。数组中的每个数字都应该是双精度的,如下图所示:

foreach ($numbers as &$number) {

img/332054_5_En_8_Figb_HTML.jpg

  1. 要修改关联数组的值,需要为键和值声明临时变量;但是只有值应该通过引用传递。以下代码在modify_03.php中:

    $book = [
        'author' => 'David Powers',
        'title' => 'PHP 8 Solutions'
    ];
    foreach ($book as $key => &$value) {
        $book[$key] = strtoupper($value);
    }
    unset($value);
    
    

这会产生以下输出:

img/332054_5_En_8_Figc_HTML.jpg

  1. 然而,假设您想要修改数组键。合乎逻辑的方法是在键前面加一个&符号,通过引用传递它,如下所示:
foreach ($book as &$key => $value) {

但是,如果您尝试这样做,将会触发致命错误。数组键不能通过引用传递。只有数组值可以。

  1. 要修改关联数组的每个键,只需在循环内部修改它,就像在循环外部一样。以下代码在modify_04.php中:

    foreach ($book as $key => $value) {
        $book[ucfirst($key)] = $value;
    }
    
    

它产生以下输出:

img/332054_5_En_8_Figd_HTML.jpg

  1. 如前面的屏幕截图所示,原始密钥与修改后的密钥一起保留。如果您只想要修改过的键,您需要像这样在循环中取消原始键的设置(代码在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()的各种方式。

  1. 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数组中的每个值都加倍。

  1. 当对关联数组使用 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相同的输出。

  1. 除了将匿名或箭头函数作为第二个参数传递给array_walk(),您还可以将已定义函数的名称作为字符串传递,如下所示(代码在array_walk_03.php):

    array_walk($book, 'output');
    function output (&$val) {
        return $val = strtoupper($val);
    }
    
    

这将产生与前面示例相同的输出。如果函数定义在同一个文件中,它是在调用array_walk()之前还是之后都没关系。但是,如果定义在一个外部文件中,那么在调用array_walk()之前必须包含该文件。

  1. 传递给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.";
    }
    
    

这会产生以下输出:

img/332054_5_En_8_Fige_HTML.jpg

  1. 使用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);
    
    
  2. 要将密钥转换为大写,将 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()将返回一个索引数组。

  1. 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数组包含回调返回的结果。

img/332054_5_En_8_Figf_HTML.jpg

  1. 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>';
    
    

如下面的屏幕截图所示,数组键保留在修改后的数组中:

img/332054_5_En_8_Figg_HTML.jpg

  1. 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()。这会产生以下结果:

img/332054_5_En_8_Figh_HTML.jpg

请注意,修改后的数组中没有保留数组键。向回调传递多个参数会产生一个索引数组。

  1. 传递给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数组中只有一个元素;但是如下图所示,这不会导致相同的值被重用。

img/332054_5_En_8_Figi_HTML.jpg

当作为参数传递给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>';

运行该脚本会产生以下输出:

img/332054_5_En_8_Figj_HTML.jpg

结果数组只包含三个元素,而不是五个元素。这是因为数组联合运算符不会将第二个数组连接到第一个数组的末尾。对于索引数组,它忽略第二个数组中与第一个数组中的元素具有相同索引的元素。在这个例子中,第二个数组中的 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;

两个数组都包含一组唯一的键,因此生成的数组包含每个元素及其相关的键,如下面的屏幕截图所示:

img/332054_5_En_8_Figk_HTML.jpg

然而,当存在重复的键时,数组联合操作符忽略第二个数组中的元素,如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 被忽略,因为他共享一个重复的密钥。

img/332054_5_En_8_Figl_HTML.jpg

忽略重复的索引或键并不总是您想要的,所以 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)保留在结果数组中:

img/332054_5_En_8_Figm_HTML.jpg

对于关联数组,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)中的值:

img/332054_5_En_8_Fign_HTML.jpg

Caution

数组合并的顺序不同于数组联合运算符。数组 union 操作符保留第一个重复值,而array_merge()保留最后一个重复值。

要保留重复键的值,需要使用array_merge_recursive()merge_07.php中的代码合并了相同的数组,如下所示:

$lead_developers = array_merge_recursive($first, $second);

如下图所示,重复键的值被合并到一个索引子数组中:

img/332054_5_En_8_Figo_HTML.jpg

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);

这将产生如下屏幕截图所示的输出:

img/332054_5_En_8_Figp_HTML.jpg

默认情况下,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>';

这会产生以下输出:

img/332054_5_En_8_Figq_HTML.jpg

字符串比较区分大小写,因此 Wet Wet Wet 被排除在外。

PHP 解决方案 8-4:用逗号连接数组

内置的 PHP implode()函数用用户提供的字符串连接数组的所有元素。这个 PHP 解决方案通过在最后一个元素前插入“and”来增强输出。它提供了限制元素数量的选项,用“and one other”或“and others”替换多余的值

  1. 打开ch08文件夹中的commas_01.php。它包含一系列索引数组,包含 20 世纪 60 年代和 70 年代的 0 到 5 个录音艺术家的名字。最后一行使用implode()用逗号连接最后一个数组:

    $too_many = ['Dave Dee', 'Dozy', 'Beaky', 'Mick', 'Tich'];
    echo implode(', ', $too_many);
    
    
  2. 将脚本加载到浏览器中。如下面的屏幕截图所示,最终名称前没有“and”时,输出看起来很笨拙:

img/332054_5_En_8_Figr_HTML.jpg

  1. 删除最后一行,开始定义一个函数,如下所示:
function with_commas(array $array, int $max = 4) { }

函数签名有两个参数:$array$maxThe类型声明指定第一个必须是一个数组,第二个必须是一个整数,所以如果有任何其他类型的数据传递给它,该函数将触发一个错误。$max设置待连接元素的最大数量。它有一个默认值4,所以它是一个可选参数。

  1. 在函数内部,我们可以使用一个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,它仍然是一个不能用echoprint显示的数组。array_pop()函数移除数组中的最后一个元素并返回它。

如果数组中有两个元素,数组被传递给implode()函数,字符串“and”两边用空格包围。

默认操作使用implode()连接数组中除最后一个元素之外的所有元素,用逗号后跟一个空格。传递给implode()的第二个参数使用array_slice()函数来选择所需的元素。array_slice()函数有三个参数:要从中提取元素的数组、要从中开始的元素的索引以及要提取的元素的数量。数组是从零开始计数的,所以从数组的开头开始,提取$length–1元素。然后,在返回结果之前,最后一个元素的值(再次使用array_pop())被连接到以“and”开头的逗号分隔的字符串。

Caution

这个脚本至少需要 PHP 8。对于旧版本的 PHP,你需要使用我的 PHP 7 解决方案中描述的 switch 语句。

  1. 保存脚本,并依次用每个测试数组测试它。例如:

  2. 这以合乎语法的方式用逗号连接数组元素:

echo with_commas($fab_four);

img/332054_5_En_8_Figs_HTML.jpg

  1. 让我们修复数组元素数量超过$max 的情况,从多一个开始。在 default 之前插入以下代码:
$max + 1 =>implode(', ', array_slice($array, 0, $max)) . ' and one other';

这将把array_slice($array, 0, $max)作为第二个参数传递给implode()。然后,在返回结果之前,将字符串“and one other”连接到结果上。

  1. 保存脚本并再次测试。如果你用$fab_four测试它,你会得到和前面截图一样的结果。现在用$too_many试试会产生以下结果:

img/332054_5_En_8_Figt_HTML.jpg

  1. 超过$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)
    };
    
    
  2. 保存脚本并再次运行。用$too_many,结果不变。但是,将第二个参数with_commas()改为一个较小的数字,如下所示:

echo with_commas($too_many, 3);

这将输出更改如下:

img/332054_5_En_8_Figu_HTML.jpg

  1. 您可以在ch08文件夹中用commas_02.php检查完成的代码。

排序数组

表 8-2 列出了许多用于排序数组的内置 PHP 函数。

表 8-2

数组排序函数

|

功能

|

描述

|
| --- | --- |
| sort() | 按升序排序(从低到高) |
| rsort() | 按降序排序(从最高到最低) |
| asort() | 按值升序排序,保持键值关系 |
| arsort() | 按值降序排序,保持键值关系 |
| ksort() | 按键升序排序,保持键与值的关系 |
| krsort() | 按键降序排序,保持键与值的关系 |
| natsort() | 以“自然顺序”按值排序,维护值关系的键 |
| natcasesort() | 以不区分大小写的“自然顺序”按值排序,保持键值关系 |
| usort() | 使用回调比较函数按值排序 |
| uasort() | 使用回调比较函数按值排序,保持键与值的关系 |
| uksort() | 使用回调比较函数按键排序,保持键与值的关系 |
| array_multisort() | 对多个或多维数组进行排序 |

表 8-2 中的所有函数影响原始数组,根据操作是否成功,只返回truefalse。前六个函数(包括krsort())可以将表 8-3 中列出的 PHP 常量作为可选的第二个参数来修改排序顺序。

表 8-3

修改排序顺序的常数

|

常数

|

描述

|
| --- | --- |
| SORT_REGULAR | 比较项目而不改变其类型(默认) |
| SORT_NUMERIC | 将项目作为数字进行比较 |
| SORT_STRING | 将项目作为字符串进行比较 |
| SORT_LOCALE_STRING | 基于当前区域设置比较项目 |
| SORT_NATURAL | 以“自然顺序”比较项目 |
| SORT_FLAG_CASE | 可以与使用竖线(&#124;)的SORT_STRINGSORT_NATURAL结合使用,对字符串进行不区分大小写的排序 |

以“自然顺序”对值进行排序的两个函数和常量以与人类相同的方式对包含数字的字符串进行排序。在ch08文件夹的natsort.php中有一个例子,用sort()natsort()对下面的数组进行排序:

$images = ['image10.jpg', 'image9.jpg', 'image2.jpg'];

下面的屏幕截图显示了不同的结果:

img/332054_5_En_8_Figv_HTML.jpg

有了sort(),顺序不仅违反直觉,而且索引也被重新编号。有了natsort(),顺序更加人性化,原来的索引都保留了下来。

Tip

natsort()natcasesort()函数没有逆序的等价函数,但是您可以将结果传递给内置的array_reverse()函数。这将返回一个新数组,其中的元素以相反的顺序排列,不进行排序。与表 8-2 中的功能不同,原始数组不变。关联数组键被保留,但索引数组被重新编号。为了防止索引数组被重新编号,传递布尔值true作为第二个(可选)参数。

usort()uasort()uksort()中使用的回调比较函数必须接受两个参数,如果第一个参数分别小于、等于或大于第二个参数,则返回一个小于、等于或大于零的整数。PHP 解决方案 8-5 展示了如何用飞船操作符来做这件事。

PHP 解决方案 8-5:用飞船操作符自定义排序

表 8-2 中的前八个排序函数在处理大多数排序操作时表现出色。然而,它们不能涵盖所有场景。这时定制排序函数就派上用场了。这个 PHP 解决方案展示了飞船操作员如何简化定制排序

  1. 打开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>';
    
    
  2. 在循环之前插入一行,使用asort()对数组进行排序:

img/332054_5_En_8_Fig1_HTML.jpg

图 8-1

asort()函数使得对多维关联数组中的值进行排序变得简单

  1. 保存文件,并将其加载到浏览器中。如图 8-1 所示,asort()不仅按字母顺序对艺人进行了排序;与每个艺术家相关的曲目也是按字母顺序排列的。
asort($playlist);

  1. 但是,假设您想按曲目名称的字母顺序对播放列表进行排序。为此,您需要一个自定义排序。用以下代码替换您在步骤 2 中插入的代码行:
usort($playlist, fn ($a, $b) => $a['track'] <=> $b['track']);

这使用了带有箭头回调函数的usort()函数。回调函数的两个参数($a$b)表示您想要比较的两个数组元素。该函数使用宇宙飞船运算符将当前 track 元素的值与下一个元素的值进行比较,根据左边的操作数是小于、等于还是大于右边的操作数,分别返回小于、等于或大于零的整数。

  1. 要使自定排序的结果看起来更清楚,请交换每个列表项中显示的艺术家和曲目的顺序:

img/332054_5_En_8_Fig2_HTML.jpg

图 8-2

播放列表现在已经按曲目名称的字母顺序排序

  1. 保存文件并在浏览器中重新加载。轨道现在按字母顺序列出(见图 8-2 )。
echo "<li>{$item['track']}: {$item['artist']}</li>";

  1. 要颠倒自定义排序的顺序,请交换 spaceship 运算符两边的操作数顺序:

  2. 您可以对照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 显示了输出。

img/332054_5_En_8_Fig3_HTML.jpg

图 8-3

尽管各州和人口数字在不同的数组中,但它们的顺序是正确的

Note

PHP 引擎在执行脚本时去掉了$population数组中的下划线。PHP 7.4 引入了在整数中使用下划线以提高可读性。

但是,如果您希望按升序或降序对人口数据进行重新排序,两个数组需要保持同步。

multisort_02.php中的代码显示了如何使用array_multisort()完成这一任务:

array_multisort($population, SORT_ASC, $states);

array_multisort()的第一个参数是要首先排序的数组。它后面可以跟两个可选参数:使用常量SORT_ASCSORT_DESC分别表示升序或降序的排序方向,以及使用表 8-3 中列出的常量之一的排序类型。剩下的参数是您希望与第一个数组同步排序的其他数组。每个后续数组后面还可以跟有排序方向和类型的可选参数。

在这个例子中,$population数组按升序排序,而$states数组与其同步重新排序。如图 8-4 所示,人口数据和州名之间的正确关系得以保持。

img/332054_5_En_8_Fig4_HTML.jpg

图 8-4

人口数字现在按升序排列,保留正确的州名

下一个 PHP 解决方案展示了一个使用array_multisort()按照多维度对多维数组重新排序的例子。

PHP 解决方案 8-6:用 array_multisort()对多维数组排序

在前面的 PHP 解决方案中,我们使用了 spaceship 操作符,通过比较分配给单个键的值,对多维数组进行自定义排序。在这个解决方案中,我们将使用array_multisort()来执行更复杂的排序操作。

  1. 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]
    ];
    
    
  2. 正如前面的解决方案所演示的,使用usort()和飞船操作符可以很容易地按照轨道的字母顺序对数组进行排序。我们也可以通过评级对数组进行排序;但是根据评级和跟踪进行分类需要不同的方法。

根据多个标准对多维数组进行排序的第一步是将待排序的值提取到单独的数组中。使用array_column()函数很容易做到这一点,该函数有两个参数:顶级数组和要从每个子数组中提取的键。在$playlist数组后添加以下代码(在multisort_04.php):

img/332054_5_En_8_Fig5_HTML.jpg

图 8-5

排序所需的值已经提取到单独的索引数组中

  1. 保存文件并在浏览器中测试。如图 8-5 所示,多维数组中的值被提取到两个索引数组中。
$tracks = array_column($playlist, 'track');
$ratings = array_column($playlist, 'rating');
print_r($tracks);
print_r($ratings);

  1. 我们不再需要检查$tracks$ratings数组的内容,所以注释掉或删除这两个对print_r()的调用。

  2. 我们现在可以使用array_multisort()对多维数组进行排序。传递给函数的参数顺序决定了分配给最终排序的优先级。我希望播放列表按收视率降序排序,然后按曲目的字母顺序。所以第一个参数需要是$ratings数组,后面是排序方向;然后是$tracks数组,接着是排序方向;最后,$playlist,多维数组。

将以下代码添加到脚本的底部:

  1. 多维数组现在已经从最高评级到最低评级进行了重新排序,同等评级的曲目按字母顺序排列。我们可以通过像这样遍历$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 显示了它工作的证据。

img/332054_5_En_8_Fig6_HTML.jpg

图 8-6

多维数组已按多个标准排序

Note

在前面的 PHP 解决方案中,array_column()与关联子数组一起使用,所以第二个参数是一个字符串,包含我们想要提取的值的键。该函数还能够从索引子数组中提取值。只需传递想要提取的值的索引作为第二个参数。在下一章的“PHP 解决方案 9-6:修改类以处理多次上传”中你会看到一个实际的例子。

PHP 解决方案 8-7:寻找一个数组的所有排列

这个 PHP 解决方案改编自 Python。它使用递归生成器中的array_slice()array_merge()函数(参见第四章中的“生成器:一种特殊类型的不断给出的函数”)来分离数组并以不同的顺序合并元素。它是递归的,因为生成器会反复调用自己,直到到达要处理的元素的末尾。

  1. 生成器的定义是这样的(代码在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()的组合产生一个重新排序的数组。循环第一次运行时,计数器$i0,因此array_slice($permutation, 0, 0)不会从BC中提取任何内容。$elements[0]Aarray_slice($permutation, 0)BC。结果,原始数组ABC被生成。

下一次循环运行时,$i1,于是从$permutation中提取出B$elements[0]仍为Aarray_slice($permutation, 1)C,产生BAC,以此类推。

  1. 要使用permutations()生成器,将一个索引数组作为参数传递给它,并将生成器分配给一个变量,如下所示:

  2. 然后,您可以使用一个带有生成器的foreach循环来获得数组的所有排列(代码在permutations.php中):

    foreach ($perms as $perm) {
        echo implode(' ', $perm) . '<br>';
    }
    
    
$perms = permutations(['A', 'B', 'C']);

这将显示 ABC 的所有排列,如以下屏幕截图所示:

img/332054_5_En_8_Figw_HTML.jpg

处理数组数据

在这一节中,我们将研究两种处理存储在数组中的数据的 PHP 解决方案:从多维关联数组中自动构建 HTML 嵌套列表,以及从 JSON 提要中提取数据。

PHP 解决方案 8-8:自动构建嵌套列表

这个 PHP 解决方案重新访问了标准 PHP 库(SPL)中的RecursiveIteratorIterator,我们在第七章的“用 FilesystemIterator 检查文件夹的内容”中使用过它来挖掘文件系统。像RecursiveIteratorIterator这样的类的一个有用的特性是你可以通过扩展它们来适应你自己的需要。当您扩展一个类时,子类——通常被称为子类——继承其父类的所有公共和受保护的方法和属性。您可以添加新的方法和属性,或者通过重写父类的方法来更改它们的工作方式。RecursiveIteratorIterator公开了几个公共方法,可以重写这些方法,以便在多维关联数组上循环时在数组键和值之间注入 HTML 标记。

Note

类可以将方法和属性声明为公共的、受保护的或私有的。Public 意味着可以在类定义之外访问它们。受保护意味着它们只能在类定义或子类中被访问。Private 意味着它们只能在类定义中访问,而不能在子类中访问。

在构建 PHP 脚本之前,让我们检查一下 HTML 中嵌套列表的结构。下图显示了一个简单的嵌套列表:

img/332054_5_En_8_Figx_HTML.jpg

HTML 代码如下所示:

<ul>
    <li>Label 1
        <ul>
            <li>Item 1</li>
            <li>Item 2</li>
        </ul>
    </li>
</ul>

需要注意的重要一点是,缩进列表嵌套在顶级列表项中。标签 1 的结束标记位于嵌套列表的结束标记之后。手工编写 HTML 嵌套列表容易出错,因为很难跟踪列表项的打开和关闭位置。当用 PHP 自动化嵌套列表时,我们需要记住这种结构。

  1. ch08文件夹中创建一个名为ListBuilder.php的文件。如果你只是想研究完整的代码,它在ListBuilder_end.php中,带有完整的注释。

  2. 定义一个名为ListBuilder的类来扩展RecursiveIteratorIterator,并为要处理的数组和输出 HTML 创建两个受保护的属性:

    class ListBuilder extends RecursiveIteratorIterator
    {
        protected $array;
        protected $output = '';
    }
    
    
  3. 大多数类都有一个构造函数方法来初始化它们并接受任何参数。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

在第九章和第十章中你会学到更多关于类和扩展类的知识。

  1. 一个 HTML 无序列表以开始和结束的<ul>标签开始和结束。RecursiveIteratorIterator有在循环开始和结束时自动调用的公共方法,所以我们可以覆盖它们,用如下组合连接操作符向$output属性添加必要的标记:

    public function beginIteration() {
        $this->output .= '<ul>';
    }
    public function endIteration() {
        $this->output .= '</ul>';
    }
    
    
  2. 在每个子数组的开头和结尾也自动调用两个公共方法。我们可以使用这些来插入嵌套列表的开始标签<ul>,并关闭嵌套列表及其父列表项:

    public function beginChildren() {
        $this->output .= '<ul>';
    }
    public function endChildren() {
        $this->output .= '</ul></li>';
    }
    
    
  3. 为了处理每个数组元素,我们可以覆盖自动调用的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继承而来的ListBuilderkey()方法来获取当前键的值。没有结束的</li>标签,因为直到子数组被处理后才会添加。

如果当前元素没有任何子元素,则执行else子句。它调用current()方法来获取当前元素的值,该元素夹在一对<li>标记之间。

  1. 为了显示嵌套列表,我们需要迭代数组并返回$output属性。我们可以用神奇的__toString()方法。这样定义它:

    public function __toString() {
        // Generate the list
        $this->run();
        return $this->output;
    }
    
    
  2. 要完成ListBuilder类,如下定义run()方法:

    protected function run() {
        self::beginIteration();
        while (self::valid()) {
            self::next();
        }
        self::endIteration();
    }
    
    

这只是调用了从RecursiveIteratorIterator继承的四个方法。他们调用beginIteration(),然后通过while循环运行数组,并结束迭代。

  1. 要测试ListBuilder,打开ch08文件夹中的multidimensional_01.php。它包含一个名为$wines的多维关联数组。包含ListBuilder定义,然后通过添加以下代码生成输出并显示(完整的代码在multidimensional_02.php):

    require './ListBuilder.php';
    echo new ListBuilder($wines);
    
    

图 8-7 显示了结果。

img/332054_5_En_8_Fig7_HTML.jpg

图 8-7

ListBuilder 扩展了 RecursiveIteratorIterator,从多维关联数组中自动构建嵌套列表

PHP 解决方案 8-9:从 JSON 中提取数据

在前一章中,我们使用了SimpleXML来消化一个 RSS 新闻提要。RSS 和其他形式的 XML 分发数据的缺点是,用于包装数据的标签使数据变得冗长。JavaScript Object Notation (JSON)越来越多地被用于在线分发数据,因为它更简洁。虽然简洁的格式使得 JSON 下载速度更快,消耗的带宽更少,但缺点是不容易阅读。

这个 PHP 解决方案从旧金山开放数据( https://datasf.org/opendata/ )访问一个 JSON 提要,将其转换为一个数组,构建数据的多维关联数组,然后过滤它以提取所需的信息。这听起来像是很多艰苦的工作,但它涉及的代码相对较少。

  1. 这个 PHP 解决方案的 JSON 数据源在ch08/data文件夹的film_locations.json中。或者,您可以从 https://data.sfgov.org/api/views/yitu-d5am/rows.json?accessType=DOWNLOAD 获得最新版本。如果您访问在线版本,请将其作为一个.json文件保存在本地硬盘上,以避免不断访问远程提要。

  2. 这些数据由旧金山电影委员会收集的电影拍摄地的数据组成。使用 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 关联数组。

img/332054_5_En_8_Fig8_HTML.jpg

图 8-8

将 JSON 提要转换成关联数组简化了数据位置的识别

  1. 保存文件并在浏览器中运行脚本。这个$data数组非常庞大。它包含了 3400 多部电影的细节。在print_r()周围包裹<pre>标签使得检查结构以识别感兴趣的数据位于何处变得容易。如图 8-8 所示,顶层数组称为meta。嵌套在里面的是一个名为view的子数组,它又包含一个名为columns的子数组。

columns子数组包含一个索引数组;在第一个元素中还有另一个数组,它有一个名为name的键。当你进一步向下滚动找到一个名为data的数组时,这一点的重要性就变得很明显了(见图 8-9 )。

img/332054_5_En_8_Fig9_HTML.jpg

图 8-9

为了紧凑,电影数据存储在索引数组中

Tip

因为 JSON 文件太大了,所以使用浏览器的 Find 实用程序来搜索[数据]。

所有有趣的信息都存储在这里。它包含一个有 3400 多个元素的索引子数组,每个元素包含另一个有 19 个元素的索引数组。数据被映射到图 8-8 中标识的名称数组,而不是数千次重复列名。为了提取我们想要的信息,有必要为这个data数组中的每部电影构建一个关联数组。

  1. 我们可以使用在“PHP 解决方案 8-6:用array_multisort()排序多维数组”中遇到的array_column()函数来获得列名然而,name元素被深埋在顶层数组中,该数组在步骤 2 中被存储为$data。图 8-8 中的缩进有助于找到作为第一个参数传递的正确子数组。将以下代码添加到脚本中(在json_02.php):

img/332054_5_En_8_Fig10_HTML.jpg

图 8-10

列名标识为每个电影位置存储的信息

  1. 使用print_r()检查是否提取了正确的值,如图 8-10 所示。
$col_names = array_column($data['meta']['view']['columns'], 'name');

  1. 现在我们有了列名,我们可以循环通过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 )。

  1. 现在包含一个关联数组的数组,每个数组包含 JSON 提要中列出的 3400 多个电影位置的详细信息。为了定位特定的信息,我们可以使用array_filter()函数,它将一个数组和一个回调函数作为参数,并返回一个新的过滤结果数组。

回调函数接受一个参数,即过滤器正在检查的当前元素。这意味着过滤标准需要在回调中硬编码。为了使回调更具适应性,我将使用一个能够从全局范围继承变量的 arrow 函数。如下定义搜索词和回调函数:

$search = 'Pier 7';
$getLocation = fn($location) => str_contains($location['Locations'], $search);

arrow 函数被分配给一个变量。它采用一个参数$location,表示当前数组元素。回调函数使用str_contains()函数对当前数组的Locations元素中的搜索词执行区分大小写的搜索,这是 PHP 8 的新功能。如果找到搜索词,结果将是true

img/332054_5_En_8_Fig11_HTML.jpg

图 8-11

这些信息是从 JSON 提要的 3400 多个条目中筛选出来的

  1. 我们现在可以过滤$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>';
    
    
  2. 保存脚本并在浏览器中测试。您应该会看到如图 8-11 所示的结果。

  3. 有些电影在 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数组中。如果是,函数返回truecontinue关键字跳过循环的当前迭代。显示结果后,$item['Title']被添加到$duplicates数组中。更新后的代码在json_04.php中。

  1. 再次运行脚本。这一次,重复项被省略。

  2. $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.phplist_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()数组速记将每行生成的数组值赋给变量。

  1. 打开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 文件。

  1. ch08文件夹中创建一个名为csv_list.php的文件,并包含csv_processor.php:

  2. 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
    
    
  3. 通过创建如下所示的csv_processor()生成器实例,将数据加载到 CSV 文件中:

require_once './csv_processor.php';

img/332054_5_En_8_Fig12_HTML.jpg

图 8-12

生成器处理 CSV 文件的每一行,包括列标题

  1. 使用发电机最简单的方法是使用foreach回路。每次循环运行时,生成器都会将 CSV 文件的当前行作为索引数组生成。使用list()数组简写将数组值赋给变量,然后用echo显示它们,如下所示:

    foreach ($scores as $score) {
        [$home, $hscore, $away, $ascore] = $score;
        echo "$home $hscore:$ascore $away<br>";
    }
    
    
  2. 保存文件,并通过将脚本加载到浏览器中来运行脚本。或者,使用ch08文件夹中的csv_list_01.php。如图 8-12 所示,输出包括 CSV 文件中的列标题行。

$scores = csv_processor('./data/scores.csv');

  1. 使用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()方法移动到下一个值,为循环再次运行做好准备。

img/332054_5_En_8_Fig13_HTML.jpg

图 8-13

在迭代剩余的值之前,已经跳过了第一行

  1. 保存文件并再次运行脚本(代码在csv_list_02.php中)。这次只显示分数,如图 8-13 所示。

用 Splat 运算符从数组中解包参数

第四章中简要介绍的 splat 运算符(...)有两个作用,即:

  • 当在函数定义中使用时,它将多个参数转换成可以在函数内部使用的数组。

  • 当调用一个函数时,它解包一个参数数组,把它们当作单独传递给一个函数。

下面的 PHP 解决方案展示了一个简单的例子,展示了它的实用价值。

PHP 解决方案 8-11:用 Splat 操作符处理 CSV 文件

fgetcsv()函数将 CSV 文件中的数据作为索引数组返回。这个 PHP 解决方案展示了如何使用 splat 操作符将数组直接传递给需要多个参数的函数,而无需分隔各个元素。它还使用了前面 PHP 解决方案中描述的csv_processor()生成器。

  1. ch08文件夹中创建一个名为csv_splat.php的文件,并包含csv_processor.php:

  2. ch08/data文件夹中,weather.csv包含以下数据:

require_once './csv_processor.php';

City,temp
London,11
Paris,10
Rome,12
Berlin,8
Athens,19

温度以摄氏度为单位。为了那些相信水在 32 度而不是 0 度结冰的人的利益,我们需要以一种用户友好的方式处理这些数据。

  1. csv_splat.php中,添加如下函数定义(代码在 csv_splat_01.php 中):
function display_temp($city, $temp) {
    $tempF = round($temp/5*9+32);
    return "$city: $temp&deg;C ($tempF&deg;F)";
}

该函数有两个参数:城市名和温度。使用标准公式(除以 5,乘以 9,然后加上 32)将温度转换为华氏温度,并四舍五入为最接近的整数。

然后,该函数返回一个字符串,该字符串由城市名称和以摄氏度表示的温度组成,后跟括号中的华氏温度。

  1. 包含数据的 CSV 文件以一行列标题开始,因此我们需要使用与上一个解决方案相同的技术跳过第一行。将数据加载到csv_processor()生成器中,并像这样跳过第一行:

  2. 使用一个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 显示了使用任一技术的结果。

img/332054_5_En_8_Fig14_HTML.jpg

图 8-14

每个数据数组都通过 splat 操作符直接传递给函数进行了处理

使用 splat 操作符来解包参数数组具有简洁的优点,但是较短的代码并不总是可读性最好的,当您没有得到预期的结果时,这会使调试变得困难。我个人认为,将数组元素赋给变量,然后将其作为参数显式传递是一种更安全的方法。但是即使你不使用特定的技术,如果你需要使用其他人的代码,理解它是如何工作的也是有用的。

第三章回顾

使用数组是 PHP 中最常见的任务之一,尤其是在使用数据库时。数据库查询的几乎所有结果都以关联数组的形式返回,因此理解如何处理它们非常重要。在这一章中,我们已经学习了修改数组,合并数组,排序和提取数据。关于在循环中使用数组,要记住的要点是 PHP 总是在数组的副本上工作,除非您通过引用将值传递到循环中。相比之下,对数组排序的函数在原始数组上工作。

你可以在 PHP 在线文档 www.php.net/manual/en/ref.array.php 中找到所有数组相关函数的全部细节。这一章展示了使用其中大约一半的实际例子,帮助你成为 PHP 中处理数组的专家。

九、上传文件

PHP 处理表单的能力不仅限于文本。它也可以用来上传文件到服务器。例如,你可以为客户建立一个房地产网站来上传他们的房产图片,或者为你所有的朋友和亲戚建立一个网站来上传他们的假期图片。然而,你能做到并不一定意味着你应该去做。允许其他人上传资料到你的网站会让你面临各种各样的问题。您需要确保图像大小合适,质量合适,并且不包含任何非法内容。您还需要确保上传的内容不包含恶意脚本。换句话说,你需要像保护你自己的电脑一样小心地保护你的网站。

PHP 使得限制接受的文件的类型和大小变得相对简单。它不能做的是检查内容的适用性。仔细考虑安全措施,例如通过将上传表单放在受密码保护的区域来限制注册用户和可信用户的上传。

在你学会如何在第 11 和 19 章中限制对 PHP 页面的访问之前,如果部署在公共网站上,只能在受密码保护的目录中使用本章中的 PHP 解决方案。大多数托管公司通过网站的控制面板提供简单的密码保护。

本章的第一部分致力于理解文件上传的机制,这将使理解后面的代码变得更容易。这是一个相当激烈的章节,而不是快速解决方案的集合。但是在本章结束时,你将已经构建了一个能够处理单个和多个文件上传的 PHP 类。然后,只需编写几行代码,就可以以任何形式使用该类。

您将了解以下内容:

  • 了解$_FILES数组

  • 限制上传的大小和类型

  • 防止文件被覆盖

  • 处理多次上传

PHP 如何处理文件上传

术语上传意味着将文件从一台计算机移动到另一台计算机,但就 PHP 而言,所发生的只是文件从一个位置移动到另一个位置。这意味着您可以在本地计算机上测试本章中的所有脚本,而无需将文件上传到远程服务器。

PHP 默认支持文件上传,但是托管公司可以限制上传的大小或者完全禁止上传。在继续之前,最好检查一下远程服务器上的设置。

检查您的服务器是否支持上传

你需要的所有信息都显示在主 PHP 配置页面上,你可以通过在你的远程服务器上运行phpinfo()来显示,如第二章所述。向下滚动,直到在核心部分找到file_uploads

如果本地值为 On,您就可以开始了,但是您还应该检查表 9-1 中列出的其他配置设置。

表 9-1

影响文件上传的 PHP 配置设置

|

管理的

|

缺省值

|

描述

|
| --- | --- | --- |
| max_execution_time | Thirty | PHP 脚本可以运行的最大秒数。如果脚本运行时间更长,PHP 会生成一个致命错误。 |
| max_file_uploads | Twenty | 可以同时上传的最大文件数。多余的文件会被忽略。 |
| max_input_time | –1 | PHP 脚本被允许解析$_POST$_GET数组和文件上传的最大秒数。默认设置为–1,使用与max_execution_time相同的值。非常大的上传很可能会超时。将该值设置为0允许无限时间。 |
| post_max_size | 8M | 所有$_POST数据、包括文件上传的最大允许大小。虽然默认是 8M (8 兆),托管公司可能会施加一个较小的限制。 |
| upload_tmp_dir | 空 | 这是 PHP 存储上传文件的地方,直到您的脚本将它们移动到一个永久的位置。如果在php.ini中没有定义值,PHP 将使用系统默认的临时目录(Mac/Linux 上的C:\Windows\Temp/tmp)。 |
| upload_max_filesize | 2M | 单个上载文件的最大允许大小。默认值为 2M(兆字节),但托管公司可能会设置一个较小的限制。整数表示字节数。k 代表千字节,M 代表兆字节,G 代表千兆字节。 |

理论上,PHP 可以处理非常大的文件的上传,但是限制取决于表 9-1 中的设置。post_max_size的值包含了$_POST数组中的所有内容,所以在一个典型的服务器上可以同时上传的文件的总大小小于 8 MB,没有一个文件大于 2 MB。服务器管理员可以更改这些默认值,因此检查托管公司设置的限制很重要。如果超出这些限制,原本完美的脚本将会失败。

如果file_uploads的本地值关闭,则上传被禁用。你对此无能为力,除了询问你的托管公司是否提供支持文件上传的软件包。您唯一的选择是转移到不同的主机或使用不同的解决方案,如通过 FTP 上传文件。

Tip

在使用phpinfo()检查您的远程服务器的设置后,删除脚本或将其放在受密码保护的目录中。

向表单添加文件上载字段

向 HTML 表单添加文件上传字段很容易。只需将enctype="multipart/form-data"添加到开始的<form>标签中,并将<input>元素的type属性设置为file。以下代码是一个简单的上传表单示例(在ch09文件夹的file_upload_01.php中):

<form action="file_upload.php" method="post" enctype="multipart/form-data">
    <p>
        <label for="image">Upload image:</label>
        <input type="file" name="image" id="image">
    </p>
    <p>
        <input type="submit" name="upload" value="Upload">
    </p>
</form>

虽然这是标准的 HTML,但它在网页中的呈现方式取决于浏览器。大多数现代浏览器显示一个选择文件或浏览按钮,并在右侧显示一条状态消息或所选文件的名称(见图 9-1 )。一些较旧的浏览器会显示一个文本输入字段,当您在该字段内单击时会启动一个文件选择面板。这些差异不会影响上传表单的操作,但是您需要在设计布局时将它们考虑在内。

img/332054_5_En_9_Fig1_HTML.jpg

图 9-1

大多数浏览器都会显示一个按钮来打开文件选择面板

了解$_FILES 数组

令许多人困惑的是,他们的文件上传后似乎就消失了。这是因为,尽管上传表单使用了post方法,PHP 还是在一个名为$_FILES的单独的超全局数组中传输上传文件的细节。此外,文件会上传到临时文件夹,除非您明确地将它们移动到所需的位置,否则它们会被删除。这允许您在接受上传之前对文件进行安全检查。

检查$_FILES 数组

理解$_FILES数组如何工作的最好方法是观察它的运行。您可以在您的计算机上的本地测试环境中测试一切。它的工作方式与上传文件到远程服务器相同。

  1. php8sols站点根目录下创建一个名为uploads的文件夹。在uploads文件夹中创建一个名为file_upload.php的文件,并插入上一节中的代码。或者,从ch09文件夹中复制file_upload_01.php,并将文件重命名为file_upload.php

  2. 在结束的</form>标签后插入以下代码(它也在file_upload_02.php中):

    </form>
    <pre>
    <?php
    if (isset($_POST['upload'])) {
        print_r($_FILES);
    }
    ?>
    </pre>
    </body>
    
    

它使用isset()来检查$_POST数组是否包含upload,即提交按钮的name属性。如果是,那么您知道表单已经提交,所以您可以使用print_r()来检查$_FILES数组。<pre>标签使输出更容易阅读。

img/332054_5_En_9_Fig2_HTML.jpg

图 9-2

$_ FILES 数组包含上传文件的详细信息

  1. 保存file_upload.php并将其加载到浏览器中。

  2. 单击浏览(或选择文件)按钮并选择一个本地文件。单击打开(或在 Mac 上选择)关闭选择对话框,然后单击上传。您应该会看到类似图 9-2 的内容。

$_FILES是多维数组——数组的数组。顶层包含一个元素,它从文件输入字段的name属性中获取键(或索引),在本例中是image

顶层image数组包含一个由五个元素组成的子数组,即:

  • name:上传文件的原始名称

  • type:上传文件的 MIME 类型

  • tmp_name:上传文件的位置

  • error:表示上传状态的整数

  • size:上传文件的大小,以字节为单位

不要浪费时间去寻找tmp_name指示的临时文件:它不会在那里。如果不立即保存,PHP 会丢弃它。

Note

MIME 类型是浏览器用来确定文件格式以及如何处理文件的标准。更多信息见 https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types

img/332054_5_En_9_Fig3_HTML.jpg

图 9-3

没有上传文件时$_ FILES 数组仍然存在

  1. 单击上传,不选择文件。这个$_FILES数组看起来应该如图 9-3 所示。

错误级别为 4 表示没有上传文件;0 表示上传成功。本章后面的表 9-2 列出了所有错误代码。

表 9-2

$_FILES 数组中不同错误级别的含义

|

误差水平

|

意义

|
| --- | --- |
| Zero | 上传成功。 |
| one | 文件超过了在php.ini中指定的最大上传大小(默认为 2 MB)。 |
| Two | 文件超过了由MAX_FILE_SIZE指定的大小(见 PHP 解决方案 9-1)。 |
| three | 文件仅部分上传。 |
| four | 提交的表单没有指定文件。 |
| six | 没有临时文件夹。 |
| seven | 无法将文件写入磁盘。 |
| eight | 上传被未指定的 PHP 扩展停止。 |

  1. 选择一个程序文件,然后单击上传按钮。在许多情况下,表单会很乐意尝试上传程序,并显示其类型为 application/zip、application/octet-stream 或类似的内容。这应该作为一个警告,说明检查上传的文件类型是很重要的。

建立上传目录

出于安全考虑,通过在线表格上传的文件不应通过浏览器公开访问。换句话说,它们不应该在站点根目录中(通常是htdocspublic_htmlwww)。在您的远程服务器上,创建一个用于在站点根目录之外上传的目录,并将权限设置为 644(所有者可以读写;其他人只能看)。

为 Windows 上的本地测试创建上传文件夹

对于下面的练习,我建议你在 c 盘的顶层创建一个名为upload_test的文件夹。在 Windows 上没有权限问题,所以这就是你需要做的。

为 macOS 上的本地测试创建上传文件夹

Mac 用户可能需要做更多的准备,因为文件权限类似于 Linux。在你的主文件夹中创建一个名为upload_test的文件夹,并按照 PHP 解决方案 9-1 中的说明进行操作。

如果一切顺利,你不需要做任何额外的事情。但是,如果您收到 PHP“未能打开流”的警告,请像这样更改upload_test文件夹的权限:

  1. 在 Mac Finder 中选择upload_test,选择文件➤获取信息(Cmd+I)打开其信息面板。

  2. 在“共享与权限”中,单击右下方的挂锁图标解锁设置,然后将所有人的设置从只读更改为读写,如以下截图所示:

img/332054_5_En_9_Figa_HTML.jpg

  1. 再次单击挂锁图标以保存新设置并关闭信息面板。现在你应该能够使用upload_test文件夹继续本章的剩余部分。

上传文件

在构建文件上传类之前,最好创建一个简单的文件上传脚本,以确保您的系统能够正确处理上传。

将临时文件移动到上传文件夹

上传文件的临时版本只有短暂的存在。如果您对该文件不做任何操作,它会立即被丢弃。你需要告诉 PHP 把它移到哪里,用什么来称呼它。使用move_uploaded_file()函数可以做到这一点,该函数有以下两个参数:

  • 临时文件的名称

  • 文件新位置的完整路径名,包括文件名本身

获取临时文件本身的名称很容易:它作为tmp_name存储在$_FILES数组中。因为第二个参数需要完整的路径名,所以它给了您重命名文件的机会。目前,让我们保持简单,使用原始文件名,它作为name存储在$_FILES数组中。

PHP 解决方案 9-1:创建一个基本的文件上传脚本

继续使用与上一练习中相同的文件。或者,使用ch09文件夹中的file_upload_03.php。这个 PHP 解决方案的最终脚本在file_upload_04.php中。

  1. 如果您正在使用上一个练习中的文件,请删除结束标签</form></body>之间以粗体突出显示的代码:

    </form>
    <pre>
    <?php
    if (isset($_POST['upload'])) {
        print_r($_FILES);
    }
    ?>
    </pre>
    </body>
    
    
  2. 除了在 PHP 配置中设置的自动限制(见表 9-1 ),您还可以在 HTML 表单中指定上传文件的最大大小。在文件输入字段前添加以粗体突出显示的以下行:

    <label for="image">Upload image:</label>
    <input type="hidden" name="MAX_FILE_SIZE" value="<?= $max ?>">
    <input type="file" name="image" id="image">
    
    

这是一个隐藏的表单域,因此不会显示在屏幕上。然而,将它放在文件输入域之前是至关重要的;不然就不行了。name属性MAX_FILE_SIZE是固定的,区分大小写。value属性以字节为单位设置上传文件的最大大小。

我没有指定一个数值,而是使用了一个名为$max的变量。该值还将用于文件上传的服务器端验证,因此定义一次是有意义的,避免了在一个地方更改它而忘记在其他地方更改的可能性。

使用MAX_FILE_SIZE的好处是,如果文件大于规定值,PHP 会放弃上传,避免文件太大时不必要的延迟。不幸的是,用户可以通过伪造隐藏字段提交的值来绕过这一限制,所以你将在本章剩余部分开发的脚本也将在服务器端检查大小。

  1. 在 DOCTYPE 声明上方的 PHP 块中定义$max的值,如下所示:

    <?php
    // set the maximum upload size in bytes
    $max = 51200;
    ?>
    <!DOCTYPE HTML>
    
    

这将最大上传大小设置为 50 KB (51,200 字节)。

  1. 将上传文件从临时位置移动到永久位置的代码需要在表单提交后运行。将以下代码插入您刚刚在页面顶部创建的 PHP 块中:

    $max = 51200;
    if (isset($_POST['upload'])) {
        // define the path to the upload folder
        $path = '/path/to/upload_test/';
        // move the file to the upload folder and rename it
        move_uploaded_file($_FILES['image']['tmp_name'],
            $path . $_FILES['image']['name']);
    }
    ?>
    
    

    虽然代码很短,但内容很多。只有当点击了上传按钮时,条件语句才执行代码,方法是检查它的键是否在$_POST数组中。

    $path的值取决于您的操作系统和upload_test文件夹的位置。

    • 如果您使用的是 Windows,并且在 c 盘的顶层创建了upload_test文件夹,它应该是这样的:

      $path = 'C:/upload_test/';

      请注意,我使用了正斜杠,而不是 Windows 约定的反斜杠。你可以使用任何一个,但是如果你使用反斜杠,最后一个需要用另一个反斜杠转义,像这样(否则,反斜杠转义引号):

      $path = 'C:\upload_test\\';

    • 在 Mac 上,如果你在你的个人文件夹中创建了upload_test文件夹,它应该是这样的(用你的 Mac 用户名替换用户名):

      $path = '/Users/``username

    • 在远程服务器上,您需要完全限定的文件路径作为第二个参数。在 Linux 上,它可能是这样的:

      $path = '/home/user/private/upload_test/';

if语句中的最后一行用move_uploaded_file()函数移动文件。该函数有两个参数:临时文件的名称和保存文件的完整路径。

$_FILES是一个多维数组,其名称取自文件输入字段。所以$_FILES['image']['tmp_name']是临时文件,$_FILES['image']['name']包含原始文件的名称。第二个参数$path . $_FILES['image']['name']将上传的文件以其原始名称存储在上传文件夹中。

Caution

您可能会遇到使用copy()而不是move_uploaded_file()的脚本。如果没有其他适当的检查,copy()会使您的网站面临严重的安全风险。例如,恶意用户可能试图欺骗您的脚本复制它不应该访问的文件,如密码文件。始终使用move_uploaded_file();安全多了。

  1. 保存file_upload.php,并将其加载到浏览器中。点击浏览或选择文件按钮,从php8sols站点的images文件夹中选择一个文件。如果您从其他地方选择一个,请确保它小于 50 KB。点按“打开”(在 Mac 上选取),以在表单中显示文件名。点击上传按钮。如果您正在本地测试,表单输入域应该几乎立即被清空。

  2. 导航到upload_test文件夹,确认您选择的图像副本在那里。如果不是,对照file_upload_04.php检查您的代码。如有必要,还要检查是否在上传文件夹上设置了正确的权限。

    注意下载文件使用C:/upload_test/。根据您自己的设置进行调整。

如果您没有收到错误信息并且找不到文件,请确保图像没有超过upload_max_filesize(参见表 9-1 )。还要检查是否没有在$path的末尾留下尾随斜线。你可能会在你的磁盘结构中找到更高一级的upload_testmyfile.jpg,而不是在upload_test文件夹中的myfile.jpg

  1. $max的值改为3000,保存file_upload.php,选择一个大于 2.9 KB 的文件上传(images 文件夹中的任何文件都可以)再次测试。点击上传按钮并检查upload_test文件夹。文件不应该在那里。

  2. 如果您有心情进行实验,请将MAX_FILE_SIZE隐藏字段移动到文件输入字段下方,然后再试一次。确保您选择的文件不同于您在步骤 6 中使用的文件,因为move_uploaded_file()会覆盖同名的现有文件。稍后您将了解如何为文件指定唯一的名称。

这一次,文件应该被复制到您的上传文件夹。隐藏字段必须出现在文件输入元素之前,MAX_FILE_SIZE才能生效。继续之前,请将隐藏字段移回其原始位置。

创建 PHP 文件上传类

正如您刚才看到的,上传一个文件只需要几行代码,但这本身还不足以完成任务。您需要通过实施以下步骤来使该过程更加安全:

  • 检查错误级别。

  • 在服务器上验证文件没有超过最大允许大小。

  • 检查文件是否属于可接受的类型。

  • 删除文件名中的空格。

  • 重命名与现有文件同名的文件,以防止覆盖。

  • 自动处理多个文件上传。

  • 通知用户结果。

每次想要上传文件时,您都需要实现这些步骤,因此构建一个易于重用的脚本是有意义的。这就是我选择使用自定义类的原因。构建 PHP 类通常被认为是一门高级学科,但是不要因此而放弃。如果您需要学习使用类和名称空间的基础知识,请参见第四章中的“构建定制类”。

如果你赶时间,完成的类在 ch09/Php8Solutions 文件夹中。即使您没有自己构建脚本,也要通读描述,这样您就可以清楚地了解它是如何工作的。

PHP 解决方案 9-2:创建基本的文件上传类

在这个 PHP 解决方案中,您将创建一个名为Upload的类的基本定义来处理文件上传。您还将创建该类的一个实例(一个Upload对象),并使用它来上传图像。给自己充足的时间来完成以下步骤。它们并不难,但是如果您从未使用过 PHP 类,它们会引入一些不熟悉的概念。

  1. php8sols站点根文件夹中创建一个名为Php8Solutions的子文件夹。在文件夹名称中使用相同的大小写字母组合。

  2. Php8Solutions文件夹中创建一个名为File(大写 F)的子文件夹。

  3. 在新的Php8Solutions/File文件夹中,创建一个名为Upload.php的文件。同样,在文件名中使用相同的大小写字母组合。然后插入以下代码:

    <?php
    namespace Php8Solutions\File;
    class Upload {
    }
    
    

所有剩下的代码都在花括号之间。这个文件将只包含 PHP 代码,所以你不需要一个结束的 PHP 标签。

Note

尽管该类改编自本书第三版和第四版的版本,但它通过 PHP 中的新特性改变了构造函数的签名。因此,我使用了与以前版本不同的名称空间。面向对象编程的一个重要原则是,即使类的内部结构发生变化,用户界面也应该保持不变。使用不同的名称空间表明 Upload 类不仅仅是可以插入现有脚本的更新版本。

  1. PHP 类通过将一些变量和函数声明为 protected 来隐藏它们的内部工作。如果你用关键字protected作为变量或函数的前缀,它只能在类或子类中被访问。这可以防止值被意外更改。

Upload类需要以下项目的受保护变量:

  • 上传文件夹的路径

  • 最大文件大小

  • 允许的 MIME 类型

  • 报告上传状态的消息

通过在花括号内添加变量,为允许的 MIME 类型和消息创建变量,如下所示:

class Upload {
    protected $permitted = [
        'image/gif',
        'image/jpeg',
        'image/pjpeg',
        'image/png',
        'image/webp'
    ];
    protected $messages = [];
}

使用引用当前对象的$this->,可以在类的其他地方访问这些属性。例如,在类定义中,您以$this->permitted的身份访问$permitted

Note

当你第一次在一个类中声明一个属性时,它像其他变量一样以美元符号开始。但是,您在- >操作符后面省略了属性名中的美元符号。

两个受保护的属性都被赋予了默认值:

  1. 创建对象时,类定义文件自动调用该类的构造函数方法,该方法初始化对象。所有类的构造函数方法被称为__construct()(带有两个下划线)。与您在上一步中定义的属性不同,构造函数需要在类之外可访问,因此您在它的定义之前添加了public关键字。
  • 包含一个图像 MIME 类型的数组。

  • $messages是一个空数组。

Upload类的构造函数有三个参数:

  • 上传表单中文件字段的名称

  • 您要上传文件的文件夹的路径

  • 允许的最大文件大小(这将有一个默认值,使其可选)

第二个和第三个参数将使用 PHP 8 新增的构造函数属性提升特性来创建受保护的属性(参见第四章中的“使用构造函数属性提升”)。将以下代码添加到受保护的属性列表之后,确保它在类定义的右大括号之前:

public function __construct(
        string $field,
        protected string $path,
        protected int $max = 51200
    ) {
        if (!is_dir($this->path) && !is_writable($this->path)) {
            throw new \Exception("$this->path must be a valid, writable directory.");
        } else {
            $this->path = rtrim($this->path, '/\\') . DIRECTORY_SEPARATOR;
            if ($this->checkFile($_FILES[$field])) {
                $this->moveFile($_FILES[$field]);
            }
        }
    }

每个参数前面都有一个类型声明,指定前两个必须是字符串,第三个必须是整数。第二个和第三个参数前面有一个可见性声明,使得它们的值可以通过使用$this的类定义的其余部分来访问。

构造函数中的条件语句将$path受保护的属性传递给is_dir()is_writable()函数,这两个函数检查提交的值是可写的有效目录(文件夹)。如果不是,构造函数抛出一个异常。

Note

类可以定义自己的异常,因为 Upload 类是在名称空间中定义的,所以不清楚构造函数应该使用自定义异常还是 PHP 的核心部分Exception类。要访问名称空间中的核心命令,需要在它们前面加一个反斜杠。这就是为什么 Exception 前面有一个反斜杠。我们使用的是核心异常类,而不是自定义的。

如果该值是一个有效的可写目录,rtrim()函数从$path的末尾删除任何空格和斜杠,然后连接操作系统的正确目录分隔符。这确保了路径以斜杠结束,不管它是否是用户在创建Upload对象时添加的。当只有一个参数传递给rtrim()时,它只移除空白。第二个可选参数是一个字符串,它包含所有要去除的字符。转义右引号需要两个反斜杠。

嵌套的条件语句将$_FILES[$field]传递给我们接下来要定义的两个内部方法。$field的值来自传递给构造函数的第一个参数,因此它将包含文件输入字段的名称。

Tip

$_FILES是 PHP 的超全局数组之一,因此它在脚本的所有部分都可用。这就是为什么不需要将它作为参数传递给类构造函数方法。

  1. 嵌套的条件语句使用$this关键字调用checkFile()$this关键字也用于调用类中定义的函数(方法)。目前,我们假设该文件没有问题,因此checkFile()将简单地返回true。将以下代码添加到类定义中:

    protected function checkFile($file) {
        return true;
    }
    
    

在定义前面加上protected关键字意味着这个方法只能在类内部访问。我们将返回 PHP 解决方案 9-3 中的checkFile(),在上传文件之前添加一系列测试。

Tip

类中函数(方法)定义的顺序并不重要,只要它们在类的花括号中。然而,我倾向于将所有公共方法放在顶部,受保护的方法放在底部。

  1. 如果文件通过了一系列测试,upload()方法中的条件语句将文件传递给另一个名为moveFile()的内部方法,它基本上是我们在 PHP 解决方案 9-1 中使用的move_uploaded_file()函数的包装器。代码如下所示:

    protected function moveFile($file) {
        $success = move_uploaded_file($file['tmp_name'],
            $this->destination . $file['name']);
        if ($success) {
            $result = $file['name'] . ' was uploaded successfully';
            $this->messages[] = $result;
        } else {
            $this->messages[] = 'Could not upload ' . $file['name'];
        }
    }
    
    

如果上传成功,move_uploaded_file()返回true。否则,它返回false。通过将返回值存储在$success中,适当的消息被存储在$messages数组中。如果$success为真,则消息最初被分配给$result,而如果失败,则直接分配给$messages数组。这是因为如果文件需要重命名,更多的信息将被添加到成功消息中。

  1. 由于$messages是一个受保护的属性,您需要创建一个公共方法来检索数组的内容:

    public function getMessages() {
        return $this->messages;
    }
    
    

这只是返回$messages数组的内容。既然这就是它的全部功能,为什么不首先将数组公开呢?可以在类定义之外访问和更改公共属性。保护$messages确保数组的内容不会被修改,所以您知道消息是由类生成的。对于这样的消息,这可能看起来没什么大不了的,但是当您开始处理更复杂的脚本或在团队中工作时,这就变得非常重要了。

  1. 保存Upload.php并切换到file_upload.php

  2. file_upload.php的顶部,通过在开始的 PHP 标签后添加下面一行来导入Upload类:

use Php8Solutions\File\Upload;

Caution

您必须在脚本的顶层导入命名空间类,即使类定义是在以后加载的。将use放在条件语句中会产生一个解析错误。

  1. 在条件语句中,删除调用move_uploaded_file()函数的代码,然后使用require_once来包含Upload类定义:

    if (isset($_POST['upload'])) {
        // define the path to the upload folder
        $path = 'C:/upload_test/';
        require_once '../Php8Solutions/File/Upload.php';
    }
    
    
  2. 我们现在可以创建一个Upload类的实例,但是因为它可能抛出一个异常,所以最好创建一个try/catch块(参见第四章中的“处理错误和异常”)。在上一步插入的代码后立即添加以下代码:

    try {
        $loader = new Upload('image', $path);
        $result = $loader->getMessages();
    } catch (Throwable $t) {
        echo $t->getMessage();
    }
    
    

这创建了一个名为$loaderUpload类的实例,通过向它传递文件输入字段的名称和upload_test文件夹的路径。然后,它调用getMessages()方法,将结果存储在$result中。

catch块将捕获内部错误和异常,因此类型声明是Throwable而不是Exception。没有必要在Throwable前面加上反斜杠,因为file_upload.php中的脚本不在名称空间中。只有类定义在命名空间中。

Caution

Upload类有一个getMessages()方法,而异常使用getMessage()。多一个“s”会有所不同。

  1. 在表单上方添加以下 PHP 代码块,以显示由$loader对象返回的任何消息:

    <body>
    <?php
    if (isset($result)) {
        echo '<ul>';
        foreach ($result as $message) {
            echo "<li>$message</li>";
        }
    echo '</ul>';
    }
    ?>
    <form action="file_upload.php" method="post" enctype="multipart/form-data">
    
    

这是一个简单的foreach循环,将$result的内容显示为一个无序列表。当页面第一次加载时,$result没有设置,所以这段代码只在表单提交后运行。

img/332054_5_En_9_Fig4_HTML.jpg

图 9-4

Upload类报告成功上传

  1. 保存file_upload.php并在浏览器中测试。只要你选择了一个小于 50 KB 的图像,你就会看到文件上传成功的确认,如图 9-4 所示。

您可以将您的代码与ch09文件夹中的file_upload_05.phpPhp8Solutions/File/Upload_01.php进行比较。

这个类做的和 PHP 解决方案 9-1 完全一样:它上传一个文件,但是它需要更多的代码来完成。但是,您已经为将要对上传的文件执行一系列安全检查的类打下了基础。这是你只需编写一次的代码。当您使用该类时,您不需要再次编写这些代码。

如果您以前没有使用过对象和类,一些概念可能看起来很奇怪。将$loader对象简单地看作是访问您在Upload类中定义的函数(方法)的一种方式。你经常创建单独的对象来存储不同的值,例如,当处理DateTime对象时。在这种情况下,单个对象足以处理文件上传。

检查上传错误

目前,Upload类不加选择地上传任何类型的文件。甚至可以绕过 50 KB 的限制,因为唯一的检查是在浏览器中进行的。在将文件交给moveFile()方法之前,checkFile()方法需要运行一系列测试。其中最重要的是检查由$_FILES数组报告的错误级别。表 9-2 显示了错误等级的完整列表。

错误等级 5 目前尚未定义。

PHP 解决方案 9-3:测试错误级别、文件大小和 MIME 类型

这个 PHP 解决方案更新了checkFile()方法,以调用一系列内部(受保护的)方法来验证该文件是否可以接受。如果文件由于任何原因失败,将会有一条错误消息报告原因。继续与Upload.php合作。或者,使用ch09/Php8Solutions/File文件夹中的Upload_01.php,将其移动到php8sols站点顶层的Php8Solutions/File,并将其重命名为Upload.php。(总是从部分完成的文件中删除下划线和数字。)

  1. checkFile()方法需要运行三个测试:错误级别、文件大小和文件的 MIME 类型。像这样更新方法定义:

    protected function checkFile($file) {
        $errorCheck = $this->getErrorLevel($file);
        $sizeCheck = $this->checkSize($file);
        $typeCheck = $this->checkType($file);
        return $errorCheck && $sizeCheck && $typeCheck;
    }
    
    

    传递给checkFile()方法的参数是$_FILES数组中的顶级元素。我们正在使用的表单中的上传字段被称为image,所以$file相当于$_FILES['image']

    原来,checkFile()只是简单的返回了true。现在,它运行一系列的内部方法,稍后你会定义这些方法。如果文件通过测试,每个方法都将返回true。否则,如果发现上传的文件有问题,它将返回false,并向$messages数组追加一条适当的错误消息。当每组检查完成后,checkFile()返回检查的组合结果。如果任何测试失败,它返回false并阻止文件上传。否则,它返回true,允许上传文件。

  2. getErrorLevel()方法使用一个match语句来检查表 9-2 中列出的错误等级。如果错误等级为 0,则表示文件上传成功,因此返回true。否则,它创建一个合适的消息添加到$messages数组并返回$result。代码如下所示:

    protected function getErrorLevel($file) {
            $result = match($file['error']) {
                0 => true,
                1, 2 => $file['name'] . ' is too big: (max: ' . $this->getMaxSize() . ').',
                3 => $file['name'] . ' was only partially uploaded.',
                4 => 'No file submitted.',
                default => 'Sorry, there was a problem uploading ' . $file['name']
            };
            return $result;
        }
    
    

    错误级别 1 和 2 的部分消息由一个名为getMaxSize()的方法创建,该方法将$max的值从字节转换为千字节。你将很快定义getMaxSize()

    只有前四个错误级别有描述性消息。关键字default捕捉其他错误级别,包括将来可能添加的任何错误级别,并添加一个通用原因。

  3. 因为如果有问题,getErrorLevel()中的match语句会返回一条错误消息,所以我们需要将它添加到$messages属性中。修改checkFile()方法来处理返回值,如下所示:

    $errorCheck = $this->getErrorLevel($file);
    if ($errorCheck !== true) {
        $this->messages[] = $errorCheck;
        $errorCheck = false;
    }
    $sizeCheck = $this->checkSize($file);
    
    

    这使用不相同的比较运算符来检查返回值。如果不是布尔值true,则getErrorLevel()中的match语句返回的错误信息被添加到$messages属性中,$errorCheck被重置为false

  4. checkSize()方法如下所示:

    protected function checkSize($file) {
        if ($file['error'] == 1 || $file['error'] == 2 ) {
            return false;
        } elseif ($file['size'] == 0) {
            $this->messages[] = $file['name'] . ' is an empty file.';
            return false;
        } elseif ($file['size'] > $this->max) {
            $this->messages[] = $file['name'] . ' exceeds the maximum size
                for a file (' . $this->getMaxSize() . ').';
            return false;
        }
        return true;
    }
    
    

    条件语句从检查错误级别开始。如果是 1 或 2,说明文件太大,所以方法简单地返回false。已经通过getErrorLevel()方法设置了适当的错误消息。

    下一个条件检查报告的大小是否为零。虽然如果文件太大或者没有选择文件会发生这种情况,但是这些情况已经被getErrorLevel()方法所涵盖。所以假设文件是空的。生成适当的消息,该方法返回false

    接下来,将报告的大小与存储在$max属性中的值进行比较。尽管太大的文件应该触发错误级别 2,但是您仍然需要进行这种比较,以防用户设法避开MAX_FILE_SIZE。错误信息也使用getMaxSize()显示最大尺寸,然后返回false

    如果大小合适,该方法返回true

  5. 第三个测试检查 MIME 类型。将以下代码添加到类定义中:

    protected function checkType($file) {
        if (!in_array($file['type'], $this->permitted)) {
            $this->messages[] = $file['name'] . ' is not a permitted type of file.';
            return false;
        }
        return true;
    }
    
    

    条件语句使用带有逻辑 Not 运算符的in_array()函数,根据存储在$permitted属性中的数组检查由$_FILES数组报告的类型。如果不在数组中,拒绝的原因被添加到$messages数组中,该方法返回false。否则返回true

  6. getErrorLevel()checkSize()使用的getMaxSize()方法将存储在$max中的原始字节数转换成更友好的格式。将以下定义添加到类文件中:

    public function getMaxSize() {
        return number_format($this->max/1024, 1) . ' KB';
    }
    
    

    这使用了number_format()函数,它通常有两个参数:想要格式化的值和想要该数字具有的小数位数。第一个参数是$this->max/1024,它将$max除以 1024(一千字节中的字节数)。第二个参数是 1,所以数字被格式化为一个小数位。最后的. ' KB'将 KB 连接到格式化的数字。

    如果您想在使用Upload类的脚本的另一部分显示值,那么getMaxSize()方法已经被声明为公共的。

  7. 保存Upload.php并用file_upload.php再次测试。对于小于 50 KB 的图像,它的工作方式和以前一样。但是如果你尝试上传一个太大并且 MIME 类型错误的文件,你会得到类似图 9-5 的结果。

您可以对照ch09/Php8Solutions/File文件夹中的Upload_02.php来检查您的代码。

img/332054_5_En_9_Fig5_HTML.jpg

图 9-5

该类现在报告大小和 MIME 类型无效的错误

更改受保护的属性

$permitted属性只允许上传图像,$max属性限制文件不能超过 50 KB,但是这些限制可能太严格了。您可以通过使用上传构造函数的可选第三个参数来更改$max。让我们为$permitted属性添加另一个可选参数。

PHP 解决方案 9-4:允许上传不同类型和大小的文件

这个 PHP 解决方案向你展示了如何允许上传其他类型的文件,以及改变最大允许大小。您还将看到当您不想更改所有可选参数时,如何使用命名参数来避免提供所有参数的需要。

继续使用以前的 PHP 解决方案中的Upload.php。或者,使用ch09/Php8Solutions/File文件夹中的Upload_02.php

  1. 为了使Upload类更加灵活,向构造函数签名添加另一个可选参数,如下所示:

    public function __construct(
            string $field,
            protected string $path,
            protected int $max = 51200,
            string|array|null $mime = null
        ) {
    
    

    $mime参数前面有一个union类型声明(参见第四章中的“指定多种数据类型”),允许字符串、数组或 null。默认值为 null。

  2. 编辑构造函数方法中的else块,向$permitted属性添加新的 MIME 类型,如下所示:

    } else {
        $this->path = rtrim($this->path, '/\\') . DIRECTORY_SEPARATOR;
        if (!is_null($mime)) {
            $this->permitted = array_merge($this->permitted, (array) $mime);
        }
            if ($this->checkFile($_FILES[$field])) {
                $this->moveFile($_FILES[$field]);
            }
        }
    
    

    嵌套的条件语句使用带有逻辑 Not 运算符的is_null()函数来检查$mime是否为null。如果不是,那么array_merge()函数会将$mime附加到$permitted属性中的数组。array_merge()的第二个参数前面是数组转换运算符(见第四章中的表 4-1)。如果单个 MIME 类型作为字符串传递给构造函数,那么这个函数会将$mime转换成一个数组。

  3. 保存Upload.php并再次测试file_upload.php。它应该像以前一样继续上传小于 50 KB 的图像。

  4. 修改file_upload.php,将可选参数$mime的命名参数添加到Upload构造函数中,如下所示:

$loader = new Upload('image', $path, mime: 'application/pdf');

命名参数使用去掉前导$符号后的参数名,后跟一个冒号。如果您不想改变它们的值,它们允许您跳过其他可选参数。

  1. 再次测试file_upload.php上传 PDF 文件。如果小于 50 KB,应该可以正常工作。但是,如果文件超过 50 KB,您应该会看到类似于图 9–6 的内容。

img/332054_5_En_9_Fig6_HTML.jpg

图 9-6

检查 MIME 类型时似乎有错误

发生的情况是文件没有被上传,因为它的大小超过了MAX_FILE_SIZE。因此,$_FILES数组的type元素没有值。当这种情况发生时,尝试检查 MIME 类型是没有意义的。

  1. 修改checkFile()方法,将对checkType()的调用包装在条件语句中,如下所示:

    protected function checkFile($file) {
        $errorCheck = $this->getErrorLevel($file);
        if ($errorCheck !== true) {
            $this->messages[] = $errorCheck;
            $errorCheck = false;
        }
        $sizeCheck = $this->checkSize($file);
        $typeCheck = false;
        if (!empty($file['type'])) {
            $typeCheck = $this->checkType($file);
        }
       return $errorCheck && $sizeCheck && $typeCheck;
    }
    
    

    这使用带有逻辑非运算符的empty()函数来验证$_FILES数组的type元素不为空。但是,如果不执行对 MIME 类型的检查,$typeCheck将是一个未定义的变量,因此需要在条件语句之前将其初始化为false。如果类型正常,$typeCheck将被checkType()方法重置为true

  2. 如果您再次用一个大的 PDF 文件测试上传表单,错误消息应该不再显示。

  3. 充分更改upload_file.php顶部的$max的值,以上传大 PDF(代码就在处理上传的条件语句之前)。您还需要将$max传递给try块中的构造函数。通常,它应该是第三个参数,但是使用命名参数的一个优点是它们可以按任何顺序排列。像这样修改对构造函数的调用:

  4. 通过改变$max的值并将其传递给构造函数,可以影响表单隐藏字段中的MAX_FILE_SIZE和存储在类中的最大值。在再次测试之前,保存file_upload.php并在浏览器中重新加载。这对于刷新隐藏表单字段中的值MAX_FILE_SIZE是必要的。现在一切都应该正常工作了。

$loader = new Upload('image', $path, mime: 'application/pdf', max: $max);

您可以对照ch09/Php8Solutions/File文件夹中的Upload_03.php来检查您的类定义。在ch09文件夹的file_upload_06.php里有一个上传表格的更新版本。

到目前为止,我希望您已经明白了如何从专门做某项工作的函数(方法)中构建一个 PHP 类。修正关于 PDF 不是允许类型的错误消息变得更加容易,因为该消息只能来自于checkType()方法。方法定义中使用的大部分代码依赖于内置的 PHP 函数。一旦您了解了哪些函数最适合手头的任务,构建一个类——或任何其他 PHP 脚本——就变得容易多了。

PHP 解决方案 9-5:重命名文件

默认情况下,如果上传的文件与上传文件夹中的文件同名,PHP 会覆盖现有文件。这个 PHP 解决方案改进了Upload类,增加了在名称冲突时在文件扩展名前插入数字的选项。它还用下划线替换文件名中的空格,因为空格有时会引起问题。

继续使用以前的 PHP 解决方案中的Upload.php。或者,使用ch09/Php8Solutions/File文件夹中的Upload_03.php

  1. 将一个新的受保护属性添加到位于Upload.php中的类定义顶部的现有属性中:
protected $newName;

这将用于存储文件的新名称,如果它被更改。

  1. 向构造函数签名添加第五个可选参数,以控制重复项的重命名,如下所示:

    public function __construct(
            string $field,
            protected string $path,
            protected int $max = 51200,
            string|array|null $mime = null,
            bool $rename = true
        ) {
    
    

这使得重命名文件成为默认设置。

img/332054_5_En_9_Fig7_HTML.jpg

图 9-7

空格已被下划线取代

  1. 我们需要在文件名通过了由checkFile()方法运行的其他测试之后检查它。将下面以粗体突出显示的行添加到构造函数方法的最后一个条件语句中:

    if ($this->checkFile($_FILES[$field]) {
        $this->checkName($_FILES[$field], $rename);
        $this->moveFile($_FILES[$field]);
    }
    
    

    如果文件没有通过之前的任何测试,你不需要检查文件名,所以只有当checkFile()返回true时,加粗的代码才会调用新方法checkName()

  2. checkName()定义为受保护的方法。代码的第一部分如下所示:

    protected function checkName($file, $rename) {
        $this->newName = null;
        $nospaces = str_replace(' ', '_', $file['name']);
        if ($nospaces != $file['name']) {
            $this->newName = $nospaces;
        }
    }
    
    

    该方法首先将$newName属性设置为null(换句话说,没有值)。该类最终将能够处理多个文件上传。因此,每次都需要重置该属性。

    然后,str_replace()函数用下划线替换文件名中的空格,并将结果赋给$nospaces。PHP 解决方案 5-4 中描述了str_replace()函数。

    $nospaces的值与$file['name']进行比较。如果它们不相同,$nospaces被赋值为$newName属性的值。

    它处理文件名中的空格。在处理重复文件名之前,让我们修复将上传的文件移动到目的地的代码。

  3. 如果名称已经更改,moveFile()方法在保存文件时需要使用修改后的名称。像这样更新moveFile()方法的开头:

    protected function moveFile($file) {
        $filename = $this->newName ?? $file['name'];
        $success = move_uploaded_file($file['tmp_name'], $this->path . $filename);
        if ($success) {
    
    

    新的第一行使用零合并操作符(参见第四章中的“使用零合并操作符设置默认值”)为$filename赋值。如果已经通过checkName()方法设置了$newName属性,则使用新名称。否则,包含来自$_FILES数组的原始值的$file['name'],被分配给$filename

    在第二行,$filename替换连接到$path属性的值。因此,如果名称已经更改,新名称将用于存储文件。但是如果没有进行更改,则使用原始名称。

  4. 让用户知道文件名是否被更改是一个好主意。对moveFile()中的条件语句进行以下更改,如果文件已成功上传,该语句将创建消息:

    if ($success) {
        $result = $file['name'] . ' was uploaded successfully';
        if (!is_null($this->newName)) {
            $result .= ', and was renamed ' . $this->newName;
        }
        $this->messages[] = $result;
    }
    
    

    如果$newName属性不是null,那么您知道文件已经被重命名,并且使用组合连接操作符(.=)将该信息添加到存储在$result中的消息中。

  5. 保存Upload.php并测试名称中含有空格的上传文件。空格应该用下划线代替,如图 9-7 所示。

  6. 接下来,将重命名重复文件的代码添加到checkName()方法中。在方法的右大括号前插入以下代码:

    if ($rename) {
        $name = $this->newName ?? $file['name'];
            if (file_exists($this->path . $name)) {
            // rename file
            $basename = pathinfo($name, PATHINFO_FILENAME);
            $extension = pathinfo($name, PATHINFO_EXTENSION);
            $this->newName = $basename . '_' . time() . ".$extension";
        }
    }
    
    

    条件语句检查$renametrue还是false。只有当它是true时,大括号内的代码才会被执行。

    条件块中的第一行代码使用 null 合并操作符来设置$name的值。这与moveFile()方法中使用的技术相同。如果$newName属性有一个值,那么这个值被分配给$name。否则,将使用原始名称。

    然后,我们可以通过将$name连接到$path属性来获取完整路径并将其传递给file_exists()函数,从而检查是否已经存在同名文件。如果上传目录中已经有一个同名文件,那么返回true

    如果一个同名文件已经存在,接下来的两行使用pathinfo()分别使用常量PATHINFO_FILENAMEPATHINFO_EXTENSION将文件名分成基本名和扩展名。既然我们已经将基本名称和扩展名存储在不同的变量中,那么通过在基本名称和扩展名之间插入一个数字来构建新名称就很容易了。理想情况下,这些数字应该从 1 开始递增。然而,在一个繁忙的网站上,这将消耗大量资源,并且不能保证防止两个人同时上传同名文件的竞争情况。我选择了一个更简单的解决方案,在基本名称和扩展名之间插入一个下划线,后跟当前的 Unix 时间戳。time()函数返回自 1970 年 1 月 1 日午夜 UTC(协调世界时)以来的秒数。

  7. 保存Upload.php并测试file_upload.php中修改后的类。首先为rename添加一个命名参数,并在对Upload构造函数的调用中将其设置为false,如下所示:

  8. 多次上传同一个文件。您应该会收到上传成功的消息,但是当您检查upload_test文件夹的内容时,应该只有该文件的一个副本。每次都会被覆盖。

  9. 从对构造函数的调用中移除最后一个参数:

$loader = new Upload('image', $path, mime: 'application/pdf', max: $max, rename: false);

img/332054_5_En_9_Fig8_HTML.jpg

图 9-8

这个类删除文件名中的空格,防止文件被覆盖。

  1. 保存file_upload.php并重复测试,多次上传相同的文件。每次上传文件时,您应该会看到一条消息,说明文件已被重命名。

  2. 通过检查upload_test文件夹的内容来检查结果。你应该会看到类似图 9-8 的东西。

$loader = new Upload('image', $path, mime: 'application/pdf', max: $max);

如有必要,对照ch09/Php8Solutions/File文件夹中的Upload_04.php检查您的代码。

Tip

在这个 PHP 解决方案中,使用命名参数不是绝对必要的,因为总是使用前两个可选参数,尽管与构造函数签名中的顺序不同。命名参数的价值在于能够跳过可选参数,同时设置函数签名中稍后列出的其他参数。命名参数只在 PHP 8 和更高版本中可用。

上传多个文件

您现在有了一个灵活的文件上传类,但是它一次只能处理一个文件。将multiple属性添加到文件字段的<input>标签允许在 HTML5 兼容浏览器中选择多个文件。

构建Upload类的最后一步是让它处理多个文件。为了理解代码是如何工作的,您需要看看当一个表单允许多次上传时,$_FILES数组会发生什么。

$_FILES 数组如何处理多个文件

因为$_FILES是一个多维数组,它能够处理多次上传。除了向<input>标签添加multiple属性之外,您还需要向name属性添加一对空方括号,如下所示:

<input type="file" name="image[]" id="image" multiple>

正如您在第六章中了解到的,向name属性添加方括号会将多个值作为一个数组提交。您可以通过使用ch09文件夹中的multi_upload.php来检查这对$_FILES数组的影响。图 9-9 显示了在支持multiple属性的浏览器中选择三个文件的结果。

img/332054_5_En_9_Fig9_HTML.jpg

图 9-9

$_ FILES 数组可以在一次操作中上传多个文件

虽然这种结构不如将每个文件的详细信息存储在单独的子数组中方便,但是数字键可以跟踪每个文件的详细信息。比如$_FILES['image']['name'][2]$_FILES['image']['tmp_name'][2]直接相关,等等。

Tip

如果你需要在旧的浏览器上支持多个文件上传,忽略multiple属性,为你想要同时上传的任意多个文件创建单独的文件输入域。给每个<input>标签相同的name属性,后跟方括号。$_FILES阵列的最终结构与图 9-9 中的相同。

PHP 解决方案 9-6:修改类以处理多次上传

这个 PHP 解决方案展示了如何修改Upload类的构造方法来处理多个文件上传。当$_FILES数组的结构如图 9-9 所示时,该类会自动检测,并使用一个循环来处理上传的文件。

当您从一个只处理单次上传的表单上传文件时,$_FILES数组将文件名作为字符串存储在$_FILES['image']['name']中。但是当你从一个能够处理多次上传的表单上传时,$_FILES['image']['name']是一个数组。即使只上传了一个文件,其名称也存储为$_FILES['image']['name'][0]

因此,通过检测name元素是否是一个数组,您可以决定如何处理$_FILES数组。如果name元素是一个数组,您需要将每个文件的细节提取到单独的数组中,然后使用一个循环来处理每个数组。

记住这一点,继续使用现有的类文件。或者,使用ch09/Php8Solutions/File文件夹中的Upload_04.php

  1. 通过添加条件语句来检查$_FILES[$field]name元素是否是数组,从而修改构造函数方法。新代码位于更新$permitted属性的部分和对checkFile()的调用之间:

    if (!is_null($mime)) {
        $this->permitted = array_merge($this->permitted, $mime);
    }
    $uploaded = $_FILES[$field];
    if (is_array($uploaded['name'])) {
        // deal with multiple uploads
    } else {
        if ($this->checkFile($_FILES[$field])) {
    
    

    新代码首先将$_FILES[$field]赋给一个简单的变量$uploaded。这避免了在稍后添加的代码中使用嵌套数组引用的需要,例如$_FILES[$field] ['name']

    如果$uploaded['name']是数组,需要特殊处理。对checkFile()的现有调用现在进入一个新的else块。

  2. 为了处理多个上传,挑战在于收集与单个文件相关联的五个值(nametype等)。)然后将它们传递给checkFile()checkName()moveFile()方法。

    如果参考图 9-9,$uploaded数组中的每个元素都是一个索引数组。因此,第一个文件的名称在name子数组的索引0处,其类型在type子数组的索引0处,依此类推。我们可以使用一个循环来提取索引0处的每个值,并将这些值与相关的键组合起来。

    首先,我们需要找出上传了多少文件。这很容易通过将name子数组传递给count()函数来完成。在多次上传注释后添加以下代码,如下所示:

    // deal with multiple uploads
    $numFiles = count($uploaded['name']);
    
    
  3. 接下来,通过在下一行添加以下代码来提取子数组键:

$keys = array_keys($uploaded);

这将创建一个由nametypetmp_file等组成的数组。

  1. 现在我们可以创建一个循环来构建每个文件细节的数组。在刚刚插入的代码后添加以下代码:

    for ($i = 0; $i < $numFiles; $i++) {
        $values = array_column($uploaded, $i);
        $currentfile = array_combine($keys, $values);
        print_r($currentfile);
    }
    
    

    这个循环重新组织了$_FILES数组的内容,这样每个文件的细节都是可用的,就好像它们是单独上传的一样。换句话说,不是所有的nametype和其他元素被组合在一起,$currentfile包含一个单个文件细节的关联数组,可以使用我们已经在Upload类中定义的方法来处理。

    它只用两行代码就实现了这一点。所以让我们来看看到底发生了什么。array_column()函数从一个多维数组中提取子数组中的所有元素,这些子数组传递给它的键或索引与第二个参数相同。在这种情况下,第二个参数是计数器$i。当循环第一次运行时,$i0。所以它在$uploaded(换句话说就是$_FILES['image'])的每个子数组中提取索引0处的值。每个子阵列都有不同的键(nametype等)。)无关紧要;array_column()仅在每个子数组中搜索匹配的键或索引。实际上,它获取了已上传的第一个文件的详细信息。

    然后,array_combine()函数构建一个数组,将每个值分配给其相关的键。因此,name子阵列的索引0处的值变为$currentfile['name'],而type子阵列的索引0处的值变为$currentfile['type'],以此类推。

    下一次循环运行时,$i递增,构建第二个文件的细节数组。循环会一直运行,直到所有文件的细节都被处理完。因为这在概念上很难理解,所以我添加了print_r()来检查结果。

  2. 保存Upload.php。为了测试它,通过在文件字段中的name属性的末尾添加一对方括号来更新file_upload.php,并插入multiple属性,如下所示:

<input type="file" name="image[]" id="image" multiple>

不需要对 DOCTYPE 声明上面的 PHP 代码做任何修改。单次和多次上传的代码是相同的。

img/332054_5_En_9_Fig10_HTML.jpg

图 9-10

每个上传文件的详细信息现在位于不同的数组中。

  1. 保存file_upload.php并在浏览器中重新加载。通过选择多个文件来测试它。当您单击上传时,每个文件的详细信息应该显示在单独的数组中。右键单击查看浏览器的源代码。您应该会看到类似图 9-10 的内容。

  2. 现在我们有了每个文件的单独的细节数组,我们可以像以前一样处理它们。简单的方法是从else块复制下面的代码块,并将其粘贴到for循环中,代替对print_r()的调用(将\(_FILES[\)field]的所有实例更改为$currentfile):

    if ($this->checkFile($_FILES[$field])) {
        $this->checkName($_FILES[$field], $rename);
        $this->moveFile($_FILES[$field]);
    }
    
    

    只有四行代码,所以重复似乎没什么大不了的。但是,将来您可能需要编辑代码,也许是为了添加进一步的检查。然后,您需要对这两个块进行相同的更改——这就是代码错误开始出现的地方。这现在是一个独立的例程,应该在一个专用的内部方法中。

    不要复制这段代码,而是将其剪切到剪贴板上。

  3. Upload类定义中创建一个新的受保护方法,并将刚刚剪切的代码粘贴到其中。将$_FILES[$field]改为$uploaded,以匹配函数签名中的第一个参数。新方法如下所示:

    protected function processUpload($uploaded, $rename) {
        if ($this->checkFile($uploaded)) {
            $this->checkName($uploaded, $renameDuplicates);
            $this->moveFile($uploaded);
        }
    }
    
    
  4. for循环和else块中调用这个新方法。构造函数方法的完整更新版本现在如下所示:

    public function __construct(
        string $field,
        protected string $path,
        protected int $max = 51200,
        string|array|null $mime = null,
        bool $rename = true
    ) {
        if (!is_dir($this->path) && !is_writable($this->path)) {
            throw new \Exception("$this->path must be a valid, writable directory.");
        } else {
            $this->path = rtrim($this->path, '/\\') . DIRECTORY_SEPARATOR;
            if (!is_null($mime)) {
                $this->permitted = array_merge($this->permitted, (array) $mime);
            }
            $uploaded = $_FILES[$field];
            if (is_array($uploaded['name'])) {
                // deal with multiple uploads
                $numFiles = count($uploaded['name']);
                $keys = array_keys($uploaded);
                for ($i = 0; $i < $numFiles; $i++) {
                    $values = array_column($uploaded, $i);
                    $currentfile = array_combine($keys, $values);
                    $this->processUpload($currentfile, $rename);
                }
            } else {
                $this->processUpload($_FILES[$field], $rename);
            }
        }
    }
    
    
  5. 保存Upload.php并尝试上传多个文件。您应该会看到与每个文件相关的消息。符合条件的文件将被上传。那些太大或类型错误的被拒绝。该类也可以处理单个文件。

您可以对照ch09/Php8Solutions/File文件夹中的Upload_05.php来检查您的代码。

使用上传类

Upload类使用起来很简单——只需导入名称空间,在脚本中包含类定义,并通过将输入字段名和文件路径传递到上传文件夹来创建一个Upload对象,如下所示:

$path = 'C:/upload_test/';
$loader = new Upload('image', $path);

Tip

上传文件夹路径末尾的斜杠是可选的。

默认情况下,该类只允许上载图像;它将最大大小限制为 50kb;它会重命名名称中包含空格或已存在于上传文件夹中的文件。通过提交以下可选参数的值,可以覆盖默认值:

  • $size:以字节为单位改变默认最大文件大小的整数(默认为 51200,相当于 50 KB)。

  • $permitted:单一 MIME 类型的字符串或多种类型的数组,允许上传除图像以外的文件。

  • $rename:设置为false会覆盖上传文件夹中的同名文件。

该类有两个公共方法,即:

  • getMessages():返回报告上传状态的消息数组。

  • getMaxSize():返回最大允许大小,格式为千字节,四舍五入到小数点后一位。

文件上传的注意事项

PHP 解决方案 9-1 中的基本脚本表明,用 PHP 从 web 表单上传文件相当简单。失败的主要原因是没有在上传目录或文件夹上设置正确的权限,以及忘记在脚本结束之前将上传的文件移动到其目标位置。基本脚本的问题是它允许上传任何东西。这就是为什么本章花了这么多精力来构建一个更健壮的解决方案。即使在Upload类中执行了额外的检查,您也应该仔细考虑安全性。

让其他人将文件上传到您的服务器会让您面临风险。实际上,您允许访问者自由地向您的服务器硬盘写入数据。你不会允许陌生人在你自己的电脑上做这种事,所以你应该以同样的警惕性来保护对你上传目录的访问。

理想情况下,上传应该仅限于注册的和可信的用户,所以上传表单应该在你的站点中有密码保护的部分。注册使你能够阻止那些滥用你信任的人。另外,上传文件夹不需要在你的站点根目录下,所以尽可能把它放在一个私人目录下。上传的图像可能包含隐藏脚本,因此它们不应位于具有执行权限的文件夹中。请记住,PHP 无法检查材料是否合法或体面,因此直接公开展示会带来超出技术层面的风险。您还应该记住以下安全要点:

  • 在 web 表单和服务器端设置上传的最大大小。

  • 通过检查$_FILES数组中的 MIME 类型来限制上传文件的类型。

  • 用下划线或连字符替换文件名中的空格。

  • 定期检查您的上传文件夹。确保里面没有不应该有的东西,时不时做点家务。即使您限制了文件上传大小,您也可能会在不知不觉中用完分配给您的空间。

第三章回顾

本章已经向你介绍了如何创建一个 PHP 类。如果你是 PHP 或编程新手,你可能会发现这很难。不要灰心。Upload类包含超过 150 行代码,其中一些很复杂,尽管我希望描述已经解释了代码在每个阶段做什么。即使你不理解所有的代码,Upload类也会帮你节省很多时间。它实现了文件上传所需的主要安全措施,但是使用它只需要不到十几行代码:

use Php8Solutions\File\Upload;
if (isset($_POST['upload'])) {
    require_once 'Php8Solutions/File/Upload.php'; // use correct path
    try {
        $loader = new Upload('image', 'C:/upload_test/');  // field name and destination folder as arguments
        $result = $loader->getMessages();
    } catch (Throwable $t) {
        echo $t->getMessage();
    }
}

如果你觉得这一章很难,等你有了更多的经验后再来看,你会发现代码更容易理解。

在下一章,你将学习如何使用 PHP 的图像处理功能从大图像中生成缩略图。您还将从本章扩展Upload类,在一次操作中上传和调整图像大小。

十、生成缩略图图像

PHP 有一系列广泛的用于处理图像的函数。你已经在第五章中见过其中之一getimagesize()。除了提供关于图像尺寸的有用信息,PHP 还可以通过调整图像大小或旋转图像来操作图像。它还可以在不影响原始文本的情况下动态添加文本,甚至可以动态创建图像。

为了让你对 PHP 图像操作有所了解,我将向你展示如何生成一个上传图像的较小副本。大多数情况下,你会希望使用一个专门的图形程序,如 Adobe Photoshop,来生成缩略图,因为它会给你更好的质量控制。然而,如果您希望允许注册用户上传图像,同时确保它们符合最大尺寸,使用 PHP 自动生成缩略图会非常有用。您可以只保存调整大小后的副本,也可以将副本与原件一起保存。

在前一章中,您构建了一个 PHP 类来处理文件上传。在这一章中,你将创建两个类:一个用于生成缩略图,另一个用于在一次操作中上传和调整图像大小。你可以基于第九章的Upload类来构建第二个类,而不是从零开始。使用类的一个很大的优点是它们是可扩展的——基于另一个类的类可以继承其父类的功能。构建上传图像并从中生成缩略图的类需要大量代码。但是一旦定义了类,使用它们只需要几行脚本。如果你很急,或者写了很多代码让你出了一身冷汗,你可以只使用完成的类。稍后回来学习代码是如何工作的。它使用了许多基本的 PHP 函数,您会发现这些函数在其他情况下也很有用。

在本章中,您将了解以下内容:

  • 缩放图像

  • 保存重新缩放的图像

  • 自动调整上传图像的大小和重命名

  • 通过扩展现有子类来创建子类

检查服务器的功能

在 PHP 中处理图像依赖于 gd 扩展。第二章中推荐的一体化 PHP 包默认支持 gd,但是你需要确保 gd 扩展也已经在你的远程 web 服务器上启用。和前面的章节一样,在您的网站上运行phpinfo()来检查服务器的配置。向下滚动,直到看到下面截图中显示的部分(应该在页面的中间位置):

img/332054_5_En_10_Figa_HTML.jpg

如果你找不到这个部分,说明 gd 扩展没有启用,所以你不能在你的网站上使用本章的任何脚本。请求将其启用或移动到不同的主机。

不要忘记删除运行phpinfo()的文件,除非它在有密码保护的目录中。

动态处理图像

gd 扩展允许您完全从头开始生成图像或使用现有图像。无论哪种方式,基本流程总是遵循四个基本步骤:

  1. 在处理过程中,在服务器内存中为图像创建一个资源。

  2. 处理图像。

  3. 显示和/或保存图像。

  4. 从服务器内存中删除图像资源。

这个过程意味着你总是只处理内存中的图像,而不是原始图像。除非在脚本终止前将图像保存到磁盘,否则任何更改都将被丢弃。处理图像通常需要大量内存,因此一旦不再需要图像资源,就将其销毁是至关重要的。如果脚本运行缓慢或崩溃,这可能表明原始图像太大。

制作图像的较小副本

本章的目的是告诉你如何在上传时自动调整图片的大小。这涉及到从第九章扩展Upload类。然而,为了更容易理解如何使用 PHP 的图像操作函数,我建议从使用服务器上已经存在的图像开始,然后创建一个单独的类来生成缩略图。

准备好

起点是下面这个简单的表单,它使用 PHP Solution 7-3 创建了一个images文件夹中图片的下拉菜单。你可以在ch10文件夹的create_thumb_01.php中找到代码。将其复制到php8sols站点根目录下名为gd的新文件夹中,并将其重命名为create_thumb.php

页面正文中的表单如下所示:

<form method="post" action="create_thumb.php">
    <p>
        <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) { ?>
                <option value="<?= $image->getRealPath() ?>">
                    <?= $image->getFilename() ?></option>
            <?php } ?>
        </select>
    </p>
    <p>
        <input type="submit" name="create" value="Create Thumbnail">
    </p>
</form>

当加载到浏览器中时,下拉菜单应该显示images文件夹中图片的名称。这使得快速挑选图像进行测试变得更加容易。通过调用SplFileInfo getRealPath()方法,每个图像的完全限定路径被插入到<option>标签的value属性中。

在你在第九章创建的upload_test文件夹中,创建一个名为thumbs的新文件夹,确保它有 PHP 写入的必要权限。如果您需要刷新记忆,请参考上一章中的“建立上传目录”。

构建缩略图类

要生成缩略图,该类需要执行以下步骤:

  1. 获取原始图像的尺寸。

  2. 获取图像的 MIME 类型。

  3. 计算缩放比例。

  4. 为原始图像创建正确 MIME 类型的图像资源。

  5. 为缩略图创建图像资源。

  6. 创建调整大小的副本。

  7. 使用正确的 MIME 类型将调整后的副本保存到目标文件夹。

  8. 销毁图像资源以释放内存。

除了生成缩略图之外,该类还自动在文件扩展名前插入_thb,但是构造函数方法的一个可选参数允许您更改该值。另一个可选参数设置缩略图的最大大小。为了简化计算,最大尺寸仅控制缩略图的较大尺寸。

为了避免命名冲突,Thumbnail类将使用一个名称空间。因为它专门用于图像,我们将在Php8Solutions文件夹中创建一个名为Image的新文件夹,并使用Php8Solutions\Image作为名称空间。

有很多事情要做,所以我将代码分成几个部分。它们都是同一个类定义的一部分,但是以这种方式表示脚本应该更容易理解,特别是如果您想在不同的上下文中使用一些代码。

PHP 解决方案 10-1:获取图像细节

这个 PHP 解决方案描述了如何获取原始图像的尺寸和 MIME 类型。

  1. Php8Solutions文件夹中新建一个名为Image的文件夹。然后在文件夹中创建一个名为Thumbnail.php的页面。该文件将只包含 PHP,所以去掉编辑程序插入的任何 HTML 代码。

  2. 在新文件的顶部声明命名空间:

  3. 该类需要跟踪相当多的属性。通过列出它们来开始类定义,如下所示:

    class Thumbnail {
        protected $original;
        protected $originalWidth;
        protected $originalHeight;
        protected $basename;
        protected $imageType;
        protected $messages = [];
    }
    
    
namespace Php8Solutions\Image;

与在Upload类中一样,所有的属性都被声明为 protected,这意味着它们不能在类定义之外被意外地更改。这些名字是描述性的,所以不需要解释。

  1. 构造函数有四个参数,其中两个是可选的。前两个必需的参数是图像的路径和创建缩略图的文件夹的路径。两个可选参数设置缩略图较长维度的最大大小和要添加到文件名的后缀。最后三个参数使用 PHP 8 的构造函数属性提升将它们设置为受保护的属性。将构造函数定义添加到上一步定义的属性列表之后,但在右花括号内:

    public function __construct(
        string $image,
        protected string $path,
        protected int $max = 120,
        protected string $suffix = '_thb'
     ) {
       if (is_file($image) && is_readable($image)) {
           $dimensions = getimagesize($image);
       } else {
           throw new \Exception("Cannot open $image.");
       }
       if (!is_array($dimensions)) {
           throw new \Exception("$image doesn't appear to be an image.");
       } else {
           if ($dimensions[0] == 0) {
               throw new \Exception("Cannot determine size of $image.");
           }
           // check the MIME type
           if (!$this->checkType($dimensions['mime'])) {
               throw new \Exception('Cannot process that type of file.');
           }
       }
       if (is_dir($path) && is_writable($path)) {
           $this->path = rtrim($path, '/\\') . DIRECTORY_SEPARATOR;
       } else {
           throw new \Exception("Cannot write to $path.");
       }
       $this->original = $image;
       $this->originalWidth = $dimensions[0];
       $this->originalHeight = $dimensions[1];
       $this->basename = pathinfo($image, PATHINFO_FILENAME);
       $this->max = abs($max);
       if ($suffix != '_thb') {
           $this->suffix = $this->setSuffix($suffix) ?? '_thb';
       }
    }
    
    

    构造函数以一个条件语句开始,该语句检查$image是一个文件并且是可读的。如果是,则传递给getimagesize(),结果存储在$dimensions中。否则,将引发异常。如前一章所述,Exception前面有一个反斜杠,表示我们希望使用核心的Exception类,而不是为这个命名空间类定制一个类。

    当您将图像传递给getimagesize()时,它会返回一个包含以下元素的数组:

    • 0:宽度(以像素为单位)

    • 1:高度

    • 2:表示图像类型的整数

    • 3:包含正确宽度和高度属性的字符串,准备插入到<img>标签中

    • mime:图像的 MIME 类型

    • channels : 3用于 RGB,4用于 CMYK 图像

    • bits:每种颜色的位数

如果作为参数传递给getimagesize()的值不是图像,它返回false。因此,如果$dimensions不是一个数组,就会抛出一个异常,报告该文件看起来不是一个图像。但是如果$dimensions是一个数组,看起来我们好像在处理一个图像。但是else块在继续之前做了两个进一步的检查。

如果$dimensions数组中第一个元素的值为 0,则图像有问题,因此会抛出一个异常,报告图像的大小无法确定。下一个检查将报告的 MIME 类型传递给一个名为checkType()的内部方法,该方法将在下一步中定义。如果checkType()返回false,则抛出另一个异常。

下一个条件语句使用与 PHP 解决方案 9–2 中相同的技术来检查将在其中创建缩略图的文件夹是否存在以及是否可写,删除任何尾随斜线并连接操作系统的适当目录分隔符。

如果图像或文件夹有问题,这一系列异常会阻止任何进一步的处理。假设脚本到此为止,图像的路径存储在$original属性中,其宽度和高度分别存储在$originalWidth$originalHeight中。

使用带有PATHINFO_FILENAME常量的pathinfo()提取不带文件扩展名的文件名,与 PHP 解决方案 9-5 中的方式相同。这存储在$basename属性中,将用于构建带有后缀的缩略图名称。

$max的值在分配给$max属性之前被传递给abs()函数。构造函数签名中的类型声明确保只接受整数,但是将值传递给abs()会在它为负数的情况下将其转换为正数。

最后的条件语句检查作为$suffix提供的参数是否不同于默认值。如果是,它将被传递给我们稍后将定义的setSuffix()方法。这会返回一个字符串或null。如果返回值是一个字符串,它被赋给$suffix属性。但是如果是null,空合并操作符(参见第四章中的“使用空合并操作符设置默认值”)会将默认值重新分配给属性。

  1. checkType()方法将 MIME 类型与一组可接受的图像类型进行比较。如果找到匹配,它将类型存储在$imageType属性中并返回true。否则返回false。该方法在内部使用,因此需要声明为 protected。将以下代码添加到类定义中:

    protected function checkType($mime) {
        $mimetypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
        if (in_array($mime, $mimetypes)) {
            // extract the characters after '/'
            $this->imageType = substr($mime, strpos($mime, '/')+1);
            return true;
        }
        return false;
    }
    
    

浏览器普遍支持 JPEG、PNG、GIF 我还包括了 WebP,因为它现在得到了广泛的支持。所有图像 MIME 类型都以image/开头。为了让这个值以后更容易使用,substr()函数提取斜杠后的字符,并将它们存储在$imageType属性中。当与两个参数一起使用时,substr()从第二个参数中指定的位置(从 0 开始计数)开始,并返回字符串的其余部分。我没有使用一个固定的数字作为第二个参数,而是使用了strpos()函数来找到斜线的位置并加 1。这使得代码更加通用,因为一些专有的图像格式以application/而不是image/开头。strpos()的第一个参数是要搜索的整个字符串,第二个参数是要搜索的字符串。

  1. setSuffix()方法需要确保该值不包含任何特殊字符。代码如下所示:

    protected function setSuffix($suffix) {
        if (preg_match('/^\w+$/', $suffix)) {
            if (!str_starts_with($suffix, '_')) {
                return '_' . $suffix;
            } else {
                return $suffix;
            }
        }
    }
    
    

这使用了preg_match(),它将一个正则表达式作为第一个参数,并在作为第二个参数传递的值中搜索匹配项。正则表达式需要包含在一对匹配的分隔符中——通常是正斜杠,就像这里使用的一样。去掉分隔符后,正则表达式如下所示:

^\w+$

在这个上下文中,插入符号(^)告诉正则表达式从字符串的开头开始。\w是匹配任何字母数字字符或下划线的正则表达式标记。+表示匹配前面的符号或字符一次或多次,$表示匹配字符串的末尾。换句话说,正则表达式匹配只包含字母数字字符和下划线的字符串。如果字符串包含空格或特殊字符,它将不会匹配。

如果匹配失败,默认的$suffix 属性保持不变。否则,将执行以下条件语句:

if (!str_starts_with($suffix, '_') ) {

如果$suffix的第一个字符是而不是下划线,则该条件等同于true。它使用了 PHP 8 新增的str_starts_with()函数。顾名思义,它检查字符串的第一个字符。因此,如果后缀不是以下划线开头,就会添加一个下划线。否则,将保留原始值。在任一情况下,都会传回值。

但是,如果提供给构造函数的参数包含除字母数字字符和下划线之外的任何字符,条件语句将失败,并且该方法将不返回任何内容—换句话说,null

  1. 在构建类时测试代码是一个好主意。及早发现错误比在长脚本中寻找问题要容易得多。为了测试代码,在类定义中创建一个名为test()的新公共方法。

方法在类定义中出现的顺序并不重要,但通常的做法是将所有公共方法放在构造函数之后,并将受保护的方法放在文件的底部。这使得代码更容易维护。

在构造函数和checkType()定义之间插入以下定义:

public function test() {
    $values = <<<END
    <pre>
    File: $this->original
    Original width: $this->originalWidth
    Original height: $this->originalHeight
    Base name: $this->basename
    Image type: $this->imageType
    Max: $this->max
    Path: $this->path
    Suffix: $this->suffix
    </pre>
    END;
    echo $values;
    if ($this->messages) {
        print_r($this->messages);
    }
}

这使用带有 heredoc 语法的echo(参见第四章中的“避免使用 Heredoc 语法转义引号的需要”)和print_r()来显示属性的值。虽然输出中没有引号,但是使用 heredoc 语法和

  1. 为了测试到目前为止的类定义,保存Thumbnail.php并将以下代码添加到create_thumb.phpDOCTYPE声明上方的 PHP 块中(代码可以在ch10文件夹的create_thumb_02.php中找到):

    use Php8Solutions\Image\Thumbnail;
        if (isset($_POST['create'])) {
            require_once('../Php8Solutions/Image/Thumbnail.php');
            try {
                $thumb = new Thumbnail($_POST['pix'] ,
                    'C:/upload_test/thumbs', suffix: '$%^');
                $thumb->test();
            } catch (Throwable $t) {
                echo $t->getMessage();
            }
    }
    
    
 tags makes both the code and the output easier to read.

这将从Php8Solutions\Image名称空间导入Thumbnail类,然后添加提交表单时要执行的代码。

create_thumb.php中提交按钮的name属性是create,所以这段代码只在表单提交后运行。它包括Thumbnail类定义;创建类的一个实例,将窗体中的选定值和路径传递到 thumbs 文件夹(根据需要进行调整以匹配您自己的设置),并为有意使用非字母数字字符的后缀传递一个命名参数。然后调用test()方法。

catch块使用Throwable作为类型声明,因此它将处理内部 PHP 错误和由Thumbnail类抛出的异常。

img/332054_5_En_10_Fig1_HTML.jpg

图 10-1

显示所选图像的细节确认代码正在工作

  1. 保存create_thumb.php并将其加载到浏览器中。选择一个图像,然后单击“创建缩略图”。这产生类似于图 10-1 的输出。

Note

图 10–1 是在 Windows 上拍摄的,所以DIRECTORY_SEPARATOR常量在路径后面附加了一个反斜杠。这没什么区别,因为 PHP 接受 Windows 路径中的正斜杠和反斜杠。

请注意,默认后缀已经替换了包含非字母数字字符的后缀。

  1. 使用不同的后缀值再次测试脚本,仅使用字母数字字符,以下划线开头或不加下划线。另外,尝试为$max属性设置不同的大小。

如有必要,对照ch10/Php8Solutions/Images文件夹中的Thumbnail_01.php检查您的代码。

Caution

$_POST['pix']的值被直接传递给test()方法,因为它直接来自我们自己的表单。在生产环境中,您应该总是检查从表单接收的值。例如,使用basename()只提取文件名并指定允许的目录。

尽管有些属性有默认值,但您需要提供选项来更改缩略图的最大尺寸和应用于文件名基础的后缀。您还需要告诉全班在哪里创建缩略图。

PHP 解决方案 10-2:计算缩略图的尺寸

这个 PHP 解决方案向Thumbnail类添加了一个受保护的方法,该方法将计算缩略图的尺寸。在$maxSize属性中设置的值决定了宽度或高度,这取决于哪个更大。为了避免扭曲缩略图,您需要计算较短尺寸的缩放比例。该比率是通过将最大缩略图尺寸除以原始图像的较大尺寸来计算的。

比如金阁(kinkakuji.jpg)原图是 270 × 346 像素。如果最大大小设置为 120,用 120 除以 346 得到的缩放比例为 0.3468。将原始图像的宽度乘以该比率会将缩略图的宽度固定为 94 像素(向上舍入为最接近的整数),从而保持正确的比例。图 10-2 显示了缩放比例是如何工作的。

img/332054_5_En_10_Fig2_HTML.jpg

图 10-2

计算缩略图的缩放比例

继续使用现有的类定义。或者,使用ch10/Php8Solutions/Image文件夹中的Thumbnail_01.php

  1. 计算缩略图尺寸不需要任何进一步的用户输入,因此可以通过受保护的方法来处理。将以下代码添加到类定义中。

    protected function calculateRatio() {
        if ($this->originalWidth <= $this->max &&
            $this->originalHeight <= $this->max) {
            return 1;
        } elseif ($this->originalWidth > $this->originalHeight) {
            return $this->max/$this->originalWidth;
        } else {
           return $this->max/$this->originalHeight;
        }
    }
    
    

条件语句首先检查原始图像的宽度和高度是否小于或等于最大尺寸。如果是,则不需要调整图像的大小,因此该方法返回的缩放比例为 1。

elseif块检查宽度是否大于高度。如果是,宽度用于计算缩放比例。如果高度大于或两边相等,则调用else块。在任一情况下,高度都用于计算缩放比例。

  1. 为了测试新方法,修改test()方法如下:

    public function test() {
        $ratio = $this->calculateRatio();
        $thumbWidth = round($this->originalWidth * $ratio);
        $thumbHeight = round($this->originalHeight * $ratio);
        $values = <<<END
        <pre>
        File: $this->original
        Original width: $this->originalWidth
        Original height: $this->originalHeight
        Base name: $this->basename
        Image type: $this->imageType
        Destination: $this->path
        Max size: $this->maxSize
        Suffix: $this->suffix
        Thumb width: $thumbWidth
        Thumb height: $thumbHeight
        </pre>
        END;
        // Remove the indentation of the preceding line in < PHP 7.3
        echo $values;
        if ($this->messages) {
            print_r($this->messages);
        }
    }
    
    

这将调用新方法。得到的缩放比例然后用于计算缩略图的宽度和高度。计算结果传递给round()函数,将结果转换为最接近的整数。计算需要从test()方法中移除,但是首先检查我们是否得到了预期的结果是很重要的。

img/332054_5_En_10_Fig3_HTML.jpg

图 10-3

该类现在正在生成创建缩略图所需的所有值

  1. 通过选择create_thumb.php中的图像并点击Create Thumbnail来测试更新的类。你应该看到屏幕上显示的数值,如图 10-3 所示。尝试缩略图最大尺寸的不同值。

如有必要,对照ch10文件夹中的Thumbnail_02.php检查您的代码。

使用 gd 函数创建图像的缩放副本

收集了所有必要的信息后,您可以从较大的图像生成缩略图。这包括为原始图像和缩略图创建图像资源。对于原始图像,您需要使用与图像的 MIME 类型相匹配的函数。以下每个函数都有一个参数,即文件的路径:

  • imagecreatefromjpeg()

  • imagecreatefrompng()

  • imagecreatefromgif()

  • imagecreatefromwebp()

因为缩略图还不存在,所以使用不同的函数imagecreatetruecolor(),它有两个参数——宽度和高度(以像素为单位)。

还有一个函数创建一个图像的大小调整副本:imagecopyresampled()。这至少需要十个参数——所有参数都是必需的。这些参数分为五对,如下所示:

  • 对两个图像资源的引用—首先是副本,其次是原件

  • 复制图像左上角位置的xy坐标

  • 原稿左上角的xy坐标

  • 副本的宽度和高度

  • 要从原件复制的区域的宽度和高度

图 10-4 显示了最后四对参数如何用于提取特定区域,使用以下参数到imagecopyresampled():

img/332054_5_En_10_Fig4_HTML.jpg

图 10-4

imagecopyresampled()函数允许你复制图像的一部分

imagecopyresampled($thumb, $source, 0, 0, 170, 20, $thbwidth,$thbheight, 170, 102);

要复制的区域的xy坐标以像素为单位,从图像的左上角开始测量。x 轴和 y 轴从左上角的 0 开始,向右下方增加。通过将要复制的区域的宽度和高度分别设置为 170 和 102,PHP 提取出白色轮廓的区域。

现在你知道网站是如何处理上传的图片的了。他们使用 JavaScript 或其他技术动态计算坐标。对于Thumbnail类,您将使用整个原始图像来生成缩略图。

使用imagecopyresampled()创建副本后,您需要保存它,再次使用特定于 MIME 类型的函数,即:

  • imagejpeg()

  • imagepng()

  • imagegif()

  • imagewebp()

每个函数的前两个参数是图像资源和保存它的路径。

imagejpeg()imagepng()imagewebp()函数采用可选的第三个参数来设置图像质量。对于imagejpeg()imagewebp(),您可以在 0(最差)到 100(最好)的范围内指定一个数字来设置质量。如果省略该参数,默认情况下imagejpeg()为 75,imagewebp()为 80。对于imagepng(),范围是 0–9。令人困惑的是,0 产生最佳质量(无压缩)。

最后,一旦保存了缩略图,就需要通过将它们传递给imagedestroy()来销毁图像资源。尽管其名称具有破坏性,但该功能对原始图像或缩略图没有任何影响。它只是通过销毁处理过程中所需的图像资源来释放服务器内存。

PHP 解决方案 10-3:生成缩略图

这个 PHP 解决方案通过创建图像资源、复制缩略图并将其保存在目标文件夹中来完成Thumbnail类。

继续使用现有的类定义。或者,使用ch10/Php8Solutions/Image文件夹中的Thumbnail_02.php

  1. 现在我们已经验证了该类正在计算正确的值来生成缩略图,我们可以重命名test()方法并删除显示结果的代码。将该方法的名称改为create(),并删除除前三行之外的所有内容。你应该留下这个:

    public function create() {
        $ratio = $this->calculateRatio();
        $thumbWidth = round($this->originalWidth * $ratio);
        $thumbHeight = round($this->originalHeight * $ratio);
    }
    
    
  2. 原始图像的图像资源需要特定于它的 MIME 类型,因此创建一个内部方法来选择正确的类型。将以下代码添加到类定义中:

    protected function createImageResource() {
        switch ($this->imageType) {
            case 'jpeg':
                return imagecreatefromjpeg($this->original);
            case 'png':
                return imagecreatefrompng($this->original);
            case 'gif':
                return imagecreatefromgif($this->original);
            case 'webp':
                return imagecreatefromwebp($this->original);
        }
    }
    
    

您在 PHP 解决方案 10-1 中创建的checkType()方法将 MIME 类型存储为jpegpnggifwebp。因此,switch语句检查 MIME 类型,将其与适当的函数匹配,并将原始图像作为参数传递。然后,该方法返回结果图像资源。

  1. create()方法需要两个图像资源:一个用于原始图像,另一个用于缩略图。像这样更新create()方法:

    public function create() {
        $ratio = $this->calculateRatio();
        $thumbWidth = round($this->originalWidth * $ratio);
        $thumbHeight = round($this->originalHeight * $ratio);
        $resource = $this->createImageResource();
        $thumb = imagecreatetruecolor($thumbWidth, $thumbHeight);
    }
    
    

这将调用您在步骤 2 中创建的createImageResource()方法,然后为缩略图创建一个图像资源,将缩略图的宽度和高度传递给imagecreatetruecolor()

  1. 创建缩略图的下一个阶段包括将两个图像资源传递给imagecopyresampled()并设置坐标和尺寸。将下面一行代码添加到create()方法中:
imagecopyresampled($thumb, $resource, 0, 0, 0, 0, $thumbWidth, $thumbHeight,
$this->originalWidth, $this->originalHeight);

前两个参数是您刚刚为缩略图和原始图像创建的图像资源。接下来的四个参数将副本和原件的xy坐标设置为左上角。接下来是为缩略图计算的宽度和高度,接着是原始图像的宽度和高度。将参数 3–6 设置为左上角,并将两组尺寸设置为最大值,会将整个原始图像复制到整个缩略图。换句话说,它创建了原始文件的一个较小的副本。

你不需要把imagecopyresampled()的结果赋给一个变量。缩小后的图像现在存储在$thumb中,但是您仍然需要保存它。

  1. 像这样完成create()的定义:

    public function create() {
        $ratio = $this->calculateRatio();
        $thumbWidth = round($this->originalWidth * $ratio);
        $thumbHeight = round($this->originalHeight * $ratio);
        $resource = $this->createImageResource();
        $thumb = imagecreatetruecolor($thumbWidth, $thumbHeight);
        imagecopyresampled($thumb, $resource, 0, 0, 0, 0, $thumbWidth,
            $thumbHeight, $this->originalWidth, $this->originalHeight);
        $newname = $this->basename . $this->suffix;
        switch ($this->imageType) {
            case 'jpeg':
                $newname .= '.jpg';
                $success = imagejpeg($thumb, $this->path . $newname);
                break;
            case 'png':
                $newname .= '.png';
                $success = imagepng($thumb, $this->path . $newname);
                break;
            case 'gif':
                $newname .= '.gif';
                $success = imagegif($thumb, $this->path . $newname);
                break;
            case 'webp':
                $newname .= '.webp';
                $success = imagewebp($thumb, $this->path . $newname);
                break;
        }
        if ($success) {
            $this->messages[] = "$newname created successfully.";
        } else {
            $this->messages[] = "Couldn't create a thumbnail for " .
                basename($this->original);
        }
        imagedestroy($resource);
        imagedestroy($thumb);
    }
    
    

新代码的第一行将后缀连接到去掉了文件扩展名的文件名。所以,如果原始文件被称为menu.jpg,并且使用默认的_thb后缀,$newname就变成了menu_thb

switch语句检查图像的 MIME 类型并附加适当的文件扩展名。在menu.jpg的情况下,$newname变成了menu_thb.jpg。缩小的图像然后被传递到适当的函数来保存它,使用目标文件夹和$newname作为保存它的路径。我省略了 JPEG、PNG 和 WebP 图像的可选质量参数。默认质量对于缩略图应该足够了。

保存操作的结果存储在$success中。根据结果,$success或者是true或者是false,一个适当的消息被添加到$messages属性中。消息是使用basename()函数而不是$basename属性创建的,因为文件扩展名已经从属性中去除,而函数保留了它。

最后,imagedestroy()通过销毁用于创建缩略图的资源来释放服务器内存。

  1. 到目前为止,您已经使用了test()方法来显示错误消息。创建一个公共方法来获取消息:

    public function getMessages() {
        return $this->messages;
    }
    
    
  2. 保存Thumbnail.php。在create_thumb.php中,用对create()的调用替换对test()方法的调用。也调用getMessages()并将结果赋给一个变量,就像这样:

    $thumb->create();
        $messages = $thumb->getMessages();
    
    
  3. 在开始的<body>标签后添加一个 PHP 代码块来显示任何消息:

    <?php
    if (!empty($messages)) {
        echo '<ul>';
        foreach ($messages as $message) {
            echo "<li>$message</li>";
        }
        echo '</ul>';
    }
    ?>
    
    

您已经在前面的章节中看到了这段代码,所以它不需要解释。

保存create_thumb.php,将其加载到浏览器中,并通过从列表中选择图像并单击创建缩略图来进行测试。如果一切顺利,你应该会看到一条消息报告缩略图的创建,你可以确认它存在于upload_testthumbs子文件夹中,如图 10-5 所示。

img/332054_5_En_10_Fig5_HTML.jpg

图 10-5

缩略图已在目标文件夹中成功创建

  1. 如果缩略图没有创建,那么由Thumbnail类生成的错误消息应该可以帮助您检测问题的根源。同样,对照ch10/Php8Solutions/Image文件夹中的Thumbnail_03.php仔细检查你的代码。如果之前的 PHP 解决方案中的测试有效,那么错误很可能出现在create()createImageResource()createThumbnail()方法定义中。当然,另一个需要检查的地方是您的 PHP 配置。该类依赖于正在启用的 gd 扩展。虽然 gd 得到了广泛的支持,但它并不总是默认开启。

上传时自动调整图像大小

现在你已经有了一个从更大的图像创建缩略图的类,修改第九章中的Upload类来从上传的图像生成缩略图就相对简单了——事实上,不仅从单幅图像,也从多幅图像。

与其更改Upload类中的代码,不如扩展该类并创建一个子类更有效。然后,您可以选择使用原始类来上传任何类型的文件,或者使用子类来在上传时创建缩略图。子类还需要提供在缩略图创建后保存或丢弃大图的选项。

在深入研究代码之前,让我们快速看一下如何创建一个子类。

扩展一个类

在“PHP 解决方案 8-8:自动构建嵌套列表”中,我们看到了一个扩展内置类RecursiveIteratorIterator的例子扩展一个类的好处是,新的子类子类继承了其父类的所有特性,包括属性和方法,但可以修改(或覆盖)其中一些特性并获得自己的新特性。这简化了创建一个类来执行更专门化的任务的过程。你在第九章创建的Upload类执行基本的文件上传。在这一章中,你将扩展它来创建一个名为ThumbnailUpload的子类,它使用其父类的基本上传功能,但是增加了创建缩略图的特殊功能。子类将被创建在Php8Solutions/Image文件夹中,所以它将使用Php8Solutions\Image作为它的名称空间。

像所有的子类一样,子类通常需要向父类借用。当您在子类中重写一个方法但又需要使用原始版本时,这种情况经常发生。要引用父版本,您可以在它前面加上关键字parent,后跟两个冒号,如下所示:

parent::originalMethod();

你将在 PHP 解决方案 10-4 中看到这是如何工作的,因为子类定义了自己的构造函数来添加额外的参数,但也需要使用父构造函数。

让我们创建一个能够同时上传图像和生成缩略图的类。

PHP 解决方案 10-4:创建缩略图上传类

这个 PHP 解决方案扩展了第九章中的Upload类,并将其与Thumbnail类结合使用来上传和调整图像大小。它演示如何创建子类并重写父方法。要创建子类,你需要第九章的和本章的Thumbnail.php。在ch09/Php8Solutions/Filech10/Php8Solutions/Image文件夹中分别有这些文件的副本。

  1. Php8Solutions/Image文件夹中新建一个名为ThumbnailUpload .php的文件。它将只包含 PHP 代码,所以去掉脚本编辑器插入的任何 HTML,并添加以下代码:

    <?php
    namespace Php8Solutions\Image;
    use Php8Solutions\File\Upload;
    require_once __DIR__ . '/../File/Upload.php';
    require_once 'Thumbnail.php';
    class ThumbnailUpload extends Upload {
    }
    
    

这将声明Php8Solutions\Image名称空间,并在包含UploadThumbnail类的定义之前,从Php8Solutions\File名称空间导入Upload类。

Note

在包含文件中使用时,__DIR__返回包含文件的目录,不带尾随斜杠。在Upload.php的相对路径的开头添加斜杠,允许 PHP 构建一个完整的路径,向上移动一级,在Php8Solutions/File文件夹中找到它。Thumbnail.phpThumbnailUpload.php在同一个文件夹中,所以只使用文件名来包含它。参见第五章中的“嵌套包含文件”。

然后,ThumbnailUpload类声明它扩展了Upload。虽然Upload在不同的名称空间中,但是您可以简单地将其称为Upload,因为它已经被导入。所有后续代码都需要插入到类定义的花括号之间。

  1. 当您扩展一个类时,您唯一需要定义构造函数方法的时候就是您想要改变构造函数的工作方式的时候。ThumbnailUpload类总共有七个参数,其中大部分是可选的。在本地测试时,一个Thumbnail对象可以访问你自己硬盘上的原始映像。然而,生成缩略图是一项服务器端操作,因此如果不先将原始图像上传到服务器,它就不能在网站上工作。因此,构造函数的前三个参数与Upload类的相同:图像输入字段($field)、图像上传位置的路径($path)和上传图像的最大文件大小($max)。其余参数设置与缩略图相关的值。所有参数前面都有一个可见性声明,使用 PHP 8 的构造函数属性提升来自动使它们成为受保护的类属性。构造函数如下所示:

    public function __construct(
        protected string $field,
        protected string $path,
        protected int $max = 51200,
        protected int $maxDimension = 120,
        protected string $suffix = '_thb',
        protected ?string $thumbPath = null,
        protected bool $deleteOriginal = false,
    ) {
        $this->thumbPath = $thumbPath ?? $path;
        if (is_dir($this->thumbPath) && is_writable($this->thumbPath)) {
            $this->thumbPath = rtrim($this->thumbPath, '/\\') .
                DIRECTORY_SEPARATOR;
        } else {
            throw new \Exception("$this->thumbPath must be a valid,
                writable directory.");
       }
       parent::__construct(
           $this->field,
           $this->path,
           $this->max
       );
    }
    
    

最后五个参数都有默认值,所以它们是可选参数。$maxDimension的值设置缩略图较大尺寸的最大尺寸。这个值也在Thumbnail类中设置。这里重复了一遍,这样你可以在上传图片和生成缩略图的时候覆盖它。$suffix参数也是如此。

$thumbPath参数前面是可空类型声明,这意味着该值必须是字符串或null。构造函数体中的第一行使用零合并运算符(??)将值赋给$thumbPath属性。如果传递给构造函数的值是null,则使用$path的值,在与上传图像相同的目录中创建缩略图。

最后一个参数$deleteOriginal是一个布尔值,它确定在创建缩略图后是否应该删除全尺寸图像。默认情况下,它被设置为false,保留图像。

构造函数体中的条件语句使用与 PHP 解决方案 9–2 中相同的技术来检查将在其中创建缩略图的文件夹是否存在以及是否可写,删除任何尾随斜线并为操作系统连接适当的目录分隔符。

最后,构造函数使用parent关键字调用Upload构造函数,向其传递文件输入字段的名称、上传文件夹的路径和最大上传大小。实际上,在调用父构造函数上传全尺寸版本之前,扩展类的构造函数初始化上传全尺寸图像和生成缩略图所需的所有类属性。

  1. 在父类中,moveFile()方法将上传的文件保存到它的目标位置。缩略图需要从原始图像中生成,因此您需要覆盖父方法的moveFile()并使用它来调用一个新的受保护的方法createThumbnail(),稍后您将定义这个方法。从Upload.php复制moveFile()方法,并通过添加粗体突出显示的代码进行修改:

    protected function moveFile($file) {
        $filename = $this->newName ?? $file['name'];
        $success = move_uploaded_file($file['tmp_name'],
            $this->path . $filename);
        if ($success) {
            // add a message only if the original image is not deleted
            if (!$this->deleteOriginal) {
                $result = $file['name'] . ' was uploaded successfully';
                if (!is_null($this->newName)) {
                    $result .= ', and was renamed ' . $this->newName;
                }
                $this->messages[] = $result;
            }
            // create a thumbnail from the uploaded image
            $this->createThumbnail($this->path . $filename);
            // delete the uploaded image if required
            if ($this->deleteOriginal) {
                unlink($this->path . $filename);
            }
        } else {
            $this->messages[] = 'Could not upload ' . $file['name'];
        }
    }
    
    

如果原始图像已经成功上传,新代码将添加一个条件语句,以便仅在$deleteOriginalfalse时生成消息。然后它调用createThumbnail()方法,将上传的图像作为参数传递给它。最后,如果$deleteOriginal已经被设置为true,它使用unlink()删除上传的图像,只留下缩略图。

  1. 生成缩略图的受保护方法如下所示:

    protected function createThumbnail($image) {
        $thumb = new Thumbnail($image, $this->thumbPath,
            $this->maxDimension, $this->suffix);
        $thumb->create();
        $messages = $thumb->getMessages();
        $this->messages = array_merge($this->messages, $messages);
    }
    
    

它接受一个参数,即图像的路径,并创建一个Thumbnail对象,将所有四个参数传递给Thumbnail构造函数:图像的路径、创建缩略图的路径、较大维度的最大大小以及附加到文件名的后缀。然后,它调用Thumbnail对象上的create()getMessages()方法来生成新图像,并获取作为结果创建的任何消息。

最后一行使用array_merge()将由Thumbnail对象生成的消息与ThumbnailUpload类的$message属性合并。虽然ThumbnailUpload类没有定义自己的$messages属性,但是子类自动从其父类继承它。

  1. 保存ThumbnailUpload.php。为了测试它,将create_thumb_upload_01.phpch10文件夹复制到gd文件夹,并保存为create_thumb_upload.php。该文件包含一个简单的表单,带有一个文件字段和一个显示消息的 PHP 块。在DOCTYPE声明上方添加以下 PHP 代码块:

    <?php
    use Php8Solutions\Image\ThumbnailUpload;
    if (isset($_POST['upload'])) {
        require_once('../Php8Solutions/Image/ThumbnailUpload.php');
        try {
            $loader = new ThumbnailUpload('image', 'C:/upload_test/',
                thumbPath: 'C:/upload_test/thumbs');
            $messages = $loader->getMessages();
        } catch (Throwable $t) {
            echo $t->getMessage();
        }
    }
    ?>
    
    

这个例子向ThumbnailUpload构造函数提供了三个参数。前两个是必需的:文件输入字段名称和要上传全尺寸图像的文件夹的路径。最后一个参数是 PHP 8 命名的参数,它指定缩略图文件的文件夹路径,跳过中间的参数并使用默认设置。如有必要,调整构造函数中的路径。

img/332054_5_En_10_Fig6_HTML.jpg

图 10-6

缩略图是在上传图像的同时创建的

  1. 保存create_thumb_upload.php并将其载入浏览器。单击浏览或选择文件按钮并选择多个图像。当您单击上传按钮时,您应该会看到消息,通知您成功上传并创建了缩略图。检查目标文件夹,如图 10-6 所示。

  2. 通过再次上传相同的图像来测试ThumbnailUpload类。这一次,原始图像和缩略图应该按照第九章中的相同方式进行重命名,即在文件扩展名前添加一个数字。

  3. 尝试不同的测试,更改插入缩略图名称的后缀,或者在创建缩略图后删除原始图像。如果你遇到问题,对照ch10/Php8Solutions/Image文件夹中的ThumbnailUpload.php检查你的代码。

使用 ThumbnailUpload 类

ThumbnailUpload类很容易使用。因为它使用命名空间,所以在文件的顶层导入该类,如下所示:

use Php8Solutions\Image\ThumbnailUpload;

然后包含类定义,并将输入字段的名称和上传文件夹的路径传递给类构造函数方法:

$loader = new ThumbnailUpload('image', 'C:/upload_test/');

默认情况下,缩略图创建在与全尺寸图像相同的文件夹中;但是,您可以使用五个可选参数来更改各种默认值,或者按正确的顺序传递它们,或者使用命名参数。下列选项可用作命名参数:

  • max:以字节为单位设置全尺寸图像的最大尺寸。默认值为 51200,相当于 50 KB。

  • maxDimension:以像素为单位指定缩略图较大尺寸的最大尺寸。默认值为 120。

  • suffix:追加到缩略图的文件名。它只能包含字母数字字符和下划线。如果它不是以下划线开头,就会自动添加一个下划线。默认为'_thb'

  • thumbPath:指定要创建缩略图的文件夹路径。默认设置是null,它会在与全尺寸图像相同的文件夹中创建缩略图。

  • deleteOriginal:确定创建缩略图后是否删除全尺寸图像。默认为false,因此保留原始图像。

该类还从父类Upload继承了以下方法:

  • getMessages():检索上传生成的消息和缩略图。

  • getMaxSize():获取单个图像的最大上传大小。默认值为 50 KB。

因为ThumbnailUpload类依赖于UploadThumbnail类,所以当在一个实时网站上使用这个类时,您需要将所有三个类定义文件上传到您的远程 web 服务器。

第三章回顾

这是另一个紧张的章节,不仅展示了如何从较大的图像生成缩略图,还向您介绍了扩展现有的类和重写继承的方法。设计和扩展类一开始可能会令人困惑,但是如果你把注意力集中在每个方法正在做的事情上,它会变得不那么可怕。类设计的一个关键原则是将大任务分解成小的、可管理的单元。理想情况下,一个方法应该执行一个任务,比如为原始图像创建图像资源。

使用类的真正好处是一旦你定义了它们,它们就能节省时间和精力。每次要向网站添加文件或缩略图上载功能时,不必键入几十行代码,调用该类只需要几行简单的代码。此外,不要认为本章中的代码专门用于创建和上传缩略图。类文件中的许多子程序可以在其他情况下使用。

在下一章中,您将了解 PHP 会话的所有内容,它保存与特定用户相关的信息,并在密码保护网页中发挥重要作用。

十一、页面记录:简单登录和多页面表单

网络是一个辉煌的幻觉。当你访问一个设计良好的网站时,你会有一种连续的感觉,就像翻阅一本书或一本杂志。所有的东西作为一个连贯的整体组合在一起。现实却大相径庭。web 服务器分别存储和处理单个页面的每个部分。除了需要知道相关文件发送到哪里,服务器对你是谁没有兴趣。每次 PHP 脚本运行时,变量只存在于服务器的内存中,通常脚本一结束就被丢弃。甚至$_POST$_GET数组中的变量也只有短暂的生命周期。它们的值被传递给下一个脚本一次,然后从内存中删除,除非您对它做了什么,比如将信息存储在一个隐藏的表单字段中。即使这样,只有当表单被提交时,它才继续存在。

为了解决这些问题,PHP 使用了会话。在简要描述了会话的工作原理之后,我将向您展示如何使用会话变量来创建一个简单的、基于文件的登录系统,并将信息从一个页面传递到另一个页面,而无需使用隐藏的表单字段。

在本章中,您将了解以下内容:

  • 了解什么是会话以及如何创建会话

  • 创建基于文件的登录系统

  • 使用定制的类检查密码强度

  • 为会话设置时间限制

  • 使用会话跟踪多个页面上的信息

什么是会话以及它们如何工作

会话通过在 web 服务器上存储一个随机标识符(即会话 ID)并作为 cookie 存储在访问者的计算机上来确保连续性。web 服务器使用 cookie 来识别它正在与同一个人通信(或者更准确地说,是与同一台计算机通信)。图 11-1 到 11-3 显示了在我的本地测试环境中创建的一个简单会话的细节。

如图 11-1 所示,浏览器中存储的 cookie 名为PHPSESSID,内容是一堆杂乱的字母和数字。这个随机字符串是会话的 ID。

img/332054_5_En_11_Fig1_HTML.jpg

图 11-1

PHP 会话在浏览器中以 cookie 的形式存储一个唯一的标识符

在网络服务器上创建一个匹配文件,其文件名中包含相同的字母和数字,如图 11-2 所示。

img/332054_5_En_11_Fig2_HTML.jpg

图 11-2

cookie 的内容标识存储在 web 服务器上的会话数据

当会话启动时,服务器将信息存储在会话变量中,只要会话保持活动状态(通常直到浏览器关闭),其他页面就可以访问这些变量。因为会话 ID 对每个访问者都是唯一的,所以存储在会话变量中的信息不能被其他任何人看到。这意味着会话是用户身份验证的理想选择,尽管它们可以用于在从一个页面传递到下一个页面时希望保留同一用户的信息的任何情况,例如多页表单或购物车。

用户计算机上存储的唯一信息是包含会话 ID 的 cookie,它本身是没有意义的。这意味着不能简单地通过检查这个 cookie 的内容来暴露私人信息。

会话变量及其值存储在 web 服务器上。图 11-3 显示了一个简单会话文件的内容。如您所见,它是纯文本的,内容不难破译。图中所示的会话有一个变量:name。变量名后跟一个竖线,然后是字母“s”,一个冒号,一个数字,另一个冒号,变量值用引号括起来。“s”代表字符串,数字表示字符串包含多少个字符。所以这个会话变量包含我的名字,它是一个五个字符长的字符串。

img/332054_5_En_11_Fig3_HTML.jpg

图 11-3

会话的详细信息以纯文本形式存储在服务器上

这种设置有几个含义。包含会话 ID 的 cookie 通常保持活动状态,直到浏览器关闭。因此,如果几个人共用同一台电脑,他们都可以访问彼此的会话,除非他们在移交给下一个人之前总是关闭浏览器,这是你无法控制的。所以提供一个注销机制来删除 cookie 和会话变量是很重要的,这样可以保证站点的安全。您还可以创建一个超时机制,自动防止任何人在一段时间的不活动后重新获得访问权限。

在 web 服务器上以纯文本的形式存储会话变量,这本身并不需要担心。只要服务器配置正确,就不能通过浏览器访问会话文件。PHP 还会定期删除不活动的文件(理论上,生命周期是 1440 秒——24 分钟——但这并不可靠)。然而,显而易见的是,如果攻击者设法破坏服务器或劫持会话,信息就可能暴露。因此,尽管会话对于网站的密码保护部分或使用多页表单来说通常是足够安全的,但是您不应该使用会话变量来存储敏感信息,如密码或信用卡详细信息。正如您将在本章后面的“使用会话限制访问”中看到的,虽然密码用于获得对受保护站点的访问,但密码本身存储(最好是散列)在一个单独的位置,而不是作为会话变量。

Note

哈希是一种单向过程,它对纯文本进行加扰以生成唯一的消息摘要。它经常与加密混淆,后者允许加密的文本被解密。当哈希正确执行时,没有办法逆转该过程来揭示原始密码。

默认情况下支持会话,因此不需要任何特殊配置。但是,如果在用户的浏览器中禁用了 cookies,会话将无法工作。可以配置 PHP 通过一个查询字符串发送会话 ID,但是这被认为是一个安全风险。

创建 PHP 会话

只需将以下命令放入您希望在会话中使用的每个 PHP 页面中:

session_start();

这个命令应该在每个页面中只调用一次,并且必须在 PHP 脚本生成任何输出之前调用,所以理想的位置是紧接在开始的 PHP 标记之后。如果在调用session_start()之前生成了任何输出,该命令将失败,并且不会为该页面激活会话。(有关解释,请参见后面的“标题已发送”部分。)

创建和销毁会话变量

通过将会话变量添加到$_SESSION超全局数组中,可以创建一个会话变量,就像分配普通变量一样。假设您想要存储访问者的姓名并显示问候语。如果在登录表单中提交的名称是$_POST['name'],您可以这样分配它:

$_SESSION['name'] = $_POST['name'];

$_SESSION['name']现在可以在任何以session_start()开头的页面中使用。因为会话变量存储在服务器上,所以您应该在脚本或应用不再需要它们时立即将其删除。取消设置会话变量,如下所示:

unset($_SESSION['name']);

要取消设置所有会话变量——例如,当您注销某人时——将$_SESSION超全局数组设置为空数组,如下所示:

$_SESSION = [];

Caution

不要尝试unset($_SESSION)。效果不错,但有点太有效了。它不仅清除当前会话,而且防止存储任何进一步的会话变量。

销毁会话

取消设置所有会话变量本身可以有效地防止任何信息被重用,但是您也应该像这样使会话 cookie 无效:

if (isset($_COOKIE[session_name()])) {
    setcookie(session_name(), ", time()-86400, '/');
}

它使用函数session_name()动态获取会话名称,并将会话 cookie 重置为空字符串,并在 24 小时前过期(86,400 是一天中的秒数)。最后一个参数('/')将 cookie 应用于整个域。

最后,使用以下命令关闭会话:

session_destroy();

这个函数的名字很糟糕。它只是关闭会话。它不会破坏任何会话变量或取消设置会话 cookie。重要的是使会话 cookie 无效并关闭会话,以避免未经授权的人访问站点的受限部分或会话期间交换的任何信息的风险。然而,访问者可能会忘记注销,因此并不总是能够保证会话会被正确关闭,这就是为什么不在会话变量中存储敏感信息如此重要。

Note

PHP 8 不支持旧脚本中常见的session_register()session_unregister()

重新生成会话 ID

当用户更改状态时,例如登录后,作为一种安全措施,建议重新生成会话 ID。这将更改标识会话的随机字母和数字字符串,但保留存储在会话变量中的所有信息。在 Pro PHP Security,Second Edition (Apress,2010,ISBN 978-1-4302-3318-3)中,Chris Snyder 和 Michael Southwell 解释说“生成新的会话 ID 的目标是消除具有低级安全会话知识的攻击者能够执行高安全性任务的可能性,不管这种可能性有多小。”

要重新生成会话 ID,只需调用session_regenerate_id()并将用户重定向到另一个页面或重新加载同一个页面。

“邮件头已发送”错误

尽管使用 PHP 会话非常容易,但有一个问题让初学者头疼不已。您会看到以下消息,而不是一切都按您预期的方式运行:

Warning: Cannot add header information - headers already sent

这个问题我之前结合header()函数提过好几次了。它也影响了session_start()setcookie()。在session_start()的情况下,解决方案很简单:确保将它放在 PHP 开始标记之后(或之后不久),并检查开始标记之前没有空格。

有时,即使 PHP 标签前面没有空格,问题也会发生。这通常是由于编辑软件在脚本开头插入了字节顺序标记(BOM)造成的。如果发生这种情况,请打开脚本编辑器的首选项,并禁止在 PHP 页面中使用 BOM。

然而,当使用setcookie()销毁会话 cookie 时,很可能需要在调用该函数之前将输出发送到浏览器。在这种情况下,PHP 让您使用ob_start()将输出保存在缓冲区中。然后在setcookie()完成工作后,用ob_end_flush()刷新缓冲区。你将在 PHP 解决方案 11-2 中看到如何做到这一点。

使用会话限制访问

当考虑限制对网站的访问时,首先想到的词可能是“用户名”和“密码”尽管这些通常会解锁站点入口,但对会话来说都不是必需的。您可以将任何值存储为会话变量,并使用它来确定是否授予对页面的访问权限。例如,你可以创建一个名为$_SESSION['status']的变量,根据它的值给访问者访问站点不同部分的权限,或者如果没有设置,就不允许访问。

一个小小的演示应该能让一切变得清晰,并向你展示会话在实践中是如何工作的。

PHP 解决方案 11-1:简单的会话示例

这应该只需要几分钟就能完成,但是您也可以在ch11文件夹的session_01.phpsession_02.phpsession_03.php中找到完整的代码。

  1. php8sols站点根目录下名为sessions的新文件夹中创建一个名为session_01.php的页面。插入一个带有名为name的文本字段和一个Submit按钮的表单。将method设置为post并将action设置为session_02.php。该表单应该如下所示:

    <form method="post" action="session_02.php">
        <p>
            <label for="name">Enter your name:</label>
            <input type="text" name="name" id="name">
        </p>
        <p>
            <input type="submit" name="Submit" value="Submit">
        </p>
    </form>
    
    
  2. 在另一个名为session_02.php的页面中,将其插入到DOCTYPE声明的上方:

    <?php
    // initiate session
    session_start();
    // check that form has been submitted and that name is not empty
    if ($_POST && !empty($_POST['name'])) {
        // set session variable
        $_SESSION['name'] = $_POST['name'];
    }
    ?>
    
    

行内注释解释了正在发生的事情。会话开始,只要$_POST['name']不为空,它的值就被赋给$_SESSION['name']

  1. session_02.php中的<body>标签之间插入以下代码:

    <?php
    // check session variable is set
    if (isset($_SESSION['name'])) {
        // if set, greet by name
        echo 'Hi there, ' . htmlentities($_SESSION['name']) . '. <a
            href="session_03.php">Next</a>';
    } else {
        // if not set, send back to login
        echo 'Who are you? <a href="session_01.php">Please log in</a>';
    }
    ?>
    
    

如果$_SESSION['name']已设置,将显示一条欢迎消息以及一个到session_03.php的链接。否则,页面会告诉访问者它不知道谁在试图访问,并提供一个返回到第一页的链接。

Caution

键入以下行时要小心:

echo 'Hi there, ' . htmlentities($_SESSION['name']) . '. <a href="session03.php">Next</a>';

前两个句点(htmlentities($_SESSION['name']))是 PHP 连接运算符。第三个句点(紧接在单引号之后)是一个普通的句点,将作为字符串的一部分显示。

  1. 创建session_03.php。在DOCTYPE上方键入以下内容以启动会话:

  2. session_03.php<body>标签之间插入以下代码:

    <?php
    // check whether session variable is set
    if (isset($_SESSION['name'])) {
        // if set, greet by name
        echo 'Hi, ' . htmlentities($_SESSION['name']) . '. See, I remembered
            your name!<br>';
        // unset session variable
        unset($_SESSION['name']);
        // invalidate the session cookie
        if (isset($_COOKIE[session_name()])) {
            setcookie(session_name(), '', time()-86400, '/');
        }
        // end session
        session_destroy();
        echo '<a href="session_02.php">Back to page 2</a>';
    } else {
        // display if not recognized
        echo "Sorry, I don't know you.<br>";
        echo '<a href="session_01.php">Please log in</a>';
    }
    ?>
    
    
<?php session_start(); ?>

如果已经设置了$_SESSION['name'],页面显示它,然后取消设置,并使当前会话 cookie 无效。通过将session_destroy()放在第一个代码块的末尾,会话及其相关变量将不再可用。

  1. session_01.php加载到浏览器中,在文本字段中键入您的姓名,然后单击提交。

  2. 您应该会看到类似下面的截图。在这个阶段,这里发生的事情和普通形式没有明显的区别:

img/332054_5_En_11_Figa_HTML.jpg

  1. 当您单击“下一步”时,会话的威力开始显现。页面会记住你的名字,即使$_POST数组对它不再可用。在大多数情况下,您可能会看到类似于以下截图的内容:

img/332054_5_En_11_Figb_HTML.jpg

但是,在某些服务器上,您可能会收到以下警告消息,指出无法修改邮件头信息,因为邮件头已经发送:

img/332054_5_En_11_Figc_HTML.jpg

Note

正如第五章所解释的,如果服务器被配置为缓冲前 4 KB 的输出,它们不会产生关于头的警告。然而,并不是所有的服务器都会缓冲输出,所以解决这个问题很重要。

  1. 单击第 2 页的链接(如果您收到错误消息,它就在消息的下面)。会话已被破坏,所以这次session_02.php不知道你是谁:

img/332054_5_En_11_Figd_HTML.jpg

  1. 在浏览器地址栏中输入session_03.php的地址并加载。它也不记得该会话,并显示相应的消息:

img/332054_5_En_11_Fige_HTML.jpg

即使您在步骤 8 中没有收到警告消息,当您将依赖于会话的页面部署到其他服务器时,也需要防止它发生。错误消息不仅看起来很糟糕,而且还意味着setcookie()不能使会话 cookie 无效。尽管在session_03.phpsession_start()紧跟在开始的 PHP 标签之后,但是警告消息是由DOCTYPE声明、<head>和其他在setcookie()之前输出的 HTML 触发的。

PHP 解决方案 11-2:用 ob_start()缓冲输出

虽然您可以将setcookie()放在 PHP 块中的DOCTYPE声明之上,但是您也需要将$_SESSION['name']的值赋给一个普通变量,因为它在会话被销毁后就不再存在了。答案不是将整个脚本分开,而是用ob_start()缓冲输出。

继续使用上一节的session_03.php

  1. 像这样修改DOCTYPE声明上面的 PHP 块:

    <?php
    session_start();
    ob_start();
    ?>
    
    

这会打开输出缓冲,并防止输出被发送到浏览器,直到脚本结束,或者直到您使用ob_end_flush()专门刷新输出。

  1. 使会话 cookie 无效后,立即刷新输出,如下所示:

    // invalidate the session cookie
    if (isset($_COOKIE[session_name()])) {
        setcookie(session_name(), ", time()-86400, '/');
    }
    ob_end_flush();
    
    
  2. 保存session_03.php并再次测试序列。这一次应该没有警告。更重要的是,会话 cookie 不再有效。(更新后的代码在session_04.php里。)

使用基于文件的身份验证

正如您刚才看到的,会话变量和条件语句的组合让您根据是否设置了会话变量向访问者呈现完全不同的页面。你所需要做的就是添加一个密码检查系统,这样你就有了一个基本的用户认证系统。

在深入研究代码之前,让我们考虑一下安全密码这个重要问题。

确保密码安全

密码不应存储在公共场所。换句话说,如果您使用基于文件的身份验证系统,该文件必须在 web 服务器的文档根目录之外。此外,密码不应以纯文本形式保存。为了更安全,建议散列密码。多年来,人们推荐使用MD5SHA- 1 算法将密码散列为 32 位或 40 位十六进制数。他们最初的优势之一,速度,变成了一个主要的弱点。自动化脚本可以在暴力攻击中每秒处理大量计算,以确定原始值——与其说是猜测,不如说是尝试每种可能的组合。

当前的建议是使用两个函数:password_hash()password_verify(),它们提供了一个更加健壮的散列和验证密码的系统。要散列一个密码,只需将它传递给password_hash()函数,就像这样:

$hashed = password_hash($password, PASSWORD_DEFAULT);

password_hash()的第二个参数是一个常量,它将加密方法留给 PHP,允许您随时更新当时被认为是最安全的方法。

Note

password_hash()功能为高级用户提供了其他选项。详见 www.php.net/manual/en/function.password-hash.php 。在 www.php.net/manual/en/faq.passwords.php 还有一个关于安全密码哈希的 FAQ(常见问题)页面。

使用password_hash()以一种不可能逆转的方式打乱纯文本密码。这意味着,即使您的密码文件被暴露,也没有人能够知道密码是什么。这也意味着您无法将密码转换回其原始值。在一个方面,这并不重要:当用户登录时,password_verify()根据散列版本检查提交的值。缺点是,如果用户忘记密码,您无法向他们发送密码提醒;必须创建新密码。然而,良好的安全性要求散列密码。

散列法并不能防止密码最常见的问题:容易猜测或使用常见单词的密码。许多注册系统现在通过要求混合字母数字字符和符号来强制使用更强的密码。

因此,第一项任务是创建一个用户注册表单,检查以下内容:

  • 密码和用户名包含最少数量的字符

  • 密码符合最小强度标准,例如包含数字、大写和小写字符以及符号的混合

  • 密码与确认字段中的第二个条目相匹配

  • 用户名没有被使用

PHP 解决方案 11-3:创建密码强度检查器

这个 PHP 解决方案展示了如何创建一个类来检查密码是否满足某些要求,比如空格的使用、最小字符数以及不同类型字符的组合。默认情况下,该类只检查密码是否只有单个空格,开头或结尾没有空格,并且包含最少数量的字符。可选方法允许您设置更严格的条件,例如使用大写和小写字符、数字和非字母数字符号的组合。

这个 PHP 解决方案从构建用户注册表单开始,这个表单也将在 PHP 解决方案 11-4 中使用。

  1. sessions文件夹中创建一个名为register.php的页面,并插入一个带有三个文本输入字段和一个提交按钮的表单。布局表单并命名输入元素,如下图所示。如果您想节省时间,请使用ch11文件夹中的register_01.php:

img/332054_5_En_11_Figf_HTML.jpg

  1. 和往常一样,您希望处理脚本只在表单提交后才运行,所以一切都需要包含在一个条件语句中,该语句检查提交按钮的name属性是否在$_POST数组中。然后,您需要检查输入是否满足您的最低要求。在 PHP 块中的DOCTYPE声明上方插入以下代码:

    if (isset($_POST['register'])) {
        $username = trim($_POST['username']);
        $password = trim($_POST['pwd']);
        $retyped = trim($_POST['conf_pwd']);
        require_once '../Php8Solutions/Authenticate/CheckPassword.php';
    }
    
    

条件语句中的代码将来自三个文本字段的输入传递给trim()以删除开头和结尾的空白,并将结果分配给简单的变量。然后,它包含一个文件,该文件将包含检查密码的类,这将在接下来定义。

Note

在这个阶段,代码不会检查$password$retyped是否相同。现在不比较它们简化了类的测试。

  1. Php8Solutions文件夹中新建一个名为Authenticate的文件夹。然后在新文件夹中创建一个名为CheckPassword.php的文件。它将只包含 PHP 脚本,所以去掉任何 HTML 并添加以下代码:

    <?php
    namespace Php8Solutions\Authenticate;
    
    class CheckPassword {
        const MIN_LENGTH = 8;
        protected array $errors = [];
    
        public function __construct(
            protected string $password,
            protected int ?$minChars = null,
            protected bool $mixedCase = false,
            protected int $minNums = 0,
            protected int $minSymbols = 0
        ) {
            if (!isset($this->minChars) || $this->minChars <
                self::MIN_LENGTH) {
                $this->minChars = self::MIN_LENGTH;
            }
            $this->check();
        }
    
    }
    
    

这定义了基本的CheckPassword类,它最初只检查密码是否包含多个空格,以空格开始或结束,并具有所需的最小字符数。您将很快添加其他特性。

该文件首先将Php8Solutions\Authenticate声明为其名称空间,然后用设置为8的常量MIN_LENGTH来定义CheckPassword类。这决定了密码中的最小字符数。还有一个名为$errors的受保护属性,它被初始化为一个空数组。如果密码未通过任何检查,这将用于存储所有错误消息。

构造函数方法有五个参数,前面都有protected可见性声明,所以 PHP 8 的构造函数属性提升会自动将它们转换为类属性。只有第一个参数是必需的:要检查的密码,它必须是一个字符串。其余的参数都有默认值,所以它们成为可选参数。

$minChars参数接受整数或null。默认情况下,是null。设置此参数的值允许您更改密码中的最小字符数。然而,构造函数体中的条件语句使用带有作用域解析操作符(::)的self关键字检查是否提供了一个大于MIN_LENGTH常数的值。如果条件的两部分都等于假,则使用MIN_LENGTH的值。否则,将使用传递给构造函数的值。这可以防止使用低于常数中指定的最小值的值。

其他参数为其他要求设置默认值:混合使用大写和小写字符,最少使用数字字符,最少使用非字母数字字符。三者的默认值都是负数。

构造函数最后调用一个名为 check()的内部方法,我们接下来将定义它。

  1. 在构造函数定义后插入以下代码:

                  protected function check() {
                      if (preg_match('/\s{2,}/', $this->password)) {
                          $this->errors[] = 'Password can contain only single spaces.';
                      }
                      if (strlen($this->password) < $this->minChars) {
                     $this->errors[] = "Password must be at least
                         $this->minChars characters.";
                     }
                 }
    
    

check()方法包含两个条件语句。第一种方法将preg_match()与正则表达式结合使用,在密码中搜索两个或更多连续的空白字符。另一个条件语句使用strlen()来确定密码字符串的长度,并将结果与$minChars属性进行比较。

如果密码没有通过这些测试,那么$errors属性将包含至少一个元素。所以我们可以用它来决定是接受还是拒绝密码。

  1. getErrors()公共方法只是返回错误消息的数组,它看起来像这样:

                  public function getErrors() {
                      return $this->errors;
                  }
    
    
  2. getErrors()方法添加到类定义中,保存CheckPassword.php,切换到register.php

  3. register.php中,在开始的 PHP 标签后添加下面一行以导入 CheckPassword 类:

use Php8Solutions\Authenticate\CheckPassword;

Caution

您必须总是在脚本的顶层导入命名空间类。试图在条件语句中导入该类会生成分析错误。

  1. 在表单提交后执行代码的条件语句中,创建一个CheckPassword对象,将$password作为参数传递。然后这样调用getErrors()方法:

    require_once '../Php8Solutions/Authenticate/CheckPassword.php';
    $checkPwd = new CheckPassword($password);
    $errors = $checkPwd->getErrors();
    }
    
    

只有CheckPassword构造函数的第一个参数是必需的。在这个阶段,该类只能检查多个空格和密码的长度。因此,唯一有影响的另一个参数是设置最小字符数。省略它会将最小值设置为默认值 8。

  1. 将以下 PHP 代码块添加到页面正文中的表单上方:

    <h1>Register User</h1>
    <?php
    if (isset($errors)) {
        echo '<ul>';
         if (empty($errors)) {
               echo '<li>Password OK</li>';
         } else {
            foreach ($errors as $error) {
                echo "<li>$error</li>";
            }
         }
        echo '</ul>';
    }
    ?>
    <form action="register.php" method="post">
    
    

这将检查$errors是否已被定义。如果有,我们知道表单已经被提交,所以一个条件语句检查$errors是否为空。如果是,我们知道密码通过了测试;并且我们显示适当的消息。否则,foreach 循环会显示错误信息。

  1. 保存register.php并将其载入浏览器。通过点击 Register 按钮测试CheckPassword类,无需填写任何字段。您应该会看到一条消息,通知您密码至少需要八个字符。

  2. 尝试使用包含八个字符的密码。您应该看到密码正常。

    提示只检查在密码字段中输入的值,所以在这个阶段不需要填写其他字段。

  3. 尝试使用至少包含八个字符的密码,但在中间插入一个空格。您应该看到密码正常。

  4. 中间放两个连续的空格。你会被警告只允许一个空格。

  5. 请尝试使用少于八个字符且中间有多个连续空格的字符。您将看到以下警告:

img/332054_5_En_11_Figg_HTML.jpg

  1. 更改register.php中的代码,将可选的第二个参数传递给CheckPassword构造函数,将最小字符数设置为 10:

  2. 保存并再次测试页面。如果您遇到任何问题,请将您的代码与ch11文件夹中的register_02.phpch11/Php8Solutions/Authenticate文件夹中的CheckPassword_01.php进行比较。

  3. 假设您的代码正在运行,修改check()方法来添加对混合大写和小写字符、最小数量的数字字符和最小数量的非字母数字字符的测试。更新后的代码如下所示:

    public function check() {
        if (preg_match('/\s{2,}/', $this->password)) {
          $this->errors[] = 'Password can contain only single spaces.';
        }
        if (strlen($this->password) < $this->minChars) {
            $this->errors[] = "Password must be at least
                $this->minChars characters.";
        }
        if ($this->mixedCase) {
            $pattern = '/(?=.*\p{Ll})(?=.*\p{Lu})/u';
            if (!preg_match($pattern, $this->password)) {
                $this->errors[] = 'Password should include uppercase
                    and lowercase characters.';
            }
        }
    
        if ($this->minNums > 0) {
            $pattern = '/\d/';
            $found = preg_match_all($pattern, $this->password, $matches);
            if ($found < $this->minNums) {
                $this->errors[] = "Password should include at least
                    $this->minNums number(s).";
            }
        }
        if ($this->minSymbols > 0) {
            $pattern =  '/[\p{S}\p{P}]/u';
            $found = preg_match_all($pattern, $this->password, $matches);
            if ($found < $this->minSymbols) {
                $this->errors[] = "Password should include at least
                    $this->minSymbols nonalphanumeric character(s).";
            }
        }
    
    
$checkPwd = new CheckPassword($password, 10);

}

只有当等效的可选参数传递给类构造函数时,三个新的条件语句才会运行。每一个都存储一个正则表达式作为$pattern,然后使用preg_match()preg_match_all()来测试密码。

如果$mixedCase属性被设置为true,则正则表达式和密码被传递给preg_match(),以在密码的任何位置查找至少一个小写字母和一个大写字母。正则表达式使用 Unicode 类别元字符表示小写和大写,因此允许的字符不限于非重音字符 A-z。结束分隔符后的小写字母 u 是一个修饰符,它将模式和主题字符串视为 UTF-8。

默认情况下,$minNums$minSymbols属性设置为 0。如果它们被重置为正数,正则表达式和密码被传递给preg_match_all()函数,以找出正则表达式匹配了多少次。该函数需要三个参数:正则表达式、要搜索的字符串和存储匹配项的变量;它返回找到的匹配数。在这种情况下,您感兴趣的只是匹配的数量。存储匹配的变量被丢弃。

最后一个条件语句中的$pattern将数学符号、货币符号、标点符号和其他符号的 Unicode 类别元字符与 UTF-8 修饰符一起使用。

  1. 保存CheckPassword.php并通过将可选参数传递给register.php中的类构造函数来测试更新后的类。您可以按照与构造函数签名中相同的顺序传递它们,也可以使用命名参数传递它们。例如,以下要求密码至少包含两个数字和一个非字母数字符号:

                  $checkPwd = new CheckPassword($password, minNums: 2,
                      minSymbols: 1);
    
    

使用各种组合来加强不同强度的密码。

如有必要,对照ch11文件夹中的register_03.phpch11/Php8Solutions/Authenticate文件夹中的CheckPassword_02.php检查您的代码。

既然我们可以检查密码强度,我们就可以构建一个简单的用户注册系统。

PHP 解决方案 11-4:创建一个基于文件的用户注册系统

这个 PHP 解决方案创建了一个简单的用户注册系统,用password_hash()函数散列密码。它使用 PHP 解决方案 11-3 中的CheckPassword类来执行最低强度要求。进一步的检查确保用户名包含最少数量的字符,并且用户已经在第二个字段中正确地重新键入了密码。

用户凭据存储在纯文本文件中,该文件必须位于 web 服务器的文档根目录之外。这些说明假设你已经建立了一个 PHP 可以写访问的private文件夹,如第七章所述。还假设您熟悉那一章中的“用 fopen()追加内容”一节。

继续使用前面的 PHP 解决方案中的文件。或者,使用ch11文件夹中的register_03.phpch11/Php8Solutions/Authenticate文件夹中的CheckPassword_02.php

  1. includes文件夹中创建一个名为register_user_csv.php的文件,并删除脚本编辑器插入的所有 HTML。

  2. 当使用命名空间类时,import 语句必须在使用该类的同一个文件中,即使它是一个包含文件。从register.php的顶部剪下下面一行,粘贴到register_user_csv.php中:

  3. register.php中剪切以下代码,粘贴到导入语句后的register_user_csv.php(如果您的密码强度设置不同也没关系):

                  require_once '../Php8Solutions/Authenticate/CheckPassword.php';
                  $checkPwd = new CheckPassword($password, minNums: 2,
                      minSymbols: 1);
                  $errors = $checkPwd->getErrors();
    
    
  4. register.php中的DOCTYPE声明上方的剩余脚本的末尾,创建一个用于存储用户凭证的文本文件位置的变量;包括register_user_csv.phpregister.php顶部的 PHP 块中的代码现在应该是这样的:

    if (isset($_POST['register'])) {
        $username = trim($_POST['username']);
        $password = trim($_POST['pwd']);
        $retyped = trim($_POST['conf_pwd']);
        $userfile = 'C:/private/hashed.csv';
        require_once '../includes/register_user_csv.php';
    }
    
    
use Php8Solutions\Authenticate\CheckPassword;

用户凭据的 CSV 文件尚不存在。当第一个用户注册时,它将自动创建。如有必要,修改private文件夹的路径以匹配您自己的设置。

  1. register_user_csv.php中,粘贴您在步骤 3 中从register.php中剪切的代码,并修改包含类定义的命令,如下所示:

                  require_once __DIR__ .
                      '/../Php8Solutions/Authenticate/CheckPassword.php';
    
    

您需要修改相对路径,因为register_user_csv.php也是一个包含文件(参见第五章中的“嵌套包含文件”)。

  1. 在 include 命令后立即插入以粗体突出显示的代码:

    require_once __DIR__ .
         '/../Php8Solutions/Authenticate/CheckPassword.php';
    $usernameMinChars = 6;
    $formErrors = [];
    if (strlen($username) < $usernameMinChars) {
        $formErrors[] = "Username must be at least $usernameMinChars characters.";
    }
    if (!preg_match('/^[-_\p{L}\d]+$/ui', $username)) {
        $formErrors[] = 'Only alphanumeric characters, hyphens, and underscores
            are permitted in username.';
    }
    if ($password != $retyped) {
        $formErrors[] = "Your passwords don't match.";
    }
    $checkPwd = new CheckPassword($password, minNums: 2,
        minSymbols: 1);
    
    

新代码的前两行指定了用户名中的最小字符数,并为注册表单中的错误消息初始化一个空数组。新代码的其余部分使用strlen()检查用户名的长度,并测试它是否包含除字母、数字、连字符和下划线之外的任何字符。检查用户名的正则表达式接受所有 UTF-8 字母,包括重音字符。虽然这允许使用非常广泛的字符,但它阻止用户注册可能被用来注入恶意代码的名称。最后一个条件语句检查两个密码字段是否包含相同的值。

Note

如果在第二个密码字段中输入的值与第一个不匹配,是否值得检查密码强度是有争议的。然而,我认为提醒用户密码不足是一个好主意,即使字段不匹配。

  1. 像这样修改register_user_csv.php底部的代码:
$errors = array_merge($formErrors, $checkPwd->getErrors());

不是简单地将调用getErrors()方法的结果分配给$errors,而是使用array_merge()函数合并两个错误消息数组。

  1. 保存register_user_csv.phpregister.php,然后再次测试表单。将所有字段留空,然后单击注册。您应该会看到一系列与此类似的错误消息(与密码相关的消息取决于类构造函数中设置的选项):

img/332054_5_En_11_Figh_HTML.jpg

尝试各种测试,以确保您的验证代码正常工作。

如果你有问题,将你的代码与ch11文件夹中的register_user_csv_01.phpregister_04.php进行比较。

假设您的代码正在运行,您已经准备好创建脚本的注册部分了。让我们停下来考虑一下主脚本需要做什么。首先,你需要散列密码。然后,在将详细信息写入 CSV 文件之前,您必须检查用户名是否唯一。这就出现了一个关于使用fopen()的模式的问题。

Note

第七章中描述了各种fopen()模式。

理想情况下,您希望内部指针位于文件的开头,这样您就可以遍历现有的记录。r+模式会这样做,但是除非文件已经存在,否则操作会失败。您不能使用w+,因为它会删除现有内容。您也不能使用x+,因为如果同名文件已经存在,它将失败。

这使得a+c+成为唯一具有所需灵活性的选项:两者都在必要时创建文件,并允许您读写。它们的不同之处在于当你打开文件时内部指针放在哪里:a+把它放在末尾,而c+把它放在开头。这使得c+在检查现有记录时更有用,但是a+的优势在于总是在文件末尾追加新内容。这避免了意外覆盖现有值的危险。我们将以a+模式打开 CSV 文件。

第一次运行脚本时,文件是空的(因为filesize()函数返回0,所以可以看出这一点),所以可以继续使用fputcsv()编写细节。这是fgetcsv()的对应物,在第七章有描述。fgetcsv()从 CSV 文件中一次提取一行数据,fputcsv()创建一个 CSV 记录。它有两个必需的参数:文件引用和作为 CSV 记录插入的值数组。它还接受可选参数来设置分隔符和包围字符(参见在线文档 www.php.net/manual/en/function.fputcsv.php )。

如果filesize()没有返回0,你需要重置内部指针并遍历记录来查看用户名是否已经注册。如果有匹配,就跳出循环并准备一条错误消息。如果在循环结束时没有匹配,您知道这是一个需要添加到文件中的新用户名。现在你已经理解了剧本的流程,你可以把它插入到register_user_csv.php中了。

  1. register_user_csv.php的底部添加以下代码:

    if (!$errors) {
        // hash password using default algorithm
        $password = password_hash($password, PASSWORD_DEFAULT);
        // open the file in append mode
        $file = fopen($userfile, 'a+');
        // if filesize is zero, no names yet registered
        // so just write the username and password to file as CSV
        if (filesize($userfile) === 0) {
            fputcsv($file, [$username, $password]);
            $result = "$username registered.";
        } else {
            // if filesize is greater than zero, check username first
            // move internal pointer to beginning of file
            rewind($file);
            // loop through file one line at a time
            while (!feof($file)) {
                $data = fgetcsv($file);
                // skip empty lines
            if (!$data) continue;
                if ($data[0] == $username) {
                    $result = "$username taken - choose a different username.";
                    break;
                }
            }
            // if $result not set, username is OK
            if (!isset($result)) {
                // insert new CSV record
                fputcsv($file, [$username, $password]);
                $result = "$username registered.";
            }
            // close the file
            fclose($file);
        }
    }
    
    

前面的解释和行内注释应该有助于您理解这个脚本。

  1. 注册脚本将结果存储在$result$errors数组中。修改register.php主体中的代码以显示结果或错误消息,如下所示:

                  <?php
             if (isset($errors) || isset($result)) {
                 echo '<ul>';
                      if (empty($errors)) {
                          echo "<li>$result</li>";
                      } else {
                          foreach ($errors as $error) {
                              echo "<li>$error</li>";
                          }
                      }
                 echo '</ul>';
             }
    ?>
    
    

这将$result的值显示为单个项目符号项。否则,如果不为空,它将遍历$errors数组。

img/332054_5_En_11_Fig4_HTML.jpg

图 11-4

使用 salt 会产生相同密码的完全不同的散列

  1. 保存register_user_csv.phpregister.php并测试注册系统。尝试多次注册同一个用户名。您应该会看到一条消息,通知您该用户名已被占用,并要求您选择另一个用户名。

  2. 打开hashed.csv。您应该看到纯文本的用户名,但密码应该已经过哈希处理。即使您为两个不同的用户选择相同的密码,哈希版本也是不同的,因为在加密之前,password_hash()会在密码中添加一个随机值,称为 salt 。图 11-4 显示了两个用户都用密码chapter11*注册。

如有必要,对照ch11文件夹中的register_user_csv_02.phpregister_05.php检查您的代码。

Tip

register_user_csv.php中的大部分代码都是通用的。要将它用于任何注册表,您只需在包含它之前定义$username$password$retyped$userfile,并使用$errors$result捕获结果。您可能需要对外部文件进行的唯一更改是设置用户名中的最小字符数和设置密码强度的参数。这些设置是在文件的顶部定义的,所以很容易访问和调整。

使用 password_verify()检查散列密码

password_verify()函数做的正是您所期望的:它验证用password_hash()散列的密码。它只需要两个参数,提交的密码和散列版本。如果提交的密码正确,函数返回true。否则返回false

PHP 解决方案 11-5:构建登录页面

这个 PHP 解决方案展示了如何通过post方法提交用户名和密码,然后对照存储在外部文本文件中的值检查提交的值。如果找到匹配,脚本会设置一个会话变量,然后将用户重定向到另一个页面。

  1. sessions文件夹中创建一个名为login.php的文件,然后插入一个带有用户名和密码文本输入字段的表单,以及一个名为login的提交按钮,就像这样(或者,使用ch11文件夹中的login_01.php):

    <form method="post" action="login.php">
        <p>
            <label for="username">Username:</label>
            <input type="text" name="username" id="username">
        </p>
        <p>
            <label for="pwd">Password:</label>
            <input type="password" name="pwd" id="pwd">
        </p>
        <p>
            <input name="login" type="submit" value="Log in">
        </p>
    </form>
    
    

这是一个简单的形式,没有什么花哨:

img/332054_5_En_11_Figi_HTML.jpg

  1. 在 PHP 块中的DOCTYPE声明上方添加以下代码:

    $error = '';
    if (isset($_POST['login'])) {
        session_start();
        $username = $_POST['username'];
        $password = $_POST['pwd'];
        // location of usernames and passwords
        $userlist = 'C:/private/hashed.csv';
        // location to redirect on success
        $redirect = 'http://localhost/php8sols/sessions/menu.php';
        require_once '../includes/authenticate.php';
    }
    
    

这将名为$error的变量初始化为空字符串。如果登录失败,这将用于显示一条错误消息,通知用户失败的原因。

条件语句然后检查$_POST数组是否包含名为login的元素。如果是,则表单已经提交,花括号内的代码启动一个 PHP 会话,并将通过$_POST数组传递的值存储在$username$password中。然后创建$userlist,它定义了包含注册用户名和密码的文件的位置,以及$redirect,用户成功登录后将被发送到的页面的 URL。

最后,条件语句中的代码包括authenticate.php,接下来您将创建它。

Note

调整$userlist的值,以匹配您自己设置中的位置。

  1. includes文件夹中创建一个名为authenticate.php的文件。它将只包含 PHP 代码,所以去掉脚本编辑器插入的任何 HTML,并插入以下代码:

    <?php
    if (!file_exists($userlist) || !is_readable($userlist)) {
        $error = 'Login facility unavailable. Please try later.';
    } else {
        $file = fopen($userlist, 'r');
        while (!feof($file)) {
            $data = fgetcsv($file);
            // ignore if the first element is empty
            if (empty($data[0])) {
                continue;
            }
            // if username and password match, create session variable,
            // regenerate the session ID, and break out of the loop
            if ($data[0] == $username && password_verify($password, $data[1])) {
                $_SESSION['authenticated'] = 'Jethro Tull';
                session_regenerate_id();
                break;
            }
        }
        fclose($file);
    }
    
    

这改编了你在 PHP 解决方案 7-2 的getcsv.php中使用的代码。条件语句检查不存在的文件或无法读取的文件。如果$userlist有问题,会立即创建错误消息。

否则,else块中的主代码通过以读取模式打开文件并使用fgetcsv()函数返回每行数据的数组来提取 CSV 文件的内容。包含用户名和散列密码的 CSV 文件没有列标题,因此 while 循环检查每一行中的数据。

如果$data[0]empty,很可能意味着当前行是空白的,所以跳过。

每行的第一个数组元素($data[0]包含存储的用户名。它与提交的值$username进行比较。

通过登录表单提交的密码存储在$password中,哈希版本存储在$data[1]中。两者都作为参数传递给password_verify(),如果匹配,它返回true

如果用户名和密码都匹配,脚本会创建一个名为$_SESSION['authenticated']的变量,并将其命名为 20 世纪 70 年代一个伟大的民谣摇滚乐队的名字。这两者都没有什么魔力(除了杰斯洛·图尔的音乐);我已经任意选择了变量的名称和值。重要的是创建了一个会话变量。一旦发现匹配,就重新生成会话 ID,并且break退出循环。

  1. 如果登录成功,header()函数需要将用户重定向到$redirect中存储的 URL,然后退出脚本。否则,需要创建一条错误消息,通知用户登录失败。完整的脚本如下所示:

    <?php
    if (!file_exists($userlist) || !is_readable($userlist)) {
        $error = 'Login facility unavailable. Please try later.';
    } else {
        $file = fopen($userlist, 'r');
        while (!feof($file)) {
            $data = fgetcsv($file);
            // ignore if the first element is empty
            if (empty($data[0])) {
                continue;
            }
            // if username and password match, create session variable,
            // regenerate the session ID, and break out of the loop
            if ($data[0] == $username && password_verify($password, $data[1])) {
                $_SESSION['authenticated'] = 'Jethro Tull';
                session_regenerate_id();
                break;
            }
        }
        fclose($file);
        // if the session variable has been set, redirect
        if (isset($_SESSION['authenticated'])) {
            header("Location: $redirect");
            exit;
        } else {
            $error = 'Invalid username or password.';
        }
    
    
  2. login.php中,在开始的<body>标签后添加以下短代码块,以显示任何错误消息:

}

<body>

<?php
if ($error) {
    echo "<p>$error</p>";
}
?>
<form method="post" action="login.php">

完整的代码在ch11文件夹的authenticate.phplogin_02.php中。在测试login.php之前,您需要创建menu.php,并使用会话限制访问。

PHP 解决方案 11-6:通过会话限制对页面的访问

这个 PHP 解决方案演示了如何通过检查会话变量的存在来限制对页面的访问,该变量指示用户的凭证已经过身份验证。如果变量没有被设置,header()函数将用户重定向到登录页面。

  1. sessions文件夹中创建两个名为menu.phpsecretpage.php的页面。它们包含什么并不重要,只要它们相互链接。或者,使用ch11文件夹中的menu_01.phpsecretpage_01.php

  2. 通过在DOCTYPE声明上方插入以下内容来保护对每个页面的访问:

    <?php
    session_start();
    // if session variable not set, redirect to login page
    if (!isset($_SESSION['authenticated'])) {
        header('Location: http://localhost/php8sols/sessions/login.php');
        exit;
    }
    ?>
    
    

启动会话后,脚本检查是否设置了$_SESSION['authenticated']。如果没有,它将用户重定向到login.php并退出。仅此而已!该脚本不需要知道$_SESSION['authenticated']的值,尽管您可以通过如下修改第 4 行来加倍确保:

             if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated']
                 != 'Jethro Tull')  {

现在,如果$_SESSION['authenticated']的值不正确,也会拒绝访问者。

  1. 保存menu.phpsecretpage.php,然后尝试将它们加载到浏览器中。你应该总是被重定向到login.php

  2. login.php中输入您在hashed.csv中注册的有效用户名和密码(值区分大小写),然后点击Log in。您应该会立即被重定向到menu.php,到secretpage.php的链接也应该可以工作。

你可以对照ch11文件夹中的menu_02.phpsecretpage_02.php来检查你的代码。

要保护站点上的任何页面,您只需在步骤 2 中的DOCTYPE声明上方添加八行代码。

PHP 解决方案 11-7:创建一个可重用的注销按钮

除了登录到站点,用户还应该能够注销。这个 PHP 解决方案展示了如何创建一个可以插入任何页面的注销按钮。

继续使用上一节中的文件。

  1. 通过插入以下表单,在menu.php<body>中创建一个注销按钮:

    <form method="post">
        <input name="logout" type="submit" value="Log out">
    </form>
    
    

该页面应该类似于下面的屏幕截图:

img/332054_5_En_11_Figj_HTML.jpg

  1. 现在,您需要添加单击 logout 按钮时运行的脚本。像这样修改DOCTYPE声明上面的代码(代码在menu_02.php):

    <?php
    session_start();
    // if session variable not set, redirect to login page
    if (!isset($_SESSION['authenticated'])) {
        header('Location: http://localhost/php8sols/sessions/login.php');
        exit;
    }
    // run this script only if the logout button has been clicked
    if (isset($_POST['logout'])) {
        // empty the $_SESSION array
        $_SESSION = [];
        // invalidate the session cookie
        if (isset($_COOKIE[session_name()])) {
            setcookie(session_name(), '', time()-86400, '/');
        }
        // end session and redirect
        session_destroy();
        header('Location: http://localhost/php8sols/sessions/login.php');
        exit;
    }
    ?>
    
    

这与本章前面的“销毁会话”中的代码相同。唯一的区别是它被包含在一个条件语句中,所以它只在点击 logout 按钮时运行,并使用header()将用户重定向到login.php

  1. 保存menu.php并点击退出测试。您应该被重定向到login.php。任何返回menu.phpsecretpage.php的尝试都会把你带回login.php

  2. 你可以把相同的代码放在每个受限页面中,但是 PHP 是为了节省工作,而不是制造工作。将它转换成包含文件是有意义的。在includes文件夹中创建一个名为logout.php的新文件。将步骤 1 和 2 中的新代码剪切并粘贴到新文件中,就像这样(它在ch11文件夹的logout.php中):

    <?php
    // run this script only if the logout button has been clicked
    if (isset($_POST['logout'])) {
        // empty the $_SESSION array
        $_SESSION = array();
        // invalidate the session cookie
        if (isset($_COOKIE[session_name()])) {
            setcookie(session_name(), ", time()-86400, '/');
        }
        // end session and redirect
        session_destroy();
        header('Location: http://localhost/php8sols/sessions/login.php');
        exit;
    }
    ?>
    <form method="post">
        <input name="logout" type="submit" value="Log out">
    </form>
    
    

因为表单将包含在不同的页面中,所以不能将action属性设置为特定的页面。然而,省略它只会导致当前页面被重新加载,因此注销脚本将在包含logout.php的每个页面中可用。

  1. menu.php中剪切表单代码的同一点,包含新文件,如下所示:
<?php include '../includes/logout.php'; ?>

像这样包含来自外部文件的代码意味着在调用setcookie()header()之前会有输出到浏览器。所以你需要缓冲输出,如 PHP 解决方案 11-2 所示。

  1. 在调用menu.php顶部的session_start()后立即添加ob_start();。没必要用ob_end_flush()或者ob_end_clean()。如果您还没有明确地这样做,PHP 会在脚本结束时自动刷新缓冲区。

  2. 保存menu.php并测试页面。它的外观和工作方式应该和以前完全一样。

  3. secretpage.php重复步骤 5 和 6。您现在有了一个简单的、可重复使用的注销按钮,它可以合并到任何受限页面中。

您可以对照ch11文件夹中的menu_04.phpsecretpage_03.phplogout.php来检查您的代码。

PHP 解决方案 11-3 到 11-7 构建了一个简单而有效的用户认证系统,不需要数据库后端。然而,它也有其局限性。最重要的是,包含用户名和密码的 CSV 文件必须位于服务器根目录之外。此外,一旦获得了多条记录,查询数据库通常比逐行遍历 CSV 文件要快得多。第十九章介绍了数据库的用户认证。

保持哈希算法最新

使用password_hash()password_verify()的主要优势是它们被设计来跟上密码学的发展。使用PASSWORD_DEFAULT作为password_hash()的第二个参数,而不是指定特定的散列算法,可以确保新注册总是使用当时认为最安全的方法。即使缺省值改变了,现有的密码仍然可以通过password_verify()函数进行验证,因为散列密码包含识别其散列方式的信息。

还有一个名为password_needs_rehash()的函数,它检查散列密码是否需要更新到当前标准。它是为用户登录网站时使用而设计的。下面的代码假设提交的密码存储在$password中,哈希密码存储在$hashed中,并且您使用的是 PHP 默认的哈希方法:

if (password_verify($password, $hashed) {
    if (password_needs_rehash($hashed, PASSWORD_DEFAULT)) {
        $hashed = password_hash($password, PASSWORD_DEFAULT);
        // store the updated version of $hashed
    }
}

每次用户登录时都执行这种检查是否过分值得商榷。PHP 的策略是仅在完整版本(如 8.1.0 或 8.2.0)时更改默认加密。唯一的例外是在紧急情况下,当在当前缺省值中发现严重的安全缺陷时。如果您紧跟 PHP 的发展,您可以创建一个脚本,每当缺省值发生变化时,它可以在一个操作中更新所有存储的密码。然而,在大多数服务器上,每次有人登录时使用password_needs_rehash()的速度快得令人难以察觉,并且可能值得添加到您的登录例程中,以保证您的站点安全。

为会话设置时间限制

默认情况下,PHP 将用户计算机上的会话 cookie 的生存期设置为 0,这将使会话保持活动状态,直到用户注销或浏览器关闭。您可以通过调用ini_set()使会话提前超时,这个函数允许您动态地更改一些 PHP 配置指令。会话一启动,就将指令session.cookie_lifetime作为第一个参数传递,并将包含您希望 cookie 保持活动状态的秒数的字符串作为第二个参数传递。例如,您可以将会话 cookie 的生存期限制为 10 分钟,如下所示:

session_start();
ini_set('session.cookie_lifetime', '600');

虽然这是有效的,但它有两个缺点。首先,过期时间是相对于服务器上的时间设置的,而不是用户计算机上的时间。如果用户的计算机时钟不正确,cookie 可能会立即过期,或者它可能会比您预期的时间长得多。另一个问题是,用户可能会被自动注销,而没有任何解释。下一个 PHP 解决方案提供了一种更加用户友好的方法。

PHP 解决方案 11-8:在一段时间不活动后结束会话

这个 PHP 解决方案展示了如果用户在指定时间内没有做任何触发页面加载的事情,如何结束会话。当会话首次启动时,通常是用户登录时,当前时间存储在会话变量中。每次用户加载页面时,会话变量都会与当前时间进行比较。如果差值大于预定的限制,会话及其变量将被销毁。否则,变量将更新为当前时间。

这些说明假设您已经在 PHP 解决方案 11-3 到 11-7 中设置了登录系统。

  1. 您需要在用户凭证通过身份验证之后、脚本将用户重定向到站点的受限部分之前存储当前时间。在authenticate.php(第 14-18 行周围)中找到以下代码段,并插入以粗体突出显示的新代码,如下所示:

    if ($data[0] == $username && password_verify($password, $data[1])) {
        $_SESSION['authenticated'] = 'Jethro Tull';
        $_SESSION['start'] = time();
        session_regenerate_id();
        break;
    }
    
    

    time()函数返回当前时间戳。通过存储在$_SESSION['start']中,以session_start()开头的每一页都可以使用它。

  2. 当一个会话超时时,毫不客气地将用户退回到登录屏幕是不友好的,所以解释一下发生了什么是个好主意。在login.php中,将粗体突出显示的代码添加到 PHP 块中,紧跟在开始的<body>标签之后(在第 22–27 行周围):

    <?php
    if ($error) {
        echo "<p>$error</p>";
    } elseif (isset($_GET['expired'])) { ?>
        <p>Your session has expired. Please log in again.</p>
    <?php } ?>
    
    

如果 URL 在查询字符串中包含名为expired的参数,则会显示该消息。

  1. 打开menu.php,剪切DOCTYPE声明上方 PHP 块中的代码,粘贴到一个新的空白文件中。

  2. 将文件另存为includes文件夹中的session_timeout.php,然后编辑如下代码:

    <?php
    session_start();
    ob_start();
    // set a time limit in seconds
    $timelimit = 15;
    // get the current time
    $now = time();
    // where to redirect if rejected
    $redirect = 'http://localhost/php8sols/sessions/login.php';
    // if session variable not set, redirect to login page
    if (!isset($_SESSION['authenticated'])) {
        header("Location: $redirect");
        exit;
    } elseif ($now > $_SESSION['start'] + $timelimit) {
        // if timelimit has expired, destroy session and redirect
        $_SESSION = [];
        // invalidate the session cookie
        if (isset($_COOKIE[session_name()])) {
            setcookie(session_name(), '', time()-86400, '/');
        }
        // end session and redirect with query string
        session_destroy();
        header("Location: {$redirect}?expired=yes");
        exit;
    } else {
        // if it's got this far, it's OK, so update start time
        $_SESSION['start'] = time();
    }
    
    

行内注释解释了正在发生的事情,你应该认识 PHP 解决方案 11-5 中的大部分elseif子句。PHP 以秒为单位测量时间,我将$timelimit(第 5 行)设置为可笑的 15 秒,纯粹是为了演示效果。要设置一个更合理的限制,比如说 15 分钟,稍后再更改,就像这样:

$timelimit = 15 * 60; // 15 minutes

当然,您可以将$timelimit设置为900,但是当 PHP 可以为您完成这项艰巨的工作时,为什么还要麻烦呢?

如果$_SESSION['start']加上$timelimit的总和小于当前时间(存储为$now,您将结束会话并将用户重定向到登录页面。执行重定向的代码行将查询字符串添加到 URL 的末尾,如下所示:

http://localhost/php8sols/sessions/login.php?expired=yes

步骤 2 中的代码没有注意到expired的值;添加yes作为值只是让它在浏览器地址栏看起来更人性化。

如果脚本到达了最后的else,这意味着$_SESSION ['authenticated']已经被设置,时间限制还没有到,所以$_SESSION['start']被更新为当前时间,页面正常显示。

  1. menu.php中的DOCTYPE声明上包含session_timeout.php。include 命令应该是 PHP 块中唯一的代码:

    <?php require_once '../includes/session_timeout.php'; ?>
    <!DOCTYPE HTML>
    
    
  2. 用同样的方法替换secretpage.phpDOCTYPE声明上面的代码。

  3. 保存您编辑的所有页面,并将menu.phpsecretpage.php加载到浏览器中。如果页面显示,请单击“注销”。然后重新登录,在menu.phpsecretpage.php之间来回导航。一旦您确认链接有效,等待 15 秒或更长时间,并尝试导航回另一个页面。您应该会自动注销,并出现以下屏幕:

img/332054_5_En_11_Figk_HTML.jpg

如有必要,对照ch11文件夹中的authenticate_02.phplogin_04.phpsession_timeout.phpmenu_05.phpsecretpage_04.php检查您的代码。

通过多页表单传递信息

通过$_POST$_GET数组传递的变量只有短暂的存在。一旦它们被传递到页面,它们就消失了,除非你以某种方式保存它们的值。保存从一种形式传递到另一种形式的信息的一种常见方法是从$_POST数组中提取其值,并将其存储在 HTML 中的一个隐藏字段中,如下所示:

<input type="hidden" name="address" id="address" value="<?= htmlentities($_POST['address']) ?>">

顾名思义,隐藏字段是表单代码的一部分,但不会显示在屏幕上。对于一两个项目,隐藏字段是可以的,但是假设您有一个超过四页的调查。如果一个页面上有 10 个项目,那么总共需要 60 个隐藏字段(第二页 10 个,第三页 20 个,第四页 30 个)。会话变量可以节省你所有的编码。他们还可以确保访问者总是从多页表单的正确页面开始。

PHP 解决方案 11-9:为多页表单使用会话

在这个 PHP 解决方案中,您将构建一个用于多页面表单的脚本,该脚本从$_POST数组收集数据并将其分配给会话变量。如果试图首先访问表单的任何其他部分,该脚本会自动将用户重定向到表单的第一页。

  1. multiple_01.phpmultiple_02.phpmultiple_03.phpmultiple_04.phpch11文件夹复制到sessions文件夹。前三页包含简单的表单,要求用户输入姓名、年龄和地址。每个<form>标签的action属性被设置为当前页面,所以表单是自处理的,但是它们还不包含任何处理脚本。前三页的数据最终将显示在最后一页。

  2. multiple_01.php中的DOCTYPE声明上方的 PHP 块中添加以下代码:

    if (isset($_POST['next'])) {
        session_start();
        // set a variable to control access to other pages
        $_SESSION['formStarted'] = true;
        // set required fields
        $required = 'first_name';
        $firstPage = 'multiple_01.php';
        $nextPage = 'multiple_02.php';
        $submit = 'next';
        require_once '../includes/multiform.php';
    }
    
    

    提交按钮的name属性是next,所以这个块中的代码只有在表单提交后才会运行。它启动一个会话并创建一个会话变量,该变量将用于控制对其他表单页面的访问。

    接下来是处理多页表单的脚本将使用的四个变量:

    • $required:这是当前页面中必填字段的name属性的数组。如果只需要一个字段,可以用字符串代替数组。如果不需要字段,可以省略。

    • $firstPage:表格第一页的文件名。

    • $nextPage:表格下一页的文件名。

    • $submit:当前页面中提交按钮的名称。

最后,代码包括处理多页表单的脚本。

  1. includes文件夹中创建一个名为multiform.php的文件。删除所有 HTML 标记,并插入以下代码:

    <?php
    if (!isset($_SESSION)) {
        session_start();
    }
    $filename = basename($_SERVER['SCRIPT_FILENAME']);
    $current = 'http://' . $_SERVER['HTTP_HOST'] .
         $_SERVER['PHP_SELF'];
    
    

multipage 表单的每一页都需要调用session_start(),但是在同一个页面上调用两次会产生错误,所以条件语句首先检查$_SESSION超全局变量是否可访问。如果不是,它将启动该页面的会话。

在条件语句之后,$_SERVER['SCRIPT_FILENAME']被传递给basename()函数来提取当前页面的文件名。这和你在 PHP 解决方案 5-3 中使用的技术是一样的。

$_SERVER['SCRIPT_FILENAME']包含了父文件的路径,所以当这个脚本包含在multiple_01.php中时,$filename的值将是multiple_01.php而不是 multiform.php

下一行从字符串http://和包含当前域名的$_SERVER['HTTP_HOST']值以及包含当前文件路径减去域名的$_SERVER['PHP_SELF']值构建当前页面的 URL。如果您在本地测试,当多页表单的第一页加载时,$currenthttp://localhost/php8sols/sessions/multiple_01.php

  1. 现在您已经有了当前文件的名称和它的 URL,您可以使用str_replace()来创建第一页和下一页的 URL,如下所示:
 $redirectFirst = str_replace($filename, $firstPage, $current);
$redirectNext = str_replace($filename, $nextPage, $current);

第一个参数是要替换的字符串,第二个是替换字符串,第三个是目标字符串。在第二步中,你将$firstPage设置为multiple_01.php,将$nextPage设置为multiple_02.php。结果,$redirectFirst变成了http://localhost/php8sols/sessions/multiple_01.php,而$redirectNext就是http://localhost/php8sols/sessions/multiple_02.php

  1. 为了防止用户在没有从头开始的情况下访问多页表单,添加一个条件语句来检查$filename的值。如果与第一页不同,并且还没有创建$_SESSION['formStarted'],那么header()函数将重定向到第一页,如下所示:

    if ($filename != $firstPage && !isset($_SESSION['formStarted'])) {
        header("Location: $redirectFirst");
        exit;
    }
    
    
  2. 脚本的其余部分遍历$_POST数组,检查空白的必填字段,并将它们添加到$missing数组中。如果没有遗漏任何内容,header()函数会将用户重定向到多页表单的下一页。multiform.php的完整脚本如下所示:

    <?php
    if (!isset($_SESSION)) {
        session_start();
    }
    $filename = basename($_SERVER['SCRIPT_FILENAME']);
    $current = 'http://' . $_SERVER['HTTP_HOST'] .
        $_SERVER['PHP_SELF'];
    $redirectFirst = str_replace($filename, $firstPage, $current);
    $redirectNext = str_replace($filename, $nextPage, $current);
    if ($filename != $firstPage && !isset($_SESSION['formStarted'])) {
        header("Location: $redirectFirst");
        exit;
    }
    if (isset($_POST[$submit])) {
        // create empty array for any missing fields
        $missing = [];
        // create $required array if not set
        if (!isset($required)) {
            $required = [];
        } else {
            // using casting operator to turn single string to array
            $required = (array) $required;
        }
        // process the $_POST variables and save them in the $_SESSION array
        foreach ($_POST as $key => $value) {
            // skip submit button
            if ($key == $submit) continue;
            // strip whitespace if not an array
            if (!is_array($value)) {
                $value = trim($value);
            }
            // if empty and required, add to $missing array
            if (in_array($key, $required) && empty($value)) {
                $missing[] = $key;
                continue;
            }
            // otherwise, assign to a session variable of the same name as $key
            $_SESSION[$key] = $value;
        }
        // if no required fields are missing, redirect to next page
        if (!$missing) {
            header("Location: $redirectNext");
            exit;
        }
    }
    
    

代码与第六章中处理反馈表的代码非常相似,所以行内注释应该足以解释它是如何工作的。包装在新代码周围的条件语句使用$_POST[$submit]来检查表单是否已经提交。为了使代码更加灵活,我使用了一个变量,而不是硬编码 Submit 按钮的名称。虽然这个脚本只在表单提交后才包含在第一页中,但是它直接包含在其他页面中,所以有必要在这里添加条件语句。

提交按钮的名称和值总是包含在$_POST数组中,所以如果关键字与提交按钮的名称相同,foreach循环使用continue关键字跳到下一项。这避免了将不需要的值添加到$_SESSION数组中。关于continue的描述,参见第四章中的“跳出循环”。

  1. multiple_02.php中的DOCTYPE声明上方的 PHP 块中添加以下代码:

    $firstPage = 'multiple_01.php';
    $nextPage = 'multiple_03.php';
    $submit = 'next';
    require_once '../includes/multiform.php';
    
    

这将设置$firstPage$nextPage$submit的值,并包括您刚刚创建的处理脚本。这个页面上的表单只包含一个可选字段,所以不需要$required变量。如果主页中没有设置空数组,处理脚本会自动创建一个空数组。

  1. multiple_03.php中,在DOCTYPE声明上方的 PHP 代码块中添加以下内容:

    // set required fields
    $required = ['city', 'country'];
    $firstPage = 'multiple_01.php';
    $nextPage = 'multiple_04.php';
    $submit = 'next';
    require_once '../includes/multiform.php';
    
    

两个字段是必需的,因此它们的name属性被列为一个数组,并被分配给$required。其他代码与上一页中的相同。

  1. multiple_01.phpmultiple_02.phpmultiple_03.php中的<form>标签上方添加以下代码:

    <?php if (isset($missing)) { ?>
    <p> Please fix the following required fields:</p>
        <ul>
        <?php
        foreach ($missing as $item) {
            echo "<li>$item</li>";
        }
        ?>
        </ul>
    <?php } ?>
    
    

这将显示尚未填写的必填项列表。

  1. multiple_04.php中,在DOCTYPE声明上方的 PHP 块中添加以下代码,以便在用户没有从第一页进入表单时将用户重定向到第一页:

    session_start();
    if (!isset($_SESSION['formStarted'])) {
        header('Location: http://localhost/php8sols/sessions/multiple_01.php');
        exit;
    }
    
    
  2. 在页面正文中,将以下代码添加到无序列表中以显示结果:

    <ul>
    <?php
    $expected = ['first_name', 'family_name', 'age',
                 'address', 'city', 'country'];
    // unset the formStarted variable
    unset($_SESSION['formStarted']);
    foreach ($expected as $key) {
        echo "<li>$key: " . htmlentities($_SESSION[$key] ) . '</li>';
        // unset the session variable
        unset($_SESSION[$key]);
    }
    ?>
    </ul>
    
    

这将表单域的name属性列为一个数组,并将该数组分配给$expected。这是一种安全措施,确保您不会处理可能被恶意用户注入到$_POST数组中的假值。

然后,代码重置$_SESSION['formStarted']并循环遍历$expected数组,使用每个值来访问$_SESSION数组的相关元素,并将其显示在无序列表中。然后,会话变量被删除。单独删除会话变量不会影响任何其他与会话相关的信息。

img/332054_5_En_11_Fig5_HTML.jpg

图 11-5

会话变量保存了来自多个页面的输入

  1. 保存所有页面,然后尝试将表单的中间页面或最后一个页面加载到浏览器中。你应该被带到第一页。单击下一步,不填写任何字段。您将被要求填写first_name字段。填写必填字段,然后单击每页上的下一步。结果应显示在最终页面上,如图 11-5 所示。

您可以对照ch11文件夹中的multiple_01_done.phpmultiple_02_done.phpmultiple_03_done.phpmultiple_04_done.phpmultiform.php来检查您的代码。

这只是一个多页表单的简单演示。在真实的应用中,当必填字段留空时,您需要保留用户输入。

通过在表单提交后的第一页上创建$_SESSION['formStarted'],并在每页上使用$required$firstPage$nextPage$submit,可以将multiform.php中的脚本用于任何多页表单。使用$missing数组处理未填写的必填字段。

第三章回顾

如果你开始阅读这本书时对 PHP 知之甚少或一无所知,那么你就不再是初学者了,而是在许多有用的方面利用了 PHP 的力量。希望到现在为止,您已经开始意识到相同或相似的技术会一次又一次地出现。不要只是复制代码,你应该开始认识到你可以适应你的需求的技术,然后自己去试验。

本书的其余部分继续建立在您的知识基础上,但引入了一个新的因素:MySQL 关系数据库(及其替代产品 MariaDB),它将使您的 PHP 技能达到一个更高的水平。下一章将介绍 MySQL,并向您展示如何为后面的章节设置它。

十二、数据库入门

与数据库相结合,动态网站有了全新的含义。从数据库中提取内容使您能够以静态网站无法实现的方式呈现材料。浮现在脑海中的例子有网店,比如Amazon.com;新闻网站,如 BBC(www.bbcnews.com);以及大型搜索引擎,包括谷歌和必应。数据库技术允许这些网站呈现数千个,通常是数百万个独特的页面。即使你的野心没有那么大,一个数据库也可以不费吹灰之力增加网站内容的丰富性。

PHP 支持所有主流数据库,包括 Microsoft SQL Server、Oracle 和 PostgreSQL,但它最常与开源 MySQL 数据库结合使用。根据 DB-Engines ( https://db-engines.com/en/ranking ),MySQL 在 2021 年年中被列为第二大最广泛使用的数据库,它已经保持了多年。然而,围绕 MySQL 的未来存在争议,谷歌和维基媒体已经放弃了 MySQL,转而支持 MariaDB ( https://mariadb.org/ ),后者在数据库引擎排名中排名第 12。几个领先的 Linux 发行版也用 MariaDB 取代了 MySQL。本章首先简要讨论了这两个数据库之间竞争的含义。

在本章中,您将了解以下内容:

  • 了解数据库如何存储信息

  • 选择图形界面与数据库交互

  • 创建用户帐户

  • 用适当的数据类型定义数据库表

  • 备份数据并将数据传输到另一台服务器

MySQL 还是 MariaDB?

MySQL 最初是由瑞典的 MySQL AB 作为一个免费的开源数据库开发的。它很快在个人开发者中流行起来,并被主要参与者采用,如维基百科和 BBC 新闻。然而,MySQL AB 在 2008 年被出售给 Sun Microsystems,该公司在两年后被主要的商业数据库供应商 Oracle 收购。许多人认为这威胁到了 MySQL 作为一个免费开源数据库的持续生存。甲骨文曾公开表示“MySQL 是甲骨文完整、开放和集成战略的一部分。”但这并没有给 MySQL 的创始人之一 Michael“Monty”wide nius 留下什么印象,他指责甲骨文从 MySQL 中删除了一些功能,并且在修复安全问题上行动迟缓。

由于 MySQL 代码是开源的,Widenius 将其分叉以创建 MariaDB,它被描述为“MySQL 的增强替代产品。”从那以后,MariaDB 开始实现自己的新特性。尽管中断了,这两个数据库系统实际上是可以互换的。MariaDB 可执行文件使用与 MySQL 相同的名称(在 macOS 和 Linux 上使用mysqld,在 Windows 上使用mysqld.exe)。主特权表也被称为mysql,默认的存储引擎将自己标识为 InnoDB,尽管它实际上是 InnoDB 的一个分支,名为 Percona XtraDB。

就本书中的代码而言,使用 MariaDB 还是 MySQL 应该没什么区别。MariaDB 理解所有 MySQL 特有的 PHP 代码。MySQL 的 phpMyAdmin 图形界面也支持它,我将在后面的章节中使用它。

Note

为了避免不断重复,您应该假设所有对 MySQL 的引用同样适用于 MariaDB,除非我特别提到了 MariaDB。

数据库如何存储信息

关系数据库(如 MySQL)中的所有数据都存储在表中,非常类似于电子表格,信息组织成行和列。图 12-1 显示了您将在本章稍后构建的数据库表,如 phpMyAdmin 中所示。

img/332054_5_En_12_Fig1_HTML.jpg

图 12-1

数据库表像电子表格一样以行和列的形式存储信息

每个都有一个名称(image_idfilenamecaption),表明它存储的内容。

这些行没有被标记,但是第一列(image_id)包含一个唯一的值,称为主键,它标识与该行相关的数据。每行包含一个单独的记录的相关数据。

存储数据的行和列的交叉点称为字段。例如,图 12-1 中第三条记录的caption字段包含值“京都金阁”,该记录的主键是 3。

Note

术语“字段”和“列”经常互换使用,尤其是在 phpMyAdmin 的旧版本中。字段为单个记录保存一条信息,而列为所有记录包含相同的字段。

主键如何工作

尽管图 12-1 将image_id显示为从 1 到 8 的连续序列,但它们不是行号。图 12-2 显示了标题按字母顺序排序的相同表格。图 12-1 中高亮显示的字段已经移动到第七行,但是它仍然有相同的image_idfilename

img/332054_5_En_12_Fig2_HTML.jpg

图 12-2

主键标识行,即使表是以不同的顺序排序的

虽然很少显示主键,但它标识记录和存储在其中的所有数据。一旦知道了记录的主键,就可以更新、删除它,或者用它在单独的页面中显示数据。不要担心你如何找到主键。使用结构化查询语言 (SQL)很容易做到,这是与所有主要数据库通信的标准手段。要记住的重要事情是为每条记录分配一个主键。

Tip

有些人将 SQL 发音为“sequel”其他人把它拼成“ess-queue-ell”MySQL 的官方发音是“My-ess-queue-ell”

  • 主键不必是数字,但必须是唯一的

  • 产品号是很好的主键。它们可能由数字、字母和其他字符组成,但总是唯一的。社会保障和员工 ID 号也是唯一的,但可能会导致个人数据泄露,因为在检索或更新数据时,主键会附加到查询字符串中。

  • MySQL 可以自动为你生成一个主键。

  • 一旦分配了主键,它就不应该重复,也不应该更改。

因为主键必须是唯一的,所以当记录被删除时,MySQL 通常不会重用这个数字。尽管这在序列中留下了间隙,但这并不重要。主键的目的是识别记录。任何填补空白的尝试都会给数据库的完整性带来严重的风险。

Tip

有些人希望消除序列中的间隔,以便跟踪表中的记录数。这是不必要的,你将在下一章中发现。

用主键和外键链接表

与电子表格不同,大多数数据库将数据存储在几个较小的表中,而不是一个大表中。这可以防止重复和不一致。假设你正在建立一个你最喜欢的引语的数据库。与其每次都键入作者的名字,不如将作者的名字放在一个单独的表中,并在每次引用时存储一个对作者主键的引用,这样效率会更高。正如你在图 12-3 中看到的,左边表格中用author_id 32标识的每一条记录都是引用自威廉·莎士比亚的话。

img/332054_5_En_12_Fig3_HTML.jpg

图 12-3

外键用于链接存储在不同表中的信息

因为名称只存储在一个地方,所以它保证拼写总是正确的。如果您确实犯了拼写错误,只需进行一处更正,就可以确保更改在整个数据库中得到反映。

将一个表中的主键存储在另一个表中被称为创建一个外键。使用外键来链接不同表中的信息是关系数据库最强大的方面之一。在早期阶段也很难掌握,所以我们将使用单个表,直到第 17 和 18 章,这两章详细介绍了外键。同时,请记住以下几点:

  • 当用作表的主键时,该值在列中必须是唯一的。所以图 12-3 右侧表格中的每个author_id只使用一次。

  • 当用作外键时,同一值可以有多个引用。所以32在左边表格的author_id列出现了几次。

将信息分解成小块

你可能已经注意到,图 12-3 中右边的表格为每个作者的名和姓提供了单独的列。这是关系数据库的一个重要原则:将复杂的 信息分解成组成部分,并分别存储每个部分。

决定这个过程要走多远并不总是容易的。除了名和姓之外,您可能需要单独的标题列(先生、夫人、女士、博士等)。)和中间名或首字母。地址最好分成街道、城镇、县、州、邮政编码等等。尽管将信息分解成小块可能很麻烦,但您总是可以使用 SQL 和/或 PHP 将它们再次连接在一起。然而,一旦您有了很多记录,尝试将存储在单个字段中的复杂信息分离出来是一项重大任务。

良好数据库设计的检查点

设计数据库没有正确的方法——每一个都是不同的。但是,以下指南应该为您指明正确的方向:

  • 给表中的每条记录一个唯一的标识符(主键)。

  • 将每组关联的数据放在自己的表中。

  • 通过将一个表中的主键用作其他表中的外键来交叉引用相关信息。

  • 在每个字段中只存储一项信息。

  • 保持干燥(不要重复自己)。

在早期阶段,你很可能会犯一些设计上的错误,然后你会后悔。试着预测未来的需求,让你的桌子结构更灵活。您可以随时添加新的表来响应新的需求。

这是目前足够的理论。让我们从第五章和第六章开始,通过为 Japan Journey 网站建立一个数据库,来讨论一些更实际的东西。

使用图形界面

与 MySQL 数据库交互的传统方式是通过命令提示符窗口或终端。但是使用第三方图形界面要容易得多,比如 phpMyAdmin,MySQL 的基于浏览器的前端(见图 12-4 )。

img/332054_5_En_12_Fig4_HTML.jpg

图 12-4

phpMyAdmin 是一个免费的 MySQL 图形界面,可以在浏览器中运行

因为 phpMyAdmin ( www.phpmyadmin.net )是与 XAMPP、MAMP 和其他大多数免费的一体化软件包一起自动安装的,所以它是本书选择的 UI。它易于使用,并具有设置和管理 MySQL 数据库所需的所有基本功能。它可以在 Windows、macOS 和 Linux 上运行。许多主机公司将其作为 MySQL 的标准接口。

如果您经常使用数据库,您可能希望最终探索其他图形界面。值得一提的是 Navicat ( www.navicat.com/en/ ),这是一款可用于 Windows、macOS 和 Linux 的付费产品。Navicat 云服务还允许您从 iPhone 或 iPad 管理数据库。Navicat 在 web 开发人员中特别受欢迎,因为它能够执行从远程服务器到本地计算机的数据库定时备份。Navicat for MySQL 同时支持 MySQL 和 MariaDB。

正在启动 phpMyAdmin

如果您在 Windows 上运行 XAMPP,有三种方法可以启动 phpMyAdmin:

  • 在浏览器地址栏输入http://localhost/phpMyAdmin/

  • 点击 XAMPP 控制面板中的 MySQL Admin按钮。

  • 在 XAMPP 管理页面(http://localhost/xampp/)中点击Tools下的phpMyAdmin链接。

如果你在 macOS 上安装了 MAMP,点击 MAMP 起始页顶部菜单中的ToolsphpMyAdmin(点击 MAMP 控件中的WebStart)。

如果您手动安装了 phpMyAdmin 或者使用了不同的 all-in-one 软件包,请遵循软件包的说明或者在浏览器地址栏中输入适当的地址(通常是http://localhost/phpmyadmin/)。

Tip

如果您收到一条消息,说服务器没有响应或套接字配置不正确,请确保 MySQL 服务器正在运行。

如果您安装了 XAMPP,您可能会看到一个要求输入用户名和密码的屏幕。如果是这样,请以超级用户的身份登录 phpMyAdmin。输入root作为用户名,并在设置 XAMPP 时使用您为 root 用户创建的密码。

Caution

当在远程服务器上访问 phpMyAdmin 时,总是使用安全连接(https)。任何窃听不安全连接的人都有可能控制您的宝贵数据,窃取、破坏甚至完全删除这些数据。

设置 phpsols 数据库

在本地测试环境中,可以在 MySQL 中创建的数据库数量没有限制,您可以随意命名它们。我将假设您在本地测试环境中工作,并向您展示如何建立一个名为phpsols的数据库,以及两个名为psreadpswrite的用户帐户。

Note

在共享主机上,你可能只限于一个由主机公司建立的数据库。如果你在远程服务器上进行测试,并且没有建立新数据库和用户账户的自由,那么用你的托管公司分配的名字和用户名分别代替phpsolspswrite

MySQL 命名规则

数据库、表和列的基本 MySQL 命名规则如下:

  • 名称最长可达 64 个字符。

  • 合法字符包括数字、字母、下划线和美元。

  • 名称可以以数字开头,但不能全部由数字组成。

一些托管公司似乎很幸运地忽略了这些规则,并给客户分配名称中包含一个或多个连字符(非法字符)的数据库。如果数据库、表或列名包含空格或非法字符,则在 SQL 查询中必须始终用反斜杠(```php)将其括起来。注意,这不是单引号('),而是一个单独的字符。在我的 Windows 键盘上,它就在 Tab 键的正上方。在我的 Mac 键盘上,它与波浪号(~)位于同一个键上的左 Shift 键旁边。

在选择名字的时候,你可能会不小心选择了 MySQL 众多保留字中的一个( https://dev.mysql.com/doc/refman/8.0/en/keywords.html ),比如date或者time。避免这种情况的一个技巧是使用复合词,比如arrival_datearrival_time等等。或者,用反斜杠将所有名称括起来。phpMyAdmin 自动完成这项工作,但是在 PHP 脚本中编写自己的 SQL 时,您需要手动完成这项工作。

Note

因为很多人使用datetexttimetimestamp作为列名,所以 MySQL 允许使用它们而不加反斜杠。但是,您应该避免使用它们。这是一种糟糕的做法,如果您将数据迁移到不同的数据库系统,这种做法也不太可能奏效。

名称区分大小写

Windows 和 macOS 将 MySQL 名称视为不区分大小写。然而,Linux 和 Unix 服务器尊重区分大小写。为了避免将数据库和 PHP 代码从本地计算机传输到远程服务器时出现问题,我强烈建议您在数据库、表和列名中只使用小写。当用一个以上的单词组成名字时,用下划线将它们连接起来。

使用 phpMyAdmin 创建新的数据库

在 phpMyAdmin 中创建新数据库很容易。

Note

phpMyAdmin 有一个频繁的发布周期,这通常会导致用户界面的微小变化。偶尔,变化会更显著。这些说明和附带的截图是基于 phpMyAdmin 5.1.1 的。尽管您使用的版本可能会有所不同,但基本过程应该大致相同。

  1. 启动 phpMyAdmin 并选择主窗口顶部的Databases选项卡。

  2. Create database下的第一个字段中输入新数据库的名称(phpsols)。字段右侧的下拉菜单设置数据库的排序规则。归类决定了数据的排序顺序。如下图所示,我的安装的默认设置是utf8mb4_general_ci。这实际上支持每一种人类语言,除非您对自己的语言有特殊需求,否则应该是合适的。ci表示排序顺序不区分大小写。在 phpMyAdmin 的旧版本中,缺省值是latin1_swedish_ci。这反映了 MySQL 的瑞典血统。英语使用相同的排序顺序。然后点击Create:

img/332054_5_En_12_Figa_HTML.jpg

  1. 屏幕上应该会出现数据库已创建的确认信息,随后会出现一个屏幕,邀请您创建一个表:

img/332054_5_En_12_Figb_HTML.jpg

  1. 在新数据库中创建表之前,最好为它创建用户帐户。让 phpMyAdmin 保持打开,因为您将在下一节中继续使用它。

创建特定于数据库的用户帐户

新安装的 MySQL 通常只有一个注册用户——名为“root”的超级用户帐户,它拥有对一切的完全控制权。(XAMPP 还创建了一个名为“pma”的用户帐户,phpMyAdmin 用它来实现本书没有涉及的高级功能。)root 用户应该永远不要用于除顶级管理之外的任何事情,例如创建和删除数据库、创建用户帐户以及导出和导入数据。每个单独的数据库应该至少有一个(最好是两个)具有有限权限的专用用户帐户。

当您将一个数据库联机时,您应该授予用户他们需要的最少的特权,而不是更多。有四种重要的特权,都是以等效的 SQL 命令命名的:

  • SELECT:从数据库表中检索记录

  • INSERT:将记录插入数据库

  • UPDATE:修改现有记录

  • DELETE:删除记录,但不删除表格或数据库(命令为DROP)

大多数情况下,访问者只需要检索信息,因此psread用户帐户只有SELECT权限,并且是只读的。但是,对于用户注册或站点管理,您需要所有四种权限。这些将提供给pswrite账户。

授予用户权限

  1. 在 phpMyAdmin 中,通过单击屏幕左上角的小房子图标返回到主屏幕。然后单击User accounts选项卡(在 phpMyAdmin 的旧版本中,它被称为Users):

img/332054_5_En_12_Figc_HTML.jpg

  1. User accounts overview页面上,点击页面中间的Add user account链接。

  2. 在打开的页面上,在User name字段中输入pswrite(或者您想要创建的用户帐户的名称)。从Host name下拉菜单中选择Local。这将自动在旁边的字段中输入localhost。选择此选项允许pswrite用户仅从同一台计算机连接到 MySQL。然后在Password字段输入密码,并在Re-type字段再次输入确认密码。

    Note

    在本书的示例文件中,我使用了0Ch@Nom1$u作为密码。MySQL 密码区分大小写。

  3. Login Information桌子下面是标有Database for user accountGlobal privileges的区域。两个都不理。向下滚动到页面底部并点击Go按钮。

  4. 这将向您确认用户已经创建,并提供编辑用户权限的选项。点击Edit privileges上方的Database按钮:

img/332054_5_En_12_Figd_HTML.jpg

  1. Database-specific privileges下,从列表中选择phpsols(如有必要,激活标有Add privileges on the following database(s))的下拉菜单并点击Go:

img/332054_5_En_12_Fige_HTML.jpg

Note

MySQL 有三个默认数据库:information_schema,一个只读的虚拟数据库,包含同一服务器上所有其他数据库的详细信息;mysql,包含所有用户账号和权限的详细信息;还有test,那是空的。除非你确定自己在做什么,否则永远不要直接编辑mysql数据库。

  1. 下一个屏幕允许您设置这个用户在phpsols数据库上的权限。您希望pswrite拥有前面列出的所有四个特权,所以单击SELECT, INSERT, UPDATEDELETE旁边的复选框。

如果将鼠标指针悬停在每个选项上,phpMyAdmin 会显示一个工具提示,描述该选项的用法,如图所示。选择四种权限后,点击Go:

img/332054_5_En_12_Figf_HTML.jpg

Caution

phpMyAdmin 中的许多屏幕都有不止一个Go按钮。始终单击要设置选项的部分底部或旁边的按钮。

  1. phpMyAdmin 向您确认用户帐户pswrite的权限已经更新;该页面再次显示Database-specific privileges表,以防您需要更改任何内容。点击页面顶部的User accounts选项卡,返回User accounts overview

  2. 点击Add user account并重复第 3 步到第 8 步,创建名为psread的第二个用户帐户。这个用户将有更多受限的特权,所以当你到第 7 步时,只选择SELECT选项。示例文件中用于psread的密码是K1yoMizu^dera

创建数据库表

现在您已经有了一个数据库和专用的用户帐户,可以开始创建表了。让我们首先创建一个保存图像细节的表格,如本章开头的图 12-1 所示。在开始输入数据之前,需要定义表结构。这包括决定以下事项:

  • 表的名称

  • 它将有多少列

  • 每列的名称

  • 每列将存储什么类型的数据

  • 列是否必须在每个字段中都有数据

  • 哪一列包含表的主键

如果你查看图 12-1 ,你可以看到该表包含三列:image_id(主键)、filenamecaption。因为它包含图像的细节,所以这是一个很好的表名。存储没有标题的文件名没有多大意义,所以每一列都必须包含数据。太好了。除了数据类型,所有的决定都已经做出。我将一边进行一边解释数据类型。

定义图像表

这些说明展示了如何在 phpMyAdmin 中定义一个表。如果您喜欢使用 Navicat 或不同的 MySQL 用户界面,请使用表 12-1 中的设置。

表 12-1

图像表的设置

|

|

类型

|

长度/值

|

属性

|

|

索引

|

A_I

|
| --- | --- | --- | --- | --- | --- | --- |
| image_id | INT |   | UNSIGNED | 取消选择 | PRIMARY | 挑选 |
| filename | VARCHAR | 25 |   | 取消选择 |   |   |
| caption | VARCHAR | 120 |   | 取消选择 |   |   |

  1. 启动 phpMyAdmin(如果它还没有打开),并从屏幕左侧的数据库列表中选择phpsols。这将打开Structure选项卡,报告在数据库中没有找到表。

  2. Create table部分中,在Name字段中键入新表的名称(images,并在Number of columns字段中输入3。然后点击Go按钮。

  3. 下一个屏幕是您定义表的地方。选项很多,但并不是都需要填写。表 12-1 列出了images表的设置。

第一列image_id,定义为类型INT,代表整数。它的属性被设置为UNSIGNED,这意味着只允许正数。当您从 Index 下拉菜单中选择PRIMARY时,phpMyAdmin 会打开一个模式面板,您可以在其中指定高级选项。接受默认设置,然后单击“Go”关闭面板。然后,选择A_I ( AUTO_INCREMENT)复选框。这告诉 MySQL 每当插入新记录时,在该列中插入下一个可用的数字(从 1 开始)。

下一列filename,被定义为类型VARCHAR,长度为25。这意味着它最多接受 25 个字符的文本。

最后一列caption也是VARCHAR,长度为120,因此它最多接受 120 个字符的文本。

取消选择所有列的Null复选框,因此它们必须总是包含一些内容。然而,这个“东西”可以小到一个空字符串。我将在本章后面的“在 MySQL 中选择正确的数据类型”一节中更详细地描述列类型。

下面的截图显示了在 phpMyAdmin 中设置后的选项(因为不需要填写,所以省略了A_I右边的列):

img/332054_5_En_12_Figg_HTML.jpg

屏幕下方是Storage Engine的一个选项。这决定了内部用来存储数据库文件的格式。从 MySQL 5.5 开始,InnoDB 就是默认的了。在此之前,MyISAM 是默认值。我将在第十七章中解释这些存储引擎之间的区别。同时,使用 InnoDB。从一个存储引擎转换到另一个非常简单。

完成后,点击屏幕底部的Save按钮。

Tip

如果你点击Go而不是Save,phpMyAdmin 会增加一个额外的列供你定义。如果发生这种情况,只需点击Save。只要不在字段中输入值,phpMyAdmin 就会忽略多余的列。

  1. 下一个屏幕显示您刚刚创建的表的详细信息(如果您没有看到这个屏幕,请单击Structure选项卡):

img/332054_5_En_12_Figh_HTML.jpg

image_id右边的金色键表示它是表的主键。要编辑任何设置,请单击相应行中的Change。这将打开上一个屏幕,并允许您更改数值。

Tip

如果你弄得一团糟,想重新开始,点击屏幕顶部的Operations标签。然后,在Delete data or table部分,点击Delete the table (DROP)并确认您想要删除该表。(在 SQL 中,删除仅指记录。你删除一个列、表格或数据库。)

将记录插入表中

现在您有了一个表,您需要将一些数据放入其中。最终,您将需要使用 HTML 表单、PHP 和 SQL 构建自己的内容管理系统,但是快速简单的方法是使用 phpMyAdmin。

使用 phpMyAdmin 手动插入记录

这些指令展示了如何通过 phpMyAdmin 接口向images表添加记录。

  1. 如果 phpMyAdmin 仍然显示上一节末尾的images表的结构,请跳到第 2 步。否则,启动 phpMyAdmin 并从左边的列表中选择phpsols数据库。然后点击images右侧的Structure,如下截图所示:

img/332054_5_En_12_Figi_HTML.jpg

Tip

主框架顶部的面包屑轨迹为页面顶部的选项卡提供了上下文。前面截图左上角的Structure选项卡指的是phpsols数据库的结构。要访问单个表格的结构,请单击表格名称旁边的Structure链接。

  1. 点击页面顶部中间的Insert选项卡。这将显示以下屏幕,您可以插入最多两条记录:

img/332054_5_En_12_Figj_HTML.jpg

  1. 这些表单显示每列的名称和详细信息。您可以忽略Function字段。MySQL 有大量的函数,您可以将这些函数应用于存储在表中的值。在接下来的章节中,你会学到更多。Value字段是您输入想要插入表格的数据的地方。

    因为您已经将image_id定义为AUTO_INCREMENT,所以 MySQL 会自动插入下一个可用的数字。所以你必须image_id Value字段留空。如下填写接下来的两个Value字段:

    • filename : basin.jpg

    • caption : Water basin at Ryoanji temple, Kyoto

  2. 在第二个表单中,将image_idValue字段留空,并像这样填写接下来的两个字段:

    • filename : fountains.jpg

    • caption : Fountains in central Tokyo

通常情况下,当您向第二个表单添加值时,Ignore复选框会被自动取消选中,但如果需要,也可以取消选中。

  1. 点击第二个表单底部的Go按钮。用于插入记录的 SQL 显示在页面顶部。我将在剩余的章节中解释基本的 SQL 命令,但是研究 phpMyAdmin 显示的 SQL 是学习如何构建自己的查询的好方法。SQL 与人类语言紧密相关,因此学习起来并不困难。

  2. 点击页面左上角的Browse标签。您现在应该会看到images表中的前两个条目,如下所示:

img/332054_5_En_12_Figk_HTML.jpg

如您所见,MySQL 在image_id字段中插入了12

您可以继续输入其余六幅图像的细节,但是让我们使用一个包含所有必要数据的 SQL 文件来加快速度。

从 SQL 文件加载图像记录

因为images表的主键已经被设置为AUTO_INCREMENT,所以需要删除该表及其所有数据。SQL 文件自动完成这项工作,并从头开始构建表。这些指令假设 phpMyAdmin 已经打开了上一节第 6 步中的页面。

  1. 如果您愿意覆盖images表中的数据,请跳到第 2 步。但是,如果您输入了不想丢失的数据,请将数据复制到不同的表中。单击页面顶部的Operations选项卡(根据屏幕的大小,操作可能隐藏在选项卡行最右侧的More中),在标题为Copy table to (database.table)的部分的空白字段中键入新表的名称,然后单击Go。以下截图显示了将images表的结构和数据复制到phpsols数据库中的images_backup的设置:

img/332054_5_En_12_Figl_HTML.jpg

点击Go后,您应该会看到表格已被复制的确认。页面顶部的面包屑痕迹表明 phpMyAdmin 仍然在images表中,因此您可以继续执行步骤 2,即使您在屏幕上有一个不同的页面。

  1. 点击页面顶部的Import选项卡。在下一个屏幕上,点击File to import中的Browse(或Choose File)按钮,然后导航到ch12文件夹中的images.sql。保留所有选项的默认设置,并点击页面底部的Go:

img/332054_5_En_12_Figm_HTML.jpg

  1. phpMyAdmin 删除原始表,创建一个新版本,并插入所有记录。当您看到文件已导入的确认信息时,单击页面左上角的Browse按钮。你现在应该会看到与本章开头的图 12-1 所示相同的数据。

如果您在文本编辑器中打开images.sql,您会看到它包含创建images表并用数据填充它的 SQL 命令。该表是这样构建的:

DROP TABLE IF EXISTS `images`;
CREATE TABLE `images` (
  `image_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `filename` varchar(25) NOT NULL,
  `caption` varchar(120) NOT NULL,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

```php

主键是在单独的 SQL 命令中设置的。像这样从 SQL 文件中导入数据就是将数据从本地测试环境传输到网站所在的远程服务器的方法。假设您的托管公司为您提供 phpMyAdmin 来管理您的远程数据库,您需要做的就是在您的远程服务器上启动 phpMyAdmin 版本,单击`Import`选项卡,在您的本地计算机上选择 SQL 文件,然后单击`Go`。

下一节将描述如何创建 SQL 文件。

### 创建用于备份和数据传输的 SQL 文件

MySQL 不把你的数据库存储在一个文件中,你可以简单地把它上传到你的网站上。即使你找到了正确的文件,除非关闭 MySQL 服务器,否则你很可能会损坏它们。无论如何,大多数托管公司不会允许你上传原始文件,因为这也涉及到关闭他们的服务器,给每个人都带来了很大的不便。

然而,将数据库从一个服务器移动到另一个服务器是很容易的。它只需要创建数据的备份**转储**,并使用 phpMyAdmin 或任何其他数据库管理程序将它加载到另一个数据库中。转储是一个文本文件,包含填充单个表甚至整个数据库所需的所有 SQL 命令。phpMyAdmin 可以创建整个 MySQL 服务器、单个数据库、选定的表或单个表的备份。

Tip

在准备好将数据传输到另一台服务器或创建备份之前,您不需要阅读如何创建转储文件的详细信息。

为了简单起见,这些说明显示了如何只备份一个数据库。

1.  在 phpMyAdmin 中,从左边的列表中选择`phpsols`数据库。如果已经选择了数据库,单击屏幕顶部的`Database: phpsols`面包屑,如下所示:

![img/332054_5_En_12_Fign_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/php8-solution/img/332054_5_En_12_Fign_HTML.jpg)

1.  从屏幕顶部的选项卡中选择`Export`。

2.  有两种导出方法:快速和自定义。快速方法对于导出文件的格式只有一个选项。缺省值是 SQL,所以你所要做的就是点击`Go`,phpMyAdmin 创建 SQL 转储文件并保存到你的浏览器的缺省文件夹`Downloads`。该文件与数据库同名,因此对于`phpsols`数据库,它被称为`phpsols.sql`。

3.  Quick 方法适用于导出少量数据,但是您通常需要对导出选项进行更多的控制;选择`Custom`单选按钮。选项很多,我们一节一节来看。

4.  `Format`部分默认为 SQL,但提供了一系列其他格式,包括 CSV、JSON 和 XML。

5.  `Table(s)`部分列出了数据库中的所有表格。默认情况下,所有选项都被选中,但您可以通过取消选中不需要的选项的复选框来选择要导出的选项。在下面的截图中,只选择了`images`表结构和数据,所以`images_backup`不会被导出:

![img/332054_5_En_12_Figo_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/php8-solution/img/332054_5_En_12_Figo_HTML.jpg)

Tip

备份单个表而不是整个数据库通常是一个好主意,因为大多数 PHP 服务器被配置为将上传限制在 2 MB。如下一步所述,压缩转储文件也有助于避开大小限制。

1.  `Output`部分有几个有用的选项。

选择标记为`Rename exported databases/tables/columns`的复选框启动一个模态面板,您可以在其中指定新名称。

`Use LOCK TABLES statement`复选框添加命令,防止任何人在使用转储文件导入数据和/或结构时插入、更新或删除记录。

还有一些单选按钮,您可以选择将 SQL 转储保存到文件中(这是默认设置),或者以文本形式查看输出。如果您想在创建文件之前检查正在生成的 SQL,以文本形式查看会很有用:

![img/332054_5_En_12_Figp_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/php8-solution/img/332054_5_En_12_Figp_HTML.jpg)

文件名模板包含一个位于`@`标记之间的值。这将从服务器、数据库或表中自动生成文件名。一个非常酷的特性是你可以使用 PHP 的`strftime()`格式化字符(参见 [`www.php.net/manual/en/function.strftime.php`](http://www.php.net/manual/en/function.strftime.php) )来增强模板。例如,您可以将当前日期自动添加到文件扩展名之前,如下所示:

@DATABASE@_%Y-%m-%d


`Character set of the file`的默认值是 utf-8。只有当您的数据以特定的区域格式存储时,才需要更改这一点。

默认情况下,转储文件是不压缩的,但是下拉菜单提供了使用 zipped 或 gzipped 压缩的选项。这可以大大减小转储文件的大小,加快数据传输速度。当导入一个压缩文件时,phpMyAdmin 会自动检测压缩类型并将其解压缩。

最后一个选项允许您跳过大于指定 MB 数的文件。

1.  在`Format-specific options`中,选项由步骤 5 中选择的格式决定。对于 SQL,您可以选择在转储文件中显示注释,并将导出包含在事务中。使用事务的价值在于,如果错误导致导入被放弃,数据库将回滚到以前的状态。

其他选项包括禁用外键检查、将视图导出为表以及导出元数据。最后,您可以选择最大限度地兼容不同的数据库系统或旧版本的 MySQL。通常情况下,该值应设置为默认值:`NONE`。

1.  `Object creation options`部分允许您微调用于创建数据库和表的 SQL。以下屏幕截图显示了默认设置:

![img/332054_5_En_12_Figq_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/php8-solution/img/332054_5_En_12_Figq_HTML.jpg)

创建备份时,选择`Add DROP TABLE / VIEW / PROCEDURE / FUNCTION / EVENT / TRIGGER`语句复选框通常是个好主意,因为备份通常用于替换已损坏的现有数据。

默认情况下选中的最后一个复选框将表名和列名用反引号(反勾号)括起来,以避免包含无效字符或使用保留字的名称出现问题。我建议总是选择这个。

1.  `Data creation options`部分控制如何将数据插入表格。在大多数情况下,默认设置就可以了。但是,您可能有兴趣更改前四个,如下图所示:

![img/332054_5_En_12_Figr_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/php8-solution/img/332054_5_En_12_Figr_HTML.jpg)

第一个复选框允许您在插入数据之前截断表。如果您想要替换现有数据(可能是已损坏的数据),这将非常有用。

另外两个复选框影响如何执行`INSERT`命令。`INSERT DELAYED`不支持默认的 InnoDB 表。此外,从 MySQL 5.6.6 开始,它已被弃用,所以最好避免使用它。

`INSERT IGNORE`跳过错误,例如重复的主键。我个人觉得最好是有错误提示,所以不建议使用。

标有`Function to use when dumping data`的下拉菜单让你选择`INSERT, UPDATE`或`REPLACE`。默认是用`INSERT`插入新记录。如果选择`UPDATE`,仅更新现有记录。`REPLACE`必要时进行更新,如果记录不存在,则插入新记录。

1.  完成所有选择后,点击页面底部的`Go`。现在您有了一个备份,可以用来将数据库的内容传输到另一台服务器上。

Tip

默认情况下,phpMyAdmin 创建的文件只包含创建和填充数据库表的 SQL 命令。它不包括创建数据库的命令,除非您选择自定义选项来创建数据库。这意味着您可以将表导入到任何数据库中。它不需要与本地测试环境中的名称相同。

## 在 MySQL 中选择正确的数据类型

当选择`image_id`列的`Type`时,您可能会感到有点震惊。phpMyAdmin 列出了所有可用的数据类型——在 MySQL 8 和 MariaDB 10 中有 40 多种。为了不让你被不必要的细节所迷惑,我将只解释那些最常用的。

您可以在位于 [`https://dev.mysql.com/doc/refman/8.0/en/data-types.html`](https://dev.mysql.com/doc/refman/8.0/en/data-types.html) 的 MySQL 文档中找到所有数据类型的完整细节。

### 存储文本

主要文本数据类型之间的差异归结为单个字段中可以存储的最大字符数、尾随空格的处理以及是否可以设置默认值。

*   `CHAR`:定长字符串。您必须在`Length/Values`字段中指定所需的长度。最大允许值为 255。在内部,字符串用空格向右填充到指定的长度,但是当您检索值时,尾部空格被删除。您可以定义默认值。

*   `VARCHAR`:变长字符串。您必须指定计划使用的最大字符数(在 phpMyAdmin 中,在`Length/Values`字段中输入数字)。最大字符数为 65,535。如果字符串存储时带有尾随空格,则在检索时会保留这些空格。接受默认值。

*   `TEXT`:存储最多 65,535 个字符的文本(比本章大约长 50%)。无法定义默认值。

`TEXT`之所以方便,是因为你不需要指定一个最大尺寸(事实上你也不能)。虽然`VARCHAR`和`TEXT`的最大大小是 65,535 个字符,但有效数量更少,因为一行中所有列可以存储的最大数量是 65,535 个字节。

Tip

保持简单:使用`VARCHAR`处理短文本项目,使用`TEXT`处理长文本项目。`VARCHAR`和`TEXT`列只占用存储输入值所需的磁盘空间。`CHAR`列总是分配声明为所需长度的全部空间,即使是空的。

### 存储数字

最常用的数字列类型如下:

*   `INT`:介于 2147483648 和 2147483647 之间的任意整数(整数)。如果列声明为`UNSIGNED`,则范围是从 0 到 4,294,967,295。

*   `FLOAT`:浮点数。您可以选择指定两个逗号分隔的数字来限制范围。第一个数字指定最大位数,第二个数字指定小数点后应该有多少位数。由于 PHP 会在计算后格式化数字,我建议您使用不带可选参数的`FLOAT`。

*   `DECIMAL`:带分数的数;小数点后包含固定位数。定义表格时,您需要指定最大位数以及小数点后应该有多少位数。在 phpMyAdmin 中,在`Length/Values`字段中输入用逗号分隔的数字。例如,6,2 允许范围从 9999.99 到 9999.99 的数字。如果不指定大小,当值存储在这种类型的列中时,小数部分将被截断。

`FLOAT`和`DECIMAL`的区别在于精度。浮点数被视为近似值,可能会出现舍入误差(详细解释请参见 [`https://dev.mysql.com/doc/refman/8.0/en/problems-with-float.html`](https://dev.mysql.com/doc/refman/8.0/en/problems-with-float.html) )。

使用`DECIMAL`存储货币。

Caution

不要使用逗号或空格作为千位分隔符。除了数字,数字中唯一允许的字符是负运算符(–)和小数点(。).

### 存储日期和时间

MySQL 只以一种格式存储日期:`YYYY-MM-DD`。这是 ISO(国际标准化组织)批准的标准,避免了不同国家惯例中固有的模糊性。我将在第十六章回到日期的话题。最重要的日期和时间列类型如下:

*   `DATE`:存储为`YYYY-MM-DD`的日期。范围是 1000-01-01 到 9999-12-31。

*   `DATETIME`:以`YYYY-MM-DD HH:MM:SS`格式显示的组合日期和时间。

*   `TIMESTAMP`:时间戳(通常由计算机自动生成)。合法值的范围从 1970 年初到 2038 年 1 月。

Caution

MySQL 时间戳使用与`DATETIME`相同的格式,这意味着它们与 Unix 和 PHP 时间戳不兼容,后者基于自 1970 年 1 月 1 日以来的秒数。不要混淆它们。

### 存储预定义列表

MySQL 允许您存储两种类型的预定义列表,它们可以被视为单选按钮和复选框状态的数据库等价物:

*   `ENUM`:该列类型存储预定义列表中的一个选项,例如“是,否,不知道”或“100–110V,220–240V”。预定义列表中可以存储的最大项目数是令人难以置信的 65,535,这是一个单选按钮组!

*   `SET`:该列类型存储预定义列表中的零个或多个选项。该列表最多可包含 64 个选项。

虽然`ENUM`非常有用,但是`SET`往往不那么有用,主要是因为它违反了每个字段只存储一条信息的原则。它可能有用的情况类型是当记录汽车上的可选附加物或调查中的多项选择时。

### 存储二进制数据

存储二进制数据,如图像,不是一个好主意。它会使你的数据库膨胀,而且你不能直接从数据库中显示图像。但是,下列列类型是为二进制数据设计的:

*   `TINYBLOB`:最多 255 字节

*   `BLOB`:最大 64 KB

*   `MEDIUMBLOB`:高达 16 MB

*   `LONGBLOB`:最高 4 GB

有了这些异想天开的名字,发现`BLOB`代表**二进制大物体**有点令人失望。

## 第三章回顾

这一章的大部分内容都致力于理论,解释良好的数据库设计的基本原则。您需要仔细规划数据库的结构,将重复的信息移动到单独的表中,而不是像电子表格一样将所有想要存储的信息都放在一个大表中。只要给表中的每条记录一个唯一的标识符(即它的主键),就可以跟踪信息,并通过使用外键将其链接到其他表中的相关记录。使用外键的概念在一开始可能很难理解,但在本书结束时应该会变得更加清晰。

您还学习了如何创建具有有限权限的 MySQL 用户帐户,以及如何定义表和使用 SQL 文件导入和导出数据。在下一章中,您将使用 PHP 连接到`phpsols`数据库,以显示存储在`images`表中的数据。

# 十三、使用 PHP 和 SQL 连接到数据库

PHP 8 提供了两种连接和交互 MySQL 数据库的方式:MySQL 改进版(MySQLi)和 PHP 数据对象(PDO)。您选择哪一个是一个重要的决定,因为它们使用不兼容的代码。您不能在同一个数据库连接中混合使用它们。同样重要的是,不要将 MySQL 与最初的 MySQL 扩展混淆,后者不再受支持。在大多数情况下,MySQLi 函数名称的唯一区别是添加了字母 *i* (例如,`mysqli_query`()而不是`mysql_query()`)。然而,参数的顺序通常是不同的,所以转换一个旧的脚本不仅仅是在函数名中插入一个 *i* 。

顾名思义,MySQL 是专门为与 MySQL 协同工作而设计的。它也完全兼容 MariaDB。另一方面,PDO 是数据库系统中立的。至少在理论上,只需修改几行 PHP 代码,就可以将网站从 MySQL 切换到 Microsoft SQL Server 或不同的数据库系统。实际上,您通常需要至少重写一些 SQL 查询,因为每个数据库供应商都在标准 SQL 的基础上添加了自定义函数。

我个人的偏好是用 PDO;但是为了完整起见,剩下的章节涵盖了 MySQLi 和 PDO。如果你想只关注其中一个,就忽略与另一个相关的部分。尽管您使用 PHP 连接到数据库并存储任何结果,但数据库查询需要用 SQL 编写。本章教你检索存储在表中的信息的基本知识。

在本章中,我们将介绍以下内容:

*   用 MySQL 和 PDO 连接到 MySQL 和 MariaDB

*   计算表中的记录数

*   使用选择查询检索数据并将其显示在网页上

*   使用准备好的语句和其他技术保护数据安全

## 检查您的远程服务器设置

XAMPP 和 MAMP 都支持 MySQLi 和 PDO,但是你需要检查你的远程服务器的 PHP 配置来验证它提供的支持程度。在您的远程服务器上运行`phpinfo()`,向下滚动配置页面,并查找以下部分。它们是按字母顺序排列的,所以您需要向下滚动很长一段距离才能找到它们:

![img/332054_5_En_13_Figa_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-php-zh/raw/master/docs/php8-solution/img/332054_5_En_13_Figa_HTML.jpg)

所有托管公司都要有第一节(`mysqli`)。如果只列出了`mysql`(没有最后的 *i* ),那么你的服务器已经过时了。让你的托管公司尽快把你转移到运行最新版本 PHP 8.x 的服务器上(你可以在 [`https://php.net/supported-versions.php`](https://php.net/supported-versions.php) 查看目前支持哪些版本的 PHP)。如果您计划使用 PDO,您不仅需要检查 PDO 是否已启用,还必须确保`pdo_mysql`已列出。PDO 要求每种类型的数据库使用不同的驱动程序。

## PHP 如何与数据库通信

不管你使用 MySQLi 还是 PDO,这个过程总是遵循这个顺序:

1.  使用主机名、用户名、口令和数据库名连接到数据库。

2.  准备一个 SQL 查询。

3.  执行查询并保存结果。

4.  从结果中提取数据(通常使用循环)。

用户名和密码是您在第十二章中创建的帐户的用户名和密码,或者是您的托管公司给您的帐户的用户名和密码。但是主机名呢?在本地测试环境中,它是`localhost`。令人惊讶的是,即使在远程服务器上也经常是`localhost`。这是因为在许多情况下,数据库服务器与您的网站位于同一服务器上。换句话说,显示页面的 web 服务器和数据库服务器位于彼此的本地。然而,如果数据库服务器在一台单独的机器上,你的主机公司会告诉你使用的地址。重要的一点是,主机名是*而不是*,通常与你网站的域名相同。

让我们快速看一下如何使用每种方法连接到数据库。

### 连接 MySQL 改进的扩展

MySQLi 有两个接口:过程的和面向对象的。过程接口的设计是为了简化从最初的 MySQL 函数的转换。因为面向对象的版本更紧凑,所以这里采用的就是这个版本。

要连接到 MySQL 或 MariaDB,可以通过向构造函数方法传递四个参数来创建一个`mysqli`对象:主机名、用户名、密码和数据库名称。这是您连接到`phpsols`数据库的方式:

```php
$conn = new mysqli($hostname, $username, $password, 'phpsols');

这将连接对象存储为$conn

如果您的数据库服务器使用非标准端口,您需要将端口号作为第五个参数传递给mysqli构造函数。

Tip

MAMP 使用套接字连接 MySQL,所以即使 MySQL 正在监听端口 8889,也不需要添加端口号。这适用于 MySQLi 和 PDO。

连接 PDO

PDO 需要一个稍微不同的方法。最重要的区别是,如果连接失败,PDO 会抛出一个异常。如果您没有捕捉到异常,调试信息会显示所有的连接细节,包括您的用户名和密码。因此,您需要将代码包装在一个try块中,并捕捉异常以防止敏感信息被显示。

PDO构造函数方法的第一个参数是一个数据源名称 (DSN)。这是一个由 PDO 驱动程序名称后跟一个冒号,再后跟特定于 PDO 驱动程序的连接详细信息组成的字符串。

要连接到 MySQL 或 MariaDB,DSN 需要采用以下格式:

'mysql:host=hostname;dbname=databaseName'

如果您的数据库服务器使用非标准端口,DSN 也应该包含端口号,如下所示:

'mysql:host=hostname;port=portNumber;dbname=databaseName'

在 DSN 之后,您将用户名和密码传递给PDO()构造函数方法。因此,连接到phpsols数据库的代码如下所示:

try {
 $conn = new PDO("mysql:host=$hostname;dbname=phpsols", $username, $password);
} catch (PDOException $e) {
 echo $e->getMessage();
}

在测试期间使用echo来显示异常产生的消息是可以接受的,但是当你在一个活动的网站上部署脚本时,你需要将用户重定向到一个错误页面,如 PHP 解决方案 5-9 中所述。

Tip

为了连接到不同的数据库系统,DSN 是 PHP 代码中唯一需要修改的部分。所有剩余的 PDO 代码都是完全数据库中立的。有关如何为 PostgreSQL、Microsoft SQL Server、SQLite 和其他数据库系统创建 DSN 的详细信息,请访问 www.php.net/manual/en/pdo.drivers.php

PHP 解决方案 13-1:制作一个可重用的数据库连接器

连接到数据库是一项日常工作,从现在开始需要在每个页面中执行。这个 PHP 解决方案创建了一个存储在连接到数据库的外部文件中的简单函数。它主要是为测试剩余章节中不同的 MySQLi 和 PDO 脚本而设计的,不需要每次都重新输入连接细节,也不需要在不同的连接文件之间切换。

  1. includes文件夹中创建一个名为connection.php的文件,并插入以下代码(在ch13文件夹中有一份完整脚本的副本):
<?php
function dbConnect($usertype, $connectionType = 'mysqli') {
    $host = 'localhost';
    $db = 'phpsols';
    if ($usertype == 'read') {
        $user = 'psread';
        $pwd = 'K1yoMizu^dera';
    } elseif ($usertype == 'write') {
        $user = 'pswrite';
        $pwd = '0Ch@Nom1$u';
    } else {
        exit('Unrecognized user');
    }
    // Connection code goes here
}

该函数有两个参数:用户类型和连接类型。第二个参数默认为mysqli。如果您想专注于使用 PDO,请将第二个参数的默认值设置为pdo

函数中的前两行存储了您想要连接的主机服务器和数据库的名称。

条件语句检查第一个参数的值,并根据需要在psreadpswrite用户名和密码之间切换。如果用户帐户未被识别,exit()功能会暂停脚本并显示Unrecognized user

  1. 用以下内容替换Connection code goes here注释:
if ($connectionType == 'mysqli') {
    $conn = @ new mysqli($host, $user, $pwd, $db);
    if ($conn->connect_error) {
        exit($conn->connect_error);
    }
    return $conn;
} else {
    try {
        return new PDO("mysql:host=$host;dbname=$db", $user, $pwd);
    } catch (PDOException $e) {
        echo $e->getMessage();
    }
}

如果第二个参数设置为mysqli,就会创建一个名为$conn的 MySQLi 连接对象。错误控制运算符(@)防止构造函数方法显示错误消息。如果连接失败,原因存储在对象的connect_error属性中。如果为空,则被视为false,因此跳过下一行,返回$conn对象。但是如果有问题,exit()会显示connect_error的值,并暂停脚本。

否则,该函数返回一个 PDO 连接对象。没有必要在PDO构造函数中使用错误控制操作符,因为如果有问题,它会抛出一个PDOExceptioncatch块使用异常的getMessage()方法来显示问题的原因。

Tip

如果您的数据库服务器使用非标准端口,不要忘记将端口号作为第五个参数添加到mysqli()构造函数中,并将其包含在 PDO DSN 中,如前面几节所述。如果数据库使用套接字连接,这是不必要的,这在 macOS 和 Linux 上很常见。

  1. phpsols站点根文件夹中创建一个名为connection_test.php的文件,并插入以下代码:
<?php
require_once './includes/connection.php';
if ($conn = dbConnect('read')) {
    echo 'Connection successful';
}

这包括连接脚本,并用psread用户帐户和 MySQLi 进行测试。

  1. 保存页面并将其加载到浏览器中。如果你看到Connection successful,一切都好。如果收到错误消息,请参考下一节中的故障排除提示。

  2. 测试与pswrite用户和 MySQLi 的连接:

    if ($conn = dbConnect('write')) {
        echo 'Connection successful';
    }
    
    
  3. 通过将'pdo'作为第二个参数添加到dbConnect()来测试 PDO 的两个用户帐户。

  4. 假设一切顺利,您就可以开始与phpsols数据库交互了。如果您遇到了问题,请查看下一部分。

数据库连接问题疑难解答

连接数据库时最常见的失败原因是用户名或密码错误。密码和用户名区分大小写。仔细检查拼写。例如,下面的截图显示了将psread改为Psread会发生什么:

img/332054_5_En_13_Figb_HTML.jpg

访问被拒绝,因为没有这样的用户。用户名的第一个大写字母非常重要。但是,即使用户名是正确的,您也可能会得到相同的错误消息,如下所示:

img/332054_5_En_13_Figc_HTML.jpg

这完全让很多人困惑。错误信息确认您正在使用密码。那么,为什么访问被拒绝呢?密码不对。这就是原因。

如果错误信息显示using password: NO,这意味着您忘记了提供密码。短语using password是问题与登录凭证有关的线索。

如果缺少该短语,则表明存在不同的问题,如下一个屏幕截图所示:

img/332054_5_En_13_Figd_HTML.jpg

这里的问题是数据库的名称不正确。如果你拼错了主机,你会得到一个消息,没有这样的主机是已知的。

本节截图由 MySQLi 生成。PDO 生成相同的消息,但也包括错误号和代码。

净化来自数据库的文本结果

当显示 SQL 查询的结果时,您可以确信存储在某些类型的列中的值将采用特定的格式。例如,数字列类型只能存储数字。类似地,与日期和时间相关的列仅以 ISO 日期-时间格式存储值。然而,与文本相关的列可以存储任何类型的字符串,包括 HTML、JavaScript 和其他可执行代码。当从与文本相关的列中输出值时,您应该始终对它们进行清理,以防止执行任意代码。

净化文本输出的简单方法是将其传递给htmlspecialchars()。这个函数与htmlentities()相关,但是它将更有限范围的字符转换成等价的 HTML 字符实体。具体来说,它转换&符号、引号和尖括号;但是它保留了句点(点)不变。这具有中和在浏览器中显示代码时执行代码的企图的效果,因为<script>和 PHP 标签的尖括号被转换了。重要的是不要转换点,因为它们用在我们想要显示的文件名中。

htmlspecialchars()的缺点是,默认情况下,它对现有的字符实体进行双重编码。结果,&被转换为&amp;。您可以通过将命名参数double_encode传递给htmlspecialchars()并将其值设置为false来关闭这个默认行为。

每次您想要调用htmlspecialchars()时,键入指定的参数是乏味的。因此,我在ch13文件夹中的一个名为utility_funcs.php的文件中定义了以下自定义函数:

function safe($text) {
    return htmlspecialchars($text, double_encode: false);
}

这只是将$text传递给htmlspecialchars(),设置可选参数,并返回结果。将utility_funcs.php复制到includes文件夹,并将其包含在从数据库输出文本的脚本中。

作为对htmlspecialchars()的替代,您可以将文本值传递给strip_tags(),这允许您指定允许的 HTML 标签(参见第七章中的“访问远程文件”)。

查询数据库并显示结果

在尝试显示数据库查询的结果之前,最好先了解有多少个结果。如果没有任何结果,您将没有什么可显示的。这对于创建一个浏览一长串结果的导航系统也是必要的(你将在下一章学习如何做)。在用户认证中(在第十九章中介绍),搜索用户名和密码时没有结果意味着登录应该失败。

MySQLi 和 PDO 使用不同的方法来计算和显示结果。接下来的两个 PHP 解决方案展示了如何用 MySQLi 实现这一点。对于 PDO,直接跳到 PHP 解决方案 13-4。

PHP 解决方案 13-2:计算结果集中的记录数(MySQLi)

这个 PHP 解决方案展示了如何提交一个 SQL 查询来选择images表中的所有记录,并将结果存储在一个MySQLi_Result对象中。对象的num_rows属性包含查询检索到的记录数。

  1. php8sols站点根目录下创建一个名为mysqli的新文件夹,然后在文件夹内创建一个名为mysqli.php的新文件。该页面最终将用于显示一个表格,因此它应该有一个DOCTYPE声明和一个 HTML 框架。

  2. 将连接文件包含在 PHP 块中的DOCTYPE声明之上,并使用具有只读权限的帐户连接到phpsols数据库,如下所示:

    require_once '../includes/connection.php';
    $conn = dbConnect('read');
    
    
  3. 接下来,准备 SQL 查询。在前一步之后(但在结束 PHP 标记之前)立即添加以下代码:

$sql = 'SELECT * FROM images';

这意味着“从images表中选择所有内容”星号(*)是“所有列”的简写

  1. 现在通过调用 connection 对象上的query()方法执行查询,并将 SQL 查询作为参数传递,如下所示:
                  $result = $conn->query($sql);

结果存储在一个变量中,我将它富有想象力地命名为$result

  1. 如果有问题,$result就会是false。为了找出问题所在,我们需要获取错误消息,该消息存储为mysqli连接对象的error属性。在前一行之后添加以下条件语句:

  2. 假设没有问题,$result现在持有一个MySQLi_Result对象,该对象有一个名为num_rows的属性。要获得查询找到的记录数,向条件语句添加一个else块,并将值赋给一个变量,如下所示:

    if (!$result) {
        $error = $conn->error;
    } else {
       $numRows = $result->num_rows;
    }
    
    
  3. 现在,您可以在页面正文中显示结果,如下所示:

    <?php
    if (isset($error)) {
        echo "<p>$error</p>";
    } else {
        echo "<p>A total of $numRows records were found.</p>";
    }
    ?>
    
    
if (!$result) {
    $error = $conn->error;
}

如果有问题,$error将被设置,所以它被显示。否则,else块显示找到的记录数。两个字符串都嵌入了变量,所以它们用双引号括起来。

  1. 保存mysqli.php并将其加载到浏览器中。您应该会看到以下结果:

img/332054_5_En_13_Fige_HTML.jpg

如有必要,用ch13文件夹中的mysqli_01.php检查您的代码。

PHP 解决方案 13-3:使用 MySQLi 显示图像表

显示SELECT查询结果的最常见方式是使用循环从结果集中一次提取一行。MySQLi_Result有一个名为fetch_assoc()的方法,该方法以关联数组的形式检索当前行,以便显示在网页上。数组中的每个元素都以表格中相应的列命名。

这个 PHP 解决方案展示了如何遍历一个MySQLi_Result对象来显示一个SELECT查询的结果。继续使用 PHP 解决方案 13-2 中的文件。

  1. utility_funcs.phpch13文件夹复制到includes文件夹,并将其包含在脚本的顶部:

    require_once '../includes/connection.php';
    require_once '../includes/utility_funcs.php';
    
    
  2. 移除页面主体中else块末尾的右花括号(应该在第 24 行左右)。尽管显示images表的大部分代码是 HTML,但它需要在else块中。

  3. 在结束 PHP 标记后插入一个空行,并在单独的 PHP 代码块中的下一行添加结束括号。修改后的代码应该如下所示:

  4. mysqli.php的主体中的两个 PHP 块之间添加下表,以便它由else块控制。这样做的原因是为了防止 SQL 查询失败时出错。显示结果集的 PHP 代码以粗体突出显示:

               } else {
echo "<p>A total of $numRows records were found.</p>";
              ?>
<?php } ?>
               </body>

<table>
     <tr>
         <th>image_id</th>
         <th>filename</th>
         <th>caption</th>
     </tr>
 <?php while ($row = $result->fetch_assoc()) { ?>
     <tr>
         <td><?= $row['image_id'] ?></td>
         <td><?= safe($row['filename']) ?></td>
         <td><?= safe($row['caption']) ?></td>
     </tr>
 <?php } ?>
</table>

提示while循环遍历数据库结果,使用fetch_assoc()方法将每条记录提取到$row中。$row的每个元素都显示在一个表格单元中。循环继续,直到fetch_assoc()到达结果集的末尾。

没有必要净化image_id的值,因为它在一个只存储整数的列中。

  1. 保存mysqli.php并在浏览器中查看。您应该会看到显示的images表的内容,如下图所示:

img/332054_5_En_13_Figf_HTML.jpg

如有必要,您可以将您的代码与ch13文件夹中的mysql_02.php进行比较。

MySQLi 连接婴儿床表

表 13-1 总结了 MySQLi 的连接和数据库查询的基本细节。

表 13-1

通过 MySQL 改进的面向对象接口连接到 MySQL/MariaDB

|

行动

|

使用

|

评论

|
| --- | --- | --- |
| 连接 | $conn = new mysqli($h,$u,$p,$d); | 所有参数都是可选的;实践中总是需要前四个:主机名、用户名、密码、数据库名。创建连接对象。 |
| 选择数据库 | $conn->select_db('dbName'); | 用于选择不同的数据库。 |
| 提交查询 | $result = $conn->query($sql); | 返回结果对象。 |
| 计数结果 | $numRows = $result->num_rows; | 返回结果对象中的行数。 |
| 提取记录 | $row = $result->fetch_assoc(); | 从结果对象中提取当前行作为关联数组。 |
| 提取记录 | $row = $result->fetch_row(); | 从结果对象中提取当前行作为索引(编号)数组。 |

PHP 解决方案 13-4:计算结果集中的记录数(PDO)

PDO 没有直接等同于 MySQLi num_rows的属性。对于大多数数据库,您需要执行一个 SQL 查询来计算表中的项数,然后获取结果。然而,PDO rowCount()方法实现了 MySQL 和 MariaDB 的双重目的。通常,它只报告受插入、更新或删除记录影响的行数,但是对于 MySQL 和 MariaDB,它还报告由SELECT查询找到的记录数。

  1. php8sols站点中创建一个名为pdo的新文件夹。然后在刚刚创建的文件夹中创建一个名为pdo.php的文件。该页面最终将用于显示一个表格,因此它应该有一个DOCTYPE声明和一个 HTML 框架。

  2. 将连接文件包含在 PHP 块中的DOCTYPE声明之上,然后使用只读帐户创建一个到phpsols数据库的 PDO 连接,如下所示:

    require_once '../includes/connection.php';
    $conn = dbConnect('read', 'pdo');
    
    
  3. 接下来,准备 SQL 查询:

$sql = 'SELECT * FROM images';

这意味着“选择images表中的每一条记录”星号(*)是“所有列”的简写

  1. 现在执行查询并将结果存储在一个变量中,如下所示:

  2. 要检查查询是否有问题,可以使用 connection 对象的errorInfo()方法从数据库中获取一组错误信息。数组的第三个元素包含出错时问题的简要描述。添加以下代码:

$result = $conn->query($sql);

$error = $conn->errorInfo()[2];

我们只对第三个元素感兴趣,所以我们可以使用在 PHP 解决方案 7-1 中遇到的数组解引用技术(“获取文本文件的内容”),方法是在调用$conn->errorInfo()后立即在一对方括号中添加数组索引,并将值赋给$error

  1. 如果查询执行成功,$error将是null,PHP 将其视为false。因此,如果没有错误,我们可以通过调用$result对象上的rowCount()方法来获得结果集中的行数,如下所示:

    if (!$error) {
        $numRows = $result->rowCount();
    }
    
    
  2. 现在,您可以在页面正文中显示查询结果,如下所示:

    <?php
    if ($error) {
        echo "<p>$error</p>";
    } else {
        echo "<p>A total of $numRows records were found.</p>";
    }
    ?>
    
    
  3. 保存页面并将其加载到浏览器中。你应该会看到和 PHP 解决方案 13-2 的第 8 步相同的结果。如有必要,用pdo_01.php检查您的代码。

统计其他数据库中 PDO 的记录

使用 PDO rowCount()来报告一个SELECT查询找到的条目数量,这在 MySQL 和 MariaDB 上都适用,但不能保证在所有其他数据库上都适用。如果rowCount()不起作用,用下面的代码代替:

// prepare the SQL query
$sql = 'SELECT COUNT(*) FROM images';
// submit the query and capture the result
$result = $conn->query($sql);
$error = $conn->errorInfo()[2];
if (!$error) {
    // find out how many records were retrieved
    $numRows = $result->fetchColumn();
    // free the database resource
    $result->closeCursor();
}

这使用带星号的 SQL COUNT()函数来计算表中的所有项目。只有一个结果,所以可以用fetchColumn()方法检索,该方法从数据库结果中获取第一列。在将结果存储在$numRows中之后,您必须调用closeCursor()方法来释放数据库资源以供任何进一步的查询使用。

PHP 解决方案 13-5:使用 PDO 显示图像表

要用 PDO 显示一个SELECT查询的结果,可以在一个foreach循环中使用query()方法来提取当前行作为一个关联数组。数组中的每个元素都以表格中相应的列命名。

继续使用与前面的 PHP 解决方案相同的文件。

  1. utility_funcs.phpch13文件夹复制到includes文件夹,并将其包含在脚本的顶部:

    require_once '../includes/connection.php';
    require_once '../includes/utility_funcs.php';
    
    
  2. 移除页面主体中else块末尾的右花括号(应该在第 26 行左右)。尽管显示images表的大部分代码是 HTML,但它需要在else块中。

  3. 在结束 PHP 标记后插入一个空行,然后在单独的 PHP 代码块中的下一行添加右括号。修改后的代码应该如下所示:

  4. pdo.php的主体中的两个 PHP 块之间添加下表,以便它由else块控制。这是为了防止 SQL 查询失败时出错。显示结果集的 PHP 代码以粗体显示:

                   } else {
    echo "<p>A total of $numRows records were found.</p>";
?>
<?php } ?>
                   </body>

  1. 保存页面并在浏览器中查看。它应该看起来像 PHP 解决方案 13-3 中的截图。您可以将您的代码与ch13文件夹中的pdo_02.php进行比较。
<table>
     <tr>
         <th>image_id</th>
         <th>filename</th>
         <th>caption</th>
     </tr>
 <?php foreach ($conn->query($sql) as $row) { ?>
     <tr>
         <td><?= $row['image_id'] ?></td>
         <td><?= safe($row['filename']) ?></td>
         <td><?= safe($row['caption']) ?></td>
     </tr>
 <?php } ?>
</table>

PDO 连接婴儿床表

表 13-2 总结了与 PDO 连接和数据库查询的基本细节。有些命令将在后面的章节中使用,但为了便于参考,在此列出。

表 13-2

与 PDO 的数据库连接

|

行动

|

使用

|

评论

|
| --- | --- | --- |
| 连接 | $conn = new PDO($DSN,$u,$p); | 在实践中,需要三个参数:数据源名称(DSN)、用户名和密码。必须包装在 try/catch 块中。 |
| 提交SELECT查询 | $result = $conn->query($sql); | 将结果作为一个PDOStatement对象返回。 |
| 提取记录 | foreach($conn->query($sql) as $row) { | 提交SELECT查询并在单个操作中获取当前行作为关联数组。 |
| 计数结果 | $numRows = $result->rowCount() | 在 MySQL/MariaDB 中,返回来自SELECT的结果数。大多数其他数据库都不支持。 |
| 获得单一结果 | $item = $result->fetchColumn(); | 获取结果第一列中的第一条记录。要从其他列获得结果,请使用列号(从 0 开始计数)作为参数。 |
| 获取下一条记录 | $row = $result->fetch(); | 从结果集中获取下一行作为关联数组。 |
| 释放数据库资源 | $result->closeCursor(); | 释放连接以允许新查询。 |
| 提交非SELECT查询 | $affected = $conn->exec($sql); | 虽然query()可以用于非SELECT查询,但是exec()会返回受影响的行数。 |

使用 SQL 与数据库交互

正如您刚才看到的,PHP 连接到数据库,发送查询,并接收结果,但查询本身需要用 SQL 编写。虽然 SQL 是一个通用标准,但是 SQL 有许多不同的方言。每个数据库供应商,包括 MySQL,都为标准语言添加了扩展。这提高了效率和功能,但通常与其他数据库不兼容。本书中的 SQL 适用于 MySQL 5.1 或更高版本以及 MariaDB,但不一定会转移到 Microsoft SQL Server、Oracle 或其他数据库。

编写 SQL 查询

SQL 语法没有太多的规则,而且都很简单。

SQL 关键字不区分大小写

images表中检索所有记录的查询如下所示:

SELECT * FROM images

大写的单词是 SQL 关键字。这纯粹是约定俗成。以下内容同样正确:

SELECT * FROM images
select * from images
SeLEcT * fRoM images

尽管 SQL 关键字不区分大小写,但同样的不适用于数据库列名。对关键字使用大写字母的好处是,它使 SQL 查询更容易阅读。你可以自由选择最适合你的风格,但是最好避免最后一个例子中的勒索信风格。

空白被忽略

这允许您将 SQL 查询分散到几行中,以增加可读性。不允许空白的地方是在函数名和左括号之间。以下内容会生成一个错误:

SELECT COUNT (*) FROM images /* BAD EXAMPLE */

空间需要像这样封闭起来:

SELECT COUNT(*) FROM images /* CORRECT */

正如您可能从这些示例中总结的那样,您可以通过将注释放在//之间来为 SQL 查询添加注释。

字符串必须用引号括起来

SQL 查询中的所有字符串都必须用引号括起来。使用单引号还是双引号并不重要,只要它们成对出现就行。然而,通常最好使用 MySQLi 或 PDO 预准备语句,这将在本章后面解释。

处理数字

一般来说,数字不应该加引号,因为加引号的都是字符串。然而,MySQL 接受用引号括起来的数字,并将它们视为等价的数字。请注意区分实数和任何其他由数字组成的数据类型。例如,日期由数字组成,但应该用引号括起来,并存储在与日期相关的列类型中。类似地,电话号码应该用引号括起来,并存储在与文本相关的列类型中。

Note

SQL 查询通常以分号结束,分号是数据库执行查询的指令。当使用 PHP 时,分号必须从 SQL 中省略。因此,在本书中,SQL 的独立例子没有以分号结尾。

细化由选择查询检索的数据

到目前为止,您运行的惟一一个 SQL 查询从images表中检索所有记录。很多时候,你想更有选择性。

选择特定列

使用星号选择所有列是一种方便的快捷方式,但是通常应该只指定那些需要的列。在SELECT关键字后列出用逗号分隔的列名。例如,该查询只为每条记录选择了filenamecaption字段:

SELECT filename, caption FROM images

你可以在ch13文件夹的mysqli_03.phppdo_03.php中测试这个。

更改结果的顺序

为了控制排序顺序,添加一个ORDER BY子句,按照优先级顺序列出列名。用逗号分隔多列。以下查询按字母顺序对images表中的标题进行排序(代码在mysqli_04.phppdo_04.php)中):

$sql = 'SELECT * FROM images ORDER BY caption';

Note

这个分号是 PHP 语句的一部分,而不是 SQL 查询的一部分。

上述查询会产生以下输出:

img/332054_5_En_13_Figg_HTML.jpg

要颠倒排序顺序,添加DESC(表示“降序”)关键字,如下所示(在mysqli_05.phppdo_05.php中有示例):

$sql = 'SELECT * FROM images ORDER BY caption DESC';

img/332054_5_En_13_Figh_HTML.jpg

还有一个ASC(代表“升序”)关键字。这是默认的排序顺序,所以通常会被忽略。

但是,当同一表中的列以不同的顺序排序时,指定ASC会增加清晰度。例如,如果您每天发布多篇文章,您可以使用以下查询按字母顺序显示标题,但按发布日期排序,最新的文章排在最前面:

SELECT * FROM articles
ORDER BY published DESC, title ASC

搜索特定值

要搜索特定的值,请在SELECT查询中添加一个WHERE子句。WHERE子句跟在表名后面。例如,mysqli_06.phppdo_06.php中的查询如下所示:

$sql = 'SELECT * FROM images
WHERE image_id = 6';

Note

SQL 使用一个等号来测试相等性,不像 PHP 使用两个。

它会产生以下结果:

img/332054_5_En_13_Figi_HTML.jpg

除了测试相等性之外,WHERE子句还可以使用比较运算符,例如大于(>)和小于(<)。我将根据需要介绍其他选项,而不是现在介绍所有选项。第十五章全面总结了四个主要的 SQL 命令,SELECTINSERTUPDATEDELETE,包括与WHERE一起使用的主要比较运算符列表。

如果与ORDER BY结合使用,WHERE子句必须放在前面。例如(代码在mysqli_07.phppdo_07.php):

$sql = 'SELECT * FROM images
WHERE image_id > 5
ORDER BY caption DESC';

这将选择三个image_id大于 5 的图像,并按照标题以相反的顺序对它们进行排序。

搜索带有通配符的文本

在 SQL 中,百分号(%)是一个通配符,匹配任何内容或不匹配任何内容。它与LIKE关键字一起用在WHERE子句中。

mysqli_08.phppdo_08.php中的查询如下所示:

$sql = 'SELECT * FROM images
WHERE caption LIKE "%Kyoto%"';

它搜索images表中caption列包含“京都”的所有记录,并产生以下结果:

img/332054_5_En_13_Figj_HTML.jpg

如前面的屏幕截图所示,它在images表的八条记录中找到了六条。所有标题都以“Kyoto”结尾,所以结尾的通配符不匹配任何内容,而开头的通配符匹配每个标题的其余部分。

如果省略前导通配符("Kyoto%"),查询将搜索以“Kyoto”开头的标题他们都不知道,所以你不会从搜索中得到任何结果。

mysqli_09.phppdo_09.php中的查询如下所示:

$sql = 'SELECT * FROM images
WHERE caption LIKE "%maiko%"';

它会产生以下结果:

img/332054_5_En_13_Figk_HTML.jpg

该查询拼写的“maiko”都是小写的,但该查询也发现它有一个首字母大写。使用LIKE的搜索不区分大小写。

要执行区分大小写的搜索,您需要像这样添加关键字BINARY(代码在mysqli_10.phppdo_10.php):

$sql = 'SELECT * FROM images
WHERE caption LIKE BINARY "%maiko%"';

到目前为止,您看到的所有示例都是硬编码的,但是大多数时候,SQL 查询中使用的值需要来自用户输入。除非您非常小心,否则这将使您面临被称为 SQL 注入的恶意利用的风险。本章的其余部分解释了这种危险以及如何避免它。

了解 SQL 注入的危险

SQL 注入非常类似于我在第六章中警告过你的邮件头注入。注入攻击试图在 SQL 查询中插入虚假条件,试图暴露或破坏您的数据。以下查询的含义应该很容易理解:

SELECT * FROM users WHERE username = 'xyz' AND pwd = 'abc'

这是登录应用的基本模式。如果查询发现一条记录,其中usernamexyzpwdabc,那么您知道已经提交了用户名和密码的正确组合,因此登录成功。攻击者需要做的只是注入一个额外的条件,就像这样:

SELECT * FROM users WHERE username = 'xyz' AND pwd = 'abc' OR 1 = 1

OR意味着只需要其中一个条件为真,所以即使没有正确的用户名和密码,登录也能成功。当查询的一部分来自变量或用户输入时,SQL 注入依赖于引号和其他控制字符不能被正确转义。

根据具体情况,你可以采取几种策略来预防 SQL 注入:

  • 如果变量是一个整数(例如,记录的主键),使用is_numeric()(int)转换操作符来确保插入查询是安全的。

  • 使用准备好的语句。在预处理语句中,SQL 查询中的占位符表示来自用户输入的值。PHP 代码自动将字符串用引号括起来,并对嵌入的引号和其他控制字符进行转义。MySQLi 和 PDO 的语法不同。

  • 前面的策略都不适合列名,列名不能用引号括起来。要将变量用于列名,请创建一个可接受值的数组,并在将提交的值插入查询之前检查它是否在数组中。

让我们来看看如何使用这些技术。

Note

我没有将 MySQLi real_escape_string()或 PDO quote()方法作为预防 SQL 注入的技术,因为它们都不能提供防弹保护。使用预处理语句将用户输入的值嵌入到 SQL 查询中。

PHP 解决方案 13-6:将用户输入的整数插入到查询中

这个 PHP 解决方案展示了如何净化用户输入的变量,以确保在将值插入 SQL 查询之前它只包含一个整数。MySQLi 和 PDO 的技术是一样的。

  1. ch13文件夹中的mysqli_integer_01.phppdo_integer_01.php复制到mysqlipdo文件夹中,并删除文件名中的_01。每个文件包含一个 SQL 查询,该查询从images表中选择image_idfilename列。在页面主体中,有一个带有下拉菜单的表单,该表单由一个循环填充,该循环遍历 SQL 查询的结果。MySQLi 版本如下所示:
<form action="mysqli_integer.php" method="get">
    <select name="image_id">
    <?php while ($row = $images->fetch_assoc()) { ?>
        <option value="<?= $row['image_id'] ?>"
        <?php if (isset($_GET['image_id']) &&
            $_GET['image_id'] == $row['image_id']) {
            echo 'selected';
         } ?>
         ><?= safe($row['filename']) ?></option>
    <?php } ?>
    </select>
    <input type="submit" name="go" value="Display">
</form>

该表单使用了get方法,并将image_id赋给了<option>标签的value属性。如果$_GET['image_id']$row['image_id']具有相同的值,则当前的image_id与通过页面的查询字符串传递的值相同,因此selected属性被添加到开始的<option>标签中。$row['filename']的值被插入到开始和结束<option>标签之间。

除了使用 PDO fetch()方法在foreach循环中直接运行查询之外,PDO 版本是相同的。

如果您将页面加载到浏览器中,您会看到一个下拉菜单,其中列出了images文件夹中的文件,如下所示:

img/332054_5_En_13_Figl_HTML.jpg

  1. 在结束的</form>标签后立即插入以下代码。MySQLi 和 PDO 的代码是一样的,除了一行代码(这是 MySQLi 版本):
<?php
if (isset($_GET['image_id'])) {
    $image_id = (int) $_GET['image_id'];
    $error = ($image_id === 0) ? true : false;
    if (!$error) {
        $sql = "SELECT filename, caption FROM images
        WHERE image_id = $image_id";
        $result = $conn->query($sql);
        $row = $result->fetch_assoc();
        ?>
        <figure><img src="../img/<?= safe($row['filename']) ?>">
            <figcaption><?= safe($row['caption']) ?></figcaption>
        </figure>
     <?php }
     if ($error) {
         echo '<p>Image not found</p>';
    }
} ?>

条件语句检查image_id是否已经通过$_GET数组发送。如果有,使用(int)转换操作符将其分配给$image_id。使用 casting 操作符有两个目的:防止通过提交浮点数来探查脚本中的错误消息,以及将非数字值转换为 0。

下一行使用三元运算符将$error设置为truefalse,这取决于$image_id 是否为 0。

如果$errorfalse,脚本查询数据库并显示选中的图像和标题。因为您知道$image_id是一个整数,所以直接插入 SQL 查询是安全的。因为是数字,所以不需要用引号括起来,但是赋给$sql的字符串需要用双引号来保证$image_id的值被插入到查询中。

新的查询通过query()方法提交给 MySQL,结果存储在$row中。最后,$row['filename']$row['caption']用于显示页面中的图片及其标题。

但是,如果$errortrue,最后的条件语句显示“找不到图像”

Tip

我选择了一个单独的条件语句来显示“Image not found ”,因为我计划稍后检查另一个错误,并且我希望对两者使用相同的错误消息。

  1. 如果您使用的是 PDO 版本,请找到以下代码行:
$row = $result->fetch_assoc();

改成这样:

  1. 保存页面并将其加载到浏览器中。当页面第一次加载时,只显示下拉菜单。

  2. 从下拉菜单中选择一个文件名,然后单击Display。应该会显示您选择的图像,如下面的屏幕截图所示:

$row = $result->fetch();

img/332054_5_En_13_Figm_HTML.jpg

如果你遇到问题,对照ch13文件夹中的mysqli_integer_02.phppdo_integer_02.php检查你的代码。

  1. 在浏览器中编辑查询字符串,将image_id的值更改为字符串。您应该看到“找不到图像”但是,如果字符串以 1 到 8 之间的数字开头,您将看到与该数字相关的图像和标题。

  2. 尝试 1.0 到 8.9 之间的浮点数。相关图像正常显示。

  3. 请尝试 1-8 范围之外的数字。在 PHP 的旧版本中,不会显示错误消息,因为查询没有问题。它只是在寻找一个不存在的值。然而,在 PHP 8 中,如果你已经将 php.ini 中的 error_reporting 设置为第二章中推荐的级别,你会得到一条类似这样的警告消息:

img/332054_5_En_13_Fign_HTML.jpg

  1. 为了避免这种情况,您应该使用 MySQLi 的num_rows属性或 PDO 的rowCount()方法来检查查询返回的行数。

为 MySQLi 更改如下代码:

    $result = $conn->query($sql);
     if ($result->num_rows) {
         $row = $result->fetch_assoc();
         ?>
         <figure><img src="../img/<?= safe($row['filename']) ?>">
             <figcaption><?= safe($row['caption']) ?></figcaption>
         </figure>
     <?php } else { ?>
         $error = true;
        }
    }
    if ($error) {
        echo '<p>Image not found</p>';
    }
} ?>

对于 PDO,用$result->rowCount()代替$result->num_rows

如果查询没有返回任何行,PHP 将 0 视为隐式的false,因此条件失败,而是执行else子句,将$error设置为true

显示“找不到图像”的条件语句可以移动到else块中,但是这个脚本有几个嵌套的条件。将它分开可以更容易阅读脚本和遵循条件逻辑。

  1. 再次测试页面。当您从下拉菜单中选择图像时,它会像以前一样正常显示。但是,如果您尝试在查询字符串中输入一个超出范围的值,您将看到错误消息。

修改后的代码在ch13文件夹的mysqli_integer_03.phppdo_integer_03.php中。

为用户输入使用准备好的语句

MySQLi 和 PDO 都支持预准备语句,这提供了重要的安全特性。预准备语句是 SQL 查询的模板,其中包含每个可变值的占位符。这不仅使在 PHP 代码中嵌入变量变得更加容易,还可以防止 SQL 注入攻击,因为引号和其他字符会在查询执行前自动转义。

使用预准备语句的其他优点是,当同一个查询被多次使用时,它们会更有效。此外,您可以将来自SELECT查询的每一列的结果绑定到命名变量,从而更容易显示输出。

MySQLi 和 PDO 都使用问号作为匿名占位符,就像这样:

$sql = 'SELECT image_id, filename, caption FROM images WHERE caption LIKE ?';

PDO 也支持使用命名占位符。命名占位符以冒号开头,后跟标识符,如下所示:

$sql = 'SELECT image_id, filename, caption FROM images WHERE caption LIKE :search';

Note

占位符不用引号括起来,即使它们代表的值是字符串。这使得构建 SQL 查询变得容易得多,因为不需要担心如何获得单引号和双引号的正确组合。

占位符只能用于列值。它们不能用于 SQL 查询的其他部分,如列名或运算符。这是因为在执行 SQL 时,包含非数字字符的值会被自动转义并括在引号中。列名和运算符不能用引号括起来。

准备好的语句比直接提交查询涉及的代码稍微多一点,但是占位符使 SQL 更容易读写,并且这个过程更安全。

MySQLi 和 PDO 的语法是不同的,所以下面几节将分别讨论它们。

在 MySQLi 预准备语句中嵌入变量

使用 MySQLi 预准备语句包括几个阶段。

初始化语句

要初始化准备好的语句,在数据库连接上调用stmt_init()方法,并将其存储在一个变量中,如下所示:

$stmt = $conn->stmt_init();

准备声明

然后将 SQL 查询传递给语句的prepare()方法。这将检查您是否在错误的位置使用了问号占位符,以及当所有内容放在一起时,查询是否是有效的 SQL。

如果有任何错误,prepare()方法将返回false,因此通常将接下来的步骤包含在条件语句中,以确保它们仅在一切正常的情况下运行。

可以通过语句的error属性访问错误消息。

将值绑定到占位符

用变量中保存的实际值替换问号在技术上被称为绑定参数。正是这一步骤保护了您的数据库免受 SQL 注入的攻击。

按照您希望将变量插入 SQL 查询的顺序,将变量传递给语句的bind_param()方法,同时传递指定每个变量的数据类型的第一个参数,同样按照变量的顺序。数据类型必须由以下四个字符之一指定:

  • b:二进制(如图像、Word 文档或 PDF 文件)

  • d : Double(浮点数)

  • i:整数(整数)

  • s:字符串(文本)

传递给bind_param()的变量数量必须与问号占位符的数量完全相同。例如,要将单个值作为字符串传递,请使用:

$stmt->bind_param('s', $_GET['words']);

为了传递两个值,SELECT查询需要两个问号作为占位符,两个变量都需要用bind_param()绑定,如下所示:

$sql = 'SELECT * FROM products WHERE price < ? AND type = ?';
$stmt = $conn->stmt_init();
$stmt->prepare($sql);
$stmt->bind_param('ds', $_GET['price'], $_GET['type']);

bind_param()'ds'的第一个参数将$_GET['price']指定为浮点数,将$_GET['type']指定为字符串。

执行语句

一旦准备好语句并且值已经绑定到占位符,调用语句的execute()方法。然后可以从语句对象中获取一个SELECT查询的结果。对于其他类型的查询,这是过程的结尾。

绑定结果(可选)

可选地,您可以使用bind_result()方法将SELECT查询的结果绑定到变量。这避免了提取每一行然后以$row['column_name']的形式访问结果的需要。

为了绑定结果,您必须在SELECT查询中专门命名每一列。以同样的顺序列出您想要使用的变量,并将它们作为参数传递给bind_result()。例如,假设您的 SQL 如下所示:

$sql = 'SELECT image_id, filename, caption FROM images WHERE caption LIKE ?';

要绑定查询结果,请使用以下代码:

$stmt->bind_result($image_id, $filename, $caption);

这允许您直接访问结果,如$image_id$filename$caption

存储结果(可选)

当您为SELECT查询使用准备好的语句时,结果是无缓冲的。这意味着它们会保留在数据库服务器上,直到您提取它们。这具有需要较少内存的优点,尤其是当结果集包含大量行时。但是,无缓冲结果会带来以下限制:

  • 一旦获取了结果,它们就不再存储在内存中。因此,不能多次使用同一个结果集。

  • 在获取或清除所有结果之前,不能在同一数据库连接上运行另一个查询。

  • 您不能使用num_rows属性来确定结果集中有多少行。

  • 不能使用data_seek()移动到结果集中的特定行。

为了避免这些限制,您可以选择使用语句的store_result()方法存储结果集。但是,如果您只是想立即显示结果,而不是在以后重用,就没有必要先存储它。

Note

要清除未缓冲的结果,调用语句的free_result()方法。

获取结果

要循环通过用准备好的语句执行的SELECT查询的结果,请使用fetch()方法。如果您已经将结果绑定到变量,请这样做:

while ($stmt->fetch()) {
 // display the bound variables for each row
}

如果您没有将结果绑定到变量,那么使用$row = $stmt->fetch()并以$row['column_name']的形式访问每个变量。

关闭语句

当您完成一个准备好的语句时,close()方法释放所使用的内存。

PHP 解决方案 13-7:在搜索中使用 MySQLi 预准备语句

这个 PHP 解决方案展示了如何使用 MySQLi 预准备语句和一个SELECT查询;它还演示了将结果绑定到命名变量。

  1. ch13文件夹中复制mysqli_prepared_01.php并在mysqli文件夹中保存为mysqli_prepared.php。该文件包含一个搜索表单和一个用于显示结果的表格。

  2. DOCTYPE声明上方的 PHP 代码块中,创建一个包含connection.phputility_funcs.php的条件语句,并在提交搜索表单时创建一个只读连接。代码如下所示:

  3. 接下来,在条件语句中添加 SQL 查询。该查询需要命名您想要从images表中检索的三列。使用问号作为搜索词的占位符,如下所示:

    $sql = 'SELECT image_id, filename, caption FROM images
               WHERE caption LIKE ?';
    
    
  4. 在将用户提交的搜索词传递给bind_param()方法之前,您需要添加通配符并将其赋给一个新变量,如下所示:

if (isset($_GET['go'])) {
    require_once '../includes/connection.php';
    require_once '../includes/utility_funcs.php';
    $conn = dbConnect('read');
}

  1. 现在可以创建预准备语句了。DOCTYPE声明上方的 PHP 块中完成的代码如下所示:
$searchterm = '%'. $_GET['search'] .'%';

if (isset($_GET['go'])) {
    require_once '../includes/connection.inc.php';
    $conn = dbConnect('read');
    $sql = 'SELECT image_id, filename, caption FROM images
               WHERE caption LIKE ?';
    $searchterm = '%'. $_GET['search'] .'%';
    $stmt = $conn->stmt_init();
    if ($stmt->prepare($sql)) {
        $stmt->bind_param('s', $searchterm);
        $stmt->execute();
        $stmt->bind_result($image_id, $filename, $caption);
        $stmt->store_result();
        $numRows = $stmt->num_rows;
    } else {
        $error = $stmt->error;
    }
}

这将初始化准备好的语句,并将其分配给$stmt。然后,SQL 查询被传递给prepare()方法,该方法检查查询语法的有效性。如果语法有问题,else块将错误消息分配给$error。如果语法没有错误,则执行条件语句中的其余脚本。

条件语句中的第一行将$searchterm绑定到SELECT查询,替换问号占位符。第一个参数告诉准备好的语句将其视为字符串。

执行准备好的语句后,下一行将把SELECT查询的结果绑定到$image_id$filename$caption。这些需要与查询中的顺序相同。我根据变量所代表的列来命名变量,但是您可以使用任何想要的变量。

然后存储结果。注意,您只需通过调用语句对象的store_result()方法来存储结果。与使用query()不同,你不用将store_result()的返回值赋给一个变量。如果您这样做了,它只是truefalse,这取决于结果是否被成功存储。

最后,查询检索的行数从 statement 对象的num_rows属性中获得,并存储在$numRows中。

  1. 在开始的<body>标记后添加一个条件语句,以便在出现问题时显示错误消息:

    <?php
    if (isset($error)) {
        echo "<p>$error</p>";
    }
    ?>
    
    
  2. 在搜索表单后添加以下代码以显示结果:

<?php if (isset($numRows)) { ?>
     <p>Number of results for <b><?= safe($_GET['search']) ?></b>:
         <?= $numRows ?></p>
    <?php if ($numRows) { ?>
        <table>
            <tr>
               <th>image_id</th>
               <th>filename</th>
               <th>caption</th>
           </tr>
           <?php while ($stmt->fetch()) { ?>
               <tr>
                   <td><?= $image_id ?></td>
                   <td><?= safe($filename) ?></td>
                   <td><?= safe($caption) ?></td>
               </tr>
           <?php } ?>
        </table>
 <?php }
} ?>

第一个条件语句包裹在段落和表格周围,防止它们在$numRows不存在的情况下显示,这种情况发生在页面第一次加载时。如果表单已经提交,$numRows将被设置,所以搜索词被重新显示,$numRows的值报告匹配的数量。

如果查询没有返回结果,$numRows为 0,被视为false,因此不显示该表。如果$numRows包含除 0 以外的任何内容,则显示表格。显示结果的while循环对准备好的语句调用fetch()方法。不需要将当前记录存储为$row,因为来自每一列的值已经被绑定到$image_id$filename$caption

  1. 保存页面并将其加载到浏览器中。在搜索字段中输入一些文本,然后点击Search。结果的数量与包含搜索词的标题一起显示,如下面的屏幕截图所示:

img/332054_5_En_13_Figo_HTML.jpg

您可以将您的代码与ch13文件夹中的mysqli_prepared_02.php进行比较。

在 PDO 预准备语句中嵌入变量

PDO 准备的声明提供了匿名和命名占位符的选择。

使用匿名占位符

匿名占位符使用问号的方式与 MySQLi 完全相同:

$sql = 'SELECT image_id, filename, caption FROM images WHERE caption LIKE ?';

使用命名占位符

命名占位符以冒号开头,如下所示:

$sql = 'SELECT image_id, filename, caption FROM images WHERE caption LIKE :search';

使用命名占位符使代码更容易理解,特别是如果您选择的名称基于包含要嵌入 SQL 的值的变量。

准备声明

准备和初始化语句只需一步(不像 MySQLi 需要两步)。您将带有占位符的 SQL 直接传递给连接对象的prepare()方法,该方法返回准备好的语句,如下所示:

$stmt = $conn->prepare($sql);

将值绑定到占位符

将值绑定到占位符有几种不同的方法。当使用匿名占位符时,最简单的方法是按照占位符的顺序创建一个值数组,然后将该数组传递给语句的execute()方法。即使只有一个占位符,也必须使用数组。例如,要将$searchterm绑定到单个匿名占位符,必须用一对方括号将它括起来,如下所示:

$stmt->execute([$searchterm]);

您也可以用类似的方式将值绑定到命名占位符,但是传递给execute()方法的参数必须是一个关联数组,使用命名占位符作为每个值的键。因此,下面的代码将$searchterm绑定到名为占位符的:search:

$stmt->execute([':search' => $searchterm]);

或者,在调用execute()方法之前,可以使用语句的bindParam()bindValue()方法绑定值。当与匿名占位符一起使用时,两种方法的第一个参数都是一个数字,从 1 开始计数,表示占位符在 SQL 中的位置。对于命名占位符,第一个参数是字符串形式的命名占位符。第二个参数是要在查询中插入的值。

然而,这两种方法之间有一个微妙的区别:

  • 对于bindParam(),第二个参数必须是一个变量。它不能是字符串、数字或任何其他类型的表达式。

  • 对于bindValue(),第二个参数应该是字符串、数字或表达式。但它也可以是一个变量。

因为bindValue()接受任何类型的值,bindParam()可能看起来是多余的。区别在于传递给bindValue()的参数值必须是已知的,因为它绑定了实际值,而bindParam()只绑定了变量。因此,该值可以在以后赋给变量。

为了说明区别,让我们使用“使用命名占位符”中的SELECT查询:search占位符跟在LIKE关键字之后,因此该值需要与通配符结合使用。尝试执行以下操作会产生错误:

// This will NOT work
$stmt->bindParam(':search', '%'. $_GET['search'] .'%');

不能用bindParam()将通配符连接到变量。在变量作为参数传递之前,需要添加通配符,如下所示:

$searchterm = '%'. $_GET['search'] .'%';
$stmt->bindParam(':search', $searchterm);

或者,您可以构建表达式作为bindValue()的参数:

// This WILL work
$stmt->bindValue(':search', '%'. $_GET['search'] .'%');

bindParam()bindValue()方法接受可选的第三个参数:指定数据类型的常数。主要常数如下:

  • PDO::PARAM_INT:整数(整数)

  • PDO::PARAM_LOB:二进制(如图像、Word 文档或 PDF 文件)

  • PDO::PARAM_STR:字符串(文本)

  • PDO::PARAM_BOOL:布尔型(真或假)

  • PDO::PARAM_NULL : 零

如果您想将数据库列的值设置为null,那么PDO::PARAM_NULL非常有用。例如,如果主键是自动递增的,那么在插入新记录时,您需要传递null作为值。这就是如何用bindValue()将名为:id的命名参数设置为null:

$stmt->bindValue(':id', NULL, PDO::PARAM_NULL);

Note

浮点数没有 PDO 常数。

执行语句

如果使用bindParam()bindValue()将值绑定到占位符,只需调用不带参数的execute()方法:

$stmt->execute();

否则,按照上一节所述传递一个值数组。在这两种情况下,查询的结果都存储在$stmt中。

可以用与 PDO 连接相同的方式访问错误消息。然而,不是在连接对象上调用errorInfo()方法,而是在 PDO 语句上使用它,就像这样:

$error = $stmt->errorInfo()[2];

如果没有错误,$error将会是null。否则,它将包含描述问题的字符串。

绑定结果(可选)

为了将SELECT查询的结果绑定到变量,需要使用bindColumn()方法单独绑定每一列,该方法有两个参数。第一个参数可以是列名,也可以是从 1 开始的列号。这个数字来自于它在SELECT查询中的位置,而不是它在数据库表中出现的顺序。因此,在我们一直使用的 SQL 示例中,要将来自filename列的结果绑定到$filename,以下任一方法都是可接受的:

$stmt->bindColumn('filename', $filename);
$stmt->bindColumn(2, $filename);

因为每一列都是单独绑定的,所以不需要全部绑定。然而,这样做更方便,因为它避免了将fetch()方法的结果赋给数组的需要。

获取结果

要获取一个SELECT查询的结果,调用语句的fetch()方法。如果您已经使用bindColumn()将输出绑定到变量,您可以直接使用变量。否则,它返回当前行的数组,该数组由列名和零索引列号索引。

Note

您可以通过传递一个常量作为参数来控制 PDO fetch()方法的输出类型。www.php.net/manual/en/pdostatement.fetch.php见。

PHP 解决方案 13-8:在搜索中使用 PDO 准备好的语句

这个 PHP 解决方案展示了如何使用 PDO 语句将用户提交的值从搜索表单嵌入到一个SELECT查询中。它使用与 PHP 解决方案 13-7 中 MySQLi 版本相同的搜索形式。

  1. ch13文件夹中复制pdo_prepared_01.php并在pdo文件夹中保存为pdo_prepared.php

  2. 在 PHP 块中的DOCTYPE声明上方添加以下代码:

if (isset($_GET['go'])) {
    require_once '../includes/connection.php';
    require_once '../includes/utility_funcs.php';
    $conn = dbConnect('read', 'pdo');
    $sql = 'SELECT image_id, filename, caption FROM images
               WHERE caption LIKE :search';
    $stmt = $conn->prepare($sql);
    $stmt->bindValue(':search', '%' . $_GET['search'] . '%');
    $stmt->execute();
    $error = $stmt->errorInfo()[2];
    if (!$error) {
        $stmt->bindColumn('image_id', $image_id);
        $stmt->bindColumn('filename', $filename);
        $stmt->bindColumn(3, $caption);
        $numRows = $stmt->rowCount();
    }
}

提交表单时,这包括连接文件并创建一个 PDO 只读连接。准备好的语句使用:search作为命名参数来代替用户提交的值。

在将搜索词绑定到准备好的语句的同时,%通配符与搜索词连接在一起。所以用bindValue()代替bindParam()

在执行该语句后,调用该语句的errorInfo()方法,查看是否生成了错误消息并存储在$errorInfo[2]中。

如果没有问题,使用bindColumn()方法将结果绑定到$image_id$filename$caption。前两个使用列名,但是caption列是通过它在SELECT查询中的位置(从 1 开始计数)来引用的。

  1. 显示结果的代码与 PHP 解决方案 13-7 中的步骤 6 和 7 相同。您可以在ch13文件夹的pdo_prepared_02.php中查看完成的代码。

PHP 解决方案 13-9:调试 PDO 准备好的语句

有时,数据库查询不会产生您期望的结果。当这种情况发生时,查看您的脚本发送到数据库服务器的确切内容是非常有用的。对于 MySQLi,没有一种简单的方法来检查由预处理语句插入到 SQL 查询中的值。但是对于 PDO 来说,这再简单不过了。这个功能是在 PHP 7.2 中引入的。

  1. 继续使用之前 PHP 解决方案中的pdo_prepared.php。或者,将ch13文件夹中的pdo_prepared_02.php复制到pdo文件夹中,并重命名为pdo_prepared.php

  2. 修改结束标签</table>后的代码,如下所示:

</table>
 <?php }
 echo '<pre>';
 $stmt->debugDumpParams();
 echo '</pre>';
 }
?>

这插入了一对<pre>标签,使得对PDOStatement对象的debugDumpParams()方法的调用的输出更具可读性。

  1. 保存文件,将其加载到浏览器中,并进行搜索。除了搜索结果之外,您还应该看到类似于以下屏幕截图的输出:

img/332054_5_En_13_Figp_HTML.jpg

SQL 查询显示两次。第一次显示了 PHP 代码中出现的查询——在本例中,包括命名参数:search。第二次,它显示发送到数据库服务器的实际值。

在这种情况下,搜索“temp”会返回两个包含“temple”的标题。如果这是你所期望的,那没关系。但是假设你只想要一个精确的匹配。看到%通配符可以解释错误的结果,使调试没有产生预期结果的准备好的语句的问题变得更加容易。

您可以将您的代码与ch13文件夹中的pdo_prepared_03.php进行比较。

Caution

在调用debugDumpParams()之前调用execute()方法很重要。

PHP 解决方案 13-10:通过用户输入改变列选项

这个 PHP 解决方案展示了如何通过用户输入在一个SELECT查询中更改 SQL 关键字的名称。SQL 关键字不能用引号括起来,所以使用预处理语句是行不通的。相反,您需要确保用户输入与一组预期值相匹配。如果找不到匹配,则使用默认值。MySQLi 和 PDO 的技术是一样的。

  1. ch13文件夹中复制mysqli_order_01.phppdo_order_01.php并保存在mysqlipdo文件夹中。两个版本都从images表中选择所有记录,并在表格中显示结果。这些页面还包含一个表单,该表单允许用户选择一个列的名称,以升序或降序对结果进行排序。在初始状态下,表单是不活动的。页面显示按image_id升序排序的详细信息,如下所示:

img/332054_5_En_13_Figq_HTML.jpg

  1. 将 PHP 块中的代码修改到DOCTYPE声明之上,如下所示(下面的清单显示的是 PDO 版本,但是粗体显示的变化对于 MySQLi 是一样的):
require_once '../includes/connection.php';
require_once '../includes/utility_funcs.php';
// connect to database
$conn = dbConnect('read', 'pdo');
// set default values
$col = 'image_id';
$dir = 'ASC';
// create arrays of permitted values
$columns = ['image_id', 'filename', 'caption'];
$direction = ['ASC', 'DESC'];
// if the form has been submitted, use only expected values
if (isset($_GET['column']) && in_array($_GET['column'], $columns)) {
    $col = $_GET['column'];
}

if (isset($_GET['direction']) && in_array($_GET['direction'], $direction)) {
    $dir = $_GET['direction'];
}
// prepare the SQL query using sanitized variables
$sql = "SELECT * FROM images
            ORDER BY $col $dir";
// submit the query and capture the result
$result = $conn->query($sql);
$error = $conn->errorInfo()[2];

新代码定义了两个变量,$col$dir,它们被直接嵌入到SELECT查询中。因为它们被赋予了默认值,所以当页面第一次加载时,查询显示按image_id列升序排序的结果。

两个数组$columns$direction定义了允许的值:列名和ASCDESC关键字。这些数组由检查columndirection$_GET数组的条件语句使用。只有当提交的值分别与$columns$direction数组中的值匹配时,它们才会被重新分配给$col$dir。这可以防止任何向 SQL 查询中注入非法值的企图。

  1. 编辑下拉菜单中的<option>标签,使其显示为$col$dir选择的值,如下所示:

  2. 保存页面并在浏览器中测试。您可以通过选择下拉菜单中的值并点击Change来更改显示的排序顺序。但是,如果您试图通过查询字符串注入非法值,页面会使用默认值$col$dir来显示按image_id升序排序的结果。

<select name="column" id="column">
    <option <?php if ($col == 'image_id') echo 'selected'; ?>
        >image_id</option>
    <option <?php if ($col == 'filename') echo 'selected'; ?>
        >filename</option>
    <option <?php if ($col == 'caption') echo 'selected'; ?>
        >caption</option>
</select>
<select name="direction" id="direction">
    <option value="ASC" <?php if ($dir == 'ASC') echo 'selected'; ?>
        >Ascending</option>
    <option value="DESC" <?php if ($dir == 'DESC') echo 'selected'; ?>
        >Descending</option>
</select>

你可以对照ch13文件夹中的mysqli_order_02.phppdo_order_02.php来检查你的代码。

第三章回顾

PHP 8 提供了两种与 MySQL 通信的方法:

  • MySQL 改进的(MySQLi)扩展:推荐所有新的 MySQL 项目使用。它比不再受支持的原始 MySQL 扩展更有效。它增加了预准备语句的安全性,并且与 MariaDB 完全兼容。

  • PHP 数据对象(PDO)抽象层,它是数据库中立的:这是我与数据库通信的首选方法。它不仅是数据库中立的;它的优点是对准备好的语句使用命名参数,使代码更容易阅读和理解。此外,调试准备好的语句非常容易。虽然代码是数据库中立的,但 PDO 要求为您选择的数据库安装正确的驱动程序。MySQL 的驱动程序与 MariaDB 完全兼容,并且通常是安装的。其他驱动不太常见。但是,如果安装了正确的驱动程序,从一个数据库切换到另一个数据库时,只需更改连接字符串中的数据源名称(DSN)。

尽管 PHP 与数据库通信并存储结果,但查询需要用 SQL 编写,SQL 是用于查询关系数据库的标准语言。本章展示了如何使用SELECT语句检索存储在数据库表中的信息,使用WHERE子句细化搜索,以及使用ORDER BY改变排序顺序。您还了解了几种防止 SQL 注入查询的技术,包括预处理语句,它使用占位符而不是直接在查询中嵌入变量。

在下一章中,你将通过创建一个在线图片库将这些知识付诸实践。

十四、创建动态图片库

前一章主要集中在将images表的内容提取为文本。本章基于这些技术来开发如图 14-1 所示的迷你图片库。

img/332054_5_En_14_Fig1_HTML.jpg

图 14-1

这个迷你图片库是通过从数据库中提取信息来驱动的

该图库还展示了一些您希望整合到文本驱动页面中的酷功能。例如,左侧的缩略图网格每行显示两个图像。只需更改两个数字,您就可以根据自己的喜好,让网格有多宽多行。单击其中一个缩略图会替换主图像和标题。这是重新加载的同一个页面,但完全相同的技术用于创建在线目录,将您带到另一个页面,其中包含有关产品的更多详细信息。缩略图网格底部的下一个链接显示了下一组图片,使用的技术与浏览一长串搜索结果完全相同。这个画廊不仅仅是一两张漂亮的脸…本章涵盖

  • 理解为什么在数据库中存储图像是一个坏主意,以及你应该怎么做

  • 规划动态画廊的布局

  • 在表格行中显示固定数量的结果

  • 限制一次检索的记录数量

  • 翻阅一长串结果

为什么不将图像存储在数据库中?

images表格包含文件名和标题,但不包含图像本身。尽管您可以在数据库中存储二进制对象,比如图像,但我不打算这么做,原因很简单,这通常会带来更多麻烦,不值得这么做。主要问题如下:

  • 如果不单独存储文本信息,就不能对图像进行索引或搜索。

  • 图像通常很大,增大了表格的大小。如果数据库中的存储量有限制,就有耗尽空间的风险。

  • 如果频繁删除图像,表碎片会影响性能。

  • 从数据库中检索图像需要将图像传递给一个单独的脚本,这会降低网页的显示速度。

将图像存储在网站上的普通文件夹中并使用数据库来获取图像信息会更有效。您只需要两条信息——文件名和标题,标题也可以用作alt文本。一些开发人员将图像的完整路径存储在数据库中,但是我认为只存储文件名会给你带来更大的灵活性。到images文件夹的路径将被嵌入到 HTML 中。没有必要存储图像的高度和宽度。正如您在第 5 和 10 章中看到的,您可以使用 PHP 的getimagesize()函数动态生成这些信息。

规划画廊

我发现设计数据库驱动网站的最好方法是从静态页面开始,用占位符文本和图像填充它。然后我创建我的 CSS 样式规则来得到我想要的页面外观,最后我用 PHP 代码替换每个占位符元素。每次我更换东西时,我都会在浏览器中检查页面,以确保所有东西都还在一起。

图 14-2 显示了我制作的图库的静态模型,并指出了需要转换成动态代码的元素。这些图像与第五章中用于随机图像发生器的图像相同,并且大小不同。我尝试通过缩放图像来创建缩略图,但认为结果看起来太不整洁,所以我将缩略图设为标准大小(80 × 54 像素)。此外,为了方便起见,我给每个缩略图取了与大图相同的名字,并将它们存储在images文件夹中一个名为thumbs的独立子文件夹中。

img/332054_5_En_14_Fig2_HTML.jpg

图 14-2

找出将静态画廊转变为动态画廊需要做的事情

在前一章中,显示images表的内容很容易。您创建了单个表格行,每个字段的内容位于单独的表格单元格中。通过遍历结果集,每条记录都显示在自己的一行上,模拟了数据库表的列结构。这一次,缩略图网格的两列结构不再与数据库结构匹配。在创建下一行之前,您需要计算一行中插入了多少个缩略图。

一旦我确定了需要做什么,我就去掉了缩略图 2-6 和导航链接的代码。下面的清单显示了gallery.php<main>元素中剩下的内容,需要转换成 PHP 代码的元素以粗体突出显示(您可以在ch14文件夹的gallery_01.php中找到代码):

<main>
    <h2>Images of Japan</h2>
    <p id="picCount">Displaying 1 to 6 of 8</p>
    <div id="gallery">
        <table id="thumbs">
            <tr>
                <!-- This row needs to be repeated -->
                <td><a href="gallery.php"><img src="img/thumbs/basin.jpg" alt=""
                    width="80" height="54"></a></td>
            </tr>
             <!-- Navigation link needs to go here -->
        </table>
        <figure id="main_image">
            <img src="img/basin.jpg" alt="" width="350" height="237">
            <figcaption>Water basin at Ryoanji temple, Kyoto</figcaption>
        </figure>
  </div>
</main>

将图库元素转换为 PHP

在显示图库的内容之前,您需要连接到phpsols数据库并检索存储在images表中的所有记录。这样做的过程与上一章相同,使用以下简单的 SQL 查询:

SELECT filename, caption FROM images

然后,您可以使用第一条记录来显示第一幅图像及其关联的标题和缩略图。不需要image_id

PHP 解决方案 14-1:显示第一张图片

如果您在第五章建立了日本之旅网站,您可以直接使用原始的gallery.php进行工作。或者,从ch14文件夹中复制gallery_01.php,并将其作为gallery.php保存在php8sols站点根目录下。您还需要将title.phpmenu.phpfooter.php复制到php8sols站点的includes文件夹中。如果编辑程序询问您是否要更新文件中的链接,请选择不更新选项。

img/332054_5_En_14_Fig3_HTML.jpg

图 14-3

准备转换的静态图库的精简版本

  1. gallery.php加载到浏览器中,确保其正确显示。页面的主要部分应该如图 14-3 所示,有一个缩略图和同一图像的放大版本。

  2. 图库依赖于到数据库的连接,因此包含connection.php,创建到phpsols数据库的只读连接,并定义 SQL 查询。在gallery.php中的DOCTYPE声明上方的结束 PHP 标签之前添加以下代码(新代码以粗体突出显示):

include './includes/title.php';
require_once './includes/connection.php';
require_once './includes/utility_funcs.php';
$conn = dbConnect('read');
$sql = 'SELECT filename, caption FROM images';

如果使用 PDO,将'pdo'作为第二个参数添加到dbConnect()中。

  1. 提交查询和从结果中提取第一条记录的代码取决于您使用的连接方法。对于 MySQLi,使用这个:
// submit the query
$result = $conn->query($sql);
if (!$result) {
    $error = $conn->error;
} else {
    // extract the first record as an array
    $row = $result->fetch_assoc();
}

对于 PDO,使用这个:

// submit the query
$result = $conn->query($sql);
// get any error messages
$error = $conn->errorInfo()[2];
if (!$error) {
    // extract the first record as an array
    $row = $result->fetch();
}

要在页面加载时显示第一个图像,您需要在创建最终显示缩略图网格的循环之前检索第一个结果。MySQLi 和 PDO 的代码提交查询,提取第一条记录,并将其存储在$row中。

  1. 您现在已经将第一个记录图像的细节存储为$row['filename']$row['caption']。除了文件名和标题,您还需要大版本的尺寸,这样您就可以在页面的主体中显示它。在获取第一个结果的代码之后,立即在else块中添加以下代码:
// get the name and caption for the main image
$mainImage = safe($row['filename']);
$caption = safe($row['caption']);
// get the dimensions of the main image
$imageSize = getimagesize('img/'.$mainImage)[3];

使用上一章定义的safe()函数对数据库中的文本值进行清理。

正如第十章所解释的,getimagesize()返回一个数组,数组的第四个元素包含一个字符串,该字符串具有准备插入到<img>标签中的图像的宽度和高度。我们只对第四个元素感兴趣,所以我们可以使用在第七章中介绍的数组解引用技术。在getimagesize()的右括号后添加[3]只会返回数组的第四个元素,该元素被赋值给$imageSize

  1. 现在,您可以使用这些信息来动态显示缩略图、主图像和标题。主图像和缩略图具有相同的名称,但是您最终希望通过循环整个结果集来显示所有缩略图。因此,表格单元格中的动态代码需要引用当前记录——换句话说,引用$row['filename']$row['caption'],而不是$mainImage$caption。它们还需要通过传递给safe()函数来净化。稍后您会看到为什么我将第一条记录中的值分配给不同的变量。如下修改表中的代码:

                   <td><a href="gallery.php">
    <img src="img/thumbs/<?= safe($row['filename']); ?>"
                           alt="<?= safe($row['caption']); ?>" width="80" height="54"></a></td>
    
    
  2. 如果查询有问题,您需要检查$error是否等于true,并阻止图库显示。在日本的<h2>图片标题后添加一个包含以下条件语句的 PHP 块:

    <?php if (isset($error)) {
        echo "<p>$error</p>";
        } else {
    ?>
    
    

    Tip

    尽管 PDO 版本的脚本在第 3 步中给$error赋值,但是您可以在这里使用isset($error),因为如果查询成功执行,这个值就是null。通过nullisset()返回false

  3. 在结束的</main>标签之前插入一个新行(在第 55 行周围),并添加一个带有else块的结束花括号的 PHP 块:

  4. 保存gallery.php并在浏览器中查看。它看起来应该和图 14-3 一样。唯一的区别是缩略图和它的alt文本是动态生成的。您可以通过查看源代码来验证这一点。最初的静态版本有一个空的alt属性,但是如下图所示,它现在包含了第一条记录的标题:

<?php } ?>

img/332054_5_En_14_Figa_HTML.jpg

如果出现问题,确保图像的src属性中静态和动态生成的文本之间没有间隙。还要检查您是否使用了与您创建的数据库连接类型相对应的正确代码。你可以对照ch14文件夹中的gallery_mysqli_02.phpgallery_pdo_02.php来检查你的代码。

  1. 一旦确认从数据库中获取了细节,就可以转换主图像的代码了。修改如下(新代码以粗体显示):
<figure id="main_image">
    <img src="img/<?= $mainImage ?>" alt="<?= $caption ?>"
        <?= $imageSize ?>></p>
    <figcaption><?= $caption ?></figcaption>
</figure>

$mainImage$caption不需要传递给safe()函数,因为它们已经在步骤 4 中被清理了。

$imageSize为主图像插入一个包含正确的widthheight属性的字符串。

  1. 再次测试页面。它看起来应该和图 14-3 中的一样,但是图像和标题是从数据库中动态绘制的,getimagesize()正在计算主图像的正确尺寸。你可以对照ch14文件夹中的gallery_mysqli_03.phpgallery_pdo_03.php来检查你的代码。

构建动态元素

转换静态页面后的第一个任务是显示所有缩略图,然后构建动态链接,使您能够显示任何被单击的缩略图的大版本。显示所有的缩略图很容易——只需遍历它们(我们稍后将研究如何以两行显示它们)。激活每个缩略图的链接需要更多的思考。您需要一种方式来告诉页面显示哪个大图像。

通过查询字符串传递信息

在上一节中,您使用了$mainImage来标识大图像,因此您需要一种每当单击缩略图时更改其值的方法。解决方案是将图像的文件名添加到链接中 URL 末尾的查询字符串中,如下所示:

<a href="gallery.php?image=filename">

然后可以检查$_GET数组是否包含一个名为image的元素。如果是,改变$mainImage的值。如果没有,将结果集中第一条记录的文件名保留为$mainImage

PHP 解决方案 14-2:激活缩略图

继续使用与上一节相同的文件。或者,将gallery_mysqli_03.phpgallery_pdo_03.php复制到php8sols站点根目录,并保存为gallery.php

  1. 找到缩略图周围链接的开始<a>标签。看起来是这样的:
<a href="gallery.php">

改成这样:

<a href="gallery.php?image=<?= safe($row['filename']) ?>">

这会在href属性的末尾添加一个查询字符串,将当前文件名赋给一个名为image的变量。重要的是?image=周围没有空格。

  1. 保存页面并将其加载到浏览器中。将鼠标指针悬停在缩略图上,检查状态栏中显示的 URL。应该是这样的:
http://localhost/php8sols/gallery.php?image=basin.jpg

如果状态栏中没有显示任何内容,请单击缩略图。页面不应该改变,但是地址栏中的 URL 现在应该包含查询字符串。检查 URL 或查询字符串中是否有空白。

  1. 要显示所有缩略图,您需要将表格单元格环绕成一个循环。在关于重复该行的 HTML 注释后插入一个新行,并创建一个像这样的do... while循环的前半部分(参见第四章了解不同类型循环的详细信息):

    <!-- This row needs to be repeated -->
    <?php do { ?>
    
    
  2. 您已经有了结果集中第一条记录的详细信息,所以获取后续记录的代码需要放在结束的</td>标记之后。在结束标签</td></tr>之间留出一些空间,并插入以下代码。每种数据库连接方法都略有不同。

对于 MySQLi,使用这个:

</td>
    <?php } while ($row = $result->fetch_assoc()); ?>
</tr>

对于 PDO,使用这个:

</td>
    <?php } while ($row = $result->fetch()); ?>
</tr>

这将获取结果集中的下一行,并将循环发送回顶部。因为$row['filename']$row['caption']具有不同的值,所以下一个缩略图及其关联的alt文本被插入到新的表格单元格中。查询字符串也用新文件名更新。

  1. 保存页面并在浏览器中测试。现在,您应该可以在画廊顶部的一行中看到所有八个缩略图,如下面的屏幕截图所示:

img/332054_5_En_14_Figb_HTML.jpg

将鼠标指针悬停在每个缩略图上,您应该会看到显示文件名的查询字符串。您可以对照gallery_mysqli_04.phpgallery_pdo_04.php检查您的代码。

  1. 单击缩略图仍然没有任何作用,因此您需要创建逻辑来更改主图像及其相关标题。在DOCTYPE声明上方的块中找到这段代码:
// get the name and caption for the main image
$mainImage = safe($row['filename']);
$caption = safe($row['caption']);

突出显示定义$caption的行,并将其剪切到剪贴板。在条件语句中换行,如下所示:

// get the name for the main image
if (isset($_GET['image'])) {
    $mainImage = safe($_GET['image']);
} else {
    $mainImage = safe($row['filename']);
}

$_GET数组包含通过查询字符串传递的值,因此如果已经设置(定义)了$_GET['image'],它将从查询字符串中获取文件名,并将其存储为$mainImage。如果$_GET['image']不存在,则像以前一样,从结果集中的第一条记录中获取值。

  1. 最后,您需要获取主图像的标题。它不再每次都是相同的,所以您需要将它移动到在thumbs表中显示缩略图的循环中。它就在循环的左花括号后面(第 48 行周围)。将光标放在大括号后,插入几行,然后粘贴在上一步中剪切的标题定义。您希望标题与主图像匹配,所以如果当前记录的文件名与$mainImage相同,那就是您要找的那个。将刚刚粘贴的代码包装在条件语句中,如下所示:

    <?php
    do {
        // set caption if thumbnail is same as main image
        if ($row['filename'] == $mainImage) {
            $caption = safe($row['caption']); // this is the line you pasted
        }
    ?>
    
    
  2. 保存页面并在浏览器中重新加载。这一次,当您单击缩略图时,主图像和标题将会改变。不要担心一些图片和标题被页脚隐藏。当缩略图移到主图像的左边时,它会自动修正。

    Note

    通过这样的查询字符串传递信息是处理 PHP 和数据库结果的一个重要方面。虽然表单信息通常通过$_POST数组传递,但是$_GET数组经常用于传递您想要显示、更新或删除的记录的详细信息。它也常用于搜索,因为查询字符串可以很容易地加入书签。

  3. 在这种情况下没有 SQL 注入的危险。但是如果有人更改了通过查询字符串传递的文件名的值,如果图像找不到并且display_errors打开,您将得到难看的错误消息。在调用getimagesize()之前,我们先了解一下镜像是否存在。用这样的条件语句包装它:

  4. 尝试将查询字符串中的值image更改为除现有文件值之外的任何值。当您加载页面时,您应该会看到Image not found

if (file_exists('img/'.$mainImage)) {
    // get the dimensions of the main image
    $imageSize = getimagesize('img/'.$mainImage)[3];
} else {
    $error = 'Image not found.';
}

如有必要,对照gallery_mysqli_05.phpgallery_pdo_05.php检查您的代码。

创建多列表格

只有八个图像,画廊顶部的单排缩略图看起来还不错。但是,如果能够通过使用循环动态地构建表格,这是很有用的,该循环在移动到下一行之前在一行中插入特定数量的表格单元格。这是通过记录插入了多少个单元格来实现的。当达到该行的限制时,代码需要为当前行插入一个结束标记,如果还有缩略图,还需要为下一行插入一个开始标记。使它易于实现的是模数运算符%,它返回除法的余数。

这就是它的工作原理。假设您希望每行有两个单元格。插入第一个单元后,计数器被设置为1。如果用模数运算符(1 % 2)将1除以2,结果是1。当插入下一个单元格时,计数器增加到22 % 2的结果是0。下一个单元格产生这个计算:3 % 2,结果是1。但是第四个细胞产生4 % 2,又是0。因此,每次计算结果为0时,您知道——或者更确切地说,PHP 知道——您在一行的末尾。

那么,您如何知道是否还有剩余的行呢?通过将插入结束和开始<tr>标签的代码放在循环的顶部,必须始终至少有一个图像。然而,第一次循环运行时,剩余的也是0,所以问题是您需要防止标签被插入,直到至少一个图像被显示。唷…让我们试试吧。

PHP 解决方案 14-3:水平和垂直循环

这个 PHP 解决方案展示了如何控制一个循环,以便在一个表格中显示特定数量的列。列数通过设置一个常数来控制。继续使用上一节中的文件。或者,使用gallery_mysqli_05.phpgallery_pdo_05.php

  1. 您可能会在稍后阶段决定要更改表中的列数,因此在脚本的顶部创建一个容易找到的常数是一个好主意,而不是将数字隐藏在代码中。在创建数据库连接之前插入以下代码:

    // define number of columns in table
    define('COLS', 2);
    
    

常量类似于变量,除了它的值不能被脚本的另一部分改变。使用define()函数创建一个常量,该函数有两个参数:常量的名称和值。按照惯例,常数总是大写并且区分大小写。与变量不同,它们不以美元符号开头。

  1. 您需要在循环之外初始化单元计数器。还要创建一个变量来指示它是否是第一行。在刚刚定义的常数后立即添加以下代码:

  2. 记录列数的代码放在显示缩略图的循环开始处的 PHP 块中。像这样修改代码:

    <?php do {
      // set caption if thumbnail is same as main image
      if ($row['filename'] == $mainImage) {
          $caption = safe($row['caption']);
      }
      // if remainder is 0 and not first row, close row and start new one
      if ($pos++ % COLS === 0 && !$firstRow) {
          echo '</tr><tr>';
      }
      // once loop begins, this is no longer true
      $firstRow = false;
    ?>
    
    
define('COLS', 2);
// initialize variables for the horizontal looper
$pos = 0;
$firstRow = true;

因为增量运算符(++)被放在$pos之后,所以它的值在被1递增之前被除以列数。循环第一次运行,余数是0,但是$firstRowtrue,所以条件语句失败。但是,$firstRow在条件语句后被重置为false。在以后的循环迭代中,条件语句会关闭当前表行,并在每次余数为 0 时开始一个新行。

  1. 如果没有更多的记录,您需要检查是否在表的底部有一个不完整的行。在现有的do. . . while回路后增加一个while回路。在 MySQLi 版本中,它看起来像这样:
<?php } while ($row = $result->fetch_assoc());
  while ($pos++ % COLS) {
      echo '<td>&nbsp;</td>';
  }
?>

新代码与 PDO 版本完全相同。唯一不同的是前面一行使用了$result->fetch()而不是$result->fetch_assoc()

第二个循环继续递增$pos,而$pos++ % COLS产生一个余数(被解释为true)并插入一个空单元格。

Caution

第二个循环没有嵌套在第一个循环中。它仅在第一个循环结束后运行。

img/332054_5_En_14_Fig4_HTML.jpg

图 14-4

缩略图现在排列整齐

  1. 保存页面并在浏览器中重新加载。画廊顶部的单行缩略图现在应该两个两个整齐排列,如图 14-4 所示。

尝试更改COLS的值并重新加载页面。主图像将被替换,因为页面只设计了两列,但是您可以看到,只需更改一个数字,就可以轻松控制每行的单元格数量。你可以对照gallery_mysqli_06.phpgallery_pdo_06.php来检查你的代码。

翻阅一长串记录

八个缩略图的网格非常适合画廊,但是如果你有 28 个或 48 个呢?答案是限制每个页面上显示的结果数量,然后构建一个导航系统,让您可以在结果中来回翻页。在使用搜索引擎时,你已经无数次地见过这种技术;现在你要学习如何自己建造它。这项任务可以分为以下两个阶段:

  1. 选择要显示的记录子集

  2. 创建浏览子集的导航链接

这两个阶段都相对容易实现,尽管它们涉及到应用一点条件逻辑。保持冷静,你会轻松度过难关的。

选择记录的子集

限制页面上的结果数量很简单——只需向 SQL 查询添加关键字LIMIT,如下所示:

SELECT filename, caption FROM images LIMIT startPosition, maximum

LIMIT关键字后面可以跟一个或两个数字。如果只使用一个数字,它将设置要检索的最大记录数。这很有用,但不适合寻呼系统。为此,您需要使用两个数字:第一个指示从哪个记录开始,第二个规定要检索的最大记录数。MySQL 从 0 开始计数记录,因此要显示前六张图像,您需要以下 SQL:

SELECT filename, caption FROM images LIMIT 0, 6

要显示下一个集合,SQL 需要改为:

SELECT filename, caption FROM images LIMIT 6, 6

images表中只有八条记录,但是第二个数字只是一个最大值,所以它检索记录 7 和 8。

要构建导航系统,您需要一种生成这些数字的方法。第二个数字永远不变,所以我们定义一个常数叫做SHOWMAX。生成第一个数字(称之为$startRecord)也很容易。从 0 开始对页码进行编号,并将第二个数字乘以当前页码。所以,如果你调用当前页面$curPage,公式看起来是这样的:

$startRecord = $curPage * SHOWMAX;

对于 SQL,它变成这样:

SELECT filename, caption FROM images LIMIT $startRecord, SHOWMAX

如果$curPage为 0,$startRecord也为 0 (0 × 6),但当$curPage增加到 1 时,$startRecord变为 6 (1 × 6),以此类推。

因为在images表中只有八条记录,所以您需要一种方法来找出记录的总数,以防止导航系统检索空的结果集。在上一章中,您使用了 MySQLi num_rows属性和 PDO 的rowCount()。但是,这一次不行,因为您想知道记录的总数,而不是在当前的结果集中有多少。答案是像这样使用 SQL COUNT()函数:

SELECT COUNT(*) FROM images

当像这样与星号结合使用时,COUNT()获取表中记录的总数。因此,要构建一个导航系统,您需要运行两个 SQL 查询:一个用于查找记录总数,另一个用于检索所需的子集。这些是简单的查询,所以结果几乎是即时的。

稍后我将处理导航链接。让我们从限制第一页上缩略图的数量开始。

PHP 解决方案 14-4:显示记录的子集

这个 PHP 解决方案展示了如何选择一个记录子集,为创建一个浏览更长记录集的导航系统做准备。它还演示了如何显示当前选择的数字以及记录的总数。

像以前一样继续处理同一个文件。或者,使用gallery_mysqli_06.phpgallery_pdo_06.php

  1. 定义SHOWMAX和 SQL 查询来查找表中的记录总数。修改页面顶部的代码,如下所示(新代码以粗体显示):

  2. 您现在需要运行新的 SQL 查询。该代码紧跟在前面步骤中的代码之后,但根据 MySQL 连接的类型而有所不同。对于 MySQLi,使用这个:

    // submit query and store result as $totalPix
    $total = $conn->query($getTotal);
    $totalPix = $total->fetch_row()[0];
    
    
// initialize variables for the horizontal looper
$pos = 0;
$firstRow = true;
// set maximum number of records
define('SHOWMAX', 6);
$conn = dbConnect('read');
// prepare SQL to get total records
$getTotal = 'SELECT COUNT(*) FROM images';

它提交查询,然后使用fetch_row()方法,该方法从MySQLi_Result对象中获取一行作为索引数组。结果中只有一列,所以我们可以通过在对fetch_row()的调用后添加方括号中的0来使用数组解引用获得images表中记录的总数。

对于 PDO,使用这个:

// submit query and store result as $totalPix
$total = $conn->query($getTotal);
$totalPix = $total->fetchColumn();

它提交查询,然后使用fetchColumn()获得一个结果,这个结果存储在$totalPix中。

  1. 接下来,设置$curPage的值。您稍后将创建的导航链接将通过查询字符串传递所需页面的值,因此您需要检查curPage是否在$_GET数组中。如果是,就使用那个值,但是要确保它是一个整数,在它前面加上(int)转换操作符。否则,将当前页面设置为 0。在上一步中的代码后立即插入以下代码:

    // set the current page
    $curPage = (isset($_GET['curPage'])) ? (int) $_GET['curPage'] : 0;
    
    
  2. 现在,您已经获得了计算起始行和构建 SQL 查询以检索记录子集所需的所有信息。在前面步骤中的代码后立即添加以下代码:

    // calculate the start row of the subset
    $startRow = $curPage * SHOWMAX;
    
    
  3. 但是有一个问题。$curPage的值来自查询字符串。如果有人在浏览器地址栏中手动更改数字,$startRow可能会大于数据库中的记录数。如果$startRow的值超过$totalPix,则需要将$startRow$curPage都重置为0。将此条件语句添加到上一步中的代码之后:

    if ($startRow > $totalPix) {
        $startRow = 0;
        $curPage = 0;
    }
    
    
  4. 原始的 SQL 查询现在应该在下一行。修改成这样:

    // prepare SQL to retrieve subset of image details
    $sql = "SELECT filename, caption FROM images LIMIT $startRow," . SHOWMAX;
    
    

这次我使用了双引号,因为我希望 PHP 处理$startRow。与变量不同,常量不会在双引号字符串中处理。所以用串联操作符(句号)将SHOWMAX添加到 SQL 查询的末尾。右引号中的逗号是 SQL 的一部分,用于分隔LIMIT子句的两个参数。

img/332054_5_En_14_Fig5_HTML.jpg

图 14-5

缩略图的数量受 SHOWMAX 常量的限制

  1. 保存页面并将其重新加载到浏览器中。你应该只看到六个缩略图,而不是八个,如图 14-5 所示。

更改SHOWMAX的值以查看不同数量的缩略图。

  1. 缩略图网格上方的文本不会更新,因为它仍然是硬编码的,所以让我们来解决这个问题。在页面正文中找到以下代码行:
<p id="picCount">Displaying 1 to 6 of 8</p>

替换为以下内容:

<p id="picCount">Displaying <?php echo $startRow+1;
if ($startRow+1 < $totalPix) {
    echo ' to ';
    if ($startRow+SHOWMAX < $totalPix) {
        echo $startRow+SHOWMAX;
    } else {
        echo $totalPix;
    }
}
echo " of $totalPix";
?></p>

让我们一行一行来。$startRow的值是从零开始的,所以需要加 1 才能得到一个更加用户友好的数字。所以$startRow+1在第一页显示 1,在第二页显示 7。

在第二行中,$startRow+1与记录总数进行比较。如果小于这个值,则意味着当前页面显示的是一系列记录,因此第三行显示文本“to ”,两边各有一个空格。

然后,您需要计算出该范围的最大值,因此嵌套的if ... else条件语句将开始行的值添加到页面上显示的最大记录数中。如果结果小于记录总数,$startRow+SHOWMAX给出页面上最后一条记录的编号。但是,如果它等于或大于总数,则显示$totalPix

最后,您退出两个条件语句并显示“of ”,后跟记录总数。

  1. 保存页面并在浏览器中重新加载。您仍然只能得到缩略图的第一个子集,但是无论何时您改变SHOWMAX的值,您都会看到第二个数字动态变化。如有必要,对照gallery_mysqli_07.phpgallery_pdo_07.php检查你的代码。

浏览记录子集

正如我在上一节的步骤 3 中提到的,所需页面的值通过一个查询字符串传递给 PHP 脚本。当页面第一次加载时,没有查询字符串,所以$curPage的值被设置为0。虽然当您点按缩略图以显示不同图像时会生成查询字符串,但它仅包括主图像的文件名,因此缩略图的原始子集保持不变。为了显示下一个子集,您需要创建一个将$curPage的值增加1的链接。因此,要返回到前面的子集,您需要另一个将$curPage的值减少1的链接。

这很简单,但是您还需要确保这些链接只在有有效的子集可供导航时才显示。例如,在第一页显示一个返回链接是没有意义的,因为没有之前的子集。同样,你不应该在显示最后一个子集的页面上显示一个前向链接,因为没有什么可以导航的。

使用条件语句可以很容易地解决这两个问题。还有最后一件事你需要处理。您还必须在单击缩略图时生成的查询字符串中包含当前页面的值。如果您没有这样做,$curPage会自动设置回0,并显示第一组缩略图,而不是当前子集。

PHP 解决方案 14-5:创建导航链接

这个 PHP 解决方案展示了如何创建导航链接,以便在每个记录子集之间来回翻页。像以前一样继续处理同一个文件。或者,使用gallery_mysqli_07.phpgallery_pdo_07.php

  1. 我已经将导航链接放置在缩略图表格底部的额外一行中。在占位符注释和结束</table>标记之间插入以下代码:
<!-- Navigation link needs to go here -->
<tr><td>
<?php

// create a back link if current page greater than 0
if ($curPage > 0) {
    echo '<a href="gallery.php?curPage=' . ($curPage-1) . '"> < Prev</a>';
} else {
    // otherwise leave the cell empty
    echo '&nbsp;';
}
?>
</td>
<?php
// pad the final row with empty cells if more than 2 columns
if (COLS-2 > 0) {
    for ($i = 0; $i < COLS-2; $i++) {
        echo '<td>&nbsp;</td>';
    }
}
?>
<td>
<?php
// create a forward link if more records exist
if ($startRow+SHOWMAX < $totalPix) {
    echo '<a href="gallery.php?curPage=' . ($curPage+1) . '"> Next ></a>';
} else {
    // otherwise leave the cell empty
    echo '&nbsp;';
}
?>
</td></tr>
</table>

看起来很多,但是代码分成三部分:第一部分在$curPage大于0时创建一个反向链接;如果有两列以上,第二个用空单元格填充最后一个表格行;第三个使用与前面相同的公式($startRow+SHOWMAX < $totalPix)来决定是否显示一个前向链接。

确保链接中的引号组合正确。另一点需要注意的是,$curPage-1$curPage+1的计算用括号括起来,以避免数字后面的句点被误解为小数点。它在这里用作连接操作符,连接查询字符串的各个部分。

  1. 现在,您需要将当前页面的值添加到缩略图周围链接中的查询字符串中。找到这段代码(在第 96 行周围):
<a href="gallery.php?image=<?= safe($row['filename']) ?>">

像这样改变它:

<a href="gallery.php?image=<?= safe($row['filename']) ?>&amp;curPage=<?= $curPage ?>">

您希望在单击缩略图时显示相同的子集,所以只需通过查询字符串传递当前的值$curPage

Caution

所有代码必须在同一行,结束 PHP 标签和&amp;之间没有空格。这段代码创建 URL 和查询字符串,其中不能有空格。

img/332054_5_En_14_Fig6_HTML.jpg

图 14-6

页面导航系统现在已经完成

  1. 保存页面并测试它。点击下一个链接,你应该会看到剩下的缩略图子集,如图 14-6 所示。没有更多的图像显示,所以下一个链接消失了,但在缩略图网格的左下方有一个上一个链接。画廊顶部的记录计数器现在反映了正在显示的缩略图的范围,如果您单击右边的缩略图,相同的子集将保留在屏幕上,同时显示适当的大图像。你完蛋了!

您可以对照gallery_mysqli_08.phpgallery_pdo_08.php检查您的代码。

章节回顾

在短短的几页中,你已经把一个枯燥的文件名列表变成了一个动态的在线图库,并配有一个页面导航系统。所有需要做的就是为每个主要图像创建一个缩略图,将两个图像上传到适当的文件夹,并将文件名和标题添加到数据库中的images表中。只要数据库不断更新imagesthumbs文件夹的内容,你就拥有了一个动态图库。不仅如此,您还学习了如何选择记录子集、通过查询字符串链接到相关信息,以及构建页面导航系统。

你使用 PHP 越多,你越会意识到技巧不在于记住如何使用许多晦涩的函数,而在于找出让 PHP 做你想做的事情所需的逻辑。这是一个如果这个,做那个的问题;如果是别的,做点不一样的。一旦您可以预见到某个情况可能发生的情况,您通常就可以构建代码来处理它。

到目前为止,您已经专注于从一个简单的数据库表中提取记录。在下一章,我将向您展示如何插入、更新和删除材料。

十五、管理内容

虽然您可以使用 phpMyAdmin 进行大量的数据库管理,但是您可能希望设置一些区域,让客户端可以登录到这些区域来更新一些数据,而不必让它们完全控制您的数据库。为此,您需要构建自己的表单并创建定制的内容管理系统。

每个内容管理系统的核心是有时被称为 CRUD(创建、读取、更新和删除)的循环,它仅使用四个 SQL 命令:INSERTSELECTUPDATEDELETE。为了演示基本的 SQL 命令,本章将向您展示如何为一个名为blog的表构建一个简单的内容管理系统。

即使您不想构建自己的内容管理系统,本章介绍的四个命令对于任何数据库驱动的页面都是必不可少的,例如用户登录、用户注册、搜索表单、搜索结果等等。

在本章中,您将了解以下内容:

  • 在数据库表中插入新记录

  • 显示现有记录的列表

  • 更新现有记录

  • 删除记录前要求确认

建立内容管理系统

管理数据库表中的内容包括四个阶段,我通常将这四个阶段分配给四个独立但相互链接的页面:一个用于插入、更新和删除记录,另一个用于现有记录的列表。记录列表有两个目的:标识数据库中存储了什么,更重要的是,通过查询字符串传递记录的主键来链接到更新和删除脚本。

blog表格包含一系列标题和文本文章,将在日本旅程网站中显示,如图 15-1 所示。为了简单起见,这个表只包含五列:article_id(主键)、titlearticlecreatedupdated

img/332054_5_En_15_Fig1_HTML.jpg

图 15-1

日本之旅网站中显示的博客表的内容

创建博客数据库表

如果您只想继续学习内容管理页面,请从ch15文件夹中的blog.sql导入表结构和数据。打开 phpMyAdmin,选择phpsols数据库,按照与第十二章相同的方式导入表格。SQL 文件创建了这个表,并用四篇短文填充它。

如果您喜欢自己从头开始创建一切,打开 phpMyAdmin,选择phpsols数据库,如果还没有选择的话,单击Structure选项卡。在Create table部分,在Name字段中输入博客,在Number of columns字段中输入 5 。然后点击Go。使用以下截图和表 15-1 : img/332054_5_En_15_Figa_HTML.jpg中显示的设置

表 15-1

博客表的列定义

|

|

类型

|

长度/值

|

默认

|

属性

|

|

索引

|

A_I

|
| --- | --- | --- | --- | --- | --- | --- | --- |
| article_id | INT |   |   | UNSIGNED | 取消选择 | PRIMARY | 挑选 |
| title | VARCHAR | 255 |   |   | 取消选择 |   |   |
| article | TEXT |   |   |   | 取消选择 |   |   |
| created | TIMESTAMP |   | CURRENT_TIMESTAMP |   | 取消选择 |   |   |
| updated | TIMESTAMP |   | CURRENT_TIMESTAMP | on update``CURRENT_TIMESTAMP | 取消选择 |   |   |

createdupdated列的默认值被设置为CURRENT_TIMESTAMP。所以当第一次输入记录时,两列得到相同的值。用于updatedAttributes列被设置为on update CURRENT_TIMESTAMP。这意味着每当记录发生更改时,它都会更新。为了跟踪记录最初是何时创建的,created列中的值永远不会更新。

创建基本的插入和更新表单

SQL 通过提供单独的命令,对插入和更新记录进行了重要的区分。INSERT仅用于创建一个全新的记录。插入记录后,必须使用UPDATE进行任何更改。因为这涉及到使用相同的字段,所以两个操作可以使用相同的页面。然而,这使得 PHP 更加复杂,所以我更喜欢先为插入页面创建 HTML,保存一个副本作为更新页面,然后分别编写代码。

插入页面中的表单只需要两个输入字段:标题和文章。其余三列的内容(主键和两个时间戳)被自动处理。插入表单的代码如下所示:

<form method="post" action="blog_insert.php">
    <p>
        <label for="title">Title:</label>
        <input name="title" type="text" id="title">
    </p>
    <p>
        <label for="article">Article:</label>
        <textarea name="article" id="article"></textarea>
    </p>
    <p>
        <input type="submit" name="insert" value="Insert New Entry">
    </p>
</form>

表单使用了post方法。你可以在blog_insert_mysqli_01.phpch15文件夹中的blog_insert_pdo_01.php中找到完整的代码。内容管理表单已经用admin.css赋予了一些基本的样式,它在styles文件夹中。在浏览器中查看时,该表单如下所示:

img/332054_5_En_15_Figb_HTML.jpg

除了标题和提交按钮之外,更新表单是相同的。按钮代码是这样的(完整代码在blog_update_mysqli_01.phpblog_update_pdo_01.php):

<input type="submit" name="update" value="Update Entry">

我给标题和文章输入字段起了与blog表中的列相同的名字。这使得以后编写 PHP 和 SQL 代码时更容易跟踪变量。

Tip

作为一种安全措施,一些开发人员建议使用数据库列的不同名称,因为任何人只需查看表单的源代码就可以看到输入字段的名称。使用不同的名称会增加闯入数据库的难度。在网站受密码保护的部分,这不应该是一个问题。但是,您可能希望考虑公开可访问的表单,例如用于用户注册或登录的表单。

插入新记录

向表中插入新记录的基本 SQL 如下所示:

INSERT [INTO] table_name (column_names)
VALUES (values)

INTO在方括号中,这意味着它是可选的。它纯粹是为了让 SQL 读起来更像人类语言。列名可以按照您喜欢的任何顺序排列,但是第二组括号中的值必须按照它们所引用的列的顺序排列。

虽然 MySQLi 和 PDO 的代码非常相似,但为了避免混淆,我将分别处理它们。

Note

本章中的许多脚本使用了一种称为“设置标志”的技术flag是一个布尔变量,它被初始化为truefalse,用于检查是否有事情发生。例如,如果$OK最初被设置为false,并且只有当一个数据库查询成功执行时才被重置为true,那么它可以被用作控制另一个代码块的条件。

PHP 解决方案 15-1:用 MySQLi 插入新记录

这个 PHP 解决方案展示了如何使用 MySQLi 预准备语句向blog表中插入一条新记录。使用预处理语句可以避免转义引号和控制字符的问题。它还可以保护你的数据库免受 SQL 注入病毒的攻击(参见第十三章)。

  1. php8sols站点根目录下创建一个名为admin的文件夹。从ch15文件夹中复制blog_insert_mysqli_01.php,在新文件夹中另存为blog_insert_mysqli.php

  2. 插入新记录的代码应该只在表单已经提交的情况下运行,所以它包含在一个条件语句中,该语句检查$_POST数组中提交按钮(insert)的name属性。将以下内容置于DOCTYPE声明之上:

<?php
if (isset($_POST['insert'])) {
    require_once '../includes/connection.php';
    // initialize flag
    $OK = false;
    // create database connection
    // initialize prepared statement
    // create SQL
    // bind parameters and execute statement
    // redirect if successful or display error
}
?>

包含连接函数后,代码将$OK设置为false。只有在没有错误的情况下,才会重置为true。结尾的五个注释规划了我们接下来要填写的剩余步骤。

  1. 以具有读写权限的用户身份创建一个到数据库的连接,初始化一个准备好的语句,并为将从用户输入中获得的数据创建带有占位符的 SQL,如下所示:
// create database connection
$conn = dbConnect('write');
// initialize prepared statement
$stmt = $conn->stmt_init();
// create SQL
$sql = 'INSERT INTO blog (title, article)
           VALUES(?, ?)';

将从$_POST['title']$_POST['article']中导出的值由问号占位符表示。其他列将自动填充。article_id列为主键,使用AUTO_INCREMENTcreatedupdated列默认为CURRENT_TIMESTAMP

Note

该代码与第十三章的顺序略有不同。该脚本将在第十七章中进一步开发,以运行一系列 SQL 查询,因此首先初始化准备好的语句。

  1. 下一步是用变量中保存的值替换问号——这个过程叫做绑定参数。插入以下代码:
if ($stmt->prepare($sql)) {
    // bind parameters and execute statement
    $stmt->bind_param('ss', $_POST['title'], $_POST['article']);
    $stmt->execute();
    if ($stmt->affected_rows > 0) {
        $OK = true;
    }
}

这是保护您的数据库免受 SQL 注入攻击的部分。按照您希望将变量插入到 SQL 查询中的顺序,将变量传递给bind_param()方法,同时传递指定每个变量的数据类型的第一个参数,同样按照变量的顺序。两者都是字符串,所以这个参数是'ss'

一旦值被绑定到占位符,调用execute()方法。

affected_rows属性记录了有多少行受到了INSERTUPDATEDELETE查询的影响。

Caution

如果查询触发了 MySQL 错误,affected_rows返回 1。与一些计算语言不同,PHP 将 1 视为true。因此,您需要检查affected_rows是否大于零,以确保查询成功。如果大于零,$OK复位到true

  1. 最后,将页面重定向到现有记录的列表,或者显示任何错误消息。在上一步之后添加以下代码:

  2. 在页面正文中添加以下代码块,以便在插入操作失败时显示错误消息:

    <h1>Insert New Blog Entry</h1>
    <?php if (isset($error)) {
        echo "<p>Error: $error</p>";
        } ?>
    <form method="post" action="blog_insert_mysqli.php">
    
    
// redirect if successful or display error
    if ($OK) {
        header('Location:
            http://localhost/php8sols/admin/blog_list_mysqli.php');
        exit;
    } else {
        $error = $stmt->error;
    }
}
?>

完整的代码在ch15文件夹的blog_insert_mysqli_02.php中。

这就完成了插入页面,但是在测试之前,创建blog_list_mysqli.php,这在 PHP 解决方案 15-3 中有描述。

注意为了关注与数据库交互的代码,本章中的脚本不认证用户输入。在实际应用中,您应该使用第六章中描述的技术来检查从表单提交的数据,如果发现错误,就重新显示。

PHP 解决方案 15-2:用 PDO 插入新记录

这个 PHP 解决方案展示了如何使用 PDO 预处理语句在blog表中插入一条新记录。如果您还没有这样做,在php8sols站点根目录下创建一个名为admin的文件夹。

  1. blog_insert_pdo_01.php复制到admin文件夹,并保存为blog_insert_pdo.php

  2. 插入新记录的代码应该只在表单已经提交的情况下运行,所以它包含在一个条件语句中,该语句检查$_POST数组中提交按钮(insert)的name属性。将以下内容放在 PHP 块中的DOCTYPE声明上方:

if (isset($_POST['insert'])) {
    require_once '../includes/connection.php';
    // initialize flag
    $OK = false;
    // create database connection
    // create SQL
    // prepare the statement
    // bind the parameters and execute the statement
    // redirect if successful or display error
}

包含连接函数后,代码将$OK设置为false。只有在没有错误的情况下,才会重置为true。结尾的五个注释指出了剩下的步骤。

  1. 以具有读写权限的用户身份创建到数据库的 PDO 连接,并构建如下 SQL:
// create database connection
$conn = dbConnect('write', 'pdo');
// create SQL
$sql = 'INSERT INTO blog (title, article)
VALUES(:title, :article)';

将从变量派生的值由命名的占位符表示,占位符由冒号(:title:article)开头的列名组成。其他列的值将由数据库生成。article_id主键自动递增,createdupdated列的默认值设置为CURRENT_TIMESTAMP

  1. 下一步是初始化准备好的语句,并将变量值绑定到占位符——这个过程称为绑定 参数。添加以下代码:
// prepare the statement
$stmt = $conn->prepare($sql);
// bind the parameters and execute the statement
$stmt->bindParam(':title', $_POST['title'], PDO::PARAM_STR);
$stmt->bindParam(':article', $_POST['article'], PDO::PARAM_STR);
// execute and get number of affected rows
$stmt->execute();
$OK = $stmt->rowCount();

首先将 SQL 查询传递给数据库连接的prepare()方法($conn),并将对语句的引用存储为变量($stmt)。

接下来,变量中的值被绑定到准备好的语句中的占位符,然后execute()方法运行查询。

当与INSERTUPDATEDELETE查询一起使用时,PDO rowCount()方法报告受查询影响的行数。如果记录插入成功,$OK1,PHP 将其视为true。否则就是0,按false处理。

  1. 最后,将页面重定向到现有记录的列表,或者显示任何错误消息。在上一步之后添加以下代码:
// redirect if successful or display error
    if ($OK) {
        header('Location: http://localhost/php8sols/admin/blog_list_pdo.php');
        exit;
    } else {
        $error = $stmt->errorInfo()[2];
    }
}
?>

错误消息(如果有的话)被存储为由$stmt->errorInfo()返回的数组的第三个元素,并使用数组解引用来访问。

  1. 在页面正文中添加一个 PHP 代码块,以显示任何错误消息:
<h1>Insert New Blog Entry</h1>
<?php if (isset($error)) {
    echo "<p>Error: $error</p>";
} ?>
<form method="post" action="blog_insert_pdo.php">

完整的代码在ch15文件夹的blog_insert_pdo_02.php中。

这就完成了插入页面,但是在测试之前,创建blog_list_pdo.php,这将在下面描述。

链接到更新和删除页面

在更新或删除记录之前,您需要找到它的主键。一种实用的方法是查询数据库以选择所有记录。您可以使用此查询的结果来显示所有记录的列表,包括指向更新和删除页面的链接。通过将article_id的值添加到每个链接的查询字符串中,可以自动识别要更新或删除的记录。如图 15-2 所示,浏览器状态栏(左下方)显示的网址将文章Tiny Restaurants Crowded Togetherarticle_id标识为 3。

img/332054_5_En_15_Fig2_HTML.jpg

图 15-2

编辑和删除链接在查询字符串中包含记录的主键

更新页面使用它来显示准备更新的正确记录。相同的信息在指向删除页面的DELETE链接中传达。

要创建这样的列表,您需要从一个 HTML 表开始,该表包含两行和您想要显示的所有列,外加两个用于编辑和删除链接的额外列。第一行用于列标题。第二行包含在一个 PHP 循环中,显示所有结果。ch15文件夹中的blog_list_mysqli_01.php中的表格如下所示(blog_list_pdo_01.php中的版本是相同的,除了最后两个表格单元格中的链接指向 PDO 版本的更新和删除页面):

<table>
    <tr>
       <th>Created</th>
       <th>Title</th>
       <th>&nbsp;</th>
       <th>&nbsp;</th>
    </tr>
    <tr>
       <td></td>
       <td></td>
       <td><a href="blog_update_mysqli.php">EDIT</a></td>
       <td><a href="blog_delete_mysqli.php">DELETE</a></td>
    </tr>
</table>

PHP 解决方案 15-3:创建更新和删除页面的链接

这个 PHP 解决方案展示了如何创建一个页面,通过显示所有记录的列表并链接到更新和删除页面来管理blog表中的记录。MySQLi 和 PDO 版本之间只有微小的差异,所以这些说明对两者都进行了描述。

blog_list_mysqli_01.phpblog_list_pdo_01.php复制到admin文件夹,并将其保存为blog_list_mysqli.phpblog_list_pdo.php,这取决于您计划使用的连接方法。不同的版本链接到适当的插入、更新和删除文件。

  1. 您需要连接到数据库并创建 SQL 查询。在 PHP 块中的DOCTYPE声明上方添加以下代码:
require_once '../includes/connection.php';
require_once '../includes/utility_funcs.php';
// create database connection
$conn = dbConnect('read');
$sql = 'SELECT * FROM blog ORDER BY created DESC';

如果使用 PDO,将'pdo'作为第二个参数添加到dbConnect()中。

  1. 通过在结束 PHP 标记前添加以下代码来提交查询。

对于 MySQLi,使用这个:

$result = $conn->query($sql);
if (!$result) {
    $error = $conn->error;
}

对于 PDO,使用这个:

  1. 在表格前添加一个条件语句以显示任何错误消息,并将表格放在else块中。表格前的代码如下所示:

    <?php if (isset($error)) {
        echo "<p>$error</p>";
    } else { ?>
    
    
$result = $conn->query($sql);
$error = $conn->errorInfo()[2];

右花括号放在右</table>标签后的一个单独的 PHP 块中。

  • 对于 MySQLi,使用这个:
  1. 现在,您需要将第二个表行包含在一个循环中,并从结果集中检索每条记录。以下代码位于第一行的结束标签</tr>和第二行的开始标签<tr>之间。
</tr>
    <?php while($row = $result->fetch_assoc()) { ?>
<tr>

对于 PDO,使用这个:

</tr>
    <?php while ($row = $result->fetch()) { ?>
<tr>

这和上一章一样,所以应该不需要解释。

  1. 在第二行的前两个单元格中显示当前记录的createdtitle字段,如下所示:

    <td><?= $row['created'] ?></td>
    <td><?= safe($row['title']) ?></td>
    
    

created列存储一个TIMESTAMP数据类型,这是一个固定的格式,所以不需要净化。但是title列是文本相关的,所以需要传递给第十三章中定义的safe()函数。

  1. 在接下来的两个单元格中,将当前记录的查询字符串和article_id字段的值添加到两个 URL 中,如下所示(虽然链接不同,但突出显示的代码对于 PDO 版本是相同的):

    <td><a href="blog_update_mysqli.php?article_id=<?= $row['article_id'] ?>"
        >EDIT</a></td>
    <td><a href="blog_delete_mysqli.php?article_id=<?= $row['article_id'] ?>"
        >DELETE</a></td>
    
    

您在这里所做的是将?article_id=添加到 URL,然后使用 PHP 显示$row['article_id']的值。article_id列只存储整数,所以不需要对值进行清理。不要留下任何可能破坏 URL 或查询字符串的空格,这一点很重要。在处理完 PHP 之后,当在浏览器中查看页面的源代码时,开始的<a>标记应该是这样的(尽管数量会根据记录而变化):

  1. 最后,用花括号封闭第二个表格行周围的循环,如下所示:

    </tr>
        <?php } ?>
    </table>
    
    
  2. 保存blog_list_mysqli.phpblog_list_pdo.php并将页面加载到浏览器中。假设您之前已经将blog.sql的内容加载到了phpsols数据库中,您应该会看到一个包含四个条目的列表,如图 15-2 所示。你现在可以测试blog_insert_mysqli.php或者blog_insert_pdo.php。插入项目后,您将返回到blog_list.php的相应版本,创建日期和时间以及新项目的标题将显示在列表的顶部。如果遇到任何问题,对照ch15文件夹中的blog_list_mysqli_02.phpblog_list_pdo_02.php检查您的代码。

<a href="blog_update_mysqli.php?article_id=2">

提示这段代码假设表中总会有一些记录。作为练习,使用 PHP 解决方案 13-2 (MySQLi)或 13-4 (PDO)中的技术来计算结果的数量,如果没有找到记录,使用条件语句来显示消息。解决方案在blog_list_norec_mysqli.phpblog_list_norec_pdo.php里。

更新记录

更新页面需要执行两个独立的过程,如下所示:

  1. 检索所选记录,并显示它以备编辑

  2. 更新数据库中已编辑的记录

第一阶段使用$_GET超全局数组从 URL 中检索主键,然后使用它来选择并在更新表单中显示记录,如图 15-3 所示。

img/332054_5_En_15_Fig3_HTML.jpg

图 15-3

主键在更新过程中跟踪记录

主键存储在更新表单的隐藏字段中。在更新页面中编辑完记录后,使用post方法提交表单,将所有细节(包括主键)传递给UPDATE命令。

SQL UPDATE命令的基本语法如下所示:

UPDATE table_name SET column_name = value, column_name = value
WHERE condition

更新特定记录时的条件是主键。因此,当更新blog表中的article_id 3时,基本的UPDATE查询如下所示:

UPDATE blog SET title = value, article = value
WHERE article_id = 3

尽管 MySQLi 和 PDO 的基本原理是相同的,但代码差别很大,需要单独的指令。

PHP 解决方案 15-4:用 MySQLi 更新记录

这个 PHP 解决方案展示了如何将一个现有记录加载到更新表单中,然后使用 MySQLi 将编辑过的细节发送到数据库进行更新。要加载记录,您需要创建列出所有记录的管理页面,如 PHP 解决方案 15-3 中所述。

  1. ch15文件夹中复制blog_update_mysqli_01.php并在admin文件夹中保存为blog_update_mysqli.php

  2. 第一个阶段包括检索您想要更新的记录的详细信息。将以下代码放在 PHP 块中的DOCTYPE声明上方:

require_once '../includes/connection.php';
require_once '../includes/utility_funcs.php';
// initialize flags
$OK = false;
$done = false;
// create database connection
$conn = dbConnect('write');
// initialize statement
$stmt = $conn->stmt_init();
// get details of selected record
if (isset($_GET['article_id']) && !$_POST) {
    // prepare SQL query
    $sql = 'SELECT article_id, title, article
               FROM blog WHERE article_id = ?';
    if ($stmt->prepare($sql)) {
        // bind the query parameter
        $stmt->bind_param('i', $_GET['article_id']);
        // execute the query, and fetch the result
        $OK = $stmt->execute();
        // bind the results to variables
        $stmt->bind_result($article_id, $title, $article);
        $stmt->fetch();
    }
}
// redirect if $_GET['article_id'] not defined
if (!isset($_GET['article_id'])) {
    $url = 'http://localhost/php8sols/admin/blog_list_mysqli.php';
    header("Location: $url");
    exit;
}
// get error message if query fails
if (isset($stmt) && !$OK && !$done) {
    $error = $stmt->error;
}

尽管这非常类似于用于插入页面的代码,但是前几行是在条件语句之外的。更新过程的两个阶段都需要数据库连接和准备好的语句,因此这避免了以后重复相同代码的需要。初始化两个标志:$OK检查检索记录是否成功,以及$done检查更新是否成功。

第一个条件语句确保$_GET['article_id']存在,并且$_POST数组为空。因此,只有在设置了查询字符串,但表单还没有提交时,才会执行大括号内的代码。

您以与准备INSERT命令相同的方式准备SELECT查询,使用问号作为变量的占位符。但是,请注意,该查询不是使用星号来检索所有列,而是按名称指定三列,如下所示:

$sql = 'SELECT article_id, title, article
           FROM blog WHERE article_id = ?';

这是因为 MySQLi 预准备语句允许您将SELECT查询的结果绑定到变量,为了能够做到这一点,您必须指定列名和您希望它们出现的顺序。

首先,您需要初始化准备好的语句,并用$stmt->bind_param()$_GET['article_id']绑定到查询。因为article_id的值必须是整数,所以将'i'作为第一个参数传递。

代码执行查询,然后在获取结果之前,按照与在SELECT查询中指定的列相同的顺序将结果绑定到变量。

如果还没有定义$_GET['article_id'],下一个条件语句将页面重定向到blog_list_mysqli.php。这可以防止任何人试图直接在浏览器中加载更新页面。重定向位置已被分配给一个变量,因为如果更新成功,稍后将向该变量添加一个查询字符串。

如果预准备语句已创建,但$OK$done仍为false,则最终条件语句会存储一条错误消息。您还没有添加更新脚本,但是如果成功检索或更新记录,其中一个将切换到true。因此,如果两者都保持false,您就知道其中一个 SQL 查询有问题。

  1. 现在您已经检索了记录的内容,您需要在更新表单中显示它们。如果准备好的语句成功,$article_id应该包含要更新的记录的主键,因为它是您用bind_result()方法绑定到结果集的变量之一。

但是,如果有错误,您需要在屏幕上显示消息。但是如果有人将查询字符串更改为无效数字,$article_id将被设置为0,因此显示更新表单没有任何意义。在开始的<form>标签前添加以下条件语句:

<p><a href="blog_list_mysqli.php">List all entries </a></p>
<?php if (isset($error)) {
    echo "<p class='warning'>Error: $error</p>";
}
if($article_id == 0) { ?>
    <p class="warning">Invalid request: record does not exist.</p>
<?php } else { ?>
<form method="post" action="blog_update_mysqli.php">

第一条条件语句显示 MySQLi 预准备语句报告的任何错误消息。第二个将更新表单包装在一个else块中,所以如果$article_id0,表单将被隐藏。

  1. 在结束的</form>标签后立即添加else块的结束花括号,如下所示:

    </form>
           <?php } ?>
    </body>
    
    
  2. 如果$article_id不是0,你知道$title$article也包含有效值,可以显示在更新表单中,无需进一步测试。然而,您需要将文本值传递给safe(),以避免引号和可执行代码的问题。在title输入字段的value属性中显示$title,如下所示:

  3. article文本区做同样的操作。因为文本区域没有 value 属性,所以代码位于开始和结束的<textarea>标记之间,如下所示:

<input name="title" type="text" id="title" value="<?= safe($title) ?>">

<textarea name="article" id="article"><?= safe($article) ?></textarea>

确保开始和结束 PHP 和<textarea>标记之间没有空格。否则,您将在更新的记录中得到不需要的空格。

  1. UPDATE命令需要知道您想要更改的记录的主键。您需要将主键存储在一个隐藏字段中,以便与其他细节一起在$_POST数组中提交。因为隐藏字段不会显示在屏幕上,所以下面的代码可以放在表单中的任何位置:

  2. 保存更新页面,并通过将blog_list_mysqli.php加载到浏览器中并选择其中一条记录的EDIT链接来测试它。记录的内容应该显示在表单字段中,如图 15-3 所示。

<input name="article_id" type="hidden" value="<?= $article_id ?>">

Update Entry按钮还不能做任何事情。只要确保一切都正确显示,并确认主键在隐藏字段中注册。如果有必要,您可以对照blog_update_mysqli_02.php检查您的代码。

  1. 提交按钮的name属性是update,所以所有的更新处理代码都需要放在一个条件语句中,该语句检查$_POST数组中是否存在update。将下面以粗体突出显示的代码放在步骤 1 中重定向页面的代码的正上方:
$stmt->fetch();
    }
}
// if form has been submitted, update record
if (isset($_POST ['update'])) {
    // prepare update query
    $sql = 'UPDATE blog SET title = ?, article = ?
               WHERE article_id = ?';
    if ($stmt->prepare($sql)) {
        $stmt->bind_param('ssi', $_POST['title'], $_POST['article'],
            $_POST['article_id']);
        $done = $stmt->execute();
    }
}
// redirect page on success or if $_GET['article_id']) not defined
if ($done || !isset($_GET['article_id'])) {
    $url = 'http://localhost/php8sols/admin/blog_list_mysqli.php';
    if ($done) {
        $url .= '?updated=true';
    }
    header("Location: $url");
    exit;
}

UPDATE查询准备了问号占位符,其中的值由变量提供。准备好的语句已经在条件语句之外的代码中初始化,所以您可以将 SQL 传递给prepare()方法,并用$stmt->bind_param()绑定变量。前两个变量是字符串,第三个是整数,所以第一个参数是'ssi'

如果UPDATE查询成功,execute()方法返回true,重置$done的值。与INSERT查询不同,使用affected_rows属性没有什么意义,因为如果用户决定单击Update Entry按钮而不做任何更改,它将返回0,所以我们在这里不使用它。您需要将$done ||添加到重定向脚本的条件中。这确保了在更新成功或有人试图直接访问页面时页面被重定向。

如果更新成功,一个查询字符串将被追加到重定向位置。

  1. 编辑blog_list_mysqli.php中表格上方的 PHP 块,显示一条记录已被更新的消息,如下所示:
<?php if (isset($error)) {
    echo "<p>$error</p>";
} else {
    if (isset($_GET['updated'])) {
        echo '<p>Record updated</p>';
    }
?>
<table>

该条件语句嵌套在现有的else块中;不是elseif的说法。因此,在记录更新后,它将与数据库记录表一起显示。

  1. 保存blog_update_mysqli.php并通过加载blog_list_mysqli.php,选择一个EDIT链接,并对显示的记录进行更改来测试它。当您点击Update Entry时,您将被带回blog_list_mysqli.php,列表上方将出现“记录已更新”。您可以通过再次单击相同的EDIT链接来验证您所做的更改。如有必要,用blog_update_mysqli_03.phpblog_list_mysqli_03.php检查您的代码。

PHP 解决方案 15-5:用 PDO 更新记录

这个 PHP 解决方案展示了如何将现有记录加载到更新表单中,然后使用 PDO 将编辑后的详细信息发送到数据库进行更新。要加载记录,您需要创建列出所有记录的管理页面,如 PHP 解决方案 15-3 中所述。

  1. ch15文件夹中复制blog_update_pdo_01.php并在admin文件夹中保存为blog_update_pdo.php

  2. 第一个阶段包括检索您想要更新的记录的详细信息。将以下代码放在 PHP 块中的DOCTYPE声明上方:

require_once '../includes/connection.php';
require_once '../includes/utility_funcs.php';
// initialize flags
$OK = false;
$done = false;
// create database connection
$conn = dbConnect('write', 'pdo');
// get details of selected record
if (isset($_GET['article_id']) && !$_POST) {
    // prepare SQL query
    $sql = 'SELECT article_id, title, article FROM blog
                WHERE article_id = ?';
    $stmt = $conn->prepare($sql);
    // pass the placeholder value to execute() as a single-element array
    $OK = $stmt->execute([$_GET['article_id']]);
    // bind the results
    $stmt->bindColumn(1, $article_id);
    $stmt->bindColumn(2, $title);
    $stmt->bindColumn(3, $article);
    $stmt->fetch();
}
// redirect if $_GET['article_id'] not defined
if (!isset($_GET['article_id'])) {
    $url = 'http://localhost/php8sols/admin/blog_list_pdo.php';
    header("Location: $url");
    exit;
}
if (isset($stmt)) {
    // get error message (will be null if no error)
    $error = $stmt->errorInfo()[2];
}

虽然这非常类似于用于插入页面的代码,但是前几行是第一个条件语句之外的。更新过程的两个阶段都需要数据库连接,因此这避免了以后复制相同代码的需要。初始化两个标志:$OK检查检索记录是否成功,以及$done检查更新是否成功。

第一个条件语句检查$_GET['article_id']是否存在,以及$_POST数组是否为空。这确保了只有在设置了查询字符串,但表单还没有提交时,才执行里面的代码。

在为插入表单准备 SQL 查询时,您为变量使用了命名占位符。这次,我们用一个问号,像这样:

$sql = 'SELECT article_id, title, article FROM blog
           WHERE article_id = ?';

只有一个变量需要绑定到匿名占位符,所以将其作为单元素数组直接传递给execute()方法,如下所示:

$OK = $stmt->execute([$_GET['article_id']]);

Caution

这段代码使用数组简写语法,所以$_GET['article_id']被放在一对方括号中。不要忘记数组的右方括号。

然后用bindColumn()方法将结果绑定到$article_id$title$article。这一次,我使用数字(从 1 开始计数)来表示将每个变量绑定到哪一列。

结果中只有一条记录要获取,所以立即调用fetch()方法。

如果还没有定义$_GET['article_id'],下一个条件语句将页面重定向到blog_list_pdo.php。这可以防止任何人试图直接在浏览器中加载更新页面。重定向位置已被分配给一个变量,因为如果更新成功,稍后将向该变量添加一个查询字符串。

最后一条条件语句从准备好的语句中检索任何错误消息。它与其余的预准备语句代码是分开的,因为它还将用于您稍后将添加的第二个预准备语句。

  1. 现在您已经检索了记录的内容,您需要在更新表单中显示它们。如果准备好的语句成功,$article_id应该包含要更新的记录的主键,因为它是您用bindColumn()方法绑定到结果集的变量之一。

但是,如果有错误,您需要在屏幕上显示该消息。但是如果有人将查询字符串更改为无效数字,$article_id将被设置为0,因此显示更新表单没有任何意义。在开始的<form>标签前添加以下条件语句:

<p><a href="blog_list_pdo.php">List all entries </a></p>
<?php if (isset($error)) {
    echo "<p class='warning'>Error: $error</p>";
}
if($article_id == 0) { ?>
    <p class="warning">Invalid request: record does not exist.</p>
<?php } else { ?>
<form method="post" action="blog_update_pdo.php">

第一条条件语句显示 PDO 预处理语句报告的任何错误消息。第二个将更新表单包装在一个else块中,所以如果$article_id0,表单将被隐藏。

  1. 在结束的</form>标签后立即添加else块的结束花括号,如下所示:

    </form>
          <?php } ?>
    </body>
    
    
  2. 如果$article_id不是0,你知道$title$article也存在,可以显示在更新表单中,无需进一步测试。然而,您需要将文本值传递给safe(),以避免引号和可执行代码的问题。在title输入字段的value属性中显示$title,如下所示:

  3. article文本区做同样的操作。因为文本区域没有 value 属性,所以代码位于开始和结束的<textarea>标记之间,如下所示:

<input name="title" type="text" id="title" value="<?= safe($title) ?>">

<textarea name="article" id="article"><?= safe($article) ?></textarea>

确保开始和结束 PHP 和<textarea>标记之间没有空格。否则,您将在更新的记录中得到不需要的空格。

  1. UPDATE命令需要知道您想要更改的记录的主键。您需要将主键存储在一个隐藏字段中,以便与其他细节一起在$_POST数组中提交。因为隐藏字段不会显示在屏幕上,所以下面的代码可以放在表单中的任何位置:

  2. 保存更新页面,并通过将blog_list_pdo.php加载到浏览器中并选择其中一条记录的EDIT链接来测试它。记录的内容应该显示在表单字段中,如图 15-3 所示。

<input name="article_id" type="hidden" value="<?= $article_id ?>">

Update Entry按钮还不能做任何事情。只要确保一切都正确显示,并确认主键在隐藏字段中注册。如果有必要,您可以对照blog_update_pdo_02.php检查您的代码。

  1. 提交按钮的name属性是update,所以所有的更新处理代码都需要放在一个条件语句中,该语句检查$_POST数组中是否存在update。将下面以粗体突出显示的代码放在步骤 1 中重定向页面的代码的正上方:
$stmt->fetch();
}
// if form has been submitted, update record
if (isset($_POST['update'])) {
    // prepare update query
    $sql = 'UPDATE blog SET title = ?, article = ?
               WHERE article_id = ?';
    $stmt = $conn->prepare($sql);
    // execute query by passing array of variables
    $done = $stmt->execute([$_POST['title'], $_POST['article'],
        $_POST['article_id']]);
}
// redirect page on success or $_GET['article_id'] not defined
if ($done || !isset($_GET['article_id'])) {
    $url = 'http://localhost/php8sols/admin/blog_list_pdo.php';
    if ($done) {
        $url .= '?updated=true';
    }
    header("Location: $url");
    exit;
}

同样,SQL 查询是使用问号作为从变量派生的值的占位符来准备的。这一次,有三个占位符,因此相应的变量需要作为数组传递给execute()方法。不用说,数组的顺序必须与占位符的顺序相同。

如果UPDATE查询成功,execute()方法返回true,重置$done的值。这里不能使用rowCount()方法来获得受影响的行数,因为如果没有做任何更改就点击Update Entry按钮,它会返回0。您会注意到我们在重定向脚本的条件中添加了$done ||。这确保了在更新成功或有人试图直接访问页面时页面被重定向。如果记录已被更新,一个查询字符串将被追加到重定向位置。

  1. 编辑blog_list_pdo.php中表格上方的 PHP 块,显示一条记录已被更新的消息,如下所示:
<?php if (isset($error)) {
    echo "<p>$error</p>";
} else {
    if (isset($_GET['updated'])) {
        echo '<p>Record updated</p>';
    }
?>
<table>

该条件语句嵌套在现有的else块中;不是elseif的说法。因此,在记录更新后,它将与数据库记录表一起显示。

  1. 保存blog_update_pdo.php并通过加载blog_list_pdo.php,选择一个EDIT链接,并对显示的记录进行更改来测试它。当您点击Update Entry时,您将被带回blog_list_pdo.php,列表上方将出现“记录已更新”。您可以通过再次单击相同的EDIT链接来验证您所做的更改。如有必要,对照blog_update_pdo_03.phpblog_list_pdo_03.php检查您的代码。

删除记录

删除数据库中的记录类似于更新记录。基本的DELETE命令如下所示:

DELETE FROM table_name WHERE condition

DELETE命令具有潜在危险的是,它是最终命令。一旦你删除了一条记录,就再也无法恢复了——它永远消失了。没有回收站或垃圾桶来把它捞出来。更糟糕的是,WHERE子句是可选的。如果你忽略了它,表中的每一条记录都会不可挽回地被送进网络遗忘。因此,最好显示要删除的记录的详细信息,并要求用户确认或取消该过程(参见图 15-4 )。

img/332054_5_En_15_Fig4_HTML.jpg

图 15-4

删除记录是不可逆的,所以在继续之前要得到确认

构建和编写删除页面的脚本几乎与更新页面相同,所以我不会给出一步一步的说明。但是,以下是要点:

  • 检索所选记录的详细信息。

  • 显示足够的详细信息,如标题,以便用户确认选择了正确的记录。

  • Confirm DeletionCancel按钮赋予不同的name属性,使用每个name属性和isset()来控制所采取的动作。

  • 使用条件语句隐藏Confirm Deletion按钮和隐藏字段,而不是将整个表单包装在else块中。

为每个方法执行删除的代码如下。

对于 MySQLi:

if (isset($_POST['delete'])) {
    $sql = 'DELETE FROM blog WHERE article_id = ?';
    if ($stmt->prepare($sql)) {
        $stmt->bind_param('i', $_POST['article_id']);
        $stmt->execute();
        if ($stmt->affected_rows > 0) {;
            $deleted = true;
        } else {
            $error = 'There was a problem deleting the record.';
        }
    }
}

对于 PDO:

if (isset($_POST['delete'])) {
    $sql = 'DELETE FROM blog WHERE article_id = ?';
    $stmt = $conn->prepare($sql);
    $stmt->execute([$_POST['article_id']]);
    // get number of affected rows
    $deleted = $stmt->rowCount();
    if (!$deleted) {
        $error = 'There was a problem deleting the record.';
        $error .= $stmt->errorInfo()[2];
    }
}

你可以在ch15文件夹的blog_delete_mysqli.phpblog_delete_pdo.php中找到完成的代码。为了测试删除脚本,将适当的文件复制到admin文件夹中。

回顾四个基本的 SQL 命令

既然你已经看到了SELECTINSERTUPDATEDELETE的运行,让我们回顾一下 MySQL 和 MariaDB 的基本语法。这不是一个详尽的列表,但它集中在最重要的选项上,包括一些尚未涉及的选项。

我在 https://dev.mysql.com/doc/refman/8.0/en/ 使用了与 MySQL 在线手册相同的排版约定(您可能也想参考):

  • 任何大写的都是 SQL 命令。

  • 方括号中的表达式是可选的。

  • 小写斜体表示变量输入。

  • 一个竖线(|)分隔选项。

尽管有些表达式是可选的,但它们必须按列出的顺序出现。例如,在一个SELECT查询中,WHEREORDER BYLIMIT都是可选的,但是LIMIT不能出现在WHEREORDER BY之前。

挑选

SELECT用于从一个或多个表中检索记录。其基本语法如下:

SELECT [DISTINCT] select_list
FROM table_list
[WHERE where_expression]
[ORDER BY col_name | formula] [ASC | DESC]
[LIMIT [skip_count,] show_count]

DISTINCT选项告诉数据库您想要从结果中消除重复的行。

select_list 是您希望包含在结果中的列的逗号分隔列表。若要检索所有列,请使用星号(*)。如果同一个列名在多个表中使用,引用必须明确,使用语法 table_name.column_name 。第 17 和 18 章详细解释了如何使用多个表格。

table_list 是一个逗号分隔的列表,从中可以提取结果。您希望包含在结果中的所有表格都必须列出。

WHERE子句指定搜索标准,例如:

WHERE quotations.family_name = authors.family_name
WHERE article_id = 2

表达式可以使用比较、算术、逻辑和模式匹配运算符。最重要的在表 15-2 中列出。

表 15-2

MySQL WHERE 表达式中使用的主要运算符

|

比较

|   |

算术

|   |
| --- | --- | --- | --- |
| < | 不到 | + | 添加 |
| <= | 小于或等于 | - | 减法 |
| = | 等于 | * | 增加 |
| != | 不等于 | / | 分开 |
| <> | 不等于 | DIV | 整数除法 |
| > | 大于 | % | 系数 |
| >= | 大于或等于 |   |   |
| IN() | 包括在列表中 |   |   |
| BETWEEN 最小值 AND 最大值 | 介于(包括两个值) |   |   |
| 逻辑 |   | 模式匹配 |   |
| AND | 逻辑与 | LIKE | 不区分大小写匹配 |
| && | 逻辑与 | NOT LIKE | 不区分大小写的不匹配 |
| OR | 逻辑或 | LIKE BINARY | 区分大小写匹配 |
| &#124;&#124; | 逻辑或(最好避免) | NOT LIKE BINARY | 区分大小写不匹配 |

在表示“不等于”的两个运算符中,<>是标准的 SQL。不是所有的数据库都支持!=

DIV是模数运算符的对应物。它产生的除法结果是一个没有小数部分的整数,而模数只产生余数:

5 / 2        /* result 2.5 */
5 DIV 2      /* result 2  */
5 % 2        /* result 1  */

我建议您避免使用||,因为它实际上在标准 SQL 中被用作字符串连接操作符。通过不在 MySQL 中使用它,如果使用不同的关系数据库,可以避免混淆。为了连接字符串,MySQL 使用了CONCAT()函数(参见 https://dev.mysql.com/doc/refman/8.0/en/string-functions.html#function_concat )。

IN()计算括号内逗号分隔的值列表,如果找到一个或多个值,则返回true。虽然BETWEEN通常用于数字,但它也适用于字符串。例如,BETWEEN 'a' AND 'd'a、b、c、d 返回true(但不是它们的大写等价物)。IN()BETWEEN都可以在NOT之前进行相反的比较。

LIKENOT LIKE和相关的BINARY运算符与以下两个通配符一起用于文本搜索:

  • %:匹配任意字符序列或不匹配

  • _(下划线):仅匹配一个字符

因此,下面的WHERE子句匹配 Dennis、Denise 等,但不匹配 Aiden:

WHERE first_name LIKE 'den%'

要匹配 Aiden,请将%放在搜索模式的前面。因为%匹配任何字符序列或者不匹配,所以'%den%'仍然匹配丹尼斯和丹尼斯。要搜索文字百分号或下划线,请在它前面加一个反斜杠(\%\_)。

条件是从左到右计算的,但是如果您希望将一组条件放在一起考虑,可以用括号将它们分组。

ORDER BY指定结果的排序顺序。这可以指定为单个列、逗号分隔的列列表或类似于RAND()的表达式,这将使顺序随机化。默认的排序顺序是升序(a-z,0-9),但是您可以指定DESC(降序)来颠倒顺序。

LIMIT后跟一个数字,规定返回的最大记录数。如果两个数字用逗号分隔,第一个告诉数据库要跳过多少行(参见第十四章中的“选择记录子集”)。

关于SELECT的更多细节见 https://dev.mysql.com/doc/refman/8.0/en/select.html

插入

INSERT命令用于向数据库添加新记录。一般语法如下:

INSERT [INTO] table_name (column_names)
VALUES (values)

单词INTO是可选的;它只是让命令读起来更像人类语言。列名和值是以逗号分隔的列表,并且两者的顺序必须相同。因此,要将纽约(暴风雪)、底特律(烟雾)和檀香山(晴天)的天气预报插入到天气数据库中,应该这样做:

INSERT INTO forecast (new_york, detroit, honolulu)
VALUES ('blizzard', 'smog', 'sunny')

使用这种语法的原因是允许您一次插入多条记录。每个后续记录都在一组单独的括号中,每组用逗号分隔:

INSERT numbers (x,y)
VALUES (10,20),(20,30),(30,40),(40,50)

你将在第十八章中使用这个多重插入语法。从INSERT查询中省略的任何列都被设置为默认值。当列设置为 AUTO_INCREMENT时,不要为主键设置显式值;在INSERT语句中去掉列名。

详见 https://dev.mysql.com/doc/refman/8.0/en/insert.html

更新

该命令用于更改现有记录。基本语法如下所示:

UPDATE table_name
SET col_name = value [, col_name = value]
[WHERE where_expression]

WHERE表达式告诉 MySQL 您想要更新哪条或哪些记录(或者可能在下面的例子中,梦见):

UPDATE sales SET q4_2021 = 25000
WHERE title = 'PHP 8 Solutions, Fifth Edition'

关于UPDATE的更多细节,请参见 https://dev.mysql.com/doc/refman/8.0/en/update.htm l

删除

DELETE可用于删除单个记录、多个记录或表格的全部内容。从单个表中删除的一般语法如下:

DELETE FROM table_name [WHERE where_expression]

尽管 phpMyAdmin 在删除记录之前会提示您进行确认,但数据库会相信您的话,并立即执行删除。DELETE是完全不可原谅的——一旦数据被删除,它就永远消失了。以下查询将删除名为subscribers的表中的所有记录,其中expiry_date中的日期已经过去:

DELETE FROM subscribers
WHERE expiry_date < NOW()

详见 https://dev.mysql.com/doc/refman/8.0/en/delete.html

Caution

虽然在UPDATEDELETEWHERE子句是可选的,但是您应该知道,如果您省略WHERE,整个表都会受到影响。这意味着,这两个命令中的任何一个不小心出错都可能导致每一条记录都完全相同,或者被删除。

安全和错误消息

当用 PHP 和数据库开发一个网站时,显示错误信息是很重要的,这样你就可以在出错时调试你的代码。然而,原始的错误消息在真实的网站上看起来很不专业。它们还可以向潜在的攻击者透露有关您的数据库结构的线索。因此,在将您的脚本部署到 Internet 上之前,您应该用您自己的中性消息替换数据库生成的错误消息,例如“对不起,数据库不可用”

章节回顾

数据库的内容管理包括插入、选择、更新和删除记录。每个记录的主键在更新和删除过程中起着重要的作用。大多数情况下,当第一次创建记录时,生成主键是由数据库自动处理的。此后,查找记录的主键只需使用一个SELECT查询,要么显示所有记录的列表,要么搜索您知道的关于记录的信息,比如一篇文章中的标题或单词。

MySQLi 和 PDO 预处理语句消除了确保引号和控制字符正确转义的需要,从而使数据库查询更加安全。如果在一个脚本中需要使用不同的变量重复相同的查询,它们还可以提高应用的速度。脚本只需要用占位符验证一次,而不是每次都验证 SQL。

尽管本章集中讨论了内容管理,但同样的基本技术也适用于大多数与数据库的交互。当然,对于 SQL 和 PHP 来说,还有很多东西。在下一章,我将解决一些最常见的问题,比如只显示一个长文本字段的第一句话和处理日期。然后在第十七章中,我们将探索在一个数据库中使用多个表。*

十六、格式化文本和日期

我们有一些前一章遗留下来的未完成的工作。第十五章中的图 15-1 显示了blog表格中的内容,其中只显示了每篇文章的前两句话以及文章其余部分的链接。然而,我没有向你展示它是如何做到的。有几种方法可以从较长文本的开头提取较短的文本。有些相当粗糙,通常在结尾留给你一个残破的单词。在这一章中,你将学习如何提取完整的句子。

另一项未完成的工作是,blog_list_mysqli.phpblog_list_pdo.php中的完整文章列表显示了原始状态的 MySQL 时间戳,这不是很优雅。你需要重新设置日期的格式,让它看起来更方便用户。处理日期可能是一个令人头疼的问题,因为 MySQL 和 MariaDB 存储日期的方式与 PHP 完全不同。本章将指导你如何在 PHP/MySQL 环境中存储和显示日期。您还将了解 PHP 的日期和时间特性,这些特性可以进行复杂的日期计算,例如查找每个月的第二个星期二,这是小菜一碟。

在本章中,您将了解以下内容:

  • 提取较长文本项的第一部分

  • 在 SQL 查询中使用别名

  • 将从数据库中检索到的文本显示为段落

  • 用 MySQL 格式化日期

  • 基于时间标准选择记录

  • 使用 PHP 的DateTimeDateTimeZoneDateIntervalDatePeriod

显示文本摘要

有许多方法可以从一段较长的文本中提取前几行或前几个字符。有时你只需要前 20 或 30 个字符来识别一个项目。在其他时候,最好显示完整的句子或段落。

提取固定数量的字符

您可以使用 PHP substr()函数或 SQL 查询中的LEFT()函数从文本项的开头提取固定数量的字符。

Note

以下示例将文本传递给第十三章中定义的safe()函数。这通过将&符号、双引号和尖括号转换为它们的 HTML 字符实体等效项来净化来自外部源的文本,但防止现有实体被双重编码。函数定义包含在文件utility_funcs.php中。

使用 PHP substr()函数

substr()函数从一个较长的字符串中提取一个子字符串。它有三个参数:要从中提取子字符串的字符串、起始点(从 0 开始计数)和要提取的字符数。以下代码显示了$row['article']的前 100 个字符:

echo safe(substr($row['article'], 0, 100));

原始字符串保持不变。如果省略第三个参数,substr()将提取字符串末尾的所有内容。只有当您选择 0 以外的起点时,这才有意义。

在 SQL 查询中使用 LEFT()函数

LEFT()函数从一列的开头提取字符。它有两个参数:列名和要提取的字符数。下面的代码从blog表的article列中检索article_idtitle和前 100 个字符:

SELECT article_id, title, LEFT(article, 100)
FROM blog ORDER BY created DESC

每当您像这样在 SQL 查询中使用函数时,列名在结果集中不再显示为article,而是显示为LEFT(article, 100)。所以使用AS关键字为受影响的列分配一个别名是个好主意。您可以将列的原始名称重新指定为别名,或者使用描述性名称,如下例所示(代码在blog_left_mysqli.php文件夹中的blog_left_pdo.php文件夹中):

SELECT article_id, title, LEFT(article, 100) AS first100
FROM blog ORDER BY created DESC

如果您将每个记录作为$row处理,那么摘录在$row['first100']中。要检索前 100 个字符和整篇文章,只需在查询中包含这两个字符,如下所示:

SELECT article_id, title, LEFT(article, 100) AS first100, article
FROM blog ORDER BY created DESC

取固定数量的字符会产生一个粗略的结果,如图 16-1 所示。

img/332054_5_En_16_Fig1_HTML.jpg

图 16-1

从一篇文章中选择前 100 个字符会把许多单词砍掉一半

结束对完整单词的提取

要结束对一个完整单词的提取,需要找到最后一个空格,并使用它来确定子串的长度。因此,如果您希望摘录最多为 100 个字符,可以使用前面的方法之一开始,并将结果存储在$extract中。然后你可以使用 PHP 字符串函数strrpos()substr()找到最后一个空格,并像这样结束提取(代码在blog_word_mysqli.phpblog_word_pdo.php):

$extract = $row['first100'];
// find position of last space in extract
$lastSpace = strrpos($extract, ' ');
// use $lastSpace to set length of new extract and add ...
echo safe(substr($extract, 0, $lastSpace)) . '... ';

这产生了如图 16-2 所示的更优雅的结果。它使用strrpos(),它在另一个字符串中找到一个字符或子字符串的最后一个位置。因为您在寻找一个空格,所以第二个参数是一对中间有一个空格的引号。结果存储在$lastSpace中,作为第三个参数传递给substr(),完成对一个完整单词的提取。最后,添加一个包含三个点和一个空格的字符串,并用连接操作符(一个句点或点)将两者连接起来。

img/332054_5_En_16_Fig2_HTML.jpg

图 16-2

在一个完整的单词上结束提取会产生一个更优雅的结果

Caution

不要将获取字符或子串最后一个位置的strrpos()与获取第一个位置的strpos()混淆。额外的“r”代表“反向”——strrpos()从字符串末尾开始搜索。

提取第一段

假设您已经使用 Enter 或 Return 键在数据库中输入了文本以指示新段落,这是非常容易的。只需检索全文,使用strpos()找到第一个换行符,使用substr()提取到该点的第一部分文本。

以下 SQL 查询用于blog_para_mysqli.phpblog_para_pdo.php:

SELECT article_id, title, article
FROM blog ORDER BY created DESC

以下代码用于显示article的第一段:

<?= safe(substr($row['article'], 0, strpos($row['article'], PHP_EOL))) ?>

让我们把它拆开,单独看一下第三个论点:

strpos($row['article'], PHP_EOL)

这使用PHP_EOL常量以跨平台的方式在$row['article']中定位行字符的第一个结尾(参见 7 一章中的“用 fopen()追加内容”)。您可以像这样重写代码:

$newLine = strpos($row['article'], PHP_EOL);
echo safe(substr($row['article'], 0, $newLine));

两组代码做的完全一样,但是 PHP 允许您将一个函数作为传递给另一个函数的参数进行嵌套。只要嵌套函数返回有效结果,您就可以经常使用这样的快捷方式。

使用PHP_EOL常量消除了处理 Linux、macOS 和 Windows 用来插入新行的不同字符的问题。

显示段落

既然我们谈到了段落这个主题,许多初学者会对从数据库中检索到的所有文本都显示为一个连续的块而感到困惑,因为段落之间没有分隔。HTML 忽略空白,包括新行。要将存储在数据库中的文本显示为段落,您有以下选择:

  • 将文本存储为 HTML。

  • 将新行转换为<br/>标签。

  • 创建一个自定义函数,用段落标签替换新行。

将数据库记录存储为 HTML

第一个选项包括在您的内容管理表单中安装一个 HTML 编辑器,例如 CKEditor ( https://ckeditor.com/ )或 TinyMCE ( www.tiny.cloud/ )。在插入或更新文本时对其进行标记。HTML 存储在数据库中,文本按预期显示。安装这些编辑器超出了本书的范围。

Note

如果你将文本作为 HTML 存储在数据库中,你不能使用safe()函数来显示它,因为 HTML 标签将作为文本的一部分显示。相反,使用strip_tags()并指定允许哪些标签(参见第七章和 www.php.net/manual/en/function.strip-tags.php 中的“访问远程文件”)。

将新行转换为

标签

最简单的选择是在显示之前将文本传递给nl2br()函数,如下所示:

echo nl2br(safe($row['article']));

瞧啊。段落。不完全是。nl2br()函数将换行符转换为<br/>标签(右斜杠是为了与 XHTML 兼容,在 HTML5 中有效)。结果,你得到的是假的段落。这是一个快速而肮脏的解决方案,但并不理想。

Tip

使用nl2br()是一个次优的解决方案。但是如果您决定使用它,您必须在将它传递给nl2br()之前净化文本。否则,<br />标签的尖括号将被转换成 HTML 字符实体,导致它们显示在你的文本中,而不是作为底层 HTML 中的标签。

创建一个函数来插入

标签

要将从数据库中检索到的文本显示为真正的段落,将数据库结果包装在一对段落标签中,然后使用preg_replace()函数将连续的换行符转换为结束</p>标签,紧接着是开始<p>标签,如下所示:

<p><?= preg_replace('/[\r\n]+/', "</p>\n<p>", safe($row['article'])); ?></p>

用作第一个参数的正则表达式匹配一个或多个回车和/或换行符。这里不能使用PHP_EOL常量,因为需要匹配所有连续的换行符,并用一对段落标签替换它们。这对<p>标记用双引号括起来,中间用\n加一个换行符,以便让 HTML 代码更容易阅读。记住正则表达式的模式可能很困难,因此您可以轻松地将其转换为自定义函数,如下所示:

function convertToParas($text) {
    $text = trim($text);
    $text = htmlspecialchars($text, double_encode: false);
    return '<p>' . preg_replace('/[\r\n]+/', "</p>\n<p>", $text) . "</p>\n";
}

这会从文本的开头和结尾修剪空白,包括换行符,然后通过将它传递给带有double_encode命名参数的htmlspecialchars()函数来净化空白,以防止 HTML 实体的&符号被转换为&amp;。函数内的第二行代码与第十三章中定义的safe()函数相同。最后一行在开头添加了一个<p>标签,用结束和开始标签替换了换行符的内部序列,并在结尾追加了一个结束</p>标签和换行符。

然后,您可以像这样使用该函数:

<?= convertToParas($row['article']); ?>

函数定义的代码在ch16文件夹中utility_funcs.php的更新版本中。你可以看到它被用在blog_ptags_mysqli.phpblog_ptags_pdo.php中。

Note

尽管utility_funcs.php的更新版本包含了safe()convertToParas()函数定义,但我决定不在convertToParas()中调用safe()函数,因为这可能会创建一个潜在的不稳定依赖。如果在将来的某个阶段,您决定采用不同的方式来净化文本并删除了safe()函数定义,调用convertToParas()将会触发致命错误,因为它依赖于一个不再存在的自定义函数。

提取完整的句子

PHP 对句子的构成没有概念。计算句号意味着你会忽略所有以感叹号或问号结尾的句子。您还会冒在小数点上断句或在句号后截断右引号的风险。为了克服这些问题,我设计了一个名为getFirst()的 PHP 函数,它可以识别普通句子末尾的标点符号:

  • 句号、问号或感叹号

  • 可选地后跟单引号或双引号

  • 后跟一个或多个空格

getFirst()函数有两个参数:要从中提取第一部分的文本和要提取的句子数量。第二个参数是可选的;如果没有提供,该函数将提取前两个句子。代码看起来是这样的(在utility_funcs.php中):

function getFirst($text, $number=2) {
    // use regex to split into sentences
    $sentences = preg_split('/([.?!]["\']?\s)/', $text, $number+1,
        PREG_SPLIT_DELIM_CAPTURE);
    if (count($sentences) > $number * 2) {
        $remainder = array_pop($sentences);
    } else {
        $remainder = '';
    }
    $result = [];
    $result[0] = implode('', $sentences);
    $result[1] = $remainder;
    return $result;
}

这个函数返回一个包含两个元素的数组:提取的句子和任何剩下的文本。您可以使用第二个元素创建一个包含全文的页面链接。

以粗体突出显示的行使用正则表达式来标识每个句子的结尾—句号、问号或感叹号,后面可选地跟一个双引号或单引号以及一个空格。这作为第一个参数传递给preg_split(),它使用正则表达式将文本分割成一个数组。第二个参数是目标文本。第三个参数决定了将文本分割成的最大块数。你想要比要提取的句子数量多一个。通常,preg_split()会丢弃正则表达式匹配的字符,但是使用PREG_SPLIT_DELIM_CAPTURE作为第四个参数,并在正则表达式中使用一对捕获括号,将它们作为单独的数组元素保存下来。换句话说,$sentences数组的元素交替地由一个句子的文本后跟标点符号和空格组成,如下所示:

$sentences[0] = '"Hello, world';
$sentences[1] = '!" ';

不可能事先知道目标文本中有多少个句子,所以你需要找出在提取出所需数量的句子后是否还有剩余。条件语句使用count()来确定$sentences数组中元素的数量,并将结果与$number乘以 2 进行比较(因为数组中每个句子包含两个元素)。如果还有更多文本,array_pop()删除$sentences数组的最后一个元素,并将其分配给$remainder。如果没有进一步的文本,$remainder是一个空字符串。

该函数的最后一步使用带有空字符串的implode()作为第一个参数,将提取的句子拼接在一起,然后返回一个包含提取的文本和任何剩余内容的两元素数组。

如果你发现这个解释很难理解,不要担心。代码相当高级。构建这个函数需要大量的实验,这些年来我一直在逐步改进它。

PHP 解决方案 16-1:显示文章的前两句话

这个 PHP 解决方案展示了如何使用上一节中描述的getFirst()函数显示blog表中每篇文章的摘录。如果你在书的前面创建了 Japan Journey 站点,使用blog.php。或者,使用ch16文件夹中的blog_01.php,并在php8sols站点根目录中将其保存为blog.php。在includes文件夹中还需要footer.phpmenu.phptitle.phpconnection.php。如果includes文件夹中没有这些文件,那么ch16文件夹中会有它们的副本。

  1. utility_funcs.php的更新版本从ch16文件夹复制到includes文件夹,并将其包含在DOCTYPE声明上方的 PHP 代码块的blog.php中。还包括connection.php并创建一个到数据库的连接。该页面需要只读权限,所以使用read作为传递给dbConnect()的参数,如下所示:

    require_once './includes/connection.php';
    require_once './includes/utility_funcs.php';
    // create database connection
    $conn = dbConnect('read');
    
    

如果使用 PDO,将'pdo'作为第二个参数添加到dbConnect()中。

  1. 准备一个 SQL 查询,从blog表中检索所有记录,然后提交它,如下所示:

  2. 添加代码以检查数据库错误。

$sql = 'SELECT * FROM blog ORDER BY created DESC';
$result = $conn->query($sql);

对于 MySQLi,使用这个:

if (!$result) {
    $error = $conn->error;
}

对于 PDO,调用errorInfo()方法并检查第三个数组元素是否存在,如下所示:

  1. 删除页面主体中<main>元素内的所有静态 HTML,并添加代码,以便在查询出现问题时显示错误消息:

    <main>
    <?php if (isset($error)) {
        echo "<p>$error</p>";
    } else {
    }
    ?>
    </main>
    
    
  2. else块内创建一个循环来显示结果:

    while ($row = $result->fetch_assoc()) {
        echo "<h2>{$row['title']}</h2>";
        $extract = getFirst($row['article']);
        echo '<p>' . safe($extract[0]);
        if ($extract[1]) {
            echo '<a href="details.php?article_id=' . $row['article_id'] . '">
                More</a>';
        }
        echo '</p>';
    }
    
    
$errorInfo = $conn->errorInfo();
if (isset($errorInfo[2])) {
    $error = $errorInfo[2];
}

PDO 的代码是一样的,除了这一行:

while ($row = $result->fetch_assoc()) {

替换为以下内容:

while ($row = $result->fetch()) {

getFirst()函数处理$row['article']并将结果存储在$extract中。$extract[0]article的前两句立刻显示出来。如果$extract[1]包含任何内容,则意味着有更多内容要显示。因此,if块中的代码显示了一个到details.php的链接,文章的主键在一个查询字符串中。

img/332054_5_En_16_Fig3_HTML.jpg

图 16-3

前两个句子是从较长的文本中干净利落地提取出来的

  1. 保存页面并在浏览器中测试。你应该会看到每篇文章的前两句显示如图 16-3 所示。

  2. 通过向getFirst()添加一个数字作为第二个参数来测试函数,如下所示:

$extract = getFirst($row['article'], 3);

这将显示前三个句子。如果您增加该数字,使其等于或超过文章中的句子数,则不会显示“更多”链接。

您可以将您的代码与ch16文件夹中的blog_mysqli.phpblog_pdo.php进行比较。

我们将在第十七章中看到details.php。在此之前,让我们先来解决在动态网站中使用日期的雷区。

让我们约会吧

日期和时间对现代生活如此重要,以至于我们很少停下来思考它们有多复杂。一分钟有 60 秒,一小时有 60 分钟,但一天有 24 小时。月份的范围在 28 到 31 天之间,一年可以是 365 或 366 天。困惑不止于此,因为 7/4 对美国人或日本人来说意味着 7 月 4 日,但对欧洲人来说是 4 月 7 日。更令人困惑的是,PHP 处理日期的方式与 MySQL 不同。是时候让混乱变得有序了…

Note

MariaDB 以同样的方式处理日期。为了避免不必要的重复,我将只提到 MySQL。

MySQL 如何处理日期

在 MySQL 中,日期和时间总是按照从大到小的降序来表示:年、月、日、小时、分钟、秒。小时总是使用 24 小时制,午夜表示为 00:00:00。即使这对您来说似乎很陌生,但这是国际标准化组织(ISO)制定的建议。

MySQL 允许单元之间的分隔符有相当大的灵活性(任何标点符号都是可以接受的),但是顺序是没有争议的——它是固定的。如果您试图以年、月、日之外的任何其他格式存储日期,MySQL 会在数据库中插入 0000-00-00。

稍后我将回到您在 MySQL 中插入日期的方式,因为最好使用 PHP 来验证和格式化它们。首先,让我们看看一旦日期存储在数据库中,您可以对其做些什么。MySQL 有很多日期和时间函数,在 https://dev.mysql.com/doc/refman/8.0/en/date-and-time-functions.html 有举例列出。

最有用的函数之一是DATE_FORMAT(),正如其名所示。

用 DATE_FORMAT()格式化选择查询中的日期

DATE_FORMAT()的语法如下:

DATE_FORMAT(date, format)

通常, date 是要格式化的表格列, format 是由格式说明符和您想要包含的任何其他文本组成的字符串。表 16-1 列出了最常见的说明符,它们都区分大小写。

表 16-1

常用的 MySQL 日期格式说明符

|

时期

|

分类符

|

描述

|

例子

|
| --- | --- | --- | --- |
| 年 | %Y | 四位数格式 | Two thousand and twenty-one |
| %y | 两位数格式 | Twenty-one |
| 月 | %M | 全名 | 一月,九月 |
| %b | 缩写名,三个字母 | 杨,Sep |
| %m | 带前导零的数字 | 01, 09 |
| %c | 不带前导零的数字 | 1, 9 |
| 一月中的某一天 | %d | 带前导零 | 01, 25 |
| %e | 不带前导零 | 1, 25 |
| %D | 带英文文本后缀 | 第 1 次、第 25 次 |
| 工作日名称 | %W | 全面测试 | 星期一,星期四 |
| %a | 缩写名,三个字母 | 我的,Thu |
| 小时 | %H | 带前导零的 24 小时制时钟 | 01, 23 |
| %k | 不带前导零的 24 小时制时钟 | 1, 23 |
| %h | 带前导零的 12 小时制时钟 | 01, 11 |
| %l(小写“L”) | 不带前导零的 12 小时制时钟 | 1, 11 |
| 分钟 | %i | 带前导零 | 05, 25 |
| 秒 | %S | 带前导零 | 08, 45 |
| 上午/下午 | %p |   |   |

如前所述,在 SQL 查询中使用函数时,使用关键字AS将结果分配给一个别名。参照表 16-1 ,您可以将blog表的created列中的日期格式化为美国通用的格式,并为其指定一个别名,如下所示:

DATE_FORMAT(created, '%c/%e/%Y') AS date_created

要以欧洲风格格式化同一日期,请颠倒前两个说明符,如下所示:

DATE_FORMAT(created, '%e/%c/%Y') AS date_created

Tip

当使用DATE_FORMAT()时,不要使用原来的列名作为别名,因为值被转换成字符串,这会破坏排序顺序。选择不同的别名,并使用原始列名对结果进行排序。

PHP 解决方案 16-2:格式化 MySQL 日期或时间戳

这个 PHP 解决方案格式化了第十五章的博客条目管理页面中的日期。

  1. 打开admin文件夹中的blog_list_mysqli.phpblog_list_pdo.php,找到 SQL 查询。看起来是这样的:

  2. 像这样改变它:

$sql = 'SELECT * FROM blog ORDER BY created DESC';

       $sql = 'SELECT article_id, title,
DATE_FORMAT(created, "%a, %b %D, %Y") AS date_created
                   FROM blog ORDER BY created DESC';

我在整个 SQL 查询中使用了单引号,所以DATE_FORMAT()中的格式字符串需要用双引号括起来。

确保DATE_FORMAT()的左括号前没有空白。

格式字符串以%a开头,显示工作日名称的前三个字母。如果使用原来的列名作为别名,那么ORDER BY子句将按相反的字母顺序对日期进行排序:Wed、Thu、Sun 等等。使用不同的别名可以确保日期仍然按时间顺序排列。

img/332054_5_En_16_Fig4_HTML.jpg

图 16-4

MySQL 时间戳的格式现在很好

  1. 在页面主体的第一个表格单元格中,将$row['created']更改为$row [ ' date _created'],以匹配 SQL 查询中的别名。

  2. 保存页面并将其加载到浏览器中。现在日期的格式应该如图 16-4 所示。尝试其他说明符来满足您的偏好。

blog_list_mysqli.phpblog_list_pdo.php的更新版本在ch16文件夹中。

添加和减去日期

处理日期时,添加或减去特定的时间段通常很有用。例如,您可能希望显示在过去 7 天内添加到数据库中的项目,或者停止显示 3 个月没有更新的文章。MySQL 通过DATE_ADD()DATE_SUB()让这变得简单。这两个函数都有同义词,分别叫做ADDDATE()SUBDATE()

它们的基本语法都是一样的,如下所示:

DATE_ADD(date, INTERVAL value interval_type)

在使用这些函数时, date 可以是包含您想要更改的日期的列、包含特定日期的字符串(格式为YYYY-MM-DD)或者 MySQL 函数,比如NOW()INTERVAL是一个关键字,后跟一个值和一个区间类型,最常见的列于表 16-2 。

表 16-2

最常用的间隔类型有 DATE_ADD()和 DATE_SUB()

|

区间类型

|

意义

|

值格式

|
| --- | --- | --- |
| DAY | 天 | 数字 |
| DAY_HOUR | 天数和小时数 | 字符串表示为'DD hh' |
| WEEK | 周末 | 数字 |
| MONTH | 月份 | 数字 |
| QUARTER | 四分之一 | 数字 |
| YEAR | 年 | 数字 |
| YEAR_MONTH | 年和月 | 字符串表示为'YY-MM' |

区间类型是常量,那么而不是DAYWEEK等的末尾加“S”使其成为复数。

这些函数最有用的应用之一是在表格中只显示最近的条目。

PHP 解决方案 16-3:显示上周更新的项目

这个 PHP 解决方案展示了如何根据特定的时间间隔限制数据库结果的显示。使用 PHP 解决方案 16-1 中的blog.php

  1. blog.php中找到 SQL 查询。看起来是这样的:

  2. 像这样改变它:

$sql = 'SELECT * FROM blog ORDER BY created DESC';

        $sql = 'SELECT * FROM blog
WHERE updated > DATE_SUB(NOW(), INTERVAL 1 WEEK)
                   ORDER BY created DESC';

这告诉 MySQL 您只想要在过去一周内更新过的项目。

  1. 在浏览器中保存并重新加载页面。根据您最后一次更新blog表中的项目的时间,您应该看不到任何内容或者看到有限范围的项目。如有必要,将间隔类型更改为DAYHOUR,以测试时间限制是否有效。

  2. 打开blog_list_mysqli.phpblog_list_pdo.php,选择blog.php中没有显示的项目,并进行编辑。重装blog.php。您刚刚更新的项目现在应该会显示出来。

您可以将您的代码与ch16文件夹中的blog_limit_mysqli.phpblog_limit_pdo.php进行比较。

将日期插入 MySQL

MySQL 要求将日期格式化为YYYY-MM-DD格式,这让允许用户输入日期的在线表单感到头疼。正如您在第十五章中看到的,可以使用TIMESTAMP列自动插入当前日期和时间。您还可以使用 MySQL 的NOW()函数在DATEDATETIME列中插入当前日期。当你需要其他日期的时候,问题就出现了。

理论上,HTML5 date输入类型应该已经解决了这个问题。支持日期输入字段的浏览器通常会在字段获得焦点时显示一个日期选择器,并以本地格式插入日期。在ch16文件夹的date_test.php中有一个例子。图 16-5 显示了谷歌 Chrome 如何在我的电脑上以正确的欧洲格式显示日期;但是当提交表单时,值被转换成 ISO 格式。尽管目前使用的绝大多数浏览器都支持date输入字段,但是谨慎对待日期输入字段是明智的。

img/332054_5_En_16_Fig5_HTML.jpg

图 16-5

HTML5 日期输入字段以本地格式显示日期,但以 ISO 格式提交

使用单个日期输入字段依赖于用户的浏览器正确支持 HTML5 日期输入,或者信任用户遵循设定的模式输入日期,例如MM/DD/YYYY。如果每个人都同意,您可以使用explode()功能重新排列日期部分,如下所示:

if (isset($_POST['theDate'])) {
    $date = explode('/', $_POST['theDate']);
    $mysqlFormat = "$date[2]-$date[0]-$date[1]";
}

如果有人偏离了这种格式,您的数据库中就会出现无效的日期。

因此,从在线表单中收集日期的最可靠方法仍然是使用单独的月、日和年输入字段。

PHP 解决方案 16-4:验证和格式化 MySQL 输入的日期

这个 PHP 解决方案专注于检查日期的有效性并将其转换为 MySQL 格式。它被设计成包含在您自己的插入或更新表单中。

  1. 创建一个名为date_converter.php的页面,并插入一个包含以下代码的表单(或者使用ch16文件夹中的date_converter_01.php):
<form method="post" action="date_converter.php">
    <p>
        <label for="month">Month:</label>
        <select name="month" id="month">
            <option value=""></option>
        </select>
        <label for="day">Date:</label>
        <input name="day" type="number" required id="day" max="31" min="1"
            maxlength="2">
        <label for="year">Year:</label>
        <input name="year" type="number" required id="year" maxlength="4">
    </p>
    <p>
        <input type="submit" name="convert" id="convert" value="Convert">
    </p>
</form>

这创建了一个名为month的下拉菜单和两个名为dayyear的输入字段。下拉菜单目前没有任何值,但是它将由一个 PHP 循环填充。dayyear字段使用 HTML5 number类型和required属性。日字段还具有maxmin属性,以便将范围限制在 1 到 31 之间。支持新 HTML5 表单元素的浏览器在字段旁边显示数字步进器,并限制输入的类型和范围。其他浏览器将它们呈现为普通的文本输入字段。为了旧浏览器的利益,两者都有maxlength属性来限制接受的字符数。

  1. 修改构建下拉菜单的部分,如下所示:
<select name="month" id="month">
    <?php
    $months = ['Jan','Feb','Mar','Apr','May','Jun',
        'Jul','Aug', 'Sep', 'Oct', 'Nov','Dec'];
    $thisMonth = date('n');
    for ($i = 1; $i <= 12; $i++) { ?>
        <option value="<?= $i ?>"
            <?php
            if ((!$_POST && $i == $thisMonth) ||
                (isset($_POST['month']) && $i == $_POST['month'])) {
                echo ' selected';
            } ?>>
            <?= $months[$i - 1] ?>
        </option>
    <?php } ?>
</select>

这将创建一个月份名称数组,并使用date()函数来查找当前月份的数字(传递给date()的参数的含义将在本章后面解释)。

然后一个for循环填充菜单的<option>标签。我已经将$i的初始值设置为1,因为我想用它来表示月份的值。在循环内部,条件语句检查两组条件,两组条件都用括号括起来,以确保它们以正确的顺序进行计算。第一组检查$_POST数组是否为空,以及$i$thisMonth的值是否相同。但是如果表单已经提交,$_POST['month']将已经被设置,因此备选条件集检查$i是否与$_POST['month']相同。因此,当第一次加载表单时,selected被插入到当前月份的<option>标记中。但是,如果表单已经提交,则用户选择的月份会再次显示。

通过从$months数组中提取月份名称,月份名称显示在<option>标记之间。因为索引数组从 0 开始,所以需要从$i的值中减去 1 来得到正确的月份。

img/332054_5_En_16_Fig6_HTML.jpg

图 16-6

对日期部分使用单独的输入字段有助于消除错误

  1. 在提交表单后,用当前日期或选择的值填充日期和年份字段:

    <label for="day">Date:</label>
    <input name="day" type="number" required id="day" max="31" min="1"
        maxlength="2" value="<?php if (!$_POST) {
               echo date('j');
           } elseif (isset($_POST['day'])) {
               echo safe($_POST['day']);
           } ?>">
    <label for="year">Year:</label>
    <input name="year" type="number" required id="year" maxlength="4"
           value="<?php if (!$_POST) {
               echo date('Y');
           } elseif (isset($_POST['year'])) {
               echo safe($_POST['year']);
    } ?>">
    
    
  2. 保存页面并在浏览器中测试。它应该显示当前日期,看起来类似于图 16-6 。

如果您测试输入字段,在大多数浏览器中,日期字段应该接受不超过两个字符,年份字段最多四个字符。尽管这降低了出错的可能性,但您仍然需要验证输入并正确格式化日期。

  1. 执行所有检查的代码是utility_funcs.php中的自定义函数。看起来是这样的:
function convertDateToISO(int $month, int $day, int $year) {
    $month = trim($month);
    $day = trim($day);
    $year = trim($year);
    if (empty($month) || empty($day) || empty($year)) {
        throw new Exception('Please fill in all fields');
    } elseif (($month < 1 || $month > 12) || ($day < 1 || $day > 31) || ($year < 1000 ||
        $year > 9999)) {
        throw new Exception('Please use numbers within the correct range');
    } elseif (!checkdate($month,$day,$year)) {
        throw new Exception('You have used an invalid date');
    }
    return sprintf('%d-%02d-%02d', $year, $month, $day);
}

该函数有三个参数:月、日和年。通过使用类型声明,如果使用了错误的输入类型,函数将自动将参数转换为整数。前三行代码修剪输入两端的任何空白。

这一系列条件语句检查输入值,看它们是否为空、不在可接受的范围内或者是否构成无效日期。即使表单已经预先填充了值,也不能保证输入来自您的表单。它可能来自自动化脚本,这就是为什么这些检查是必要的。

年的范围由 MySQL 的合法范围决定。万一您需要超出该范围的年份,您必须选择不同的列类型来存储数据。

如果输入通过了前两次测试,它将接受 PHP 函数checkdate(),该函数足够智能,可以知道何时是闰年,并防止出现类似 9 月 31 日这样的错误。

任何错误都会导致函数抛出异常。但是如果输入通过了所有这些测试,那么在使用sprintf()函数以正确的格式重新构建以插入 MySQL 之后,它将被返回。它将一个格式化字符串作为它的第一个参数,其中%d代表一个整数,%02d代表一个两位数的整数,如果需要,用前导零填充。连字符按字面意思处理。以下三个参数是要放入格式化字符串的值。这将产生 ISO 格式的日期,在月和日前面加零。

Note

sprintf()详见 www.php.net/manual/en/function.sprintf.php

  1. 出于测试目的,将此代码添加到页面主体中的表单下方:
if (isset($_POST['convert'])) {
    try {
        $converted = convertDateToISO($_POST['month'], $_POST['day'],
            $_POST['year']);
        echo 'Valid date: ' . $converted;
    } catch (Throwable $t) {
        echo 'Error: ' . $t->getMessage() . '<br>';
        echo 'Input was: ' . $months[$_POST['month'] - 1] . ' ' .
            safe($_POST['day']) . ', ' . safe($_POST['year']);
    }
}

这将检查表单是否已提交。如果是,它将表单值传递给convertDateToISO()函数,将结果保存在$converted中。因为函数可能抛出一个Exception,所以代码被嵌入在一个try / catch结构中。

如果输入和日期有效,则显示格式化的日期。如果日期不能转换成 ISO 格式,catch块显示存储在Exception中的错误信息,以及原始输入。为了显示正确的月份值,从$_POST['month']的值中减去 1,并将结果用作$months数组的键。$_POST['day']$_POST['year']的值被传递给safe()函数,以防止表单被远程利用。

img/332054_5_En_16_Fig7_HTML.jpg

图 16-7

日期已经过验证并转换为 ISO 格式

  1. 保存页面,并通过输入日期并单击“转换”进行测试。如果日期有效,你应该看到它被转换成 ISO 格式,如图 16-7 所示。

img/332054_5_En_16_Fig8_HTML.jpg

图 16-8

函数的作用是:拒绝无效的日期

  1. 如果您输入了无效的日期,您应该会看到一条适当的消息(参见图 16-8 )。

您可以将您的代码与ch16文件夹中的date_converter_02.php进行比较。

为需要用户输入日期的表格创建表单时,以与date_converter.php相同的方式添加月、日和年三个字段。在将表单输入插入数据库之前,包含utility_funcs.php(或者您决定存储该函数的任何地方),并使用convertDateToISO()函数来验证日期并将其格式化以便插入数据库:

require_once 'utility_funcs.php';
try {
    $date = convertDateToMySQL($_POST['month'], $_POST['day'], $_POST['year']);
} catch(Throwable $t) {
    $errors[] = $t->getMessage();
}

如果您的$errors数组有任何元素,放弃插入或更新过程并显示错误。否则,在 SQL 查询中插入$date是安全的。

Note

本章的其余部分将致力于在 PHP 中处理日期。这是一个重要但复杂的课题。我建议您浏览每一节以熟悉 PHP 的日期处理功能,并在需要实现特定功能时返回本节。

在 PHP 中使用日期

PHP 与其他计算机语言一样,通过从 Unix 纪元,即 1970 年 1 月 1 日午夜 UTC(协调世界时)开始以秒计算来处理复杂的日期和时间。幸运的是,PHP 通过它的DateTimeDateTimeZoneDateIntervalDatePeriod类在后台完成了大部分艰苦的工作。基本操作由简单的函数处理。

可用日期的范围取决于 PHP 的编译方式。DateTime和相关的类在内部将日期和时间信息存储为 64 位数字,这使得表示从过去大约 2920 亿年到未来相同数量年的日期成为可能。但是,如果 PHP 是在 32 位处理器上编译的,那么表 16-3 的后半部分的函数就被限制在大约 1901 年到 2038 年 1 月的范围内。

表 16-3 总结了 PHP 中与日期和时间相关的主要类和函数。

表 16-3

PHP 日期和时间相关的类和函数

|   |

名字

|

争论

|

描述

|
| --- | --- | --- | --- |
| 班级 |   |   |   |
|   | DateTime | 日期字符串,DateTimeZone对象 | 创建一个区分时区的对象,包含可用于日期和时间计算的日期和/或时间信息。 |
|   | DateTimeImmutable | 同DateTime | 与DateTime相同,但是改变任何值都会返回一个新的对象,原始对象保持不变。 |
|   | DateTimeZone | 时区字符串 | 存储用于DateTime对象的时区信息。 |
|   | DateInterval | 区间说明 | 以年、月、小时等表示固定的时间量。 |
|   | DatePeriod | 开始,间隔,结束/重复,选项 | 计算一段时间内的重复日期或重复次数。 |
| 功能 |   |   |   |
|   | time() | 没有人 | 为当前日期和时间生成 Unix 时间戳。 |
|   | mktime() | 小时、分钟、秒、月、日、年 | 为指定的日期/时间生成 Unix 时间戳。 |
|   | strtotime() | 日期字符串,时间戳 | 尝试从英文文本描述中生成 Unix 时间戳,例如“next Tuesday”返回值相对于第二个参数(如果提供的话)。 |
|   | date() | 格式字符串,时间戳 | 使用表 16-4 中列出的说明符格式化英文日期。如果省略第二个参数,则使用当前日期和时间。 |
|   | strftime() | 格式字符串,时间戳 | 与date()相同,但使用系统区域设置指定的语言。 |

设置默认时区

PHP 中的所有日期和时间信息都是根据服务器的默认时区设置存储的。web 服务器与您的目标受众位于不同的时区是很常见的,因此了解如何更改默认设置是很有用的。

服务器的默认时区通常应该在php.inidate.timezone指令中设置,但是如果你的托管公司忘记这样做或者你想使用不同的时区,你需要自己设置。

如果你的托管公司让你控制你自己版本的php.ini,在那里改变date.timezone的值。这样,它会自动为您的所有脚本设置。

如果您的服务器支持.htaccess.user.ini文件,您可以通过在站点根目录中添加适当的命令来更改时区。对于.htaccess,用这个:

php_value date.timezone 'timezone'

对于.user.ini,命令如下所示:

date.timezone=timezone

时区替换为您所在位置的正确设置。您可以在 www.php.net/manual/en/timezones.php 找到有效时区的完整列表。

如果这些选项都不可用,请在任何使用日期或时间函数的脚本的开头添加以下内容(用适当的值替换时区):

ini_set('date.timezone', 'timezone');

创建日期时间对象

要创建一个DateTime对象,只需使用new关键字后跟DateTime(),就像这样:

$now = new DateTime();

这将创建一个对象,该对象根据 web 服务器的时钟和默认时区设置来表示当前日期和时间。

DateTime()构造函数还接受两个可选参数:一个包含日期和/或时间的字符串和一个DateTimeZone对象。第一个参数的日期/时间字符串可以是 www.php.net/manual/en/datetime.formats.php 中列出的任何格式。与只接受一种格式的 MySQL 不同,PHP 走向了相反的极端。例如,要为 2021 年圣诞节创建一个DateTime对象,以下所有格式都有效:

'12/25/2021'
'25-12-2021'
'25 Dec 2021'
'Dec 25 2021'
'25-XII-2021'
'25.12.2021'
'2021/12/25'
'2021-12-25'
'December 25th, 2021'

这不是一份详尽的清单。它只是有效格式的选择。潜在的混乱出现在分隔符的使用上。例如,在美式日期(12/25/2021)和 ISO 日期(2021/12/25)中允许使用正斜杠,但在日期以欧洲顺序显示或月份由罗马数字表示时则不允许。要以欧洲顺序显示日期,分隔符必须是点、制表符或破折号。

也可以使用相对表达式来指定日期,例如“下周三”、“明天”或“上周一”然而,这里也存在潜在的混乱。有些人用“下周三”来表示“下周三”PHP 从字面上解释这个表达式。如果今天是星期二,“下星期三”意味着第二天。

您不能单独使用echo来显示存储在DateTime对象中的值。除了echo,您需要告诉 PHP 如何使用format()方法格式化输出。

用 PHP 格式化日期

DateTime类的format()方法使用与date()函数相同的格式字符。虽然这有助于保持连续性,但格式字符通常很难记住,而且似乎背后没有明显的逻辑。表 16-4 列出了最有用的日期和时间格式字符。

DateTime类和date()函数只用英语显示工作日和月份的名称,但是strftime()函数使用服务器的语言环境指定的语言。因此,如果服务器的地区设置为西班牙语,那么DateTime对象和date()显示星期六,但是strftime()显示萨巴多。除了DateTime类和date()函数使用的格式字符,表 16-4 列出了strftime()使用的等效字符。不是所有的格式在strftime()中都有对应的。

表 16-4

主要日期和时间格式字符

|

单位

|

日期时间/日期( )

|

strftime( )

|

描述

|

例子

|
| --- | --- | --- | --- | --- |
| 一天 | D | %d | 以零开头的一个月中的某一天 | 01–31 |
|   | J | %e * | 不带前导零的一月中的某一天 | 1–31 |
| S |   | 表示一个月中某一天的英语序数后缀 | 第一、第二、第三或第四 |
| D | %a | 日期名称的前三个字母 | 星期二,星期日 |
| l(小写“L”) | %A | 一天的全名 | 星期天,星期二 |
| 月 | M | %m | 带前导零的月份数 | 01–12 |
|   | N |   | 不带前导零的月份数 | 1–12 |
| M | %b | 月份名称的前三个字母 | 珍,Jul |
| F | %B | 月份的全名 | 一月,七月 |
| 年 | Y | %Y | 以四位数显示的年份 | Two thousand and fourteen |
| y | %y | 以两位数显示的年份 | Fourteen |
| 小时 | g |   | 不带前导零的 12 小时制小时 | 1–12 |
| h | %I | 带有前导零的 12 小时格式的小时 | 01–12 |
| G |   | 24 小时格式的小时,不带前导零 | 0–23 |
| H | %H | 带有前导零的 24 小时制小时 | 01–23 |
| 分钟 | i | %M | 分钟,如有必要,带前导零 | 00–59 |
| 秒 | s | %S | 秒,如有必要,带前导零 | 00–59 |
| 上午/下午 | a |   | 小写字母 | 是 |
| 上午/下午 | A | %p | 大写字母 | 首相 |

*注意:Windows 不支持%e。

您可以根据自己的喜好将这些格式字符与标点符号结合起来,在网页上显示当前日期。

要格式化一个DateTime对象,将格式字符串作为参数传递给format()方法,如下所示(代码在ch16文件夹的date_format_01.php中):

<?php
$now = new DateTime();
$xmas2021 = new DateTime('12/25/2021');
?>
<p>It's now <?= $now->format('g.ia') ?> on <?= $now->format('l, F jS, Y') ?></p>
<p>Christmas 2021 falls on a <?= $xmas2021->format('l') ?></p>

在这个例子中,创建了两个DateTime对象:一个用于当前日期和时间,另一个用于 2021 年 12 月 25 日。使用表 16-4 中的格式字符,从两个对象中提取不同的日期部分,产生如下截图所示的输出:

img/332054_5_En_16_Figa_HTML.jpg

date_format_02.php中的代码通过使用date()strtotime()函数产生相同的输出,如下所示:

<?php $xmas2021 = strtotime('12/25/2021') ?>
<p>It's now <?= date('g.ia') ?> on <?= date('l, F jS, Y') ?></p>
<p>Christmas 2021 falls on a <?= date('l', $xmas2021) ?></p>

第一行使用strtotime()创建 2021 年 12 月 25 日的时间戳。不需要为当前日期和时间创建时间戳,因为在没有第二个参数的情况下使用时,date()默认为当前日期和时间。

如果圣诞节的时间戳没有在脚本的其他地方使用,第一行可以省略,对date()的最后一次调用可以重写为这样(参见date_format_03.php):

date('l', strtotime('12/25/2021'))

从自定义格式创建日期时间对象

您可以使用表 16-4 中的格式字符为DateTime对象指定一个自定义输入格式。不是用new关键字创建对象,而是使用createFromFormat()静态方法,就像这样:

$date = DateTime::createFromFormat(format_string, input_date, timezone);

第三个参数,时区,是可选的。如果包含的话,应该是一个DateTimeZone对象。

一个静态方法属于整个类,而不是某个特定的对象。使用类名后跟范围解析操作符(双冒号)和方法名来调用静态方法。

Tip

在内部,作用域解析操作符被称为PAAMAYIM_NEKUDOTAYIM,希伯来语是“双冒号”的意思。为什么是希伯来语?为 PHP 提供动力的 Zend 引擎最初是由 Zeev Suraski 和 Andi Gutmans 开发的,当时他们还是以色列技术学院的学生。除了在极客问答游戏中获得分数之外,当你在 PHP 错误消息中看到PAAMAYIM_NEKUDOTAYIM时,知道它的意思可以让你省去很多挠头的麻烦。

例如,您可以使用createFromFormat()方法接受以欧洲格式表示的日、月、年的日期,用斜线分隔,就像这样(代码在date_format_04.php中):

$xmas2021 = DateTime::createFromFormat('d/m/Y', '25/12/2021');
echo $xmas2021->format('l, jS F Y');

这会产生以下输出:

img/332054_5_En_16_Figb_HTML.jpg

Caution

试图将 2021 年 12 月 25 日用作DateTime构造函数的输入会触发致命错误,因为不支持DD/MM/YYYY。如果您想使用一种不被DateTime构造函数支持的格式,您必须使用createFromFormat()静态方法。

虽然createFromFormat()方法很有用,但它只能在你知道日期总是特定格式的情况下使用。

在 date()和 DateTime 类之间选择

当显示日期时,使用DateTime类总是一个两步过程。在调用format()方法之前,需要实例化对象。通过date()功能,你可以一次完成。因为它们都使用相同的格式字符,所以在处理当前日期和/或时间时,date()轻而易举地胜出。

Tip

从技术上讲,通过将对象的创建放在一对括号中,可以在实例化一个DateTime对象的同时调用format()方法。但是使用date()要简单得多。您可以在date_format_05.php中比较两种显示日期的方法。

对于简单的任务,如显示当前日期、时间或年份,使用date()。当使用表 16-5 中列出的方法处理与日期相关的计算和时区时,DateTime类开始发挥作用。

表 16-5

主要的日期时间方法

|

方法

|

争论

|

描述

|
| --- | --- | --- |
| format() | 格式字符串 | 使用表 16-4 中的格式字符格式化日期/时间。 |
| setDate() | 年、月、日 | 更改日期。参数应该用逗号分隔。超出允许范围的月份或天数将被添加到结果日期中,如正文中所述。 |
| setTime() | 小时、分钟、秒 | 重置时间。参数是逗号分隔的值。秒是可选的。超出允许范围的值将被添加到结果日期/时间中。 |
| modify() | 相对日期字符串 | 使用相对表达式更改日期/时间,如“+2 周”。 |
| getTimestamp() | 没有人 | 返回日期/时间的 Unix 时间戳。 |
| setTimestamp() | Unix 时间戳 | 根据 Unix 时间戳设置日期/时间。 |
| setTimezone() | DateTimeZone对象 | 更改时区。 |
| getTimezone() | 没有人 | 返回一个代表DateTime对象时区的DateTimeZone对象。 |
| getOffset() | 没有人 | 返回相对于 UTC 的时区偏移量,以秒为单位。 |
| add() | DateInterval对象 | 按设定的周期增加日期/时间。 |
| sub() | DateInterval对象 | 从日期/时间中减去设定的时间段。 |
| diff() | DateTime对象,布尔型 | 返回一个代表当前DateTime对象和作为参数传递的对象之间差异的DateInterval对象。使用true作为可选的第二个参数将负值转换为正的等值。 |

setDate()setTime()添加超出范围的值会导致超出部分被添加到结果日期或时间中。例如,使用 14 作为月份会将日期设置为下一年的二月。将小时设置为 26 会导致第二天凌晨 2 点。

使用setDate()的一个有用技巧是,通过将月份值设置为下个月,将日期设置为 0,可以将日期设置为任意一个月的最后一天。setDate.php中的代码用 2022 年和 2024 年(闰年)2 月的最后一天证明了这一点:

<?php
$format = 'F j, Y';
$date = new DateTime();
$date->setDate(2022, 3, 0);
?>
<p>Non-leap year: <?= $date->format($format) ?>.</p>
<p>Leap year: <?php $date->setDate(2024, 3, 0);
    echo $date->format($format); ?>.</p>

前面的示例产生以下输出:

img/332054_5_En_16_Figc_HTML.jpg

用相对日期处理溢出

modify()方法接受相对日期字符串,这可能会产生意想不到的结果。例如,如果将一个月添加到代表 2022 年 1 月 31 日的DateTime对象中,得到的值不是 2 月的最后一天,而是 3 月 3 日。

发生这种情况是因为在原始日期上加一个月会得到 2 月 31 日,但在非闰年中 2 月只有 28 天。因此,超出范围的值被添加到月份中,结果为 3 月 3 日。如果您随后从同一个DateTime对象中减去一个月,它会将您带回到 2 月 3 日,而不是最初的开始日期。date_modify_01.php中的代码说明了这一点,如图 16-9 所示:

img/332054_5_En_16_Fig9_HTML.jpg

图 16-9

加减月份会导致意想不到的结果

<?php
$format = 'F j, Y';
$date = new DateTime('January 31, 2022');
?>
<p>Original date: <?= $date->format($format) ?>.</p>
<p>Add one month: <?php
$date->modify('+1 month');
echo $date->format($format);
$date->modify('-1 month');
?>
<p>Subtract one month: <?= $date->format($format) ?>

避免这个问题的方法是在相对表达式中使用'last day of',像这样(代码在date_modify_02.php中):

<?php
$format = 'F j, Y';
$date = new DateTime('January 31, 2022');
?>
<p>Original date: <?= $date->format($format) ?>.</p>
<p>Add one month: <?php
    $date->modify('last day of +1 month');
    echo $date->format($format);
    $date->modify('last day of -1 month');
    ?>
<p>Subtract one month: <?= $date->format($format) ?>

如图 16-10 所示,这就产生了想要的结果。

img/332054_5_En_16_Fig10_HTML.jpg

图 16-10

在相对表达式中使用“最后一天”可以解决这个问题

使用 DateTimeZone 类

一个DateTime对象自动使用 web 服务器的默认时区,除非您已经使用前面描述的方法之一重置了时区。然而,您可以通过构造函数可选的第二个参数或者通过使用setTimezone()方法来设置单个DateTime对象的时区。在这两种情况下,参数必须是一个DateTimeZone对象。

要创建一个DateTimeZone对象,将 www.php.net/manual/en/timezones.php 中列出的一个支持的时区作为参数传递给构造函数,如下所示:

$UK = new DateTimeZone('Europe/London');
$USeast = new DateTimeZone('America/New_York');
$Hawaii = new DateTimeZone('Pacific/Honolulu');

当检查支持的时区列表时,重要的是要认识到它们是基于地理区域和城市,而不是基于官方时区。这是因为 PHP 自动将夏令时考虑在内。不使用夏令时的亚利桑那州被America/Phoenix覆盖。

将时区组织成地理区域会带来一些惊喜。美洲不是指美国,而是南北美洲和加勒比海的大陆。因此,檀香山不在美国列出,而是作为一个太平洋时区。欧洲也指欧洲大陆,包括不列颠群岛和爱尔兰,但不包括其他岛屿。所以雷克雅未克和马德拉被列为大西洋时区,而挪威斯瓦尔巴特岛上的朗伊尔城享有唯一的北极时区的特权。

timezones.php中的代码为伦敦、纽约和檀香山创建DateTimeZone对象,然后使用第一个对象初始化一个DateTime对象,如下所示:

$now = new DateTime('now', $UK);

使用echoformat()方法显示日期和时间后,使用setTimezone()方法更改时区,如下所示:

$now->setTimezone($USeast);

下次显示$now时,它显示纽约的日期和时间。最后,再次使用setTimezone()将时区更改为檀香山,产生以下输出:

img/332054_5_En_16_Figd_HTML.jpg

Caution

时区转换的准确性取决于编译到 PHP 中的时区数据库是否是最新的。

要找到服务器的时区,可以检查php.ini或者使用带有DateTime对象的getTimezone()方法。getTimezone()方法返回一个DateTimeZone对象,而不是一个包含时区的字符串。要获得时区的值,您需要使用DateTimeZone对象的getName()方法,就像这样(代码在timezone_display.php中):

$now = new DateTime();
$timezone = $now->getTimezone();
echo $timezone->getName();

DateTimeZone类有几个公开时区信息的其他方法。为了完整起见,它们被列在表 16-6 中,但是DateTimeZone类的主要用途是为DateTime对象设置时区。

表 16-6

DateTimeZone 方法

|

方法

|

争论

|

描述

|
| --- | --- | --- |
| getLocation() | 没有人 | 返回包含国家代码、纬度、经度和时区注释的关联数组。 |
| getName() | 没有人 | 返回包含时区的地理区域和城市的字符串。 |
| getOffset() | DateTime对象 | 计算作为参数传递的DateTime对象相对于 UTC 的偏移量(秒)。 |
| getTransitions() | 开始,结束 | 返回一个多维数组,其中包含夏令时的历史和未来切换日期和时间。接受两个时间戳作为可选参数来限制结果的范围。 |
| listAbbreviations() | 没有人 | 生成一个大型多维数组,包含 PHP 支持的 UTC 偏移量和时区名称。 |
| listIdentifiers() | DateTimeZone常量,国家代码 | 返回所有 PHP 时区标识符的数组,如欧洲/伦敦、美国/纽约等。接受两个可选参数来限制结果的范围。使用 www.php.net/manual/en/class.datetimezone.php 中列出的DateTimeZone常量之一作为第一个参数。如果第一个参数是DateTimeZone::PER_COUNTRY,那么可以使用两个字母的国家代码作为第二个参数。 |

表 16-6 中的最后两个方法是静态方法。通过使用范围解析运算符直接在类上调用它们,如下所示:

$abbreviations = DateTimeZone::listAbbreviations();

用 DateInterval 类添加和减去设定的周期

使用add()sub()方法,DateInterval类用于指定从DateTime对象中增加或减少的周期。它也被返回一个DateInterval对象的diff()方法使用。一开始使用DateInterval类感觉很奇怪,但是理解起来相对简单。

要创建一个DateInterval对象,需要向构造函数传递一个指定区间长度的字符串;该字符串必须根据 ISO 8601 标准进行格式化。该字符串总是以字母P(代表句点)开头,后跟一对或多对整数和字母,称为句点标志符。如果时间间隔包括小时、分钟或秒,时间元素前面会有字母T。表 16-7 列出了有效的周期指示器。

表 16-7

DateInterval 类使用的 ISO 8601 时段指示符

|

周期指示符

|

意义

|
| --- | --- |
| Y | 年 |
| M | 月份 |
| W | 周—不能与日结合使用 |
| D | 天—不能与周结合使用 |
| H | 小时 |
| M | 分钟 |
| S | 秒 |

以下示例将阐明如何指定时间间隔:

$interval1 = new DateInterval('P2Y');           // 2 years
$interval2 = new DateInterval('P5W');           // 5 weeks
$interval3 = new DateInterval('P37D');          // 5 weeks 2 days
$interval4 = new DateInterval('PT6H20M');       // 6 hours 20 minutes
$interval5 = new DateInterval('P1Y2DT3H5M50S'); // 1 year 2 days 3 hours 5 min 50 sec

注意$interval3需要指定总天数,因为周会自动转换为天,所以WD不能组合在同一个区间定义中。

要将DateInterval对象与DateTime类的add()sub()方法一起使用,请将该对象作为参数传递。例如,这会将 2021 年圣诞节的日期增加 12 天:

$xmas2021 = new DateTime('12/25/2021');
$interval = new DateInterval('P12D');
$xmas2021->add($interval);

如果不需要重用区间,可以直接将DateInterval构造函数作为参数传递给add(),如下所示:

$xmas2021 = new DateTime('12/25/2021');
$xmas2021->add(new DateInterval('P12D'));

该计算的结果在date_interval_01.php中显示,产生以下输出:

img/332054_5_En_16_Fige_HTML.jpg

除了使用表 16-7 中列出的周期指示器之外,还可以使用静态createFromDateString()方法,该方法以与strtotime()相同的方式将英文相对日期字符串作为参数。使用createFromDateString(),前面的例子可以改写成这样(代码在date_interval_02.php):

$xmas2021 = new DateTime('12/25/2021');
$xmas2021->add(DateInterval::createFromDateString('+12 days'));

这产生了完全相同的结果。

Caution

DateInterval加减月份的效果和前面描述的一样。如果结果日期超出范围,则增加额外的天数。例如,将 1 月 31 日加上一个月会得到 3 月 3 日或 3 月 2 日,这取决于是否是闰年。要获得一个月的最后一天,请使用前面“用相对日期处理溢出”中描述的技术

用 diff()方法找出两个日期之间的差异

为了找出两个日期之间的差异,为两个日期创建一个DateTime对象,并将第二个对象作为参数传递给第一个对象的diff()方法。结果作为一个DateInterval对象返回。要从DateInterval对象中提取结果,需要使用该对象的format()方法,该方法使用表 16-8 中列出的格式字符。这些不同于DateTime类使用的格式字符。幸运的是,大多数都很容易记住。

表 16-8

DateInterval format()方法使用的格式字符

|

格式符

|

描述

|

例子

|
| --- | --- | --- |
| %Y | 几年了。至少两位数,必要时带前导零 | 12, 01 |
| %y | 年份,无前导零 | 12, 1 |
| %M | 带前导零的月份 | 02, 11 |
| %m | 月份,没有前导零 | 2, 11 |
| %D | 带前导零的天数 | 03, 24 |
| %d | 天,没有前导零 | 3, 24 |
| %a | 总天数 | 15, 231 |
| %H | 带前导零的小时 | 03, 23 |
| %h | 小时,无前导零 | 3, 23 |
| %I | 带前导零的分钟 | 05, 59 |
| %i | 分钟,无前导零 | 5, 59 |
| %S | 带前导零的秒 | 05, 59 |
| %s | 秒,没有前导零 | 5, 59 |
| %R | 负数时显示减号,正数时显示加号 | -, + |
| %r | 负数时显示减号,正数时不显示符号 | - |
| %% | 百分比符号 | % |

date_interval_03.php中的以下示例显示了如何使用diff()获取当前日期和美国独立宣言之间的差异,并使用format()方法显示结果:

<p><?php
$independence = new DateTime('7/4/1776');
$now = new DateTime();
$interval = $now->diff($independence);
echo $interval->format('%Y years %m months %d days'); ?>
since American Declaration of Independence.</p>

如果你把date_interval_03.php加载到一个浏览器中,你应该会看到类似下面截图的东西(当然实际的时间段会有所不同):

img/332054_5_En_16_Figf_HTML.jpg

格式字符遵循一种逻辑模式。大写字符总是产生至少两位数,必要时带有前导零。小写字符没有前导零。

Caution

除了代表总天数的%a之外,格式字符仅代表整个时间间隔的特定部分。例如,如果您将格式字符串更改为$interval->format('%m months'),它将只显示自去年 7 月 4 日以来已经过去的整月数。它不显示自 1776 年 7 月 4 日以来的总月数。

使用 DatePeriod 类计算重复日期

多亏了DatePeriod类,计算出重复的日期,比如每个月的第二个星期二,现在变得非常容易。它与一个DateInterval协同工作。

DatePeriod构造函数的不同寻常之处在于它以三种不同的方式接受参数。创建DatePeriod对象的第一种方法是提供以下参数:

  • 一个代表开始日期的DateTime对象

  • 表示重复间隔的DateInterval对象

  • 表示重复次数的整数

  • DatePeriod::EXCLUDE_START_DATE常量(可选)

一旦创建了一个DatePeriod对象,就可以使用DateTime format()方法在一个foreach循环中显示重复出现的日期。

date_interval_04.php中的代码显示 2022 年每个月的第二个星期二:

$start = new DateTime('12/31/2021');
$interval = DateInterval::createFromDateString('second Tuesday of next month');
$period = new DatePeriod($start, $interval, 12, DatePeriod::EXCLUDE_START_DATE);
foreach ($period as $date) {
   echo $date->format('l, F jS, Y') . '<br>';
}

它产生如图 16-11 所示的输出。

img/332054_5_En_16_Fig11_HTML.jpg

图 16-11

使用 DatePeriod 类计算重复日期非常简单

PHP 代码的第一行将开始日期设置为 2021 年 12 月 31 日。下一行使用DateInterval静态方法createFromDateString()设置下个月第二个星期二的间隔。这两个值都被传递给DatePeriod构造函数,同时传递的还有 12(循环次数)和DatePeriod::EXCLUDE_START_DATE常量。常量的名称是不言自明的。最后,foreach循环使用DateTime format()方法显示结果日期。

创建DatePeriod对象的第二种方法是用表示结束日期的DateTime对象替换第三个参数中的重复次数。date_interval_05.php的代码被修改成这样:

$start = new DateTime('12/31/2021');
$interval = DateInterval::createFromDateString('second Tuesday of next month');
$end = new DateTime('12/31/2022');
$period = new DatePeriod($start, $interval, $end, DatePeriod::EXCLUDE_START_DATE);
foreach ($period as $date) {
    echo $date->format('l, F jS, Y') . '<br>';
}

这产生了与图 16-11 所示完全相同的输出。

您还可以使用 ISO 8601 循环时间间隔标准( https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals )创建一个DatePeriod对象。这不是用户友好的,主要是因为需要以正确的格式构造一个字符串,如下所示:

Rn/YYYY-MM-DDTHH:MM:SStz/Pinterval

R n 是字母R后跟循环次数; tz 是相对于 UTC 的时区偏移量(或Z表示 UTC,如下例所示);并且P 区间使用与DateInterval类相同的格式。date_interval_06.php中的代码显示了如何使用DatePeriod和 ISO 8601 循环间隔的示例。看起来是这样的:

$period = new DatePeriod('R4/2021-06-19T00:00:00Z/P10D');
foreach ($period as $date) {
    echo $date->format('l, F j, Y') . '<br>';
}

ISO 重复间隔设置从 UTC 2021 年 6 月 19 日午夜开始的四次重复,间隔 10 天。重复发生在原始日期之后,因此前面的示例生成了五个日期,如以下输出所示:

img/332054_5_En_16_Figg_HTML.jpg

章节回顾

这一章的很大一部分是关于强大的日期和时间类的。我还没有介绍过DateTimeImmutable类,因为除了一点之外,它在各个方面都与DateTime相同。一个DateTimeImmutable物体从不修改自己。相反,它总是返回一个带有修改值的新对象。如果你有一个永远不变的日期,比如一个人的出生日期,这就很有用。对这种类型的对象使用setDate()add()方法将返回一个新对象,保留原始细节并为更新的细节提供一个新对象,如开始工作、结婚、退休年龄等等。

您可能不需要每天都学习与日期和时间相关的课程,但是它们非常有用。MySQL 的日期和时间函数也使得格式化日期和基于时间标准执行查询变得容易。

也许日期的最大问题是决定是使用 SQL 还是 PHP 来处理格式和/或计算。PHP DateTime类的一个有用特性是构造函数接受以 ISO 格式存储的日期,因此您可以使用数据库中的无格式日期或时间戳来创建DateTime对象。然而,除非您需要执行进一步的计算,否则将DATE_FORMAT()函数作为SELECT查询的一部分会更有效。

本章还提供了三个格式化文本和日期的函数。在下一章中,您将学习如何在多个数据库表中存储和检索相关信息。

十七、从多个表中提取数据

正如我在第十三章中解释的,关系数据库的主要优势之一是能够通过使用一个表中的主键作为另一个表中的外键来链接不同表中的数据。phpsols数据库有两个表:imagesblog。是时候再添加一些并加入它们了,这样你就可以给博客条目分配类别,并将图片与单独的文章关联起来。

您并不实际连接多个表,而是通过 SQL 来连接。通常,可以通过识别主键和外键之间的直接关系来连接表。但是,在某些情况下,这种关系更加复杂,需要通过第三个表,作为另外两个表之间的交叉引用。

在本章中,您将学习如何建立表之间的关系,以及如何将一个表中的主键作为外键插入到另一个表中。虽然从概念上听起来很难,但实际上非常简单——使用数据库查询在第一个表中查找主键,保存结果,然后在另一个查询中使用该结果将它插入到第二个表中。

特别是,您将了解以下内容:

  • 了解不同类型的表关系

  • 对多对多关系使用交叉引用表

  • 改变表的结构以添加新列或索引

  • 将主键作为外键存储在另一个表中

  • INNER JOINLEFT JOIN链接表格

了解表关系

最简单的关系类型是一对一(通常表示为 1:1 )。这种类型的关系通常出现在包含只有某些人应该看到的信息的数据库中。例如,公司通常将员工的工资明细和其他机密信息存储在一个表中,与更容易访问的员工列表分开。将每个员工记录的主键作为外键存储在 tables 表中,可以在两个表之间建立直接关系,从而允许会计部门查看所有信息,同时限制其他人只能查看公共信息。

phpsols数据库中没有机密信息,但是你可以在images表中的一张图片和blog表中的一篇文章之间建立一对一的关系,如图 17-1 所示。

img/332054_5_En_17_Fig1_HTML.jpg

图 17-1

一对一关系将一个记录与另一个记录直接链接起来

这是在两个表之间创建关系的最简单的方法,但并不理想。随着更多文章的加入,这种关系的性质可能会改变。与图 17-1 中的第一篇文章相关的图片显示枫叶漂浮在水面上,因此它可能适合于说明一篇关于季节变化或秋天色调的文章。清澈的水、竹制的水瓢和竹制的烟斗也暗示了图片可以用来说明的其他主题。因此,你很容易就会发现同一张图片被用于几篇文章,或者是一个一对多(或 1:n )的关系,如图 17-2 所示。

img/332054_5_En_17_Fig2_HTML.jpg

图 17-2

一对多关系将一个记录与其他几个记录联系起来

正如您已经了解到的,主键必须是唯一的。因此,在一个1:n关系中,您将关系的1端的表中的主键(主表父表)作为外键存储在n端的表中(辅表子表)。在这种情况下,images表中的image_id需要作为外键存储在blog表中。关于1:n关系,重要的是要理解它也是1:1关系的集合。从右向左看图 17-2 每篇文章都与一个单独的图像有关系。如果没有这种一对一的关系,您将无法识别哪个图像与特定的文章相关联。

如果你想给每篇文章关联多个图片,会发生什么?您可以在blog表中创建几列来保存外键,但是这很快就变得难以处理了。你可能以image1image2image3开始,但是如果大多数文章只有一张图片,那么两栏在很多时候是多余的。你会为那篇需要四张图片的特别文章增加一个额外的专栏吗?

当面临适应多对多(或 n:m )关系的需求时,您需要一种不同的方法。imagesblog表没有包含足够的记录来展示n:m关系,但是您可以添加一个categories表来标记单独的文章。大多数文章可能属于多个类别,每个类别都与几篇文章相关。

解决复杂关系的方法是通过一个交叉引用表(有时称为链接表),它在相关记录之间建立一系列一对一的关系。这是一个特殊的表,只包含两列,这两列都声明为联合主键。图 17-3 显示了其工作原理。交叉引用表中的每条记录都存储了blogcategories表中各个文章之间关系的详细信息。要查找属于Kyoto类别的所有文章,您需要将categories表中的cat_id 1与交叉引用表中的cat_id 1进行匹配。这将blog表中带有article_id 234的记录标识为与Kyoto相关联。

img/332054_5_En_17_Fig3_HTML.jpg

图 17-3

交叉引用表将多对多关系解析为 1:1

通过外键建立表之间的关系对于如何更新和删除记录有着重要的意义。如果你不小心,你会以断链而告终。确保依赖关系不被破坏被称为维护参照完整性。我们将在下一章讨论这个重要的课题。首先,让我们集中检索存储在通过外键关系链接的单独表中的信息。

将图像链接到文章

为了演示如何使用多个表,让我们从图 17-1 和 17-2 中概述的简单场景开始:通过将一个表(父表)中的主键存储为另一个表(子表或依赖表)中的外键,可以将关系解析为1:1。这涉及到在子表中添加一个额外的列来存储外键。

改变现有表的结构

理想情况下,您应该在用数据填充数据库之前设计好数据库结构。然而,关系数据库,如 MySQL,非常灵活,允许您添加、删除或更改表中的列,即使它们已经包含记录。要将图像与数据库phpsols中的单个文章相关联,您需要向blog表中添加一个额外的列,以将image_id存储为外键。

PHP 解决方案 17-1:向表中添加额外的列

这个 PHP 解决方案展示了如何使用 phpMyAdmin 向现有的表中添加额外的列。它假设您在第十五章的phpsols数据库中创建了blog表。

  1. 在 phpMyAdmin 中,选择phpsols数据库,并单击blog表的Structure链接。

  2. blog表格结构下面是一个允许您添加额外列的表单。您希望只添加一列,所以在Add column(s)文本框中使用默认值就可以了。通常的做法是将外键放在表的主键之后,所以从下拉菜单中选择after article_id,如下图所示。然后点击Go:

img/332054_5_En_17_Figa_HTML.jpg

  1. 这将为您打开定义列属性的屏幕。使用以下设置:

    • 名称:image_id

    • 类型:INT

    • 属性:UNSIGNED

    • 空:已选择

    • Index: INDEX(不需要在弹出的模态对话框中给它命名)

不要选择A_I ( AUTO_INCREMENT)复选框。你不希望image_id自动递增。其值将从images表中插入。

选择Null复选框是因为并非所有文章都与图像相关联。点击Save

  1. 选择Structure选项卡,检查blog表结构现在看起来像这样:

img/332054_5_En_17_Figb_HTML.jpg

  1. 如果您单击屏幕左上方的Browse选项卡,您将看到在每条记录中image_id的值为空。现在的挑战是插入正确的外键,而不需要手动查找数字。我们接下来会解决这个问题。

在表中插入外键

在另一个表中插入外键的基本原理非常简单:查询数据库以找到想要链接到另一个表的记录的主键。然后,您可以使用一个INSERTUPDATE查询将外键添加到目标记录中。

为了演示基本原理,您将修改第 15 ( blog_update_mysqli.phpblog_update_pdo.php)章中的更新表单,以添加一个下拉菜单,列出已经在图像表中注册的图像(参见图 17-4 )。

img/332054_5_En_17_Fig4_HTML.jpg

图 17-4

一个动态生成的下拉菜单插入适当的外键

菜单是由一个显示SELECT查询结果的循环动态生成的。每个图像的主键存储在<option>标签的value属性中。提交表单时,选择的值作为外键被合并到UPDATE查询中。

PHP 解决方案 17-2:添加图像外键(MySQLi)

这个 PHP 解决方案展示了如何通过添加选定图像的主键作为外键来更新blog表中的记录。它改编自第十五章中的admin/blog_update_mysqli.php。使用您在第十五章中创建的版本。或者,将blog_update_mysqli_03.phpch15文件夹复制到admin文件夹,并从文件名中删除_03

  1. 检索要更新的文章的细节的现有的SELECT查询需要修改,以便它包括外键image_id,并且结果需要绑定到新的结果变量$image_id。然后,您需要运行第二个SELECT查询来获取images表的细节。在这样做之前,您需要通过调用准备好的语句的free_result()方法来释放数据库资源。将下面以粗体突出显示的代码添加到现有脚本中:

    if (isset($_GET['article_id']) && !$_POST) {
        // prepare SQL query
        $sql = 'SELECT article_id, image_id, title, article FROM blog
            WHERE article_id = ?';
        if ($stmt->prepare($sql)) {
            // bind the query parameter
            $stmt->bind_param('i', $_GET['article_id']);
            // execute the query
            $OK = $stmt->execute();
            // bind the results to variables and fetch
            $stmt->bind_result($article_id, $image_id, $title, $article);
            $stmt->fetch();
            // free the database resources for the second query
            $stmt->free_result();
        }
    }
    
    

您可以在调用fetch()方法后立即释放结果,因为结果集中只有一条记录,并且每一列中的值都被绑定到一个变量。

  1. 在表单内部,您需要显示存储在images表中的文件名。因为第二个SELECT语句不依赖于外部数据,所以使用query()方法比使用预准备语句更简单。在article文本区域后添加以下代码(这都是新代码,但是为了便于参考,PHP 部分用粗体突出显示):

    <p>
        <label for="image_id">Uploaded image:</label>
        <select name="image_id" id="image_id">
            <option value="">Select image</option>
            <?php
            // get the list images
            $getImages = 'SELECT image_id, filename
                          FROM images ORDER BY filename';
            $images = $conn->query($getImages);
            while ($row = $images->fetch_assoc()) {
                ?>
                <option value="<?= $row['image_id'] ?>"
                    <?php
                     if ($row['image_id'] == $image_id) {
                         echo 'selected';
                     }
                     ?>><?= safe($row['filename']) ?></option>
            <?php } ?>
        </select>
    </p>
    
    

第一个<option>标签被硬编码为标签Select image,它的value被设置为空字符串。剩余的<option>标签由一个while循环填充,该循环将每条记录提取到一个名为$row的数组中。

条件语句检查当前的image_id是否与已经存储在articles表中的相同。如果是,selected被插入到<option>标签中,以便在下拉菜单中显示正确的值。

确保不要省略下一行中的第三个字符:

?>><?= safe($row['filename']) ?></option>

它是<option>标签的结束尖括号,夹在两个 PHP 标签之间。

  1. 保存页面并将其加载到浏览器中。您应该会被自动重定向到blog_list_mysqli.php。选择其中一个编辑链接,确保你的页面看起来如图 17-4 所示。检查浏览器源代码视图,确认<option>标签的value属性包含每张图片的主键。

    Tip

    如果<select>菜单没有列出图像,那么几乎可以肯定第 2 步中的SELECT查询有错误。在调用query()方法后立即添加echo $conn->error;,并重新加载页面。您需要查看浏览器源代码来查看错误消息。如果消息是“命令不同步;您现在不能运行这个命令”,问题在于在步骤 1 中没有用free_result()释放数据库资源。

  2. 最后一步是将image_id添加到UPDATE查询中。因为一些博客条目可能与图像没有关联,所以您需要创建替代的准备好的语句,如下所示:

    // if form has been submitted, update record
    if (isset($_POST ['update'])) {
        // prepare update query
        if (!empty($_POST['image_id'])) {
            $sql = 'UPDATE blog SET image_id = ?, title = ?, article = ?
                        WHERE article_id = ?';
            if ($stmt->prepare($sql)) {
                $stmt->bind_param('issi', $_POST['image_id'], $_POST['title'],
                    $_POST['article'], $_POST['article_id']);
                $done = $stmt->execute();
            }
        } else {
            $sql = 'UPDATE blog SET image_id = NULL, title = ?, article = ?
                       WHERE article_id = ?';
            if ($stmt->prepare($sql)) {
                $stmt->bind_param('ssi', $_POST['title'], $_POST['article'],
                    $_POST['article_id']);
                $done = $stmt->execute();
            }
        }
    }
    
    

如果$_POST['image_id']有一个值,您可以用占位符问号将它作为第一个参数添加到 SQL 中。因为它必须是一个整数,所以您将i添加到bind_param()的第一个参数的开头。

但是,如果$_POST['image_id']不包含值,您需要创建一个不同的预准备语句,在 SQL 查询中将image_id的值设置为NULL。因为它有一个显式值,所以你不把它加到bind_param()里。

  1. 再次测试页面,从下拉菜单中选择一个文件名,然后单击Update Entry。您可以通过刷新 phpMyAdmin 中的Browse或者选择相同的文章进行更新来验证外键是否已经被插入到了articles表中。这一次,正确的文件名应该显示在下拉菜单中。

如有必要,对照ch17文件夹中的blog_update_mysqli_04.php检查您的代码。

PHP 解决方案 17-3:添加图像外键(PDO)

这个 PHP 解决方案使用 PDO 通过添加一个选中图像的主键作为外键来更新blog表中的记录。与 MySQLi 的主要区别在于,PDO 可以使用bindValue()方法将null值绑定到占位符。这些说明改编自第十五章中的admin/blog_update_pdo.php。使用您在第十五章中创建的版本。或者,将blog_update_pdo_03.phpch15文件夹复制到admin文件夹,并从文件名中删除_03

  1. image_id添加到检索待更新文章详细信息的SELECT查询中,并将结果绑定到$image_id。这包括对作为第一个参数传递给$title$articlebindColumn()的列进行重新编号。修改后的代码如下所示:

    if (isset($_GET['article_id']) && !$_POST) {
        // prepare SQL query
        $sql = 'SELECT article_id, image_id, title, article FROM blog
                   WHERE article_id = ?';
        $stmt = $conn->prepare($sql);
        // pass the placeholder value to execute() as a single-element array
        $OK = $stmt->execute([$_GET['article_id']]);
        // bind the results
        $stmt->bindColumn(1, $article_id);
        $stmt->bindColumn(2, $image_id);
        $stmt->bindColumn(3, $title);
        $stmt->bindColumn(4, $article);
        $stmt->fetch();
    }
    
    
  2. 在表单内部,您需要显示存储在images表中的文件名。因为第二个SELECT语句不依赖于外部数据,所以使用query()方法比使用预准备语句更简单。在article文本区域后添加以下代码(这都是新代码,但是为了便于参考,PHP 部分用粗体突出显示):

    <p>
        <label for="image_id">Uploaded image:</label>
        <select name="image_id" id="image_id">
            <option value="">Select image</option>
            <?php
            // get the list images
            $getImages = 'SELECT image_id, filename
                          FROM images ORDER BY filename';
            foreach ($conn->query($getImages) as $row) {
                ?>
                <option value="<?= $row['image_id'] ?>"
                    <?php
                    if ($row['image_id'] == $image_id) {
                        echo 'selected';
                    }
                    ?>><?= safe($row['filename']) ?></option>
            <?php } ?>
        </select>
    </p>
    
    

第一个<option>标签被硬编码为标签Select image,它的value被设置为空字符串。剩余的<option>标记由一个foreach循环填充,该循环执行$getImages SELECT查询并将每条记录提取到一个名为$row的数组中。

条件语句检查当前的image_id是否与已经存储在articles表中的相同。如果是,selected被插入到<option>标签中,以便在下拉菜单中显示正确的值。

确保不要省略下一行中的第三个字符:

?>><?= safe($row['filename']) ?></option>

它是<option>标签的结束尖括号,夹在两个 PHP 标签之间。

  1. 保存页面并将其加载到浏览器中。您应该会被自动重定向到blog_list_pdo.php。选择其中一个EDIT链接,确保你的页面看起来如图 17-4 。检查浏览器源代码视图,验证<option>标签的值属性包含每个图像的主键。

  2. 最后一步是将image_id添加到UPDATE查询中。当一个博客条目不与图像相关联时,您需要在image_id栏中输入null。这包括更改值绑定到预准备语句中的匿名占位符的方式。您需要使用bindValue()bindParam(),而不是将它们作为数组传递给execute()方法。修改后的代码如下所示:

    // if form has been submitted, update record
    if (isset($_POST['update'])) {
        // prepare update query
        $sql = 'UPDATE blog SET image_id = ?, title = ?, article = ?
                   WHERE article_id = ?';
        $stmt = $conn->prepare($sql);
        if (empty($_POST['image_id'])) {
            $stmt->bindValue(1, NULL, PDO::PARAM_NULL);
        } else {
            $stmt->bindParam(1, $_POST['image_id'], PDO::PARAM_INT);
        }
        $stmt->bindParam(2, $_POST['title'], PDO::PARAM_STR);
        $stmt->bindParam(3, $_POST['article'], PDO::PARAM_STR);
        $stmt->bindParam(4, $_POST['article_id'], PDO::PARAM_INT);
        // execute query
        $done = $stmt->execute();
    }
    
    

使用数字将这些值绑定到匿名占位符,从 1 开始计数,以标识它们应该应用到哪个占位符。条件语句检查$_POST['image_id']是否为空。如果是,bindValue()将值设置为null,使用关键字NULL作为第二个参数,使用 PDO 常数作为第三个参数。正如在第十三章的“在 PDO 预准备语句中嵌入变量”中所解释的,当被绑定的值不是变量时,你需要使用bindValue()

其余的值都是变量,所以它们使用bindParam()绑定。我用 PDO 常数表示整数,用字符串表示剩余的值。这不是绝对必要的,但它使代码更清晰。

最后,从execute()方法的括号中删除了值数组。

  1. 再次测试页面,从下拉菜单中选择一个文件名,然后单击Update Entry。您可以通过刷新 phpMyAdmin 中的Browse或者选择相同的文章进行更新来验证外键是否已经被插入到了articles表中。这一次,正确的文件名应该显示在下拉菜单中。

如有必要,对照ch17文件夹中的blog_update_pdo_04.php检查您的代码。

从多个表中选择记录

有几种方法可以在一个SELECT查询中链接表,但是最常用的是列出表名,用INNER JOIN隔开。INNER JOIN独自产生所有可能的行组合(笛卡尔连接)。要仅选择相关值,您需要指定主键/外键关系。例如,要从blogimages表中选择文章及其相关图像,您可以使用一个WHERE子句,如下所示:

SELECT title, article, filename, caption
FROM blog INNER JOIN images
WHERE blog.image_id = images.image_id

titlearticle列只存在于blog表中。同样,filenamecaption只存在于images表中。他们是明确的,不需要被限定。但是,image_id在两个表中都存在,所以您需要在每个引用前面加上表名和句点。

多年来,用逗号代替INNER JOIN是一种常见的做法,就像这样:

SELECT title, article, filename, caption
FROM blog, images
WHERE blog.image_id = images.image_id

Caution

使用逗号连接表会导致 SQL 语法错误,因为从 MySQL 5.0.12 开始,连接的处理方式发生了变化。用INNER JOIN代替。

你可以用ON代替WHERE子句,就像这样:

SELECT title, article, filename, caption
FROM blog INNER JOIN images ON blog.image_id = images.image_id

当两列具有相同的名称时,您可以使用下面的语法,这是我个人的偏好:

SELECT title, article, filename, caption
FROM blog INNER JOIN images USING (image_id)

Note

USING后的列名必须在括号中。

PHP 解决方案 17-4:构建详细页面

这个 PHP 解决方案展示了如何连接blogimages表来显示一篇选中的文章及其相关的图片。MySQLi 和 PDO 的代码几乎相同,所以这个解决方案涵盖了这两者。

img/332054_5_En_17_Fig5_HTML.jpg

图 17-5

详细信息页面包含一个占位符图像和文本

  1. ch17文件夹中的details_01.php复制到php8sols站点根目录,并重命名为details.php。如果编辑环境提示您更新链接,请不要这样做。确保footer.phpmenu.phpincludes文件夹中,并在浏览器中加载页面。它看起来应该如图 17-5 所示。

img/332054_5_En_17_Fig6_HTML.jpg

图 17-6

不与图像相关联的项目的外键被设置为空

  1. blog_list_mysqli.phpblog_list_pdo.php加载到浏览器中,并通过指定所示的图像文件名来更新以下三篇文章:

    • 满足的盆地:basin.jpg

    • 拥挤在一起的小餐馆:menu.jpg

    • 见习艺妓逛街:maiko.jpg

  2. 导航到 phpMyAdmin 中的blog表,并单击Browse选项卡检查外键是否已经注册。至少有一条image_id的值为NULL,如图 17-6 所示。

  3. 在试图显示一幅图像之前,我们需要确保它来自我们期望的地方,并且它确实是一幅图像。在 details.php 的顶部创建一个变量来存储图像目录的相对路径(以斜杠结束),如下所示:

    // Relative path to image directory
    $imageDir = './img/';
    
    
  4. 接下来,包含上一章的utility_funcs.php(如有必要,将其从ch16文件夹复制到includes文件夹)。然后包含数据库连接文件,创建一个只读连接,并在DOCTYPE声明上方的 PHP 代码块中准备 SQL 查询,如下所示:

require_once './includes/utility_funcs.php';
require_once './includes/connection.php';
// connect to the database
$conn = dbConnect('read');  // add 'pdo' if necessary
// check for article_id in query string
$article_id = isset($_GET['article_id']) ? (int) $_GET['article_id'] : 0;
$sql = "SELECT title, article,DATE_FORMAT(updated, '%W, %M %D, %Y') AS updated,
            filename, caption
            FROM blog INNER JOIN images USING (image_id)
            WHERE blog.article_id = $article_id";
$result = $conn->query($sql);
$row = $result->fetch_assoc();  // for PDO use $result->fetch();

该代码使用三元运算符检查URL查询字符串中的article_id。用isset()测试其存在的条件。如果返回true,使用(int)转换操作符将值分配给$article_id,以确保它是一个整数。否则,$article_id被设置为0。你可以选择一篇默认的文章,但是暂时把它放在0上,因为我想说明一个重要的观点。

SELECT查询从blog表中检索titlearticleupdated列,从images表中检索filenamecaption列。如第十六章所述,使用DATE_FORMAT()函数和别名对updated的值进行格式化。因为只检索一条记录,所以使用原始列名作为别名不会导致排序顺序问题。

使用与两个表中的image_id列中的值相匹配的INNER JOINUSING子句来连接这两个表。WHERE子句选择由$article_id标识的商品。因为已经检查了$article_id的数据类型,所以在查询中使用它是安全的。没有必要使用预先准备好的语句。

注意,该查询用双引号括起来,以便解释$article_id的值。为了避免与外面的一对引号冲突,在作为参数传递给DATE_FORMAT()的格式字符串周围使用了单引号。

  1. 既然我们已经查询了数据库,我们可以检查图像。为了确保它在我们期望的地方,将$row['filename']的值传递给basename()函数,并将结果连接到图像目录的相对路径。然后我们可以检查文件是否存在并且可读。如果是,使用getimagesize()得到它的宽度和高度。在上一步插入的代码后立即添加以下代码:

    if ($row && !empty($row['filename'])) {
        $image = $imageDir . basename($row['filename']);
        if (file_exists($image) && is_readable($image)) {
            $imageSize = getimagesize($image)[3];
        }
    }
    
    

正如 PHP 解决方案 10-1 第十章 10 中所解释的那样,getimagesize()返回一个关于图像的信息数组,包括索引 3 处的一个字符串,该字符串包含准备插入到<img>标签中的正确的宽度和高度属性。这里,我们使用数组解引用将它直接赋给$imageSize

  1. 其余代码在页面主体中显示 SQL 查询的结果。替换<h2>标签中的占位符文本,如下所示:

    <h2><?php if ($row) {
            echo safe($row['title']);
        } else {
            echo 'No record found';
        }
        ?>
    </h2>
    
    

如果SELECT查询没有找到结果,$row将为空,PHP 将其解释为false。因此,如果结果集为空,将显示标题或“未找到记录”。

  1. 像这样替换占位符日期:

  2. 紧跟在日期段落之后的是一个包含占位符图像的<figure>元素。并非所有文章都与图像相关联,因此需要将<figure>包装在一个条件语句中,以检查$imageSize是否包含值。这样修改<figure>:

    <?php if (!empty($imageSize)) { ?>
       <figure>
           <img src="<?= $image ?>" alt="<?= safe($row['caption']) ?>" <?= $imageSize ?>>
       </figure>
    <?php } ?>
    
    
  3. 最后,你需要展示文章。删除占位符文本的段落,并将以下代码添加到上一步中最后一个代码块末尾的右大括号和右 PHP 标记之间:

<p><?php if ($row) { echo $row['updated']; } ?></p>

<?php } if ($row) { echo convertToParas($row['article']); } ?>

它使用utility_funcs.php中的convertToParas()函数将博客条目包装在<p>标签中,并用结束和开始标签替换换行符序列(参见第十六章中的“显示段落”)。

  1. 保存页面并将blog.php加载到浏览器中。点击文章的More链接,该文章具有通过外键分配的图像。你应该可以看到details.php,文章和图片的全文如图 17-7 所示。

如有必要,用ch17文件夹中的details_mysqli_01.phpdetails_pdo_01.php检查你的代码。

img/332054_5_En_17_Fig7_HTML.jpg

图 17-7

详细信息页面从一个表中提取文章,从另一个表中提取图像

img/332054_5_En_17_Fig8_HTML.jpg

图 17-8

缺少关联的图像会导致选择查询失败

  1. 点击返回blog.php的链接,测试其他项目。每篇有相关图片的文章都应该正确显示。点击没有图片的文章的More链接。这次你应该会看到如图 17-8 所示的结果。

您知道文章在数据库中,因为前两个句子不会显示在blog.php中。为了理解这种突然的“消失”,请参考图 17-6 。对于没有相关图像的记录,image_id的值是NULL。因为images表中的所有记录都有一个主键,所以USING子句找不到匹配项。下一节将解释如何处理这种情况。

查找没有匹配外键的记录

从 PHP 解决方案 17-4 中复制SELECT查询,并删除搜索特定文章的条件,剩下如下内容:

SELECT title, article, DATE_FORMAT(updated, '%W, %M %D, %Y') AS updated, filename, caption
FROM blog INNER JOIN images USING (image_id)

如果您在 phpMyAdmin 的 SQL 选项卡中运行这个查询,它会产生如图 17-9 所示的结果。

img/332054_5_En_17_Fig9_HTML.jpg

图 17-9

内部联接只查找在两个表中都匹配的记录

使用INNER JOIN,SELECT查询只成功找到完全匹配的记录。其中一篇文章没有相关联的图像,因此articles表中的image_id的值是NULL,它与images表中的任何内容都不匹配。

在这种情况下,你需要使用LEFT JOIN而不是INNER JOIN。使用LEFT JOIN,结果包括在左表中匹配,但在右表中不匹配的记录。“左”和“右”是指执行连接的顺序。像这样重写SELECT查询:

SELECT title, article, DATE_FORMAT(updated, '%W, %M %D, %Y') AS updated, filename, caption
FROM blog LEFT JOIN images USING (image_id)

当您在 phpMyAdmin 中运行它时,您会得到所有四篇文章,如图 17-10 所示。

img/332054_5_En_17_Fig10_HTML.jpg

图 17-10

左连接包括在右表中没有匹配项的记录

如您所见,右表中的空字段(images)显示为NULL

如果两个表中的列名不同,请像这样使用 ON:

FROM table_1 LEFT JOIN table_2 ON table_1.col_name = table_2.col_name

所以现在您可以像这样重写details.php中的 SQL 查询:

$sql = "SELECT title, article, DATE_FORMAT(updated, '%W, %M %D, %Y') AS updated,
            filename, caption
            FROM blog LEFT JOIN images USING (image_id)
            WHERE blog.article_id = $article_id";

如果您点击More链接查看没有关联图片的文章,您现在应该看到文章正确显示,如图 17-11 所示。其他文章也应该仍然可以正确显示。完成的代码可以在details_mysqli_02.phpdetails_pdo_02.php中找到。

img/332054_5_En_17_Fig11_HTML.jpg

图 17-11

LEFT JOIN 还检索没有匹配外键的文章

创建智能链接

details.php底部的链接直接回到blog.php。在blog表中只有四个条目没问题,但是一旦你开始在数据库中获得更多的记录,你就需要建立一个导航系统,正如我在第十四章中向你展示的那样。导航系统的问题是,你需要一种方法让访问者返回到他们来自的结果集中的同一点。

PHP 解决方案 17-5:回到导航系统中的同一点

这个 PHP 解决方案检查访问者来自内部还是外部链接。如果引用的页面在同一个站点内,链接会将访问者返回到同一个地方。如果引用的页面是一个外部站点,或者服务器不支持必要的超级全局变量,脚本会用一个标准链接来代替。这里显示的是details.php的上下文,但是它可以用在任何页面上。

代码不依赖于数据库,所以它对 MySQLi 和 PDO 是一样的。

  1. details.php的主体中找到后连杆。看起来是这样的:

  2. 将光标放在第一个引号的右侧,插入以粗体突出显示的以下代码:

    <p><a href="
    <?php
    // check that browser supports $_SERVER variables
    if (isset($_SERVER['HTTP_REFERER']) && isset($_SERVER['HTTP_HOST'])) {
        $url = parse_url($_SERVER['HTTP_REFERER']);
        // find if visitor was referred from a different domain
        if ($url['host'] == $_SERVER['HTTP_HOST']) {
            // if same domain, use referring URL
            echo $_SERVER['HTTP_REFERER'];
        }
    } else {
        // otherwise, send to main page
        echo 'blog.php';
    } ?>">Back to the blog</a></p>
    
    
<p><a href="blog.php">Back to the blog</a></p>

$_SERVER['HTTP_REFERER']$_SERVER['HTTP_HOST']是超全局变量,包含引用页面的 URL 和当前主机名。你需要用isset()检查它们的存在,因为不是所有的服务器都支持它们。此外,浏览器可能会阻止引用页面的 URL。

parse_url()函数创建一个包含 URL 的每个部分的数组,所以$url['host']包含主机名。如果它与$_SERVER['HTTP_HOST']匹配,您就知道该访问者是由一个内部链接推荐的,所以内部链接的完整 URL 被插入到href属性中。这包括任何查询字符串,因此链接会将访问者送回导航系统中的相同位置。否则,将创建一个指向目标页面的普通链接。

完成的代码在ch17文件夹的details_mysqli_03.phpdetails_pdo_3.php中。

第三章回顾

使用INNER JOINLEFT JOIN检索存储在多个表中的信息相对简单。成功使用多个表的关键在于构建它们之间的关系,这样复杂的关系总是可以通过1:1来解决,如果需要的话,可以通过交叉引用(或链接)表来解决。下一章继续探索使用多个表,向您展示在插入、更新和删除记录时如何处理外键关系。

十八、管理多个数据库表

前一章向您展示了如何使用INNER JOINLEFT JOIN来检索存储在多个表中的信息。您还了解了如何通过向子表添加额外的一列来链接现有的表,并单独更新每条记录以插入外键。但是,大多数情况下,您会希望在两个表中同时插入数据。这带来了挑战,因为INSERT命令一次只能在一个表上操作。您需要以正确的顺序处理插入操作,从父表开始,这样您就可以获得新记录的主键,并将其与其他细节同时插入到子表中。在更新和删除记录时,也需要考虑类似的因素。所涉及的代码并不难,但是在构建脚本时,您需要清楚地记住事件的顺序。

本章将指导您在blog表格中插入新文章,选择相关图片或上传新图片,并将文章分配到一个或多个类别,所有这些都在一个操作中完成。然后,您将构建脚本来更新和删除文章,而不会破坏相关表的引用完整性。

您还将了解如何使用事务将多个查询作为一个批处理来处理,如果批处理的任何部分失败,将数据库回滚到其原始状态,以及外键约束,外键约束控制在您试图删除另一个表中仍然具有外键关系的记录时会发生什么。并不是所有的数据库都支持事务和外键约束,所以检查您的远程服务器是否支持是很重要的。本章还解释了如果您的服务器不支持外键约束,您可以采取什么措施来保持数据的完整性。

特别是,您将了解以下内容:

  • 在相关表中插入、更新和删除记录

  • 创建记录后立即查找记录的主键

  • 将多个查询作为单个批处理进行处理,并在任何部分失败时回滚

  • 转换表的存储引擎

  • 在 InnoDB 表之间建立外键约束

维护引用完整性

对于单表,更新一条记录的频率或删除多少条记录都无关紧要;对其他记录的影响为零。一旦将记录的主键作为外键存储在不同的表中,就创建了一个需要管理的依赖项。例如,图 18-1 显示了来自blog表的第二篇文章(“见习艺妓去购物”)通过article2cat对照表链接到KyotoPeople类别。

img/332054_5_En_18_Fig1_HTML.jpg

图 18-1

您需要管理外键关系以避免孤立记录

如果您删除了文章,但未能删除交叉引用表中的article_id 2条目,则在KyotoPeople类别中查找所有文章的查询会尝试匹配blog表中不存在的记录。同样,如果您决定删除其中一个类别,而不删除交叉引用表中的匹配记录,则查找与文章关联的类别的查询会尝试匹配一个不存在的类别。

不久之后,您的数据库中就会充斥着孤立的记录。幸运的是,维护引用完整性并不困难。SQL 通过建立称为外键约束的规则来做到这一点,外键约束告诉数据库当您更新或删除在另一个表中有依赖记录的记录时该做什么。

支持事务和外键约束

MySQL 5.5 和更高版本中的默认存储引擎 InnoDB 支持事务和外键约束。MariaDB 中的等效存储引擎是 Percona XtraDB,但它将自己标识为 InnoDB,并具有相同的功能。即使你的远程服务器运行的是 MySQL 或 MariaDB 的最新版本,也不能保证 InnoDB 是受支持的,因为你的托管公司可能已经禁用了它。

如果您的服务器运行的是旧版本的 MySQL,默认的存储引擎是 MyISAM,它不支持事务或外键约束。但是,您仍然可以访问 InnoDB,因为它从 4.0 版开始就是 MySQL 不可或缺的一部分。将 MyISAM 表转换成 InnoDB 非常简单,只需要几秒钟。

如果您不能访问 InnoDB,那么您需要通过在 PHP 脚本中构建必要的规则来维护引用完整性。本章展示了这两种方法。

Note

MyISAM 表的优点是速度非常快。它们需要较少的磁盘空间,非常适合存储不经常更改的大量数据。然而,MyISAM 引擎已经不再被积极开发,所以不建议将其用于新项目。

PHP 解决方案 18-1:检查是否支持 InnoDB

这个 PHP 解决方案解释了如何检查您的远程服务器是否支持 InnoDB 存储引擎。

img/332054_5_En_18_Fig2_HTML.jpg

图 18-2

通过 phpMyAdmin 检查存储引擎支持

  1. 如果你的托管公司提供 phpMyAdmin 来管理你的数据库,在你的远程服务器上启动 phpMyAdmin,点击屏幕顶部的Engines标签,如果它可用的话。这将显示类似于图 18-2 的存储引擎列表。

Note

图 18–2 中的截图是在 MariaDB 服务器上拍摄的。您可能会在 MySQL 服务器上看到不同的存储引擎选择,但是 MySQL 和 MariaDB 通常都应该至少提供 InnoDB 和 MyISAM。Aria 存储引擎是 MariaDB 对 MyISAM 的改进版本。本书没有涉及它,因为它在 MySQL 上不可用,也不支持事务或外键约束。

img/332054_5_En_18_Fig3_HTML.jpg

图 18-3

确认支持 InnoDB

  1. 该列表显示所有存储引擎,包括不受支持的存储引擎。不支持或禁用的存储引擎呈灰色显示。如果您不确定 InnoDB 的状态,请在列表中单击它的名称。

  2. 如果不支持 InnoDB,您会看到一条消息告诉您这一点。另一方面,如果您看到类似于图 18-3 的变量列表,那么您很幸运——InnoDB 是受支持的。

img/332054_5_En_18_Fig4_HTML.jpg

图 18-4

表选项中列出了可用的存储引擎

  1. 如果 phpMyAdmin 中没有Engines选项卡,选择数据库中的任意一个表,然后单击屏幕右上角的Operations选项卡。在Table options部分,点击Storage engine字段右侧的向下箭头显示可用选项(参见图 18-4 )。如果列出了 InnoDB,它是受支持的。

img/332054_5_En_18_Fig5_HTML.jpg

图 18-5

storage_engines.php 中的 SQL 查询报告了支持哪些

  1. 如果前面的方法都没有给你答案,打开ch18文件夹中的storage_engines.php。编辑前三行,在远程服务器上插入数据库的主机名、用户名和密码。

  2. storage_engines.php上传到你的网站,并将页面加载到浏览器中。您应该会看到存储引擎和支持级别的列表,如图 18-5 所示。在某些情况下,NO会被DISABLED代替。

如图 18-5 所示,一个典型的安装支持多个存储引擎。令人惊讶的是,您可以在同一个数据库中使用不同的存储引擎。事实上,建议你这样做。即使您的远程服务器支持 InnoDB,对于不需要事务或没有外键关系的表,使用 MyISAM 或 Aria 通常更有效。对于需要事务或具有外键关系的表,使用 InnoDB。

我将在本章的后面解释如何将表格转换成 InnoDB。在此之前,让我们看看如何建立和使用外键关系,而不管使用的是什么存储引擎。

将记录插入多个表中

一个INSERT查询只能向一个表中插入数据。因此,在处理多个表时,需要仔细规划插入脚本,以确保存储所有信息并建立正确的外键关系。

上一章的 PHP 解决方案 17-2 (MySQLi)和 17-3 (PDO)展示了如何为已经在数据库中注册的图像添加正确的外键。然而,当插入一个新的博客条目时,您需要能够选择一个现有的图像,上传一个新的图像,或者选择没有图像。这意味着您的处理脚本需要检查图像是否已被选择或上传,并相应地执行相关命令。此外,用零个或多个类别标记博客条目增加了脚本需要做出的决策数量。图 18-6 显示了决策链。

img/332054_5_En_18_Fig6_HTML.png

图 18-6

插入带有图像和类别的新博客文章的决策链

当页面第一次加载时,表单还没有提交,所以页面只显示插入表单。通过查询数据库,现有图像和类别都列在插入表单中,查询方式与 PHP 解决方案 17-2 和 17-3 中更新表单中的图像相同。

提交表单后,处理脚本将执行以下步骤:

  1. 如果已经上传了一个图像,则处理上传,图像的细节存储在images表中,脚本获得新记录的主键。

  2. 如果没有上传图像,但是选择了一个现有的图像,那么脚本从通过$_POST数组提交的值中获取外键。

  3. 在这两种情况下,新的博客文章会作为外键与图像的主键一起插入到blog表中。但是,如果既没有上传图像,也没有从现有图像中选择图像,那么文章将被插入到blog表中,而没有外键。

  4. 最后,脚本检查是否选择了任何类别。如果有,脚本会获取新文章的主键,并将其与article2cat表中所选类别的主键相结合。

如果在任何阶段出现问题,脚本都需要放弃流程的其余部分,重新显示用户的输入。这个剧本很长,所以我将把它分成几个部分。第一步是创建article2cat对照表。

创建交叉引用表

当处理数据库中的多对多关系时,你需要建立一个对照表,如图 18-1 所示。交叉引用表只包含两列,它们被共同声明为表的主键(称为复合主键)。如果你查看图 18-7 ,你会看到article_idcat_id列都多次包含相同的数字——这在主键中是不可接受的,主键必须是唯一的。但是,在复合主键中,两个值的组合是唯一的。前两个组合1,32,1不会在表中的任何地方重复出现,其他组合也不会。

img/332054_5_En_18_Fig7_HTML.jpg

图 18-7

在交叉引用表中,两列一起构成一个复合主键

设置类别和交叉引用表

ch18文件夹中,您将找到categories.sql,它包含创建categories表和交叉引用表article2cat的 SQL,以及一些示例数据。在 phpMyAdmin 中,选择phpsols数据库,并使用Import选项卡加载categories.sql来创建表格和数据。表 18-1 和 18-2 中列出了这些表的设置。两个数据库表都只有两列。

表 18-2

article2cat 对照表设置

|

名字

|

类型

|

长度/值

|

属性

|

|

索引

|

阿奇

|
| --- | --- | --- | --- | --- | --- | --- |
| article_id | INT |   | UNSIGNED | 取消选择 | PRIMARY |   |
| cat_id | INT |   | UNSIGNED | 取消选择 | PRIMARY |   |

表 18-1

类别表的设置

|

名字

|

类型

|

长度/值

|

属性

|

|

索引

|

A_I

|
| --- | --- | --- | --- | --- | --- | --- |
| cat_id | INT |   | UNSIGNED | 取消选择 | PRIMARY | 挑选 |
| category | VARCHAR | Twenty |   | 取消选择 |   |   |

关于对照表的定义,重要的一点是两列都设置为主键,并且两列都没有选中A_I ( AUTO_INCREMENT)复选框。

Caution

若要创建复合主键,必须同时将两列声明为主键。如果您错误地只声明了一个主键,数据库会阻止您在以后添加第二个主键。您必须从单个列中删除主键索引,然后将其重新应用于两个列。这两列的组合被视为主键。

获取上传图像的文件名

这个脚本使用了第九章的Upload类,但是这个类需要稍微调整一下,因为上传文件的文件名被合并到了$messages属性中。

PHP 解决方案 18-2:改进上传类

这个 PHP 解决方案修改了第九章中的Upload类,创建了一个新的受保护属性来存储成功上传的文件的名称,并使用一个公共方法来检索名称数组。

  1. 打开Php8Solutions/File文件夹中的Upload.php。或者,从ch18/Php8Solutions/File文件夹中复制Upload.php并保存在php8sols站点根目录下的Php8Solutions/File中。

  2. 将以下行添加到文件顶部的属性列表中:

protected $filenames = [];

这将名为$filenames的受保护属性初始化为一个空数组。

  1. 修改moveFile()方法,如果文件上传成功,将修改后的文件名添加到$filenames属性中。新代码以粗体突出显示:

    protected function moveFile($file) {
        $filename = $this->newName ?? $file['name'];
        $success = move_uploaded_file($file['tmp_name'], $this->destination . $filename);
        if ($success) {
            // add the amended filename to the array of uploaded files
            $this->filenames[] = $filename;
            $result = $file['name'] . ' was uploaded successfully';
            if (!is_null($this->newName)) {
                $result .= ', and was renamed ' . $this->newName;
            }
            $this->messages[] = $result;
        } else {
            $this->messages[] = 'Could not upload ' . $file['name'];
        }
    }
    
    

仅当文件成功移动到目标文件夹时,该名称才会添加到$filenames数组中。

  1. 添加一个公共方法来返回存储在$filenames属性中的值。代码如下所示:

    public function getFilenames() {
        return $this->filenames;
    }
    
    

将这些代码放在类定义中的什么地方并不重要,但是将所有公共方法放在一起是一种常见的做法。

  1. 保存Upload.php。如果你需要检查你的代码,将它与ch18/Php8Solutions/File文件夹中的Upload_01.php进行比较。

调整插入表单以处理多个表格

您在第十五章中创建的博客文章插入表单已经包含了在blog表中插入大部分细节所需的代码。与其从头开始,不如修改现有页面。目前,页面只包含标题的文本输入字段和文章的文本区域。

您需要为类别添加一个多选<select>列表,为现有图像添加一个下拉<select>菜单。

为了防止用户在上传新图像的同时选择现有图像,一个复选框和 JavaScript 控制相关输入字段的显示。选中该复选框将禁用现有图像的下拉菜单,并显示新图像和标题的输入字段。取消选中该复选框会隐藏和禁用文件和标题字段,并重新启用下拉菜单。如果 JavaScript 被禁用,上传新图像和标题的选项将被隐藏。

Note

为了节省篇幅,本章剩余的大多数 PHP 解决方案只给出了 MySQLi 的详细说明。PDO 版本的结构和 PHP 逻辑是相同的。唯一的区别在于用于提交 SQL 查询和显示结果的命令。完整注释的 PDO 文件在ch18文件夹中。

PHP 解决方案 18-3:添加类别和图像输入字段

这个 PHP 解决方案通过添加类别和图像的输入字段,开始修改第十五章中的博客条目插入表单。

  1. admin文件夹中,找到并打开您在第十五章中创建的blog_insert_mysqli.php版本。或者,将blog_insert_mysqli_01.phpch18文件夹复制到admin文件夹,并从文件名中删除_01

  2. 当页面首次加载时,类别和现有图像的<select>元素需要查询数据库,因此您需要将连接脚本和数据库连接移到检查表单是否已提交的条件语句之外。找到以粗体突出显示的行:

    if (isset($_POST['insert'])) {
        require_once '../includes/connection.php';
        // initialize flag
        $OK = false;
        // create database connection
    $conn = dbConnect('write');
    
    

将它们移出条件语句,并包含utility_funcs.php,如下所示:

  1. 页面主体中的表单需要能够上传文件,因此您需要将enctype属性添加到开始的<form>标签,如下所示:
require_once '../includes/connection.php';
require_once '../includes/utility_funcs.php';
// create database connection
$conn = dbConnect('write');
if (isset($_POST['insert'])) {
    // initialize flag
    $OK = false;

  1. 如果在尝试上传文件时出现错误(例如,文件太大或者不是图像文件),插入操作将会暂停。使用与第六章所示相同的技术,修改现有的文本输入字段和文本区域,以重新显示数值。文本输入字段如下所示:

    <input name="title" type="text" id="title" value="<?php if (isset($error)) {
        echo safe($_POST['title']);
    } ?>">
    
    
<form method="post" action="blog_insert_mysqli.php" enctype="multipart/form-data">

文本区域如下所示:

<textarea name="article" id="article"><?php if (isset($error)) {
     echo safe($_POST['article']);
} ?></textarea>

确保开始和结束的 PHP 标签和 HTML 之间没有间隙。否则,您将在文本输入字段和文本区域中添加不需要的空白。

  1. 新的表单元素位于文本区域和提交按钮之间。首先,为类别的多选<select>列表添加代码。代码如下所示:

    <p>
        <label for="category">Categories:</label>
        <select name="category[]" size="5" multiple id="category">
            <?php
            // get categories
            $getCats = 'SELECT cat_id, category FROM categories ORDER BY category';
            $categories = $conn->query($getCats);
            while ($row = $categories->fetch_assoc()) {
                ?>
                <option value="<?= $row['cat_id'] ?>" <?php
                if (isset($_POST['category']) && in_array($row['cat_id'],
                    $_POST['category'])) { echo 'selected';
                } ?>><?= safe($row['category']) ?></option>
            <?php } ?>
        </select>
    </p>
    
    

为了允许选择多个值,multiple属性被添加到了<select>标签中,size属性被设置为5。这些值需要以数组的形式提交,所以在name属性后面添加了一对方括号。

SQL 查询categories表,一个while循环用主键和类别名填充<option>标签。while循环中的条件语句将selected添加到<option>标签中,以便在insert操作失败时重新显示选定的值。

img/332054_5_En_18_Fig8_HTML.jpg

图 18-8

多选列表

  1. 保存blog_insert_mysqli.php并将页面加载到浏览器中。该表单现在应该如图 18-8 所示。

  2. 查看页面的源代码,验证每个类别的主键是否正确嵌入了每个<option>标记的value属性中。你可以将你的代码与ch18文件夹中的blog_insert_mysqli_02.php进行比较。

  3. 接下来,创建<select>下拉菜单,显示已经在数据库中注册的图像。在步骤 5 中插入的代码之后立即添加此代码:

    <p>
        <label for="image_id">Uploaded image:</label>
        <select name="image_id" id="image_id">
            <option value="">Select image</option>
            <?php
            // get the list of images
            $getImages = 'SELECT image_id, filename
                          FROM images ORDER BY filename';
            $images = $conn->query($getImages);
            while ($row = $images->fetch_assoc()) {
                ?>
                <option value="<?= $row['image_id'] ?>"
                    <?php
                    if (isset($_POST['image_id']) && $row['image_id'] ==
                        $_POST['image_id']) {
                        echo 'selected';
                    }
                    ?>><?= safe($row['filename']) ?></option>
            <?php } ?>
        </select>
    </p>
    
    

这创建了另一个SELECT查询来获取存储在images表中的每个图像的主键和文件名。代码现在应该非常熟悉了,所以不需要解释。

  1. 标题的复选框、文件输入字段和文本输入字段位于上一步中的代码和提交按钮之间。代码如下所示:

    <p id="allowUpload">
        <input type="checkbox" name="upload_new" id="upload_new">
        <label for="upload_new">Upload new image</label>
    </p>
    <p class="optional">
        <label for="image">Select image:</label>
        <input type="file" name="image" id="image">
    </p>
    <p class="optional">
        <label for="caption">Caption:</label>
        <input name="caption" type="text" id="caption">
    </p>
    
    

包含复选框的段落被赋予了 ID allowUpload,另外两个段落被赋予了一个名为optional的类。admin.css中的样式规则将这三段的display属性设置为none

  1. 保存blog_insert_mysqli.php并在浏览器中加载页面。images <select>下拉菜单显示在categories列表的下面,但是您在步骤 9 中插入的三个表单元素是隐藏的。如果浏览器中禁用了 JavaScript,将会显示以下内容。用户可以选择类别和现有的图像,但不能上传新的图像。

如有必要,对照 ch18 文件夹中的blog_insert_mysqli_03.php检查您的代码。

  1. ch18文件夹中的toggle_fields.js复制到admin文件夹。该文件包含以下 JavaScript:

    const cbox = document.getElementById('allowUpload');
    cbox.style.display = 'block';
    const uploadImage = document.getElementById('upload_new');
    uploadImage.onclick = function () {
        const image_id = document.getElementById('image_id');
        const image = document.getElementById('image');
        const caption = document.getElementById('caption');
        const sel = uploadImage.checked;
        image_id.disabled = sel;
        image.parentNode.style.display = sel ? 'block' : 'none';
        caption.parentNode.style.display = sel ? 'block' : 'none';
        image.disabled = !sel;
        caption.disabled = !sel;
    }
    
    

这使用在步骤 8 中插入的元素的 id 来控制它们的显示。如果启用了 JavaScript,页面加载时会自动显示复选框,但标题的文件输入字段和文本输入字段保持隐藏。如果选中该复选框,则禁用现有图像的下拉菜单,并显示隐藏的元素。如果随后取消选中该复选框,下拉菜单将重新启用,并且文件输入字段和标题字段将再次隐藏。

  1. 在结束</body>标签之前用<script>标签将toggle_fields.js链接到blog_insert_mysqli.php,就像这样:

    </form>
          <script src="toggle_fields.js"></script>
    </body>
    
    

在页面底部添加 JavaScript 可以加速下载和显示。如果将toggle_fields.js中的代码添加到<head>中,它将无法正常工作。

img/332054_5_En_18_Fig9_HTML.jpg

图 18-9

复选框控制文件和标题输入字段的显示

  1. 保存blog_insert_mysqli.php并在浏览器中加载页面。在支持 JavaScript 的浏览器中,复选框应该显示在<select>下拉菜单和提交按钮之间。选中复选框,禁用下拉菜单,显示隐藏字段,如图 18-9 所示。

  2. 取消选中该复选框。文件和标题输入字段被隐藏,下拉菜单被重新启用。如果有必要,你可以用ch18文件夹中的blog_insert_mysqli_04.phptoggle_fields.js来检查你的代码。

我使用 JavaScript 而不是 PHP 来控制文件和标题输入字段的显示,因为 PHP 是一种服务器端语言。PHP 引擎将输出发送到浏览器后,它不再与页面交互,除非您向 web 服务器发送另一个请求。另一方面,JavaScript 在浏览器中工作,所以它能够在本地操作页面内容。JavaScript 也可以与 PHP 结合使用,在后台向 web 服务器发送请求,并且它可以使用结果来刷新页面的一部分,而无需重新加载它——这是一种称为 Ajax 的技术,这超出了本书的范围。

更新后的插入表单现在有了类别和图像的输入字段,但是处理脚本仍然只处理标题的文本输入字段和博客条目的文本区域。

PHP 解决方案 18-4:将数据插入多个表格

该 PHP 解决方案采用blog_insert_mysqli.php中的现有脚本上传新图像(如果需要),然后按照图 18-6 中概述的决策链将数据插入imagesblogarticle2cat表中。它假设你已经建立了article2cat交叉引用表,并完成了 PHP 解决方案 18-2 和 18-3。

不要试图匆匆读完这一部分。代码很长,但是它集合了您以前学过的许多技术。

Note

如果您使用的是 PDO,这个 PHP 解决方案后面有一个单独的部分描述了代码中的主要差异。

  1. 您在 PHP 解决方案 18-2 中更新的Upload类使用了一个名称空间,因此您需要在脚本的顶层导入它。在blog_insert_mysqli.php顶部的开始 PHP 标签后立即添加这一行:

  2. 初始化准备好的语句后,立即插入以下条件语句来处理图像(如果已经上载或选择了图像):

    // initialize prepared statement
    $stmt = $conn->stmt_init();
    // if a file has been uploaded, process it
    if(isset($_POST['upload_new']) && $_FILES['image']['error'] == 0) {
        $imageOK = false;
        require_once '../Php8Solutions/File/Upload.php';
        $loader = new Upload('image', '../img/');
        $names = $loader->getFilenames();
        // $names will be an empty array if the upload failed
        if ($names) {
            $sql = 'INSERT INTO images (filename, caption) VALUES (?, ?)';
            if ($stmt->prepare($sql)) {
                $stmt->bind_param('ss', $names[0], $_POST['caption']);
                $stmt->execute();
                $imageOK = $stmt->affected_rows;
            }
        }
        // get the image's primary key or find out what went wrong
        if ($imageOK) {
            $image_id = $stmt->insert_id;
        } else {
            $imageError = implode(' ', $loader->getMessages());
        }
    } elseif (!empty($_POST['image_id'])) {
        // get the primary key of a previously uploaded image
        $image_id = $_POST['image_id'];
    }
    // create SQL
    $sql = 'INSERT INTO blog (title, article) VALUES(?, ?)';
    
    
use Php8Solutions\File\Upload;

首先检查$_POST['upload_new']是否已经设置。如第六章所述,只有当复选框被选中时,它才会包含在$_POST数组中。因此,如果复选框没有被选中,那么条件失败,取而代之的是测试底部的elseif子句。elseif子句检查$_POST['image_id']的存在。如果存在且不为空,则意味着已经从下拉菜单中选择了一个现有图像,该值存储在$image_id中。

如果两个测试都失败,则既没有上传图像,也没有从下拉菜单中选择图像。该脚本稍后在为blog表准备INSERT查询时会考虑到这一点,允许您创建一个没有图像的博客条目。

但是,如果$_POST['upload_new']存在,则复选框已被选中,图像可能已被上传。为了确保这一点,条件语句还会检查$_FILES['image']['error']的值。正如您在第九章中了解到的,错误代码0表示上传成功。任何其他错误代码意味着上传失败或没有选择文件。

假设一个文件已经从表单成功上传,条件语句包括Upload类定义并创建一个名为$loader的对象,传递给它文件输入字段的名称,并将目标文件夹设置为images。为了避免代码复杂化,我没有对Upload构造函数使用三个可选参数。因此,将使用默认的最大大小和 MIME 类型,并且具有重复文件名的图像将被重命名。

您在 PHP 解决方案 18-2 中对Upload类所做的更改会将上传文件的名称添加到$filenames属性中,前提是该文件已成功移动到目标文件夹中。getFilenames()方法检索$filenames属性的内容,并将结果分配给$names

如果文件被成功移动,其文件名将被存储为$names数组的第一个元素。因此,如果$names包含一个值,您可以安全地继续执行INSERT查询,该查询将$names[0]$_POST['caption']的值作为字符串绑定到准备好的语句。

执行完语句后,affected_rows属性会重置$imageOK的值。如果INSERT查询成功,$imageOK1,作为true处理。

如果图像细节被插入到images表中,那么准备好的语句的insert_id属性将检索新记录的主键,并将其存储在$image_id中。在运行任何其他 SQL 查询之前,必须访问insert_id属性,因为它包含最近查询的主键。

然而,如果$imageOK仍然为假,else块调用上传对象的getMessages()方法,并将结果分配给$imageErrorgetMessages()方法返回一个数组,因此implode()函数用于将数组元素连接成一个字符串。失败最可能的原因是文件太大或者 MIME 类型不正确。

  1. 只要图片上传没有失败,流程的下一步就是将博客条目插入到blog表中。INSERT查询的形式取决于图像是否与博客条目相关联。如果是,$image_id存在,需要作为外键插入到blog表中。否则,可以使用原始查询。

像这样修改原始查询:

// insert blog details only if there hasn't been an image upload error
if (!isset($imageError)) {
    // if $image_id has been set, insert it as a foreign key
    if (isset($image_id)) {
        $sql = 'INSERT INTO blog (image_id, title, article) VALUES(?, ?, ?)';
        if ($stmt->prepare($sql)) {
            $stmt->bind_param('iss', $image_id, $_POST['title'], $_POST['article']);
            $stmt->execute();
        }
    } else {
        // create SQL
        $sql = 'INSERT INTO blog (title, article)
                   VALUES(?, ?)';
        if ($stmt->prepare($sql)) {
            // bind parameters and execute statement
            $stmt->bind_param('ss', $_POST['title'], $_POST['article']);
            $stmt->execute();
        }
    }
    if ($stmt->affected_rows > 0) {
        $OK = true;
    }
}

这一整段代码被包装在一个条件语句中,该语句检查$imageError是否存在。如果是的话,插入新的博客条目就没有意义了,所以整个代码块都被忽略了。

然而,如果$imageError不存在,嵌套的条件语句根据$image_id是否存在准备不同的INSERT查询,然后执行已经准备好的查询。

检查affected_rows属性的条件语句被移出else块,这样它就可以应用于任何一个INSERT查询。

  1. 流程的下一阶段将值插入到article2cat交叉引用表中。代码紧跟在上一个步骤的代码之后,如下所示:

    // if the blog entry was inserted successfully, check for categories
    if ($OK && isset($_POST['category'])) {
        // get the article's primary key
        $article_id = $stmt->insert_id;
        foreach ($_POST['category'] as $cat_id) {
            if (is_numeric($cat_id)) {
                $values[] = "($article_id, " . (int) $cat_id . ')';
            }
        }
        if ($values) {
            $sql = 'INSERT INTO article2cat (article_id, cat_id)
                       VALUES ' . implode(',', $values);
            // execute the query and get error message if it fails
            if (!$conn->query($sql)) {
                $catError = $conn->error;
            }
        }
    }
    
    

$OK的值由在blog表中插入数据的查询的affected_rows属性决定,只有选择了任何类别,多选<select>列表才会包含在$_POST数组中。因此,只有当数据成功插入到blog表中,并且在表单中至少选择了一个类别时,这个代码块才会运行。它首先从准备好的语句的insert_id属性中获取插入操作的主键,并将其赋给$article_id

表单将类别值作为数组提交。foreach循环检查$_POST['category']中的每个值。如果该值是数字,则执行下面一行:

$values[] = "($article_id, " . (int) $cat_id . ')';

这将创建一个包含两个主键$article_id$cat_id的字符串,用逗号分隔,并用一对括号括起来。(int)造型操作符确保$cat_id是一个整数。结果被分配给一个名为$values的数组。例如,如果$article_id10并且$cat_id4,则分配给数组的结果字符串是(10, 4)

如果$values包含任何元素,implode()将其转换为逗号分隔的字符串,并将其附加到 SQL 查询中。例如,如果选择了类别245,结果查询如下所示:

INSERT INTO article2cat (article_id, cat_id)
VALUES (10, 2),(10, 4),(10, 5)

正如在第十五章的“回顾四个基本的 SQL 命令”中所解释的,这就是如何用一个INSERT查询插入多行。

因为$article_id来自可靠的来源,并且已经检查了$cat_id的数据类型,所以在 SQL 查询中直接使用这些变量是安全的,不需要使用准备好的语句。使用query()方法执行查询。如果失败,连接对象的错误属性将存储在$catError中。

  1. 代码的最后一部分处理成功和错误消息的重定向。修改后的代码如下所示:

    // redirect if successful or display error
    if ($OK && !isset($imageError) && !isset($catError)) {
        header('Location: http://localhost/php8sols/admin/blog_list_mysqli.php');
        exit;
    } else {
        $error = $stmt->error;
        if (isset($imageError)) {
            $error .= ' ' . $imageError;
        }
        if (isset($catError)) {
            $error .= ' ' . $catError;
        }
    }
    
    

控制重定向的条件现在确保了$imageError$catError不存在。如果有,该值将连接到原始的$error,其中包含来自准备好的语句对象的任何错误消息。

  1. 保存blog_insert_mysqli.php并在浏览器中测试。尝试上传太大的图像或错误 MIME 类型的文件。表单应该重新显示一条错误消息,并保留博客的详细信息。也可以尝试插入带有或不带有图片和/或类别的博客条目。您现在有了一个多用途的插入表单。

如果您没有合适的图像上传,请使用phpsols images文件夹中的图像。Upload类重命名它们以避免覆盖现有的文件。

您可以对照ch18文件夹中的blog_insert_mysqli_05.php来检查您的代码。

PDO 版本的主要区别

最终的 PDO 版本可以在ch18文件夹的blog_insert_pdo_05.php中找到。它遵循与 MySQLi 版本相同的基本结构和逻辑,但是在将值插入数据库的方式上有一些重要的不同。

步骤 2 中的代码严格遵循 MySQLi 版本,但是使用了命名占位符而不是匿名占位符。为了获得受影响的行数,PDO 在 statement 对象上使用了rowCount()方法。最近插入操作的主键是使用 connection 对象上的lastInsertId()方法获得的。像 MySQLi insert_id属性一样,您需要在执行完INSERT查询后立即访问它。

最大的变化是第 3 步中的代码,它将细节插入到 blog 表中。因为 PDO 可以使用bindValue()将一个null值插入到一个列中,所以只需要一条准备好的语句。步骤 3 的 PDO 代码如下所示:

// insert blog details only if there hasn't been an image upload error
if (!isset($imageError)) {
    // create SQL
    $sql = 'INSERT INTO blog (image_id, title, article)
                VALUES(:image_id, :title, :article)';
    // prepare the statement
    $stmt = $conn->prepare($sql);
    // bind the parameters
    // if $image_id exists, use it
    if (isset($image_id)) {
        $stmt->bindParam(':image_id', $image_id, PDO::PARAM_INT);
    } else {
        // set image_id to NULL
        $stmt->bindValue(':image_id', NULL, PDO::PARAM_NULL);
    }
    $stmt->bindParam(':title', $_POST['title'], PDO::PARAM_STR);
    $stmt->bindParam(':article', $_POST['article'], PDO::PARAM_STR);
    // execute and get number of affected rows
    $stmt->execute();
    $OK = $stmt->rowCount();
}

如果图像已经上传,以粗体突出显示的条件语句将$image_id的值绑定到命名的:image_id占位符。但是如果没有上传图像,bindValue()将该值设置为NULL

在第 4 步中,PDO 版本使用exec()而不是query()将值插入到article2cat表中。exec()方法执行 SQL 查询并返回受影响的行数,因此当不需要准备好的语句时,它应该与INSERTUPDATEDELETE查询一起使用。

另一个重要的区别是在出现问题时构建错误消息的代码。因为创建和准备报表在 PDO 中是一步到位的过程,所以如果出现问题,报表对象可能不存在。如果没有语句,对errorInfo()的调用将是null。因此,代码使用 null 合并操作符从数据库连接对象获取错误消息。还需要将$error初始化为一个空字符串,将各种消息连接起来,就像这样:

// redirect if successful or display error
if ($OK && !isset($imageError) && !isset($catError)) {
    header('Location: http://localhost/php8sols/admin/blog_list_pdo.php');
    exit;
} else {
    $error = ";
    $error .= $stmt->errorInfo()[2] ?? $conn->errorInfo()[2];
    if (isset($imageError)) {
        $error .= ' ' . $imageError;
    }
    if (isset($catError)) {
        $error .= ' ' . $catError;
    }
}

更新和删除多个表中的记录

添加了categoriesarticle2cat表意味着上一章 PHP 解决方案 17-2 和 17-3 中对blog_update_mysqli.phpblog_update_pdo.php的更改不再充分涵盖phpsols数据库中的外键关系。除了修改更新表单之外,您还需要创建脚本来删除记录,而不破坏数据库的参照完整性。

更新交叉引用表中的记录

交叉引用表中的每条记录只包含一个复合主键。通常情况下,主键不应该被改变。此外,它们必须是独特的。这给更新article2cat表带来了问题。如果在更新博客条目时没有对所选类别进行更改,则不需要更新交叉引用表。但是,如果类别发生变化,您需要确定要删除哪些交叉引用以及要插入哪些新的交叉引用。

一个简单的解决方案是删除所有现有的交叉引用并再次插入所选的类别,而不是纠结于是否进行了任何更改。如果没有进行任何更改,您只需再次插入相同的内容。

PHP 解决方案 18-5:向更新表单添加类别

这个 PHP 解决方案修正了上一章 PHP 解决方案 17-2 中的blog_update_mysqli.php,允许你更新与博客条目相关的类别。为了保持结构简单,对与条目相关联的图像的唯一改变是选择不同的现有图像或者根本不选择图像。

  1. 继续使用 PHP 解决方案 17-2 中的blog_update_mysqli.php。或者,从ch18文件夹中复制blog_update_mysqli_04.php并作为blog_update_mysqli.php保存在admin文件夹中。

  2. 当页面第一次加载时,您需要运行第二个查询来获取与博客条目相关的类别。将以下突出显示的代码添加到获取所选记录详细信息的条件语句中:

    $stmt->free_result();
    // get categories associated with the article
    $sql = 'SELECT cat_id FROM article2cat
            WHERE article_id = ?';
    if ($stmt->prepare($sql)) {
        $stmt->bind_param('i', $_GET['article_id']);
        $OK = $stmt->execute();
        $stmt->bind_result($cat_id);
        // loop through the results to store them in an array
        $selected_categories = [];
        while ($stmt->fetch()) {
            $selected_categories[] = $cat_id;
        }
    }
    
    

该查询从交叉引用表中匹配所选博客条目主键的所有记录中选择cat_id。结果被绑定到$cat_id,一个while循环将这些值提取到一个名为$selected_categories的数组中。

  1. 在 HTML 页面的主体中,在文本区域和显示图像列表的<select>下拉菜单之间添加一个多选<select>列表。使用另一个 SQL 查询来填充它,如下所示:

    <p>
        <label for="category">Categories:</label>
        <select name="category[]" size="5" multiple id="category">
            <?php
            // get categories
            $getCats = 'SELECT cat_id, category FROM categories
                               ORDER BY category';
            $categories = $conn->query($getCats);
            while ($row = $categories->fetch_assoc()) {
                ?>
                <option value="<?= $row['cat_id'] ?>" <?php
                if (isset($selected_categories) &&
                    in_array($row['cat_id'], $selected_categories)) {
                    echo 'selected';
                } ?>><?= safe($row['category']) ?></option>
            <?php } ?>
        </select>
    </p>
    
    

while循环通过在value属性中插入cat_id并显示开始和结束标签之间的类别来构建每个<option>标签。如果cat_id$selected_categories数组中,selected被插入到<option>标签中。这将选择已经与博客条目关联的类别。

  1. 保存blog_update_mysqli.php并选择blog_list_mysqli.php中的EDIT链接之一,以确保多选列表中填充了类别。如果您在 PHP 解决方案 18-4 中插入了一个新条目,那么您与该条目相关联的类别应该被选中,如下面的屏幕截图所示。

img/332054_5_En_18_Figa_HTML.jpg

如有必要,您可以对照 ch18 文件夹中的blog_update_mysqli_05.php检查您的代码。PDO 版本出现在blog_update_pdo_05.php中。

  1. 接下来,您需要编辑提交表单时更新记录的代码部分。新代码首先删除交叉引用表中匹配article_id的所有条目,然后插入在更新表单中选择的值。行内注释指出了为节省空间而省略的现有代码:

    // if form has been submitted, update record
    if (isset($_POST ['update'])) {
        // prepare update query
        if (!empty($_POST['image_id'])) {
            // existing code omitted
        } else {
            // existing code omitted
                $done = $stmt->execute();
            }
        }
        // delete existing values in the cross-reference table
        $sql = 'DELETE FROM article2cat WHERE article_id = ?';
        if ($stmt->prepare($sql)) {
            $stmt->bind_param('i', $_POST['article_id']);
            $done = $stmt->execute();
        }
        // insert the new values in articles2cat
        if (isset($_POST['category']) && is_numeric($_POST['article_id'])) {
            $article_id = (int) $_POST['article_id'];
            foreach ($_POST['category'] as $cat_id) {
                $values[] = "($article_id, " . (int) $cat_id . ')';
            }
            if ($values) {
                $sql = 'INSERT INTO article2cat (article_id, cat_id)
                          VALUES ' . implode(',', $values);
                $done = $conn->query($sql);
            }
        }
    }
    
    

插入在更新表单中选择的值的代码与 PHP 解决方案 18-4 的步骤 4 中的代码相同。需要注意的关键点是,它使用了一个INSERT查询,而不是UPDATE。原始值已被删除,所以您要重新添加它们。

  1. 保存blog_update_mysqli.php并通过更新blog表中的现有记录进行测试。如果有必要,你可以对照ch18文件夹中的blog_update_mysqli_06.php来检查你的代码。PDO 版本见于blog_update_pdo_06.php

将多个查询视为事务中的一个块

前面的 PHP 解决方案对信任要求很高。更新序列包括三个独立的查询:更新blog表,删除article2cat表中的引用,并插入新的引用。如果其中任何一个失败,$done将被设置为false;但是如果下一次成功,它将被重置为true。您可能很容易只得到部分更新,但是除非是一系列查询的最后一部分失败,否则您不会知道。

一种解决方案可能是运行一系列条件语句,如果前面的查询失败,则阻止任何进一步的执行。问题是,您最终仍然会得到部分更新。当更新多个表中的连接记录时,需要将整个序列视为一个块。如果一部分失败,整个序列都会失败。只有当更新序列的所有部分都成功时,才会处理更新序列。将多个查询视为一个统一的块在 SQL 中称为事务。在 MySQLi 和 PDO 中实现交易都很简单。

Note

要在 MySQL 和 MariaDB 中使用事务,必须使用 InnoDB 存储引擎。

在 MySQLi 中使用事务

默认情况下,MySQL 和 MariaDB 在自动提交模式下工作。换句话说,SQL 查询会立即执行。要使用事务,您需要关闭自动提交模式,然后像这样调用数据库连接对象上的begin_transaction()方法(假设$conn是数据库连接):

$conn->autocommit(false);
$conn->begin_transaction();

然后正常运行 SQL 查询序列,根据查询是否成功执行,将变量设置为truefalse。如果检测到任何错误,您可以在序列结束时将所有表回滚到它们的原始状态。否则,您可以提交事务,将序列作为单个块进行处理,如下所示:

if ($trans_error) {
    $conn->rollback();
} else {
    $conn->commit();
}

使用 PDO 的交易

PDO 也在自动提交模式下工作。在数据库连接对象上调用beginTransaction()方法会关闭自动提交模式。PHP 8 一遇到问题就会自动抛出异常,所以没有必要使用变量来跟踪单个查询的成功。只需使用一个catch块将表回滚到原始状态。基本结构是这样的:

try {
    $conn->beginTransaction();
    // run sequence of SQL queries
    // commit the transaction if no problems have been encountered
    $done = $conn->commit();
    // catch the exception if there’s a problem
} catch (Exception $e) {
    // roll back to the original state and get the errormessage
    $conn->rollBack();
    $trans_error = $e->getMessage();
}

Caution

PHP 中的函数和方法名是不区分大小写的,所以对于 MySQLi 和 PDO 来说,rollBack()rollback()同样是可以接受的。然而,在begin_transaction() (MySQLi)和beginTransaction() (PDO)之间有一个微妙的区别。PDO 方法没有下划线。

在 PHP 8 之前,PDO 的默认错误模式是静默的。如果您的服务器运行的是旧版本的 PHP,您需要明确设置错误模式,以便在遇到类似这样的问题时抛出异常:

$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

PHP 解决方案 18-6:将表格转换为 InnoDB 存储引擎

这个 PHP 解决方案展示了如何将一个表格从 MyISAM 转换成 InnoDB。如果您计划将表上传到您的远程服务器,它还必须支持 InnoDB(参见 PHP 解决方案 18-1)。

img/332054_5_En_18_Fig10_HTML.jpg

图 18-10

在 phpMyAdmin 中,更改表的存储引擎非常容易

  1. 在 phpMyAdmin 中选择phpsols数据库,然后选择article2cat表。

  2. 单击屏幕右上角的操作选项卡。

  3. 在“表选项”部分,“存储引擎”字段报告表当前使用的引擎。如果显示 MyISAM,从下拉菜单中选择 InnoDB,如图 18-10 所示。

  4. 点击Go。改变存储引擎就是这么简单!

Note

每个表都需要单独转换。不能在一次操作中更改数据库中的所有表。

PHP 解决方案 18-7:在事务中包装更新序列(MySQLi)

这个 PHP 解决方案改进了blog_update_mysqli.php中的脚本,将更新blogarticle2cat表的 SQL 查询序列封装在一个事务中,如果序列的任何部分失败,就将数据库回滚到原始状态。

  1. 如有必要,将blogarticle2cat表的存储引擎转换为 InnoDB,如前面的 PHP 解决方案所述。

  2. 继续使用 PHP 解决方案 18-5 中的blog_update_mysqli.phpblog_list_mysqli.php。或者,将blog_update_mysqli_06.phpblog_list_mysqli_04.phpch18文件夹复制到php8sols站点根目录下的admin文件夹,并删除文件名中的数字。

  3. blog_update_mysqli.php的顶部初始化一个空数组来存储错误信息:

  4. 关闭自动提交模式,并在运行更新查询序列的条件语句的开头开始一个事务,如下所示:

    // if form has been submitted, update record
    if (isset($_POST ['update'])) {
       // set autocommit to off
       $conn->autocommit(false);
       $conn->begin_transaction();
       // prepare update query
    
    
  5. 在更新 blog 表的查询之后,添加一个条件语句,以便在查询失败时将任何错误消息添加到$trans_error 数组中。为了节省空间,省略了一些现有代码:

    if (!empty($_POST['image_id'])) {
        // existing code omitted
            $done = $stmt->execute();
        }
    } else {
        // existing code omitted
           $done = $stmt->execute();
        }
    }
    if (!$done) {
        $trans_error[] = $stmt->error;
    }
    
    
  6. 添加一个类似的条件语句,以捕获因删除交叉引用表中的现有值而产生的任何错误消息:

    // delete existing values in the cross-reference table
    $sql = 'DELETE FROM article2cat WHERE article_id = ?';
    if ($stmt->prepare($sql)) {
        $stmt->bind_param('i', $_POST['article_id']);
        $done = $stmt->execute();
        if (!$done) {
            $trans_error[] = $stmt->error;
        }
    }
    
    
  7. 从在article2cat表中插入更新值中捕获任何错误消息的代码需要稍有不同,因为它使用了query()方法,而不是准备好的语句。您需要像这样访问数据库连接对象的error属性,而不是语句对象的error属性:

    if ($values) {
        $sql = 'INSERT INTO article2cat (article_id, cat_id)
                    VALUES ' . implode(',', $values);
        $done = $conn->query($sql);
        if (!$done) {
            $trans_error[] = $conn->error;
        }
    }
    
    
  8. 在一系列查询之后,使用条件语句回滚或提交事务,如下所示(代码位于条件语句中,当单击 Update 按钮时,条件语句将运行脚本):

    if ($trans_error) {
        $conn->rollback();
        $done = false;
    } else {
        $conn->commit();
    }
    
    
$trans_error = [];

如果$trans_error包含任何错误信息,有必要将$done明确设置为false。这是因为$done将被任何在事务之外成功的查询设置为true

  1. 需要修改重定向页面的条件语句来处理事务。添加以粗体突出显示的新代码:

    // redirect page after updating or if $_GET['article_id']) not defined
    if (($done || $trans_error) || (!$_POST && !isset($_GET['article_id']))) {
        $url = 'http://localhost/php8sols/admin/blog_list_mysqli.php';
        if ($done) {
            $url .= '?updated=true';
        } elseif ($trans_error) {
            $url .= '?trans_error=' . serialize($trans_error);
        }
        header("Location: $url");
        exit;
    }
    
    

条件现在被分组在括号内,以确保它们被正确解释。第一对检查$done$trans_error是否等于true。通过检查$_POST数组是否为空,最终条件变得更加具体。这是必要的,因为点击更新按钮后,!isset($_GET['article_id'])总是true

如果$trans_error包含任何错误消息,那么它等同于true,因此一个查询字符串被附加到重定向位置。因为$trans_error是一个数组,所以在连接到查询字符串之前,需要将它传递给serialize()函数。这会将数组转换为字符串,该字符串可以转换回其原始格式。

  1. 最后的改变是在blog_list_mysqli.php中表格上方的 PHP 块中。添加粗体代码,以便在更新失败时显示任何错误消息:

    if (isset($_GET['updated'])) {
        echo '<p>Record updated</p>';
    } elseif (isset($_GET['trans_error'])) {
        $trans_error = unserialize($_GET['trans_error']);
        echo "<p>Can't update record because of the following error(s):</p>";
        echo '<ul>';
        foreach ($trans_error as $item) {
           echo '<li>' . safe($item) . '</li>';
        }
        echo '</ul>';
    }
    
    

unserialize()函数逆转了serialize()的效果,将错误消息转换回一个数组,然后在foreach循环中显示。

img/332054_5_En_18_Fig11_HTML.jpg

图 18-11

由于列名中的错误,更新失败

  1. 保存blog_update_mysqli.phpblog_list_mysqli.php,并更新现有记录。脚本应该和以前一样工作。

  2. blog_update_mysqli.php中的 SQL 中引入一些故意的错误,并再次测试。这一次,当您返回到blog_list_mysqli.php时,您应该会看到类似于图 18-11 的一系列错误信息。

  3. 单击您刚刚尝试更新的记录的EDIT链接,并确认没有任何值发生变化。你可以对照ch18文件夹中的blog_update_mysqli_07.phpblog_list_mysqli_05.php来检查你的代码。

PHP 解决方案 18-8:在事务中包装更新序列(PDO)

这个 PHP 解决方案改进了blog_update_pdo.php中的脚本,将更新blogarticle2cat表的 SQL 查询序列封装在一个事务中,如果序列的任何部分失败,就将数据库回滚到原始状态。

  1. 如有必要,将blogarticle2cat表的存储引擎转换为 InnoDB,如 PHP 解决方案 18-6 所述。

  2. 继续使用 PHP 解决方案 18-5 中的blog_update_pdo.phpblog_list_pdo.php。或者,将blog_update_pdo_06.phpblog_list_pdo_04.phpch18文件夹复制到php8sols站点根目录下的admin文件夹,并删除文件名中的数字。

  3. 初始化页面顶部的一个变量以跟踪事务,并将其值设置为false:

$trans_error = false;

Note

PHP 8 在 PDO 中遇到错误时会自动抛出异常,所以只有当您的服务器运行的是旧版本的 PHP 时,才需要执行以下步骤。如果您运行的是 PHP 8 或更高版本,请跳到步骤 5。

  1. 在运行查询序列以更新blogarticle2cat表的条件语句中,设置 PDO 在遇到类似这样的问题时抛出异常:

    if (isset($_POST['update'])) {
        $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        // prepare update query
        $sql = 'UPDATE blog SET image_id = ?, title = ?, article = ?
                WHERE article_id = ?';
    
    
  2. 将运行更新查询的所有代码包装在一个try / catch块中,并在try块的开头开始一个事务,如下所示:

    if(isset($_POST['update'])) {
        try {
            $conn->beginTransaction();
            // prepare update query
            // other database queries omitted
        } catch (Exception $e) {
            $conn->rollBack();
            $trans_error = $e->getMessage();
        }
    }
    
    
  3. 在现有代码中,执行每个查询的返回值都设置为$done。这不再是必要的,因为我们正在使用一个事务。我们将使用$done作为成功提交事务的返回值。找到以下行(它们在第 53、57 和 69 行周围):

    $done = $stmt->execute();
    $done = $stmt->execute([$_POST['article_id']]);
    $done = $conn->exec($sql);
    
    

将它们更改为:

  1. 紧接在catch块之前,添加粗体代码以提交事务:

        $done = $conn->commit();
    } catch (Exception $e) {
        $conn->rollBack();
        $trans_error = $e->getMessage();
    }
    
    
  2. 需要修改重定向页面的条件语句来处理事务。添加以粗体突出显示的新代码:

    // redirect page after updating or if $_GET['article_id'] not defined
    if (($done || $trans_error) || (!$_POST && !isset($_GET['article_id']))) {
        $url = 'http://localhost/php8sols/admin/blog_list_pdo.php';
        if ($done) {
            $url .= '?updated=true';
        } elseif ($trans_error) {
            $url .= "?trans_error=$trans_error";
        }
        header("Location: $url");
        exit;
    }
    
    
$stmt->execute();
$stmt->execute([$_POST['article_id']]);
$conn->exec($sql);

条件现在被分组在括号内,以确保它们被正确解释。第一对检查$done$trans_error是否等于true。通过检查$_POST数组是否为空,最终条件变得更加具体。这是必要的,因为点击更新按钮后,!isset($_GET['article_id'])总是true

如果$trans_error包含任何错误消息,那么它等同于true,因此一个查询字符串被附加到重定向位置。

  1. 最后的改变是在blog_list_pdo.php中表格上方的 PHP 块中。添加粗体代码,以便在更新失败时显示任何错误消息:

    if (isset($_GET['updated'])) {
        echo '<p>Record updated</p>';
    } elseif (isset($_GET['trans_error'])) {
        echo "Can't update record because of the following error: ";
        echo safe($_GET['trans_error']) . '</p>';
    }
    
    

PDO 一遇到错误就抛出异常,所以即使有多个错误,也只会有一个错误消息。

  1. 保存blog_update_pdo.phpblog_list_pdo.php,并更新现有记录。脚本应该和以前一样工作。

  2. blog_update_pdo.php中的一个更新查询中故意引入一个错误,并再次测试。这一次,当您返回到blog_list_pdo.php时,您将看到错误消息。

  3. 单击您刚刚尝试更新的记录的EDIT链接,并确认没有任何值发生变化。你可以对照ch18文件夹中的blog_update_pdo_07.phpblog_list_pdo_05.php来检查你的代码。

Tip

在一系列查询只有在满足特定条件的情况下才应该被处理的情况下,事务是必不可少的。例如,在金融数据库中,只有在有足够的资金可用的情况下,才能进行资金转移。

删除时保持引用完整性

在 PHP 解决方案 18-5 中,当您删除交叉引用表中的记录时,无需担心引用完整性,因为存储在每个记录中的值都是外键。每个记录只是引用存储在blogcategories表中的主键。参见本章开头的图 18-1 ,从对照表中删除将article_id 2cat_id 1组合在一起的记录,只是断开了标题为“见习艺妓去购物”的文章与Kyoto类别之间的链接。文章和类别都不受影响。它们都保留在各自的表中。

如果你决定删除文章或类别,情况就大不相同了。如果您从blog表中删除“见习艺妓去购物”这篇文章,所有对article_id 2的引用也必须从交叉引用表中删除。类似地,如果您删除了Kyoto类别,那么所有对cat_id 1的引用都必须从交叉引用表中删除。或者,如果项目的主键作为外键存储在其他地方,则必须停止删除。

最好的方法是通过建立外键约束。为此,相关表必须使用 InnoDB 存储引擎。如果您使用的是 MySQL 或 MariaDB 5.5 或更高版本,InnoDB 是默认的。此外,本书附带的所有.sql文件都选择了 InnoDB 引擎。但是,如果您有使用 MyISAM 存储引擎创建的现有表,您需要在建立外键约束之前转换它们(参见 PHP 解决方案 18-6)。

PHP 解决方案 18-9:设置外键约束

这个 PHP 解决方案描述了如何在 phpMyAdmin 中的article2catblogcategories表之间设置外键约束。外键约束必须始终在子表中定义。在本例中,子表是article2cat,因为它将其他表中的article_idcat_id主键存储为外键。

img/332054_5_En_18_Fig12_HTML.jpg

图 18-12

外键约束是在 phpMyAdmin 的关系视图中定义的

  1. 在 phpMyAdmin 中选择article2cat表,然后单击 Structure 选项卡。

  2. 单击结构表上方的关系视图(图 18-12 中的圆圈)(在 phpMyAdmin 的旧版本中,它是结构表下方的链接)。

  3. 打开的屏幕是您定义外键约束的地方。将“约束名称”字段留空。phpMyAdmin 将自动为约束生成一个名称。

  4. 外键约束只能在已索引的列上设置。article2cat中的article_idcat_id列是表的复合主键,所以它们都列在列下拉菜单中。选择文章标识。然后在外键约束(INNODB)下选择以下设置:

    • 数据库:phpsols

    • 表格:博客

    • 列:文章标识

这在父表(blog)中的 article_id 和子表(article2cat)中的 article_id 之间建立了一个约束。

  1. 接下来,您需要决定约束应该如何表现。“删除时”下拉菜单包含以下选项:

    • 级联:当您删除父表中的记录时,子表中的所有相关记录都会被删除。例如,如果删除blog表中主键为article_id 2的记录,那么article2cat表中所有主键为article_id 2的记录都会被自动删除。

    • SET NULL:删除父表中的记录时,子表中的所有相关记录都将外键设置为 NULL。外键列必须接受空值。

    • 无操作:在一些数据库系统中,这允许外键约束检查被延迟。MySQL 会立即执行检查,因此这与 RESTRICT 具有相同的效果。

    • RESTRICT:如果子表中仍然存在相关记录,这可以防止删除父表中的记录。

Note

同样的选项也适用于ON UPDATE。除了RESTRICT之外,它们的意义有限,因为只有在特殊情况下才应该更改记录的主键。ON UPDATE RESTRICT不仅阻止对父表中主键的更改;它还拒绝在子表中插入或更新任何可能导致在父表中没有匹配的外键值的内容。

如果是对照表,CASCADE是合理的选择。如果您决定删除父表中的记录,您希望同时删除对该记录的所有交叉引用。然而,为了演示外键约束的默认行为,为ON DELETEON UPDATE都选择RESTRICT

img/332054_5_En_18_Fig13_HTML.jpg

图 18-13

为交叉引用表设置外键约束

  1. 单击添加约束链接,使用以下设置为 cat_id 建立外键约束:

    • 数据库:phpsols

    • 表格:类别

    • 列:cat_id

  2. ON DELETEON UPDATE设置为RESTRICT。设置应该如图 18-13 所示。然后点击保存按钮。

Note

phpMyAdmin 旧版本中的 Relation 视图的布局有所不同,它将数据库、表和列下拉菜单组合在一个下拉菜单中。

img/332054_5_En_18_Fig14_HTML.jpg

图 18-14

尝试删除类别表中的记录

  1. 如果您还没有这样做,请更新至少一个博客条目,使其与类别相关联。

  2. 在 phpMyAdmin 中,选择categories表,然后点击与博客条目相关的类别旁边的Delete,如图 18-14 所示。

img/332054_5_En_18_Fig15_HTML.jpg

图 18-15

如果存在相关记录,外键约束会阻止删除

  1. 当 phpMyAdmin 要求您确认删除时,单击OK。如果你已经正确设置了外键约束,你会看到一个类似于图 18-15 的错误信息。

  2. 如果错误消息出现在模式对话框中,请单击该框将其消除。

  3. 选择article2cat表,并点击结构选项卡。然后点击“关系视图”

    Note

    在 phpMyAdmin 的旧版本中,ON DELETEON UPDATE可能为空。将这些选项留空与选择RESTRICT效果相同,这是两者的默认设置。

  4. 将两个ON DELETE设置都更改为CASCADE,并点击Save

  5. blog表中选择一条已知与类别相关联的记录。记下它的article_id,然后删除记录。

  6. 检查article2cat表。与您刚刚删除的记录相关联的记录也被删除。

要继续探索外键约束,请选择blog表,并与images表中的image_id建立外键关系。如果从 images 表中删除一条记录,那么 blog 表中的image_id外键需要设置为NULL。如果将ON DELETE的值设置为SET NULL,这将自动完成。通过从images表中删除一条记录并检查blog表中的相关记录来进行测试。

Note

如果需要将 InnoDB 表转换回 MyISAM,必须首先删除所有外键约束。选择“关系视图”,然后单击每个约束左上角的“放下”。在 phpMyAdmin 的旧版本中,将“外键(INNODB)”字段设置为空白,然后单击Save。移除约束后,您可以按照 PHP 解决方案 18-6 中的描述更改存储引擎。选择MyISAM而不是InnoDB

创建带有外键约束的删除脚本

在 InnoDB 表中选择ON DELETE的值取决于表之间关系的性质。在使用phpsols数据库的情况下,将article2cat交叉引用表中的两列都设置为CASCADE不仅安全,而且是可取的。如果在blogcategories父表中删除了一条记录,则需要删除对照表中的相关值。

imagesblog表之间的关系是不同的。如果您从images表中删除一条记录,您可能不想删除blog表中的相关文章。在那种情况下,SET NULL是一个合适的选择。当从images表中删除一条记录时,相关文章中的外键被设置为NULL,但文章保持不变。

另一方面,如果图像对理解文章至关重要,请选择RESTRICT。任何删除仍有相关文章的图像的尝试都会自动停止。

这些考虑会影响您处理删除脚本的方式。当外键约束设置为CASCADESET NULL时,您不需要做任何特殊的事情。您可以使用一个简单的DELETE查询,剩下的交给数据库。

但是,如果外键约束被设置为RESTRICT,那么DELETE查询将会失败。要显示适当的错误消息,请使用 MySQLi 语句对象的errno属性。因外键约束而失败的查询的 MySQL 错误代码是1451。在调用了execute()方法之后,您可以检查 MySQLi 中的错误,如下所示(参见blog_delete_mysqli_innodb.php):

$stmt->execute();
if ($stmt->affected_rows > 0) {
    $deleted = true;
} else {
    $deleted = false;
    if ($stmt->errno == 1451) {
        $error = 'That record has dependent files in a child table, and cannot be deleted.';
    } else {
        $error = 'There was a problem deleting the record.';
    }
}

如果您使用 PDO,请使用errorCode()方法。由于外键约束而失败的查询的代码是HY000。在检查了受影响的行数之后,您可以使用 PDO 准备好的语句来检查错误代码,就像这样(参见blog_delete_pdo_innodb.php):

$deleted = $stmt->rowCount();
if (!$deleted) {
    if ($stmt->errorCode() == 'HY000') {
        $error = 'That record has dependent files in a child table, and cannot be deleted.';
    } else {
        $error = 'There was a problem deleting the record.';
    }
}

如果您使用 PDO exec()方法,技术是相同的,该方法使用非SELECT查询返回受影响的行数。当使用exec()时,在数据库连接上调用errorCode()方法:

$deleted = $conn->exec($sql);
if (!$deleted) {
    if ($conn->errorCode() == 'HY000') {
        $error = 'That record has dependent files in a child table, and cannot be deleted.';
    } else {
        $error = 'There was a problem deleting the record.';
    }
}

创建没有外键约束的删除脚本

如果您不能使用 InnoDB 表,那么您需要将相同的逻辑构建到您自己的删除脚本中。为了达到和ON DELETE CASCADE一样的效果,运行两个连续的DELETE查询,像这样(代码在blog_delete_mysqli_myisam_cascade.php;PDO 版本在blog_delete_pdo_myisam_cascade.php):

$sql = 'DELETE FROM article2cat WHERE article_id = ?';
$stmt->prepare($sql);
$stmt->bind_param('i', $_POST['article_id']);
$stmt->execute();
$sql = 'DELETE FROM blog WHERE article_id = ?';
$stmt->prepare($sql);
$stmt->bind_param('i', $_POST['article_id']);
$stmt->execute();

为了达到与ON DELETE SET NULL相同的效果,运行一个UPDATE查询结合一个DELETE查询,如下所示:

$sql = 'UPDATE blog SET image_id = NULL WHERE image_id = ?';
$stmt->prepare($sql);
$stmt->bind_param('i', $_POST['image_id']);
$stmt->execute();
$sql = 'DELETE FROM images WHERE image_id = ?';
$stmt->prepare($sql);
$stmt->bind_param('i', $_POST['image_id']);
$stmt->execute();

为了达到与ON DELETE RESTRICT相同的效果,您需要在继续执行DELETE查询之前运行SELECT查询来查找是否有相关记录,如下所示:

$sql = 'SELECT image_id FROM blog WHERE image_id = ?';
$stmt->prepare($sql);
$stmt->bind_param('i', $_POST['image_id']);
$stmt->execute();
// store result to find out how many rows it contains
$stmt->store_result();
// if num_rows is not 0, there are dependent records
if ($stmt->num_rows) {
    $error = 'That record has dependent files in a child table, and cannot be deleted.';
} else {
    $sql = 'DELETE FROM images WHERE image_id = ?';
    $stmt->prepare($sql);
    $stmt->bind_param('i', $_POST['image_id']);
    $stmt->execute();
}

第三章回顾

一旦您学会了与数据库通信所需的基本 SQL 和 PHP 命令,使用单个表就非常容易了。然而,通过外键来链接表是很有挑战性的。关系数据库的强大之处在于它的灵活性。问题是这种无限的灵活性意味着没有单一的“正确”做事方式。

不过,不要因此而分心。您的直觉可能是坚持使用单个表,但是沿着这条路走下去会有更大的复杂性。让使用数据库变得容易的关键是在早期阶段限制你的野心。像本章一样建立简单的结构,用它们做实验,了解它们是如何工作的。逐渐添加表和外键链接。有大量数据库工作经验的人说,他们经常花一半以上的开发时间来考虑表结构。在那之后,编码是容易的部分!

在最后一章中,我们将回到使用单个表,解决使用数据库进行用户身份验证的重要问题,以及如何处理散列和加密的密码。

十九、使用数据库认证用户

第十一章向您展示了用户认证和会话的原则,以对您的网站的部分进行密码保护,但是登录脚本都依赖于存储在 CSV 文件中的用户名和密码。将用户详细信息保存在数据库中更安全也更高效。数据库不仅仅存储用户名和密码的列表,还可以存储其他细节,比如名、姓、电子邮件地址等等。数据库还为您提供了使用散列(单向且不可逆)或加密(双向)的选项。在本章的第一节,我们将检查两者之间的区别。然后,您将为这两种类型的存储创建注册和登录脚本。

本章涵盖

  • 决定如何存储密码

  • 使用单向密码散列进行用户注册和登录

  • 使用双向加密进行用户注册和登录

  • 解密密码

选择密码存储方法

第十一章中的 PHP 解决方案使用了密码散列——一旦密码被散列,这个过程就无法逆转。这既是优点也是缺点。它为用户提供了更高的安全性,因为以这种方式存储的密码是保密的。然而,没有办法重新发布丢失的密码,因为甚至网站管理员也不能从散列版本中提取原始密码。唯一的解决办法是重设密码。

另一种方法是使用秘密密钥加密。这是一个双向、可逆的过程,依赖于一对函数:一个对密码进行加密,另一个将密码转换回纯文本,从而可以方便地向健忘的用户重新发布密码。双向加密使用传递给两个函数的密钥来执行转换。密钥只是一个你自己编的字符串。显然,为了保证数据的安全,密钥需要足够难以猜测,并且永远不应该存储在数据库中。但是,您需要将密钥嵌入到您的注册和登录脚本中——要么直接嵌入,要么通过包含文件嵌入——因此,如果您的脚本暴露了,您的安全性就会大大降低。

MySQL 和 MariaDB 提供了许多双向加密功能,但AES_ENCRYPT()被认为是最安全的。它使用美国政府批准的 128 位密钥长度(AES-128)的高级加密标准,用于保护机密级别的机密材料(绝密材料需要 AES-192 或 AES-256)。

哈希和密钥加密都有优点和缺点。许多安全专家建议,密码应该经常更改。因此,强迫用户更改忘记的密码,因为它无法被解密,可以被视为一个很好的安全措施。另一方面,用户可能会因为每次忘记现有密码时都需要处理新密码而感到沮丧。我将让您决定哪种方法最适合您的环境,我将只关注技术实现。

使用密码哈希

为了简单起见,我将使用与第十一章中相同的基本形式,因此只有用户名和散列密码存储在数据库中。

创建一个表来存储用户的详细信息

在 phpMyAdmin 中,在phpsols数据库中创建一个名为users的新表。该表需要三列,设置如表 19-1 所示。

表 19-1

用户表的设置

|

名字

|

类型

|

长度/值

|

属性

|

|

索引

|

A_I

|
| --- | --- | --- | --- | --- | --- | --- |
| user_id | INT |   | UNSIGNED | 取消选择 | PRIMARY | 挑选 |
| username | VARCHAR | 15 |   | 取消选择 | UNIQUE |   |
| pwd | VARCHAR | 255 |   | 取消选择 |   |   |

为了确保没有人可以注册与已经使用的用户名相同的用户名,username列被赋予了一个UNIQUE索引。

用于密码的pwd列允许存储最多 255 个字符的字符串。这比password_hash()使用的默认散列算法所需的 60 个字符要长得多。但是PASSWORD_DEFAULT常数被设计成随着新的更强的算法被添加到 PHP 中而随时间改变。所以推荐的大小是 255 个字符。

在数据库中注册新用户

要在数据库中注册用户,您需要创建一个要求输入用户名和密码的注册表单。已经用一个UNIQUE索引定义了username列,因此如果有人试图注册与现有用户名相同的用户名,数据库将返回一个错误。除了认证用户输入,处理脚本还需要检测错误,并建议用户选择不同的用户名。

PHP 解决方案 19-1:创建用户注册表单

这个 PHP 解决方案展示了如何改编第十一章中的注册脚本来使用 MySQL 或 MariaDB。它使用了 PHP 解决方案 11-3 中的CheckPassword类和 PHP 解决方案 11-4 中的register_user_csv.php

如果需要,将ch19/Php8Solutions/Authenticate文件夹中的CheckPassword.php复制到php8sols站点根目录下的Php8Solutions/Authenticate文件夹中,将register_user_csv.phpch19文件夹中复制到includes文件夹中。你也应该阅读 PHP 解决方案 11-3 和 11-4 中的说明来理解原始脚本是如何工作的。

  1. register_db.phpch19文件夹复制到php8sols站点根目录下一个名为authenticate的新文件夹中。该页面包含与第十一章相同的基本用户注册表单,有一个用于用户名的文本输入字段、一个密码字段、另一个用于确认的密码字段和一个用于提交数据的按钮,如下面的屏幕截图所示:

img/332054_5_En_19_Figa_HTML.jpg

  1. 在 PHP 块中的DOCTYPE声明上方添加以下代码:
if (isset($_POST['register'])) {
    $username = trim($_POST['username']);
    $password = trim($_POST['pwd']);
    $retyped = trim($_POST['conf_pwd']);
    require_once '../includes/register_user_mysqli.php';
}

这与 PHP 解决方案 11-4 中的代码非常相似。如果表单已经提交,用户输入将被去掉前导和尾部的空格,并分配给简单的变量。然后包含一个名为register_user_mysqli.php的外部文件。如果您计划使用 PDO,请将包含文件命名为register_user_pdo.php

  1. 处理用户输入的文件基于章节 11 中的register_user_csv.php。制作一份原始文件的副本(或使用ch19文件夹中的版本)并以register_user_mysqli.phpregister_user_pdo.php的名称保存在includes文件夹中。

  2. 在您刚刚复制并重命名的文件中,找到如下开头的条件语句(在第 18 行周围):

  3. 删除条件语句中的其余代码。条件语句现在应该是这样的:

if (!$errors) {
    // hash password using default algorithm
    $password = password_hash($password, PASSWORD_DEFAULT);

  1. 将用户详细信息插入数据库的代码放在条件语句中。首先包含数据库连接文件,并创建一个具有读写权限的连接:
if (!$errors) {
    // hash password using default algorithm
    $password = password_hash($password, PASSWORD_DEFAULT);
}

if (!$errors) {
    // hash password using default algorithm
    $password = password_hash($password, PASSWORD_DEFAULT);
    // include the connection file
    require_once 'connection.php';
    $conn = dbConnect('write');
}

连接文件也在includes文件夹中,所以您只需要文件名。对于 PDO,添加'pdo'作为dbConnect()的第二个参数。

  1. 代码的最后一部分准备并执行准备好的语句,将用户的详细信息插入到数据库中。因为username列有一个UNIQUE索引,如果用户名已经存在,查询就会失败。如果发生这种情况,代码需要生成一条错误消息。MySQLi 和 PDO 的代码是不同的。

对于 MySQLi,添加以粗体突出显示的代码:

if (!$errors) {
    // hash password using default algorithm
    $password = password_hash($password, PASSWORD_DEFAULT);
    // include the connection file
    require_once 'connection.php';
    $conn = dbConnect('write');
    // prepare SQL statement
    $sql = 'INSERT INTO users (username, pwd) VALUES (?, ?)';
    $stmt = $conn->stmt_init();
    if ($stmt = $conn->prepare($sql)) {
        // bind parameters and insert the details into the database
        $stmt->bind_param('ss', $username, $password);
        $stmt->execute();
    }
    if ($stmt->affected_rows == 1) {
        $success = htmlentities($username) . ' has been registered.
            You may now log in.';
    } elseif ($stmt->errno == 1062) {
        $errors[] = htmlentities($username) . ' is already in use.
            Please choose another username.';
    } else {
        $errors[] = $stmt->error;
    }
}

新代码首先将参数绑定到准备好的语句。用户名和密码是字符串,所以bind_param()的第一个参数是'ss'(参见第十三章中的“在 MySQLi 预准备语句中嵌入变量”)。执行完语句后,条件语句检查affected_rows属性的值。如果是1,说明细节已经插入成功。

提示你需要明确检查affected_rows的值,因为如果有错误,它就是 1。与一些编程语言不同,PHP 将 1 视为true

替代条件检查准备好的语句的errno属性的值,该属性包含 MySQL 错误代码。索引为UNIQUE的列中重复值的代码是1062。如果检测到该错误代码,就会向$errors数组添加一条错误消息,要求用户选择不同的用户名。如果生成了不同的错误代码,存储在语句的error属性中的消息将被添加到$errors数组中。

PDO 版本看起来是这样的:

if (!$errors) {
    // encrypt password using default encryption
    $password = password_hash($password, PASSWORD_DEFAULT);
    // include the connection file
    require_once 'connection.php';
    $conn = dbConnect('write', 'pdo');
    try {
        // prepare SQL statement
        $sql = 'INSERT INTO users (username, pwd) VALUES (:username, :pwd)';
        $stmt = $conn->prepare($sql);
        // bind parameters and insert the details into the database
        $stmt->bindParam(':username', $username, PDO::PARAM_STR);
        $stmt->bindParam(':pwd', $password, PDO::PARAM_STR);
        $stmt->execute();
        if ($stmt->rowCount() == 1) {
            $success = htmlentities($username) . ' has been registered.
                You may now log in.';
        }
    } catch (PDOException $e) {
          if ($e->getCode() == 23000) {
              $errors[] = htmlentities($username) . 'is already in use.
                  Please choose another username.';
          } else {
               $errors[] = $e->getMessage();
          }
    }
}

PHP 8 中 PDO 的默认错误模式是出错时抛出异常,因此向数据库提交查询的代码需要包装在try / catch块中。

准备好的语句对usernamepwd列使用命名参数。提交的值通过bindParam()方法绑定到它,使用PDO::PARAM_STR常量将数据类型指定为字符串。执行完语句后,条件语句使用rowCount()方法检查记录是否已经创建。

如果准备好的语句失败,catch块处理 PDOException。条件语句调用异常对象上的getCode()方法。如果用户名已经存在于数据库中,则返回值为23000。PDO 使用 ANSI SQL 标准定义的错误代码,而不是 MySQL 生成的错误代码。如果错误代码匹配,则向$errors数组添加一条消息,要求用户选择不同的用户名。否则,使用来自getMessage()方法的错误消息。

Note

在 MySQLi 和 PDO 脚本中,当在一个活动的网站上部署注册脚本时,用一个通用错误消息替换else块中的代码。显示语句的error属性(MySQLi)或$e->getMessage() (PDO)的值仅用于测试目的。

  1. 剩下的工作就是添加在注册页面上显示结果的代码。在register_db.php中的开始<form>标记之前添加以下代码:

  2. 保存register_db.php,并在浏览器中加载。通过输入您知道违反密码强度规则的输入来测试它。如果您在同一次尝试中犯了多个错误,错误消息的项目符号列表应该出现在表单的顶部,如下一个屏幕截图所示:

<h1>Register user</h1>
<?php
if (isset($success)) {
    echo "<p>$success</p>";
} elseif (isset($errors) && !empty($errors)) {
    echo '<ul>';
    foreach ($errors as $error) {
        echo "<li>$error</li>";
    }
    echo '</ul>';
}
?>
<form action="register_db.php" method="post">

img/332054_5_En_19_Figb_HTML.jpg

  1. 现在正确地填写登记表。您应该会看到一条消息,告诉您已经为您选择的用户名创建了一个帐户。

  2. 尝试再次注册相同的用户名。这一次,您应该会看到与下面的屏幕截图类似的消息:

img/332054_5_En_19_Figc_HTML.jpg

  1. 如有必要,对照register_db_mysqli.phpregister_user_mysqli.phpregister_db_pdo.phpregister_user_pdo.php检查你的代码,这些都在ch19文件夹中。

现在您已经在数据库中注册了用户名和密码,您需要创建一个登录脚本。ch19文件夹包含一组复制 PHP 解决方案 11-5 到 11-7 中设置的文件:一个登录页面和两个受密码保护的页面。

PHP 解决方案 19-2:用数据库认证用户凭证

这个 PHP 解决方案展示了如何通过查询数据库找到用户名密码的散列版本,然后将其作为参数与用户提交的密码一起传递给password_verify(),从而认证用户存储的凭证。如果password_verify()返回true,用户将被重定向到受限页面。

  1. login_db.phpmenu_db.phpsecretpage_db.phpch19文件夹复制到authenticate文件夹。同样将logout_db.phpsession_timeout_db.phpch19文件夹复制到includes文件夹。

    这建立了与第十一章中使用的相同的基本测试平台。唯一的区别是链接被改为重定向到authenticate文件夹。

  2. login_db.php中,在DOCTYPE声明上方的 PHP 块中添加以下代码:

$error = ";
if (isset($_POST['login'])) {
    session_start();
    $username = trim($_POST['username']);
    $password = trim($_POST['pwd']);
    // location to redirect on success
    $redirect = 'http://localhost/php8sols/authenticate/menu_db.php';
    require_once '../includes/authenticate_mysqli.php';
}

这与第十一章中登录表单的代码遵循相似的模式。它首先将$error初始化为一个空字符串。如果表单已提交,条件语句将启动一个会话。从用户输入字段中删除空白,用户成功时将被重定向到的页面位置存储在一个变量中。最后,包含了您接下来要构建的身份验证脚本。

如果您使用 PDO,使用authenticate_pdo.php作为处理脚本。

  1. 创建一个名为authenticate_mysqli.phpauthenticate_pdo.php的新文件,并保存在includes文件夹中。该文件将只包含 PHP 脚本,所以去掉任何 HTML 标记。

  2. 包括数据库连接文件,使用只读帐户创建到数据库的连接,并使用准备好的语句获取用户的详细信息。

对于 MySQLi,使用以下代码:

<?php
require_once 'connection.php';
$conn = dbConnect('read');
// get the username's hashed password from the database
$sql = 'SELECT pwd FROM users WHERE username = ?';
// initialize and prepare statement
$stmt = $conn->stmt_init();
$stmt->prepare($sql);
// bind the input parameter
$stmt->bind_param('s', $username);
$stmt->execute();
// bind the result, using a new variable for the password
$stmt->bind_result($storedPwd);
$stmt->fetch();

这是一个如此简单的SELECT查询,以至于我在将它传递给 MySQLi prepare()方法时没有使用条件语句。用户名是一个字符串,所以bind_param()的第一个参数是's'。如果找到匹配,结果将绑定到$storedPwd。您需要为存储的密码使用一个新的变量,以避免覆盖用户提交的密码。

执行完语句后,fetch()方法获得结果。

对于 PDO,请改用以下代码:

<?php
require_once 'connection.php';
$conn = dbConnect('read', 'pdo');
// get the username's hashed password from the database
$sql = 'SELECT pwd FROM users WHERE username = ?';
// prepare statement
$stmt = $conn->prepare($sql);
// pass the input parameter as a single-element array
$stmt->execute([$username]);
$storedPwd = $stmt->fetchColumn();

这段代码和 MySQLi 版本做的一样,但是使用了 PDO 语法。用户名作为单元素数组传递给execute()方法。因为结果中只有一列,fetchColumn()返回值并将其分配给$storedPwd

  1. 一旦您获得了用户名的密码,您需要做的就是将提交和存储的版本传递给password_verify()。如果password_verify()返回true,创建会话变量以指示成功登录和会话开始的时间,重新生成会话 ID,并重定向到受限页面。否则,在$error中存储一条错误信息。

在上一步中输入的代码后插入以下代码。MySQLi 和 PDO 都是如此。

// check the submitted password against the stored version
if (password_verify($password, $storedPwd)) {
    $_SESSION['authenticated'] = 'Jethro Tull';
    // get the time the session started
    $_SESSION['start'] = time();
    session_regenerate_id();
    header("Location: $redirect");
    exit;
} else {
    // if not verified, prepare error message
    $error = 'Invalid username or password';
}

正如在第十一章中一样,$_SESSION['authenticated']的值并不重要。

  1. 保存authenticate_mysqli.phpauthenticate_pdo.php,用你在 PHP 解决方案 19-1 末尾注册的用户名和密码登录,测试login_db.php。登录过程应该与第十一章中的方式完全相同。不同之处在于,所有细节都更安全地存储在数据库中。

如果有必要,你可以对照login_mysqli.phpauthenticate_mysqli.php或者login_pdo.phpauthenticate_pdo.php来检查你的代码,它们都在ch19文件夹中。如果遇到问题,最常见的错误是在数据库中为散列密码创建了太窄的列。它必须至少有 60 个字符宽,建议它能够存储多达 255 个字符,以防将来的加密方法生成更长的字符串。

虽然在数据库中存储哈希密码比使用文本文件更安全,但密码是以明文、未加密的文本形式从用户的浏览器发送到服务器的。为了安全起见,应该通过传输层安全(TLS)或安全套接字层(SSL)连接来登录和访问后续页面。

使用密钥加密

为秘钥加密设置用户注册和验证的主要区别在于,密码需要使用BLOB数据类型作为二进制对象存储在数据库中(更多信息请参见第十二章中的“存储二进制数据”),密码验证发生在 SQL 查询中,而不是在 PHP 脚本中。

创建存储用户详细信息的表

在 phpMyAdmin 中,在phpsols数据库中创建一个名为users_2way的新表。它需要三列,设置如表 19-2 所示。

表 19-2

users_2way 表的设置

|

名字

|

类型

|

长度/值

|

属性

|

|

索引

|

A_I

|
| --- | --- | --- | --- | --- | --- | --- |
| user_id | INT |   | UNSIGNED | 取消选择 | PRIMARY | 挑选 |
| username | VARCHAR | 15 |   | 取消选择 | UNIQUE |   |
| pwd | BLOB |   |   | 取消选择 |   |   |

注册新用户

AES_ENCRYPT()函数有两个参数:要加密的值和加密密钥。加密密钥可以是您选择的任何字符串。出于这个例子的目的,我选择了takeThisWith@PinchOfSalt,但是一系列随机的字母数字字符和符号会更安全。默认情况下,AES_ENCRYPT()用 128 位密钥对数据进行编码。对于更安全的 256 位密钥长度,需要将 MySQL 中的block_encryption_mode系统变量设置为aes-256-cbc(详见 https://dev.mysql.com/doc/refman/8.0/en/encryption-functions.html#function_aes-decrypt )。

单向密码散列和密钥加密的基本注册脚本是相同的。唯一的区别在于将用户数据插入数据库的部分。

提示下面的脚本将加密密钥直接嵌入页面。出于安全考虑,您应该在包含文件中定义密钥,并将其存储在服务器的文档根目录之外。

MySQLi 的代码如下所示(完整的清单在ch19文件夹的register_2way_mysqli.php中):

if (!$errors) {
    // include the connection file
    require_once 'connection.php';
    $conn = dbConnect('write');
    // create a key
    $key = 'takeThisWith@PinchOfSalt';
    // prepare SQL statement
    $sql = 'INSERT INTO users_2way (username, pwd)
            VALUES (?, AES_ENCRYPT(?, ?))';
    $stmt = $conn->stmt_init();
    if ($stmt = $conn->prepare($sql)) {
        // bind parameters and insert the details into the database
        $stmt->bind_param('sss', $username, $password, $key);
        $stmt->execute();
    }
    if ($stmt->affected_rows == 1) {
        $success = htmlentities($username) . ' has been registered. You may now log in.';
    } elseif ($stmt->errno == 1062) {
        $errors[] = htmlentities($username) . ' is already in use. Please choose another username.';
    } else {
        $errors[] = $stmt->error;
    }
}

对于 PDO,它看起来是这样的(完整列表见ch19文件夹中的register_2way_pdo.php):

if (!$errors) {
    // include the connection file
    require_once 'connection.php';
    $conn = dbConnect('write', 'pdo');
    // create a key
    $key = 'takeThisWith@PinchOfSalt';
    try {
        // prepare SQL statement
        $sql = 'INSERT INTO users_2way (username, pwd)
                   VALUES (:username, AES_ENCRYPT(:pwd, :key))';
        $stmt = $conn->prepare($sql);
        // bind parameters and insert the details into the database
        $stmt->bindParam(':username', $username, PDO::PARAM_STR);
        $stmt->bindParam(':pwd', $password, PDO::PARAM_STR);
        $stmt->bindParam(':key', $key, PDO::PARAM_STR);
        $stmt->execute();
        if ($stmt->rowCount() == 1) {
            $success = htmlentities($username) . ' has been registered. You may now log in.';
        }
    } catch (PDOException $e) {
          if ($e->getCode() == 23000) {
              $errors[] = htmlentities($username) . ' is already in use. Please choose another username.';
          } else {
               $errors[] = $e->getMessage();
          }
    }
}

严格来说,没有必要为$key使用绑定参数,因为它不是来自用户输入。但是,如果直接将其嵌入到查询中,整个查询需要用双引号括起来,而$key需要用单引号括起来。

为了测试前面的脚本,将它们复制到includes文件夹中,并包含在register_db.php中,而不是register_db_mysqli.phpregister_db_pdo.php中。

双向加密的用户认证

创建双向加密的登录页面非常简单。连接到数据库后,将用户名、密钥和未加密的密码合并到一个SELECT查询的WHERE子句中。如果查询找到匹配,则允许用户进入站点的受限部分。如果不匹配,登录将被拒绝。代码与 PHP 解决方案 19-2 中的相同,除了下面的部分。

对于 MySQLi,看起来是这样的(见authenticate_2way_mysqli.php):

<?php
require_once 'connection.php';
$conn = dbConnect('read');
// create key
$key = 'takeThisWith@PinchOfSalt';
$sql = 'SELECT username FROM users_2way
        WHERE username = ? AND pwd = AES_ENCRYPT(?, ?)';
// initialize and prepare statement
$stmt = $conn->stmt_init();
$stmt->prepare($sql);
// bind the input parameters
$stmt->bind_param('sss', $username, $password, $key);
$stmt->execute();
// to get the number of matches, you must store the result
$stmt->store_result();
// if a match is found, num_rows is 1, which is treated as true
if ($stmt->num_rows) {
    $_SESSION['authenticated'] = 'Jethro Tull';
    // get the time the session started
    $_SESSION['start'] = time();
    session_regenerate_id();
    header("Location: $redirect"); exit;
} else {
    // if not verified, prepare error message
    $error = 'Invalid username or password';
}

注意,在访问num_rows属性之前,需要存储预处理语句的结果。如果不这样做,num_rows将始终是0,即使用户名和密码正确,登录也会失败。

修订后的 PDO 代码如下所示(见authenticate_2way_pdo.php):

<?php
require_once 'connection.php';
$conn = dbConnect('read', 'pdo');
// create key
$key = 'takeThisWith@PinchOfSalt';
$sql = 'SELECT username FROM users_2way
        WHERE username = ? AND pwd = AES_ENCRYPT(?, ?)';
// prepare statement
$stmt = $conn->prepare($sql);
// bind variables by passing them as an array when executing statement
$stmt->execute([$username, $password, $key]);
// if a match is found, rowCount() produces 1, which is treated as true
if ($stmt->rowCount()) {
    $_SESSION['authenticated'] = 'Jethro Tull';
    // get the time the session started
    $_SESSION['start'] = time();
    session_regenerate_id();
    header("Location: $redirect"); exit;
} else {
    // if not verified, prepare error message
    $error = 'Invalid username or password';
}

为了测试这些脚本,将它们复制到includes文件夹中,用它们代替authenticate_mysqli.phpauthenticate_pdo.php

解密密码

解密使用双向加密的密码只需将密钥作为准备好的语句中的第二个参数传递给AES_DECRYPT(),如下所示:

$key = 'takeThisWith@PinchOfSalt';
$sql = "SELECT AES_DECRYPT(pwd, '$key') AS pwd FROM users_2way
        WHERE username = ?";

该密钥必须与最初用于加密密码的密钥完全相同。如果您丢失了密钥,密码仍然像使用单向哈希存储的密码一样不可访问。

通常,只有当用户请求密码提醒时,才需要解密密码。创建适当的安全策略来发送这样的提醒在很大程度上取决于您正在运行的站点的类型。然而,不言而喻,你不应该在屏幕上显示解密的密码。您需要设置一系列安全检查,比如询问用户的出生日期,或者提出一个只有用户可能知道答案的问题。即使用户答对了,您也应该通过电子邮件将密码发送到用户的注册地址。

如果你已经在这本书里学了这么多,所有必要的知识都应该唾手可得。

更新用户详细信息

我没有包括任何用户注册页面的更新表单。这是一个你在这个阶段应该能够独立完成的任务。关于更新用户注册细节最重要的一点是,您不应该在更新表单中显示用户的现有密码。如果你使用的是密码散列法,无论如何都不行。

接下来去哪里?

这本书涵盖了大量的领域。如果您已经掌握了这里介绍的所有技术,那么您正在成为一名中级 PHP 开发人员,再努力一点,您将进入高级水平。如果这是一场斗争,不要担心。再看一遍前面的章节。你练习得越多,它就变得越容易。

你可能在想,“我到底怎么能记住所有这些?”你不需要。不要羞于查阅资料。将 PHP 在线手册( www.php.net/manual/en/ )加入书签,定期使用。它不断更新,并且有很多有用的例子。在每页右上角的搜索框中键入一个函数名称,即可直接进入该函数的完整描述。即使您不记得正确的函数名,手册也会将您带到一个页面,提示最可能的候选函数。大多数网页都有实际的例子来展示这个函数或类是如何使用的。

让动态 web 设计变得容易的不是 PHP 函数和类的百科知识,而是对条件语句、循环和其他结构如何控制脚本流的深刻理解。一旦你可以用“如果这种情况发生了,接下来会发生什么?”来想象你的项目你是自己游戏的主人。我经常查阅 PHP 在线手册。对我来说,它就像一本字典。大多数时候,我只是想检查我的论点顺序是否正确,但我经常发现一些东西吸引了我的眼球,并打开了新的视野。我可能不会立即使用这些知识,但我会把它们储存在脑海中以备将来使用,并在需要检查细节时回去查看。

MySQL 在线手册( https://dev.mysql.com/doc/refman/8.0/en/ )同样有用。MariaDB 的文档位于 https://mariadb.com/kb/en/library/documentation/ 。让 PHP 和数据库在线手册成为你的朋友,你的知识将会突飞猛进。

posted @ 2024-08-03 11:23  绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报