PHP-速成课-全-

PHP 速成课(全)

原文:zh.annas-archive.org/md5/a645d52b4680a001fe349ae8b04ab2c1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

PHP 是驱动互联网的引擎之一:它在用户在网页上看到的内容和后台发生的事情(例如处理表单提交、与其他网站对话、与数据库交互)中都发挥着作用。该语言首次发布于 1995 年,但直到 2000 年代末,当我的计算机学生鼓励我将 PHP 引入他们的网页编程课程时,我才开始认真使用它。他们希望提高自己的技能,以便在就业市场上更具竞争力,因为他们认为互联网将继续在计算机工作中变得越来越重要。显然,他们是对的。 ## 本书适用人群

PHP 速成课程适合任何想要以实用、动手的方式学习 PHP 编程的人,无论你是否有编程经验。由于大多数 PHP 程序都是网页应用程序,了解用于表示网页内容和视觉样式的超文本标记语言(HTML)和层叠样式表(CSS)可能会有所帮助,但你不需要了解任何网页编程语言,如 JavaScript。

为什么选择 PHP?

PHP 目前已经进入第八个主要版本,因此它不仅经过了充分的测试,而且比以往更加快速和安全。它是一种免费、开源且维护良好的语言。虽然还有其他流行的网页编程语言,但大约 70%的互联网是由 PHP 程序驱动的,包括 Etsy、Facebook(使用其 PHP 方言 Hack)、Spotify、Wikipedia 和 WordPress。

PHP 也有一个相对较浅的学习曲线。我们将从几行代码开始,一旦你掌握了基础知识,我们将进入更大、更结构化的网页应用系统。

注意

该语言的最初版本发布为个人主页工具(PHP Tools)。然而,现在,PHP 是一个递归首字母缩略词,代表 PHP:超文本预处理器。

你将学到什么

在本书中,你将学习如何用 PHP 编程,从短小、简单的脚本到多文件、数据库驱动、登录安全的面向对象的网页应用程序。

在第一部分,语言基础部分,你将开始编写小型 PHP 程序脚本。本部分介绍了语言的一些基础知识,包括将不同类型的值存储在命名变量中、处理文本以及编写基于决策的逻辑。

第一章:PHP 程序基础   介绍了如何在在线编码环境和电脑上的编辑器中编写和执行 PHP 脚本。

第二章:数据类型   讨论不同类型的数据以及 PHP 如何自动在它们之间进行转换。

第三章:字符串与字符串函数   介绍如何在自己的代码中以及使用语言提供的一些内建文本函数来处理文本。

第四章: 条件语句   探索语言中的条件元素,如 if...else、switch 和 match,并提供了每种语句最合适的使用指南。你还将了解操作符,如逻辑比较,用于实现决策逻辑。

第五章: 自定义函数   介绍函数,它们是可重用的、自包含的代码序列,用于完成任务。

在 第二部分,数据处理部分中,你将使用循环来重复操作,并学习更复杂的数据结构,如数组和文件。

第六章: 循环   介绍通过结构重复操作来增加灵活性并避免代码重复。

第七章: 简单数组   介绍数组,这是一种存储和操作多个数据项的机制,通过单一变量名进行管理。

第八章: 高级数组   在上一章的基础上,深入探讨更复杂的键值映射和多维数组。

第九章: 文件和目录   探索如何以及何时在 PHP 脚本中使用函数与文件进行交互。

在 第三部分,Web 应用程序编程中,你将开始创建 Web 应用程序,包括接收和验证通过表单提交的数据。

第十章: 客户端/服务器通信和 Web 开发基础   介绍关于客户端、服务器以及 PHP 驱动的 Web 应用程序如何工作的基本概念。

第十一章: 创建和处理 Web 表单   介绍如何设计 Web 表单以及如何编写 PHP 脚本处理通过这些表单提交的数据。

第十二章: 验证表单数据   探索验证接收数据的方法,并涵盖一些典型的决策逻辑,根据接收到的值是否正确或缺失采取适当的行动。

第十三章: 组织一个 Web 应用程序   逐步探索模型-视图-控制器(MVC)软件架构方法,该方法将维护应用程序的职责分配给多个脚本,从而使应用程序能够增长而不至于变得无法管理。

第四部分,使用浏览器会话存储用户数据,介绍了会话,允许网站在页面请求之间记住数据;这对购物车和登录非常有用。

第十四章: 会话管理   介绍 PHP 功能,使 Web 应用程序能够记住跨时间段的信息。

第十五章: 实现购物车   介绍如何将商品添加到购物车,并如何在用户准备结账支付时记住这些商品。

第十六章: 认证与授权实现了安全认证(确定使用计算机系统的人的身份)和授权(决定用户是否被允许访问系统的特定部分)。

在第五部分,面向对象 PHP,你将学习到强大的面向对象编程(OOP)技术。

第十七章: 面向对象编程简介讨论了从函数式编程转向 OOP 方法的动机以及这两者之间的区别。

第十八章: 声明类和创建对象讲解了面向对象编程中类和对象的核心特性。

第十九章: 继承描述了面向对象编程中强大的继承概念,并讲解了如何在代码中实现它。

第二十章: 使用 Composer 管理类和命名空间介绍了在 PHP 编程语言中实现 OOP 解决方案的关键机制,并展示了 Composer 命令行工具如何提供帮助。

第二十一章: 使用 Twig 进行高效模板设计探索了基于继承的 Twig 库系统,用于开发页面模板,它简化了将应用数据与 HTML“装饰”并返回给用户的过程。

第二十二章: 结构化面向对象的 Web 应用程序介绍了一种常用的、可扩展的 Web 应用程序软件架构。

第二十三章: 使用异常处理错误讨论了异常的错误处理机制,这是许多编程语言的特性。

第二十四章: 记录事件、消息和事务展示了如何维护系统日志,包括将日志输出到日志文件或外部的云日志服务,这是大规模 Web 应用程序中常见的做法。

第二十五章: 静态方法、属性和枚举讲解了面向对象编程中类级别的静态成员,并讨论了枚举这一特殊类别,便于提供一组固定的可能值。

第二十六章: 抽象方法、接口和特性探索了如何在多个类之间共享方法,而不通过传统的继承过程。

在第六部分,数据库驱动的应用程序开发,详细介绍了如何编写与数据库系统通信的程序,并最终讨论了如何在程序代码中以及数据库中处理日期和时间。

第二十七章: 数据库简介介绍了数据库及其与 Web 应用程序的关系。

第二十八章: 使用 PDO 库进行数据库编程讨论了编写与数据库通信的代码。

**第二十九章: 编程 CRUD 操作   ** 展示了如何将数据库 CRUD(创建、读取、更新、删除)功能引入到网页应用中。

**第三十章: ORM 库与数据库安全   ** 介绍了通过对象关系映射(ORM)库自动化代码与数据库结构之间关系的好处,并概述了几个安全的数据库驱动网页开发的最佳实践。

**第三十一章: 处理日期和时间   ** 介绍了如何处理时间信息,包括如何处理时区和夏令时等歧义问题。

最后,附录介绍了如何设置开始使用 PHP 所需的工具。

**附录 A: 安装 PHP   ** 讲解了如何在 macOS、Linux 和 Windows 操作系统上安装 PHP。

**附录 B: 数据库设置   ** 讲解了如何确保 MySQL 和 SQLite 数据库管理系统在本地计算机上正确设置。

**附录 C: Replit 配置   ** 讨论了如何重新配置更高级的 Replit 项目,以便与 Composer 依赖管理器和数据库管理系统等工具一起使用。

在线资源

本书的代码清单以及我建议的练习解答可以通过 github.com/dr-matt-smith/php-crash-course 下载。

每一章的末尾都包含了练习题。我建议你先自己尝试这些题目,再查看我的解答。

关于本书的更新和其他信息,请参见 nostarch.com/php-crash-course

第一部分 语言基础

第一章:1 PHP 程序基础

本章将介绍两种创建和运行 PHP 程序的方法:使用在线编码环境和使用本地安装的编辑器。我们将尝试这两种方法,以练习一些关键的编程任务,比如打印文本消息、给变量赋值以及处理不同类型的数据。我们还将探索 PHP 语言的核心特性,包括注释、常量和表达式。

运行 PHP 的两种方法

通常,学习编程语言的最简单方法是使用一个已经为你配置好的在线环境。这样你可以立刻开始编码并实时查看结果,而不需要安装和配置语言引擎、代码编辑器、网络服务器或其他工具。另一方面,一些人更喜欢自己计算机上安装的编程环境,因为它提供了更多的自定义和控制。

在本节中,你将探索这两种方法,并写出你的第一个 PHP 程序。然后,你可以使用任意一种方法来跟随本书中的示例。

Replit 在线编码环境

有多个在线平台可以支持交互式 PHP 开发,并且可以为你运行 PHP 网络服务器。我们将重点介绍 Replit (replit.com),这是一个广受欢迎的服务,适合初学者的免费项目。要试用它,请访问 Replit 网站并创建一个账户。

注意

Replit 的名称源自 读-评估-打印循环(REPL),这是一种计算机环境,在这种环境中,程序员输入一个表达式,系统立即执行它,打印出响应并等待下一个输入。命令行终端就是一种 REPL,程序员输入单行命令,终端执行这些命令。

Replit 提供了两种官方的、预配置的模板来创建 PHP 项目:PHP CLI(即命令行界面)和 PHP Web 服务器。前者适用于仅在命令行终端窗口输出文本或处理数据文件的项目,而后者则用于 web 开发。我们来看看这两个模板,以便在学习 PHP 程序工作原理的过程中获得一些了解。

创建命令行界面项目

要创建一个 PHP 命令行界面项目,首先进入 Replit 账户的主页,然后点击 Create Repl。这将弹出一个窗口,你可以在其中搜索项目模板。在搜索框中输入 PHP。结果中应包括 Replit 的 PHP CLI 和 PHP Web 服务器模板。(在这些官方的 Replit 模板下,你还可能看到一些由 Replit 用户创建并标注为 PHP 语言的其他模板。)选择 PHP CLI,并为你的项目输入一个名称,或者使用默认的随机单词名称。然后点击 Create Repl 来启动项目。

网站将花费一些时间来设置你的新项目,包括创建文件和文件夹结构,并启动一个云虚拟机来运行它。当项目加载时,你将看到如图 1-1 所示的三列屏幕。

图 1-1:新 PHP CLI 项目屏幕,带有默认的“Hello, world!”脚本

左侧列显示项目的文件和文件夹,中间列是一个在线代码编辑器,右侧列是命令行终端输出(称为控制台)和 Replit 创建的虚拟计算机的交互式终端(称为Shell)。屏幕顶部的运行按钮会运行项目,此时所有输出将显示在控制台中。

一个典型的 PHP 项目包含一个或多个文件,称为脚本,并以.php 文件扩展名保存。在这种情况下,Replit PHP CLI 项目会自动启动一个名为main.php的预编写文件。该文件包含 PHP 代码,用于在控制台输出消息“Hello, world!”。编写一个显示此消息的程序是学习新语言时的一项传统。除了有趣,它还提供了一个机会,学习如何命名包含程序的文本文件,如何编写有效的语句,以及如何执行程序。更重要的是,“Hello, world!”脚本作为计算机系统上语言工具的基本测试:如果程序运行并成功输出“Hello, world!”消息,那么 PHP 就正常工作了。

清单 1-1 显示了 Replit 提供的默认“Hello, world!”脚本,它位于main.php中。

<?php
echo "Hello, world!\n";

清单 1-1:main.php 中的“Hello, world!”程序

脚本开头的<?php 是一个开头的 PHP 标签。此标签表示接下来的内容是 PHP 代码。在这个例子中,代码使用 echo 命令在控制台中打印出文本“Hello, world!”,后面跟着一个换行符(\n)。请注意,要打印的文本被双引号括起来。这些引号表示文本是一个字符串,它是一种由一系列字符组成的数据类型。我们将在第三章中详细讨论字符串和特殊字符,如 \n。

echo 语句是一条语句的例子,它是一个指示计算机执行某个任务的单一命令(在本例中是显示某些文本)。每个 PHP 语句必须以分号(;)结束,以表示命令结束,就像这个 echo 语句一样。可以把分号看作是句子结尾的句号;没有它,语句被认为是不完整的。

运行 main.php 脚本

点击绿色的运行按钮来运行main.php脚本。你应该会看到控制台打印出“Hello, world!”消息。恭喜你,你刚刚运行了第一个 PHP 程序!但是,当你点击“运行”时,实际上发生了什么呢?

PHP 是一种脚本化编程语言。这意味着一个名为解释器的程序会在执行 PHP 文件时将其内容翻译成机器代码。其他脚本化语言包括 Python 和 JavaScript。脚本化语言不同于 编译型 编程语言,如 C、C++ 和 Swift,这些语言的翻译过程发生在执行之前的独立步骤中。在这个额外的步骤中,所有程序文件都会被编译并优化为一个或多个可执行文件。

将 PHP 脚本转换为可执行代码的解释器通常被称为PHP 引擎。当你点击 Replit 的运行按钮来运行 main.php 文件时,Replit 调用 PHP 引擎,PHP 引擎随后读取文件内容并解释执行其中的代码行。对于简单的 PHP 脚本(例如我们的 Replit main.php),这些脚本仅由一个或多个按顺序执行的语句组成,整个过程相对简单。然而,几乎所有程序都涉及更复杂的决策逻辑,需要进行测试,以便代码能够动态响应事件并确定执行哪些语句以及按照什么顺序执行。PHP 引擎决定接下来做什么的方式被称为控制流程。我们将在讨论第四章和第六章中的条件语句和循环时进一步探讨这个概念。

点击 Replit 的运行按钮并不是执行 PHP 脚本的唯一方式。你还可以通过命令行调用 PHP 引擎,使用 php 命令并跟上你想要执行的脚本的名称。要尝试这个方法,切换到 Replit 项目右侧栏的 Shell 标签页,以打开交互式命令行终端。然后在 $ 提示符后输入以下内容:

$ **php main.php**
Hello, world!

php main.php 命令指示 PHP 引擎执行 main.php 脚本。像以前一样,这会输出 "Hello, world!" 消息。你可以使用相同的方法在本地计算机的命令行上执行 PHP 文件,在这种情况下,你不一定能够使用 Replit 方便的运行按钮。

创建 Web 服务器项目

PHP 主要用于开发 web 应用程序,因此现在让我们尝试通过使用 Replit 的 PHP Web 服务器模板来创建一个基本的基于 Web 的 PHP 项目。返回你的 Replit 主页,创建一个新项目,这次在模板搜索框中输入 PHP 后,选择 PHP Web Server 模板。你的新项目应该类似于图 1-2。

图 1-2:新的 PHP Web 服务器模板项目屏幕

左栏中显示的唯一文件是一个模板 index.php 文件。像这样的 index 文件具有特殊的意义:它代表着当你访问一个网站的首页时,默认显示的文件。(我们将在第三部分中详细讨论这如何工作。)该文件的内容显示在中间栏。右栏是控制台和终端标签,这里也是当我们运行 Web 服务器以显示渲染后的网页时,Webview 标签会出现的地方。

index.php 文件应包含 列表 1-2 中显示的代码。

<html>
  <head>
    <title>PHP Test</title>
  </head>
  <body>
  ❶ <?php echo '<p>Hello World</p>'; ?>

</html>

列表 1-2:index.php 中的 Web 服务器脚本

该文件的大部分内容不是 PHP 代码,而是创建通用网页所需的超文本标记语言(HTML),这一点可以通过文件开头和结尾的 和 标签看到。正如我们在 第 13 页 的《模板文本与 PHP 代码》中将进一步讨论的,许多 PHP 脚本将动态的 PHP 代码(用于即时解释和执行)与 HTML 这种静态文本混合在一起。在这个例子中,唯一的 PHP 代码是一个 echo 语句,用来显示文本 Hello World ❶。这些文本被包裹在 HTML 的

标签中,这意味着它将在渲染后的网页中作为一个正文段落显示,而整个 echo 语句被 PHP 标签(开头的 标签)包围,以表明它是实际的 PHP 代码,不同于周围的 HTML。

运行 Web 服务器项目

点击 运行 按钮,Replit 将启动一个 Web 服务器,托管 index.php 文件并运行 PHP 引擎来解释文件中的 PHP 代码。这一次,你不应在控制台标签中看到文本,而应在 Webview 标签中看到作为基本网页显示的 Hello World(参见 图 1-3)。

图 1-3:在 Replit Webview 面板中查看 index.php 脚本输出

在运行 Web 服务器时,Replit 会将临时页面发布到 replit.dev 域名。这意味着它提供了公共的网页,你可以在一个独立的浏览器标签中查看和交互,而不仅仅是通过 Replit 网站本身。要尝试此操作,请点击 Webview 面板中绿色的 {...}.replit.dev URL 地址栏。然后复制弹出窗口中显示的 URL,并将其粘贴到浏览器中新标签页中。你应该会看到相同的 Hello World 消息被渲染为一个独立的网页,与 Replit 界面分开。恭喜你,你已经发布了第一个 PHP 网站!

注意

如果你选择使用 Replit 跟随本书进行学习,你将需要做额外的配置来处理后续章节中的一些更复杂的项目。详情请参见 附录 C。

本地 PHP 安装

像 Replit 这样的在线编辑器非常好用,但它们在免费计划下可能会比较慢并受到限制,并且需要稳定快速的互联网连接。许多开发者更喜欢在自己的机器上进行本地开发。要做到这一点,第一步是安装 PHP。如果你还没有安装,请按照附录 A 中的指南,安装适合你操作系统的最新版本的 PHP。

一旦 PHP 安装完成,你将需要一个集成开发环境(IDE)来编写代码。IDE 是一个强大的文本编辑器,包含了许多有用的编程工具,如终端、复杂的查找和替换功能、代码拼写检查,甚至为常见任务自动生成代码。

本节将重点介绍使用 PhpStorm 进行本地 PHP 开发,PhpStorm 是 JetBrains 出品的一款流行 IDE。任何人都可以免费使用 30 天,之后许多人(如学生、教师、编程训练营的学员、用户组成员以及开源项目的参与者)可以获得免费许可证。访问 www.jetbrains.com/phpstorm/ 下载 PhpStorm,并按照安装说明进行操作。

注意

如果你不想使用 PhpStorm,其他免费 IDE 也提供了辅助 PHP 编程的插件,包括 Visual Studio Code、Eclipse 和 Apache NetBeans。

使用 PhpStorm 创建“Hello, world!”

让我们用 PhpStorm 创建一个“Hello, world!”项目,类似于 Replit 的 PHP CLI 模板中的默认脚本。打开 PhpStorm IDE,点击新建项目,从可能的模板列表中选择PHP 空项目。选择项目的位置,并将未命名的默认名称更改为你希望的项目名称。确保在项目名称的路径前加上正斜杠,如/program1。然后点击创建

PhpStorm 会在你选择的项目名称下,在指定位置创建一个新文件夹。所有的项目文件都将包含在这个文件夹中;更复杂的项目可能还会有子文件夹,用于组织数据、程序文件、配置文件等。文件夹创建后,PhpStorm 会加载到项目编辑视图,如图 1-4 所示。

图 1-4:PhpStorm 的三个主要面板

PhpStorm 的左上面板显示项目文件夹及其内容。右上面板是用来编辑代码和数据文件的地方。点击应用程序窗口左侧列中的终端(>_)图标,在应用程序窗口底部打开命令行终端,在这里你可以输入命令并查看程序的文本输出。这个终端会自动打开到项目文件夹的位置。

我们准备向项目中添加一个基础的“Hello, world!”脚本。在应用窗口的左上面板中选择你的项目文件夹,然后从顶部菜单中选择 文件新建PHP 文件。输入 hello 作为文件名(PhpStorm 会自动为你添加 .php 文件扩展名),然后点击 确定。你应该能在项目内容面板中看到这个新建的 hello.php 文件,并且该文件应该会在代码编辑面板中打开,文件中已经包含了指定文件内容为 PHP 代码所需的 PHP 开始标签(<?php)。现在编辑文件,使其与 列表 1-3 中的代码匹配。

<?php
print "Hello, world!\n";

列表 1-3:我们在 hello.php 中的 “Hello, world!” 程序

和我们在 Replit 命令行程序中一样,这段代码仅仅是打印出文本 Hello, world!,后面跟着一个换行符(\n)。注意,这次语句使用的是 print 而不是 echo 来显示文本。二者大致可以互换;关于更多信息,请参见下面的“print 还是 echo?”框。

要运行你的脚本,请打开终端面板(如果还没打开的话),然后在命令行中输入以下内容:

% **php hello.php**
Hello, world!

你应该能在终端的下一行看到 Hello, world! 消息。

另一种在 PhpStorm 中运行脚本的方法是点击绿色的运行按钮(位于绿色“bug”按钮旁边),该按钮位于应用窗口的右上角。这将执行当前正在编辑的文件。如果点击按钮后出现一个下拉菜单,提供 PHP 和 JS(JavaScript)两种运行脚本的方式,选择 PHP 选项。

如果你以这种方式执行脚本,屏幕底部应该会弹出一个运行面板,显示你正在使用的 PHP 引擎以及正在执行的脚本的位置。如果你在一台计算机上有多个 PHP 引擎版本,这些信息非常有用,能够帮助你测试脚本与不同引擎的兼容性。下面应该显示运行程序的输出,后面跟着一个退出代码 0,表示程序成功完成了执行。

在本地运行 PHP Web 服务器

安装 PHP 后,它会自带一个内置的 Web 服务器,用于在你的系统上本地测试 Web 开发项目。你可以通过使用 phpinfo() 函数来查看有关此 Web 服务器的信息(并验证它是否正常工作)。该函数生成一串很长的 HTML 文本,报告有关当前 PHP 安装的详细信息。运行一个调用此函数的脚本是测试任何 PHP 系统用于 Web 开发时的有用第一步,无论是在本地计算机上还是在托管的 Web 服务器上。

使用 PhpStorm(或你选择的其他 IDE),在名为 web_project_1 的文件夹中创建一个新项目。然后为该项目创建一个名为 index.php 的新文件。如前所述,index 这个名称表示这是 Web 服务器托管项目时会返回的默认文件。编辑文件,使其与 列表 1-4 中的内容一致。

<?php
phpinfo();

列表 1-4:我们在 index.php 中的 info Web 应用

在必需的 PHP 开始标签后,你使用语句 print phpinfo(); 来显示调用 phpinfo() 函数后生成的报告。你可以通过在网页浏览器中执行脚本来查看这个报告,它会以格式化良好的网页形式呈现。在 PhpStorm 中,选择 视图在浏览器中打开内置预览,或者在文件编辑面板中,将鼠标放在 PhpStorm 图标上并点击(参见图 1-5)。

图 1-5:使用 PhpStorm 的网页预览

启动内置预览应该会运行 PHP 网页服务器,并在 PhpStorm 中的示例浏览器窗口中显示 index.php 脚本的结果,如图 1-6 所示。

图 1-6:PhpStorm 预览中的 phpinfo() 函数输出

你可能希望滚动浏览此网页,了解更多关于系统 PHP 设置的信息。你将找到 PHP 引擎的版本、php.ini 配置文件的位置、启用的数据库扩展(如果有的话)、PHP 语言的主要贡献者名称等信息。

你也可以在真实的网页浏览器中查看 index.php 脚本的结果,比如 Google Chrome 或 Mozilla Firefox,而不是在 PhpStorm 中查看。(如果你使用的是其他 IDE,这可能是你唯一的选择。)首先,打开 IDE 的终端并输入以下命令:

% **php -S localhost:8000**

这条命令告诉 PHP 启动其内置的网页服务器,并使当前项目在localhost:8000上可用。这里,localhost 指代你的本地计算机系统,而 8000 设置了端口号。每个需要通过互联网发送和接收消息的应用程序都需要一个唯一的端口号;你可以把这些端口想象成同一地点的不同邮箱。用于测试目的的网页服务器通常使用 8000 或 8080 端口,而生产(线上)网页服务器通常使用 80 端口。就个人而言,我在本地开发时总是使用 8000 端口。

在网页服务器运行时,打开网页浏览器并在地址栏中输入 localhost:8000。你应该会看到与之前相同的 PHP 脚本输出。当你完成时,回到终端并按 CTRL-C 终止网页服务器。

请注意,要在浏览器中查看一个不是 index.php 命名的脚本,你需要将脚本的文件名附加到浏览器地址栏 URL 的末尾。例如,要将列表 1-3 中的 hello.php 脚本作为网页查看,你首先需要运行命令 php -S localhost:8000 启动 PHP 网页服务器,然后在浏览器中访问 localhost:8000/hello.php

模板文本与 PHP 代码

PHP 是一种流行的网页开发语言,因为 PHP 脚本可以轻松地输出 HTML(或 CSS 或 JavaScript),以便在网页浏览器中显示。输出的某些部分通常是不变的模板文本,而其他部分则通过执行 PHP 程序语句动态生成。静态的模板文本和动态的代码生成文本的结合构成了几乎所有互动网站的基础。

举个例子,想象一下在一个在线零售网站查看购物车。网页设计师不需要为展示每种可能的购物车物品配置编写单独的脚本。他们只需编写一个脚本,将任何购物车中都会出现的元素的通用模板文本(如隐藏的 HTML 头元素、导航栏、公司标志等)与动态填写每个具体购物车项目的名称、价格、数量等细节所需的 PHP 代码混合在一起。

将模板文本与 PHP 代码混合的能力是我们为什么至今需要在脚本中加入开头的标签。任何在这些标签外的部分都被视为模板文本并将逐字输出;任何在这些标签内的部分都会被解析为 PHP 代码并相应执行。如果脚本完全由 PHP 代码组成,如列表 1-1、1-3 和 1-4 中所示,那么只需要开头的标签。

为了澄清模板文本和 PHP 代码之间的区别,我们来编写一个将两者结合的示例脚本。创建一个新项目(可以在线使用 Replit,或者在本地使用 PhpStorm),在该项目中创建一个名为hello2.php的新文件。编辑文件,使其完全匹配列表 1-5 的内容。

I am template text, not PHP code.
print "Hello, world!\n";
I am more template text.

列表 1-5:hello2.php 脚本,包含没有 PHP 代码块标签的模板文本

这个脚本的第一行和第三行是模板文本,执行脚本时应直接输出。中间一行是 PHP 代码,用于输出“Hello, world!”并换行。或者是吗?尝试在命令行终端输入php hello2.php运行这个脚本,看看结果:

% **php hello2.php**
I am template text, not PHP code.
print "Hello, world!\n";
I am more template text.%

输出会逐字复制文件中的三行文本,正如它们在文件中显示的那样。特别是中间那一行,包括了像print关键字、引号和分号等 PHP 代码元素,它们本不应显示。问题在于我们没有加上任何标识 PHP 代码的开闭标签,所以整个脚本被当作模板文本直接输出。

还注意到,输出的最后一行以新的终端提示符(此时是百分号符号)结束。这是因为,当它们出现在 PHP 脚本标签之外作为模板文本时,空格、制表符和换行符会被精确复制。因此,终端会立即接上新的提示符,输出从中断处继续,而不会额外添加换行符。如果我们在hello2.php脚本的末尾添加了一个空行,新的终端提示符将出现在自己的新行上。

让我们更新脚本,解决这两个问题。清单 1-6 显示了修订后的hello2.php版本,并且变更部分已加粗。

I am template text, not PHP code.
**<?php**
print "Hello, world!\n";
**?>**
I am more template text.

清单 1-6:修复 hello2.php,区分模板文本与 PHP 代码

我们在 print 语句前添加了一个打开的标签。这告诉 PHP 解释器,标签之间的内容应作为 PHP 代码进行解释和执行。我们还在脚本末尾添加了一个空行。

如果你重新运行这个脚本,PHP 引擎现在应该会找到包裹在 print "Hello, world!\n";语句前后的起始和结束 PHP 程序标签,因此,除了输出这些标签外,它还会执行那行代码,打印出“Hello, world!”以及换行符。以下是再次执行脚本后的结果:

% **php hello2.php**
I am template text, not PHP code.
Hello, world!
I am more template text.
%

这次注意到脚本的第一行和最后一行已原样输出为模板文本,而中间一行仅包含“Hello, world!”消息,表明它已成功作为 PHP 代码被解释执行。由于我们在清单 1-6 的脚本末尾添加了一个空行,新的终端提示符现在也出现在自己的新行上。

注释

注释是任何编程语言中有用的功能。它们是一种告诉 PHP 引擎忽略 PHP 代码块内某些文本的方式,因此这些文本既不会被输出,也不会被解释为应执行的代码。

注释在计算机程序中可以发挥多个作用。首先,它们是将人类可读的注释嵌入代码中的一种方式,例如对某个功能的解释,或者为什么以某种方式编写代码的原因,或者提醒自己需要做的事情。其次,将一行或多行代码变为注释是调试或尝试其他方法时暂时禁用该代码的一种好方法,而不需要完全删除代码。最后,注释还可以包含特殊内容,供预处理工具(如文档生成器或代码测试工具)使用。

和大多数语言一样,PHP 提供了几种定义注释的方式。单行注释以两个正斜杠(//)开头,格式如下:

// I am a comment and will be ignored.

斜杠后面的所有内容都会被视为注释,在执行代码时会被忽略。这意味着你可以在程序语句后面加注释,且程序语句本身仍会被执行,如下所示:

print 2 + 2; // Should print 4

在这里,执行 2 + 2 将会输出 4,但 PHP 引擎会忽略行尾的 // 应输出 4 注释。

以 /* 开头并以 */ 结尾的注释可以跨越多行。列表 1-7 展示了这种多行注释的语法。

/*
I
am
a multiline
comment.
*/

列表 1-7:多行注释示例

这种注释风格特别有用,尤其是当你有一段较长的代码块,希望临时禁用或注释掉时。

注意

如果你处理或维护的是多年前编写的遗留代码,你可能还会遇到较老的 shell 风格单行注释,它以#字符开头,而不是两个斜杠。尽管这些 shell 风格的注释在 PHP 程序中仍然有效,但// 语法是现代 PHP 编程中首选的单行注释风格。

变量

计算机程序的一个显著特点是它们是动态的,这意味着它们的行为每次执行时会根据不同的数据和事件发生变化。程序的核心就在于使用变量,即在代码中引用数据的命名值或引用。变量使你能够存储值并使用有意义的名称(标识符)引用它们。

它们被称为变量,因为它们所指的值在每次程序执行时可能会变化。例如,一个变量可能代表当前的日期或时间,而程序可能有逻辑根据该变量的值执行某些特定的操作。也许它会在用户生日时显示问候语,或者每天早上 6 点触发一个警报。另一个变量可能表示日志文件的大小,当该文件的大小超过某个阈值时,程序可能会自动备份该文件的内容并开始一个新的文件。

变量的值不仅会在程序的每次运行中发生变化;它们还可以在程序执行的过程中发生变化。例如,表示在线购物车总金额的变量将从 0 开始,并在添加或移除物品时进行更新。一个存储系统中同时登录用户数的变量也会随着程序的运行而变化。在繁忙时段,如果该值非常高,可能需要向系统添加更多的内存或磁盘空间。

创建变量

你可以通过给变量命名并为其赋值来创建一个 PHP 变量。例如,在这里,我们创建了一个名为 $age 的变量,并为其赋值 21:

$age = 21;

PHP 变量名必须以美元符号 ($) 开头,这是 PHP 代码与几乎所有其他编程语言的一个区别。给变量赋值是通过等号 (=) 完成的,这个等号在这里被称为赋值运算符。变量名放在等号的左边,值放在右边。由于设置变量值是一种语句,整个过程以分号结束。

赋值运算符右侧的代码是一个表达式。表达式 是一种能够产生单一值或可以被计算为单一值的东西。最简单的表达式就是一个字面量值,就像这个例子中的数字 21。字面量 是指以其本身的形式表达的值。例如 21(整数二十一)、3.5(浮点数三点五)、true(布尔值 true)和 "Matt Smith"(文本字符串 Matt Smith)都是字面量。

其他表达式更为复杂。它们可能涉及数学计算,包含其他变量,甚至如你在第五章中看到的那样,可能会调用一个函数。在这些情况下,表达式必须被 计算,也就是说,必须先确定其结果值,然后才将该值赋给变量。清单 1-8 展示了一些赋值语句的示例。

<?php
$username = "matt";            // A string literal
$total = 3 + 5;                // A calculated expression
$numSlices = $numPizzas * 8;   // A calculation with another variable
$timestamp = time();           // A function that returns a value

清单 1-8:将表达式的值赋给变量的示例

我们首先将字符串字面量 "matt" 赋值给 $username 变量。变量可以存储表示多种数据类型的值,我们将在第二章中详细讨论。接着,我们将 $total 变量赋值为计算结果 3 + 5。因此,该变量将存储数字 8。对于 $numSlices 变量的值,我们将另一个变量 $numPizzas 的值乘以 8(在 PHP 中,* 符号表示乘法)。最后,我们将 $timestamp 变量设置为调用 time() 函数得到的值。(你将在第五章中了解更多关于如何从函数中获取值的内容。)

如果你尝试执行清单 1-8 中的代码,它将无法正常工作。PHP 会产生类似以下的警告信息:

PHP Warning: Undefined variable $numPizzas in main.php on line 4

这里的问题是 $numPizzas 变量是 未定义的,意味着它还没有被赋值。在你第一次使用变量之前,始终为其赋值非常重要。 #### 使用变量

一旦你创建了一个变量,就可以在需要引用该变量值的任何地方使用它的名称。例如,清单 1-9 展示了一个程序,演示了如何使用变量来计算并打印给定披萨数量时的总披萨片数。创建一个包含此清单内容的 pizza.php 文件。

<?php
$numPizzas = 1;
$numSlices = $numPizzas * 8;
print $numSlices;
print "\n";

$numPizzas = 3;
$numSlices = $numPizzas * 8;
print $numSlices;
print "\n";

清单 1-9:在 pizza.php 中使用变量

首先,我们将数字值 1 赋给 $numPizzas 变量。然后,我们将 $numPizzas 的值乘以 8,赋给 $numSlices 变量。记住,变量必须以美元符号开始;随着你编写更多 PHP 代码,你会很快习惯这个规则。接着,我们使用打印语句显示 $numSlices 里的值,然后再使用另一个打印语句并加上 \n 换行符来创建一个换行。

如前所述,变量的值可以在程序运行时改变,因此接下来我们将 $numPizzas 变量的值从 1 更新为 3。然后我们再次通过将 $numPizzas 乘以 8 来更新 $numSlices 的值,并打印出新值。以下是命令行执行该程序的结果:

% **php pizza.php**
8
24

注意到 $numSlices 的值在程序执行过程中从 8 变为 24。这些值是基于 $numPizzas 变化的值计算得出的。你可以尝试自己修改 $numPizzas 变量中的数字,看看能得到多少片不同的披萨。

变量命名

在 PHP 中有一些命名变量的规则和约定。首先,如我们之前讨论的那样,所有变量名必须以美元符号开始。如果在引用变量时忘记加上美元符号,PHP 通常会报告一个未定义常量的致命错误,程序将崩溃。(我们将在下一节讨论常量。)

变量名中的第一个字符(即美元符号后的字符)必须是字母(或在某些情况下是下划线)。按照约定,这个字母应该是小写字母。虽然技术上可以使用大写字母,但通常首字母大写是保留给类名而非变量名的。(你将在第 V 部分中开始学习类和面向对象编程。)变量名中的其余符号可以是字母、数字或下划线。

单词变量名通常应该全部小写,例如 $name 或 $total。对于多词变量名,我们有两种常见的命名约定。一种是 蛇形命名法:所有字母小写,单词之间用下划线分隔,例如 $game_lives_remaining 或 $customer_number。另一种是 小驼峰命名法:第一个单词全部小写,后续单词的首字母大写,例如 $gameLivesRemaining 或 $customerNumber。

最重要的原则是,无论选择哪种命名约定,都要保持一致,尽可能遵循 PHP 的风格建议,最重要的是,选择能够清楚传达变量表示内容的名称。像 $customerNumber 这样的名称比像 $custNo 这样缩写的名称更清晰,当然也比像 $x 或 $variable 这样没有意义的变量名更好。

请记住,PHP 变量名是区分大小写的,因此像 $username 和 $userName 这样的标识符会被视为不同的变量。如果你在引用变量时大小写写错了(或其他地方输入错误的变量名),PHP 将不知道你指的是什么。列表 1-10 展示了一个示例。

<?php
$username = "matt";
print $userName;

列表 1-10: 拼写错误的变量名

我们将值 "matt" 分配给 $username 变量,然后尝试打印该变量的值。然而,由于 $userName 的大小写错误,在命令行执行这个脚本时会显示如下警告信息:

PHP Warning:  Undefined variable $userName in main.php on line 3

因为 PHP 变量区分大小写,所以 PHP 引擎将 $userName 视为一个完全不同的变量,它之前并未赋值。在 PHP 看来,这与在示例 1-8 中未定义 $numPizzas 变量就尝试使用它是同一个问题。

请记住,虽然 PHP 中的某些部分(如变量名)区分大小写,但语言中的其他部分是大小写不敏感的,意味着大小写不会影响。例如关键字如 if、for、switch 和 print;数据类型如 int 和 string;值如 true 和 false;以及函数和方法名。因此,通常的做法是对语言关键字和数据类型使用小写,对函数和方法名使用小驼峰式命名法。本章末的练习建议了一种编码风格指南,帮助你了解更多关于这些约定的信息。

常量

有些值永远不会改变,例如 π(总是 3.14)或 pH 值的中性值(总是 7)。在代码中引用这些值时,最好使用 常量 而非变量。与变量不同,一旦常量被定义,它的值就不能被更新。按照惯例,常量的名称采用 大写蛇形命名法,即所有字母大写,单词之间用下划线连接,如 MAX_PROJECTS 或 NEUTRAL_PH。与变量不同,常量不以美元符号($)开头。

一些常量是内建于 PHP 语言中的。表 1-1 列出了几个示例。

表 1-1:内建 PHP 常量示例

常量 描述
M_PI π,圆周率与直径的比值 3.1415926535898
M_E e,欧拉常数 2.718281828459
PHP_INT_MAX 系统支持的最大整数值 对于 64 位系统,通常是 9223372036854775807

你也可以通过使用 define() 函数来创建自己的自定义常量。示例 1-11 中的脚本展示了一个例子。

<?php
define("MAX_PROJECTS", 99);
print "The maximum number of projects is: ";
print MAX_PROJECTS;
print "\n";

示例 1-11:定义和打印常量

我们调用 define() 函数来创建一个名为 MAX_PROJECTS 的常量,其值为 99。这样常量在代码中的任何地方都可以使用。接着,我们将常量的值作为消息的一部分打印出来。(如果多个打印语句连续执行且不包含换行符,它们会输出到同一行;最后的打印 "\n" 会添加换行符,确保下一次输出——在本例中是下一个命令行提示符——会出现在新的一行上。)运行此脚本的输出应该如下所示:

The maximum number of projects is: 99

请注意,当我们使用 define() 函数创建常量时,常量的名称必须用引号括起来。如果没有这些引号,创建常量将失败。例如,如果你写 define(MAX_PROJECTS, 99) 而没有引号,PHP 会将 MAX_PROJECTS 视为对一个先前定义的常量的引用,并报错。然而,一旦定义了常量,在引用时就不需要引号了。

注意

PHP 文档中令人困惑的是,将数值和布尔字面量称为 常量 ,虽然它也使用了 字符串字面量 这个术语。在阅读文档时,区分简单常量(字面值本身)和命名常量(例如通过 define() 函数创建的常量)是很有用的。

运算符和操作数

运算符 是我们在编程语言中用来操作数据的符号,比如加号(+)用于数值加法,等号(=)用于给变量赋值。操作数 是运算符操作的数据(字面量、变量或复杂表达式)。例如,数值加法运算符需要两个操作数:加号左边的一个数字和右边的另一个数字,像 2 + 2 或 $price + $salesTax。

PHP 有不同的运算符用于处理不同类型的数据。在本节中,我们将主要关注用于数值的运算符。在后面的章节中,我们还会考虑其他运算符,例如用于比较值的运算符(第二章)和用于操作逻辑真假值的运算符(第四章)。

算术运算符

PHP 有 算术运算符 用于基本的数学计算,例如加法(+)、减法(-)、乘法(*)和除法(/)。这些都是 二元运算符,意味着它们需要两个操作数。PHP 还提供了 ** 运算符用于将数字提高到给定的幂次,以及取余运算符(%),它将一个数字除以另一个数字并返回余数。表 1-2 总结了这些运算符。

表 1-2:六个二元(两个操作数)算术运算符

运算符 描述 示例表达式 表达式值
加法 返回两个操作数的和 3 + 1 4
减法 返回两个操作数的差值 10 - 2 8
乘法 返回两个操作数的乘积 2 * 3 6
除法 返回两个操作数的商 8 / 2 4
取余 返回第一个操作数除以第二个操作数的余数 8 % 3 2
指数运算 返回第一个操作数的第二个操作数次方 2 ** 3 8

就像数学运算一样,这些运算符有一个优先级顺序,它决定了当表达式包含多个运算时,如何对其进行求值。乘法、除法和取模的算术运算符具有较高的优先级,而加法和减法的运算符优先级较低。因此,在算术表达式 1 + 2 * 3 中,2 * 3 部分会先被计算出 6,然后 1 + 6 再进行计算,所以整个表达式的结果是 7。你可以使用括号强制优先级;例如,表达式(1 + 2) * 3 的结果是 9,而不是 7,因为括号强制加法部分先被计算为 3,再进行乘法计算。

注意

查看 PHP 文档中的完整运算符优先级列表www.php.net/manual/en/language.operators.precedence.php 以获取所有 PHP 运算符的优先级顺序。

组合算术赋值运算符

我们已经讨论过基础的=赋值运算符,它将一个值赋给一个变量。其他赋值运算符,如+=和-=,将赋值与算术操作结合起来。这些组合运算符的存在,是因为通常我们需要获取变量中的值,进行计算后将结果重新存储回同一个变量,替换掉之前的值。

举个例子,假设我们有一个\(total 变量,用于跟踪在线购物车中商品的总成本。每次用户添加或移除购物车中的商品时,我们都想通过该商品的成本来改变\)total 的值。我们可以使用常规的=运算符,通过编写类似$total = \(total + 25 或\)total = \(total - 15 来实现。然而,使用组合算术赋值运算符,我们可以通过更简洁的语法完成相同的任务:\)total += 25 或\(total -= 15。这些语句指示 PHP 引擎通过将 25 加到之前的值上,或将 15 从之前的值中减去,来更新\)total 变量的值。

类似的算术赋值运算符也用于其他算术操作。例如,*=运算符将一个变量乘以一个给定值。其他运算符,如/=、%=和**=,分别用于除法、取模和指数运算,但这些运算符的使用频率较低。

增量和减量运算符

数字也可以通过特殊运算符进行递增(增加 1)或递减(减少 1):使用双加号(++)进行递增,使用双减号(--)进行递减。与结合的算术赋值运算符类似,++和--提供了更加简洁的语法,用于完成常见的编程任务。例如,要向\(age 变量的现有值添加 1,我们可以使用基本的赋值运算符并写成\)age = \(age + 1,或者使用算术赋值运算符并写成\)age += 1。然而,使用递增运算符时,我们只需写\(age++。同样,\)age--会从$age 的值中减去 1。这些是一元运算符的例子,意味着它们只需要一个操作数。

当你想在表达式中使用递增或递减操作的结果时,运算符在值之前或之后的位置很重要。假设\(age 包含整数值 21。表达式\)age++将返回\(age 的当前值(21),然后递增该变量。另一方面,++\)age 将首先应用递增操作,然后返回变量的新值(22)。示例 1-12 说明了这一区别。

<?php
$person1Age = 21;
print "Person 1 age = ";
❶ print ++$person1Age;
print "\nPerson 1 age (after increment) = ";
print $person1Age;

$person2Age = 21;
print "\nPerson 2 age = ";
❷ print $person2Age++;
print "\nPerson 2 age (after increment) = ";
print $person2Age;

示例 1-12:演示前后递增运算符的区别

在这里,我们将\(person1Age 和\)person2Age 都设置为 21,然后使用++递增每个人的年龄。然而,由于在一种情况下使用了++\(person1Age ❶,而在另一种情况下使用了\)person2Age++ ❷,所以打印出的结果不同,正如脚本输出所示:

Person 1 age = 22
Person 1 age (after increment) = 22
❶ Person 2 age = 21
Person 2 age (after increment) = 22

每次打印\(person1Age 时,我们都会在输出中看到 22。这是因为将++运算符放在变量前面,确保它的值在第一次打印之前从 21 递增到 22。相反,将++运算符放在\)person2Age 后面,则会先看到它原始的值 21 ❶,因为它强制递增操作发生在变量值在表达式中使用之后。

为了避免关于是否使用递增前后值的混淆,许多程序员选择使用两行代码:一行用于递增变量的值,另一行用于使用该新值,如示例 1-13 所示。

$person1Age = 21;
$person1Age++;
print $person1Age;

示例 1-13:将递增操作与打印语句分开

在这里,我们使用一个语句只对$person1Age 进行递增,另一个语句只打印该变量的值。这明确确保了最终输出将是 22。

摘要

本章中我们介绍了一些重要的 PHP 编程基础知识。我们探讨了两种创建和运行 PHP 程序的方式:使用 Replit 在线环境和在本地计算机上使用 PhpStorm IDE。我们涵盖了语句、表达式、变量、常量和运算符,这些都是计算机程序的关键构建块,并讨论了如何使用注释来帮助使代码对人类更易读,并在开发和调试程序时暂时禁用代码块。我们还首次了解了如何将 PHP 程序语句与不变的模板文本交织在一起,这是创建能够动态定制返回给用户网页的 web 应用程序的基本技术。

练习

1.   访问 PHP 网站,www.php.net,并了解该语言文档页面的布局。在你编写 PHP 程序时,这些文档将是一个很好的参考。

2.   访问另一个实用的在线 PHP 资源,PHP the Right Way (phptherightway.com)。这个网站汇集了 PHP 编程社区中的最佳实践。如今,社区中的几乎每个人都遵循相同的代码风格规范,这使得不同程序员的 PHP 代码易于阅读、理解和贡献。你可以在 phptherightway.com/#code_style_guide 上阅读这些规范,同时你还可以找到来自 PHP Framework Interop Group(PHP-FIG)的特定风格推荐链接,PHP-FIG 是一个由国际 PHP 专业人士组成的非正式小组,致力于推动语言的编码标准。

3.   使用注释禁用以下代码中的部分行,使得只有 Cat、Dog 和 Helicopter 被打印出来:

<?php
print "Cat\n";
print "Elephant\n";
print "Dog\n";
print "Helicopter\n";
print "Bus\n";
print "Spacecraft\n";

4.   编写一个脚本,创建一个包含你名字的 $name 变量,并使用该变量打印出“我的名字是你的名字”,然后加上换行符。

第二章:2 种数据类型

在本章中,我们将探讨 PHP 中可用的数据类型。我们还将考虑如何强制将一个值转换为指定的数据类型(类型转换),以及 PHP 在何种情况下会自动尝试转换数据类型,以便让表达式的各个部分能够协同工作(类型自动转换)。

数据类型是程序中值的分类,用于指定 PHP 引擎如何解释该值,从而确定可以对其应用哪些操作。例如,如果一个值是整数,PHP 引擎知道可以进行加法和乘法等操作,而且这些操作的结果仍然是整数;同时,PHP 引擎知道,整数除法的结果可能是另一个整数或浮动小数(十进制)数值。

了解哪些数据类型可用——以及何时和如何改变一个值的数据类型——在处理输入、执行计算和输出数据时至关重要。如果你不了解正在操作的数据类型,或者不知道这些数据在执行不同操作时如何反应,你可能会得到意外的结果。

PHP 数据类型

在第一章中,我们将单词“matt”存储在一个变量中,并将数字 99 赋值给一个常量。这些值属于不同的数据类型:一个是字符串,另一个是整数。总的来说,PHP 有 10 种内置数据类型,分为三类,如图 2-1 所示。

图 2-1:PHP 数据类型

目前,我们主要关注四种标量数据类型,它们一次只能保存一个值。我们还会关注一下特殊的 NULL 数据类型。在后面的章节中,你将学习两种复合数据类型——数组(在第八章和第九章中)和对象(在第 V 部分中),它们可以存储和操作多个值的集合。资源特殊类型以及可调用和可迭代的复合类型仅用于复杂和特殊的场景,在本书中不会讨论。

标量数据类型

四种标量(单值)数据类型分别是字符串、整数、浮点数和布尔值。字符串类型用于文本,整数类型用于整数,浮点数类型用于浮动小数(十进制)数字,而布尔值类型则用于布尔真/假值。

让我们使用 PHP 的交互模式来探索标量数据类型。该模式允许你在命令行中输入单个 PHP 语句并立即查看结果。我们将在接下来的章节中使用交互模式来快速演示基本概念并获取即时反馈,而不需要编写完整的 PHP 脚本。只需在命令行中输入 php -a 启动交互模式,然后输入以下内容:

php > **$username = "matt";**
php > **print gettype($username);**
string

在这里,我们再次将“matt”赋值给\(username 变量。然后我们使用 PHP 内置的 gettype()函数打印出变量的类型。输出确认\)username 包含的是字符串。

如果你之前看过或者写过像 Java 或 C#这样的强类型语言的代码,你可能已经注意到,在给变量赋值时不需要指定数据类型。PHP 是一种宽松类型的语言,这意味着相同的变量可以在不同的时间存储不同数据类型的值,而 PHP 引擎会自动推断表达式的数据类型。

注意

我们也可以在 PHP 中显式声明数据类型,稍后在第五章中,我们将开始编写函数时这样做。不过现在,当我们处理简单的变量时,我们将让解释器推断数据类型。

\(唯一用户名(\)username)变量的例子中,值“matt”被推断为字符串。我们可以类似地为变量赋予数字值,无论是否有小数,PHP 会根据情况将其解释为整数或浮动数:

php > **$age = 21;**
php > **print gettype($age);**
integer
php > **$price = 9.99;**
php > **print gettype($price);**
double

在这里,我们看到变量\(age 的值是整数,因此它被解释为一个整数,而\)price 的值包含小数,所以它被解释为…一个双精度浮点数?尽管文档中将浮动值称为 float 数据类型,但出于历史原因(PHP 是一种古老的语言!),当在浮动值上使用 gettype()函数时,它返回 double——这是指用于存储浮动值的双精度格式。然而,PHP 只有一种浮动点数据类型,因此尽管其他编程语言可能对浮动数、双精度数、实数等有不同的精度和内存表示,但在 PHP 中,所有浮动点值都是 float 数据类型(无论 gettype()函数怎么说)。

接下来,我们尝试创建一个布尔类型的变量。请输入以下代码:

php > **$isDutyFree = true;**
php > **print gettype($isDutyFree);**
boolean
php > **print $isDutyFree;**
1

当我们在$isDutyFree 变量上使用 gettype()时,我们看到输出为布尔值。这是 PHP 中 bool 类型的别名;两者通常可以互换使用,但为了避免一些别名不起作用的情况,请始终在代码中使用 bool(在本书中我也会这样做)。

更有趣的是,注意当我们尝试打印$isDutyFree 的值时,我们在输出中看到的数字是 1,而不是 true。这并不是错误。这与布尔值如何被转换或转换成字符串有关。print 命令期望一个字符串,因此无论我们在 print 关键字后提供什么,PHP 引擎都会将其自动转换为字符串表达式。对于布尔类型,true 会被转换为字符串“1”,而 false 会被转换为空字符串(即一个没有内容的字符串,表示为一对空的引号:“”)。我们将在本章稍后讨论通过手动强制转换和自动类型转换进行的类型转换。

要查看$ isDutyFree 的实际布尔值,请使用内置的 var_dump()函数,而不是 print。这个有用的函数会输出有关变量的信息。当学习 PHP 和调试时,知道变量在代码执行的某一时刻的值非常有帮助:

php > **$isDutyFree = true;**
php > **var_dump($isDutyFree);**
bool(true)

var_dump()的输出确认$ isDutyFree 的数据类型是 bool,并且其值为 true。

特殊的 NULL 类型

PHP 有一种特殊的数据类型,用常量 NULL 或 null(不区分大小写)表示。在三种情况下,一个变量是 NULL。第一种情况是,变量从未被赋值,如此处所示:

php > **var_dump($lastName);**
Warning: Undefined variable $lastName in php shell code on line 1
NULL

当我们尝试对$ lastName 使用 var_dump()而没有为该变量赋值时,首先会收到一个警告,表示$ lastName 未定义。然后我们会看到该变量因为没有被赋值而评估为 NULL。

其次,如果一个变量显式地将常量 NULL 赋值给它,那么它的值就是 NULL:

php > **$firstName = NULL;**
php > **var_dump($firstName);**
NULL

在这里,我们看到一个重要的区别,即一个变量从未被赋值(如前面示例所示),与一个变量包含 NULL 值之间的差异。在前一种情况,var_dump()会产生警告,而在这种情况下我们不会收到警告;我们只会看到变量的值(NULL)被打印出来。将 NULL 赋给一个变量是可以的,就像给变量赋其他值一样。

最后,如果一个变量被unset,即使用内置的 unset()函数清除了其值,那么它将变为 NULL:

php > **$lastName = "Smith"**
php > **var_dump($lastName);**
string(5) "Smith"
php > **unset($lastName);**
php > **var_dump($lastName);**
Warning: Undefined variable $lastName in php shell code on line 1
NULL

在这里,我们为$ lastName 赋一个值,然后使用 unset()将其值删除。当我们尝试在 unset 之后对$ lastName 使用 var_dump()时,我们会收到与之前相同的警告,并看到它的值为 NULL。删除变量就像从未给它赋过值一样。

在更复杂的程序中处理变量和数据项时,有时需要设计逻辑来处理遇到 NULL 的情况。例如,如果你在创建数据库连接时遇到问题,连接变量将被设置为 NULL。在另一个例子中,如果你期望传递一个对象的引用(例如,已登录的用户),但没有这样的对象存在,那么该变量将为 NULL。我们将在第五部分和第六部分中讨论面向对象编程和数据库时,探讨这些情况。

测试数据类型的函数

PHP 有许多函数根据提供的变量或表达式是否属于某种数据类型来返回 true 或 false。这些函数包括 is_string()、is_int()、is_float()、is_bool()和 is_null()。这些函数非常有用,如果你需要在操作变量之前确认它是某种特定类型,或者反之,你需要检查变量是否不是 NULL。以下是这些函数的示例:

php > **$gpa = 3.5;**
php > **var_dump(is_string($gpa));**
bool(false)
php > **var_dump(is_int($gpa));**
bool(false)
php > **var_dump(is_float($gpa));**
bool(true)
php > **$middleName = NULL;**
php > **var_dump(is_bool($middleName));**
bool(false)
php > **var_dump(is_null($middleName));**
bool(true)

我们的变量$ gpa 包含一个小数值,因此只有 is_float()为 true。同样,$ middleName 包含 NULL,因此将其传递给 is_null()会返回 true。

PHP 的一些类型检查函数适用于更广泛的数据类型。例如,is_numeric()函数对 int 或 float 类型的变量返回 true:

php > **$gpa = 3.5;**
php > **$age = 21;**
php > **var_dump(is_numeric($gpa));**
bool(true)
php > **var_dump(is_numeric($age))**;
bool(true)

在这里,我们看到十进制值 3.5 和整数值 21 都通过了 is_numeric()测试。对于仅包含数字字符的字符串,is_numeric()同样返回 true,但如果混入了非数字字符,则返回 false:

php > **$price = "9.99";**
php > **var_dump(is_numeric($price));**
bool(true)
php > **$price = "9.99 dollars";**
php > **var_dump(is_numeric($price));**
bool(false)

当$price 包含字符串"9.99"时,is_numeric()返回 true。然而,当我们在字符串末尾添加美元这个词时,is_numeric()变为 false。

类型转换

在某些情况下,PHP 引擎会自动将一个值从一种数据类型转换为另一种数据类型。这被称为类型转换。请看这个示例:

php > **$answer = "1" + 3;**
php > **var_dump($answer);**
int(4)

在这里,我们尝试将字符串"1"和整数 3 相加。当 PHP 引擎计算这个表达式并将结果存储到$answer 时,它会看到加号运算符(+),并假设进行的是数值加法。因此,PHP 引擎会检查这两个操作数(加号两边的值),并尝试将它们解释为数字(浮动数或整数)。3 已经是一个整数,但字符串"1"会被转换(转换)成一个整数,以允许加法操作进行。最终,我们得到整数 4 作为答案。

在六种情况下,PHP 会自动将表达式转换为不同的类型:数值上下文、字符串上下文、比较上下文、逻辑上下文、函数上下文和位运算(整数和字符串)上下文。接下来我们将讨论其中的一些上下文。

数值上下文

当一个表达式包含算术运算符时,PHP 会尝试将操作数转换为整数或浮动数。这通常发生在一个或多个操作数是字符串的情况下,正如我们刚才看到的"1" + 3 示例。我们将在第五章中看到一个示例,说明函数参数如何被强制转换为整数。布尔值也可以转换为整数;true 变为整数 1,false 变为整数 0。

在数值上下文中,有两个重要的问题:结果会变成整数(int)还是浮动数(float),当字符串同时包含数字和非数字字符时会发生什么?

整数与浮动数

如果算术表达式中的任一操作数是浮动数(或不能解释为整数),则两个值都会被转换为浮动数,并执行浮动数运算。否则,两个值都会被转换为整数,并执行整数运算。例如,当两个操作数都是整数时,结果是整数:

php > **$answer = (1 + 1);**
php > **var_dump($answer);**
int(2)

当一个操作数是整数,另一个操作数是能转换为整数的数值字符串时,两个操作数都会变成整数,结果也是整数:

php > **$answer = (1 + "1");**
php > **var_dump($answer);**
int(2)

在将字符串转换为数字时,会忽略前导和尾随的空白字符,因此如果我们在字符串的两端添加空格,仍然会得到相同的结果:

php > **$answer = (1 + "  1 ");**
php > **var_dump($answer);**
int(2)

请注意,字符串中“1”字符前后多余的空格没有影响,字符串仍然会转换为整数 1。

当两个操作数都是数字且其中一个操作数是浮动数时,两个操作数都会转换为浮动数,结果也是浮动数:

php > **$answer = (1.5 + 1);**
php > **var_dump($answer);**
float(2.5)

为了得到 2.5 的结果,整数 1 会在幕后被转换为浮动数,然后才进行加法运算。当一个操作数是整数,另一个是可评估为浮动数的数字字符串时,也会发生相同的过程。两个操作数都会转换为浮动数,结果是浮动数:

php > **$answer = (1 + "9.9");**
php > **var_dump($answer);**
float(10.9)

简而言之,如果需要浮动数运算,PHP 会使用浮动数运算。否则,使用整数运算。

数字、前导数字和非数字字符串

PHP 区分数字字符串(其内容完全评估为整数或浮动数)和前导数字字符串(以数字字符开头,但也包括非数字字符,如字母或特殊符号)。当前导数字字符串被转换为整数或浮动数时,从第一个非数字字符开始的部分会被丢弃。如果一个字符串以非数字字符开头,那么整个字符串被视为非数字,即使它也包含数字,也无法转换为数字。

注意

关于字符串以非数字字符开头的规则的例外是以空格开头并后跟数字字符的特殊情况。这样的字符串会被视为数字字符串,因为前导(或尾随)空格会被忽略。

让我们尝试使用一个前导数字字符串进行数字加法,该字符串评估为整数:

php > **$answer = (1 + "1 dollar");**
Warning:  A non-numeric value encountered in php shell code on line 1
php > **var_dump($answer);**
int(2)

这里“1 dollar”是一个前导数字字符串。当用于算术表达式时,开头的“1”会转换为整数,而结尾的“ dollar”会被忽略。请注意,PHP 会对此发出警告,但仍然进行类型转换。由于两个操作数都可以转换为整数,结果是整数。

带有前导数字字符串的加法(该字符串可评估为浮动数)也以相同的方式工作:

php > **$answer = (1 + "9.99 dollars");**
Warning:  A non-numeric value encountered in php shell code on line 1
php > **var_dump($answer);**
int(10.99)

在这种情况下,“9.99 dollars”是一个前导数字字符串,其开头评估为浮动数 9.99。由于两个操作数都可以转换为浮动数,因此结果是浮动数。同样,由于前导数字字符串,PHP 会发出警告。

相反,如果你尝试在算术表达式中使用非数字字符串,将会得到一个类型错误,意味着无法使用给定的数据类型执行操作。这会中断代码的执行。下面是一个示例:

php > **$answer = (1 + "April 1");**
Warning: Uncaught TypeError: Unsupported operand types: int + string in
php shell code:1
Stack trace:
#0 {main}
  thrown in php shell code on line 1

这里“April 1”是一个非数字字符串,因为它以字母开头,而不是数字。该字符串无法评估为数字,因此会触发错误。如果我们使用空字符串("")也会发生相同的错误:

php > **$answer = (1 + "");**
Warning: Uncaught TypeError: Unsupported operand types: int + string in
php shell code:1
Stack trace:
#0 {main}
  thrown in php shell code on line 1

空字符串像其他一些语言那样评估为 0。它是一个非数字字符串,因此在算术表达式中会导致类型错误。

字符串上下文

在某些情况下,PHP 会自动将值转换为字符串。首先,涉及打印或回显语句的表达式会被转换为字符串,因为这些命令期望后续的内容是字符串。例如,您已经看到,当布尔类型的值作为打印语句的一部分时,它们会被转换为字符串 "1"(表示 true)或 ""(表示 false)。类似地,数字也会转换为其字符串等价物。

其次,涉及字符串连接运算符(.)的表达式也会被转换为字符串,就像在字符串内部解析的表达式一样。我们将在第三章中讨论这些主题。

比较上下文

类型转换还会发生在比较两种不同数据类型的值时。比较表达式会评估为布尔值 true 或 false,通常您将其用于决策逻辑(如我们将在第四章中讨论的)。当 PHP 引擎看到一个比较运算符,例如 ==(表示等于)或 >(表示大于)时,它会知道这是一个比较表达式。PHP 有规则来确定这些表达式如何根据涉及的数据类型进行转换和评估。

相同值与相等值

PHP 在相同值和相等值之间做出了重要区分。只有当两个表达式在类型转换之前是相同的数据类型并且包含相同的值时,它们才被视为相同。相比之下,两个表达式如果在类型转换之后包含相同的值,则被视为相等

我们使用不同的运算符来测试身份和相等性。三重等号 (=) 是 相同运算符,而双等号 () 是 等于运算符。考虑以下比较字符串 "1" 和整数 1 的示例:

php > **var_dump("1" === 1);**
bool(false)
php > **var_dump("1" == 1);**
bool(true)

首先,我们尝试使用相同运算符进行比较。由于操作数的数据类型不同,因此评估结果为 false。接下来,我们尝试使用等于运算符进行比较。这一次,结果为 true,因为 PHP 会在进行比较之前将字符串 "1" 转换为整数。

PHP 还有用于“不相同” (!==) 和“不等于” (!=) 的运算符。考虑这些整数和浮点数之间的比较:

php > **var_dump(1 !== 1.0);**
bool(true)
php > **var_dump(1 != 1.0);**
bool(false)

使用不相同运算符时,比较结果为 true,因为值的类型不同。使用不等于运算符时,数字首先被转换为相同类型。这样它们就具有相同的值,因此不等于比较结果为 false。

注意

PHP <> 运算符等价于 != 运算符;两者都表示“不同”。就个人而言,我总是使用 != 运算符,因为感叹号(!)在其他上下文中也表示“不是”。

字符串与数字

自 PHP 8.0 起,当将字符串与数字进行比较时,如果该字符串是数字字符串,则进行数字比较。否则,进行字符串比较。我们在测试整数 1 和数字字符串"1"的相等性时看到过数字比较。如果我们尝试使用一个以数字开头的字符串进行相同的比较,结果将为假:

php > **var_dump(1 == "1 dollar");**
bool(false)

尽管"1 dollar"以数字开始,但它不是一个完全的数字字符串。因此,PHP 将整数 1 转换为字符串"1"并进行字符串比较。这两个字符串不相等,因此结果为假。

仅使用数字比较完全数字字符串有两个重要的影响,即任何前导或尾部的空格会被忽略,并且空字符串被认为是数字字符串,因此它不等于数字 0,如下所示:

php > **var_dump(0 == "");**
bool(false)

在这个比较中,由于空字符串不是数字,整数 0 被转换成字符串"0"。然后,字符串"0"和""被比较并发现它们不相等。

小于和大于

在处理数字时,使用小于(<)、大于(>)、小于或等于(<=)或大于或等于(>=)运算符是直接的,因为是否一个数字小于、等于或大于另一个数字非常清晰。以下是一些例子:

php > **var_dump(1 < 2);**
bool(true)
php > **var_dump(1 <= 1.01)**
bool(true)
php > **var_dump(2 >= 2);**
bool(true)
php > **var_dump(2 > 2);**
bool(false)

你也可以将这些运算符用于非数字数据类型,在这种情况下,PHP 有多种规则来评估比较。例如,布尔值 true 被认为大于 false,也大于 NULL:

php > **var_dump(true > false);**
bool(true)
php > v**ar_dump(true > NULL);**
bool(true)

字符串之间是逐个字符进行比较的,字母表中后面的字母被认为大于前面的字母:

php > **var_dump("abc" < "acb");**
bool(true)

小写字母被认为大于大写字母,但是:

php > **var_dump("a" > "B");**
bool(true)

字符串通常被认为大于任何数字,如以下情况所示:

php > **var_dump("abc" > 123);**
bool(true)
php > **var_dump("one" > 1000000);**
bool(true)

然而,存在一些对这些通用规则的例外。如我们之前讨论过的,当完全数字的字符串与数字进行比较时,字符串会先转换为数字,然后进行数字比较。例如,字符串"15"会变为整数 15 用于比较:

php > **var_dump("15" < 19);**
bool(true)

另一个例外是以特殊字符开头的字符串,如:! # $ % & ' () * + , - . /。这种字符串总是被认为小于数字:

php > **var_dump("*77" < 5);**
bool(true)

如果一个数字与一个以数字开头但包含其他字符的字符串进行比较,数字会被转换为字符串,字符串逐字符比较:

php > **var_dump("1a" > 10);**
bool(true)
php > **var_dump("1a" > 20);**
bool(false)

在第一个例子中,整数 10 在与字符串"1a"比较之前被转换为字符串"10"。第一个字符相同,但字符 a(字母)被认为大于字符 0(数字)。在第二个例子中,字符串"20"中的数字 2(经过转换)被认为大于字符串"1a"中的数字 1,因此该表达式为假。

了解这些字符串-数字和布尔比较规则是有用的,但依赖它们可能是危险的。最好使用验证逻辑先将字符串转换为数字,然后进行简单的数字比较。例如,我们将在第十二章中测试从网页表单接收的价格是否为数字。

飞船操作符

PHP 语言中相对较新的一个特性是飞船操作符(<=>)。与 true 或 false 不同,使用该操作符时,根据比较的两个表达式,会返回 0、1 或-1 的整数值。如果两个表达式相同(经过任何类型转换后),该操作符返回 0;如果第一个表达式大于第二个,返回 1;如果第二个表达式大于第一个,返回-1。例如:

php > **var_dump(11 <=> 22);**
int(-1)
php > **var_dump(55 <=> 22);**
int(1)
php > **var_dump("22" <=> 22);**
int(0)

在第一个案例中,我们看到输出是-1,因为 11 小于 22。在第二个案例中,我们看到输出是 1,因为 55 大于 22。在最后一个案例中,我们看到输出是 0,因为"22"和 22 经过类型转换后是相同的。

飞船操作符可能看起来像是小于、大于和等于操作符的奇怪结合。然而,它在将数据集合排序为所需的顺序时特别有用,因为某些排序函数需要这种 0、1 或-1 的编码方案。

逻辑和其他上下文

当期望逻辑值为真或假时,PHP 将把其他类型的值转换为布尔类型。这三种逻辑类型转换上下文如下:

  • 逻辑操作符,如 AND (&&) 和 OR (||)

  • 三元操作符(?)

  • 条件语句,如 if 和 switch

我们将在第四章中讲解所有这些逻辑上下文。

类型转换也可能发生在函数上下文和位运算上下文中。我们将在第五章探讨函数上下文(即在函数签名中评估参数)。位运算上下文在网页应用中很少使用,超出了本书的范围。

类型转换

类型转换是指显式地将一个表达式或变量转换为所需的数据类型。手动类型转换与 PHP 引擎自动执行的类型转换相对立。要将表达式的值转换为特定类型,只需在表达式前加上括号并指定新数据类型。例如,(float)21 确保值 21 被处理为浮动数,而不是整数。以下是转换各种标量数据类型的示例:

php > **$age = (int)20.5;**
php > **var_dump($age);**
❶ int(20)
php > **$price = (string)9.99;**
php > **var_dump($price);**
string(4) "9.99";
php > **$inventory = (bool)0;**
php > **var_dump($inventory);**
❷ bool(false)

请注意,从浮动数转换为整数时,会截断小数点后的所有内容,实际上是向下舍入到最接近的整数 ❶。当从数字转换为布尔值时,0 变为 false ❷,而任何其他数字值(包括负数!)都变为 true。

类型转换是 PHP 中较少使用的特性之一。它的一个使用示例是轻松获取浮动数的整数部分,例如在比较两个时间戳时,获取秒数的整数部分。

总结

本章向你介绍了 PHP 的四种标量数据类型,以及特殊的 NULL 类型。你了解了包含 NULL 的变量与因未定义或未设置而评估为 NULL 的变量之间的区别,并且通过使用像 is_int()和 is_null()这样的函数,练习了测试变量是否为特定类型。稍后,这将帮助你编写代码,仔细测试值以应对从用户或外部数据源(如数据库)接收输入时可能出现的情况。

本章还向你展示了表达式的数据类型如何变化,可以通过类型转换自动或手动地进行。你学习了类型转换可能发生的上下文,以及评估不同数据类型的表达式的规则。理解何时以及如何进行类型转换将帮助你避免在处理混合类型数据时出现意外的结果。

练习

1.   编写一个脚本,使用整数转换将浮动数字向下取整。执行以下操作:

a.   创建一个包含 55.9 的$scoreFloat 变量。

b.   创建第二个变量\(scoreInt,将\)scoreFloat 转换为整数并赋值给它。

c.   打印出$scoreFloat 的类型,然后是它的值,再输出一个换行符。

d.   打印出$scoreInt 的类型,然后是它的值,再输出一个换行符。

你的程序输出应如下所示:

double scoreFloat = 55.9
integer scoreInt = 55

将$age 变量赋值为整数 21,并使用var_dump输出其值。然后将该变量赋值为 NULL,再次使用var_dump输出。最后,使用unset删除该变量,再次使用var_dump输出。请注意,当变量赋值为 NULL 与被 unset 时,输出结果是不同的。

第三章:3 字符串与字符串函数

在本章中,我们将深入探讨字符串,包括如何创建它们、如何组合它们,以及如何使用 PHP 的许多内置字符串函数来搜索、转换和处理它们。几乎每个 PHP 程序和 Web 应用程序都涉及文本,因此了解如何创建和操作字符串非常重要。

本章介绍了 PHP 的四种字符串写法风格:使用单引号或双引号,或者在较长的多行字符串中使用 heredoc 或 nowdoc。这些风格有不同的特点,例如能否嵌入变量或表示特殊符号。尽管这些风格有所不同,它们最终都会得到相同的结果:一个字符串数据类型的值,也就是一串字符。

空白字符

在我们讨论可见字符的字符串(如字母和数字)之前,先谈谈空白字符,即那些你看不见的字符。这些字符在打印时不会使用任何墨水(例如空格字符、制表符字符、换行符字符等等)。有时区分水平空白字符(如空格和制表符)与垂直空白字符(如换行符和垂直制表符)是很有用的。

当你编写代码时,你使用的空白字符的细节通常对代码的执行并不重要。只要表达式之间至少有一个空白字符(例如空格或换行符),是否有额外的空白字符(如额外的空格或制表符)并不重要。例如,PHP 引擎会将以下四个语句解释为完全相同,忽略变量名和等号两边的多个空格、制表符和换行符,以及字符串两边的空白字符:

$lastName = 'Smith';
      $lastName = 'Smith';
   $lastName      =      'Smith'    ;
$lastName
=
    'Smith'
;

然而,当你声明或处理字符串表达式中的内容时,你必须精确地使用空白字符。否则,单词可能会粘在一起,没有空格,或者某些文本可能会出现在不同的行上。例如,这四个语句中的字符串都是不同的,因为多余的空白字符引号内:

$lastName = 'Smith';
$lastName = '   Smith';
$lastName = 'Smith   ';
$lastName = '     Smith   ';

空白字符不仅仅来源于你写的代码。当你从用户或外部软件系统(如应用程序编程接口(API))获取输入字符串时,也可能会引入空白字符。在这种情况下,你通常需要验证该输入并修剪其开头和结尾的任何不必要的空白字符。同样,你可能希望将字符串中的任何制表符或换行符替换为单个空格(有时用户在输入时不小心按下功能键,导致输入中出现了意外的、不可见的空白字符)。你将在本章中学习如何做这些事情(例如,参见“移除所有不必要的空白字符”在第 60 页)。

PHP 中最简单的字符串类型是用单引号括起来的,例如 'matt smith'。几乎所有出现在单引号内的内容都会被字面处理,意味着如果打印该字符串,内容将按原样逐字符输出。

PHP 只有两个特殊的单引号字符串情况。首先,由于单引号用于限定字符串,因此必须有一种机制将单引号包含在字符串内部。否则,单引号会被解释为字符串的结束。解决方案是在单引号前加上反斜杠('),就像 'matt smith's string' 中那样。当这个字符串打印时,单引号会显示出来,但反斜杠不会显示,正如你在 PHP 的交互模式中所看到的那样:

php > **print 'matt smith\'s string';**
matt smith's string

这种在字符串中使用特殊字符序列,并让 PHP 解释为某个特定字符的技术被称为 转义。由于反斜杠需要用来转义单引号,因此也必须有一种方法在单引号字符串中指定反斜杠字符。为此,可以写两个反斜杠:\。

' 和 \ 这两个转义序列是 PHP 引擎在单引号字符串中唯一会解释为其他意义的字符。你可能知道的其他转义序列,例如 \n 用于换行,在单引号字符串中不会被识别。

然而,这并不意味着你不能在单引号字符串中包含换行符。为此,只需在字符串中你希望出现换行的地方添加换行符,PHP 会按你写的方式精确地再现这些换行符。实际上,PHP 要求用分号来声明语句结束的原因之一,是为了允许将单个语句分布在多行中。列表 3-1 展示了一个跨多行的字符串。

<?php
print 'the
cat
sat on
the mat!
';

列表 3-1:包含换行符的字符串脚本

这个脚本包括一个打印语句,输出包含多个换行符的字符串。以下是在命令行运行此脚本的输出:

the
cat
sat on
the mat!

输出包括了字符串中所有的换行符。 ### 字符串连接:连接

经常你需要将多个字符串、变量或常量组合成一个单一的字符串表达式。你可以通过 PHP 的字符串连接运算符实现,表示为句点(.)。考虑以下在 PHP 交互模式中的例子:

php > **$name = 'Matt Smith';**
php > **print 'my name is ' . $name;**
my name is Matt Smith

我们声明了变量 $name,其值是字符串 'Matt Smith'。然后,我们使用字符串连接运算符将这个变量的值与另一个字符串组合,并打印出结果的更长字符串。注意,在字符串 'my name is ' 和闭合的单引号之间有额外的空格。连接运算符不会在连接的字符串之间添加任何空格,所以这个额外的空格是为了防止像 isMatt 这样的字符串被打印出来。

当在一行中连接字符串时,良好的编程实践是在句点的两边添加空格,以提高可读性,如前面的示例所示。你也可以将这样的语句分布在几行上,这有助于使长字符串表达式更具可读性,正如清单 3-2 中的脚本所示。这展示了我个人的偏好,即缩进语句的每一行,并让每行以连接操作符开始,这样就能清楚地表明每一行都在向字符串表达式追加内容。

<?php
$name = 'Matt Smith';
print 'my name is '
    . $name
    . ', I\'m pleased to meet you!'
    . PHP_EOL;

清单 3-2:一个用于连接并打印多个字符串的脚本

该脚本打印出一个由四个较短字符串连接而成的字符串,其中包括前一个示例中的两个字符串。请注意,我们已经转义了单引号,以在第三个字符串中的单词 I'm 中创建撇号。

表达式末尾的第四个字符串是特殊的字符串常量 PHP_EOL,代表行结束符。这是一个包含系统适用字符(或字符组)的字符串,用来将光标移动到命令行终端的下一行的开始位置(就像按下 ENTER 键一样)。由于不同操作系统在指定行结束符时采用的方式略有不同,过去曾经需要这种特殊常量。现在大多数操作系统的应用程序通常能够处理彼此的文件,因此这已经不再是问题,但该常量依然方便用来确保单引号字符串后面的下一个终端提示符出现在新的一行。以下是运行此脚本时在命令行中的输出:

% **php multi_line.php**
my name is Matt Smith, I'm pleased to meet you!
%

我将来自清单 3-2 的脚本保存为名为multi_line.php的文件。运行该脚本时,它将连接字符串并在同一行上打印结果。请注意,接下来的终端提示符(在本例中为百分号字符%)会出现在下一行,这要归功于 PHP_EOL 常量。

如果一个变量已经包含了一个字符串,我们可以使用连接赋值操作符(.=)将另一个字符串表达式追加到该变量的末尾。以下是使用 PHP 交互模式的示例:

php > **$name = 'Matt';**
php > **$name .= ' Smith';**
php > **print** **$name;**
Matt Smith

首先,我们将\(name 变量初始化为字符串'Matt'。然后,我们使用连接赋值操作符将'Smith'追加到\)name 的内容末尾。当我们打印该变量时,可以看到它现在包含了 Matt Smith。### 双引号字符串

PHP 字符串的第二种类型是用双引号括起来的,例如"Matt Smith"。双引号字符串与单引号字符串的不同之处在于,它们会被 PHP 引擎解析,即 PHP 引擎会处理它们,这意味着它们可以包含 PHP 变量。当你在双引号字符串中写入一个变量(以美元符号开头)时,PHP 引擎会查找变量的值,并在打印之前将其插入到字符串中。这通常比使用句点连接单引号字符串与变量内容更加方便,就像我们在上一节中所做的那样。以下是使用 PHP 交互模式的示例:

php > **$name = 'Matt Smith';**
php > **print "My name is $name \nI'm pleased to meet you";**
My name is Matt Smith
I'm pleased to meet you

首先,我们将字符串'Matt Smith'赋值给\(name 变量,就像之前一样。注意,我们本可以使用双引号表示这个字符串,但大多数 PHP 程序员通常仅在需要解析的字符串中使用双引号。接下来,我们打印一个包含\)name 变量的双引号字符串。输出显示 PHP 成功解析了这个字符串,并将 Matt Smith 替换为变量值。

注意,双引号字符串中包含了收缩词 I'm 中的单引号字符。这在双引号字符串中是完全有效的,并且不需要转义。我们的双引号字符串还包含了转义序列\n,用于在输出中间创建换行符。这是双引号字符串中可以使用的多个转义序列之一。表格 3-1 列出了其中一些常见的转义序列。

表格 3-1:双引号字符串中的常见转义序列

转义序列 描述
\ 反斜杠
" 双引号
$ 美元符号
\n 换行符
\t 制表符

特别需要注意的是,由于美元符号会让 PHP 引擎解析变量,所以在双引号字符串中包含实际的美元符号时,必须使用$。同样,要在字符串中包含双引号,必须使用"。

你不能在双引号字符串中包含常量,因为 PHP 引擎无法分辨字符串中的字符和常量的名称之间的区别。(回忆一下第一章,常量是没有美元符号的。)其中一个副作用是,你不能在双引号字符串中包含 PHP_EOL 常量来在字符串末尾创建换行符。相反,你应该使用\n 换行符转义序列。

注意

在极少数情况下,当你需要操作系统无关的 PHP_EOL 常量与双引号字符串一起使用时,可以通过字符串连接操作符将常量添加到字符串中,就像你在示例 3-2 中看到的那样。这种情况可能发生在脚本需要根据 PHP 引擎运行的系统精确输出适当的换行符和光标到行首的字符序列时(例如,确保系统文件具有正确的行尾符)。

处理变量名后的字符

当变量名后跟一个空格出现在双引号字符串中时,例如"my name is \(name \nI'm pleased to meet you",PHP 引擎可以轻松识别变量(\)name),并确认其值后应跟一个空格。即使是标点符号,如句号、逗号和冒号,紧跟在变量后面也没有问题,转义序列也是如此,因为这些字符在变量名中是不合法的。例如,在$name 变量后面紧跟逗号是完全可以的:

php > **$name = 'Matt Smith';**
php > **print "my name is $name, and I'm pleased to meet you";**
my name is Matt Smith, and I'm pleased to meet you

然而,如果你希望其他字符紧随变量后面成为字符串的一部分,情况就会变得稍微复杂一些。在变量名的第一个字母(或下划线字符)之后,变量名的字符可以是字母、数字和下划线,因此,如果在双引号字符串中紧跟这些字符,PHP 引擎会将它们视为变量名的一部分。例如,如果我们有一个$weight 变量,并希望其值后面紧跟 kg(例如 80kg),我们不能这样写:

print "$weightkg";

PHP 引擎会报错,提示没有名为$weightkg 的变量。解决这些更复杂的双引号字符串解析任务的方法是将变量名用花括号(大括号)括起来:

php > **$weight = 80;**
php > **print "my weight is {$weight}kg";**
my weight is 80kg

由于使用了花括号,PHP 可以顺利地在\(weight 的值后面直接打印出 kg。请注意,这是*类型转换*字符串上下文的一个例子,如第二章中介绍的那样。当字符串"my weight is {\)weight}kg"被解析时,$weight 的值会从整数 80 转换为字符串'80',并插入到最终字符串中。

包含 Unicode 字符

并非所有字符都可以直接通过键盘输入,或者属于当前计算机系统的语言设置,但这并不意味着你不能在双引号字符串中包含它们。Unicode是一个国际标准,用于声明和处理各种字符和符号,包括普通的英文字母、表情符号、其他字母表的字母等等。每个 Unicode 字符都有一个唯一的十六进制代码。例如,代码 1F60A 对应多个笑脸表情之一。

要在双引号字符串中使用 Unicode 字符,可以以转义序列\u 开始,然后在花括号中提供该字符的十六进制代码。Listing 3-3 展示了声明和打印多个 Unicode 字符的代码。

<?php
$smiley = "\u{1F60A}";
$elephant = "\u{1F418}";
$cherokeeTSV = "\u{13E8}";

print "this is a smiley unicode character\n$smiley\n";
print "followed by some elephants of course\n$elephant $elephant $elephant\n";
print "Cherokee letter TSV\n$cherokeeTSV\n";

Listing 3-3:显示各种 Unicode 字符的脚本

首先,我们声明变量\(smiley、\)elephant 和$cherokeeTSV,分别包含带有相应的笑脸和大象表情符号,以及切罗基 TSV 符号的双引号字符串。然后,我们打印一些包含这些变量的双引号字符串。结果如下:

请注意,我们本可以直接在被打印的双引号字符串中包含 Unicode 字符的转义序列,而不是先将它们赋值给变量。将它们放在变量中可以让我们更容易地在整个脚本中重用它们——例如,打印三只大象,而不仅仅是一只。(PHP 社区特别喜欢大象。)要查看完整的 Unicode 字符及其对应的十六进制代码,请访问home.unicode.org。### Heredocs

Heredoc是双引号字符串的替代品。它们与双引号字符串类似,都会被解析,因此可以包含变量,但它们的不同之处在于,heredoc 通常跨越多行。尽管双引号字符串也可以跨越多行,但许多程序员更喜欢使用 heredoc 来处理多行字符串,因为它们的语法使得它们与周围的代码更明显地区分开来。

要声明一个 heredoc,首先使用 heredoc 运算符(<<<),然后跟上你选择的字符序列,作为分隔符。接着,在新的一行开始编写字符串。当你写到字符串的结尾时,在独立的一行重复你选择的分隔符,后面加上分号以结束语句。

最常用的 heredoc 分隔符是 EOT(即end of text的缩写),但选择哪个分隔符并不重要,只要该字符序列没有出现在被声明的字符串中,并且 heredoc 的开头和结尾的分隔符匹配。为了提高代码的可读性,建议要么始终使用 EOT,要么选择与 heredoc 内容相关的有意义的分隔符,例如,如果它包含 SQL 语句,则使用 SQL,或者如果它包含 HTML,则使用 HTML。列表 3-4 展示了一个包含示例 heredoc 的脚本。

<?php
$age = 22;
$weight = 80;

❶ $message = <<<EOT
my age is $age
my weight is {$weight}kg

❷ EOT;

print $message;

列表 3-4:使用 EOT 分隔符声明并打印的 heredoc 字符串

这段代码创建了变量\(age 和\)weight,分别包含 22 和 80。然后,我们将一个 heredoc 表达式赋值给\(message 变量❶。heredoc 以<<<EOT 开始,它的内容是从下一行开始直到遇到换行符和 EOT;结束❷。最后,我们打印\)message 的内容。以下是结果:

% **php heredoc.php**
my age is 22
my weight is 80kg
%

我将脚本保存为heredoc.php并在命令行运行。请注意,变量\(age 和\)weight 在 heredoc 中成功解析,包括当我们使用大括号时,使得变量后面的字符可以立即输出。还要注意,接下来的命令行提示符在新的一行开始。这是因为 heredoc 在关闭分隔符前有一个空行;heredoc 可以包含换行符。

转义序列

你不能使用 " 转义序列在 heredoc 中写入双引号。如果你在 heredoc 中写入 ",反斜杠将成为字符串中的普通字符,就像双引号一样。在 heredoc 中你根本不需要这个转义序列:由于你不再使用双引号来界定字符串,在字符串内使用双引号不会引起混淆。

可以在 heredoc 中包含其他转义序列,比如 \t 表示制表符,\n 表示换行。然而,这些转义序列并不是严格必要的,因为你可以直接在编写 heredoc 内容时使用 TAB 和 ENTER 键。如果你的编辑器支持 Unicode,你还可以直接在 heredoc 中输入 Unicode 字符。清单 3-5 展示了一个示例。

清单 3-5:在 heredoc 字符串中声明 Unicode 字符的脚本

我们通过使用 UNICODE 作为定界符来声明这个 heredoc。代码中的 Unicode 字符是直接输入到字符串中的,而不是通过转义序列创建的。运行这个脚本的输出与清单 3-3 的输出相同。

缩进

heredocs 的一个偶尔有用的特性是,如果在结束定界符之前出现了缩进(空格或制表符),PHP 引擎会尝试从 heredoc 的所有行中移除相同数量的缩进。例如,在清单 3-6 中,我们声明并打印了一个 heredoc 字符串,其中每一行(包括包含结束 TEXT 定界符的那一行)都缩进了四个空格。

<?php
$message = <<<TEXT
    If the closing delimiter is indented
    then that amount of indention
    is removed from the lines of the string

    TEXT;

print $message;

清单 3-6:带有缩进的 heredoc 脚本

由于这个 heredoc 的每一行都与结束定界符行有相同的缩进,因此在打印字符串时,所有缩进都会从每一行中移除。输出结果如下:

If the closing delimiter is indented
then that amount of indention
is removed from the lines of the string

如果 heredoc 中的任何行比结束定界符行有更多的缩进,这部分额外的缩进将保留在输出中。清单 3-7 展示了一个示例。

<?php
$message = <<<END
    I'm the same indention as the ending delimiter (4 spaces)
      I have 2 extra spaces
      So have I!
    I'm back to 4 spaces again

    END;

print $message;

清单 3-7:保留 heredoc 中额外缩进的脚本

这个 heredoc 的第一行和最后一行都缩进了四个空格,结束定界符也有四个空格的缩进。heredoc 中间的两行多缩进了两个空格。在输出中,四个空格将从每一行的开头被去掉,留下中间两行的两个空格缩进,如下所示:

I'm the same indentation as the ending delimiter (4 spaces)
  I have 2 extra spaces
  So have I!
I'm back to 4 spaces again

移除 heredoc 缩进的这个特性主要在 heredoc 作为函数体的一部分时非常有用,因为在这种情况下,所有代码通常都有一定的缩进级别。这样,你就可以编写更整洁的 heredoc,使其符合周围代码的缩进约定。我们将在第五章中介绍函数。

注意

如果 heredoc 中的任何行缩进比结束定界符少,或者它们使用了不同类型的缩进(比如使用了制表符而不是空格),则在运行时会发生错误。

Nowdocs

PHP 字符串的最后一种风格是 nowdoc,它是一个使用 <<< 运算符和定界符编写的未解析字符串。本质上,nowdoc 对未解析的单引号字符串的作用就像 heredoc 对解析的双引号字符串的作用。声明 nowdoc 和 heredoc 的唯一区别是 nowdoc 的起始定界符必须用单引号括起来,如 <<<'EOL'。结束定界符则不使用单引号。

nowdocs 的一种用途是打印 PHP 代码。由于 nowdocs 是未解析的,任何代码,包括变量名,都将被字面复制到字符串表达式中。清单 3-8 显示了一个示例。

<?php
❶ $name = "Matt Smith";

❷ $codeSample = <<<'PHP'
    $message = "hello \n world \n on 3 lines!";
    $age = 21;
  ❸ print $name;
    print $age;

    PHP;

print $codeSample;

清单 3-8:声明一个包含未解析 PHP 代码的 nowdoc 脚本

首先,我们声明一个 $name 变量 ❶。然后,我们通过使用定界符 PHP 声明一个 nowdoc,并将其赋值给 \(codeSample 变量 ❷。(注意,起始定界符用单引号括起来,而结束定界符没有。)nowdoc 包含诸如变量声明(\)age)、带有转义字符的字符串以及对 $name 变量的引用 ❸。当我们打印 nowdoc 时,所有这些内容都没有被解析,正如你在输出中看到的:

$message = "hello \n world \n on 3 lines!";
$age = 21;
print $name;
print $age;

整个 nowdoc 被逐字打印,包括转义序列和字符 $name。nowdoc 中的程序语句没有被执行;它们仅仅成为声明的 nowdoc 字符串的一部分。需要注意的是,nowdoc 中的缩进被去除了,因为它与结束定界符的缩进相匹配。这与 heredoc 的行为一致。

我们已经完成了四种字符串风格的概述。选择使用哪一种通常是个人偏好的问题。一般来说,单引号最适用于不包含需要解析的变量的短字符串。对于没有解析的较长多行字符串,可以考虑使用 nowdocs。如果需要包含解析的变量,则对于较短的字符串使用双引号,或者对于较长的多行字符串使用 heredocs。

内置字符串函数

PHP 有超过 100 个内置函数,用于操作和分析字符串,涵盖从标准任务(如大小写转换)到更专业的任务(如实现哈希算法)。我们将在本节中介绍一些最常用的字符串函数。

如果你是从其他编程语言转到 PHP,可能已经习惯于将这些操作作为直接在字符串本身上调用的方法。然而,由于 PHP 最初并不是面向对象的,因此这些操作作为独立函数存在。

注意

有关 PHP 字符串函数的完整列表,请参见 www.php.net/manual/ref.strings.php

转换为大写和小写

在处理用户输入时,你经常需要通过确保所有字符串都遵循相同的大小写规则来标准化字符串。这使得比较字符串更容易,或者以一致的格式将文本存储到数据库或发送到 API。为此,PHP 提供了调整字符串大小写的函数。strtolower() 和 strtoupper() 函数分别将所有字母转换为小写或大写。以下是一些使用 PHP 交互模式演示的示例:

php > **$myString = 'the CAT sat on the Mat';**
php > **print strtolower($myString);**
the cat sat on the mat
php > **print strtoupper($myString);**
THE CAT SAT ON THE MAT

我们声明一个包含大写字母和小写字母的字符串。将该字符串传递给 strtolower() 函数会将所有字母转换为小写,而传递给 strtoupper() 函数则会将所有字母转换为大写。

PHP 的 ucfirst() 函数将字符串的首字母大写(如果它还没有大写的话)。这在创建要输出给用户的消息时非常有用;将首字母大写有助于使消息看起来像语法正确的句子:

php > **$badGrammar = 'some people don\'t type with capital letters.';**
php > **print ucfirst($badGrammar);**
Some people don't type with capital letters.

相关的函数 lcfirst() 会将字符串的首字母转换为小写:

php > **$worseGrammar = 'SOME PEOPLE TYPE WITH ALL CAPS.';**
php > **print lcfirst($worseGrammar);**
sOME PEOPLE TYPE WITH ALL CAPS.

通常这不会对语法带来太大的改进,但它在某些情况下是有用的,例如,如果你正在编写一个输出代码的脚本。在这种情况下,为了遵循编程语言的命名约定(例如变量的命名),确保字符串的第一个字符是小写可能很重要。

ucwords() 函数将字符串中每个单词的首字母大写。如果单词之间用空格分隔,PHP 可以区分这些不同的单词:

php > **$mixedCaps = 'some peoPLE use CAPS spoRADically.';**
php > **print ucwords($mixedCaps);**
Some PeoPLE Use CAPS SpoRADically.

注意,如果单词中的后续字母是大写的,它们将保持不变。只有每个单词的首字母会受到影响。PHP 没有类似的函数可以将每个单词的首字母转换为小写。

搜索和计数

PHP 的多个内置字符串函数用于分析目的,例如报告字符串的长度、在字符串中查找字符或子字符串,或计算字符串中字符或子字符串的出现次数。(子字符串是一个较大字符串的部分。)不过,在我们研究这些函数之前,有必要区分字符串中字符的数量字符在字符串中的位置

以字符串 'cat scat' 为例。它包含八个字符(中间的空格也算),但是在 PHP 中,字符位置是从零开始编号的。因此,位置 0 上的字符是 c,位置 1 上的是 a,以此类推,直到位置 7 上的 t。这个从零开始计数的方式被称为 零基索引,它在计算机编程中很常见。使用这个系统,我们可以说子字符串 'cat' 在字符串中出现了两次,第一次出现在位置 0,第二次出现在位置 5:

cat scat
01234567
cat  cat

有了这个概念,我们来试一下对字符串 'cat scat' 使用一些分析性字符串函数。首先,strlen() 函数报告字符串的长度,如下所示,使用 PHP 交互模式:

php > **$myString = 'cat scat';**
php > **print strlen($myString);**
8

正如预期的那样,这告诉我们 'cat scat' 的长度是 8 个字符。

substr_count() 函数用于计算子字符串在字符串中出现的次数。例如,在这里,我们计算子字符串 'cat' 出现的次数:

php > **$myString = 'cat scat';**
php > **print substr_count($myString, 'cat');**
2

我们向 substr_count() 函数传递两个字符串。第一个是我们要搜索的字符串所在的位置,在这里我们提供的是 $myString 变量。第二个是我们要搜索的字符串目标:在这个例子中是 'cat'。在计算机搜索术语中,这两个字符串通常被称为大海捞针中的,来源于“在大海捞针”这一表达。

注意

在函数的括号中输入的项目,例如 $myString 'cat' 在上面的例子中,被称为参数。它们是函数执行任务所需要的数据。我们将在第五章详细讨论函数。

大多数涉及字符串搜索的 PHP 函数,包括 substr_count(),都是区分大小写的,因此在处理时需要注意字母的大小写。例如,如果我们尝试在 'cat scat' 中搜索子字符串 'Cat' 而不是 'cat',我们会得到计数为 0 的结果:

php > **$myString = 'cat scat';**
php > **print substr_count($myString, 'Cat');**
0

strpos() 函数报告子字符串在字符串中出现的起始位置(从零开始计数)。如果子字符串出现多次,只会给出第一次出现的位置。在这里,我们搜索子字符串 'cat' 的第一次出现:

php > **$myString = 'cat scat';**
php > **print strpos($myString, 'cat');**
0

与 substr_count() 相似,我们向 strpos() 函数传递两个字符串作为参数,第一个是大海(haystack),第二个是针(needle)。该函数报告 'cat' 第一次出现的位置为 0。

可选地,你可以向 strpos() 函数提供一个偏移量作为附加参数,这是一个告诉它从不同位置开始搜索子字符串的数字,而不是从字符串的开头开始搜索。在这里,我们告诉函数从位置 2 开始搜索:

php > **$myString = 'cat scat';**
php > **print strpos($myString, 'cat', 2);**
5

这一次,由于函数不是从字符串的开头进行搜索,它会识别出 'cat' 的第二次出现,位置为 5。如果函数没有找到目标子字符串,它会返回 false。

count_chars() 函数分析字符串中包含和不包含的字符。它是一个强大的字符串分析函数,你可能会在评估密码复杂度或进行数据加密和解密任务时使用。它有几个模式,可以通过在调用函数时指定数字来选择。在下面的例子中,我们使用模式 3,它生成一个新字符串,其中包含被分析字符串中所有唯一的字符:

php > **$myString = 'cat scat';**
php > **print count_chars($myString, 3);**
acst

我们在 $myString 上调用 count_chars() 函数,并指定模式 3。结果字符串显示了 'cat scat' 中每个字符的一个实例,并按字母顺序排列。它告诉我们,'cat scat' 仅包含字母 acst

注意

count_chars() 函数有其他模式来统计每个字符的出现次数,但结果是以数组形式返回的,所以我们在这里不讨论这些模式。我们将在 第七章 和 第八章 中讨论数组。

提取和替换子串

其他 PHP 函数通过提取字符串的一部分或用其他内容替换部分字符串来操作字符串。例如,substr() 函数提取字符串的一部分,从给定的位置开始。它是这样工作的:

php > **$warning = 'do not enter';**
php > **print substr($warning, 7);**
enter

我们声明一个字符串变量 'do not enter',然后将其传递给 substr() 函数。数字 7 告诉函数从位置 7 开始提取所有字符直到末尾,结果是 enter。

如果你使用负数,函数会从字符串的末尾开始计数来提取。例如,在这里我们使用 -2 来获取最后两个字符:

php > **$warning = 'do not enter';**
php > **print substr($warning, -2);**
er

你可以选择在函数调用中包括第二个数字来指定提取的长度。例如,在这里我们从位置 7 开始提取三个字符:

php > **$warning = 'do not enter';**
php > **print substr($warning, 7, 3);**
ent

strstr() 函数提供了另一种提取字符串部分的技术。默认情况下,它会查找字符串中第一次出现的子串,并从该子串开始提取字符串内容。例如,在这里我们查找子串 '@' 来提取电子邮件地址中的域名和扩展名:

php > **$email = 'the.cat@aol.com';**
php > **print strstr($email, '@');**
@aol.com

该函数查找第一个出现的 @ 符号并返回从该点开始到字符串结尾的所有内容,包括 @ 符号本身。我们也可以使用 strstr() 提取字符串中第一次出现子串之前的所有内容。为此,向函数调用的末尾添加 true,如下所示:

php > **$email = 'the.cat@aol.com';**
php > **print strstr($email, '@', true);**
the.cat

再次,我们在字符串中查找第一个出现的 @ 符号,但这一次,感谢添加的 true,函数返回的是字符串中从开头到 @ 符号(不包括它)的内容。这给我们返回的是电子邮件地址中的用户名。

注意

strstr() 函数是区分大小写的,但 PHP 提供了一个不区分大小写的版本,称为 stristr()

str_replace() 函数查找字符串中所有子串的出现并将其替换为另一个子串。替换的结果作为一个新字符串返回,这意味着原始字符串本身没有被修改。以下是一个示例:

php > **$foodchain = 'dogs eat cats, cats eat mice, mice eat cheese';**
php > **print str_replace('eat', 'help', $foodchain);**
dogs help cats, cats help mice, mice help cheese
php > **print $foodchain;**
dogs eat cats, cats eat mice, mice eat cheese

当我们调用 str_replace() 时,需要提供三个字符串。第一个是要查找的子串(在此示例中为 'eat')。第二个是替换用的子串(在此示例中为 'help')。第三个是要搜索的字符串,我们将其赋值给 $foodchain 变量。该函数通过将所有 'eat' 替换为 'help' 来生成一个新字符串。然后我们打印 $foodchain 的值,确认它没有被函数调用所影响。

要使替换结果保持一定程度的永久性,可以将调用 str_replace() 的结果存储在一个新变量中,如下所示:

php > **$foodchain = 'dogs eat cats, cats eat mice, mice eat cheese';**
❶ php > **$friendchain = str_replace('eat', 'help', $foodchain);**
php > **print $foodchain;**
❷ dogs eat cats, cats eat mice, mice eat cheese
php > **print $friendchain;**
dogs help cats, cats help mice, mice help cheese

这次当我们调用 str_replace() 时,我们将结果赋值给 $friendchain ❶。原始的 $foodchain 变量仍然没有受到替换操作的影响 ❷,但至少现在我们已经有了一个修改后的字符串,可以在之后使用。

另一个替换函数是 substr_replace()。与指定要替换的子字符串不同,这个函数允许你指定替换操作应该发生的位置。以下是一个示例:

php > **$foodchain = 'dogs eat cats, cats eat mice, mice eat cheese';**
php > **print substr_replace($foodchain, 'help', 5, 3);**
dogs help cats, cats eat mice, mice eat cheese

当我们调用 substr_replace() 函数时,首先提供原始字符串(在 $foodchain 中)和替换字符串('help')。然后我们提供两个额外的数字作为参数。第一个数字 5 是原始字符串中替换开始的位置。第二个数字 3 是原始字符串中从指定位置开始替换的字符数。这样会将单词 eat(该单词长度为 3,从位置 5 开始)替换为单词 help,同时保留字符串的其余部分不变。

通过将替换长度设置为 0,我们可以使用 substr_replace() 向字符串中插入子字符串,而不对其进行其他修改,如下所示:

php > **$foodchain = 'dogs eat cats, cats eat mice, mice eat cheese';**
php > **print substr_replace($foodchain, 'don\'t ', 5, 0);**
dogs don't eat cats, cats eat mice, mice eat cheese

这将在 $foodchain 字符串的第 5 位开始插入单词 don’t,而不会替换任何字符。

去除空白字符

通常需要去除字符串开头或结尾的空白字符,这个任务叫做 trimming(修剪)。PHP 提供了三个修剪函数:

trim()   去除字符串开头和结尾的空白字符

ltrim()   去除字符串开头的空白字符(l 代表 left,即左侧)

rtrim()   去除字符串结尾的空白字符(r 代表 right,即右侧)

这三个函数的工作原理相同:给定一个字符串,它们会去除所有的空格、制表符、垂直制表符、换行符、回车符或美国标准信息交换码(ASCII)空字节字符,直到第一个非空白字符和/或字符串最后一个非空白字符为止。例如,这里我们使用 trim() 去除字符串开头和结尾的空格和换行符:

php > **$tooSpacey = "    \n\nCAT\nDOG\n\n";**
php > **print trim($tooSpacey);**
CAT
DOG

输出显示,字符串的换行符以及开头和结尾的空白字符已经被去除,但请注意,CATDOG 之间仍然保留了一个换行符。trim 函数对字符串中间的空白字符没有影响。

你可以选择性地通过在调用函数时,在一个单独的双引号字符串中指定要去除的空白字符。以下是一个示例:

php > **$evenSpacier = "\n\n    CAT\nDOG\n\n";**
php > **print ltrim($evenSpacier,** ❶ **"\n");**
    CAT
DOG
❷
php >

这次我们使用字符串 "\n" ❶ 来指定仅去除换行符。在输出中,注意到单词 CAT 前的空格被保留,因为函数只忽略了除换行符外的其他字符。下一个提示符前的空行 ❷ 也表明,字符串末尾的换行符保持原样,因为 ltrim() 函数只影响字符串的开头部分。

移除所有不必要的空白字符

要去除字符串中的所有空白字符,而不仅仅是边缘上的空白,可以使用 str_replace() 来查找特定的空白字符,并将其替换为空字符串。例如,这里我们使用这种技巧去除字符串中的所有制表符字符:

php > **$tooTabby = "\tCat \tDog \t\tMouse";**
php > **print $tooTabby;**
    Cat     Dog         Mouse
php > **print str_replace("\t", '', $tooTabby);**
Cat Dog Mouse

分配给 $tooTabby 的字符串包含多个制表符字符。将每个 "\t" 替换为 ''(空字符串)可以去除制表符,同时保留每个单词之间的普通空格。

清单 3-9 将这一技巧进一步推广,反复使用 str_replace() 来移除字符串中的任何空白字符,除了单词之间的单个空格字符。这包括去除制表符、换行符和多个连续的空格字符。

<?php
❶ $string1 = <<<EOT
the
    cat     sat
    \t\t on    the
mat

EOT;

❷ $noTabs = str_replace("\t", ' ', $string1);
$noNewlines = str_replace("\n", ' ', $noTabs);

❸ $output = str_replace('  ', ' ', $noNewlines);
$output = str_replace('  ', ' ', $output);
$output = str_replace('  ', ' ', $output);

$output = trim($output);

print "[$output]";

清单 3-9:在字符串中替换所有空白字符(除了单个空格字符)

我们使用 heredoc 来声明 $string1 变量,它包含制表符、换行符以及单词之间的多个空格 ❶。然后我们使用 str_replace() 函数两次,第一次将所有制表符替换为单个空格,第二次将所有换行符替换为单个空格 ❷。(我们没有将它们替换为空字符串,以防制表符或换行符是两个单词之间唯一的字符。)

接下来,我们反复使用 str_replace() 来将两个空格字符的任何实例替换为一个空格 ❸。需要三次函数调用,直到只剩下单个空格。(在第六章中,我们将深入探讨循环,这提供了一种更高效的方式来多次重复相同的代码,或直到满足特定条件。)为了确保万无一失,我们使用 trim() 来移除字符串开始或结束处的任何残留空白,然后再打印出结果字符串,字符串被方括号括起来,便于查看它的起始和结束位置。以下是运行该脚本的输出:

[the cat sat on the mat]

最终的字符串在前后没有空白字符,每个单词之间只有单个空格。所有多余的空白字符都已被移除。

重复与填充

一些 PHP 字符串函数通过重复一个字符或子字符串来生成更长的字符串。例如,要通过重复一个字符串一定次数来创建一个新字符串,可以使用 str_repeat(),像这样:

php > **$lonely = 'Cat';**
php > **print str_repeat($lonely, 5);**
CatCatCatCatCat

这会通过重复 'Cat' 五次,为我们孤单的字符串增添一些“伴侣”。

与重复密切相关的是 填充:一个字符或子字符串被反复添加到字符串的开头或结尾,直到字符串达到所需的长度。填充非常有用,例如,如果你要显示不同长度的多个数字,并希望它们的数字对齐。在这种情况下,你可能会在数字前面添加空格或零作为填充,示例如下:

 12 // padded with spaces
   1099

000001 // padded with zeros
000855

PHP 有一个 str_pad() 函数用于这样的填充任务。这里,举例来说,我们用连字符(-)填充字符串 'Cat',直到它的长度为 20 个字符:

php > **$tooShort = 'Cat';**
php > **print str_pad($tooShort, 20, '-');**
Cat-----------------

我们调用 str_pad(),提供原始字符串($tooShort)、所需长度(20)和用作填充的字符串('-')。默认情况下,PHP 会将填充添加到原始字符串的右侧,但你可以在函数调用中添加常量 STR_PAD_LEFT 或 STR_PAD_BOTH,以便将填充添加到左侧或将填充均匀地添加到两侧。以下是一些示例:

php > **$tooShort = 'Cat';**
php > **print str_pad($tooShort, 20, '-', STR_PAD_LEFT);**
-----------------Cat
php > **print str_pad($tooShort, 20, '-', STR_PAD_BOTH);**
--------Cat---------

在每种情况下,该函数会添加连字符,直到生成的字符串长度为 20 个字符。

总结

字符串是你在创建每个网页应用时可能都会用到的核心数据类型。在本章中,你学习了声明字符串的四种方式:单引号字符串、双引号字符串、heredoc 和 nowdoc。你看到双引号字符串和 heredoc 是如何解析的,因此可以包含变量,而单引号字符串和 nowdoc 则不能。你还尝试了 PHP 内置的字符串操作函数,并学习了如何使用 . 和 .= 运算符来连接字符串。

练习

1.   编写一个脚本,声明一个包含你名字的 $name 变量,并将其设置为单引号字符串。然后,使用字符串连接运算符 (.) 来将 $name 的内容与字符串 ' is learning PHP' 合并,并输出结果。当你运行脚本时,输出应如下所示:

Matt is learning PHP

2.   在一个脚本中,创建一个 $fruit 变量,内容为字符串 'apple'。然后,使用双引号字符串和 print 语句输出以下信息:

apple juice is made from apples.

修改你的脚本,使得 $fruit 包含 orange,从而输出以下内容:

orange juice is made from oranges.

提示:你需要使用花括号来从 $fruit 变量创建复数形式的水果名称。

3.   编写一个脚本,声明一个 heredoc 字符串变量 $happyMessage,内容如下(包括换行):

输出 $happyMessage 变量的内容。

4.   在一个脚本中,创建一个 $appleJuice 变量,内容为字符串 'apple juice is made from apples.' 然后,使用 str_replace() 函数创建一个新的字符串变量 $grapefruitJuice,内容为字符串 'grapefruit juice is made from grapefruits.' 尝试使用其他 PHP 函数进一步转换字符串。例如,将字符串的首字母大写,使其看起来像是一个语法正确的句子。

第四章:4 条件语句

在本章中,你将学习 PHP 语言中的条件元素,包括 if...else 语句、switch 语句和 match 语句。这些结构,以及三元运算符、空合并运算符和逻辑运算符等语言特性,使得编写动态代码成为可能,代码根据一组条件来决定执行什么操作。这些条件可能依赖于特定的输入(例如来自用户或软件系统如数据库或 API 的输入),也可能依赖于其他变化的数据(例如当前日期或时间,或者文件是否存在)。

条件为真或为假

在任何决策逻辑的核心都是布尔表达式,即返回真或假的代码。最简单的布尔表达式就是 true 或 false 的字面值。然而,在几乎所有情况下,我们都会编写包含某种测试的表达式。这个测试可能会检查变量的值,或者调用一个函数并检查它返回的值。无论哪种方式,测试最终都会评估为真或假。测试的示例包括以下内容:

  • 一个变量是否包含特定的值?

  • 变量是否有任何值被赋值?

  • 文件或文件夹是否存在?

  • 一个变量的值是否大于或小于另一个值?

  • 一个函数是否根据提供的参数返回真或假?

  • 字符串的长度是否大于某个最小值?

  • 一个变量是否包含特定数据类型的值?

  • 两个表达式是否都为真,还是只有一个为真,或者都不为真?

这些测试都会评估为真或假。它们构成了你可以在代码中使用的选择语句的条件。

if 语句

可能任何编程语言中最常见的条件语句是 if 语句。它允许你仅在某个条件为真时执行语句。否则,该语句会被跳过。在 PHP 中,if 语句的写法如下:

if (condition) statementToPerform;

从 if 关键字开始,后面跟着括号中的条件,条件就是评估为真或假的布尔表达式。通常做法是在 if 关键字后、左括号前添加一个空格。接下来是当条件为真时应该执行的语句。

列表 4-1 展示了一个 if 语句的示例。如果一天的小时数小于 12(假设使用 24 小时制时钟),它会打印“早上好”。

<?php
$hourNumber = 10;
if ($hourNumber < 12) print 'Good morning';

列表 4-1:一个 if 语句示例

首先,我们将变量\(hourNumber 设置为 10。然后我们使用 if 语句来测试条件:\)hourNumber 的值是否小于 12。由于 10 小于 12,条件为真,因此条件后的语句会被执行,打印出“早上好”。

在这个例子中,我们只想在条件为真时执行一条语句。但如果我们想执行多条语句呢?我们需要一种方法将这些语句分组,以便明确它们都是 if 语句的一部分。为此,我们可以在条件之后立即将这些语句用大括号括起来。大括号划定了一个语句组,这是 PHP 的一个结构,可以包含零个、一个或多个语句,并且 PHP 会将其视为一个单一的语句。列表 4-2 展示了一个带语句组的条件语句示例。

<?php
$hourNumber = 10;
if ($hourNumber < 12) {
    print 'Good';
    print ' morning';
}

列表 4-2:一个重构过的 if 语句,包含语句组

这个 if 语句产生与列表 4-1 相同的结果,但我们已将其重写为包含多个打印语句,每个语句对应消息中的一个单词。这些语句被大括号括起来,以便将它们分组。通常写法是将开括号写在条件的同一行,然后在后续行中写出语句组中的每个语句,最后在另起一行写出闭括号。根据约定,语句组中的每个语句都会缩进。

注意

即使你只有一个条件语句要执行,通常也会将该语句用大括号括起来,形成语句组。这样,所有的 if 语句都会遵循相同的风格,不管涉及多少语句。

if...else 语句

许多情况下需要程序在条件为真时执行一组操作,在条件为假时执行另一组操作。对于这些情况,使用 if...else 语句。列表 4-3 展示了一个例子,其中我们选择打印“Good morning”或“Good day”。

<?php
$hourNumber = 14;
if ($hourNumber < 12) {
    print 'Good morning';
} else {
    print 'Good day';
}

列表 4-3:一个 if...else 语句

这段代码再次检查 $hourNumber 的值是否小于 12。如果是,条件为真,执行 if 分支的语句,打印出“Good morning”,如同之前一样。然而,如果条件为假,并且 $hourNumber 不小于 12,我们将执行 else 分支的语句,打印出“Good day”。请注意,else 关键字出现在 if 分支的语句组的右大括号之后。然后,else 分支会有一个独立的语句组,用大括号括起来。

在这种情况下,$hourNumber 是 14(下午 2 点),因此条件判断为假,执行了 else 分支的语句。

嵌套 if...else 语句

if...else 语句在两个行动之间做出选择。如果你有多个行动可以选择,你有几个选项。一个方法是将更多的 if...else 语句嵌套在原有的 else 分支中。列表 4-4 展示了一个例子。这个脚本编码了以下逻辑:如果小时数小于 12,打印“Good morning”;如果小时数在 12 到 17(下午 5 点)之间,打印“Good afternoon”;否则,打印“Good day”。

<?php
$hourNumber = 14;
❶ if ($hourNumber < 12) {
    print 'Good morning';
} else {
  ❷ if ($hourNumber < 17) {
        print 'Good afternoon';
    } else {
        print 'Good day';
    }
}

列表 4-4:嵌套的 if...else 语句

首先,我们有一个 if 语句测试小时数是否小于 12 ❶。如果这个条件不成立,else 语句将会被执行。else 语句的语句组是一个第二个(嵌套的)if...else 语句。这个第二个 if...else 语句的条件是小时数是否小于 17 ❷。(如果我们已经执行到这个步骤,就表示小时数不小于 12,因此实际上我们是在测试小时数是否介于 12 和 17 之间。)如果这个新的测试通过,系统会打印“下午好”。否则,我们进入嵌套 if...else 语句的 else 部分,打印“日安”。尝试使用不同的$hourNumber 值来观察它如何影响脚本的输出。

if...elseif...else 语句

在编程中,选择三种或更多行为是一个非常常见的模式,因此 PHP 提供了一种更简洁的语法,避免了嵌套的需要:在 if 语句和 else 语句之间,放置一个或多个 elseif 语句。PHP 引擎会首先测试 if 语句的条件。如果该语句为假,PHP 引擎会继续测试第一个 elseif 语句的条件,然后是下一个 elseif 语句,以此类推。当 PHP 找到一个成立的条件时,会执行该分支的语句,并跳过其余的条件检查。如果没有任何 if 或 elseif 条件成立,那么最后的 else 语句(如果有)将会被执行。

清单 4-5 展示了与清单 4-4 相同的逻辑,但它是使用 if...elseif...else 重写的。

<?php
$hourNumber = 14;
if ($hourNumber < 12) {
    print 'Good morning';
❶ } elseif ($hourNumber < 17) {
    print 'Good afternoon';
} else {
    print 'Good day';
}

清单 4-5:用 if...elseif...else 简化嵌套的 if...else 语句

我们的第二个条件现在以 elseif 语句的形式出现在 if 语句之后 ❶,而不再需要嵌套在 else 语句中。你可以在 if 和 else 之间添加任意数量的 elseif 语句。

替代语法

PHP 为 if、if...else 和 if...elseif...else 语句提供了一种替代语法,使用冒号而不是大括号来区分代码的各个部分。这种语法在清单 4-6 中得到了展示,复现了清单 4-3 中的 if...else 语句。

<?php
$hourNumber = 14;
if ($hourNumber < 12):
    print 'Good morning';
else:
    print 'Good day';
endif;

清单 4-6:条件语句的替代语法

在这种替代语法中,if 语句的条件后面跟一个冒号(😃。这一行就像是一个大括号的开始,因此它与 else(或 elseif)关键字之间的所有语句都被认为是条件成立时需要执行的语句组。else 关键字同样后面跟一个冒号,而不是一个大括号。else 分支的语句组以 endif 关键字结束,标志着整个 if...else 结构的结束。

这种替代语法在 Web 应用程序中特别有用,因为 HTML 模板文本可能出现在 if 语句和 else 语句之间,而使用缩进的花括号在代码中可能会让人难以跟踪。同样,endif 关键字清楚地表示整体条件语句的结束。

逻辑运算符

PHP 的 逻辑运算符 用于操作或组合布尔表达式,产生一个单一的真或假值。通过这种方式,你可以编写比单纯比较两个值更复杂的条件判断语句,就像我们迄今为止所做的那样(例如,测试两个条件是否为真)。这些逻辑运算符执行如 AND、OR 和 NOT 等操作。运算符的总结见 表 4-1。

表 4-1:PHP 逻辑运算符

名称 运算符 示例 描述
NOT ! !$a | 如果 $a 为假,则为真
AND and&& $a and \(b\)a && $b 如果 $a 和 $b 都为真,则为真
OR 或|| $a 或 \(b\)a || $b 如果 $a 或 $b 为真,或两者都为真,则为真
XOR xor $a xor $b 如果 $a 或 $b 为真,但不能两者都为真,则为真

请注意,AND 和 OR 操作可以有两种写法:使用单词(and 或 or)或使用符号(&& 或 ||)。这两种写法执行相同的功能,但符号版本在表达式求值时优先级高于单词版本。(我们在 第一章 中讨论了运算符优先级的顺序,主要是在算术运算符的上下文中。)

NOT

感叹号(!)表示“NOT”操作符。该操作符用于否定一个布尔表达式或测试该表达式是否不为真。例如,清单 4-7 使用 NOT 操作符测试驾驶员的年龄。在爱尔兰,开车必须年满 17 岁。

<?php
$age = 15;
if (!($age >= 17)) {
    print 'Sorry, you are too young to drive a car in Ireland.';
}

清单 4-7:使用 NOT (!) 操作符的 if 语句

if 语句检查 $age 是否 为真,即是否小于 17。由于 15 小于 17,因此运行脚本时应该看到以下消息被打印出来:

Sorry, you are too young to drive a car in Ireland.

请注意,我们将 $age >= 17 放在括号中,以将其与 NOT 操作符分开。这是因为 NOT 操作符通常优先级高于 >= 操作符,但我们希望在使用 ! 否定结果之前,先检查 \(age 是否大于或等于 17。如果我们写成 if (!\)age >= 17) 而没有内括号,PHP 会首先尝试计算 !$age。NOT 操作符要求布尔操作数,因此 $age 中的值 15 会被转换为 true(任何非零值都一样)。然后,由于 !true 为 false,表达式变成 false >= 17。

接下来,PHP 会尝试评估>=比较操作,因为其中一个操作数是布尔值,它也会尝试将第二个操作数转换为布尔值。因此,整数 17 会被转换为 true(因为它是非零的),从而得到表达式 false >= true,这个结果为 false。最终,在没有额外括号的情况下,!\(age >= 17 会对任何非零整数值的\)age 评估为 false。

为了避免所有这些类型转换和因缺少括号而可能导致的错误,我通常会在引入 NOT 运算符之前,为 if 语句创建一个临时布尔变量。例如,清单 4-8 展示了清单 4-7 的另一版本,增加了一个额外的变量来避免混合整数和布尔值的任何可能性。

<?php
$age = 15;
$seventeenAndOlder = ($age >= 17);
if (!$seventeenAndOlder) {
    print 'Sorry, you are too young to drive a car in Ireland.';
}

清单 4-8:清单 4-7 的简洁版本,增加了一个布尔变量

我们使用\(seventeenAndOlder 变量来存储\)age >= 17 测试的布尔值。然后,if 语句使用 NOT 运算符测试$seventeenAndOlder 是否为假。虽然与清单 4-7 相比,这增加了一行代码,但由于我们将年龄测试的布尔表达式与 if 语句的条件分开,它更容易理解。

注意

将像 $age >= 17 这样的表达式放在括号内,在将其值赋给变量时并非必要。清单 4-8 使用括号来帮助使代码更易于阅读。

使用 AND 运算符的表达式在两个操作数都为真时为真。你可以使用关键字 and 或双重与符号(&&)来创建 AND 操作。例如,清单 4-9 中的 if...else 语句使用 AND 运算符来判断一个驾驶员是否满足两个条件才能申请驾照考试。在爱尔兰,必须通过理论考试并持有至少六个月的学习驾驶执照,才能申请驾照考试。

<?php
$passedTheoryTest = true;
$monthsHeldLearnersLicense = 10;
$heldLearnersLicenseEnough = ($monthsHeldLearnersLicense >= 6);

if ($passedTheoryTest and $heldLearnersLicenseEnough) {
    print 'You may apply for a driving test.';
} else {
    print "Sorry, you don't meet all conditions to take a driver's test.";
}

清单 4-9:使用 AND 运算符的 if...else 语句

我们将\(passedTheoryTest 变量声明为 true,并将\)monthsHeldLearnersLicense 的值设置为 10。然后,我们测试\(monthsHeldLearnersLicense 是否大于或等于 6,并将结果布尔值(此例为 true)存储在\)heldLearnersLicenseEnough 变量中。接下来,我们声明一个 if...else 语句,条件为\(passedTheoryTest 和\)heldLearnersLicenseEnough。由于两个值都为真,AND 操作也为真,因此消息“你可以申请驾照考试”将被输出。

尝试将\(passedTheoryTest 更改为 false 或将\)monthsHeldLearnersLicense 设置为小于 6 的值。此时,AND 操作应评估为 false,并且语句的 else 分支中的消息应该输出。

OR 运算在任一操作数或两个操作数都为真时返回 true。你可以使用关键字 or 或双竖线(||)来编写 OR 运算。清单 4-10 展示了一个使用 OR 运算符的 if 语句,判断密码是否未通过基本安全规则(通过包含字符串 'password' 或长度小于六个字符)。

<?php
$password = '1234';
$passwordContainsPassword = str_contains($password, 'password');
$passwordTooShort = (strlen($password) < 6);

❶ if ($passwordContainsPassword || $passwordTooShort) {
    print 'Your password does not meet minimal security requirements.';
}

清单 4-10:一个使用 OR 运算符的 if 语句

我们声明了一个 \(password 变量,存储字符串 '1234'。然后我们声明了两个布尔变量来帮助测试。首先,\)passwordContainsPassword 被赋值为将变量 $password 和字符串 'password' 传递给内置的 str_contains() 函数的结果。如果第二个字符串参数(“needle”)在第一个字符串参数(“haystack”)中找到,函数返回 true,否则返回 false。由于此例中 \(password 变量不包含字符串 'password',\)passwordContainsPassword 的值为 false。另一个布尔变量 $passwordTooShort,如果 $password 的长度小于 6,则为 true,通过内置的 strlen() 函数进行测试。由于 $password 中的字符串 '1234' 长度小于六个字符,因此该变量将被赋值为 true。

最后,我们声明一个 if 语句,使用 OR 运算符(||)根据两个布尔变量 ❶ 创建条件。由于至少有一个变量为真,if 语句条件通过,并打印出一条消息,指示密码不安全:

Your password does not meet minimal security requirements.

尝试将 $password 的值更改为六个字符或更长的字符串(不是 'password'),例如 "red\(99poppy"。此时,\)passwordContainsPassword 和 $passwordTooShort 都不会为 true,因此 if 语句中的逻辑 OR 测试将为假,且不会打印任何消息。

异或

异或运算(XOR,exclusive OR)仅在两个操作数中只有一个为真时返回 true,而两个都不为真时返回 false。我们使用关键字 xor 来创建一个 XOR 表达式。清单 4-11 展示了一个使用 XOR 运算符的 if...else 语句。该代码判断一个甜点是否奶油丰富,但不至于过于奶油。 (卡仕达 冰淇淋就太多了!)

<?php
$containsIceCream = true;
$containsCustard = false;
if ($containsIceCream xor $containsCustard) {
    print 'a nice creamy dessert';
} else {
    print 'either too creamy or not creamy enough!';
}

清单 4-11:一个带有异或运算符的 if...else 语句

我们声明了两个布尔变量 $containsIceCream 和 $containsCustard,将其中一个设置为 true,另一个设置为 false。然后我们声明了一个 if...else 语句,条件为 $containsIceCream xor $containsCustard。由于 XOR 运算符的缘故,若这两个变量中只有一个为真,条件将评估为真,并打印出一条美味的奶油甜点消息。如果两个变量都不为真,或者两个都为真,则 XOR 表达式为假,相应地会打印出“奶油过多”或“奶油不足”的消息。

在此示例中,由于只有一个变量为 true,我们应该会得到一条漂亮的奶油甜点消息。尝试更改两个布尔变量的值,看看 XOR 表达式的结果如何受到影响。

switch 语句

switch 语句是一种条件结构,用于将一个变量与多个可能的值进行比较,或称为 case。每个 case 有一个或多个语句,如果其值与变量匹配(通过类型转换,因此它执行类似 == 的相等性测试),则这些语句会被执行。你还可以提供一个默认的 case,如果没有任何值匹配。如果你需要从三个或更多可能的路径中选择,switch 语句是 if...elseif...else 语句的一个方便替代方案,只要决策依据是单一变量的值。

清单 4-12 显示了一个 switch 语句,该语句根据 $country 变量的值打印相应的货币信息。

<?php
$country = 'Ireland';

❶ switch ($country) {
  ❷ case 'UK':
        print "The pound is the currency of $country\n";
        break;
  ❸ case 'Ireland':
    case 'France':
    case 'Spain':
      ❹ print "The euro is the currency of $country\n";
        break;
    case 'USA':
        print "The dollar is the currency of $country\n";
        break;
  ❺ default:
        print "(country '$country' not recognized)\n";
}

清单 4-12:使用 switch 语句根据 $country 的值打印货币

首先,我们将 \(country 赋值为 'Ireland'。然后,我们开始一个 switch 语句,使用 switch 关键字并将要测试的变量放在括号中(\)country)❶。switch 语句的其余部分被一对花括号括起来。在 switch 语句内部,我们声明需要检查的 $country 的值,每个值都在各自的缩进的 case 子句中定义。每个 case 子句使用 case 关键字定义,后跟要测试的值,再后跟一个冒号(:)。然后,如果该 case 匹配,就在新的、更深的缩进行中写出要执行的语句。例如,如果 $country 的值是 'UK' ❷,则会打印出信息:The pound is the currency of UK(英镑是英国的货币)。

如果你希望相同的操作适用于多个 case,可以将这些 case 按顺序列出,只需在最后列出一次要执行的语句。例如,爱尔兰、法国和西班牙都使用欧元,因此我们按顺序列出了这些 case ❸。这些 case 后面的 print 语句 ❹ 将适用于它们中的任何一个;你不需要为每个 case 重复该语句。

我们的脚本为当 $country 的值为 'USA' 时增加了一个额外的 case。然后,switch 语句的最后部分使用 default 关键字声明一个默认的 case,而不是使用 case ❺。如果没有任何其他 case 与正在测试的变量匹配,默认的 case 会被执行。考虑到我们将 $country 设置为 'Ireland',脚本应该输出信息:The euro is the currency of Ireland(欧元是爱尔兰的货币)。

请注意,我们在每个 case 的语句组中都包含了 break 关键字,在每个 print 语句后面。这会中断,或跳出 switch 语句,防止执行该语句中的任何后续代码。理解 break 语句的作用非常重要。一旦找到匹配的 case,switch 语句主体中的所有剩余语句都会被执行,即使是其他不匹配的 case 的语句,除非遇到 break 语句中断执行。例如,如果我们从 清单 4-12 中删除所有的 break 语句,最终输出将是:

The euro is the currency of Ireland
The dollar is the currency of Ireland
(country 'Ireland' not recognized)

$country 的值是 'Ireland',而不是 'UK',因此第一个 case 不匹配,第一个打印语句被跳过。然而,一旦我们遇到 'Ireland' 的 case,接下来的三个打印语句就会执行,因为没有 break 语句来中断 switch 语句。这通常不是你希望从 switch 语句中得到的行为,因此几乎在每种情况下,你都需要在每个 case(或一组 cases)的末尾添加 break 语句,就像我们在 示例 4-12 中所做的那样。

match 语句

match 语句根据另一个变量的值为一个变量选择一个值。你可以使用 switch 语句来完成相同的任务,但 match 语句更加简洁。此外,match 语句依赖于严格比较(相当于使用 === 测试身份),而 switch 语句在进行任何类型转换后才进行比较(相当于使用 == 测试相等)。因此,当一个变量需要与多个相同类型的值进行比较,并且根据该测试执行的操作是为变量赋值时,使用 match 语句会更合适。

示例 4-13 展示了与 示例 4-12 中的 switch 语句相同的逻辑,但采用了 match 语句来实现。

<?php
$country = 'Ireland';

❶ $currency = match ($country) {
    'UK' => 'pound',
    'Ireland' => 'euro',
    'France' => 'euro',
    'Spain' => 'euro',
    'USA' => 'dollar',
  ❷ default => '(country not recognized)'
};

print "The currency of $country is the $currency";

示例 4-13:使用 match 语句根据 $country 的值设置 $currency

我们将 match 语句写成 $currency 变量赋值的一部分 ❶。它由 match 关键字组成,后面跟着要检查的变量(括号内),接着是由逗号分隔的、用大括号包围的 arms 序列。每个 arm 的形式是 x => y,其中 y 是当 $country 的值与 x 匹配时赋值给 $currency 的值。与 switch 语句一样,我们还提供了一个默认的 arm,以防没有值匹配 ❷。在 match 语句之后,我们打印一条消息,包含 $country$currency 的值。

与 示例 4-12 中的 switch 语句相比,这个 match 语句更简洁。赋值给 $currency 后,我们只需要写一个打印语句,而不需要为 switch 语句中的每个 case 写一个单独的打印语句。我们也不再需要所有的 break 语句;使用 match 语句后,一旦找到匹配,余下的语句会被忽略。

match 语句是 PHP 语言中新出现的一种语法。许多经验丰富的程序员仍然使用 switch,而在某些情况下,match 更加高效。(我有时也会犯这个错误。)一般来说,如果你需要测试一个变量的多个值,我建议你首先尝试使用 match 语句。只有在该方案不适用时,才应切换到 switch 语句。

三元运算符

PHP 的三元运算符(或三部分运算符)根据测试条件是否为真来选择两个值之一。这个运算符由两个独立的符号组成,一个问号(?)和一个冒号(:),并且按以下形式书写:

booleanExpression ? valueIfTrue : valueIfFalse

问号左侧写一个布尔表达式,它的值为真或假(例如,比较两个值)。问号右侧写两个由冒号分隔的值。如果布尔表达式为真,则选择冒号左侧的值(valueIfTrue)。如果布尔表达式为假,则选择冒号右侧的值(valueIfFalse)。通常,结果会赋值给一个变量。

本质上,三元运算符提供了一种更简洁的方式来编写 if...else 语句,只要 if...else 语句的目的是给变量赋值(而不是执行其他操作)。为了说明,列表 4-14 展示了根据\(region 的值选择\)currency 的两种方法:首先使用 if...else 语句,然后使用三元运算符。

<?php
$region = 'Europe';

❶ if ($region == 'Europe') {
    $currency = 'euro';
} else {
 $currency = 'dollar';
}

print "The currency of $region is the $currency (from if...else statement)\n";

$region = 'USA';
❷ $currency = ($region == 'Europe') ? 'euro' : 'dollar';

print "The currency of $region is the $currency (from ternary operator statement)\n";

列表 4-14:比较 if...else 和三元运算符语句

我们将\(region 赋值为'欧洲'。然后声明一个 if...else 语句,如果地区是'欧洲',则将\)currency 的值设置为'euro',否则设置为'dollar' ❶。我们打印一条消息以验证结果。接下来,我们将\(region 更改为'美国',并使用三元运算符重新赋值\)currency ❷。三元运算符表达式遵循与 if...else 语句相同的逻辑:如果\(region 等于'欧洲',代码将\)currency 设置为'euro',否则将$currency 设置为'dollar'。再次,我们打印消息以检查结果。以下是运行脚本后的输出:

The currency of Europe is the euro (from if...else statement)
The currency of USA is the dollar (from ternary operator statement)

第二行显示三元运算符已按预期工作,因为\(region 的值不是'欧洲',因此\)currency 的值被赋为'dollar'。正如你所见,在这种需要在两个可能的值之间做出简单选择的情况下,三元运算符非常简洁,只有一行代码,而 if...else 语句则有四行代码。

空合并运算符

另一种在两个值之间进行选择的运算符是空合并运算符,使用两个问号(??)表示。这个运算符根据变量是否为 NULL 来做出选择。使用空合并运算符的表达式的一般形式如下:

$variable = value ?? valueIfNull

首先,空合并运算符检查左侧的值,即 ?? 运算符左侧的表达式。这可以是一个变量,或者是一个返回值的函数。如果该表达式不是 NULL,则将值赋给 $variable。否则,空合并运算符右侧的值(valueIfNull)将被赋给该变量。这提供了一种后备机制,防止在变量未定义(或为 NULL)时抛出警告或错误。当你期望从用户那里获得一个值但没有提供,或者当你在数据库中查找记录而该记录不存在时,这种机制特别有用。

列表 4-15 显示了空合并运算符的使用示例。我们使用它来测试 $lastname_from_user 变量两次,第一次是在它还未被赋值时(因此是 NULL),第二次是在它被赋值之后。

<?php
❶ $lastname = $lastname_from_user ?? 'Anonymous';
print "Hello Mr. $lastname\n";

$lastname_from_user = 'Smith';
❷ $lastname = $lastname_from_user ?? 'Anonymous';
print "Hello Mr. $lastname\n";

列表 4-15:使用空合并运算符测试 NULL

首先,我们使用空合并运算符来设置 $lastname ❶ 的值。该运算符测试 \(lastname_from_user 变量,由于该变量尚未被赋值,因此为 NULL。因此,\)lastname 应该被赋予 ?? 运算符右侧的值(字符串 'Anonymous')。我们打印出一条消息以检查结果。接着,在给 $lastname_from_user 赋值后,我们使用相同的空合并运算符表达式再次设置 $lastname 的值 ❷。这次,由于 $lastname_from_user 包含非 NULL 值,该值应该被传递给 $lastname。以下是结果:

Hello Mr. Anonymous
Hello Mr. Smith

第一行显示,由于变量 \(lastname_from_user 为 NULL,\)lastname 被赋值为字符串 'Anonymous'。然而,第二次,$lastname_from_user 中的字符串 'Smith' 成功地存储在 $lastname 变量中并被打印出来。

小结

在本章中,你学习了用于编写做出决策的代码的关键字和运算符。计算机和编程语言的许多强大功能都建立在我们讨论的各种运算符和选择语句上。你看到 if 和 if...else 语句如何基于单一测试做出选择,尽管该测试本身可能会结合布尔表达式与逻辑运算符,如 AND 或 OR。你还看到了如何通过在 if 和 else 之间添加 elseif 分支来结合多个测试。然后你学习了其他条件结构,包括 switch 和 match 语句,它们会测试变量是否具有不同的可能值。这些结构允许你定义一个或多个在找到特定值时执行的语句。与这些结构紧密相关的是三元运算符和空合并运算符,它们都在两个可能的值之间做选择。

练习

1.   编写一个脚本,将一个名字赋值给 $name 变量,然后如果字符串的长度小于四个字符,则打印消息 "That is a short name"。

2.   编写一个脚本,用于确定洗衣机的大小。该脚本应检查变量$laundryWeightKg 的值,如果值小于 9,则打印“适合标准洗衣机”,否则打印“需要中型到大型洗衣机”。

3.   使用 switch 语句或 match 语句测试$vehicle 变量的值,并根据该值打印相应的消息。使用以下值/消息组合:

bus   "滴滴声"

train   "在轨道上行驶"

car   "至少有三个轮子"

helicopter   "可以飞行"

bicycle   "学会了就永远不会忘记"

(上述内容无)   "你选择了人迹罕至的道路"

4.   编写一个脚本,如果\(用户名字正确且\)密码正确,打印消息“您现在已登录”。否则,打印“凭证无效,请重试”。

第五章:5 自定义函数

在本章中,你将学习如何声明和使用你自己的函数,这些函数是命名的、独立的代码序列,旨在完成特定任务。你将看到函数如何促进代码的重用,因为将代码放入函数中要比每次执行该任务时都重新编写相同的代码序列更高效。函数还可以让你编写通过少量语句就能完成大量工作的程序,因为每个语句都可以调用隐藏在你函数中的复杂逻辑。

自定义函数通常会在与应用程序主程序语句不同的文件中声明。这源于PHP 标准推荐(PSRs),它是一系列关于 PHP 编程的指南和最佳实践。根据 PSR-1,文件应该要么声明符号(例如函数),要么产生副作用,但不能两者兼具。副作用是执行一段代码的具体结果,例如输出文本、更新全局变量、修改文件内容等等。

尽管函数本身可以产生这样的副作用,但声明一个函数(定义函数将做什么)与调用一个函数(让函数实际执行那个任务)是不同的。因此,函数应该在一个文件中声明,并在另一个文件中调用。为了遵循这一准则,本章首先介绍如何处理跨多个文件的代码基础知识,然后再转到函数部分。在第九章中,我们将更详细地讨论如何处理文件。

将代码分成多个文件

即使我们不考虑将函数声明放在单独文件中的最佳实践,仍然有必要将应用程序的代码拆分到多个文件中。考虑到一个复杂的应用程序可能包含成千上万行代码,如果所有代码都在一个庞大的文本文件中,那么浏览该文件并找到需要处理的特定代码段将非常困难。将代码组织成不同的文件使得项目更易于管理。

使用多个文件还促进了代码的重用性。一旦你开始编写自己的函数,你会发现将这些函数声明在不同的文件中,可以轻松地在项目的不同部分或完全不同的项目中重用这些函数。再举个例子,多个页面的网页应用程序通常在许多页面中包含相同的元素,例如 HTML 头部、底部和导航列表。与其为每个需要这些元素的页面重复写这些代码,不如将通用代码写在一个文件中。这样,如果需要对其进行更改(例如,更新网页徽标的图片引用),你只需在一个地方进行更改,而不是追踪并更新每个重复代码的实例。软件工程师称这一做法为不要重复自己(DRY)原则。

一旦你开始将应用程序的代码分布到多个文件中,就需要一种方法从一个文件中访问另一个文件的代码。在本节中,我们将探讨一些使这成为可能的 PHP 语言特性。

读取并执行另一个脚本

PHP 的 require_once 命令可以读取另一个文件的代码并执行它。为了了解这个命令是如何工作的,我们将创建两个脚本。其中一个是主脚本,它将使用 require_once 来访问另一个脚本中的代码。首先,创建一个名为main.php的文件,包含 Listing 5-1 中的代码。

<?php
print "I'm in main.php\n";

require_once 'file2.php';

print "I'm back in main.php\n";

Listing 5-1: 读取并执行来自不同脚本的代码的主脚本

在这个脚本中,我们打印出两条消息,指示我们正在主应用程序文件中。在两条消息之间,我们使用 require_once 命令来读取并执行 file2.php 脚本的内容。文件名作为字符串立即出现在命令后面。由于我们没有指定与文件名一起的目录路径(例如,Users/matt/file2.php),因此默认理解该文件与当前脚本位于同一文件夹中。这被称为相对路径:文件的位置是相对于当前脚本的位置确定的。

现在创建一个名为file2.php的文件,包含 Listing 5-2 中的代码。确保将此文件保存在与main.php相同的位置。

<?php
print "\t I'm printing from file2.php\n";
print "\t I'm also printing from file2.php\n";

Listing 5-2: 从另一个脚本读取并执行的 file2.php 内容

这个脚本包含两个打印语句,打印出消息,表示它们来自file2.php。请注意,每条消息的开头都有一个制表符转义字符(\t)。这样,这些消息就会缩进,而我们主脚本中打印的消息则不会,这是一个视觉提示,表明这些消息来自不同的脚本。

现在在命令行中输入 php main.php 来运行主脚本。以下是输出结果:

I'm in main.php
    I'm printing from file2.php
    I'm also printing from file2.php
I'm back in main.php

我们看到主脚本的第一条消息,接着是来自 file2.php 的两条缩进消息。这确认了由于主脚本中的 require_once 语句,file2.php 的内容已被读取并执行。最后,程序控制流返回主脚本,经过 require_once 语句后,我们看到了主脚本打印的最终消息。

注意

除了 require_once,PHP 还提供了另外三个命令,用于读取和执行声明在独立文件中的代码: require include* 和 include_once。它们的工作方式类似;你可以在 PHP 文档中阅读它们的区别。在我编写的 99.99% 的 Web 应用程序中,我使用 require_once。*

创建绝对文件路径

常量 DIR 始终指向当前执行脚本的 绝对文件路径,即从根目录开始的完整文件路径。这是 PHP 的 魔术常量 之一,内置常量,其值根据上下文而变化。对于 DIR 来说,值取决于 DIR 被评估时所在文件的位置。

编写 require_once 语句时,最好尽可能使用 DIR:只需将 DIR 的值与任何剩余的相对路径信息连接起来,即可访问你试图读取和执行的文件。这可以避免混淆路径是与当前脚本(调用 require_once 命令的脚本)相关,还是与可能已经要求当前脚本的脚本相关。假设你有一系列的脚本,其中一个脚本要求另一个脚本,而那个脚本也要求另一个脚本。如果这些脚本位于不同的目录中,使用 DIR 魔术常量可以确保无论你在何处写 require_once 语句,你都能知道路径正确指向你希望读取和执行的文件。

要尝试使用 DIR,请按 列表 5-3 所示更新你的 main.php 文件。更改部分以黑色文字显示。

<?php
print "I'm in main.php\n";

$callingScriptPath = __DIR__;
print "callingScriptPath = $callingScriptPath\n";

❶ require_once __DIR__ . '/file2.php';

print "I'm back in main.php\n";

列表 5-3:使用 DIR 读取和执行不同脚本中的代码的主脚本

我们将 $callingScriptPath 变量赋值为 DIR 魔术常量的值,并打印包含该变量的消息。然后在 require_once 命令之后使用 DIR,明确表示 file2.php 脚本与此主脚本位于同一目录❶。请注意,我们使用字符串连接运算符(.)将 DIR 的值与字符串 '/file2.php' 结合,构建到另一个文件的绝对路径。以下是运行主脚本后的输出:

I'm in main.php
❶ callingScriptPath = /Users/matt/magic
    I'm printing from file2.php
    I'm also printing from file2.php
I'm back in main.php

如前所述,首先打印出来自 main.php 的消息。然后我们看到打印出的主脚本路径(DIR 的值)❶。对我来说,这是 /Users/matt/magic,即我电脑上该示例项目所在目录的路径。其余输出与之前相同,包含来自 file2.php 的消息,最后是主脚本打印的最终消息。

声明和调用一个函数

现在,让我们关注如何声明和使用我们的第一个自定义函数。这个函数将确定两个数字中哪个较小。按照最佳实践,我们将函数声明在一个文件中,my_functions.php,然后从另一个文件中调用它,main.php。开始一个新项目并创建包含列表 5-4 代码的 my_functions.php

<?php
function which_is_smaller(int $n1, int $n2): int
{
    if ($n1 < $n2) {
        return $n1;
    } else {
        return $n2;
    }
}

列表 5-4:在 my_functions.php 中声明函数

这里我们声明了一个名为 which_is_smaller() 的函数。我们从关键字 function 开始,后跟函数名称。按照约定,函数名使用蛇形命名法,全部小写字母并用下划线连接多个单词。这使得你可以编写有意义、易读的函数名(尽管遗憾的是,由于语言早期设计时的选择,并不是所有 PHP 内置函数都遵循这种命名约定)。

函数名后面跟着一对括号,括号内是函数的参数,它们是函数完成工作的输入。在这个例子中,我们有两个参数,$n1 和 $n2,代表我们希望函数比较的两个数字。每个参数名前面都有数据类型,以确保正确的数据类型传递给函数。例如,int $n1 表示参数 $n1 应该是一个整数。

注意

如果一个函数不需要任何参数,你仍然需要在函数名称后面包括一对空括号。

括号后面跟着一个冒号(:),然后是函数的返回类型。大多数函数会执行一些操作并产生一个结果值,然后该函数将这个值返回给调用它的脚本。返回类型指定了该值的数据类型。在这个例子中,函数将返回整数 $n1 或 $n2 中较小的一个,因此我们将返回类型设置为 int。

到目前为止我们写的代码已经定义了函数的签名,它是函数的名称、参数(及其类型)和返回类型的组合。PHP 引擎使用函数的签名来唯一标识该函数,识别我们何时调用它,验证传递给函数参数的数据是否合适,并确保函数返回一个合适的值。

接下来是函数的主体,它是一个被大括号括起来的语句组,包含每次调用函数时会执行的代码。我们 which_is_smaller()函数的主体由一个 if...else 语句组成,测试整数\(n1 是否小于整数\)n2。如果$n1 较小,则执行 return \(n1;语句。否则(如果\)n2 较小或等于$n1),则执行 return \(n2;语句。在这两种情况下,我们使用 return 关键字将值(\)n1 或$n2)传递给调用它的脚本。一旦函数到达 return 语句,函数会停止执行并将控制权交还给调用脚本。即使函数体内在 return 语句之后有其他语句,它们也不会在函数返回值后执行。

现在我们已经声明了一个函数,接下来让我们使用它。创建与my_functions.php位于同一位置的main.php文件,并输入清单 5-5 中显示的代码。

<?php
require_once __DIR__ . '/my_functions.php';

$result1 = which_is_smaller(5, 2);
print "the smaller of 5 and 2 = $result1\n";

$result2 = which_is_smaller(5, 22);
print "the smaller of 5 and 22 = $result2\n";

清单 5-5:从 main.php 调用 which_is_smaller()函数

我们使用 require_once 从my_functions.php文件中读取函数声明。这并不会调用函数,它只是使函数在main.php脚本中可用。接下来,我们通过编写函数名并在括号中跟上我们希望函数比较的值(5 和 2)来调用函数。这些值被称为参数;它们填充函数的参数值。注意,我们将函数调用作为\(results1 变量赋值语句的一部分进行调用。这样,函数的返回值将存储在\)results1 中,以供以后使用(在这种情况下,是在下一行代码中,那里会将其打印出来)。当一个函数有返回值时,通常会遵循这种调用函数并将结果赋值给变量的模式。

我们通过再次调用该函数来结束脚本,这次使用 5 和 22 作为参数。这就是函数的魅力:你可以根据需要调用它们多次,每次使用不同的输入值。我们将第二次函数调用的返回值存储在$result2 变量中,并再次打印出显示结果的消息。下面是运行main.php脚本的输出:

the smaller of 5 and 2 = 2
the smaller of 5 and 22 = 5

我们可以看到我们的函数工作正常。它返回 5 和 2 中的较小值 2,以及 5 和 22 中的较小值 5。

参数与实参

参数实参这两个术语密切相关,经常互相混淆。当你声明一个函数时,参数是代表函数将要处理的输入的变量。如你在清单 5-4 中所见,参数会列在函数名称后的括号内。在我们的 which_is_smaller() 函数中,参数是 $n1 和 $n2。每个参数都是一个临时变量,仅在函数内部可见,在函数被调用时会被赋予一个值。这些变量只在函数执行期间存在。一旦函数执行完毕,局部参数变量会从计算机的内存中被丢弃。

变量在软件系统中“存在”的时间长度被称为作用域。在函数中声明的任何变量,包括参数,其作用域是局部的,仅限于函数本身。因此,你不能期望从函数声明外的任何代码访问函数的变量。在我们的例子中,我们不能在 main.php 中使用变量 $n1 和 $n2。相反,获取函数返回值的方法是使用返回语句。

当我们调用一个函数时,实参是我们在函数名称后的括号中传递给函数的具体值。这些实参为函数的参数提供值。例如,当我们调用 which_is_smaller(5, 22) 时,实参 5 被赋值给参数 $n1,实参 22 被赋值给参数 $n2。实参的顺序与参数的顺序相匹配。在这个例子中,实参是字面量,但实参也可以是变量,如下所示:

which_is_smaller($applesCount, $orangesCount)

就是这么简单。实参是在执行函数时传递的值,参数是在函数执行时创建的局部变量,由接收到的实参填充。因此,在函数执行期间,每个传递给函数的实参都会有一个对应的局部(临时)参数变量。(有一个例外是通过引用传递参数的特殊情况,我们将在本章后面讨论。)

错误来源于不正确的函数调用

在两种常见情况下,调用函数时会发生错误:如果你没有传递正确数量的实参,或者传递了错误数据类型的实参。(有关错误及其他类型警告的信息,请参见第 88 页的“错误、警告和通知”。)考虑调用我们自定义的 which_is_smaller() 函数:

$result = which_is_smaller(3);

该函数需要两个整数类型的实参,但我们只提供了一个。如果你尝试执行这个表达式,应用程序将停止运行,你会看到类似以下的致命错误:

PHP Fatal error:  Uncaught ArgumentCountError: Too few arguments to function
which_is_smaller(), 1 passed in /Users/matt/main.php on line 9 and exactly 2
expected in /Users/matt/my_functions.php:2

如果你传递了错误数据类型的实参(即不能转换为函数声明中指定的参数数据类型的值),你也会遇到致命错误。考虑下面这个表达式,我们将非数字的字符串传递给 which_is_smaller() 函数:

$result = which_is_smaller('mouse', 'lion');

尝试执行该语句会产生类似于以下的错误消息:

PHP Fatal error:  Uncaught TypeError: which_is_smaller(): Argument #1 ($n1)
must be of type int, string given, called in /Users/matt/main.php on line 10
and defined in /Users/matt/my_functions.php:2

发生了一个致命的类型错误(TypeError),因为我们的函数需要两个整数参数,但我们提供了字符串类型的参数。

类型转换

为了避免我们刚刚看到的那种类型错误(TypeError),当传入错误类型的参数时,PHP 引擎会尝试将这些参数转换成期望的数据类型。(有关类型转换的回顾,请参阅第二章。)清单 5-6 展示了在我们向which_is_smaller()函数传入非整数类型参数时的一些示例。更新你的main.php文件,使其与清单一致。

<?php
require_once __DIR__ . '/my_functions.php';

$result1 = which_is_smaller(3.5, 2);
print "the smaller of 3.5 and 2 = $result1\n";

$result2 = which_is_smaller(3, '55');
print "the smaller of 3 and '55' = $result2\n";

$result3 = which_is_smaller(false, -8);
print "the smaller of false and -8 = $result3\n";

清单 5-6:更新 main.php 脚本以演示类型转换

我们调用which_is_smaller()函数三次并打印结果。由于所有参数都可以转换为整数,因此这些函数调用都不会触发错误。首先,我们使用浮动类型 3.5 和整数 2 调用函数。浮动类型将转换为整数 3。接下来,我们使用整数 3 和字符串'55'作为参数。这一次,字符串将转换为整数 55。最后,我们传递布尔值 false 和整数-8 作为参数。false 将转换为整数 0。以下是运行脚本后的输出结果:

PHP Deprecated:  Implicit conversion from float 3.5 to int loses precision in
/Users/matt/my_functions.php on line 2

the smaller of 3.5 and 2 = 2
the smaller of 3 and '55' = 3
the smaller of false and -8 = -8

当你运行脚本时,首先打印出来的应该是一个废弃警告消息,告知你当浮动类型 3.5 转换为整数 3 时,精度会丢失。此消息表示,在未来的某个时刻(可能是 PHP 9),PHP 将停止自动将带有小数部分的浮动类型转换为整数,因此代码有朝一日将停止工作并触发错误。在该消息之后,你应该能看到三次打印语句的结果,表明由于 PHP 的自动类型转换,这三次函数调用都正常执行了。

注意

当你遇到废弃警告消息时,阅读有关即将更改的讨论可能会很有帮助。例如,解释清单 5-6 中废弃消息输出的请求评论(RFC)文档可以在线查看,链接为 wiki.php.net/rfc/implicit-float-int-deprecate

尽管这些函数调用在参数类型不正确的情况下仍然成功,但编写良好的程序应尽量避免依赖类型转换。注意类似我们刚刚遇到的废弃警告,并寻找方法修改代码,以便在没有警告或错误的情况下处理不同类型的值。在这种情况下,我们可以重构该函数,使用联合类型(在第 98 页的《联合类型》中讨论),这样就可以同时接受整数和浮动类型作为参数。

没有显式返回值的函数

不是每个函数都必须显式返回一个值。例如,你可以编写一个函数,仅仅打印一条消息而不返回任何内容给调用脚本。当一个函数没有显式的返回值时,应将其返回类型声明为 void。

为了演示,我们将声明一个函数,该函数打印出给定数量的星号,并在两侧用另一个填充字符进行填充,以实现固定的行长度。我们可以使用该函数创建 ASCII 艺术图像,即通过排列字符文本来形成的图像。启动一个新项目,并创建包含 列表 5-7 中代码的 my_functions.php 文件。

<?php
function print_stars(int $numStars, string $spacer): void
{
    $lineLength = 20;
    $starsString = str_repeat('*', $numStars);
    $centeredStars = str_pad($starsString, $lineLength, $spacer, STR_PAD_BOTH);
    print $centeredStars . "\n";
}

列表 5-7:在 my_functions.php 中声明 print_stars() 函数

在这里,我们声明了一个名为 print_stars() 的函数。该函数需要两个参数:$numStars 和 $spacer。整数 $numStars 是要打印的星号(*字符)数量。字符串 $spacer 是在星号两边作为填充的字符。在括号后面,我们使用 : void 来指示该函数不会显式返回任何值。

在函数体内,我们将要打印的行长度设置为 20 个字符。(由于这个值是硬编码在函数中的,所以每次调用该函数时,它的值都将相同;一个更灵活的替代方法是将 $lineLength 设置为一个参数。)然后,我们生成一个包含由 \(numStars 参数指定数量的星号的字符串(\)starsString)。接着,我们使用内置的 str_pad() 函数(在第三章中讨论)来创建一个 20 个字符长的字符串,$starsString 在其中居中,并且两侧对称地用 $spacer 参数中的字符进行填充。例如,如果 \(numStars 为 10,\)spacer 为 '.',则会生成字符串 '.....**********.....',即 10 个星号,两侧各有 5 个句点,总长度为 20。最后,我们打印出结果,并输出一个换行符。

请注意,我们没有在函数体内包含 return 语句。因为没有必要,函数的作用仅仅是构造并打印一个字符串。如果我们尝试从该函数返回一个值,会触发一个致命错误,因为我们将该函数声明为 void。

现在让我们使用我们的函数生成一个树的 ASCII 艺术图像。创建 main.php 文件,包含 列表 5-8 中的代码。

<?php
require_once __DIR__ . '/my_functions.php';

❶ $spacer = '/';
print_stars(1, $spacer);
print_stars(5, $spacer);
print_stars(9, $spacer);
print_stars(13, $spacer);
print_stars(1, $spacer);
print_stars(1, $spacer);

列表 5-8:在 main.php 中使用 print_stars() 函数生成树形图案的脚本

在通过 require_once 引入函数声明后,我们将填充字符设置为正斜杠 (/) ❶。然后我们调用 print_stars() 函数六次,打印出由 1、5、9 和 13 个星号组成的树形图案,并且再加上两行只有 1 个星号的树干。以下是在终端运行 main.php 脚本的输出:

/////////*//////////
///////*****////////
/////*********//////
///*************////
/////////*//////////
/////////*//////////

我们在暴风雨中创建了一棵树!

返回 NULL

即使函数声明为 void,它在技术上仍然有一个返回值:NULL。如果一个函数执行完毕没有返回值,函数会默认返回 NULL。为了证明这一点,我们可以再次调用 print_stars() 函数,并像处理有返回值的函数一样,将结果赋给一个变量。更新你的 main.php 文件以匹配 Listing 5-9。更改部分用黑色文本显示。

<?php
require_once __DIR__ . '/my_functions.php';

$spacer = '/';
print_stars(1, $spacer);
print_stars(5, $spacer);
print_stars(9, $spacer);
print_stars(13, $spacer);
print_stars(1, $spacer);
$result = print_stars(1, $spacer);

var_dump($result);

Listing 5-9: 更新 main.php 来存储并打印 print_tree() 函数的 NULL 返回值

我们像之前一样调用 print_stars() 函数,但这次我们将最后一次函数调用的返回值存储在 $result 变量中。然后使用 var_dump() 查看 $result 的内容。由于 print_stars() 没有显式的返回值,因此 $result 应该包含 NULL。以下是运行 main.php 脚本的输出:

/////////*//////////
///////*****////////
/////*********//////
///*************////
/////////*//////////
/////////*//////////
NULL

我们可以再次看到 ASCII 树,随后是从调用 var_dump() 得到的 NULL。这证明了尽管函数声明为 void,它仍然默认返回 NULL。

提前退出函数

声明为 void 的函数仍然可以使用 return 语句,只要该语句不包含值。如前所述,函数在遇到 return 语句时会立即停止执行,因此不带值的 return 提供了一种提前退出函数的机制。这在例如函数的某个参数出现问题时非常有用。你可以在函数开始时添加验证逻辑来检查参数,并使用 return 来提前停止函数的执行,如果一个或多个参数值不符合预期,则会恢复主调用脚本的执行。

我们一直在使用的 str_pad() 函数,如果填充字符串为空,会触发致命错误。为了避免程序崩溃,我们将更新 print_stars() 函数,首先检查 $spacer 字符串参数是否为空。如果为空,我们将使用 return 提前退出函数。修改 my_functions.php 以匹配 Listing 5-10。

<?php
function print_stars(int $numStars, string $spacer): void
{
if (empty($spacer)) {
    return;
    }
 $lineLength = 20;
 $starsString = str_repeat('*', $numStars);

 $centeredStars = str_pad($starsString, $lineLength, $spacer, STR_PAD_BOTH);
 print $centeredStars . "\n";
}

Listing 5-10: 向 print_stars() 函数中添加 return 语句以提前退出

我们在函数体开始时添加了一个 if 语句,使用内建的 empty() 函数来检查 $spacer 是否为空字符串。如果为空,我们使用不带值的 return 来提前结束函数执行,并将程序控制返回给调用脚本。如果函数执行通过了这个 if 语句,则表示 $spacer 不为空,这样我们的 str_pad() 调用应该能正常工作。

为了查看 return 语句是否有效,更新 main.php 脚本,如 Listing 5-11 所示。

<?php
require_once __DIR__ . '/my_functions.php';

$spacer = '';
print_stars(1, $spacer);
print_stars(5, $spacer);
print_stars(9, $spacer);
print_stars(13, $spacer);
print_stars(1, $spacer);
$result = print_stars(1, $spacer);

var_dump($result);

Listing 5-11: 更新 main.php 来调用 print_tree() 并传入一个空的填充字符串

我们将\(spacer 设置为空字符串,而不是斜杠,之后再调用 print_stars()。运行主脚本的输出现在应该只是 NULL。每次调用 print_stars()函数时,它都会提前返回,因为\)spacer 是空字符串,因此我们不再看到 ASCII 树形图。另一方面,我们也没有看到致命错误,因为返回语句阻止我们使用无效的参数调用 str_pad()。我们依然在输出中看到 NULL,这是 var_dump()调用的结果。这表明,当函数遇到没有返回值的返回语句时,它会返回 NULL,就像没有返回语句一样。

在函数内部调用函数

在一个函数的主体内调用另一个函数是完全合理的。事实上,我们已经多次这样做了,在 print_stars()函数内部调用了内置的 PHP 函数,如 str_repeat()和 str_pad()。同样,在其他自定义函数中调用你自己的自定义函数也是可能的,实际上这是非常常见的做法。

编程的强大之处在于将问题分解为更小的任务。你编写基本的函数来处理这些小任务,然后再编写更高层次的函数,将这些任务组合在一起解决更大的问题。最终,你的主应用脚本看起来非常简单:你只需要调用一到两个函数。诀窍在于,这些函数本身会调用多个其他函数,以此类推。

我们调用 print_stars()函数六次来生成一个 ASCII 树。让我们将这六次调用移到另一个函数 print_tree()中。这样,每次我们想打印一棵树时,主脚本中只需要一次函数调用。将新的 print_tree()函数添加到my_functions.php中,如 Listing 5-12 所示。

<?php
function print_stars(int $numStars, string $spacer): void
{
--snip--
}

function print_tree(string $spacer): void
{
    print_stars(1, $spacer);
    print_stars(5, $spacer);
    print_stars(9, $spacer);
    print_stars(13, $spacer);
    print_stars(1, $spacer);
    print_stars(1, $spacer);
}

Listing 5-12:将 print_tree()函数添加到 my_functions.php

我们在之前声明的 print_stars()函数后声明 print_tree()函数。它需要一个名为\(spacer 的字符串参数。在函数体内,我们编写了六个原始的 print_stars()调用。请注意,print_tree()函数的参数\)spacer 在调用 print_stars()时也充当了一个参数。这样,我们只需在调用 print_tree()时传入不同的字符串,就可以轻松地打印带有不同填充字符的星号树形图。

有了这个新函数,我们现在可以大大简化我们的主脚本。按照 Listing 5-13 所示更新main.php

<?php
require_once __DIR__ . '/my_functions.php';
print_tree('/');
print_tree(' ');

Listing 5-13:通过 print_tree()函数简化 main.php 脚本

在读取函数声明文件后,我们调用 print_tree()两次生成两棵树。第一次我们像之前一样使用正斜杠作为间隔符,第二次我们使用空格字符。以下是结果:

/////////*//////////
///////*****////////
/////*********//////
///*************////
/////////*//////////
/////////*//////////
         *
       *****
     *********
   *************
         *
         *

我们的主脚本通过调用两次print_tree(),完成了原本需要调用 12 次print_stars()的任务。当然,那些print_stars()的调用仍然存在,但我们将它们隐藏在print_tree()的定义中,使我们的主脚本变得更加简洁。你可以开始看到函数在组织代码和促进可重用性方面的强大作用。

返回多个值和参数类型的函数

对于简单的情况,你通常可以编写一个执行某项任务并返回单一类型值或不返回任何值的函数。然而,其他时候,你可能希望通过允许函数根据情况返回不同数据类型的值来提高其可重用性。同样,你可能希望函数的参数能够接受不同数据类型的值,以确保代码能够应对输入验证问题。可空类型联合类型提供了优雅的方法来允许多种类型,既适用于函数的返回值,也适用于函数的参数。

可空类型

编写通常返回一种类型值(如字符串或数字),但有时返回NULL的函数是非常常见的。例如,一个通常执行计算的函数,如果接收到无效输入,可能会返回NULL;或者一个从数据库中检索信息的函数,如果无法建立数据库连接,也可能返回NULL(我们将在第六部分讨论数据库时看到这一点)。为了实现这一点,可以通过在返回类型前立即添加问号(?)来声明函数的返回类型为可空。例如,在函数声明的第一行末尾添加: ?int意味着该函数将返回NULL或整数。

让我们通过一个尝试返回拼写出来的数字的整数值的函数来看看它是如何工作的(例如返回 1 而不是'one')。如果函数无法识别输入的字符串,它将返回NULL。开始一个新项目,创建包含列表 5-14 内容的my_functions.php文件。

<?php
function string_to_int(string $numberString): ❶ ?int
{
    return match ($numberString) {
        'one' => 1,
        'two' => 2,
        'three' => 3,
        'four' => 4,
        'five' => 5,
      ❷ default => NULL
    };
}

列表 5-14:一个返回整数或NULL的函数

我们声明了string_to_int()函数,使用可空类型?int来表示该函数将返回NULL或整数 ❶。该函数接收字符串参数$numberString。它的主体是一个单一的返回语句,通过使用match表达式选择要返回的值。这是可能的,因为match表达式的值总是一个单一的结果。该表达式有五个子句,将字符串'one'到'five'分别匹配到相应的整数。第六个子句设置了默认情况 ❷,如果提供任何其他字符串,则返回NULL。通过这种方式,match表达式返回一个整数或NULL,就如同函数的可空返回类型所指示的那样。

现在我们将编写一个 main.php 文件,其中包含一个调用我们函数的脚本。当你调用具有可空返回类型的函数时,测试返回值是否为 NULL 是很重要的。清单 5-15 显示了如何操作。

<?php
require_once __DIR__ . '/my_functions.php';

❶ $text1 = 'three';
$number1 = string_to_int($text1);
❷ if (is_null($number1)) {
    print "sorry, could not convert '$text1' to an integer\n";
} else {
    print "'$text1' as an integer = $number1\n";
}

$text2 = 'onee';
$number2 = string_to_int($text2);
if (is_null($number2)) {
    print "sorry, could not convert '$text2' to an integer\n";
} else {
    print "'$text2' as an integer = $number2\n";
}

清单 5-15:一个调用可空类型 string_to_int() 函数的 main.php 脚本

我们将字符串 'three' 赋值给变量 $text1,然后将该变量传递给 string_to_int() 函数,将返回值存储在 $number1 ❶ 中。接下来,我们使用 if...else 语句测试 $number1 中的值是否为空(NULL) ❷。如果是,我们打印一条消息,说明该字符串无法转换为整数。否则,我们打印一条显示字符串及其对应整数的消息。然后,我们重复这个过程,使用字符串 'onee'。以下是输出:

'three' as an integer = 3
sorry, could not convert 'onee' to an integer

我们可以看到,当参数是字符串 'three' 时,函数返回整数 3,但当参数是拼写错误的字符串 'onee' 时,它返回 NULL。通过将 string_to_int() 函数声明为可空返回类型,我们可以灵活地以有意义的方式应对这种问题输入。

就像函数可以有可空返回类型一样,你也可以使用相同的问号语法来声明函数参数为可空类型,这意味着参数可以是 NULL 或其他类型。例如,参数列表 (?string $name) 意味着该函数接受一个 $name 参数,该参数可以是 NULL 或字符串。

我们不需要像在 清单 5-15 中那样,每次调用 string_to_int() 函数时都重复编写 if...else 语句。我们可以将函数的 NULL 或整数返回值作为参数传递给另一个函数,以生成适当的消息。因此,该函数需要能够接受一个可能为 NULL 或整数的参数。清单 5-16 显示了这样一个名为 int_to_message() 的函数。将该函数添加到你的 my_functions.php 文件的末尾。

function int_to_message(?int $number): string
{
    if (is_null($number)) {
        return "sorry, could not convert string to an integer\n";
    } else {
        return "an integer = $number\n";
    }
}

清单 5-16:一个具有可空类型的 $number 参数的函数

该函数的签名包含一个名为 $number 的单一参数,其类型为可空的 ?int。这意味着传递给该函数的参数可以是 NULL 或整数。函数体使用了我们在 main.php 脚本中写的 if...else 语句,根据传递的数据类型返回相应的消息。

现在,通过移除重复的 if...else 语句并改为调用我们的新函数,我们可以大大简化主脚本。清单 5-17 显示了更新后的脚本。

<?php
require_once __DIR__ . '/my_functions.php';

❶ $text1 = 'three';
❷ $number1 = string_to_int($text1);
❸ print int_to_message($number1);

$text2 = 'onee';
$number2 = string_to_int($text2);
print int_to_message($number2);

❹ print int_to_message(string_to_int('four'));

清单 5-17:使用 int_to_message() 函数简化 main.php

请注意,由于生成消息的逻辑已经移到函数中,我们的主脚本现在变得更简洁了。对于每个输入,我们遵循三个基本语句的模式:声明一个字符串❶,存储调用string_to_int()函数时返回的整数(或 NULL)❷,并打印通过将该整数或 NULL 值传递给int_to_message()函数而返回的字符串❸。

如果我们真的想让代码更加简洁,可以将这三条语句合并成一行❹,在调用int_to_message()函数时,将string_to_int()函数放在括号内。这样,前者的返回值就会直接作为参数传递给后者,而无需使用中介变量。这种做法属于编程风格的选择。就我个人而言,我更倾向于使用中介变量,以防止一行代码过于复杂。

联合类型

如果你希望一个函数能够返回多种数据类型,可以使用联合类型来声明其返回值。这是一个值的可能数据类型的列表,类型之间用竖线分隔。例如,int|float表示一个值可以是整数或浮动值。联合类型既可以应用于函数参数,也可以应用于返回值。

可空类型本质上是联合类型的一种特殊类别,其问号语法提供了当某种可能的数据类型为 NULL 时的简便写法。例如,联合类型string|NULL与更简洁的可空类型?string相同。联合类型在代码中有多个非 NULL 类型时特别有用,比如int|float,或者有多个非 NULL 类型加上 NULL 时,比如string|int|NULL,表示数据类型可能是字符串、整数或 NULL。使用可空类型语法无法表达这种情况,因为你不能像写?string|int那样在联合类型中混合可空类型。你也不能在联合类型中包含void类型。

为了演示联合类型,我们将string_to_int()函数修改为string_to_number()函数,该函数根据传入的字符串返回整数、浮动值或 NULL。我们还将int_to_message()函数更新为number_to_message(),该函数可以接受整数、浮动值或 NULL 作为参数。更新my_functions.php以匹配示例 5-18。

<?php
function string_to_number(string $numberString):❶int|float|NULL
{
 return match ($numberString) {
    ❷'half' => 0.5,
 'one' => 1,
 'two' => 2,
 'three' => 3,
 'four' => 4,
 'five' => 5,
 default => NULL
 };
}

function number_to_message(string $text, ❸ int|float|NULL $number): string
{
❹if (is_int($number)) {
        return "'$text' as an integer = $number\n";
    }

❺if (is_float($number)) {
        return "'$text' as a float = $number\n";
    }

  ❻ return "sorry, could not convert '$text' to a number\n";
}

示例 5-18:使用联合类型作为函数的返回值和参数

首先,我们声明string_to_number(),它是我们string_to_int()函数的修订版。我们使用联合类型int|float|null来表示该函数将返回一个整数、浮动值或 NULL❶。就像以前的string_to_int()一样,这个函数接受一个字符串参数。我们在函数体的match语句中增加了一个新的条件,将字符串'half'匹配为浮动值 0.5❷,因此需要使用联合类型。

接下来,我们声明 number_to_message(),它是 int_to_message() 的修订版本,返回一个字符串。这个函数接受两个参数。第一个参数,字符串 \(text,将与传递给我们的 string_to_number() 函数的字符串相同。第二个参数,\)number,将是该函数的返回值,因此它可能是一个整数、一个浮点数或 NULL。因此,我们对参数 ❸ 使用相同的 int|float|NULL 联合类型。

在函数体中,我们首先测试 $number 是否包含一个整数值 ❹,如果是,我们返回一条消息,说明 $text 是一个整数。接下来,我们测试 $number 是否包含一个浮点数值 ❺,如果是,则返回一条适当的消息。最后,我们返回一条消息,说明 $text 无法转换为数字 ❻。如果之前的任何一个 return 语句被执行,执行就不会到达这一步,所以我们知道此时 $number 既不是整数也不是浮点数。因此,我们不需要将这个最终的 return 语句放在一个 else 子句或另一个 if 语句中,尽管我们可以这样做。

这种选择是个人的编程风格问题。我喜欢像这样用一个无条件的 return 语句来结束函数,这样我可以清楚地看到要返回的默认值。然而,一些程序员更喜欢用一个 else 子句来结束最后一个 if 语句,以此来传达默认值。无论哪种方式,执行结果都是一样的。

现在让我们测试一下我们的函数。更新你的 main.php 脚本以匹配 清单 5-19。

<?php
require_once __DIR__ . '/my_functions.php';

$text1 = 'three';
$number1 = string_to_number($text1);
print number_to_message($text1, $number1);

清单 5-19:在 main.php 中使用联合类型参数和返回值调用函数

我们调用 string_to_number() 函数,传入字符串 'three',并将结果存储在 $number1 变量中。然后我们将 $number1 传递给我们的 number_to_message() 函数,并打印它返回的消息。这段代码应该输出消息 'three' as an integer = 3。

可选参数

如果一个参数的值在每次调用函数时通常都是相同的,你可以在声明函数时为该参数设置一个默认值。实际上,这使得该参数成为可选的。只有当你知道你希望该值与默认值不同时,你才需要包含一个与该参数对应的参数。

PHP 的许多内置函数都有带有默认值的可选参数。例如,PHP 的 number_format() 函数,它接受一个浮点数并将其转换为字符串,有几个可选参数控制字符串的格式。在命令行输入 php -a 以在交互模式下尝试以下代码:

❶ php > **print number_format(1.2345);**
1
❷ php > **print number_format(1.2345, 2);**
1.23
❸ php > **print number_format(1.2345, 1, ',');**
1,2

number_format()函数的第一个参数是必需的,它是我们想要格式化的浮动数值。默认情况下,仅传递一个参数❶时,函数会返回去掉小数部分的数字字符串。当我们添加一个整数作为可选的第二个参数❷时,函数使用该整数来设置保留的小数位数。我们使用了 2 这个值来保留两位小数。默认情况下,小数分隔符使用句点,但如果我们添加一个字符串作为可选的第三个参数❸,则函数会使用该字符串作为小数分隔符。在这种情况下,我们使用逗号,这是欧洲大陆常见的小数分隔符。

Listing 5-20 显示了 number_format()函数的签名,取自 PHP 在线文档,用于说明如何声明参数的默认值。

number_format(
    float $num,
    int $decimals = 0,
    ?string $decimal_separator = ".",
    ?string $thousands_separator = ","
): string

Listing 5-20: 内置的 number_format()函数,包括具有默认值的可选参数

首先,请注意,当你有一个长参数列表时,可以将它们分散在多行中,以使代码更具可读性。该函数最多接受四个参数,但第二、第三和第四个参数都使用赋值运算符(=)在参数名后面赋予了默认值。例如,第二个参数\(decimals 的默认值为 0,因此当我们调用 number_format(1.2345)而没有提供第二个参数时,函数将使用\)decimals 的默认值,并将数字格式化为没有小数位。同样,\(decimal_separator 参数的默认值为句点,而\)thousands_separator 参数的默认值为逗号。

参数声明的顺序很重要。所有必填参数(没有默认值的参数)必须先列出,后跟可选参数。这是因为调用函数时,参数的顺序必须与声明的顺序匹配。如果可选参数在必填参数之前,而你又省略了可选参数,那么就无法知道你的第一个参数是对应于第二个参数的。此规则的唯一例外是当你使用命名参数时,正如我们稍后在本章讨论的那样。

现在我们已经了解了可选参数的工作方式,让我们为一个自定义函数添加一个可选参数。我们将重新回顾本章前面提到的 which_is_smaller()函数,并添加一个可选参数,用于控制当传入的比较值相等时函数的行为。返回到该项目的my_functions.php文件,并更新脚本以匹配 Listing 5-21。

<?php
function which_is_smaller(int $n1, int $n2, ❶ bool $nullIfSame = false): ?int
{
 if ($n1 < $n2) {
 return $n1;
 }

    if ($n2 < $n1) {
        return $n2;
    }

 ❷ if ($nullIfSame) {
        return NULL;
    }

  ❸ return $n1;
}

Listing 5-21: 更新 which_is_smaller()函数以包含一个可选参数

我们向函数中添加了第三个参数,布尔值\(nullIfSame,并给它设置了默认值 false ❶。由于这个默认值,当\)n1 和\(n2 被发现相等时,函数通常会返回\)n1 ❸。然而,如果用户在调用函数时传递 true 作为第三个参数来覆盖这个默认值,则会返回 NULL ❷。为了考虑这种情况,我们使用可空类型?int 来设置函数的返回类型。

这里 if 和 return 语句的顺序非常重要。只有当\(n1 和\)n2 相等时,代码才会进入 if (\(nullIfSame) ❷。由于\)nullIfSame 默认是 false,因此这个条件通常会失败,所以最终会执行 return \(n1; ❸。只有当用户将\)nullIfSame 设置为 true 时,函数才会返回 NULL。

更新项目的main.php文件,如清单 5-22 所示,以测试该功能。

<?php
require_once __DIR__ . '/my_functions.php';

$result1 = which_is_smaller(1, 1);
var_dump($result1);
$result2 = which_is_smaller(1, 1, true);
var_dump($result2);

清单 5-22:在 main.php 中调用 which_is_smaller(),带有和不带有可选参数

我们调用 which_is_smaller()两次,使用 var_dump()显示结果。第一次我们传入 1 和 1,并省略可选参数,因此$nullIfSame 将默认是 false。第二次,我们添加了 true 作为第三个参数,覆盖了默认值。以下是运行主脚本时的输出:

int(1)
NULL

第一行表示函数遵循了默认行为,当我们省略可选参数时,返回 1(第一个参数的值)。然而,当我们使用第三个参数将$nullIfSame 设置为 true 时,函数返回 NULL。

位置参数与命名参数

当你调用一个函数时,PHP 引擎默认会按位置解释参数,依据它们的顺序将其与函数的参数匹配。然而,你也可以通过使用命名参数来调用函数:你显式地将参数的值与相应参数的名称配对。在这种情况下,参数的顺序就不再重要。命名参数在函数有可选参数时特别有用。

要使用命名参数而非位置参数,你不需要以任何方式修改函数声明,尽管此时函数参数的名称变得更加重要。你需要做的只是,在调用函数时,在括号内包含参数名称(去掉美元符号),然后加上冒号(:)和所需的参数值。例如,要在调用 which_is_smaller()函数时使用命名参数将 true 作为$nullIfSame 参数的值传入,你应在参数列表中加入 nullIfSame: true。约定是在冒号后加一个空格。

清单 5-23 显示了更新后的main.php文件,增加了一个使用命名参数调用 which_is_smaller()的额外实例。

<?php
require_once __DIR__ . '/my_functions.php';

$result1 = which_is_smaller(1, 1);
var_dump($result1);
$result2 = which_is_smaller(1, 1, true);
var_dump($result2);
❶ $result3 = which_is_smaller(nullIfSame: true, n1: 1, n2: 1);
var_dump($result3);

清单 5-23:使用位置参数和命名参数调用 which_is_smaller()

新的 which_is_smaller() ❶调用在功能上与之前的调用等效,但我们使用了命名参数。因此,我们能够按不同于参数声明顺序的顺序列出参数:首先是\(nullIfSame,然后是\)n1,最后是$n2。以下是结果:

int(1)
NULL
NULL

输出的最后两行都是 NULL,表示最后两个函数调用通过位置参数和命名参数达成了相同的结果。

在这个例子中,每个函数调用要么完全使用位置参数,要么完全使用命名参数,但你也可以在同一次函数调用中混合使用两种参数方式。在这种情况下,位置参数必须排在前面,顺序与函数声明中的顺序一致,然后是你选择的顺序排列的命名参数。考虑以下例子:

$result = which_is_smaller(5, nullIfSame: true, n2: 5);

这里,第一个参数 5 没有名称。因此,PHP 会按照位置来处理它,并将其匹配到声明的第一个参数$n1。剩下的参数是有名称的,因此可以按任意顺序出现。相比之下,这是另一个调用该函数的例子:

$result = which_is_smaller(nullIfSame: true, 5, n2: 5);

这次我们首先用了命名参数\(nullIfSame。然后我们使用了一个没有名称的参数 5,可能是用来传递\)n1 参数的。然而,由于我们一开始就用了命名参数,PHP 引擎无法识别这一点,因此这个函数调用会触发一个错误。

跳过的参数

当一个函数有多个可选参数时,你可以使用命名参数仅设置你想要的可选参数,同时跳过其余的参数。这之所以有效,是因为命名参数让你不必遵循参数的顺序。任何跳过的参数将使用默认值。为了说明这一点,我们来创建一个打印自定义问候语的函数。创建一个新项目,并创建my_functions.php,使其符合列表 5-24。

<?php
function greet(
    string $name,
    string $greeting = 'Good morning',
    bool $hasPhD = false
): void
{
    if ($hasPhD) {
      ❶ print "$greeting, Dr. $name\n";
    } else {
        print "$greeting, $name\n";
    }
}

列表 5-24:一个带有两个可选参数的 greet()函数

我们将 greet()函数声明为 void,因为它输出一条消息,但不返回任何值。该函数有一个必需的字符串参数\(name,以及两个带默认值的可选参数\)greeting 和\(hasPhD。函数体是一个 if 语句,它输出\)greeting 和\(name 的值,如果\)hasPhD 参数为真,则在两者之间插入标题 Dr. ❶。

现在我们来看看几种调用 greet()函数的方法。创建一个包含列表 5-25 中代码的main.php

<?php
require_once __DIR__ . '/my_functions.php';

greet('Matt');
greet('Matt', hasPhD: true);

列表 5-25:一个主脚本调用 greet(),并跳过参数

第一次调用 greet()时,我们只传递字符串'Matt'作为参数。我们没有使用命名参数,因此这个参数会按位置匹配到$name 参数。其他参数会使用默认值,结果输出的消息是“Good morning, Matt”。

第二次调用 greet()时,我们使用位置参数'Matt'和命名参数 hasPhD: true。请注意,$hasPhD是函数声明中的第三个参数;我们跳过了第二个参数!这是完全没问题的。我们跳过的参数$message有一个默认值,感谢我们使用命名参数,PHP 引擎会清楚地知道哪些提供的参数与哪些函数参数匹配。最终我们应该得到消息:Good morning, Dr. Matt。

这是运行main.php脚本的输出:

Good morning, Matt
Good morning, Dr. Matt

输出正如我们所预期的那样。由于默认参数值和命名参数的结合,我们能够顺利跳过$message参数。

值传递与引用传递

默认情况下,PHP 函数使用值传递的方法将参数与参数匹配:参数的值会被复制并赋值(传递)给适当的参数,这些参数在函数的作用域内作为临时变量存在。通过这种方式,如果在函数执行过程中修改了任何参数的值,这些更改将不会对函数外部的任何值产生影响。毕竟,函数是处理原始值的副本。

另一种方法是引用传递:函数参数传递的是指向原始变量本身的引用,而不是副本。通过这种方式,如果一个变量作为参数传递给函数,函数可以永久改变该变量的值。为了指示引用传递参数,在声明函数时,请在参数名之前立即放置一个“&”符号。

我通常不推荐使用引用传递参数;事实上,在过去 20 年里,我无法想到任何我写过的引用传递参数。允许函数修改传递给它们的变量会使程序变得更加复杂,从而更难理解、测试和调试。尽管如此,熟悉这一概念仍然很重要,因为你可能会在别人编写的代码中遇到引用传递参数,包括在你可能希望在自己项目中使用的第三方库中。不了解如何使用引用传递参数就调用函数,可能会导致意外的结果。

注意

在某些编程语言中,程序员使用多个引用传递参数,作为函数“返回”多个值的一种方式,而无需使用return语句。然而,在现代 PHP 中有更好的方法,例如返回一个数组(参见第七章)或一个对象(参见第 V 部分)。

为了说明值传递和引用传递参数之间的区别,并展示为什么后者通常最好避免,我们将创建一个计算某人未来年龄的函数的两个版本。开始一个新项目并创建包含清单 5-26 内容的my_functions.php

<?php
function future_age (int $age): void
{
    $age = $age + 1;
    print "You will be $age years old on your next birthday.\n";
}

清单 5-26:按值传递的 future_age()版本

在这里,我们声明了一个名为 future_age()的函数。它具有一个整数参数\(age,按通常方式声明,因此这是一个正常的值传递参数。由于不需要返回任何值,函数被声明为 void。在函数体内,我们将\)age 加 1,并打印出包含结果的消息。

现在在main.php中创建一个主脚本,包含清单 5-27 中显示的代码。

<?php
require_once __DIR__ . '/my_functions.php';

$currentAge = 20;
print "You are $currentAge years old.\n";
future_age($currentAge);
print "You are $currentAge years old.\n";

清单 5-27:测试按值传递的 future_age()版本

我们为\(currentAge 变量赋值为整数 20。然后我们打印出显示该变量值的消息。接着,我们调用我们的 future_age()函数,并将\)currentAge 作为参数传递。然后我们再打印出另一条消息,显示变量的值。这让我们可以查看函数调用前后$currentAge 的值。以下是结果:

You are 20 years old.
You will be 21 years old on your next birthday.
You are 20 years old.

输出的第一行和最后一行是相同的,表明调用 future_age()对\(currentAge 变量的值没有影响。实际上,当函数被调用时,会在函数的作用域内创建一个局部变量\)age,并将\(currentAge 的值复制到其中。这样,当函数将\)age 加 1 时,它是在不改变$currentAge 值的情况下进行的。这就是值传递参数的工作方式:它们不会对函数外部的作用域产生任何影响。

现在让我们修改我们的 future_age()函数,改为使用按引用传递的参数,看看这会有什么不同。更新你的my_functions.php文件,如清单 5-28 所示。

<?php
function future_age (int &$age): void
{
 $age = $age + 1;
 print "You will be $age years old on your next birthday.\n";
}

清单 5-28:按引用传递的 future_age()版本

这里唯一的变化是,在参数名称前加上了一个&符号,表示\(age 是按引用传递的参数。因此,\)age 将不再是一个局部变量,包含调用函数时传入参数的值的副本。相反,\(age 将成为该变量的引用,因此对\)age 所做的任何更改也将反映到该变量上。为了验证这一点,再次运行你的main.php脚本。这次你应该会看到以下输出:

You are 20 years old.
You will be 21 years old on your next birthday.
You are 21 years old.

请注意,在函数内部对\(age 参数加 1 也会使得函数外部的\)currentAge 变量加 1。除非用户的生日发生在函数调用和最终打印语句之间的那一瞬间,否则这可能不是我们想要的。这说明了使用按引用传递参数的危险:它们可能会改变通常在函数作用域之外的变量值。

总结

在本章中,我们探讨了如何通过声明和调用函数来促进代码的可重用性,函数是完成特定任务的一系列命名代码。你练习了在独立的.php文件中声明函数,然后通过 require_once 将其加载到主应用程序文件中,从而编写简洁、结构良好的脚本。你看到返回语句如何允许函数将值返回给调用脚本,同时也提供了一种提前终止函数的机制,并且你探索了如何通过可空和联合类型使函数能够灵活地接受或输出各种数据类型的值。

你了解了参数(函数内使用的变量)和实参(调用函数时传递给这些变量的值)之间的区别。你看到如何通过为参数设置默认值使其变为可选参数,以及如何使用命名参数按照任意顺序传入值,甚至可以跳过某些参数。最后,你了解了值传递和引用传递参数之间的区别,在你希望函数能够更新其作用域外的变量时,这是一个罕见的情况。

练习

1.   创建一个项目,其中包含独立的main.phpfile2.php脚本。file2.php脚本应该打印出字符串 '456'。在你的main.php脚本中,首先打印出 '123',然后读取并执行 file2.php,接着打印出 '789'。最终输出应该是 123456789,但中间的 456 是从 file2.php 打印出来的。

2.   编写一个项目,声明一个 which_is_larger() 函数,该函数返回两个整数中的较大者。你的main.php脚本应当读取并执行声明该函数的文件,然后打印出以下参数传入函数的结果:

4 和 5

21 和 19

3 和 3

最后一种情况发生了什么,当参数相同时?

3.   修改你的 which_is_larger() 函数,使其能够接受整数或浮点数,并在两个数字相同的情况下返回整数、浮点数或 NULL。

4.   创建一个 my_functions.php 文件,声明一个无返回值的函数,用于打印出你名字的首字母的 ASCII 艺术风格。此函数应有两个参数,一个(\(character)为字符串,设置用于制作艺术作品的字符,另一个(\)spacer)为字符串,设置用于填充空白的字符。为每个参数指定合适的默认值。例如,由于我名字的第一个字母是 M,我的函数可能是 capital_m(string $character = 'M', string $spacer = ' '),如果没有传入任何参数,它可能会提供如下输出:

MM          MM
MMMM      MMMM
MM MMM  MMM MM
MM   MMMM   MM
MM          MM
MM          MM
MM          MM

接下来,编写一个 main.php 脚本,调用你的函数且不传入任何参数(使用默认值)。然后使用命名参数再次调用该函数两次,一次只传入主字符,另一次只传入填充字符。

第二部分 处理数据

第六章:6 循环

本章介绍了循环,一种控制结构,它允许你反复执行一系列语句。一旦你理解了如何使用循环,你将能够轻松创建脚本,处理大量数据,并用高效编写的代码执行重复性任务。

我们将介绍两种主要的 PHP 循环:while 循环和 for 循环。while 循环用于在满足特定条件之前重复一系列操作,而 for 循环用于重复固定次数的操作。我们还将了解 do...while 循环,它是 while 循环的一种变体。第四种类型的 PHP 循环是 foreach 循环,专门用于遍历数据集合,如数组。我们将在第七章介绍 foreach 循环时讲解数组。

while 循环

PHP 中的一种循环是 while 循环:一系列语句会在某个条件为真时反复执行。图 6-1 展示了这一过程是如何运作的。

图 6-1:while 循环的逻辑流程

当脚本包含 while 循环时,通常会有一些语句在循环开始之前执行。然后,PHP 引擎检查控制循环的条件。如果条件为假,则循环中的语句将永远不会执行,程序会跳过循环后面的语句。如果条件为真,循环中的语句会执行一次,然后再次检查条件。如果条件仍然为真,循环中的语句将再次执行。这个过程会继续进行,PHP 引擎会在每次新一轮重复前检查条件,直到条件为假,循环结束。

编写 while 循环时,从 while 关键字开始,后跟括号中的布尔条件,控制循环的执行。然后,将需要重复执行的语句放入大括号中。如果循环只包含一条语句,大括号是可选的,但大多数程序员仍然会使用大括号,以强调该语句是循环的一部分。

示例 6-1 显示了一个使用 while 循环来测试密码长度的脚本。该循环会不断提示用户输入新密码,直到密码足够长。

<?php
$password = "cat";

while (strlen($password) < 6) {
    $password = readline("enter new password (at least 6 characters): ");
}

print "password now set to '$password'";

示例 6-1:一个要求密码长度为六个字符或更多的 while 循环

在循环之前,我们将 $password 变量设置为初始值 "cat"。然后我们声明 while 循环,使用 strlen($password) < 6 作为条件。在每次循环之前,PHP 会检查 $password 的长度。如果密码长度小于六个字符,条件为真,循环中的语句将执行。如果密码长度为六个字符或更多,条件为假,循环将结束。由于 $password 最初是三个字符,所以我们知道循环至少会执行一次。

在循环内,我们使用内置的 readline() 函数从用户那里获取密码。该函数将传递的字符串作为命令行提示符显示,然后读取用户在命令行输入的内容,直到按下 ENTER 键。我们将结果存储回 $password 变量中,因此在下一次循环重复之前,我们会检查新的值。当密码长度足够并且循环结束时,我们会将新值的 $password 返回给用户确认。

注意

这个程序 不是 一个良好的安全密码协议示例,因为密码会显示在屏幕上,任何人都可以看到。但它是一个演示 while 循环 的便捷示例。

以下是该脚本的一个示例运行。我在输入一个可接受的密码之前,先输入了几个过短的密码:

enter new password (at least 6 characters): **dog**
enter new password (at least 6 characters): **whale**
enter new password (at least 6 characters): **catdog123**
password now set to 'catdog123'

以下是每次 PHP 引擎检查 strlen($password) < 6 条件时的情况,发生在此脚本的运行过程中:

1.   $password 包含 "cat",因此条件为真。循环执行一次。

2.   $password 包含 "dog",所以条件依然为真。循环第二次执行。

3.   $password 包含 "whale",所以条件依然为真。循环第三次执行。

4.   $password 包含 "catdog123",所以条件为假。循环结束,最后的打印语句执行。

总的来说,PHP 引擎检查循环条件四次,并且循环内容重复执行三次。每次 while 循环的条件检查次数总比循环的重复次数多一次,因为条件检查发生在循环内容执行之前。试着将$password 的初始值改为至少六个字符的文本,然后再次运行脚本。你会发现脚本直接跳到最后的打印语句,因为第一次检查循环条件时条件值为假。

do...while 循环

do...while 循环是 while 循环的另一种形式:条件检查发生在每次循环重复之后,而不是之前。这样,循环中的语句保证至少执行一次,且循环的重复次数将与条件检查的次数相匹配。图 6-2 展示了这一过程是如何工作的。

图 6-2:do...while 循环的逻辑流程

使用 do...while 循环时,我们首先执行一次循环中的语句。然后测试循环条件。如果条件为假,程序会跳到循环后的语句,因此循环只执行一次。如果条件为真,循环语句会再次执行,依此类推,直到条件为假。

要编写 do...while 循环,首先使用 do 关键字,然后用大括号括起应重复执行的语句。闭括号之后,写上 while 关键字,然后在括号内写上循环条件。例如,示例 6-2 展示了如何通过使用 do...while 循环来重写 示例 6-1 中的密码检查脚本,而不是使用 while 循环。更改部分用黑色文字显示。

<?php
do {
 $password = readline("enter new password (at least 6 characters): ");
} while (strlen($password) < 6);

print "password now set to '$password'";

示例 6-2:示例 6-1 的修改版本,使用了 do...while 循环

我们通过 do 关键字开始循环。然后,在 readline() 函数的语句之后,在闭括号所在的同一行,我们写上 while 关键字,然后在括号内写上与之前相同的 strlen ($password) < 6 循环条件。请注意,我们需要在条件后加上分号,以结束语句。

这与之前版本脚本的关键区别在于,我们不再需要在进入循环前为 $password 变量设置初始值。相反,在第一次运行循环时,我们从用户那里读取一个初始密码,然后测试其值并决定是否需要重复。一般来说,如果你知道希望循环中的语句至少执行一次,那么 do...while 循环可能比 while 循环更适合。

布尔标志

如果决定是否继续循环的逻辑比单一条件更复杂,通常通过使用一个布尔变量(称为标志)来控制循环会更加清晰。通常,你会将标志设置为 true,然后进入循环,使用该标志作为条件。循环本身会包含逻辑(可能是一个 if 语句系列),在循环准备结束时,将标志的值设置为 false。

例如,假设我们想要反复提示用户输入,直到他们输入 quitq。我们可以通过以下方式使用 while 循环来实现:

while (($userInput != 'q') && ($userInput != 'quit')) {

这个循环的条件几乎难以阅读,如果我们想要监控第三个输入,它会变得更加复杂。通过布尔标志控制循环可以使代码更清晰,正如在示例 6-3 中所示。

<?php
$continueLooping = true;
while ($continueLooping) {
    $userInput = readline("type something (or: quit): ");

    if ($userInput == 'quit') {
        $continueLooping = false;
    }
    if ($userInput == 'q') {
        $continueLooping = false;
    }

    print "you typed '$userInput'\n";
}

print '--- I have left the loop! ---';

示例 6-3:使用布尔标志变量作为循环条件

我们首先创建布尔标志变量 $continueLooping 并将其设置为 true。然后,我们声明一个 while 循环,以该标志作为循环条件。请注意,这比之前的复合条件更清晰。由于条件最初为 true,我们将进入循环并至少执行一次循环语句组。在提示用户输入文本并将其存储在 $userInput 变量中之后,我们使用两个 if 语句来检查该变量是否包含 'quit' 或 'q'。这两个 if 语句代替了原来的复合循环条件;当用户要求退出时,它们将标志设置为 false,从而结束循环。在 if 语句之后,我们打印用户的输入。然后,在循环外部,我们打印一条消息,确认循环已经结束。

这是脚本的一个示例运行:

type something (or: quit): **the**
you typed 'the'
type something (or: quit): **cat sat**
you typed 'cat sat'
type something (or: quit): **on**
you typed 'on '
type something (or: quit): **the mat**
you typed 'the mat'
type something (or: quit): **quit**
you typed 'quit'
--- I have left the loop! ---

如你所见,脚本会打印出用户输入的任何文本。在这个例子中,循环在我输入quit后结束,但如果你输入字母q,它也会结束。

break语句

break关键字会立即退出循环,不允许任何剩余的语句在循环中执行。在前一个脚本的输出中,你可能注意到当我输入quit时,这个词会在循环的最后一个打印语句(你输入了'quit')中重复显示,然后才会终止循环。使用break语句后,当用户输入quitq时,我们可以立即停止循环,而不会执行打印语句。

使用break还消除了布尔标志的需求。相反,我们可以通过写while (true)来直接使用布尔值true作为循环条件。由于true始终为真,这个循环理论上会永远重复,或者至少直到某个条件逻辑触发break语句为止。列表 6-4 展示了如何更新我们的用户输入脚本,使用while (true)break

<?php
❶ while (true) {
 $userInput = readline("type something (or: quit): ");

 if ($userInput == 'quit'){
        break;
 }

 if ($userInput == 'q'){
        break;
 }

  ❷ print "you typed '$userInput'\n";
}

❸ print '--- I have left the loop! ---';

列表 6-4:列表 6-3 的更新版本,使用break语句退出while (true)循环

我们删除了\(continueLooping 布尔标志,并用字面量`true`替换了`while`循环的条件❶。在循环内部,我们仍然有两个`if`语句来测试`\)userInput是否包含字符串'quit'或'q',但这次每个if语句中都简单地包含了break关键字,以立即退出循环。这样,如果任何一个if`语句通过,我们将直接跳到最终的打印语句❸,而不会执行循环内部的打印语句❷。

这是更新后的脚本版本的一个示例运行:

type something (or: quit): **hello**
you typed 'hello'
type something (or: quit): **world**
you typed 'world'
type something (or: quit): **quit**
--- I have left the loop! ---

这次,当我输入quit时,循环不再打印“你输入了'quit'”这样的消息。相反,循环会立即结束,所以下一条消息是——我已经离开循环!——。

在许多情况下,是否使用break语句或布尔标志来终止循环,取决于个人偏好。如果你来自于不支持break语句的语言,布尔标志可能会感觉更自然。另一方面,如果需要测试多个条件来决定循环是否结束,break语句则更为实用。例如,在编写代码编译器或编程语言工具时,可能需要包含几十个甚至上百个测试。使用break语句可以避免你在执行完所有测试后,需要滚动浏览代码页面以查看后续循环语句中可能发生的情况。

for循环

for 循环是一种重复固定次数的循环。如果你确切知道你想要重复任务的次数(例如,给用户三次机会输入正确的密码,或者从试题库中随机选择 10 道题目测试学生),那么 for 循环可能比 while 循环更合适。for 循环围绕一个 计数器变量,通常称为 $i(即 迭代器 的缩写),它控制循环的重复次数。声明一个 for 循环需要三个表达式,所有表达式都涉及到这个计数器变量:

1.   一个表达式,用于将计数器初始化为起始值

2.   一个表达式,用于测试计数器的值,以决定何时停止循环

3.   一个表达式,用于在每次循环后递增(或递减)计数器

要声明一个 for 循环,这三个表达式会在 for 关键字后面依次写在一行内,并且被一对括号括起来。下面是一个示例:

for ($i = 1; $i <= 5; $i++) {

这里 $i = 1 将计数器变量初始化为 1。然后 $i <= 5 设置循环条件;只要 \(i 小于或等于 5,循环将继续执行。PHP 会在每次新一轮循环前检查这个条件。最后,\)i++ 使用增量运算符(++)告诉 PHP 在每次循环后将 1 加到 \(i 上。这样,\)i 就会在每个循环周期中获取一个新的值,并且这个值可以与循环条件进行比较。在这种情况下,第一次执行时,\(i 的值为 1,第二次执行时为 2,以此类推。当第五次执行时,\)i 的值为 5,\(i <= 5 的条件仍然成立,但在第五次循环结束时,\)i 会被递增到 6。此时,$i 不再小于或等于 5,因此循环在执行完五次后结束。

为了验证 for 循环是否按预期工作,让我们在循环体内填入一个简单的 print 语句,如示例 6-5 所示。这个脚本应该会打印相同的消息五次。

<?php
for ($i = 1; $i <= 5; $i++) {
    print "I am a for loop\n";
}

示例 6-5:一个 for 循环的示例

我们声明一个 for 循环,使用我们刚刚讨论过的循环变量 $i 的相同表达式集。在大括号内,定义了循环语句组,我们写入一个 print 语句。下面是运行该脚本的输出:

I am a for loop
I am a for loop
I am a for loop
I am a for loop
I am a for loop

如你所见,由于 for 循环的计数器变量从 1 开始递增,直到不再小于或等于 5,消息确实打印了五次。

在循环中使用计数器

for 循环的一个优势是计数器变量 $i 可以在循环体内使用。这有助于处理数学任务或使用通过一系列整数索引或标识的有序数据集。例如,我们可能希望处理数据库表中所有 ID 为从 1 开始的整数序列的项(有关数据库的更多信息,请参见 第六部分)。或者我们可能希望顺序遍历一个整数索引数组中的所有元素(有关数组的更多信息,请参见 第七章)。

为了演示,列表 6-6 显示了我们原始 for 循环的更新版本,该版本将 $i 的值包含到打印的消息中。由于 $i 在每次循环重复时都有不同的值,现在每条消息都将是唯一的。

<?php
for ($i = 1; $i <= 5; $i++) {
    print "I am repetition $i of a for loop\n";
}

列表 6-6:在 for 循环中使用计数器变量 $i

我们已经更新了循环的打印语句,以包括 $i 的值。以下是结果:

I am repetition 1 of a for loop
I am repetition 2 of a for loop
I am repetition 3 of a for loop
I am repetition 4 of a for loop
I am repetition 5 of a for loop

请注意,每一行输出中的数字会根据 $i 的值变化。输出有助于说明计数器变量是如何工作的:它从 1 开始,并随着每次循环重复增加 1。一旦 \(i 达到 6,\)i <= 5 条件不再成立,因此循环结束,而没有打印 "I am repetition 6 of a for loop"。

到目前为止,我们一直将 $i 初始化为 1,但你可以将其初始化为任何你想要的值。实际上,你会发现很多 for 循环的例子使用了从 0 开始的计数器变量。这是因为 for 循环通常与数组一起使用,数组是从 0 开始编号的项目集合。我们将在 第七章 中讨论数组,以及如何遍历它们。

当你将计数器变量初始化为 0 时,通常也会使用小于运算符 (<) 设置循环条件,而不是小于或等于运算符 (<=),如下所示:

for ($i = 0; $i < 3; $i++) {

只要 $i 小于 3,循环就会继续执行。由于 \(i 从 0 开始,循环将执行三次,\)i 分别为 0、1 和 2。

跳过循环语句

continue 关键字停止当前循环的重复,但与 break 关键字不同,它不会完全结束循环。相反,循环会立即跳到下一个重复。这在你想跳过某些循环次数时非常有用。例如,也许你正在从数据库中检索条目,并希望忽略某些值,或者你只想使用某些序列中的数字,例如从一个随机排列的数据集中每三个项目的随机样本。

列表 6-7 显示了一个包含 continue 语句的 for 循环示例。在这里,我们使用 continue 跳过计数器变量 $i 的奇数值,因此我们最终只打印偶数值。该列表还展示了如何让 for 循环递减计数器变量,而不是递增它。

<?php
for ($i = 8; $i > 0; $i--) {
  ❶ $odd = ($i % 2);

    if ($odd) {
        continue;
    }
  ❷ print "I am an even number: $i\n";
}

列表 6-7:使用 continue 跳过 for 循环的部分

我们声明了一个 for 循环,其中 $i 计数器变量从 8 开始,并在每次重复后通过 $i-- 表达式递减。循环将倒计时到 1,然后当 $i 等于 0 时停止。在循环内部,我们使用取模运算符 (%) 测试当前 \(i 的值是偶数还是奇数 ❶。如果是偶数,\)i % 2 的结果为 0;如果是奇数,$i % 2 的结果为 1。不论哪种情况,我们将结果存储在 $odd 变量中,然后将该变量作为 if 语句的条件。

由于 if 语句需要一个布尔条件,$odd 将被强制转换为布尔值:当为 1 时为 true,0 时为 false。这样,当 $i 为奇数时,我们会执行 if 语句,该语句只包含 continue 关键字,用于中断当前循环的重复并跳到下一个。当 $i 为偶数时,我们不执行 if 语句的主体,从而通过执行 print 语句 ❷ 完成当前循环的重复。最终效果是我们只打印出 $i 的偶数值,输出如下所示:

I am an even number: 8
I am an even number: 6
I am an even number: 4
I am an even number: 2

由于我们的条件逻辑触发了循环中的 continue 语句,我们成功跳过了奇数。

注意

continue 关键字在 while 循环中的作用与在 for 循环中一样。同样,break 关键字也用于完全停止一个 for 循环。

处理最后一次重复

有时候,你可能希望在循环的最后一次重复时做一些不同的事情。对于 while 或 do...while 循环,直到循环结束时你才知道是否是最后一次重复,但对于 for 循环,你可以通过条件逻辑预见到最后一次重复,并编写代码以不同的方式处理这一重复。

比如说,我们使用一个 for 循环来构建一个字符串,其中包含用户输入的项列表,并且我们希望用逗号分隔每一项。我们可能会想写类似 Listing 6-8 这样的代码。

<?php
$message = "go to the market and buy: ";
$numItems = 3;
for ($i = 1; $i <= $numItems; $i++) {
    $item = readline("type something to buy: ");
    $message .= "$item, ";
}

print $message;

Listing 6-8: 一个使用 for 循环创建的项列表,并用逗号分隔

我们初始化一个 $message 变量,值为 "go to the market and buy: "。然后我们将 \(numItems 的值设为 3。这将是我们 for 循环的重复次数。接着,我们声明 for 循环,在该循环中,\)i 从 1 数到 $numItems(3)。每次循环时,我们提示用户输入一个要购买的商品,并将输入值存储在 $item 变量中。然后我们将 $item 的值附加到 $message 字符串后面,后面跟一个逗号和一个空格。当循环结束时,我们打印出构建好的 $message 字符串。

问题在于我们将循环的每次重复都视为相同,因此最终消息中的每一项后面都会跟一个逗号,包括最后一项。你可以在以下脚本的示例运行中看到这一点:

type something to buy: **bread**
type something to buy: **butter**
type something to buy: **apples**
go to the market and buy: bread, butter, apples,

我们的脚本已经构建了一个包含在命令行输入的三项内容的消息,但不幸的是,最后一项“apples”后面出现了逗号。我们可以通过添加一个测试来确定是否是最后一次循环迭代,然后只有在不是最后一次迭代时才添加逗号。清单 6-9 展示了如何更新脚本。

<?php
$message = "go to the market and buy: ";
$numItems = 3;
for ($i = 1; $i <= $numItems; $i++) {
 $item = readline("type something to buy: ");
    $message .= $item;
    $lastIteration = ($i == $numItems);
    if (!$lastIteration) {
        $message .= ', ';
    }
}

print $message;

清单 6-9:更新清单 6-8 的脚本以去掉最后的逗号

这次,我们首先仅将 $item 的值附加到 $message 字符串中,不加逗号和空格。然后,我们创建了一个布尔变量 $lastIteration,并赋予它表达式 $i == \(numItems 的值。这个表达式只有在循环的最后一次迭代时才为 true。接下来,我们使用 if 语句,并将 !\)lastIteration 作为条件。感谢 NOT 操作符(!),这个条件在所有循环迭代中都为 true,除了最后一次。在 if 语句内部,我们将逗号和空格附加到 $message。这样,除了最后一项,列表中的每一项后面都会有逗号。

下面是更新后的脚本运行示例:

type something to buy: **bread**
type something to buy: **butter**
type something to buy: **apples**
go to the market and buy: bread, butter, apples

我们不再在列表的最后一项“apples”后面加逗号,因为我们将 for 循环的最后一次迭代与其他迭代区分开来处理。

注意

一旦我们开始使用数组,就可以通过使用内置的 implode() 函数来避免这种特殊的最后一次迭代循环逻辑。它智能地在列表中的每一项之间添加分隔符,但不会在最后一项后添加。我们将在第七章中讨论这个问题。

另一种循环语法

PHP 提供了另一种语法来编写 while 和 for 循环,用冒号(:)而不是用大括号将循环内容括起来。就像我们在第四章中讨论的 if 语句的替代语法一样,这种编写循环的方式在将 PHP 语句与模板文本(如用于 web 应用程序的 HTML)结合使用时非常有用。为了演示,清单 6-10 使用替代语法重写了清单 6-1 中的密码设置 while 循环。

<?php
$password = "cat";

while (strlen($password) < 6):
    $password = readline("enter new password (at least 6 characters): ");
endwhile;

print "password now set to '$password'";

清单 6-10:来自清单 6-1 的 while 循环的另一种语法

请注意,声明 while 循环的那一行以冒号结尾,而不是以大括号开头。我们使用 endwhile 关键字来表示循环的结束,而不是闭合大括号。

清单 6-11 同样展示了来自清单 6-6 的 for 循环的另一种语法。

<?php
for ($i = 1; $i <= 5; $i++):
    print "I am repetition $i of a for loop\n";
endfor;

清单 6-11:来自清单 6-6 的 for 循环的另一种语法

再次注意声明循环的那一行以冒号结尾,并且使用 endfor 关键字来表示循环的结束。

注意

PHP 没有 do...while 循环的另一种语法

避免无限循环

很容易不小心陷入一个无限循环,导致循环不断重复,因为停止条件永远无法满足。为了避免这种情况,确保控制循环的条件能够在适当的时候变为假是非常重要的。一种常见的错误写法是在循环条件相对的方向上设置递增表达式。例如,考虑以下代码:

for ($i = 1; $i > 0; $i++) {

这个递增表达式在每次重复后给\(i 加 1。同时,循环条件会测试\)i 是否大于 0。由于\(i 在每次重复时都在增加,它将始终大于 0,因此循环永远不会结束。解决方法是让\)i 递减,而不是递增,或者将循环条件改为某种小于比较。

使用 while 和 do...while 循环时,如果循环条件中的变量在循环语句中没有机会被正确改变,就容易陷入无限重复。例如,假设我们想编写一个脚本,将用户输入的价格累加,直到总和超过 100 美元,然后打印出结果。我们可能会不小心写出像列表 6-12 这样的代码,导致无限循环。

<?php
$total = 0;

do {
    $costString = readline("enter item cost: ");

    if (is_numeric($costString)) {
      ❶ $total = floatval($costString);
    }
} while ($total < 100);

print "grant total = \$$total\n";

列表 6-12:一个无意中的无限 do...while 循环

我们将\(total 设置为 0,然后进入一个 do...while 循环,只要\)total 小于 100 就一直重复。在循环内部,我们从用户那里获取一行输入,并验证其是否为数字。如果是数字,我们将输入转换为浮动类型,并将该值存储在$total 中❶。

你看到问题了吗?我们本应该使用类似$total += floatval($costString);这样的代码来将最新输入的值加到已有的\(total 值中,但我们使用了常规的赋值操作符(=),而不是加法赋值操作符(+=)。因此,\)total 的值将始终是最后输入的值。如果用户输入的值大于 100,循环会结束,打印语句会回显该最后的值。否则,我们就会陷入无限循环,而无法真正计算累计总和。

无限 while 循环还会发生在循环条件中测试的变量永远不会变化的情况下,一旦进入循环,就无法退出。回到列表 6-12,例如,我们可能会使用一个\(grandTotal 变量来设置循环条件,如`while(\)grandTotal < 100)`,但在循环内部递增的是\(total 变量,而不是\)grandTotal。这样,$grandTotal 将永远不变,循环将永远执行下去。

总结

在本章中,我们研究了 while 循环、do...while 循环和 for 循环,这些循环提供了不同的方式来重复执行一系列语句。关键在于如何决定循环的重复次数:可以设置循环停止的条件,如 whiledo...while 循环,或者指定固定的重复次数,如 for 循环。除了这些基本的循环结构外,我们还讨论了 breakcontinue 语句,分别提供了强制结束整个循环或当前循环重复的机制。掌握了像循环和选择语句这样的控制结构后,你将能够编写出执行重复任务并根据当前条件作出决策的复杂程序。

练习

1.   使用 do...while 循环持续接受用户输入的单词,直到输入的单词以大写字母开头。

提示:将输入的字符串与 ucfirst() 函数返回的值进行比较。

2.   使用 while (true) 循环配合 break 语句,持续接受用户输入的字符串,直到输入的字符串是数字。

提示:使用 is_numeric() 函数。

3.   在 for 循环中使用 continue 语句,打印出所有小于等于 21 的 3 的倍数(3、6、9,依此类推)。

第七章:7 简单数组

本章介绍了数组,这是一种复合数据类型,用于存储和操作多个数据项,在一个变量名称下。数组允许你将相关数据分组,并高效地对每个数据项应用相同的操作。

从本质上讲,数组是值与键的映射。每个是你想要存储在数组中的数据,而它的是与该值关联的唯一标识符,以便你可以从数组中访问它。在本章中,我们将重点讲解简单数组,它们使用整数作为键。你将学习如何创建和操作简单数组,并通过 foreach 循环遍历数组中的项目。在下一章中,我们将探讨如何通过使用字符串(以及其他数据类型)作为键来创建更复杂的数组,而不是使用整数。

创建数组并访问其值

让我们从创建一个简单数组开始,这个数组存储了某个地点的每月降水总量(列表 7-1)。

<?php
$rainfall = [10, 8, 12];

列表 7-1:声明一个简单数组

我们通过在方括号内使用逗号分隔的值序列来声明一个名为$rainfall 的数组:[10, 8, 12]。这是一个三元素数组,包含值 10、8 和 12。

默认情况下,PHP 会给每个数组值分配一个整数作为键。键是按顺序分配的,从零开始:第一个值(10)的键是 0,第二个值(8)的键是 1,第三个值(12)的键是 2。接受这个默认映射就是使$rainfall 成为一个简单数组(与我们将在第八章中探讨的具有自定义键值映射的复杂数组相对)。

现在我们有了一个数组,可以使用其键单独访问其值。在列表 7-2 中,我们将$rainfall 数组中的每个值连接成一个字符串消息并打印出来。

<?php
$rainfall = [10, 8, 12];

print "Monthly rainfall\n";
print "Jan: " . $rainfall[0] . "\n";
print "Feb: " . $rainfall[1] . "\n";
print "Mar: " . $rainfall[2] . "\n";

列表 7-2:通过整数键访问数组元素

我们通过在数组名称后面指定方括号中的键来访问数组中的项目。例如,\(rainfall[0]给我们返回\)rainfall 数组中的第一个值(10),然后我们将其与字符串"Jan: "连接。同样,我们可以通过\(rainfall[1]访问数组中的第二个元素。由于整数键从零开始,具有*n*个元素的数组的最后一个元素的键是*n* - 1。在这个例子中,我们通过\)rainfall[2]访问我们这个三元素数组的最后一个元素。以下是运行该脚本的输出:

Monthly rainfall
Jan: 10
Feb: 8
Mar: 12

值 10、8 和 12 已经成功地从数组中读取并使用它们的整数键 0、1 和 2 打印出来。

如果你尝试使用一个未分配的键访问数组元素,PHP 会给出警告。例如,假设我们在列表 7-2 的末尾添加以下打印语句:

print "Apr: " . $rainfall[3] . "\n";

这条语句将触发类似于以下的警告:

PHP Warning:  Undefined array key 3 in /Users/matt/main.php on line 8

我们的数组只有三个元素,键分别为 0、1 和 2,因此没有与$rainfall[3]对应的元素。稍后我们将在本章中讨论如何通过首先确保数组中具有特定键的元素存在来避免类似的警告,然后再尝试访问它。

更新数组

通常,在创建数组后,你需要通过添加或删除元素来更新数组。例如,通常会从一个空数组开始,通过将一个空的方括号([])赋值给一个变量来创建它,然后随着脚本的执行,逐渐向其中添加元素。在本节中,我们将讨论一些常见的技术,用于更改数组的内容。

追加元素

如果你正在向一个数组添加新元素,你通常会希望将其添加到数组的末尾,这个操作被称为追加。这是一个非常常见的任务,PHP 通过简单的赋值语句使其变得非常容易。在等号左侧,你写数组的名称,后面跟着一个空的方括号;在等号右侧,你写你想要追加到数组中的值。例如,Listing 7-3 展示了一个脚本,它创建了一个空的动物数组,然后将元素追加到它的末尾。

<?php
$animals = [];
$animals[] = 'cat';
$animals[] = 'dog';

print "animals[0] = $animals[0] \n";
print "animals[1] = $animals[1] \n";

Listing 7-3: 追加元素到数组的末尾

我们通过写一个空的方括号来声明一个名为\(animals 的空数组。然后,我们一个一个地将两个元素添加到数组的末尾。例如,\)animals[] = 'cat' 将字符串值'cat'添加到数组的末尾。PHP 会自动为新元素分配下一个可用的整数作为键。在这个例子中,由于$animals 在添加'cat'时是空的,所以它的键被赋值为 0。当我们使用相同的符号将'dog'添加到数组时,该元素自动得到键 1。为了确认这一点,我们在脚本末尾打印数组的每个值,输出如下:

animals[0] = cat
animals[1] = dog

输出结果表明,'cat'成功映射到了数组的键 0,而'dog'映射到了键 1。PHP 引擎能够找到数组中最高的整数键,给它加 1,并将结果作为追加到数组时的下一个唯一整数键。

使用特定键添加元素

虽然更常见的做法是将元素追加到数组,并让 PHP 自动分配下一个可用的整数键,但你也可以在向数组添加元素时手动指定该元素的键。例如,\(heights[22] = 101 将值 101 添加到\)heights 数组,并为其分配整数键 22。如果该键已经存在值,则该值会被覆盖。因此,这种直接赋值技术通常用于更新数组中已存在的值,而不是添加一个全新的值。Listing 7-4 扩展了我们的$animals 数组脚本,演示了如何进行这种操作。

<?php
$animals = [];
$animals[] = 'cat';
$animals[] = 'dog';
$animals[0] = 'hippo';

var_dump($animals);

Listing 7-4: 使用指定键直接赋值数组元素

如之前所示,我们将 'cat' 和 'dog' 添加到 $animals 数组中。然后,我们通过直接将该字符串赋值给数组的键 0 来替换第一个数组元素的值为 'hippo'。这是运行该脚本后的输出:

array(2) {
    [0]=>
    string(3) "hippo"
    [1]=>
    string(3) "dog"
}

请注意,'hippo' 现在映射到了键 0,这表示它已经替换了原来的 'cat' 值。

当你用特定的键添加新数组元素时要小心。如果为你提供的键不存在的数组元素进行此操作,可能会破坏整数键的顺序。如果你使用了超出数组当前大小的键,就会发生这种情况。创建一个具有断裂整数键序列的数组是允许的,但如果你在其他地方编写的代码依赖于键的连续序列,这可能会引发问题。我们将在 第八章 中探讨非顺序和非整数键的数组。

添加多个元素

到目前为止,我们只能一次向数组中添加一个元素,但内置的 array_push() 函数可以一次将多个元素添加到数组的末尾。该函数接受可变数量的参数。第一个参数是你要更新的数组,后面是要附加的新值,你可以添加任意多个。例如,示例 7-5 重新展示了 示例 7-3 中的脚本,在该脚本中我们首先向 $animals 数组添加了元素并打印出来,然后使用 array_push() 将另外两个动物添加到数组末尾。

<?php
$animals = [];
$animals[] = 'cat';
$animals[] = 'dog';
array_push($animals, 'giraffe', 'elephant');

print "animals[0] = $animals[0] \n";
print "animals[1] = $animals[1] \n";
print "animals[2] = $animals[2] \n";
print "animals[3] = $animals[3] \n";

示例 7-5:使用 array_push() 将多个值附加到数组末尾

我们调用 array_push() 函数,将 $animals 数组和我们想要添加的两个字符串值,'giraffe' 和 'elephant',传入。由于新元素被添加到数组的末尾,它们会自动分配下一个可用的整数键值,分别是 2 和 3。我们通过访问这两个额外的元素并根据它们的键值打印出来,确认了这一点,连同两个原始元素一起输出:

animals[0] = cat
animals[1] = dog
animals[2] = giraffe
animals[3] = elephant

输出表明,'giraffe' 成功映射到键 2,'elephant' 映射到了键 3。

你可能已经注意到,当我们调用 array_push() 函数时,并没有将其作为赋值语句的一部分,函数调用位于等号的右侧,变量名在左侧用于捕获函数的返回值。这是因为 array_push() 会直接修改传递给它的数组。从这个意义上讲,array_push() 与我们在 第三章 中看到的字符串操作函数非常不同,后者是创建并返回一个新的字符串,而不是直接修改传递给它们的原始字符串。

array_push() 函数可以直接修改提供的数组,因为它的第一个参数是通过按引用传递的方式声明的。正如我们在第五章中讨论的,这意味着该函数会获得传入参数值的直接引用,而不是通过按值传递方式获得该参数值的副本。我们可以通过查看 PHP 文档中的函数签名来确认这一点:

array_push(array &$array, mixed ...$values): int

第一个参数前的与符号(&)即 &$array,表示这是一个按引用传递的参数。

由于 array_push() 是直接修改数组的,因此无需返回数组的副本,或者我们在调用该函数时使用赋值语句来捕获返回值。实际上,array_push() 确实有返回值,它返回一个整数,表示数组的新长度。如果你需要在更新数组时跟踪数组的长度,这个返回值就很有用;在列表 7-5 中我们并不需要这个返回值,所以我们只是独立地调用了该函数,而没有将结果赋值给变量。

移除最后一个元素

内置的 array_pop() 函数返回数组中的最后一个元素,并同时从数组中移除该元素。这是另一个按引用传递的函数示例,它会改变提供的数组。在列表 7-6 中,我们使用 array_pop() 来获取并移除 $animals 数组的最后一个元素。

<?php
$animals = [];
$animals[] = 'cat';
$animals[] = 'dog';

$lastAnimal = array_pop($animals);
print "lastAnimal = $lastAnimal\n";
var_dump($animals);

列表 7-6:使用 array_pop() 函数获取并移除数组中的最后一个元素

我们调用 array_pop(),将 $animals 数组作为参数传递,并将函数的返回值存储在 $lastAnimal 变量中。然后,我们打印出 $lastAnimal 和 $animals 数组,查看哪些元素还剩下。结果如下:

lastAnimal = dog
array(1) {
    [0]=>
    string(3) "cat"
}

变量 \(lastAnimal 中的字符串为 'dog',因为这是最后一个被添加到数组中的元素。\)animals 的 var_dump 显示,调用 array_pop() 后数组只包含 'cat',这演示了这个通过引用传递的函数如何能够改变传入的数组。

检索数组信息

我们已经考虑了修改数组的一些函数,但其他函数可以在不改变数组的情况下返回关于数组的有用信息。例如,count() 返回数组中元素的数量。如果你想检查一个数组是否为空(零个元素可能意味着购物车为空,或者没有从数据库中检索到记录),或者它是否包含比预期更多的项(也许某个客户有多个地址存档),count() 就非常有用。有时,知道数组中有多少项对于控制遍历数组的循环也很有帮助。在列表 7-7 中,我们使用 count() 来打印 $animals 数组中的总项数。

<?php
$animals = ['cat', 'dog', 'giraffe', 'elephant'];

print count($animals);

列表 7-7:计算数组中元素的数量

我们调用 count()函数,传入我们希望计数的数组名称,并打印结果。由于$animals 数组包含四个元素,因此此脚本应输出整数 4。

注意

sizeof() 函数是 count() 的别名。如果你看到一个使用 sizeof() 的脚本,知道它的功能与 count() 相同。

另一个分析性数组函数是 array_is_list()。PHP 区分了列表数组和非列表数组。为了被认为是列表,一个长度为n的数组必须拥有从 0 到n – 1 的连续编号键。array_is_list()函数接收一个数组,并根据该数组是否符合该定义返回 true 或 false。本章讨论的所有数组都符合列表的标准,因为它们依赖于 PHP 的默认行为,即从 0 开始顺序分配键。然而,在下一章,我们将探索具有非整数键的数组,以及 unset()函数,它可以删除一个具有特定键的数组元素,这可能打破连续的数字键链,使数组不再符合列表的定义。因此,array_is_list()函数对于在将数组传递给期望列表结构的代码之前评估数组是否符合要求非常有用。

array_key_last()函数返回给定数组最后一个元素的键。假设数组是一个有效的列表,且键是连续编号的,则 array_key_last()的返回值应该比 count()的返回值少 1。例如,在列表 7-7 的末尾调用 array_key_last($animals)将返回整数 3,因为这是数组中第四个(也是最后一个)元素的键。

之前我提到过,尝试访问一个不存在的数组键会触发警告。为了避免这种情况,在尝试访问数组键之前,使用 isset()函数检查该键是否存在。列表 7-8 展示了该函数的实际应用。

<?php
$animals = ['cat', 'dog', 'giraffe', 'elephant'];

❶ if (isset($animals[3])) {
    print "element 3 = $animals[3]\n";
} else {
    print "sorry - there is no element 3 in this array\n";
}

print "(popping last element [3])\n";
❷ array_pop($animals);

if (isset($animals[3])) {
    print "element 3 = $animals[3]\n";
} else {
    print "sorry - there is no element 3 in this array\n";
}

列表 7-8:使用 isset()测试数组键的存在

首先,我们创建了一个包含四个元素的\(animals 数组。然后,我们使用带有 isset()的 if...else 语句,只有当键为 3 的元素存在时才访问它(此时它应该存在)❶。接着,我们使用 array_pop()删除\)animals 数组中的最后一个元素(键为 3 的元素)❷。然后,我们重复相同的 if...else 语句。现在数组中没有键为 3 的元素,但由于我们在尝试访问该元素之前使用 isset()进行测试,因此我们不应该收到警告。看看脚本的输出:

element 3 = elephant
(popping last element [3])
sorry - there is no element 3 in this array

输出的第一行表示第一次调用 isset()返回了 true,触发了条件语句的 if 分支。最后一行显示第二次调用 isset()返回了 false,触发了 else 分支,避免了尝试访问一个不存在的数组元素。

遍历数组

访问数组元素并逐个处理它们是常见的操作。假设数组是一个列表,你可以通过使用一个计数器变量作为当前数组元素的键来使用for循环实现这一点。从 0 开始计数器并将其递增直到数组的长度,你可以依次访问每个元素。清单 7-9 使用for循环打印我们$animals 数组中的每个元素。

<?php
$animals = ['cat', 'dog', 'giraffe', 'elephant'];

$numElements = count($animals);
for ($i = 0; $i < $numElements; $i++) {
  ❶ $animal = $animals[$i];
    print "$animal, ";
}

清单 7-9:使用for循环遍历数组

我们使用count()来查找数组的长度,并将结果存储到\(numElements 变量中。然后我们声明一个`for`循环,将计数器\)i 从 0 递增到\(numElements 的值(但不包括\)numElements 本身)。(我们可以将停止条件硬编码为\(i < 4,但使用变量可以使代码在数组长度变化时更具灵活性。)在循环语句组内,我们使用\)animals[\(i]来获取当前循环变量\)i 对应的数组元素,并将其存储到\(animal ❶。然后我们打印这个\)animal 字符串,后面跟着一个逗号和一个空格。当我们在终端运行此脚本时,输出如下:

cat, dog, giraffe, elephant,

每个数组元素按顺序打印出来。(别担心,我们稍后会修正那个多余的逗号。)

使用foreach循环

这种for循环方法是可行的,但由于遍历数组元素是一个非常常见的任务,PHP 提供了另一种循环类型——foreach循环,来更高效地完成此任务。foreach循环的核心语法是foreach ($array as $value);其中\(array 是要循环的数组名,而\)value 是一个临时变量,每次被赋值为数组中的一个元素。清单 7-10 展示了清单 7-9 的更新版本,使用了foreach循环而不是for循环。

<?php
$animals = ['cat', 'dog', 'giraffe', 'elephant'];

foreach ($animals as $animal) {
 print "$animal, ";
}

清单 7-10:使用foreach循环优雅地遍历数组

我们通过使用foreach ($animals as $animal)来声明这个循环。在这里,\(animal 是一个临时变量,依次获取每个数组元素的值,我们然后在循环体内打印它。注意,我们不再需要担心确定数组的长度来设置循环的停止条件,也不需要像在`for`循环版本中那样手动访问每个数组元素(如\)animals[$i])。foreach循环会自动访问每个元素。结果与for循环版本相同,但foreach循环的语法更加优雅简洁。

foreach循环还有一个额外的好处,我们无需关心提供的数组是否是真正的列表。在for循环版本中,我们依赖于数组键的连续整数编号;如果某个键缺失,尝试访问该键时会出现警告。相比之下,foreach循环会简单地访问数组中的每个元素,无论键是什么。

访问键和值

foreach 循环的另一种语法允许你访问每个数组元素的键和值,而不仅仅是值。为此,可以按以下格式声明循环:foreach ($array as $key => $value)。这里,$array 是你想要遍历的数组,$key 是一个临时变量,保存当前元素的键,$value 是一个临时变量,保存当前元素的值。=> 操作符将键与值连接起来。在 第八章 中,当我们处理包含字符串和其他数据类型键的复杂数组时,会更加广泛地使用这种语法。

访问键和值使我们能够消除数组中最后一个元素后的多余逗号。回顾一下,array_key_last() 函数返回数组最后一个元素的键。通过将这个函数的返回值与 foreach 循环中的当前键进行比较,我们可以决定是否在每个元素后面打印逗号。 清单 7-11 展示了这一方法。

<?php
$animals = ['cat', 'dog', 'giraffe', 'elephant'];

foreach ($animals as $key => $animal) {
    print "$animal";
    if ($key != array_key_last($animals)) {
        print ", ";
    }
}

清单 7-11:一个修改后的 foreach 循环,访问每个数组元素的键和值

我们通过使用 foreach ($animals as $key => $animal) 来声明一个 foreach 循环。在每次循环中,$key 是当前数组元素的键,而 $animal 是当前数组元素的值。在循环内,我们首先打印出 $animal 中的字符串。然后,我们使用 if 语句来判断,如果当前元素的键 等于数组的最后一个键(可以通过 array_key_last() 函数获得),则打印出一个逗号和一个空格。这样应该会产生如下输出:

cat, dog, giraffe, elephant

我们已经成功地去掉了数组中最后一个元素后的逗号。

将数组转换为字符串

清单 7-11 中的代码本质上是打印一个包含数组元素的字符串,元素之间有一个分隔符(在本例中是逗号和空格)。这是一个常见的任务,因此 PHP 提供了一个内建函数 implode() 来自动执行此操作,无需使用任何类型的循环。

该函数接受两个参数:一个作为分隔符的字符串和一个要转换为字符串的数组。分隔符 位于 元素之间,而不是 每个 元素后面,因此代码不会在数组的最后一个元素后添加额外的分隔符。清单 7-12 展示了一个更新后的脚本,使用 implode() 而不是 foreach 循环。

<?php
$animals = ['cat', 'dog', 'giraffe', 'elephant'];

print implode(', ', $animals);

清单 7-12:使用 implode() 将数组转换为字符串

在这里,我们打印调用 implode() 函数对 $animals 数组进行处理后的结果。我们使用字符串 ', ' 作为分隔符,在数组元素之间添加逗号和空格。输出应该与 清单 7-11 完全相同,但 implode() 使得代码写起来更高效。

implode()函数可能已经让我们在这种情况下不再需要 foreach 循环,但不要被这个误导。foreach 循环在很多场景下都是正确的工具。一般来说,当你要应用到数组中每个元素的代码比简单地打印出该元素的值更复杂时,使用 foreach 循环通常是合适的。

带有可变数量参数的函数

数组的一个重要应用是,你可以用它们来声明接受可变数量参数的函数。当我们在第五章中创建自定义函数时,我们需要准确知道每个函数会接受多少个参数,这样我们才能定义具有相应参数数量的函数。然而,这并不总是可能的。

例如,假设我们想声明一个 sum()函数,它接受一个不确定数量的数字,将它们全部加起来并返回结果。我们不知道用户会传递两个数字、三个数字或更多作为参数,因此无法为每个数字创建一个单独的参数。相反,我们使用一个单一的参数来表示所有数字,并在参数名前写三个点(...)。这种语法告诉 PHP 将该参数视为数组,并将其填充为所有传入的参数,无论有多少个。

列表 7-13 展示了如何通过声明刚才描述的 sum()函数来实现这一方法。记住,函数应该在与调用它们的代码分开的文件中声明,所以创建一个名为my_functions.php的文件,包含此列表的内容。

<?php
function sum(...$numbers): int
{
    $total = 0;

    foreach ($numbers as $number) {
        $total += $number;
    }

    return $total;
}

列表 7-13:一个接受可变数量整数参数并返回它们总和的函数

我们声明了 sum()函数,它返回一个整数。它有一个单一的参数,...\(numbers。由于省略号,函数接收到的任何参数都会作为元素分配给一个名为\)numbers 的本地数组。注意,使用省略号表示法时,我们没有为参数指定数据类型;我们知道整体的\(numbers 变量将是数组类型,尽管数组的单个元素可以是任何类型。在函数体内,我们将\)total 初始化为 0。然后我们使用 foreach 循环遍历\(numbers 数组的元素,将每个元素的值加到\)total 中。当循环结束时,我们返回$total,它保存了所有参数的总和。

注意

我们的 sum() 函数没有包含任何逻辑来确认 $numbers 中的元素实际上是数字。一个真实的函数需要某种形式的数据验证,如果传入的参数不是所有数字,它可能会返回 NULL 或以其他方式指示数据无效。还需要注意的是,PHP 已经有一个内置的 array_sum() 函数,用来计算数组中数字的总和。我们实现了自己的版本用于演示。

列表 7-14 展示了一个main.php脚本,用于读取 sum()函数的声明并通过传递不同数量的参数进行测试。

<?php
require_once 'my_functions.php';

print sum(1, 2, 3) . "\n";
print sum(20, 40) . "\n";
print sum(1, 2, 3, 4, 5, 6, 7) . "\n";

列表 7-14:一个调用 sum()并传入不同参数个数的主脚本

在使用 require_once 读取并执行my_functions.php之后,我们调用了三次 sum(),每次传入不同数量的参数,并打印结果。脚本输出如下:

6
60
28

打印的三个总和已正确计算。这表明我们的 sum()函数已经成功地将可变数量的参数收集到一个数组中。

数组复制与数组引用

假设你有一个包含数组的变量,并将其赋值给第二个变量。在一些语言中,例如 Python 和 JavaScript,第二个变量将被赋值为指向原始数组的引用。两个变量将指向计算机内存中的同一个数组,因此一个变量的变化也会影响另一个变量。而在 PHP 中,默认情况下是将第二个变量赋值为数组的副本。由于两个变量各自拥有独立的数组,因此一个变量的变化不会影响另一个变量。列表 7-15 回到我们的$animals 数组来说明这一点。

<?php
$animals = ['cat', 'dog', 'giraffe', 'elephant'];

$variable2 = $animals;
array_pop($variable2);
var_dump($animals);

列表 7-15:复制数组

我们声明了常规的\(animals 数组,然后将\)animals 赋值给\(variable2。这会在\)variable2 中创建一个数组的独立副本,因此我们对一个数组的操作不应影响另一个数组。为了证明这一点,我们使用 array_pop()删除\(variable2 数组中的最后一个元素,然后打印原始的\)animals 数组。结果如下:

array(4) {
    [0]=>
    string(3) "cat"
    [1]=>
    string(3) "dog"
    [2]=>
    string(7) "giraffe"
    [3]=>
    string(8) "elephant"
}

即使我们从\(variable2 数组中删除了最后一个元素('elephant'),\)animals 数组中的四个动物字符串仍然存在,因此这两个变量确实持有独立的数组。

如果你希望将第二个变量赋值为对原始数组的引用,而不是数组的副本,就像其他语言中常见的做法那样,可以在赋值时使用引用操作符(&)。列表 7-16 更新了列表 7-15 中的代码,展示了两者的区别。

<?php
$animals = ['cat', 'dog', 'giraffe', 'elephant'];

$variable2 = &$animals;
array_pop($variable2);
var_dump($animals);

列表 7-16:在赋值数组时使用引用操作符

这次我们在将\(animals 数组赋值给\)variable2 时,前缀加上了引用操作符(&)。这意味着一个变量的变化将影响另一个变量,因为两个变量都引用了内存中的同一个数组。更新后的脚本输出结果如下:

array(3) {
    [0]=>
    string(3) "cat"
    [1]=>
    string(3) "dog"
    [2]=>
    string(7) "giraffe"
}

输出结果显示,从\(variable2 数组中弹出元素 3 也同时删除了\)animals 数组中的元素 3。这确认了\(variable2 和\)animals 都引用了内存中的同一个数组。

这些数组赋值的方法没有哪种天生比另一种更好,它们只是不同。有时最好在操作数组之前先复制一份。例如,一个网页可能让用户编辑购物清单,同时提供一个取消按钮来撤销编辑。在这种情况下,你可能希望在确认更改之前处理购物清单数组的副本,因为如果用户点击取消,你可能需要恢复到原始数组。其他时候,最好让多个变量引用内存中同一个数组。也许代码包含选择多个数组中的一个的逻辑,因此需要将某个变量设置为所选数组的引用。

本节的关键点是,除非使用引用操作符(&),否则 PHP 默认会复制数组。如果你在学习 PHP 之前已经学习过其他编程语言,或者将来学习其他语言,那么理解赋值副本与赋值引用之间的区别,并了解语言默认使用的行为是很重要的。

将字符串视为字符数组

从某种程度上讲,你可以把字符串看作是一个个独立字符的数组。这在某些情况下很有用,因为你可能希望逐字符“遍历”字符串,执行加密、拼写检查等任务,就像遍历数组的元素一样。

正如你在第三章中看到的,字符串中的字符是从 0 开始编号的,就像简单数组中的元素一样。事实上,你可以使用相同的方括号表示法来访问数组元素,同时也可以访问字符串中的特定字符。例如,如果\(name 保存的是字符串'Smith',那么\)name[0]将返回'S',\(name[1]将返回'm',依此类推。字符串还支持*负*整数键,用于从字符串的末尾反向计数字符:\)name[-1]返回'h'(最后一个字符),$name[-2]返回't',依此类推。

注意

与字符串不同,数组本身并不会将负整数键解释为从数组末尾反向计数。相反, $animals[-1] 将被解释为 $animals 数组中一个实际键为 -1 的元素。虽然你可以手动将负整数作为键分配给数组元素,但我个人不记得曾经需要这么做。

列表 7-17 展示了使用数组键语法访问字符串中单个字符的示例。

<?php
$name = 'Smith';

$firstChar = $name[0];
$secondToLastChar = $name[-2];

print "first character = '$firstChar'\n";
print "second to last character = '$secondToLastChar'\n";

列表 7-17:使用方括号表示法访问字符串字符

我们将字符串 'Smith' 分配给\(name 变量。接下来,我们将字符串的第一个字符(\)name[0])复制到\(firstChar 变量,将倒数第二个字符(\)name[-2])复制到$secondToLastChar 变量。然后,我们打印出这些变量的值,输出如下信息:

first character = 'S'
second to last character = 't'

与数组不同,我们不能将字符串传递给 foreach 循环以循环遍历其所有字符。然而,我们可以使用 PHP 内置的 str_split() 函数将字符串转换为实际的字符数组,然后将该数组传递给 foreach 循环,如 示例 7-18 所示。

<?php
$name = 'Smith';

$characters = str_split($name);
foreach ($characters as $key => $character) {
    print "character with key $key = '$character'\n";
}

示例 7-18:使用 str_split()foreach 循环遍历字符串中的字符

我们将相同的 $name 字符串传递给 str_split()。默认情况下,该函数将字符串拆分为单个字符,将它们作为数组的元素,并返回结果,我们将其存储在 $characters 变量中。然后,我们使用 foreach 循环访问字符串的数组版本中的每个键和值并打印它们。结果如下:

character with key 0 = 'S'
character with key 1 = 'm'
character with key 2 = 'i'
character with key 3 = 't'
character with key 4 = 'h'

输出显示,在使用 str_split() 将字符串转换为数组后,我们已成功循环遍历原始字符串中的字符。

str_split() 函数有一个可选的第二个参数,用于控制结果数组中每个字符串元素的字符数。该参数的默认值为 1,将字符串拆分为单个字符,但如果我们调用 str_split($name, 2),例如,那么结果数组将包含两个字符的字符串:['Sm', 'it', 'h']。

其他数组函数

本章已讨论了处理数组的一些内置函数,但 PHP 还有许多其他函数。适用于数组的其他有用函数包括:

sort()   将数组的值按升序排列(对于字符串值按字母顺序,对于数字值按数字顺序)

usort()   根据用户定义的函数将值排序为自定义顺序

array_flip()   交换数组元素的键和值

array_slice()   返回一个包含现有数组子序列的新数组

array_walk()   对数组的每个元素调用用户定义的函数

array_map()   对数组的每个元素调用用户定义的函数,并返回一个包含结果的新数组

array_rand()   返回数组中的随机键

要查看完整的数组函数列表,请参阅 PHP 文档中的 www.php.net/manual/en/ref.array.php

总结

在这一章中,我们开始探索数组,这是一种复合数据类型,它在单一的变量名下存储多个值,每个值都有自己的标识键。目前,我们主要关注简单数组,它们的键是整数。本章介绍了多种技术,用于添加、删除和访问数组元素,以及获取数组信息的函数,如 count()isset()。你还学会了如何通过使用 foreach 循环依次处理每个数组元素。在某些情况下,PHP 提供了内置函数来处理常见的数组任务,例如 implode() 函数,它将数组中的所有元素连接成一个字符串。这些函数有时可以让你用一个函数调用替代整个循环和条件语句。

练习

1.   创建一个 $colors 数组,其中包含五种颜色的字符串名称。打印数组中的随机颜色。

提示:你可以通过调用 array_rand($colors) 获取一个有效的随机键。

2.   写一个 foreach 循环,在新的一行中打印出练习 1 中数组的每个颜色,格式如下:

color 0 = blue
color 1 = red
...

3.   使用 array_pop() 打印练习 1 中颜色数组的最后一个元素。然后使用 var_dump() 显示该元素已经从数组中移除。

4.   创建一个包含整数年龄 23、31 和 55 的数组。使用内置函数计算并打印数组中项的数量及其平均值。

第八章:8 高级数组

在本章中,我们将采用更复杂的方法来处理 PHP 数组,并探讨如何手动分配数组的键。这为使用有意义的字符串作为键提供了可能,而不是 PHP 默认的使用顺序整数的行为。我们还将讨论多维数组,其中数组元素的值本身就是另一个数组,并且我们将了解更多用于操作数组的函数和操作符。通过对 PHP 数组的更深入了解,您将开始看到它们如何存储和处理更复杂的数据结构。

显式声明数组键

我们已经讨论过 PHP 如何自动为数组元素分配顺序整数键,从 0 开始,在这种情况下,生成的数组将符合列表的定义。然而,您也可以在声明数组时使用双箭头操作符(=>)显式地将键映射到每个值,而不是依赖于这种默认行为。这样,您就不必遵循默认的键模式。例如,您可以使用非顺序的整数作为键,或从 0 以外的数字开始计数。无论哪种方式,生成的数组将不再被视为列表,但它仍然是一个有效的数组。为了说明,清单 8-1 显示了一个显式使用非顺序整数键的数组脚本。

<?php
$rainfallValues = [
    0 => 10,
    4 => 8,
    3 => 12
];

print "-- Monthly rainfall --\n";
foreach ($rainfallValues as $key => $rainfallValue) {
    print "$key: $rainfallValue\n";
}

var_dump(array_is_list($rainfallValues));

清单 8-1:显式声明不按顺序排列的整数数组键

在这里,我们声明了一个 $rainfallValues 数组。在数组的方括号内,我们使用 => 操作符显式地为每个数组元素分配键。例如,0 => 10 会向数组中添加一个值为 10、键为 0 的元素。键/值对通过逗号分隔,就像我们在第七章中没有显式声明键时用逗号分隔数组值一样。在这种情况下,我们还将每个键/值对放在了单独的缩进行中,以提高清晰度。声明数组后,脚本继续通过循环遍历它并打印出键/值对。

请注意,我们声明的数组键不是顺序的。第二个数组元素的键是 4,第三个元素的键是 3。这可能不是分配键的最直观方式,但如果这是我们想要的,PHP 完全可以接受。数组将不再符合列表的定义(因此脚本末尾的 array_is_list() 调用应该返回 false),但数组仍然有效。以下是运行脚本的输出:

-- Monthly rainfall --
0: 10
4: 8
3: 12
bool(false)

即使数组不是一个完整的列表,foreach 循环仍然有效,它会遍历数组的键/值对并将其打印出来。请注意,键为 4 的元素在键为 3 的元素之前打印出来。关键在于元素是如何声明的,而不是键本身的数字顺序。输出末尾的 false 确认了该数组不再符合列表的要求。

一旦你开始显式声明键,就不一定需要为每一个数组元素都声明一个键。如果一个元素没有声明键,PHP 将自动查找最近的整数键,递增它,并使用该键作为新的键。如果你希望数组的键按顺序排列,但不从 0 开始,这种方式会非常有用。

例如,假设你有一个学生班级,并希望创建一个数组,将学生的 ID 映射到他们的成绩。每个 ID 是一个七位数的数字,开头是年份,后面跟着三个数字,按顺序递增。例如,在 2025 年,第一位学生的 ID 为 2025001,下一位为 2025002,依此类推。在这种情况下,你可以显式声明第一个数组键,并让 PHP 自动分配其余的键。清单 8-2 展示了具体做法。

<?php
$studentGrades = [
    2025001 => 'A',
  ❶ 'B',
    'A',
    'D',
    'F'
];

print "-- Student grades--\n";
foreach ($studentGrades as $studentId => $grade) {
    print "$studentId => $grade\n";
}

var_dump(array_is_list($studentGrades));

清单 8-2:显式声明第一个数组键,其余自动分配

在$studentGrades 数组中,我们显式地为第一个元素指定了键 2025001。然后,从第二个元素❶开始,我们只提供值。默认情况下,PHP 会将这些值映射到整数键 2025002、2025003 等。和之前一样,我们通过循环并打印键/值对来完成脚本,并测试该数组是否算作列表。输出如下所示:

-- Student grades--
2025001 => A
2025002 => B
2025003 => A
2025004 => D
2025005 => F
bool(false)

请注意,PHP 已按顺序分配了其余的键,从显式声明的键 2025001 开始递增。然而,尽管这些键是按顺序排列的,它们并不是从 0 开始的。因此,该数组并不是一个列表,输出末尾的 false 证明了这一点。

使用字符串作为键的数组

让我们进一步扩展我们的代码:既然我们已经显式分配了数组键,谁说它们一定得是整数呢?它们同样可以是字符串,在这种情况下,数组中的每个值都可以有一个有意义的名字作为键。例如,回到清单 8-1 中的$rainfallValues 数组,我们可以使用月份名称作为键,而不是使用整数。这种修改将更好地表明数组中的每个值代表一个月的降水总量。清单 8-3 已经相应地修正了脚本。

<?php
$rainfallValues = [
    'jan' => 10,
    'feb' => 8,
    'march' => 12
];

print "-- Monthly rainfall --\n";
foreach ($rainfallValues as $key => $rainfallValue) {
 print "$key: $rainfallValue\n";
}

var_dump(array_is_list($rainfallValues));

清单 8-3:将字符串用作数组键

这次我们将键'jan'分配给值 10,将键'feb'分配给值 8,将键'march'分配给值 12。我们使用和之前相同的=>运算符来将键与值配对。唯一的不同是,现在键是字符串。以下是脚本的输出:

-- Monthly rainfall --
jan: 10
feb: 8
march: 12
bool(false)

字符串键清晰地说明了值的实际含义。输出末尾的 false 显示该数组不是一个列表。这并不奇怪,因为这些键甚至不是整数。

使用字符串键从数组中访问单个值的方式和使用整数键访问数组值一样:在数组名后面提供键,并用方括号括起来。例如,以下是如何打印三月降水量的值:

print $rainfallValues['march'];

同样,你也可以使用方括号表示法,通过字符串键来添加或更新数组元素。在这里,我们为四月添加了新的降水总量:

$rainfallValues['april'] = 14;

这是一个简单的例子,但希望你可以开始看到 PHP 数组在构建有意义的数据集合方面的潜力。当你不需要面向对象编程(在第五部分中讨论)所提供的全部功能时,使用带有字符串键的数组可以帮助你处理那些值与任务相关联的键(如日期或月份、人名或 ID、或产品名称或代码)数据。

多维数组

到目前为止,我们所探索的数组都是一维的:它们包含一个元素序列,每个元素都是一个标量(单一)值,映射到一个键。然而,你也可以声明包含数组元素的数组,从而得到一个多维数组。例如,假设你想创建一个任务数组,每个任务需要的时间(以分钟为单位)。数组中的每个元素本身可以是一个数组,包含任务名称和其相关的持续时间,如下所示:

$tasksAndMinutes = [
    ['shopping', 30],
    ['gym', 60],
    ['nap', 15]
];

这里的\(tasksAndMinutes 是一个多维数组。它的第一个元素,['shopping', 30],是一个包含字符串任务名称和整数分钟数的两元素数组。其他数组元素遵循相同的格式。对于这样一个多维数组,我们把整个\)tasksAndMinutes 称为外部数组,它的元素称为内部数组。

处理多维数组的一种方式是使用一组嵌套的 foreach 循环,一个循环遍历外部数组的元素,另一个循环遍历每个内部数组的元素。然而,在$tasksAndMinutes 数组中,所有的内部数组具有相同的结构(这并不总是如此)。因此,在这种情况下,你可以利用对该结构的了解,通过使用单一的 foreach 循环来遍历外部数组,从每个内部数组中提取值。清单 8-4 展示了这一方法。

<?php
$tasksAndMinutes = [
    ['shopping', 30],
    ['gym', 60],
    ['nap', 15]
];

foreach ($tasksAndMinutes as $item) {
    $task = $item[0];
    $minutes = $item[1];
    print "allow $minutes minutes today for task: $task\n";
}

清单 8-4:处理多维数组

我们像之前一样声明\(tasksAndMinutes 数组。接下来,我们声明一个 foreach 循环,遍历\)tasksAndMinutes 的元素,使用\(item 变量来表示当前元素。如我们所见,每个元素本身是一个包含任务名称和时间(分钟)的数组。因此,我们可以提取\)item 的第一个元素(使用整数索引 0)到\(task 变量,第二个元素(索引 1)到\)minutes 变量。然后,我们使用这两个变量打印关于当前任务的信息,输出如下:

allow 30 minutes today for task: shopping
allow 60 minutes today for task: gym
allow 15 minutes today for task: nap

在 foreach 循环中,时间和任务名称已经成功地从每个内层数组中提取出来。

在这个例子中,内部数组默认使用整数键,但如你所知,数组也可以使用非数字键。将每个内部数组中的值与有意义的字符串键(如 'task' 和 'minutes')配对,会让代码更加易读。例如,我们可以通过 $task = $item['task'] 来访问当前元素的任务,而不是 $task = $item[0]。列表 8-5 展示了这一改进。

<?php
$tasksAndMinutes = [
    ['task' => 'shopping', 'minutes' => 30],
    ['task' => 'gym', 'minutes' => 60],
    ['task' => 'nap', 'minutes' => 15],
];

foreach ($tasksAndMinutes as $item) {
    $task = $item['task'];
    $minutes = $item['minutes'];
 print "allow $minutes minutes today for task: $task\n";
}

列表 8-5:重构列表 8-4,在内部数组中使用字符串键

这次我们显式地将字符串键 'task' 和 'minutes' 分配给 $tasksAndMinutes 中每个数组的值。然后,我们在 foreach 循环中使用这些有意义的键来提取当前正在处理的内部数组中的值。结果和之前完全一样,但代码更容易阅读。在 PHP 支持面向对象编程之前,像这样的具有良好标签的多维数组是许多程序数据相关功能代码的核心部分。 ### 更多数组操作

在第七章中,我们讨论了数组操作,如向简单数组的末尾添加元素和从末尾删除元素。现在我们已经探讨了复杂数组,让我们考虑更多的数组操作。我们将学习如何从数组的任意位置删除元素,如何使用数组运算符如并集(+)和展开(...),以及如何将数组元素提取到单独的变量中。

从数组中删除任何元素

你可以通过将元素的键传递给 unset() 函数来从数组中删除元素。与上一章中介绍的 array_pop() 函数不同,array_pop() 函数专门删除数组中的最后一个元素,而 unset() 可以从任何位置删除元素。与 array_pop() 不同,unset() 函数不会返回被删除的元素,它只是消失了。

当你开始使用字符串而非整数作为数组键时,使用 unset() 变得更加合适。对于字符串键,数组元素的顺序往往不再重要,因此基于键而非数组中的位置删除元素更有意义。列表 8-6 重新访问了 $rainfallValues 数组作为例子。

<?php
$rainfallValues = [
 'jan' => 10,
 'feb' => 8,
 'march' => 12
];

unset($rainfallValues['feb']);

print "-- Monthly rainfall --\n";
foreach ($rainfallValues as $key => $rainfallValue) {
 print "$key: $rainfallValue\n";
}

列表 8-6:使用 unset() 从数组中删除元素

我们使用 unset() 从 $rainfallValues 数组中删除键为 'feb' 的元素。然后,我们像之前一样遍历数组,打印剩余元素的详细信息。结果如下:

-- Monthly rainfall --
jan: 10
march: 12

注意,对于键为 'feb' 的元素没有打印任何数据,因为该元素不再存在于数组中。

注意

调用 unset() 在整个数组上,例如 unset($rainfallValues),将删除整个数组,就像在任何其他变量上调用 unset() 将清空该变量一样。 #### 合并和比较数组

你可以使用一些相同的加法、相等性和身份操作符来组合或比较数组,这些操作符适用于标量(单值)变量。表 8-1 总结了六个可用的数组操作符。

表 8-1:数组操作符

名称 符号 示例 描述
联合 + $a + $b 返回一个包含数组 $a 和 $b 元素的数组。
展开 ... [1, ...$a] | 返回一个数组,数组的第一个元素是 1,接着是数组 $a 的元素。
相等 == $a == $b 如果数组 $a 和 $b 拥有相同的键/值对,则返回 true。
相同 === $a === $b 如果数组 $a 和 $b 完全相同:它们具有相同的键/值对,元素的顺序和类型也相同,则返回 true。
不相等 != 或 <> $a != $b $a <> $b 如果数组 $a 和 $b 没有相同的键/值对,则返回 true。
不相同 !== $a !== $b 如果数组 $a 和数组 $b 不相同,则返回 true。

列表 8-7 展示了这些操作符的实际应用。

<?php
$cars1 = ['audi' => 'silver', 'bmw' => 'black'];
$cars2 = ['audi' => 'white', 'ferrari' => 'red'];
$names1 = ['matt' => 'smith', 'joelle' => 'murphy'];
$names2 = ['joelle' => 'murphy', 'matt' => 'smith',];

print_r($cars1 + $cars2);
var_dump($names1 == $names2);
var_dump($names1 === $names2);
❶ print_r(['rolls royce' => 'yellow', ...$cars1, ...$names1]);

列表 8-7:使用数组操作符

首先,我们声明一些示例数组来进行操作:$cars1 和 $cars2 以汽车品牌为键,汽车颜色为值,而 $names1 和 \(names2 以名字为键,姓氏为值。(注意,\)names1 和 $names2 拥有相同的元素,但顺序相反。)

然后我们将操作符应用到这些数组,并打印结果。我们使用联合 (+) 操作符来合并 $cars1 和 $cars2,使用相等 () 和相同 (=) 操作符来测试 $names1 和 $names2。我们还使用数组展开操作符 (...) 来创建一个新数组,其键为 'rolls royce',值为 'yellow',并包含 $cars1 和 $names1 数组的所有元素 ❶。注意,我们使用 print_r() 来显示返回数组的操作结果;该函数比 var_dump() 更简洁地展示数组。运行脚本后,得到如下输出:

❶ Array
(
    [audi] => silver
    [bmw] => black
    [ferrari] => red
)
❷ bool(true)
bool(false)
❸ Array
(
    [rolls royce] => yellow
    [audi] => silver
    [bmw] => black
    [matt] => smith
    [joelle] => murphy
)

输出的第一部分展示了 $cars1 + $cars2 ❶ 的结果。两个汽车数组都包含一个键为 'audi' 的元素,但数组不能有两个相同的键。因此,联合操作符从 $cars1 中取出 'audi' => 'silver' 元素,但忽略了 $cars2 中的 'audi' => 'white' 元素,结果是一个包含三个元素的数组。接下来,输出中的 true 和 false 结果 ❷ 表明 $names1 和 $names2 数组是 相等 的,因为它们有相同的键和值,但不是 完全相同 的,因为元素的顺序不同。最后的数组显示了使用展开操作符 (...) ❸ 的结果。新数组包含一个 'rolls royce' 元素,接着是 $cars1 和 $names1 中的元素。

值得强调的是,展开运算符 (...) 在这里的作用:它从一个数组中提取元素,并将它们一个一个地插入到另一个数组中。如果没有展开运算符,整个数组会作为单个元素插入到新数组中,从而创建一个多维数组,而不是将它的各个元素展开到新数组中。举个例子,假设我们在 示例 8-7 中,省略了 $cars1 前面的展开运算符 ❶,如下所示:

print_r(['rolls royce' => 'yellow', $cars1, ...$names1]);

结果数组将包含一个元素,该元素包含整个 $cars1 数组,如下所示:

Array
(
    [rolls royce] => yellow
    [0] => Array
        (
            [audi] => silver
            [bmw] => black
        )

 [matt] => smith
    [joelle] => murphy
)

现在数组中的第二个元素,键值为 0,本身是一个数组,包含了完整的 $cars1 内容。这个例子也展示了数组如何将整数键与非整数键混合使用。当整个 $cars1 数组作为元素添加到新数组时,由于没有手动指定键值,它会自动分配第一个可用的整数键 0。同时,新数组中的其他元素都显式地分配了字符串键。像这样混合键的数组是比较少见的;通常这样的数组表示某些东西出了问题,例如这里缺少了展开运算符。

将数组解构为多个变量

有时提取数组中的值并将它们赋值给单独的变量会很有用,这个过程叫做 解构。如果你知道数组中的元素数量,你可以通过一条语句解构它,如 示例 8-8 所示。

<?php
$rainfallValues = [10, 8, 12];

❶ [$jan, $feb, $march] = $rainfallValues;

print "-- Monthly rainfall --\n";
print "Jan: $jan \n";
print "Feb: $feb \n";
print "Mar: $march \n";

示例 8-8:将一个包含三个元素的数组解构为三个单独的变量

我们声明 $rainfallValues 数组包含三个元素。然后,我们将该数组解构到 \(jan、\)feb 和 $march 变量 ❶ 中。为此,我们在赋值运算符 (=) 的左边列出目标变量,并在右边提供包含整个数组的变量。最后,我们打印出这三个变量中的值,产生如下输出:

-- Monthly rainfall --
Jan: 10
Feb: 8
Mar: 12

注意,数组中的值已经成功地赋值到单独的 \(jan、\)feb 和 $march 变量中,并从这些变量中打印出来。 ### 回调函数与数组

回调函数,简称 回调,是一种不会直接调用的函数,而是作为参数传递给另一个函数。然后,另一个函数会为你调用回调函数。PHP 中有多个函数使用回调与数组结合。

例如,array_walk() 接收一个数组和一个回调函数作为参数,并对数组中的每个元素应用回调函数,从而在这个过程中改变原始数组。类似地,array_map() 接收一个数组和一个回调函数,应用回调函数到每个数组元素,并返回一个包含结果的新数组。array_walk() 和 array_map() 都被称为 高阶函数,因为它们将一个函数作为参数。

如果你在一个单独的文件中声明了一个函数(如第五章所讨论的),或者使用了 PHP 的内置函数,你可以通过将包含函数名的字符串传递给高阶函数来使用该函数作为回调。例如,假设我们声明了一个名为 my_function()的函数,并且想要通过使用 array_map()将它应用到$my_array 中的每个元素。下面是具体做法:

$my_new_array = array_map('my_function', $my_array);

我们将字符串'my_function'(所需回调的名称)和数组作为参数传递给 array_map(),它会对数组中的每个元素调用 my_function()。结果会以新数组的形式返回,我们将其存储在$my_new_array 变量中。

除了单独声明回调函数,另一种常见的做法是在高阶函数的参数列表中直接定义匿名(无名)回调函数。在我们在像 array_map()这样的高阶函数中使用匿名函数之前,先让我们单独考虑一下匿名函数,以便更好地理解语法。以下是一个简单的匿名函数,它接收一个数字并返回其值的两倍:

function (int $n): int {return $n * 2;}

该函数以 function 关键字开始,后面是函数的签名(int $n): int,表示该函数接收一个整数参数\(n 并返回一个整数值。请注意,函数签名中没有包括名称,因为该函数是匿名的。签名后面是匿名函数的主体,主体被花括号包围。该主体返回提供的\)n 参数的两倍值。

另一个选择是将匿名回调函数写成箭头函数,使用更简洁的语法,采用双箭头操作符(=>)来分隔函数的签名和主体。这种语法省去了return关键字、主体周围的花括号以及结束语句的分号。以下是我们倍增操作的箭头函数版本:

fn (int $n): int => $n * 2

现在,我们不再使用 function,而是以fn开始,这是声明箭头函数的保留关键字。接着是函数的签名,和之前一样。然后是双箭头操作符(=>),后面跟着一个定义函数返回值的表达式(在此例中为$n * 2)。没有了花括号、分号和 return 关键字,箭头函数变得非常简洁。

现在让我们尝试将这个箭头函数用作回调。列表 8-9 展示了如何将箭头函数传递给 array_map(),以便将数组中的每个值都加倍。

<?php
$numbers = [10, 20, 30];

$doubleNumbers = array_map(
  ❶ fn (int $n): int => $n * 2,
    $numbers
);

var_dump($doubleNumbers);

列表 8-9:将箭头回调函数传递给 array_map()

我们声明了一个包含 10、20 和 30 的\(numbers 数组。然后,我们调用 array_map()函数。对于第一个参数,我们使用箭头函数语法声明了我们刚刚讨论过的双倍回调函数❶。注意,箭头函数以逗号结束,因为它是 array_map()函数参数列表的一部分。第二个参数是\)numbers 数组。array_map()函数会自动对数组中的每个元素应用箭头函数,并返回一个包含结果的新数组。我们将这个新数组存储在$doubleNumbers 变量中。下面是运行此脚本并打印结果数组的输出:

array(3) {
    [0]=>
    int(20)
    [1]=>
    int(40)
    [2]=>
    int(60)
}

\(doubleNumbers 数组包含值 20、40 和 60。这表示 array_map()函数成功访问了\)numbers 数组中的每个值,并对其应用了双倍箭头函数。

总结

数组是灵活的数据结构,尤其当我们开始为元素的值分配有意义的字符串键,而不是使用默认的整数键时。在这一章中,你学习了如何使用字符串键的数组。你还看到如何将数组嵌套在其他数组中,创建多维数组,并且如何对数组的每个元素应用回调函数(使用箭头函数语法编写)。像这样的技巧使得数组成为一个复杂数据表示和操作的高级结构。

练习

1.   使用字符串键的数组来存储以下姓名和身高(单位:米)对:

Fred      1.82
Joelle    1.55
Robin     1.70

编写一个 foreach 循环来遍历数组元素并打印它们。

2.   创建一个多维数组来表示以下关于电影的数据:

Back to the Future
    duration        116
    leadingActor    Michael J. Fox
The Fifth Element
    duration        126
    leadingActor    Bruce Willis
Alien
    duration        117
    leadingActor    Sigourney Weaver

3.   声明一个包含从 1 到 9 的奇数(1、3、5、7、9)的数组,另一个包含偶数(2、4、6、8)的数组。使用数组扩展操作符(...)将两个数组合并,然后使用 PHP 的内置 sort()函数将它们按数字顺序排序。

第九章:9 文件和目录

许多应用程序需要你从文件中读取或写入数据。在本章中,我们将探索如何通过 PHP 脚本与文件进行交互。我们将主要关注简单的 .txt 文件,尽管我们也会简要介绍 PHP 如何处理其他常见的文本文件格式。

PHP 提供了许多内置函数来处理文件。有些函数一次性读取或写入文件,而其他更底层的函数则提供更细粒度的控制,允许你打开和关闭文件,并在特定位置选择性地读取或写入内容。并不是所有的 Web 应用程序都需要你处理外部文件,但掌握这些函数仍然很有用,以防有需要。举例来说,在 Web 应用程序之外,你可能会需要重新格式化文件中的数据,或者移动和重命名文件和目录。通过我们将在这里讨论的函数,你可以编写一个 PHP 脚本来自动化这个过程。

将文件读取到字符串中

如果你知道文件存在并且希望将其所有内容作为单个字符串读取到脚本中,你可以通过一个语句实现,只需调用内置的 file_get_contents() 函数。为了说明这一点,让我们首先创建一个待读取的文件。列表 9-1 显示了一个由 Jorge Suarez 编写的编程俳句文件(可以在 selavo.lv/wiki/index.php/Programming_haiku 找到)。创建一个名为 data.txt 的新文件,包含以下内容。

what is with this code?
oh my, looks like I wrote it
what was I thinking?

列表 9-1:包含编程诗歌的文本文件 data.txt

这个文件包含三行文本。换行符表示前两行以一个不可见的换行符结尾。

现在我们有了一个文件可以操作,我们可以编写一个脚本来读取并打印它的内容。在与 data.txt 相同目录下创建一个 main.php 文件,并输入 列表 9-2 中的代码。

<?php
$file = __DIR__ . '/data.txt';

$text = file_get_contents($file);
print $text;

列表 9-2:一个 main.php 脚本,用于读取并打印文件内容

首先,我们声明一个 $file 变量,包含文本文件的路径和文件名。由于文本文件和主脚本在同一目录下,我们通过将 DIR 魔术常量(主脚本所在位置的路径)与正斜杠和 data.txt 文件名连接起来,构建这个文件位置字符串。然后,我们使用 file_get_contents() 函数将文件内容读取到 $text 变量中。最后,我们打印出包含文件内容的字符串。

在终端运行主脚本,你应该看到诗句分布在三行上,正如在示例 9-1 中所显示的那样。这是因为文件中的不可见新行字符被传递到了$text 字符串中,就像可见字符一样。我们可以通过几种方式证明这些不可见字符的存在:检查文本文件的大小,或者通过将字符串中的新行替换为可见字符来验证从文件中读取的内容。为了更清楚地看到新行字符是如何成为文本文件的一部分的,让我们将data.txt的内容替换为示例 9-3 中的内容。

a
b

示例 9-3:一个简化版的 data.txt 文件

现在,文件只包含两个字符,每个字符位于单独的行上,我们可以更轻松地检查文件内容。更新main.php以匹配示例 9-4。

<?php
$file = __DIR__ . '/data.txt';

$text = file_get_contents($file);

$numBytes = filesize($file);
$newlinesChanged = str_replace("\n", 'N', $text);

print "numBytes = $numBytes\n";
print $newlinesChanged;

示例 9-4:一个更新后的 main.php 脚本,用于证明新行字符的存在

和之前一样,我们首先将文件内容读取到\(text 变量中。然后,我们使用内建的 filesize()函数读取文件的大小,它返回文件的字节数。在一个只有基本 ASCII 字符的文本文件中,每个字符(包括不可见字符)占用 1 个字节,所以我们应该预期结果是 3。接下来,我们生成另一个字符串,将\)text 中的每个新行字符("\n")替换为大写字母 N,并将结果存储在$newLinesChanged 变量中。最后,我们打印文件大小和更新后的字符串。以下是运行此脚本后在终端中输出的内容:

numBytes = 3
aNb

第一行确认文件只包含三个字符(字节)的数据:字母 a、一个新行字符和字母 b。第二行是代表文件内容的字符串,其中的新行字符被替换为可见字符:aNb 再次确认文件只包含三个字符,其中两个字母之间有一个新行字符。

确认新行的存在并不是一项简单的任务:在本章后面,我们将探索逐行处理文件内容的函数。这些函数依赖于不可见的新行字符来判断一行的结束和下一行的开始。

注意

file_get_contents() 函数也可以从网上读取文件,而不仅仅是从本地机器读取,只需传递文件位置的完整 URL。例如,尝试将 URL filesamples.com/samples/document/txt/sample1.txt 存储在 \(file *变量中,然后像示例 9-2 中那样调用* file_get_contents(\)file) 。你应该会得到一串无意义的拉丁文文本。

确认文件是否存在

之前的示例假设存在一个名为data.txt的文件。然而,在实际操作中,最好在尝试读取文件内容之前先测试文件是否存在。否则,如果你尝试打开或读取一个找不到的文件,你将收到类似以下的运行时警告:

PHP Warning:  file_get_contents(/Users/matt/nofile.txt): Failed to open
stream: No such file or directory in /Users/matt/main.php on line 4

执行将在警告之后继续,如果脚本尝试操作不存在的文件内容,这可能会导致进一步的警告和错误。为了使你的代码更健壮并能应对缺失的文件,你可以使用内置的 file_exists()函数。它返回一个布尔值,确认所提供的文件是否存在。让我们通过更新main.php并参考示例 9-5 的内容来试一试。

<?php
$file = __DIR__ . '/data.txt';
$file2 = __DIR__ . '/data2.txt';

$text = "file not found: $file";
$text2 = "file not found: $file2";

if (file_exists($file)) {
 $text = file_get_contents($file);
}

if (file_exists($file2)) {
    $text2 = file_get_contents($file2);
}

print $text . "\n";
print $text2 . "\n";

示例 9-5:更新后的 main.php 脚本,在读取文件之前确认文件的存在

在这里,我们添加了\(file2,它是一个保存不存在的文件路径*data2.txt*的第二个变量。在尝试读取任何内容之前,我们将默认的文件未找到消息分配给\)text 和\(text2 变量。这样,即使我们未能读取文件内容,这些变量仍然会保存一些内容。接下来,我们使用 file_exists()函数,在两个连续的 if 语句中确保仅在找到相应文件时才尝试读取*data.txt*和*data2.txt*的内容。然后我们打印\)text 和$text2 的内容,每个后面跟一个换行符。结果如下:

a
b
file not found: /Users/matt/data2.txt

由于data.txt文件存在,它的内容已被读取到\(text 中(替代了默认的文件未找到消息),并被打印出来。与此同时,由于*data2.txt*文件不存在,打印\)text2 会显示一条指示文件无法找到的消息。#### “触摸”文件

Linux 和 macOS 具有 touch 文件终端命令,它会将指定文件的最后访问或修改时间戳更新为当前日期时间,或者如果文件不存在,则创建一个空文件。PHP 提供了几乎相同的 touch()函数,它提供了另一种在访问文件之前确保文件存在的方法。如果你不介意文件内容为空,你可以将示例 9-5 中的默认文件未找到消息和 if 语句替换为简单的 touch()语句,如示例 9-6 所示。

<?php
$file = __DIR__ . '/data.txt';
$file2 = __DIR__ . '/data2.txt';

touch($file);
touch($file2);

$text1 = file_get_contents($file);
$text2 = file_get_contents($file2);

print $text1 . "\n";
print $text2 . "\n";

示例 9-6:更新后的 main.php 脚本,在读取文件之前“触摸”文件

现在我们在使用 file_read_contents()读取文件之前,先将每个文件名传递给 touch()。这让我们可以安全地读取文件,无需使用 if 语句和 file_exists(),因为我们知道如果文件不存在,touch()会创建文件(尽管是空文件)。

确保目录存在

到目前为止,我们一直在处理与执行脚本位于同一目录中的文件,但文件也可能位于不同的目录中。在这种情况下,确认目录是否存在(并且如果不存在则可能需要创建它)非常重要,因为就像缺失的文件一样,不存在的目录会触发运行时警告。PHP 有两个内置函数来处理这种情况:is_dir()返回一个布尔值,确认指定的目录路径是否可以找到,mkdir()则尝试在指定路径创建目录。

注意

mkdir() 函数如果尝试创建的目录已存在,或者基于当前权限设置无法创建该目录,会抛出运行时警告。有关权限的更多信息,请参见第 163 页的《目录和文件权限》一章。

要尝试这些函数,请按照示例 9-7 中所示更新 main.php 的内容。

<?php
$dir = __DIR__ . '/var';
$file = $dir . '/data.txt';

if (!is_dir($dir)) {
    mkdir($dir);
}

touch($file);

$text = file_get_contents($file);
print $text;

示例 9-7:一个更新后的 main.php 脚本,在目录不存在时创建该目录

我们将目标路径和文件名拆分为两个变量:\(dir 存储着文件所在目录的路径,\)file 存储着路径加文件名。我们将 \(dir 设置为执行脚本所在目录(__DIR__)下的*/var* 子目录;该子目录不存在。if (!is_dir(\)dir)) 语句检查 $dir 是否不是有效的目录路径,如果不是有效路径,则调用 mkdir() 创建该目录。现在我们可以安全地调用 touch() 创建文件,因为我们已经确认目录存在,然后读取文件,因为 touch() 会在文件不存在时创建该文件。

mkdir() 的默认选项是非递归的:如果目标目录的父目录不存在,它将无法创建该目录。然而,该函数有一个可选的递归参数;如果设置为 true,函数将同时创建任何缺失的父目录。示例 9-8 中展示了一个示例。

<?php
$dir = __DIR__ . '/sub/subsub';
$file = $dir . '/data.txt';

if (!is_dir($dir)) {
    mkdir($dir, recursive: true);
}

touch($file);

$text = file_get_contents($file);
print $text;

示例 9-8:更新后的 main.php 脚本,如果目录缺失则递归创建目录

现在,目录路径包含一个位于当前执行脚本目录下的/sub 目录中的/subsub 子目录。在 if 语句内,我们调用 mkdir(),并将递归参数设置为 true。这确保了该函数不仅会创建 /subsub 目录,还会在必要时创建其父目录 /sub。我们必须将递归设置为命名参数,因为 mkdir() 还接受另一个可选参数来设置新目录的权限,而该参数在函数签名中排在递归参数之前。

将字符串写入文本文件

就像你可以使用 file_get_contents() 将文件内容读取到字符串中一样,你也可以使用互逆的 file_put_contents() 函数将字符串内容写入文本文件。如果目标文件不存在,file_put_contents() 会自动创建该文件,因此你不需要事先测试文件名。更新后的 main.php 脚本在示例 9-9 中展示了其用法。

<?php
$content = <<<CONTENT
    the cat
    sat
    on the mat!
    CONTENT;

$file = __DIR__ . '/newfile.txt';

file_put_contents($file, $content);
$text = file_get_contents($file);
print $text;

示例 9-9:一个 main.php 脚本将数据从字符串写入文件

首先,我们声明一个三行 heredoc 字符串 $content,使用 CONTENT 作为分隔符。然后,我们将 $file 变量设置为当前目录路径加上文件名 newfile.txt。接下来,我们调用 file_put_contents() 函数,将目标文件和要写入该文件的文本传递给它。这应该会创建一个包含 $content heredoc 内容的 newfile.txt 文件。为了确认文件已创建并包含文本内容,我们使用 file_get_contents() 读取文件中的文本,并将其存储到 $text 变量中,然后打印出来。以下是结果:

the cat
sat
on the mat!

输出与原始 heredoc 字符串一致,表明我们成功地将字符串写入 newfile.txt 并再次读取出来。

如果你试图写入的文件已经存在,file_put_contents() 的默认行为是完全替换(覆盖)该文件的内容。为了避免这种情况,可以在调用该函数时使用 FILE_APPEND 选项。这将把新文本添加到文件现有内容的末尾。列表 9-10 展示了一个示例,更新自列表 9-9。

<?php
$newContent = <<<CONTENT
    the rat
    spat
    on the cat!
    CONTENT;

$file = __DIR__ . '/newfile.txt';

file_put_contents($file, $newContent, FILE_APPEND);
$text = file_get_contents($file);
print $text;

列表 9-10:一个 main.php 脚本,将文本追加到文件末尾

这次我们创建一个不同的 heredoc 字符串,并通过将 FILE_APPEND 作为第三个参数调用 file_put_contents() 将其添加到 newfile.txt 文件中。这应该会将字符串追加到文件当前内容之后,输出确认了这一点:

the cat
sat
on the mat!
the rat
spat
on the cat!

尝试再次运行列表 9-10 中的代码,不使用 FILE_APPEND 选项。你会发现只有 $newContent 中的文本出现在输出中,因为文件中已有的文本被覆盖了。

管理文件和目录

除了读取和写入文件外,PHP 还提供了帮助管理现有文件和目录的函数。例如,你可以使用 unlink() 函数删除文件,或者使用 rmdir() 删除整个目录。如果成功,两个函数都会返回 true,否则返回 false。与读取文件一样,在尝试删除文件之前,测试文件或目录是否存在非常重要。否则,如果你对不存在的文件或目录调用 unlink()rmdir(),你会收到警告(但执行会继续)。列表 9-11 展示了这些函数的实际应用。

<?php
$dir = __DIR__ . '/var';
$file = $dir . '/data.txt';

if (!is_dir($dir)) {
    mkdir($dir);
}

touch($file);

var_dump(is_dir($dir));
var_dump(file_exists($file));

unlink($file);
rmdir($dir);

var_dump(file_exists($file));
var_dump(is_dir($dir));

列表 9-11:一个 main.php 脚本,用于创建并删除目录和文件

和之前的一些示例一样,我们在两个变量 $dir 和 \(file 中声明目标目录和文件名。然后,如果目录尚未存在,我们会创建该目录,并通过 touch() 创建文件。此时,我们应该确保在*/var* 目录中存在 *data.txt* 文件;我们通过调用 is_dir() 和 file_exists() 并使用 var_dump() 来确认这一点。接下来,我们使用 unlink(\)file) 和 rmdir($dir) 删除文件及其目录。最后,我们再次调用 var_dump(),以确保在脚本执行完毕后,目录和文件都不存在。如果你运行此脚本,你应该看到 true, true, false, false 的输出,确认目录和文件曾经存在并已成功删除。

另一个有用的文件管理函数是 rename(),它用于更改文件或目录的名称。例如,你可以使用以下语句将oldfile.txt重命名为newfile.txt

rename('oldfile.txt', 'newfile.txt');

使用此函数时需要小心,首先要测试旧的文件或目录是否存在。还需要特别注意新文件或目录。如果你正在重命名一个文件,而另一个同名的文件已经存在,那么该文件将被覆盖而不会显示错误或警告,如果你需要被覆盖文件的内容,这可能会造成问题。如果你正在重命名一个目录,而新目录已经存在,则会生成一个警告,这也不是理想的情况,因为最好避免出现警告。如果你正在将文件重命名到另一个目录中,你还应该确保新目录存在,并且如果需要的话,该目录是可写的(这是 Windows 系统的要求)。有关此函数的更多信息,请参见 www.php.net/manual/en/function.rename.php

将文件读取到数组中

PHP 内置的 file() 函数将文件的内容读取到一个数组中,而不是单一的字符串,每一行对应数组中的一个元素。当你想对每一行执行某个操作(例如,像以下示例一样显示行的内容及其行号),或者当每一行代表需要处理的数据集中的一个项时,这个功能非常有用,比如逗号分隔值(CSV)文件中的数据。Listing 9-12 展示了一个主脚本,演示了 file() 函数的用法。

<?php
$file = __DIR__ . '/data.txt';

$lines = file($file);

foreach ($lines as $key => $line) {
    print "[$key]$line";
}

Listing 9-12: 一个用于循环遍历并打印文本文件每一行的 main.php 脚本

我们将文件信息(保存在\(file 变量中)传递给 file() 函数,后者会将*data.txt*的内容逐行读取到一个名为\)lines 的数组中。然后,我们使用 foreach 循环逐个打印数组的每个元素(文件中的一行),并显示其数字键。如果data.txt包含Listing 9-1中的三行俳句,则输出应该如下所示:

[0]what is with this code?
[1]oh my, looks like I wrote it
[2]what was I thinking?

你可以将可选的标志作为第二个参数传递给 file() 函数,例如,排除每行末尾的换行符(FILE_IGNORE_NEW_LINES)或完全忽略文件中的空行(FILE_SKIP_EMPTY_LINES)。

使用低级文件函数

file_get_contents() 和 file_put_contents() 函数会为你处理所有与文件相关的步骤,比如打开文件、访问文件内容以及再次关闭文件。在大多数情况下,这些函数已经足够满足需求。然而,有时你可能需要更低级地处理文件,可能是逐行,甚至逐字符地处理。在这种情况下,你可能需要通过一系列单独的低级函数调用来显式地管理文件访问的各个步骤。

PHP 的低级文件函数要求你使用 文件系统指针(或简称 文件指针),这是文件数据位置的引用。在内部,PHP 将文件视为 字节流(一个可以线性读取和写入的资源对象),文件指针提供对该字节流的访问。你可以通过调用 fopen() 并传入你想访问的文件路径来获得文件指针。你还需要传入一个字符串,指定 如何 与文件进行交互;例如,文件可以只为读取、只为写入、同时为读写等模式打开。表 9-1 显示了指定一些常见 fopen() 模式的字符串。

表 9-1:常见的 fopen() 模式

模式字符串 描述 文件指针位置 如果文件不存在的结果
'r' 仅读 文件开头 警告
'r+' 读写(覆盖) 文件开头 警告
'w' 仅写(覆盖) 文件开头(并通过移除现有内容来截断文件) 尝试创建文件
'a' 仅写(追加) 文件末尾 尝试创建文件

操作文件的典型步骤如下:

1.   以适当的模式打开文件并获取文件指针。

2.   如果需要,改变文件指针在文件中的位置。

3.   在文件指针的位置读取或写入。

4.   根据需要重复步骤 2 和 3。

5.   关闭文件指针。

示例 9-13 演示了这个过程。这个脚本通过使用低级的 fopen()、fread() 和 fclose() 函数,达到了与 示例 9-2(将文件内容读取为字符串)相同的效果。

<?php
$file = __DIR__ . '/data.txt';

$fileHandle = fopen($file, 'r');
$filesizeBytes = filesize($file);
$text = fread($fileHandle, $filesizeBytes);
fclose($fileHandle);

print $text;

示例 9-13:使用低级函数读取文件

首先,我们使用 fopen() 打开 data.txt,使用字符串 'r' 来指定只读模式。该函数返回一个文件指针,指向文件的开头,我们将其存储在 \(fileHandle 变量中。接下来,我们调用 filesize() 查找文件的大小(以字节为单位)。然后我们调用 fread() 函数,将文件指针和文件大小(\)filesizeBytes)传递给它,以将整个文件的内容读取到 $text 变量中。如果我们只想读取文件的一部分,可以在 fread() 函数的第二个参数中指定不同的字节数。(如果文件指针位于文件的某个位置而不是开头,我们也需要指定不同的字节数。)最后,我们通过将文件指针传递给 fclose() 函数来关闭文件。关闭文件可以使其被其他系统进程使用,并在脚本执行过程中发生错误时防止文件被损坏。

本示例展示了一些最常见的低级文件操作函数,但 PHP 还有许多其他函数。例如,fgets() 从当前文件指针位置读取一行(直到下一个换行符),fgetc() 从当前文件指针位置读取一个字符。feof() 函数接受一个文件指针,并根据指针是否处于文件末尾返回 true 或 false。这在如下循环中非常有用:

while (!feof($fileResource)) {
    // Do something at current file pointer position
}

这里我们使用 NOT 运算符(!)来否定 feof() 的结果,因此循环将持续进行,直到指针到达文件末尾。在这种循环中,我们可能会使用 fgets() 从文件中读取一行,使用 fgetc() 读取下一个字符,或者使用 fread() 读取固定数量的字节。然后,循环中的逻辑将处理读取到的数据(如果成功读取),如果在读取过程中到达文件末尾,循环将终止。

一些函数只是用于操作和更改文件指针。例如,rewind() 将文件指针移回到文件的开头,ftell() 返回文件指针的当前位置信息,表示为从文件开头开始的字节数。fseek() 函数将文件指针移动到文件中的指定位置,该位置相对于当前指针位置、文件开头或文件末尾指定。

处理多个文件

让我们通过一个更复杂的示例,将本章迄今讨论的内容结合起来,程序化地从多个文件中提取数据,并将其汇总到一个新的摘要文件中。我们将尝试收集三名玩家的姓名和游戏分数,每个玩家的数据存储在单独的文件中(joe.txtmatt.txtsinead.txt),对数据进行重新格式化,然后写入名为 total.txt 的输出文件中。清单 9-14 到 9-16 显示了我们想要处理的三个原始数据文件。

Joe
O'Brien

55

清单 9-14:joe.txt

Matthew

Smith

99

清单 9-15:matt.txt

 Sinead
Murphy

101

清单 9-16:sinead.txt

请注意,每个数据文件的内容有点杂乱,存在随机位置的空行:清单 9-15 以一个空行结尾,清单 9-16 以两个空行开始和结束。尽管如此,每个数据文件的内容顺序是相同的:第一行包含玩家的名字,第二行包含他们的姓氏,第三行包含他们的整数分数。

在输出文件中,我们希望将每个玩家的所有数据合并到一行中,并显示所有三名玩家分数的总和。清单 9-17 展示了最终生成的total.txt文件应该如何显示。

Player = Joe O'Brien / Score = 55
Player = Matthew Smith / Score = 99
Player = Sinead Murphy / Score = 101
total of all scores = 255

清单 9-17:我们想要创建的合并后的 total.txt 文件

为了实现最终结果,我们需要分别处理每个数据文件的不同部分,因此不能仅仅使用 file_get_contents()将整个文件加载为一个字符串。更好的做法是使用 file()函数将每个文件读取为一个包含单独行的数组。

在处理多个文件时,PHP 名为 glob()的函数是一个强大的工具。它返回一个匹配给定模式的文件和目录路径数组。这对于识别并循环遍历指定位置的所有数据文件特别有用。例如,以下语句提供了一个包含/data子文件夹中所有.txt文件路径的数组,相对于执行脚本所在的位置:

$files = glob(__DIR__ . '/data/*.txt')

是一个通配符,代表任意数量的字符,因此'/data/.txt'将匹配给定文件夹中任何以.txt扩展名结尾的文件名。这正是我们在这个示例中收集玩家数据文件所需要的。

启动一个新项目,并创建一个包含前面在清单 9-14 至 9-16 中展示的文本文件joe.txtmatt.txtsinead.txt/data子文件夹。然后,在主项目文件夹中,创建一个名为main.php的脚本,并包含清单 9-18 中的内容。

<?php
$dir = __DIR__ . '/data/';
$fileNamePattern = '*.txt';
$files = glob($dir . $fileNamePattern); ❶

$outputFile = __DIR__ . '/total.txt';
touch($outputFile);
unlink($outputFile);

$total = 0;
foreach ($files as $file) {❷
    $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    $firstName = $lines[0];
    $lastName = $lines[1];
    $scoreString = $lines[2];
    $score = intval($scoreString);

    $outputFileHandle = fopen($outputFile, 'a');
    fwrite($outputFileHandle, "Player = $firstName $lastName / Score = $score\n"); ❸
    fclose($outputFileHandle);

    $total += $score;
}

$outputFileHandle = fopen($outputFile, 'a');
fwrite($outputFileHandle, "total of all scores = $total");
fclose($outputFileHandle);

print file_get_contents($outputFile); ❹

清单 9-18:处理多个文件的脚本

我们首先将执行脚本所在位置的/data子文件夹路径赋值给\(dir 变量,并将文件名模式字符串'*.txt'赋值给\)fileNamePattern,使用通配符表示任何.txt*文件。接着,我们调用 glob()函数,获取\(dir 中与\)fileNamePattern 匹配的所有文件数组,并将结果存储在\(files 变量中❶。由于 glob()函数的帮助,我们知道\)files 数组中的所有文件都存在,因此可以避免在尝试读取文件之前检查它们是否存在的麻烦。

接下来,我们将total.txt的路径赋值给$outputFile 变量。这个文件可能已经存在,也可能不存在,但我们希望每次运行脚本时都能生成一个新的输出文件。因此,我们使用 touch()函数来创建文件(如果它尚不存在),然后使用 unlink()函数删除该文件。现在,我们可以确保在将数据合并到total.txt时,文件是空的。

在将\(total 变量初始化为 0 之后,我们使用 foreach 循环❷遍历\)files 数组中的文件路径,将每个路径存储到临时变量\(file 中。对于每个文件,我们使用 file()函数将其内容读取到一个名为\)lines 的数组中。使用 FILE_IGNORE_NEW_LINES 和 FILE_SKIP_EMPTY_LINES 标志调用该函数,确保忽略行尾字符并排除空行。这意味着根据我们对每个数据文件的了解,\(lines 应该是一个包含三个元素的数组:第一个元素是玩家的名字,第二个元素是他们的姓氏,第三个元素是他们的分数(以字符串形式表示)。我们从数组中读取这些值到单独的\)firstName、\(lastName 和\)scoreString 变量,并使用内置的 intval()函数将分数从字符串转换为整数。

仍然在 foreach 循环内,我们调用 fopen()来获取输出文件(total.txt)的文件指针,采用写附加模式(通过模式字符串'a'指定),这意味着指针将定位到文件末尾。第一次通过循环时,total.txt文件不会存在,因此 fopen()会创建该文件。然后,我们使用 fwrite()将一个字符串附加到输出文件中,总结玩家的姓名和分数,并以换行符(\n)结束❸。我们通过 fclose()关闭输出文件,并将当前玩家的分数添加到$total 变量中。

最后,在 foreach 循环完成后,我们再次以写附加模式访问输出文件,并附加一个包含$total 值的最终字符串。然后,为了确保一切正常工作,我们调用 file_get_contents()函数读取输出文件到一个字符串中并打印结果❹。注意,我们直接从打印语句中调用该函数,而不是先将字符串存储到一个变量中。

如果运行main.php脚本,你应该得到之前在列表 9-17 中显示的total.txt文件。事实上,你可以随意多次运行该脚本,结果将始终相同,因为任何现有的total.txt文件都会在 touch()和 unlink()函数的组合操作下被删除。

严格来说,我们的main.php脚本并不是实现所需逻辑的最高效方式。我们不需要在每次执行 foreach 循环时都打开和关闭输出文件;我们可以在循环之前只打开一次文件,然后在附加总分后再关闭它。然而,每次通过循环时打开文件可以说明写附加模式的价值,这种模式将文件指针放置在文件的末尾。这样,任何新写入文件的内容都会被添加到现有内容之后。

JSON 和其他文件类型

PHP 不仅能处理 .txt 文件。例如,它还可以处理 JavaScript 对象表示法(JSON)以及其他基于文本的数据格式。对于 JSON 数据,内置的 json_encode() 函数可以将 PHP 数组转换为 JSON 字符串,而 json_decode() 函数则执行相反的操作。这种类型的转换特别顺畅,因为 JSON 数据与 PHP 数组一样,都是围绕键/值对构建的。示例 9-19 展示了这些函数的实际应用。

<?php
$filePath = __DIR__ . '/data.json';

$data = [
    'name' => 'matt',
    'office' => 'E-042',
    'phone' => '086-111-2323',
];

$jsonString = json_encode($data);

file_put_contents($filePath, $jsonString);

$jsonStringFromFile = file_get_contents($filePath);
print $jsonStringFromFile;

$jsonArrayFromFile = json_decode($jsonStringFromFile, true);
print "\n";
var_dump($jsonArrayFromFile);

示例 9-19:一个将数组转换为 JSON 以及将 JSON 转换回数组的脚本

我们将 data.json 的路径存储在 $filePath 变量中。然后我们声明一个 $data 数组,将 'matt'、'E-042' 和 '086-111-2323' 分别映射到键 'name'、'office' 和 'phone'。接下来,我们使用 json_encode() 函数将数组转换为 JSON 格式的字符串,并将结果存储在 $jsonString 变量中。然后,我们使用 file_put_contents() 将 JSON 字符串写入 data.json 文件,就像我们写入 .txt 文件一样。

脚本的其余部分执行相同的反向操作。我们使用 file_get_contents() 从文件中读取 JSON 数据到 $jsonStringFromFile 变量中,并将其打印出来。该变量包含一个 JSON 字符串,但我们使用 json_decode() 将字符串转换为 PHP 数组,并通过 var_dump() 展示。我们需要提供 true 作为 json_decode() 函数的第二个参数,否则结果将是一个对象类型,而不是数组类型。以下是运行此脚本时在终端的输出:

{"name":"matt","office":"E 042","phone":"086 111 2323"}
array(3) {
  ["name"]=>
  string(4) "matt"
  ["office"]=>
  string(5) "E-042"
  ["phone"]=>
  string(12) "086 111 2323"
}

第一行展示了我们写入并从 data.json 文件中读取的 JSON 字符串。该字符串由一个 JSON 对象组成,使用大括号括起来,包含三个由逗号分隔的键/值对。键与其对应的值通过冒号分隔。其余部分显示了 $jsonArrayFromFile 的内容,这是通过解码 JSON 数据创建的数组。注意 JSON 对象中的键/值对与 PHP 数组中的键/值对之间的直接对应关系。

对于 YAML(YAML 不是标记语言)文本数据文件,PHP 提供了多个函数。例如,yaml_parse() 和 yaml_emit() 函数类似于 json_decode() 和 json_encode(),用于在 YAML 字符串和 PHP 数组之间转换。PHP 还提供了直接的文件到字符串和字符串到文件的 YAML 函数:yaml_parse_file() 和 yaml_emit_file()。

对于 CSV 文件,PHP 提供了直接的文件到字符串和字符串到文件的函数 fgetcsv() 和 fputcsv()。str_getcsv() 函数接受一个 CSV 格式的字符串并将其转换为数组。然而,该函数存在一些缺陷。例如,它不会转义换行符,因此无法处理来自电子表格(如 Google Sheets 或 Microsoft Excel)的典型 CSV 文件。可能正因为这样 PHP 在处理 CSV 数据时不符合标准,导致它没有一个相应的函数来从数组创建 CSV 编码的字符串。

使用可扩展标记语言(XML)要复杂一些。PHP 用对象表示 XML 数据,因此你需要掌握面向对象编程的基础知识,才能使用像 simplexml_load_file() 这样的函数和 SimpleXMLElement 这样的类。然而,一旦你掌握了这些语言特性,PHP 提供了几种强大的方法来遍历和操作 XML 数据。我们将在 Part V 中讨论面向对象的 PHP。

总结

在本章中,我们使用了基本的 PHP 函数,如 file_get_contents() 和 file_put_contents(),用于读取和写入外部文件的数据。我们还讨论了 file() 函数,它将文件的每一行读取为单独的数组元素,以及像 fread() 和 fwrite() 这样的低级函数,它们允许你通过指针遍历文件。我们探讨了如何在与文件交互之前,确保文件或目录存在(或不存在),以及如何使用 glob() 获取所有匹配特定标准的文件的引用。虽然我们大部分时间都在处理 .txt 文件,但我们也涉及了一些 PHP 函数,用于处理 JSON、YAML、CSV 和 XML 数据格式。

练习

1.   在网上找一首打油诗,或者自己写一首。我找到了一首:

A magazine writer named Bing
Could make copy from most anything
But the copy he wrote
of a ten-dollar note
Was so good he now lives in Sing Sing

编写一个脚本,声明一个数组;数组的每个元素是打油诗中的一行。然后将这些行写入一个名为limerick.txt的文本文件中。

2.   在网上找到一个可以通过 URL 访问的示例 JSON 文件(例如,* jsonplaceholder.typicode.com *)。编写一个脚本,从 URL 读取 JSON 字符串,将其转换为数组,然后使用 var_dump() 显示该数组。

3.   在 data 文件夹中为游戏玩家及其最高分添加一个新的数据文件,以供 Listing 9-18 中的脚本处理。运行主脚本,你应该会看到输出文件中多了一行,并且新分数已添加到总分中。

第三部分 编程 Web 应用程序

第十章:10 客户端/服务器通信与网页开发基础

作为互联网语言,PHP 与网页客户端和网页服务器之间的通信紧密相关。在本章中,我们将了解客户端和服务器如何工作,检查它们之间传递的消息。我们还将学习如何有效地将 PHP 语句嵌入静态 HTML 代码中,以构建一个完整的 HTML 文本文件,网页浏览器客户端可以理解并在屏幕上呈现为网页。最后,我们将讨论典型的 PHP 网页应用程序的结构,包括对模型-视图-控制器(MVC)架构的初步了解。

不管你是否意识到,你可能每天都会使用客户端和服务器。当你检查你的电子邮件或社交媒体账户时,你正在使用一个客户端应用程序与服务器进行通信,请求更新。这类应用程序不断地向服务器发送请求;例如,你的电子邮件应用程序会向像 Google Gmail 或 Apple iCloud 这样的服务器请求,以便下载任何新邮件并更新手机上的信息和文件夹,确保与服务器上的变化保持同步。

你可以在两个地方运行网页服务器应用程序:本地计算机上,或者是一个可以公开访问的互联网计算机上。作为 PHP 程序员,你会在自己的机器上进行大量本地开发。当你觉得一个项目准备好了,你会在公共服务器上测试它,最后,在所有测试完成后将网站发布到线上。

HTTP 请求-响应周期

在基于网页的客户端/服务器通信的核心是HTTP 请求-响应周期。从高层次来看,客户端向服务器发送请求,然后服务器向客户端返回响应。响应本身可以是一个错误代码,或者是一个包含文本、图像文件、二进制可执行文件或其他内容的消息。图 10-1 展示了一个简单的请求-响应周期。

图 10-1:一个简单的 HTTP 请求-响应周期

客户端,即一个网页浏览器,发送请求,要求获取index.xhtml文件❶。服务器接收到并解码请求,然后搜索并成功找到请求的资源(文件)❷。服务器接着创建并返回一个响应,其主体是index.xhtml的 HTML 文本❸。最后,网页浏览器读取收到的 HTML,并在浏览器窗口中漂亮地显示网页内容给用户❹。

客户端可以发送不同类型的请求。两种最常见的请求方法是 GET 和 POST。HTTP GET 方法更简单,在使用 Web 浏览器客户端时,显示许多发送的内容在 Web 浏览器的 URL 地址栏中。例如,如果你使用 Google 搜索引擎搜索 cheese cake 这个短语,你会看到这些词出现在 URL 的末尾,发送查询给 Google 时是这样的:https://www.google.com/search?q=cheese+cake。实际上,每当你在浏览器地址栏中输入一个 URL 并按下回车键时,你就是在发送 GET 请求。

POST 方法可以隐藏请求消息体中发送的许多内容。因此,它通常用于更私密的网站操作。

除了 GET 和 POST,原始的 HTTP 1.0 定义了第三种方法 HEAD。它请求一个没有消息体的响应,只有头部,其中包含关于响应的一般信息。自从引入 HTTP 1.1 后,还允许使用其他五种方法(OPTIONS、PUT、DELETE、TRACE 和 CONNECT)。这些方法对于本书中讨论的 Web 开发层级并不需要,尽管它们对于复杂的 Web 应用程序来说是有用的。

响应状态码

每个由服务器返回的 HTTP 响应开头都有一个三位数的 HTTP 状态码,告诉客户端服务器尝试处理并完成请求的状态。所有符合 HTTP 标准的服务器必须使用一套标准代码,此外,不同的服务器还会使用自定义代码。最常见的代码是 200 OK,表示请求已成功完成,以及 404 Not Found,表示服务器无法找到请求的资源。

状态码的第一位数字表示服务器对请求的解释和处理的总体状态。以下是第一位数字的含义概述:

1nn (信息性状态码)   请求头已经接收并理解,进一步处理需要进行。换句话说,“到目前为止一切顺利,但还没有完成。”这些状态码相对不常见,它们是信息性状态码,用于当服务器需要向客户端传达某些信息,但不是完整的响应时。

2nn (成功)   请求已接收、理解并接受(例如,200 OK)。

3nn (重定向)   请求已理解,但客户端必须采取进一步行动,例如从选项中选择(300 多重选择)或如果资源已永久移动,按照新 URL 访问(301 永久移动)。

4nn (客户端错误)   请求无效(如 400 错误请求),或者由于客户端错误,服务器无法完成请求(如 404 未找到或 403 禁止访问)。

5nn (服务器错误)   服务器遇到错误或因其他原因无法完成请求。例子包括 500 服务器错误和 502 服务不可用。

你可以在 Todd Fredrich 的免费在线 REST API 教程中了解更多关于 HTTP 及其状态码的内容:www.restapitutorial.com

一个 GET 请求的例子

让我们通过查看访问 No Starch Press 网站时发生的幕后情况,来看一个简单的请求-响应周期的例子。首先,你需要显示浏览器的请求-响应检查工具。在 Google Chrome 中,这些工具通常可以通过名为开发者工具的菜单项访问。打开开发者工具后,你会看到一个窗口,类似于图 10-2 底部的样子。

图 10-2:GET 请求 No Starch Press 主页

点击网络标签,你就可以开始记录和查看 HTTP 请求-响应周期了。在浏览器地址栏中输入nostarch.com。当你按下 ENTER 键时,你应该能看到主页出现。在开发者窗口的左侧找到“名称”列,定位到第一个文件,它应该是nostarch.com,然后点击它。点击头部以查看 HTTP 头部的总结,显示在图 10-3 中。

这个总结表示,HTTP 请求的目标是网址nostarch.com,请求方法为 GET(因为我们刚在地址栏中输入了网址)。HTTP 响应头中最重要的部分是 200 的成功状态码。

向下滚动 HTTP 头部内容,你将看到 HTTP 请求和 HTTP 响应头部的详细信息。在请求头部部分,你可以看到网页客户端愿意接受的文件类型列表,如 HTML、XML、图像等。你还可以看到内容可用的语言(例如,EN 代表英语)。对应地,响应头部则指示响应体中的实际内容类型,如 text/html、文件的最后修改日期等。

注意

现代大多数网站现在使用超文本传输协议安全(HTTPS),它使客户端和服务器能够交换证书,从而确保 HTTP 消息的加密传输。这就是为什么 No Starch Press 的网址以 https://开头的原因。HTTPS 已集成在许多 PHP 网页服务器中,因此我们此时不再深入讨论它。

现在点击响应标签查看响应体的内容,如图 10-3 所示。这是网页浏览器接收到的 HTML 文本,然后它会渲染成一个吸引人的图形网页,供你查看和交互。

图 10-3:HTTP 响应体中的 HTML 文本内容

在 HTML 代码的底部,你会看到一列 CSS 链接。当处理收到的 HTML 时,Web 客户端(浏览器)会寻找页面所需的其他内容文件,如 CSS 样式表、图像文件和 JavaScript 文件。浏览器会快速地(在现代网络速度下我们很少注意到这一点)向服务器发起额外的 HTTP 请求来获取这些文件,并且随着相应的 HTTP 响应到达,浏览器会渲染网页。这些从 Web 服务器收到的额外文件可以在 图 10-2 的名称列中看到,位于原始请求 nostarch.com 下面,它们的名称像 css_lQaZ 等等。

需要强调的是,并非每个 HTTP 请求都必须由用户通过输入 URL、点击链接或提交表单来发起。Web 浏览器可以在后台(异步地)发起额外的请求,以获取所需的资源,例如图像、CSS 文件和 JavaScript 文件。这些额外的请求可能会发送到与浏览器正在处理的 HTML 相同的 Web 服务器,或者发送到其他 Web 服务器(例如,下载免费的 Google 字体或 Bootstrap CSS 和 JavaScript)。

注意

JavaScript 代码也可以发起额外的 HTTP 请求,例如从远程网站获取数据。这被称为 异步 JavaScript 和 XML (AJAX),虽然许多类型的数据文件可以被检索,例如 JSON 和纯文本,因此这样的 HTTP 请求并不限于仅检索 XML 数据。这个话题超出了本 PHP 书的范围。

服务器的运作方式

我们已经在较高层次上讨论了客户端和服务器如何通过 HTTP 请求和响应进行通信。现在,让我们更仔细地看看 Web 服务器的工作原理。我们还将开始了解 PHP 如何在服务器的操作中发挥作用。

用于文件检索的简单 Web 服务器

简单 Web 服务器的任务是监听资源请求,并在收到请求时,识别请求的资源并返回包含资源的消息,或者如果找不到该资源,则返回错误消息。简单的 Web 服务器本质上是一个能够理解 HTTP 请求并发送 HTTP 响应的文件服务器。图 10-4 展示了一个简单的 Web 服务器。

图 10-4:简单的 Web 服务器与 Web 客户端的通信

通常,客户端发送一个 GET 请求 ❶,请求一个文件,如 index.xhtmlstyle.csslogo.png。服务器接收并解释该请求,然后搜索请求的资源(文件) ❷。如果文件找不到,服务器会创建并返回一个 404 Not Found 错误。如果文件找到,服务器将检索其内容 ❸。最后,服务器会创建并返回一个响应给客户端 ❹。响应体是请求文件的内容,其头部包括 200 OK 状态码。

这个过程的一个很好的类比是,一个简单的网页服务器就像图书馆中的图书管理员:图书管理员会去找到请求的书籍,并带回书籍或一条信息,说明书籍无法找到。

简单的网页服务器足以支持超文本或超媒体浏览不变的 HTML 页面集,例如常见问题解答(FAQ)及其回答段落,或者是很少需要更新的参考资料,如用户手册。简单的网页服务器是无状态的,这意味着相同的请求总是返回相同的文件。不同的客户端也会收到相同的文件。这通常被称为静态内容,表示它是固定不变的。回到图书管理员的类比,你不会期望图书管理员在取书时改变书的内容。

我们可以总结简单的无状态网页服务器的行为如下:

  • 永不改变

  • 无论用户是否曾经访问过,都一样

  • 对每个用户都相同

大多数网页活动比仅仅点击链接获取特定的静态文档要更加互动。大多数现代网页项目需要动态交互性,即系统根据用户输入做出不同的响应。动态交互性包括处理网页表单、管理购物车、根据最近的浏览历史定制内容等任务。大多数 PHP 网页应用程序是动态网页服务器,接下来我们将深入探讨。

处理数据的动态网页服务器

对于一个基于网页的系统,要实现超越静态资源获取的交互性,除了基本的内容标记和超文本链接外,还需要其他技术。这些能力包括以下内容:

  • 支持用户输入方法,如输入文本、点击按钮和从菜单中选择

  • 可以根据不同的用户输入以不同方式处理和响应的简短代码脚本

  • 浏览器将用户输入或数据发送到服务器程序,以便处理数据并生成交互式响应的方法

具备这些功能的动态服务器处理许多现代互联网活动,如在搜索引擎中输入关键词并呈现一个定制的、优先排序的链接页面,登录到个人电子邮件系统并查看自己的收件箱中的邮件,在线浏览商品目录并使用信用卡完成购买。

在本书中,我们最关注的是理解并能运行 PHP 脚本的动态网页服务器。图 10-5 展示了客户端与这样一个动态网页服务器的通信。

图 10-5:动态网页服务器与网页客户端的通信

在此模型中,客户端向服务器发送 HTTP 请求 ❶。然后,服务器程序解析请求并识别应执行哪个 PHP 服务器脚本 ❷。脚本执行 ❸ 并生成输出,例如 HTML 文本。运行 PHP 脚本还可能触发 Web 服务器上的其他操作,例如与数据库的通信,这是我们将在第六部分中探讨的内容。接下来,Web 服务器应用程序接收输出 ❹。最后,输出被打包到 HTTP 响应消息的主体中,并带着适当的头信息返回给最初发出请求的客户端 ❺。

路由过程

路由是 Web 服务器用来决定如何响应所收到的 HTTP 请求的过程;服务器检查请求并确定它认为客户端请求的操作,如请求文件、尝试使用包含在请求中的用户名和密码数据登录、从数据库中删除项等。在最简单的情况下,请求包含特定资源文件的有效路径,如/images/logo.jpg/styles/homepage.css。在这种情况下,Web 服务器充当文件服务器,返回包含文件内容并带有适当头信息的 HTTP 响应消息。

如果请求了有效的公开可用的php文件路径,如/about.php,则该 PHP 脚本将被解释并执行,以构建返回给客户端的 HTTP 响应。如果没有请求特定的文件,几乎所有 Web 服务器都有定义的默认路由,通常会路由到主页文件,通常为index。简单的静态 Web 服务器会查找index.xhtml作为默认主页返回,而 PHP Web 服务器通常会首先查找index.php,如果没有找到默认的 PHP 文件,则可能会查找index.xhtml。如果没有请求文件且未找到索引文件,服务器将返回 404 Not Found 响应。

复杂的 PHP Web 应用程序将使用编码在默认index.php脚本中的逻辑来检查请求的 URL 路径的内容和模式,从而决定如何响应请求。使用这种逻辑来管理多功能网站复杂性的index.php 文件被称为前端控制器

以下是 Web 浏览器用来向 Web 服务器发出请求的一些 URL 示例,并附有解释其含义:

tudublin.ie   在域名后没有指定路径,因此 Web 服务器将执行默认的主页脚本(如果是 PHP Web 服务器,则为index.php)。TU Dublin 主页的 HTML 内容会返回给客户端。

bbc.com/travel/columns/discovery   该路径包含通过斜杠分隔的文本,因此主页脚本执行逻辑,搜索站点数据库中与主要话题旅行和子话题发现相关的内容。

nostarch.com/sites/all/themes/nostarch/logo.png   路径包含一个静态资源文件,因此 Web 服务器会定位并返回logo.png图像文件的内容。

google.com/search?q=cheese+cake   路径包含指示搜索的文本,在正斜杠后面(search),然后是搜索文本(cheese cake),这是一个在问号字符(?)后面赋值的变量(q)。因此,Google 主页脚本会执行逻辑,搜索与cheese cake相关的网页。在第十一章中,你将学习如何通过变量在 URL 中传递数据。

在第十三章中,我们将研究如何编写 PHP 前端控制器逻辑来执行路由决策,类似于这里总结的内容。

模板

几乎所有 PHP 应用程序都设计用来运行网站。对于大多数 HTTP 请求,响应的内容是某种文本,如 HTML、JavaScript 或 CSS 文件,或者可能是编码为 JSON 或 XML 的数据。因此,PHP 的设计是为了方便输出文本(例如,使用 print 和 echo 命令)。

此外,正如第一章中所暗示的,语言还使得将预写文本(如 HTML)与通过执行 PHP 代码动态生成的文本混合变得容易。这个特性使得 PHP 成为模板语言:它可以将动态生成的值插入到 HTML 或其他文本的静态模板中。基于 PHP 的网站受益于这种动态输出,可能是通过数据库交互或与各种数据源(如 Google Maps、天气 API 等)的通信来实现的。

在前几章中,我们编写了纯 PHP 程序,这些程序仅包含 PHP 代码。一旦我们开始使用 PHP 作为模板语言,混合 PHP 语句和其他模板文本(通常是 HTML)变得更加常见。这使得我们可以将不变的 HTML 网页部分直接写为 HTML;任何需要动态变化的部分都可以通过我们在 PHP 语句中编写的逻辑来输出。方便的是,许多网页中的 HTML 包含大量相同的内容,例如页头、导航栏(可能仅通过突出显示访问的特定页面而发生变化)和页面布局 HTML 代码(例如,div、header 和 footer 元素的层次结构)。所有这些不变的静态内容都非常适合用于 PHP 模板。

理论上,通过编写大量的 print 语句,可以让纯 PHP 脚本输出 HTML,但这种方法会导致代码冗长且难以阅读。请查看清单 10-1,它通过使用纯 PHP print 语句来输出 HTML。

<?php
print '<!doctype html>';
print '<head><title>home</title></head>';
print '<body>';
print '<p>Welcome to My Great Website<br>';
❶ print 'today is ' . date('F d, Y');
print '<p>';
print '</body></html>';

清单 10-1:通过 print 语句输出 HTML

我们在这里使用的唯一真正的 PHP 逻辑是调用 date() 函数,以字符串形式获取当前日期,格式为 月 日, 年(例如,2025 年 1 月 1 日)❶。其他所有行都是输出不变的 HTML 的打印语句,这些打印语句并不是必需的。通过仅在需要的地方使用 PHP,并将其插入到 HTML 模板中,我们可以使代码更加简洁和可读。这正是我们在示例 10-2 中所做的,在那里不变的 HTML 被直接写入,正如它最终将出现在发送给客户端的 HTML 文本文件中。

<!doctype html>
<head><title>home</title></head><body>
<p>Welcome to My Great Website<br>
today is
❶ <?php
    print date('F d, Y');
❷ ?>
</p>
</body></html>

示例 10-2:将 HTML 模板文本与 PHP 代码块混合

我们使用开头的 ❶ 和结尾的 ❷ PHP 标签将调用 date() 函数的打印语句包围起来,因为这是唯一需要 PHP 代码来动态生成内容的地方。与此同时,我们将其他所有内容编写为常规 HTML;不需要打印语句、引号或分号。图 10-6 显示了 PHP 如何查看并处理脚本内容。

图 10-6:PHP 如何处理混合模板文本和动态代码

首先,需要将一块模板文本逐字复制到文本输出中。接下来,需要解释和执行一块 PHP 代码(位于 之间),然后将结果添加到脚本的文本输出中。最后,需要将另一块模板文本逐字复制到输出文本中。PHP 脚本的多个部分输出的文本的临时存储区被称为 输出缓冲区

假设示例 10-2 中的脚本是来自 web 浏览器的 HTTP 请求的一部分。当脚本中的所有 PHP 执行完成后,输出缓冲区中的文本将通过添加头部信息被封装成一个 HTTP 响应,并发送回浏览器客户端。然后,浏览器将渲染(绘制)网页以供用户查看,解释它在 HTTP 响应的主体文本中收到的 HTML,从而显示图 10-6 底部所示的简单页面。

PHP 标签

正如你刚才看到的,当你在模板文本中嵌入 PHP 代码时,重要的是使用开头的 标签来界定代码。相比之下,当编写仅包含代码的 PHP 脚本时,脚本应以开头的 标签。

你省略结尾标签有两个原因。首先,你不需要它,因为代码没有模板文本需要与 PHP 语句分隔开。其次,如果你包括了结尾的 PHP 标签,任何(无意的且不可见的)空白字符,如空格、制表符或换行符,都会在结尾标签后出现,并被解释为模板文本,可能会过早开始创建输出缓冲区。

短 echo 标签

到目前为止,我们一直关注 PHP 的主要 <?php 标签,但该语言还提供了一个简短回显标签,用 <?= 符号表示,它进一步简化了模板编写。这个标签可以让你避免编写冗长的命令,当你只想将表达式的结果输出为文本时。比如,显示一个变量的内容,或者是复杂计算或字符串连接的结果。例如,你可以用简短的回显标签 <?= $someVariable ?> 代替写 <?php print $someVariable; ?> 来输出 $someVariable 的值。

简短回显标签减少了输入,因为它省略了 print(或 echo),并且不需要结尾的分号。此外,任何有经验的 PHP 程序员看到简短回显标签时,能立刻识别出唯一的逻辑就是输出一个字符串。总体来说,简短回显标签的主要优势在于,当脚本主要包含 HTML 模板文本时,它不会让读者(或编写者)被多余的 PHP 代码块语法分心。动态生成的 PHP 代码值与周围的 HTML 更好地融合,正如清单 10-3 所示。

<?php
❶ $dateString = date('F d, Y', time());
?>
<!doctype html><head><title>home</title></head><body>
<p>Welcome to My Great Website<br>
❷ Today is <?= $dateString ?>
</p>
</body></html>

清单 10-3:使用 PHP 简短回显标签简化代码

在一个完整的 PHP 代码块中,包围在普通 PHP 标签之间,我们创建了一个包含格式化日期字符串的 $dateString 变量 ❶。这让我们可以简单地在模板中希望输出字符串的位置写 <?= $dateString ?>,使用简短回显标签 ❷。无需编写 print 语句或分号。

模型-视图-控制器架构

几乎所有的大型 Web 应用程序都会将不同的责任委派给不同的系统组件。大多数应用通过实现某种形式的模型-视图-控制器(MVC)架构来做到这一点。这是一种软件设计模式,用于区分软件的基础数据(模型)、数据如何显示给用户(视图)以及何时显示哪些数据的决策(控制器)。

本章中我们已经涉及了 MVC 架构的一些方面。我们提到过 PHP 应用程序如何根据传入的 HTTP 请求做路由决策(控制器任务),以及如何通过将动态生成的值注入静态 HTML 文本来使用 PHP 进行模板化(视图任务)。现在让我们再填补一些空白,看看 MVC 模式如何适应请求-响应周期。图 10-7 展示了 Web 应用程序中 MVC 架构的典型解释。

图 10-7:Web 应用程序常见的 MVC 架构

首先,Web 客户端发送一个 HTTP 请求❶。然后,控制器(主要应用逻辑)解析该请求并决定采取什么操作❷。这可能涉及检查任何存储的安全凭证和其他数据(如购物车内容),并决定在收到请求后采取的适当行动。通常,控制器需要读取数据存储的内容,如数据库系统、文件存储,甚至是运行在另一个服务器上的 API。这些数据就是 MVC 模式中的模型组件。如果收到的请求包含来自表单的数据,控制器可能需要更新或删除某些模型数据。

然后,控制器调用视图组件❸,例如模板文件,以创建响应内容并返回给用户。如果需要,控制器在调用视图组件时会传递从模型中收集的数据。最后,控制器将其创建的响应返回给 Web 客户端(并添加任何适当的头信息、响应代码等)❹。

本书中我们将多次回顾 MVC 模式,并进一步探讨如何构建 PHP Web 应用程序。如前所述,在第十三章中,我们将研究如何创建一个前端控制器脚本来管理架构中的控制器部分。在第二十一章中,我们将介绍 Twig 库,它简化了架构中视图部分的模板化。最后,在第六部分,从第二十七章开始,我们将探讨如何将 PHP 应用程序与数据库集成,以处理架构中的模型部分。

PHP Web 开发项目的结构

正如我们在第一章中讨论的那样,PHP 引擎自带一个内置的 Web 服务器,供测试使用,你可以通过命令行使用 php -S localhost:8000 命令运行它。默认情况下,这个命令会使当前命令行所在目录中的所有文件和文件夹通过 Web 服务器公开。例如,如果你的命令行导航到了主硬盘的根目录(例如 Windows 计算机上的*C:*),然后执行了 PHP Web 服务器命令,那么你就会将硬盘上的所有内容都暴露出来!从安全角度来看,这可能并不是一个好主意。

即使是在特定的 PHP 项目文件夹内,你也可能有一些文件或内容不希望公开发布,例如包含用户名和密码凭证的代码,用于数据访问的脚本,或仅应由授权用户访问的脚本。因此,在进行 PHP 网络开发项目时,通常(并强烈建议)在整个文件夹中创建一个public文件夹。该public文件夹(及其子文件夹,如果有的话)应只包含那些通过 web 服务器公开访问的文件,包括任何图片、音频文件、视频文件、CSS 样式表、JavaScript 文本文件以及网站所需的其他内容。任何响应来自 web 客户端的 HTTP 请求并需要执行的 PHP 脚本也应位于public文件夹中,而不应公开访问的其他内容应存放在项目目录结构中的其他位置。

组织一个安全的 web 应用程序的通常方法是,在项目的public文件夹中仅有一个名为index.php的 PHP 脚本。这个脚本(我们将在第十三章进一步讨论的前控制器)然后根据传入的 HTTP 请求和其他存储的数据,决定应该执行哪些其他非公开脚本。因此,一个典型的 PHP 项目文件夹结构如下所示:

通常,最好从项目的根文件夹而非public文件夹中进行命令行操作。由于这种做法非常常见,内置的 PHP 网络服务器提供了 -t 命令行选项,用于指定从哪个子文件夹提供网页。因此,在命令行界面导航到根项目目录后,你可以输入以下命令,仅通过端口 8000 提供public文件夹中的文件:

**php -S localhost:8000 -t public**

让我们测试这两种运行内置 PHP 网络服务器的方式:带和不带 -t 选项。首先,创建一个名为* 第十章的新空文件夹,并在该文件夹中创建一个名为index.php*的文件,文件内容为列表 10-4 中显示的代码。

<?php
❶ $total = 2 + 2;
?>
<!doctype html><html><head><title>Home page</title></head>
<body>
❷ <?= "total = $total" ?>
</body></html>

列表 10-4:一个简单的 index.php 文件

这个脚本仅包含两条 PHP 语句:在完整的 PHP 标签内,我们将 $total 变量设置为评估数学表达式 2 + 2 ❶ 的结果,并使用简短的 echo 标签输出该变量的内容 ❷。为了确保这个脚本能够正常工作,请将命令行界面导航到chapter10文件夹(如果你还没在该文件夹中,使用 cd 命令切换目录),然后运行内置 PHP 网络服务器,在不指定文件夹的情况下通过端口 8000 提供服务:

% **php -S localhost:8000**

打开 web 浏览器并访问localhost:8000,你应该能看到一个显示 PHP 输出语句结果的网页:total = 4。

现在让我们看看为什么发布项目文件夹的全部内容是一个坏主意。在你的chapter10文件夹中,还创建一个名为password.txt的文本文件,内容是 password=mysecret。然后在 Web 浏览器中访问localhost:8000/password.txt,你会发现这个文本文件也像index.php脚本一样可以公开访问(参见图 10-8)。

图 10-8:Web 服务器发布秘密密码

让我们通过创建一个名为public的子文件夹,并将index.php脚本移动到这个子文件夹中,同时将password.txt保留在主chapter10文件夹中,从而让这个文件更加安全。一旦完成此更改,按 CTRL-C 终止旧的 Web 服务器进程,然后重新启动 Web 服务器,这次限制它只访问public子文件夹中的内容:

% **php -S localhost:8000 -t public**

再次尝试在浏览器中访问localhost:8000localhost:8000/password.txt。你应该仍然能够看到首页,因为它位于public文件夹中,但当你尝试访问password.txt文件时,应该会收到 404 错误,因为它不在public文件夹中。我们将在本书中遵循使用public文件夹来隔离仅应公开访问的资源的结构。

在本章中,我们探讨了 PHP Web 开发的基本概念。我们考虑了形成 Web 客户端/服务器通信基础的 HTTP 消息,并开始讨论路由的概念,即 Web 服务器如何评估 HTTP 请求路径的内容并决定返回哪些文件或执行哪些服务器脚本。我们还看到了 PHP 作为模板语言的应用,它使我们能够将动态的 PHP 语句与不变的模板文本混合。我们展示了一种使用短 echo 标签将 PHP 输出与 HTML 模板文本整洁地结合在一起的方法。

我们首次了解了 MVC 架构,这是分割和组织驱动 Web 应用程序的任务和数据的一种强大方式。最后,我们查看了 PHP Web 开发项目的典型结构。特别地,我们讨论了需要一个包含所有应公开访问资源的public子文件夹的必要性;任何不应公开访问的文件或脚本必须位于此子文件夹之外。

练习

1.   打开 Web 浏览器的开发者工具并访问一个你喜欢的网站。检查你 HTTP GET 请求的头部以及返回给浏览器的 HTTP 响应消息的正文。

2.   打开 Web 浏览器的开发者工具,访问一个提供表单的网页。填写表单并提交时,查看 HTTP 请求体。你应该能够看到通过 POST HTTP 方法发送到 Web 服务器的名称或值变量。

3.   编写一个“纯”PHP 脚本,全部写在一个 PHP 代码块中,完成以下任务:

a.   定义一个 PHP $pageTitle 变量,包含字符串 'Home Page'。

b.   输出 <!doctype html>。</p> <p>c.   输出$pageTitle 变量中的值。</p> <p>d.   输出

4.   重新编写第 3 题的答案,尽可能使用模板文本代替 PHP 代码。对于 PHP 代码,使用完整的代码块,包含标签。

5.   重新编写第 4 题的答案,使用短回显标签输出$pageTitle 变量中的值。

第十一章:11 创建与处理网页表单

在简单、可点击的链接之后,网页表单可能是人们与网站互动的最常见方式。在本章中,我们将探讨网页客户端如何向服务器脚本提交表单数据,并创建一系列发送数据的网页表单。我们还将练习编写服务器端的 PHP 脚本,以提取和处理传入的表单数据。你将学习如何处理来自各种网页表单元素的数据,并使用 GET 和 POST HTTP 请求发送这些数据。

网页表单只是网页的一部分,允许用户输入数据,然后将用户输入传递给服务器应用程序。网页表单的交互实例包括创建 Facebook 帖子、预订航班或娱乐票、以及输入登录信息。如你所见,网页上的每个表单都在开始和结束的 HTML

标签之间定义。表单数据可能是用户输入的文本,或者可能来自单选按钮、选择列表或复选框等机制。本章将讨论如何处理这些不同类型的输入。

网页表单的基本客户端/服务器通信

在典型的网页表单背后,是网页客户端(如用户的浏览器)与网页服务器之间四个消息的序列,其中表单被请求、接收、提交和处理。图 11-1 总结了这些消息。

图 11-1:显示和处理网页表单的典型消息交换

首先,网页浏览器客户端从服务器请求表单的 HTML ❶。用户通过点击链接或按钮等操作触发此请求。接下来,服务器检索并在某些情况下定制表单的 HTML,然后将其发送回客户端 ❷。用户输入数据并提交表单后,表单数据会发送回服务器 ❸。最后,服务器处理接收到的数据,构造适当的消息并发送回客户端 ❹。这个最终消息可以是简单的数据接收确认,或者如果发生问题则是错误消息,或者它可能是原始表单,表明缺少必填数据。

GET 与 POST 请求

如第十章所述,从客户端发送到服务器的两种最常见的 HTTP 请求是 GET 和 POST。当你创建 HTML 表单时,可以使用这两种请求中的任何一种将数据发送到服务器,因为 GET 和 POST 都可以将变量从浏览器客户端发送到网页服务器作为请求的一部分。在几乎所有情况下,从网页表单发送到服务器的数据变量都是简单的名称/值对,例如 username=matt 或 q=chocolate。

你使用的请求类型取决于表单的目的以及你希望表单数据如何发送。正如你将看到的,GET 方法会使提交的变量在 URL 中可见,而 POST 方法则可以将变量隐藏在 HTTP 请求的主体中。

使用 GET 发送数据

HTTP GET 请求主要用于从服务器获取数据或网页。虽然你可以在请求中发送数据以帮助检索,但 GET 请求不应导致服务器上存储的内容发生变化(例如,修改数据库中的值)。

在 GET 请求中,服务器完成请求所需的任何变量,包括通过 Web 表单提交的值,都被添加到请求的 URL 末尾,紧跟着一个问号字符(?)。问号后面的这部分 URL 被称为查询字符串,并且它将在浏览器的地址栏中可见。变量以名称/值对的形式进行编码,格式为 name=value,例如 username=matt。例如,当你使用 Google 或 Bing 搜索时,输入的搜索词会被分配给变量 q,添加到 URL 查询字符串中,并通过 GET 请求发送。

假设你使用 Google 搜索短语 cheese cake。当你查看搜索结果时,地址栏中应该会看到类似 https://www.google.com/search?q=cheese+cake 的内容。单个字母 q 表示你的搜索查询,并与输入到 Google Web 表单中的值配对。这表明你的查询是通过 GET 请求传递给 Google 服务器的。

特殊规则定义了 URL 中允许的字符,使用 HTTP GET 方法发送的变量也必须遵循这些规则。因此,特殊字符和空格不能在查询字符串中按原样表示,而必须编码为其他符号。例如,每个空格被替换为 %20 或加号 (+),这就是为什么 Google 搜索查询字符串显示为 q=cheese+cake 而不是 q=cheese cake 的原因。当编码两个或更多变量时(例如来自表单中不同字段的变量),名称/值对通过和符号(&)分隔,如 ?firstname=matt&lastname=smith。Web 浏览器会自动处理这种编码,但了解这一点很有用,因为它解释了为什么在使用 GET 方法发送表单数据时,你经常会看到一些加密的、百分号编码的字符。

GET 方法的一个常见用途是创建一个易于书签保存的 URL,可能是通过电子邮件或短信与他人分享。例如,Google 的芝士蛋糕查询就是一个例子:https://www.google.com/search?q=cheese+cake。另一个例子可能是 Google Maps 搜索,例如这个指向爱尔兰都柏林的链接:https://www.google.com/maps?q=dublin+ireland。URL 中的变量可以来自用户输入的值(如 Google 搜索的情况),也可以通过明确添加问号和所需的名称/值对到 URL 的末尾来硬编码。

图 11-2 展示了后者的例子,点击 COMP H2029 -FT 链接会发起一个 GET 请求,其中包括查询字符串中的名称/值对 id=1499。这个 id 值不是来自用户输入,而是硬编码在网站的逻辑中。

图 11-2:一个带有硬编码值的链接,通过 GET 请求方法发送

网络服务器不关心 GET 请求是如何创建的。无论查询字符串变量来自表单提交还是硬编码,服务器端脚本都可以提取这些名称/值对进行处理。

使用 POST 隐形发送数据

HTTP POST 请求主要用于创建或修改服务器上的资源。使用 POST 方法,你可以将变量发送到 HTTP 消息的主体中,这意味着它们不会出现在结果 URL 中。对于任何机密数据,比如用户名和密码,你应该使用 POST 请求,以便屏幕上的人无法看到发送到服务器的数据值。事实上,大多数网页表单都使用 POST 方法发送数据。图 11-3 展示了一个 POST 方法的登录表单。

图 11-3:POST 方法在请求主体中的变量

在这个例子中,我尝试用用户名 matt 和密码 smith 登录一个网站。浏览器的 HTTP 消息检查工具显示,用户名和密码值是作为 HTTP POST 请求的主体发送的。因此,这些值并未出现在地址栏的 URL 中。

注意

一个POST请求可以像GET请求一样,直接在查询字符串中发送数据,也可以在请求主体中发送数据。我们将在《发送不可编辑数据和表单变量一起发送》一节中探讨POST请求的两种方式,详见第 206 页。

一个简单的例子

为了更清楚地理解 GET 和 POST 方法如何以不同方式发送数据,我们来构建一个简单的网站,其中包含一个只有单一文本框的表单,用户可以在其中输入他们的名字(见图 11-4)。我们将尝试使用这两种 HTTP 方法从表单传递数据。正如你所见,在创建 HTML 元素时,我们可以选择使用哪种方法。

图 11-4:浏览器中显示的简单网页表单

我们的项目将包含一个public文件夹,其中包含两个 PHP 脚本文件,index.phpprocess.php。其中,index.php是默认的主页脚本,用于显示表单,而process.php将接收用户在主页提交的姓名,并生成一个Hello 消息作为响应。

使用 GET 方法创建表单

我们将从简单网页表单的 GET 版本开始。创建一个新的项目,并在该项目的public文件夹中创建一个名为index.php的新的 PHP 脚本文件。输入示例 11-1 中显示的代码。

<!doctype html><html><head><title>simple form</title></head>
<body>
<form method="GET" action="process.php">
    <input name="firstName">
    <input type="submit">
</form>
</body>
</html>

示例 11-1:使用 GET 方法的简单网页表单的 HTML 代码

该文件完全由 HTML 模板文本组成,包括一个定义网页表单的元素。我们使用该元素的 method 属性声明表单数据应使用 GET HTTP 方法提交,并使用 action 属性指定 HTTP 请求及其数据应发送到process.php服务器脚本。请注意,HTML 表单的 method 属性中的 GET 或 POST 值是不区分大小写的,因此我们也可以写 method="get"。

在表单内,我们创建一个元素并为其指定名称为 firstName。我们还创建第二个元素,类型为 submit,以向表单中添加一个提交按钮。由于我们没有指定 firstName 输入的类型,HTML 5 会自动将默认的表单输入类型定义为文本框。文本框会以矩形输入框的形式显示给用户。如果你想明确声明输入类型,可以通过来实现。你还可以进一步设置文本框的字符宽度和其他规格,使用各种 HTML 表单输入的可选属性。

由于我们的表单只输入一个值,我们不需要向用户显示文本标签。然而,当表单包含多个输入控件时,应在每个输入框前加上提示,以便用户知道哪个文本框(或单选按钮或其他输入类型)对应哪个值。例如,如果我们想让用户输入年龄,可能会写上模板文本 Age:,然后是表单输入,像这样:

Age: <input name="age">

现代 HTML 的最佳实践还要求我们为年龄输入添加 id 属性,使用,并使用

我们已经创建了一个 HTML 网页表单,但我们的工作才完成一半;我们还需要编写process.php脚本来处理通过表单提交的数据。在编写简单表单的处理脚本时,我们只需要知道脚本将接收的变量名称,以及该变量是通过查询字符串(如 GET 方法)提交的,还是通过请求体(如 POST 方法)提交的。

在这种情况下,脚本应该尝试从通过 GET 请求接收到的查询字符串中查找 firstName 变量的值,然后输出 HTML 以显示包含该名称的问候语。将 process.php 文件添加到项目的 public 文件夹中,并在其中输入 示例 11-2 中的代码。

<?php
//----- (1) LOGIC -----
❶ $firstName = filter_input(INPUT_GET, 'firstName');
?>

<!-- (2) HTML template output -->
<!doctype html> <html><head><title>process</title></head><body>
❷ Hello <?= $firstName ?>
</body></html>

示例 11-2:一个 process.php 服务器脚本,用于响应 Web 表单

请注意,我们在这个脚本中使用了两种类型的注释,因为它混合了两种语言:一个以 // 开头的 PHP 注释和一个 HTML 注释 <!--。在最初的 PHP 代码块中,我们调用了 filter_input() 函数来读取来自 HTTP 请求的数据,并将结果存储在 $firstName 变量中 ❶。INPUT_GET 参数指定我们要读取嵌入在 URL 查询字符串中的数据,而 'firstName' 参数标识我们要查找的特定 HTML 表单变量。表单输入变量名是区分大小写的,因此在调用 filter_input() 函数时,务必要小心匹配 HTML 表单中定义的变量名。例如,如果我们传递的是 'firstname' 而不是 'firstName',脚本就无法正常工作。当将表单变量的值传递给 PHP 变量时,像我们这里所做的那样,通常的良好实践是给 PHP 变量与对应表单变量相同的名称。

接下来,我们声明应该作为响应发送的 HTML,这包括模板文本 Hello,后面跟着 PHP 短标签内的 $firstName 变量值 ❷。

注意

INPUT_GET 参数传递给 filter_input() 函数时,其名称可能会让人误解。它的目的是从 URL 查询字符串中检索数据,无论这些数据是通过 GET 方法(所有变量都是查询字符串的一部分)还是通过 POST 方法(数据可以在查询字符串中,也可以在请求体中)发送的。因此,当你在 PHP 表单处理代码中看到 GET 时,应将其理解为 查询字符串变量,而不必假设它们来自 GET 请求。##### 测试表单

现在我们已经在 index.php 中创建了 Web 表单,并编写了 process.php 脚本来响应它,接下来让我们测试一下我们的工作。通过命令行启动 PHP Web 服务器,使用 php -S localhost:8000 -t public 命令,如 第十章 中所讨论的那样;然后打开浏览器标签页并访问 localhost:8000。默认情况下,我们创建的表单应该会显示出来,因为文件名是 index.php。你将看到类似于 图 11-4 中的表单:一个带有提交按钮的文本框。

在表单中输入你的姓名,然后点击 提交。此时,输入的文本应作为 HTTP GET 请求的一部分从浏览器发送到 PHP 服务器。GET 请求触发服务器执行 process.php 脚本,如 HTML 元素中的 action 属性在 index.php 中所声明。该脚本提取提交的值并将其注入到 HTML 模板文本中,然后将其添加到文本缓冲区,这些文本最终成为服务器发送回请求客户端(即网页浏览器)的 HTTP 响应消息的正文。你应该看到类似 图 11-5 的结果。

图 11-5:process.php 脚本的结果,确认通过表单接收到的数据

你不仅在页面显示的问候语中看到自己的名字(例如图中的 Hello matt),还可以在浏览器地址栏的 URL 末尾看到它,因为表单是通过 GET 请求提交的。例如,当我通过表单提交 matt 时,得到的 URL 是 localhost:8000/process.php?firstName=matt。这表明 GET 请求正尝试访问 process.php 脚本,并传递一个值为 matt 的 firstName 变量。注意,问号将查询字符串与 URL 其余部分分开。

切换到 POST 方法

让我们修改我们的项目,改用 POST 方法发送表单数据,而非 GET,看看会有什么不同。只需做几个小修改。首先,更新 index.php 脚本,如 列表 11-3 所示。

<!doctype html><html><head><title>simple form</title></head>
<body>
<form method="POST" action="process.php">
 <input name="firstName">
 <input type="submit">
</form>
</body>
</html>

列表 11-3:在 index.php 中从 GET 切换到 POST

现在我们将 元素的 method 声明为 POST。就这样:无需对 index 脚本做进一步修改,确保网页浏览器使用 POST 方法而非 GET 提交表单数据。接下来,更新 process.php,如 列表 11-4 所示。

<?php
//----- (1) LOGIC -----
$firstName = filter_input(INPUT_POST, 'firstName');
?>

<!-- (2) HTML template output -->
<!doctype html> <html><head><title>process</title></head><body>
Hello <?= $firstName ?>
</body></html>

列表 11-4:在 process.php 中从 GET 切换到 POST

这是另一个简单的更改:我们只需将 INPUT_GET 改为 INPUT_POST 作为 filter_input() 的参数,告诉该函数在请求体中查找通过 POST 提交的变量。

尝试重新运行 web 服务器,并再次通过表单提交你的姓名。你应该仍然会看到与之前相同的 Hello 问候语。然而,如果你仔细查看,你会看到一些关键的不同之处,如 图 11-6 所示。

图 11-6:使用浏览器开发者工具查看 HTTP POST 请求和 firstName 变量

首先,浏览器地址栏中的 URL 应该只显示 localhost:8000/ process.php。因为 firstName 表单变量现在通过 POST 请求体发送,它不再出现在所有人都能看到的 URL 查询字符串中。你可以通过查看浏览器的开发者工具来验证变量是否仍然被传输。在这个例子中,我通过表单提交了名字 Fred,你可以在 图 11-6 中看到,确实,表单数据变量 firstName=Fred 显示在 POST 请求的请求体中。

filter_input() 函数

我们的简单网页表单项目演示了如何使用 PHP 的 filter_input() 函数接收传入的表单数据。这个函数使得提取通过 GET 和 POST 方法提交的值变得容易。然而,编写表单处理脚本并不总是如此简单;在较早的 PHP 版本中,从用户接收的数据通常是通过访问内建的 $_GET 和 $_POST 超全局数组 来提取的。

$_GET 数组包含表示作为 URL 查询字符串一部分接收到的所有变量的键/值对,而 $_POST 数组包含表示通过 POST HTTP 方法接收到的所有变量的键/值对。例如,通过 GET 请求提交名字 Matt 的简单网页表单将产生一个包含 ['firstName' => 'Matt'] 的 $_GET 数组,以及一个空的 $_POST 数组。

注意

\(_GET* 和 *\)_POST 数组是 PHP 的超全局变量的例子。这些数组总是存在的,可以从 PHP 代码的任何地方(即任何作用域中),包括函数和类方法内部访问。

即使在现代 PHP 编程中,从这两个超全局数组中提取表单数据仍然在理论上是可能的。但 PHP 5.2 版本引入了 filter_input() 函数,作为访问提交数据的更好方法。为了说明这一改进,让我们来看一下处理这些超全局数组的过程。

在 第七章 中,你已经学到,尝试访问数组中不存在的键将触发警告,你可以通过使用 isset() 函数来验证数组键是否存在,以避免这种警告。进行这种测试可以使脚本更健壮,减少错误,尤其是在直接处理 $_GET 和 $_POST 数组时更为重要。不幸的是,这种测试也会向脚本中添加额外的代码。例如,示例 11-5 演示了如何安全地从 $_GET 数组中提取 $firstName 变量。

<?php
if (isset($_GET['firstName'])) {
    $firstName = $_GET['firstName'];
    // Now use filters / apply sanitization/validation to extracted value
} else {
    $firstName = NULL;
}

示例 11-5:在尝试从 $_GET 中提取值之前,使用 isset() 测试数组键

我们在 if...else 语句中使用 isset()来检查\(_GET 数组中是否存在'firstName'键(表示通过传入的查询字符串提交了 firstName 表单变量)。如果该键存在,我们将其值传递给\)firstName 变量。否则,我们将$firstName 设置为 NULL。这个 if...else 语句可以避免我们在没有警告的情况下直接访问数组中不存在的值。

列表 11-5 将正常工作,但它表示了 PHP 表单处理代码中一个常见的动作序列,因此引入了 filter_input()函数来封装这一过程。因此,我们的整个 if...else 语句可以用一句话来替换:

$firstName = filter_input(INPUT_GET, 'firstName')

filter_input()函数会在尝试访问变量之前自动检查所需的变量是否存在,如果不存在,通常会返回 NULL。这使我们免去了像列表 11-5 中那样编写繁琐的条件测试。

filter_input()函数的另一个优点是,它可以使用过滤器忽略并移除接收到的表单数据中的不需要的和潜在危险的内容。这有助于防止安全漏洞,如跨站脚本攻击。例如,为了过滤掉(丢弃)用户输入中的任何非字母字符,我们可以在 filter_input()调用中添加一个第三个参数 FILTER_SANITIZE_SPECIAL_CHARS:

$firstName = filter_input(INPUT_GET, 'firstName',
FILTER_SANITIZE_SPECIAL_CHARS);

发送数据的其他方式

通过网页表单获取用户输入并不是通过 HTTP 请求发送数据的唯一方式。在本节中,我们将考虑其他将数据传输到服务器的技术。我们将研究如何将不可编辑的数据嵌入查询字符串中,以便与用户输入的表单数据一起提交,如何发送关于表单提交按钮本身的数据,以及如何将查询字符串变量添加到常规超链接中,独立于任何网页表单。过程中,你还将看到如何处理查询字符串和 POST 变量的混合,以及如何利用 PHP 数组和循环以编程方式生成查询字符串变量。

与表单变量一起发送不可编辑数据

通常,你希望网页表单发送一些用户无法编辑的额外数据。最常见的例子可能是用户(也许是员工)正在编辑数据库中某个项的详细信息。这个项可能是有关产品或客户的记录,它已经有一个分配的 ID,这个 ID 应该包含在表单数据中,但 ID 本身不应通过表单进行更改。对于这种情况,你可以将不可编辑的值作为查询字符串变量通过表单的 action 属性(例如,action="/process.php?id=1022")在 URL 的末尾发送。

为了说明,假设我们创建一个新的网页表单,用于提交关于电影的信息。启动一个新的项目,其中包含一个public文件夹,并在其中创建一个index.php脚本。然后输入列表 11-6 中的 HTML 代码以创建网页表单。

<!doctype html><html><head><title>Movie Form 1</title></head><body>
<h1>Edit movie</h1>
❶ <form method="POST" action="/process.php?id=1022">
  ❷ <label for="title">Title: </label><input name="title" id="title">
 <br>
  ❸ <label for="price">Price: </label><input name="price" id="price">
    <br>
    <input type="submit">
</form>
</body>
</html>

列表 11-6:index.php 中电影表单的 HTML 代码

我们使用 POST 方法声明了一个表单 ❶。(在更现实的场景中,这个表单很可能会导致数据库中记录的更改,因此此处使用 POST 而非 GET 方法是合适的。)对于表单的 action 属性,我们指定了处理表单的脚本为 process.php,并且还发送了一个名为 id 的 URL 查询字符串变量,值为硬编码的 1022。当表单提交时,这个额外的名称/值对将在生成的 URL 中可见(就像使用 GET 方法发送的数据一样)。同时,表单还会将用户输入的两个变量 title ❷ 和 price ❸ 作为 POST 请求体中的数据发送。

处理混合查询字符串和 POST 变量

现在让我们编写 process.php 脚本来接收并提取来自此电影表单的数据。与我们早期的表单处理脚本不同,这个脚本需要从传入的 POST 请求中提取多个变量,包括通过查询字符串发送的 id 变量和嵌入在请求体中的 title 和 price 变量。将 process.php 添加到项目的 public 文件夹中,并在其中输入 列表 11-7 中的代码。

<?php
//----- (1) LOGIC -----
$id = filter_input(INPUT_GET, 'id');
$title = filter_input(INPUT_POST, 'title');
$price = filter_input(INPUT_POST, 'price');
?>

<!-- (2) HTML template output -->
<!doctype html> <html><head><title>process</title></head><body>
id = <?= $id ?>
<br>title = <?= $title ?>
<br>price = <?= $price ?>
</body></html>

列表 11-7:处理电影表单的 PHP 服务器脚本

我们使用 filter_input() 函数,并带上 INPUT_GET 参数,将 id 查询字符串变量读取到对应的 PHP 变量 $id 中。(记住,INPUT_GET 只是意味着我们正在从查询字符串读取数据,即使这些数据是通过 POST 方法而非 GET 方法发送的。从服务器端脚本的角度来看,HTTP 请求的实际方法差别不大。)然后我们再使用 filter_input() 函数两次,带上 INPUT_POST,将请求体中的两个值读取到 $title 和 $price 变量中。经过一些基础的 HTML 页面标签后,我们通过 PHP 短 echo 标签输出每个变量的名称和值,用 HTML
换行符分隔。

图 11-7 显示了 process.php 脚本如何处理传入的表单数据。

图 11-7:发送查询字符串和 POST 变量的 HTTP 请求

在这个示例中,我在表单的标题字段中填写了《失落的世界》,在价格字段中填写了 9.99。浏览器中的输出显示了这些值的回显,以及我们在查询字符串中硬编码的 id 值 1022。你还应该在浏览器的地址栏中看到 URL 中的 id 变量,如果使用浏览器开发者工具查看请求,你会看到 id 被列为查询字符串参数,而 title 和 price 被列为请求体中的表单数据变量。

提供多个提交按钮

通过表单发送数据的另一种方式是给表单的提交按钮添加一个 name 属性。当你希望表单包含多个提交按钮时,这种方式尤其有用,因为用户可以选择如何处理表单中的数据。

例如,租用在线视频的客户可能希望支付费用后立即开始观看,或者他们可能希望先支付费用,但稍后再观看。每个选项都可以通过不同的提交按钮触发,如 图 11-8 所示。服务器端脚本可以检测用户点击的按钮名称,并作出相应的响应。

图 11-8:同一表单的两个提交按钮

让我们设计一个带有多个提交按钮的表单。创建一个新项目,其中包含一个名为 index.php 的 PHP 脚本文件,并在其中输入 列表 11-8 中的代码。

<!doctype html><html><head><title>Movie Rent Form 1</title>
<link rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
> ❶
</head>
<body class="container">
<h1>Rent movie</h1>
<form method="POST" action="process.php?movieId=80441"> ❷
    <p><label for="number">Credit card number:</label>
    <input name="number" id="number"></p>
    <p><label for="date">
    Expiry date:</label>
    <input name="date" id="date"></p>
    <p><label for="ccv">
    CCV code:</label>
    <input name="ccv" id="ccv"></p>
    <p>
    <input type="submit" name="watchNow" ❸
        value="Pay and start watching now" class="btn btn-success">
    <input type="submit" name="watchLater" ❹
        value="Pay and watch later" class="btn btn-success">
    </p>
</form>
</body>
</html>

列表 11-8:带有两个提交按钮的表单 HTML 代码

首先,我们引入了 Bootstrap CSS 样式表 ❶。这使得我们可以通过使用 class="btn btn-success" 来将提交类型的输入框样式化为漂亮的绿色按钮,而无需自己编写任何 CSS 代码。然后我们使用 POST 方法设置表单,因为该表单提交的数据可能会导致服务器上的更改(处理支付并记录电影被用户租用) ❷。注意,我们通过表单的 action 属性将 movieID 变量硬编码到查询字符串中,就像前面的电影表单示例一样。

我们为表单提供了用户信用卡信息的输入字段,然后定义了两个提交按钮,一个具有 watchNow ❸ 的 name 属性,另一个具有 watchLater ❹ 的 name 属性。这些按钮还具有 value 属性,用于定义每个按钮上显示的文本。由于这些按钮的 name 属性,当其中一个按钮被点击时,它的 name 和 value 会作为键/值对与其他表单数据一起发送到 POST 请求的主体中。例如,如果用户点击 watchNow 按钮,将会发送 watchNow=Pay and start watching now 的请求。值部分意义不大,但服务器端脚本可以检查表单数据中是否包含 watchNow 键,以确定点击了哪个提交按钮。列表 11-9 显示了一个 process.php 文件,它正是实现了这一功能。

<?php
if (filter_has_var(INPUT_POST, 'watchNow')) {
    print 'you clicked the button to <b>Watch Now</b>';
} else {
    print 'you clicked the button to <b>Watch Later</b>';
}

列表 11-9:在 process.php 中检测点击了哪个提交按钮

由于表单只有两个提交按钮,我们使用 if...else 语句测试是否点击了其中一个按钮(watchNow);如果没有点击,我们可以安全地假设点击了另一个按钮。(如果有三个或更多按钮,我们可以使用 elseif 语句或 switch 语句来检测正确的按钮。)理论上,我们可以像往常一样调用 filter_input() 函数,提取 watchNow 变量的值,并检查其值是否为 NULL,以确定是否是该按钮被点击。由于我们并不关心 watchNow 的值,而是关心该变量是否存在于传入的请求中,因此我们改用 PHP 的 filter_has_var() 函数来设置 if...else 语句的条件。该函数接受两个输入参数,变量的来源(通常是 INPUT_GET 或 INPUT_POST)和变量的名称,并根据是否找到该命名值返回 true 或 false。

图 11-9 显示了通过我们的电影租赁网页表单提交的示例。

图 11-9:在请求体中的 POST 变量中找到提交按钮的名称

在这个示例中,我使用了 watchNow 按钮来提交表单数据。结果页面上的消息确认了 process.php 脚本检测到了该按钮。此外,通过浏览器的开发者工具查看请求,显示 watchNow 与其他 POST 变量一起列出。

在超链接中编码数据

除了通过网页表单发送数据外,我们还可以通过在 HTML 超链接的 URL 末尾添加名称/值对将数据发送到服务器。这些数据将通过 GET 方法发送,因为每当你点击网页上的一个链接时,浏览器会使用该链接的 URL 发起 HTTP GET 请求。HTML 超链接通过锚点()元素表示;链接的 URL 通过元素的 href 属性设置。

我们将通过一个典型的示例来探讨这种通过 GET 方法发送数据的附加方式,该示例展示了一个显示在线购物车中项目详细信息的链接。图 11-10 显示了我们希望创建的按钮样式链接。

图 11-10:购物车中的按钮样式的详细信息超链接

购物车中的每个项目旁边都有一个超链接(样式为按钮),用来显示该项目的详细信息。链接可能指向像 /show.php?id=102 这样的 URL。这个 URL 会通过 GET 方法请求 PHP 脚本 show.php,同时将产品的 ID(在此例中为 102)通过查询字符串 id 变量传递。由于此处的目标只是显示数据(因此不会改变服务器上的任何内容),使用 GET 方法比 POST 方法更合适。

硬编码链接

让我们创建如图 11-10 所示的页面。为简便起见,我们将从硬编码产品 ID 到超链接开始。创建一个包含 public 文件夹的新项目,并将 index.php 脚本添加到该文件夹中。然后输入列表 11-10 中的代码。

<!doctype html><html><head><title>Basket Form 1</title>
<link rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
>
</head>
<body class="container">
<h1>Your shopping cart</h1>

<div class="row">
    <div class="col-lg-3 text-end py-2">
        Hammer $ 19.99
        <a href="/show.php?id=102" class="btn btn-primary">Details</a> ❶
    </div>
</div>

<div class="row">
    <div class="col-lg-3 text-end py-2">
        Bucket of nails $ 9.99
        <a href="/show.php?id=511" class="btn btn-primary">Details</a> ❷
    </div>
</div>
</body>
</html>

列表 11-10:一个包含数据并嵌入 Details 超链接的 index.php 文件

如前面的示例所示,我们通过读取 Bootstrap CSS 样式表来快速为页面添加样式。在页面的 HTML 中,我们创建了两个文本为“Details”的链接,并使用 Bootstrap CSS 类 btn btn-primary 将其样式化为蓝色按钮:一个指向锤子 ❶,另一个指向一桶钉子 ❷。每个链接都指向 PHP 脚本 show.php,产品的 ID 会以 ?id=102 这种方式编码到 URL 中。点击其中一个链接将通过 GET 请求发送相应的 id 变量,如图 11-11 所示。

图 11-11:点击 Details 链接后的结果

我们不必担心编写 show.php 脚本,但请注意,点击钉子桶的“Details”链接会发起一个 GET 请求,其中 id 值 511 被嵌入到查询字符串中。

注意

在更实际的场景中,我们不会像这样将 id 值硬编码到链接中。相反,我们会通过循环遍历表示用户购物车中产品的数组,并编程地将每个产品的 ID 插入到相应的 Details 链接中。接下来我们将讨论如何做到这一点。

程序化生成链接

大多数网页展示的内容都是基于网站数据库中值动态生成的。因此,链接通常不会像 /show.php?id=102 这样硬编码产品 ID,而是通过 PHP 语句编程生成链接,遍历表示用户购物车中商品的一个数据集合。每次循环时,都会查找并动态插入该商品的 ID 到超链接中。产品的描述和价格也会类似地动态插入到通用的 HTML 模板文本中。

让我们更新购物车页面,尝试这种方法。我们将使用一个数组来表示整个购物车;数组中的每个项将是一个代表特定购物车商品的数组,包含该产品的 ID、描述和价格三个值。按列表 11-11 所示修改 index.php 文件。

<?php
// Set up data array
$items = [
    ['id' => 102, 'description' => 'Hammer', 'price' => 9.99],
    ['id' => 511, 'description' => 'Bucket of nails', 'price' => 19.99],
];
?>
<!doctype html><html><head><title>Cart From Array</title>
<link rel="stylesheet"
 href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
>
</head>
<body class="container">
<h1>Your shopping cart</h1>
<?php foreach ($items as $item): ?> ❶
 <div class="col-lg-3 text-end py-2">
        <?= $item['description'] ?> $ <?= $item['price'] ?>
        <a href="/show.php?id=<?= $item['id'] ?>" class="btn btn-primary">Details</a> ❷
 </div>
<?php endforeach; ?> ❸
</body>
</html>

列表 11-11:使用 PHP 循环为购物车中的商品创建 Details 链接

在任何 HTML 之前,我们使用一个 PHP 代码块来声明一个\(items 数组,包含我们两个产品的信息。(当然,我们仍然在硬编码这些信息,尽管是放在数组中;在更实际的场景中,我们会从数据库中获取产品信息,正如我们在第六部分中将要讨论的那样。)然后我们开始 HTML 模板文本。在“您的购物车”标题下,我们使用另一个 PHP 代码块开始一个 foreach 循环,其中\)items 数组的当前元素由$item 变量表示 ❶。我们使用带冒号(:)的替代循环语法来设置循环的开始,并使用 endforeach ❸来结束循环。请参见第六章以回顾这种替代的循环语法,它使得将 PHP 与 HTML 结合更加容易。

在循环内部,我们结合使用 HTML 和 PHP 短标签来在<div>中插入当前产品的'描述'、'价格'和'id'值到模板文本中。通过这种方式,我们为每个产品动态创建一个<div>元素,包括一个 Bootstrap 样式的详细信息链接。特别需要注意的是,我们将产品的 ID 嵌入到<a>元素的 href 属性中 ❷,这将生成类似于/show.php?id=102的超链接,与之前相同。总体而言,页面应该看起来与图 11-10 完全相同。

其他表单输入类型

单一文本和数字表单输入(如文本框、密码框、文本区域等)都会作为名称/值对发送到 HTTP 请求中,但其他类型的表单数据就没有那么简单了。理解浏览器如何为这些其他表单元素选择变量名称和值非常重要,这样你才能编写服务器脚本,正确获取和验证传入的数据。在本节中,我们将讨论如何处理其他常见的表单元素,如单选按钮、复选框以及单选和多选列表。

单选按钮

单选按钮是一组两个或更多供用户选择的表单输入选项,其中只能选择一个值。单选按钮输入以共享相同名称属性的分组方式声明,每个选项都有一个唯一的值属性,用于区分选中了哪个输入。通过这种方式,一组单选按钮形成了一个互斥的选项组,供分配给共享名称属性的值选择。除非在少数情况下不接受选择任何选项,否则应该自动选中其中一个单选按钮,以便用户能够选择默认选项。这样,值将在 HTTP 请求中被发送。

让我们编写一个 HTML 表单,使用单选按钮呈现两个爱尔兰县之间的选择,如图 11-12 所示。该图还展示了当表单通过process.php处理时的输出。

图 11-12:通过查询字符串参数提交值的单选按钮

创建一个新项目,其中包含一个public文件夹和一个index.php脚本,然后输入清单 11-12 中显示的代码。

<!doctype html><html><head><title> Radio Buttons </title>
<link rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
>
</head>
<body class="container">
<form method="GET" action="process.php">
    <br>
    <label>
        <input type="radio" name="county" value="dublin" checked> ❶
        Dublin
    </label>

 <br>
    <label>
        <input type="radio" name="county" value="cork"> ❷
        Cork
    </label>
    <br>
    <input type="submit" class="btn btn-primary">
</form>
</body>
</html>

列表 11-12:演示单选按钮的 HTML 表单

我们使用 GET 方法创建一个表单,并通过 action 属性请求process.php脚本。在表单内,我们声明了两个单选按钮(类型为"radio"的元素),它们的 name 属性均为"county"。其中一个的值为"dublin" ❶,另一个的值为"cork" ❷。我们使用 checked 属性将都柏林选项设置为默认选项。用户在页面上看到的单选按钮是一个小圆形输入框,因此在每个单选按钮旁边添加文本或图片提示非常重要,同时还要使用

注意

标签显示每个县的名称,首字母大写(例如都柏林),而对应的值以小写字母开头(dublin)。就个人而言,我总是为单选按钮、复选框和其他输入使用小驼峰命名法。采用一致的命名规范可以使编写表单处理逻辑变得更加容易,无需反复查看表单代码本身,也能减少出错的概率。

由于这个表单使用的是 GET 方法,我们在提交表单时会在 URL 中看到?county=dublin 或?county=cork。换句话说,单选按钮组的 name 属性充当查询字符串变量的键,选中按钮的 value 属性充当该变量的值。因此,我们可以使用 filter_input(INPUT_GET, 'county')来提取用户通过按钮组提交的值。

复选框

复选框为用户提供布尔值(真/假)选择。它们在浏览器中显示为可以勾选或取消勾选的小方框。例如,您可能会在一个表单中使用复选框,允许用户在点餐时选择披萨的配料。与单选按钮不同,复选框不是互斥的;用户可以选择任意多个复选框。与单选按钮一样,在每个复选框旁边添加文本或图片提示可以让用户知道他们在选择什么。

表单中的复选框可以单独处理,也可以作为数组进行统一处理。我们将查看这两种方法。

单独处理

当复选框被单独处理时,每个复选框应有一个唯一的 name 属性。您还可以为每个复选框定义一个 value 属性,但对于表单处理来说这并不是必须的。复选框只有在被选中时才会将其 name/value 对随 HTTP 请求发送,因此在接收端,只需通过 filter_has_var()函数测试复选框的 name 即可,无需关心复选框的 value。如果复选框被选中且没有定义 value,则会将默认值"on"与表单数据一起提交。

为了演示这个功能,让我们使用复选框来创建一个披萨配料的表单。图 11-13 展示了表单的外观。

图 11-13:带有复选框的表单

从一个包含 清单 11-13 中代码的 public/index.php 文件开始一个新项目,用于设计披萨配料表单。

<!doctype html><html><head><title> Checkboxes </title>
<link rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
>
</head>
<body class="container">
<h1>Extra pizza toppings</h1>
<form method="GET" action="process.php">
    <p><label><input type="checkbox" name="olives"> Olives</label></p>
    <p><label><input type="checkbox" name="pepper"> Pepper</label></p>
    <p><label><input type="checkbox" name="garlic"> Garlic salt</label></p>
    <p><input type="submit" class="btn btn-primary"></p>
</form>
</body>
</html>

清单 11-13:带有复选框的 HTML 表单

在使用 GET 方法声明的表单内,我们为橄榄、辣椒和大蒜盐选项创建复选框,并添加一个提交按钮。每个复选框都有一个独特的 name 属性,紧随其后的是一个文本标签,指示复选框代表的选项。由于复选框没有显式声明值属性,当用户提交表单时,每个选中的复选框会将一个变量添加到查询字符串中,形式为 =on。例如,如果只有“橄榄”被选中,表单将触发一个 GET 请求,URL 为 http://localhost:8000/process.php?olives=on

清单 11-14 展示了我们在 process.php 脚本中可能使用的逻辑,用于检测其中一个复选框。

<?php
$olivesSelected = filter_has_var(INPUT_GET, 'olives');
var_dump($olivesSelected);

清单 11-14:检测单个复选框

我们使用 filter_has_var() 与 INPUT_GET 和 'olives' 一起调用,检测是否有 olives 变量随 GET 请求发送,表示该复选框已被选中。我们将结果的 true/false 值存储在 $olivesSelected 中,出于简便起见,我们将其传递给 var_dump()。在更现实的场景中,我们可能会使用该布尔值来设置条件逻辑。我们可以对其他复选框进行类似的 filter_has_var() 测试,只要每个复选框都有唯一的名称。

作为数组处理

有时候,将两个或多个复选框视为相关联的更实际做法是为它们都赋予相同的 name 属性,并以方括号结尾。例如,披萨配料表单中的复选框可以都命名为 toppings[]。这样,所有选中的配料会被分组到一个数组中,并在 HTTP 请求中以相同的 toppings 变量名发送。当采用这种方法时,必须确保每个复选框都有一个独特的值属性,以便能够区分各个复选框。

清单 11-15 显示了如何重写披萨配料表单,将所有选中的复选框作为值发送到一个单一的数组中。

<!doctype html><html><head><title>Checkboxes array</title>
<link rel="stylesheet"
 href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
>
</head>
<body class="container">
<h1>Extra pizza toppings</h1>
<form method="GET" action="process.php">
    <p><label>
        <input type="checkbox" name="toppings[]" value="olives"> Olives
    </label></p>
 <p><label>
        <input type="checkbox" name="toppings[]" value="pepper"> Pepper
    </label></p>
    <p><label>
        <input type="checkbox" name="toppings[]" value="garlic"> Garlic salt
    </label></p>
 <p><input type="submit" class="btn btn-primary"></p>
</form>
</body>
</html>

清单 11-15:将表单中的复选框分组为一个数组

和之前一样,我们为三个配料选项声明复选框。然而,这次我们为每个复选框分配 toppings[] 作为 name,这样它们就会被分组到一个 toppings 数组中。我们还为每个复选框添加了一个值属性,表示如果该选项被选中,所添加到数组中的配料类型。图 11-14 显示了当提交表单并勾选所有三个复选框时,toppings[] 的多个值如何出现在查询字符串中。

图 11-14:复选框的值作为单一数组变量的值提交

为了成功处理这个表单,我们必须确保 process.php 脚本已经设置好以接受复选框值的数组。如果没有选择复选框,数组可能为空,或者数组可能包含一个或多个值。列表 11-16 使用 if...else 语句来处理这两种情况,因此无论选择了多少个比萨配料,它都能正常工作。

<?php
❶ $toppings
      = filter_input(INPUT_GET, 'toppings', options: FILTER_REQUIRE_ARRAY);

if (empty($toppings)) {
  ❷ print 'no extra toppings selected';
} else {
  ❸ $toppingsString = implode('+', $toppings);
    print "toppings: $toppingsString";
}

列表 11-16:处理复选框值的数组

现在复选框的值变得重要了,我们需要使用 filter_input() 而不是 filter_has_var() ❶。像往常一样,我们使用函数的前两个参数从查询字符串中获取 toppings 变量,但这次我们还使用了一个命名参数 options: FILTER_REQUIRE_ARRAY,指定我们需要获取 toppings 变量名下的一个数组。我们将结果数组存储在 $toppings PHP 变量中。

我们需要在这里使用命名参数,因为 $options 是 filter_input() 函数的第四个参数。我们跳过了第三个参数 $filter,让它保持默认值 FILTER_DEFAULT。关于命名参数和可选参数的复习,请参见 第五章。

接下来,我们使用 if...else 语句来检查 $toppings 数组是否为空。如果为空,我们会相应地打印一条消息 ❷。否则,我们使用内置的 implode() 数组函数将 toppings 数组压缩成一个单一的字符串,字符串中的配料名称通过加号连接 ❸。回顾 图 11-14 底部,可以看到当三个选框都被选中时,生成的配料字符串。

单选列表

单选列表 显示为一个下拉菜单,允许用户选择一个选项。这个列表是通过 HTML <select> 元素创建的,元素中包含 <option> 元素,表示可能的选择。<select> 元素有一个名称,在表单提交时,该名称将与所选 <option> 元素的值一起传送,从而在接收端形成一个简单的名称/值对。例如,图 11-15 显示了一个提供简单单选花卉列表的表单。

图 11-15:单选列表

让我们来创建这个花卉表单。首先创建一个新项目,包含 public/index.php 文件,并输入 列表 11-17 的内容。

<!doctype html><html><head><title>Single Selection list</title>
<link rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
>
</head>
<body class="container">
<h1>Flower selection</h1>
<form method="GET" action="process.php">
<div class="row">
    <div class="col-lg-3 text-end py-2">
        <input type="submit" value="Select a flower" class="btn btn-primary">
    </div>
    <div class="col-lg-3 text-end py-2">
        <select name="flower" class="custom-select"> ❶
            <option value="poppy">Poppy</option>
            <option value="daisy">Daisy</option>
            <option value="tulip">Tulip</option>
        </select>
    </div>
</div>
</form>
</body>
</html>

列表 11-17:带有单选列表的 HTML 表单

我们声明一个使用 GET 方法的表单,表单中有一个提交按钮和一个名为 flower 的选择列表 ❶。<select> 元素包含三个 <option> 元素,它们的值分别为 poppy、daisy 和 tulip。当表单提交时,所选的花卉将作为名为 flower 的查询参数发送到 HTTP 请求中。例如,在 图 11-15 中,当选择了 poppy 时,查询字符串中会出现 flower=poppy

我们可以通过使用 filter_input()和选择元素的名称来处理通过单选列表提交的选择。例如,在我们的花卉表单中,我们可以使用 filter_input(INPUT_GET, 'flower')来提取用户选择的花卉。

多选列表

多选列表允许用户从菜单中选择多个选项。通过在 HTML 的 元素。请注意,输入框有一个 placeholder 属性,其值将显示为淡灰色的填充文本,并且我们为密码输入框指定了 type="password" 属性,以便在输入密码时进行隐藏。

为了完成登录页面,我们将创建 CSS 样式表 public/css/login.css,如 清单 16-9 所示。它为登录表单添加了自定义样式。回想一下,通用的 _header.php 模板会为每个页面读取这个样式表。

.formLogin {
    background-color: lightgray;
    padding: 4rem;
    max-width: 30rem;
}

清单 16-9:login.css 中的 CSS 代码

样式表定义了 清单 16-8 中引用的 formLogin 类。此样式将表单背景设置为浅灰色,添加填充,并设置最大宽度为 30 个字符。

编写前端控制器

如同往常一样,我们将创建一个单一的前端控制器,所有对我们 Web 应用的请求都必须通过它。创建 public/index.php,其中包含 清单 16-10 中的代码。

<?php
require_once __DIR__ . '/../src/functions.php';

$action = filter_input(INPUT_GET, 'action');

switch ($action) {
  ❶ case 'contact':
        contact();
        break;

  ❷ case 'login':
        $isSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST');
        if ($isSubmitted) {
            // POST method so process submitted login data
            processLogin();
        } else {
            // GET method to display login form
            loginForm();
        }
        break;

  ❸ default:
        home();
}

清单 16-10:index.php 前端控制器脚本

该脚本遵循通常的模式,读取函数声明文件,提取动作查询字符串参数的值(如果请求中存在),并将其传递给一个 switch 语句来决定执行的操作。如果值是 contact ❶,我们调用 contact(),它会读取模板并显示联系我们页面。如果值是 'login' ❷,我们测试 HTTP 请求是否使用了 POST 方法,表明用户已通过登录表单提交了用户名和密码,如果是这样,则调用 processLogin() 函数。否则,我们调用 loginForm() 函数显示登录页面。最后,默认情况 ❸ 调用 home() 函数显示首页。

实现逻辑函数

接下来,我们需要创建实现网站逻辑的函数,这些函数保存在src/functions.php中。五个函数非常简单:它们只是显示网站的四个主要页面(主页、联系我们、登录页面、银行安全页面),以及一个错误信息页面。我们将首先查看这些函数,如清单 16-11 所示。

<?php
function home(): void
{
    require_once __DIR__ . '/../templates/homepage.php';
}

function contact(): void
{
    require_once __DIR__ . '/../templates/contact.php';
}

function loginForm(): void
{
    require_once __DIR__ . '/../templates/login.php';
}

function secureBanking(): void
{
    require_once __DIR__ . '/../templates/secureBanking.php';
}

function showError($message): void
{
    require_once __DIR__ . '/../templates/error.php';
}

清单 16-11:functions.php 中的显示函数

前四个函数都执行相同的任务:它们使用 require_once 语句读取并显示其中一个模板脚本。接下来,showError()函数期望一个\(message 字符串作为参数。它也使用 require_once 语句读取并显示其中一个模板脚本。在这种情况下,由于\)message 是一个参数,它在读取并执行error.php模板时具有作用域,因此模板可以显示$message 字符串中的内容。(我们将很快创建error.php模板。)

functions.php脚本的第二部分,如清单 16-12 所示,声明了三个用于处理从登录表单提交的用户名和密码的函数。

❶ function getUsers(): array
{
    $users = [];
    $users['matt'] = 'smith';
    $users['james'] = 'bond';
    $users['jane'] = 'doe';

    return $users;
}

❷ function processLogin(): void
{
    $username = filter_input(INPUT_POST, 'username');
    $password = filter_input(INPUT_POST, 'password');

  ❸ if (validLoginCredentials($username, $password)) {
        secureBanking();
    } else {
        showError('invalid login credentials - try again');
    }
}

❹ function validLoginCredentials($username, $password): bool
{
    $users = getUsers();

    if (isset($users[$username])) {
        $storedPassword = $users[$username];
        if ($password == $storedPassword) {
            return true;
        }
    }

    // If get here, no matching username/password
  ❺ return false;
}

清单 16-12:functions.php 的第二部分

在这部分脚本中,我们声明了 getUsers()函数❶,它返回一个名为$users 的数组,数组的键是用户名,值是密码。这是可以通过我们网站的登录系统进行身份验证的用户列表(通过提供有效的用户名及其对应的密码)。虽然这里使用的是数组,但在实际网站中,通常会从数据库中获取用户名和密码数据,并且密码会出于安全原因进行哈希处理。我们将在第三十章中学习如何实现这一点。

接下来,我们定义了 processLogin()函数❷。在这个函数中,我们使用 filter_input()尝试获取通过登录表单提交的用户名和密码,并将这些值存储在\(username 和\)password 变量中。然后,我们将这些值传递给 validLoginCredentials()函数❸。如果该函数返回 true,我们就成功验证了用户,因为他们能够提供匹配的用户名-密码组合。因此,我们通过调用 secureBanking()函数来显示银行安全页面。如果 validLoginCredentials()返回 false,则调用 showError()函数显示错误页面,并传递一个错误信息,说明登录凭据无效。

请注意,错误信息并没有告诉用户问题出在用户名还是密码。这遵循了最小信息披露的常见安全做法。我们不应该告诉用户(或黑客机器人或任何尝试登录的程序)他们是否已经找到了有效的用户名。有了这个信息,攻击者可以反复使用有效的用户名,搭配不同的密码尝试访问系统,这比每次都需要猜测用户名密码要容易。

最终的函数是 validLoginCredentials() ❹,它期望两个参数,\(username 和\)password。这是我们执行至关重要的任务——验证尝试登录的用户的地方。我们首先从 getUsers()获取以用户名为索引的密码数组,并将其存储在$users 变量中。

然后我们测试是否可以在\(users 中找到键\)username 对应的元素。如果没有找到这样的键(isset(\(users[\)username])为 false),我们将退出 if 语句,函数返回 false ❺,表示提交的用户名和密码无效。然而,如果\(users 中可以找到\)username,相关的值将存储在\(storedPassword 变量中。然后我们测试从登录表单接收到的密码(\)password)是否与从数组中获取的密码($storedPassword)匹配。如果两个密码匹配,我们就拥有有效的凭证,因此返回 true。否则,脚本将退出 if 语句并返回 false。

创建错误页面模板

现在我们将创建错误页面的模板(图 16-5)。

图 16-5:无效登录凭证后的错误信息页面

这个模板保存在templates/error.php中,类似于我们之前创建的其他页面模板,如清单 16-13 所示。

<?php
$pageTitle = 'Error page';
require_once '_header.php';
?>

<div class="alert alert-danger" role="alert">
    Sorry, there was a problem:
    <p>
      ❶ <?= $message ?>
    </p>
</div>
</body>
</html>

清单 16-13:error.php 模板

我们将\(pageTitle 的值设置为“错误页面”,然后读取并执行公共的*header.php*模板。在一个粉色的 Bootstrap 警告样式的<div>中,我们输出\)message 变量中的字符串 ❶。所有包含此错误页面模板的脚本,应该首先将一个字符串赋值给此变量(正如我们在清单 16-12 中所做的那样,当我们调用 showError()并传递字符串“无效的登录凭证 - 请再试一次”时)。 ### 使用会话存储登录数据

虽然我们的网站目前允许用户通过登录表单进行身份验证并访问安全银行页面,但该网站并不记住成功的登录凭证。一旦用户离开银行详细信息页面,他们将需要返回登录表单并重新提交凭证才能再次查看。为了使网站更加用户友好,我们可以使用 PHP 会话来记住成功的登录。

如果所有登录用户应该具有相同级别的访问权限,我们可以在成功登录后简单地将用户名存储到会话中,正如我们将在本节中所做的那样。如果不同的用户有不同的角色,并且每个角色对应不同的授权级别(例如销售、主管、经理、管理员),我们可以将用户名和相应的角色都存储在会话中。然后我们会编写逻辑,使已登录的用户只能访问适合其角色的页面。我们将在本章末的练习 3 中讨论第二种方法。

让我们添加一些代码,将登录数据保存到会话中。我们还将为导航栏添加一个指向安全银行页面的链接,但只有在用户登录后才能访问该页面。否则,我们将显示身份验证错误信息。

更新前端控制器

我们首先需要编辑我们的index.php前端控制器,以处理导航到安全银行详情页面。由于我们现在计划使用会话来记住登录数据,因此我们还需要在前端控制器脚本的开始部分(重新)启动 PHP 会话。列表 16-14 显示了更新后的脚本,并突出显示了新增的代码。

<?php
session_start();
require_once __DIR__ . '/../src/functions.php';

$action = filter_input(INPUT_GET, 'action');

switch ($action) {
 case 'contact':
 contact();
 break;

 case 'login':
 $isSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST');
 if ($isSubmitted) {
 // POST method so process submitted login data
 processLogin();
 } else {
 // GET method to display login form
 loginForm();
 }
 break;

 ❶ case 'secured':
        if (isLoggedIn()) {
            secureBanking();
        } else {
            showError('invalid login credentials - try again');
        }
        break;

 default:
 home();
}

列表 16-14:更新后的 index.php 前端控制器脚本

在脚本开始时,我们(重新)启动会话。然后我们为当 $action 的值为 'secured' 时,向 switch 语句添加一个新的 case❶。在这种情况下,我们调用 isLoggedIn() 函数,稍后我们将编写该函数。如果返回 true,则调用 secureBanking() 显示安全银行页面。否则,我们显示错误页面,并提示“无效的登录凭据 - 请重试”。

编写登录功能

现在我们需要编写一个新的 isLoggedIn() 函数,用来检查 $_SESSION 数组中是否存储了用户名,从而表明用户已成功登录。我们还需要更新我们的 processLogin() 函数,以便在处理有效的登录凭据时,将用户名存储到 $_SESSION 中。首先,按照 列表 16-15 中所示,将 isLoggedIn() 添加到 src/functions.php 的末尾。

function isLoggedIn(): bool
{
    if (isset($_SESSION['username'])) {
        return true;
    } else {
        return false;
    }
}

列表 16-15:isLoggedIn() 函数

该函数使用简单的 if...else 语句,基于是否可以在 $_SESSION 数组中找到 'username' 字符串键的值。如果找到了,我们返回 true;如果没有找到,我们返回 false。请注意,我们不需要测试会话中 'username' 键下存储的实际值。我们只测试该键是否存储了任何值。我们并不关心已登录用户的用户名是什么,只要他们已经成功登录。

现在,按照 列表 16-16 中所示,编辑 src/functions.php 中的 processLogin() 函数,以便在成功登录后将用户名存储到会话中。

function processLogin(): void
{
 $username = filter_input(INPUT_POST, 'username');
 $password = filter_input(INPUT_POST, 'password');

 if (validLoginCredentials($username, $password)) {
        $_SESSION['username'] = $username;
 secureBanking();
 } else {
 showError('invalid login credentials - try again');
 }
}

列表 16-16:更新 processLogin() 函数

在函数条件逻辑的 if 分支中,我们将提交的用户名存储在 $_SESSION 数组的 'username' 键下。这样,isLoggedIn() 的测试将在成功登录后通过。

更新头部模板

现在让我们编辑公共的 templates/_header.php 文件,添加一个指向安全银行页面的导航栏链接,并包含其相关的 CSS 样式变量。我们将使用 if 语句,使得此链接仅在用户登录时才会出现。我们需要将这个条件导航项添加到主页和联系我们页面的导航栏项之后,如 列表 16-17 所示。

<?php
$homeLink =  $homeLink ?? '';
$contactLink =  $contactLink ?? '';
$loginLink =  $loginLink ?? '';
❶ $securedLink =  $securedLink ?? '';

$pageTitle =  $pageTitle ?? '';
?>

--snip--

<ul class="navbar-nav">
 <li class="nav-item">
 <a class="nav-link <?= $homeLink ?>" href="/">
 Home
 </a>
 </li>

 <li class="nav-item">
 <a class="nav-link <?= $contactLink ?>" href="/?action=contact">
 Contact Us
 </a>
 </li>

❷ <?php if (isLoggedIn()): ?>
    <li class="nav-item">
      ❸ <a class="nav-link <?= $securedLink ?>" href="/?action=secured">
            Secure banking
        </a>
    </li>
<?php endif; ?>
</ul>
--snip--

列表 16-17:在 _header.php 中为安全银行页面添加条件导航链接

我们使用空合并操作符,如果\(securedLink 变量尚无值,则将其设置为空字符串 ❶。然后,我们添加一个 if 语句,使用 isLoggedIn()函数测试用户是否已登录 ❷。如果是,那么 if 语句中的导航链接将会显示。该链接会将 action=secured 变量添加到查询字符串中 ❸。还需要注意,\)securedLink 变量的值是该链接的 CSS 类的一部分。与其他导航链接一样,如果该变量包含字符串'active',则该链接会被高亮显示。

更新银行页面模板

既然我们已经为安全银行页面添加了一个导航链接,我们需要更新templates/secureBanking.php脚本,将$securedLink 变量设置为'active'。这将在查看页面时高亮显示页面的导航链接。按 Listing 16-18 所示更新模板。

<?php
$pageTitle = 'Secure Banking- Swiss bank account details';
$securedLink = 'active';
require_once '_header.php';
?>
--snip--

Listing 16-18:更新 secureBanking.php 模板

在这里,我们需要做的唯一更改是,在读取共享的头部模板之前,添加设置$securedLink 变量的语句。

提供登出功能

如果我们提供了用户登录并记住其登录信息的方式,我们也应该提供登出的方式。登出用户意味着将$_SESSION 数组设置为空,这样它就不再包含字符串键'username'的元素。为了实现这一点,我们需要添加一个新函数,更新前端控制器,并在导航栏中创建一个登出链接。

添加登出功能

首先,让我们在src/functions.php中编写一个 logout()函数,清除会话中的用户数据。将 Listing 16-19 中的代码添加到文件末尾。

function logout(): void
{
    $_SESSION = [];
    home();
}

Listing 16-19:logout()函数

我们将$_SESSION 设置为空数组,清除会话中的存储用户名。然后,我们调用 home()函数,在用户登出后显示主页。

更新前端控制器

现在,我们需要在index.php前端控制器的 switch 语句中添加一个新的登出案例。按 Listing 16-20 所示更新文件。

--snip--

 case 'secured':
 if (isLoggedIn()) {
 secureBanking();
 } else {
 showError('invalid login credentials - try again');
 }
 break;

  ❶ case 'logout':
        logout();
        break;

 default:
 home();
}

Listing 16-20:index.php 中的登出案例

我们添加了一个案例,当$action 变量的值为'logout'时调用 logout()函数 ❶。

显示登出链接

最后,我们需要根据用户是否已登录来有条件地决定是提供登录链接还是登出链接。因此,我们需要在公共的templates/_header.php文件中添加一个 if 语句,如 Listing 16-21 所示。

--snip--
 <?php if (isLoggedIn()):?>
 <li class="nav-item">
 <a class="nav-link <?= $securedLink ?>" href="/?action=secured">
 Secure Banking
 </a>
 </li>
 <?php endif; ?>

 </ul>

 <ul class="navbar-nav ms-auto p-2">
 <li class="nav-item">

      ❶ <?php if (isLoggedIn()): ?>
            <a class="nav-link" href="/?action=logout">
                Logout
            </a>
      ❷ <?php else: ?>
 <a class="nav-link <?= $loginLink ?>" href="/?action=login">
 Login
 </a>
        <?php endif; ?>

 </li>
 </ul>
</header>

Listing 16-21:_header.php 中条件性登录/登出导航栏链接

在声明具有 nav-item 类的 HTML 列表项内部,我们使用 if...else 语句来测试 isLoggedIn()函数返回的值。如果用户已登录 ❶,我们显示/?action=logout 链接。否则,如果用户未登录 ❷,我们像以前一样显示/?action=login 链接。

图 16-6 显示了用户成功登录并访问安全银行详细信息页面时的导航栏。

图 16-6:显示“安全银行”和“注销”链接的导航栏

请注意,注销链接出现在右侧,而不是登录链接。此外,中间的“安全银行”链接被突出显示,因为这是用户当前查看的页面。

显示已登录的用户名

我们将为网站添加的最后一个功能是,在导航栏中显示已登录用户的用户名,位于注销链接的上方。为此,我们需要一个函数来返回存储在 $_SESSION 数组中的用户名。我们还需要更新共享的头部模板并向我们的 CSS 样式表中添加额外的代码。

检索用户名

要查找当前用户的用户名,请将 列表 16-22 中的函数添加到 src/functions.php 文件的末尾。

function usernameFromSession(): string
{
    if (isset($_SESSION['username']))
        return $_SESSION['username'];
    else
        return '';
}

列表 16-22:usernameFromSession() 函数

在这里,我们定义了 usernameFromSession() 函数。使用 isset(),我们检查 $_SESSION 数组中是否可以在 'username' 键下找到值。如果存在值,它将被返回。否则,函数将返回一个空字符串。

更新导航栏

列表 16-23 显示了我们需要在公共 templates/_header.php 文件中添加什么,以显示当前用户名以及注销链接。

--snip--
<?php if (isLoggedIn()):?>
    <span class="username">
        You are logged in as:
        <strong><?= usernameFromSession() ?></strong>
    </span>
 <a class="nav-link" href="/?action=logout">
 Logout
 </a>
<?php else: ?>
--snip--

列表 16-23:在 _header.php 中显示用户名

我们声明一个 HTML 元素,并使用 CSS username 类(我们接下来将创建)进行样式设置。它显示文本“您已登录为:”,后跟从 usernameFromSession() 函数返回的值。由于我们应该仅在用户登录时显示此文本,因此始终会有存储的用户名,因此 usernameFromSession() 永远不应返回空字符串。

更新 CSS

最后,我们需要为用户名类添加一个 CSS 规则到 public/css/login.css,如 列表 16-24 中所示。此样式规则将用户名文本的颜色设置为黄色(与导航栏的深色背景形成对比)。username {

 color: yellow;
}

列表 16-24:login.css 中的用户名 CSS 类

图 16-7 显示了由于此 CSS 声明,用户名是如何在导航栏中显示的。

图 16-7:导航栏中的用户名和注销链接

显示用户名的文本出现在注销链接的上方。在此示例中,我使用用户名 matt 登录。这个用户名已成功存储在 $_SESSION 数组中,并随后被检索并显示出来。

总结

在本章中,我们创建了一个前端控制器驱动的网站,使用登录表单方法来验证用户身份。虽然这是一个只有几页的小网站,但其基本架构和安全性方法与现实世界中安全网站的运作方式类似。我们编写了函数来检查提交的用户名和密码是否与存储的用户名和密码对匹配。

我们将成功认证的用户的详细信息存储在 PHP 会话中,以便记住用户何时已登录。然后,我们编写了程序逻辑,例如 isLoggedIn() 函数,使我们的网站能够决定用户是否有权查看银行详情。我们使用相同的逻辑来决定是否在导航栏中显示登录或注销链接。

练习

1.   为本章的网站添加第二个受保护的页面,显示一个数学问题的解答(答案 = -2!)。在导航栏中,添加一个链接到该受保护页面的链接,只有在用户成功登录时才会显示。

提示:你需要在 index.php 前端控制器中添加一个新的情况,并在 functions.php 中添加一个新的函数来显示页面。

2.   为系统添加两个额外的授权用户,一个用户名为 fred,密码为 flintstone,另一个用户名为 teddy,密码为 cuddly。

3.   通过为网站添加两个用户认证角色:'USER' 和 'BANKER',尝试为网站添加另一层安全保护。任何已登录的用户都可以查看数学解题页面,但只有具有 'BANKER' 角色的用户才能查看银行详情页面。为系统添加两个更多的授权银行用户凭据,一个用户名为 banker1,密码为 rich,另一个用户名为 banker2,密码为 veryrich。

提示:尝试以下方法:

a.   就像你有 getUsers() 函数一样,添加一个 getBankers() 函数。

b.   将 validLoginCredentials() 函数重命名为 validUSERLoginCredentials()。

c.   编写该函数的第二个版本,命名为 validBANKERLoginCredentials()。

d.   更改 processLogin() 函数中的逻辑,执行以下操作:如果一个有效用户登录,存储其用户名到会话中并显示主页。如果一个有效银行用户登录,存储其用户名到会话中,将其角色存储到会话中 ($_SESSION['role'] = 'BANKER'),并显示主页。

e.   添加一个新的 getRoleFromSession() 函数,用于返回会话中找到的角色。如果 $_SESSION['role'] 中有值,则返回该字符串;否则,返回一个空字符串。

f.   更改 index.php 前端控制器中的逻辑如下:对于数学解题页面,检查用户是否已登录;对于银行页面,检查已登录用户的角色是否为 'BANKER'。你可以写类似 getRoleFromSession() == 'BANKER' 的代码。

第五部分 面向对象的 PHP

第十七章:17 面向对象编程简介

到目前为止,我们一直在使用 PHP 编写过程式代码,这是按顺序执行的一系列指令。现在,我们将把注意力转向另一种使用 PHP 的方式:面向对象编程(OOP)。本章将概述一些重要的 OOP 概念。接下来的几章将更深入地介绍如何在 PHP 项目中应用 OOP。

面向对象的编程风格围绕着对象展开,对象是现实世界事物的计算机表示,以及,即定义了每个特定类别的对象应具备的所有能力和特征的通用模型。在面向对象的计算机系统中,对象之间相互发送消息,解释这些消息,并决定响应时要执行的指令,通常会生成一个返回给发送者的值。

OOP 的力量在于它的抽象能力:程序员可以将大量精力集中在规划一个与应用程序要解决的现实任务或问题相关的类系统上,而不是总是需要考虑代码本身。例如,一个在线银行系统可能需要像 Client、BankAccount 和 Transaction 这样的类,从这些类创建的对象将表示特定的客户、银行账户和交易实例。修改这些对象的消息和操作可能包括像 withdrawCash(\(sum)、setNewOverdraft(\)limit) 或 updateClientAddress(\(address) 这样的函数。类似地,一个在线电脑游戏可能需要像 Player、Level 和 InventoryItem 这样的类,操作和消息可能包括 purchaseInventoryItem(\)itemID) 和 setPlayerName($name)。程序员可以在编写一行代码之前就识别出所有这些需求,并规划出必要的类关系网。得益于这种规划和组织,编写代码的过程变得更加容易。

最终,程序员必须声明每个类,这确实需要编写代码。程序员将声明数据变量和函数,以执行诸如进行数值计算、操作字符串和数组等典型的编程任务。然而,OOP 的美妙之处在于,一旦你创建了一个类,它的结构本质上是“隐藏在幕后”的。剩下的编程过程可以集中在利用对象的消息和函数上,这些消息和函数与现实世界的概念和任务密切相关。

类和对象

面向对象程序由声明类的 PHP 文件组成。类可以被视为一个蓝图或模板,基于这个蓝图可以创建对象。就像汽车的蓝图只是纸上的一张图纸一样,声明类的 PHP 文件本身并不做任何事情。然而,正如你可以要求工厂根据汽车蓝图制造一辆或多辆实际汽车一样,你可以要求 PHP 引擎使用类声明来创建基于该类的对象。

有时候人们把对象称为类的 实例,因为每个对象是类定义的通用特征和行为的一个具体体现。你可以将对象实例视为同义词:它是计算机内存中的一个对象,从类模板创建,具有一组数据值,并能够响应消息和执行函数。图 17-1 说明了类与从该类创建的对象之间的关系。

图 17-1:Client 类和两个 Client 对象,matt 和 aoife

图中的类 Client 代表银行的客户。你需要了解类的三个重要方面:它的名称、它的数据变量和它的函数。在这个例子中,我们的 Client 对象将包含客户的 ID 号、姓名和联系方式等数据变量。当变量作为类的一部分声明时,它们被称为 属性。同样,我们的 Client 对象有几个函数:你可以关闭、暂停或恢复客户账户。当函数作为类的一部分声明时,它们被称为 方法。类的各个部分统称为它的 成员;类的成员包括所有属性(变量)、方法(函数)和常量。

图 17-1 底部还展示了从 Client 类创建的两个对象(或实例),分别命名为 matt 和 aoife。每个对象都有自己的一组属性(例如,matt 对象的姓氏是 Smith,地址是都柏林主街 10 号),并且这两个对象都可以访问 Client 类中定义的方法。在 PHP 编程中,你可以拥有一个 $matt 变量,作为对同名 Client 对象的引用,你可以通过编写 $matt->closeAccount() 发送消息来关闭 Matt 的账户。当 $matt 对象收到这个消息时,它将执行其 closeAccount() 方法。

警告

当你编写面向对象的 PHP 代码时,确保不要将 -> 对象运算符(用于对象和消息)与 => 运算符混淆,后者用于数组中的键/值关系。 ### 创建对象之间的关系

面向对象编程(OOP)的一个强大特性是,你可以通过将一个对象的属性链接到另一个对象,建立对象之间的关系。在某些情况下,你可能会将同一类的对象关联起来。例如,如果你有一个 Person 类,你可能会将一个 Person 对象链接到另一个 Person 对象,以展示一个人是另一个人的父母。其他时候,你可能会将不同类的对象关联起来,比如建立一个 Client 对象是一个 BankAccount 对象的所有者,如图 17-2 所示。

图 17-2:BankAccount 类声明每个 BankAccount 对象都与一个 Client 对象相连接。

图的顶部显示了 BankAccount 类。与我们之前讨论的 Client 类一样,它包含了该类对象可能拥有的数据属性和方法:每个 BankAccount 对象都有一个账户号码、一个所有者、一个余额、一个透支限额和一个类别,并且拥有存款、取款以及设置透支限额的方法。

owner 属性特别重要:它的值必须是指向 Client 对象的引用。因此,owner 属性在 BankAccount 和 Client 类的对象之间创建了一个联系。例如,如你在图的底部看到的,DUB1070,一个 BankAccount 对象,链接到 matt,一个 Client 对象。这个机制的妙处在于,对于我们正在处理的任何 BankAccount 对象,我们可以通过 owner 属性的链接找到其相关的 Client 对象,从而得知拥有该银行账户的人的姓名、地址以及其他细节。### 封装与信息隐藏

一个类将对象的数据和可以影响这些数据的方法组织在一起,将它们聚集在同一地方。这一原则被称为封装,它是面向对象编程的核心。封装有助于保持项目的组织性;回到图 17-1 中的例子,可以看出,处理客户数据的方法声明在同一个文件中,该文件还声明了应存储的客户数据属性。

然而,如果一个对象的所有数据都可以被计算机系统中任何可以访问该对象的部分直接更改,就会产生风险。例如,我们不希望 Client 对象的年龄被设置为 0 或负数!事实上,银行可能有政策要求客户的最低年龄为 16 岁。为了避免这种未经授权的更改并确保数据有效,面向对象的语言(包括 PHP)提供了控制访问对象数据的方式。

面向对象编程(OOP)中的一个特性是管理对象数据和方法的访问权限,这被称为信息隐藏。在 PHP 中,你可以使用 public、private 和 protected 关键字来声明不同级别的访问权限,以控制类对象的属性和方法的访问权限。继续我们之前的例子,我们可能通过将 Client 对象的年龄属性设为 private 来防止直接访问。然后,我们可能声明一个 public 的 setAge()方法,只有在满足某些验证要求(如年龄为 16 岁或更大且为整数)时,才会更新年龄。我们将在接下来的几章中详细讨论如何使用面向对象的 PHP 特性。

超类、继承与重写

你可以将几个类之间共有的属性和方法分配给一个超类,这是一个通用类,其他类(称为子类)可以从中继承特性。例如,银行的员工和客户将共享许多共同的数据属性,如姓名、地址和电话号码。图 17-3 显示了 Client 和 StaffMember 类的共同属性和方法,已用粗体标出。一些属性和方法是每个类特有的,例如 Client 对象的 clientId 和 StaffMember 对象的 staffId。

图 17-3:Client 和 StaffMember 类有许多重复的成员——效率极低!

图 17-4 说明了我们如何将共同的属性和方法概括成一个名为 Person 的新超类,Client 和 StaffMember 类都从中继承。只有那些特定子类独有的属性和方法才会直接在子类中定义。在 PHP 中,我们可以通过简单地写class Client extends Person来表示一个类从另一个类继承。

图 17-4:通用的 Person 超类消除了重复。

超类和继承帮助你避免在多个类中重复代码。例如,你不希望在多个地方编写验证电话号码和地址等操作的代码;如果某些内容发生变化(例如 2014 年引入了爱尔兰的邮政编码——Eircodes!),你将不得不更新多个类,可能会导致地址和电话号码在系统的不同部分被不同对待。得益于超类和继承,代码只需要更新一次。

通常,你会希望子类继承其父类的所有方法,但这并不总是适用。有时,一个类可能需要具有与其父类不同的逻辑。例如,你可能有一个客户的子类,它的成本或税费计算方式不同,或者你可能有一些产品需要显示特殊的免责声明。在这种情况下,子类可以重写继承的方法;也就是说,你可以在子类中直接创建一个方法,这个方法将优先于父类中同名的方法。在 PHP 中,重写方法非常简单:如果子类声明实现了一个与父类继承的方法匹配的方法,那么将使用子类的方法。

面向对象系统的控制流

每种类型的编程语言都有一个控制流,它指示计算机系统启动后如何运行,并决定接下来要做什么。正如你在前几章中看到的,面向过程的 PHP Web 应用程序的控制流通常由index.php PHP 脚本中的前端控制器驱动。当 Web 服务器接收到 HTTP 请求时,index.php中的语句按顺序执行。然而,在面向对象的应用程序中,许多 PHP 文件用于声明对象类,控制流可能看起来更加晦涩。那些类的对象到底什么时候会被创建,并开始相互发送消息呢?

面向对象的 PHP Web 应用程序仍然有一个index.php脚本,尽管它看起来与我们之前看到的有所不同。它通常会创建主要的应用程序对象,该对象作为前端控制器,并指示该对象处理接收到的请求并作出适当的响应。示例 17-1 展示了我们将在接下来的章节中编写的index.php脚本的类型。

<?php
require_once __DIR__ . 'path to class declaration file';

$app = new WebApplication();

$app->run();

示例 17-1:面向对象的 index.php 前端控制器的典型代码

首先,我们读取我们将使用的类(或类)的声明。你将在第十八章中学到一种简单的方法来实现这一点,然后在第二十章中,你将学习如何使用 Composer PHP 命令行工具以更通用的方式加载类声明。

接下来,我们创建一个新的 WebApplication 类对象,并将对这个新对象的引用存储在$app 变量中。WebApplication 类将包含处理来自 Web 客户端的 HTTP 请求的逻辑,这些逻辑本来是我们以前会直接放入index.php脚本中的内容。

然后,我们向 WebApplication 对象发送消息 run(),这将导致在 WebApplication 类中为特定的$app 对象执行声明的 run()方法。run()方法的代码将包括诸如从 URL 编码的变量中提取动作并检查会话中的登录状态等操作。代码还可能调用其他方法或根据需要创建新对象,以完成网页客户端请求的操作。

总结来说,对于像这样的网页应用程序,控制流是在index.php文件中顺序进行的;该文件中的语句会按顺序执行。因此,会创建一个对象,并向该对象发送消息以开始处理 HTTP 请求。从此之后,网页应用程序的所有其他逻辑将位于 WebApplication 类的方法中,或者是 WebApplication 类方法执行过程中会创建对象的其他类中。

示例类声明

现在让我们来看一个 PHP 类声明的示例。在 Listing 17-2 中,我们声明了一个名为 Player 的类,可能是在线游戏系统的一部分。暂时不要太担心代码的细节;我们将在后面的章节中更加详细地介绍如何编写 PHP 类。现在,这个示例只是提供了即将到来的代码类型的一瞥。

<?php
class Player
{
    private string $name;
  ❶ private int $highScore = 0;

  ❷ public function setName(string $name)
    {
        $this->name = $name;
    }

  ❸ public function setHighScore(int $newScore)
    {
        if ($newScore > $this->highScore) {
            $this->highScore = $newScore;
        }
    }
}

Listing 17-2:声明 Player 类的 PHP 代码

我们使用 class 关键字声明一个名为 Player 的类,并为该类提供两个属性:name 和 highScore。就像在非面向对象 PHP 中声明变量一样,你可以为属性指定默认值。我们在这里这么做,设置 highScore 的默认值为 0❶,这样每个新的 Player 对象都会创建时拥有初始的高分 0。接着,我们声明 setName()方法❷,当调用时,它会接收一个字符串参数并将其存储在 Player 对象的 name 属性中。然后我们声明 setHighScore()方法❸。它接收一个参数($newScore),如果这个新分数高于该对象存储的高分,那么新的分数将存储在对象的 highScore 属性中。

当一个方法被执行时,它将在特定的由类创建的对象的属性上进行操作。在方法的定义中,特殊的 PHP 关键字\(this 表示将调用该方法的对象。因此,在 setName()方法的定义中,我们使用\)this 关键字(如$this->name = \(name;)作为占位符,代表正在设置名称的 Player 对象。例如,我可能有一个对象\)player1,其名称设置为"Matt",另一个对象\(player2,其名称设置为"Aoife"。在这两种情况下,setName()方法代码❷都会被调用来设置名称,并且在这两种情况下,\)this 都会代表正在设置名称的 Player 对象:首先是\(player1,然后是\)player2。

我们的类声明包括了信息隐藏的示例,因为 name 和 highScore 属性被声明为私有的。它们不能被 Player 类外部的代码修改。然而,我们也将 setName() 和 setHighScore() 方法声明为公共的。这些setter 方法允许与 Player 对象进行外部交互,但仅通过方法中的内部验证检查(例如,在覆盖 highScore 属性之前检查新得分是否超过了之前的最高得分);这些检查确保对象的数据永远不会被设置为无效或不一致的值。

请注意,列表 17-2 中的每个方法都很简短和简单。一旦应用程序的架构创建完成,编写代码以声明类的每个属性和方法通常是相对直接的。尽管一个完整的 Web 应用程序的方法会比此示例中的方法更长,但面向对象编程(OOP)的一个好处是,它通常允许程序员一次专注于编写类的一个方法,每个方法都有一个明确的职责。

使用面向对象编程,你不需要考虑方法可能的所有使用方式;你只需要确保你编写的行为是该类的正确行为。例如,不论一个 Player 对象的名字是在游戏开始时第一次设置,还是在游戏中间因为玩家改变了主意而更新,或者因为玩家变成了青蛙而自动改变,都不重要。关键是编写 setName() 方法,确保它要求有效的参数,并正确地修改 Player 对象的属性。因此,代码将易于编写和维护。

总结

在本章中,我们探讨了一些重要的面向对象编程概念。你看到了类是创建对象的模板,并且类允许你将对象所需的所有变量和函数存储在一个地方,这个概念叫做封装。你还看到对象可以通过它们的属性相互关联,不同子类的对象可以继承父类的共享属性和方法。最后,你初步了解了一些实现这些概念的 PHP 代码。

在接下来的几章中,你将学习如何声明类、创建对象、以及向对象发送消息以调用其方法并更改其数据。然后,你将开始将所有这些知识结合起来,创建结构良好的面向对象的 Web 应用程序,基于我们在前几章中探讨的特性。在你继续阅读时,别忘了你已经知道了编写面向对象 PHP 程序的许多核心要求,因为 OOP 的核心就是声明变量并在函数中编写 PHP 语句;只不过变量(属性)和函数(方法)被组织(封装)在类中,并且方法会响应发送给类的对象的消息进行调用。

练习

1.   选择一个计算机系统,例如一个在线商店、桌面或笔记本上的应用程序,或是手机或平板上的应用。思考该系统可能使用的某些对象类。选择一个对象类,并写出它可能存储的数据项以及它可能需要用来处理这些数据的一些方法。

2.   再次思考练习 1 中的类,尝试识别一个你希望限制访问的数据属性,以便它只能通过一个方法进行更改,该方法会记录更改日志。

3.   考虑一个图书馆的计算机系统。想想计算机系统可能使用的两类共享多个数据属性和方法的对象(例如,图书馆提供借阅的不同类型物品)。现在将这些公共属性和方法概括为一个适当命名的超类,并绘制类似于图 17-4 的类图,显示从超类继承的属性和方法以及每个子类特有的属性和方法。

第十八章:18 声明类并创建对象

在本章中,您将学习如何通过使用类声明文件来定义类的结构,并练习创建该类的各个对象。您将看到,具有公共属性的类允许您直接更改对象的数据,而具有私有属性的类则意味着您只能通过其方法来更改对象的数据,其中一些方法可以执行验证。您还将学习 PHP 的“魔术”方法,这些方法使编写面向对象的代码变得更容易。

声明类

类声明定义了一个类:它列出了该类的每个对象将拥有的属性(变量),以及可以作用于这些属性的方法(函数)。类声明还建立了该类与其他类的任何关系(例如继承,您将在第十九章中学习到)。

像函数声明一样,类声明存储在项目的src目录中的 PHP 文件中。对于本书中的所有项目,每个类将声明在其自己的文件中;如果一个项目有五个类,那么它将有五个类声明文件,依此类推。

注意

在本书中,我们不会探讨匿名类这一高级主题,它是少数几个可以在一个文件中声明多个类的情况之一。您可以在www.php.net/manual/en/language.oop5.anonymous.php了解更多信息。

根据面向对象编程(OOP)中的约定,类名和类声明文件名始终以大写字母开头。如果名称包含多个单词,每个单词应以大写字母开头,且单词之间不应有空格。这被称为大驼峰命名法,有时也称为Pascal 命名法。有效的类名示例包括 Product、NetworkSupplier、DesktopComputer、ReferenceBook 和 InventoryItem。

在本章中,我们将使用一个名为 Product 的类,它可以表示通过电子商务网站销售的各种商品。现在让我们来声明它。创建一个新目录用于新项目,并在其中创建一个src目录。在此src目录中,创建一个Product.php文件并输入列表 18-1 的内容。

<?php
class Product
{
    public string $name;
    public float $price;
}

列表 18-1:声明 Product 类的 Product.php 文件

我们从标准的 PHP 起始代码标签开始,因为我们使用 PHP 代码来声明类。接着我们使用 class 关键字来声明一个名为 Product 的新类。类名后面跟着大括号,我们在其中定义与该类对象相关的任何属性或方法。在这个例子中,我们为每个 Product 类的对象声明了两个属性:name,类型为字符串,以及 price,类型为浮点数。我们将两个属性声明为 public,这意味着程序的任何部分,只要能够访问 Product 对象,都可以读取和修改其属性值。我们将在本章后面探讨 public 属性的含义。

如果我们希望所有对象在某个属性上都有一个默认值,我们可以在类声明中为该属性赋值。例如,如果我们的系统为每个新的 Product 对象设置一个初始的 -1 价格,我们可以这样写:public float $price = -1

图 18-1 显示了一个统一建模语言(UML)类图,直观地展示了我们刚刚编写的类。UML 是一种常用的工具,用于通过图表和文本表示类、对象及其交互。

图 18-1:Product 类

图表的第一行表示类名(Product),第二行列出了与该类相关的属性,并显示了每个属性预期的数据类型。每个属性名前的加号表示这些属性具有公共可见性。

创建对象

你使用 PHP 关键字 new 来创建类的对象。new 关键字后跟类的名称,然后是一对圆括号。在圆括号内,你可以传递初始化参数,正如我们在《使用构造方法初始化值》一节中讨论的那样,参见 第 346 页。创建对象的语句的一般形式是 new ClassName()。创建对象也叫做实例化,因为该对象是该类的实例

通过写下新关键字和类名,你是在要求 PHP 创建一个指定类的新对象。当一个对象在计算机内存中使用 new 关键字创建时,PHP 引擎会自动返回对新对象的引用。在大多数情况下,你会希望将对该新创建对象的引用存储在一个变量中——例如,$myObject = new ClassName()。重要的是要理解,对于这样的语句,变量 $myObject 实际上并不包含对象本身,而是包含了该对象的引用。多个变量,甚至没有变量,都有可能引用内存中的同一个对象。

一旦你引用了一个对象,使用对象操作符(->)来访问该对象的属性和方法。例如,你可以写 $myObject->description 来访问由 $myObject 引用的对象的 description 属性。同样,你可以通过写类似 $myObject->setDescription('small carpet') 的代码来调用对象的 setDescription() 方法。括号的有无很重要,因为它们告诉 PHP 引擎(以及阅读代码的人)一条语句是在访问属性(没有括号)还是在调用方法(有括号)。

考虑到这些,我们来创建一个对象。我们将编写一个 index.php 脚本,读取 Product.php 类声明文件,创建一个 Product 对象,并设置它的属性值。图 18-2 展示了我们的目标:一个 $product1 变量,存储了对一个 Product 对象的引用,该对象的属性值为 'hammer' 和 9.99。

图 18-2:$product1 变量引用了一个 Product 类的对象。

为了简化起见,我们将从创建一个 Product 对象并仅设置它的 name 属性开始。为了确保我们的代码正常工作,我们还将打印该对象的 name 属性值到项目的主页。在你的项目目录中,创建一个 public 文件夹,并在该文件夹中创建一个包含清单 18-2 中代码的 index.php 文件。

<?php
require_once __DIR__ . '/../src/Product.php';

$product1 = new Product();
$product1->name = 'hammer';

print 'product 1 name = ' . $product1->name;

清单 18-2:一个创建和操作 Product 对象的 index.php 脚本

我们在声明 Product 类时,使用 DIR 魔术常量从 index.php 文件所在位置(位于 public 文件夹内)创建路径到 Product.php 文件所在位置(位于 src 文件夹内)。然后我们使用 new 关键字来创建一个 Product 类的新对象。由于该类在创建对象时不需要任何初始值或选项,我们在类名后面不传递任何参数。

如果你在创建新对象时没有传递任何参数,PHP(与大多数面向对象语言不同)允许你省略类名后面的括号。写 new Product() 和写 new Product 是等价的。然而,有几个很好的理由总是包含括号,因此你将在本书中看到这种风格。也许最重要的理由是,始终在 new 关键字后使用括号可以提醒我们,构造方法可能会在新对象创建时执行;我们将在“使用构造方法初始化值”章节中讨论这类方法,详见第 346 页。

新的 Product() 表达式创建了一个新对象,并返回一个对它的引用,我们将这个引用存储在 $product1 变量中。为了重申,$product1 并不包含对象本身,也不包含对象的副本。它仅仅包含一个指向计算机系统内存中创建的对象的引用。“对象变量作为引用”一节在 第 351 页 中,我们将用两个变量引用同一个对象来帮助说明这个概念。

接下来,我们将对象的名称属性值设置为字符串 'hammer';我们之所以能这样做,是因为该属性被声明为 public。我们在 $product1 变量后使用对象操作符 -> 来引用该对象的名称属性。

警告

不要-> 字符后写美元符号$product->name 是正确的,而 $product->$name 是错误的。如果你写后者,PHP 引擎不会产生警告或错误,但它会将代码解释为:有一个名为 $name 的变量,其值是你想访问的 $product 对象上的属性名。这与访问 $product 对象的 name 属性的值是完全不同的。如果你的代码行为异常,请检查是否有这个编程错误。

最后,脚本通过打印一条消息来结束,消息中包含从 $product 对象的名称属性中检索到的值。如果你运行 web 服务器并访问项目的主页,你应该能看到这行文本显示出来:

product 1 name = hammer

-> 操作符允许你通过名称操作对象的任何公共属性。让我们更新脚本,以设置并显示对象的价格以及名称。修改 index.php 文件,如 列表 18-3 所示。

<?php
require_once __DIR__ . '/../src/Product.php';

$product1 = new Product();
$product1->name = 'hammer';

print 'product 1 name = ' . $product1->name;

$product1->price = 9.99;
print ", and price = {$product1->price}";

列表 18-3:在 index.php 中设置并显示产品价格

我们将对象的价格属性设置为 9.99,格式与 列表 18-2 中设置名称属性时使用的格式相同。然后我们显示该属性的值。请注意,这次我们使用双引号字符串将消息和属性值组合在一起。这说明在双引号字符串中,指向对象公共属性的引用,例如 $product1->price,将被解析,结果值将被输出,就像简单变量一样。

再次访问主页时,你应该能看到产品名称和价格显示出来:

product 1 name = hammer, and price = 9.99

我们现在已经创建了一个 Product 类的对象,并且由于该类的属性是 public,我们可以直接设置和获取这些属性的值。然而,在实际开发中,大多数类会使用 private 而不是 public 属性。

具有公共访问器方法的私有属性

当属性声明为 private 时,它们不能被类声明外的代码访问。相反,它们可以通过公共的访问器方法来访问,这些方法允许检索(getter 方法)或更新(setter 方法)对象属性值。使用私有属性和公共访问器方法的机制可以减少无效属性值的风险;当必须通过 setter 方法修改属性时,可以在方法中实现验证逻辑(例如,防止负值或超出范围的值)。此外,相关属性或其他对象可能需要一起更新,比如一个银行账户的余额减少与另一个账户的余额增加相同的数值。通过 setter 方法和私有属性,可以轻松强制执行这些规则,以确保应用程序中的数据保持正确且内部一致。

类成员的默认可见性是 public,因此如果没有为属性提供访问修饰符,PHP 引擎会自动将其声明为具有 public 可见性。即使有默认行为,当你希望类成员具有 public 可见性时,最好在类声明中明确使用 public 访问修饰符。否则,使用 private 访问修饰符将成员声明为私有。

注意

除了publicprivate,还有第三种访问修饰符,protected,可以与继承一起使用。我们将在第十九章中探讨这个话题。

对于 PHP 和几乎所有的 OOP 语言,getter 或 setter 方法的名称通常以getset开头,后跟该方法影响的属性名称,并且首字母大写。根据这一惯例,我们的 Product 类的 name 属性的 getter 方法应为 getName(),setter 方法应为 setName()。对于 price 属性,方法应为 getPrice()和 setPrice()。这个惯例的例外情况是当属性包含一个布尔值(true/false)时。在这种情况下,getter 方法通常命名为 isPropertyName,而不是 getPropertyName。例如,如果 Product 类有一个名为 dangerousItem 的属性,其值为 true 或 false,那么 getter 方法应命名为 isDangerousItem()。

getter 方法通常返回与它所配对的属性相同数据类型的值(尽管有时我们会为对象属性的不同表示形式编写多个 getter 方法,例如返回浮动属性的四舍五入整数和浮动值的方法)。setter 方法通常接收一个相同类型的参数,并将其值存储在属性中,可能在过程中进行验证检查。通常,setter 方法不会返回任何值,因此它们声明为返回 void。

让我们修改 Product 类的声明,将其 name 和 price 属性设为私有,并添加四个公共访问器方法,每个属性各两个。请按照列表 18-4 所示更新src/Product.php文件。

<?php
class Product
{
    private string $name;
    private float $price;

 ❶ public function getName(): string
    {
        return $this->name;
    }

  ❷ public function setName(string $name): void
    {
      ❸ $this->name = $name;
    }

    public function getPrice(): float
    {
        return $this->price;
    }

    public function setPrice(float $price): void
    {
        $this->price = $price;
    }
}

列表 18-4:修改 Product 类以使用 getter 和 setter 方法

首先,我们将两个属性的声明修改为私有。然后,我们声明 getName(),这是 name 属性的公共 getter 方法❶。类中的方法可以使用特殊的伪变量\(`this`来引用调用对象;也就是说,\)this 代表我们正在操作的对象的属性和方法。因此,我们的 getName()方法返回当前调用该方法的 Product 对象的 name 属性值。该方法的返回类型是字符串,因为 name 属性是一个字符串。

接下来,我们声明 setName(),这是 name 属性的公共 setter 方法❷。该方法通过\(name 参数接收一个新的字符串 name 值,并将该值存储到当前对象的 name 属性中,再次使用\)this来引用该对象。此 setter 方法没有返回值。price 的 getter 和 setter 方法遵循相同的模式。

请注意在 setName()方法体内,PHP 如何区分\(name 参数和当前对象的 name 属性❸。前者以美元符号\)开头,而后者附加在\(`this->`后面,并且没有美元符号,表示它是当前对象的属性。换句话说,setName()方法中的\)name 明确指的是传递给该方法的参数值,而通过 setName()方法接收到消息的对象的私有 name 属性则通过$this->name明确指代。同样的情况适用于 setPrice()方法中的 float $price 参数和调用该方法的对象的 price 属性。

当你在类声明文件中编写方法时,必须时刻记住,同样的方法可能会在零个、一个或成千上万个对象上执行,这取决于对象收到带有方法名称(及任何必要参数)的消息。尽管你可能在编写声明时只打算创建并使用类的一个实例(对象),但一个编写良好的类封装了该类的任何对象的数据(属性)和行为(方法)。当你在编程时考虑到通用使用时,通常可以在同一个项目的其他部分,甚至是完全不同的项目中使用该类,而无需或几乎无需更改类声明。编写良好的类声明有助于复用。

注意

虽然你可以手动编写访问器方法,但许多代码编辑器,包括 PhpStorm,提供了自动化功能来为你生成简单的 getter 和 setter 方法。自动生成代码比手动输入更快,并且可以确保生成遵循 PHP 编程规范的无错误脚本。

获取和设置私有属性

由于任何 Product 对象的两个属性现在被声明为私有,我们无法直接访问它们,比如通过写 $product1->name$product1->price。如果你运行现有的 index.php 脚本,你会遇到一个关于无法访问私有 name 属性的致命错误。相反,我们必须通过使用它们的公共访问器方法来读取和修改这些私有属性。列表 18-5 显示了如何更新 index.php 来使用这些新方法。

<?php
require_once __DIR__ . '/../src/Product.php';

$product1 = new Product();
$product1->setName('hammer');
$product1->setPrice(9.99);
print 'product 1 name = ' . $product1->getName();
print ", and price = {$product1->getPrice()}";

列表 18-5:在 index.php 中使用访问器方法

如同在 列表 18-3 中一样,我们创建了 \(product1 对象,设置了它的属性,并打印出了这些属性。然而这次,我们完全依赖于访问器方法。我们使用 setter 方法来更新对象属性的值,比如 `\)product1->setName('hammer')。同样,我们使用 getter 方法来从对象中获取值,比如 \(product1->getName()`。得益于这些方法,\)product1 对象中的数据被安全地封装,但仍然可以访问。

筛选无效数据

保护对象数据属性的一个优点是,你可以向 setter 方法中添加验证逻辑,以防止无效值存储在属性中。例如,大多数企业可能不希望为产品设置负数价格(尽管某些东西可能是免费的赠品,因此我们会允许价格为 0)。因此,我们应该在 setPrice() 方法中添加一个 if 语句,只有当新值大于或等于 0 时才更新存储的价格。列表 18-6 显示了如何更新 src/Product.php 中的方法。

--snip--
public function setPrice(float $price): void
{
    if ($price >= 0) {
 $this->price = $price;
}
}

列表 18-6:向 Product 类的 setPrice() 方法添加验证逻辑

在我们的验证逻辑中,我们确认新传入的 $price 参数大于或等于 0,然后再设置对象的价格属性。为了确保验证检查有效,我们可以更新 index.php 脚本,尝试设置一个无效的负价格值。我们应该看到无效值不会被存储在对象中。列表 18-7 为 index.php 添加了额外的语句,用于验证逻辑的两次测试。

<?php
require_once __DIR__ . '/../src/Product.php';

$product1 = new Product();
$product1->setPrice(9.99);

print "(initial value) product 1 price = {$product1->getPrice()}\n";

$product1->setPrice(-0.5);
print '<br>(test 1) trying -0.5: ';
print "product 1 price = {$product1->getPrice()}\n";

$product1->setPrice(22);
print '<br>(test 2) trying 22: ';
print "product 1 price = {$product1->getPrice()}\n";

列表 18-7:在 index.php 中测试 setter 验证逻辑

如前所述,我们创建一个新的 Product 对象并将其价格设置为 9.99。然后我们尝试将价格设置为无效的负数值,再设置一个有效的正数值,这个值与初始值不同,并且每次都打印出产品价格。以下是该脚本在浏览器中的输出:

(initial value) product 1 price = 9.99
(test 1) trying -0.5: product 1 price = 9.99
(test 2) trying 22: product 1 price = 22

对于测试 1(负价格 -0.5),存储的价格保持不变,仍为 9.99。对于测试 2(非负值 22),存储的价格被更新。我们的验证逻辑已经起作用。在这个例子中,我们只是忽略了无效值,但通常最好以某种方式表明存在问题。一种选择是,当没有设置值时,setters 返回布尔值 false。另一种选择是抛出一个异常对象,我们将在第二十三章中讨论。

PHP 提供了多个魔术方法,用于重写对象的默认行为。例如,__construct()魔术方法重写了创建类的对象的默认方式,而 __toString()魔术方法重写了在打印语句和其他需要字符串的上下文中处理对象的方式。在本节中,我们将逐一探讨这些魔术方法。

尽管名称中有“魔术”二字,魔术方法与 PHP 的魔术常量无关。魔术方法是面向对象 PHP 的一个特性,它允许更改对象的默认行为。所有魔术方法的名称都以双下划线(__)开头;因此,当你为类声明一个魔术方法时,应以此前缀命名方法。你可以在www.php.net/manual/en/language.oop5.magic.php中找到所有 PHP 魔术方法的列表。

使用构造方法初始化值

通常,我们希望在创建对象后立即设置该对象的一些(或所有)属性。如清单 18-5 所示,你可以通过首先创建对象,然后依次调用 setter 方法来设置每个属性的值。然而,立即在创建对象后初始化对象属性是一个非常常见的需求,因此 PHP 允许你通过编写一个名为构造函数的魔术方法,将这些操作合并为一步,并将其作为类声明的一部分。

每个类声明文件要么不声明构造方法(如本章前面所述),要么声明一个名为 __construct()的单一构造魔术方法。它被称为“魔术”是因为它重写了创建对象的默认方式:在不设置任何属性的情况下创建对象。__construct()方法接收一系列参数,并将它们作为新创建的对象属性的初始值。例如,在index.php文件中使用构造方法,只需在类名后的小括号内提供初始值作为参数:\(myObject = new ClassName(\)value1, $value2)。得益于 new 关键字的使用,PHP 会自动将参数与构造方法关联,即使 __construct()没有显式调用。

注意

PHP 作为面向对象的语言有些不同,构造函数方法的名称并不与类名相同。在其他大多数面向对象语言中,Product() 方法在 Product 类中将是构造函数方法,但在 PHP 中,方法名称与声明所在类名相同并没有什么特别之处。

将属性作为构造函数方法的一部分来设置,可以在创建新对象时节省一些代码。例如,如果我们知道在创建 Product 对象时需要设置名称和价格属性,我们可以为 Product 类添加一个构造函数方法,该方法接受 $name$price 参数,自动设置这些属性。这样,当我们在 index.php 中创建 $product1 对象时,就可以将以下三条语句替换为一条语句:

$product1 = new Product();
$product1->setName('hammer');
$product1->setPrice(9.99);

只需一条语句:

$product1 = new Product('hammer', 9.99);

更新 Product.php 如列表 18-8 所示,添加一个构造函数方法来设置名称和价格属性。

<?php
class Product
{
 private string $name;
 private float $price;

  public function __construct(string $name, float $price)
    {
        $this->setName($name);
        $this->setPrice($price);
    }

 public function getName()
--snip--

列表 18-8:为 Product 类添加构造函数方法

我们声明了一个新的 __construct() 方法。它通过要求两个参数(新 Product 对象的初始字符串 namefloat 类型的 price 值)来替代默认的无参创建对象方式 new Product()。请注意,构造函数方法不指定任何返回类型。在 __construct() 方法定义中,我们调用了 setName()setPrice() 方法,这些方法已经在 Product 类声明的其他地方定义过,并将 $name$price 参数传递给它们。虽然这看起来并不比在 index.php 脚本中调用这些方法更简单,但随着你开始创建更多相同对象的实例,通过构造函数设置属性会变得更高效。这种方法还确保了,在对象构造时设置值与之后通过直接调用 setter 方法更改值时,所应用的验证完全相同。

注意

许多集成开发环境(例如 PhpStorm)提供了交互式构造函数生成器,允许你将选定的属性作为参数添加,并通过生成的构造函数代码设置它们的值。

列表 18-9 展示了如何简化 index.php 以利用新的构造函数方法。

<?php
require_once __DIR__ . '/../src/Product.php';

$product1 = new Product('hammer', 9.99);
print 'product 1 name = ' . $product1->getName();
print ", and price = {$product1->getPrice()}";

列表 18-9:简化的 index.php 脚本,使用构造函数方法

当我们创建 $product1 对象时,我们将所需的初始值(名称和价格属性)作为构造函数的参数传入。如前所述,这将把三行代码(创建对象并设置其两个属性)合并为一行。 #### 将对象转换为字符串

将对象的内容总结为字符串是很常见的做法,有时用于显示对象的详细信息,有时用于调试和记录目的。将对象转换为字符串的一个常见原因是生成 Web 界面上的对象列表,例如下拉菜单。图 18-3 显示了一个包含我教授的一些课程的下拉菜单示例。

图 18-3:作为字符串总结的课程列表

你可以想象,这些课程中的每一门都在 PHP 中由一个 Course 对象表示,拥有像 courseNumber 和 courseName 这样的属性。为了生成下拉菜单,PHP 会将每个 Course 对象转换为字符串,格式为 courseNumber - courseName,例如 COMP H2029 - Web Development Server-Side。然后,这些字符串可以传递到 HTML 代码中,用于显示菜单。

那么,如何将其转换为字符串呢?大多数面向对象的语言,包括 PHP,都提供了一种方法,在需要字符串的表达式中使用对象时返回一个字符串(例如,像 print $course1 这样的表达式,其中 $course1 是指向一个 Course 对象的引用)。在 PHP 中,这一功能来自另一个魔术方法,方法名前有两个下划线字符:__toString()。

你并不必须为每个类实现 __toString() 方法,但如果你知道自己需要对象的字符串摘要(例如,用于下拉 HTML 菜单),或者你想将对象的详细信息记录到报告中,那么 __toString() 方法是非常有用的。如果一个类没有 __toString() 方法,而你尝试在需要字符串的表达式中引用该类的对象,则会出现“无法转换为字符串”的致命错误。让我们通过将 index.php 脚本末尾的 print 语句替换为 print $product1 来看看这个问题。更新 index.php 以匹配 Listing 18-10。

<?php
require_once __DIR__ . '/../src/Product.php';

$product1 = new Product('hammer', 9.99);
print $product1;

Listing 18-10:尝试在 index.php 中通过 print 输出对象的详细信息

我们将表达式 $product1 传递给 print 语句。因为 print 语句期望的是字符串表达式,而 $product1 不是字符串,PHP 会尝试将其转换为字符串。由于 PHP 引擎无法在没有 __toString() 方法的情况下将对象引用转换为字符串,因此会发生致命错误。

现在让我们为 Product 类实现一个 __toString() 方法,既为了探索面向对象编程的这一常见特性,又为了让我们能够使用简化的 index.php 脚本,参考 Listing 18-10。Listing 18-11 显示了新添加的 __toString() 方法,该方法已被加入到 src/Product.php 文件中。

<?php
class Product
{
 private string $name;
 private float $price;

 public function __construct(string $name, float $price)
 {
 $this->setName($name);
 $this->setPrice($price);
 }

    public function __toString(): string
    {
      ❶ return '(Product) name = ' . $this->name
            . ', and price = ' . $this->price;
    }
 public function getName(): void
--snip--

Listing 18-11:为 Product 类添加 __toString() 方法

我们为类添加了一个新的 __toString()方法。这个方法包含一个简单的语句,用于构建并返回一个总结对象属性值的字符串。请注意,我们将字符串消息的一部分进行了泛化,改为从'(Product) '开头,而不是'product 1 '❶。由于这是类的方法,且可能会被多个对象使用,所以我们不应该在类的通用声明文件中硬编码引用特定对象的变量名称。

按照列表 18-10 中更新的方式运行index.php脚本,你应该看到 print $product1 语句正确执行,这要归功于新增的 __toString()方法。### 对象变量作为引用

如前所述,贯穿本章的$product1 变量是指向内存中一个 Product 对象的引用,而不是 Product 对象本身。这个区别的一个含义是,多个变量可以引用同一个内存中的对象。这种情况可能在很多场景下发生。例如,当你需要遍历一个对象集合并对每个对象进行操作时,就会出现这种情况。在这种情况下,一个临时的局部变量会引用当前正在处理的对象,而集合中也会持有对该对象的另一个引用。

为了查看对象变量如何只是内存位置的引用,请更新index.php,如列表 18-12 所示。在这段代码中,我们创建了\(variable2,使其引用与\)product1 相同的对象,并通过\(variable2 修改了该对象的一个属性。如你所见,这一修改同样影响了\)product1 引用的对象,证明了这两个变量引用的是同一个对象。

<?php
require_once __DIR__ . '/../src/Product.php';

$product1 = new Product('hammer', 5.00);
print $product1;
print '<br>';

❶ $variable2 = $product1;
print 'changing price via $variable2';
print '<br>';
$variable2->setPrice(20.00);
print $product1;

列表 18-12:更新 index.php,演示对象变量是如何作为引用的

我们将\(variable2 设置为与\)product1 引用同一个对象❶。然后我们调用 setPrice()方法,修改\(variable2 所引用的对象的价格属性,将其设置为 20.00。接着我们第二次打印\)product1。由于$product1 是一个对象引用,它的 __toString()方法将被调用。这会在浏览器中产生以下输出:

(Product) name = hammer, and price = 9.99
Changing price via $variable2
(Product) name = hammer, and price = 20

通过\(variable2 修改价格时,\)product1 引用的对象的价格被改为了 20。因此,这两个变量必须引用同一个对象。

处理缺失对象

有时代码的编写方式是,你期望一个变量引用某个对象,但并未找到该对象。在这种情况下,该变量会是 NULL,因此,在编写面向对象的代码时,通常需要包含 NULL 检查。

让我们考虑一个示例。假设你正在为一个博客编写代码。为了显示一篇特定的博客文章,代码期望从 HTTP 请求中获取一个有效的博客文章 ID,然后使用该 ID 从数据库中检索数据并构建一个 Blog 对象。如果请求中没有找到 ID,或者 ID 无效,或者 ID 与数据库中的任何项都不匹配,那么应用程序就无法创建 Blog 对象,因此代码会返回 NULL,而不是对象引用。

为了处理这种情况,其他期望与 Blog 对象一起工作的代码会首先测试 NULL,然后决定是处理无效的 ID(比如 0 或负数),还是处理成功检索到的 Blog 对象。清单 18-13 展示了一个示例方法,可能来自数据库驱动的博客网站,用来说明这一点。

<?php
--snip--

public function blogFromId (int $id): ?Blog
{
  ❶ if (is_numeric($id) && $id > 0) {
        return $this->blogRepository->find($id);
    }

    return NULL;
}

--snip--

清单 18-13:使用可空返回类型

这个 blogFromId() 方法接受一个 $id 值,并返回一个 Blog 对象的引用或 NULL,使用可空返回类型 ?Blog。(我们也可以将其写作联合返回类型 Blog|NULL。)该方法测试 $id 是否为数字且大于 0 ❶。如果是,它将有效的 $id 传递给 blogRepository 属性的 find() 方法,并返回该方法的值(无论是 NULL 还是数据库中为此 ID 查找到的 Blog 对象)。如果 $id 无效,则返回 NULL。

这个示例做了很多假设,但关键是,调用 blogFromId() 方法的结果所赋值的变量,要么是指向一个对象的引用,要么是 NULL。像这样的代码在面向对象编程(OOP)中非常常见(如你将在第六部分中看到的),这也是为什么你经常需要测试一个你期望是对象引用的变量是否为 NULL,以确定是否有对象被引用。这与处理非面向对象的 PHP 变量相比,其中 NULL 可能意味着,例如,变量尚未初始化,或 HTTP 表单提交中的 URL 编码变量没有接收到任何字符串值。

你可以为一个类编写各种自定义方法,除了标准的 getter、setter 和 __construct() 及 __toString() 魔术方法。记住,方法只是附加在对象类上的函数,因此自定义方法是实现与类的对象相关的逻辑和计算的函数。例如,我们的 Product 类可能会包含一个计算产品总价的方法,包括税费。税率将是一个浮动值,比如 0.5(即 50%)。这样的一个方法仍然可以作为 getter 使用,但与简单地返回存储的属性值不同,它会在每次调用时动态计算一个值。

为了查看它是如何工作的,我们将在 Product 类声明中添加一个 getPriceIncludingTax()方法。该方法将从相应的对象属性中获取税率和产品的税前价格,进行必要的计算,并返回包含税费的总价格。例如,对于一个税率为 0.1(10%)且价格为 5.00 的产品,该方法应返回 1.1 * 5.00 = 5.50。为了创建这个方法,我们还需要在类中添加一个私有的 taxRate 属性,以及用于设置和获取产品税率的访问器方法。

示例 18-14 展示了更新后的Product.php类声明文件。除了添加 taxRate 属性、它的访问器和自定义方法外,我们还修改了 __toString()方法,以显示税费计算的结果。

<?php
class Product
{
 private string $name;
 private float $price;
❶private float $taxRate;

 public function __construct(string $name, float $price)
 {
 $this->setName($name);
 $this->setPrice($price);
 }

 public function __toString(): string
 {
 return '(Product) name = ' . $this->name
 . ', and price = ' . $this->price
            . ', and price with Tax = ' . $this->getPriceIncludingTax();
    }

  ❷ public function getTaxRate(): float
    {
        return $this->taxRate;
    }

    public function setTaxRate(float $taxRate): void
    {
 $this->taxRate = $taxRate;
    }

  ❸ public function getPriceIncludingTax(): float
    {
        return (1 + $this->taxRate) * $this->price;
    }

 public function getName()
--snip--

示例 18-14:向 Product 类中添加 taxRate 属性及相关方法

我们声明了 taxRate 属性❶及其简单的 getter 和 setter 方法❷。然后,我们声明了 getPriceIncludingTax()方法❸。该方法返回含税后的价格。

如你所见,我们的 getPriceIncludingTax()自定义方法只是一个为我们的类执行有用计算的函数。在这种情况下,它本质上是一个额外的 getter 方法,提供了对类存储属性 price 的变化。实际上,在面向对象编程中,看到多个 getter 方法用于同一对象属性是非常常见的:返回产品税前和税后价格的方法,返回同一属性但精度不同的方法(例如,四舍五入到最接近的整数或包括最多两位小数),以及返回相同属性转换成不同货币或单位的方法(如美元与欧元、英尺与米)等。

在其他情况下,自定义方法可以充当虚拟属性:它们不仅提供现有属性的变体,还通过计算得到全新的信息。虚拟属性的一个例子可能是一个计算产品年龄的方法。如果产品有一个 dateReceived 属性,产品的年龄可以通过 getProductAge()方法动态计算。该方法将当前日期减去 dateReceived。在这种情况下,产品的年龄实际上并没有作为对象的属性存储,但由于有了 getProductAge()方法,信息就像属性一样可以被获取。

自定义方法突显了 OOP 的一些强大功能:编写代码的人使用 Product 对象的 public getProductAge()方法时,不需要担心该方法是如何实现的。唯一重要的是该方法能正常工作。如果方法的实现发生变化(例如将 dateReceived 属性的数据类型从存储的 MySQL datetime 值更改为 Linux 时间戳),但其行为保持正确且不变,那么这对系统中向 Product 对象发送消息并使用这些方法返回值的部分没有任何影响。

总结

本章介绍了如何声明类,如何将这些声明读取到index.php文件并用它们创建对象,以及如何调用对象的方法来设置和获取它们的属性值。你了解了如何通过将对象的数据属性声明为 private 来保护对象的属性数据,以及如何使用声明为 public 的 getter 和 setter 方法来管理对对象属性的访问并在相关情况下进行验证。我们还讨论了如何使用 PHP 的“魔术”方法执行一些常见的有用操作,例如通过构造方法创建新对象并初始化某些属性,以及通过声明 __toString()方法生成表示对象属性的字符串消息。

练习

1.   为 Cat 类编写 PHP 类声明,包含 public 属性 name、breed 和 age。然后编写一个index.php文件来读取类声明并创建一个 Cat 对象。将对新对象的引用存储在名为$cat1 的变量中,并按以下方式设置它的属性:

name = 'Mr. Fluffy'

breed = 'long-haired mix'

age = 2

最后,添加语句来打印$cat1 的每个属性的数据值。

2.   为 Pet 类编写 PHP 类声明,包含一个 private 的 name 属性,以及用于该 name 变量的 public get 和 set 访问器方法。然后编写一个index.php文件来读取类声明并创建一个由名为$pet1 的变量引用的 Pet 对象。使用 setter 方法将其 name 设置为'Fifi',并添加语句打印该对象存储的 name。

3.   为你的 Pet 类添加一个构造方法,这样你就可以使用类似以下的语句来创建具有初始值 name 变量的新 Pet 对象:

$pet1 = new Pet('Mr. Fluffy');

更新你的index.php文件,使用此构造方法,而不是通过 setter 方法设置 name。

4.   对于以下属性和类型,编写它们对应的访问器(getter/setter)方法名称:

age // 整数

houseNumber // 整数

color // 字符串

length // 浮动值

heavy // 布尔值

第十九章:19 继承

本章介绍了面向对象编程(OOP)中或许是最强大且最重要的特性:继承。继承是指一个或多个类(称为子类)能够自动继承另一个类(称为超类)的所有属性和方法。继承使得 OOP 更加高效:你只需要在超类中定义一次任何通用的共享成员。子类的对象将继承这些属性和方法,并且还会有属于各自子类的特有属性和方法。

与继承相关的是子类能够在继承的方法不适合特定子类时,重写超类的继承方法。正如本章中所探讨的,你也可以通过使用 parent 关键字实现两全其美(执行继承的方法并且通过子类的方法添加额外的行为)。此外,我们还将讨论 PHP 提供的第三种方法和属性保护方式:protected。你将了解 protected 成员与 public 和 private 成员的区别,以及何时使用每种可见性修饰符。

继承作为泛化

继承通过识别和概括对象类之间共享的属性和行为来简化代码。考虑图 19-1 中显示的汽车和船舶对象的属性。

图 19-1:汽车类和船舶类,具有以粗体显示的共享属性

每个类的前三个属性都是相同的:汽车和船舶对象都有制造商和型号、乘客数和最高速度。为了避免冗余,我们可以将这些共有的属性(以及任何相关的方法)概括到一个超类中,我们将其命名为车辆(Vehicle)。图 19-2 展示了这个泛化的车辆类,其中包含三个共享属性,并且简化了的汽车和船舶类,每个类现在只包含一个自己的属性。

图 19-2:车辆超类和汽车、船舶子类

从汽车类和船舶类指向车辆类的箭头表示汽车和船舶类都继承自车辆类;也就是说,汽车和船舶是泛化车辆超类的子类。请注意,图中并未标明属性的可见性(public 与 private)。目前,我们将通过使用简单的公共属性来实现这些类。稍后在本章中,我们将通过使用第三种可见性设置——protected 来重构这些类。

列表 19-1 实现了图 19-2 中显示的车辆类。开始一个新项目,并创建包含此代码的src/Vehicle.php文件。

<?php
class Vehicle
{
    public string $makeModel;
    public int $numPassengers;
    public float $topSpeed;
}

列表 19-1:实现车辆超类

我们声明了一个车辆类(Vehicle),它有三个公共属性:makeModel、numPassengers 和 topSpeed。

列表 19-2 实现了 Vehicle 类的 Car 子类,这个类应当创建在src/Car.php中。

<?php
class Car extends Vehicle
{
    public string $bodyShape;
}

列表 19-2:将 Car 作为 Vehicle 的子类实现

我们声明 Car 类应当是 Vehicle 类的子类,只需在类声明的开头添加 extends 关键字,后面跟上超类名称。我们创建的任何 Car 对象将自动继承 Vehicle 超类的所有属性和方法,同时还会包含在 Car 类中直接声明的 bodyShape 属性。

从子类创建对象

为了验证我们的继承示例是否有效,我们将创建一个 Car 对象并为其属性设置值——包括在 Car 类中直接声明的属性以及从 Vehicle 类继承的属性。创建public/index.php并输入列表 19-3 中的代码。

<?php
require_once __DIR__ .  '/../src/Vehicle.php';
require_once __DIR__ .  '/../src/Car.php';

$car1 = new Car();

$car1->bodyShape = 'Sedan';
$car1->makeModel = 'Ford Mustang';
$car1->numPassengers = 5;
$car1->topSpeed = 150;

var_dump($car1);

列表 19-3:在 index.php 中创建 Car 对象

我们首先读取声明 Vehicle 和 Car 类的文件。必须读取代码所使用的所有类的声明,包括像 Vehicle 这样的继承类,即使我们不会直接创建该类的对象。我们读取文件的顺序也很重要:为了让 Car 类能够扩展 Vehicle 类,必须先读取并执行 Vehicle 类的声明,再读取并声明 Car 类。否则,会报错提示找不到 Vehicle 类。

注意

大型项目可能需要几十个,甚至上百个类声明。在第二十章中,我们将介绍一种方法,通过使用一个名为autoloader的脚本,以单个语句按需要的顺序读取所有类声明。

接下来,我们创建一个新的 Car 对象,并将其引用存储在\(car1 变量中。然后,我们为 bodyShape、makeModel、numPassengers 和 topSpeed 属性设置值。无论属性是直接在 Car 类中声明的,还是从 Vehicle 类继承的,语法都是一样的:无论如何,我们都只是使用\)car1->propertyName = value。最后,我们使用 var_dump()输出关于$car1 变量及其在内存中引用的对象的结构化信息。以下是运行命令行中 index 脚本的结果:

$ **php public/index.php**
object(Car)#1 (4) {
  ["bodyShape"]=>
  string(5) "Sedan"
  ["makeModel"]=>
  string(12) "Ford Mustang"
  ["numPassengers"]=>
  int(5)
  ["topSpeed"]=>
  float(150)
}

Car 对象确实有四个属性,并且我们已经成功设置了这四个属性,包括在Car.php中声明的属性(bodyShape)和从 Vehicle 超类继承的三个属性。

让我们通过声明 Boat 子类,完成图 19-2 中的类层次结构实现。创建src/Boat.php并输入列表 19-4 中的代码。

<?php
class Boat extends Vehicle
{
    public string $countryOfRegistration;
}

列表 19-4:实现 Boat 类

我们声明 Boat 类应该继承自 Vehicle 类,再次使用 extends 关键字来建立子类/父类的关系。这个类只有一个独特的属性 countryOfRegistration,但 Boat 对象也将继承 Vehicle 类的三个公共属性:makeModel、numPassengers 和 topSpeed。

使用多层次的继承

类层次结构可以涉及多个继承层级:类 A 可以有子类 B,类 B 可以有子类 C,依此类推。在这种情况下,类 C 将从其直接超类 B 和超类的超类 A 继承方法和属性。图 19-3 说明了我们如何利用这一机制进一步扩展我们的车辆类层次结构。

图 19-3:一个三层类层次结构,包含子类的子类

类层次结构中包含 Boat 的两个子类:MotorBoat 和 SailBoat。所有 MotorBoat 和 SailBoat 对象将继承 Boat 作为超类的属性和方法。这包括 Boat 从 Vehicle 继承的属性和方法。同时,MotorBoat 对象还将拥有自己的特殊属性 engineSize,而 SailBoat 则将有一个独特的属性 numberOfMasts。

当我们声明 MotorBoat 类时,我们会从 class MotorBoat extends Boat 开始。我们不需要在 MotorBoat 类声明中提到 Vehicle;从 Vehicle 继承的部分已经在src/Boat.php类声明文件中涵盖了。

像大多数面向对象语言一样,PHP 允许每个类只能有一个直接的超类。这个保护机制防止了继承来源的歧义,但也为设计类层次结构带来了挑战。例如,由于汽车和摩托艇都有引擎,可能有意义创建一个 Vehicle 的 MotorizedVehicle 子类。Car 类将自然地继承自 MotorizedVehicle,但摩托艇呢?它应该继承自 Boat 还是 MotorizedVehicle?它只能直接继承其中的一个。正如这个例子所示,一旦类层次结构变得更加复杂,在设计时就必须小心谨慎。

Protected 可见性

随着继承的引入,除了 public 和 private 之外,第三个可见性关键字变得相关:protected。当方法或属性被声明为 protected 时,它可以被任何继承它的子类访问,但不能被项目中其他地方的代码访问。例如,Vehicle 超类的一个 protected 属性可以在Car.php中访问,因为 Car 继承自 Vehicle。相比之下,该 protected 属性无法在一般的index.php文件中访问。

protected 可见性比 public 更具限制性,因为 public 属性和方法可以从项目代码中的任何地方访问。另一方面,protected 比 private 更不具限制性。当方法或属性是 private 时,它仅在类内部可访问;即使是子类的方法也不能直接访问超类的 private 属性和方法。

为了说明三种可见性关键字之间的区别,我们将重构 Vehicle 类。首先,我们将更新类,使其具有私有属性和公共访问器方法,正如我们在第十八章中讨论的那样。然后,我们会将 protected 关键字加入其中。列表 19-5 显示了对 src/Vehicle.php 的首次修改。

<?php
class Vehicle
{
    private string $makeModel;
    private int $numPassengers;
    private float $topSpeed;

  ❶ public function getMakeModel(): string
    {
        return $this->makeModel;
    }

    public function setMakeModel(string $makeModel): void
    {
        $this->makeModel = $makeModel;
    }

    public function getNumPassengers(): int
    {
        return $this->numPassengers;
    }

    public function setNumPassengers(int $numPassengers): void
    {
        $this->numPassengers = $numPassengers;
    }

 public function getTopSpeed(): float
    {
        return $this->topSpeed;
    }

    public function setTopSpeed(float $topSpeed): void
    {
        $this->topSpeed = $topSpeed;
    }
}

列表 19-5:修改 Vehicle 类,使其具有私有属性和公共访问器方法

我们将所有三个类属性声明为私有而非公共。这意味着它们不能直接设置。相反,我们为每个属性声明了公共的 getter 和 setter 方法❶。

我们现在需要更新 index 脚本,使用 setter 方法为 Car 对象设置属性值。修改 public/index.php,使其与列表 19-6 中的代码相符。

<?php
require_once __DIR__ .  '/../src/Vehicle.php';
require_once __DIR__ .  '/../src/Car.php';

$car1 = new Car();

❶ $car1->bodyShape = 'Sedan';

$car1->setMakeModel('Ford Mustang');
$car1->setNumPassengers(5);
$car1->setTopSpeed(150);

var_dump($car1);

列表 19-6:更新 index.php,通过 setter 方法设置继承的属性

我们仍然可以直接访问在 Car 类中声明的 bodyShape 属性❶,因为它仍然是公共的。然而,我们现在需要使用 setter 方法为三个继承的属性(makeModel、numPassengers 和 topSpeed)赋值,因为这些属性现在在 Vehicle 超类中声明为私有。Car 对象继承了来自 Vehicle 的访问器方法,就像它继承了属性本身一样。由于有了访问器方法,var_dump() 将像以前一样工作。

index.php 中使用公共 setter 方法来更新私有属性是有意义的;这正是我们在第十八章中所做的。但是,如果我们想将从 Vehicle 继承的某个属性作为 Car 类中声明的一个方法的一部分来使用,会发生什么呢?我们来试试,看会发生什么。我们将为 Car 添加一个新的 getMakeModelShape() 方法,返回对象一些属性的字符串总结。更新 src/Car.php,使其与列表 19-7 中的代码相符。

<?php
class Car extends Vehicle
{
 public string $bodyShape;

    public function getMakeModelShape(): string
    {
        return "$this->makeModel, $this->bodyShape";
    }
}

列表 19-7:尝试从子类访问私有超类属性

我们在 Car 类中声明了新的 getMakeModelShape() 方法。该方法试图将 makeModel 和 bodyShape 属性的值插入到一个字符串中。为了查看这个方法是否有效,我们将在 index 脚本中调用它。更新 public/index.php,如列表 19-8 所示。

<?php
require_once __DIR__ .  '/../src/Vehicle.php';
require_once __DIR__ .  '/../src/Car.php';

$car1 = new Car();

$car1->bodyShape = 'Sedan';

$car1->setMakeModel('Ford Mustang');
$car1->setNumPassengers(5);
$car1->setTopSpeed(150);

print $car1->getMakeModelShape();

列表 19-8:在 index.php 中测试 getMakeModelShape() 方法

我们已将 var_dump() 函数调用替换为一个打印语句,调用我们 $car1 对象引用的 getMakeModelShape() 方法。然而,如果你现在运行 index 脚本,你会得到一个未定义属性的警告,如下所示:

$ **php public/index.php**

Warning: Undefined property: Car::$makeModel in /Users/matt/src/Car.php
on line 10
, Sedan

我们的 Car 类方法中的语句无法访问继承的私有属性,因此 PHP 引擎在执行 getMakeModelShape() 时无法找到 makeModel 属性。然而,注意到警告信息后面跟着文本 "Sedan"。这表明,即使出现问题,索引脚本仍然继续执行。实际上,在从未定义的属性警告继续执行后,PHP 引擎对 getMakeModelShape() 方法中的 $this->bodyShape 部分没有任何问题,因为 bodyShape 是直接在 Car 类本身定义的属性。

我们访问继承属性值的一种方式是使用公共 getter 方法访问 makeModel 属性,但在某些情况下,最好不要为属性提供公共的 getter 或 setter 方法。当你希望让子类的方法直接访问从父类继承的属性或方法,而又不想让该属性或方法公开时,最佳的解决方案是将该属性或方法设为受保护可见性。为此,更新 Vehicle 类,如 清单 19-9 所示。

<?php
class Vehicle
{
  ❶ protected string $makeModel;
 private int $numPassengers;
 private float $topSpeed;

 public function getMakeModel(): string
 {
 return $this->makeModel;
 }

--snip--

清单 19-9:在 Vehicle 类中使用受保护可见性来访问 makeModel

我们现在将 Vehicle 属性 makeModel 的可见性声明为受保护 ❶,这意味着它可以被 Car 子类中的语句直接访问。重新执行索引脚本,你将看到没有警告的 make、model 和 shape 字符串输出:Ford Mustang,Sedan。

图 19-4 显示了我们三个类的 UML 图,更新后指示了每个类属性和方法的可见性。

图 19-4:显示类层次结构中可见性设置的更新 UML 图

在 Vehicle 类中,井号(#)表示 makeModel 属性的受保护可见性,而其他两个属性则以负号表示为私有。Car 和 Boat 类的所有属性和方法都是公开的,因此用加号表示。图中的 «get/set» 注释表示 Vehicle 类所有三个属性的公共 getter 和 setter 访问方法。

这个示例展示了私有和受保护可见性之间的区别。为了说明公共和受保护可见性属性之间的差异,让我们修改 public/index.php 脚本,尝试直接设置 makeModel 属性的值。更新索引脚本以匹配 清单 19-10。

<?php
require_once __DIR__ .  '/../src/Vehicle.php';
require_once __DIR__ .  '/../src/Car.php';

$car1 = new Car();

$car1->bodyShape = 'Sedan';
$car1->makeModel = 'Ford Mustang';

$car1->setNumPassengers(5);
$car1->setTopSpeed(150);

print $car1->getMakeModelShape();

清单 19-10:展示公共和受保护可见性之间的区别

我们像以前一样将公共 bodyShape 属性的值设置为 'Sedan'。然后,我们尝试直接将 $car1 中的 makeModel 属性的值更改为字符串 'Ford Mustang'。执行这个脚本时,你将遇到致命错误,如下所示:

$ **php public/index.php**

Fatal error: Uncaught Error: Cannot access protected property Car::$makeModel
in /Users/matt/public/index.php:13
Stack trace:
#0 {main}
  thrown in /Users/matt/public/index.php on line 13

makeModel 属性具有受保护可见性,因此无法从我们的索引脚本中访问。只有 Vehicle 的子类才能直接访问该属性。

抽象类

抽象类是指永远不会用于创建对象的类。相反,抽象类通常声明为它们的成员(属性和方法)可以被子类继承。例如,Vehicle 类就是一个抽象类。我们永远不希望创建一个 Vehicle 对象;我们仅仅声明这个类是为了从 Car 和 Boat 类中泛化常见的属性和方法。

抽象类的另一个用途是声明与整个类相关的静态成员、属性或方法,而不是与单个对象相关。我们将在第二十五章中探讨静态成员。

最好尽可能具体地说明类及其成员的使用方式。为此,如果你知道一个类是抽象的,最好在声明时包含 abstract 关键字。这个关键字确保在没有触发错误的情况下不能创建该类的对象。为了演示,我们将在 Vehicle 类的声明中添加 abstract 关键字。请更新src/Vehicle.php,如清单 19-11 所示。

<?php

abstract class Vehicle
{
 protected string $makeModel;
--snip--

清单 19-11:将 Vehicle 声明为抽象类

我们通过在 class 关键字前面立即添加 abstract 来声明 Vehicle 是一个抽象类。如果你在做出这个更改后再次运行索引脚本,你将不会看到程序行为有任何差异。然而,如果你现在尝试创建一个 Vehicle 对象,如清单 19-12 所示,将会发生致命错误。请更新public/index.php以匹配清单内容。

<?php
require_once __DIR__ .  '/../src/Vehicle.php';
require_once __DIR__ .  '/../src/Car.php';

❶ $vehicle1 = new Vehicle();

$car1 = new Car();
--snip--

清单 19-12:尝试创建一个抽象 Vehicle 类的对象

我们尝试创建一个 Vehicle 类的对象,并将创建的对象的引用存储在$vehicle1 变量中❶。如果你尝试运行这个索引脚本,结果将是这样:

$ **php public/index.php**

Fatal error: Uncaught Error: Cannot instantiate abstract class Vehicle in
/Users/matt/public/index.php:5
Stack trace:
#0 {main}
  thrown in /Users/matt/public/index.php on line 5

会发生致命错误,错误信息为“无法实例化抽象类”。这正是我们想要的:通过将 Vehicle 类声明为抽象类,我们确保了无法创建任何 Vehicle 对象。

重写继承的方法

在某些情况下,你可能希望改变子类中继承方法的行为,相比于它在父类中的定义。这称为重写方法,你可以通过在子类中直接定义该方法,并将其命名为与父类继承的方法相同的名称来实现。PHP 会优先执行类层次结构中较低层级定义的属性和方法,因此它会执行来自子类的定义,而不是父类中的方法定义。

让我们通过一个声明了 __toString()方法的父类,以及一个通过自己的 __toString()实现来重写该声明的子类来进行说明。图 19-5 显示了我们将用于研究如何重写继承方法的两个类的 UML 图,并附有注释说明每个 __toString()方法的行为。我们将创建一个通用的 Food 类和一个 Food 类的 Dessert 子类。

图 19-5:Dessert 子类重写了从 Food 继承的 __toString()方法。

Food 类有一个 __toString()方法,它生成类似"(FOOD) foodname"的字符串,例如"(FOOD) apple"。与此同时,Dessert 子类通过自己的 __toString()方法覆盖了这一行为,生成类似“我是一种名为 foodname 的甜点”的字符串,例如“我是一种名为草莓芝士蛋糕的甜点”。

首先,我们将声明 Food 类。创建一个新项目,然后创建src/Food.php,并将 Listing 19-13 中的代码填入其中。

<?php
class Food
{
    protected string $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    public function __toString(): string
    {
        return "(FOOD) $this->name";
    }
}

Listing 19-13:Food 类

我们声明 Food 类,并给它一个名为 name 的字符串属性,该属性具有保护可见性,以便所有子类可以直接访问它。该类有一个构造函数,用于在创建每个新对象时初始化 name 属性,并且包含如图 19-5 所示的 __toString()方法。

现在让我们将 Dessert 声明为 Food 的子类。创建src/Dessert.php并输入 Listing 19-14 中的内容。

<?php
class Dessert extends Food
{
    public function __toString(): string
    {
        $message = "I am a Dessert named $this->name";
        return $message;
    }
}

Listing 19-14:Dessert 类,包含自己的 __toString()方法

我们声明 Dessert 是 Food 的一个子类(继承自 Food),并为其提供自己的 __toString()方法,如图 19-5 所示。由于 PHP 优先使用类层级中较低位置声明的方法,因此这个 __toString()方法将覆盖为 Food 父类定义的 __toString()方法。请注意,我们在 Dessert 类的 __toString()方法中引入了一个局部变量$message。这个变量现在看似不必要,但我们将在本章稍后回到这个示例,并将其添加到消息中。

通过创建public/index.php脚本来测试这些类,该脚本在 Listing 19-15 中有展示。这个脚本创建了两个对象,一个 Food 对象和一个 Dessert 对象,并打印出每个对象的内容,这将导致对象的 __toString()方法被调用。

<?php
require_once __DIR__ . '/../src/Food.php';
require_once __DIR__ . '/../src/Dessert.php';

$f1 = new Food('apple');
❶ print $f1;
print '<br>';

$f2 = new Dessert('strawberry cheesecake');
print $f2;

Listing 19-15:在 index.php 中创建并打印 Food 和 Dessert 对象

我们读取并执行 Food 和 Dessert 类的声明,从父类开始。然后我们创建并打印每个类的对象,这将触发对象的 __toString()方法❶。以下是运行 Web 服务器并请求页面后的浏览器输出:

(FOOD) apple
I am a Dessert named strawberry cheesecake

请注意,Dessert 对象输出“我是一种名为草莓芝士蛋糕的甜点”,这表明它成功地重写了从 Food 继承的 __toString()方法,并用自己的方法定义来替代。重写一个方法并不会打乱子类继承父类的其他方法或属性。然而,在这个例子中,Dessert 对象依然成功地继承了 Food 中的 name 属性和 __construct()方法。

扩展继承行为

有时,你可能并不希望完全替换(覆盖)继承的方法,而是想要增强继承的方法,加入特定于子类的附加行为。PHP 提供了关键字 parent 来实现这一点:它允许你在子类声明文件中引用父类的方法或属性。为了说明这一点,我们将修改 Dessert 子类的 __toString()方法。它将通过使用父类 Food 的 __toString()方法生成一个字符串,然后添加一个特定于 Dessert 对象的特殊消息。请按照 Listing 19-16 中的示例更新src/Dessert.php

<?php
class Dessert extends Food
{
 public function __toString(): string
 {
        $message = parent::__toString();
        $message .= " I am a Dessert!";
 return $message;
 }
}

Listing 19-16:Dessert 子类更新后的 __toString()方法

我们调用了父类(Food)的 __toString()方法,并将结果存储到$message 变量中。要访问父类方法,我们使用关键字 parent,然后是双冒号作用域解析操作符(::),接着是方法名。接下来,我们使用字符串拼接将“ I am a Dessert!”添加到消息的末尾。所有这些都发生在 Dessert 类的 __toString()方法的定义中,这意味着我们仍然在技术上覆盖了父类的 __toString()方法。只是我们通过 parent 来访问父类的方法定义,同时进行覆盖。

现在访问索引网页,你应该能看到如下内容:

(FOOD) apple
(FOOD) strawberry cheesecake I am a Dessert!

Dessert 对象返回的字符串是由 Food 类的 __toString()方法生成的字符串“(FOOD)草莓芝士蛋糕”,加上 Dessert 对象特有的“ I am a Dessert!”消息拼接而成的。

你可以通过显式地按名称引用父类,而不是使用 parent::来访问父类的方法,例如在我们的例子中是 Food::。在多层次的类层级结构中(例如,D 是 C 的子类,C 是 B 的子类,B 是 A 的子类),这非常有用,你可能希望引用层级结构中更高处的继承类,而不是直接的父类。在这种情况下,显式命名类是唯一的选择,因为 parent 关键字总是指向直接的父类。

增强方法而不是完全覆盖方法的一个常见原因是创建一个适用于子类的构造函数。如果子类拥有父类的所有属性加上它自己的一些属性,那么创建一个构造函数,首先使用 parent::__construct()调用父类的构造函数,然后再设置特定于子类的属性,这样就很高效。这样,你可以重用父类构造方法中的任何验证或其他重要逻辑。

注意

构造函数不受 LSP 方法签名规则的约束。如果子类和父类构造函数的参数列表不兼容,也没关系,就像子类构造函数有额外的强制性参数一样。

为了演示如何增强构造函数,我们将在 Dessert 对象中添加一个新的 calories 属性。然后,我们将为 Dessert 对象创建一个构造函数,通过设置 calories 和 name 来增强 Food 构造函数。更新 src/Dessert.php 文件以匹配 示例 19-17 中的代码。

<?php
class Dessert extends Food
{
    private int $calories;

  ❶ public function __construct(string $name, int $calories)
    {
        parent::__construct($name);
        $this->setCalories($calories);
    }

 ❷ public function setCalories(int $calories)
    {
        $this->calories = $calories;
    }

 public function __toString(): string
 {
 $message = parent::__toString();
        $message .= " I am a Dessert containing $this->calories calories!";
 return $message;
 }
}

示例 19-17:为 Dessert 类添加增强的构造函数

我们声明了新的 calories 属性并将其设为私有(因为我们在这个项目中没有 Dessert 的子类)。然后,我们声明了一个构造方法,它接受两个参数,分别是要创建的新 Dessert 对象的名称和卡路里 ❶。在方法定义中,我们使用 parent:: 调用 Food 超类的构造方法,在此过程中设置名称属性。接着,我们完成 Dessert 构造函数,通过设置对象的 calories 属性。我们使用 setter 方法 setCalories(),该方法我们随后会声明 ❷。最后,我们更新 __toString() 方法,以便输出对象的 calories 属性值,以确认代码是否正常工作。

在我们创建一个 Dessert 对象时,现在需要在索引脚本中为 calories 属性添加一个值。更新 public/index.php 以匹配 示例 19-18 中的代码。

<?php
require_once __DIR__ . '/../src/Food.php';
require_once __DIR__ . '/../src/Dessert.php';

$f1 = new Food('apple');
print $f1;
print '<br>';

$f2 = new Dessert('strawberry cheesecake', 99);
print $f2;

示例 19-18:在索引脚本中添加卡路里参数

我们创建一个 Dessert 对象,并传入两个参数,分别对应名称和卡路里属性。以下是访问网页后的结果:

(FOOD) apple
(FOOD) strawberry cheesecake I am a Dessert containing 99 calories!

第二条消息表明 Dessert 对象的两个属性已经通过类的构造方法成功设置,该方法增强了其父类的构造方法。此外,它还显示我们成功增强了 Dessert 子类的 __toString() 方法,并调用了其父类的 __toString() 方法。### 防止子类化和重写

在某些情况下,你可能永远不希望允许从你声明的类中创建子类。在这种情况下,可以使用 final 关键字声明该类,防止其他类扩展(子类化)该类。关键字应放在类声明的最开始(例如:final class Dessert)。你还可以将 final 关键字添加到单个方法的声明中,以防止该方法被子类重写。

尤其对于整个类的 final 关键字使用,在面向对象编程(OOP)社区中一直是一个激烈争论的话题。只有在有充分理由的情况下,才建议将类或方法声明为 final。例如,如果你有一个 API 库,你可能会将某个类声明为 final,以防止任何人对其进行子类化,因为你不希望允许或鼓励程序员期望 API 中声明的行为之外的不同表现。final 声明还可以帮助防止版本间或更新后的代码出现问题:当一个类是 final 的时,类的内部实现(私有方法和属性)发生变化也不会对类外的代码产生意外影响。

声明一个 final 类

让我们创建一个 final 类的示例,并查看如果尝试声明其子类时会发生什么错误。启动一个新项目,并创建 src/Product.php,如 清单 19-19 所示。

<?php
❶ final class Product
{
    private int $skuId;
    private string $description;

  ❷ public function __construct(int $skuId, string $description)
    {
        $this->skuId = $skuId;
        $this->description = $description;
    }

    public function getSkuId(): int
    {
        return $this->skuId;
    }

    public function getDescription(): string
    {
        return $this->description;
    }
}

清单 19-19:声明为 final 的 Product 类

我们声明 Product 类,并将其指定为 final ❶。这个简单的类具有库存单位(SKU)编号和文本描述属性,这些属性在类的构造函数 ❷ 中设置。它还为每个属性提供了一个 getter 方法。

接下来,我们将尝试通过声明一个子类来扩展 Product。创建 src/MyProduct.php,包含 清单 19-20 中的代码。

<?php
class MyProduct extends Product
{
}

清单 19-20:Product 的 MyProduct 子类

我们声明 MyProduct 是 Product 的子类,同时将其主体留空。

现在创建一个 public/index.php 脚本,读取这两个类的声明,如 清单 19-21 所示。

<?php
require_once __DIR__ . '/../src/Product.php';
require_once __DIR__ . '/../src/MyProduct.php';

清单 19-21:读取 Product 和 MyProduct 声明的索引脚本

为了演示声明为 final 的类不能被扩展,我们不需要更多的 require_once 语句。执行索引脚本后,你应该会看到这个错误:Fatal error: Class MyProduct may not inherit from final class Product。

声明 final 方法

将特定方法声明为 final 可以防止它们被重写,同时仍允许类拥有子类。除了其他应用场景外,这一技术确保方法在类层次结构的所有级别上具有一致的验证逻辑。

为了说明这一点,让我们修改 Product 类,移除其整体 final 声明并添加一个新方法来设置 skuId 属性。该方法将进行验证,确保 SKU 编号大于 0。我们将把这个方法声明为 final,这样任何子类都无法用不包含验证逻辑的方法来替换它。更新 Product 类声明,如 清单 19-22 所示。

<?php
class Product
{
    protected int $skuId;
 private string $description;

 public function __construct(int $skuId, string $description)
 {
 $this->skuId = $skuId;
 $this->description = $description;
 }

 public function getSkuId(): int
 {
 return $this->skuId;
 }

 public function getDescription(): string
 {
 return $this->description;
 }

    final public function setSkuId(int $skuId): void
    {
        if ($skuId > 0)
            $this->skuId = $skuId;
    }
}

清单 19-22:为 Product 类添加 final 方法

我们从整体类声明中移除 final 关键字,允许该类被扩展,并将 skuId 属性的可见性更改为 protected,这样它可以在子类的方法中使用。然后,我们为 skuId 属性添加一个新的 final setter 方法,确认所需的值大于 0。

现在更新 MyProduct 类,使其与 清单 19-23 中的内容相匹配,在其中我们尝试重写 setSkuId() 方法。

<?php
class MyProduct extends Product
{
  ❶ public function setSkuId(int $skuId): void
    {
        $this->skuId = $skuId;
    }
}

清单 19-23:在 MyProduct 中重写 setSkuId() 方法

我们尝试直接在 MyProduct 类 ❶ 中声明 setSkuId() 方法,而不进行大于零的验证检查。这是不允许的,因为在 Product 超类中,setSkuId() 方法已经声明为 final。如果重新运行索引脚本,你应该会看到一个致命错误,指出无法重写 Product 类的 setSkuId() 方法。 ### 总结

在本章中,我们探讨了几个面向对象编程(OOP)特性,最显著的是类之间的继承。我们还讨论了与保护可见性相关的主题,这使得子类能够访问继承的属性和方法,以及方法重写和通过父类关键字调用继承的方法行为。最后,我们看了如何通过使用 abstract 和 final 关键字来限制类和方法的使用。

练习

1.   实现 图 19-6 中的 Jam 类图。

图 19-6:Jam 类及其 $food1 对象

编写一个索引脚本来创建图中所示的 Jam 类的 $food1 对象。使用打印语句调用该对象的 __toString() 方法。

2.   复制你在练习 1 中的项目。创建两个新类,Spread 和 Honey,并简化 Jam 类,如 图 19-7 所示。注意 Spread 类中属性的可见性:# 表示保护级别,+ 表示公共级别。

图 19-7:Jam 和 Honey 子类继承自 Spread

更新你的索引脚本,创建并打印一个 Jam 对象和一个 Honey 对象。

提示:你可以通过创建一个私有的辅助方法 manukaString() 来简化字符串创建代码,该方法根据 isManuka 属性的值返回字符串(Manuka)或(NOT Manuka)。

3.   查看 图 19-8 中的 Car 和 Van 类,并计划一个抽象的超类来包含这两个类的共同成员。

图 19-8:Car 类和 Van 类

声明你的超类,以及 Car 和 Van 类。然后编写一个索引脚本,创建一个 Car 对象和一个 Van 对象,并使用打印语句调用它们的 __toString() 方法。

4.   测试使用 final 关键字防止子类化。复制你在练习 2 中的项目,并在 Spread 类声明的开头将 abstract 关键字替换为 final 关键字。运行你的索引脚本,Jam 和 Honey 类的声明应该触发一个致命错误。

第二十章:20 使用 Composer 管理类和命名空间

随着你的 PHP 项目变得越来越大且复杂,你越来越有可能遇到 命名冲突 的问题,或者出现两个同名的类。在本章中,你将了解 命名空间,这是一种面向对象语言提供的解决方案,用于避免命名冲突。此外,你还将学习如何使用有用的 Composer 命令行工具,它能够自动加载类和函数声明文件,并简化与命名空间的工作。几乎每个现代的面向对象 PHP 项目都会使用 Composer,我们将在本书的其余部分中使用它。

你可能认为命名冲突不太可能发生;毕竟,直到现在,我们一直在 PHP 文件中编写与类名相同的类声明,并将这些类声明文件放置在项目的 src 目录中。由于 PHP 不允许在同一目录中有两个同名的文件,难道我们不会遇到两个同名的类吗?

事实上,命名冲突可能在几种情况下发生。首先,你可能会尝试声明一个与 PHP 语言的内置类同名的类,例如 Error、Directory 或 Generator。其次,你可能会在不同的目录中声明两个同名的类(例如,在 src 的不同子目录中)。第三,你可能会将自己的类与第三方库的类混合在一起。

命名空间

命名空间 可以看作是一个虚拟的目录层级结构,用于存放类,以避免类名冲突。类被组织在命名空间和子命名空间中,就像计算机文件被组织在目录和子目录中一样。就像你需要指定计算机文件在硬盘上的目录位置一样,使用命名空间的类也需要同时指定类名和命名空间,以唯一标识某个类。

反斜杠字符(\)用于分隔命名空间、子命名空间(如果有的话)和类名。例如,\MyNamespace\MySubNamespace\MyClass 指的是一个名为 MyClass 的类,它位于 MySubNamespace 子命名空间中,而 MySubNamespace 是更大命名空间 MyNamespace 的一部分。通过命名空间和子命名空间来标识 MyClass,可以避免与其他命名空间中名为 MyClass 的类发生冲突,例如 \YourNamespace\MyClass。根据约定,命名空间或子命名空间的首字母大写,类似于类名。命名空间或子命名空间中的其他字母也可以大写。

PHP 语言内建的类被认为是位于根命名空间,它只由一个反斜杠字符标识。例如,你可以写 \DateTime 或 \Exception 来明确引用 PHP 内建的 DateTime 或 Exception 类。在本书至今的章节中,我们省略了内建类名称前的反斜杠,因为我们在编写自己的类时并未使用命名空间。包含反斜杠使得我们明确表示是在引用根命名空间中的 PHP 类。

在接下来的示例中,我将使用 Mattsmithdev 命名空间。这是我为我编写的所有类所使用的命名空间,每个我工作过的项目都有一个子命名空间。你可能想为自己创建一个命名空间,例如 Supercoder 或 DublinDevelopers,并在跟随本书的章节时使用它并编写自己的类。我们还将在“将第三方库添加到项目中”一章中遇到其他命名空间,见 第 390 页。

声明类的命名空间

要声明类的命名空间,使用 namespace 关键字后跟命名空间的名称。这应该是类声明文件中的第一行 PHP 代码。为了演示,我们将声明一个名为 Shirt 的类,并将其归属于 Mattsmithdev 命名空间。开始一个新项目,创建一个名为 src/Shirt.php 的文件,并输入 列表 20-1 中的代码。

<?php
namespace Mattsmithdev;

class Shirt
{
    private string $type ='t-shirt';

    public function getType(): string
    {
        return $this->type;
    }

    public function setType(string $type): void
    {
        $this->type = $type;
    }
}

列表 20-1:Mattsmithdev 命名空间中的 Shirt 类

在 PHP 标签的开头,我们使用 namespace Mattsmithdev 来将我们接下来要声明的类包含到 Mattsmithdev 命名空间中。我们在命名空间声明后加上两行空白行,这是 PHP 编码规范推荐的做法。然后,我们照常进行类声明。在这个例子中,Shirt 类有一个名为 type 的私有属性,默认值为 't-shirt',并且有该属性的公共 getter 和 setter 方法。

使用命名空间类

一旦类声明属于一个命名空间,你需要明确告诉 PHP 引擎这是你想要使用的类。你可以通过两种方式来做到这一点。

第一个选项是始终在引用类时包含命名空间;这被称为使用类的完全限定名称。例如,要创建一个新的 Shirt 对象,你可以写 new \Mattsmithdev\Shirt()。现在让我们尝试一下。将 public/index.php 文件添加到你的项目中,并输入 列表 20-2 中的代码。

<?php
require_once __DIR__ . '/../src/Shirt.php';

$shirt1 = new \Mattsmithdev\Shirt();
$shirt2 = new \Mattsmithdev\Shirt();

print "shirt 1 type = {$shirt1->getType()}";

列表 20-2:在 index.php 中创建 \Mattsmithdev\Shirt 类的对象

在读取类声明文件之后,我们创建了两个 Shirt 类的对象,使用该类的完全限定名称。通过命令行运行项目,使用 php public/index.php,你应该看到以下内容:

shirt 1 type = t-shirt

消息指示通过类的完全限定名称成功创建了一个 Shirt 对象。

引用特定命名空间中的类的第二种方式是,在调用类之前包含一个use语句。例如,use Mattsmithdev\Shirt 告诉 PHP 引擎,任何后续对 Shirt 类的引用都是专门指向 Mattsmithdev 命名空间中的那个类。要查看 use 语句如何工作,请更新你的public/index.php 文件以匹配 Listing 20-3。

<?php
require_once __DIR__ . '/../src/Shirt.php';

use Mattsmithdev\Shirt;

$shirt1 = new Shirt();
$shirt2 = new Shirt();

print "shirt 1 type = {$shirt1->getType()}";

Listing 20-3: 在 index.php 中使用 use 语句引用 Shirt 类

我们在读取类声明后包含一个 use 语句,以确保代码中后续的 Shirt 引用指向 Mattsmithdev\Shirt。请注意,在 use 语句中我们不包括命名空间前的反斜杠。这种类标识符,没有初始反斜杠,称为限定名,与包含初始反斜杠的完全限定名相对。然后我们可以简单地使用 new Shirt() 来创建两个 Shirt 对象,因为得益于 use 语句,PHP 引擎知道我们正在引用哪个类。重新运行 index 脚本,你应该会看到输出没有变化。我们仍然成功地创建了一些 Shirt 对象。

如果你需要在同一段代码中区分两个同名但属于不同命名空间的类,你可以通过它们的完全限定名来引用它们(例如,\Mattsmithdev\Shirt 和 \OtherNamespace\Shirt),或者为其中一个类提供 use 语句,并对另一个类进行限定。

在类声明中引用命名空间

假设你正在为一个命名空间类编写类声明文件(而不是像index.php 这样的通用脚本),并且你想引用来自其他命名空间的类。如果你没有写 use 语句,你必须使用另一个类的完全限定名,从反斜杠开始。例如,如果你正在为一个在 Mattsmithdev 命名空间中声明的类编写代码,并且你想引用 PHP 内置的 DateTime 类,你必须写成 \DateTime 来表明它属于根命名空间。同样,如果你想引用一个第三方类,你需要写一个反斜杠,然后是第三方命名空间,再一个反斜杠,最后是类名,例如 \MathPHP\Algebra

如果没有初始反斜杠,PHP 会假设你引用的是当前命名空间中的类或子命名空间。例如,在 Mattsmithdev 命名空间中的一个类中,如果引用 DateTime() 没有加初始反斜杠,PHP 会认为是指 Mattsmithdev\DateTime,即 Mattsmithdev 命名空间中的 DateTime 类。同样,引用 MathPHP\Algebra 如果没有加初始反斜杠,PHP 会认为是指 Mattsmithdev\MathPHP\Algebra,即 MathPHP 被认为是 Mattsmithdev 的子命名空间,Algebra 被认为是该子命名空间中的类。写上以反斜杠开头的完全限定命名空间,可以确保 PHP 引擎正确理解你引用的类的命名空间。

另一方面,如果你正在引用当前命名空间中的类或子命名空间,则不应在类或子命名空间前加反斜杠。例如,如果你在 Mattsmithdev 命名空间中工作,Shirt() 被理解为指的是 Mattsmithdev 命名空间中的 Shirt 类,而 SubNamespace\Example 被理解为指的是 Mattsmithdev\SubNamespace\Example 类。

如果你只使用来自另一个命名空间的类一次,直接写该类的完全限定名(包括初始反斜杠)可能更合适。然而,如果你需要多次引用该类,最好在类声明的开始写一个 use 语句,这样更高效。在这种情况下,不需要加上初始反斜杠。随着你阅读和编写更多的 PHP 代码,你会经常看到在类声明的开头会有很多 use 语句,当代码使用来自其他命名空间的类时,正如 列表 20-4 中所示。

<?php

namespace App\Controller;

use App\Entity\ChessGame;
use App\Entity\Comment;
use App\Form\ChessGameType;
use App\Repository\ChessGameRepository;
use App\Repository\CommonRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;

/**
* @Route("/chessgame")
*/
class ChessGameController extends AbstractController
{
    private $session;

    public function __construct(SessionInterface $session)
    {
--snip--

列表 20-4:包含多个 use 语句的类声明

这段代码片段是我在一个 PHP Symfony 网页框架的国际象棋项目中的一个类声明开始部分。它包含了多达 11 个 use 语句,引用了来自多个命名空间和子命名空间的类。use 语句帮助我们理清这些类的来源,但如果要同时处理这么多类仍然让你觉得有些不知所措,不用担心:接下来我们会讨论一个用于管理项目中所有类的工具。

Composer

Composer 是一个命令行工具,用于支持面向对象的 PHP 编程。它帮助加载类和函数声明文件(包括你自己的文件以及第三方库的文件),并简化了使用不同命名空间中类的工作。它是一个重要且易于使用的工具,适用于专业的 Web 应用程序项目。在本节中,你将设置 Composer,并学习如何使用它来创建命令行别名、自动加载类声明文件以及管理项目的第三方依赖项。

注意

SymfonyCasts 提供了一个很棒的免费视频,介绍了 Composer 工具,观看地址是 symfonycasts.com/screencast/composer

安装和测试 Composer

对于 Windows,Composer 提供了一个简单的安装程序,可以在 getcomposer.org/Composer-Setup.exe 上找到。对于 macOS,你可以使用 Homebrew 安装 Composer,如 附录 A 中所述。对于 Linux,你需要执行几个命令行语句来下载并运行 composer.php 脚本。你可以在 getcomposer.org/download/ 上找到详细信息。如果你使用 Replit 跟随本书内容,请参见 附录 C,了解如何将 Composer 集成到你的项目中。

安装 Composer 后,通过打开一个新的命令行终端应用程序并输入 composer 来测试它。这将启动 Composer 工具,显示一个漂亮的 ASCII 艺术标志、版本号以及命令行选项的列表。

创建 composer.json 配置文件

要在项目中使用 Composer 命令行工具,你需要创建一个 composer.json 文件,用于存放 Composer 所需的所有项目信息。(关于这种文件格式的回顾,请参见下面的“JSON 文件格式”框。)例如,composer.json 文件记录了你自己代码的命名空间和类的位置,以及项目所依赖的第三方包。此外,你还可以在 composer.json 文件中声明命令行 别名,这些快捷方式可以避免你在命令行输入冗长的命令。我们将从声明一个简单的别名开始探索 composer.json 文件。

composer.json 文本文件必须位于 PHP 项目目录的顶层,而不是像 srcpublic 这样的子文件夹内。继续本章的项目,创建 composer.json 文件,将其保存在项目目录的顶层,然后输入 Listing 20-5 中的内容。该代码创建了一个名为 hello 的别名,用于代替命令 echo Hello World

{
    "scripts": {
        "hello": "echo Hello World"
    }
}

Listing 20-5: 在 composer.json 文件中声明一个别名

composer.json 的内容始终是一个 JSON 对象,因此它总是以一对大括号开始和结束。在对象内部,我们声明了一个名为 "scripts" 的属性,其值本身是一个对象。在该子对象中,我们声明了一个名为 "hello"(即我们的别名)的属性,值为 "echo Hello, world!"(将被快捷方式别名替换的代码)。

我们现在有了一个简单但有效的 composer.json 文件,告诉 Composer 有一个名为 "hello" 的命令行别名。要查看是否有效,请在终端输入 composer hello。你应该看到 "Hello, world!" 作为结果:

$ **composer hello**
> echo Hello, world!
Hello, world!

在这种情况下,我们写了更多的字符来声明别名,比在命令行中完整地写出 echo 语句还要多。然而,有时候这些脚本别名是很有用的。例如,下面是我在一些项目中使用的一个别名,用来输出报告,显示 src 文件夹中有多少代码需要修复,以符合 PHP 编程标准(尽管由于空间原因,别名在这里显示为两行,但在文件中会是单行):

"reportfixsrc":"php php-cs-fixer.phar fix --level=psr2
--dry-run --diff ./src > ./tests/fixerReport.txt"

这个别名让我在命令行输入 composer reportfixsrc,而不是输入一个长的 PHP 命令来运行一个带有多个参数的 PHP 归档(.phar)文件。

正如你很快会看到的,Composer 能做的不仅仅是跟踪命令行别名。目前,我们已经成功为我们的项目创建了 composer.json 文件,这是使用这个强大工具的必要第一步。

创建自动加载器

自动加载器 是一种系统,它会在需要时自动获取类声明文件,这样你就不需要自己将它们都加载到 index.php 文件中。随着面向对象 PHP 项目的规模和复杂性不断增长,涉及许多命名空间中的类,自动加载器变得非常有用。如果你必须在 index.php 前端控制器中为每个类写 require_once 语句,不仅会非常繁琐,而且很容易漏掉一个或两个,特别是当项目不断发展时。这会导致错误,并迫使你不断返回更新需要加载的文件列表。而自动加载器会为你处理这个过程,只要类是正确命名空间并按照自动加载规则正确定位的。

Composer 工具最强大的功能之一就是其自动加载器。它符合 PSR-4,这是 PHP 推荐的自动加载规则集。根据 PSR-4,你必须指定包含每个命名空间类的基础目录。例如,你可能想声明 Mattsmithdev 命名空间中的类可以在src目录中找到。此外,PSR-4 规定,任何子命名空间将被认为在声明的命名空间基础目录中有相应的子目录。例如,类 Mattsmithdev\Trigonometry\Angles.php 应位于 src/Trigonometry 目录下,类 Mattsmithdev\Utility\Security.php 应位于 src/Utility 目录下,依此类推。只要子目录的名称与子命名空间相同,你就不需要告诉自动加载器去哪里找到这些子命名空间的类。

要让 Composer 的自动加载器工作,需要三个步骤:

1.   在项目的 composer.json 文件中声明每个命名空间的基础目录。

2.   告诉 Composer 创建或更新其自动加载器脚本。

3.   在项目的 public/index.php 前端控制器的开头添加一个 require_once 语句来引入自动加载器脚本。这个单一的 require_once 语句替代了为每个单独类所写的多个 require_once 语句。

我们将演示如何设置 Composer 自动加载器以加载我们的 Mattsmithdev\Shirt 类。首先,列表 20-6 展示了在 composer.json 文件中需要写入的内容,用于声明 Mattsmithdev 命名空间中的类可以在 src 目录中找到。

{
    "autoload": {
        "psr-4": {
            "Mattsmithdev\\": "src"
        }
    }
}

列表 20-6:在 composer.json 文件中设置自动加载器

我们声明一个“autoload”属性,它的值是一个对象。在这个对象中,我们声明“psr-4”属性,它的值是另一个对象。它包含一个“Mattsmithdev\" 属性,值为 "src"。这告诉 Composer,Mattsmithdev 命名空间中的类文件位于 src 目录中。注意命名空间后面的两个反斜杠字符 (\)。这是 PSR-4 的要求。

对于我们在接下来的章节中将要处理的项目,composer.json 文件将基本与 列表 20-6 相同。每个项目之间唯一可能的不同是“psr-4”对象中声明的实际命名空间和位置。

注意

Composer 自动加载器有一些额外的细节。如果你想了解更多,请参考 Composer 文档 getcomposer.org/doc/04-schema.md#psr-4

现在我们已经在 composer.json 文件中声明了 Mattsmithdev 命名空间的基础目录,我们可以告诉 Composer 为我们生成类的自动加载器。在当前项目的工作目录下输入以下命令:

$ **composer dump-autoload**

此命令将在项目中创建一个名为 vendor 的新文件夹(如果它还不存在的话),并在该文件夹内生成或更新多个文件。这个 vendor 文件夹是 Composer 用来存储项目工作文件的地方。你可以查看其内容,但不应更改其中的内容。你也可以随时删除这个文件夹并让 Composer 重新生成它,所以在备份项目时可以安全地省略这个文件夹。

vendor 目录中,你应该能看到一个 vendor/autoload.php 文件,以及一个包含多个脚本的 vendor/composer 文件夹,其中包括 autoload_psr4.php,它编码了我们的 PSR-4 合规声明。这个文件包含返回 Mattsmithdev 命名空间类的位置(src/)的语句。

现在我们已经生成了自动加载器,我们可以更新 public/index.php 脚本,仅引用这个 autoload.php 文件,无论我们在项目中需要引用多少个类。只要在 composer.json 文件中声明了命名空间的基础目录,并且通过 composer dump-autoload 命令更新了自动加载器,那么每当我们编写命名空间类的 use 语句时,PHP 引擎将加载其声明,准备供我们的代码使用。列表 20-7 展示了如何更新 index.php

<?php
require_once __DIR__ . '/../vendor/autoload.php';

use Mattsmithdev\Shirt;

$shirt1 = new Shirt();
$shirt2 = new Shirt();

print "shirt 1 type = {$shirt1->getType()}";

列表 20-7:将 Composer 生成的自动加载脚本加载到 index.php 中

我们将 require_once 语句更改为从 vendor 目录中读取并执行 Composer 生成的自动加载器脚本。当你运行项目时,输出将与之前一样,但现在我们使用 Composer 自动加载器来自动读取 src 文件夹中 Mattsmithdev\Shirt 类的声明,而不是手动读取它。虽然对于我们这个单类项目来说,似乎差别不大,但对于包含多个类的项目,自动加载器可以节省大量时间。

将第三方库添加到项目中

Composer 另一个强大的功能是能够将第三方库添加到项目中,并在 composer.json 文件中维护这些依赖关系的记录。成千上万的开源库可供使用,许多由经验丰富的软件开发者保持最新;在许多情况下,几分钟的搜索就能找到一个现成的库,能够完成你所需要的全部或大部分功能。维护良好的开源项目经过了充分的测试,并进行了重构以实现最佳实践,因此,合理使用第三方库可以减少你的工作量,同时帮助保持软件项目的质量。

如果没有 Composer 的包依赖功能,你需要从网站或 Git 仓库下载第三方库的代码,将其复制到适当的位置,比如 lib 文件夹,并更新 composer.json 文件,记录这些库类的命名空间和位置。相反,你只需告诉 Composer 你需要在项目中使用某个第三方库,它将为你完成所有繁重的工作。它会自动下载代码,将文件创建并复制到 vendor 下的适当子目录中,更新自动加载器,并在 composer.json 文件中记录依赖关系(及其版本)。你只需要知道你需要的包名和供应商。

要查看这如何工作,我们将告诉 Composer 将来自供应商 markrogoyski 的 math-php 包添加到我们的章节项目中。这是一个提供许多有用数学运算的优秀包。在当前项目工作目录的命令行中,输入以下内容:

$ **composer require markrogoyski/math-php**

这个 require 命令触发了 Composer 执行一系列操作。首先,如果你检查项目的 vendor 文件夹,你应该会看到 Composer 创建了一个新的子文件夹,名称与包的供应商名称匹配(在这个例子中是 vendor/markrogoyski)。在里面,你会找到一个 math-php 包的文件夹,包含所有必要的代码。

请记住,供应商名称(markrogoyski)和包名称(math-php)不是命名空间。它们只是 Composer 用来标识和定位要添加到项目中的第三方脚本的名称。Composer 会自动确定所有开源库类的命名空间,因此 vendor/composer 的内容会更新,包含所有已添加到 vendor 文件夹中的这些类。特别地,autoload_psr4.php 很可能会更新为命名空间第三方类的基本目录,因为大多数开源库使用 PSR-4 自动加载标准。同时,你需要阅读包的文档,了解第三方类的命名空间,以便在代码中正确引用它们。

require 命令还会提示 Composer 更新 composer.json 文件,添加有关 markrogoyski/math-php 包的信息。如果你查看该文件,现在应该能看到类似于清单 20-8 的内容。

{
    "autoload": {
        "psr-4": {
            "Mattsmithdev\\": "src"
        }
    },
    "require": {
 "markrogoyski/math-php": "².10"
    }
}

清单 20-8:composer.json 文件中记录的 math-php 库依赖

除了我们之前写的 "autoload" 属性外,composer.json 中的主对象现在还有一个 "require" 属性,这是 Composer 自动生成的。它的值是一个对象,列出了项目所需的所有包。在这种情况下,有一个 "markrogoyski/math-php" 条目。它的值 "².10" 表示包的可接受版本。插入符号(^)意味着我们愿意使用具有相同主版本号的新版本(例如 2.10.1、2.11、2.2 等),但不使用版本 3.x 或更高版本,因为那可能会破坏向后兼容性。

现在 Composer 已经将 markrogoyski/math-php 包集成到我们的项目中,我们可以尝试使用它。具体来说,我们将使用该包的 Average 类来计算一系列数字的平均值。请更新 public/index.php 的内容,使用清单 20-9 中的代码。

<?php
require_once __DIR__ . '/../vendor/autoload.php';

use MathPHP\Statistics\Average;

$numbers = [13, 18, 13, 14, 13, 16, 14, 21, 13];
$numbersString = implode(', ', $numbers);
$mean = Average::mean($numbers);
print "average of [$numbersString] = $mean";

清单 20-9:计算一组整数的平均值

我们首先通过 use 语句告诉 PHP 引擎,Average 是指命名空间为 MathPHP\Statistics\Average 的类。注意,这个类的命名空间与我们之前在 require 语句中为 Composer 使用的供应商和包名称不同。接下来,我们声明一个 $numbers 数组,并使用内置的 implode() 函数将其转换为字符串,以便用户友好的输出。然后,我们调用 Average 类中的 mean() 方法,将结果存储在 $mean 中。接着,我们打印出数字列表和计算出的平均值。

请注意,我们调用了 mean() 方法,而不需要实际创建 Average 类的对象。这是因为 mean() 是一个 静态方法。我们将在第二十五章中详细探讨这一面向对象的概念。

在哪里可以找到 PHP 库

你可能会想知道 Composer 工具是如何知道在哪里下载 markrogoyski/math-php 包文件以供我们的项目使用的。答案是 Packagist (packagist.org),这是一个用于发布开源 PHP 包的网站。供应商可以在该网站上注册(例如,我在 Packagist 上的用户名是 mattsmithdev),然后发布 PHP 包,供任何人通过 Composer 安装。

在发布包时,供应商必须提供包括包的公开可下载文件的 GitHub(或其他仓库)位置。例如,markrogoyski/math-php 包的 Packagist 页面列出了一个 GitHub 地址* github.com/markrogoyski/math-php*。这就是 Composer 去下载包文件的位置。Packagist 的每个页面还列出了您需要的确切 require 命令,以便 Composer 将该包添加到您的项目中。

总结

在本章中,您学习了如何通过使用命名空间清晰地区分同名类。您还学习了如何使用强大的 Composer 命令行工具支持面向对象的 PHP 编程。学习如何维护composer.json文件,以及如何使用 Composer 自动加载类并将第三方库集成到项目中,将为您节省无数小时的繁琐手动工作。

练习

1.   开始一个新项目,并为一个命令创建 Composer 脚本别名,显示消息 Hello name,将 name 替换为您的名字。然后使用 Composer 执行该命令。

提示:在composer.json中声明一个脚本别名,然后使用 composer 别名在命令行中运行它。

2.   开始一个新项目,并创建一个src/Product.php文件,声明一个 Product 类,包含私有属性\(id、\)description 和$price,以及每个属性的公有 getter 和 setter 方法。声明该类位于 Mattsmithdev 命名空间下。将一个composer.json文件添加到项目的根文件夹,声明 Mattsmithdev 命名空间中的类可以在src目录中找到。然后使用 Composer 生成自动加载器。

编写一个public/index.php文件,执行以下操作:

a.   读取并执行vendor/autoload.php中的 Composer 自动加载器

b.   创建一个新的 Product 对象$p1,id 为 7,描述为'hammer',价格为 9.99

c.   使用 var_dump()输出$p1 的详细信息

运行代码时,您应该看到类似以下内容:

object(Mattsmithdev\Product)#4 (3) {
  ["id":"Mattsmithdev\Product":private]=>
  int(7)
  ["description":"Mattsmithdev\Product":private]=>
  string(6) "hammer"
 ["price":"Mattsmithdev\Product":private]=>
  float(9.99)
}

3.   访问 Packagist 网站,打开* packagist.org并搜索 mattsmithdev/faker-small-english 包。查看文档,然后使用 Composer 为新项目要求 mattsmithdev/faker-small-english 包。编写一个public/index.php*文件,循环 10 次,从 FakerSmallEnglish 对象中显示 10 个随机名字。

第二十一章:21 使用 Twig 进行高效模板设计

本章介绍了免费的开源 Twig 模板库,这是一个应用面向对象原则(如继承)来处理页面展示模板的第三方软件包。模板与面向对象编程(OOP)的结合简化了 Web 应用程序的设计过程。我们将首先学习使用 Twig 的基础知识,然后利用它逐步构建一个多页面的网站。

本章还首次展示了面向对象编程风格如何影响我们在第三部分中得到的 Web 应用架构。例如,我们不再编写实现前端控制器逻辑的函数,而是创建包含前端控制器方法的面向对象类。随着我们进一步开发本章的多页面网站,面向对象的应用开发方法将在第二十二章中得到进一步完善。

Twig 模板库

类似 Twig 库这样的 Web 模板系统(可以在 twig.symfony.com 找到)通过区分不变内容和可能在每次请求时发生变化的内容,管理了 Web 应用程序输出中常见的重复内容。这使得开发 Web 应用程序的 MVC 架构中的视图部分变得更加高效。正如我们在前几章中讨论的那样,许多基础的 HTML 往往会在每个页面的模板脚本中重复,因为多个页面通常共享如页眉和导航栏等元素。例如,在第十三章中创建的三页网站中,每个页面模板文件中的大部分代码都是重复的 HTML;几乎没有任何代码是特定于当前页面的。

在多个模板中复制大量 HTML 会导致两个问题。首先,如果需要更改网站设计的某个方面(例如导航栏的外观和感觉,或者特殊的页眉或页脚),你必须编辑每一个页面模板,对于一个大型网站来说,这可能涉及到几十、几百甚至几千个文件。其次,在编辑页面模板的内容或行为时,当内容被隐藏在大量通用代码中时,识别出当前页面特有的内容会变得非常困难。你希望能够专注于编辑特定页面的内容。

一种解决方案是将网页的公共部分分离成子模板,例如头部、导航、页脚等。例如,在第十五章中,我们创建了一个 templates/_header.php 文件,其中包含了我们购物车网站中所有页面共有的头部元素。然而,一个更优雅的解决方案是使用专门的 PHP 模板库,特别是像 Twig 这样的支持模板继承的库。正如你所看到的,模板继承的一个关键优势是,在我们声明某个页面的模板继承自父模板之后,子页面中唯一的内容就是该页面本身的内容;我们不需要为标准的页面头部、页脚、导航栏等编写 require 语句。这样,每个子模板就能专注于其代表的页面,避免了多余的干扰。

模板库的另一个优势是,它们通过将 PHP 代码从模板中移除,限制了可以在页面模板中编码的行为。如果你在一个团队中工作,这些限制意味着你可以安全地让团队中的其他成员(比如营销部门)编辑页面模板,以完善网站的外观和感觉,且可以放心他们不会不小心插入可能导致网站崩溃或产生安全漏洞的 PHP 代码。毕竟,模板文件是 MVC 架构中的视图组件,因此它们只应使用类似 HTML 这样的标记语言,通过标签来“装饰”提供的数据。

Twig 工作原理

在我们使用 Twig 创建任何内容之前,让我们先了解在使用 Twig 模板时涉及的基本文件、对象和方法。当在代码中使用 Twig 时,我们会操作一个 Twig\Environment 类的对象。这个对象负责根据模板文件和任何需要的数据显示来生成网页的 HTML 内容。它通过其 render() 方法来实现这一点。通常,你会将 Twig\Environment 对象的引用存储在一个名为 twig 的变量中,而这个变量本身是一个通用应用程序类的属性,该类封装了所有与网页应用程序相关的高级逻辑。

render() 方法需要两个参数。第一个是一个包含所需模板文件路径的字符串。这个字符串通常存储在一个名为 $template 的变量中。第二个参数是一个数组,包含需要提供给模板的变量,用以定制页面的内容。这个数组通常存储在一个名为 \(args 的变量中,因为该数组本质上是在向模板提供参数。数组中的字符串键应该对应于 Twig 模板中使用的任何变量的名称。如果模板不需要任何值,\)args 将是一个空数组。

举个例子,假设你想显示一个显示购物车中商品的页面。这个页面的模板文件叫做shoppingCart.xhtml.twig(按照约定,输出 HTML 的 Twig 模板文件的命名形式为.xhtml.twig),模板中会有一个 products 变量,代表用户购物车中的所有商品(Twig 模板中的变量不以美元符号开头)。

你需要将模板文件路径作为第一个参数传递给 Twig\Environment 类的 render()方法。第二个参数将是一个包含'products'键(与模板中变量的名称相同)的$arg 数组,该键的值是一个 Product 对象的数组。然后,render()方法将返回一个包含网页 HTML 的字符串,网页上列出了所有产品,呈现为一个漂亮的购物车,带有小计、总计、删除商品或更改数量的链接等等。这个字符串可以被打印到输出缓冲区,作为返回给 Web 客户端的 HTTP 响应体的一部分。

如果一个 Web 应用程序使用 Twig 模板,每个返回 HTML 响应文本的方法都会包含类似示例 21-1 中的代码。

$template = 'path/templateName.xhtml.twig';
$args = [
    'variable1NameForTwig' => $phpVariable1,
    'variable2NameForTwig' => $phpVariable2,
    ...
];
$html = $this->twig->render($template, $args);
print $html;

示例 21-1:从 Twig 模板创建并打印 HTML 的典型代码

我们将模板路径存储在\(template 变量中,然后使用所需的模板变量构建\)args 数组。我们将这些变量传递给 render()方法。记住,通常我们会在一个顶层的 Application 类中存储对拥有 render()方法的 Twig\Environment 对象的引用,这就是为什么调用该方法时写作\(this->twig->render()而不是\)twig->render()。我们将返回的字符串存储在$html 中,然后打印它。很快,你会在实际应用中看到这种代码模式,因为我们使用 Twig 创建一个基本的网页。

一个简单的示例

在本节中,我们将使用 Twig 创建一个简单的“Hello, world!”网页,显示一个问候信息。通过 Twig,我们将能够根据 PHP 变量的值定制问候语,填入被问候的人的名字。除了演示使用 Twig 的基本操作,这个项目还将首次展示一个面向对象的 Web 应用程序是如何结构化的。

为了创建我们的基本问候页面,我们首先要设置 Twig。接着,我们将编写一个包含姓名变量的 Twig 模板,并编写必要的 PHP 脚本使模板能够正常工作。

将 Twig 添加到项目中

将 Twig 添加到项目中最简单的方法是使用 Composer。为项目创建一个新的空文件夹,然后在命令行中输入以下内容:

$ **composer require twig/twig**

此命令触发 Composer 将 Twig 包的最新版本安装到项目的vendor文件夹中(该文件夹将被创建,因为项目尚未包含此文件夹)。该命令还会安装 Twig 所需的任何额外依赖项,如 symfony/polyfill-mbstring 和 symfony/polyfill-ctype。如果你查看安装完成后的vendor文件夹内容,你应该能看到这些包的文件夹已经被创建。你甚至可以查看每个包的src文件夹,检查包中的每个类和配置文件。

现在 Twig 包已经被复制到我们的项目文件夹中,我们可以在类中添加 use 语句,以便在项目中创建并利用该库的功能。

编写 Twig 模板

根据约定,Twig 模板文件存储在templates文件夹中,就像我们在前面的章节中编写的 PHP 模板文件一样。将此文件夹添加到项目目录中,然后在该文件夹中创建一个hello.xhtml.twig模板,并输入 Listing 21-2 的内容。

<!doctype html>
<html lang="en">
<head>
    <title>Twig hello</title>
</head>

<body>
  ❶ Hello {{name}}, nice to meet you
</body>
</html>

Listing 21-2:创建围绕 name 变量的 HTML 问候语的 hello.xhtml.twig 模板

和许多网页模板语言一样,Twig 模板使用双大括号来表示应该填写的值,例如此模板中的 {{name}} ❶。这声明了一个名为 name 的 Twig 变量的值将在 render() 方法生成 HTML 输出字符串时插入到此处。在调用此模板的 PHP 脚本中,我们需要通过 $args 数组为该变量传递一个值,并使用相同名称的键。例如,如果我们将 $args 数组声明为 ['name' => 'Matt'],则 render() 方法会生成 Listing 21-3 中显示的 HTML,将 Matt(这里以粗体显示)插入 name 变量的位置。

<!doctype html>
<html lang="en">
<head>
    <title>Twig hello</title>
</head>

<body>
  ❶ Hello **Matt**, nice to meet you
</body>
</html>

Listing 21-3:当 name 包含 'Matt' 时,Twig 模板渲染的 HTML

我们看到 Matt 已经被插入到 HTML 文本输出中说 hello 的那一行 ❶。

Twig 的双大括号类似于 PHP 的 <?= 短 echo 标签,因为它们都标识了需要插入到输出 HTML 中的字符串表达式。(就像 PHP 一样,Twig 还有其他标签用于标识逻辑代码,如循环和条件语句,而不是字符串输出语句。)然而,由于 Twig 模板不能包含 PHP 语句,使用 Twig 的双大括号而非 PHP 短 echo 标签,可以保护网站的模板免受 PHP 安全漏洞的影响。

创建应用程序类

现在让我们为问候页面编写一个 Application 类。它将在构造方法中设置 Twig\Environment 对象,并拥有一个 run() 方法,用于设置变量并通过 Twig 生成页面的 HTML。所有面向对象的 MVC 网页应用程序都有类似的 Application 类来执行任何所需的设置和初始化,然后执行处理应用程序请求的主要逻辑。

目前,类始终输出相同的 HTML,但在本章稍后,我们将在类的 run()方法中看到一些逻辑,以根据请求中接收到的变量输出不同的 HTML 内容。要声明该类,请创建src/Application.php,其中包含示例 21-4 中的代码。

<?php
namespace Mattsmithdev;

use \Twig\Loader\FilesystemLoader;
use \Twig\Environment;

class Application
{
    const PATH_TO_TEMPLATES = __DIR__ . '/../templates';

    private Environment $twig;

    public function __construct()
    {
        $loader = new FilesystemLoader(self::PATH_TO_TEMPLATES);
      ❶ $this->twig = new Environment($loader);
    }

    public function run()
    {
        $name = 'Matt';

        $template = 'hello.xhtml.twig';
        $args = [
            'name' => $name,
        ];

      ❷ $html = $this->twig->render($template, $args);
        print $html;
    }
}

示例 21-4:类 src/Application.php,用于创建一个 Twig 对象并输出 HTML 的方法

我们将 Application 类声明为 Mattsmithdev 命名空间的一部分,并包含两个 use 语句,因为我们需要在构造方法中创建来自 Twig 命名空间的类的对象。接着,我们声明一个名为 PATH_TO_TEMPLATES 的常量,它保存着所有模板文件所在的基础templates目录的路径。我们还声明了一个私有的 twig 属性,它将是指向 Twig\Environment 对象的引用。

接下来,我们声明类的构造方法。在构造方法中,我们创建了两个与 Twig 相关的对象,FilesystemLoader 和 Environment。后者包含了至关重要的 render()方法,而前者则帮助 Environment 对象访问模板文件。FilesystemLoader 对象只在临时作用域内创建,因为它的引用存储在\(loader 变量中,只在构造方法的作用域内有效。当我们创建 Environment 对象(使用\)loader 时),我们将其引用存储在 Application 对象的 twig 属性中 ❶,以便所有 Application 方法都能通过写入$this->twig 来访问它。

注意,当我们通过使用 PATH_TO_TEMPLATES 常量来创建 FilesystemLoader 对象时,必须在常量标识符前加上 self::。正如我们将在第二十五章中讨论的那样,在引用同一类中声明的常量时,这个前缀是必要的。这是因为 PHP 并不会为每个类的对象创建常量的副本。相反,所有类的对象共享一个常量,因此写$ this->PATH_TO_TEMPLATES 是不合法的。

接下来,我们声明 Application 类的 run()方法。在该方法中,我们定义了一个\(name 变量,用来保存我们想要问候的人的名字。然后我们创建了一个\)template 变量,它保存了我们的模板文件的名字(hello.xhtml.twig),以及一个\(args 变量,它是一个数组,包含一个元素,该元素在'name'键下保存了\)name 变量的值。正如我们所讨论的,这个键对应着模板文件中被双大括号包围的 Twig 变量。在 run()方法中,我们接着调用了 Twig 对象的 render()方法,将返回的字符串存储在\(html 变量中 ❷。最后,我们打印出\)html 的内容,它将作为响应的 HTML 主体返回给 Web 客户端。

创建自动加载器

现在让我们让 Composer 为位于 src 目录中的 Mattsmithdev 命名空间类(例如我们的 Application 类)创建自动加载器。为此,我们需要在项目的顶级目录中的 composer.json 文件中添加一个 "autoload" 属性。当我们使用 Composer 将 Twig 包添加到项目中时,这个文件会自动创建,并且它应该已经包含有关 Twig 的 "require" 属性。按照 Listing 21-5 中的内容更新此文件。

{
    "autoload": {
        "psr-4": {
            "Mattsmithdev\\": "src"
        }
  ❶},
 "require": {
 "twig/twig": "³.10"
 }
}

Listing 21-5:更新 composer.json 以支持类的自动加载

我们添加了一个 "autoload" 属性,声明 Mattsmithdev 命名空间的类符合 PSR-4 规范,并可以在 src 目录中找到。别忘了在关闭的大括号后加上逗号 ❶;如果缺少这个逗号,你将会遇到 JSON 语法错误。

一旦你更新了 composer.json,在命令行中输入以下内容:

$ **composer dump-autoload**

这指示 Composer 在 vendor 文件夹中生成必要的自动加载脚本。

添加索引脚本

启动我们的问候页面的最后一步是创建一个简单的索引脚本,它将加载自动加载器,创建一个 Application 对象,并调用其 run() 方法。我们所有面向对象的 Web 应用程序都会有一个像这样的简单索引脚本,因为所有的工作都由 Application 对象执行。创建 public/index.php,如 Listing 21-6 所示。

<?php
require_once __DIR__ . '/../vendor/autoload.php';

use Mattsmithdev\Application;
$app = new Application();
$app->run();

Listing 21-6:index.php 脚本

我们首先读取并执行生成的自动加载脚本。注意,Composer 创建的自动加载器将加载 Mattsmithdev 命名空间中声明的任何类,以及 Composer 添加到项目中的第三方库中的任何类(例如 Twig)。接下来,我们添加一个 use 语句,以便我们可以在不每次指定命名空间的情况下引用 Application 类。然后,我们创建一个 Application 对象并调用其 run() 方法。

如果你运行 PHP Web 服务器并访问项目主页,你应该看到类似于 Figure 21-1 的内容。图中还显示了 Web 客户端收到的响应的 HTML 源代码。注意,HTML 和生成的网页中,Twig {{name}} 变量的位置都填充了 Matt

图 21-1:我们的基本 Twig 项目的 Web 输出,包含 HTML 源代码

这可能看起来对于一个简单的“Hello, world!”网站来说是 很多 工作,但我们现在已经创建了任何使用强大 Twig 模板系统的项目所需的所有结构。在将这些结构应用到一个更大的网站之前,让我们先探索一些 Twig 包的其他有用功能。

在 Twig 模板中操作对象和数组

除了像字符串这样的简单数据类型,Twig 模板还可以与 PHP 对象、数组等一起使用。然而,语法与我们在 PHP 中习惯的有所不同,因为 Twig 使用点表示法来访问对象的属性或方法,或者访问数组中的元素。例如,在 PHP 中,你会写 $product->price 来访问 Product 对象的 price 属性,而在 Twig 模板中,你需要写 {{product.price}} 来实现相同的操作。方便的是,无论属性是公共的还是私有的,这种方式都有效,前提是如果属性是私有的,必须有一个公共的 getter 方法,且该方法遵循通常的命名规则,如 getPropertyName() 或 isBooleanPropertyName()。例如,{{product.price}} 仍然可以成功访问对象的私有 price 属性,只要该对象有一个公共的 getPrice() 方法。你不需要在 Twig 模板中显式引用 getPrice() 方法,因为 Twig 会自动调用该方法。

为了说明 Twig 模板如何与这些更复杂的数据类型一起工作,我们将更新我们的 "Hello, world!" 网页,以展示从 PHP 对象和数组中获取的信息。首先,我们需要编写一个类,以便可以创建一个对象并将其传递给 Twig 模板。列表 21-7 显示了一个简单的 Product 类,我们可以将其用作示例。创建 src/Product.php 并输入列表中的内容。

<?php
namespace Mattsmithdev;

class Product
{
    private string $description;
    private float $price;

    public function getDescription(): string {
        return $this->description;
    }

    public function setDescription(string $description): void {
        $this->description = $description;
    }

    public function getPrice(): float {
        return $this->price;
    }

    public function setPrice(float $price): void {
        $this->price = $price;
    }

    public function __toString(): string {
        return "(PRODUCT) description =
            $this->description / price = $this->price";
    }
}

列表 21-7:在 Twig 演示中使用的简单 Product 类

该类有两个私有属性:description 和 price。代码还为这些属性提供了公共的 getter 和 setter 方法,以及一个 __toString() 方法,用于生成对象的字符串摘要。

接下来,我们将修改 Application 类的 run() 方法。新的方法将创建一个数组和一个 Product 对象,并将它们连同原始的 name 变量一起传递给 Twig 模板。更新 src/Application.php 以匹配列表 21-8 中的内容。

--snip--
 public function run()
 {
        $meals = [
            'breakfast' => 'toast',
            'lunch' => 'salad',
            'dinner' => 'fish and chips',
        ];

        $product1 = new Product();
        $product1->setDescription('bag of nails');
        $product1->setPrice(10.99);

 ❶ $template = 'demo.xhtml.twig';
        $args = [
            'name' => 'matt',
            'meals' => $meals,
            'product' => $product1
        ];

 $html = $this->twig->render($template, $args);
 print $html;
 }
}

列表 21-8:更新后的 Application 类,将对象和数组传递给模板

我们创建了一个包含键 'breakfast'、'lunch' 和 'dinner' 的 $meals 数组,以及一个描述为 'bag of nails' 且价格为 10.99 的 Product 对象。接着我们声明了 render() 方法所需的 $template 和 $args 变量。在 $args 中,我们为 Twig 模板中的三个变量:name、meals 和 product 传递了值。这次我们直接在数组中声明 'name' 键的值,而不是将其作为单独的变量声明。

请注意,我们声明 Twig 模板为 demo.xhtml.twig,而不是之前创建的 hello.xhtml.twig 模板 ❶。我们现在将创建这个新模板,并设计它来展示 Twig 如何与对象和数组进行交互。复制 hello.xhtml.twig,将其重命名为 demo.xhtml.twig,并更新该新文件以匹配列表 21-9。

<!doctype html>
<html lang="en">
<head>
    <title>Twig examples</title>
</head>

<body>
❶ Hello {{name}}
<hr>
❷ for dinner you will have: {{meals.dinner}}
<hr>
the price of ❸ {{product.getDescription()}} is $ ❹ {{product.price}}

<hr>
❺ details about product: {{product}}

</body>
</html>

列表 21-9:demo.xhtml.twig 模板

在模板的主体部分,我们首先打印 Twig 变量 name 的值 ❶。然后,我们打印 meals 数组变量中 dinner 键的值 ❷。这里的 Twig 点表示法{{meals.dinner}}对应 PHP 表达式$meals['dinner']。

接下来,我们打印产品对象变量 ❸的 getDescription()方法返回的值。在这个例子中,Twig 的点表示法{{product.getDescription()}}对应 PHP 表达式\(product->getDescription()。我们还打印对象的 price 属性值 ❹。当 Twig 尝试访问这个 price 属性时,它会发现该属性是私有的,因此它会自动尝试调用对象的 getPrice()访问器方法。Twig 的点表示法{{product.price}}因此对应 PHP 表达式\)product->getPrice()。

最后,我们将 Twig 产品变量放入双大括号中,不使用任何点表示法 ❺。当 Twig 看到 product 是一个对象时,它会自动尝试调用它的 __toString()方法。这类似于 PHP 在预期字符串的上下文中使用对象引用时自动调用该对象的 __toString()方法。本质上,Twig 中的{{product}}对应于 PHP 中的 print \(product,进而对应于\)product->__toString()。图 21-2 展示了从这个模板渲染出的 HTML 在浏览器中的显示效果。

图 21-2:浏览器呈现的来自 Twig 演示模板的 HTML

正如你所看到的,Twig 成功地填充了所有从对象和数组传递到模板中的信息。图中的注释总结了用于访问每个信息的 Twig 表示法,以及在 PHP 中等效的表达式。

Twig 控制结构

除了打印单个值,Twig 模板语言还提供了若干控制结构,包括 if 和 for 语句,允许你向 Twig 模板添加条件逻辑和循环。这大大扩展了模板根据它们接收到的$args 数组中的数据进行适应的能力。

Twig 控制语句是写在单个大括号和百分号字符内的,例如{% if condition %}或{% for ... %},每个控制结构必须以结束标签结尾,例如{% endif %}或{% endfor %}。

Twig 可以使用{% for value in array %}来循环遍历数组中的所有值,类似于 PHP 中的 foreach 循环。如果你需要每个数组项的键和值,可以写成{% for key, value in array %}。现在,让我们尝试通过创建一个 Twig 模板,来遍历前面章节中创建的 PHP $meals 数组中的项。按照列表 21-10 中的示例更新 Twig 模板demo.xhtml.twig

<!doctype html>
<html lang="en">
<head>
 <title>Twig examples</title>
</head>

<body>
<ul>
  ❶ {% for key, value in meals %}
        <li>
          ❷ meal: {{key}} = {{value}}
        </li>
  ❸ {% else %}
        <li>
            (there are no meals to list)
        </li>
  ❹ {% endfor %}
</ul>
</body>
</html>

列表 21-10:循环输出 meals 作为 HTML 列表

我们声明一个 Twig for 循环来迭代餐点数组中的元素❶。数组中每个元素的键和值将成为 HTML 无序列表中的一个列表项,并以 HTML 形式输出,格式为

  • meal: lunch = salad
  • ❷。我们无需为列表中的每一项编写单独的 HTML 代码,只需使用 Twig 变量的键和值编写一个项,for 循环将为我们生成所有项。循环还包括一个 Twig else 语句❸,如果给定数组为空,则会执行该语句。在这种情况下,我们输出一条消息,说明列表中没有餐点。循环以闭合的 endfor 标签❹结束。图 21-3 展示了当你使用 Web 服务器提供此代码时呈现的网页。

    图 21-3:Twig for 循环生成的网页和 HTML 源代码

    Twig 的 for 循环成功地使用$meals 数组中的键和值生成了一个 HTML 列表。

    使用 Twig 创建多页面网站

    在本章的其余部分,我们将利用 Twig 模板引擎创建一个多页面网站。除了展示 Twig 模板引擎的价值外,构建这个网站还将展示我们在前几章使用的前端控制器结构是如何转化为面向对象的 Web 应用程序的。图 21-4 展示了我们将逐步开发的网站:这是我们在第十六章中创建的简化版两页网站,登录页面已被删除。

    图 21-4:使用 Twig 模板引擎创建的两页网站

    我们的网站将包括一个主页和一个联系我们页面,两个页面共享相同的头部和导航链接。所有公共的 HTML 都将在一个基础模板中声明,页面特定的内容模板将继承该模板。

    这意味着每个页面(子)模板将只包含该页面的特定内容。这还意味着网站中的每个页面都可以通过简单地更新基础模板来进行修改(例如,如果我们想要添加或更改导航链接、更改标志,或者在圣帕特里克节时将网站背景改为绿色)。

    文件结构和依赖关系

    首先,我们来建立应用程序的文件结构。创建一个新的项目文件夹。在其中,我们将构建以下目录和文件:

    有两个文件与本章之前示例中的文件完全相同:public/index.phpcomposer.json。正如你在清单 21-6 中看到的,index.php脚本仅仅读取自动加载器,创建一个 Application 类的对象,并调用该类的 run()方法。composer.json文件(清单 21-5)提供了自动加载器的信息以及项目的命名空间(Mattsmithdev)和第三方库要求(Twig)。将这两个文件复制到为项目创建的文件夹中。

    同时,复制本书随附文件中的public/images/logo.png图片,位于github.com/dr-matt-smith/php-crash-course(或者使用你自己的 logo 图片)。最后,由于我们将使用与之前相同的命名空间和 Twig 库,你也可以复制vendor文件夹,以获得相同的自动加载器和库文件。

    Application 类

    我们项目中的 Application 类充当了前端控制器的角色:它根据 URL 编码的导航动作来决定显示网站的哪个页面。如果 URL 没有 action 变量,Application 将显示主页。如果 Application 找到一个值为 contact 的 action 变量,它将显示“联系我们”页面。

    Application 类还负责创建一个 Twig\Environment 对象来管理 Twig 模板,因此,Application 类声明的前几行与我们之前在本章“Hello, world!”项目中使用的相同。从之前的项目中复制src/Application.php文件,并更新它以匹配清单 21-11。

    <?php
    namespace Mattsmithdev;
    
    use \Twig\Loader\FilesystemLoader;
    use \Twig\Environment;
    
    class Application
    {
     const PATH_TO_TEMPLATES = __DIR__ . '/../templates';
    
     private Environment $twig;
    
     public function __construct()
     {
     $loader = new FilesystemLoader(self::PATH_TO_TEMPLATES);
     $this->twig = new Environment($loader);
     }
    
      ❶ public function run(): void
        {
            $action = filter_input(INPUT_GET, 'action');
            switch ($action) {
                case 'contact':
                    $this->contactUs();
                    break;
    
                case 'home':
                default:
                    $this->homepage();
            }
        }
    
      ❷ private function homepage(): void
        {
            $template = 'homepage.xhtml.twig';
            $args = [
                'pageTitle' => 'Home Page'
            ];
    
            $html = $this->twig->render($template, $args);
            print $html;
        }
    
      ❸ private function contactUs(): void
        {
            $template = 'contactUs.xhtml.twig';
            $args = [
                'pageTitle' => 'Contact Us Page'
            ];
    
            $html = $this->twig->render($template, $args);
            print $html;
        }
    }
    

    清单 21-11:两页网站的 Application 类

    我们首先声明 run()方法❶,它替代了我们之前在index.php脚本中编写的前端控制器代码。该方法尝试找到名为 action 的 URL 编码变量,然后将其值传递给一个典型的前端控制器 switch 语句。如果值为'contact',则调用 contactUs()方法。否则,调用 homepage()方法。

    接下来,我们声明 homepage()方法❷。它输出运行 twig 属性(其中包含 Twig\Environment 对象的引用)中的 render()方法的结果。当我们调用 render()时,我们传入\(template,值为 'homepage.xhtml.twig',并传入\)args 数组,提供 Twig 的 pageTitle 变量,值为 'Home Page'。

    我们还声明了 contactUs()方法,用于显示“联系我们”页面❸。该方法类似地调用 render()并输出结果,这次传入\(template,值为 'contactUs.xhtml.twig',并传入\)args,提供 Twig 的 pageTitle 变量,值为 'Contact Us Page'。

    这两个方法,homepage()和 contactUs(),替代了我们之前在functions.php文件中编写的独立帮助函数。通过这种方式,我们的面向对象应用程序将所有显示逻辑封装在 Application 类中。

    Twig 模板

    现在,完成我们网站的唯一任务就是为这两页编写 Twig 模板文件。我们从主页开始。创建templates/homepage.xhtml.twig,并在其中编写清单 21-12 中的代码。

    <!doctype html>
    <html lang="en">
    <head>
      ❶ <title>MGW: {{pageTitle}}</title>
    </head>
    
    <body>
    <header>
      ❷ <img src="/images/logo.png" alt="logo" width="200px">
        <ul>
            <li>
              ❸ <a href="/">
                    Home
                </a>
            </li>
            <li>
              ❹ <a href="/?action=contact">
                    Contact Us
                </a>
            </li>
        </ul>
    </header>
    
    ❺ <blockquote>
        <p>
            <b>MGW. </b>
            <br>You know it makes sense!
        </p>
        <p>
            Welcome to My Great Website (MGW).
        </p>
    </blockquote>
    
    ❻ <h1>{{pageTitle}}</h1>
    
    ❼ <p>
        Welcome to the secure website demo.
    </p>
    
    </body>
    </html>
    

    清单 21-12:homepage.xhtml.twig 模板

    我们声明 HTML 标题,输出 MGW,然后是 Twig pageTitle 变量的内容 ❶。接着显示站点 logo 图像 ❷。然后呈现一个简单的导航列表,包含链接到主页 ❸ 和联系我们页面 ❹。接着使用

    元素展示网站的标语和问候语 ❺,随后是一个一级标题,重新使用 Twig pageTitle 变量 ❻。最后,声明特定于页面的内容;对于这个主页,它只是一个段落中的一句话 ❼。

    Listing 21-13 显示了 Contact Us Twig 模板中不同的页面部分。复制 homepage.xhtml.twig,将副本命名为 contactUs.xhtml.twig,并编辑此文件以匹配该列表。

    --snip--
     </p>
     <p>
     Welcome to My Great Website (MGW).
     </p>
    </blockquote>
    
    <h1>{{pageTitle}}</h1>
    
    ❶ <p>
        Contact us as follows:
    </p>
    
    <dl>
        <dt>Email</dt>
        <dd>inquiries@securitydemo.com</dd>
    
        <dt>Phone</dt>
        <dd>+123 22-333-4444</dd>
    
        <dt>Address</dt>
        <dd>1 Main Street,<br>Newtown,<br>Ireland</dd>
    </dl>
    </body>
    </html>
    

    Listing 21-13: contactUs.xhtml.twig 模板

    这个模板唯一与主页不同的内容是在 HTML body 末尾的段落和定义列表 ❶。由于使用了 Twig 的 pageTitle 变量,其它部分模板完全相同。Twig 会根据需要填充变量,主页或联系我们页面。

    此时,网站已经具备了显示和导航这两页的所有内容。如果你运行项目,你会看到类似 Figure 21-5 的内容。

    Figure 21-5: 使用 Twig 构建的简单两页网站

    请注意,Twig 已正确填写了每个页面的 pageTitle 变量的值。

    Twig 特性以提高效率

    我们的两页网站现在按照预期工作,但两个 Twig 模板文件中仍然有很多重复的代码。在本节中,我们将探索提高模板效率的技术,例如 include 语句和模板继承。这些特性使得 Twig 在开发多页 web 应用程序时特别有用。

    include 语句

    如你所见,网站中的页面通常共享大部分相同的 HTML 代码。Twig 的 include 语句使得创建包含共享代码的部分模板成为可能,并将这些部分模板的渲染输出添加到需要它们的实际页面模板中。这些 include 语句的格式是 {{include(templateName)}}。

    为了演示,我们将把每个页面模板顶部的所有共享内容放入一个公共的 _header.xhtml.twig 文件中。(记住,像这样的部分模板通常以下划线为前缀。)然后,我们将使用 include 语句将这个部分模板添加到页面模板文件的顶部,这样我们就可以简化模板,只包含每个页面独有的内容。

    创建 templates/_header.xhtml.twig 并将页面模板文件顶部的代码复制进去,如 Listing 21-14 所示。

    <!doctype html>
    <html lang="en">
    <head>
        <title>MGW: {{pageTitle}}</title>
    </head>
    
    --snip--
        <p>
            Welcome to My Great Website (MGW).
        </p>
    </blockquote>
    
    <h1>{{pageTitle}}</h1>
    

    Listing 21-14: 部分 _header.xhtml.twig 模板

    从页面的第一级标题开始(同样使用 Twig 的 pageTitle 变量),所有内容都已被移入这个header.xhtml.twig 部分模板。这样,我们可以大大减少首页和“联系我们”模板中的内容。列表 21-15 显示了更新后的首页 Twig 模板,将所有重复的内容替换为简单的 Twig include 语句。按此列出的内容更新 templates/homepage.xhtml.twig

    {{include('_header.xhtml.twig')}}
    
    <p>
     Welcome to the secure website demo.
    </p>
    
    </body>
    </html>
    

    列表 21-15:简化版首页模板 homepage.xhtml.twig

    我们从 Twig include 语句 {{include('_header.xhtml.twig')}} 开始,告诉 Twig 读取部分模板文件 _header.xhtml.twig。模板中剩下的只是页面特定的内容。

    我们可以类似地从“联系我们”模板中移除重复内容。按列表 21-16 所示更新templates/contactUs.xhtml.twig

    {{include('_header.xhtml.twig')}}
    <p>
     Contact us as follows:
    </p>
    
    <dl>
     <dt>Email</dt>
     <dd>inquiries@securitydemo.com</dd>
    
     <dt>Phone</dt>
     <dd>+123 22-333-4444</dd>
    
     <dt>Address</dt>
     <dd>1 Main Street,<br>Newtown,<br>Ireland</dd>
    </dl>
    
    </body>
    </html>
    

    列表 21-16:简化版联系我们模板 contactUs.xhtml.twig

    再次,我们移除了重复的内容,并将其替换为 Twig include 语句。接下来是页面特定的内容。

    使用 Twig include 语句,我们已经大大简化了各个页面模板。然而,注意到我们仍然保留了在两个模板中共享的最终 和 标签。理论上,我们可以将这些标签移动到一个部分模板 _footer.xhtml.twig 中。对于一个简单的网站,这可能是一个合理的做法,但对于更复杂的页面和大型网站,Twig 提供了一个比 include 语句更强大的功能来合并冗余内容:模板继承。

    模板继承

    模板继承 是指创建一个基础模板,其中包含一组网页共享的所有内容,然后创建单独的子模板,通过填充或覆盖特定页面独有的内容来扩展基础模板。这就像面向对象编程中创建子类,继承、扩展并重写父类的某些行为。

    基础模板确保所有网站页面都具备所需的有效、格式正确的 HTML,包括结束标签,从而使子模板能够专注于其特定页面内容。如你所见,这种继承方法比使用 include 语句来合并部分模板要简洁得多。

    为了使用模板继承,我们首先将 templates/_header.xhtml.twig 文件转换为其他模板可以继承并扩展的基础模板。将 _header.xhtml.twig 重命名为 base.xhtml.twig,并按列表 21-17 所示进行编辑。

    <!doctype html>
    <html lang="en">
    <head>
     <title>MGW: {{pageTitle}}</title>
    </head>
    
    --snip--
     <p>
     Welcome to My Great Website (MGW).
     </p>
    </blockquote>
    
    <h1>{{pageTitle}}</h1>
    
    {% block main %}
    {% endblock %}
    
    </body>
    </html>
    

    列表 21-17:基础模板 base.xhtml.twig

    模板继承的关键在于在基础模板中使用 Twig 语句来划定代码块,这些代码块将在每个子页面模板中被填充或覆盖。在这个例子中,我们定义了一个名为 main 的代码块。这就是每个页面的独特内容将要放置的位置。然而,在我们的基础模板中,这个代码块是空的,因此 blockendblock 语句之间没有内容。Twig 的代码块有名字(在本例中是 main),这样子模板就可以指定哪些代码块(如果有的话)需要用页面特定的内容进行覆盖。

    请注意,在这种继承方式中,我们的基础模板包含了一个完整的网页;也就是说,它不是一个部分模板。特别是,基础模板包含了闭合的 </body></html> 标签。一个 Twig 基础模板本身就是一个完整的 HTML 页面,尽管它可能有一些默认或空的代码块,旨在被子模板覆盖。

    我们现在可以更新我们的首页和联系我们模板,让它们继承自基础模板,并用各自的页面内容覆盖 main 块。首先,更新 templates/homepage.xhtml.twig 以匹配 列表 21-18。

    {% extends 'base.xhtml.twig' %}
    
    {% block main %}
    <p>
     Welcome to the secure website demo.
    </p>
    {% endblock %}
    

    列表 21-18: homepage.xhtml.twig 模板,继承自基础模板

    我们声明这个模板继承自(即从...继承) base.xhtml.twig。然后我们在 main 块内嵌入页面特定的段落,覆盖基础模板中该块的空内容。最后,我们使用 endblock 语句结束,以便 Twig 知道覆盖内容何时结束。endblock 语句非常重要,因为在更复杂的页面中,我们可能会在子页面模板中覆盖两个或更多的代码块。注意,在文件末尾我们不再有闭合的 HTML 标签,因为这些标签已转移到基础模板中。

    接下来,我们需要对联系我们页面做相同的修改。更新 templates/contactUs.xhtml.twig 以匹配 列表 21-19。

    {% extends 'base.xhtml.twig' %}
    
    {% block main %}
    <p>
     Contact us as follows:
    </p>
    
    <dl>
     <dt>Email</dt>
     <dd>inquiries@securitydemo.com</dd>
    
     <dt>Phone</dt>
     <dd>+123 22-333-4444</dd>
    
     <dt>Address</dt>
     <dd>1 Main Street,<br>Newtown,<br>Ireland</dd>
    </dl>
    
    {% endblock %}
    

    列表 21-19: contactUs.xhtml.twig 模板,继承自基础模板

    再次使用 extends,这样该模板将继承自 base.xhtml.twig,并用页面特定的内容覆盖 main 块。和之前一样,我们用 endblock 语句结束 main 块。

    使用代码块代替变量

    对于这个简单的静态两页网站,我们不应该需要通过 $args 数组向 Twig 模板传递任何变量。当我们调用 render() 方法时,目前我们的控制器方法将页面标题作为一个名为 pageTitle 的 Twig 变量传递。然而,我们可以将页面标题设置为基础模板中的一个代码块,并在每个子模板中覆盖该块,使用适当的文本。

    让我们移除在我们的 Application 类中由控制器方法传递的 pageTitle 变量。更新 src/Application.php 以匹配 列表 21-20。

    --snip--
     private function homepage(): void
     {
     $template = 'homepage.xhtml.twig';
            $args = [];
    
     $html = $this->twig->render($template, $args);
     print $html;
     }
    
     private function contactUs(): void
     {
     $template = 'contactUs.xhtml.twig';
            $args = [];
    
     $html = $this->twig->render($template, $args);
     print $html;
     }
    }
    

    列表 21-20: 在 Application 类中传递一个空的 $args 数组

    我们在 homepage() 和 contactUs() 方法中将 $args 声明为空数组。虽然我们可以直接将空数组作为 render() 的第二个参数传递,但首先将数组声明为变量有助于澄清是否有任何变量被传递给 Twig 模板。

    我们现在必须更新基础模板,声明一个页面标题块,而不是直接输出 Twig 变量的内容。更新 templates/base.xhtml.twig 文件,参见列表 21-21。

    <!doctype html>
    <html lang="en">
    <head>
        <title>MGW: {% block pageTitle %}{% endblock %}</title>
    </head>
    
    --snip--
     <p>
     Welcome to My Great Website (MGW).
     </p>
    </blockquote>
    
    ❶ <h1>{{block('pageTitle')}}</h1>
    
    {% block main %}
    {% endblock %}
    
    </body>
    </html>
    

    列表 21-21:向 base.xhtml.twig 模板添加页面标题块

    我们声明一个新的 pageTitle Twig 块,其内容将成为 HTML 元素的一部分。我们还需要稍后重复这个块的内容,作为一级标题 ❶。然而,我们不允许声明第二个同名的块。相反,我们通过使用 Twig 的 block() 函数来输出该块的内容,block() 函数接受一个参数,指示要输出的块的名称。我们需要将此函数调用括在双大括号中,就像其他 Twig 表达式一样。</p> <p>剩下的工作是更新每个子页面,声明一个包含适当页面名称的 pageTitle 块,覆盖基础模板中的默认空 pageTitle 块。更新 <em>templates/homepage.xhtml.twig</em> 文件,以匹配列表 21-22。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block pageTitle %}Home Page{% endblock %} {% block main %} <p> Welcome to the secure website demo. </p> {% endblock %} </code></pre> <p>列表 21-22:在 homepage.xhtml.twig 模板中声明页面标题块</p> <p>我们声明一个包含主页内容的 pageTitle 块。仅此一条声明便足以填充 base 模板中两个位置的页面标题。以同样的方式更新 <em>templates/contactUs.xhtml.twig</em> 文件,声明一个包含联系我们页面内容的 pageTitle 块。</p> <p>当您现在加载网站时,应该会发现页面没有任何变化。然而,我们通过使用模板继承使代码变得更加高效。</p> <h4 id="使用-css-改进页面样式">使用 CSS 改进页面样式</h4> <p>我们的网站已经可以正常工作,模板代码高效且结构清晰,但页面本身看起来并不吸引人。我们将通过引入一些 CSS 来完善网站的设计,使其看起来更加精致。由于所有网站的通用内容都被限制在单一的 <em>base.xhtml.twig</em> 模板文件中,您会发现,Twig 使得更新网站外观的过程变得非常简单。</p> <h5 id="高亮当前导航链接">高亮当前导航链接</h5> <p>高亮当前页面的导航栏链接是一种常见的方式,用来告知用户他们正在查看哪个页面。在第十六章中,我们使用了 PHP 变量来实现这一点。现在您将看到,Twig 模板继承使得这个过程变得更简单。在基础模板中,我们将为导航列表中每个链接元素的 class 属性内容声明一个独特名称的 Twig 块。然后,在子模板中,我们将重写相应的 Twig 块,将当前页面链接的 class 属性设置为 active。我们将使用 CSS 将激活的链接与其他链接区分开,使用不同的颜色进行样式化。</p> <p>首先,更新 <em>base.xhtml.twig</em> 文件,以匹配列表 21-23。</p> <pre><code><!doctype html> <html lang="en"> <head> <title>MGW: {% block pageTitle %}{% endblock %}</title> <style>@import '/css/style.css'</style> </head> <body> <header> <img src="/images/logo.png" alt="logo" width="200px"> <ul> <li> <a href="/" class="{% block homeLink %}{% endblock %}"> Home </a> </li> <li> <a href="/?action=contact" class="{% block contactLink %}{% endblock %}"> Contact Us </a> </ul> </header> --snip-- </code></pre> <p>列表 21-23:带有导航链接块的 base.xhtml.twig 模板</p> <p>我们添加一个样式导入声明,以便网站的所有页面都能使用 <em>public/css/style.css</em> 中声明的 CSS 样式(我们稍后会创建该文件)。然后,我们声明一个 homeLink Twig 块作为主页链接的类属性内容。该块为空,因此如果没有被覆盖,链接将不会分配任何类。同样,我们声明一个 contactLink Twig 块,作为“联系我们”链接的类属性内容。</p> <p>现在我们需要让子模板覆盖这些块。按照 列表 21-24 所示更新 <em>homepage.xhtml.twig</em> 模板文件。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block pageTitle %}Home Page{% endblock %} {% block homeLink %}active{% endblock %} {% block main %} <p> Welcome to the secure website demo. </p> {% endblock %} </code></pre> <p>列表 21-24:在 homepage.xhtml.twig 中声明一个 homeLink 块</p> <p>我们声明 homeLink 块具有活动内容,从而将其分配给一个 CSS 类,以便将该页面的链接高亮显示为与默认导航链接不同的颜色。列表 21-25 展示了如何以相同的方式更新 <em>contactUs.xhtml.twig</em> 模板文件。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block pageTitle %}Contact Us Page{% endblock %} {% block contactLink %}active{% endblock %} {% block main %} <p> Contact us as follows: </p> --snip-- </code></pre> <p>列表 21-25:在 contactUs.xhtml.twig 中声明一个 contactLink 块</p> <p>我们声明 contactLink 块并赋予其活动内容,这样当用户访问页面时,它将再次突出显示页面的链接。</p> <p>最后,我们需要声明一些简单的 CSS 规则。我们将页面头部(包含导航列表)设置为深色背景,并定义链接的默认颜色和活动颜色。为项目创建一个新的 <em>public/css</em> 文件夹,然后在其中创建 <em>style.css</em> 文件,并输入 列表 21-26 的内容。</p> <pre><code>header { background-color: rebeccapurple; } ❶ a { color: gray; text-decoration: none; } ❷ a.active { color: white; } ❸ header ul { display: inline-block; } </code></pre> <p>列表 21-26:style.css 样式表</p> <p>我们将所有 <a> 元素的默认颜色设置为灰色 ❶,而任何具有 active 类属性的 <a> 元素则会显示为白色 ❷。最后,我们在头部元素中声明无序列表显示为 inline-block ❸,这样导航项就会与头部的 logo 图像在同一行显示。图 21-6 展示了我们网站更新后的主页,以及该页面的 HTML 源代码。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure21-6.jpg" alt="" loading="lazy"></p> <p>图 21-6:主页及其在 HTML 源代码中对应的活动 CSS 类</p> <p>在 HTML 中,可以注意到 homeLink 块中的活动内容出现在导航栏的主页链接的类元素中。因此,主页链接显示为白色,以指示这是当前正在查看的页面。</p> <h5 id="使用-bootstrap-美化网站">使用 Bootstrap 美化网站</h5> <p>与其自己编写 CSS 来实现更专业且响应式的页面布局,不如再次利用强大的 Bootstrap CSS 框架来为我们完成大部分工作。Twig 使得集成 Bootstrap 样式变得简单。我们所需要做的只是对基础模板进行一些修改,这些修改将影响网站的每个页面。我们不需要修改任何子页面模板。</p> <p>我们将让 Bootstrap 来样式化我们的导航链接,并使用 Bootstrap 提供的预定义颜色,因此可以完全删除文件夹和文件 <em>css/style.css</em>。接着,我们只需修改网站的基础模板。编辑 <em>base.xhtml.twig</em> 以匹配清单 21-27 的内容。</p> <pre><code><!doctype HTML> <html> <head> <title>MGW: {% block pageTitle %}{% endblock %}</title> <meta name="viewport" content="width=device-width"> ❶ <link rel="stylesheet" href=https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css> ❷ </head> <body class="container"> ❸ <header class="navbar navbar-expand navbar-dark bg-dark"> ❹ <img src="/images/logo.png" alt="logo" width="200px"> <ul class="navbar-nav"> ❺ <li class="nav-item"> <a class="nav-link {% block homeLink %}{% endblock %}" href="/"> Home </a> </li> <li class="nav-item"> <a class="nav-link {% block contactLink %}{% endblock %}" href="/?action=contact"> Contact Us </a> </li> </ul> </header> <div class="row bg-light p-5 mb-4"> ❻ <div class="col display-6"> <span class="fw-bold">MGW. </span> <br>You know it makes sense! </div> <div class="col"> <p> Welcome to My Great Website (MGW). </p> </div> </div> <h1>{{block('pageTitle')}}</h1> --snip-- </code></pre> <p>清单 21-27:使用 Bootstrap 更新 base.xhtml.twig 模板</p> <p>我们添加了一个 meta 元素,以防页面内容在移动设备上查看时显示得过小 ❶。接着,我们加载了 Bootstrap 样式表 ❷,并将整个 HTML 页面主体作为 Bootstrap 容器 ❸。这样,在 Bootstrap 确定了页面内容的最大宽度后,它会在页面的左右边距添加基本的间距,以适应 Web 客户端视口的尺寸。</p> <p>我们为包含 logo 和导航栏的 header 元素分配了几个 Bootstrap 类,以将该元素渲染为暗模式下的导航栏,并使用预定义的 bg-dark 背景色 ❹。包含导航链接的无序列表则应用了 navbar-nav 样式 ❺,使链接看起来更加专业。我们将每个链接的列表项样式化为 nav-item,将其锚链接元素样式化为 nav-link。请注意,Twig 中的 homeLink 和 contactLink 块仍然作为链接类属性的一部分,和 nav-link 类一起出现。这样,当前显示页面的链接元素将同时应用 nav-link 和 active 样式,Bootstrap 会相应地高亮该链接。</p> <p>通过结合使用 Bootstrap 工具类,我们实现了 header 的背景色、间距和多列布局。我们用一个样式化为具有浅色背景(bg-light)、四个边都有大量填充(p-5)且底部有中等间距(mb-4)的行(row)的 <div> 元素替换了 header 中原有的 <blockquote> 元素 ❻。该 header <div> 包含一个作为列(col display-6)样式化的 <div>,用于显示主标题和标语,这将出现在另一个 <div> 的左侧,该 <div> 用于显示站点的问候语。</p> <p>通过添加 Bootstrap 样式,我们的网站现在拥有了如之前在图 21-4 所示的专业外观和感觉。我们只需更改基础模板文件,就能实现这种样式,而无需触及任何子页面模板。</p> <h3 id="总结-13">总结</h3> <p>在本章中,你学习了 Twig 模板包的基础知识,它极大简化了创建通用 HTML 模板的过程,这些模板可以通过页面特定的内容进行自定义。使用 Twig,我们创建了一个多页面网站,由一个应用类的 run() 方法驱动,充当前端控制器。我们的网站应用程序中唯一不是面向对象的部分是 <em>public/index.php</em> 脚本中的代码,它读取并执行 Composer 自动加载器,创建一个 Application 对象,并调用它的 run() 方法。</p> <p>多亏了 Twig,我们可以安全地将创建和修改页面模板的责任交给无需了解 PHP 编程的团队成员。通过使用 Twig 的继承和可覆盖区块功能,每个页面的模板都很小,并专注于该页面特定的内容。我们利用了 Twig 强大的继承功能,使我们能够通过在顶级基本模板中的声明,添加诸如专业的 Bootstrap 样式和活动链接高亮等功能。总体来说,使用像 Twig 这样的模板系统意味着我们强烈地将 Web 应用程序的视图组件与其控制器和模型分离:Twig 模板仅负责装饰提供的数据,使用 HTML 创建响应正文,以返回给请求的 Web 客户端。</p> <h3 id="练习-19">练习</h3> <p>1.   创建一个项目,包含一个脚本<em>public/index.php</em>,该脚本返回一个完整且结构良好的 HTML 页面正文,正文包含一个段落,内容为“Hello name”,其中 name 是一个 URL 编码的变量。然后按照以下顺序逐步重构项目:</p> <p>a.   将 HTML 移到<em>templates/hello.php</em>,并在<em>public/index.php</em>中编写前端控制器 PHP 代码来显示此模板。</p> <p>b.   将前端控制器逻辑移动到一个命名空间为“Application”的类中的 run()方法。run()方法应提取 URL 编码的 name 变量,并将其传递给 hello()方法,后者显示<em>templates/hello.php</em>模板。你还需要为类的命名空间创建一个<em>composer.json</em>文件,生成 Composer 自动加载器,并更新<em>public/index.php</em>来加载自动加载器,创建一个 Application 对象,并调用 run()方法。</p> <p>c.   将<em>templates/hello.php</em>文件转换为一个名为<em>templates/hello.xhtml.twig</em>的 Twig 模板,并更新 Application 类,在其构造函数中创建一个 twig 属性。在 hello()方法中使用此属性来创建并打印一个$html 变量,用于返回给 Web 客户端的请求正文。</p> <p>2.   复制练习 1 中的项目,并通过以下步骤逐步将其转变为一个两页的网站:</p> <p>a.   将 HTML 结构的核心部分分离到<em>base.xhtml.twig</em>模板中,然后重构<em>hello.xhtml.twig</em>,使其扩展此基本模板,并用“hello”消息覆盖其主体区块。</p> <p>b.   创建第二个页面模板<em>privacy.xhtml.twig</em>,该模板同样扩展基本模板,并显示以下句子:<em>该网站不存储任何 Cookies,因此不会以任何方式影响您的浏览隐私。</em></p> <p>c.   在<em>hello.xhtml.twig</em>模板中添加一个页脚,内容为“隐私政策”,并链接到 URL<em>/?action=privacy</em>。</p> <p>d.   向 Application 类添加一个 privacy()方法。该方法应显示<em>privacy.xhtml.twig</em>模板。</p> <p>更新 Application 类的 run() 方法中的逻辑,以便 URL 编码的 action 变量的值(如果找到)存储在 $action 变量中。然后,添加一个 switch 语句,如果 $action 的值为 privacy,则调用 privacy() 方法;否则,调用 hello() 方法。</p> <p>创建一个面向对象、基于继承、使用 Twig 模板的三页网站,包括主页、员工详情页和隐私政策页。页面应包含 Bootstrap CSS 和一个三项导航栏,其中正在显示的页面的导航栏项应通过使用 active CSS 类来高亮显示。员工详情页应使用 Twig 的 for 循环来显示一个包含三名员工的 HTML 表格,数据来源于提供的 Staff 对象数组。Staff 类应具有 name 和 jobTitle 属性。</p> <h2 id="第二十二章22-面向对象的-web-应用程序结构">第二十二章:22 面向对象的 Web 应用程序结构</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>在前一章中,我们使用面向对象的 PHP 代码创建了一个由 Application 类控制的简单两页网站。在本章中,我们将重新访问该网站,并探讨如何进一步利用 OOP 技术来改进其结构。你将学习如何使用多个类来划分应用程序逻辑,并看到继承如何帮助这些类之间共享代码,从而减少冗余。</p> <p>将应用程序逻辑划分到多个类中将有助于使站点更易于管理。这对于一个包含两页的网站来说可能看起来微不足道,但想象一下,如果该网站扩展到包含数十、数百甚至数千页,Application 类很快就会变得不堪重负。此时,必须将代码组织成不同类型的操作,并将这些操作分配给不同的类。</p> <p>对于我们在第二十一章中的应用程序,需要执行两种主要类型的操作。第一种是在请求进入 Web 服务器时决定做什么。我们可以将此任务分配给一个前端控制器类,该类将检查每个传入的请求,包括其 URL 模式和收到的任何数据变量,并决定返回给 Web 客户端的页面类型。</p> <p>另一个主要操作是显示请求的页面。我们可以将此任务分配给一系列页面生成控制器类。例如,一个类可以设计用于显示基本页面(如主页和联系我们),另一个用于显示带有安全功能的页面,如登录和更新密码,另一个用于显示产品列表,等等。每个页面控制器类可以在前端控制器已经做出返回适当页面的决策后,继续工作。</p> <h3 id="分离显示和前端控制器逻辑">分离显示和前端控制器逻辑</h3> <p>我们通过将前端控制器决策逻辑(在 Application 类中)与主页和联系我们页面的基本页面生成操作分离,开始改进我们的应用架构。我们将后者移到一个名为 DefaultController 的新类中。这个名字反映了当请求 URL 模式 / 时,主页是默认显示的页面,但该类也可以合理地命名为 BasicPageController、HomePageController 或类似名称。</p> <p>复制<em>src/Application.php</em>,将副本命名为<em>src/DefaultController.php</em>,并从这个新 DefaultController 类中删除 run() 方法。还需要将 homepage() 和 contactUs() 方法设为 public,以便它们仍然可以从 Application 类中调用。经过这些更改后,文件应该与清单 22-1 一致。</p> <pre><code><?php namespace Mattsmithdev; use \Twig\Loader\FilesystemLoader; use \Twig\Environment; class DefaultController { const PATH_TO_TEMPLATES = __DIR__ . '/../templates'; private Environment $twig; public function __construct() { $loader = new FilesystemLoader(self::PATH_TO_TEMPLATES); $this->twig = new Environment($loader); } public function homepage() { $template = 'homepage.xhtml.twig'; $args = []; $html = $this->twig->render($template, $args); print $html; } public function contactUs() { $template = 'contactUs.xhtml.twig'; $args = []; $html = $this->twig->render($template, $args); print $html; } } </code></pre> <p>清单 22-1:声明 DefaultController 类</p> <p>这个新的 DefaultController 类有一个常量用于模板文件的路径,一个用于渲染模板的 twig 属性,一个构造方法,以及用于显示 Web 应用程序两个页面的 homepage() 和 contactUs() 方法。</p> <p>现在我们已经将显示网页的逻辑封装到一个单独的类中,我们可以简化 Application 类,让它仅专注于决定显示哪个页面。我们只需要在 Application 中保留 run() 方法,它将决定显示哪个页面并调用相应的 DefaultController 方法。按照 清单 22-2 更新 <em>src/Application.php</em>。</p> <pre><code><?php namespace Mattsmithdev; class Application { public function run(): void { $defaultController = new DefaultController(); $action = filter_input(INPUT_GET, 'action'); switch ($action) { case 'contact': ❶ $defaultController->contactUs(); break; case 'home': default: ❷ $defaultController->homepage(); } } } </code></pre> <p>清单 22-2:简化的 Application 类</p> <p>我们更新后的 Application 类唯一的内容,即 run() 方法,首先创建一个新的 DefaultController 对象。然后,在 switch 语句中,我们根据 HTTP 请求中收到的动作,调用该对象的 contactUs() 方法❶或 homepage() 方法❷来显示相应的页面。</p> <p>在这种新安排中,Application 充当了真正的前端控制器:它接收来自客户端的请求并决定如何响应。与此同时,生成和打印响应的代码已被委托给 DefaultController 类。对于我们这个简单的两页网站来说,这看起来可能是过度设计,但对于更复杂的网站,这种将前端控制器逻辑与页面生成逻辑分离的做法意味着,当我们为多个页面添加方法时,我们就不会遇到单一的、过于拥挤的 Application 类去做太多的事情。</p> <p>例如,假设我们有一些页面只能由已登录的用户访问。我们可以将显示这些页面的方法封装在一个 SecureActions 控制器类中。然后,我们会在前端控制器 Application 类中检查用户是否已登录,只有当用户已登录时才调用 SecureActions 的方法。否则,我们可以根据需要向用户提供一个错误页面或登录页面。</p> <p>将前端控制器动作与页面控制器分离的另一个好处是测试传入 URL 模式中的数据参数。假设我们网站的一些页面通过使用 NewsItem 页面控制器类来显示新闻项。该类的方法需要从数据库或文件存储中检索新闻项的 ID,这取决于 URL 模式,如 <em>/?action=news&id=<id></em>。在这种情况下,我们的前端控制器可以检查 URL 中的整数 ID 和新闻动作,然后将该 ID 传递给相应的 NewsItem 对象方法。如果 URL 中没有找到这样的整数 ID,我们可以向用户提供一个错误页面。</p> <p>在这两个例子中,页面控制器类中的方法可以在已知任何必要的检查和决策(如判断用户是否已登录或检索新闻项 ID)已经完成并得到满足的情况下编写。我们正在将 <em>做什么</em>(前端控制器)的决策与定义 <em>如何做</em>(页面控制器类方法)的动作分离开来。</p> <h3 id="使用多个控制器类">使用多个控制器类</h3> <p>我们的 DefaultController 类适用于显示像首页这样包含静态内容的简单页面,但具有其他功能的页面会受益于组织在自己的专门控制器类中。例如,一个电子商务网站可能会有几种与产品相关的页面:列出所有可用产品的页面、显示与特定用户查询匹配的产品的搜索结果页面、展示单一产品详情的页面,等等。每个页面可能需要一种与 Product 类的对象交互的方法,可能是将这些对象作为 $products 数组或单个 $product 对象传递到页面模板中。</p> <p>我们的 DefaultController 类当前无法处理这些与产品相关的操作。我们可以扩展和修改该类,但更合逻辑的做法是创建一个单独的 ProductController 类来处理显示与产品相关页面所需的专门操作。同样,包含登录表单的页面可能有自己的 LoginController 类,显示和编辑购物车的页面可能有自己的 CartController 类,等等。</p> <p>为了展示多个控制器类的好处,并演示如何轻松地为面向对象的 Web 应用程序添加更多页面和部分,我们将在我们的网站上添加一个产品列表页面,如图 22-1 所示,并且我们将创建一个 ProductController 类来展示这个页面。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure22-1.jpg" alt="" loading="lazy"></p> <p>图 22-1:我们将创建的产品列表页面</p> <p>我们的新页面将显示一组产品的名称和价格,其中每个产品都是 Product 类的一个实例(对象)。通过使用 Twig 模板继承,我们将为页面提供与网站其他页面相同的导航栏和头部内容。我们将通过我们新的 ProductController 类来协调页面的显示,该类将专门用于收集可以传递到 Twig 模板中的 Product 对象数组。</p> <p>为了构建新页面,我们将首先创建 Product 类来表示每个产品的名称和价格。创建一个新文件,<em>src/Product.php</em>,其中包含清单 22-3 中的代码。</p> <pre><code><?php namespace Mattsmithdev; class Product { public string $name; public float $price; ❶ public function __construct(string $name, float $price) { $this->name = $name; $this->price = $price; } } </code></pre> <p>清单 22-3:Product 类</p> <p>我们为每个 Product 对象声明了两个公共属性:name 和 price。然后,我们声明了一个构造方法 ❶,该方法将在创建新 Product 对象时接受这两个属性的初始值。</p> <p>现在我们已经有了 Product 类,可以创建 ProductController 类来显示页面。创建一个新的 <em>src/ProductController.php</em> 文件,如清单 22-4 所示。</p> <pre><code><?php namespace Mattsmithdev; use \Twig\Loader\FilesystemLoader; use \Twig\Environment; class ProductController { const PATH_TO_TEMPLATES = __DIR__ . '/../templates'; private Environment $twig; public function __construct() { $loader = new FilesystemLoader(self::PATH_TO_TEMPLATES); $this->twig = new Environment($loader); } public function productList() { $product1 = new Product('Hammer', 9.99); $product2 = new Product('Bag of nails', 6.00); $product3 = new Product('Bucket', 2.00); ❶ $products = [$product1, $product2, $product3]; $template = 'productList.xhtml.twig'; $args = [ ❷ 'products' => $products ]; ❸ $html = $this->twig->render($template, $args); print $html; } } </code></pre> <p>清单 22-4:声明 ProductController 类的 src/ProductController.php 文件</p> <p>ProductController 类的构造方法类似于 DefaultController 类的构造方法:它执行与 Twig 模板协作所需的设置。该控制器与其他控制器的不同之处在于它具有用于显示新产品列表页面的 productList() 方法。</p> <p>在该方法中,我们创建了三个 Product 对象,并将它们打包成 $products 数组 ❶。然后,我们将 $template 变量设置为 'productList.xhtml.twig',这是我们将创建的新 Twig 模板文件,用来列出所有产品。接着,我们构建了 $args 数组。它将 'products' 键(将成为一个 Twig 变量名)映射到 $products,这个包含 Product 对象的数组 ❷。然后,我们将 $template 和 $args 变量传递给 Twig,生成页面所需的 HTML 代码 ❸。</p> <p>接下来,我们需要更新 Application 类中的前端控制器逻辑,当 URL 中的 action 值为 products 时,调用 ProductController 类的 productList() 方法。更新 <em>src/Application.php</em>,使其与清单 22-5 相匹配。</p> <pre><code><?php namespace Mattsmithdev; class Application { public function run(): void { $defaultController = new DefaultController(); ❶ $productController = new ProductController(); $action = filter_input(INPUT_GET, 'action'); switch ($action) { ❷ case 'products': $productController->productList(); break; case 'contact': $defaultController->contactUs(); break; case 'home': default: $defaultController->homepage(); } } } </code></pre> <p>清单 22-5:更新 Application 类以处理产品案例</p> <p>在 run() 方法中,我们创建了 $productController 变量,它引用了一个新的 ProductController 对象 ❶。然后我们向 switch 语句中添加了一个新的 case ❷。当 URL 中的 action 值为 products 时,我们将向 ProductController 对象发送消息,调用它的 productList() 方法。</p> <p>现在我们可以编写 Twig 模板,循环并显示提供的产品数组。创建新的 Twig 模板文件 <em>templates/productList.xhtml.twig</em>,如清单 22-6 所示。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block pageTitle %}Product List{% endblock %} {% block productsLink %}active{% endblock %} {% block main %} <p> Here is a list of our products. </p> <dl class="container bg-light"> ❶ {% for product in products %} <dt>{{product.name}}</dt> <dd> $ {{product.price | number_format(2)}}</dd> {% else %} <dt>(sorry, there are no products to list)</dt> {% endfor %} </dl> {% endblock %} </code></pre> <p>清单 22-6:productList.xhtml.twig 模板</p> <p>像我们其他的页面模板一样,这个模板继承自 <em>base.xhtml.twig</em>,从而使其能够访问所有页面共享的内容。因此,我们可以专注于只填充基模板中需要自定义的块。首先,我们重写 pageTitle Twig 块,设置为 "Product List"。然后,我们重写 productsLink Twig 块,将其文本设置为 active,以在导航栏中突出显示该页面的链接(接下来我们将在基模板中添加一个新的导航栏链接)。</p> <p>接下来,我们重写了主要的 Twig 块,以填充页面特定的主体内容。该内容的核心是一个循环,通过所有的 Product 对象,遍历 products Twig 数组变量,从而生成 HTML 定义列表项 ❶。每个产品的名称作为定义项(<dt>)声明,定义数据元素(<dd>)是该产品的价格,并使用 Twig 的 number_format 过滤器将其格式化为两位小数。如果 products 数组为空,则 Twig else 语句将显示相应的消息。</p> <p>我们使 Product List 页面正常工作的最后一个步骤是,在基模板的导航栏中添加一个新的项目。更新 <em>templates/base.xhtml.twig</em>,使其与清单 22-7 相匹配。</p> <pre><code>--snip-- <body class="container"> <header class="navbar navbar-expand navbar-dark bg-dark"> <img src="/images/logo.png" alt="logo" width="200px"> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link {% block homeLink %}{% endblock %}" href="/"> Home </a> </li> <li class="nav-item"> <a class="nav-link {% block contactLink %}{% endblock %}" href="/?action=contact"> Contact Us </a> </li> <li class="nav-item"> <a class= "nav-link {% block productsLink %}{% endblock %}" href="/?action=products" > Product List </a> </li> </ul> </header> --snip-- </code></pre> <p>清单 22-7:将产品列表链接添加到 base.xhtml.twig 模板</p> <p>我们在导航栏中为 Product List 页面添加了第三个项目。与其他链接一样,我们包含了一个 class 属性,里面有一个名为 productsLink 的 Twig 块,以便在需要时为该链接添加 active 样式。</p> <p>我们现在已在网站上添加了一个产品列表页面。在新的 ProductController 类中,我们的 productList() 方法创建了一个对象数组,并使用 Twig 模板 <em>templates/productList.xhtml.twig</em> 来生成页面的 HTML。向我们的基础 Twig 模板添加一个新的导航链接非常简单。点击该链接会创建一个包含 action=products 的 GET 请求。在我们的 Application 类的前端控制器中,创建了一个 ProductController 的实例,这样当请求 URL 中找到该 action 的值时,就可以调用 productList() 方法。总的来说,产品列表功能的新代码大部分都很好地组织在它自己的控制器类和相应的 Twig 模板中。</p> <h3 id="通过继承共享控制器功能">通过继承共享控制器功能</h3> <p>作为最后一步,让我们使用面向对象编程中的继承原则来简化我们的控制器类。目前,DefaultController 和 ProductController 有几行相同的代码:都声明了一个 PATH_TO_TEMPLATES 常量,都有一个私有的 twig 属性,并且有相同的构造方法来创建一个 Twig\Environment 对象。如果我们需要创建更多的控制器类(如登录安全、购物车等),它们也需要这些相同的代码。</p> <p>为了避免这些重复,我们将把所有控制器类应该具备的共同属性和行为提取出来,成为一个通用的 Controller 父类。各个具体的控制器类,例如 DefaultController 和 ProductController,将从这个父类继承,并用自己的独特属性和方法扩展它。图 22-2 显示了我们将要创建的类结构的图示。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure22-2.jpg" alt="" loading="lazy"></p> <p>图 22-2:Controller 父类及其 DefaultController 和 ProductController 子类</p> <p>我们将把新的 Controller 类声明为抽象类,这意味着我们无法实际创建一个 Controller 对象。这是合适的,因为 Controller 类仅存在于存储所有控制器应该具有的通用代码,并且供我们想要实例化的具体控制器类进行子类化。记住,声明一个类为抽象类是一种告知其他程序员(以及未来的自己)你不希望该类被实例化的方式。</p> <p>在图 22-2 中,注意加号(+)表示公共方法和常量,以及控制器父类中 twig 属性旁边的井号(#),这表示该属性具有保护可见性,而不是公共或私有可见性。我们不希望这个 twig 属性是公共的,因为它可能会被任何访问到 Controller 对象或其子类的代码错误地更改或使用。然而,如果我们将 twig 属性设为私有,子类方法中的代码也无法访问它。这会造成问题,因为使用 Twig 渲染模板是我们所有控制器类的核心行为。</p> <p>将 twig 属性设置为 protected 可确保 Controller 的子类可以访问它,同时防止任何位于 Controller 类层次结构之外的代码直接访问它。这是我们在第十九章中探讨的继承概念的一个实用实例。</p> <p>清单 22-8 显示了 Controller 超类的代码。创建包含此清单代码的 <em>src/Controller.php</em> 文件。</p> <pre><code><?php namespace Mattsmithdev; use \Twig\Loader\FilesystemLoader; use \Twig\Environment; ❶ abstract class Controller { const PATH_TO_TEMPLATES = __DIR__ . '/../templates'; ❷ protected Environment $twig; public function __construct() { $loader = new FilesystemLoader(self::PATH_TO_TEMPLATES); $this->twig = new Environment($loader); } } </code></pre> <p>清单 22-8:Controller 超类</p> <p>我们将类声明为抽象类,因此它不能被实例化 ❶,并且将 twig 属性指定为 protected,以便子类可以访问它 ❷。除此之外,这段代码与 DefaultController 和 ProductController 类开始时的代码相同。现在,这段代码已经移到 Controller 类中,冗余部分可以被删除。清单 22-9 显示了简化后的 DefaultController 类代码。</p> <pre><code><?php namespace Mattsmithdev; class DefaultController extends Controller { private function homepage() { $template = 'homepage.xhtml.twig'; $args = []; $html = $this->twig->render($template, $args); print $html; } private function contactUs() { $template = 'contactUs.xhtml.twig'; $args = []; $html = $this->twig->render($template, $args); print $html; } } </code></pre> <p>清单 22-9:简化后的 DefaultController 类,Controller 的子类</p> <p>我们声明 DefaultController 扩展 Controller 类,从而使其继承构造函数和 twig 属性。由于继承的存在,DefaultController 现在只有两个自己的方法,用于显示主页和联系我们模板。我们可以以类似方式简化 ProductController 类的代码,如清单 22-10 所示。</p> <pre><code><?php namespace Mattsmihdev; class ProductController extends Controller { public function productList() { $product1 = new Product('Hammer', 9.99); $product2 = new Product('Bag of nails', 6.00); $product3 = new Product('Bucket', 2.00); $products = [$product1, $product2, $product3]; $template = 'productList.xhtml.twig'; $args = [ 'products' => $products ]; $html = $this->twig->render($template, $args); print $html; } } </code></pre> <p>清单 22-10:简化后的 ProductController 类,Controller 的子类</p> <p>再次声明类时,我们使用 <code>extends Controller</code>,使得 ProductController 能够继承自 Controller。子类特有的唯一方法是 productList(),用于显示产品列表页面。</p> <p>我们现在成功地使用继承将公共的 twig 属性及其初始化抽象到 Controller 超类中。这简化了两个页面控制器类,同时仍然提供完全相同的功能。</p> <h3 id="总结-14">总结</h3> <p>在本章中,我们改进了面向对象的 Web 应用程序架构。我们将控制网站的前端控制器逻辑(位于 Application 类中)与显示单个网页的页面控制器逻辑分开。后者被划分为一个抽象的 Controller 超类,其中包含显示任何网页所需的 Twig 设置代码,以及多个子类,这些子类仅包含与显示特定类型页面相关的逻辑代码。</p> <p>本章中的示例站点只有三个页面:主页、联系我们页面和产品列表页面。然而,本章演示的架构可以轻松扩展到具有数百或数千个页面及复杂功能(如会话交互、购物车、登录安全等)的复杂网站。</p> <h3 id="练习-20">练习</h3> <p>1.   复制本章中的项目,并为隐私政策添加一个第四个页面。按以下步骤操作:</p> <p>a.   创建一个 <em>privacy.xhtml.twig</em> 模板。</p> <p>b.   向 DefaultController 类添加一个新的 privacyPolicy() 方法,用于显示新的模板。</p> <p>c.   在<code>*base.xhtml.twig*</code>模板中添加一个隐私政策导航栏链接,URL 为<code>?action=privacy</code>。</p> <p>d.   在<code>Application</code>类的<code>run()</code>方法中,向<code>switch</code>语句添加一个新案例,当 URL 中的<code>action</code>值为<code>privacy</code>时,调用<code>DefaultController</code>对象的<code>privacyPolicy()</code>方法。</p> <p>2.   从练习 1 中复制你的项目,并添加一个用于列出公司员工的第五个页面。按照以下步骤操作:</p> <p>a.   创建一个<code>Staff</code>类来表示员工详情,包括<code>firstName</code>、<code>lastName</code>和<code>email</code>属性。</p> <p>b.   创建一个名为<code>StaffController</code>的<code>Controller</code>子类。为其提供一个<code>list()</code>方法,该方法创建两个或三个员工对象,并将它们作为数组传递给 Twig 的<code>render()</code>方法,以及模板名<code>*staff.xhtml.twig*</code>。</p> <p>c.   在<code>*base.xhtml.twig*</code>模板中添加一个新的员工列表导航栏链接,URL 为<code>?action=staffList</code>。</p> <p>d.   基于<code>productList.xhtml.twig</code>模板,创建一个<em>staff.xhtml.twig</em>模板,使用 Twig 代码循环并打印出接收到的数组中的每个<code>Staff</code>对象。</p> <p>e.   在<code>Application</code>类的<code>run()</code>方法中,创建一个新的<code>$staffController</code>对象,该对象是<code>StaffController</code>类的实例。然后添加一个新的<code>switch</code>语句案例,如果 URL 中的<code>action</code>值为<code>staffList</code>,则调用<code>$staffController->list()</code>。</p> <h2 id="第二十三章23-使用异常处理错误">第二十三章:23 使用异常处理错误</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>应用程序并不总是按你想要的方式运行。例如,文件可能因为网络错误而无法上传,或者来自用户或 Web API 的数据可能以某种方式被破坏。在本章中,你将学习如何使用<em>异常</em>来预见这些问题并从中恢复,这样当出现问题时,你的应用程序就不会总是崩溃。你将使用 PHP 的通用 Exception 类,以及内置语言中其他更专业化的异常类。你还将看到如何设计自己的自定义异常类,以及如何设计应用程序以安全地处理可能发生的所有异常。</p> <h3 id="异常基础">异常基础</h3> <p><em>异常</em>是类,提供了一种复杂且可定制的方式来处理和恢复面向对象编程(OOP)中预期的、具有问题的情况。它们不同于<em>错误</em>,后者通常是无法恢复的情况或事件,例如计算机系统内存不足或类声明尝试使用一个找不到的常量。PHP 具有一个内置的 Exception 类,用于处理通用问题,以及其他更多针对特定类型错误的专业化异常类。你还可以开发自己的自定义异常类。</p> <p>基于异常的软件设计允许你按最自然的顺序编写代码,假设一切都会正常工作,然后单独编写代码来捕获并解决可能发生的任何典型问题。这涉及到将测试写入类的方法中,这些方法会生成异常对象,并在发生异常或无效情况时打断程序控制的流程,例如为构造函数或设置方法提供无效的参数。由于有这些测试,可以安全地假设方法中后续出现的代码,如果执行到那一步,表示没有触发抛出异常的条件,代码按预期工作。</p> <p>异常应用编程的核心是 throw 和 catch 语句。方法使用 throw 语句在发生问题时创建异常对象,这叫做<em>抛出异常</em>。throw 语句会中止方法的执行并打断程序的流程。仅仅抛出异常可能会导致致命错误,除非你用 catch 语句<em>捕获</em>异常。catch 语句包含的代码是当异常被抛出时执行的代码;这些代码可能允许应用程序从问题中恢复,或者如果无法恢复,则可以记录问题并优雅地结束执行。</p> <p>在本节中,我们将探讨抛出和捕获异常的基础知识。我们还将介绍 finally 语句,它是在过程结束时执行的代码块,无论是否抛出了异常。</p> <h4 id="抛出异常">抛出异常</h4> <p>首先,我们将考虑如何抛出未捕获的异常,以导致致命错误并终止应用程序。我们将研究一个常见用例,即当向类的 setter 方法提供无效参数时抛出异常。我们将创建一个变体版本的 Food 和 Dessert 类,基于 第十九章,在 Dessert 类的 setCalories() 方法中添加基于异常的验证行为。如果为新的 Dessert 对象提供负数的 calories 值,则会抛出异常。我们将创建的项目在 图 23-1 的 UML 类图中有所展示。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure23-1.jpg" alt="" loading="lazy"></p> <p>图 23-1:一个展示 Dessert 类抛出异常的图示</p> <p>回顾一下,Dessert 是 Food 的一个子类,具有自己的 __toString() 方法和 calories 属性。图示表明,如果 calories 值无效,将抛出异常。</p> <p>首先,我们声明 Food 类。创建一个新项目,并将 列表 23-1 中的代码放入 <em>src/Food.php</em>。</p> <pre><code><?php namespace Mattsmithdev; class Food { protected string $name; public function __construct(string $name) { $this->name = $name; } public function __toString(): string { return "(FOOD) $this->name"; } } </code></pre> <p>列表 23-1:Food 超类</p> <p>我们将 Food 类分配到 Mattsmithdev 命名空间,并为其提供一个 name 属性,该属性的访问权限为 protected,以便所有子类可以直接访问。该类有一个简单的构造函数,用于在创建每个新对象时初始化 name,并有一个 __toString() 方法,用于返回 "(FOOD) name" 格式的字符串。</p> <p>现在,让我们声明 Dessert 作为 Food 的子类。创建 <em>src/Dessert.php</em> 并输入 列表 23-2 的内容。</p> <pre><code><?php namespace Mattsmithdev; class Dessert extends Food { private int $calories; public function __construct(string $name, int $calories) { parent::__construct($name); ❶ $this->setCalories($calories); } public function getCalories(): int { return $this->calories; } public function setCalories(int $calories) { ❷ if ($calories < 0) { throw new \Exception( 'attempting to set calories to a negative value'); } ❸ $this->calories = $calories; } public function __toString(): string { return "I am a Dessert containing $this->calories!"; } } </code></pre> <p>列表 23-2:Dessert 类,如果 calories 值无效,则抛出异常</p> <p>Dessert 类有一个 calories 属性,在构造函数中通过 setCalories() 方法进行赋值❶。这样,我们将所有的验证逻辑保留在 setter 方法中,因此每一个新的 calories 值都会经过验证,无论是在对象构造时提供,还是通过 setter 在之后的某个时间点提供。</p> <p>在 setCalories() 中,我们使用 if 语句进行验证❷。如果提供的整数参数 $calories 小于 0,则抛出一个新的 Exception 对象,错误信息为 '试图将 calories 设置为负值'。如果 $calories 参数大于或等于 0,并且没有抛出异常,则代码会继续执行,并将提供的值存储在 Dessert 对象的 calories 属性中❸。</p> <p>注意抛出异常的语法。我们以 throw 关键字开始,这告诉 PHP 如果 if 语句为真,就中断程序流程。接着我们使用 new 关键字来创建一个新的 Exception 类对象,并传递我们想要显示的错误信息作为参数。由于 Exception 类属于 PHP 的根命名空间,我们必须在前面加上反斜杠(\),而 Dessert 类属于 Mattsmithdev 命名空间。如果没有反斜杠,Exception 会被认为也属于 Mattsmithdev 命名空间。</p> <p>接下来,我们需要编写一个 <em>composer.json</em> 文件来自动加载我们的类。按照 列表 23-3 中的示例创建该文件。</p> <pre><code>{ "autoload": { "psr-4": { "Mattsmithdev\\": "src" } } } </code></pre> <p>列表 23-3:用于自动加载的 composer.json 文件</p> <p>一旦你有了这个文件,通过在命令行输入 composer dump-autoload 来生成自动加载脚本。</p> <p>现在让我们编写一个索引脚本,尝试创建一个 Food 和一个 Dessert 对象。创建<em>public/index.php</em>,使其与列表 23-4 匹配。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Food; use Mattsmithdev\Dessert; $f1 = new Food('apple'); print $f1 . PHP_EOL; $f2 = new Dessert('strawberry cheesecake', -1); print $f2; </code></pre> <p>列表 23-4:在 index.php 中尝试创建无效的 Dessert 对象</p> <p>我们读取并执行了自动加载器,并为所需的两个类提供了使用语句。然后我们创建并打印了一个 Food 对象和一个 Dessert 对象,并为后者的卡路里属性传递了无效参数 -1。以下是运行此索引脚本时在命令行中得到的结果:</p> <pre><code>$ **php public/index.php** (FOOD) apple Fatal error: Uncaught Exception: attempting to set calories to a negative value in /Users/matt/src/Dessert.php:23 Stack trace: #0 /Users/matt/src/Dessert.php(12): Mattsmithdev\Dessert->setCalories(-1) #1 /Users/matt/public/index.php(10): Mattsmithdev\Dessert->__construct ('strawberry chee...', -1) #2 {main} thrown in /Users/matt/src/Dessert.php on line 23 </code></pre> <p>输出的第一行显示,Food 对象已成功创建并打印出来,但由于负卡路里值抛出的异常,我们遇到了致命错误。这个异常被称为<em>未捕获</em>,因为我们没有编写任何代码告诉 PHP 如果抛出异常该怎么办。结果,应用程序停止运行并打印出了我们提供的错误消息,随后是<em>堆栈跟踪</em>,它报告了代码的执行步骤,显示异常的原因。</p> <p>堆栈跟踪告诉我们以下内容:</p> <ul> <li> <h1 id="0-显示当-setcalories-在srcdessertphp-文件的第-12-行传递--1-作为参数时抛出了异常">0 显示,当 setCalories() 在<em>src/Dessert.php</em> 文件的第 12 行传递 -1 作为参数时,抛出了异常。</h1> </li> <li> <h1 id="1-显示当调用-dessert-类的构造方法并传递参数-strawberry-chee--1-时setcalories-被调用了食物名称字符串已被简化">1 显示,当调用 Dessert 类的构造方法并传递参数 ('strawberry chee...', -1) 时,setCalories() 被调用了(食物名称字符串已被简化)。</h1> </li> <li> <h1 id="2-报告指出抛出异常的代码位于srcdessertphp文件的第-23-行">2 报告指出抛出异常的代码位于<em>src/Dessert.php</em>文件的第 23 行。</h1> </li> </ul> <p>请注意,输出以堆栈跟踪结束,这意味着索引脚本无法打印出 Dessert 对象。未捕获的异常中止了程序的执行,因此索引脚本的最后一行没有执行。</p> <h4 id="捕获异常">捕获异常</h4> <p>为了避免致命错误并安全地管理异常,我们需要通过在索引脚本中编写 try...catch 语句来<em>捕获</em>异常。try 部分指示在正常情况下我们想要做的事情,而 catch 部分指示在抛出异常时该做什么。</p> <p>通过捕获异常,我们可以防止应用程序用户看到致命错误和随之而来的堆栈跟踪。除了尴尬且不友好,打印堆栈跟踪会“泄漏”关于 Web 应用程序代码结构的信息(例如,在前面的例子中,我们泄漏了文件夹名<em>src</em>和类文件名<em>Dessert.php</em>)。虽然堆栈跟踪并不是严重的安全漏洞问题,但任何这样的信息泄漏都可能对攻击者有帮助,因此应该尽可能避免。捕获异常使我们可以决定如何处理异常数据,以及当出现问题时用户将看到什么。</p> <blockquote> <p>注意</p> </blockquote> <p><em>在第二十四章中,我们将探讨日志记录,它允许存储有用的调试数据,如堆栈跟踪,供开发人员和网站管理员访问,同时不向任何公共网站访问者或软件客户端发布这些信息。</em></p> <p>为了捕获当输入负的卡路里值时引发的异常,请更新<em>public/index.php</em>脚本以匹配示例 23-5。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev \Food; use Mattsmithdev \Dessert; ❶ try { $f1 = new Food('apple'); print $f1 . PHP_EOL; $f2 = new Dessert('strawberry cheesecake', -1); print $f2; ❷} catch (\Exception $e) { print '(caught!) - an exception occurred!' . PHP_EOL; ❸ print $e->getMessage(); } </code></pre> <p>示例 23-5:向 index.php 添加<code>try...catch</code>语句</p> <p>以前创建并打印 Food 和 Dessert 对象的代码现在放在一个<code>try</code>块中❶。如果在此过程中发生任何异常,PHP 会检查异常的类是否与接下来的<code>catch</code>块中指定的类匹配❷。如果类匹配,就会执行<code>catch</code>块。在本例中,<code>catch</code>语句是针对<code>\Exception</code>类的对象,如<code>catch</code>关键字后面的小括号所指定的。小括号中的变量<code>$e</code>将成为捕获到的 Exception 对象的引用。</p> <p>在<code>catch</code>块中,我们打印出消息'(caught!) - 发生了异常!',并跟随一个换行符。然后,我们通过 Exception 对象的<code>public getMessage()</code>方法打印 Exception 对象中的消息❸。这就是我们之前定义的“尝试将卡路里设置为负值”的消息。</p> <p>现在我们已经添加了捕获异常的代码,请尝试再次在命令行运行 index 脚本。你应该会看到如下输出:</p> <pre><code>$ **php public/index.php** (FOOD) apple (caught!) - an exception occurred! attempting to set calories to a negative value </code></pre> <p>再次,Food 对象已成功创建并打印。接下来,我们看到从<code>catch</code>语句内部打印的消息,后跟来自 Exception 对象本身的消息。在这个示例中,捕获异常后,我们仍然为用户打印出一条消息,但我们已经控制了显示的信息。由于我们正在使用<code>catch</code>语句处理异常,现在没有泄漏任何堆栈跟踪信息。</p> <h4 id="以finally语句结束">以<code>finally</code>语句结束</h4> <p><code>finally</code>语句是一个代码块,不管是否抛出异常,都会被执行。它写在<code>try</code>和<code>catch</code>语句之后,通常包含<em>清理代码</em>,用于优雅地结束任何已启动的进程。例如,你可以使用<code>finally</code>语句来确保任何文件流或数据库连接都会被关闭。</p> <p>让我们在 index 脚本中添加一个<code>finally</code>语句,以便每次运行时都优雅地关闭应用程序,即使抛出了异常。修改<em>public/index.php</em>以匹配示例 23-6。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; try { $f1 = new Food('apple'); print $f1 . PHP_EOL; $f2 = new Dessert('strawberry cheesecake', -1); print $f2; } catch (\Exception $e) { print '(caught!) - an exception occurred!' . PHP_EOL; print $e->getMessage(); } finally { print PHP_EOL . '(finally) -- Application finished --'; } </code></pre> <p>示例 23-6:向 index.php 添加<code>finally</code>语句</p> <p>我们声明了一个<code>finally</code>块,在<code>try</code>或<code>catch</code>块结束后打印一条简单的消息。下面是运行更新后的 index 脚本的结果:</p> <pre><code>$ **php public/index.php** (FOOD) apple (caught!) - an exception occurred! attempting to set calories to a negative value (finally) -- Application finished -- </code></pre> <p><code>finally</code>块中的消息会在输出的最后打印出来,显示异常消息之后。</p> <p>为了确保即使 catch 语句没有执行,finally 语句仍然会执行,我们将更新脚本,确保 Dessert 对象的卡路里数量是有效的,也就是说不会抛出异常。按照 列表 23-7 中的方式修改 <em>public/index.php</em> 中的 Dessert 对象实例化代码。</p> <pre><code>--snip-- $f2 = new Dessert('strawberry cheesecake', **500**); --snip-- </code></pre> <p>列表 23-7:在 index.php 中创建有效的 Dessert 对象</p> <p>当你现在运行 index 脚本时,你应该能看到 Food 和 Dessert 对象的消息,以及最终的消息:</p> <pre><code>$ **php public/index.php** (FOOD) apple I am a Dessert containing 500 calories! (finally) -- Application finished -- </code></pre> <p>输出确认 Dessert 对象已成功创建且没有抛出异常,同时 regardless 的 finally 语句块仍然会执行。### 使用多个异常类</p> <p>除了 PHP 的根 Exception 类外,标准 PHP 库(SPL)还提供了其他几种异常类,例如 InvalidArgumentException 类。这些异常类都与 Exception 类通过继承层次结构连接在一起,作为其子类、子类的子类,依此类推。你也可以创建自定义异常类,这些类是某个内置异常类的子类。</p> <p>乍一看,可能觉得没有必要使用 Exception 的子类,因为我们可以为每种情况创建具有自定义消息的基本 Exception 对象来抛出异常。然而,通过编写抛出不同 Exception 子类对象的代码,你可以包含多个 catch 语句,每个语句处理一个 Exception 子类,从而能够针对每种类型的异常做出不同的响应。</p> <p>例如,你可以将多个验证检查写入一个 setter 方法,并根据哪个验证检查失败抛出特定的异常类。然后,你可以为每个异常类编写一个单独的 catch 语句,这样每种类型的异常都会生成定制的响应。通常,你最后会以一个 catch 语句来捕获通用的 Exception 对象,这样你就能捕获任何你没有提前考虑到的异常。我们将在接下来的章节中查看这种方法是如何工作的。</p> <h4 id="其他内置异常类">其他内置异常类</h4> <p>让我们使用另一个内置的 PHP 异常类,并结合根 Exception 类来使用。我们将更新 Dessert 类的 setCalories() 方法,使其抛出两种异常类对象之一,以验证接收到的 $calories 参数。我们的验证测试如下:</p> <ul> <li> <p>如果 $calories 小于 0,抛出一个 \InvalidArgumentException 对象,因为甜点不能有负卡路里。</p> </li> <li> <p>如果 $calories 大于 5000,抛出一个通用的 \Exception 对象,因为那是 <em>太多了</em>,一个甜点不可能有这么多卡路里。</p> </li> </ul> <p>更新 <em>src/Dessert.php</em> 中的 setCalories() 方法,使其符合 列表 23-8。</p> <pre><code>public function setCalories(int $calories): void { if ($calories < 0) { throw new \InvalidArgumentException( 'attempting to set calories to a negative value'); } if ($calories > 5000) { throw new \Exception('too many calories for one dessert!'); } $this->calories = $calories; } </code></pre> <p>列表 23-8:更新 setCalories() 方法以抛出不同的异常类</p> <p>首先,我们将当接收到负数作为参数时抛出的异常类更改为\InvalidArgumentException。再次注意,在类名之前有一个斜杠,表示这个类声明在根 PHP 命名空间下。然后我们添加第二个验证测试:当卡路里数大于 5000 时,将抛出一个通用的\Exception 类对象。如果代码执行通过了这两个 if 语句而没有抛出任何异常,我们将像之前一样把提供的值存储在对象的 calories 属性中。</p> <p>接下来,我们需要更新索引脚本。我们将编写多个 catch 语句,分别适当地处理每个异常对象类。然后,我们将为 Dessert 对象的 calories 属性尝试不同的值,以测试验证逻辑。编辑<em>public/index.php</em>,以匹配示例 23-9。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Food; use Mattsmithdev\Dessert; $calories = -1; // Negative invalid argument $calories = 6000; // General exception $calories = 500; // Valid try { $f2 = new Dessert('strawberry cheesecake', $calories); print $f2; } ❶ catch (\InvalidArgumentException $e) { print '(caught!) - an Invalid Argument Exception occurred!' . PHP_EOL; print $e->getMessage(); } ❷ catch (\Exception $e) { print '(caught!) - a general Exception occurred!' . PHP_EOL; print $e->getMessage(); } finally { print PHP_EOL . '(finally) -- Application finished --'; } </code></pre> <p>示例 23-9:public/index.php 脚本中的多个 catch 语句</p> <p>我们从为<span class="math inline">\(calories 变量赋不同值的三个赋值语句开始。为了彻底测试脚本,将除一个赋值语句外的所有语句注释掉,每次选择一个不同的赋值语句。在 try 块中,我们创建一个新的 Dessert 对象,并将\)</span>calories 变量作为参数传入。然后,我们创建两个 catch 语句,一个用于 InvalidArgumentException 类 ❶,另一个用于通用 Exception 类 ❷。每个 catch 语句都会打印不同的消息,并附带从异常对象中获取的消息,使用$e->getMessage()。</p> <p>表 23-1 展示了$calories 的三个值的输出,证明我们的基于异常的逻辑如预期般工作。</p> <p>表 23-1:卡路里值的输出</p> <table> <thead> <tr> <th>$calories 的值</th> <th>程序输出</th> </tr> </thead> <tbody> <tr> <td>-1</td> <td>(已捕获!)- 发生了一个无效参数异常!尝试将卡路里设置为负值(最终)-- 应用程序完成 --</td> </tr> <tr> <td>6000</td> <td>(已捕获!)- 发生了一个通用异常!一个甜点的卡路里太多了!(最终) -- 应用程序完成 --</td> </tr> <tr> <td>500</td> <td>我是一个含有 500 卡路里的甜点!(最终)-- 应用程序完成 --</td> </tr> </tbody> </table> <p>请注意,-1 和 6000 的值分别触发了它们各自的异常类,而 500 允许成功创建并打印 Dessert 对象。</p> <h4 id="自定义异常类">自定义异常类</h4> <p>PHP 为你提供了编写自定义异常类的灵活性,只要它们是 Exception 或其他内置 PHP 异常类的子类。此外,许多第三方库带有自己专门为该库中的方法设计的自定义异常类。无论你是在编写自己的异常类还是使用他人的,自定义异常类都能让你有更多的自由来组织代码,以便针对各种预期问题做出不同的响应。</p> <p>让我们为 Dessert 项目添加一个自定义异常类:Mattsmithdev\NegativeCaloriesException。我们将更新项目,以抛出该类的异常对象,而不是 InvalidArgumentException 类。 图 23-2 显示了我们的 Dessert 对象可以抛出的两类异常。请注意,NegativeCaloriesException 类位于 Mattsmithdev 命名空间内,而 Exception 类在外部,因为它位于根 PHP 命名空间中。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure23-2.jpg" alt="" loading="lazy"></p> <p>图 23-2:Dessert 对象可以抛出的两类异常</p> <p>首先,在 <em>src/NegativeCaloriesException.php</em> 中创建一个新类,包含 列表 23-10 中的代码。</p> <pre><code><?php namespace Mattsmithdev; class NegativeCaloriesException extends \Exception { } </code></pre> <p>列表 23-10:自定义的 NegativeCaloriesException 类</p> <p>我们将 NegativeCaloriesException 声明为根 \Exception 类的子类。它不包含任何方法。由于它没有自己的构造方法,因此将继承其 Exception 超类的构造方法,从而允许它接收用于显示的消息。</p> <p>现在让我们更新 Dessert 类的 setCalories() 方法,当提供的卡路里值为负时抛出 NegativeCaloriesException 对象。与之前的示例一样,当提供的值大于 5000 时,我们将抛出一个通用的 Exception 对象。更新 <em>src/Dessert.php</em> 中的 setCalories() 方法以匹配 列表 23-11。</p> <pre><code>public function setCalories(int $calories) { if ($calories < 0) { throw new NegativeCaloriesException( 'attempting to set calories to a negative value'); } if ($calories > 5000) { throw new \Exception('too many calories for one dessert!'); } $this->calories = $calories; } </code></pre> <p>列表 23-11:在 setCalories() 方法中抛出自定义异常</p> <p>当接收到的参数为负数时,我们将抛出的异常类更改为 NegativeCaloriesException 类的对象。由于这个新类与我们的 Dessert 类在同一个命名空间中,因此我们不在类标识符前加反斜杠。</p> <p>接下来,我们需要更新 index 脚本中的 catch 语句,以处理新的自定义异常类。按照 列表 23-12 中所示修改 <em>public/index.php</em>。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Dessert; use Mattsmithdev\NegativeCaloriesException; $calories = -1; // Negative invalid argument $calories = 6000; // General exception $calories = 500; // Valid try { $f2 = new Dessert('strawberry cheesecake', $calories); print $f2; } catch (NegativeCaloriesException) { print '(caught!) - a Negative Calories Value Exception occurred!' . PHP_EOL; print $e->getMessage(); } catch (\Exception $e) { print '(caught!) - a general Exception occurred! ' . PHP_EOL; print $e->getMessage(); } finally { print PHP_EOL . '(finally) -- Application finished --'; } </code></pre> <p>列表 23-12:在 index.php 脚本中捕获自定义异常对象</p> <p>我们添加一个 use 语句,以便可以在不带 Mattsmithdev 命名空间前缀的情况下引用 NegativeCaloriesException 类。然后我们为该类的异常创建一个 catch 语句,打印适当的消息。如果你尝试创建一个卡路里为 -1 的新 Dessert 对象,确认抛出 NegativeCaloriesException,以下是你应该得到的输出:</p> <pre><code>$ **php public/index.php** (caught!) - a Negative Calories Value Exception occurred! attempting to set calories to a negative value (finally) -- Application finished -- </code></pre> <p>测试负值是一个简单的示例,但它说明了创建 Exception 的自定义子类是多么简单,这使你能够编写不同的逻辑来处理运行时预期的不同问题。</p> <h3 id="调用栈冒泡">调用栈冒泡</h3> <p>如果一个异常发生在某个代码块中并且没有被该代码块捕获,它将 <em>冒泡到</em> 调用栈中调用该代码块的代码。如果在那里没有捕获,它将继续向上冒泡,经过逐渐更高层次的代码,直到它被捕获并处理,或者到达调用栈的顶部。如果异常在顶层代码块中没有被捕获,则会导致致命错误,正如我们在本章的第一个示例中看到的那样。基于这个原因,建议在应用程序的顶层代码中加入一些代码,以捕获任何可能已经冒泡到调用栈顶部的异常。</p> <p>正如你所看到的,典型的面向对象 PHP Web 应用程序的控制流程是从索引脚本开始的,该脚本创建一个 Application 类的对象并调用其 run() 方法。这又触发了其他对象的创建和其他方法的调用。这些活动中可能会抛出异常。你可以尝试在 Application 类中捕获所有这些异常,但任何未捕获的异常最终都会冒泡到索引脚本,并应在那里捕获,以避免致命错误。</p> <p>为了演示这一过程,我们将更新我们的 Dessert 项目,将原本位于索引脚本中的代码封装进 Application 类中。这个类将负责捕获在创建 Dessert 对象时抛出的任何 NegativeCaloriesException 对象,但我们将允许其他杂项异常冒泡到调用栈的顶部。然后,我们将在顶层索引脚本中捕获这些异常。</p> <p>首先,让我们更新我们的索引脚本,以创建一个 Application 对象并调用它的 run() 方法。我们将把这段代码包装在 try...catch 语句中,以处理任何未被捕获的异常,并在其中添加一个 finally 语句来优雅地关闭应用程序。我们还将通过在 catch 和 finally 块打印的消息前添加 (index.php) 前缀,明确表示这些消息是来自这个索引脚本。修改 <em>public/index.php</em> 以匹配 列表 23-13。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Application; try { $app = new Application(); $app->run(); } catch (\Exception $e) { print '(index.php) Exception caught!'; }finally { print PHP_EOL; print '(index.php) finally -- Application finished --'; } </code></pre> <p>列表 23-13:创建 Application 对象的简化 index.php 脚本</p> <p>在 try 块中,我们创建一个 Application 对象,将该对象的引用存储在 $app 变量中,并调用其 run() 方法。如果有任何未捕获的 Exception 对象从 try 块语句中冒泡出来,我们将使用 catch 块来处理它们并打印一条消息。由于每个异常对象都是 Exception 类的一个实例(无论是直接的还是作为子类),因此这个 catch 语句作为一个捕获所有异常的通用处理器,用于处理在程序执行过程中抛出的但未在代码中其他地方捕获的异常。我们还添加了一个 finally 块,它会打印最终消息,无论是否抛出异常。</p> <p>现在让我们编写 Application 类。创建一个名为 <em>src/Application.php</em> 的新文件,以匹配 列表 23-14。</p> <pre><code><?php namespace Mattsmithdev; class Application { public function run(): void { $calories = -1; // Negative invalid argument $calories = 6000; // General exception $calories = 500; // Valid try { $f2 = new Dessert('strawberry cheesecake', $calories); print $f2; } catch (NegativeCaloriesException $e) { print '(Application->run) - Negative Calories Value Exception caught!'; } } } </code></pre> <p>列表 23-14:Application 类</p> <p>我们声明了一个包含 <code>run()</code> 方法的 Application 类,该方法包含了来自旧版 index 脚本的许多语句。和之前一样,我们为 <code>$calories</code> 变量包含了三个赋值语句,你可以选择性地注释掉这些语句来测试项目。然后,我们在一个 try 块中创建并打印一个新的 Dessert 对象,并使用 catch 块来处理 NegativeCaloriesException 对象。表 23-2 显示了在使用不同 <code>$calories</code> 值时运行应用程序的结果。</p> <p>表 23-2:使用调用栈冒泡捕获异常</p> <table> <thead> <tr> <th>$calories 的值</th> <th>程序输出</th> </tr> </thead> <tbody> <tr> <td>500</td> <td>我是一个包含 500 卡路里的甜点! (index.php) 最终 -- 应用程序结束 --</td> </tr> <tr> <td>-1</td> <td>(Application->run) - 捕获到负卡路里值异常! (index.php) 最终 -- 应用程序结束 --</td> </tr> <tr> <td>6000</td> <td>(index.php) 捕获到异常! (index.php) 最终 -- 应用程序结束 --</td> </tr> </tbody> </table> <p>当使用有效值 500 时,对象的属性会被打印出来。当值为 -1 时,NegativeCaloriesException 会在 Application 类的 <code>run()</code> 方法内被捕获。当使用过高的值 6000 时,Application 类的 <code>run()</code> 方法未能捕获抛出的通用异常,因为该方法只会捕获 NegativeCaloriesException 对象。因此,异常会冒泡到 index 脚本,在那里会命中通用的 catch 块。在所有情况下,输出都会以 index 脚本中 finally 块的消息结束。</p> <p>向 index 脚本添加通用的 try...catch 语句可以确保任何冒泡上来的未捕获异常都会被处理,这意味着应用程序将避免与异常相关的运行时错误。同时,处理更具体异常的代码,如我们自定义的 NegativeCaloriesException,位于应用程序代码的更低级别,这样可以保持 index 脚本简洁且结构良好。</p> <h3 id="总结-15">总结</h3> <p>本章介绍了如何处理异常。你学习了如何在预期的有问题情况发生时通过编写 throw 语句来创建异常,并通过 try...catch...finally 结构来管理异常。所有异常都是 PHP 顶级 \Exception 类的实例,但我们讨论了如何通过使用提供的异常子类(如 InvalidArgumentException)或声明自定义异常子类来优化程序逻辑。</p> <p>我们还探讨了一种通用的应用架构,该架构利用未捕获异常的冒泡。当预期的特定异常发生时,可以在类方法中捕获,而任何剩余的异常则可以在应用程序顶层的 index 脚本中捕获。</p> <h3 id="练习-21">练习</h3> <p>1.   创建一个新项目,并实现一个简单的 Product 类,该类具有私有的 name 和 price 属性,针对每个属性的公共访问方法,以及一个构造函数,该构造函数接收每个属性的新值并使用 setter 方法设置它们。添加以下验证:</p> <p>a.   如果收到负值的价格,将抛出 InvalidArgumentException。</p> <p>b.   如果收到的价格大于 1000000,将抛出一个通用 Exception。</p> <p>c.   如果为 name 属性提供一个空字符串,将抛出 InvalidArgumentException。</p> <p>创建一个<em>composer.json</em>文件和一个索引脚本,尝试使用有效和无效的名称和价格创建一个 Product 对象。然后用 try...catch 语句包裹你的索引代码,这样你就可以处理代码抛出的各种异常。</p> <p>2.   复制你的第 1 题解答,并引入一个类似于清单 23-14 中的 Application 类。按以下方式重构你的代码:</p> <p>a.   在你的 Application 类的 run()方法中创建 Product 对象。捕获 InvalidArgumentException 对象,并在 run()方法中打印相应的消息。</p> <p>b.   在索引脚本中,创建一个 Application 对象并调用其 run()方法。捕获任何向上传播的通用 Exception 对象并打印相应的消息。</p> <p>3.   复制你的第 2 题解答,并引入一个名为 EmptyStringException 的自定义异常,该异常由 setName()方法抛出。在 Application 类的 run()方法中添加一个适当的 catch 块,以捕获并处理此异常。</p> <h2 id="第二十四章24-日志事件消息和事务">第二十四章:24 日志事件、消息和事务</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>几乎所有的实时商业 Web 应用程序都会保持一个<em>日志</em>,记录应用程序在运行过程中生成的消息、错误、事件、性能摘要和其他信息。在本章中,我们将探讨如何为 PHP Web 应用程序维护日志,以便您在应用程序发生问题时能够分析性能并做出响应。您将了解 PHP 的内置日志资源,以及 Monolog,一个流行的第三方 PHP 日志包,还将学习如何将日志消息写入不同的地方。</p> <p>有时,日志会记录审计事件,例如查看电子货币交易是否存在异常。其他时候,交易会被记录用于备份和恢复目的。例如,如果在向数据库写入信息时出现问题,可以通过恢复到已知时间点的备份(称为<em>镜像</em>或<em>快照</em>),然后重新运行该快照创建后记录的交易序列,从而将数据库恢复到正确的状态。日志记录也与异常密切相关,我们在上一章中已经讨论过。当抛出异常时,它可以被记录到系统日志中,供日后分析。</p> <h3 id="内置-php-日志资源">内置 PHP 日志资源</h3> <p>日志记录是服务器编程的核心部分,因此 PHP 提供了许多资源来简化这一过程。这些资源包括一组预定义常量,表示不同的日志严重性级别,以及用于将日志消息写入文件的内置函数。我们现在将探讨这些功能。</p> <h4 id="严重性级别的预定义常量">严重性级别的预定义常量</h4> <p>大多数计算机日志系统允许根据特定的紧急性或重要性级别对消息进行分类。为此,PHP 提供了八个预定义常量,用于确定日志的严重性级别。这些严重性级别从 0 到 7 编号,从最紧急到最不紧急,对应于 RFC 5424 中定义的八个级别,RFC 5424 是 IETF 制定的广泛使用的 syslog 协议标准。您可以在 <em><a href="https://www.rfc-editor.org/rfc/rfc5424" target="_blank"><code>www.rfc-editor.org/rfc/rfc5424</code></a></em> 查找该协议。</p> <p>您可以结合使用 PHP 常量和内置的 syslog() 函数(我们将在接下来的内容中讨论),生成适当严重性级别的日志消息。表 24-1 显示了八个严重性级别,它们的 RFC 5424 级别名称以及其含义的摘要。</p> <p>表 24-1:来自 RFC 5424 的日志消息严重性级别</p> <table> <thead> <tr> <th>Syslog 严重性值</th> <th>RFC 5424 日志级别</th> <th>含义</th> </tr> </thead> <tbody> <tr> <td>0</td> <td>紧急</td> <td>系统无法使用或不可用。</td> </tr> <tr> <td>1</td> <td>警报</td> <td>发生了问题,需要立即采取行动。</td> </tr> <tr> <td>2</td> <td>严重</td> <td>问题即将发生,必须立即解决。</td> </tr> <tr> <td>3</td> <td>错误</td> <td>发生了一个非紧急但需要在一定时间内采取行动的故障。</td> </tr> <tr> <td>4</td> <td>警告</td> <td>需要采取行动,因为该事件可能导致错误。</td> </tr> <tr> <td>5</td> <td>通知</td> <td>发生了一个预期但重要的事件,值得记录日志,但不需要采取任何行动。</td> </tr> <tr> <td>6</td> <td>信息</td> <td>发生了一个预期的事件,供报告和度量使用。</td> </tr> <tr> <td>7</td> <td>调试</td> <td>用于软件开发人员记录支持当前调试和代码分析的详细信息。</td> </tr> </tbody> </table> <p>表 24-2 显示了与 RFC 5424 日志级别相对应的 PHP 八个命名常量,以及这些常量在 macOS、Unix 和 Windows 系统中的整数值。</p> <p>表 24-2:PHP 日志级别常量</p> <table> <thead> <tr> <th>PHP 常量</th> <th>macOS 和 Unix 值</th> <th>Windows 值</th> </tr> </thead> <tbody> <tr> <td>LOG_EMERG</td> <td>0</td> <td>1</td> </tr> <tr> <td>LOG_ALERT</td> <td>1</td> <td>1</td> </tr> <tr> <td>LOG_CRIT</td> <td>2</td> <td>1</td> </tr> <tr> <td>LOG_ERR</td> <td>3</td> <td>4</td> </tr> <tr> <td>LOG_WARNING</td> <td>4</td> <td>5</td> </tr> <tr> <td>LOG_NOTICE</td> <td>5</td> <td>6</td> </tr> <tr> <td>LOG_INFO</td> <td>6</td> <td>6</td> </tr> <tr> <td>LOG_DEBUG</td> <td>7</td> <td>6</td> </tr> </tbody> </table> <p>在 macOS 和 Unix 系统中,每个常量都有一个整数值,对应八个严重性级别中的一个。例如,LOG_EMERG 常量在 macOS 和 Unix 中的值为 0。如果你在 Windows 服务器上运行 PHP,这些常量的值会有所不同,因为系统头文件的标准不同。无论哪种系统,日志级别的严重性随着常量值的减少而增加,这与 RFC 5424 的原则一致。在本章中,我们将以 macOS 和 Unix 的值为准。</p> <p>各种严重性级别有其传统用途。例如,在测试和调试代码时,通常使用 LOG_DEBUG 严重性,可能会将这些日志条目定向到自己的调试日志文件中。你可能会将关于标准、非关键问题的消息记录为 LOG_INFO 或 LOG_NOTICE 严重性,比如用户尝试上传过大或类型错误的文件。这样,如果同样的问题多次发生,就可以考虑对用户界面或文件大小进行改进。应当充分考虑那些可能导致错误的事件,并将其记录为 LOG_ERR 严重性。同样,编写异常处理代码时,识别那些可能影响 Web 应用程序整体功能的异常,并使用 LOG_EMERG、LOG_ALERT 或 LOG_CRIT 严重性进行记录,始终是非常重要的。</p> <p>按照严重性级别对日志消息进行分类,可以让你设计出具有逻辑响应的计算机系统,以不同的方式应对不同重要性的日志消息。例如,当新的日志消息出现在前三个严重性级别(紧急、警报或危急)时,日志系统规则可能会执行诸如发送短信和自动电话呼叫给值班技术人员等操作。与此同时,较低重要性的消息可能会被写入归档文件,或者通过 Web API 发送到云日志系统。我们将在《根据严重性管理日志》一节中详细讨论如何为不同的严重性级别创建定制化响应,第 466 页。</p> <h4 id="日志功能">日志功能</h4> <p>PHP 有两个内置的函数用于记录消息:error_log() 和 syslog()。它们的区别在于消息记录的位置。</p> <p>error_log() 函数将消息附加到 PHP 错误日志文件,该文件的位置由 <em>php.ini</em> 文件中的 error_log 路径或服务器日志设置定义,或者可以通过调用函数时作为参数传递到其他位置。(有关如何定位系统的 <em>php.ini</em> 文件的信息,请参见 附录 A)</p> <p>相比之下,syslog() 函数将消息附加到计算机系统的通用 syslog 文件中。表 24-3 显示了该文件在 macOS、Unix 和 Windows 上的默认名称和位置。</p> <p>表 24-3:Syslog 文件的默认名称和位置</p> <table> <thead> <tr> <th>操作系统</th> <th>文件名</th> <th>位置</th> </tr> </thead> <tbody> <tr> <td>macOS</td> <td>system.log</td> <td>/var/log</td> </tr> <tr> <td>Unix</td> <td>syslog</td> <td>/etc</td> </tr> <tr> <td>Windows</td> <td>SysEvent.evt</td> <td>C:\WINDOWS\system32\config\</td> </tr> </tbody> </table> <p>在设置应用程序时,选择记录消息的位置可能会很困难:你是想为这个应用程序单独设置日志文件,还是想将 PHP Web 应用程序的日志记录到与其他 PHP 日志相同的位置,或者你希望将应用程序的日志添加到计算机系统的通用日志系统中?正如你将在 第 472 页 的“记录到云端”中看到的,使用第三方日志库提供了更多的选择:选择文件名和位置、使用多个文件记录不同类型的日志,甚至可以记录到 Web API。</p> <p>选择使用哪个函数在一定程度上取决于项目的性质。对于个人项目开发,将日志记录到本地机器可能是合理的,而对于需要报告实时生产系统的关键任务系统,可能更适合将日志记录到 Web 服务器或云端的文件中,甚至可能是你所工作组织的要求和标准所规定的。</p> <p>将日志记录到系统的通用 syslog 文件中的一个优点(就像 PHP 的 syslog() 函数一样)是,所有应用程序和进程的日志都集中在一个地方,因此你可以查看与你的 Web 应用程序相关的其他系统问题(例如内存或处理速度问题)。此外,你可以使用多种应用程序来查看、搜索和分析通用日志系统,无论是 Windows、macOS 还是 Unix。然而,通用日志文件很大,并且不断被正在运行的进程附加内容,因此在开发和运行生产站点时,将 Web 应用程序的日志定向到专用文件(如 error_log() 函数那样)通常是更有意义的。有鉴于此,我们来看看这两个内置的 PHP 日志记录函数是如何工作的。</p> <p>你可以通过写出类似以下的语句,使用 error_log() 将消息记录到 PHP 错误日志文件:</p> <pre><code>error_log('Some event has happened!'); </code></pre> <p>将你想要记录的消息作为参数传递给函数。默认情况下,这条消息将被附加到<em>php.ini</em>设置中指定的文件中。你可以通过命令行使用 cat(macOS/Unix)或 type(Windows)命令查看该文件,后跟文件名。例如,以下是我在 macOS 笔记本上通过之前的 error_log()调用添加到日志文件中的条目(该文件名为<em>php_error.log</em>):</p> <pre><code>$ **cat php_error.log** [28-Jan-2025 22:08:16 UTC] Some event has happened! </code></pre> <p>日志条目以时间戳开始,后跟作为参数传递给函数的消息字符串。</p> <p>syslog()函数接受两个参数。第一个是表示严重性级别的整数(0 到 7)之一,或者是用该整数值声明的常量。这正是之前讨论过的 PHP 内建常量的作用所在。第二个参数是要记录到系统通用 syslog 文件中的字符串消息。以下是调用该函数的示例:</p> <pre><code>syslog(LOG_WARNING, 'warning message from Matt'); </code></pre> <p>我们使用 LOG_WARNING 常量作为第一个参数,PHP 将其定义为值 4,对应于 RFC 5424 严重性等级表中的第五级。此事件需要采取行动,因为它可能导致错误。</p> <p>syslog 文件通常包含数百甚至数千条条目,记录了来自许多系统程序和应用程序的事件和操作。与其显示整个日志文件,过滤出你需要的条目更为有用。对于 macOS 或 Unix,你可以使用 grep 查看包含特定字符串的条目。Windows 有等效的 findstr 命令。以下是使用 grep 查看刚刚通过 syslog()函数创建的日志条目的示例:</p> <pre><code>$ **grep "from Matt" system.log** Jan 28 22:15:15 matts-MacBook-Pro-2 php[4304]: warning message from Matt </code></pre> <p>这里我使用 grep 只显示包含字符串“from Matt”的日志条目。(在 Windows 中,命令将是 findstr "from Matt" SysEvent.evt。)在我的 Apple MacBook 上,syslog()创建的日志条目以格式化日期开头,后跟计算机名称(matts-MacBook-Pro-2)。接下来是附加到日志的程序或服务(在这种情况下是 php),然后是进程 ID(4304),这是操作系统分配给每个活动进程的唯一标识符。最后,条目以传递给 syslog()函数的消息字符串结束。Windows 中的每个 syslog 条目的内容类似,包含事件类型、事件 ID、来源、消息等。</p> <blockquote> <p>注意</p> </blockquote> <p><em>如果你不习惯在命令行查看 syslog 文件,许多应用程序可以用来查看、过滤和分析这些文件。例如,Windows 有事件查看器,而 macOS 有控制台。</em></p> <h3 id="monolog-日志库">Monolog 日志库</h3> <p>在 Web 应用程序中,日志记录非常常见,因此存在多个第三方 PHP 库来帮助处理日志,其中包括流行的 Monolog 库。大多数 PHP Web 框架和云日志系统都与 Monolog 提供集成。它通常是许多 PHP 程序员学习使用的第一个日志系统,有时也是唯一一个。这个库使得开发定制化、复杂的日志记录策略变得更加容易,不同类型的日志条目会以不同的方式处理,日志消息可以记录到各种位置,包括本地文件、基于云的系统等。</p> <p>Monolog 遵循 PSR-3,这是一个针对 PHP 日志系统的标准推荐。该标准使用与 RFC 5424 syslog 标准相同的八个日志严重性级别。为了符合 PSR-3 标准,日志接口应为每个日志级别提供方法。每个方法应要求一个字符串参数,该字符串包含要记录的消息,并且可以选择性地提供一个数组,以提供有关消息上下文的更多信息。</p> <blockquote> <p>注意</p> </blockquote> <p><em>Monolog 的源代码和文档可以在 GitHub 上找到,网址是</em> <a href="https://github.com/Seldaek/monolog" target="_blank"><code>github.com/Seldaek/monolog</code></a><em>,你还可以在</em> <a href="https://www.php-fig.org/psr/psr-3/" target="_blank"><code>www.php-fig.org/psr/psr-3/</code></a>* <em>了解更多关于 PSR-3 标准的内容。</em></p> <p>让我们创建一个使用 Monolog 记录消息的示例项目。创建一个新的项目文件夹,然后使用命令 composer require monolog/monolog 来添加 Monolog 库。现在,你应该有一个 <em>composer.json</em> 文件和一个包含自动加载器和 Monolog 库类的 <em>vendor</em> 文件夹。接下来,在 <em>public/index.php</em> 中创建一个包含 列表 24-1 代码的索引脚本。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Monolog\Logger; use Monolog\Handler\StreamHandler; $logFile = __DIR__ . '/../logs/mylogs.log'; $logger = new Logger('demo'); $logger->pushHandler(new StreamHandler($logFile)); ❶ $logger->warning('I am a warning.'); $logger->error('I am a test error!'); </code></pre> <p>列表 24-1:在 public/index.php 中设置和使用 Monolog</p> <p>如常,我们的索引脚本通过加载自动加载器脚本开始。然后我们为 Monolog 的 Logger 和 StreamHandler 类提供使用声明。接下来,我们声明了一个指向 <em>logs</em> 文件夹中的 <em>mylogs.log</em> 文件的路径,用于记录日志消息,但你也可以提供任何你想要的文件路径。第一次 Monolog 尝试将消息附加到这个文件时,它会在文件和目录不存在时创建它们。</p> <p>接下来,我们创建一个新的 Logger 对象来管理日志,提供频道名称 demo。我们将在下一节中探讨频道及其用途。每个 Logger 对象都需要一个或多个 <em>log handler</em> 类来告诉它如何处理日志条目,因此我们还通过调用 Logger 对象的 pushHandler() 方法来创建一个日志处理程序,并传入 Monolog 的 StreamHandler 类的新对象。这个类用于将消息记录到文件中(在我们的例子中,是 $logFile 变量指定的 <em>logs/mylogs.log</em> 文件),但 Monolog 也有其他处理程序类用于执行其他操作,例如记录到浏览器、云 API 或数据库。我们将在“记录到云端”一节中讨论另一个日志处理程序,见 第 472 页。</p> <p>由于 Monolog 遵循 PSR-3 标准,Logger 对象提供了用于记录每个八个标准严重性级别的日志消息的方法。我们使用了其中的两个方法。首先,我们使用 warning() 方法创建一个警告日志条目,内容为 'I am a warning.'❶ 然后,我们使用 error() 方法创建一个错误日志条目,内容为 'I am a test error!'</p> <p>执行 index 脚本后,<em>logs/mylogs.log</em> 文件的内容应类似于以下内容:</p> <pre><code>[2025-01-28T23:26:51.686815 + 00:00] demo.WARNING: I am a warning. [] [] [2025-01-28T23:26:51.688375 + 00:00] demo.ERROR: I am a test error! [] [] </code></pre> <p>请记住,你可以通过 cat(macOS 和 Unix)或 type(Windows)在命令行查看文件。</p> <p>注意,每个由 Monolog 生成的日志条目都以时间戳开始,后跟通道名称和严重性级别(例如 demo .WARNING),然后是日志消息。每个日志条目末尾的空方括号表示没有提供额外的信息。我们将在“记录异常”部分的 第 469 页 中添加更多关于日志消息上下文的信息。</p> <h4 id="使用通道组织日志">使用通道组织日志</h4> <p>较大的系统被组织成定义明确的子系统,知道哪些子系统生成了哪些日志条目对调试、错误追踪和代码评估有很大帮助。Monolog 通过为每个 Logger 对象分配通道名称,使这一点成为可能。通过创建具有唯一通道名称的多个 Logger 对象,你可以根据日志条目的来源来组织日志。例如,一个在线商店可能会有像 security、database 和 payments 这样的通道,用于记录不同种类的系统事件。</p> <p>在上一节中,我们创建了一个 Logger 对象,并将其设置为 demo 通道的一部分,我们还看到该通道名称被包含在每个日志条目中。现在,让我们修改项目,以区分两个通道:demo 和 security。更新 <em>public/index.php</em> 以匹配 清单 24-2 的内容。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Monolog\Logger; use Monolog\Handler\StreamHandler; $logFile = __DIR__ . '/../logs/mylogs.log'; $demoLogger = new Logger('demo'); $demoLogger->pushHandler(new StreamHandler($logFile)); ❶ $securityLogger = $demoLogger->withName('security'); $demoLogger->error('I am a test error!'); $securityLogger->warning('invalid username entered'); </code></pre> <p>清单 24-2:在 index.php 中将日志记录到两个独立的通道</p> <p>我们为名为 demo 的通道创建了一个新的 Logger 对象 $demoLogger,并将其日志处理器设置为 StreamHandler,指向 <em>logs/mylogs.log</em> 文件。然后我们创建了一个名为 security 的通道的第二个 Logger 对象。接下来,我们使用 $demoLogger 对象的 withName() 方法,创建一个通道名为 security 的克隆对象 ❶。这样,我们就避免了重新创建第二个 Logger 对象及其日志处理器(该处理器指向与 $demoLogger 相同的文件)。</p> <p>现在我们有两个 Logger 对象,$demoLogger(通道名为 demo)和 $securityLogger(通道名为 security)。这两个 Logger 对象使用相同的日志处理器,将日志写入 <em>logs/mylogs.log</em> 文件。根据我们使用的 Logger 对象,可以确保日志条目被标记上适当的通道名称,从而有助于后续的日志文件分析。我们通过将消息记录到每个通道来完成脚本。最终的 <em>logs/mylogs.log</em> 文件内容应类似于以下内容:</p> <pre><code>[2025-01-30T08:54:05.091158 + 00:00] demo.ERROR: I am a test error! [] [] [2025-01-30T08:54:05.092702 + 00:00] security.WARNING: invalid username entered [] [] </code></pre> <p>请注意,错误日志条目被发送到演示频道,而警告日志条目则发送到安全频道。我们可以通过使用 Unix 的 grep 命令或 Windows 的 findstr 命令过滤日志文件,仅显示某一频道的条目。例如,我们可以通过在 Windows 命令终端输入 findstr "security." logs/mylogs.log 来搜索安全频道的条目。</p> <h4 id="根据严重性管理日志">根据严重性管理日志</h4> <p>除了将条目按频道分类外,我们还可以通过根据日志条目的严重性等级对其进行不同的处理,进一步提升日志记录策略的复杂性。Monolog 可以通过将多个日志处理器(统称为<em>栈</em>)添加到同一个 Logger 对象来实现这一点。当我们添加日志处理器时,可以选择指定它适用于哪些严重性等级。例如,我们可以为三个最严重的等级设置一个日志处理器,该处理器与 Web API 配合使用,自动通过短信通知 IT 工作人员立即处理问题。第二个日志处理器则可以响应较低严重性的日志条目,并将消息记录到日志文件中。</p> <p>Monolog 处理器还有一个可选功能,称为<em>冒泡</em>,它允许日志条目由一个处理器处理,并且也会被传递(<em>冒泡</em>)到栈下,由其他日志处理器再次处理。例如,除了高严重性的日志条目会触发自动消息发送到 IT 工作人员的手机外,这些相同的日志条目也可以与低严重性的条目一起存储到日志文件中,以供归档和分析。图 24-1 展示了一个使用冒泡机制并根据严重性管理日志条目的日志处理器栈的示例。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure24-1.jpg" alt="" loading="lazy"></p> <p>图 24-1:使用冒泡机制和严重性等级创建复杂的日志记录策略</p> <p>该图展示了一个由三个日志处理器组成的栈。在栈的顶部,handler1 捕获任何严重性为临界或更高(严重性等级 0 到 2)的日志条目,并将其记录在<em>immediateActions.log</em>中。这个第一个处理器启用了冒泡机制,因此高严重性的日志条目也会进一步传递到栈下进行额外处理。</p> <p>栈中的下一个是 handler2,它捕获所有重要性为信息或更高(等级 0 到 6)的日志条目,并将其记录在<em>infoActions.log</em>中。由于冒泡机制,高严重性日志条目因此会被记录在两个独立的文件中。handler2 的冒泡功能被关闭,因此该处理器处理的任何日志条目都不会传递到栈下继续处理。因此,唯一到达日志处理栈底部的日志条目是严重性等级为 7(调试)的条目。这些条目会被 handler3 接收,并记录在<em>debug.log</em>中。请注意,handler3 被设置为接收任何严重性等级的日志条目,但实际上它只会接收调试条目,因为所有其他严重性等级的日志条目都停留在 handler2 处。</p> <p>让我们修改项目,实施这三个日志处理程序的堆栈。为了确保堆栈按预期工作,我们将为所有八个严重性级别生成日志条目,并检查三个日志的内容。更新<em>public/index.php</em>,如清单 24-3 所示。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Monolog\Logger; use Monolog\Handler\StreamHandler; ❶ use Monolog\Level; ❷ $immediateActionFile = __DIR__ . '/../logs/immediateActions.log'; $logFile = __DIR__ . '/../logs/infoActions.log'; $debugFile = __DIR__ . '/../logs/debug.log'; ❸ $handler1 = new StreamHandler($immediateActionFile, Level::Critical); $handler2 = new StreamHandler($logFile, Level::Info, false); $handler3 = new StreamHandler($debugFile); ❹ $logger = new Logger('logger'); $logger->pushHandler($handler3); $logger->pushHandler($handler2); $logger->pushHandler($handler1); ❺ $logger->emergency('0 emergency'); $logger->alert('1 alert'); $logger->critical('2 critical'); $logger->error('3 error'); $logger->warning(' 4 warning'); $logger->notice(' 5 notice'); $logger->info('6 info'); $logger->debug('7 debug'); </code></pre> <p>清单 24-3:在 index.php 中使用三个日志处理程序的堆栈按严重性级别管理日志条目</p> <p>首先,我们添加了一个 use 语句来让我们访问 Monolog\Level 类中的常量❶。我们声明了三个变量,分别对应<em>immediateActions.log</em>、<em>infoActions.log</em>和<em>debug.log</em>的文件路径❷。然后我们创建了三个引用 StreamHandler 对象的变量❸。这些将是我们堆栈中的三个日志处理程序。</p> <p>对于第一个<span class="math inline">\(handler1,我们传递即时操作日志文件的路径,并使用常量 Level::Critical 作为第二个参数,将此处理程序分配给 Critical 或更高重要性的条目。处理程序默认启用冒泡机制。我们为\)</span>handler2 提供信息操作文件的路径,并使用 Level::Info 将其分配给 Info 级别或更高的日志条目(所有日志,除调试条目外)。第三个参数 false 关闭了$handler2 的冒泡机制。</p> <p>要创建$handler3,我们只需传递调试日志文件的路径并省略其他参数。默认情况下,所有日志条目将由此处理程序处理,并启用冒泡机制。然而,处理程序只会接收调试级别的条目,并且由于它位于堆栈的底部,因此没有其他日志处理程序可供日志条目冒泡到。</p> <p>接下来,我们创建一个新的 Logger 对象❹并逐一将所有三个日志处理程序分配给它。当多个处理程序被添加到同一个 Logger 对象时,最后添加的一个会被认为是堆栈的顶部,并且将优先处理所有日志条目。因此,我们以相反的顺序添加处理程序,从<span class="math inline">\(handler3 开始,最后是\)</span>handler1。最后,我们记录了八条消息❺,每条消息对应一个严重性级别,并附上了确认级别编号和名称的消息。</p> <p>执行 index 脚本后,<em>logs/immediateActions.log</em>文件应类似于以下内容:</p> <pre><code>[2025-02-13T10:50:52.818515 + 00:00] logger.EMERGENCY: 0 emergency [] [] [2022-02-13T10:50:52.820236 + 00:00] logger.ALERT: 1 alert [] [] [2022-02-13T10:50:52.820352 + 00:00] logger.CRITICAL: 2 critical [] [] </code></pre> <p>只有 Critical、Alert 和 Emergency 日志由位于日志处理程序堆栈顶部的$handler1 处理并写入<em>immediateActions.log</em>。以下是<em>logs/infoActions.log</em>的内容:</p> <pre><code>[2025-02-13T10:50:52.818515 + 00:00] logger.EMERGENCY: 0 emergency [] [] [2025-02-13T10:50:52.820236 + 00:00] logger.ALERT: 1 alert [] [] [2025-02-13T10:50:52.820352 + 00:00] logger.CRITICAL: 2 critical [] [] [2025-02-13T10:50:52.820454 + 00:00] logger.ERROR: 3 error [] [] [2025-02-13T10:50:52.820509 + 00:00] logger.WARNING: 4 warning [] [] [2025-02-13T10:50:52.820563 + 00:00] logger.NOTICE: 5 notice [] [] [2025-02-13T10:50:52.820617 + 00:00] logger.INFO: 6 info [] [] </code></pre> <p>所有从级别 0 到 6 的日志都由位于日志处理程序堆栈中间的<span class="math inline">\(handler2 处理并写入*infoActions.log*。由于我们已经在*immediateActions.log*中看到来自\)</span>handler1 的级别 0、1 和 2 的日志,因此在<em>infoActions.log</em>中再次看到它们,确认了冒泡机制的正常工作,允许这些日志也被$handler2 接收。最后,以下是<em>logs/debug.log</em>文件的内容:</p> <pre><code>[2025-02-13T10:50:52.820672 + 00:00] logger.DEBUG: 7 debug [] [] </code></pre> <p>只有级别 7(调试)的条目可以在<em>debug.log</em>中看到。这表明位于堆栈底部的$handler3 只接收了这一单一的日志条目。</p> <h3 id="记录异常">记录异常</h3> <p>日志的一个常见用途是记录程序执行过程中发生的异常。在第二十三章中,我们探讨了如何通过 try...catch 语句组织程序:try 语句包含正常情况下应该执行的代码,catch 语句用于处理异常。当应用程序使用日志时,异常会作为 catch 语句的一部分被记录。</p> <p>让我们创建一个简单的单类项目,来说明如何做到这一点。我们的项目将有一个 Product 类,当我们尝试创建一个价格为负的 Product 对象时,它会抛出异常。我们将使用 Monolog 将这些异常记录到<em>logs/debug.log</em>文件中。我们将首先声明 Product 类。创建一个新项目,并在<em>src/Product.php</em>中编写清单 24-4 中的代码。</p> <pre><code><?php namespace Mattsmithdev; class Product { private string $name; private float $price; public function __construct(string $name, float $price) { ❶ if ($price < 0) { throw new \Exception( 'attempting to set price to a negative value'); } $this->price = $price; $this->name = $name; } } </code></pre> <p>清单 24-4:一个抛出异常的 Product 类</p> <p>我们在 Mattsmithdev 命名空间中声明了 Product 类,并为其定义了两个私有属性,name 和 price。类的构造方法接收<span class="math inline">\(name 和\)</span>price 值,用于创建新对象。在构造函数中,我们验证$price 参数的值,如果其值为负,则抛出异常❶。对于这个简单的例子,我们使用 PHP 的根 Exception 类。</p> <p>我们现在需要创建一个<em>composer.json</em>文件来自动加载该类。清单 24-5 展示了如何操作。</p> <pre><code>{ "autoload": { "psr-4": { "Mattsmithdev\\": "src" } } } </code></pre> <p>清单 24-5:composer.json 文件</p> <p>接下来,在命令行中使用 Composer 生成自动加载器脚本,并将 Monolog 库添加到项目中:</p> <pre><code>$ **composer dump-autoload** $ **composer require monolog/monolog** </code></pre> <p>现在,我们需要编写一个 index 脚本,尝试创建一个 Product 对象,并在尝试失败时记录异常。创建<em>public/index.php</em>,其内容与清单 24-6 一致。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Product; use Monolog\Logger; use Monolog\Handler\StreamHandler; $debugFile = __DIR__ . '/../logs/debug.log'; $logger = new Logger('demo'); $logger->pushHandler(new StreamHandler($debugFile)); try { $p1 = new Product('hammer', -1); } catch (\Exception $e) { ❶ $logger->error('problem creating new product', ['exception' => $e]); } </code></pre> <p>清单 24-6:在 index.php 中尝试创建无效的 Product 对象</p> <p>首先,我们读取并执行自动加载器,并为所需的类添加 use 语句。然后,我们通过创建一个变量来设置日志记录的<em>logs/debug.log</em>文件路径,创建一个新的 Logger 对象,为其命名为 demo 的频道,并为其指定日志处理器。接下来,在 try 块中,我们创建一个新的 Product 对象,传递-1 作为价格。在相关的 catch 块中,如果创建产品失败,我们使用 Logger 对象记录一个 Error 级别的日志条目❶。</p> <p>除了提供日志消息('创建新产品时出问题'),我们还将一个数组作为第二个参数来记录额外的信息。具体来说,我们传递整个 Exception 对象$e,并将其指定为键 exception。在 Monolog 文档中,这个可选的数组被称为日志条目的<em>上下文</em>。它可以包含多个元素,并可以自定义键,这在查看日志并分析模式时非常有用。</p> <p>执行 index 脚本后,<em>logs/debug.log</em>文件应该如下所示:</p> <pre><code>[2025-01-25T11:48:46.813377 + 00:00] demo.ERROR: problem creating new product {"exception":"[object] (Exception(code: 0): attempting to set price to a negative value at /Users/matt/src/Product.php:15)"} [] </code></pre> <p>已向演示通道的日志文件添加了一个错误级别的日志,消息为 "创建新产品时出现问题"。该日志条目还包含由 Product 构造方法抛出的异常对象的详细信息,包括与异常相关的消息(尝试将价格设置为负值)和触发异常的代码位置。 ### 云日志记录</p> <p>到目前为止,我们一直在将消息记录到文件中,但大多数大型 Web 应用程序会将日志记录到专用的基于云的日志系统,而不是服务器上的文件。一个流行的云日志系统是 Mezmo(之前称为 LogDNA)。使用如 Mezmo 这样的云日志 API 提供了许多好处,包括日志的历史存储、强大的过滤和搜索功能,以及全面的分析和报告功能。像 Mezmo 这样的云日志 API 还可以与警报通知系统(如 Atlassian 的 Opsgenie)连接,发送需要立即处理的日志条目的电子邮件或短信警报。</p> <p>让我们创建一个向 Mezmo 发送日志条目的项目。我们将记录到两个独立的通道,并尝试每个严重级别的日志条目。首先,访问 Mezmo 网站(* <a href="https://www.mezmo.com" target="_blank"><code>www.mezmo.com</code></a> *),并创建一个免费账户。记下在账户详情中为你创建的唯一十六进制 Mezmo 摄取密钥;你将在脚本中引用它。</p> <p>要在 PHP 代码中与 Mezmo 交互,我们将使用由 Nicolas Vanheuverzwijn 维护的 monolog-logdna 包。这个包为 Monolog 添加了 Mezmo API 通信功能。创建一个新的项目文件夹,并通过在命令行输入 composer require nvanheuverzwijn/monolog-logdna 来添加该包。你现在应该拥有一个 <em>composer.json</em> 文件和一个 <em>vendor</em> 文件夹,其中包含自动加载器以及用于记录到 Mezmo API 的 Monolog 和其他库类。现在,在 <em>public/index.php</em> 中创建一个包含 Listing 24-7 中代码的索引脚本。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Monolog\Logger; use Zwijn\Monolog\Handler\LogdnaHandler; ❶ $INGESTION_KEY='your-MEZMO-ingestion-key-goes-here'; $generalLogger = new Logger('general'); $handler = new LogdnaHandler($INGESTION_KEY, 'host-mgw.com'); $generalLogger->pushHandler($handler); ❷ $generalLogger->emergency('0 emergency'); $generalLogger->alert('1 alert'); $generalLogger->critical('2 critical'); $generalLogger->error('3 error'); $generalLogger->warning(' 4 warning'); $generalLogger->notice(' 5 notice'); $generalLogger->info('6 info'); $generalLogger->debug('7 debug'); ❸ $securityLogger = $generalLogger->withName('security'); $securityLogger->debug('7 debug - from security channel', ['context-1' => 'some data']); </code></pre> <p>Listing 24-7:在 public/index.php 中设置和使用 Monolog</p> <p>我们提供了 Monolog 的 Logger 类和 LogDnaHandler 的 use 语句,这是记录到 Mezmo 所需的日志处理器。然后,我们声明一个变量,用于存储所需的 Mezmo 摄取密钥;务必在此处填写你自己的密钥 ❶。接下来,我们创建一个名为 $generalLogger 的新 Logger 对象,提供 "general" 作为通道名称,并为其设置日志处理器,传递摄取密钥并将日志主机源命名为 host-mgw.com(通常是 My Great Website 的缩写)。不同的 Web 应用程序或子站点可以在其处理器中使用不同的主机名,以进一步区分日志的来源。</p> <p>我们将八条消息记录到 $generalLogger 对象 ❷ 中,每条消息对应一个严重级别,并附带确认该级别编号和名称的消息。然后,我们通过使用 withName() 方法创建一个名为 $securityLogger 的 $generalLogger 对象的克隆,并将频道名称设置为 security ❸。这两个 Logger 对象使用相同的日志处理程序,因此可以将日志发送到 Mezmo API。我们使用第二个对象记录一个调试条目,传递一个包含 'context-1' 键和 'some data' 数据字符串的单元素数组作为第二个参数。这测试了我们如何在日志条目中记录额外数据。</p> <p>图 24-2 显示了我们执行的索引脚本的日志,这些日志已接收并显示在 Mezmo 网站上。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure24-2.jpg" alt="" loading="lazy"></p> <p>图 24-2:Mezmo 云服务上的日志条目</p> <p>Mezmo 显示来自 general 和 security 频道的时间戳日志,所有条目来自 host-mgw。每个条目都标有其严重级别。最后一条日志(发送到 security 频道)的详细信息在图中已展开,显示了我们通过数组传递给 Logger 对象的上下文数据。</p> <h3 id="总结-16">总结</h3> <p>如你在本章中所见,你可以通过多种方式为 Web 应用程序创建日志,从简单的 error_log() 函数调用到复杂的 Monolog 开源日志库包,再到像 Mezmo 这样的 API 用于云存储和分析。每个项目的规模和重要性将决定最合适的方式,但对于几乎所有需要质量保证和维护的项目,你可能需要采用某种形式的日志记录来记录和管理错误与异常,并收集有关系统使用和性能的历史数据。</p> <h3 id="练习-22">练习</h3> <p>1.   通过使用 syslog() 和 error_log() 函数创建消息条目。在你的计算机系统中找到这些函数写入的文件,并查看日志文件中的消息。</p> <p>2.   创建一个新项目,并使用 Composer 添加 Monolog 包。在你的索引脚本中,为名为 general 的频道创建一个新的 Logger 对象,并添加一个 StreamHandler,将日志追加到<em>logs/mylogs.log</em>文件中。记录几条不同严重级别的日志条目,并在执行索引脚本后查看日志文件中的日志条目。</p> <p>3.   创建一个新项目,使用两个处理程序的堆栈:handler1(追加到<em>urgent.log</em>文件)和 handler2(追加到<em>other.log</em>文件)。首先添加 handler2,这样 handler1 将位于堆栈的顶部。关闭 handler1 的冒泡,并将其配置为捕捉所有严重级别为 Critical 或更高的日志条目。生成所有八个严重级别的日志条目。你应该能在<em>urgent.log</em>中看到严重级别为 0、1 和 2 的日志条目,在<em>other.log</em>中看到所有其他(3 到 7)级别的日志条目。</p> <p>4.   在像 Mezmo 这样的云日志网站上创建一个账户,并更新第 3 题中的项目,将日志条目发送到该网站的 API。在线查看日志,确认你的程序通过 API 成功发送了日志。</p> <h2 id="第二十五章25-静态方法属性和枚举">第二十五章:25 静态方法、属性和枚举</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>在本章中,我们将探索 <em>静态成员</em>。与我们迄今使用的实例级别的属性和方法不同,静态属性和方法是通过类整体访问的。因此,您不需要创建类的对象就可以使用其静态成员。</p> <p>我们将讨论如何使用静态成员,并展示它们在存储类所有实例信息或在整个应用程序中共享资源等情况中的有用性。我们还将简要介绍枚举,它提供了一种列出数据类型所有可能值的方式。</p> <h3 id="存储类级别信息">存储类级别信息</h3> <p>静态成员的一个常见用途是跟踪有关类的所有实例的信息。当需要向类的所有对象发送消息,或者当计算必须基于类的当前实例时,这非常有用。考虑图 25-1 中描述的 AudioClip 类。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure25-1.jpg" alt="" loading="lazy"></p> <p>图 25-1:带有静态成员的 AudioClip 类</p> <p>AudioClip 类具有实例成员,用于存储有关给定音频文件的详细信息。这些成员包括标题(title)和持续时间(durationSeconds)属性,以及它们相关的 getter 和 setter 方法。构造函数和 __toString() 方法也是实例成员,因为它们与创建或总结特定对象的内容有关。与此同时,该类有一个 maxClipDurationSeconds 属性,用于跟踪所有已创建的 AudioClip 对象的最大持续时间。由于该属性包含与类的所有对象相关的信息,因此它是静态成员的良好示例(在图中通过下划线表示)。该属性的 getter 方法也应该是静态的。</p> <p>为了展示静态成员如何有助于存储类级别信息,我们将声明 AudioClip 类并创建它的三个实例。该图表显示了每个实例将拥有自己的标题和持续时间属性值。例如,clip1 的标题为 'Hello World',持续时间为 2 秒。</p> <p>创建一个新的项目文件夹,并添加常规的 <em>composer.json</em> 文件,声明 Mattsmithdev 命名空间类位于 <em>src</em> 文件夹中。通过在命令行中输入 composer dump-autoload 来生成自动加载文件。然后通过创建 <em>src/AudioClip.php</em> 文件并输入清单 25-1 的内容来声明 AudioClip 类。</p> <pre><code><?php namespace Mattsmithdev; class AudioClip { // --- Static (per-class) members --- ❶ private static int $maxClipDurationSeconds = -1; ❷ public static function getMaxClipDurationSeconds(): int { return self::$maxClipDurationSeconds; } // --- Object (instance) members --- ❸ private string $title; private int $durationSeconds = 0; ❹ public function __construct(string $title, int $durationSeconds) { $this->setTitle($title); $this->setDurationSeconds($durationSeconds); } public function getTitle(): string { return $this->title; } public function setTitle(string $title): void { $this->title = $title; } public function getDurationSeconds(): int { return $this->durationSeconds; } ❺ public function setDurationSeconds(int $durationSeconds): void { // Exit with no action if negative if ($durationSeconds < 0) return; $this->durationSeconds = $durationSeconds; if ($durationSeconds > self::$maxClipDurationSeconds) { self::$maxClipDurationSeconds = $durationSeconds; } } ❻ public function __toString(): string { return "(AudioClip) $this->title ($this->durationSeconds seconds) \n"; } } </code></pre> <p>清单 25-1:AudioClip 类</p> <p>我们通过使用 static 关键字声明 maxClipDurationSeconds,以指定这是一个独立于任何 AudioClip 类对象存在的静态成员❶。我们将其初始化为-1,确保无论第一个被创建的 AudioClip 对象的持续时间是多少,它的持续时间都大于-1,并将被存储在这个静态属性中。稍后,我们将看到这是如何在 setDurationSeconds()设置方法中完成的。</p> <p>我们将 maxClipDurationSeconds 设置为私有,但声明了一个公共的 getter 方法 getMaxClipDurationSeconds(),它也是静态的❷。将这个方法设为公共方法,允许类内外的代码查询自程序或请求启动以来创建的最长 AudioClip 对象的值。稍后我们会探讨这意味着什么,以及这个属性是如何被使用的。</p> <p>接下来,我们为每个 AudioClip 对象声明两个实例级别的属性,title 和 durationSeconds❸,其中后者的默认值为 0,以确保即使在构造时提供无效参数,它也会被设置。类的构造方法❹接受这两个属性的初始值,并通过调用相应的 setter 方法将它们设置到对象中。</p> <p>实例级别的访问器方法都很直接,除了 setDurationSeconds(),它有自定义的验证逻辑❺。一个片段的持续时间不应该是负数,因此我们首先测试这一点,如果提供了负值,则使用 return 停止方法的执行,不再进行其他操作。如果我们通过了这个检查,我们知道提供的参数是 0 或正值,因此我们将其存储在对象的 durationSeconds 属性中。</p> <p>然后,我们检查对象的新持续时间是否大于存储在 maxClipDurationSeconds 静态属性中的值。如果是,我们更新这个属性,使其等于新的持续时间。由于 maxClipDurationSeconds 初始时的哨兵值为-1,并且由于我们在方法开始时进行的验证,我们知道不会有任何 AudioClip 对象的持续时间为负数。因此,一旦第一个有效持续时间的片段被创建,这个静态属性将被相应地设置。</p> <p>请注意,我们必须使用 self::来访问 maxClipDurationSeconds 属性。这个<em>作用域解析运算符</em>(::)是访问同一类内静态成员的语法。</p> <p>我们通过 __toString()方法❻完成了类的声明。它返回一个字符串,概述了一个 AudioClip 对象的内容,格式为 (AudioClip) title (durationSeconds 秒)。</p> <p>现在,让我们通过一个索引脚本来使用我们的类,创建 AudioClip 对象并输出静态 maxClipDurationSeconds 属性的变化值。创建<em>public/index.php</em>,如 Listing 25-2 所示。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\AudioClip; print '- Max AudioClip duration so far = ' . AudioClip::getMaxClipDurationSeconds() . PHP_EOL; $clip1 = new AudioClip('hello world', 2); print $clip1; print '- Max AudioClip duration so far = ' . AudioClip::getMaxClipDurationSeconds() . PHP_EOL; $clip2 = new AudioClip('bad duration', -10); print $clip2; print '- Max AudioClip duration so far = ' . AudioClip::getMaxClipDurationSeconds() . PHP_EOL; $clip3 = new AudioClip('My Way', 275); print $clip3; print '- Max AudioClip duration so far = ' . AudioClip::getMaxClipDurationSeconds() . PHP_EOL; </code></pre> <p>Listing 25-2:index.php 脚本</p> <p>首先,我们打印一条信息,显示通过公共静态方法 getMaxClipDurationSeconds()访问的 maxClipDurationSeconds 的值。由于我们还没有实例化任何 AudioClip 对象,该属性的初始值应为-1。注意,我们必须使用 AudioClip::前缀来引用静态成员,而不是 self::,因为我们是在索引脚本中编写代码,而不是在静态成员的类内部。然后,我们创建三个 AudioClip 对象,<span class="math inline">\(clip1、\)</span>clip2 和$clip3,打印每个对象并再次显示 maxClipDurationSeconds 的值。</p> <p>运行索引脚本的输出如下:</p> <pre><code>- Max AudioClip duration so far = -1 (AudioClip) hello world (2 seconds) - Max AudioClip duration so far = 2 (AudioClip) bad duration (0 seconds) - Max AudioClip duration so far = 2 (AudioClip) My Way (275 seconds) - Max AudioClip duration so far = 275 </code></pre> <p>最大时长的初始值为-1,但每当创建一个时长非负的 AudioClip 对象时,如果新剪辑的时长更长,这个值就会通过 setDurationSeconds()方法中的逻辑更新。在创建 hello world 对象后,最大时长从-1 变为 2。创建具有无效时长-10 的 bad duration 对象后,值没有变化。(从对象的打印输出中可以看到,它的时长被存储为 0,即默认值,而不是-10,这得益于我们在 setDurationSeconds()方法中的初始验证逻辑。)最后,在创建 My Way 对象后,最大时长更新为 275。</p> <p>我们更新 maxClipDurationSeconds 的逻辑对于演示目的来说效果不错,但实际上这不是跟踪最长 AudioClip 对象的好方法,因为它假设所有对象在整个应用程序运行期间都存在。假设我们决定不再需要其中一个音频剪辑(用户可能选择将其从列表中删除)。目前的逻辑没有提供回滚最大剪辑时长的方式,如果该剪辑被删除的话。一种更好的方法可能是维护一个活动 AudioClip 对象的数组。每次删除一个剪辑时,我们可以遍历数组并重新计算最长活动剪辑的时长。### 静态属性与类常量</p> <p>静态属性不应与类常量混淆。在这两种情况下,静态属性或类常量只有一个副本存在于类本身,而不是每个类实例有一个独立的副本。然而,类常量是不可变的,因此其值永远不会改变。相比之下,静态属性的值是可以变化的(正如你在 maxClipDurationSeconds 中看到的那样),就像对象的普通属性值一样。从这个角度来看,<em>静态</em>这个术语可能会有点误导。所有 PHP 面向对象的属性,无论是每个对象实例的属性还是每个类的静态属性,都会以美元符号开头,这使得它们与常量区分开来。</p> <p>当你有一个值应该适用于类的所有对象并且永远不会改变时,请使用类常量。我们之前已经遇到过类常量;一个例子是用于创建 Twig\Environment 对象进行模板渲染的 PATH_TO_TEMPLATES 常量。在这种情况下,模板文件夹的文件路径永远不应更改,并且应适用于所有的 Twig\Environment 对象。类常量的其他使用场景包括定义特殊值,比如将学校成绩点平均值(GPA)的最高分设置为 4.0,或者将 pH 酸碱度尺度的中性值设置为 7.0。</p> <p>在我们的 AudioClip 类的情况下,我们可能会有类常量来定义诸如音频文件的通道数或采样率等细节,假设这些参数在所有音频剪辑中都是标准的。为了探索类常量和静态属性之间的区别,我们现在将在 AudioClip 项目中添加一些类常量。图 25-2 展示了更新后的 AudioClip 类的图示。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure25-2.jpg" alt="" loading="lazy"></p> <p>图 25-2:向 AudioClip 类添加类常量和额外方法</p> <p>图示显示,AudioClip 类现在将提供四个公共类常量,这些常量在类图中标记为 {只读}。对于我们的项目,我们将对类所表示的音频剪辑做出一些假设:它们都是立体声(两个通道),每个样本存储 16 位(CD 质量的数字音频)。每秒的样本数将始终为 44,100,这是一个常见的音频采样率。这些值由 AudioClip 类常量 NUM_CHANNELS、BITS_PER_SAMPLE 和 SAMPLES_PER_SECOND 表示,并一起用于计算 BITS_PER_SECOND 常量。</p> <blockquote> <p>注意</p> </blockquote> <p><em>对于更现实的示例,这些常量可以被实例属性替代,因为并非所有音频剪辑都可能符合常量所设定的标准。</em></p> <p>我们还将添加一个新的 getSizeBits() 方法,该方法使用类常量来计算存储音频剪辑所需的内存或磁盘上的位数。此外,我们还将把这个新方法的值作为对象摘要的一部分,通过 __toString() 方法返回。</p> <p>要实现这些更改,请按照 清单 25-3 中所示编辑 <em>src/AudioClip.php</em> 文件。</p> <pre><code><?php namespace Mattsmithdev; class AudioClip { const NUM_CHANNELS = 2; const BITS_PER_SAMPLE = 16; const SAMPLES_PER_SECOND = 44100; const BITS_PER_SECOND = self::NUM_CHANNELS * self::BITS_PER_SAMPLE * self::SAMPLES_PER_SECOND; --snip-- public function getSizeBits(): int { return self::BITS_PER_SECOND * $this->durationSeconds; } ❶ public function __toString(): string { $numBitsFormatted = number_format($this->getSizeBits()); return "(AudioClip) $this->title " . "($this->durationSeconds seconds), $numBitsFormatted bits \n"; } } </code></pre> <p>清单 25-3:向 AudioClip 类添加常量和 getSizeBits() 方法</p> <p>在声明类时,通常会先列出常量,再列出任何属性和方法,因此我们通过声明常量 NUM_CHANNELS、BITS_PER_SAMPLE 和 SAMPLES_PER_SECOND 来开始 AudioClip 类的定义,其值如前所述。然后我们声明了 BITS_PER_SECOND,它是一个 <em>计算常量</em> 的例子,因为其值是根据其他常量的值确定的,而不是直接设置的。通过预先计算这个值并将其存储为常量,我们避免了每次需要将音频的秒数转换为数据的位数时都要重复计算。请注意,在 BITS_PER_SECOND 计算中使用了 self:: 来访问其他常量。这种语法用于在类内部访问类常量,就像访问静态成员一样。</p> <p>接下来,我们声明了 getSizeBits(),这是每个 AudioClip 对象的一个有用的额外获取器方法。它返回一个整数,表示存储音频片段所需的位数,通过将预先计算的 BITS _PER_SECOND 常量乘以对象的 durationSeconds 属性来获得。我们还更新了 __toString() 方法 ❶,总结了 AudioClip 对象的内容,包括其位数大小,感谢我们新方法的加入。请注意,我们使用了内置的 number_format() 函数来创建一个临时的 $numBitsFormatted 字符串变量。使用函数的默认设置,它会在每三位数字之间创建逗号分隔符,从而使数字的表示更加易读。</p> <p>这是重新运行索引脚本后的终端输出(索引脚本无需更改):</p> <pre><code>- Max AudioClip duration so far = -1 (AudioClip) hello world (2 seconds), 2,822,400 bits - Max AudioClip duration so far = 2 (AudioClip) bad duration (0 seconds), 0 bits - Max AudioClip duration so far = 2 (AudioClip) My Way (275 seconds), 388,080,000 bits - Max AudioClip duration so far = 275 </code></pre> <p>每个 AudioClip 对象的输出现在都会以该音频片段数据占用的位数的整数形式结束,感谢我们使用的类常量。即使是一个两秒钟的片段,也需要超过 200 万位(这就是为什么我们将位数格式化为每三位用逗号分隔的原因)。</p> <h3 id="带有静态成员的工具类">带有静态成员的工具类</h3> <p>静态成员也可以作为 <em>工具类</em> 的一部分来创建;这些工具类旨在帮助其他类完成工作,并不用于创建对象。工具类的静态成员可能会存储有用的信息或执行基本的、通用的计算,这些计算在其他项目或当前项目的其他部分可能会用到。</p> <p>继续以我们的 AudioClip 示例为例,假设我们想要以兆字节而不是位的形式显示每个音频片段的大小。我们需要一种从位转换为兆字节的方法。我们可以重构 AudioClip 类的 getSizeBits() 方法来进行必要的计算。这个计算,以及支持性的信息,比如一个字节中包含的位数,足够通用,可能对项目中的其他非音频部分或者完全不同的项目也有用。因此,将必要的代码放在工具类中是有意义的。</p> <p>我们将创建一个名为 SizeUtilities 的工具类,帮助 AudioClip 类计算内存大小。通常,工具类不用于创建对象,而 SizeUtilities 也不例外。由于这个类的对象永远不会被实例化,我们将把 SizeUtilities 声明为抽象类。因此,由于不会有 SizeUtilities 对象,所有类的成员需要通过类本身访问。也就是说,SizeUtilities 必须由类级常量和静态成员组成。图 25-3 显示了修改后的 AudioClip 类和新的 SizeUtilities 工具类的图示。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure25-3.jpg" alt="" loading="lazy"></p> <p>图 25-3:展示静态方法和属性的 AudioClip 和 SizeUtilities 类图</p> <p>SizeUtilities 类提供了三个公共类常量,图示中标明为{只读}:BITS_PER_BYTE、BYTES_PER_KILOBYTE 和 BITS_PER_MEGABYTE。这些常量将在计算文件大小时非常有用。该类还提供了一个名为 bitsToMegaBytes() 的公共静态方法,该方法接收一个比特数作为参数,并返回相应的以兆字节为单位的数字(默认精确到两位小数)。同时,AudioClip 类声明了一个新的 getSizeMegaBytes() 方法,借助 SizeUtilities 类返回一个表示该音频剪辑所需内存大小(以兆字节为单位)的浮动值。请注意,AudioClip 类还修改了 __toString() 方法,该方法在对象摘要中包含了文件大小(以兆字节为单位)。</p> <p>让我们声明 SizeUtilities 类。创建 <em>src/SizeUtilities.php</em> 文件,包含清单 25-4 中的代码。</p> <pre><code><?php namespace Mattsmithdev; abstract class SizeUtilities { const BITS_PER_BYTE = 8; const BYTES_PER_KILOBTYE = 1024; const BITS_PER_MEGABTYE = self::BITS_PER_BYTE * self::BYTES_PER_KILOBTYE * 1024; public static function bitsToMegaBytes(int $bits): float { return $bits / self::BITS_PER_MEGABTYE; } } </code></pre> <p>清单 25-4:包含常量和静态方法的 SizeUtilities 类</p> <p>我们将 SizeUtilities 声明为抽象类,因为该类的所有成员都是常量或静态成员。它的 BITS_PER_BYTE 和 BYTES_PER_KILOBYTE 常量分别为 8 和 1024,而 BITS_PER_MEGABYTE 常量是基于这两个常量计算得出的。该类的 bitsToMegaBytes() 静态方法接受一个比特数,并将其除以 BITS_PER_MEGABYTE 常量,以返回相应的兆字节数。</p> <p>接下来,按照清单 25-5 中的内容更新 <em>src/AudioClip.php</em> 文件。</p> <pre><code><?php namespace Mattsmithdev; class AudioClip { --snip-- public function getSizeBits(): int { return self::BITS_PER_SECOND * $this->durationSeconds; } public function getSizeMegaBytes(): float { ❶ return SizeUtilities::bitsToMegaBytes($this->getSizeBits()); } public function __toString(): string 2 { $numMegaBytesFormatted = number_format($this->getSizeMegaBytes(), 2); return "(AudioClip) $this->title " . "($this->durationSeconds seconds), $numMegaBytesFormatted MB \n"; } } </code></pre> <p>清单 25-5:更新 AudioClip 类以使用 SizeUtilities</p> <p>我们添加了 getSizeMegaBytes() 方法,该方法返回一个浮动值,表示 AudioClip 对象的大小(以兆字节为单位)。该方法获取比特数,并将其传递给我们在 SizeUtilities 类中声明的公共静态方法 bitsToMegaBytes() ❶。这是一个很好的例子,展示了如何在其他类中使用公共静态方法:我们无需创建 SizeUtilities 类的对象即可使用其公共静态方法,而是简单地写 SizeUtilities:: 后跟方法名。</p> <p>在新的 __toString() 方法中,我们创建了一个临时的 $numMegaBytesFormatted 变量,保存了以兆字节为单位的剪辑大小,并将其格式化为带有两位小数的字符串。然后,我们将这个变量的值作为字符串的一部分返回。再一次,我们不需要对索引脚本做任何更改来测试我们的更新,因为我们仍然使用 __toString() 来输出信息。以下是更新后的终端输出:</p> <pre><code>- Max AudioClip duration so far = -1 (AudioClip) hello world (2 seconds), 0.34 MB - Max AudioClip duration so far = 2 (AudioClip) bad duration (0 seconds), 0 MB - Max AudioClip duration so far = 2 (AudioClip) My Way (275 seconds), 46.26 MB - Max AudioClip duration so far = 275 </code></pre> <p>每个 AudioClip 对象的大小现在以兆字节为单位给出。我们新的实用类成功地帮助 AudioClip 类通过其静态方法和类常量完成了位到兆字节的转换。</p> <h3 id="在应用程序中共享资源">在应用程序中共享资源</h3> <p>静态方法的另一种用途是为软件系统的所有部分提供功能(例如从数据库读取或写入,或将消息附加到日志文件),而无需每个部分都重复所需的设置代码。其思路是创建一个包含静态方法的抽象类,这些方法负责执行必要的工作,比如建立数据库连接或设置日志记录器及日志处理器。然后,您可以在代码中的任何地方调用这些静态方法,每当您需要该功能时。这样,像日志记录或与数据库连接的操作就变得非常简单。</p> <p>为了说明这如何工作,我们来创建一个项目,允许在系统中的任何地方进行日志记录,只需像这样写:</p> <pre><code>Logger::debug('my message'); </code></pre> <p>为了使这个功能工作,我们需要一个具有公共静态 debug() 方法的 Logger 类,该方法可以在系统中的任何地方调用。这个方法将处理日志记录过程的细节,这样系统的其他部分就不需要处理这些。</p> <p>创建一个新的项目文件夹并创建通常的 <em>composer.json</em> 文件来自动加载 Mattsmithdev 命名空间的类。然后在命令行中输入此命令将 Monolog 库添加到项目中:</p> <pre><code>$ **composer require monolog/monolog** </code></pre> <p>由于我们有一个 <em>composer.json</em> 文件,Composer 在这一步也会生成自动加载脚本,除了加载 Monolog 库外,所有相关文件都会放在您的项目的 <em>vendor</em> 目录中。</p> <p>接下来,创建一个自定义的 Logger 类,文件位置为 <em>src/Logger.php</em>,如示例 25-6 所示。</p> <pre><code><?php namespace Mattsmithdev; ❶ use Monolog\Logger as MonologLogger; use Monolog\Handler\StreamHandler; abstract class Logger { const PATH_TO_LOG_FILE = __DIR__ . '/../logs/debug.log'; ❷ public static function debug(string $message): void { $logger = new MonologLogger('channel1'); $logger->pushHandler(new StreamHandler(self::PATH_TO_LOG_FILE)); $logger->debug($message); } } </code></pre> <p>示例 25-6:具有公共静态日志记录方法的 Logger 类</p> <p>我们在 Mattsmithdev 命名空间中声明 Logger 类,以避免与 Monolog 库的 Logger 类发生命名冲突,并将其指定为抽象类,因为我们永远不需要实例化它。use 语句允许我们在代码中引用 Monolog 类,而无需编写完整的命名空间。请注意,我们将 Monolog 的 Logger 类别名为 MonologLogger,以便与我们自己的类更好地区分 ❶。</p> <p>在类内,我们为<em>logs/debug.log</em>的文件路径创建一个常量。然后我们将 debug()声明为一个公共静态方法,它接受一个<span class="math inline">\(message 字符串参数❷。该方法为 channel1 创建一个新的 MonologLogger 对象,并为其分配一个日志处理器,用于将信息写入调试日志文件,然后使用 Monolog 类的 debug()方法将\)</span>message 记录到日志文件中。</p> <p>现在在<em>public/index.php</em>中创建一个索引脚本,包含清单 25-7 中的代码。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Application; $app = new Application(); $app->run(); </code></pre> <p>清单 25-7:在 index.php 中创建 Application 对象</p> <p>我们读取并执行 Composer 生成的自动加载器,创建一个 Application 类的对象,并调用它的 run()方法。这是我们在第二十一章中讨论的面向对象的 Web 应用程序索引脚本的基本模式。</p> <p>最后,在<em>src/Application.php</em>中声明 Application 类,如清单 25-8 所示。该类包括通过我们 Logger 类的静态方法记录消息的代码。</p> <pre><code><?php namespace Mattsmithdev; class Application { public function run(): void { print 'Hello, world!'; Logger::debug('Hello, world! printed out'); Logger::debug('another log message'); } } </code></pre> <p>清单 25-8:应用程序类</p> <p>在 Application 类的 run()方法中,我们打印出一个“Hello, world!”消息。然后我们调用 Logger 类的静态 debug()方法,将消息记录到调试日志文件中。请注意,我们不需要在 Application 类中创建 Logger 对象或包含任何设置代码(例如声明日志文件路径或创建日志处理器);所有这些都由我们在 Logger 类中定义的静态方法处理。如果 Application 类和 Logger 类位于不同的命名空间,我们只需在使用时添加 use 语句或完全限定类名,像这样:</p> <pre><code>Mattsmithdev\Logger::debug('Hello, world! printed out'); </code></pre> <p>我们可以通过运行索引脚本后查看<em>logs/debug.log</em>的内容来确认这两条消息已经被附加到日志文件中。记得使用 cat 命令(macOS 和 Unix)或 type 命令(Windows),如第二十四章所讨论的那样。你应该能看到类似这样的内容:</p> <pre><code>$ **cat debug.log** [2025-01-30T10:49:54.516974 + 00:00] channel1.DEBUG: Hello, world! printed out [] [] [2025-01-30T10:49:54.519278 + 00:00] channel1.DEBUG: another log message [] [] </code></pre> <p>我们成功地使用我们的静态方法将消息附加到日志文件。如果我们愿意,可以向此方法添加可选参数,以更改日志文件的名称和位置,记录到不同的频道,或提供一个上下文数组,例如异常对象。这个基本示例一般说明了一个类如何提供一个静态方法,使得软件系统中的任何部分都可以轻松利用它的功能。 ### 使用单例模式节省资源</p> <p>我们在前一部分中使用的共享资源的方法可以在许多情况下工作,但在某些情况下,比如创建数据库连接或设置邮件或文件写入对象,静态方法的任务会占用足够的时间和内存,影响应用程序的性能。在这些情况下,一种叫做<em>单例模式</em>的面向对象设计技术可以帮助节省计算资源,同时仍然使得操作在整个应用程序中可用。</p> <p>回顾一下上一节的 Listing 25-6,我们在其中声明了 Logger 类。根据我们对静态 debug()方法的定义,每次调用该方法记录调试信息时,都会创建一个新的 MonologLogger 对象和一个新的 StreamHandler 对象。在我们应用程序的 run()方法中(见 Listing 25-8),我们调用了该方法两次,因此为了记录两条信息,创建了四个对象,这有点浪费。当操作消耗资源时,更高效的做法是只执行一次,然后<em>缓存</em>(存储)这些创建的资源以供以后使用。单例模式就是一种实现方式。</p> <p>单例模式声明了一个具有私有构造函数的类,并添加了逻辑来确保最多只创建一个类的对象。该类提供了一个名为 getInstance()的公共静态方法,用于返回类的唯一实例的引用。如果没有实例存在,则在第一次调用 getInstance()方法时创建一个实例。否则,类会缓存或记录该唯一实例,以便下次调用 getInstance()时返回同一个实例。</p> <p>每当你需要使用单例类时,可以在代码中的任何地方写类似如下的代码:</p> <pre><code>$myObject = Singleton::getInstance(); </code></pre> <p>这将把对单例类唯一实例的引用存储在<span class="math inline">\(myObject 变量中。然后,你可以通过\)</span>myObject 引用使用 Singleton 类的资源。Listing 25-9 展示了单例风格类的典型骨架。</p> <pre><code><?php class Singleton { private static ?Singleton $instance = NULL; private function __construct() { // -- Do the resource-expensive work here -- } public static function getInstance(): Singleton { ❶ if (self::$instance == NULL) { ❷ self::$instance = new Singleton(); } return self::$instance; } } </code></pre> <p>Listing 25-9:Singleton 类</p> <p>我们声明了一个名为 instance 的私有静态属性,并将其初始化为 NULL。最终,这个属性将保存对单例类唯一对象的引用。然后,我们声明了一个私有构造方法,在这里可以完成任何资源密集型的工作。由于构造方法被声明为私有的,它不能在类外部通过 new 关键字调用。</p> <p>接下来,我们声明该类唯一的公共成员:静态的 getInstance()方法。该方法首先测试 instance 是否为 NULL ❶。如果是,这必定是第一次调用该方法,因此会创建一个新的 Singleton 对象(这会调用构造函数,触发资源密集型的工作),并将新对象的引用存储在静态 instance 属性中 ❷。然后,方法返回 instance 中的对象引用,使得该对象可以在应用程序的其他地方使用。</p> <p>让我们修改前一节的项目,使用单例模式来节省计算资源。这样,无论我们记录多少条消息,我们都只需要创建一次 Logger 对象和日志处理器。通过将自定义的 Logger 类设置为 Monolog 的 Logger 类的子类,我们还可以使应用程序更加灵活,这样我们就能使用后者的任何方法和可选参数(例如,提供上下文数据以及通过继承的方法记录不同严重程度的日志)。</p> <p>首先,更新<em>src/Logger.php</em>中 Logger 类的声明,使其与列表 25-10 一致。这个重新设计的类 closely modeled on 列表 25-9 中演示的骨架单例类。</p> <pre><code><?php namespace Mattsmithdev; use Monolog\Logger as MonologLogger; use Monolog\Handler\StreamHandler; ❶ class Logger extends MonologLogger { const PATH_TO_LOG_FILE = __DIR__ . '/../logs/debug.log'; private static ?Logger $instance = NULL; private function __construct() { parent::__construct('channel1'); $this->pushHandler(new StreamHandler(self::PATH_TO_LOG_FILE)); } ❷ public static function getInstance(): Logger { if (self::$instance == NULL) { self::$instance = new Logger(); } return self::$instance; } } </code></pre> <p>列表 25-10:实现使用单例模式的 Logger 类</p> <p>我们将 Logger 类声明为 Monolog Logger 类的子类(别名为 MonoLogger)❶。由于我们将只创建此类的一个实例,因此不再将其声明为抽象类。接下来,我们将私有静态实例属性初始化为 NULL,并声明一个私有构造函数。构造函数使用 parent::调用 Monolog Logger 类的构造函数,创建一个带有 channel1 的新对象,然后将其分配一个调试日志文件的日志处理程序。由于这一切都封装在私有构造函数内,因此它只会执行一次。</p> <p>我们还声明了公共静态方法 getInstance(),这是典型的单例类方法❷。该方法遵循列表 25-9 中描述的逻辑,如果实例为 NULL,则创建并返回一个 Logger 对象,或者如果实例已经创建,则直接返回该实例。</p> <p>现在让我们更新我们应用程序类的 run()方法。我们将获取 Logger 类的单一实例的引用,然后使用该对象引用记录一些条目。修改<em>src/Application.php</em>,使其与列表 25-11 一致。</p> <pre><code><?php namespace Mattsmithdev; class Application { public function run(): void { print 'Hello, world!'; Logger::getInstance()->warning('I am a warning.'); Logger::getInstance()->error('I am a test error!', ['exception' => new \Exception('example of exception object')]); } } </code></pre> <p>列表 25-11:更新后的应用程序类,使用我们的单例 Logger 类</p> <p>我们通过编写 Logger::getInstance()获取 Logger 单例实例的引用,并使用它通过继承自 Monolog Logger 类的 warning()方法记录一个警告级别的消息。由于这是第一次尝试获取单例实例,因此 Logger 类的实例属性为 NULL,将创建一个新的 Logger 对象并将其引用保存在实例中。</p> <p>然后,我们再次获取 Logger 单例实例的引用并记录一个错误级别的消息,创建并传递一个新的 Exception 对象作为上下文的第二个参数。这次,Logger 的实例属性不是 NULL,因此返回现有对象的引用。通过这种方式,两个日志条目都是由 Logger 类的单一实例创建的,从而节省了时间和资源,同时在应用程序的任何地方仍然可以使用日志功能。</p> <p>运行 index 脚本后,使用 cat 或 type 命令查看<em>logs/debug.log</em>的内容。你应该能看到如下内容:</p> <pre><code>$ **cat debug.log** [2025-01-30T14:37:32.728758 + 00:00] channel1.WARNING: I am a warning. [] [] [2025-01-30T14:37:32.730002 + 00:00] channel1.ERROR: I am a test error! {"exception":"[object] (Exception(code: 0): example of exception object at /Users/matt/src/Application.php:13)"} [] </code></pre> <p>两条消息已成功附加到日志文件中,包括添加到错误级别日志条目的异常对象上下文数据。</p> <p>你已经看到了单例模式的有用之处。然而,许多程序员认为它是一个<em>反模式</em>,即解决常见问题的方案,结果却比它试图解决的问题更糟。单例模式的批评者反对它作为一种全局程序状态,这使得代码测试变得更加困难,因为使用单例的代码不能与单例本身分开测试。此外,单例具有<em>全局可见性</em>,意味着应用程序中的任何代码都可能依赖于它。因此,需要更复杂的代码分析来确定应用程序中哪些部分依赖于或不依赖于全局可见的单例。另一方面,支持者认为,资源使用的减少足以抵消一些测试或其他部分设计上的开销。</p> <h3 id="枚举">枚举</h3> <p><em>枚举</em>是逐一列举每一个可能性,在计算机编程中,<em>枚举</em>是数据类型所有可能值的列表。一个很好的枚举数据类型例子是 bool,它列出了唯一的两个可能值:true 和 false。布尔值是语言内建的,但从 8.1 版本开始,PHP 允许你创建并使用自己的自定义枚举类型,也被称为<em>枚举类</em>。</p> <p>当你有的数据项只能取闭合集合中的一个值时,枚举类非常有用。例如,一张披萨订单可以指定为外送或自取,员工的工作状态可以是全职或兼职,扑克牌的花色可以是红桃、方块、梅花或黑桃。虽然当你只有两个可能值时可以使用布尔属性(例如,$fullTime = false),但当有超过两个可能值时,枚举类更为合适。即使只有两个可能值,通过枚举类来定义它们也能使选择更加明确。</p> <p>在一个枚举类内部,你声明该类的每个可能案例,每个案例都是该类的一个对象。例如,你可能有一个扑克牌花色的枚举类,其中的可能案例包括 HEARTS、DIAMONDS、CLUBS 和 SPADES。要引用枚举类的案例,你使用与使用静态成员或类常量时相同的双冒号语法(作用域解析运算符)(例如,$card1Suit = Suit::SPADES)。</p> <p>由于这种共享的语法,枚举类与静态成员和类常量有一定的关系。主要的区别在于,枚举类的每个案例都是该类的一个真正的<em>对象</em>,而类常量和静态属性、方法是类的<em>成员</em>。关键是,这意味着枚举类可以作为其他类的属性的数据类型,以及函数的参数和返回值。</p> <p>作为一个简单的例子,Listing 25-12 显示了扑克牌花色的枚举类声明。像普通的类声明文件一样,在名为 <em>Suit.php</em> 的文件中声明此枚举类。</p> <pre><code><?php namespace Mattsmithdev; enum Suit { case CLUBS; case DIAMONDS; case HEARTS; case SPADES; } </code></pre> <p>Listing 25-12: Suit 枚举类</p> <p>在命名空间之后,我们使用 enum 关键字,后跟类名 Suit。然后我们为这个枚举类声明了四个案例,代表四种扑克牌花色。因为枚举案例的作用类似于类常量(即定义固定值),我倾向于将它们写成全大写,尽管许多程序员只将每个案例的第一个字母大写。</p> <p>现在我们可以将 Suit 枚举类的对象分配给一个变量。为了说明,Listing 25-13 展示了一个简单的 Card 类,利用了 Suit 枚举。</p> <pre><code><?php namespace Mattsmithdev; class Card { ❶ private Suit $suit; private int $number; public function getSuit(): Suit { return $this->suit; } public function setSuit(Suit $suit): void { $this->suit = $suit; } public function getNumber(): int { return $this->number; } public function setNumber(int $number): void { $this->number = $number; } ❷ public function __toString(): string { return "CARD: the " . $this->number . " of " . $this->suit->name; } } </code></pre> <p>Listing 25-13: 带有使用 Suit 枚举类的 suit 属性的 Card 类</p> <p>我们首先将 suit 声明为 Suit 数据类型的属性 ❶。再次强调,由于枚举被视为类,它们可以用作有效的数据类型。我们还为类添加了一个数字属性,并为 suit 和 number 提供了简单的 getter 和 setter。然后我们声明一个 __toString() 方法来输出 Card 对象属性的详细信息 ❷。在其中,我们从其 name 属性获取枚举对象名称的字符串版本,通过表达式 $this->suit->name 访问。</p> <p>Listing 25-14 展示了一个简单的索引脚本,演示了 Suit 和 Card 类的使用。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Suit; use Mattsmithdev\Card; $card1 = new Card(); ❶ $card1->setSuit(Suit::SPADES); $card1->setNumber(1); print $card1; </code></pre> <p>Listing 25-14: 使用 Card 和 Suit 在索引脚本中</p> <p>在为这两个类提供使用语句后,我们创建一个新的 Card 对象,并将其 suit 属性设置为 Suit 枚举类的 SPADES 案例的引用,使用双冒号 Suit::SPADES 语法引用案例 ❶。我们还将此 Card 对象的 number 设置为 1(代表一张 Ace)。然后我们打印 $card1 变量的详细信息,这将调用其 __toString() 方法。运行此索引脚本的输出如下:</p> <pre><code>$ **php public/index.php** CARD: the 1 of SPADES </code></pre> <p>输出显示了数字属性为 1 和字符串 SPADES,这是对象的 suit 属性中引用的 Suit 枚举类的名称。</p> <h4 id="支持的枚举">支持的枚举</h4> <p>除了给枚举案例命名如 SPADES 和 HEARTS 外,你还可以将整数或字符串值与每个案例关联起来。当你给案例赋值时,该类被称为<em>值支持的枚举</em>,或简称<em>支持的枚举</em>。必须在枚举类名后声明 int 或 string 类型,并且必须为每个案例分配一个值;否则将会出现错误。</p> <p>让我们通过为每个案例分配一个字符串值将 Suit 转换为支持的枚举。我们可以选择与案例名称完全匹配的字符串('HEARTS'、'CLUBS' 等),或者我们可以玩得开心一点,分配具有相应扑克牌花色符号的字符串。请参见 Listing 25-15 中更新后的 Suit 声明。</p> <pre><code><?php namespace Mattsmithdev; enum Suit: string { case CLUBS = '♣'; case DIAMONDS = '♦'; case HEARTS = '♥'; case SPADES = '♠'; } </code></pre> <p>Listing 25-15: 将 Suit 转换为支持的枚举类</p> <p>我们通过在类名后添加冒号和字符串类型来声明 Suit 为一个字符串类型的枚举类型。然后,我们将字符串 '♣' 作为 CLUBS 的值,并为其他三个花色分配相应的符号。要获取一个枚举类型的值,可以使用其公开的 value 属性。例如,如果 $card1Suit 变量是指向 Suit 枚举对象的引用,我们可以通过表达式 $card1Suit->value 来获取它的字符串值。这些值是只读的;一旦在枚举类声明中设置,它们就不能在代码的其他地方被更改。</p> <h4 id="所有情况的数组">所有情况的数组</h4> <p>所有枚举类都有一个内置的静态方法 cases(),它返回一个包含所有枚举案例对象的数组。我们可以利用这个方法来构建一个包含案例关联值的数组。例如,列表 25-16 显示了一个索引脚本,利用循环遍历每个 Suit 枚举对象,并将其字符串值附加到一个数组中进行打印。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Suit; $cases = Suit::cases(); $caseStrings = []; foreach ($cases as $case) { $caseStrings[] = $case->value; } print implode($caseStrings); </code></pre> <p>列表 25-16:创建所有 Suit 枚举案例值的数组</p> <p>我们调用 Suit::cases() 静态方法来获取一个包含每个可能 Suit 枚举值的实例的 $cases 数组。然后我们将 $caseStrings 初始化为空数组。接下来,我们遍历 $cases 中的 Suit 枚举对象,并将它们的字符串值附加到 $caseStrings 数组中。最后,我们使用内置的 implode() 函数将字符串数组打印为一个单一的字符串。以下是运行此索引脚本时在命令行上的输出:</p> <pre><code>$ **php public/index.php** ♣♦♥♠ </code></pre> <p>所有四个花色的字符都已经在同一行打印出来。在这里,我们仅仅打印了案例值,但我们也可以使用类似的循环技术,例如,从枚举类中创建一个包含所有案例值的下拉菜单,用户可以选择其中一个选项。</p> <h3 id="小结-1">小结</h3> <p>本章中,我们探讨了类的静态成员:这些成员与个别对象无关,而是属于整个类。你已经看到了如何通过 self:: 在类内访问静态成员,以及如何通过 ClassName:: 在类外访问静态成员(假设静态成员是 public)。你了解了静态成员的使用,包括记录类范围的信息、通过工具类提供公共方法、以及通过抽象类或单例设计模式在应用程序中共享功能。我们还讨论了与类常量和枚举类相关的概念。</p> <h3 id="练习-23">练习</h3> <p>1.   创建一个新的项目来实现一个 Car 类,表示不同的汽车。如图 25-4 所示,该类应该具有表示品牌、型号和价格的实例属性,并且在创建新对象时,通过构造函数传入每个实例属性的值。你还应该有 private 静态属性用于 totalPrice 和 numInstances。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure25-4.jpg" alt="" loading="lazy"></p> <p>图 25-4:Car 类</p> <p>每次创建一个新的 Car 对象时,构造函数方法应该增加 numInstances,并将新创建的 Car 对象的价格加到 totalPrice 上。</p> <p>2.   在练习 1 中的 Car 类中添加一个公共静态方法 averagePrice(),该方法使用静态的 numInstances 和 totalPrice 属性来计算并返回所有已创建的 Car 对象的平均价格。</p> <p>3.   创建一个新项目,包含一个名为 DietType 的枚举类,其中有三个值:VEGAN(纯素)、VEGETARIAN(素食)、CARNIVORE(食肉)。同时创建一个 Dessert 类,具有 name 属性(一个字符串)和 diet 属性(一个 DietType 枚举值),并且包含一个 __toString()方法,用于以'(DESSERT) 甜点名称 (DietType 菜肴)'的形式总结 Dessert 对象。编写一个索引脚本,创建一个 Dessert 对象并打印其详细信息。输出应类似于以下内容:</p> <pre><code>(DESSERT) Eton Mess (VEGETARIAN dish) </code></pre> <h2 id="第二十六章26-抽象方法接口和-traits">第二十六章:26 抽象方法、接口和 traits</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>在本章中,我们将超越从超类到子类的标准继承机制,探索在类之间共享方法的其他策略。你将会接触到抽象方法、接口和 traits。</p> <p>正如你将看到的,抽象方法和接口允许你跨多个类共享方法的签名,而无需指定方法的实现细节。实际上,这些机制充当了<em>契约</em>:为了使用抽象方法或接口,一个类必须同意提供这些方法的合适实现。同时,traits 是一种绕过继承的方式,可以在不同层次结构的类之间共享完全实现的方法。接口也超越了类层次结构,而抽象方法仍然通过继承在超类和子类之间传递。</p> <p>抽象方法、接口和 traits 一起可以促进应用程序的更新,而不会破坏任何代码,因为它们确保了某些方法将会在类中存在,以供应用程序的其他部分使用。特别是抽象方法和接口促进了类的可互换性。通过强制方法签名,同时对实现保持中立,它们使得在项目需求变化时(例如需要写入新的文件类型、与新的数据库管理系统通信,或是为日志记录和异常处理指定新的目标)可以轻松替换那些以不同方式实现方法的类。另一方面,traits 有助于避免冗余并促进代码的重用,因为它们避免了你必须在多个不相关的类中声明相同的方法。从这个意义上说,它们有点像工具类,旨在使应用程序中的所有类都能使用某些常见的操作。</p> <p>在本章涉及的主题中,接口特别是在中型到大型的 PHP 项目中非常常见。即使你自己并不编写许多接口,你可能会使用它们,因为它们是许多第三方库的一个特性,这些库用于核心 Web 应用程序组件,包括数据库通信以及处理 HTTP 请求和响应。</p> <h3 id="从继承到接口">从继承到接口</h3> <p>在本节中,我们将逐步构建一个示例类网络,以说明抽象方法和接口的特点与优点。我们将首先回顾子类从超类继承方法的传统过程,然后过渡到使用抽象方法,最后使用接口来标准化不相关类的特性。为了简单起见,这将是一个玩具示例。然而,一旦我们建立了基础,我们将转向接口的更实际和更具实际应用性的场景。</p> <h4 id="从超类继承完全实现的方法">从超类继承完全实现的方法</h4> <p>如我们在第十九章中讨论的,继承使得可以将超类方法的定义传递给一组相关的子类。如果某些子类需要以不同方式实现该方法,它们可以始终用自己的实现来覆盖它,而其他子类则会简单地继承超类的默认实现。</p> <p>有时,超类可能是抽象的,这意味着它永远不会被实例化。在这种情况下,一个或多个非抽象的子类必须扩展抽象超类,以便创建对象并执行超类的方法。举个例子,图 26-1 展示了一个简单的动物类层次结构,所有这些动物类都能够返回描述它们发出声音的字符串。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure26-1.jpg" alt="" loading="lazy"></p> <p>图 26-1:一个类层次结构,抽象超类传递一个 getSound()方法</p> <p>在这个类层次结构的顶部是抽象的 Animal 超类。它的 sound 属性默认值为“喵”,并具有保护(#)可见性,这意味着如果需要,子类可以访问(并覆盖)该属性的值。它的 numLegs 属性是公共的(+),默认值为 4。此外,getSound()方法返回存储在 sound 中的字符串。</p> <p>在该层次结构的下一层,Cat 子类扩展了 Animal 类,因此继承了 sound 和 numLegs 属性以及 getSound()方法。这意味着 Cat 对象将发出“喵”声。Dog 子类也扩展了 Animal 类,但声明了自己的 sound 属性值为“汪”,覆盖了从超类继承的值。</p> <p>最后,让我们假设关于鸟类发出的声音存在不同的看法;有时“叽叽喳喳”更受欢迎,有时“啾啾”更常见。为了解决这个问题,Bird 子类声明了 getSound()的自定义实现,覆盖了从超类继承的方法。在运行时,每个 Bird 对象的 getSound()方法将访问一个(虚构的)API <em><a href="http://www.mostPopularBirdSound.com" target="_blank">www.mostPopularBirdSound.com</a></em> 来确定最流行的鸟类声音字符串,而忽略其继承的 sound 属性的值。此外,由于鸟类只有两条腿,Bird 类还覆盖了继承的腿数属性。</p> <p>清单 26-1 显示了 Animal 类的代码。</p> <pre><code><?php namespace Mattsmithdev; abstract class Animal { protected string $sound = "meow"; public int $numLegs = 4; ❶ public function getSound(): string { return $this->sound; } } </code></pre> <p>清单 26-1:Animal 类</p> <p>我们声明 Animal 类为抽象类,这样它就无法被实例化,并为其分配了 sound 和 numLegs 属性。我们还提供了 getSound()方法的实现❶,该方法返回 sound 属性的值。对于任何继承该方法的子类实例,调用 getSound()方法时,对象的 sound 属性值将在运行时确定。例如,Cat 对象将返回'meow',Dog 对象将返回'bark',而 Bird 对象将覆盖该方法,并返回从<em><a href="http://www.mostPopularBirdSound.com" target="_blank">www.mostPopularBirdSound.com</a></em> API 获取的任何字符串。</p> <p>这个例子告诉我们,一个超类(无论是否抽象)提供了一种默认方法实现的方式,子类可以继承这个实现。需要时,这个实现可以被单独的子类覆盖。</p> <h4 id="继承抽象方法">继承抽象方法</h4> <p><em>抽象方法</em>是超类中的一个没有实现的方法。它声明的只是方法的签名:方法的名称、参数和返回类型。任何继承该超类的子类必须提供自己对该抽象方法的实现。实现的具体细节由每个子类自行决定,只要实现符合超类中指定的方法签名。</p> <p>抽象方法适用于需要展示相同行为但非常不同的类。例如,像动物一样,汽车也会发出声音,它们可能也需要一个返回字符串的 getSound()方法,就像我们的 Animal 类一样。然而,汽车与动物有很大的不同,甚至它们发出声音的方式也大不相同;汽车可能会发出如'putt-putt-putt'、'purr'或'vroom-vroom'等声音,这取决于它们的发动机大小、燃料类型等。因此,汽车的 getSound()方法将与动物的不同,但它们仍然会有相同的签名,因为在这两种情况下,方法最终都返回一个字符串。</p> <p>让我们通过引入一个新的抽象 SoundMaker 超类来解决这个问题,该超类声明了一个抽象的 getSound()方法。任何从 SoundMaker 继承的子类,如 Animal 和 Car,必须提供适当的 getSound()实现。图 26-2 显示了新的修改后的类层次结构。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure26-2.jpg" alt="" loading="lazy"></p> <p>图 26-2:通过 SoundMaker 超类共享抽象 getSound()方法</p> <p>清单 26-2 展示了新的 SoundMaker 类的声明。注意,类没有为 getSound()方法提供实现,只声明了它的签名。</p> <pre><code><?php namespace Mattsmithdev; abstract class SoundMaker { abstract public function getSound(): string; } </code></pre> <p>清单 26-2:带有抽象方法的 SoundMaker 类</p> <p>我们将 getSound()方法指定为抽象方法,并仅声明它的签名。由于没有提供实现,方法声明被视为语句,因此必须以分号结束。</p> <p>非抽象的 Car 子类现在必须为 getSound() 方法提供实现,才能成功地继承自 SoundMaker。如果没有实现,我们将得到一个类似下面的致命错误:</p> <pre><code>PHP Fatal error: Class Mattsmithdev\Car contains 1 abstract method and must therefore be declared abstract or implement the remaining methods </code></pre> <p>我们已经在 Animal 类上提供了 getSound() 的实现,因此它可以成功继承自 SoundMaker。Cat 和 Dog 子类继承了 Animal 的方法实现,因此它们也满足了 SoundMaker 类的要求。Bird 类仍然可以用自己的实现覆盖从 Animal 继承来的 getSound() 实现。</p> <p>为了看到将 getSound() 作为抽象方法的好处,假设我们的应用程序有一个需要知道对象发出声音的功能。该功能可以要求一个 SoundMaker 对象(或它的子类)作为参数,并且知道无论接收到哪个 SoundMaker 的子类,都将有一个可以调用的 getSound() 方法来返回一个字符串。无论是 Animal 对象还是 Car 对象都没有关系;该方法肯定会存在。通过这种方式,抽象方法最大化了类的可互换性,同时仍然允许不同的类拥有非常不同的实现方式。</p> <p>如果一个类声明了一个或多个抽象方法,那么该类本身也必须是抽象的。这是因为你不能实例化一个包含抽象方法的类,因为该方法没有提供实现。然而,反过来并不一定成立:一个类可以声明为抽象类,但不包含任何抽象方法。例如,你可能有一个由完全实现的静态成员组成的抽象类。</p> <h4 id="使用接口要求方法实现">使用接口要求方法实现</h4> <p><em>接口</em> 是一种声明一个或多个方法签名的方式,类应该实现这些方法。然后,类通过声明具有这些指定签名的方法来<em>实现</em>接口。接口类似于抽象方法,因为它们都确保一个或多个类应该拥有某些方法,但不指定这些方法应该如何实现。它们都通过保证这些方法签名的一致性来促进类的可互换性。不同之处在于,接口<em>不是</em>类,因此独立于任何类层次结构,而抽象方法是作为类的一部分声明的。因此,任何实现抽象方法的类必须属于声明这些方法的类的层次结构。</p> <p>由于接口独立于类层次结构,因此当你希望在非常不同的类之间共享某种行为,这些类不属于同一个类层次结构时,或者你希望在多个非常不同的类之间共享多种行为时,接口就非常有用。</p> <p>继续前面章节中的示例,管风琴也能发出声音,像汽车和动物一样。管风琴和汽车也需要定期维护,而动物则不需要。假设 Maintainable 类的子类必须实现一个返回某种 Date 对象的 nextService()方法。服务日期的计算方法在 Car 对象和 PipeOrgan 对象中会有所不同。汽车的服务日期可能基于发动机类型和行驶的里程,而管风琴对象的服务日期可能根据管道的长度和材质来计算。</p> <p>我们可能会倾向于创建一个新的抽象 Maintainable 类,声明一个抽象的 nextService()方法。Car 和 PipeOrgan 类将从 Maintainable 继承,并提供各自的 nextService()实现,同时也继承自 SoundMaker 类,以及 Animal 类。这将是一个<em>多重继承</em>的例子,即一个类能够同时从两个或多个超类继承。图 26-3 中的类图展示了这一方案。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure26-3.jpg" alt="" loading="lazy"></p> <p>图 26-3:具有多重继承的类层次结构</p> <p>这种安排可能看起来很吸引人:Car 和 PipeOrgan 类都从两个超类继承,因此像之前一样,它们都可以继承来自抽象 SoundMaker 类的 getSound()方法要求,同时也能继承来自抽象 Maintainable 类的 nextService()方法要求。然而,尽管一些编程语言允许多重继承,PHP 并不允许多重继承,以避免歧义问题。如果一个类从多个超类继承,并且这些超类中有两个或更多声明了相同名称的常量或方法,那么继承类该如何知道使用哪个呢?</p> <p>我们可以尝试通过将 SoundMaker 和 Maintainable 超类放在同一类层次结构的不同层级来绕过对多重继承的禁止。也就是说,我们可以让 Maintainable 成为 SoundMaker 的子类,而 Car 和 PipeOrgan 成为 Maintainable 的子类,就像在图 26-4 中那样。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure26-4.jpg" alt="" loading="lazy"></p> <p>图 26-4:一个类层次结构,其中 Maintainable 是 SoundMaker 的子类</p> <p>初看之下,这似乎是可行的。层次结构中的任何类的对象都必须有一个 getSound()方法,Car 和 PipeOrgan 各自也必须实现一个 nextService()方法。然而,如果我们识别出一些应该有但其他类没有的行为呢?这些行为在提议的层次结构中可能没有意义。另外,如果我们想添加一个不发声的 Maintainable 子类怎么办?例如,烟囱需要定期维护,但它是静音的。</p> <p>显然,我们创建了一个脆弱且人为的类层次结构。 完全不相关的类,如鸟类、汽车和烟囱,可能被迫成为它们与之无关的类的子类,所有这些都是为了强制继承 getSound() 和 nextService() 方法签名。 解决方案是使用接口来定义一组必需的方法签名,这些方法可以由不在同一层次结构中的类来实现。 这样既避免了多重继承的非法解决方案,又规避了单一类层次结构的要求。</p> <p>为了演示,我们首先将 SoundMaker 定义为一个接口而不是一个类。 然后我们可以规定我们示例中的类都应该实现 SoundMaker 接口。 这在 图 26-5 中有所说明。</p> <p>在图表的左下角,SoundMaker 接口声明了 getSound() 方法的签名。 将 SoundMaker 重新定义为接口而不是类之后,我们可以将我们的类分解为单独、更有意义和更健壮的层次结构:我们有抽象的 Animal 类及其子类,以及抽象的 Vehicle 类和其子类 Car 和 Helicopter。 与动物或车辆无关的 PipeOrgan 类单独存在。 实现 SoundMaker 接口的类用接口名称和 <em>棒棒糖符号</em> 进行注释。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure26-5.jpg" alt="" loading="lazy"></p> <p>图 26-5:实现了多个类的 SoundMaker 接口</p> <p>注意,当抽象类(如 Animal 或 Vehicle)实现一个接口时,并不需要提供所有(或任何)在接口中声明的方法的完整实现。 具体的实现可以留给非抽象的子类来完成。 在这里,Animal 提供了一个 getSound() 的实现(尽管被 Bird 子类覆盖),而 Vehicle 没有。 在后一种情况下,Car 和 Helicopter 子类必须分别提供自己定制的 getSound() 实现,以实现 Vehicle 类通过声明其将实现 SoundMaker 接口而做出的承诺。</p> <h5 id="声明一个接口">声明一个接口</h5> <p>声明接口的代码应该放在与接口同名的 <em>.php</em> 文件中,就像类声明一样。 例如,SoundMaker 接口应该在 <em>SoundMaker.php</em> 文件中声明。 清单 26-3 展示了其代码。</p> <pre><code><?php namespace Mattsmithdev; interface SoundMaker { public function getSound(): string; } </code></pre> <p>清单 26-3:SoundMaker 接口</p> <p>我们通过使用 interface 关键字声明 SoundMaker。它的主体仅包含 getSound() 方法的签名,没有实际的实现。就像声明一个抽象方法一样,getSound() 的签名必须以分号结尾,以表示语句的结束。请注意,我们为 getSound() 提供了公共可见性。虽然显式包括 public 修饰符并非严格必要(因为在接口中声明的所有方法都会自动被视为公共方法),但最佳实践是显式声明,因为其他系统部分可以利用任何实现接口的类的行为。</p> <blockquote> <p>注意</p> </blockquote> <p><em>除了声明方法签名外,接口还可以声明常量。实现该接口的类将继承接口常量,尽管从 PHP 8.1 开始,类可以根据需要覆盖接口常量。</em></p> <h5 id="实现接口">实现接口</h5> <p>现在让我们看看一个类如何实现一个接口。作为示例,列表 26-4 展示了实现 SoundMaker 接口的 PipeOrgan 类的代码。</p> <pre><code><?php namespace Mattsmithdev; class PipeOrgan implements SoundMaker { public function getSound(): string { return 'dum, dum, dum-dum'; } } </code></pre> <p>列表 26-4:使用 PipeOrgan 类实现 SoundMaker 接口</p> <p>我们通过使用 implements 关键字声明该类实现了 SoundMaker 接口。由于 PipeOrgan 类实现了 SoundMaker,类有义务提供 getSound() 方法的实现:在这种情况下,它返回字符串 'dum, dum, dum-dum'。该方法与 SoundMaker 接口声明的签名匹配。我们也会在 Animal 和 Vehicle 类中声明 getSound() 方法。每个实现的细节并不重要,只要该方法被命名为 getSound() 且返回一个字符串。</p> <p>如果一个(非抽象)类没有包含实现接口所需的某个方法定义,那么会发生致命错误。例如,如果 PipeOrgan 类的代码没有声明 getSound() 方法,那么在尝试创建该类的对象时,你会看到如下错误:</p> <pre><code>PHP Fatal error: Class Mattsmithdev\PipeOrgan contains 1 abstract method and must therefore be declared abstract or implement the remaining methods </code></pre> <p>请注意,这与子类未能实现其超类声明的抽象方法时的致命错误完全相同。PHP 引擎处理接口方法签名时,就像它们是抽象方法一样;在创建任何对象之前,必须在实现接口的类层次结构中实现这些方法。</p> <h5 id="使用一个类实现多个接口">使用一个类实现多个接口</h5> <p>接口的一个强大特性是,一个类可以实现多个接口。当一个类实现一个接口时,它承诺提供一组公共方法,这些方法的签名在该接口中声明,且没有理由一个类不能为多个接口提供这些方法。</p> <p>回到我们的示例,PipeOrgan 类可以实现 Maintainable 接口,承诺声明 <code>nextService()</code> 方法的实现,并通过声明 <code>getSound()</code> 方法实现 SoundMaker 接口。同样,如果所有车辆都需要维护并发出声音,Vehicle 类也可以实现 Maintainable 和 SoundMaker 两个接口。图 26-6 展示了这些类如何实现多个接口。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure26-6.jpg" alt="" loading="lazy"></p> <p>图 26-6:实现多个接口的类</p> <p>图中展示了 Maintainable 接口与 SoundMaker 接口,PipeOrgan 和 Vehicle 类现在有两个“棒棒糖”,表示它们实现了这两个接口。与图 26-4 中展示的人工类层次结构或图 26-3 中的多重继承方案相比,这种类和接口的安排在概念上更加简洁。</p> <p>要查看如何声明一个类实现多个接口,请参考清单 26-5,其中展示了更新后的 PipeOrgan 类代码。</p> <pre><code><?php namespace Mattsmithdev; class PipeOrgan implements SoundMaker, Maintainable { public function getSound(): string { return 'dum, dum, dum-dum'; } public function nextService(): \DateTime { return new \DateTime('2030-01-01'); } } </code></pre> <p>清单 26-5:使用 PipeOrgan 类实现多个接口</p> <p>当一个类实现多个接口时,只需要使用一次 <code>implements</code> 关键字,后跟接口名称,并用逗号分隔,如 <code>implements SoundMaker, Maintainable</code>。除此之外,实现多个接口只需为所有必需的方法提供定义。在这种情况下,我们添加了 <code>nextService()</code> 方法,该方法返回一个 <code>DateTime</code> 对象,符合 Maintainable 接口的要求(我们将在第三十一章中讨论如何处理日期)。</p> <p>我之前提到过,反对多重继承的一个论点是,如果一个类试图从多个父类继承相同的成员,会造成歧义。但对于一个实现多个接口的类来说,这不是问题。无论一个、两个,还是任意数量的接口声明了相同的方法签名,所有这些接口的契约都可以通过类中实现的单个方法来满足。例如,如果因为某些原因 Maintainable 接口声明了 <code>nextService()</code> 和 <code>getSound()</code> 两个方法,只要 PipeOrgan 类声明了这两个方法的实现,代码仍然能正常工作。只要来自接口的所有方法都在实现这些接口的类中定义,就不会有歧义,PHP 引擎将始终一致、正确、无错误地运行。</p> <h5 id="比较接口与抽象类">比较接口与抽象类</h5> <p>一开始,可能会觉得接口与抽象类是一样的,因为它们都不能用于实例化对象。然而,尽管这两个概念相关,但它们之间存在关键差异,每个概念适用于不同的情况。最重要的一点是,抽象类是一个类,而接口不是;它是一个类必须实现的方法签名的承诺或契约。另一个关键区别是,类只能继承一个抽象类,而可以实现多个接口。</p> <p>接口不能声明或处理实例级别的成员,因此接口不能拥有实例属性或实现与实例成员交互的方法。事实上,接口根本不能实现方法;它们只指定实例方法的要求。与此不同,抽象类可以是一个完全实现的类,或者是一个部分实现的类,包括实例变量、构造函数以及实现的实例方法和未实现的抽象方法。在后一种情况下,扩展抽象类的类只需要通过完善继承的抽象方法来完成实现。</p> <p>接口和抽象类在方法可见性方面也有所不同。接口上声明的方法必须是公共的,而抽象类可以声明受保护的方法,这些方法仅供类层次结构内的对象内部使用。此外,虽然在接口上声明构造方法的签名在技术上是可能的,但强烈不推荐这样做;而抽象类可以拥有构造函数。最后需要注意的是,接口可以扩展另一个接口,就像子类扩展父类一样。然而,与类继承不同,接口可以扩展多个接口。</p> <h3 id="接口的现实世界应用">接口的现实世界应用</h3> <p>我们的 SoundMaker 和 Maintainable 场景可能是一个简单的例子,但接口在现实世界中也有重要的应用。它们对于标准化类的方法签名特别有用,尤其是那些随着 Web 应用演变其行为可能发生变化的类。将方法签名声明为接口可以确保应用程序仍然有效;即使方法实现的细节发生变化,调用方法的方式也不会改变,因此应用程序的其他代码不会受到影响。</p> <p>在第二十四章中,我们已经使用了一个实际的、真实世界的接口来讨论日志记录。PSR-3 标准定义了一个 Logger 接口,列出了任何实现该接口的类必须提供的多个方法,如 log()、error() 等。你可以与任何实现了这个接口的类一起工作,并且可以放心地知道这些方法会存在。例如,在第二十四章中,我们使用了 Monolog 库的 Logger 类,该类实现了 Logger 接口,但其他第三方库的类也实现了这个接口。这些类中的任何一个都可以使用,而且你甚至可以在不同的 Logger 实现之间切换,而无需更改使用提供的日志对象的代码。</p> <p>接口可以帮助的另一个功能是暂时缓存(存储)数据,比如在处理表单提交或 HTTP 请求时缓存数据。在 Web 应用程序中缓存数据有助于避免在控制器对象和方法之间传递大量参数;你只需要在代码的一部分将数据存储到缓存中,然后在另一部分从缓存中检索数据。</p> <p>缓存有许多方法,比如使用浏览器会话、数据库、JSON 或 XML 文件、PHP 扩展社区库(PECL)语言扩展,或者可能通过 API 连接到其他服务。如果你声明一个通用缓存操作的接口,你可以编写与任何符合接口的缓存系统兼容的代码。这样,你就可以根据项目需求的变化轻松切换缓存系统。例如,在开发一个项目时,你可能使用一种缓存系统,而在实际生产网站上则使用另一种缓存系统。</p> <p>在本节中,我们将探讨缓存的不同方法,并演示如何通过缓存接口对它们进行标准化。我们将通过一个网页应用程序进行测试,该应用程序设计用于缓存任何传入 HTTP 请求的 ID,并在 About 页面上显示该 ID。</p> <blockquote> <p>注意</p> </blockquote> <p><em>PHP 已经有 PSR-6 和 PSR-16 标准推荐用于缓存接口,但它们对于我们的目的来说过于复杂。我们将创建一个更简单的缓存方法,通过一个更直接的例子来探索接口的好处。</em></p> <h4 id="缓存方法-1使用数组">缓存方法 1:使用数组</h4> <p>首先,让我们实现一个名为 CacheStatic 的缓存类,该类使用静态(类级别)数组来存储和检索以字符串键为索引的值。我们可能会在开发的早期阶段使用这种简单的方法来快速使缓存工作。除了获取和设置值之外,我们还希望该类提供一个 has() 方法,用于返回一个布尔值,指示是否已为给定键存储了某个值。</p> <p>启动一个新项目,并创建通常的 <em>composer.json</em> 文件,声明 <em>src</em> 作为 Mattsmithdev 命名空间下类的存放位置。使用 Composer 生成自动加载器,并创建通常的 <em>public/index.php</em> 脚本,该脚本读取并执行自动加载器,创建 Application 对象,并调用其 run() 方法。一旦完成,你就可以在 <em>src/CacheStatic.php</em> 中声明 CacheStatic 类,如 清单 26-6 所示。</p> <pre><code><?php namespace Mattsmithdev; class CacheStatic { ❶ private static array $dataItems = []; ❷ public static function set(string $key, string $value): void { self::$dataItems[$key] = $value; } ❸ public static function get(string $key): ?string { if (self::has($key)) { return self::$dataItems[$key]; } return NULL; } ❹ public static function has(string $key): bool { return array_key_exists($key, self::$dataItems); } } </code></pre> <p>清单 26-6:CacheStatic 类</p> <p>我们将私有静态数据项属性初始化为空数组 ❶。这将是我们的缓存。然后我们声明 set() 静态方法,它接受两个字符串参数,一个键和一个值,用于存储在缓存中 ❷。接着,我们声明 get() 静态方法,它接受一个字符串键并返回缓存数组中为该键存储的值 ❸。该方法包括一个测试,如果给定键没有值,则返回 NULL。最后,我们声明静态方法 has() ❹,它返回 true 或 false,用以指示是否有缓存值存储在给定的键中。</p> <p>接下来,我们将声明 Application 类。它的 run() 方法将缓存 HTTP 请求中的 ID,然后实例化一个 MainController 对象(我们稍后会声明这个类)来响应请求。按照 清单 26-7 中的代码创建文件 <em>src/Application.php</em>。</p> <pre><code><?php namespace Mattsmithdev; class Application { public function run() { $action = filter_input(INPUT_GET, 'action'); ❶ $id = filter_input(INPUT_GET, 'id'); if (empty($id)) { $id = "(no id provided)"; } // Cache ID from URL ❷ CacheStatic::set('id', $id); $mainController = new MainController(); ❸ switch ($action) { case 'about': $mainController->aboutUs(); break; default: $mainController->homepage(); } } } </code></pre> <p>清单 26-7:Application 类</p> <p>在像往常一样获取 URL 编码的 action 变量后,我们尝试获取另一个 URL 编码的变量 id,并将其值存储在 $id 变量中 ❶。如果这个查询字符串变量为空,我们将 $id 设置为 '(未提供 id)'。然后,我们使用 CacheStatic 类的 set() 静态方法将 $id 变量中的字符串存储在缓存中,键名为 'id' ❷。如果需要,我们现在可以使用 CacheStatic 的公共静态方法 get('id') 来检索存储的字符串。run() 方法以一个典型的 switch 语句结束,根据 action 变量的值调用 MainController 对象的 homepage() 或 aboutUs() 方法 ❸。</p> <p>现在我们将声明 MainController 类。按照 清单 26-8 中所示创建 <em>src/MainController.php</em>。</p> <pre><code><?php namespace Mattsmithdev; class MainController { public function homepage() { require_once __DIR__ . '/../templates/homepage.php'; } public function aboutUs() { ❶ $id = CacheStatic::get('id'); require_once __DIR__ . '/../templates/aboutUs.php'; } } </code></pre> <p>清单 26-8:MainController 类</p> <p>homepage() 方法简单地输出首页模板。在 aboutUs() 方法中,我们使用 CacheStatic 类的 get() 方法从缓存数组中检索 ID,将结果存储在 $id 变量中 ❶。然后,我们读取并执行 About 页面模板,该模板将能够访问 $id。</p> <p>清单 26-9 显示了首页模板的内容。将此代码输入到 <em>templates/homepage.php</em> 中。</p> <pre><code><!DOCTYPE html> <html lang="en"> <head> <title>home page</title> </head> <body> <?php ❶ require_once '_nav.php' ?> <h1>home page</h1> <p> welcome to home page </p> </body> </html> </code></pre> <p>清单 26-9:homepage.php 模板</p> <p>这个基本的 HTML 首页模板通过输出来自部分模板文件 <em>templates/_nav.php</em> ❶ 的导航栏来重用一些代码。清单 26-10 显示了该部分模板的内容。</p> <pre><code><ul> <li> <a href="/"> Home </a> </li> <li> <a href="/?action=about"> About Us </a> </li> <li> ❶ <a href="/?action=about&id=<?= rand(1,99) ?>"> about (with ID in URL) </a> </li> </ul> <hr> </code></pre> <p>清单 26-10:_nav.php 部分模板</p> <p>导航栏以两个简单的链接开始,其中<em>/* 是主页的 URL,</em>/?action=about* 是关于页面的 URL。我们还提供了一个额外的、更复杂的链接到关于页面 ❶,使用 PHP 的 rand() 函数从 1 到 99 中随机选择一个整数,并将其作为 id 查询字符串变量的值传递。这个值将被缓存,然后显示在关于页面的内容中,以确认缓存是否有效。</p> <p>清单 26-11 显示了 <em>templates/aboutUs.php</em> 中的关于页面模板。</p> <pre><code><!DOCTYPE html> <html lang="en"> <head> <title>about page</title> </head> <body> <?php require_once '_nav.php' ?> <h1>about page</h1> <p> welcome to about page <br> ❶ your ID = <?= $id ?> </p> </body> </html> </code></pre> <p>清单 26-11:aboutUs.php 模板</p> <p>至于主页,我们借用了部分的 <em>_nav.php</em> 模板,以简化手头的文件。然后,我们将 $id 变量的值嵌入到页面正文中 ❶。图 26-7 显示了生成的网页。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure26-7.jpg" alt="" loading="lazy"></p> <p>图 26-7:关于页面,包括缓存的 ID 值</p> <p>注意到 URL 编码的 id 变量的值已被打印到页面。这表明 ID 已成功通过 Application 类中的 run() 方法被缓存,然后通过 MainController 类的 aboutUs() 方法检索,最终由 <em>aboutUs.php</em> 模板打印。</p> <h4 id="缓存方法-2使用-json-文件">缓存方法 2:使用 JSON 文件</h4> <p>假设我们后来决定添加第二种缓存方法,将数据缓存到 JSON 文件中。这个 JSON 方法,例如,会让我们更容易在不同时间将缓存的不同状态记录到一个接受 JSON 数据的日志 API 中。我们来声明一个名为 CacheJson 的新缓存类来实现这种方法。创建 <em>src/CacheJson.php</em> 文件,包含 清单 26-12 中的代码。</p> <pre><code><?php namespace Mattsmithdev; class CacheJson { private const CACHE_PATH = __DIR__ . '/../var/cache.json'; ❶ public function set(string $key, string $value): void { $dataItems = $this->readJson(); $dataItems[$key] = $value; $this->writeJson($dataItems); } ❷ public function get(string $key): ?string { $dataItems = $this->readJson(); if ($this->has($key)) { return $dataItems[$key]; } return NULL; } ❸ public function has(string $key): bool { $dataItems = $this->readJson(); return array_key_exists($key, $dataItems); } private function readJson(): array { $jsonString = file_get_contents(self::CACHE_PATH); if (!$jsonString) { return []; } $dataItems = json_decode($jsonString, true); return $dataItems; } private function writeJson(array $dataItems): bool { $jsonString = json_encode($dataItems); return file_put_contents(self::CACHE_PATH, $jsonString); } } </code></pre> <p>清单 26-12:CacheJson 类</p> <p>在 CacheJson 中,我们声明了 public set() ❶、get() ❷ 和 has() ❸ 方法。从外部来看,它们类似于我们 CacheStatic 类中的方法,只不过它们是实例方法,属于类的每个对象,而不是属于整个类的静态方法。内部实现上,这些方法的定义与 CacheStatic 中的不同:它们通过使用私有的 readJson() 和 writeJson() 方法来读取和写入 JSON 文件,而这些方法又使用 第九章 中介绍的内建函数 file_get_contents() 和 file_put_contents()。</p> <p>然而,这些细节对应用程序的其余部分是隐藏的,因此这些更改对我们的代码的影响是最小的。例如,清单 26-13 显示了我们只需要对 Application 类做出的唯一更改。</p> <pre><code><?php namespace Mattsmithdev; class Application { public function run() { $action = filter_input(INPUT_GET, 'action'); $id = filter_input(INPUT_GET, 'id'); if (empty($id)) { $id = "(no id provided)"; } $cache = new CacheJson(); $cache->set('id', $id); --snip-- } } </code></pre> <p>清单 26-13:更新 Application 类以使用 CacheJson 对象</p> <p>我们将 CacheStatic::set('id', $id) 替换为两条语句,创建一个 CacheJson 对象并调用其 set() 方法。MainController 类需要做类似的小调整,如 清单 26-14 所示。</p> <pre><code><?php namespace Mattsmithdev; class MainController { public function homepage() { require_once __DIR__ . '/../templates/homepage.php'; } public function aboutUs() { ❶$cache = new CacheJson(); $id = $cache->get('id'); require_once __DIR__ . '/../templates/aboutUs.php'; } } </code></pre> <p>清单 26-14:更新 MainController 类以使用 CacheJson 对象</p> <p>代替 <code>$id = CacheStatic::get('id')</code> 语句,我们创建一个 CacheJson 对象并调用其 get() 方法来检索缓存中 'id' 键下的值 ❶。如果你现在重新测试应用程序,它应该和之前一样工作。唯一的区别是,ID 被缓存到 JSON 文件中,而不是数组中。</p> <h4 id="缓存方法-3创建一个-cacheable-接口">缓存方法 3:创建一个 Cacheable 接口</h4> <p>我们已经为应用程序使用了两种缓存方法,未来我们可能还需要使用其他方法。这种情况适合将缓存类的公共操作抽象为一个接口,然后编写实现该接口的类。这样,只要我们的代码能够创建任何实现了该接口的类的对象,我们就可以使用该类的 get()、set() 和 has() 方法,而无需担心缓存对象是哪个类的实例,或者该类是如何执行工作的。</p> <p>为了进行此更改,我们首先声明一个通用的 Cacheable 接口。除了 get()、set() 和 has() 方法外,我们还规定了一个第四个方法,reset(),该方法完全清空缓存中的所有存储值。创建 <em>src/Cacheable.php</em> 并输入 列表 26-15 的内容。</p> <pre><code><?php namespace Mattsmithdev; interface Cacheable { public function reset(): void; public function set(string $key, string $value): void; public function get(string $key): ?string; public function has(string $key): bool; } </code></pre> <p>列表 26-15:Cacheable 接口</p> <p>我们声明了 Cacheable 接口,并为任何实现该接口的类必须具有的四个方法定义了签名。这些方法都是公共实例方法,具有适当的类型化参数和返回类型。例如,set() 接收字符串类型的键和值作为缓存内容,并返回 void,而 get() 接收一个字符串类型的键并返回一个字符串或 NULL。</p> <p>当我们从使用 CacheStatic 切换到 CacheJson 时,我们需要对 Application 和 MainController 类做一些更新。接下来,我们将重构这些类,以便可以在不更改任何内容的情况下切换 Cacheable 接口的实现。我们从 Application 类开始。列表 26-16 展示了对 <em>src/Application.php</em> 的更新。</p> <pre><code><?php namespace Mattsmithdev; class Application { ❶ private Cacheable $cache; ❷ public function __construct(Cacheable $cache) { $this->cache = $cache; $this->cache->reset(); } ❸ public function getCache(): Cacheable { return $this->cache; } public function run() { $action = filter_input(INPUT_GET, 'action'); $id = filter_input(INPUT_GET, 'id'); if (empty($id)) { $id = "(no id provided)"; } ❹ $this->cache->set('id', $id); ❺ $mainController = new MainController($this); switch ($action) { case 'about': $mainController->aboutUs(); break; default: $mainController->homepage(); } } } </code></pre> <p>列表 26-16:重构 Application 类以使用 Cacheable 接口</p> <p>我们首先为类添加一个私有缓存属性,该属性的值是指向一个可缓存对象(Cacheable) ❶ 的引用。这是接口的一个强大特性:我们可以将接口名作为变量、方法参数或方法返回值的数据类型,任何实现该接口的类中的对象都可以正常工作。</p> <p>接下来,我们获取该属性的 Cacheable 对象引用,作为构造方法传递的参数❷。当创建 Application 对象时传递的任何对象,必须是实现了 Cacheable 接口的类。因此,构造函数调用提供的 Cacheable 对象的 reset()方法,因此我们知道在开始处理当前 HTTP 请求时,缓存将是空的。由于缓存属性是私有的,我们声明了一个公共的 getter 方法,以便它可以在 Application 类外部访问❸。</p> <p>请注意,到目前为止,所有这些新语句都已经写得如此方式,以至于 Application 类不需要知道构造函数提供的参数引用的是哪个 Cacheable 接口的实现。稍后你将看到在 index 脚本中如何创建 Cacheable 对象,因此如果我们选择使用不同的 Cacheable 实现,这里是唯一需要修改的地方。</p> <p>在 run()方法内部,我们使用 Cacheable 对象预期的 set()方法将<span class="math inline">\(id 变量的值存储到缓存中❹。然后,当我们创建一个 MainController 对象时,我们将\)</span>this 作为参数传递❺,这意味着 MainController 对象将有一个指向 Application 对象的引用。通过这种方式,MainController 对象也可以通过 Application 对象的缓存属性访问 Cacheable 对象。</p> <p>现在让我们更新 MainController 类。清单 26-17 展示了更新后的<em>src/MainController.php</em>文件。</p> <pre><code><?php namespace Mattsmithdev; class MainController { ❶ private Application $application; ❷ public function __construct(Application $application) { $this->application = $application; } public function homepage() { require_once __DIR__ . '/../templates/homepage.php'; } public function aboutUs() { ❸ $cache = $this->application->getCache(); $id = $cache->get('id'); require_once __DIR__ . '/../templates/aboutUs.php'; } } </code></pre> <p>清单 26-17:重构 MainController 类以使用 Cacheable 接口</p> <p>我们声明了一个私有的应用程序属性❶,其值是作为参数传递给构造方法的 Application 对象的引用❷。然后,在 aboutUs()方法中,我们使用应用程序对象的公共 getCache()方法来获取 Cacheable 对象的引用❸。这样,我们就可以像以前一样调用 get()方法,从缓存中检索存储的 ID,并在页面模板中使用它。</p> <p>接下来,我们需要更新<em>public/index.php</em>脚本,以便创建一个缓存对象并在创建 Application 对象时将其传递给它。如前所述,这部分代码是唯一需要知道我们想要使用哪种 Cacheable 接口实现的地方。按清单 26-18 所示更新 index 脚本。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Application; use Mattsmithdev\CacheJson; use Mattsmithdev\CacheStatic; $cache1 = new CacheJson(); $cache2 = new CacheStatic(); app = new Application($cache2); $app->run(); </code></pre> <p>清单 26-18:在 index.php 中选择 Cacheable 实现</p> <p>我们创建了两个对象:<span class="math inline">\(cache1 是一个 CacheJson 对象,\)</span>cache2 是一个 CacheStatic 对象。然后,我们在构造 Application 对象时传递这两个变量之一。尝试使用这两个变量的代码,每次应该都能正常工作。</p> <p>最后的步骤是修改我们的缓存类以实现 Cacheable 接口。清单 26-19 显示了更新后的 CacheStatic 类。为了满足 Cacheable 接口的契约要求,我们需要将 set()、get() 和 has() 方法改为实例方法(而不是静态方法),同时必须添加一个公共的 reset() 实例方法。按照清单中的内容更新 <em>src/CacheStatic.php</em>。</p> <pre><code><?php namespace Mattsmithdev; class CacheStatic implements Cacheable { private static array $dataItems = []; ❶ public function reset(): void { self::$dataItems = []; } public function set(string $key, string $value): void { self::$dataItems[$key] = $value; } public function get(string $key): ?string { if (self::has($key)) { return self::$dataItems[$key]; } return NULL; } public function has(string $key): bool { return array_key_exists($key, self::$dataItems); } } </code></pre> <p>清单 26-19:修改 CacheStatic 以实现 Cacheable 接口</p> <p>我们声明该类实现了 Cacheable 接口,然后为必需的 reset() 方法提供实现,将 <span class="math inline">\(dataItems 设置为空数组❶。set()、get() 和 has() 方法的实现与之前相同,唯一的区别是我们将它们从静态方法改为了实例方法。\)</span>dataItems 数组本身仍然是静态成员。</p> <p>清单 26-20 显示了修改后的 CacheJson 类,位于 <em>src/CacheJson.php</em>。</p> <pre><code><?php namespace Mattsmithdev; class CacheJson implements Cacheable { private const CACHE_PATH = __DIR__ . '/../var/cache.json'; ❶ public function reset(): void { $directory = dirname(self::CACHE_PATH); $this->makeDirIfNotExists($directory); $this->makeEmptyFile(self::CACHE_PATH); } private function makeDirIfNotExists(string $directory): bool { return is_dir($directory) || mkdir($directory); } private function makeEmptyFile(string $path): bool { return file_put_contents($path, ''); } public function set(string $key, string $value): void --snip-- </code></pre> <p>清单 26-20:我们重构后的 CacheJson 类,实现了 Cacheable 接口</p> <p>再次提醒,我们必须为 reset() 方法提供实现❶。它使用私有的 makeDirIfNotExists() 和 makeEmptyFile() 方法(在清单中接下来声明)来确保在调用 reset() 后,空文件和目录存在。其余代码,包括 set()、get() 和 has() 方法,和 清单 26-12 中的一样。</p> <p>正如这个例子所示,将像缓存这样的有用特性声明为接口意味着你可以创建该特性的不同实现,同时在编写大部分代码(例如 Application 和 MainController 类)时采用通用方式。这使得你可以在不必更新所有代码的情况下切换接口的实现,或者稍后创建新的实现,而不需要因为硬编码引用旧方式而导致应用程序出错。唯一需要改变的代码是实际实例化实现接口的类的代码。我们已经将这段代码方便地放在了索引脚本中,在那里可以轻松更新而不会破坏应用程序。</p> <h3 id="特性">特性</h3> <p><em>特性</em> 是一种提供多个不相关类共享的默认方法实现的方式。此特性不仅提供方法签名(如接口或抽象方法),还提供实际的方法实现。当一个类使用特性时,称为<em>插入</em>,因为特性本质上是在类中插入方法,而不需要类自己定义该方法。也就是说,特性可以插入到类中,并且如果需要,类可以通过自己的方法实现覆盖特性中的方法。这在大多数类(但不是所有类)插入特性时,能够使用相同的方法实现时特别有用。</p> <blockquote> <p>注意</p> </blockquote> <p><em>在其他一些编程语言中,特性被称为</em> mixins<em>,就像冰淇淋中可以混合的坚果或糖果等额外成分一样。</em></p> <p>特性是一种允许跨类层次复用代码的方式,而不需要使用多重继承,它是一种类似于方法的复制粘贴功能,可以在需要时覆盖这些方法。例如,如果你有多个类实现相同的接口,而这些类中的某些方法版本完全相同,使用特性可以避免代码在类中重复,从而遵循了 DRY(不要重复自己)原则。在这种情况下,你可以将这些方法声明为特性,只需要编写一次代码,然后通过告诉相关类使用该特性,将其添加到所有相关类中。</p> <p>更广泛地说,当多个类层次中的类需要执行公共操作时,特性可能会派上用场。例如,几个类可能需要<code>makeDirIfNotExists()</code>和<code>makeEmptyFile()</code>这两个方法的行为,而这两个方法我们之前在<code>CacheJson</code>类中声明过。一个解决方案是将这些方法作为某个工具类(例如,<code>FileUtilities</code>)的公共成员,这样每个需要该功能的类都可以创建一个<code>FileUtilities</code>对象并调用这些方法;或者,我们可以将这些方法声明为工具类的公共静态成员,以避免创建对象。</p> <p>然而,应用程序可能会随着时间的推移而变化,一些类可能需要主方法实现的特定变体。因此,与其将方法放入工具类中,我们可以将它们声明为特性。这样,这些方法将可供任何类使用,但每个类都可以在需要时用自定义实现替换这些方法,而不会影响代码库的其他部分。</p> <p>最终,特性(traits)和工具类(utility classes)是相似的概念,因为它们都能为来自不同类层次的类提供相同的完全实现的方法。然而,特性比工具类更为复杂和灵活,因为它们可以根据需要被重写。一个类对特性的依赖可能比对工具类的依赖更加明显,因为特性必须通过<code>use</code>语句进行引用,而工具类的方法调用可能隐藏在方法的实现中;从这个角度看,特性使得代码依赖关系更加透明。另一方面,特性可能更难直接测试,因为它们的方法通常是私有的或受保护的,而工具类的方法通常是公共的。</p> <h4 id="声明特性">声明特性</h4> <p>声明特性的方法类似于声明类,只不过是使用<code>trait</code>关键字而不是<code>class</code>关键字。为了演示如何使用特性,接下来我们将<code>makeDirIfNotExists()</code>和<code>makeEmptyFiles()</code>方法的声明从类中移到<code>FileSystemTrait</code>特性中。在前一节的基础上,创建一个新的<em>src/FileSystemTrait.php</em>文件,并按 Listing 26-21 所示复制这两个方法的定义。</p> <pre><code><?php namespace Mattsmithdev; trait FileSystemTrait { private function makeDirIfNotExists(string $directory): bool { return is_dir($directory) || mkdir($directory); } private function makeEmptyFile(string $path): bool { return file_put_contents($path, ''); } } </code></pre> <p>Listing 26-21:FileSystemTrait 特性</p> <p>我们使用<code>trait</code>关键字声明 FileSystemTrait 为一个 trait。它包含了<code>makeDirIfNotExists()</code>和<code>makeEmptyFile()</code>的声明。这两个方法的实现与它们在 CacheJson 类中的实现完全相同。</p> <p>在我们处理这些问题时,接下来让我们从 CacheJson 类中提取两个 JSON 文件方法,<code>readJson()</code>和<code>writeJson()</code>,并将它们声明为第二个 trait,JsonFileTrait,因为这些方法也定义了多个类可能需要的功能。将方法定义复制到新的<em>src/JsonFileTrait.php</em>文件中,并按照 Listing 26-22 中的示例进行更新。</p> <pre><code><?php namespace Mattsmithdev; trait JsonFileTrait { private function readJson(string $path): array { $jsonString = file_get_contents($path); if (!$jsonString) { return []; } $dataItems = json_decode($jsonString, true); return $dataItems; } private function writeJson(string $path, array $dataItems): bool { $jsonString = json_encode($dataItems); return file_put_contents($path, $jsonString); } } </code></pre> <p>Listing 26-22:JsonFileTrait trait</p> <p>我们声明了 JsonFileTrait trait,包含两个方法,<code>readJson()</code>和<code>writeJson()</code>。再次说明,这些方法的实现几乎与原来 CacheJson 类中的方法相同,但这次我们使用了一个字符串类型的<code>$path</code>参数来表示需要读取或写入的 JSON 文件,而不是硬编码的类常量。这使得这些方法更加通用。</p> <h4 id="插入-traits">插入 Traits</h4> <p>现在,让我们看看如何通过重构 CacheJson 类来使用这两个 traits。在 Listing 26-23 中,展示了修改后的<em>src/CacheJson.php</em>文件。</p> <pre><code><?php namespace Mattsmithdev; class CacheJson implements Cacheable { ❶ use FileSystemTrait, JsonFileTrait; private const CACHE_PATH = __DIR__ . '/../var/cache.json'; ❷ public function reset(): void { $directory = dirname(self::CACHE_PATH); $this->makeDirIfNotExists($directory); $this->makeEmptyFile(self::CACHE_PATH); } public function set(string $key, string $value): void { $dataItems = $this->readJson(self::CACHE_PATH); $dataItems[$key] = $value; $this->writeJson(self::CACHE_PATH, $dataItems); } public function get(string $key): ?string { $dataItems = $this->readJson(self::CACHE_PATH); if($this->has($key)){ return $dataItems[$key]; } return NULL; } public function has(string $key): bool { $dataItems = $this->readJson(self::CACHE_PATH); return array_key_exists($key, $dataItems); } } </code></pre> <p>Listing 26-23:更新 CacheJson 类以使用 traits</p> <p>我们从一个<code>use</code>语句开始,语句中包含了一个用逗号分隔的 trait 列表 ❶。在<code>reset()</code>方法 ❷中,注意我们如何调用<code>makeDirIfNotExists()</code>和<code>makeEmptyFile()</code>方法,这些方法现在来自 trait,就像我们之前做的那样。使用这些方法时,我们不需要提及 trait;我们像平常一样直接按名称调用方法。同样,我们也能像之前一样使用<code>readJson()</code>和<code>writeJson()</code>方法,但现在我们传递了<code>CACHE_PATH</code>常量作为参数。</p> <p>我们现在拥有一个更简单的 CacheJson 类。常用的文件系统和 JSON 文件操作方法已被重构为 traits,这使得 CacheJson 本身更加专注于与缓存相关的任务。同时,traits 上的方法也可以被其他任何类使用。</p> <h4 id="解决-trait-冲突">解决 Trait 冲突</h4> <p>如果一个类使用了两个或多个 traits,可能会在多个 traits 中声明相同的成员。这个潜在问题类似于允许多重继承的语言中会发生的冲突。在这种情况下,如果你尝试调用该方法,将会遇到致命错误,因为 PHP 引擎无法确定应该调用哪个实现。</p> <p>为了解决歧义并避免错误,使用<code>insteadof</code>关键字来指定要使用的方法版本。以下是一个示例:</p> <pre><code>use TraitA, TraitB { TraitA::printHello insteadof TraitB; </code></pre> <p>这段代码指定,如果<code>printHello()</code>在 TraitA 和 TraitB 中都被声明,那么应该插入 TraitA 中的实现。</p> <h3 id="何时使用什么">何时使用什么?</h3> <p>我们在本章中讨论的各种策略有相当大的重叠。在给定情况下决定使用哪种方法,可能是个人偏好或更大团队的偏好。话虽如此,图 26-8 通过总结我们讨论的各种方法的相似性和差异,提供了一些指导。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure26-8.jpg" alt="" loading="lazy"></p> <p>图 26-8:比较代码重用和类可互换性的策略</p> <p>对于基本的类层次结构,简单的继承可以实现很多功能,允许子类从具体❸或抽象❹超类继承完全实现的方法。如果许多子类需要对继承的方法进行自定义实现,您可能会将它们声明为超类的抽象方法❺。这样,只有方法的签名会被指定,具体实现则由子类决定。接口是另一种声明方法签名的方式,但在这种情况下,方法可以在类层次结构❻之间共享。</p> <p>总的来说,继承、抽象方法和接口促进了类的可互换性,同时也减轻了软件系统中组件之间的依赖关系。这大大促进了协作软件开发。通过标准化方法签名,同时允许方法实现的灵活性,接口尤其可以成为软件组件之间以及协作开发者之间的合同,每个开发者负责编码系统的不同部分。只要遵循接口要求,整个团队可以确信系统将按预期运行。这种软件设计方法有时被称为<em>松耦合</em>:软件组件之间可破坏依赖关系的数量和形式减少,因此任何一个组件的变化不太可能影响性能或需要重构其他组件。</p> <p>同时,如果你的目标是减少代码重复,可以使用特征(traits)提供默认方法实现的集合,这些方法实现可以明确地插入到来自不同层次结构的类中❷。对于小型系统,可能带有公共静态方法的工具类可以是提供相同功能给系统不同部分的另一种方式❶。特征提供了更多的灵活性(例如,插入特征的类仍然可以用自己的自定义实现重写该特征中的方法),但工具类中的公共方法更容易暴露,便于彻底测试。</p> <h3 id="摘要-1">摘要</h3> <p>在本章中,我们探讨了几种在类之间共享方法的策略,包括在类层次结构内外的共享。你看到了抽象方法和接口如何强制方法签名,而不提供具体实现。应用程序的其他部分可以安全地调用相关方法,无论实现如何,因为方法签名始终保持一致。你在创建一个可缓存接口(Cacheable)时看到了这一点,该接口允许我们切换缓存方式(使用静态数组与外部 JSON 文件),几乎不会对应用程序的其余部分产生影响。</p> <p>你还看到了如何使用特性(traits)将完全实现的方法插入到不相关的类中,同时仍然保持在必要时覆盖这些方法的灵活性。我们利用特性让通用的文件系统和 JSON 处理方法可供任何类在我们的缓存项目中使用。这促进了代码的可重用性,并使我们能够声明更简单、功能更聚焦的类。</p> <h3 id="习题-1">习题</h3> <p>1.   声明一个 Book 类,该类具有以下成员:</p> <p>一个私有的 string 类型 title 属性,包含 get 和 set 方法</p> <p>一个私有的 float 类型 price 属性,包含 get 和 set 方法</p> <p>一个公共的 <code>getPriceIncludingSalesTax()</code> 方法,返回一个 float 类型的值,计算方式为价格加上 5% 的销售税</p> <p>编写一个主脚本,创建一个 Book 对象,并打印其含税和不含税的价格,如下所示:</p> <pre><code>Book "Life of Pi" price (excl. tax) = $20.00 price (incl. tax) = $21.00 </code></pre> <p>2.   重构习题 1 中的项目,声明一个名为 SalesTaxable 的接口,要求类实现 <code>getPriceIncludingSalesTax()</code> 方法,该方法返回一个 float 类型的值。Book 类应实现 SalesTaxable 接口。</p> <p>接下来,声明一个 Donut 类,它也实现 SalesTaxable 接口,并具有以下成员:</p> <p>一个私有的 string 类型 topping 属性,包含 get 和 set 方法</p> <p>一个私有的 float 类型 price 属性,包含 get 和 set 方法</p> <p>一个公共的 <code>getPriceIncludingSalesTax()</code> 方法,实现 SalesTaxable 接口并返回价格加上 7% 的销售税</p> <p>最后,编写一个主脚本,创建以下两个对象,并打印它们含税和不含税的价格:</p> <pre><code>Book "Life of Pi" price (excl. tax) = $20.00 price (incl. tax) = $21.00 Donut "strawberry icing" price (excl. tax) = $10.00 price (incl. tax) = $10.70 </code></pre> <p>3.   编写一个 TaxFunctions 工具类,声明一个公共静态 <code>addTaxToPrice()</code> 方法,接受一个 float 类型的价格和税率,并返回加税后的价格。重构 Book 和 Donut 类中 <code>getPriceIncludingSalesTax()</code> 方法的实现,使用这个工具类的方法,以避免代码重复。</p> <p>4.   将 TaxFunctions 工具类更改为一个特性(trait),声明一个(非静态的)<code>addTaxToPrice()</code> 方法。重构 Book 和 Donut 类,插入该特性,并在它们的 <code>getPriceIncludingSalesTax()</code> 实现中使用 <code>addTaxToPrice()</code> 方法。</p> <p>5.   将你的项目重构为一个类层次结构,创建一个抽象的 SellableItem 超类,该类声明一个完全实现的<code>getPriceIncludingSalesTax()</code>方法,并设置为受保护可见性。将 Book 和 Donut 作为 SellableItem 的子类,并删除接口和特征文件;在这种设计中它们并不需要。有时,对于简单的情况,最简单的解决方案是最合适的。</p> <h1 id="第六部分-数据库驱动的应用开发">第六部分 数据库驱动的应用开发</h1> <h2 id="第二十七章27-数据库简介">第二十七章:27 数据库简介</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>数据库是计算机系统存储数据的一种方式,使得当代码稍后运行时,数据可以被持久地保存并被记住。我们将在接下来的几章中探讨如何使用 PHP 与数据库进行交互,从本章的数据库基础知识开始。我们将介绍基本的数据库术语,了解数据库的结构,并考虑将 Web 应用程序连接到数据库的动机和好处。</p> <p>我们还将讨论数据库如何与我们迄今为止讨论的 Web 应用程序架构相结合。你将学习如何将数据库的内容映射到面向对象的类和对象结构中,并且你将看到数据库如何形成早期章节中描述的 MVC 架构模型组件的核心。</p> <blockquote> <p>注意</p> </blockquote> <p><em>本书并不旨在提供关于关系数据库设计的全面指南,这本身就是一个复杂的领域。我们的重点将是通过使用 PHP 与数据库进行交互。有关 SQL 和数据库的更多学习书籍包括</em>《实用 SQL》(第二版,2022 年),作者 Anthony DeBarros;<em>《漫画数据库指南》(2009 年),作者 Mana Takahashi;以及</em>《MySQL 快速教程》(2023 年),作者 Rick Silva,均由 No Starch Press 出版。*</p> <h3 id="关系数据库基础">关系数据库基础</h3> <p>现代数据库系统大多数是<em>关系型</em>的,这意味着它们由一组相互关联的表组成。每个表代表一种实体。例如,客户表可能存储关于电子商务网站客户的信息。</p> <p>一个表由列和行组成。每列代表实体的一个属性(例如,客户表可能有客户姓名、地址、电话号码等列)。每一行代表实体的一个实例(例如,一个单独的客户)。每一行也叫做<em>记录</em>。</p> <p>数据库表之间的关系通过键来建立;每个<em>键</em>是与表中一条记录相关联的唯一标识符。从一个表中引用另一个表的键会在这两个表之间创建一个链接,同时避免数据的重复。继续我们电子商务的例子,我们的客户表中的每个客户可以被赋予一个<em>主键</em>,即一个唯一的客户 ID 号码。与此同时,我们可能还会有一个发票表,用于记录交易,每个发票都有一个唯一的 ID 号码。每个发票应与一个客户(发起交易的人)相关联,而每个客户可以与多个发票相关联,因为一个人可以发起多次交易。</p> <p>我们通过将与每个发票相关联的客户 ID 存储为发票表中的一列来建立这种关系,明确地将每张发票与一个且仅一个客户关联。在发票表的上下文中,客户 ID 被称为<em>外键</em>,因为它连接到另一个表中的字段。得益于外键,发票表不需要重复存储客户的姓名、地址和其他信息;我们只需根据给定发票分配的客户 ID,在客户表中查找这些详细信息。这就是关系型数据库的强大之处。</p> <p>为每一行分配唯一的键还有助于维护数据库的正确性或<em>完整性</em>。在进行数据库更改时,这些键充当不同表中数据项之间的链接。系统可以确保有一个与另一个表中引用的键相对应的数据项。可以在数据库中建立规则,防止在尝试创建新数据时,如果它试图链接到不存在的数据项,就会阻止该数据的创建。例如,这可以避免客户为不存在的发票付费,或防止将发票分配给不存在的客户。其他规则可能与数据删除相关,如果我们尝试删除与其他项目关联的项目,则会创建警告或异常。</p> <p>总的来说,数据库表的结构、表之间的关系以及管理数据完整性的规则被称为该数据库的<em>关系模式</em>。复杂的 Web 应用程序通常需要多个并行操作的关系模式。例如,一个模式可能用于存储组织的财务记录,另一个用于人力资源详情,另一个用于库存项目和客户订单。</p> <h4 id="数据库管理系统">数据库管理系统</h4> <p>创建、修改、从中检索和存储数据库的软件称为<em>数据库管理系统(DBMS)</em>。对于关系型数据库,我们有时更具体地称之为<em>关系型数据库管理系统(RDBMS)</em>。在本书中,我们将重点讨论两种 (R)DBMS:MySQL 和 SQLite。这是当今最流行的两个免费开源系统。</p> <p>一些数据库管理系统(DBMS)作为服务器应用程序运行,需要用户名和密码。它们可以运行在与使用它们的 Web 应用程序相同的计算机系统上,也可以运行在完全独立的互联网服务器上。MySQL 就是一个基于服务器的 DBMS 示例。其他数据库管理系统,如 SQLite,是基于文件的,意味着数据存储在与 Web 应用程序相同计算机上的文件中。像 MySQL 这样的基于服务器的 DBMS 可以与多个数据库模式一起使用,而 SQLite 和大多数其他基于文件的 DBMS 在每个文件中存储单一的数据库模式。例如,一个 SQLite 文件可能存储财务记录数据库,另一个文件可能存储人力资源详情数据库,依此类推。</p> <p>为了让像 PHP 这样的计算机语言与特定的 DBMS 进行通信,你需要一个<em>数据库驱动程序</em>。这个软件组件允许程序通过其标准协议与 DBMS 进行通信。例如,PHP 有 MySQL 驱动程序、SQLite 驱动程序,以及其他数据库管理系统的驱动程序。MySQL 和 SQLite 的 PHP 驱动程序可能已经在你的系统中启用。如果没有,当你尝试运行以下章节中的代码时,会出现驱动程序错误,并且你可能需要在<em>php.ini</em>配置文件中进行调整。有关在本地设置这两种数据库系统的说明,请参见附录 B,如果你在 Replit 环境中工作,请参考附录 C。</p> <p>当你的 PHP 程序需要与 DBMS 交互时,它在运行时使用一个数据库<em>连接</em>。这个连接是计算机程序与 DBMS 之间的活跃通信链接。要与基于服务器的数据库系统建立连接,你必须提供主机和端口信息,通常还需要提供适当的用户名和密码认证信息。在某些情况下,可以直接连接到特定的数据库架构(例如,人力资源详情架构);在其他情况下,会与 DBMS 建立一个通用连接,连接后可以创建新的架构,或选择使用已有的架构。一旦建立了与特定架构的连接,就可以执行所需的操作,其中可能包括创建表和关系、插入或删除数据,或者从架构的表中检索数据。</p> <p>数据库相对于其他持久存储方法(如文件)的一大优势是,许多 DBMS 设计时就考虑到了多人同时安全使用。因此,将数据库集成到 Web 应用程序中,能够让多人同时与应用程序互动,同时确保系统数据的安全性和完整性。这是基于服务器的 DBMS(如 MySQL)相较于基于文件的 DBMS(如 SQLite)的一个显著优势。虽然 SQLite 允许多个用户同时操作其文件数据库,但当用户进行更改时,它会锁定整个数据库文件。这对于本地机器测试和开发来说没问题,但在面对重流量的真实世界 Web 应用程序时,会导致无法接受的延迟。而像 MySQL 这样的系统可以处理大量并发连接,仅锁定单个表甚至单个数据库行,从而最小化对其他用户的干扰。</p> <p>基于服务器的数据库管理系统(DBMS)也有可能作为多个实例运行,从而帮助系统处理大量同时在线的用户,支持 web 应用程序及其数据库的多个版本。可以根据需求增加或删除实例,以应对随时间变化的流量负载。这种技术被称为<em>负载均衡</em>,许多云服务都自动实现了这一技术。</p> <h4 id="结构化查询语言">结构化查询语言</h4> <p>现代大多数关系数据库管理系统(RDBMS)都使用结构化查询语言(SQL)进行操作。SQL 旨在完成与关系数据库交互的三个关键方面:</p> <ul> <li> <p>定义相关表格的结构</p> </li> <li> <p>操作存储的数据(创建、更新或删除数据)</p> </li> <li> <p>查询数据(搜索数据库,根据给定的标准进行匹配)</p> </li> </ul> <p>列表 27-1 说明了每种操作的 SQL 语句。</p> <pre><code>CREATE TABLE IF NOT EXISTS product ( id integer PRIMARY KEY AUTO_INCREMENT, description text, price float ) DELETE FROM product WHERE price < 0 SELECT * FROM product WHERE price > 99 </code></pre> <p>列表 27-1:SQL 定义、操作和查询语句示例</p> <p>第一个 SQL 语句创建了一个产品表并定义了其结构。该表中的每个条目将存储产品的 id、描述和价格。每一列都指定了数据类型(例如,价格列为浮动型),id 列被指定为表的主键,这意味着每个表条目应该具有唯一的 id 值。</p> <p>第二个 SQL 语句演示了如何操作存储在数据库中的数据;该语句删除所有价格为负数的产品表条目。最后,第三个 SQL 语句是数据库查询的示例;它使用 SELECT 请求所有价格大于 99 的产品表条目。</p> <p>虽然 SQL 不区分大小写,但通常的做法是将 SQL 关键字(如 SELECT、FROM 和 WHERE)写为大写,并将表和列名以及构成语句条件的字符串使用小写字母。遵循这一惯例有助于使 SQL 语句更具可读性。</p> <h3 id="数据库和-web-应用程序架构">数据库和 Web 应用程序架构</h3> <p>数据库自然适应面向对象的 web 应用程序架构。对象的类可以编写得与存储在数据库表中的数据项紧密映射,数据库及其类通常是 MVC web 应用程序模式中模型(M)组件的首选。</p> <h4 id="面向对象编程">面向对象编程</h4> <p>面向对象编程的类结构可以轻松地映射到关系数据库表。与数据库交互的 web 应用程序的一种常见且直接的结构方式是设计一个与数据库中每个表对应的类。这些<em>实体类</em>具有与表的列相对应的属性,类的实例将对应于表中的一条记录(行)。</p> <p>如果我们需要将数据写入数据库表,我们首先会创建一个包含新数据的适当类的对象,然后使用我们的数据库连接将该对象的数据发送到数据库表。我们甚至可以将数据发送回该对象;例如,如果数据库需要为新记录选择一个新的唯一键,则可以将此值发送回 web 应用程序,并将其存储在相应的对象中以供将来参考。相反,如果我们需要从数据库表中读取整个记录,我们会将检索到的数据读入适当类的新对象,此时应用程序的其他部分可以通过访问该对象来利用数据库数据。</p> <p>考虑一个实现各种类别产品的 Web 应用程序(及其数据库)。我们可能会有名称为食品、五金和家具的类别,每个产品必须与其中一个类别相关联。图 27-1 显示了数据库的关系模式。这种图表称为 <em>实体关系(ER)模型</em>。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure27-1.jpg" alt="" loading="lazy"></p> <p>图 27-1:展示相关的 Product 和 Category 实体的 ER 图</p> <p>我们的数据库将有一个 Product 表,包含每个产品的条目,还有一个 Category 表,列出产品可能属于的类别。这些表之间的连接线展示了这些表中的条目如何关联。连接线的 Category 端的“1”表示关系“每个产品都与恰好一个类别相关联。”而 Product 端的鸟脚链接和星号(*)表示关系“每个类别都可以与零个、一个或多个产品相关联。”</p> <p>Category 表中的每条记录将具有唯一的整数 id 属性(主键)和文本描述。表格 27-1 显示了表中的示例条目。</p> <p>表格 27-1:类别表的示例行</p> <table> <thead> <tr> <th>id(主键)</th> <th>name</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>"食品"</td> </tr> <tr> <td>2</td> <td>"五金"</td> </tr> <tr> <td>3</td> <td>"家具"</td> </tr> </tbody> </table> <p>Product 表中的每条记录也将具有唯一的整数 id 属性作为主键,并具有文本描述和浮动价格。每个产品还将通过 category_id 列与恰好一个类别相关联,该列将存储对 Category 表中某条记录键的引用。同样,这被称为 <em>外键</em>。表格 27-2 显示了 Product 表的示例行。</p> <p>表格 27-2:产品表的示例行</p> <table> <thead> <tr> <th>id(唯一键)</th> <th>description</th> <th>price</th> <th>category_id</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>"花生棒"</td> <td>1.00</td> <td>1(食品)</td> </tr> <tr> <td>2</td> <td>"锤子"</td> <td>9.99</td> <td>2(五金)</td> </tr> <tr> <td>3</td> <td>"梯子"</td> <td>59.99</td> <td>2(五金)</td> </tr> </tbody> </table> <p>我们可以轻松地将数据库表映射到面向对象的类。图 27-2 显示了 Product 和 Category 的相应类图。请注意,这个 UML 图实际上与 图 27-1 中的 ER 模型是相同的。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure27-2.jpg" alt="" loading="lazy"></p> <p>图 27-2:相关的 Product 和 Category 类的类图</p> <p>每个类都包含其对应数据库表的所有列的属性;例如,Product 类具有 id、description、price 和 category 属性。每个 Product 对象将通过其 category 属性与恰好一个 Category 对象相关联,该属性将存储对 Category 对象的引用。请注意,这是我们类结构与数据库结构之间的主要区别。在 Product 数据库表中,category_id 列仅存储与之相关的类别的整数 ID,而在我们的类中,我们可以存储对完整 Category 对象的引用。</p> <p>图 27-3 显示了当我们从 表 27-1 和 27-2 读取示例数据库行到我们的 Web 应用程序时,将创建的对象。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure27-3.jpg" alt="" loading="lazy"></p> <p>图 27-3:将产品对象与分类对象连接</p> <p>我们最终得到了三个产品对象,它们与相应的分类对象相关联。请注意,每个产品对象仅与一个分类对象相关联。相比之下,一个分类对象可以与零个、一个或多个产品对象相关联,因为在某些时候我们可能没有任何产品属于某些分类,也可能只有一个产品,或者更多。</p> <h4 id="模型-视图-控制器模式">模型-视图-控制器模式</h4> <p>在前面的章节中,我们讨论了 MVC 软件架构,它将操作 Web 应用程序所需的各种任务分配给系统的不同部分。我们主要集中在如何像 Twig 这样的模板库提供 MVC 的视图组件,准备要展示给用户的内容,以及前端控制器和其他专业控制器类如何提供控制器组件,做出如何响应每个用户请求的决策。</p> <p>到目前为止,我对这种架构中的模型部分,即支撑 Web 应用程序的实际数据,介绍得不多。这正是数据库的作用所在。它以有组织的格式存储数据,并在控制器类的指令下提供或修改数据。与数据库表对应的类也是应用程序模型组件的一部分。图 27-4 说明了数据库在 MVC Web 应用架构中的位置。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure27-4.jpg" alt="" loading="lazy"></p> <p>图 27-4:MVC 架构,突出显示模型组件</p> <p>请注意,动作控制器类与(从数据库中读取和修改)<em>模型类</em>进行通信。正如你将在接下来的章节中学到的,这些是与数据库进行交互的面向对象类。所有的数据库交互都与应用程序的控制器和视图组件完全分离。这种模块化意味着我们可以更换底层数据库(例如从基于文件的 DBMS 更换到基于服务器的 DBMS),而不需要对前端控制器、动作控制器类或模板做任何修改。</p> <h3 id="总结-17">总结</h3> <p>在本章中,我们回顾了数据库的概念,特别是基于 SQL 的关系型数据库,并考虑了将数据库添加到 Web 应用程序中的一些优势。我们还探讨了数据库在 Web 应用架构中的作用,包括数据库如何成为 MVC 模式模型组件的核心部分。我们观察到,关系型数据库中的表、列和行与面向对象编程(OOP)中使用的类、属性和实例之间有着密切的映射关系。通过这次介绍,你现在已为本书剩余章节做好准备,在接下来的章节中,你将学习如何使用 PHP 连接、创建、修改和从 MySQL 和 SQLite 关系型数据库中检索数据。</p> <h3 id="练习-24">练习</h3> <p>1.   阅读 phoenixNAP 文章《什么是数据库?》中的一些数据库历史内容,作者为 Milica Dancuk (<em><a href="https://phoenixnap.com/kb/what-is-a-database" target="_blank"><code>phoenixnap.com/kb/what-is-a-database</code></a></em>).</p> <p>2.   DB Fiddle (<em><a href="https://www.db-fiddle.com" target="_blank"><code>www.db-fiddle.com</code></a></em>) 是一个非常棒的在线资源,可以用来练习 SQL 语句和设计数据库。你可以创建并填充表格、查询数据并查看结果。尝试使用 DB Fiddle 来实现本章中讨论的产品和类别数据库表。为每个数据库表插入三行示例数据(参见 表 27-1 和 27-2),然后运行查询以选择每个表中的数据。</p> <h2 id="第二十八章28-使用-pdo-库进行数据库编程">第二十八章:28 使用 PDO 库进行数据库编程</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>将数据库集成到 Web 应用程序中需要编写代码来执行操作,如打开与数据库系统的连接;创建数据库及其表结构;通过插入、删除和更新来操作数据库数据;以及查询和检索符合期望条件的数据。在本章中,您将了解 PHP 数据对象(PDO)库,它使得执行这些数据库操作变得更加简单。我们将使用该库逐步开发一个简单的多页面 Web 应用程序,从数据库中提取信息。</p> <h3 id="pdo-库">PDO 库</h3> <p>自 2005 年以来,数据库操作的 PDO 库已成为 PHP 语言的内置功能,并且通过各种驱动程序与许多数据库管理系统(DBMS)兼容,包括 MySQL 和 SQLite,正如我们将在本章中看到的那样。这使得开发灵活的 Web 应用程序变得极为简单,开发者可以在最少修改代码的情况下切换数据库管理系统。在 PDO 出现之前,切换到不同的 DBMS 意味着使用不同的库。</p> <p>除了提供一种标准(因此是可重用的)方式来在不同的关系型数据库系统上运行 SQL 命令外,PDO 还通过使用<em>预处理语句</em>使得编写更安全的数据库通信代码变得更加容易。这些是数据库查询的模板,包括一些占位符,用于在查询执行时将其设置为实际值。基本的模式是将 SQL 语句构建为一个字符串(包括任何占位符),将该字符串传递给 PDO 连接对象以“准备”该语句,传递任何用于填充占位符的值,然后在数据库上执行准备好的语句。</p> <p>通过预处理语句处理 SQL 代码可以避免 SQL 注入攻击问题,因此本书中我们只会使用预处理语句。在<em>SQL 注入</em>中,接收到的用户输入(例如通过 Web 表单或登录字段)被连接到 SQL 查询字符串中,并在数据库上执行。恶意用户可以利用这种常见的 Web 应用程序漏洞,修改原始 SQL 查询或添加一个额外的 SQL 查询,然后也在数据库上执行。Web 漫画 XKCD 以幽默的方式展示了 SQL 注入攻击,见图 28-1。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure28-1.jpg" alt="" loading="lazy"></p> <p>图 28-1:Randall Munroe 的“Bobby Tables”漫画(<a href="https://xkcd.com/327/" target="_blank"><code>xkcd.com/327/</code></a>)是一个轻松幽默的例子,展示了 SQL 注入攻击可能造成的损害。</p> <p>使用 PDO 的另一个好处是它提供了 <em>对象获取模式</em>,通过该模式,从数据库查询到的数据会自动打包成 PHP 代码中相应类的对象(<em>模型类</em>)。你只需告诉 PDO 哪些类对应哪些数据库表。如果没有这个特性,你将不得不编写代码来处理如何根据查询结果构建对象的细节,这通常需要与多维数组和列标题打交道。</p> <p>在本章中,我们将探索如何使用 PDO 库的基础知识,同时开发一个面向对象的、数据库驱动的 web 应用程序。</p> <blockquote> <p>注意</p> </blockquote> <p><em>本章仅仅触及了 PDO 库功能的皮毛。想了解更多关于它的功能,我推荐那篇名为“(唯一合适的)PDO 教程”的文章,在线阅读地址为</em> <a href="https://phpdelusions.net/pdo" target="_blank"><code>phpdelusions.net/pdo</code></a><em>。</em></p> <h3 id="一个简单的数据库驱动-web-应用程序">一个简单的数据库驱动 web 应用程序</h3> <p>要开始使用 PDO,我们首先将创建一个简单的应用程序,其中有一个页面用来从数据库中检索并显示一些产品的信息。这将展示如何连接数据库、创建表、填充数据,并以面向对象的方式检索这些数据以供应用程序使用。在《一个多页面的数据库驱动 web 应用程序》一节中,位于 第 553 页,我们将扩展应用程序,增加多个页面、组织良好的控制器逻辑和 Twig 模板。</p> <p>目前,项目将具有以下文件结构:</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/pg543.jpg" alt="" loading="lazy"></p> <p>首先,创建一个新的项目文件夹,并添加通常的 <em>composer.json</em> 文件,声明 Mattsmithdev 命名空间的类位于 <em>src</em> 文件夹中。然后添加一个 <em>public</em> 文件夹,其中包含常见的 <em>index.php</em> 脚本,读取自动加载器,创建一个 Application 对象并调用它的 run() 方法。这样,我们就准备好设置数据库了。<em>db</em> 文件夹将包含创建 MySQL 和 SQLite 版本数据库的脚本,以支持我们的应用程序。</p> <h4 id="设置数据库架构">设置数据库架构</h4> <p>我们的 web 应用程序将能够使用 MySQL 或 SQLite 作为数据库管理系统(DBMS),在本节中,我们将编写 PHP 脚本,使用这两种系统来设置一个新的数据库架构。对于小型本地项目,项目的 <em>var</em> 文件夹中的 SQLite 数据库文件通常已经足够。对于大规模、生产就绪的 web 应用程序,MySQL 更为常见,且数据库通常运行在不同的服务器上(或者多个服务器上)。</p> <p>对于这个简单的例子,我们将把 MySQL 和 SQLite 数据库设置脚本保存在项目的 <em>db</em> 文件夹中。然而,在更现实的场景中,数据库结构应该是固定的,数据库也已经设置好了,因此这些脚本通常不会作为应用程序文件夹结构的一部分保留下来。</p> <p>对于我们的数据库,我们将创建一个由单个名为 product 的表组成的简单架构,并将两条示例记录插入该表中。图 28-2 显示了该表的 ER 模型。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure28-2.jpg" alt="" loading="lazy"></p> <p>图 28-2:产品表的 ER 模型</p> <p>如图所示,产品表将包含产品的 id(一个唯一的数字标识符)、描述和价格字段。</p> <h5 id="mysql">MySQL</h5> <p>清单 28-1 使用 PDO 来创建 MySQL 数据库架构,定义一个产品表的结构,并将两条示例记录插入到该表中。将这个脚本命名为<em>create_database.php</em>并保存在<em>db</em>文件夹中。</p> <pre><code><?php ❶ define('DB_NAME', 'demo1'); ❷ $connection = new \PDO( 'mysql:host=localhost:3306', 'root', 'passpass' ); ❸ $sql = 'CREATE DATABASE ' . DB_NAME ; $stmt0 = $connection->prepare($sql); $stmt0->execute(); $connection = new \PDO( ❹ 'mysql:dbname=' . DB_NAME . ';host=localhost:3306', 'root', 'passpass' ); $sql = 'CREATE TABLE IF NOT EXISTS product (' ❺ . 'id integer PRIMARY KEY AUTO_INCREMENT,' . 'description text,' . 'price float' . ')'; $stmt1 = $connection->prepare($sql); $stmt1->execute(); $sql = "INSERT INTO product (description, price) VALUES ('hammer', 9.99)"; $stmt2 = $connection->prepare($sql); $stmt2->execute(); $sql = "INSERT INTO product (description, price) VALUES ('ladder', 59.99)"; $stmt3 = $connection->prepare($sql); $stmt3->execute(); </code></pre> <p>清单 28-1:用于创建 MySQL 数据库的脚本</p> <p>首先,我们定义一个 DB_NAME 常量来保存数据库架构名称'demo1' ❶。将名称放入常量中使得这个脚本易于编辑,并且可以在其他数据库架构上重用——只需更新常量中的名称。</p> <p>接下来,我们创建一个新的 PDO 对象以建立与数据库的连接,将结果存储在$connection 变量中 ❷。第一个参数是<em>数据源名称(DSN)</em>,它是一个标准化的字符串,提供有关数据库连接的信息。DSN 字符串以'mysql:'开头,告诉 PDO 我们想连接到 MySQL 服务器,后面跟着一个或多个由分号分隔的键=值对。现在,我们只需要一个键=值对来指定 MySQL 数据库运行的主机是 localhost 服务器,端口为 3306。我们在这里不包含架构名称,因为我们还没有创建该架构。传递给 PDO 构造函数的第二个和第三个参数提供了用户名'root'和密码'passpass'。将它们替换为您 MySQL 设置的数据库用户名和密码(请参见附录 B)。</p> <p>接下来,我们构建并执行一个 SQL 语句来创建 DB_NAME 常量中指定的数据库架构 ❸。我们将 SQL 语句作为字符串存储在<span class="math inline">\(sql 变量中,内容为 SQL 关键字'CREATE DATABASE'加上架构名称。我们将该字符串传递给\)</span>connection 对象的 prepare()方法,以安全地准备该语句,并将结果(PDO 库的 PDOStatement 类的一个对象)存储在$stmt0 变量中。然后我们执行预处理的语句。在与数据库交互时,我们将反复使用这种准备和执行 SQL 语句的基本模式。在大多数情况下,我们将在语句执行后执行某个操作,例如返回已检索对象的列表,或者确认数据库已按预期更改,并通知用户是否已经更改。</p> <p>现在我们已经创建了架构,接下来需要在其中创建产品表。首先,我们通过新建一个连接到数据库架构的连接来覆盖$connection,而不是连接到 MySQL 服务器。请注意,这次我们在构造 PDO 对象时在 DSN 字符串中指定了架构名称 ❹。具体来说,我们将 DB_NAME 常量的值作为 dbname 键的值,并用分号(;)将这个键/值对与 DSN 字符串中的其他部分分开。然后我们构建并执行另一个 SQL 语句,创建包含 id、description 和 price 字段的产品表。MySQL 数据库支持自增功能,它会自动生成唯一的数字键并按顺序排列。我们的 SQL 语句利用了这个功能,作为 id 字段主键声明的一部分,因此我们不必担心手动设置产品 ID ❺。</p> <p>我们通过创建并执行两个 INSERT SQL 语句来为产品表添加两行数据:一把售价 9.99 的'锤子'和一把售价 59.99 的'梯子'。我们没有为每个产品的 id 字段提供值,因为 MySQL 会自动生成它们。</p> <p>编写完此脚本后,输入 php db/create_database.php 在命令行运行它。这将创建并填充 MySQL 架构。</p> <h5 id="sqlite">SQLite</h5> <p>现在,让我们将列表 28-1 中的脚本修改一下,以便创建与 SQLite 数据库文件相同的架构。正如你所看到的,过程类似,因为 PDO 库可以像与 MySQL 一样轻松地与 SQLite 一起使用。将列表 28-2 中的内容保存为<em>create_databaseSQLite.php</em>,并放入项目的<em>db</em>子目录中。SQLite 数据库文件本身将位于<em>var</em>子目录中,该目录是在脚本运行时创建的。</p> <pre><code><?php define('FILENAME', 'demo1.db'); ❶ define('FOLDER_PATH', __DIR__ . '/../var/'); if (!file_exists(FOLDER_PATH)) { mkdir(FOLDER_PATH); } $connection = new \PDO( ❷'sqlite:' . FOLDER_PATH . FILENAME ); $sql = 'CREATE TABLE IF NOT EXISTS product (' ❸. 'id integer PRIMARY KEY AUTOINCREMENT,' . 'description text,' . 'price float' . ')'; $stmt1 = $connection->prepare($sql); $stmt1->execute(); $sql = "INSERT INTO product (description, price) VALUES ('hammer', 9.99)"; $stmt2 = $connection->prepare($sql);- $stmt2->execute(); $sql = "INSERT INTO product (description, price) VALUES ('ladder', 59.99)"; $stmt3 = $connection->prepare($sql); $stmt3->execute(); </code></pre> <p>列表 28-2:创建 SQLite 数据库的脚本</p> <p>首先,我们定义数据库文件名('demo1.db')和文件夹路径的常量。请记住,路径中的双点(..)表示父目录,因此<em>/../var</em>表示<em>var</em>应该与运行脚本的目录处于同一级别 ❶。如果该目录不存在,我们会创建它。</p> <p>然后,我们再次创建一个新的 PDO 对象,传入一个 DSN 字符串作为参数,提供我们希望连接的数据库信息 ❷。这次,DSN 字符串以'sqlite:'开头,告诉 PDO 我们希望连接到 SQLite 服务器,后面跟着完整的文件路径,包括目录路径和文件名,指向所需的数据库。与 MySQL 不同,我们不需要编写并执行创建数据库架构的 SQL 语句;如果需要,SQLite 数据库文件将在建立连接时自动创建。而且,由于 SQLite 只是操作一个文件,因此不需要任何用户名或密码。</p> <p>一旦 PDO 建立了数据库连接,通常不再关心它正在使用的数据库管理系统(DBMS),因此脚本的其余部分与 清单 28-1 几乎相同:我们创建并执行 SQL 语句来创建产品表并向其中添加两条记录。唯一的区别是 SQLite 使用没有下划线的关键字 AUTOINCREMENT(与 MySQL 的 AUTO_INCREMENT 不同) ❸。</p> <p>与 MySQL 版本一样,你需要运行此脚本来创建和填充 SQLite 数据库模式。在命令行输入 php db/create_databaseSQLite.php。</p> <h4 id="编写-php-类">编写 PHP 类</h4> <p>现在,我们通过编写一些 PHP 类来组织我们简单 Web 应用程序的逻辑。目前,我们需要三个类。像往常一样,我们将创建一个 Application 类,作为应用程序的前端控制器。我们还将编写一个 Product 类,具有与数据库中产品表字段对应的属性,以便于在数据库与 PHP 代码之间传输数据。最后,我们将设计一个 Database 类,用于封装创建数据库连接的逻辑。这样不仅有助于保持代码整洁和面向对象,而且还使我们能够轻松重构应用程序,在 MySQL 和 SQLite 之间切换,而不会对其他代码产生太大影响。</p> <p>我们从 Application 类开始。在 <em>src/Application.php</em> 中声明此类,如 清单 28-3 所示。</p> <pre><code><?php namespace Mattsmithdev; use Mattsmithdev\Product; class Application { ❶ private ?\PDO $connection; public function __construct() { $db = new Database(); ❷ $this->connection = $db->getConnection(); } public function run() { if (NULL != $this->connection){ ❸ $products = $this->getProducts(); print '<pre>'; var_dump($products); print '</pre>'; } else { print '<p>Application::run() - sorry ' . '- there was a problem with the database connection'; } } public function getProducts(): array { $sql = 'SELECT * FROM product'; $stmt = $this->connection->prepare($sql); $stmt->execute(); ❹ $stmt->setFetchMode(\PDO::FETCH_CLASS, Product::class); $products = $stmt->fetchAll(); return $products; } } </code></pre> <p>清单 28-3:Application 类</p> <p>我们首先为类添加一个私有的连接属性 ❶。该属性的数据类型是可空的 ?\PDO,因此它将是指向 PDO 数据库连接对象的引用,或者是 NULL(如果连接失败)。在类的构造方法中,我们创建一个新的 Database 对象,并调用其 getConnection() 方法(我们稍后会定义该类和方法)。</p> <p>接下来,我们将生成的数据库连接引用存储到 Application 类的连接属性中 ❷。这看起来可能比之前直接连接数据库的方式更为间接,但将建立连接的细节委托给 Database 类,可以让 Application 类无论使用何种 DBMS 都能正常工作。</p> <p>接下来我们声明应用程序的 run() 方法。在其中,我们测试连接属性以确保它不是 NULL,如果不是,则调用 getProducts() 方法 ❸,该方法返回从数据库检索到的 Product 对象数组。为简便起见,我们将数组打印出来,并在前面加上 HTML <pre> 标签。(我们将在本章后续扩展应用程序时,精细化项目以输出有效的 HTML。)如果连接为 NULL,则打印错误信息。</p> <p>我们通过声明 getProducts() 方法来结束类的定义。该方法使用连接属性来准备和执行一个 SQL 语句,从数据库的产品表中选择所有行。此查询的原始结果保存在 PDOStatement 对象中,通过 $stmt 变量引用,但我们希望将结果表示为 Product 对象。</p> <p>这时,PDO 库的对象获取模式派上用场。我们通过调用 $stmt->setFetchMode() ❹ 来设置它,传递 \PDO::FETCH_CLASS 常量作为第一个参数,表示我们希望结果是类的对象。第二个参数 Product::class 告诉 PDO 使用哪个(带命名空间的)类。::class 魔术常量返回完整限定类名字符串(在这个例子中是 'Mattsmithdev\Product')。然后,我们调用 $stmt->fetchAll() 来获取结果。由于我们从数据库中选择了多行,这将创建一个 Product 对象的数组,而不仅仅是一个对象。我们通过 $products 变量返回这个数组。</p> <p>现在我们将创建 Product 模型类。该类的属性必须与产品数据库表的列相对应(即具有相同的名称和数据类型),以便 PDO 能够成功地将查询结果返回为 Product 对象。将 Listing 28-4 的内容保存到 <em>src/Product.php</em> 文件中。</p> <pre><code><?php namespace Mattsmithdev; class Product { private int $id; private string $description; private float $price; } </code></pre> <p>Listing 28-4:Product 类</p> <p>这段代码做的就是声明该类的三个私有属性(id、description 和 price),它们的名称和数据类型与产品表中的字段相匹配。这就是 PDO 库需要的所有内容,用以将表中的行作为该类的对象检索出来。由于目前我们的应用程序仅仅使用 var_dump() 来显示一个 Product 对象数组,我们从未需要访问类的私有属性。当我们扩展应用程序时,我们将为 Product 类添加访问器方法,这样我们就可以编写一个更优雅的模板页面,遍历并输出每个对象的属性,以自定义且有效的 HTML 格式展示。</p> <p>最后,我们声明 Database 类来管理建立并存储 MySQL 数据库连接的过程。创建 <em>src/Database.php</em> 文件,内容为 Listing 28-5。</p> <pre><code><?php namespace Mattsmithdev; class Database { ❶ const MYSQL_HOST = 'localhost'; const MYSQL_PORT = '3306'; const MYSQL_USER = 'root'; const MYSQL_PASSWORD = 'passpass'; const MYSQL_DATABASE = 'demo1'; const DATA_SOURCE_NAME = 'mysql:dbname=' . self::MYSQL_DATABASE ❷ . ';host=' . self::MYSQL_HOST . ':' . self::MYSQL_PORT; ❸ private ?\PDO $connection; public function getConnection(): ?\PDO { return $this->connection; } public function __construct() { ❹ try { $connection = new \PDO( self::DATA_SOURCE_NAME, self::MYSQL_USER, self::MYSQL_PASSWORD ); $this->connection = $connection; ❺} catch (\Exception $e) { print "Database::__construct() - Exception ' . '- error trying to create database connection"; } } } </code></pre> <p>Listing 28-5:Database 类</p> <p>我们声明类常量,用于存储创建 MySQL 数据库实时连接所需的五个独立数据项 ❶:主机(localhost)、端口(3306)、MySQL 用户名和密码(根据需要填写),以及我们想要操作的数据库架构名称(demo1)。然后,我们将其中一些常量组合成另一个常量,表示 DSN 字符串,该字符串将作为创建 PDO 对象时传递的第一个参数 ❷。</p> <p>接下来,我们为该类声明一个私有连接属性 ❸,以及一个公有的 getConnection() 方法来返回其值。这个属性的数据类型为可空的 ?\PDO,因此它要么为 NULL,要么为一个 PDO 对象的引用。</p> <p>在数据库类的构造方法中,我们通过创建一个新的 PDO 对象来尝试连接 MySQL 数据库,使用类常量提供所需的 DSN 字符串、用户名和密码。数据库连接的引用存储在类的 connection 属性中。这些操作被包装在一个 try 语句中❹,因此在此过程中抛出的任何异常都会被捕获❺,并打印出错误信息。因此,每当创建一个新的数据库对象(来自应用程序类中)时,构造方法都会尝试连接数据库。随后调用 getConnection() 方法将返回一个 PDO 连接对象,或者如果创建连接时出现问题,则返回 NULL。</p> <p>这样,我们就可以准备好运行应用程序并查看结果了。当你访问运行 Web 应用程序的本地主机服务器时,应该能看到类似于 图 28-3 的内容。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure28-3.jpg" alt="" loading="lazy"></p> <p>图 28-3:显示 $products 数组内容的网页</p> <p>在这一阶段,网页看起来并不复杂;它仅显示了 $products 数组的 var_dump()。由于我们尚未在应用程序类的 run() 方法中包含任何决策逻辑,因此无论 URL 编码的请求变量如何,都会始终显示这个页面。然而,数组的打印内容表明我们已经成功从产品的 MySQL 数据库表中检索到条目,并将它们映射为我们自定义的 Product 类的对象,这是数据库驱动应用程序开发中的一个重要第一步。</p> <h4 id="从-mysql-切换到-sqlite">从 MySQL 切换到 SQLite</h4> <p>之前,我们在 MySQL 和 SQLite 中设置了相同的数据库模式;那么,如何重构我们的应用程序以使用 SQLite 模式而不是 MySQL 呢?我们已经设计了应用程序,使得所有数据库管理系统(DBMS)特定的逻辑都被封装在数据库类中,并且我们只在应用程序类的构造方法中引用该类一次(见 清单 28-3)。在这里,我们使用语句 $db = new Database() 来获取新数据库对象的引用,然后调用其 getConnection() 方法来获得一个 PDO 数据库连接。</p> <p>让我们将此语句替换为创建一个 DatabaseSQLite 类实例的语句,该实例将连接到 SQLite,而不是 MySQL。 清单 28-6 显示了对 <em>src/Application.php</em> 的必要更改。</p> <pre><code>--snip-- public function __construct() { $db = new DatabaseSQLite(); $this->connection = $db->getConnection(); } --snip-- </code></pre> <p>清单 28-6:更新应用程序类以创建 DatabaseSQLite 对象而不是 Database 对象</p> <p>现在,我们需要声明一个 DatabaseSQLite 类来封装创建和存储实时 SQLite 数据库连接的工作。为了让我们其余的应用程序代码保持原样,它需要一个返回 PDO 连接对象引用的 getConnection() 方法,就像数据库类一样。创建 <em>src/DatabaseSQLite.php</em> 文件,并包含 清单 28-7 中的代码。</p> <pre><code><?php namespace Mattsmithdev; class DatabaseSQLite { const DB_DIRECTORY = __DIR__ . '/../var'; const DB_FILE_PATH = self::DB_DIRECTORY . '/demo1.db'; const DATA_SOURCE_NAME = 'sqlite:' . self::DB_FILE_PATH; private ?\PDO $connection = NULL; public function getConnection(): ?\PDO { return $this->connection; } public function __construct() { try { $this->connection = new \PDO(self::DATA_SOURCE_NAME); } catch (\Exception $e){ print 'DatabaseSQLite::__construct() - Exception - ' . 'error trying to create database connection' . PHP_EOL; } } } </code></pre> <p>清单 28-7:DatabaseSQLite 类</p> <p>这个新类遵循与旧 Database 类相同的结构;只有与 SQLite 相关的细节不同。我们首先声明用于创建 SQLite 数据库连接所需的数据常量:包含数据库文件的目录位置(DB_DIRECTORY);包含目录位置和文件名的完整文件路径(DB_FILE_PATH);以及 DSN 字符串,包含完整文件路径(DATA_SOURCE_NAME)。然后我们声明一个私有连接属性,数据类型为可为空的 \PDO(?\PDO),并像以前一样提供公共的 getConnection()方法。最后,我们声明一个构造方法,使用 try 和 catch 语句来尝试创建新的 PDO 数据库连接对象,并报告任何错误——这与 Database 类一样。</p> <p>重新运行网页服务器后,你应该会看到应用程序的功能和之前完全一样,展示了从数据库中获取的$products 数组的内容。唯一不同的是,现在我们使用的是 SQLite 而不是 MySQL。这个切换几乎不需要更新代码,除了声明新的 DatabaseSQLite 类,并修改一行代码以创建一个 DatabaseSQLite 对象,而不是 Database 对象。</p> <h3 id="一个多页面的数据库驱动网页应用程序">一个多页面的数据库驱动网页应用程序</h3> <p>现在让我们将这个数据库驱动的网页应用程序扩展为多个页面,包括主页、产品列表页、显示单个产品详情的页面和显示错误消息的页面。我们还将使用 Twig 模板引擎来简化这些页面设计的过程。扩展后的项目将具有以下文件结构:</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/pg553.jpg" alt="" loading="lazy"></p> <p>图 28-4 展示了该网站的四个页面。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure28-4.jpg" alt="" loading="lazy"></p> <p>图 28-4:扩展后的网页应用程序</p> <p>页面使用了 Bootstrap 格式化的 HTML,并提供了一个导航栏,链接到主页和产品列表。当点击某个产品旁边的<em>(show)</em>链接时,产品的详细信息将在新页面上显示。请注意,点击的产品的 ID 会作为查询字符串的一部分出现在页面的 URL 中(例如,<em>id=1</em>显示关于锤子的页面)。如果发生错误,比如缺少 ID 或 ID 与数据库中的行不匹配,错误页面将显示一个适当的错误消息;图 28-4 中的示例显示浏览器地址栏中 ID 为 99,并且有错误消息,表示没有找到具有该 ID 的产品。</p> <p>由于应用程序将涉及多个操作,我们将创建两个控制器类:ProductController 用于列出所有产品并显示单个项目的详细页面,DefaultController 用于显示主页和错误信息页面。我们将恢复使用 MySQL 和原始的 Database 类来管理数据库连接,但请记住,你始终可以通过改用 DatabaseSQLite 类来切换到 SQLite。由于项目将使用 Twig 模板,请确保在命令行中运行 <code>composer require twig/twig</code> 来将 Twig 库添加到项目中。</p> <h4 id="管理产品信息">管理产品信息</h4> <p>让我们开始扩展应用程序,重点关注通过数据库交互管理产品信息的类。首先,我们将为 Product 类添加 getter 和 setter 方法。这是必要的,因为应用程序现在需要单独访问 Product 对象的属性,以比简单的 var_dump() 更优雅的方式显示它们。更新 <em>src/Product.php</em>,使其与列表 28-8 的内容一致。</p> <pre><code><?php namespace Mattsmithdev; class Product { private int $id; private string $description; private float $price; public function getId(): int { return $this->id; } public function setId(int $id): void { $this->id = $id; } public function getDescription(): string { return $this->description; } public function setDescription(string $description): void { $this->description = $description; } public function getPrice(): float { return $this->price; } public function setPrice(float $price): void { $this->price = $price; } } </code></pre> <p>列表 28-8:具有每个属性的 getter 和 setter 方法的 Product 类</p> <p>在这里,我们为 Product 类的三个属性:id、description 和 price 添加了简单的 getter 和 setter 方法。这是该类的六个新方法。</p> <p>我们现在将通过将与从数据库检索产品相关的逻辑放入 ProductRepository 类中,来更好地组织我们的应用程序代码。在数据库驱动的应用程序中,使用这种类(称为<em>仓库类</em>)来将访问数据库的逻辑与应用程序中的其他逻辑分离是很常见的。仓库类方法接受并返回对象,处理与数据库的任何交互(通过 Database 类本身帮助建立数据库连接)。应用程序的其余部分则处理这些返回的对象,根本不涉及数据库。</p> <p>我们的 ProductRepository 类将包括一个方法来检索所有产品,就像我们最初在 Application 类中实现的那样,同时还将新增一个方法,通过给定的 ID 检索单个产品。(通常,仓库类也会有其他数据库操作方法,比如添加、更新或删除条目。我们将在第二十九章中讨论这些操作。)由于这个新类需要与数据库交互,它将负责创建必要的 Database 对象。这些更改将使得主要的 Application 类能够专注于控制器逻辑。</p> <p>创建新的 ProductRepository 类,在 <em>src/ProductRepository.php</em> 中,如列表 28-9 所示。黑色代码是全新的;灰色代码来自 Application 类(请参见列表 28-3)。</p> <pre><code><?php namespace Mattsmithdev; class ProductRepository { private ?\PDO $connection = NULL; public function __construct() { $db = new Database(); $this->connection = $db->getConnection(); } public function findAll(): array { ❶ if (NULL == $this->connection) return []; $sql = 'SELECT * FROM product'; $stmt = $this->connection->prepare($sql); $stmt->execute(); $stmt->setFetchMode(\PDO::FETCH_CLASS, Product::class); $products = $stmt->fetchAll(); return $products; } public function find(int $id): ?Product { if (NULL == $this->connection) return NULL; $sql = 'SELECT * FROM product WHERE id = :id'; $stmt = $this->connection->prepare($sql); ❷ $stmt->bindParam(':id', $id); $stmt->execute(); $stmt->setFetchMode(\PDO::FETCH_CLASS, Product::class); ❸ $product = $stmt->fetch(); ❹ if ($product == false) { return NULL; } return $product; } } </code></pre> <p>列表 28-9:新的 ProductRepository 类</p> <p>我们声明了一个私有的连接属性,接着是一个构造方法,该方法创建一个新的 Database 对象,并调用其 getConnection() 方法来获取数据库连接。这与原始的 Application 类相同。接下来,我们声明一个 findAll() 方法,其中包含了 Application 类中 getProducts() 方法的逻辑。(由于这已经是 ProductRepository 类中的一个方法,所以我们不需要在方法名中使用 <em>product</em> 一词。)该方法以一行额外的代码开始,用于测试数据库连接是否为 NULL,如果是的话,返回一个空数组 ❶。这是必要的,因为 Application 类现在不再能够访问数据库连接。如果连接不为 NULL,方法会从数据库中获取所有产品,并将它们作为一个 Product 对象数组返回,和之前一样。</p> <p>接下来,我们声明了新的 find() 方法。该方法接受一个整数类型的 $id 参数,用于从数据库表中检索单个 Product 对象。同样,该方法的第一条语句是对数据库连接的 NULL 测试。如果连接为 NULL,方法会立即返回 NULL 并结束执行。如果连接不为 NULL,我们准备一个 SQL 查询字符串 'SELECT * FROM product WHERE id = :id'。其中的 :id 是一个命名的 PDO 占位符,由冒号和一个标识符(id)组成,用来替换 SQL 语句中需要填充的变量值部分。</p> <p>我们使用 PDO 语句对象的 bindParam() 方法,将占位符与 $id 参数的值绑定 ❷。这一机制使得 PDO 的预处理语句能够防止 SQL 注入攻击。占位符语法强制要求 $id 的值作为要在 id 列中查找的可能值,而变量的值不可能改变查询本身,从而避免了潜在的恶意操作。</p> <p>该方法使用 PDO 的对象提取模式,将查询结果作为一个 Product 对象(如果数据库中没有匹配的产品 ID,则返回 NULL)。注意,我们是通过调用 $stmt->fetch() ❸ 获取 Product 对象,而不是像在 findAll() 方法中那样使用 $stmt->fetchAll(),因为这次我们只期望得到一个单一的结果。由于 fetch() 方法在失败时返回的是 false(而非 NULL),我们会检查该值,并在没有成功获取对象时返回 NULL ❹。</p> <h4 id="实现控制器逻辑">实现控制器逻辑</h4> <p>接下来,我们将重点关注实现应用程序控制器逻辑的类。首先,我们将从 Application 类中移除与数据库相关的代码(因为这些代码现在已经放入了 ProductRepository),并更新该类,以便它能够接收请求并将其委派给适当的控制器。修改 <em>src/Application.php</em> 文件,使其与 Listing 28-10 中的内容一致。</p> <pre><code><?php namespace Mattsmithdev; class Application { private DefaultController $defaultController; private ProductController $productController; public function __construct() { $this->defaultController = new DefaultController(); $this->productController = new ProductController(); } public function run(): void { $action = filter_input(INPUT_GET, 'action'); switch ($action) { case 'products': ❶ $this->productController->list(); break; case 'show': ❷ $id = filter_input(INPUT_GET, 'id', FILTER_SANITIZE_NUMBER_INT); if (empty($id)) {❸ $this->defaultController-> error('error - To show a product, an integer ID must be provided'); } else {❹ $this->productController->show($id); } break; default: ❺ $this->defaultController->homepage(); } } } </code></pre> <p>Listing 28-10:更新后的 Application 类,包含简单的前端控制器逻辑</p> <p>我们声明了两个私有的 defaultController 和 productController 属性,然后使用构造函数将它们填充为 DefaultController 和 ProductController 对象。接着,我们声明了 run() 方法,该方法从传入的 URL 中获取 $action 的值,并将其传递给 switch 语句来决定要做什么。如果值是 'products' ❶,我们调用 ProductController 对象的 list() 方法来显示列出所有产品的页面。</p> <p>如果 $action 的值是 'show' ❷,我们希望显示一个包含单个产品详情的页面。为此,我们尝试从 URL 中提取一个整数 $id 变量,使用 FILTER_SANITIZE_NUMBER_INT 来移除变量中的非整数字符。如果 $id 最终为空 ❸,我们通过将错误消息字符串传递给 DefaultController 对象的 error() 方法来显示错误页面。如果 $id 的值不为空 ❹,我们将其传递给 ProductController 对象的 show() 方法以显示该产品页面。最后,我们声明了 switch 语句的默认操作 ❺,即通过调用 DefaultController 对象的 homepage() 方法来显示主页。</p> <p>现在我们将创建一个抽象的 Controller 类,它将成为我们两个控制器类 DefaultController 和 ProductController 的超类。在 <em>src/Controller.php</em> 中创建包含 列表 28-11 内容的代码。请注意,这个类与 第 436 页 的列表 22-8 是一样的。</p> <pre><code><?php namespace Mattsmithdev; use Twig\Loader\FilesystemLoader; use Twig\Environment; abstract class Controller { const PATH_TO_TEMPLATES = __DIR__ . '/../templates'; protected Environment $twig; public function __construct() { $loader = new FilesystemLoader(self::PATH_TO_TEMPLATES); $this->twig = new Environment($loader); } } </code></pre> <p>列表 28-11:抽象的 Controller 超类,提供一个 twig 属性</p> <p>我们声明这个类为抽象类,以便它不能被实例化。我们为 Twig 模板目录路径声明了一个类常量。然后我们声明了一个具有受保护可见性的 twig 属性,以便它可以在该类的子类方法中使用。在类的构造函数中,我们创建了两个 Twig 对象:FilesystemLoader 对象和 Environment 对象。后者包含了至关重要的 render() 方法,并存储在 twig 属性中,而前者则帮助 Environment 对象访问模板文件。</p> <p>在声明了这个 Controller 超类之后,我们现在可以声明将从它继承的子类。我们从 DefaultController 开始,它将负责显示主页和错误页面。在 <em>src/DefaultController.php</em> 中声明该类,如 列表 28-12 所示。</p> <pre><code><?php namespace Mattsmithdev; class DefaultController extends Controller { ❶ public function homepage(): void { $template = 'home.xhtml.twig'; $args = []; print $this->twig->render($template, $args); } ❷ public function error(string $message): void { $template = 'error.xhtml.twig'; $args = [ 'message' => $message ]; print $this->twig->render($template, $args); } } </code></pre> <p>列表 28-12:用于简单页面操作的 DefaultController 类</p> <p>我们声明这个类继承自 Controller,以便它可以继承超类的 twig 属性。该类的 homepage() 方法 ❶ 调用继承的 twig 属性的 render() 方法来渲染 <em>home.xhtml.twig</em> 模板,然后打印出接收到的文本。类似地,error() 方法 ❷ 渲染并打印 <em>error.xhtml.twig</em> 模板。对于这个模板,我们传递 $message 参数的值,以便错误页面会显示一个自定义错误信息。</p> <p>现在我们将创建另一个控制器子类,ProductController,用于显示与产品相关的页面。创建 <em>src/ProductController.php</em> 文件,匹配 Listing 28-13 中的代码。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; public function __construct() { parent::__construct(); $this->productRepository = new ProductRepository(); } ❶ public function list(): void { $products = $this->productRepository->findAll(); $template = 'product/list.xhtml.twig'; $args = [ 'products' => $products ]; print $this->twig->render($template, $args); } ❷ public function show(int $id): void { $product = $this->productRepository->find($id); if (empty($product)) { $defaultController = new DefaultController(); $defaultController->error( 'error - No product found with ID = ' . $id); } else { $template = 'product/show.xhtml.twig'; $args = [ 'product' => $product ]; print $this->twig->render($template, $args); } } } </code></pre> <p>Listing 28-13: ProductController 类</p> <p>像 DefaultController 一样,这个类声明为扩展 Controller。它的构造方法首先调用父类(Controller)的构造方法,设置继承的 twig 属性。然后,构造方法创建一个新的 ProductRepository 对象,并将其存储在 productRepository 属性中。该类将能够使用此对象从数据库中获取产品信息。</p> <p>该类的 list() 方法 ❶ 通过调用 ProductRepository 对象的 findAll() 方法获取一个 Product 对象数组。然后它渲染并打印 <em>list.xhtml.twig</em> 模板,传递该产品数组,来显示完整的产品列表页面。show() 方法 ❷ 与 list() 类似,但它使用提供的整数 $id 参数从数据库中检索一个单一的 Product 对象(通过 ProductRepository 对象的 find() 方法)。然后,它通过渲染并打印 <em>show.xhtml.twig</em> 模板来显示该产品的详细信息。如果该方法中从仓库接收到 NULL,而不是对象,则表明数据库中没有该 ID 的产品,此时将显示错误页面。</p> <h4 id="设计模板">设计模板</h4> <p>剩下的工作就是为应用程序的各个页面设计 Twig 模板。所有模板将扩展一个公共的基础模板,该模板定义了每个页面的 HTML 骨架,并包含了 Bootstrap CSS 样式表。我们首先编写这个基础模板。创建 <em>templates/base.xhtml.twig</em> 文件,包含 Listing 28-14 中的 Twig 代码。</p> <pre><code><html lang="en"> <head> <title>MGW - {% block title %}{% endblock %}</title> ❶ <meta name="viewport" content="width=device-width"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"> </head> <body class="container"> <ul class="nav nav-pills"> ❷ <li class="nav-item"> <a class="nav-link {% block homeLink %}{% endblock %}" href="/">Home page</a> </li> <li class="nav-item"> <a class="nav-link {% block productLink %}{% endblock %}" href="/?action=products">Product List page</a> </li> </ul> {% block body %} ❸ {% endblock %} </body></html> </code></pre> <p>Listing 28-14: 顶级 base.xhtml.twig 模板</p> <p>HTML <head> 元素包含一个 Twig 标题块,可以在其中插入页面标题 ❶。每个单独的页面模板将声明自己的标题,并在该块之前将其附加到 MGW 文本。<head> 元素还包含一个链接,用于从 <em><a href="https://www.jsdelivr.com" target="_blank"><code>www.jsdelivr.com</code></a></em> 下载压缩版的 Bootstrap 5 样式表。</p> <p>在 HTML <body> 中,我们声明一个使用 nav nav-pills CSS 类样式的无序列表,用于表示每个页面顶部的导航栏 ❷。我们希望导航栏包含指向主页和产品列表的链接。我们将每个项声明为使用 nav-item CSS 类样式的列表元素,并将其包含在一个使用 nav-link CSS 类样式的锚点链接元素中。每个锚点链接元素的类声明包括一个空的 Twig 块(分别称为 homeLink 和 productLink),我们可以在页面模板中覆盖它,以添加活动的 CSS 类。通过这种方式,当前页面将在导航栏中高亮显示。基础模板以一个 body Twig 块结束,在这里我们将填充页面特定的内容 ❸。</p> <p>既然我们已经有了基础的 Twig 模板,接下来我们可以开始声明各个子模板,从主页开始。创建新的 Twig 模板<em>templates/home.xhtml.twig</em>,并将 Listing 28-15 中的代码放入其中。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}Home page{% endblock %} {% block homeLink %}active{% endblock %} {% block body %} <h1>Home page</h1> <p> Welcome to the home page </p> {% endblock %} </code></pre> <p>Listing 28-15:主页的 home.xhtml.twig 模板</p> <p>我们首先声明该模板继承自基础模板。因此,该文件非常简短,因为我们只需要填写页面特定的内容。我们覆盖标题块,使用文本内容主页,以便该页面的标题为 MGW - 主页。接着,我们覆盖 homeLink 块,使用文本内容 active,使得主页链接在该模板页面显示时呈现为一个有颜色的按钮。最后,我们覆盖主体块,添加一个基础的标题和段落。</p> <p>现在我们将创建错误页面的 Twig 模板。将 Listing 28-16 中的内容输入到<em>templates/error.xhtml.twig</em>中。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}error page{% endblock %} {% block body %} <h1>Error</h1> <p class="alert alert-danger"> {{message}} </p> {% endblock %} </code></pre> <p>Listing 28-16:错误页面的 error.xhtml.twig 模板</p> <p>首先,我们覆盖标题块,并使用文本内容错误页面,使得该页面的标题为 MGW - 错误页面。然后,我们覆盖主体块,添加一个标题和一个段落。这个段落使用了 alert alert-danger CSS 类,以便使其成为一个间隔适当、粉色的警告信息展示给用户。段落的文本内容是 Twig 的消息变量,该变量通过$arguments 数组从 DefaultController 类的 error()方法传递给模板。Figure 28-5 展示了该错误页面在浏览器中的显示效果。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure28-5.jpg" alt="" loading="lazy"></p> <p>Figure 28-5:当没有提供整数类型的产品 ID 时显示的错误页面</p> <p>接下来,我们将在<em>templates/product/list.xhtml.twig</em>中创建一个产品列表页面的 Twig 模板。</p> <p>为了准备其他模型类的模板,我们创建一个名为<em>product</em>的<em>templates</em>子目录,在这里创建产品类的模板。此外,由于产品类的模板在此文件夹中,我们不需要在模板名称前加上类名。例如,我们可以将这个列表模板命名为<em>list.xhtml.twig</em>,而不是<em>productList.xhtml.twig</em>,依此类推。</p> <p><em>templates/product/list.xhtml.twig</em>的代码如 Listing 28-17 所示。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}Product List page{% endblock %} {% block productLink %}active{% endblock %} {% block body %} <h1>Product List page</h1> <ul> ❶ {% for product in products %} <li> id: {{product.id}} <br> description: {{ product.description}} <br> ❷ price: $ {{product.price | number_format(2)}} <br> ❸ <a href="/?action=show&id={{product.id}}">(show)</a> </li> {% endfor %} </ul> {% endblock %} </code></pre> <p>Listing 28-17:列出所有产品的 list.xhtml.twig 模板</p> <p>我们覆盖标题块,使用文本内容产品列表页面,并像在主页中一样,覆盖 productLink 块,使用文本内容 active。然后,在主体块内部,我们使用 Twig 的 for 循环❶来遍历 products 变量中的 Product 对象,将每个对象格式化为无序列表中的一个列表项。(这些 Product 对象通过数组的形式作为 ProductController 类 list()方法的一部分传递给模板。)</p> <p>我们提取每个对象的每个属性,分别显示在各自的行上,使用 Twig 的双大括号表示法。例如,{{product.id}}访问当前 Product 对象的 id 属性。请注意,我们将产品的价格格式化为包含两位小数 ❷。每个产品的列表项末尾都有一个锚点链接元素,用于显示该产品的详情页面 ❸。我们将产品的 ID 插入链接的 URL 中。这个 ID 将被传递到 ProductRepository 类的 find()方法中,用于从数据库中获取该产品的信息。</p> <p>最后,我们将在<em>templates/product/show.xhtml.twig</em>中创建单独的产品详情页面模板。清单 28-18 展示了具体实现方法。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}Product Details page{% endblock %} {% block body %} <h1>Product Details page</h1> id: {{product.id}} <br> description: {{ product.description}} <br> price: $ {{product.price | number_format(2)}} {% endblock %} </code></pre> <p>清单 28-18:显示单个产品详情的 show.xhtml.twig 模板</p> <p>这个模板比主产品列表页面模板简单,因为只显示了单个 Product 对象的属性,这些属性在主体块内被显示。该对象是从 ProductController 类的 show()方法传递到此模板中的 Twig 产品变量。与产品列表页面模板中的方法一样,我们分别输出对象的每个属性,通过双大括号表示法访问它们。</p> <p>至此,应用程序已完成。试着启动它并访问四个页面,点击<em>(show)</em>链接查看每个产品的产品详情页面。你应该能够看到应用程序能够从数据库中检索所有产品,或者根据相应的整数 ID 仅检索一个产品。你还应该能够通过简单地将 Database 类替换为 SQLiteDatabase 类,在 MySQL 和 SQLite 之间进行切换。### 总结</p> <p>在本章中,我们探索了 PHP 内置的 PDO 库与数据库交互的基础知识。我们使用该库创建了数据库模式并向表中插入数据。然后,我们从表中检索数据,将查询结果映射到 PHP 模型类的对象中,使用 PDO 的对象获取模式。我们还使用了预处理 SQL 语句,增加了一层防止 SQL 注入攻击的保护。</p> <p>我们将数据库模式整合到一个组织良好的多页面 Web 应用程序中。管理数据库连接的代码被抽象到合适的类中,无论是 Database 类还是 SQLiteDatabase 类,使得我们可以在 MySQL 和 SQLite 之间无缝切换,作为应用程序的数据库管理系统(DBMS)。从数据库中检索数据并将其转换为 Product 对象的逻辑被封装在 ProductRepository 类中,前端控制器逻辑被放置在 Application 类中,而用于显示简单页面的逻辑则位于 DefaultController 类中。与请求单个或多个产品相关的操作放入 ProductController 类,该类使用 ProductRepository 方法查询数据库。</p> <p>该架构可以轻松扩展,增加额外的存储库、模型和控制器类,以支持其他数据库表(用户、客户、订单、供应商等)。整个应用程序使用 Twig 模板引擎进行样式设计,并使用一个基础(父)模板来有效地共享所有单独子模板中的公共页面元素。</p> <h3 id="练习-25">练习</h3> <p>1.   创建一个名为 Book 的新模型类,包含以下属性:</p> <p>id(整数),自增长的主键</p> <p>title(字符串)</p> <p>author(字符串)</p> <p>price(浮动)</p> <p>创建一个设置脚本(基于 Listings 28-1 和 28-2),用于创建一个包含书籍表的数据库,并将其映射到 Book 类的属性和类型(对于 SQL 数据类型使用 int、text 和 float)。同时,向数据库表中插入至少两条书籍记录,包含你选择的 title、author 和 price 属性。然后编写一个面向对象的项目(或适应本章中的示例),从数据库中检索并列出所有书籍。该项目应包括以下内容:</p> <p>一个 Database 类,用于创建与数据库的连接。</p> <p>一个 <em>public/index.php</em> 脚本,它创建一个 Application 对象并调用其 run() 方法。</p> <p>一个 Application 类,带有 run() 方法,用于获取一组 Book 对象并使用 var_dump() 输出它们。</p> <p>你的 Book 模型类</p> <p>2.   如以下所示扩展你在练习 1 中的项目:</p> <p>a.   为你的 Book 类中的每个属性添加 getter 和 setter 访问器方法。</p> <p>b.   更改你的 Application 逻辑,以测试 URL 中的 action 变量。默认的 action 应该是使用适当的模板显示首页。如果 action 是 books,应用程序应显示一个包含所有书籍信息的页面,使用适当的模板。</p> <h2 id="第二十九章29-编程-crud-操作">第二十九章:29 编程 CRUD 操作</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>在前一章中,我们开始开发一个基于数据库的 Web 应用程序,重点学习如何从数据库读取数据。然而,读取只是四种主要数据库操作之一,这四种操作统称为 <em>CRUD</em>,即 <em>创建(create)、读取(read)、更新(update)、删除(delete)</em>。在本章中,我们将研究 CRUD 中的其他操作,同时扩展我们的 Web 应用程序。我们将编写代码,允许用户通过交互式链接和 Web 表单,通过删除、添加或更新条目来更改数据库数据。</p> <p>几乎所有基于数据库的移动或 Web 应用程序都围绕这四个 CRUD 操作展开。以电子邮件应用程序为例:当你写一封新邮件并发送时,这 <em>创建</em> 了一个表示收件人的数据库条目,并且在你自己的系统的已发送邮件箱数据库中也会创建一个条目。通常你会在收件箱中 <em>读取</em> 或 <em>删除</em> 邮件,或者你可能会草拟一封邮件,稍后再 <em>更新</em> 它,然后发送(或删除)它。</p> <p>当我们开始将剩余的 CRUD 特性添加到 Web 应用程序时,你会注意到一个模式。每个更改都会以 Application 类中的前端控制器开关语句中的新案例开始,并调用 ProductController 类中的新方法。该方法将进一步调用一个新的存储库类方法,实际的数据库交互将在这里进行。最后,我们将更新相应的页面模板,添加新功能所需的用户界面。</p> <h3 id="删除数据">删除数据</h3> <p>有时我们需要从数据库表中删除数据。例如,一家汽车制造商可能停止生产某个特定型号的汽车。该型号的详细信息可能会被复制到归档数据库表中,然后从汽车模型的主表中删除该型号。要从表中删除数据,我们使用 DELETE SQL 关键字。如果没有提供任何条件,所有记录都会从指定的表中删除。</p> <p>当删除特定行或符合某些条件的行时,我们需要提供一个 SQL WHERE 子句。例如,要从模型表中删除 ID 为 4 的行,SQL 语句如下:</p> <pre><code>DELETE FROM model WHERE id = 4 </code></pre> <p>在本节中,我们将查看删除整个表以及选择性删除表中条目的示例。</p> <h4 id="从表中删除所有内容">从表中删除所有内容</h4> <p>让我们首先为前一章中的 Web 应用程序添加一个功能,从数据库中的产品表删除所有产品。图 29-1 显示了我们将创建的“删除所有产品”链接,以及一个弹出确认对话框。总是一个好主意为用户提供重新考虑和取消破坏性操作(例如永久删除数据)的机会(假设他们确实有删除数据的选项)。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure29-1.jpg" alt="" loading="lazy"></p> <p>图 29-1:删除所有产品</p> <p>首先,我们将添加一个新的路由来检测 POST 提交,其中包含 action=deleteAll 变量。更新 Application 类中的 run()方法,使其与列表 29-1 的内容一致。</p> <pre><code><?php namespace Mattsmithdev; class Application { --snip-- public function run(): void { $action = filter_input(INPUT_GET, 'action'); $isPostSubmission = ($_SERVER['REQUEST_METHOD'] === 'POST'); switch ($action) { --snip-- case 'deleteAll': if ($isPostSubmission) { $this->productController->deleteAll(); } else { $this->defaultController-> error('error - not a POST request'); } break; default: $this->defaultController->homepage(); } } } </code></pre> <p>列表 29-1:更新后的 Application 类,用于执行 deleteAll 操作</p> <p>我们添加一个新的$isPostSubmission 变量,如果接收到的请求使用 POST 方法,则该变量为 true。虽然技术上可以编写一个响应 GET 消息并更改服务器状态(例如数据库内容)的 Web 应用程序,但这将违反 HTTP GET 方法的定义。因此,在本章中,对于任何更改数据库的请求(删除、创建或更新),我们将使用 POST 方法和 HTML <form>元素。</p> <p>接下来,我们在前控制器的 switch 语句中添加一个新的 case,用于处理 URL 中的 action 值为'deleteAll'的情况。当通过 POST 请求接收到该操作时,我们调用 ProductController 对象的 deleteAll()方法。如果$isPostSubmission 为 false,我们将使用 defaultController 返回一个错误消息给用户。</p> <p>接下来,我们将定义 deleteAll()方法。更新<em>src/ProductController.php</em>,使其与列表 29-2 的内容一致。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; --snip-- public function deleteAll(): void { $this->productRepository->deleteAll(); $this->list(); } } </code></pre> <p>列表 29-2:将 deleteAll()方法添加到 ProductController</p> <p>我们声明 ProductController 类的 deleteAll()方法,依次调用 ProductRepository 类的 deleteAll()方法(该类负责与数据库的通信)。然后,我们调用 list()方法,使应用程序显示产品列表页面,使用 header()函数和位置 URL /?action=products。因此,用户在所有产品被删除后应该看到一个空的产品列表。</p> <p>现在我们将 deleteAll()方法添加到 ProductRepository 类。更新<em>src/ProductRepository.php</em>,使其与列表 29-3 的内容一致。</p> <pre><code><?php namespace Mattsmithdev; class ProductRepository { private ?\PDO $connection = NULL; --snip-- public function deleteAll(): int { if (NULL == $this->connection) return 0; $sql = 'DELETE FROM product'; $stmt = $this->connection->prepare($sql); $stmt->execute(); ❶ $numRowsAffected = $stmt->rowCount(); return $numRowsAffected; } } </code></pre> <p>列表 29-3:将 deleteAll()方法添加到 ProductRepository</p> <p>我们声明 deleteAll()方法,返回一个整数值,表示从数据库中删除的行数。如果连接为 NULL,则返回 0。否则,我们声明、准备并执行'DELETE FROM product'的 SQL 查询语句,该语句删除产品表中的所有条目。然后,我们调用 PDO 语句对象的 rowCount()方法❶,它返回最近执行的查询所影响的行数。我们在方法的末尾返回该整数值。</p> <p>最后,我们需要更新产品列表页面的模板,提供一个删除所有产品的链接。更新<em>templates/product/list.xhtml.twig</em>,使其与列表 29-4 的内容一致。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}product list page{% endblock %} {% block productLink %}active{% endblock %} {% block body %} <h1>Product list page</h1> <ul> {% for product in products %} --snip-- {% endfor %} </ul> <p> <form method="POST" action="/?action=deleteAll"> <button class="btn btn-danger m-1" onclick="return confirm('Delete ALL products: Are you sure?');"> Delete ALL products</button> </form> </p> {% endblock %} </code></pre> <p>列表 29-4:用于列出所有产品的 list.xhtml.twig 模板</p> <p>在这里,我们在模板的末尾添加一个段落,声明一个包含 Bootstrap 样式按钮的 POST 方法表单,按钮文本为“删除所有产品”。控制器接收的操作(deleteAll)通过表单的 action 属性传递。此按钮包含一个弹出确认消息(通过 JavaScript 的 confirm()函数触发),并在其 onclick 属性中声明,这样用户就能确认或取消请求。</p> <h4 id="根据-id-删除单个项目">根据 ID 删除单个项目</h4> <p>就像我们可以根据产品 ID 展示特定产品一样,我们也可以使用 ID 指定要从数据库中删除的单个产品。现在让我们添加这个功能。图 29-2 展示了我们将要创建的页面截图:在产品列表页面中,每个产品将有自己显示和删除按钮样式的链接,每个链接都根据产品 ID 触发数据库操作。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure29-2.jpg" alt="" loading="lazy"></p> <p>图 29-2:每个单独产品的显示和删除按钮</p> <p>我们首先需要添加一个新的路由 URL 模式,action=delete,其中要删除的产品 ID 通过 POST 表单提交作为变量 id。请根据清单 29-5 更新 Application 类代码。</p> <pre><code><?php namespace Mattsmithdev; class Application { --snip-- public function run(): void { $action = filter_input(INPUT_GET, 'action'); $isPostSubmission = ($_SERVER['REQUEST_METHOD'] === 'POST'); switch ($action) { --snip-- case 'delete': ❶ $id = filter_input(INPUT_POST, 'id', FILTER_SANITIZE_NUMBER_INT); if ($isPostSubmission && !empty($id)) { $this->productController->delete($id); } else { $this->defaultController->error('error - to delete a product an integer id must be provided by a POST request'); } break; default: $this->defaultController->homepage(); } } } </code></pre> <p>清单 29-5:更新后的 Application 类,用于处理删除操作</p> <p>在这里,我们在前端控制器的 switch 语句中添加一个新的 case,用于当 URL 中的 action 值为“delete”时。在这种情况下,我们尝试从请求接收到的 POST 变量中提取一个整数变量 id ❶。如果$isPostSubmission 为 true 并且 ID 不为空,我们将 ID 传递给 ProductController 对象的 delete()方法。否则,我们将适当的错误信息传递给 DefaultController 对象的 error()方法进行显示。</p> <p>要定义 delete()方法,请根据清单 29-6 更新<em>src/ProductController.php</em>。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; --snip-- public function delete(int $id): void { $this->productRepository->delete($id); $this->list(); } } </code></pre> <p>清单 29-6:向 ProductController 添加 delete()方法</p> <p>delete()方法接受一个整数类型的产品 ID,并将其传递给 ProductRepository 对象的 delete()方法。然后,它通过 list()方法使应用程序显示产品列表页面。因此,用户在点击删除链接后,应该看到删除项后的产品列表。</p> <p>现在我们将在<em>src/ProductRepository.php</em>中向 ProductRepository 类添加 delete()方法。清单 29-7 展示了如何操作。</p> <pre><code><?php namespace Mattsmithdev; class ProductRepository { private ?\PDO $connection = NULL; --snip-- public function delete(int $id): bool { if (NULL == $this->connection) return false; $sql = 'DELETE FROM product WHERE id = :id'; $stmt = $this->connection->prepare($sql); $stmt->bindParam(':id', $id); $success = $stmt->execute(); return $success; } } </code></pre> <p>清单 29-7:向 ProductRepository 添加 delete()方法</p> <p>delete()方法接受一个整数参数(ID),并返回一个布尔值,表示删除操作是否成功。如果连接为 NULL,我们返回 false。否则,我们声明 SQL 查询字符串 'DELETE FROM product WHERE id = :id' 来删除指定 ID 的产品。然后,我们准备语句并将$id 参数绑定到:id 占位符。执行该语句后会返回一个布尔成功值,我们将其存储并返回。</p> <p>最后,我们需要更新产品列表模板,为每个产品提供样式为按钮的 Show 和 Delete 链接。修改 <em>templates/product/list.xhtml.twig</em> 文件,使其与列表 29-8 的内容匹配。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}product list page{% endblock %} {% block productLink %}active{% endblock %} {% block body %} <h1>Product list page</h1> <ul> {% for product in products %} <li class="mt-5"> id: {{product.id}} <br> description: {{product.description}} <br> price: $ {{product.price | number_format(2)}} <br> <a href="/?action=show&id={{product.id}}" ❶ class="btn btn-secondary m-1">Show</a> <br> <form method="POST" action="/?action=delete"> ❷ <input type="hidden" name="id" value="{{product.id}}"> <button class="btn btn-danger m-1" onclick="return confirm( 'Delete product with ID = {{product.id}}: Are you sure?');" > Delete</button> </form> </li> ❸ {% else %} <li> (there are no products to display) </li> {% endfor %} </ul> <p> <form method="POST" action="/?action=deleteAll"> --snip-- </code></pre> <p>列表 29-8:提供按 ID 删除功能的 list.xhtml.twig 模板</p> <p>我们将现有的 Show 链接样式修改为次级按钮 ❶。然后我们声明一个 POST 提交表单,action=delete,并设置一个名为 Delete 的按钮,传递 id 作为隐藏变量,用 Twig 的 {{product.id}} 占位符填充产品 ID ❷。与删除所有产品的表单一样,该表单按钮包括一个在 onclick 属性中声明的弹出确认消息,这样用户就可以确认或取消请求。我们还添加了一个 Twig else 块 ❸,如果数据库中没有找到产品,将显示消息(没有产品可显示)。</p> <h3 id="创建新的数据库条目">创建新的数据库条目</h3> <p>让我们来关注 CRUD 中的 <em>C</em>:通过 SQL INSERT 语句创建新的数据库条目。例如,下面是一个向名为 cat 的表中插入新行的 SQL 语句:</p> <pre><code>INSERT INTO cat (name, gender, age) VALUES ('fluffy', 'female', 4) </code></pre> <p>为名称、性别和年龄列提供了三个值。INSERT SQL 语句要求我们首先列出列名的顺序,然后跟随这些值,插入到相应的列中。</p> <p>我们在“设置数据库模式”章节中提到过如何创建新的数据库条目,并且介绍了如何在应用程序的数据库中设置两个初始产品,页面 543 中有详细说明。现在,我们将通过向应用程序中添加一个表单来使这个过程更加互动,让用户可以定义新的产品并将其提交到数据库中。图 29-3 展示了我们将创建的表单。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure29-3.jpg" alt="" loading="lazy"></p> <p>图 29-3:创建新产品的链接及其相关表单</p> <p>我们将在产品列表页面的底部添加一个按钮链接,用于创建新产品。该链接将启动一个创建新产品页面,包含提交新产品描述和价格的表单字段。</p> <h4 id="通过-web-表单添加产品">通过 Web 表单添加产品</h4> <p>为了提供创建新产品页面表单功能,我们首先需要向应用程序添加两个新的路由动作,一个用于显示表单(action=create),另一个用于处理表单提交(action=processCreate)。列表 29-9 展示了如何向应用程序类的前端控制器中添加这两个动作的 case。</p> <pre><code><?php namespace Mattsmithdev; class Application { --snip-- public function run(): void { $action = filter_input(INPUT_GET, 'action'); $isPostSubmission = ($_SERVER['REQUEST_METHOD'] === 'POST'); switch ($action) { --snip-- case 'create': ❶ $this->productController->create(); break; case 'processCreate': ❷ $description = filter_input(INPUT_POST, 'description'); $price = filter_input(INPUT_POST, 'price', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION); if ($isPostSubmission && !empty($description) && !empty($price)) {❸ $this->productController->processCreate($description, $price); } else { $this->defaultController->error( 'error - new product needs a description and price (via a POST request)'); } break; default: $this->defaultController->homepage(); } } } </code></pre> <p>列表 29-9:将 'create' 和 'processCreate' 路由添加到前端控制器</p> <p>首先,我们在前端控制器的 switch 语句中为 URL 中 action 值为 'create' 时添加一个新 case ❶。这将调用 ProductController 对象的 create() 方法。</p> <p>接下来,我们声明 'processCreate' 动作的 case ❷。为此,我们从 POST 提交的变量中获取描述和价格的值。请注意,浮动价格变量使用了两个过滤器;FILTER_FLAG_ALLOW_FRACTION 参数是必需的,以允许小数点字符。</p> <p>如果 $isPostSubmission 为 true 且 $description 和 $price 都不为空 ❸,则描述和价格将传递给 ProductController 对象的 processCreate() 方法。否则,将使用 DefaultController 对象的 error() 方法显示适当的错误信息。</p> <p>本示例假设产品数据库表使用自动递增的方式为新添加的行选择一个新的唯一整数 ID,如 第二十八章 所示。如果没有此功能,我们还需要为新产品提供一个 ID,可能需要使用逻辑先查找数据库中当前最大的 ID,然后加 1。</p> <p>现在我们将把 create() 和 processCreate() 方法添加到 ProductController 类中。更新 <em>src/ProductController.php</em> 文件以匹配 列表 29-10。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; --snip-- ❶public function create(): void { $template = 'product/create.xhtml.twig'; $args = []; print $this->twig->render($template, $args); } ❷ public function processCreate(string $description, float $price): void { $this->productRepository->insert($description, $price); $this->list(); } } </code></pre> <p>列表 29-10:将 create() 和 processCreate() 方法添加到 ProductController</p> <p>create() 方法 ❶ 只是渲染 <em>templates/product/create.xhtml.twig</em> 模板来显示新产品表单(我们稍后会创建这个模板)。processCreate() 方法 ❷ 接受一个字符串作为新描述和一个浮动值作为新价格,并将它们传递给 ProductRepository 对象的 insert() 方法,以便插入到数据库中。然后,processCreate() 调用 list() 方法来跳转到产品列表页面,以便用户看到更新后的产品列表,其中包括新创建的产品。</p> <p>如果我们完全按照规范操作,processCreate() 方法将不会调用 list() 方法,而是会强制重定向,向服务器发送新请求以列出所有产品。如果不进行重定向,我们将遇到一个问题:如果用户在提交表单后刷新浏览器页面,表单将被提交第二次。但是,现在添加重定向会让我们在下一部分的工作变得更加复杂,所以我们目前就先调用 list() 方法,等到本章最后再制定一个更好的重定向方案。</p> <p>要将 insert() 方法添加到 ProductRepository 类中,请更新 <em>src/ProductRepository.php</em> 文件,按照 列表 29-11 的示例。</p> <pre><code><?php namespace Mattsmithdev; class ProductRepository { private ?\PDO $connection = NULL; --snip-- public function insert(string $description, float $price): int { if (NULL == $this->connection) return -1; $sql = 'INSERT INTO product (description, price)' . ' VALUES (:description, :price)'; $stmt = $this->connection->prepare($sql); $stmt->bindParam(':description', $description); $stmt->bindParam(':price', $price); $success = $stmt->execute(); ❶ if ($success) { return $this->connection->lastInsertId(); } else { return -1; } } } </code></pre> <p>列表 29-11:将 insert() 方法添加到 ProductRepository</p> <p>新的 insert() 方法接受一个字符串参数(<span class="math inline">\(description)和一个浮动值参数(\)</span>price),并返回一个整数——要么是新创建的数据库记录的 ID,要么是 -1,表示没有创建记录。如果数据库连接为 NULL,我们会立即返回 -1。否则,我们声明并准备 SQL 查询字符串 'INSERT INTO product (description, price) VALUES (:description, :price)' 来向产品表中添加一个新条目。</p> <p>然后,我们将<span class="math inline">\(description 参数绑定到:description 占位符,将\)</span>price 参数绑定到:price 占位符,之后执行语句。最后,我们测试执行结果的布尔值$success ❶。如果为真,我们使用 PDO 连接对象的 lastInsertId()方法返回最近插入的数据库条目的 ID,该 ID 应对应于新产品。如果为假,我们返回-1。</p> <p>现在,让我们修改产品列表页面模板,以包含添加新产品的链接。更新<em>templates/product/list.xhtml.twig</em>以匹配清单 29-12 中的内容。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}product list page{% endblock %} {% block productLink %}active{% endblock %} {% block body %} --snip-- Delete ALL products</button> </form> </p> <p> <a href="/?action=create" class="btn btn-secondary m-1"> Create NEW product </a> </p> {% endblock %} </code></pre> <p>清单 29-12:将新产品链接添加到 list.xhtml.twig 模板</p> <p>在这里,我们向模板的末尾添加一个段落,其中包含一个 Bootstrap 样式的按钮链接,文本为“创建新产品”。该链接触发 create URL 动作。</p> <p>现在,让我们添加 Twig 模板以显示创建新产品页面的表单。创建<em>templates/product/create.xhtml.twig</em>模板文件,包含清单 29-13 中所示的代码。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}create product page{% endblock %} {% block body %} <h1>Create NEW Product page</h1> ❶ <form method="POST" action="/?action=processCreate"> <p> Description: <input name="description"> </p> <p> Price: <input name="price" type="number" min="0" step="0.01"> </p> <input type="submit"> </form> {% endblock %} </code></pre> <p>清单 29-13:新产品表单的 create.xhtml.twig 模板</p> <p>该模板呈现一个 HTML 表单 ❶,其提交动作为 action=processCreate,因此提交的值将传递给前面描述的 ProductController 类中的 processCreate()方法。表单包含两个段落,分别用于描述和价格,然后是一个提交按钮。</p> <h4 id="突出显示新创建的产品">突出显示新创建的产品</h4> <p>当某些内容发生变化时,突出显示变化对用户非常有帮助。让我们更新应用程序,以在新产品添加到数据库后,在产品列表中突出显示它。图 29-4 展示了我们想要实现的效果;它显示了我们添加了一个非常昂贵的钉子袋,价格为 999 美元!</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure29-4.jpg" alt="" loading="lazy"></p> <p>图 29-4:显示新创建的产品并突出显示背景</p> <p>为实现此功能,我们可以利用 ProductRepository 类的 insert()方法返回值,该方法在前面的章节中已声明。这个返回值表示新创建的产品的 ID,因此我们可以向应用程序添加逻辑,以突出显示 ID 与该值匹配的产品。首先,我们需要更新 ProductController 类,如清单 29-14 所示。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; public function list(?int $newProductId = NULL): void { $products = $this->productRepository->findAll(); $template = 'product/list.xhtml.twig'; $args = [ 'products' => $products, ❶ 'id' => $newProductId ] print $this->twig->render($template, $args); } --snip-- public function processCreate(string $description, float $price): void { $newProductId = $this->productRepository->insert($description, $price); $this->list($newProductId); } } </code></pre> <p>清单 29-14:更新 ProductController 类中的 list()和 update()方法</p> <p>我们更新 list()方法(显示完整的产品列表),使其接受一个可选的$newProductId 参数,默认值为 NULL。我们将此参数传递给 Twig 产品列表模板,以及产品数组 ❶。接下来,我们更新 processCreate()方法,接收从 insert()返回的新产品 ID,并将其传递给 list()方法。</p> <p>现在我们可以更新产品列表模板,以突出显示与传递给模板的 id 变量匹配的产品。由于产品 ID 从 1 开始并且自动递增,-1 的值永远不会与从数据库中检索到的对象匹配,因此 list()方法的默认$newProductId 参数值-1 将导致没有产品被高亮显示。按照列表 29-15 中的示例,修改<em>templates/product/list.xhtml.twig</em>模板。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}product list page{% endblock %} {% block productLink %}active{% endblock %} {% block body %} <h1>Product list page</h1> <ul> {% for product in products %} ❶{% if id == product.id %} {% set highlight = 'active' %} {% else %} {% set highlight = '' %} {% endif %} ❷<li class="{{highlight}}"> id: {{product.id}} <br> --snip-- {% endblock %} </code></pre> <p>列表 29-15:更新 list.xhtml.twig 模板,以在列表中突出显示新增的产品</p> <p>我们在遍历产品的 Twig 循环中添加了一个 if 语句,该语句检查当前产品的 ID 是否与接收到的 Twig 变量 id ❶匹配。如果匹配,Twig 的高亮变量会被设置为'active',否则高亮变量会被设置为空字符串。我们将 highlight 的值包含到每个列表项的 CSS 样式类中 ❷,因此每个产品将根据需要被高亮显示或不显示。</p> <p>最后,我们需要在基础模板中添加一个<style>元素来为活动的 CSS 类设置样式。按照列表 29-16 更新<em>/templates/product/base.xhtml.twig</em>。</p> <pre><code><!doctype html> <html lang="en"> <head> <title>MG- - {% block title %}{% endblock %}</title> <meta name="viewport" content="width=device-width"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"> <style> li.active {background-color: pink;} </style> </head> <body class="container"> --snip-- </code></pre> <p>列表 29-16:在 base.xhtml.twig Twig 模板中声明<style>元素</p> <p>在<head>元素中,我们添加了一个 CSS 规则,指定活动的列表项应该有粉色背景。</p> <h3 id="更新数据库条目">更新数据库条目</h3> <p>最后一项要探索的 CRUD 操作是<em>U</em>代表<em>更新</em>。这个操作很重要,因为数据库中的数据需要不断更新,以反映现实世界中的变化,比如人的新地址、产品价格的上涨、用户更改订阅状态等。为了修改表中的现有记录,我们可以使用 SQL 的 UPDATE 关键字。例如,下面是一个 SQL 语句,它将 ID 为 1 的猫的年龄改为 5:</p> <pre><code>UPDATE cat SET age = 5 WHERE id = 1 </code></pre> <p>让我们为我们的 Web 应用程序添加一个更新现有产品的功能。就像创建新产品一样,我们通过 Web 表单来实现。图 29-5 展示了这个新功能的工作原理。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure29-5.jpg" alt="" loading="lazy"></p> <p>图 29-5:更新现有产品</p> <p>我们将在产品列表页面的每个产品上添加一个“编辑”按钮,点击该按钮后,用户将进入编辑产品页面,页面上有表单字段用于修改产品的描述和价格(ID 字段为只读)。这些字段初始时会填入当前的值。一旦提交更改,更新后的产品将在产品列表页面上突出显示。</p> <p>为了实现这个功能,我们首先需要添加两个新的路由动作,一个用于显示编辑表单(action=edit),一个用于处理表单提交(action=processEdit)。列表 29-17 将这两个新操作添加到了应用程序类的前端控制器中。</p> <pre><code><?php namespace Mattsmithdev; class Application { --snip-- public function run(): void { $action = filter_input(INPUT_GET, 'action'); $isPostSubmission = ($_SERVER['REQUEST_METHOD'] === 'POST'); switch ($action) { --snip-- case 'edit': ❶ $id = filter_input(INPUT_GET, 'id', FILTER_SANITIZE_NUMBER_INT); if (empty($id)) { $this->defaultController->error( 'error - To edit a product, an integer ID must be provided'); } else { $this->productController->edit($id); } break; case 'processEdit': ❷ $id = filter_input(INPUT_POST, 'id', FILTER_SANITIZE_NUMBER_INT); $description = filter_input(INPUT_POST, 'description'); $price = filter_input(INPUT_POST, 'price', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION); if ($isPostSubmission && !empty($id) && !empty($description) && !empty($price)) { $this->productController->processEdit($id, $description, $price); } else { $this->defaultController->error( 'error - Missing data (or not POST method) when trying to update product'); } break; default: $this->defaultController->homepage(); } } } </code></pre> <p>列表 29-17:将'edit'和'processEdit'路由添加到前端控制器</p> <p>我们在前端控制器的 switch 语句中添加一个新的 case,用于处理当 URL 中 action 的值为'edit'❶时的情况。与'show'和'delete'的情况一样,我们尝试从请求中接收到的 URL 编码变量中提取一个整数 id 变量。如果 id 的值为空,我们通过将字符串消息传递给 DefaultController 对象的 error()方法来显示适当的错误消息。如果值不为空,我们将其传递给 ProductController 对象的 edit()方法。</p> <p>接下来,我们添加'processEdit' case❷,它首先通过 POST 提交的变量检索 id、description 和 price。如果$isPostSubmission 为 true,并且三个变量(id、description 和 price)都不为空,我们将这些值传递给 ProductController 对象的 processEdit()方法。否则,我们再次使用 DefaultController 对象的 error()方法显示适当的错误消息。</p> <p>现在我们将向 ProductController 类中添加新方法。更新<em>src/ProductController.php</em>文件,使其与 Listing 29-18 的内容一致。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; --snip-- public function edit(int $id): void { ❶ $product = $this->productRepository->find($id); $template = 'product/edit.xhtml.twig'; $args = [ 'product' => $product ]; print $this->twig->render($template, $args); } public function processEdit(int $id, string $description, float $price): void { ❷ $this->productRepository->update($id, $description, $price); $this->list($id); } } </code></pre> <p>Listing 29-18:向 ProductController 添加 edit()和 processEdit()方法</p> <p>edit()方法使用提供的整数<span class="math inline">\(id 参数从数据库中检索单个 Product 对象❶。然后它将该对象传递给*/templates/product/edit.xhtml.twig*模板,该模板显示用于编辑产品的表单。processEdit()方法接受一个\)</span>id 整数、<span class="math inline">\(description 字符串和\)</span>price 浮动值,并将它们传递给 ProductRepository 对象的 update()方法❷。接着,它通过 list()方法使应用程序显示到产品列表页面。与 processCreate()方法类似,我们将更新产品的 ID 传递给 list(),以便该产品会被高亮显示。</p> <p>Listing 29-19 展示了如何将新的 update()方法添加到 ProductRepository 类中。</p> <pre><code><?php namespace Mattsmithdev; class ProductRepository { private ?\PDO $connection = NULL; --snip-- public function update(int $id, string $description, float $price): bool { if (NULL == $this->connection) return false; $sql = 'UPDATE product SET description = :description, price = :price WHERE id=:id'; $stmt = $this->connection->prepare($sql); $stmt->bindParam(':id', $id); $stmt->bindParam(':description', $description); $stmt->bindParam(':price', $price); $success = $stmt->execute(); return $success; } } </code></pre> <p>Listing 29-19:将 update()方法添加到 ProductRepository 类中</p> <p>update()方法接受一个产品的 ID、描述和价格,并返回一个布尔值,表示更新的成功或失败。如果数据库连接为 NULL,则返回 false。否则,我们声明 SQL 查询字符串'UPDATE product SET description = :description, price = :price WHERE id=:id',使用 WHERE 子句和对象的 ID 来指定要更新的特定数据库行。在准备好语句后,我们将<span class="math inline">\(id、\)</span>description 和$price 变量绑定到相应的占位符。</p> <p>然后我们执行该语句并返回结果布尔值的成功状态。请注意,这里返回的是布尔值,而不是像我们在之前的 insert()方法中那样返回产品 ID。这里的区别在于,调用方法已经知道了相关的产品 ID,因此只需要返回执行数据库更新语句的真/假成功状态即可。</p> <p>现在我们需要为产品列表页面上的每个产品提供一个编辑按钮。更新 <em>templates/product/list.xhtml.twig</em> 文件,如列表 29-20 所示。</p> <pre><code>--snip-- {% block body %} <h1>Product list page</h1> --snip-- <li class="{{highlight}}"> id: {{product.id}} <br> description: {{product.description}} <br> price: $ {{product.price | number_format(2)}} <br> <a href="/?action=show&id={{product.id}}" class="btn btn-secondary m-1">Show</a> <br> <a href="/?action=edit&id={{product.id}}" class="btn btn-secondary m-1">Edit</a> --snip-- {% endblock %} </code></pre> <p>列表 29-20:向 list.xhtml.twig 模板添加编辑按钮</p> <p>在当前产品的 Twig 循环中,我们添加了一个使用 Bootstrap 样式的按钮链接,文本为“编辑”。这个编辑操作的链接包含当前产品的 ID。</p> <p>最后,让我们添加一个 Twig 模板来显示编辑产品详细信息的表单。创建 <em>templates/product/edit.xhtml.twig</em> 文件,并包含列表 29-21 中所示的代码。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}edit product page{% endblock %} {% block body %} <h1>Edit Product page</h1> ❶ <form method="POST" action="/?action=processEdit"> <p> ID: ❷ <input name="id" value="{{product.id}}" readonly> </p> <p> Description: <input name="description" value="{{product.description}}"> </p> <p> Price: <input name="price" value="{{product.price}}" type="number" min="0" step="0.01"> </p> <input type="submit"> </form> {% endblock %} </code></pre> <p>列表 29-21:用于编辑产品的 edit.xhtml.twig 模板</p> <p>该模板的主要工作是呈现一个 HTML 表单 ❶,其提交操作为 action=processEdit,因此提交的值将传递给前面描述的 ProductController 类中的 processEdit() 方法。此表单包含三个段落,分别用于 ID、描述和价格,然后是一个提交按钮。ID、描述和价格表单输入框将被填充为 Product 对象中相应属性的值。ID 输入框具有 readonly 属性;因为我们不希望用户编辑此值,它会被显示但不可编辑 ❷。</p> <h3 id="避免通过重定向进行双重表单提交">避免通过重定向进行双重表单提交</h3> <p>在我们当前的实现中,我们在处理完表单提交以添加或编辑产品后调用 list() 方法。通过将产品 ID 传递给此方法,我们可以使模板突出显示已创建或更新的产品。然而,如果用户在提交表单后刷新浏览器页面,浏览器将尝试通过重复 HTTP POST 请求再次提交表单数据。我们可以通过使服务器在表单提交后<em>重定向</em>到请求产品列表页面来避免这个问题(即发送一个 GET 请求到 URL /?action=products)。如果页面被刷新,GET 请求将重新发送以列出所有产品,而不是 POST 请求。这种技术有时被称为<em>后提交重定向获取(PRG)模式</em>。</p> <p>现在,让我们更新应用程序以使用这种重定向方法。我们不再将产品 ID 作为参数传递给 list() 方法,而是需要将 ID 存储在 $_SESSION 数组中。正如我们在第十四章中讨论的那样,这是一个专门用于存储用户当前浏览器会话数据的数组。首先,我们将更新 list() 方法,并在 <em>src/ProductController.php</em> 中添加一个新的会话辅助方法,如列表 29-22 所示。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; --snip-- public function list(): void { $products = $this->productRepository->findAll(); ❶ $id = $this->getIdFromSession(); $template = 'product/list.xhtml.twig'; $args = [ 'products' => $products, 'id' => $id ]; print $this->twig->render($template, $args); } private function getIdFromSession(): ?int { $id = NULL; ❷ if (isset($_SESSION['id'])) { $id = $_SESSION['id']; // Remove it now that it's been retrieved unset($_SESSION['id']); } return $id; } } </code></pre> <p>列表 29-22:更新 list() 方法并将 getIdFromSession() 添加到 ProductController 类</p> <p>list()方法不再有任何参数输入。相反,我们尝试使用 getIdFromSession()方法从会话中检索 ID ❶。该方法将<span class="math inline">\(id 变量初始化为 NULL,然后测试\)</span>_SESSION 数组是否包含一个键为'id' ❷的变量。如果存在这样的键,则其值被检索并存储在<span class="math inline">\(id 中,然后该数组元素会被删除,以便在检索后不再存储在会话中。该方法返回\)</span>id 的值,可能是 NULL 或从会话中检索到的值。</p> <p>现在,我们可以更新 ProductController 类方法,在将 ID 存储到会话中后使用重定向。按照清单 29-23 所示,更新<em>src/ProductController.php</em>中的这些方法。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; --snip-- public function delete(int $id): void { $this->productRepository->delete($id); ❶ $location = '/?action=products'; header("Location: $location"); } public function deleteAll(): void { $this->productRepository->deleteAll(); $location = '/?action=products'; header("Location: $location"); } --snip-- public function processCreate(string $description, float $price): void { $newObjectId = $this->productRepository->insert($description, $price); ❷ $_SESSION['id'] = $newObjectId; $location = '/?action=products'; header("Location: $location"); } --snip-- public function processEdit( int $id, string $description, float $price): void { $this->productRepository->update($id, $description, $price); // Store ID of product to highlight in the SESSION ❸ $_SESSION['id'] = $id; $location = '/?action=products'; header("Location: $location"); } } </code></pre> <p>清单 29-23:更新 ProductController 中的 POST 操作方法以使用重定向</p> <p>删除(delete())和删除所有(deleteAll())方法已更新为使用内置的 header()函数,重定向服务器处理 URL /?action=products 的 GET 请求 ❶。该 action 值将导致我们的 list()方法列出产品。</p> <p>我们还更新了 processCreate()方法,将新创建的产品的 ID(<span class="math inline">\(newObjectId)存储在会话中,键名为'id' ❷。然后,它将服务器重定向到处理 URL /?action=products 的 GET 请求。同样,我们更新了 processEdit()方法,将编辑过的产品的 ID(\)</span>id)存储在会话中,键名为'id' ❸,并以相同的方式重定向到/?action=products。现在,我们已经改进了 Web 应用程序,在处理 POST 表单提交后正确重定向,因此刷新浏览器时不会导致表单数据的重复提交。</p> <p>由于我们将 ID 存储在会话中,因此我们必须确保每次接收到请求时,前控制器 index 脚本会启动会话。按照清单 29-24 所示,更新<em>/public/index.php</em>。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; session_start(); use Mattsmithdev\Application; $app = new Application(); $app->run(); } </code></pre> <p>清单 29-24:更新 index.php 以确保我们的 Web 应用程序会话处于活动状态</p> <p>我们现在在执行任何操作之前调用 session_start()函数。这确保了我们的 Web 应用程序可以存储和检索来自用户 HTTP 会话的值。</p> <h3 id="总结-18">总结</h3> <p>在本章中,我们探讨了标准数据库操作的全方位内容:创建、读取、更新和删除条目,统称为<em>CRUD</em>。与前一章一样,我们对所有数据库查询都使用了预处理语句,使得绑定参数到这四个操作变得容易,例如通过 ID 删除一行或将新值插入到数据库条目的多个字段中。</p> <p>我们继续看到,数据库驱动的网页应用程序的架构与非数据库应用程序是多么相似。我们的代码核心围绕一个前端控制器展开,它负责检查从网页客户端接收到的请求,并调用相应的控制器方法。通过将数据库操作封装在一个存储库类中,我们能够保持控制器类的逻辑专注于通过整理数据并渲染适当的 Twig 模板来响应请求。</p> <p>我们还看到,创建和更新数据库行的表单是如何呈现和处理的,和其他任何网页表单一样,只不过现在数据会传递到存储库方法,用于与数据库进行交互。接着,我们看到如何通过在处理表单提交后使用重定向来改进系统,以避免在浏览器页面刷新时重复表单操作。</p> <h3 id="练习-26">练习</h3> <p>1.   打开一个你常用的网络应用程序,例如社交媒体应用或电子商务网站。探索作为用户你可以执行的操作,并思考每个操作对应的 CRUD 操作。数据是如何从你使用的网页应用后端数据库中获取并保存的?</p> <p>2.   为书籍对象创建一个 CRUD 网页应用,具有以下属性:</p> <p>id(整数),自增长主键</p> <p>标题(字符串)</p> <p>作者(字符串)</p> <p>价格(浮动)</p> <p>你可以从头开始创建一个新项目,或者重用本章和第二十八章的练习中的类。我建议你按照以下顺序逐步为应用程序添加 CRUD 功能:</p> <p>a.   列出所有对象。</p> <p>b.   列出一个对象,给定一个 ID。</p> <p>c.   删除所有对象。</p> <p>d.   删除一个对象,给定一个 ID。</p> <p>e.   创建一个新对象。</p> <p>f.   编辑一个对象,给定一个 ID。</p> <h2 id="第三十章30-orm-库与数据库安全">第三十章:30 ORM 库与数据库安全</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>在这一章中,我们将探索使数据库工作更简单、更安全的技术。首先,仓库类中的许多 CRUD 代码会变得乏味且重复,仅在模型类名称和其属性上有所不同。<em>对象关系映射(ORM)库</em>解决了这个问题,自动化了低级工作,比如根据应用程序的模型类的命名和结构准备和执行 SQL 查询。你将看到如何使用这样的库,通过几行代码简化或替换我们的仓库类。我们将从向我们的示例网页应用程序添加一个简单的 ORM 库开始,然后在项目中整合专业级的 Doctrine ORM 库。</p> <p>在安全方面,采用 ORM 库将推动我们从代码中移除任何硬编码的数据库凭证,而是将这些凭证放在一个单独的数据文件中。我们还将探索在网页应用程序中处理登录信息的最佳实践,包括使用<em>密码哈希</em>来避免在数据库中存储明文密码。正如你将看到的,PHP 提供了内置的函数,使这个过程变得非常简单。</p> <h3 id="使用-orm-库简化数据库代码">使用 ORM 库简化数据库代码</h3> <p>一种使网页应用程序与数据库进行通信的方法是为每个项目从零开始设计并编写必要的低级代码。这包括连接数据库服务器的代码、创建模式和表格的代码,以及执行四个 CRUD 操作的代码,以便数据库表格能够存储支持应用程序的数据。实现这些代码需要对项目需求进行仔细分析,尤其是哪些数据需要持久化到数据库中。最终的结果是,代码是针对当前应用程序量身定制的,可以根据计算效率来编写,以最大化速度。</p> <p>在过去的几章中,我们采用了设计和编写定制的、特定应用程序的数据库代码的方法。这对学习如何与数据库交互很有帮助,但也带来了一些缺点。首先,每个新应用程序都需要花时间设计、编写和测试代码。其次,如果应用程序的需求发生变化,网页应用程序的数据库通信代码和数据库结构本身也需要相应地做出调整。最后,任何加入正在进行项目的开发者都必须学习系统设计的所有细节,以便能够与数据库进行交互。</p> <p>另一种方法是使用 ORM 库来抽象化与数据库通信的底层工作。ORM 库使用应用程序模型类的结构和关联(通常还会有一些附加的元数据)来自动创建和更新相应数据库表的结构。如果应用程序需求的变化导致模型类发生变化(例如新增模型类,或者现有类添加了新属性或关联),那么 ORM 库可以根据新的模型类声明自动更新数据库表结构,并管理基于这些更新后的模型类的数据库查询。</p> <p>与自定义编写的低级数据库通信代码相比,ORM 库的计算效率可能较低。然而,如果速度不是 Web 应用程序中最重要的特性,它们有几个优点。首先,数据库结构和查询会在模型类更新的同时自动更新,这简化了编码过程。此外,如果项目使用的是一个知名的工业标准 ORM 库,那么加入项目的新开发人员很可能已经熟悉使用 ORM 库处理数据库操作的抽象方式。</p> <p>在我们深入了解如何使用 ORM 库之前,让我们先看一个例子,说明这种方法与使用自定义代码相比的优势。清单 30-1 展示了前两章开发的 ProductRepository 类的部分内容。</p> <pre><code><?php namespace Mattsmithdev; class ProductRepository { private ?\PDO $connection = NULL; public function __construct() { $db = new Database(); $this->connection = $db->getConnection(); } public function findAll(): array { if (NULL == $this->connection) return []; $sql = 'SELECT * FROM product'; $stmt = $this->connection->prepare($sql); $stmt->execute(); $stmt->setFetchMode(\PDO::FETCH_CLASS, 'Mattsmithdev\\Product'); $products = $stmt->fetchAll(); return $products; } public function find(int $id): ?Product { if (NULL == $this->connection) return NULL; $sql = 'SELECT * FROM product WHERE id = :id'; $stmt = $this->connection->prepare($sql); $stmt->bindParam(':id', $id); $stmt->execute(); $stmt->setFetchMode(\PDO::FETCH_CLASS, 'Mattsmithdev\\Product'); $product = $stmt->fetch(); return $product; } --snip-- } </code></pre> <p>清单 30-1:ProductRepository 类的部分内容</p> <p>我们手动开发了这个类,这意味着我们必须实现一些底层方法,比如构造函数来获取数据库连接,以及 CRUD 方法,如 findAll() 和 find(),以准备和执行 SQL 查询。将这段代码与清单 30-2 进行对比,后者通过 ORM 库的帮助声明了一个等效的 ProductRepository 类。</p> <pre><code><?php namespace Mattsmithdev; use Mattsmithdev\PdoCrudRepo\DatabaseTableRepository; class ProductRepository extends DatabaseTableRepository { } </code></pre> <p>清单 30-2:一个继承自 ORM 库的 ProductRepository 类</p> <p>对于直接的数据库交互,ORM 库几乎可以为我们完成所有工作。我们不需要在 repository 类中实现自定义方法,而是简单地从 ORM 库的超类(在这种情况下是 DatabaseTableRepository)继承这些方法。超类设计使用<em>反射</em>,这是一种检查它与之交互的类和对象的技术,比如 Product 模型类。然后,超类利用找到的信息生成适用于这些类对象的 SQL 查询。</p> <p>在接下来的章节中,我们将通过使用一个简单的 ORM 库来更详细地探讨这一过程,这是我在 GitHub 上作为开源项目维护的一个库。稍后,我们还将尝试一个工业级的 ORM 库,名为 Doctrine,这是现代 PHP 中最流行的 ORM 库之一。不过现在,先花点时间欣赏一下 ORM 辅助的 ProductRepository 类声明比手动编写版本要简短得多。</p> <h4 id="将-orm-库添加到项目中">将 ORM 库添加到项目中</h4> <p>让我们扩展前几章中的数据库驱动 Web 应用程序,使用我维护的一个简单 ORM 库——pdo-crud-for-free-repositories(* <a href="https://github.com/dr-matt-smith/pdo-crud-for-free-repositories" target="_blank"><code>github.com/dr-matt-smith/pdo-crud-for-free-repositories</code></a> *)。该库功能有限,但易于使用,是介绍 ORM 库基础知识的好工具。要开始使用,在命令行中输入以下内容以将库添加到项目中:</p> <pre><code>$ **composer require mattsmithdev/pdo-crud-for-free-repositories** </code></pre> <p>这将会在<em>vendor</em>文件夹中添加一个<em>mattsmithdev</em>文件夹,里面包含库的代码。</p> <p>在撰写本文时,发布的 pdo-crud-for-free-repositories 版本仅与 MySQL 兼容,因此我们将重点介绍我们的 Web 应用程序的 MySQL 版本,而非 SQLite 版本。</p> <h4 id="将数据库凭据移至env-文件">将数据库凭据移至.env 文件</h4> <p>我们使用的 ORM 库要求所有数据库凭据都声明在名为<em>.env</em>的文件中,该文件通常被称为<em>dotenv 文件</em>,而不是硬编码在我们目前的 Database 类中。Dotenv 文件是人类可读的文本文件,用于定义程序运行所需的名称/值对;其他常见的文件类型包括 XML 和 YAML。这一要求并不是坏事,因为它还增强了应用程序的安全性。</p> <p>通常,在使用版本控制系统(如 Git)时,我们会排除 dotenv 文件,以便在代码归档或推送到开源项目时,不会包含敏感的数据库凭据。这减少了通过代码发布或分发给未经授权的人所引发的安全漏洞的可能性。这种方法的另一个好处是,可以在多个 dotenv 文件中设置不同的环境,如本地开发、远程开发、测试和实际生产系统。</p> <p>为了满足该 ORM 库的要求,创建一个名为<em>.env</em>的文件,并将其保存在主项目目录中。将列表 30-3 中的内容输入文件中,并根据您计算机上运行的 MySQL 服务器属性修改密码和端口等值。</p> <pre><code>MYSQL_USER=root MYSQL_PASSWORD=password MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_DATABASE=demo1 </code></pre> <p>列表 30-3:.env 文件中的数据库凭据</p> <p>这些 MySQL 属性之前都作为常量定义在我们的 Database 类中。现在,我们可以将 Database 类从项目中删除,因为 ORM 库自带了一个类来管理与数据库的连接,基于 dotenv 文件中的信息。</p> <h4 id="将产品操作委托给-orm-库">将产品操作委托给 ORM 库</h4> <p>既然 ORM 库已经可以访问数据库,我们就可以将所有与产品表相关的 CRUD 操作的责任,从 ProductRepository 类转移到 ORM 库中。我们仍然会使用 ProductRepository 类,但正如之前所暗示的那样,我们不再手动填充它的方法来准备和执行 SQL 语句,而是简单地将它声明为 ORM 库类的一个子类。用 Listing 30-4 中显示的代码替换 <em>src/ProductRepository.php</em> 文件的内容。</p> <pre><code><?php namespace Mattsmithdev; use Mattsmithdev\PdoCrudRepo\DatabaseTableRepository; class ProductRepository extends DatabaseTableRepository { } </code></pre> <p>Listing 30-4: 极大简化的 ProductRepository 类</p> <p>使用语句指定我们想要引用 Mattsmithdev\PdoCrudRepo 命名空间中的 DatabaseTableRepository 类。然后我们将 ProductRepository 声明为 DatabaseTableRepository 的子类,类体中不包含任何代码。就这样!我们现在拥有了一个只包含几行代码的有效 ProductRepository 类。它将继承 DatabaseTableRepository 超类中所有的方法,这些方法恰好遵循我们之前使用的命名约定:find()、findAll()、delete()、deleteAll() 等。</p> <p>那么,DatabaseTableRepository 类是如何知道我们希望它与一个具有 id、description 和 price 字段的产品表配合使用的呢?这就是反射技术发挥作用的地方。DatabaseTableRepository 类使用这种技术来推断如何根据它接触到的类和对象构建适当的 SQL 语句。在这种情况下,反射代码假设 ProductRepository 仓库类管理一个与同一命名空间中的名为 Product 的模型类相对应的数据库方法,并且数据库中相应的产品表具有与 Product 类的属性名称匹配的字段。只要所有的名称一致,ORM 库就能完成它的工作。</p> <p>为了让反射过程正常工作,DatabaseTableRepository 方法需要接收适当模型类的对象,而不是我们之前在 第二十九章 设计 CRUD 方法时使用的自由浮动变量。为了最终完成对 ORM 库的转换,我们需要重构我们的 ProductController 类,在调用 insert() 和 update() 方法处理新产品和更新产品时,传入 Product 对象。按照 Listing 30-5 中的方式修改 <em>src/ProductController.php</em> 文件。</p> <pre><code><?php namespace Mattsmithdev; class ProductController extends Controller { private ProductRepository $productRepository; --snip-- public function processCreate(string $description, float $price): void { $product = new Product(); $product->setDescription($description); $product->setPrice($price); ❶ $newObjectId = $this->productRepository->insert($product); $_SESSION['id'] = $newObjectId; $location = '/?action=products'; header("Location: $location"); } --snip-- public function processEdit(int $id, string $description, float $price): void { ❷ $product = $this->productRepository->find($id); $product->setDescription($description); $product->setPrice($price); $this->productRepository->update($product); $_SESSION['id'] = $id; $location = '/?action=products'; header("Location: $location"); } } </code></pre> <p>Listing 30-5: 更新后的 src/ProductController.php 类</p> <p>在修改后的 processCreate()方法中,我们首先创建一个新的 Product 对象,并将其描述和价格属性设置为传入方法的值。然后,我们将这个 Product 对象传递给 ProductRepository 对象的 insert()方法,以将新产品添加到数据库中❶。ORM 库假设每个数据库表都有一个名为 id 的自增主键,因此在创建新数据库行时,不需要为产品 ID 提供值。我们对 processEdit()方法做了类似的修改,使用 id 来获取要更新的 Product 对象的引用❷,设置从表单接收到的其他属性,并将对象引用传递给仓库类的 update()方法。</p> <p>运行 Web 服务器,你现在应该能看到 Web 应用程序与之前一样工作,但代码大大减少!通过这种方式,使用 ORM 库大大简化了执行标准数据库 CRUD 操作的任务。</p> <blockquote> <p>注意</p> </blockquote> <p><em>在继续之前,先在此时备份你的项目。在《Doctrine ORM 库》一节中(见第 615 页),我们将修改该副本以使用 Doctrine。</em></p> <h4 id="添加新数据库表">添加新数据库表</h4> <p>现在我们已经将 ORM 库集成到项目中,让我们通过向数据库添加另一个表来扩展我们的 Web 应用程序。由于库处理所有 CRUD 操作,整个过程将比我们在第二十九章中为产品表实现 CRUD 时更高效。当我们在《安全最佳实践》一节(见第 608 页)中讨论安全时,我们将讨论处理密码的最佳实践,因此我们将继续添加一个存储用户名和密码信息的用户表。</p> <p>除了新的数据库表外,我们还需要一个 User 模型类,一个 UserRepository 仓库类(命名符合 ORM 库的要求),一个 UserController 控制器类,以及一个用于显示所有用户的页面。图 30-1 展示了该页面。当然,这个页面仅用于演示数据库方法是否正常工作;显示用户名和密码列表<em>并不是</em>安全 Web 开发的示例。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure30-1.jpg" alt="" loading="lazy"></p> <p>图 30-1:用户列表页面</p> <p>我们将从声明 User 模型类开始。将列表 30-6 中的代码添加到<em>src/User.php</em>文件,并将其添加到项目中。</p> <pre><code><?php namespace Mattsmithdev; class User { private int $id; private string $username; private string $password; public function getId(): int { return $this->id; } public function setId(int $id): void { $this->id = $id; } public function getUsername(): string { return $this->username; } public function setUsername(string $username): void { $this->username = $username; } public function getPassword(): string { return $this->password; } public function setPassword(string $password): void { $this->password = $password; } } </code></pre> <p>列表 30-6:User 类</p> <p>该类具有一个整数 id 属性(这是 ORM 库的要求),以及用于存储用户名和密码的字符串属性。我们为这些属性声明了标准的 getter 和 setter 方法。</p> <p>现在我们将在<em>src/UserRepository.php</em>中创建 UserRepository 类。与 ProductRepository 一样,我们将让这个类继承 ORM 库的 DatabaseTableRepository 类。列表 30-7 展示了代码。</p> <pre><code><?php namespace Mattsmithdev; use Mattsmithdev\PdoCrudRepo\DatabaseTableRepository; class UserRepository extends DatabaseTableRepository { } </code></pre> <p>列表 30-7:简单的 UserRepository 类</p> <p>我们不需要为这个仓库类声明任何方法,因为它将继承自 DatabaseTableRepository 类的所有必要的 CRUD 方法。由于 UserRepository 和 User 类的命名,这些 CRUD 方法将知道与数据库中的用户表进行交互。</p> <p>接下来,让我们创建一个 UserController 类,其中包含一个从数据库中检索所有用户并使用 Twig 模板显示它们的方法。创建<em>src/UserController.php</em>并包含清单 30-8 中的代码。</p> <pre><code><?php namespace Mattsmithdev; class UserController extends Controller { private UserRepository $userRepository; public function __construct() { parent::__construct(); $this->userRepository = new UserRepository(); } public function list(): void { ❶ $users = $this->userRepository->findAll(); $template = 'user/list.xhtml.twig'; $args = [ 'users' => $users, ]; print $this->twig->render($template, $args); } } </code></pre> <p>清单 30-8:UserController 类声明 list()方法</p> <p>我们将 UserController 声明为 Controller 的子类,以便它继承一个用于渲染模板的 twig 属性。我们声明一个私有的 userRepository 属性,并在构造函数中初始化它(同时必须先调用父类 Controller 的构造函数以设置 twig 属性)。接着,我们声明一个 list()方法,它使用 UserRepository 对象的 findAll()方法(继承自 ORM 库)从数据库中检索所有用户❶。结果作为对象数组返回,我们将其存储为$users。然后,我们将这个数组作为 Twig 变量 users 传递给<em>templates/user/list.xhtml.twig</em>模板进行渲染。</p> <p>现在,我们将在所有其他模板继承的基本 Twig 模板中添加一个指向用户列表页面的导航栏链接。更新<em>templates/base.xhtml.twig</em>以匹配清单 30-9 的内容。</p> <pre><code><!doctype html> <html lang="en"> --snip-- <body class="container"> <ul class="nav nav-pills"> <li class="nav-item"> <a class="nav-link {% block homeLink %}{% endblock %}" href="/">Home page</a> </li> <li class="nav-item"> <a class="nav-link {% block productLink %}{% endblock %}" href="/?action=products">Product List page</a> </li> <li class="nav-item"> <a class="nav-link {% block userLink %}{% endblock %}" href="/?action=users">User List page</a> </li> </ul> {% block body %} {% endblock %} </body></html> </code></pre> <p>清单 30-9:将用户列表链接添加到/templates/base.xhtml.twig</p> <p>在这里,我们添加了一个导航列表项,文本为用户列表页面。该锚元素的 action 为 users,CSS 类属性声明了一个名为 userLink 的空 Twig 块。与其他导航栏项一样,这个块可以被重写为文本 active,以突出显示该链接。</p> <p>更新了基本模板后,我们现在可以为用户列表页面创建<em>templates/user/list.xhtml.twig</em>子模板。清单 30-10 展示了如何操作。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}User List page{% endblock %} ❶ {% block userLink %}active{% endblock %} {% block body %} <h1>User List page</h1> <ul> ❷ {% for user in users %} <li class="mt-5"> id: {{user.id}} <br> username: {{ user.username}} <br> password: {{ user.password}} </li> {% endfor %} </ul> {% endblock %} </code></pre> <p>清单 30-10:list.xhtml.twig 模板</p> <p>在这个模板中,我们重写了 userLink 块,使其包含文本 active ❶,在导航栏中突出显示用户列表页面链接。在 body 块中,我们使用 Twig 的 for 循环❷遍历用户数组,为每个用户创建一个列表项,显示关联的 ID、用户名和密码。</p> <p>现在,我们需要为 action=users 路由在我们的前端控制器应用程序类中添加一个案例。更新<em>src/Application.php</em>以匹配清单 30-11 的内容。</p> <pre><code><?php namespace Mattsmithdev; class Application { private DefaultController $defaultController; private ProductController $productController; private UserController $userController; public function __construct() { $this->defaultController = new DefaultController(); $this->productController = new ProductController(); $this->userController = new UserController(); } public function run(): void { $action = filter_input(INPUT_GET, 'action'); $isPostSubmission = ($_SERVER['REQUEST_METHOD'] === 'POST'); switch ($action) { case 'products': $this->productController->list(); break; case 'users': $this->userController->list(); break; --snip-- } </code></pre> <p>清单 30-11:在 Application 类中添加用户列表的路由</p> <p>我们声明一个 userController 属性,并在构造函数中将其初始化为一个新的 UserController 对象。然后,在 switch 语句中,我们声明一个当 action 为'users'时的 case,调用 UserController 对象的 list()方法。</p> <p>现在,我们只需要将用户表添加到数据库模式中,并向表中插入用户行。创建一个新的辅助脚本,<em>db/setup_users.php</em>,如清单 30-12 所示。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\User; use Mattsmithdev\UserRepository; $userRepository = new UserRepository(); ❶ $userRepository->resetTable(); $user1 = new User(); $user1->setUsername('matt'); $user1->setPassword('password1'); $userRepository->insert($user1); $user2 = new User(); $user2->setUsername('john'); $user2->setPassword('password2'); $userRepository->insert($user2); $users = $userRepository->findAll(); print '<pre>'; var_dump($users); print '</pre>'; </code></pre> <p>清单 30-12:/db/setup_users.php 中用户表的设置脚本</p> <p>在我们第一次设置第二十八章中的产品表时,我们需要手动输入并执行每个 SQL 语句,以便向数据库中添加一行新数据。现在,我们可以通过将每一行构建为 User 类的实例,并通过调用 UserRepository 类的 insert()方法(该方法继承自 ORM 库)来将其添加到表中。在这个脚本中,我们为两个用户做了这个操作,分配了他们的用户名和密码。</p> <p>首先,我们调用 UserRepository 类的 resetTable()方法❶,该方法会删除任何已映射到 User 类的现有表,并根据 User 类的名称和数据类型创建一个新表。通过继承自 ORM 库的 DatabaseTableRepository 类,这又是一个“免费”的方法,自动可用于我们的存储库类。为了确认数据库表已被创建并且两个 User 记录已被插入,脚本最后通过 findAll()方法从数据库中检索所有用户并使用 var_dump()打印它们。</p> <p>在终端输入 php db/setup_users.php 来运行这个设置脚本。你应该会看到以下输出:</p> <pre><code>$ **php db/setup_users.php** <pre>array(2) { [0]=> object(Mattsmithdev\User)#8 (3) { ["id":"Mattsmithdev\User":private]=> int(1) ["username":"Mattsmithdev\User":private]=> string(4) "matt" ["password":"Mattsmithdev\User":private]=> string(9) "password1" } [1]=> object(Mattsmithdev\User)#9 (3) { ["id":"Mattsmithdev\User":private]=> int(2) ["username":"Mattsmithdev\User":private]=> string(4) "john" ["password":"Mattsmithdev\User":private]=> string(9) "password2" } } </code></pre> <p>终端输出显示了一个包含两个 User 对象的数组,证明用户数据库表已被添加到数据库模式中,并且已插入了两个用户。此时,你也可以重新启动 Web 应用程序并访问用户列表页面。它应该看起来像图 30-1。</p> <p>通过使用我的 pdo-crud-for-free-repositories 库,我们已经看到如何使用 ORM 库来避免编写底层数据库查询。这减少了每个 Web 应用程序所需的代码量,从而简化了开发过程。我们将继续使用这个库,转而关注应用程序安全性,但稍后我们会回到 ORM 库的话题,看看使用像 Doctrine 这样更复杂的库能带来的额外好处。</p> <h3 id="安全最佳实践">安全最佳实践</h3> <p>安全性是软件开发中的一个关键部分,无论是在本地开发环境中,还是在将 Web 应用程序部署到实际的公共网站时。如今,用户最常遇到的安全性体现之一便是用户名/密码登录表单。我们将在本节中探讨保护登录信息的最佳实践。</p> <h4 id="存储哈希密码">存储哈希密码</h4> <p>你绝不应该在应用程序的数据库中存储明文密码。否则,如果有人获得了数据库的访问权限,所有这些账户都会受到威胁。安全存储数据的一个选择是 <em>加密</em>,即以某种方式对数据进行编码,以便在以后能够解码回原始形式。例如,在发送机密消息时,通常会先对其加密,并在接收方收到消息后提供解密方法。然而,对于密码来说,加密并不是最好的解决方案;如果数据库被访问,暴力破解技术可能会允许攻击者最终解密数据(尽管根据他们计算机的速度,可能需要很长时间)。</p> <p>一种更好的密码存储技术是 <em>哈希处理</em>。这是一种不可逆的方式,通过原始数据生成新的数据;无法从哈希版本恢复出明文密码。然而,相同的密码通过相同的哈希算法处理时将始终生成相同的哈希值。因此,当用户登录应用程序时,你可以通过哈希他们输入的密码并与数据库中存储的哈希值进行比较,从而验证密码是否有效。使用这种机制,永远不需要存储原始的明文密码。</p> <p>让我们通过在用户数据库表中存储哈希值而不是明文密码来提高我们 Web 应用的安全性。方便的是,现代 PHP 提供了一个内置的 <code>password_hash()</code> 函数,用于计算字符串的哈希值。我们将修改用户实体类的 <code>setPassword()</code> 方法,利用这个函数。请更新 <em>src/User.php</em> 文件,使其内容与 Listing 30-13 匹配。</p> <pre><code><?php namespace Mattsmithdev; class User { private int $id; private string $username; private string $password; --snip-- public function setPassword(string $password): void { $hashedPassword = password_hash($password, PASSWORD_DEFAULT); $this->password = $hashedPassword; } } </code></pre> <p>Listing 30-13: 在用户类中存储哈希密码</p> <p>修改后的 <code>setPassword()</code> 方法接受新用户的明文密码,并将其传递给 <code>password_hash()</code> 函数进行哈希处理。<code>PASSWORD_DEFAULT</code> 常量意味着该函数将使用安装的 PHP 版本中最强的哈希算法,尽管也有其他常量可以明确选择某种特定的哈希算法。我们将哈希值存储在 <code>$hashedPassword</code> 变量中,并将其作为用户对象的密码属性的值。</p> <p>通过这个更改,任何新创建并传递到数据库的用户对象将包含哈希密码,而不是明文密码。为了验证这一点,在命令行输入 <code>php db/setup_users.php</code> 重新运行用户表设置脚本。这将删除并重新创建包含修改后用户类的表。以下是在终端中显示的 <code>var_dump()</code> 输出:</p> <pre><code>$ **php db/setup_users.php** <pre>array(2) { [0]=> object(Mattsmithdev\User)#8 (3) { ["id":"Mattsmithdev\User":private]=> int(1) ["username":"Mattsmithdev\User":private]=> string(4) "matt" ["password":"Mattsmithdev\User":private]=> string(60) "$2y$10$k25neEiR.2k8j4gM7Gn6aeiHK8T7ZNgS18QUVsTdm592fGfN23SZG" } [1]=> object(Mattsmithdev\User)#9 (3) { ["id":"Mattsmithdev\User":private]=> int(2) ["username":"Mattsmithdev\User":private]=> string(4) "john" ["password":"Mattsmithdev\User":private]=> string(60) "$2y$10$telY8TmtAD7a/niym3/W5OvlKIFbu.CYOfrX0u3yRKdPEyD1V6KRi" } } </code></pre> <p>黑色文本行显示每个密码字段中的哈希值。每个哈希值都是一个长字符字符串,与原始密码没有明显的关系。</p> <h4 id="登录时验证哈希密码">登录时验证哈希密码</h4> <p>另一个有用的内建 PHP 函数是 password_verify(),它接受一个明文密码,将其哈希化,并与现有的哈希值进行比较,以确定密码是否正确。使用这个函数,我们可以为应用程序实现一个登录页面,用户在其中输入用户名和密码,系统会根据用户数据库表中的记录进行验证。图 30-2 展示了我们将要创建的登录页面。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure30-2.jpg" alt="" loading="lazy"></p> <p>图 30-2:新的登录页面</p> <p>我们的应用程序需要两个新路由,一个用于请求显示登录页面(action=login),另一个用于请求处理从登录表单提交的数据(action=processLogin)。首先,我们将为这些路由添加对应的情况到我们的前端控制器 Application 类中。更新 <em>src/Application.php</em> 中的 switch 语句以匹配 列表 30-14。</p> <pre><code><?php namespace Mattsmithdev; class Application { --snip-- public function run(): void { $action = filter_input(INPUT_GET, 'action'); $isPostSubmission = ($_SERVER['REQUEST_METHOD'] === 'POST'); switch ($action) { case 'login': $this->userController->loginForm(); ❶ break; case 'processLogin': $username = filter_input(INPUT_POST, 'username'); $password = filter_input(INPUT_POST, 'password'); if (empty($username) || empty($password)) {❷ $this->defaultController->error( 'error - you must enter both a username and a password to login'); } else { $this->userController->processLogin($username, $password); ❸ } break; --snip-- } </code></pre> <p>列表 30-14:向应用程序类添加登录路由</p> <p>对于 'login' 情况,我们调用 UserController 对象的 loginForm() 方法 ❶。对于 'processLogin' 情况,我们首先尝试从 POST 请求中提取 'username' 和 'password' 的值。如果其中任何一个为空 ❷,则通过将字符串消息传递给 DefaultController 对象的 error() 方法,显示相应的错误信息。否则,用户名和密码会传递给 UserController 对象的 processLogin() 方法 ❸。</p> <p>现在我们需要向 UserController 类添加新方法。更新 <em>src/UserController.php</em>,如 列表 30-15 所示。</p> <pre><code><?php namespace Mattsmithdev; class UserController extends Controller { --snip-- public function loginForm(): void { $template = 'user/login.xhtml.twig'; $args = []; print $this->twig->render($template, $args); } public function processLogin(string $username, string $password): void { ❶ $loginSuccess = $this->isValidUsernamePassword($username, $password); if ($loginSuccess) { print 'success - username and password found in database'; } else { print 'sorry - there was an error with your username/password'; } } private function isValidUsernamePassword($username, $password): bool { ❷ $user = $this->userRepository->findOneByUsername($username); // False if no user for username if ($user == NULL) { return false; } // See if entered password matches stored (hashed) one ❸ return password_verify($password, $user->getPassword()); } } </code></pre> <p>列表 30-15:向 UserController 类添加登录方法</p> <p>对于 loginForm() 方法,我们仅渲染相应的 Twig 模板,该模板不需要任何参数。对于 processLogin() 方法,我们接收 $username 和 $password 变量,并将它们传递给 isValidUsernamePassword() 辅助方法 ❶,该方法返回一个布尔值。如果返回 true,我们显示成功消息;如果返回 false,则显示错误消息。在完整的 Web 应用中,在此阶段,我们会像在 第十六章 中那样将登录成功信息存储到会话中。</p> <p>isValidUsernamePassword() 辅助函数负责确定数据库中是否存在与接收到的用户名和密码匹配的记录。首先,我们调用 UserRepository 类的方法 findOneByUsername(),它尝试从用户表中检索与提供的用户名匹配的记录(以 User 对象的形式) ❷。如果无法检索到单个用户,findOneByUsername() 会返回 NULL,此时验证方法返回 false。否则,我们调用 PHP 内建的 password_verify() 函数,将提交的密码($password)和正确的密码哈希(通过 User 对象的 getPassword() 方法访问)传入 ❸。password_verify() 函数会对提供的明文密码进行哈希处理,并返回一个布尔值,指示它是否与提供的哈希值匹配。</p> <p>现在让我们为 UserRepository 类编写 findOneByUsername()方法。更新<em>src/UserRepository.php</em>,使其与清单 30-16 中的代码一致。</p> <pre><code><?php namespace Mattsmithdev; use Mattsmithdev\PdoCrudRepo\DatabaseTableRepository; class UserRepository extends DatabaseTableRepository { public function findOneByUsername(string $username): ?User { $users = $this->searchByColumn('username', $username); if (count($users) != 1) { return NULL; } return $users[0]; } } </code></pre> <p>清单 30-16:将 findOneByUsername()方法添加到 UserRepository 类</p> <p>新的 findOneByUsername()方法具有可空的?User 返回类型。它使用从 ORM 库继承的 searchByColumn()方法,该方法接受列名('username')和一个值(在$username 变量中),并返回一个记录数组,其中数据库表中该列的值与给定值匹配。如果返回的数组的长度不等于 1(无论是为空还是返回了多个记录),findOneByUsername()将返回 NULL。然而,如果单个用户与提交的用户名字符串匹配,则返回相应的 User 对象。</p> <p>请注意,这个方法中的逻辑本可以成为 UserController 中 isValidUsernamePassword()方法的一部分,但我们需要的是一个通过给定用户名查询用户的操作,这属于模型数据库查询。因此,将其作为我们 UserRepository 类中的自定义方法是合适的,因为所有查询用户数据库表的代码都在这里。值得一提的是,尽管我们依赖 ORM 库中的通用方法,如 find()和 findAll(),但通常仍然需要通过扩展仓库类来支持特定于应用程序的更专业的控制器逻辑。在这种情况下,我们需要按用户名列而不是 ID 来查询,因此继承的 find()方法不适用。ORM 库仍然通过 searchByColumn()方法帮助我们,但我们仍然需要自定义逻辑来验证确实只获取了一个 User 对象。</p> <p>接下来,我们将在基础模板中的导航栏中添加一个登录页面链接。更新<em>templates/base.xhtml.twig</em>,如清单 30-17 所示。</p> <pre><code><!doctype html> <html lang="en"> --snip-- <body class="container"> <ul class="nav nav-pills"> --snip-- <li class="nav-item"> <a class="nav-link" href="/?action=login">Login</a> </li> </ul> {% block body %} {% endblock %} </body></html> </code></pre> <p>清单 30-17:将登录链接添加到 base.xhtml.twig 模板</p> <p>在这里,我们添加了一个导航列表项,文本为“登录”,URL 路由为 action=login。添加后,我们可以在<em>/templates/user/login.xhtml.twig</em>中创建登录页面本身的子模板。清单 30-18 显示了代码。</p> <pre><code>{% extends 'base.xhtml.twig' %} {% block title %}login page{% endblock %} {% block body %} <h1>Login</h1> <form method="POST" action="/?action=processLogin"> <p> Username: <input name="username"> </p> <p> Password: ❶ <input name="password" type="password"> </p> <input type="submit"> </form> {% endblock %} </code></pre> <p>清单 30-18:login.xhtml.twig 模板</p> <p>页面主体包含一个<form>元素,POST 动作为 processLogin。该表单包含用户名和密码字段,以及一个提交按钮。请注意,密码输入框的类型是"password" ❶。设置为此后,浏览器将显示占位符字符,例如点或星号,从而隐藏用户输入的实际字符。</p> <p>尝试使用用户名 matt 和密码 password1,或使用任何不正确的用户名/密码组合来测试新的登录表单。得益于 PHP 的安全、便捷的 password_verify()函数,您应该会发现表单能够正常工作,即使数据库中存储的是密码哈希,而不是明文密码。</p> <h4 id="数据库凭证的安全性">数据库凭证的安全性</h4> <p>另一个重要的 Web 应用程序安全措施是避免暴露数据库凭证。无论你是将这些凭证声明为类常量,还是像我们在本章早些时候所做的那样存放在一个完全独立的文件中(如<em>.env</em>),都必须确保它们不出现在任何公开文件中。</p> <p>开始时,你应该只为凭证拥有一个单独的文件。如果你使用类常量而不是<em>.env</em>文件,我建议你创建一个完全独立的类,仅声明常量。然后你可以在数据库类(或任何其他负责建立数据库连接的类)中引用这个类。</p> <p>接下来,标记包含凭证的文件,以便任何备份或归档系统忽略该文件。例如,如果你使用的是 Git 分布式版本控制系统,你需要在项目的<em>.gitignore</em>文件中列出该文件。</p> <h3 id="doctrine-orm-库">Doctrine ORM 库</h3> <p>开源的 Doctrine 项目是一个维护良好、功能齐全的 PHP ORM 库。它被广泛使用;例如,Symfony 框架使用 Doctrine 进行所有数据库通信。我的小型 ORM 库适用于小型项目和学习基础知识,但对于具有多个相互关联模型类的大型项目,Doctrine 是一个更强大、更复杂的解决方案。它的一些特性包括轻松实现对象与对象之间的引用,这些引用会成为数据库模式中的外键,并且提供低级别的控制,可以更改数据库表和列名,而不仅仅是使用默认的命名约定。</p> <p>在列表 30-5 之后(在添加 User 模型类之前),你被要求复制你的项目。(如果当时你没有复制项目,不用担心;你可以从书中的代码获取我的 listing30-05,网址为<em><a href="https://github.com/dr-matt-smith/php-crash-course" target="_blank"><code>github.com/dr-matt-smith/php-crash-course</code></a></em>。)接下来的部分将展示如何将该项目副本调整为使用 Doctrine,而不是我之前的 pdo-crud-for-free-repositories ORM 库。</p> <h4 id="移除之前的-orm-库">移除之前的 ORM 库</h4> <p>首先,让我们从项目中移除之前的 ORM 库功能。请在命令行输入以下内容,将<code>pdo-crud-for-free-repositories</code>库从项目的<em>/vendor</em>文件夹以及<em>composer.json</em>项目依赖文件中移除:</p> <pre><code>$ **composer remove mattsmithdev/pdo-crud-for-free-repositories** </code></pre> <p>我们还需要从 ProductRepository 类声明中移除对旧库的 DatabaseTableRepository 类的引用。列表 30-19 展示了如何更新<em>src/UserRepository.php</em>文件。</p> <pre><code><?php namespace Mattsmithdev; class ProductRepository { } </code></pre> <p>列表 30-19:ProductRepository 类,未继承自 ORM 库</p> <p>目前,我们只剩下一个空的类声明,但稍后我们会回到这个类,并将其与 Doctrine 集成。</p> <h4 id="添加-doctrine">添加 Doctrine</h4> <p>现在我们将使用 Composer 将 Doctrine ORM 库添加到项目中,并一起添加另外两个必需的库。在命令行中输入以下内容:</p> <pre><code>$ **composer require doctrine/orm** $ **composer require symfony/cache** $ **composer require symfony/dotenv** </code></pre> <p>Doctrine 需要缓存来提高性能,推荐使用 symfony/cache。此外,symfony/dotenv 将使我们能够轻松访问项目的<em>.env</em>文件中的值。</p> <p>接下来,我们需要将 Doctrine 与数据库连接起来。在项目的顶层目录中创建一个名为<em>bootstrap.php</em>的脚本,包含 Listing 30-20 中的代码。这个脚本是基于 Doctrine 文档页面中的内容编写的,链接地址是<em><a href="https://www.doctrine-project.org" target="_blank"><code>www.doctrine-project.org</code></a></em>。</p> <pre><code><?php require_once "vendor/autoload.php"; use Doctrine\DBAL\DriverManager; use Doctrine\ORM\EntityManager; use Doctrine\ORM\ORMSetup; use Symfony\Component\Dotenv\Dotenv; ❶ $dotenv = new Dotenv(); $dotenv->load(__DIR__ . '/.env'); // Get Doctrine to create DB connection $connectionParams = [ 'dbname' => $_ENV['MYSQL_DATABASE'], 'user' => $_ENV['MYSQL_USER'], 'password' => $_ENV['MYSQL_PASSWORD'], 'host' => $_ENV['MYSQL_HOST'], 'driver' => 'pdo_mysql', ]; $config = ORMSetup::createAttributeMetadataConfiguration( paths: [__DIR__.'/src'], isDevMode: true, ); ❷ $connection = DriverManager::getConnection($connectionParams, $config); ❸ $entityManager = new EntityManager($connection, $config); </code></pre> <p>Listing 30-20:用于设置 Doctrine 的 bootstrap.php 脚本</p> <p>我们读取了 Composer 自动加载器,创建了一个 Dotenv 对象来从项目的<em>.env</em>文件中加载数据库凭证 ❶,并将这些凭证打包成$connectionParams 数组。然后我们使用这个数组和一些 Doctrine 的静态方法来建立数据库连接 ❷,并创建一个 EntityManager 对象 ❸。EntityManager 类是 Doctrine 工作机制的关键;该类维护 PHP 代码中的模型类对象与其对应的数据库表行之间的联系,这些表行是通过唯一的主键来定义的。</p> <p>任何其他读取<em>bootstrap.php</em>的脚本,现在都可以通过<span class="math inline">\(connection 变量访问数据库连接,并通过\)</span>entityManager 变量访问 Doctrine 的实体管理器。</p> <h4 id="验证-doctrine-是否工作正常">验证 Doctrine 是否工作正常</h4> <p>在继续之前,让我们确保 Doctrine 已经成功地与项目的数据库建立了连接。Listing 30-21 展示了一个简单的脚本,它通过从数据库中检索 Product 对象并以关联数组的形式返回,来测试 Doctrine。将此脚本保存为<em>public/doctrine1.php</em>。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../bootstrap.php'; $sql = 'SELECT * FROM product'; $stmt = $connection->executeQuery($sql); $result = $stmt->fetchAllAssociative(); // Print results foreach ($result as $row) { print "ID: {$row['id']}, Description: {$row['description']}\n"; } </code></pre> <p>Listing 30-21:用于从数据库中检索现有行的 doctrine1.php 脚本</p> <p>在读取自动加载器和 Doctrine 引导脚本后,我们创建一个 SQL 查询,选择产品数据库表中的所有行,然后通过 Doctrine 的数据库连接(存储在$connection 变量中)执行查询。结果将以嵌套数组的形式返回;每个内部数组将列名映射到特定行中的值。我们遍历这个数组并打印每一行。如果你运行这个<em>public/doctrine1.php</em>脚本,你应该会看到如下输出:</p> <pre><code>ID: 1, Description: bag of nails ID: 2, Description: bucket </code></pre> <p>我们已经成功地从数据库中检索到了两个产品,表明 Doctrine 已经成功运行。</p> <h4 id="创建数据库表">创建数据库表</h4> <p>Doctrine 的一个优势是它能够根据应用程序的 PHP 代码中遇到的类来更新数据库的结构,必要时创建新的表和列。为了了解这一点,让我们将项目切换到一个新的空数据库。然后,我们可以使用 Doctrine 从头开始创建产品表。</p> <p>首先,打开项目的<em>.env</em>文件,并将 MYSQL_DATABASE 键对应的值修改为 demo2。接下来,我们需要编写一个脚本来创建这个新的 demo2 数据库架构。创建<em>db/create_database.php</em>并输入 Listing 30-22 的内容。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Symfony\Component\Dotenv\Dotenv; use Doctrine\DBAL\DriverManager; $dotenv = new Dotenv(); ❶ $dotenv->load(__DIR__ . '/../.env'); $connectionParams = [ 'user' => $_ENV['MYSQL_USER'], 'password' => $_ENV['MYSQL_PASSWORD'], 'host' => "{$_ENV['MYSQL_HOST']}:{$_ENV['MYSQL_PORT']}", 'driver' => 'pdo_mysql', ]; try { // Get connection $connection = DriverManager::getConnection($connectionParams); ❷ $databaseNames = $connection->createSchemaManager()->listDatabases(); $databaseExists = array_search($_ENV['MYSQL_DATABASE'], $databaseNames); ❸ // Drop database if exists already if ($databaseExists) { $connection->createSchemaManager()->dropDatabase($_ENV['MYSQL_DATABASE']); } // Create database $connection->createSchemaManager()->createDatabase($_ENV['MYSQL_DATABASE']); ❹ print "succeeded in (re)creating database: {$_ENV['MYSQL_DATABASE']}\n"; } catch (Exception $e) {❺ print "there was a problem creating the database: $e"; } </code></pre> <p>清单 30-22:创建名为 .env 文件中指定的数据库的 db/create_database.php 脚本</p> <p>我们使用一个 Dotenv 对象 ❶ 从 <em>.env</em> 文件中读取数据库凭证,并创建一个连接参数数组。然后,在 try...catch 块中,我们通过使用 Doctrine 的 DriverManager::getConnection() 方法 ❷ 与 MySQL 数据库服务器建立连接。接着,我们获取所有数据库名称的数组,并在该数组中搜索来自 <em>.env</em> 文件的数据库名称,将结果(真或假)存储在 $databaseExists 变量 ❸ 中。</p> <p>如果数据库存在,我们使用 dropDatabase() 方法将其删除。然后,我们通过使用 createDatabase() 方法 ❹ 重新创建数据库,并打印成功消息。如果捕获到任何异常 ❺,则打印错误消息。运行此脚本后,您应该会得到一个新的、空的数据库架构,名为 demo2。</p> <p>Doctrine 的基本用法是运行一个命令行脚本,读取 PHP 代码中模型类的元数据(在 Doctrine 的术语中称为 <em>实体类</em>),并执行 SQL 语句,在数据库架构中创建相应的结构。命令行脚本通常保存在名为 <em>/bin/doctrine</em> 的文件中(不带 .php 文件扩展名)。按照 清单 30-23 中所示创建此文件。</p> <pre><code><?php require_once __DIR__ . '/../bootstrap.php'; use Doctrine\ORM\Tools\Console\ConsoleRunner; use Doctrine\ORM\Tools\Console\EntityManagerProvider\SingleManagerProvider; ConsoleRunner::run(new SingleManagerProvider($entityManager), []); </code></pre> <p>清单 30-23:/bin/doctrine 命令行脚本</p> <p>该脚本调用了 Doctrine 的 ConsoleRunner 类的 run() 方法。该方法接受命令行中的参数,并使用它们运行在终端中 bin/doctrine 后输入的任何 Doctrine 命令。让我们运行此脚本,尝试更新新的数据库架构。在命令行中输入 php bin/doctrine orm:schema-tool:create。您应该会看到以下输出:</p> <pre><code>$ **php bin/doctrine orm:schema-tool:create** [OK] No Metadata Classes to process. </code></pre> <p>脚本还没有做任何事情,因为我们还没有添加 Doctrine 所需的元数据,以便知道哪些模型类和属性应映射到哪些数据库表和列。接下来,我们将向 Product 模型类添加元数据,以便 Doctrine 在数据库中创建一个表。正如您所见,每个元数据标签前面都有一个井号(#),并用方括号括起来。按照 清单 30-24 中所示修改 <em>src/Product.php</em> 文件。</p> <pre><code><?php namespace Mattsmithdev; use Doctrine\ORM\Mapping as ORM; ❶ #[ORM\Entity] #[ORM\Table(name: 'product')] class Product { ❷#[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue] private ?int $id; #[ORM\Column(type: 'string')] private string $description; #[ORM\Column()] private float $price; --snip-- } </code></pre> <p>清单 30-24:向 Product 类添加 Doctrine 元数据</p> <p>为了让元数据更易于阅读,我们首先使用 <code>use</code> 语句将 Doctrine\ORM\Mapping 类别名为 ORM。接着,我们向类本身及其每个属性添加元数据。我们将类声明为实体 ❶,表示它应当对应一个数据库表,并指定该表应命名为 product。否则,Doctrine 默认将使用 Product(以大写字母开头)作为表名,与类名匹配。</p> <p>对于类的 id 属性,Id 标签表示此属性应作为主键使用❷,Column 表示该属性应与数据库表中的一列对应,GeneratedValue 意味着该属性应在数据库系统中自增。对于其余的属性,我们只需要 Column 标签。请注意,我们可以通过 Column 标签指定数据库列的数据类型,也可以让 Doctrine 猜测合适的数据类型。</p> <p>在添加了这些元数据后,我们可以再次运行 Doctrine 命令行脚本。首先,添加--dump-sql 选项,这将显示 Doctrine<em>将</em>执行的 SQL 语句,但不会实际执行:</p> <pre><code>$ **php bin/doctrine orm:schema-tool:create --dump-sql** CREATE TABLE product (id INT AUTO_INCREMENT NOT NULL, description VARCHAR(255) NOT NULL, price DOUBLE PRECISION NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB; </code></pre> <p>这显示了 Doctrine 将发出 SQL 代码来创建一个包含自增整数主键 id、文本描述和浮动价格的产品表。正是我们想要的!现在再次运行命令行脚本,但不带--dump-sql 选项来执行该 SQL:</p> <pre><code>$ **php bin/doctrine orm:schema-tool:create** ! [CAUTION] This operation should not be executed in a production environment! Creating database schema... [OK] Database schema created successfully! </code></pre> <p>Doctrine 现在已经在 demo2 数据库模式中创建了产品表。#### 向表中添加记录</p> <p>现在我们已经使用 Doctrine 将 Product 类映射到产品数据库表中,我们可以创建新的 Product 对象并将其数据存储到数据库中。清单 30-25 显示了用于此操作的<em>public/doctrine2.php</em>脚本。将该文件添加到项目中。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../bootstrap.php'; use Mattsmithdev\Product; ❶ $product1 = new Product(); $product1->setDescription("small hammer"); $product1->setPrice(4.50); $entityManager->persist($product1); $entityManager->flush(); // Retrieve products from Database ❷ $productRepository = $entityManager->getRepository(Product::class); $products = $productRepository->findAll(); foreach ($products as $product) { print "Product OBJECT = ID: {$product->getId()}, " . "Description: {$product->getDescription()}\n"; } </code></pre> <p>清单 30-25:用于插入和检索数据库行的 public/doctrine2.php 脚本</p> <p>我们创建一个 Product 对象❶并设置它的描述和价格。然后我们使用 Doctrine 的 EntityManager 对象将该产品的数据添加到队列中(persist()方法),并将对象插入到数据库中(flush()方法)。</p> <p>为了确认此操作已成功,我们使用 EntityManager 创建并获取一个 Doctrine 存储库对象的引用,针对 Product 类❷。这是一个自定义的存储库对象,它将 Product 类与产品数据库表中的记录关联。我们使用这个存储库对象通过对象的 findAll()方法从表中检索所有记录(在此情况下,仅有一条记录)。然后我们遍历结果中的$products 数组并打印每个对象。下面是运行此脚本的输出:</p> <pre><code>Product OBJECT = ID: 1, Description: small hammer </code></pre> <p>该输出确认 Doctrine 已成功将"小锤子"对象添加到产品数据库表中。#### 将 Doctrine 集成到应用程序代码中</p> <p>现在所有代码都已到位,将 Doctrine ORM 库集成到我们的主 Web 应用程序中,这样我们就可以轻松地映射对象和数据库表行。首先,为了最小化对应用程序代码的更改,我们将添加一个名为 OrmHelper 的辅助类,它管理对 Doctrine EntityManager 实例的访问。清单 30-26 显示了如何在<em>src/OrmHelper.php</em>中声明这个类。</p> <pre><code><?php namespace Mattsmithdev; use Doctrine\ORM\EntityManager; class OrmHelper { private static EntityManager $entityManager; public static function getEntityManager(): EntityManager { return self::$entityManager; } public static function setEntityManager( EntityManager $entityManager): void { self::$entityManager = $entityManager; } } </code></pre> <p>清单 30-26:OrmHelper 类存储并提供对$entityManager 属性的访问</p> <p>该类声明了一个私有的静态 entityManager 属性,并提供了公共静态的 getter 和 setter 方法。我们使用静态成员,以便在我们的应用程序代码中的任何地方(在变量被设置之后)检索到对 Doctrine EntityManager 对象的引用,而无需创建对象或在创建应用程序、控制器或存储库类时通过多个构造方法传递对象引用。</p> <p>注意,setter 方法接受一个 EntityManager 对象的引用,并将其传递给类的 entityManager 属性。我们已经在 <em>bootstrap.php</em> 脚本中创建了该引用,因此我们只需要在调用 setter 方法之前读取引导脚本。我们现在通过更新 <em>public/index.php</em> 脚本来实现这一点,如 列表 30-27 所示。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; ❶ require_once __DIR__ . '/../bootstrap.php'; session_start(); use Mattsmithdev\Application; ❷ use Mattsmithdev\OrmHelper; OrmHelper::setEntityManager($entityManager); $app = new Application(); $app->run(); </code></pre> <p>列表 30-27:更新 index.php 脚本以引导 Doctrine 并存储 EntityManager 对象的引用</p> <p>我们添加一个 require_once 语句来读取并运行我们的 Doctrine 引导脚本 ❶。我们添加一个 use 语句,以便在代码中引用 OrmHelper 类 ❷。然后,我们通过调用 OrmHelper 类的 setEntityManager() 静态方法,存储对脚本中 EntityManager 对象的引用。这意味着通过公共静态方法 OrmHelper::getEntityManager(),EntityManager 对象现在可以在我们的 Web 应用程序逻辑中的任何地方使用。</p> <p>最后,我们需要填写我们的 ProductRepository 类,在切换到 Doctrine 时我们将其作为空类声明。我们的 ProductController 类期望 ProductRepository 具有像 find()、findAll()、insert()、delete() 等 CRUD 方法。列表 30-28 展示了如何相应地更新 <em>src/ProductRepository.php</em>。</p> <pre><code><?php namespace Mattsmithdev; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Mattsmithdev\Product; class ProductRepository extends EntityRepository { private EntityManager $entityManager; public function __construct() { ❶ $this->entityManager = OrmHelper::getEntityManager(); $entityClass = Product::class; $entityMetadata = $this->entityManager-> getClassMetadata($entityClass); ❷ parent::__construct($this->entityManager, $entityMetadata); } public function insert(Product $product): int { $this->entityManager->persist($product); $this->entityManager->flush(); return $product->getId(); } public function update(Product $product): void { $this->entityManager->persist($product); $this->entityManager->flush(); } public function delete(int $id): void { $product = $this->find($id); $this->entityManager->remove($product); $this->entityManager->flush(); } public function deleteAll(): void { $products = $this->findAll(); foreach ($products as $product) { $this->entityManager->remove($product); } $this->entityManager->flush(); } } </code></pre> <p>列表 30-28:使用基于 Doctrine 的 CRUD 方法更新 ProductRepository</p> <p>我们将 ProductRepository 声明为 Doctrine\ORM\EntityRepository 的子类。这意味着它将继承如 find() 和 findAll() 这样的父类方法。该类声明了一个实例变量,一个 EntityManager 对象,在构造函数中通过我们的 OrmHelper 类 ❶ 为其赋值。构造函数中的剩余代码检索关于 Product 类的必要元数据,并将其传递给父类的构造函数,以便将存储库类定制为产品表 ❷。</p> <p>我们继续为该类声明应用程序期望的剩余 CRUD 方法。对于 insert() 和 update(),我们使用 EntityManager 对象的 persist() 和 flush() 方法来添加或修改数据库记录。delete() 方法使用 EntityManager 对象的 remove() 和 flush() 方法来删除记录。最后,deleteAll() 方法使用继承的 findAll() 方法检索所有对象,然后循环遍历它们以从数据库中删除每个对象。</p> <h4 id="创建外键关系">创建外键关系</h4> <p>这看起来像是我们为集成 Doctrine 做了很多工作,但在功能上几乎没有超出之前的 ORM 库。然而,当我们开始在数据库表之间以及它们对应的模型类之间创建外键关系时,我们就能开始看到 Doctrine ORM 库的真正强大功能。在我们的代码中,我们通过向模型类添加一个属性来建立这种关系,该属性的值是对另一个模型类对象的引用。凭借正确的元数据,Doctrine 可以看到这种关系,并生成所有实现它所需的 SQL。</p> <p>为了说明问题,首先我们将向项目中添加一个 Category 模型类,以及对应的 category 数据库表。然后我们将修改 Product 模型类,使每个产品都与一个类别相关联。在这个过程中,我们将看到 Doctrine 如何管理这种关联背后的外键关系。列表 30-29 展示了声明新 Category 类的<em>src/Category.php</em>脚本。</p> <pre><code><?php namespace Mattsmithdev; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] ❶ #[ORM\Table(name: 'category')] class Category { #[ORM\Id] #[ORM\Column(type: 'integer')] ❷ #[ORM\GeneratedValue] private ?int $id; #[ORM\Column(type: 'string')] private string $name; public function getId(): ?int { return $this->id; } public function setId(?int $id): void { $this->id = $id; } public function getName(): string { return $this->name; } public function setName(string $name): void { $this->name = $name; } } </code></pre> <p>列表 30-29:Category 模型类,包括 Doctrine ORM 元数据</p> <p>类名之前的初始元数据表明,这个简单的模型类(或 Doctrine 实体)应对应一个名为 category 的数据库表❶。该类有两个属性:一个唯一的整数 id 和一个字符串 name。与 Product 类一样,我们包含一个标签,指定 id 将由数据库自动生成❷。对于每个属性,我们声明了基本的 getter 和 setter 方法。</p> <p>现在,让我们向 Product 类添加一个类别属性,以便每个 Product 对象都将与一个 Category 对象相关联。列表 30-30 展示了如何修改<em>src/Product.php</em>。</p> <pre><code><?php namespace Mattsmithdev; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity] #[ORM\Table(name: 'product')] class Product { #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue] private ?int $id; #[ORM\Column(type: 'string')] private string $description; #[ORM\Column()] private float $price; ❶#[ORM\ManyToOne(targetEntity: Category::class)] private Category|NULL $category = NULL; public function getCategory(): ?Category { return $this->category; } public function setCategory(?Category $category): void { $this->category = $category; } --snip-- } </code></pre> <p>列表 30-30:向 Product 类添加类别属性</p> <p>我们将类别属性声明为 NULL 或 Category 对象的引用,并为其提供公共的 getter 和 setter 方法。属性前面的元数据属性❶告诉 Doctrine,这个数据库字段应保存指向 category 表中某行的外键引用。在这里,ManyToOne 表示外键建立了一个<em>多对一</em>关系,即多个产品可以属于同一个类别,targetEntity 设置了关系另一端的模型类(和数据库表)。</p> <p>由于我们已经更改了 Product 模型类的结构,并且添加了新的 Category 类,我们需要 Doctrine 相应地更新数据库的结构。首先,让我们使用<em>bin/doctrine</em>命令行脚本从数据库模式中删除旧的产品表:</p> <pre><code>$ **php bin/doctrine orm:schema-tool:drop --force** [OK] Database schema dropped successfully! </code></pre> <p>这将删除模式中的<em>所有</em>表(在我们的例子中,只有产品表)。现在,我们将再次使用命令行脚本重新创建数据库模式,包含产品表和类别表,以及它们之间的外键关系。和之前一样,我们将首先使用--dump-sql 选项查看 Doctrine 希望执行的 SQL 语句:</p> <pre><code>$ **php bin/doctrine orm:schema-tool:create --dump-sql** CREATE TABLE category (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id)) --snip-- CREATE TABLE product (id INT AUTO_INCREMENT NOT NULL, category_id INT DEFAULT NULL, description VARCHAR(255) NOT NULL, price DOUBLE PRECISION NOT NULL, INDEX IDX_1x (category_id), PRIMARY KEY(id)) --snip-- ALTER TABLE product ADD CONSTRAINT FK_1x FOREIGN KEY (category_id) REFERENCES category (id); </code></pre> <p>这表明 Doctrine 将生成 SQL 代码来创建类别和产品表,其中产品表有一个 <code>category_id</code> 字段,并且该字段通过外键关联到类别数据库行。现实世界的数据库中充满了这样的外键引用,在这里我们看到 Doctrine 在管理这些关系的 SQL 方面表现出色,这样我们就不需要自己处理这些了。</p> <p>再次运行命令行脚本,不带 --dump-sql 选项,以执行 SQL 语句并创建这些相关的数据库表。为了确保相关表已经成功创建在数据库中,我们将编写一个临时脚本,创建相关的 Product 和 Category 对象,将它们保存到数据库中并进行检索。Listing 30-31 展示了 <em>public/doctrine3.php</em> 实现这些操作。将此文件添加到您的项目中。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../bootstrap.php'; use Mattsmithdev\ProductRepository; use Mattsmithdev\OrmHelper; OrmHelper::setEntityManager($entityManager); // -- Create 2 categories --- $category1 = new \Mattsmithdev\Category(); $category1->setName('HARDWARE'); $entityManager->persist($category1); $category2 = new \Mattsmithdev\Category(); $category2->setName('APPLIANCES'); $entityManager->persist($category2); // Push category objects into DB ❶ $entityManager->flush(); // -- Create 2 products --- $productRepository = new ProductRepository(); $productRepository->deleteAll(); $product1 = new \Mattsmithdev\Product(); $product1->setDescription("small hammer"); $product1->setPrice(4.50); $product1->setCategory($category1); $productRepository->insert($product1); $product2 = new \Mattsmithdev\Product(); $product2->setDescription("fridge"); $product2->setPrice(200); $product2->setCategory($category2); $productRepository->insert($product2); // Retrieve products from Database ❷ $products = $productRepository->findAll(); if (empty($products)) { print 'no products found in DB'; } else { foreach ($products as $product) { print "Product OBJECT = ID: {$product->getId()}, " . "Description: {$product->getDescription()} // " . "Category = {$product->getCategory()->getName()}\n"; } } </code></pre> <p>Listing 30-31:public/doctrine3.php 脚本,用于将相关记录插入数据库</p> <p>我们创建了两个 Category 对象,分别代表 HARDWARE 和 APPLIANCES,然后通过引导脚本中的 Doctrine EntityManager 对象将它们存储到数据库中。请注意,我们对每个 Category 对象单独调用了 persist() 方法,然后在 ❶ 调用了 flush() 方法;flush() 将批量处理所有已经排队的操作,例如通过 persist() 方法添加的操作。接下来,我们使用 ProductRepository 类创建并插入两个 Product 对象到数据库中,每个类别一个。然后,我们使用 ProductRepository 类的 findAll() 方法 ❷ 从数据库中检索所有产品的数组。如果该数组不为空,我们遍历数组并打印每个产品。以下是运行此脚本时的输出:</p> <pre><code>Product OBJECT = ID: 1, Description: small hammer // Category = HARDWARE Product OBJECT = ID: 2, Description: fridge // Category = APPLIANCES </code></pre> <p>每个产品都与其关联的类别一起展示。通过在 Product 模型类中加入一点元数据(在类别属性之前添加的 Doctrine ManyToOne 属性),我们已经创建了一个完整的外键声明和存储映射数据库。</p> <p>总的来说,虽然从我简单的 ORM 库切换到 Doctrine 增加了代码的复杂性,比如需要引导脚本和在模型类中添加元数据标签,但 Doctrine 带来了额外的好处,如更高的灵活性和对外键关系的支持。使用像 Doctrine 这样流行的 ORM 库进行项目开发,还意味着你与之协作的开发人员更有可能已经熟悉其操作,这可以节省代码开发和维护的时间。像 Doctrine 这样的 ORM 库的另一个优点是,它们允许你无缝地从一个数据库管理系统(DBMS)切换到另一个(比如从 MySQL 切换到 PostgreSQL),而无需更改核心的 web 应用程序代码。唯一的缺点可能是初学时学习该库所需要的努力,或者由于额外的抽象层带来的性能下降。但在许多情况下,这些优点将超过任何轻微的性能下降。</p> <h3 id="摘要-2">摘要</h3> <p>在本章中,我们使用了我的 pdo-crud-for-free-repositories 库来探索 ORM 库的基础,看到它们如何通过消除为 CRUD 应用编写大量重复代码的需求来简化与数据库交互的过程。当我们转向 Doctrine ORM 库时,我们看到这个更强大且功能完整的库增加了诸如更大的灵活性和支持模型类及其相应数据库表之间外键关联等优势。</p> <p>本章还概述了 Web 应用安全中的重要实践。在之前的章节中,我们已经使用了预处理 SQL 语句,这有助于防止 SQL 注入攻击。现在,我们添加了存储和验证哈希密码的功能,这样我们就不需要在数据库中存储明文密码了。我们还强调了将数据库凭据保存在单独的文件中的重要性,这样它们就不会被发布或归档并意外泄露。</p> <h3 id="练习-27">练习</h3> <ol> <li>我创建了一个公开共享的示例项目,帮助探索 pdo-crud-for-free-repositories 库。该项目使用 PHP 模板(不是 Twig)展示了如何使用 ORM 库来处理 Movie 模型类及其关联的 MovieRepository 类。要查看该项目,请执行以下操作:</li> </ol> <p>a. 在命令行中输入以下内容,以基于我发布的项目模板创建一个名为 <em>demo1</em> 的新项目:</p> <pre><code>$ **composer create-project mattsmithdev/pdo-repo-project demo1** </code></pre> <p>b. 在创建的 <em>demo1</em> 目录中,编辑 <em>.env</em> 文件中的 MySQL 凭据,使其与你计算机的设置相匹配。</p> <p>c. 运行 <em>db/migrateAndLoadFixtures.php</em> 中的数据库设置脚本。</p> <p>d. 启动一个 Web 服务器,访问主页和电影列表页面。</p> <p>e. 检查 Movie 模型类和 Application 类中的 listMovies() 方法。</p> <ol start="2"> <li>使用 pdo-crud-for-free-repositories 库为 Book 对象创建一个 MySQL CRUD Web 应用程序,Book 对象包含以下属性:</li> </ol> <p>id (整数),自动递增的主键</p> <p>title (字符串)</p> <p>author (字符串)</p> <p>price (浮动)</p> <p>你可以从零开始创建一个新项目,扩展上一练习中的演示项目,或者修改你在上一章第 2 个练习中的工作。你可能会觉得修改 Listing 30-12 中的数据库模式创建和初始数据脚本有帮助,或者如果你使用的是演示项目,你可以修改 <em>db/migrateAndLoadFixtures.php</em> 中的数据库设置脚本。</p> <ol start="3"> <li>Web 应用安全是一个庞大的话题(是整本书的内容),在这里详尽地讨论是不可能的。通过探索以下资源,了解更多关于 PHP 安全最佳实践的信息:</li> </ol> <p>Paragon Initiative Enterprises PHP 安全指南,* <a href="https://paragonie.com/blog/2017/12/2018-guide-building-secure-php-software" target="_blank"><code>paragonie.com/blog/2017/12/2018-guide-building-secure-php-software</code></a> *</p> <p>开放式 Web 应用安全项目,* <a href="https://owasp.org" target="_blank"><code>owasp.org</code></a> *</p> <p>PHP The Right Way 的安全章节,由 Josh Lockhart (codeguy)编写,* <a href="https://phptherightway.com/#security" target="_blank"><code>phptherightway.com/#security</code></a> *</p> <h2 id="第三十一章31-处理日期和时间">第三十一章:31 处理日期和时间</h2> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>网络应用程序以多种方式使用日期和时间:如维护日历、记录发票创建时间、记录消息发送时间等等。本章将探讨 PHP 内置的日期和时间存储及处理资源,包括处理时区和夏令时等复杂性的方法。我们还将了解如何在 PHP 和像 MySQL 这样的数据库系统之间传递日期时间信息,后者有自己独立的存储方式。</p> <h3 id="iso-8601-标准">ISO 8601 标准</h3> <p>各国在日常生活中有不同的日期和时间表示习惯。例如,美国人写 11/2 表示 11 月 2 日,而爱尔兰和英国则写作 2/11 表示该日期。同样,有些国家使用 24 小时制,而有些国家则使用 12 小时制,并加上 AM 和 PM 标识。计算机程序在存储和处理日期与时间时不能容忍这种歧义,因此达成一致的标准非常重要。如今,几乎所有计算机领域的人都使用<em>ISO 8601</em>定义的日期时间格式,这一标准最初发布于 1988 年,之后经过了多次更新。</p> <p>ISO 8601 的两个关键原则是:日期和时间用数字表示,并且它们按从最重要到最不重要的顺序排列。因此,日期从年份开始,然后是月份,再到日期。例如,1968 年 11 月 22 日通常写作 1968-11-22,年份用四位数表示,月份和日期用两位数表示,如果需要,则会加上前导零(例如,3 月应该写作 03 而不是 3)。遵循相同的原则,时间按小时、分钟、秒的顺序书写。例如,上午 9:05:30 就是 9 点 5 分 30 秒。若需要表示秒的小数部分,可以加上小数点和更多数字,所以 09:05:30.01 表示比 09:05:30 多出百分之一秒。该标准使用 24 小时制,因此晚上 11 点会写作 23:00:00。</p> <blockquote> <p>注意</p> </blockquote> <p><em>PHP 的创造者,拉斯穆斯·勒多夫(Rasmus Lerdorf),于 1968 年 11 月 22 日出生。网络上并没有提到他出生的具体时间,因此我为本章的示例编造了 9:05</em> <em>AM</em> <em>和 30 秒。</em></p> <p>这些示例展示了 ISO 8601 日期和时间格式的<em>扩展版本</em>,它在日期字段之间加上了破折号,在时间字段之间加上了冒号,以提高人类可读性。计算机内部可能不会使用这些分隔符,但建议在向人类展示日期或时间时始终使用它们,因为像 1968-11-22 这样的格式比 19681122 更容易为人理解。</p> <p>在组合日期和时间时,ISO 8601 规范要求日期和时间部分之间使用大写字母 T。因此,1968 年 11 月 22 日上午 9:05:30 应写作 1968-11-22T09:05:30。值得注意的是,原始的 ISO 8601 标准允许在日期和时间之间使用空格而不是 T,因此许多计算机实现也会接受 1968-11-22 09:05:30 作为有效格式。</p> <blockquote> <p>注意</p> </blockquote> <p><em>在大多数编程语言中都有一个概念是</em> 日期时间<em>。因此,尽管我们人类把日期和时间看作分开处理,或者可能会说到一个</em> 日期和时间<em>,但在计算机编程中,我们通常使用存储日期和时间数据的对象。如果我们只关心日期,在代码中就忽略时间部分,通常将时间部分默认设置为一天的起始时间(</em>00:00:00<em>)。如果我们只关心时间,则忽略日期部分,通常将日期部分默认为当前日期。</em></p> <p>可以将时区字符或时区偏移量添加到日期时间字符串的末尾;我们将在“格式化日期时间信息”部分(见 第 634 页)讨论这一操作。ISO 8601 还定义了其他日期和时间组件(例如,周数),但我描述的这些组件涵盖了 99%的日期和时间格式化需求,适用于在 PHP 程序中创建和处理时间数据。</p> <h3 id="创建日期和时间">创建日期和时间</h3> <p>PHP 中用于处理单独日期和时间的基础类是 DateTimeImmutable。如果你在创建 DateTimeImmutable 对象时没有向构造函数提供参数,则新对象会默认使用创建时刻的当前日期和时间,并根据计算机系统的本地时区设置来确定时间。示例 31-1 展示了如何实例化此类。</p> <pre><code><?php $today = new DateTimeImmutable(); var_dump($today); </code></pre> <p>示例 31-1:为当前日期和时间创建一个 DateTimeImmutable 对象</p> <p>我们创建一个没有任何参数的 DateTimeImmutable 对象,并使用 var_dump() 函数输出它的值。如果你在命令行中执行这个脚本,你应该看到类似下面的输出:</p> <pre><code>object(DateTimeImmutable)#1 (3) { ["date"]=> string(26) "1968-11-22 09:05:30.000000" ["timezone_type"]=> int(3) ["timezone"]=> string(3) "Europe/Dublin" } </code></pre> <p><code>var_dump()</code> 函数返回对象的内部日期值,以日期时间字符串的形式输出。请注意,这个字符串输出在日期和时间之间使用空格而不是“T”,以便提高人类可读性,并且时间部分使用六个小数位来精确到微秒(百万分之一秒)。对象的其他属性与时区有关(例如,我在爱尔兰都柏林),我们将在“时区”部分(见 第 641 页)详细讨论。</p> <blockquote> <p>注意</p> </blockquote> <p><em>要获取当前日期和时间,你也可以使用</em> new DateTimeImmutable('now')<em>。</em> 'now' <em>参数如果你需要提供第二个参数,指定不同于默认时区的时间,这时非常有用。</em></p> <p>要创建一个 DateTimeImmutable 对象来存储另一个(非当前的)日期和时间,可以将包含所需日期和时间的字符串传递给构造函数,像这样:</p> <pre><code>$rasmusBirthdate = new DateTimeImmutable('1968-11-22T09:05:30'); </code></pre> <p>这将创建一个新的 DateTimeImmutable 对象,表示日期 1968 年 11 月 22 日,时间为 9:05:30。</p> <h4 id="格式化日期时间信息">格式化日期时间信息</h4> <p>DateTimeImmutable 类的 format() 方法使得可以以不同方式调整日期和时间的输出格式。例如,Listing 31-2 展示了如何将日期时间字符串格式化为 ISO 8601 标准。</p> <pre><code><?php $now = new DateTimeImmutable(); $atomDateString = $now->format(DateTimeInterface::ATOM); print 'now (ISO-8601): ' . $atomDateString . PHP_EOL; </code></pre> <p>Listing 31-2:格式化一个 DateTimeImmutable 对象常量</p> <p>我们实例化 $now 作为一个默认的 DateTimeImmutable 对象,然后调用它的 format() 方法,传入 DateTimeInterface 的 ATOM 常量(DateTimeImmutable 类是此接口的实现)。这个常量表示日期和时间应根据 ISO 8601 标准进行格式化。format() 方法返回一个字符串,我们将其存储在 $atomDateString 中并打印。输出应该类似如下:</p> <pre><code>now (ISO-8601): 1968-11-22T09:05:30 + 00:00 </code></pre> <p>注意,输出遵循 ISO 8601 格式,包括日期和时间之间的 T。结尾的 +00:00 与时区有关,我们稍后会讨论。</p> <p>format() 方法也可以接受一个字符串来指定自定义格式。这允许你创建更易读的日期时间输出。例如,你可以告诉 format() 拼出月份的名称,包含星期几,转换为 12 小时制,给日期数字添加后缀(如 <em>1st</em>、<em>2nd</em> 或 <em>23rd</em>),等等。Listing 31-3 展示了如何实现这一点。</p> <pre><code><?php $now = new DateTimeImmutable(); $formattedDateString = $now-> format('l \t\h\e jS \o\f F Y \a\t i \m\i\n\s \p\a\s\t ga'); print 'now (nice format): ' . $formattedDateString . PHP_EOL; </code></pre> <p>Listing 31-3:自定义日期时间输出字符串格式</p> <p>这一次,我们向 format() 提供一个字符串,定义日期时间信息的自定义格式。这个字符串使用字母代码,如 l、j 和 S,来代表日期和时间的各个部分。下面是 Listing 31-3 中字母的含义:</p> <p>l   星期几的全名(如星期一、星期二等)</p> <p>j   月份的日期,以整数形式表示,不带前导空格(如 1、4、20、31 等)</p> <p>S   表示月份日期的两字母后缀(<em>st</em> 表示 1st,<em>nd</em> 表示 2nd,依此类推)</p> <p>F   月份的全名(如一月、五月等)</p> <p>Y   四位数的年份(如 2000、2019、2025 等)</p> <p>i   带前导零的分钟数(00 到 59)</p> <p>g   12 小时制的小时数,不带前导零(1 到 12)</p> <p>a   适当的 AM 或 PM 缩写</p> <p>每个字符的大小写非常重要,因为不同的字符代表不同的值,具体取决于它们是大写还是小写。例如,小写的 d 表示月份的日期(如果需要,前面会有零以确保两位数),而大写的 D 是星期几的三字母缩写(如 Mon、Tue 等)。</p> <blockquote> <p>注意</p> </blockquote> <p><em>PHP 文档提供了所有这些特殊格式字符的完整列表,详见</em> <a href="https://www.php.net/manual/en/datetime.format.php" target="_blank"><code>www.php.net/manual/en/datetime.format.php</code></a><em>。</em></p> <p>任何希望在格式化字符串中原样包含的文本都需要逐字符转义(在前面加上反斜杠)。因此,示例 31-3 中 format() 方法的字符串参数包含了像 \t\h\e(单词 <em>the</em>)和 \o\f(单词 <em>of</em>)这样的字符序列。示例 31-3 应输出类似以下内容:</p> <pre><code>now (nice format): Friday the 22nd of November 1968 at 05 mins past 9am </code></pre> <p>你还可以使用字母代码与 createFromFormat() 静态方法,根据格式化字符串创建新的 DateTimeImmutable 对象。参考这个例子:</p> <pre><code>$date = DateTimeImmutable::createFromFormat('j-M-Y', '15-Feb-2009'); </code></pre> <p>createFromFormat() 方法接受两个参数。第一个是使用我们讨论过的字母代码构建的格式化字符串。第二个是一个遵循指定格式的字符串,为新的 DateTimeImmutable 对象设置值。</p> <h4 id="使用-datetimeimmutable-与-datetime">使用 DateTimeImmutable 与 DateTime</h4> <p>我们的重点是 DateTimeImmutable 类,但 PHP 还提供了类似的 DateTime 类。唯一的区别(但至关重要)是,DateTimeImmutable 对象一旦创建,其包含的值不会发生变化,而 DateTime 对象的值可以被更新。一个后果是,可能会更改日期或时间的 DateTimeImmutable 方法将返回一个新的 DateTimeImmutable 对象,而不是修改原始对象的值。</p> <p>尽可能使用 DateTimeImmutable 而非 DateTime,以避免对象发生意外变化并返回对其自身的引用。要了解原因,请参见 示例 31-4。</p> <pre><code><?php $today = new DateTime(); print 'today (before modify) = ' . $today->format('Y-m-d') . PHP_EOL; $tomorrow = $today->modify('+1 day'); print 'today = ' . $today->format('Y-m-d') . PHP_EOL; print 'tomorrow = ' . $tomorrow->format('Y-m-d') . PHP_EOL; </code></pre> <p>示例 31-4:创建一个 DateTime 对象并修改它</p> <p>我们创建了一个新的 DateTime(而不是 DateTimeImmutable)对象,命名为 $today,并打印其值(以年-月-日格式)。然后,我们调用该对象的 modify() 方法将日期向前推一天,并将结果存储在 $tomorrow 变量中。然而,调用 modify() 方法并没有为第二天创建一个新的 DateTime 对象,而是更改了原始的 DateTime 对象并返回了对该对象的引用。为确认这一点,我们打印了 $today 和 $tomorrow。输出结果大致如下:</p> <pre><code>today (before modify) = 1968-11-22 today = 1968-11-23 tomorrow = 1968-11-23 </code></pre> <p>最初,<span class="math inline">\(today 的值表示 11 月 22 日。调用 modify() 方法后,\)</span>today 和 $tomorrow 都表示 11 月 23 日。两个变量都引用同一个 DateTime 对象,该对象的值被 modify() 方法更改。</p> <p>虽然在某些情况下,具有可变日期时间信息可能是可取的,但在使用 DateTime 而非 DateTimeImmutable 之前,务必确认这是你想要的。这个例子也展示了不恰当的变量命名如何使代码难以理解和调试。如果我们故意创建了一个可变的 DateTime 对象并对其日期进行更改,那么该对象的命名就不应与特定的日期(如 $today 或 $tomorrow)相关,因为在某些时候,变量的名称将不再准确地反映其值。</p> <p>为了真正看到这两个 PHP 日期时间类之间的区别,尝试将 示例 31-4 中的类从 DateTime 更改为 DateTimeImmutable,然后再次运行脚本。以下是输出结果:</p> <pre><code>today (before modify) = 1968-11-22 today = 1968-11-22 tomorrow = 1968-11-23 </code></pre> <p>这次,<span class="math inline">\(today 的值保持不变,而\)</span>tomorrow 则提前一天。这是因为 DateTimeImmutable 对象的 modify()方法创建并返回一个全新的 DateTimeImmutable 对象,修改了值,但原始对象保持不变。因此,<span class="math inline">\(today 和\)</span>tomorrow 指代的是两个不同的 DateTimeImmutable 对象,两个变量名都是合理的,因为它们始终正确地指代它们所引用的对象的值。</p> <h3 id="操作日期和时间">操作日期和时间</h3> <p>PHP 的 DateTimeImmutable 类提供了多个方法,这些方法可以基于现有对象的值创建一个新的 DateTimeImmutable 对象。这些方法使得程序可以对日期和时间信息进行操作。</p> <p>你已经看到了一个示例,即示例 31-4 中的 modify()方法。这个方法接受一个字符串参数,表示如何相对于当前 DateTimeImmutable 对象的值设置新的日期或时间。例如,字符串“yesterday”和“tomorrow”会返回前一天或次日的午夜(时间为 00:00:00);“noon”和“midnight”字符串会返回当天的中午(12:00:00)或午夜;“本月的第一天”或“本月的最后一天”会根据需要更改日期,同时保持时间不变。这些修饰符字符串可以组合使用,例如,“明天中午”和“本月的第一天午夜”都是有效的。</p> <p>其他修饰符字符串使用+或-后跟数量和时间单位,以更精细地控制新的 DateTimeImmutable 对象的值,例如“+1 天”、“-2 小时”或“+30 秒”。这些修饰符也可以组合成更长的字符串,例如“+1 天+30 秒”。示例 31-5 展示了这些修饰符的实际应用。</p> <pre><code><?php function showModify(string $modifier): void { print PHP_EOL. $modifier . PHP_EOL; $date1 = new DateTimeImmutable(); $date2 = $date1->modify($modifier); print 'date1 = ' . $date1->format(DateTimeInterface::ATOM) . PHP_EOL; print 'date2 = ' . $date2->format(DateTimeInterface::ATOM) . PHP_EOL; } showModify('first day of this month'); showModify('+1 day'); showModify('+30 seconds'); showModify('-10 seconds'); showModify('+1 month +3 days +1 seconds'); </code></pre> <p>示例 31-5:将相对日期时间字符串传递给 modify()方法</p> <p>为了帮助展示修改前后的日期时间,我们首先声明一个 showModify()函数,它接受一个修饰符字符串作为参数。该函数打印修饰符字符串本身,创建一个当前时间的 DateTimeImmutable 对象,并将该字符串传递给 modify()方法,以创建另一个修改过的 DateTimeImmutable 对象。然后它以 ISO 8601 格式打印两个对象。接着,我们通过一系列 showModify()调用来演示不同的修饰符字符串。输出应该像这样:</p> <pre><code>first day of this month date1 = 1968-11-22T09:05:30 + 00:00 ❶ date2 = 1968-11-01T09:05:30 + 00:00 +1 day date1 = 1968-11-22T09:05:30 + 00:00 ❷ date2 = 1968-11-23T09:05:30 + 00:00 +30 seconds date1 = 1968-11-22T09:05:30 + 00:00 date2 = 1968-11-22T09:06:00 + 00:00 -10 seconds date1 = 1968-11-22T09:05:30 + 00:00 date2 = 1968-11-22T09:05:20 + 00:00 +1 month +3 days +1 seconds date1 = 1968-11-22T09:05:30 + 00:00 date2 = 1968-12-25T09:05:31 + 00:00 </code></pre> <p><span class="math inline">\(date1 的值对于每次函数调用都是相同的,时间为 1968 年 11 月 22 日 9 点 05 分 30 秒。使用“本月的第一天”修饰符时,\)</span>date2 的日期部分变为 11 月 1 日,但时间部分保持不变❶。使用“+1 天”时,日期部分移动到 11 月 23 日,但时间部分同样保持不变❷。“+30 秒”和“-10 秒”字符串分别使时间部分前后移动,而不改变日期部分,类似地,“+1 个月+3 天+1 秒”则同时更改日期和时间。</p> <p>如果你向 modify() 方法提供一个日期,该日期将替换新创建的 DateTimeImmutable 对象中的原始日期,而时间保持不变。例如,将字符串 '2000-12-31' 应用于一个包含 1968-11-22T09:05:30 的对象,将导致创建一个新的对象,日期为 2000-12-31 T09:05:30,时间保持不变,只是日期变为 2000 年 12 月 31 日。同样,提供一个时间会导致一个新的对象,其日期不变,但时间更新为新值。</p> <p>也就是说,仅修改 DateTimeImmutable 对象的日期或时间组件可以通过使用 setDate() 和 setTime() 方法更轻松地实现。例如,如果 $date1 是一个 DateTimeImmutable 对象,以下两个语句会创建相同的新的对象:</p> <pre><code>$date2 = $date1->modify('2000-12-31'); $date2 = $date1->setDate(2000, 12, 31); </code></pre> <p>请注意,setDate() 方法接受三个独立的整数作为参数,而不是一个字符串。这些整数分别表示期望的年份、月份和日期。类似地,setTime() 方法接受四个整数,表示新的小时、分钟、秒和微秒。后两个默认值为 0。</p> <h4 id="使用日期时间间隔">使用日期时间间隔</h4> <p>DateInterval 类表示一段时间跨度,而不是特定的日期时间。这个类提供了另一种处理日期时间信息以及思考不同日期时间之间关系的有用方法。例如,DateTimeImmutable 类的 add() 和 sub() 方法接受一个 DateInterval 对象,并返回一个新的 DateTimeImmutable 对象,该对象在时间上根据指定的时间间隔向前或向后偏移。清单 31-6 说明了它是如何工作的。</p> <pre><code><?php $interval1 = DateInterval::createFromDateString('30 seconds'); $interval2 = DateInterval::createFromDateString('1 day'); $date1 = new DateTimeImmutable(); $date2 = $date1->add($interval1); $date3 = $date1->sub($interval2); print '$date1 = ' . $date1->format(DateTimeInterface::ATOM) . PHP_EOL; print '$date2 = ' . $date2->format(DateTimeInterface::ATOM) . PHP_EOL; print '$date3 = ' . $date3->format(DateTimeInterface::ATOM) . PHP_EOL; </code></pre> <p>清单 31-6:创建由时间间隔偏移的新的 DateTimeImmutable 对象</p> <p>首先,我们使用 createFromDateString() 静态方法创建两个 DateInterval 对象。通过此方法,我们可以使用像 '30 seconds' 或 '1 day' 这样的字符串来表示期望的时间间隔。接着,我们创建一个表示当前日期和时间的 DateTimeImmutable 对象,然后调用它的 add() 和 sub() 方法,传入 DateInterval 对象。这会创建两个新的 DateTimeImmutable 对象,根据给定的时间间隔进行偏移。输出中的三个日期时间字符串应该类似于这样:</p> <pre><code>$date1 = 1968-11-22T09:05:30 + 00:00 $date2 = 1968-11-22T09:06:00 + 00:00 $date3 = 1968-11-21T09:05:30 + 00:00 </code></pre> <p>请注意,$date2 拥有相同的日期,但其时间是 09:06:00,比 <span class="math inline">\(date1 晚了 30 秒。与此同时,\)</span>date3 拥有相同的时间,但日期早了 1 天:是 11 月 21 日,而不是 11 月 22 日。注意,我们可以创建负的 DateInterval 对象,也可以创建正的。例如,我们可以通过使用字符串 '-1 day' 来创建一个 DateInterval。</p> <p>创建 <code>DateInterval</code> 对象的一个更常见方法是使用 <code>DateTimeImmutable</code> 类的 <code>diff()</code> 方法。给定一个 <code>DateTimeImmutable</code> 对象,调用它的 <code>diff()</code> 方法并传入另一个 <code>DateTimeImmutable</code> 对象,方法会返回一个表示这两个日期时间差异的 <code>DateInterval</code> 对象。当用户提供了开始日期和结束日期,并且需要根据这两个日期间的间隔大小进行某些逻辑或计算时,这个方法非常有用。例如,酒店预订网页应用程序可能会根据所选的开始和结束日期之间的天数来计算住宿费用。清单 31-7 展示了这个机制是如何工作的。</p> <pre><code><?php $date1 = new DateTimeImmutable('1968-11-22'); $date2 = new DateTimeImmutable('1968-11-16'); $interval = $date1->diff($date2); print '$interval = ' . $interval-> format('%m months, %d days, %i minutes, %s seconds'); </code></pre> <p>清单 31-7:获取两个 <code>DateTimeImmutable</code> 对象之间的间隔</p> <p>我们创建了两个 <code>DateTimeImmutable</code> 对象,<code>$date1</code> 和 <code>$date2</code>,每个对象仅指定了日期,这两个对象相隔六天。然后我们调用 <code>$date1</code> 上的 <code>diff()</code> 方法,并将 <code>$date2</code> 作为参数传入。这会生成一个 <code>DateInterval</code> 对象,保存两个日期之间的差异,我们对其进行格式化并打印。结果如下:</p> <pre><code>$interval = 0 months, 6 days, 0 minutes, 0 seconds </code></pre> <p>如预期的那样,<code>DateInterval</code> 对象表示两个日期之间相隔四天。注意,<code>DateInterval</code> 类的 <code>format()</code> 方法与格式化实际日期的方式不同。它接收一个字符串,使用百分号(%)表示 <code>DateInterval</code> 对象中应该插入的值的位置。例如,%d 会被输出字符串中的天数(6)替代。</p> <h4 id="按规律间隔循环">按规律间隔循环</h4> <p>对于显示一系列日期时间值、更新日历或生成历史报告,通常需要在两个日期之间按规律间隔进行循环。PHP 的 <code>DatePeriod</code> 类可以实现这一点。该类的对象可以像数组一样使用 <code>foreach</code> 循环进行迭代。每次迭代都会产生一个新的 <code>DateTimeImmutable</code> 对象,所有对象在时间上均匀分布。</p> <p>要创建 <code>DatePeriod</code> 对象,必须提供起始和结束日期,以及定义迭代速率的 <code>DateInterval</code> 对象。清单 31-8 展示了如何使用此类自动列出一个月的前七天。</p> <pre><code><?php $today = new DateTimeImmutable(); print 'today: ' . $today->format('l \t\h\e jS \o\f F Y') . PHP_EOL; $firstOfMonth = $today->modify('first day of this month'); $oneWeekLater = $firstOfMonth->modify('+1 week'); $interval = DateInterval::createFromDateString("1 day"); ❶ $period = new DatePeriod($firstOfMonth, $interval, $oneWeekLater); print '--- first 7 days of current month ---'. PHP_EOL; ❷ foreach ($period as $date) { print $date->format('l \t\h\e jS \o\f F Y') . PHP_EOL; } </code></pre> <p>清单 31-8:迭代 <code>DatePeriod</code> 对象</p> <p>首先,我们创建并打印当前日期的 <code>DateTimeImmutable</code> 对象(<code>$today</code>)。然后,我们使用 <code>modify()</code> 方法创建两个新的 <code>DateTimeImmutable</code> 对象,<code>$firstOfMonth</code> 表示当前月的第一天,<code>$oneWeekLater</code> 表示一周后的日期。这些将作为迭代的起始和结束点。接着,我们创建一个一天的 <code>DateInterval</code> 对象,将它与起始和结束日期一起,用来创建一个 <code>DatePeriod</code> 对象 ❶。参数的顺序是起始日期、间隔和结束日期。最后,我们运行一个 <code>foreach</code> 循环来迭代 <code>DatePeriod</code> 对象 ❷,并为每个日期打印格式化的字符串。输出应如下所示:</p> <pre><code>today: Friday the 22nd of November 1968 --- first 7 days of current month --- Friday the 1st of November 1968 Saturday the 2nd of November 1968 Sunday the 3rd of November 1968 Monday the 4th of November 1968 Tuesday the 5th of November 1968 Wednesday the 6th of November 1968 Thursday the 7th of November 1968 </code></pre> <p>基于 1968 年 11 月 22 日作为开始日期,脚本成功地循环并显示了该月的前七天。</p> <p>创建 DatePeriod 对象的另一种方式是给定开始日期、时间间隔和重复次数,而不是开始和结束日期。这个第三个参数不计算开始日期本身,因此,要列出一个月的前七天,重复次数应为 6。DatePeriod 构造函数还有一个可选参数,通过传递常量 DatePeriod::EXCLUDE_START_DATE 作为第四个参数来排除开始日期。</p> <blockquote> <p>注意</p> </blockquote> <p><em>目前,</em> DatePeriod <em>仅适用于正向的</em> DateInterval <em>对象,因此循环必须按时间向前推进。</em></p> <h3 id="时区">时区</h3> <p><em>时区</em>是一个地理区域,遵循相同的时间。如今,全球所有时区都相对于<em>协调世界时(UTC)</em>来定义。这是国际参考子午线(0°经度)上的时间,经过英国格林威治。例如,UTC +3 和 UTC –2 分别表示比 UTC 提前三小时和滞后两小时。UTC 也被昵称为<em>Zulu 时间</em>,<em>Zulu</em>是北约音标字母表中字母<em>Z</em>的标准代码词。(<em>Z</em>代表<em>零</em>。)表 31-1 列出了某些示例 UTC 偏移量及其相关的时区。</p> <p>表 31-1:示例时区</p> <table> <thead> <tr> <th>UTC 偏移量</th> <th>缩写</th> <th>常用名称</th> </tr> </thead> <tbody> <tr> <td>UTC +0</td> <td>GMT</td> <td>格林威治标准时间</td> </tr> <tr> <td>UTC +1</td> <td>BST</td> <td>英国夏令时</td> </tr> <tr> <td>UTC +1</td> <td>IST</td> <td>爱尔兰标准时间</td> </tr> <tr> <td>UTC +11</td> <td>AEDT</td> <td>澳大利亚东部夏令时(塔斯马尼亚是我双胞胎兄弟的居住地)</td> </tr> <tr> <td>UTC –5</td> <td>EST</td> <td>美国东部标准时间</td> </tr> <tr> <td>UTC +2</td> <td>CEST</td> <td>中欧夏令时</td> </tr> </tbody> </table> <p>你可以在<em>php.ini</em>配置文件中设置系统的默认时区。时区本身通过<em>时区标识符</em>来表示,这是一个包含区域(如美国、欧洲或太平洋)和位于目标时区的城市的字符串,中间用斜杠分隔。例如,清单 31-9 显示了我已经将我的系统设置为欧洲/都柏林时区。</p> <pre><code>--snip-- [Date] ; Defines the default timezone used by the date functions ; https://php.net/date.timezone date.timezone = Europe/Dublin --snip-- </code></pre> <p>清单 31-9:一个将默认时区设置为欧洲/都柏林的 php.ini 文件片段</p> <p>你可以使用 date.timezone 设置 PHP 引擎的时区标识符。要验证系统的时区是否已正确设置,可以使用 date_default_timezone_get()函数。这对于我来说返回的是欧洲/都柏林。</p> <blockquote> <p>注意</p> </blockquote> <p><em>你可以在 PHP 文档中找到接受的完整时区标识符列表,网址是</em> <a href="https://www.php.net/manual/en/timezones.php" target="_blank"><code>www.php.net/manual/en/timezones.php</code></a><em>。但是,除了</em> UTC<em>之外,避免使用“其他”区域列出的任何标识符。这些标识符仅用于向后兼容,未来可能会更改。</em></p> <p>当你在 PHP 脚本中创建一个 DateTimeImmutable 对象时,它默认使用你系统的时区。我更倾向于通过将一个 DateTimeZone 对象作为第二个参数传递给 DateTimeImmutable 构造函数来指定时区。另一种常见的方法是将 UTC 偏移量附加到 ISO 8601 字符串的末尾。例如,向字符串末尾添加 +03:00 表示该日期时间比 UTC 提前三个小时。示例 31-10 展示了这两种方法。</p> <pre><code><?php function prettyPrintDatetime(string $name, DateTimeImmutable $date) { print '---------' . $name . '---------' . PHP_EOL; print $date->format(DATE_ATOM) . ' ' . $date->getTimezone()->getName(). PHP_EOL . PHP_EOL; } $iceCreamDay = '2009-08-02'; $localDatetime = new DateTimeImmutable($iceCreamDay); ❶ $utcDatetime = new DateTimeImmutable($iceCreamDay, new DateTimeZone('UTC')); ❷ $londonDatetime = new DateTimeImmutable($iceCreamDay, new DateTimeZone('Europe/London')); $parisDatetime = new DateTimeImmutable($iceCreamDay, new DateTimeZone('Europe/Paris')); $hobartDatetime = new DateTimeImmutable($iceCreamDay, new DateTimeZone('Australia/Hobart')); $threeHoursAhead = new DateTimeImmutable('2000-01-01T10:00:00 + 03:00'); ❸ print 'local time zone = ' . date_default_timezone_get() . PHP_EOL; ❹ prettyPrintDatetime('local', $localDatetime); ❺ prettyPrintDatetime('UTC', $utcDatetime); prettyPrintDatetime('London', $londonDatetime); prettyPrintDatetime('Paris', $parisDatetime); prettyPrintDatetime('Hobart', $hobartDatetime); prettyPrintDatetime('+03', $threeHoursAhead); </code></pre> <p>示例 31-10:创建具有不同时区的 DateTimeImmutable 对象</p> <p>我们声明了一个 prettyPrintDatetime() 函数,用于漂亮地打印出 DateTimeImmutable 对象及其时区,并将作为 $name 参数传递的字符串标签一起打印。时区通过 DateTimeImmutable 对象的 getTimezone() 方法访问,该方法返回一个 DateTimeZone 对象。然后,我们必须调用 DateTimeZone 对象的 getName() 方法,该方法返回时区的名称作为字符串。</p> <p>接下来,我们声明了一系列不同时区的 DateTimeImmutable 对象,所有对象都指向 2009 年 8 月 2 日(2009-08-02),即美国的第一个国家冰淇淋三明治日。$localDatetime 对象 ❶ 持有根据系统默认时区(对于我来说是欧洲/都柏林,依据我的 <em>php.ini</em> 文件)设置的日期。由于我们没有指定时间,时间将默认为午夜。</p> <p><span class="math inline">\(utcDatetime 对象 ❷ 通过向 DateTimeImmutable 构造函数传递两个参数来设置为 UTC:\)</span>iceCreamDay 用于指定日期,DateTimeZone 对象设置为 'UTC' 用于指定时区。我们使用相同的技术为伦敦、巴黎和霍巴特的时间创建对象。$threeHoursAhead 对象 ❸ 是通过将 UTC 偏移量 +03:00 附加到传递给 DateTimeImmutable 构造函数的日期时间字符串 2000-01-01T10:00:00 来创建的,表示该时间比 UTC 提前三个小时。</p> <p>我们使用内置函数 date_default_timezone_get() ❹ 打印出计算机系统的时区设置。然后,我们将我们的 DateTimeImmutable 对象逐个传递给 prettyPrintDatetime() 函数 ❺。输出应该类似于下面的内容:</p> <pre><code>❶ local time zone = Europe/Dublin ---------local--------- 2009-08-02T00:00:00 + 01:00 Europe/Dublin ---------UTC--------- 2009-08-02T00:00:00 + 00:00 UTC ---------London--------- 2009-08-02T00:00:00 + 01:00 Europe/London ---------Paris--------- 2009-08-02T00:00:00 + 02:00 Europe/Paris ---------Hobart--------- 2009-08-02T00:00:00 + 10:00 Australia/Hobart ---------+03--------- ❷ 2000-01-01T10:00:00 + 03:00 +03:00 </code></pre> <p>第一个打印的数据是系统的时区设置,对于我来说是欧洲/都柏林 ❶。然后,当每个 DateTimeImmutable 对象被打印时,其时区信息通过两种方式出现在输出中:通过日期时间字符串末尾的 UTC 偏移量(例如,伦敦和都柏林是 +01:00,霍巴特是 +10:00),以及我们在 prettyPrintDatetime() 函数中提取的单独时区字符串。然而,请注意,当我们使用类似 +03:00 这样的通用 UTC 偏移量而不是更具体的时区标识符来指定时区时,DateTimeImmutable 对象会记录为这种方式 ❷。这是因为 PHP 不知道应该将该偏移量与哪个区域和城市相关联。例如,+03:00 可能是欧洲/莫斯科、亚洲/利雅得或非洲/摩加迪沙。</p> <h3 id="夏令时">夏令时</h3> <p>全球约四分之一的国家实行夏令时制度:春季时钟向前拨动一小时(“春季前进”),秋季时钟向后拨动一小时(“秋季后退”)。如果对象的时区标识符所指定的位置观察夏令时,PHP 的 DateTimeImmutable 对象会自动考虑这些变化。</p> <p>DateTimeImmutable 类的 format()方法有一个特殊值用于识别该对象是否处于夏令时:大写字母 I。调用 format('I')会返回 1(true),如果适用夏令时,或者返回 0(false),如果不适用。清单 31-11 显示了从清单 31-10 更新后的时区脚本,其中扩展了 prettyPrintDatetime()函数,以显示关于夏令时的额外信息。</p> <pre><code><?php function prettyPrintDatetime(string $name, DateTimeImmutable $date) { print '---------' . $name . '---------' . PHP_EOL; ❶ $isDaylightSaving = $date->format('I'); if ($isDaylightSaving) { $dstString = ' (daylight saving time = TRUE)'; } else { $dstString = ' (daylight saving time = FALSE)'; } print $date->format(DATE_ATOM) . ' ' . $date->getTimezone()->getName() . $dstString. PHP_EOL . PHP_EOL; } $iceCreamDay = '2009-08-02'; $localDatetime = new DateTimeImmutable($iceCreamDay); $utcDatetime = new DateTimeImmutable( $iceCreamDay, new DateTimeZone('UTC')); $londonDatetime = new DateTimeImmutable( $iceCreamDay, new DateTimeZone('Europe/London')); --snip-- </code></pre> <p>清单 31-11:更新后的 prettyPrintDatetime()函数,输出关于夏令时的消息</p> <p>我们在传递给 prettyPrintDatetime()函数的 DateTimeImmutable 对象上调用 format('I'),将结果 1 或 0 存储在$isDaylightSaving 变量中❶。然后,我们使用 if...else 语句,根据此变量创建一个关于夏令时的适当的真/假消息。所有的 DateTimeImmutable 对象都已经为国家冰淇淋三明治日创建,这个日期是北半球夏季的一个有用日期,用来演示不同时间区是否适用夏令时。执行此更新脚本时的输出如下:</p> <pre><code>local time zone = Europe/Dublin ---------local--------- 2009-08-02T00:00:00 + 01:00 Europe/Dublin (daylight saving time = FALSE) ---------UTC--------- 2009-08-02T00:00:00 + 00:00 UTC (daylight saving time = FALSE) ---------London--------- 2009-08-02T00:00:00 + 01:00 Europe/London (daylight saving time = TRUE) ---------Paris--------- 2009-08-02T00:00:00 + 02:00 Europe/Paris (daylight saving time = TRUE) ---------Hobart--------- 2009-08-02T00:00:00 + 10:00 Australia/Hobart (daylight saving time = FALSE) ---------+03--------- 2000-01-01T10:00:00 + 03:00 +03:00 (daylight saving time = FALSE) </code></pre> <p>夏令时从不适用于 UTC,因此 UTC 行显示为 FALSE。英国和法国从 3 月底开始实施夏令时,因此伦敦和巴黎都显示为 TRUE。</p> <p>奇怪的是,尽管爱尔兰的时钟也会向前拨动,但都柏林显示为 FALSE。这似乎是因为爱尔兰共和国的时区在法律上定义为冬季使用 GMT,夏季使用 IST(爱尔兰标准时间)。相比之下,英国和法国在夏令时生效时被定义为<em>夏季</em>时间,而不是<em>标准</em>时间(伦敦使用 BST,即英国夏令时,巴黎使用 CEST,即中欧夏令时)。因此,尽管欧洲/都柏林和欧洲/伦敦的时区在夏季的 UTC+01:00 偏移是正确的,一个被视为夏令时,另一个则不是,通过 format('I')方法来区分。</p> <p>许多计算机系统以<em>纪元</em>为基准来测量时间,纪元是一个固定的时间点,被视为时间 0。例如,Unix 系统(包括 macOS)使用<em>time_t</em>格式,通常称为<em>Unix 时间</em>,它表示自 1970 年 1 月 1 日(星期四,00:00:00)以来经过的秒数。表 31-2 显示了几个 Unix 时间戳及其对应的 ISO 8601 日期时间。注意,1970 年之前的时间戳表示为负值。</p> <p>表 31-2:示例 Unix 时间戳</p> <table> <thead> <tr> <th>日期</th> <th>时间戳</th> </tr> </thead> <tbody> <tr> <td>1969-12-31 23:59:00</td> <td>–60</td> </tr> <tr> <td>1970-01-01 00:00:00</td> <td>0</td> </tr> <tr> <td>1970-01-01 00:02:00</td> <td>120</td> </tr> <tr> <td>2009-08-02 00:00:00</td> <td>1249171200</td> </tr> </tbody> </table> <p>PHP 的内置 <code>time()</code> 函数返回当前的日期和时间,以 Unix 时间戳的形式表示。虽然现代 PHP 程序员通常使用 <code>DateTimeImmutable</code> 对象,但你可能会在旧代码或非面向对象编程的代码中遇到 <code>time()</code> 函数。因此,能够处理存储这些 Unix 时间戳的代码是很有用的。如果你有一个 <code>DateTimeImmutable</code> 对象,可以通过使用该对象的 <code>getTimestamp()</code> 方法获取其对应的 Unix 时间戳。清单 31-12 显示了一个脚本,用于创建对象并打印 表 31-2 中每一行的相应时间戳。</p> <pre><code><?php function print_timestamp(string $dateString): void { $date = new DateTimeImmutable($dateString); print $date->format('D, F j, Y g.i:s'); print ' / timestamp = ' . $date->getTimestamp() . PHP_EOL; } print_timestamp('1969-12-31T23:59:00'); print_timestamp('1970-01-01T00:00:00'); print_timestamp('1970-01-01T00:02:00'); print_timestamp('2009-08-02T00:00:00'); </code></pre> <p>清单 31-12:将 <code>DateTimeImmutable</code> 对象转换为 Unix 时间戳</p> <p>首先,我们声明一个 <code>print_timestamp()</code> 函数,传入一个日期时间字符串,创建一个 <code>DateTimeImmutable</code> 对象,并使用 <code>getTimestamp()</code> 方法打印出对应的时间戳(以及格式化过的、易于阅读的人类版日期时间)。然后,我们调用这个函数四次,每次处理 表 31-2 中的一行。结果如下:</p> <pre><code>Wed, December 31, 1969 11.59:00 / timestamp = -60 Thu, January 1, 1970 12.00:00 / timestamp = 0 Thu, January 1, 1970 12.02:00 / timestamp = 120 Sun, August 2, 2009 12.00:00 / timestamp = 1249171200 </code></pre> <p><code>getTimestamp()</code> 的逆操作是 <code>setTimeStamp()</code> 方法,它会创建一个与给定 Unix 时间戳相对应的新 <code>DateTimeImmutable</code> 对象,如下所示:</p> <pre><code>$datetime = (new DateTimeImmutable())->setTimeStamp($timestamp); </code></pre> <p>请注意 <code>(new DateTimeImmutable())</code> 周围的额外括号。这会创建一个新的默认 <code>DateTimeImmutable</code> 对象,我们接着用它来调用 <code>setTimeStamp()</code> 方法,传入 <code>$timestamp</code> 变量中的相关时间戳。这样就创建了另一个新的 <code>DateTimeImmutable</code> 对象,并与该时间戳相对应,我们将其存储在 <code>$datetime</code> 变量中。如果没有额外的括号,语句将如下所示,PHP 引擎将无法理解该语法:</p> <pre><code>// This will not work $datetime = new DateTimeImmutable()->setTimeStamp($timestamp); </code></pre> <p>在处理使用 Unix 时间戳的代码时,我建议重构代码,使用 <code>setTimeStamp()</code> 方法创建一个等效的 <code>DateTimeImmutable</code> 对象,然后使用该对象处理所有逻辑。之后,你可以使用 <code>getTimeStamp()</code> 方法将最终结果转换回时间戳。或者,更好的做法是,将所有代码重构为使用 <code>DateTimeImmutable</code> 对象,并完全不涉及时间戳。</p> <blockquote> <p>注意</p> </blockquote> <p><em>Unix 时间戳最初是使用 32 位整数存储的,这是一种短视的方案。能够正确存储在原始 32 位格式中的最后一个时间戳是</em> +2147483647 <em>(2**³¹</em> <em>– 1),即 2038 年 1 月 19 日凌晨 3:14 和 7 秒。再往前推进一秒将会导致溢出错误,时间戳为</em> -2147483648<em>,即 1901 年 12 月 13 日晚上 8:45 和 52 秒。幸运的是,大多数系统已经升级为使用 64 位存储 Unix 时间戳,这使得溢出错误推迟了 2920 亿年。</em></p> <h3 id="web-应用中的日期时间信息">Web 应用中的日期时间信息</h3> <p>在本节中,我们将构建一个简单的 Web 应用程序,总结我们迄今为止在 PHP 中处理日期时间信息的知识。该应用程序将提供一个表单,供用户输入地址和日期,并显示该日期和地点的日出和日落时间,以及通过 DateInterval 类确定的该天白昼的总时长。</p> <p>实际上,PHP 有一个内置的 date_sun_info()函数,用于报告给定日期和位置的日出和日落时间(以及其他信息)。然而,该函数需要将位置指定为纬度和经度坐标,而不是街道地址。因此,我们的应用程序还将演示如何从外部 API 获取数据,因为我们将依赖 OpenStreetMap 将地址转换为坐标。我们将使用一个流行的开源 PHP 库 Guzzle 与 OpenStreetMap 进行通信。Guzzle 提供了一个 HTTP 客户端,允许代码发送和接收 HTTP 请求。这使得将 PHP Web 应用程序与外部 Web 服务集成变得非常简单。</p> <p>图 31-1 展示了我们将要创建的页面的截图。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figure31-1.jpg" alt="" loading="lazy"></p> <p>图 31-1:日出日落 Web 应用程序的截图</p> <p>主页允许用户提交一个街道地址和日期。这样就会跳转到一个结果页面,显示计算出来的信息。结果页面还会有一个链接,用于在 OpenStreetMap 中查看指定位置。</p> <p>要开始,请创建一个新的项目,并包括常规的<em>composer.json</em>文件和<em>public/index.php</em>脚本,该脚本会创建一个 Application 对象并调用其 run()方法。然后在命令行中输入<code>composer require guzzlehttp/guzzle</code>,将第三方 Guzzle 库添加到项目中。由于我们正在运行 Composer,并且已经为我们的命名空间创建了<em>composer.json</em>文件,Composer 此时还会为我们的命名空间类生成自动加载器。</p> <h4 id="application-类-1">Application 类</h4> <p>现在我们将在<em>src/Application.php</em>中声明我们网站的 Application 类。该类将处理请求,通过显示主页或处理来自应用程序网页表单的数据来响应请求。列表 31-13 显示了相关代码。</p> <pre><code><?php namespace Mattsmithdev; class Application { public function run(): void { $action = filter_input(INPUT_GET, 'action'); switch ($action) { case 'processForm': ❶ $address = filter_input(INPUT_POST, 'address'); $date = filter_input(INPUT_POST, 'date'); if (empty($address) || empty($date)) {❷ $this->homepage('you must enter a valid address and a date'); } else { $this->processForm($date, $address); } break; default: ❸ $this->homepage(); } } private function homepage(string $errorMessage = ''): void ❹ { require_once __DIR__ . '/../templates/homepage.php'; } private function processForm(string $dateString, string $address): void { try { $streetMapper = new StreetMap(); $latLongArray = $streetMapper->getOpenStreetMapData($address); ❺ $latitude = $latLongArray['latitude']; $longitude = $latLongArray['longitude']; $date = new \DateTimeImmutable($dateString); $sunData = new SunData($date, $latitude, $longitude); ❻ $sunrise = $sunData->getSunrise()->format('g:ia'); $sunset = $sunData->getSunset()->format('g:ia'); $hoursDaylight = $sunData->getHoursDaylight()->format("%h hours %i minutes"); ❼ require_once __DIR__ . '/../templates/result.php'; ❽ } catch (\Exception) {❾ print 'sorry - an error occurred trying to retrieve data from Open Street Map'; print '<br>'; print '<a href="/">home</a>'; } } } </code></pre> <p>列表 31-13:声明两个路由的 Application 类</p> <p>和往常一样,类的 run()方法使用一个 switch 语句来处理传入的请求。首先,我们声明当$action 为'processForm'时的情况❶。对于这种情况,我们尝试从请求的 POST 数据中提取地址和日期变量。如果其中任何一个为空❷,我们调用 homepage()方法,并传入错误信息。否则,我们将地址和日期传递给 processForm()方法。</p> <p>switch 语句中的唯一其他情况是默认路由 ❸,它仅调用 homepage() 方法且不传入任何参数。homepage() 方法本身 ❹ 使用 require_once 显示 <em>homepage.php</em> 模板(我们为了简单起见使用普通的 PHP 文件作为模板,而不是 Twig)。该方法有一个名为 $errorMessage 的参数,默认值为空字符串。这个变量将在模板的作用域内打印出来。</p> <p>该类的核心是 processForm() 方法,它接受提交的地址和日期字符串,并利用它们获取日出和日落时间,以及白昼的总时长。我们首先需要将地址转换为纬度和经度坐标。为此,我们创建一个新的 StreetMap 对象(稍后我们会查看这个类),并调用它的 getOpenStreetMapData() 方法 ❺,传入 $address 字符串。返回的结果存储在 $latLongArray 变量中,它是一个包含 'latitude' 和 'longitude' 键的数组,保存了必要的坐标,我们将其提取到单独的变量中。</p> <p>然后,我们使用通过网页表单提交的日期字符串,创建一个相应的 DateTimeImmutable 对象,命名为 $date。接着我们创建一个新的 SunData 对象(另一个我们稍后会查看的类),将 <span class="math inline">\(date、\)</span>latitude 和 <span class="math inline">\(longitude 作为参数 ❻ 传入。SunData 对象使用提供的信息来计算日出和日落时间以及白昼时长。我们通过相应的 getter 方法提取这些数据,并通过链式调用 format() 方法将日期时间信息转换为字符串。\)</span>sunrise 和 <span class="math inline">\(sunset 变量的格式为 8.35am。\)</span>hoursDaylight 变量是一个 DateInterval 对象,格式为 16 小时 39 分钟,使用 %h 表示小时,%i 表示分钟 ❼。在这些变量都有了作用域后,我们显示 <em>result.php</em> 模板 ❽。</p> <p>所有在 processForm() 方法中的活动都嵌套在一个 try 块中。如果出现问题,例如无法连接到 OpenStreetMap,方法末尾的 catch 语句 ❾ 会显示错误信息,并提供一个返回主页的链接。</p> <h4 id="辅助类">辅助类</h4> <p>接下来,我们声明应用程序的辅助类,从 StreetMap 开始,它负责与 OpenStreetMap 网络服务器的交互。创建 <em>src/StreetMap.php</em> 文件,如 Listing 31-14 所示。</p> <pre><code><?php namespace Mattsmithdev; use GuzzleHttp\Client; class StreetMap { private Client $client; public function __construct() { $this->client = new Client([ 'timeout' => 10.0, 'headers' => [ 'User-Agent' => 'matt smith demo', ❶ 'Accept' => 'application/json', ], 'verify' => true, ]); } public function getOpenStreetMapData( string $address = 'grafton street, dublin, ireland' ): array { $url = $this->buildQueryString($address); ❷ $response = $this->client->request('GET', $url); if ($response->getStatusCode() == 200) { $responseBody = $response->getBody(); ❸ $jsonData = json_decode($responseBody, true); if (empty($jsonData)) { throw new \Exception('no JSON data received'); } } else { ❹ throw new \Exception('Invalid status code'); } ❺ return [ 'latitude' => $jsonData[0]['lat'], 'longitude' => $jsonData[0]['lon'], ]; } private function buildQueryString(string $address): string { ❻ $query = http_build_query([ 'format' => 'jsonv2', 'q' => $address, 'addressdetails' => 1, ]); $url = "https://nominatim.openstreetmap.org/search?$query"; return $url; } } </code></pre> <p>Listing 31-14:用于访问 OpenStreetMap 服务器的 StreetMap 类</p> <p>首先,使用语句允许我们调用位于 GuzzleHttp 命名空间中的 Guzzle 库的 Client 类。该类将负责处理向外部站点发起 HTTP 请求的细节。然后,我们声明客户端实例变量,并在构造函数中将其初始化为 Client 对象。我们提供了各种 Guzzle Client 参数,如超时设置(等待响应的时间)和发送代理名称(对于这个项目,“matt smith demo”就可以)。我们特别配置 Client 以接受 JSON 数据❶,因为 OpenStreetMap API 返回的数据格式就是 JSON。</p> <p>接下来,我们声明 getOpenStreetMapData()方法。它接收一个地址(为了测试,我提供了一个默认值),并通过 buildQueryString()方法将其转化为适当的查询字符串。然后,它使用 Client 对象的 request()方法向 OpenStreetMap API 发送请求,并存储收到的响应❷。如果响应代码有效(200),则将接收到的数据解码为<span class="math inline">\(jsonData 数组❸。如果响应代码不是 200 或收到的是空数组,我们会抛出一个异常,提示调用代码从 OpenStreetMap API 获取数据时出现了问题❹。如果代码通过 if...else 语句没有抛出异常,则从接收到的\)</span>jsonData 中提取出纬度和经度,并以数组形式返回❺。</p> <p>最后,我们声明了 buildQueryString()方法。该方法使用 PHP 内置的 http_build_query()函数,将地址和其他细节编码成适用于 Nominatim OpenStreetMap API❻的查询字符串。我们将查询信息作为键/值对的数组提供给 http_build_query(),然后将编码后的查询字符串(保存在变量<span class="math inline">\(query 中)附加到\)</span>url 的末尾。</p> <blockquote> <p>注意</p> </blockquote> <p><em>关于 Nominatim OpenStreetMap API 的更多要求,见</em> <a href="https://nominatim.org" target="_blank"><code>nominatim.org</code></a><em>。Nominatim</em>(拉丁语意思为“按名称”)是一个开源软件项目,提供 OpenStreetMap 数据的搜索功能。它支持<em>地理编码</em>(根据给定的名称和地址获取位置)和<em>反向地理编码</em>(根据给定的位置获取地址)。</p> <p>现在我们将看看 SunData 类,它旨在简化与 PHP 内置的 date_sun_info()函数一起使用的过程。在[src/SunData.php]中声明该类,如列表 31-15 所示。</p> <pre><code><?php namespace Mattsmithdev; class SunData { private \DateTimeImmutable $sunrise; private \DateTimeImmutable $sunset; private \DateInterval $hoursDaylight; public function __construct(\DateTimeImmutable $date, float $latitude, float $longitude) { $timestamp = $date->getTimestamp(); ❶ $data = date_sun_info($timestamp, $latitude, $longitude); ❷ $this->sunrise = $this->dateFromTimestamp($data['sunrise']); $this->sunset = $this->dateFromTimestamp($data['sunset']); $this->hoursDaylight = $this->sunset->diff($this->sunrise); ❸ } private function dateFromTimestamp(int $timestamp): \DateTimeImmutable { return (new \DateTimeImmutable())->setTimeStamp($timestamp); ❹ } public function getSunrise(): \DateTimeImmutable { return $this->sunrise; } public function getSunset(): \DateTimeImmutable { return $this->sunset; } public function getHoursDaylight(): \DateInterval { return $this->hoursDaylight; } } </code></pre> <p>列表 31-15:用于处理 date_sun_info()函数的 SunData 类</p> <p>我们为 SunData 类提供了三个实例变量:sunrise 和 sunset 是用于日出和日落时间的 DateTimeImmutable 对象,hoursDaylight 是一个 DateInterval 对象,表示白昼持续时间。SunData 的构造函数接受三个参数:日期(一个 DateTimeImmutable 对象)以及感兴趣位置的纬度和经度。这些是 date_sun_info() 函数所需要的信息,尽管日期必须是 Unix 时间戳格式,因此构造函数开始时调用 getTimeStamp() 方法来进行转换 ❶。</p> <p>然后我们调用 date_sun_info(),并将结果存储在 $data 变量中,这个结果是一个信息数组 ❷。我们从 $data 数组中提取日出和日落时间,并将它们存储到相应的实例变量中。由于 date_sun_info() 返回的是 Unix 时间戳形式的日期时间信息,我们使用 dateFromTimestamp() 辅助方法将时间戳转换回 DateTimeImmutable 对象。(在这个方法中,请注意,我们必须在创建新的 DateTimeImmutable 对象时,额外加上括号,才能调用它的 setTimeStamp() 方法 ❹。)</p> <p>对于白昼时长,我们只需计算日落和日出时间的差值 ❸。SunData 类的其余部分由简单的 getter 方法组成,用于返回三个实例变量。</p> <h4 id="模板-1">模板</h4> <p>我们现在准备好创建首页(包含网页表单)和结果页的模板。我们将从 <em>templates/homepage.php</em> 中的首页模板开始。代码见 列表 31-16。</p> <pre><code><!doctype html> <html lang="en"> <head><title>Sun Data</title></head> <body> ❶ <?php if (!empty($errorMessage)): ?> <p style="background-color: pink; padding: 2rem"> <?= $errorMessage ?> </p> <?php endif; ?> <form action="/?action=processForm" method="post"> <p> Address: <input name="address"> </p> <p> ❷ <input name="date" type="date"> </p> <input type="submit"> </form> </body> </html> </code></pre> <p>列表 31-16:从用户输入地址和日期的表单</p> <p>在页面主体中,我们首先使用替代的 if 语句语法显示一个粉色样式的段落,包含错误信息字符串,前提是 $errorMessage 变量不为空 ❶。然后我们创建一个 action 为 processForm 的表单,表单中包含地址和日期字段。对于日期字段,我们使用一个 type 为 date 的 <input> 元素 ❷,大多数网页浏览器将其显示为一个用户友好的日历日期选择器控件,如 图 31-1 所示。</p> <p>第二个模板用于向用户显示结果。创建 <em>templates/result.php</em>,并使用 列表 31-17 中的代码。</p> <pre><code><!doctype html> <html lang="en"> <head><title>results</title></head> <body> <a href="/">(back to home page)</a> ❶ <hr> <h1>Latitude and Longitude</h1> Date = <?= $dateString ?><br> Latitude = <?= $latitude ?><br> Longitude = <?= $longitude ?><br> <a href="http://www.openstreetmap.org/?zoom=17&mlat=<?= $latitude ?>&mlon=<?= $longitude ?>"> ❷ Open maps link to: <?= $address ?> </a> <hr> Sunrise <img src="/images/sunrise.png" width="50" alt="Sunrise"> <?= $sunrise ?> <br> Sunset <img src="/images/sunset.png" width="50" alt="Sunset"> <?= $sunset ?> <p> so there will be <?= $hoursDaylight ?> of daylight </p> <footer> ❸ icon attribution: <a href="https://www.flaticon.com/free-icon/sunrise_3920688" title="sunrise icons"> Sunrise</a> <a href="https://www.flaticon.com/free-icon/sunset_3920799" title="sunset icons"> Sunset</a> icons created by Mehwish - Flaticon </footer> </body> </html> </code></pre> <p>列表 31-17:向用户呈现太阳数据结果的模板</p> <p>在这个模板中,我们首先提供一个链接,让用户返回首页 ❶。然后我们显示提供的日期以及与提供的地址对应的纬度和经度。</p> <p>接下来,我们提供一个链接以在 OpenStreetMap 中查看位置,将 $latitude 和 $longitude 变量的值插入到链接中,用于 mlat 和 mlon 查询字段 ❷。然后,我们输出日出、日落和白昼时长的数值,并在日出和日落时间旁边显示相应的图像(<em>sunrise.png</em> 和 <em>sunset.png</em>,由用户 Mehwish 提供,图像来源于 <em><a href="https://www.flaticon.com" target="_blank"><code>www.flaticon.com</code></a></em>)。</p> <p>这些图片的链接及其出版者的致谢已在页面底部的元素❸中提供。下载这些图片并将它们复制到<em>public/images</em>目录中,以完成 Web 应用程序的创建。然后尝试运行 Web 服务器,并使用不同的日期和地址测试应用程序。</p> <p>这个项目汇集了本章的许多概念,展示了 DateInterval 类的实际应用,并展示了如何在 DateTimeImmutable 对象和 Unix 时间戳之间进行转换。它还说明了开源库的强大功能,本文展示了 Guzzle 如何使发送请求到外部 API 并处理返回的 JSON 数据变得轻松。</p> <h3 id="mysql-日期">MySQL 日期</h3> <p>在 PHP 中工作时,使用原生的 PHP 日期和时间对象及函数是很有意义的。然而,在数据库中存储时间数据时,最好使用数据库系统的原生格式。这样,数据库查询可以直接在这些字段上执行,而且用其他编程语言编写的应用程序也可以处理存储在数据库中的数据。因此,了解数据库的日期时间格式并学习如何在读写数据库时在 PHP 和相关数据库格式之间进行转换非常重要。在本节中,我们将探讨 MySQL 如何处理日期时间信息,但这些原则适用于任何数据库管理系统(DBMS)。</p> <p>MySQL 可以存储日期、日期时间和时间戳,但我们这里重点讨论日期时间。MySQL 的基本日期和时间格式与 ISO 8601 相同,日期格式为 YYYY-MM-DD,时间格式为 HH:MM:SS。不过,MySQL 在日期和时间之间使用空格而不是字母 T 作为分隔符,因此 ISO 8601 格式中的 1968-11-22T09:05:30 会在 MySQL 中存储为 1968-11-22 09:05:30。像 PHP 一样,MySQL 可以为时间组件添加小数位,以存储秒的小数部分,精确到微秒(六位小数)。</p> <p>要指定你希望 MySQL 表中的某个列存储日期时间,请使用 DATETIME 数据类型声明该列。现代 MySQL 系统默认为时间组件的零小数位(即整秒)。要包含秒的小数部分,可以在数据类型后面加上括号并指定所需的小数位数。例如,要存储精确到微秒的日期时间,可以声明 DATETIME(6)类型的列。</p> <p>MySQL 将日期时间数据存储为 UTC 值。因此,如果 MySQL 服务器设置为非 UTC 的时区,它将把任何日期时间转换为 UTC 进行存储,然后在检索时再从 UTC 转换回来。实际上,常见做法是创建 UTC DateTimeImmutable 对象以存储到数据库中,之后由 Web 应用程序逻辑将检索到的日期时间转换为其他所需的时区。</p> <blockquote> <p>注意</p> </blockquote> <p><em>如果你使用的是</em> TIMESTAMP <em>数据类型,需注意 MySQL 会根据你的 MySQL 服务器的时区设置自动将其转换为 UTC。</em></p> <p>为了从 PHP 脚本中将 MySQL datetime 字符串插入到表中,首先使用 DateTimeImmutable 对象,并使用它的 format() 方法,格式字符串为 'Y-m-d H:i:s'(表示整秒)或 'Y-m-d H:i:s.u'(表示带小数秒)。特别注意 d 和 H 之间的空格。同样,当从 MySQL 获取日期时间字符串时,可以使用 DateTimeImmutable 类的 createFromFormat() 静态方法来获取该 MySQL 数据的等效 DateTimeImmutable 对象。</p> <p>为了演示如何在 DateTimeImmutable 对象和 MySQL DATETIME 字段之间来回转换,我们将创建一个包含 Appointment 实体类的项目,并创建一个相应的预约数据库表来存储预约的名称和日期时间。首先创建一个包含常规 <em>composer.json</em> 文件的新项目,并在命令行输入 composer dump-autoload 来生成自动加载器。接着,创建一个名为 date1 的新的 MySQL 数据库架构,并使用 Listing 31-18 中的 SQL 语句在该架构中创建一个预约表。(参见 第 543 页 的“设置数据库架构”部分,回顾如何将此 SQL 语句集成到 PHP 脚本中。)</p> <pre><code>CREATE TABLE IF NOT EXISTS appointment ( id integer PRIMARY KEY AUTO_INCREMENT, title text, startdatetime datetime(6) ) </code></pre> <p>Listing 31-18:创建预约 MySQL 数据库表的 SQL 语句</p> <p>表中有一个自增的 id 字段作为主键,一个 title 字段用于描述预约,以及一个 startdatetime 字段表示预约开始的时间。我们将 startdatetime 字段声明为 datetime(6) 类型,以说明如何处理秒的小数部分,但请注意,MySQL 默认的零小数位对于大多数实际的会议或预约应用程序来说已足够。</p> <p>现在我们将声明与此表对应的 Appointment 类。将 Listing 31-19 的内容输入到 <em>src/Appointment.php</em> 文件中。</p> <pre><code><?php namespace Mattsmithdev; class Appointment { private int $id; private string $title; private \DateTimeImmutable $startDateTime; ❶ public function getId(): int { return $this->id; } public function setId(int $id): void { $this->id = $id; } public function getTitle(): string { return $this->title; } public function setTitle(string $title): void { $this->title = $title; } public function getStartDateTime(): \DateTimeImmutable { return $this->startDateTime; } public function setStartDateTime(\DateTimeImmutable|string $startDateTime): void ❷ { if (is_string($startDateTime)) { $startDateTime = \DateTimeImmutable::createFromFormat( AppointmentRepository::MYSQL_DATE_FORMAT_STRING, $startDateTime); } $this->startDateTime = $startDateTime; } } </code></pre> <p>Listing 31-19:Appointment 实体类,包含一个 DateTimeImmutable 属性</p> <p>Appointment 类具有 id、title 和 startDateTime 属性,以匹配预约表中的列。请注意,startDateTime 属性是一个 DateTimeImmutable 对象 ❶。我们为每个属性提供了适当的 getter 和 setter 方法。这包括一个特殊的 setter 方法,它使用联合类型 DateTimeImmutable|string,允许提供 DateTimeImmutable 对象或字符串作为参数 ❷。</p> <p>如果接收到的参数是字符串,我们将使用公共常量 MYSQL_DATE_FORMAT_STRING 来帮助格式化,并将其转换为 DateTimeImmutable 对象。(我们将在稍后的 AppointmentRepository 类中声明此常量。)此机制允许同一个 setter 方法既能处理 PHP 的 DateTimeImmutable 对象,也能处理从数据库接收到的 MySQL 日期时间字符串。如果使用像 Doctrine 这样的 ORM 库,就可以避免额外的逻辑,它能无缝地在 PHP 数据类型和其数据库等效物之间进行转换。</p> <p>接下来,我们将创建一个 AppointmentRepository 类,该类包含将新预约插入到 appointments 表中的方法,并获取所有预约。为了简化,我们将数据库连接和仓库方法结合在这个类中,但请参阅第二十八章以了解如何在单独的数据库类中管理数据库连接的示例。创建<em>src/AppointmentRepository.php</em>,并包含列表 31-20 中的代码。</p> <pre><code><?php namespace Mattsmithdev; class AppointmentRepository { public const MYSQL_DATE_FORMAT_STRING = 'Y-m-d H:i:s.u'; ❶ public const MYSQL_DATABASE = 'date1'; public const MYSQL_HOST = 'localhost:3306'; public const MYSQL_USER = 'root'; public const MYSQL_PASS = 'passpass'; private ?\PDO $connection = NULL; public function __construct() { try { $this->connection = new \PDO('mysql:dbname=' . self::MYSQL_DATABASE . ';host=' . self::MYSQL_HOST , self::MYSQL_USER, self::MYSQL_PASS ); ❷ } catch (\Exception) { print 'sorry - there was a problem connecting to database ' . self::MYSQL_DATABASE; } } public function insert(Appointment $appointment): int { if (NULL == $this->connection) return -1; $title = $appointment->getTitle(); $startDateTime = $appointment->getStartDateTime(); $dateString = $startDateTime->format(self::MYSQL_DATE_FORMAT_STRING); ❸ // Prepare SQL $sql = 'INSERT INTO appointment (title, startdatetime) VALUES (:title, :startdatetime)'; $stmt = $this->connection->prepare($sql); // Bind parameters to statement variables $stmt->bindParam(':title', $title); $stmt->bindParam(':startdatetime', $dateString); // Execute statement $success = $stmt->execute(); if ($success) { return $this->connection->lastInsertId(); } else { return -1; } } public function findAll(): array { $sql = 'SELECT * FROM appointment'; $stmt = $this->connection->prepare($sql); $stmt->execute(); $objects = $stmt->fetchAll(); ❹ $appointments = []; foreach ($objects as $object) { $appointment = new Appointment(); $appointment->setId($object['id']); $appointment->setTitle($object['title']); $appointment->setStartDateTime($object['startdatetime']); $appointments[] = $appointment; } return $appointments; } } </code></pre> <p>列表 31-20:用于 MySQL 数据库交互的 AppointmentRepository 类</p> <p>我们声明一个公共的 MYSQL_DATE_FORMAT_STRING 常量,它包含了必要的格式化字符串,用于确保 MySQL 日期时间与 PHP 的 DateTimeImmutable 对象之间的兼容性❶。接着,我们声明更多常量来保存数据库凭证(请务必填写您自己的值),并定义一个私有的连接属性来保存 PDO 数据库连接对象。在构造函数中,我们创建数据库连接并将其存储在连接属性中❷,同时使用 try...catch 结构来处理可能出现的问题。</p> <p>然后,我们声明 insert()方法,它接收一个 Appointment 对象并将其 title 和 startDateTime 属性提取到单独的变量中。为了创建 MySQL 日期字符串$dateString,我们将 MYSQL_DATE_FORMAT_STRING 常量传递给 DateTimeImmutable 对象的 format()方法,以获取正确的字符串格式❸。接着,我们准备一个 SQL INSERT 语句,将其填充为适当的值,并执行该语句以向预约表中添加一行数据。</p> <p>在 findAll()方法中,我们使用 PDO 的 fetchAll()方法从预约表中检索所有条目,返回一个包含键值对的关联数组❹。然后,方法会遍历这个数组,从每个元素创建一个 Appointment 对象,并将其添加到$appointments 数组中,最后返回该数组。</p> <p>最后,我们将创建一个可以在命令行运行的索引脚本,用于创建几个示例的 Appointment 对象,将它们的数据添加到数据库中,然后从数据库中检索这些条目并返回为 Appointment 对象的数组。创建<em>public/index.php</em>,如列表 31-21 所示。</p> <pre><code><?php require_once __DIR__ . '/../vendor/autoload.php'; use Mattsmithdev\Appointment; use Mattsmithdev\AppointmentRepository; $appointmentRepository = new AppointmentRepository(); $appointment = new Appointment(); $appointment->setTitle('get an ice cream sandwich'); $appointment->setStartDateTime(new DateTimeImmutable('2009-08-02T11:00:00.5')); $appointmentRepository->insert($appointment); $appointment2 = new Appointment(); $appointment2->setTitle('celebrate birthday'); $appointment2->setStartDateTime(new DateTimeImmutable('2025-11-22T09:05:30.77')); $appointmentRepository->insert($appointment2); $appointments = $appointmentRepository->findAll(); foreach ($appointments as $appointment) { var_dump($appointment); } </code></pre> <p>列表 31-21:在/public/index.php 中测试我们的 MySQL 日期示例的索引脚本</p> <p>我们需要加载自动加载器并声明 Mattsmithdev 命名空间中的 AppointmentRepository 和 Appointment 类。接着,我们创建一个名为 $appointmentRepository 的 AppointmentRepository 对象,并创建两个示例 Appointment 对象,通过 $appointmentRepository 对象的 insert() 方法将其插入数据库。每个对象都被赋予带有小数秒部分的时间,并使用 ISO 8601 格式指定,包括日期和时间之间的 T 分隔符。然而,这种格式并不重要,因为我们已经编写了 AppointmentRepository 类,其中包含将其转换为 MySQL 日期时间格式的逻辑。最后,我们通过调用 repository 对象的 findAll() 方法来检索所有数据库行作为 Appointment 对象的数组,然后遍历这些对象并将其传递给 var_dump()。</p> <p>以下是当索引脚本执行时的输出:</p> <pre><code>object(Mattsmithdev\Appointment)#9 (3) { ["id":"Mattsmithdev\Appointment":private]=> int(1) ["title":"Mattsmithdev\Appointment":private]=> string(25) "get an ice cream sandwich" ["startDateTime":"Mattsmithdev\Appointment":private]=> object(DateTimeImmutable)#10 (3) { ["date"]=> string(26) "2009-08-02 11:00:00.500000" ["timezone_type"]=> int(3) ["timezone"]=> string(13) "Europe/Dublin" } } object(Mattsmithdev\Appointment)#11 (3) { ["id":"Mattsmithdev\Appointment":private]=> int(2) ["title":"Mattsmithdev\Appointment":private]=> string(18) "celebrate birthday" ["startDateTime":"Mattsmithdev\Appointment":private]=> object(DateTimeImmutable)#12 (3) { ["date"]=> string(26) "2025-11-22 09:05:30.770000" ["timezone_type"]=> int(3) ["timezone"]=> string(13) "Europe/Dublin" } } </code></pre> <p>从数据库中检索并输出到控制台的有两个约会。第一个是“去吃冰淇淋三明治”约会,开始时间为 2009-08-02 11:00:00.500000。第二个是“庆祝生日”约会,开始时间为 2025-11-22 09:05:30.770000。两个日期组件都存储到了小数点后六位,精确到秒的分数部分。注意,时区为 Europe/Dublin,这是我 PHP 设置的时区,在创建新的 DateTimeImmutable 对象时默认应用。如果 Web 应用程序需要处理来自不同时区的日期,解决方案之一是将时区与每个日期时间的 UTC 版本一起存储在数据库中,然后在检索时将获取到的日期时间转换回该时区。</p> <h3 id="总结-19">总结</h3> <p>操作日期和时间通常是开发应用程序中必不可少的一部分,因为日期和时间信息为用户提供了有用的功能(例如,维护日历)并且在记录动作和请求发生的时间方面非常重要。在本章中,我们探讨了与日期和时间相关的最有用的 PHP 类和函数,包括 DateTimeImmutable 和 DateInterval 类。</p> <p>我们在一个 Web 应用程序中使用这些语言特性,该应用程序报告日出和日落信息,并依赖 Guzzle 库来发起 HTTP 请求访问外部站点。我们还探讨了如何在 PHP 脚本和 MySQL 数据库表之间来回移动日期时间信息,并根据需要转换格式。</p> <h3 id="练习-28">练习</h3> <p>1.   编写一个脚本来创建(并使用 var_dump)UTC(Zulu 时间)、爱尔兰标准时间和东部标准时间的 DateTimeImmutable 对象,时间为:</p> <p>2025-01-01 10:00:00</p> <p>2025-01-02 12:00:00.05</p> <p>2.   编写一个脚本来创建(并使用 var_dump)2000-01-01 22:00:00 和以下时间之间的 DateInterval 对象:</p> <p>2000-01-02 22:00:00</p> <p>2010-05-06 00:00:00</p> <p>2010-05-06 00:00:30</p> <p>2020-01-01 22:00:00</p> <p>3.   开发一个项目,用于创建、存储和检索患者与医生的会面记录。该项目应使用 MySQL 数据库存储记录。将项目围绕一个名为 Consultation 的实体类进行设计,包含以下属性:</p> <p>患者姓名(字符串)</p> <p>医生姓名(字符串)</p> <p>会诊日期和时间(DateTimeImmutable)</p> <p>持续时间(分钟)(整数)</p> <p>以下是创建此类记录的数据库表的 SQL 语句:</p> <pre><code>CREATE TABLE IF NOT EXISTS consultation ( id integer PRIMARY KEY AUTO_INCREMENT, patient text, doctor text, duration integer, consultationdatetime datetime ) </code></pre> <p>4.   创建一个新项目,用于查找 1999 年 12 月 31 日,即上一千年的最后一天,纽约和都柏林的日照时间。</p> <h1 id="第三十二章安装-php">第三十二章:安装 PHP</h1> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>虽然可以在云端进行 PHP 开发,但许多程序员和学生更喜欢在本地计算机上安装 PHP。本附录将介绍如何在 macOS、Linux 和 Windows 操作系统上安装 PHP。</p> <h2 id="macos">macOS</h2> <p>在 macOS 上安装 PHP 最简单的方法是使用 Homebrew,这是一款免费的包管理器,能够大大简化软件包的安装和设置。如果你还没有 Homebrew,首先访问 <em><a href="https://brew.sh" target="_blank"><code>brew.sh</code></a></em> 按照页面上的说明安装。</p> <p>安装 Homebrew 后,在命令行中输入以下内容以安装 PHP:</p> <pre><code>$ **brew install php** </code></pre> <p>若要验证是否成功,请输入以下内容:</p> <pre><code>$ **php -v** </code></pre> <p>如果 PHP 安装成功,你应该在输出中看到最新的 PHP 版本号。</p> <p>你还可以使用 Homebrew 安装 Composer 依赖管理器,相关内容请参见 第二十章:</p> <pre><code>$ **brew install composer** </code></pre> <p>最后一步是检查 PHP 安装正在读取哪个(如果有的话)INI 配置文件。使用以下命令:</p> <pre><code>$ **php --ini** </code></pre> <p>输出应类似于以下内容:</p> <pre><code>Configuration File (php.ini) Path: /opt/homebrew/etc/php/8.x Loaded Configuration File: /opt/homebrew/etc/php/8.x/php.ini --snip-- </code></pre> <p>显示的前两行会告诉你 PHP 引擎读取其设置的 INI 文件的路径和文件名。例如,在我的 macOS 计算机上,INI 文件位于 <em>/opt/homebrew/etc/php/8.<x>/php.ini</em>。</p> <h2 id="linux">Linux</h2> <p>许多 Linux 发行版使用高级包装工具(APT)包管理器进行软件安装。在使用 APT 命令行时,建议首先通过以下命令更新和升级任何已安装的包:</p> <pre><code>$ **sudo apt-get update** $ **sudo apt-get upgrade** </code></pre> <p>然后,你可以按照以下方式安装 PHP:</p> <pre><code>$ **sudo apt-get install php** $ **sudo apt-get install php-cgi** </code></pre> <p>这应该适用于大多数 Linux 发行版,如 Ubuntu,但你可以在 <em><a href="https://www.zend.com/blog/installing-php-linux" target="_blank"><code>www.zend.com/blog/installing-php-linux</code></a></em> 上找到有关在更多发行版上安装 PHP 的详细信息。</p> <p>若要验证安装是否成功,请输入以下内容:</p> <pre><code>$ **php -v** </code></pre> <p>如果 PHP 安装成功,你应该在输出中看到最新的 PHP 版本号。你还应该使用此命令检查 PHP 安装正在读取哪个(如果有的话)INI 配置文件:</p> <pre><code>$ **php --ini** </code></pre> <p>结果应类似于以下内容:</p> <pre><code>Configuration File (php.ini) Path: /etc/php/8.x/cli Loaded Configuration File: /etc/php/8.x/cli/php.ini --snip-- </code></pre> <p>显示的前两行会告诉你 PHP 引擎读取其设置的 INI 文件的路径和文件名。例如,在我的 Ubuntu Linux 计算机上,INI 文件位于 <em>/etc/php/8.<x>/cli/php.ini</em>。</p> <h2 id="windows">Windows</h2> <p>在 Windows 计算机上安装 PHP 最简洁的方法是访问 <em><a href="https://www.php.net" target="_blank"><code>www.php.net</code></a></em> 的下载页面,点击 <strong>Windows Downloads</strong> 链接以获取最新版本的 PHP,下载 ZIP 文件。然后将该 ZIP 文件的内容解压到 <em>c:\php</em>。</p> <blockquote> <p>注意</p> </blockquote> <p><em>你不必将 PHP 安装到</em> c:\php<em>,但我觉得这是最简单的位置。如果你更喜欢将 PHP 安装到其他位置,请记下你选择的安装文件夹路径,以便稍后将其添加到 PATH 环境变量中,具体操作将在后面讨论。</em></p> <p>现在,你需要将 <em>c:\php</em> 路径添加到你电脑系统的 PATH 环境变量中。在快速启动搜索框中,输入 <strong>环境</strong>,然后选择 <strong>编辑系统环境变量</strong> 打开系统属性对话框的高级选项卡。接着,点击 <strong>环境变量</strong> 按钮,调出环境变量对话框。在系统变量列表中找到 Path 行,点击 <strong>编辑</strong>,如图 A-1 所示。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figureA-1.jpg" alt="" loading="lazy"></p> <p>图 A-1:访问环境变量对话框中的路径行</p> <p>在下一个对话框中(见图 A-2),点击<strong>新建</strong>并输入 PHP 的位置 <em>c:\php</em>。然后不断点击<strong>确定</strong>保存更改,直到所有对话框关闭。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figureA-2.jpg" alt="" loading="lazy"></p> <p>图 A-2:将 c:\php 路径添加到 PATH 环境变量</p> <p>为了确保一切正常,打开命令行终端并输入以下内容:</p> <pre><code>> **php -v** </code></pre> <p>如果 PHP 已成功安装,你应该在输出中看到最新的 PHP 版本号。你还应该确保你的安装中有一个 INI 配置文件,方法是通过命令行输入以下内容:</p> <pre><code>> **php --ini** </code></pre> <p>你应该看到类似以下内容的回应:</p> <pre><code>Configuration File (php.ini) Path: Loaded Configuration File: c:\php\php.ini </code></pre> <p>这些行会告诉你 PHP 引擎正在读取其设置的 INI 文件的路径和文件名。例如,在我的 Windows 电脑上,INI 文件位于 <em>c:\php\php.ini</em>。如果没有列出 INI 文件,请转到 <em>c:\php</em> 文件夹并将 <em>php.ini-development</em> 文件重命名为 <em>php.ini</em>。然后再次运行 <code>php --ini</code> 命令,你应该能看到这个文件被列为加载的配置文件。</p> <p>对于 Windows,你需要确保在 INI 文件中启用了四个常用的扩展。一旦找到该文件,使用文本编辑器打开并搜索 <code>extension=curl</code>。然后去掉这四个扩展所在行的行首分号:</p> <pre><code>extension=curl extension=pdo_mysql extension=pdo_sqlite extension=zip </code></pre> <p>这些中的第一个和最后一个(curl 和 zip)将帮助 Composer 工具管理项目的第三方包(见第二十章)。中间的两个(pdo_mysql 和 pdo_sqlite)启用 MySQL 和 SQLite 的 PDO 数据库通信扩展(见第二十八章)。如果你做了任何更改,请在关闭文件前保存更新的文本文件。</p> <h2 id="amp-安装">AMP 安装</h2> <p>虽然我建议按照本附录中列出的步骤安装 PHP,但一些开发者更喜欢安装一个完整的 AMP 堆栈,这个堆栈使用一个安装程序将 PHP Web 应用开发的三个常见组件捆绑在一起:一个 Web 服务器(如 Apache HTTP Server)、一个数据库管理系统(如 MySQL)以及 PHP 引擎。常见的 AMP 系统包括以下几种:</p> <ul> <li> <p>XAMPP(适用于 Linux、macOS 和 Windows):<em><a href="https://www.apachefriends.org" target="_blank"><code>www.apachefriends.org</code></a></em></p> </li> <li> <p>WampServer(适用于 Windows):<em><a href="https://wampserver.aviatechno.net" target="_blank"><code>wampserver.aviatechno.net</code></a></em></p> </li> <li> <p>MAMP(适用于 macOS 和 Windows):<em><a href="https://www.mamp.info" target="_blank"><code>www.mamp.info</code></a></em></p> </li> </ul> <p>访问这些网站以了解更多关于必要安装过程的信息。</p> <h1 id="第三十三章b-数据库设置">第三十三章:B 数据库设置</h1> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>本书的第 VI 部分概述了如何使用 PHP 与 MySQL 和 SQLite 数据库进行交互。本附录涵盖了如何确保这些数据库管理系统在您的本地计算机上已正确设置。</p> <h2 id="mysql-1">MySQL</h2> <p>MySQL 有多个版本可供选择。对于本书的目的,免费版的 MySQL Community Server 足够使用。我们将讨论如何为您选择的操作系统安装 MySQL。</p> <h3 id="macos-和-windows">macOS 和 Windows</h3> <p>要在 macOS 或 Windows 上安装 MySQL Community Server,请访问 <em><a href="https://dev.mysql.com/downloads/mysql/" target="_blank"><code>dev.mysql.com/downloads/mysql/</code></a></em>。该网站应该能够自动检测您的操作系统,因此您只需下载适合您系统的最新版本安装程序即可。对于 macOS,我推荐使用其中一个 DMG 压缩文件:适用于 M 系列机器的 ARM 安装程序,或适用于基于 Intel 的机器的 x86 安装程序。对于 Windows,我建议使用 Microsoft 软件安装程序 (MSI)。</p> <p>下载适合您系统的安装程序后,运行它并接受默认选项。您需要特别注意的部分是当系统要求您为 MySQL 服务器的 root 用户输入密码时。选择一个您能够记住的密码,因为在您与数据库服务器通信的 PHP 脚本中,您需要提供此密码。</p> <p>完成安装过程后,MySQL 服务器应该可以与您的 PHP 应用程序一起使用。默认安装会将服务器配置为每次重新启动系统时自动启动并在后台运行,因此在使用 MySQL 之前,您不需要手动启动服务器。</p> <h3 id="linux-1">Linux</h3> <p>如果您是 Linux 用户,您需要安装 PDO 和 MySQL 服务器扩展包,以便使用 PDO 库让 PHP 与 MySQL 数据库进行通信。使用以下命令:</p> <pre><code>$ **sudo apt-get install php-mysql** $ **sudo apt-get install mysql-server** </code></pre> <p>数据库服务器在安装完成后应该已经启动,您可以通过以下命令检查其状态:</p> <pre><code>$ **sudo ss -tap | grep mysql** LISTEN 0 70 127.0.0.1:33060 0.0.0.0:* users:(("mysqld",pid=21486,fd=21)) LISTEN 0 151 127.0.0.1:mysql 0.0.0.0:* users:(("mysqld",pid=21486,fd=23)) </code></pre> <p>这表示服务器正在运行,并且运行在端口 33060 上。如果您需要重新启动 MySQL 服务器,可以使用以下命令:</p> <pre><code>$ **sudo service mysql restart** </code></pre> <p>如果您愿意,您可以为 root MySQL 用户设置密码,方法如下(将密码替换为您喜欢的任何内容):</p> <pre><code>$ **sudo mysql** mysql> **ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '**`**password**`**';** mysql> **exit** Bye </code></pre> <p>现在,您可以将 MySQL 数据库用于您的 PHP 项目。</p> <h2 id="sqlite-1">SQLite</h2> <p>在 macOS 上通过 Homebrew 安装 PHP 时,SQLite 应该默认启用。在 Windows 上,只要在您的 INI 文件中启用了 pdo_sqlite 扩展,SQLite 就会可用。我们在附录 A 中讨论了如何验证这一点。</p> <p>在 Linux 上,使用以下命令启用 PHP 与 SQLite 数据库进行通信:</p> <pre><code>$ **sudo apt install php-sqlite3** </code></pre> <p>截至本文写作时,SQLite 的最新稳定版本是版本 3。</p> <h2 id="确认-mysql-和-sqlite-扩展">确认 MySQL 和 SQLite 扩展</h2> <p>你可以随时通过创建一个调用 phpinfo()函数的<em>index.php</em>脚本来检查当前激活的 PHP 数据库扩展。正如在第一章中讨论的那样,这个函数会打印出关于你 PHP 安装的详细报告。列表 B-1 展示了你需要的<em>index.php</em>文件。</p> <pre><code><?php phpinfo(); </code></pre> <p>列表 B-1:用于查看 PHP 设置的 index.php 脚本</p> <p>通过在命令行输入 php -S localhost:8000 来提供此脚本,然后在浏览器中打开<em>localhost:8000</em>。在生成的页面中搜索<strong>PDO</strong>以查看 PDO 数据库扩展的列表。如果一切正常,你应该能看到 MySQL 和 SQLite 都已启用。</p> <h1 id="第三十四章c-replit-配置">第三十四章:C REPLIT 配置</h1> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/opener.jpg" alt="" loading="lazy"></p> <p>如果你选择使用 Replit 在线编码环境来跟随本书学习,你可以直接使用 Replit 默认的 PHP 设置开始。然而,在你逐步阅读本书的过程中,你可能需要做一些更改,以便让 Replit 支持更复杂的工具,比如 Composer 依赖管理器和数据库管理系统。本附录讨论了如何重新配置你的 Replit 项目。我们将讨论的设置适用于 PHP CLI 和 PHP Web Server 项目。</p> <h2 id="更改-php-版本">更改 PHP 版本</h2> <p>一个新的 Replit 项目可能默认不会运行最新版本的 PHP。要确认这一点,可以在 Replit 命令行 shell 中输入 php -v。你应该会看到 PHP 版本号的响应。如果这不是最新版本的 PHP,你可以通过编辑项目的隐藏配置文件来更改版本。首先,通过点击左侧文件栏中的三个垂直点小部件并选择 <strong>显示隐藏文件</strong> 来显示隐藏文件(参见 图 C-1)。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figureC-1.jpg" alt="" loading="lazy"></p> <p>图 C-1:显示当前 Replit 项目的隐藏文件</p> <p>现在,你应该会在文件栏中看到一个新的名为“配置文件”的部分,其中包含两个文件:<em>.replit</em> 和 <em>replit.nix</em>。选择 <em>replit.nix</em> 文件后,你应该会看到其中的内容显示在中间的编辑器栏中。内容应该类似于 列表 C-1。</p> <pre><code>{pkgs}: { deps = [ pkgs.php ]; } </code></pre> <p>列表 C-1:replit.nix 配置文件</p> <p>要更改 PHP 版本,请在 pkgs.php 后添加两个数字,表示你想要的主版本号和次版本号(例如,使用 pkgs.php82 来使用 PHP 版本 8.2.<em>x</em>)。然后,如果你稍等片刻后再次在命令行中输入 php -v,你应该会看到列出的新版本号。</p> <p>这可能需要一些反复尝试,因为 Replit 可能无法使用最新版本的 PHP。例如,在写作时,它无法运行 PHP 8.3,尽管在未来,你应该能够使用 pkgs.php83 来运行 PHP 8.3.<em>x</em>,然后使用 pkgs.php84 来运行 PHP 8.4.<em>x</em>,依此类推。</p> <blockquote> <p>注意</p> </blockquote> <p><em>与其猜测更改配置设置后重建环境需要多长时间,不如关闭当前的 shell 标签页,然后打开一个新的标签页。在新的标签页中,你将不会看到命令行提示符,直到新环境完全加载完成。</em></p> <h2 id="添加-composer-工具">添加 Composer 工具</h2> <p>第二十章介绍了 Composer 命令行工具,用于依赖管理。这个工具在 Replit PHP 项目中默认不可用,但你可以通过编辑 <em>replit.nix</em> 配置文件轻松添加它。按照 列表 C-2 中所示的更改进行操作,将 8 后面的 x 替换为适当的 PHP 次版本号,例如 PHP 版本 8.2 对应的 2。</p> <pre><code>{pkgs}: { deps = [ pkgs.php8x pkgs.php8xPackages.composer ]; } </code></pre> <p>列表 C-2:将 Composer 添加到 replit.nix 配置文件</p> <p>在 Replit 环境更新后,在命令行中输入 composer。如果一切正常,你应该看到 Composer 工具中所有可用命令的列表。</p> <h2 id="使用-sqlite-数据库系统">使用 SQLite 数据库系统</h2> <p>第 VI 部分介绍了使用 MySQL 和 SQLite 数据库系统进行数据库编程。如果你正在跟随 Replit 的教程,最直接的选择是使用 SQLite,截至本文写作时,所有 Replit PHP 项目默认都支持 SQLite。你可以通过执行 phpinfo() 函数并检查 PDO 和 pdo_sqlite 条目来验证这一点,如图 C-2 所示。</p> <p><img src="https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/php-crs-crs/img/figureC-2.jpg" alt="" loading="lazy"></p> <p>图 C-2:通过打印 phpinfo() 确认 PDO SQLite 特性</p> <p>如果将来默认安装的 SQLite 被删除,你可以通过编辑<em>replit.nix</em>配置文件,添加这两行额外的代码来将其添加到 Replit 项目中,如列表 C-3 所示。这是我典型的 PHP Web Server 项目的<em>replit.nix</em>文件在 SQLite 被添加为默认项之前的样子。</p> <pre><code>{pkgs}: { deps = [ pkgs.php8x pkgs.php8xPackages.composer pkgs.php8xExtensions.pdo pkgs.sqlite ]; } </code></pre> <p>列表 C-3:在 replit.nix 文件中启用 SQLite</p> <p>这些额外的行添加了 PDO 扩展和 SQLite 到项目中。再次提醒,将 x 替换为可用的最新 PHP 小版本号。</p> <h2 id="从-public-目录提供页面">从 public 目录提供页面</h2> <p>当你在 PHP Web Server 项目中点击 Run 按钮时,Replit 默认会服务项目中的所有文件和文件夹。然而,如第十章所讨论的,出于安全原因,最好为项目创建一个<em>public</em>文件夹,并只服务该文件夹的内容。虽然你总是可以通过在终端中输入 <code>php -S localhost:8000 -t public</code> 来从<em>public</em>文件夹提供服务,但你可能会发现更改 Run 按钮的行为会更方便。为此,打开隐藏的<em>.replit</em>配置文件并将第一行更改如下:</p> <pre><code>run = "php -S 0.0.0.0:8000 -t ./public" </code></pre> <p>如果你的<em>index.php</em>脚本位于<em>public</em>文件夹中,并且你没有进行此更改,点击 Run 按钮将触发 404 Not Found 错误,因为 Replit 会在项目的根目录中查找<em>index.php</em>脚本。</p> <h1 id="第三十五章索引">第三十五章:索引</h1> <ul> <li> <p>符号</p> </li> <li> <p>200 OK 状态码, 179</p> </li> <li> <p>404 错误代码, 179, 192</p> </li> <li> <ul> <li>(加法运算符), 21</li> </ul> </li> <li> <p>& (和符号), 107, 138</p> </li> <li> <p>&& (与运算符), 39, 70</p> </li> <li> <p>= (赋值运算符), 17, 22, 101, 124</p> </li> <li> <p>\ (反斜杠转义字符), 43</p> </li> <li> <p>{} (大括号), 47–48, 67, 85, 112</p> </li> <li> <p>::cases() 静态方法, 495</p> </li> <li> <p>: (冒号), 122</p> </li> <li> <p>+= (组合赋值和加法运算符), 122, 124</p> </li> <li> <p>/= (组合赋值和除法运算符), 23</p> </li> <li> <p><strong>= (组合赋值和指数运算符)</strong>, 23</p> </li> <li> <p>%= (组合赋值和取模运算符), 23</p> </li> <li> <p>*= (组合赋值和乘法运算符), 23</p> </li> <li> <p>-= (组合赋值和减法运算符), 22</p> </li> <li> <p>.= (串联赋值运算符), 45</p> </li> <li> <p>-- (递减运算符), 23</p> </li> <li> <p>/ (除法运算符), 21–23</p> </li> <li> <p>$ (美元符号), 17–18, 20</p> </li> <li> <p>=> (双箭头运算符), 76, 135, 144, 329</p> </li> <li> <p>{{}} (Twig 中的双大括号), 399</p> </li> <li> <p>== (等于运算符), 35–36, 74–75</p> </li> <li> <p>\ (转义反斜杠), 46</p> </li> <li> <p>" (转义双引号), 43</p> </li> <li> <p>\n (转义换行符), 46, 52, 59, 60, 170</p> </li> <li> <p>' (转义单引号), 43, 59</p> </li> <li> <p>\t (转义制表符), 46 60</p> </li> <li> <p>** (指数运算符), 21</p> </li> <li> <p><em>« »</em> (法语引号, UML 符号), 366</p> </li> <li> <p>=== (相等运算符), 36, 75, 229</p> </li> <li> <p>++ (递增运算符), 23, 117–118</p> </li> <li> <p>?int 可空整数类型, 96</p> </li> <li> <p>% (取模运算符), 21–22, 120</p> </li> <li> <p>/<em>..</em>/ (多行注释), 16</p> </li> <li> <ul> <li>(乘法运算符), 21</li> </ul> </li> <li> <p><> (不等于运算符), 36, 150</p> </li> <li> <p>!= (不等于运算符), 36, 150</p> </li> <li> <p>!==(不相等运算符),36,150</p> </li> <li> <p>!(NOT 运算符),36,70–71,122</p> </li> <li> <p>??(空合并运算符),77–78,305</p> </li> <li> <p>||(OR 运算符),39,70,72–73</p> </li> <li> <p>::(作用域解析运算符),372,478</p> </li> <li> <p>%20(空格替代字符),197</p> </li> <li> <p><==>(太空船运算符),38</p> </li> <li> <p>...(展开运算符),137,150–151</p> </li> <li> <p>[](方括号),126–127</p> </li> <li> <p>{%%} 语句在 Twig 中,406–407</p> </li> <li> <p>.(字符串连接运算符),35,44,84</p> </li> <li> <p>-(减法运算符),21</p> </li> <li> <p>?(三元运算符),39,76–77</p> </li> <li> <p>\u{<hex>}(Unicode 转义),48</p> </li> <li> <p>+(联合运算符),150</p> </li> <li> <p>A</p> </li> <li> <p>绝对文件路径,83</p> </li> <li> <p>抽象类,367,502,505,509</p> </li> <li> <p>抽象超类,498–499,526</p> </li> <li> <p>抽象,327</p> </li> <li> <p>abstract 关键字,367,437</p> </li> <li> <p>抽象方法,497,500–509,526</p> </li> <li> <p>访问控制逻辑,301</p> </li> <li> <p>访问器方法,341–342,363</p> </li> <li> <p>操作控制器,241</p> </li> <li> <p>加法、组合赋值和加法运算符(+=),122,124</p> </li> <li> <p>加法运算符(+),21</p> </li> <li> <p>add() 方法(DateTimeImmutable),639</p> </li> <li> <p>高级包装工具(APT),664</p> </li> <li> <p>AJAX(异步 JavaScript 和 XML),182</p> </li> <li> <p>别名,387</p> </li> <li> <p>替代的循环语法,122–123</p> </li> <li> <p>foreach 循环,214</p> </li> <li> <p>AMP(Apache HTTP Server,MySQL,PHP)安装,667</p> </li> <li> <p>和号(&),107,138</p> </li> <li> <p>AND 运算操作,70,71–72</p> </li> <li> <p>AND 运算符(&&),39,70</p> </li> <li> <p>匿名类,338</p> </li> <li> <p>匿名函数,153</p> </li> <li> <p>反模式,491</p> </li> <li> <p>应用程序编程接口(API),42</p> </li> <li> <p>API 密钥,473</p> </li> <li> <p>架构, web 应用, 177, 438</p> </li> <li> <p>参数, 55, 86</p> </li> <li> <p>命名的, 102</p> </li> <li> <p>算术表达式, 33–34</p> </li> <li> <p>臂, 76</p> </li> <li> <p>数组函数</p> </li> <li> <p>array(), 127</p> </li> <li> <p>array_flip(), 141</p> </li> <li> <p>array_is_list(), 132, 144</p> </li> <li> <p>array_key_last(), 132</p> </li> <li> <p>array_map(), 141, 153</p> </li> <li> <p>array_pop(), 130, 149</p> </li> <li> <p>array_push(), 129</p> </li> <li> <p>array_rand(), 141</p> </li> <li> <p>array_slice(), 141</p> </li> <li> <p>array_sum(), 137</p> </li> <li> <p>array_walk(), 141, 153</p> </li> <li> <p>数组, 28, 111, 119, 125</p> </li> <li> <p>访问键和值, 126, 134</p> </li> <li> <p>添加一个元素, 127</p> </li> <li> <p>基于数组的表单验证逻辑, 235–237</p> </li> <li> <p>复选框作为, 218–220</p> </li> <li> <p>组合, 150</p> </li> <li> <p>比较, 150</p> </li> <li> <p>复制, 137–139</p> </li> <li> <p>数据类型, 137</p> </li> <li> <p>默认映射, 126</p> </li> <li> <p>解构到变量中, 152</p> </li> <li> <p>元素, 126</p> </li> <li> <p>空的, 128</p> </li> <li> <p>ID 索引, 282</p> </li> <li> <p>imploding, 135</p> </li> <li> <p>整数键, 126</p> </li> <li> <p>负的, 139</p> </li> <li> <p>非顺序的, 144</p> </li> <li> <p>唯一的, 128</p> </li> <li> <p>键/值对, 144</p> </li> <li> <p>遍历, 133</p> </li> <li> <p>使用它管理多个验证错误, 227</p> </li> <li> <p>多维的, 147–148, 542</p> </li> <li> <p>非整数键, 129</p> </li> <li> <p>运算符, 150–151</p> </li> <li> <p>引用, 137–139</p> </li> <li> <p>简单的, 125</p> </li> <li> <p>精密的, 143</p> </li> <li> <p>作为栈, 131</p> </li> <li> <p>字符串键, 146</p> </li> <li> <p>超全局, 204, 265</p> </li> <li> <p>箭头函数, 154</p> </li> <li> <p>ASCII(美国信息交换标准代码), 59</p> </li> <li> <p>ASCII 艺术, 90</p> </li> <li> <p>赋值运算符 (=), 17, 22, 101, 124</p> </li> <li> <p>异步 JavaScript 和 XML (AJAX), 182</p> </li> <li> <p>ATOM 常量, 634</p> </li> <li> <p>扩展继承行为, 371–373</p> </li> <li> <p>认证,301–316, 533</p> </li> <li> <p>令牌,265</p> </li> <li> <p>授权,301, 303, 316</p> </li> <li> <p>自动递增,579</p> </li> <li> <p>自动加载器,360, 388–391</p> </li> <li> <p>类命名空间,388</p> </li> <li> <p>自动加载属性,389</p> </li> <li> <p>B</p> </li> <li> <p>后备枚举,494</p> </li> <li> <p>反斜杠转义字符(\),43</p> </li> <li> <p>转义(\),46</p> </li> <li> <p>基本模板,Twig,408</p> </li> <li> <p>块(Twig 关键字),416–419</p> </li> <li> <p>block() 函数,419</p> </li> <li> <p>代码块,15, 416</p> </li> <li> <p>布尔数据类型,28</p> </li> <li> <p>布尔值,21, 33</p> </li> <li> <p>表达式,66, 70</p> </li> <li> <p>标志,114, 116</p> </li> <li> <p>变量,72</p> </li> <li> <p>Bootstrap CSS,209, 304, 581</p> </li> <li> <p>Bootstrap 脚本,616–17</p> </li> <li> <p>大括号({}),47–48, 67, 85, 112</p> </li> <li> <p>双(Twig 输出),399</p> </li> <li> <p>break 语句,74, 116</p> </li> <li> <p>浏览器</p> </li> <li> <p>开发者工具,180, 203–204, 208</p> </li> <li> <p>会话,262–263</p> </li> <li> <p>暴力破解技术,608</p> </li> <li> <p>冒泡,454–456, 467</p> </li> <li> <p>缓冲区,输出,187</p> </li> <li> <p>内置类,382</p> </li> <li> <p>内置 Web 服务器,190</p> </li> <li> <p>C</p> </li> <li> <p>缓存,303, 488, 509, 616</p> </li> <li> <p>回调函数,153</p> </li> <li> <p>调用栈冒泡,454–456, 467</p> </li> <li> <p>驼峰命名法</p> </li> <li> <p>小写,19–20, 85, 216</p> </li> <li> <p>Pascal,338</p> </li> <li> <p>上层,338</p> </li> <li> <p>无法实例化抽象类错误消息,368</p> </li> <li> <p>大写,19, 37</p> </li> <li> <p>层叠样式表(CSS),181, 190, 209, 277, 291, 304, 309</p> </li> <li> <p>案例,19–20, 37, 53–54, 74, 338</p> </li> <li> <p>敏感度,19,57</p> </li> <li> <p>::cases() 静态方法,495</p> </li> <li> <p>类型转换,27,30,39</p> </li> <li> <p>捕获异常,446–447</p> </li> <li> <p>捕获语句,442</p> </li> <li> <p>通道,465–466</p> </li> <li> <p>复选框,216–220</p> </li> <li> <p>子模板,408,415</p> </li> <li> <p>chmod() 函数,163</p> </li> <li> <p>选择语句,66</p> </li> <li> <p>类,19,327,329,334,338–339</p> </li> <li> <p>抽象,367,502,505,509</p> </li> <li> <p>匿名,338</p> </li> <li> <p>内建,382</p> </li> <li> <p>常量,480</p> </li> <li> <p>自定义异常,441,451–453</p> </li> <li> <p>声明,337–348,374</p> </li> <li> <p>默认行为,346</p> </li> <li> <p>实体,535,619</p> </li> <li> <p>枚举,491</p> </li> <li> <p>辅助,622</p> </li> <li> <p>层级结构,360,436,504</p> </li> <li> <p>可互换性,526</p> </li> <li> <p>日志处理器,465,472</p> </li> <li> <p>成员,329</p> </li> <li> <p>模型,538,542,596,601</p> </li> <li> <p>名称,338</p> </li> <li> <p>仓库,556,570,595,622</p> </li> <li> <p>工具,482</p> </li> <li> <p>客户端/服务器通信,177,196–204</p> </li> <li> <p>代码块,15,416</p> </li> <li> <p>代码重用,522</p> </li> <li> <p>集合,111,351</p> </li> <li> <p>冒号(:),122</p> </li> <li> <p>组合赋值运算符</p> </li> <li> <p>加法(+=),122,124</p> </li> <li> <p>除法(/=),23</p> </li> <li> <p>指数运算(**=),23</p> </li> <li> <p>模运算(%=),23</p> </li> <li> <p>乘法(*=),23</p> </li> <li> <p>减法(-=),22</p> </li> <li> <p>命令行界面(CLI),4,6,9</p> </li> <li> <p>逗号分隔值(CSV),126,166</p> </li> <li> <p>注释,15–16</p> </li> <li> <p>比较表达式,35</p> </li> <li> <p>比较运算符,35–37</p> </li> <li> <p>编译型编程语言, 6</p> </li> <li> <p>Composer, 381, 386, 398, 673</p> </li> <li> <p>自动加载器, 389</p> </li> <li> <p>包依赖特性, 391</p> </li> <li> <p><em>composer.json</em> 配置文件, 386, 464</p> </li> <li> <p>复合数据类型, 125</p> </li> <li> <p>计算效率, 596</p> </li> <li> <p>连接赋值操作符 (.=), 45</p> </li> <li> <p>连接, 45, 126, 542</p> </li> <li> <p>连接, 字符串操作符 (.), 35, 44, 84</p> </li> <li> <p>条件循环, 112</p> </li> <li> <p>条件语句, 39, 65</p> </li> <li> <p>确认对话框, 570</p> </li> <li> <p>控制台, 5</p> </li> <li> <p>常量, 20</p> </li> <li> <p>计算, 481</p> </li> <li> <p>类, 480</p> </li> <li> <p>魔术, 83, 340, 346</p> </li> <li> <p>未定义, 18</p> </li> <li> <p>常量时间函数, 612</p> </li> <li> <p>__construct() 方法, 346</p> </li> <li> <p>构造方法, 346–348, 433</p> </li> <li> <p>构造器属性提升, 348</p> </li> <li> <p>continue 关键字, 119</p> </li> <li> <p>合同, 497</p> </li> <li> <p>控制器, 240–241</p> </li> <li> <p>动作, 241</p> </li> <li> <p>类, 428</p> </li> <li> <p>多个, 430–435</p> </li> <li> <p>前端, 185, 333, 428–430</p> </li> <li> <p>应用程序类作为, 409</p> </li> <li> <p>创建, 284</p> </li> <li> <p>更新, 320</p> </li> <li> <p>模型-视图-控制器, 177, 189, 240, 396, 535</p> </li> <li> <p>cookies, 263–264</p> </li> <li> <p>超时, 270</p> </li> <li> <p>协调世界时 (UTC), 641</p> </li> <li> <p>count_chars() 函数, 56</p> </li> <li> <p>计数器变量, 117</p> </li> <li> <p>count() 函数, 131</p> </li> <li> <p>createFromDateString() 方法, 639</p> </li> <li> <p>createFromFormat() 方法, 635</p> </li> <li> <p>创建对象, 339</p> </li> <li> <p>凭证, 数据库, 598</p> </li> <li> <p>安全, 615</p> </li> <li> <p>CRUD(创建,读取,更新,删除)数据库操作, 569, 585, 596</p> </li> <li> <p>大括号({}), 47, 67, 85, 112</p> </li> <li> <p>双精度, 399</p> </li> <li> <p>当前页面链接, 249, 251, 305, 420, 434</p> </li> <li> <p>自定义异常类, 441, 451–453</p> </li> <li> <p>自定义表单验证逻辑, 226</p> </li> <li> <p>自定义方法, 353</p> </li> <li> <p>D</p> </li> <li> <p>数据</p> </li> <li> <p>过滤, 205</p> </li> <li> <p>排序, 39</p> </li> <li> <p>源名称, 545–546</p> </li> <li> <p>结构, 299</p> </li> <li> <p>类型, 27–28, 75, 85, 88, 125, 137, 492</p> </li> <li> <p>验证, 233, 255</p> </li> <li> <p>数据库, 531</p> </li> <li> <p>抽象化低层工作, 596</p> </li> <li> <p>列, 620</p> </li> <li> <p>连接, 533, 581, 598</p> </li> <li> <p>并发, 534</p> </li> <li> <p>凭证, 596, 598</p> </li> <li> <p>基于数据库的应用程序, 529, 543, 569</p> </li> <li> <p>驱动, 533</p> </li> <li> <p>图片, 282, 460</p> </li> <li> <p>完整性, 532</p> </li> <li> <p>管理系统(DBMS), 533</p> </li> <li> <p>编程, 541</p> </li> <li> <p>查询, 535</p> </li> <li> <p>记录, 532</p> </li> <li> <p>关系型, 532</p> </li> <li> <p>模式, 533, 607</p> </li> <li> <p>安全, 595, 615</p> </li> <li> <p>表, 532</p> </li> <li> <p>日期类</p> </li> <li> <p>DateInterval, 639</p> </li> <li> <p>DatePeriod, 640</p> </li> <li> <p>DateTimeImmutable, 633–641</p> </li> <li> <p>日期函数</p> </li> <li> <p>date_default_timezone_get(), 643</p> </li> <li> <p>date_sun_info(), 647</p> </li> <li> <p>date.timezone, 642</p> </li> <li> <p>日期, 631</p> </li> <li> <p>操作, 637</p> </li> <li> <p>日期时间, 632–640</p> </li> <li> <p>字母代码, 635</p> </li> <li> <p>DATETIME MySQL 数据类型, 656</p> </li> <li> <p>DateTime 对象, 636</p> </li> <li> <p>夏令时, 631, 644</p> </li> <li> <p>调试, 15, 446</p> </li> <li> <p>小数位格式化, 100, 281, 288, 292, 434, 484, 564, 576, 589</p> </li> <li> <p>声明</p> </li> <li> <p>抽象类, 367</p> </li> <li> <p>抽象方法, 501</p> </li> <li> <p>数组键, 144</p> </li> <li> <p>数组, 126–127</p> </li> <li> <p>类, 337–348</p> </li> <li> <p>最终, 374</p> </li> <li> <p>枚举, 492</p> </li> <li> <p>异常, 452</p> </li> <li> <p>最终方法, 375</p> </li> <li> <p>函数, 82, 84–88</p> </li> <li> <p>接口, 505</p> </li> <li> <p>静态方法, 522</p> </li> <li> <p>特性, 522</p> </li> <li> <p>自减运算符 (--), 23</p> </li> <li> <p>解密, 608</p> </li> <li> <p>默认</p> </li> <li> <p>类行为, 346</p> </li> <li> <p>方法实现, 500</p> </li> <li> <p>路由, 185</p> </li> <li> <p>值, 101</p> </li> <li> <p>可见性, 342</p> </li> <li> <p>默认关键字, 74</p> </li> <li> <p>define() 函数, 21</p> </li> <li> <p>定界符, 49–50</p> </li> <li> <p>依赖管理, 391</p> </li> <li> <p>弃用消息, 88–89</p> </li> <li> <p>设计模式, 240</p> </li> <li> <p>开发者工具, 浏览器, 180, 203–204, 208</p> </li> <li> <p>die() 函数, 234</p> </li> <li> <p>diff() 方法, 639</p> </li> <li> <p><strong>DIR</strong> 常量, 83, 340</p> </li> <li> <p>目录, 157</p> </li> <li> <p>确认存在性, 161</p> </li> <li> <p>创建, 161</p> </li> <li> <p>递归创建, 162</p> </li> <li> <p>删除, 165</p> </li> <li> <p>路径, 83</p> </li> <li> <p>数组的, 169</p> </li> <li> <p>权限, 163</p> </li> <li> <p>重命名, 165</p> </li> <li> <p>除法, 组合赋值和除法运算符 (/=), 23</p> </li> <li> <p>除法运算符 (/), 21–23</p> </li> <li> <p>Doctrine ORM 库, 595, 615</p> </li> <li> <p>bootstrap 脚本, 616–17</p> </li> <li> <p>美元符号 ($), 17–18, 20</p> </li> <li> <p>不重复自己 (DRY) 原则, 82</p> </li> <li> <p>dotenv 文件, 598</p> </li> <li> <p>Dotenv 对象, 617</p> </li> <li> <p>点符号表示法, 403</p> </li> <li> <p>双箭头运算符 (=>), 76, 135, 144, 329</p> </li> <li> <p>双关键字, 29</p> </li> <li> <p>双精度格式, 29</p> </li> <li> <p>双引号字符串,41,46–48</p> </li> <li> <p>对象访问,341</p> </li> <li> <p>do...while 循环,111,113</p> </li> <li> <p>DriverManager::getConnection() 方法,619</p> </li> <li> <p>删除表,SQL,607</p> </li> <li> <p>DSN(数据源名称),545–546</p> </li> <li> <p>动态网页服务器,183</p> </li> <li> <p>E</p> </li> <li> <p>echo 命令,5–7,10</p> </li> <li> <p>效率,计算效率,596</p> </li> <li> <p>元素,附加,127</p> </li> <li> <p>elseif 语句,68</p> </li> <li> <p>else 语句,67</p> </li> <li> <p>Twig 中的{% else %}语句,407</p> </li> <li> <p>表情符号,48</p> </li> <li> <p>空数组,128</p> </li> <li> <p>empty() 函数,93,227</p> </li> <li> <p>封装,331</p> </li> <li> <p>加密,608</p> </li> <li> <p>endblock 语句,416</p> </li> <li> <p>endfor 语句,123</p> </li> <li> <p>endif 语句,69</p> </li> <li> <p>实体类,535,619</p> </li> <li> <p>EntityManager 类,617</p> </li> <li> <p>实体-关系(ER)模型,535–536,544</p> </li> <li> <p>枚举(enums),475,491–492</p> </li> <li> <p>后备,494</p> </li> <li> <p>类,491</p> </li> <li> <p>值,494</p> </li> <li> <p>.<em>env</em> 文件,598</p> </li> <li> <p>环境变量对话框,665</p> </li> <li> <p>EOT(文本结束)分隔符,49</p> </li> <li> <p>纪元,646</p> </li> <li> <p>等值测试,36,74</p> </li> <li> <p>等于运算符(==),35–36,74–75</p> </li> <li> <p>等号(=),17</p> </li> <li> <p>实体-关系(ER)模型,535–536,544</p> </li> <li> <p>图表,536</p> </li> <li> <p>error_log() 函数,462</p> </li> <li> <p>error() 方法,465</p> </li> <li> <p>错误</p> </li> <li> <p>代码,178–179,192</p> </li> <li> <p>致命错误,87–88,350,454</p> </li> <li> <p>处理,441</p> </li> <li> <p>消息,160,368,575,587,611</p> </li> <li> <p>多重验证,227</p> </li> <li> <p>页,243,256</p> </li> <li> <p>转义反斜杠(\),46</p> </li> <li> <p>转义字符(\),43</p> </li> <li> <p>日期时间格式化,635</p> </li> <li> <p>转义双引号("),43</p> </li> <li> <p>转义换行符 (\n),46,52,59,60,170</p> </li> <li> <p>转义单引号 ('),43,59</p> </li> <li> <p>转义制表符 (\t),46 60</p> </li> <li> <p>转义序列,46–48,50</p> </li> <li> <p>Unicode,48</p> </li> <li> <p>表达式的求值,17</p> </li> <li> <p>异常对象,442,471,619</p> </li> <li> <p>异常,441–457,460</p> </li> <li> <p>冒泡,454–456,467</p> </li> <li> <p>内置,449–451</p> </li> <li> <p>捕获,446–447</p> </li> <li> <p>自定义,441,451–453</p> </li> <li> <p>finally 语句,447–448</p> </li> <li> <p>日志记录,469</p> </li> <li> <p>多个类,449–454</p> </li> <li> <p>子类,456</p> </li> <li> <p>抛出,442–446</p> </li> <li> <p>未捕获,442–446</p> </li> <li> <p>排除或 (XOR) 运算,70,73</p> </li> <li> <p>执行,停止,442</p> </li> <li> <p>指数运算,结合赋值和指数运算符 (**=),23</p> </li> <li> <p>指数运算符 (**),21</p> </li> <li> <p>表达式,17,21–24,32–35</p> </li> <li> <p>布尔值,66,70</p> </li> <li> <p>比较,35</p> </li> <li> <p>求值,17</p> </li> <li> <p>字符串,30,35,44,52,350</p> </li> <li> <p>extends 关键字,359,438</p> </li> <li> <p>Twig,418</p> </li> <li> <p>可扩展标记语言 (XML),173,599</p> </li> <li> <p>F</p> </li> <li> <p>致命错误,87–88,350,454</p> </li> <li> <p>网站图标,267</p> </li> <li> <p>FILE_APPEND 选项,164</p> </li> <li> <p>文件函数</p> </li> <li> <p>fclose() 函数,167</p> </li> <li> <p>feof() 函数,168</p> </li> <li> <p>fgetc() 函数,168</p> </li> <li> <p>fgetcsv() 函数,173</p> </li> <li> <p>fgets() 函数,168</p> </li> <li> <p>file_exists() 函数,160</p> </li> <li> <p>file() 函数,166</p> </li> <li> <p>file_get_contents() 函数,158,166</p> </li> <li> <p>file_put_contents() 函数,163,166</p> </li> <li> <p>filesize() 函数,159</p> </li> <li> <p>fopen() 函数,167</p> </li> <li> <p>fputcsv() 函数,173</p> </li> <li> <p>fread() 函数,167</p> </li> <li> <p>fseek() 函数,168</p> </li> <li> <p>ftell() 函数,168</p> </li> <li> <p>FILE_IGNORE_NEW_LINES 标志,166</p> </li> <li> <p>文件名模式字符串,170</p> </li> <li> <p>文件未找到消息,160</p> </li> <li> <p>文件,157</p> </li> <li> <p>路径数组,169</p> </li> <li> <p>字节流,167</p> </li> <li> <p>关闭,168</p> </li> <li> <p>确认存在性,159</p> </li> <li> <p>删除,164</p> </li> <li> <p>结束,168</p> </li> <li> <p>扩展,10</p> </li> <li> <p>权限,163</p> </li> <li> <p>读取,158</p> </li> <li> <p>转换为数组,166</p> </li> <li> <p>重命名,165</p> </li> <li> <p>大小,159</p> </li> <li> <p>文本,158,177,190,387,599</p> </li> <li> <p>命名,5</p> </li> <li> <p>写入,163–164</p> </li> <li> <p>触碰,161</p> </li> <li> <p>.<em>txt</em>,157</p> </li> <li> <p>文件服务器,182</p> </li> <li> <p>FILE_SKIP_EMPTY_LINES 标志,166</p> </li> <li> <p>文件系统指针,167</p> </li> <li> <p>FILTER_DEFAULT 值,220</p> </li> <li> <p>filter_has_var() 函数,210,217</p> </li> <li> <p>filter_input() 函数,201,203–204,226,243</p> </li> <li> <p>FILTER_REQUIRE_ARRAY 参数,220,223</p> </li> <li> <p>FILTER_SANITIZE_SPECIAL_CHARS 参数,205</p> </li> <li> <p>最终声明,374–376</p> </li> <li> <p>finally 语句,447–448</p> </li> <li> <p>findstr 命令,463</p> </li> <li> <p>Flaticon(网站),655</p> </li> <li> <p>浮动数据类型,28–29</p> </li> <li> <p>浮点数,27,29</p> </li> <li> <p>控制流,333</p> </li> <li> <p>foreach 循环,111,134–136,145</p> </li> <li> <p>外键,532</p> </li> <li> <p>Doctrine 中的关系,624</p> </li> <li> <p>for 循环,111,117–122,133</p> </li> <li> <p>最后一轮迭代,处理,120–122</p> </li> <li> <p>表单操作</p> </li> <li> <p>避免重复表单提交,590</p> </li> <li> <p>默认,234</p> </li> <li> <p>处理数据,201</p> </li> <li> <p>验证,225–226,231–233,235</p> </li> <li> <p>format() 方法,634–635</p> </li> <li> <p>for 语句,Twig,406</p> </li> <li> <p>前端控制器,185,333,428–430</p> </li> <li> <p>应用类,409</p> </li> <li> <p>创建, 284</p> </li> <li> <p>更新, 320</p> </li> <li> <p>完全限定名, 548</p> </li> <li> <p>命名空间类, 383</p> </li> <li> <p>函数, 10–11, 17, 21, 81, 85. <em>另见</em> 单个函数的名称</p> </li> <li> <p>匿名, 153</p> </li> <li> <p>回调, 153</p> </li> <li> <p>调用, 82, 86</p> </li> <li> <p>声明, 82, 84–88</p> </li> <li> <p>helper, 297</p> </li> <li> <p>高阶, 153</p> </li> <li> <p>将网站逻辑移至, 245</p> </li> <li> <p>参数, 85–86</p> </li> <li> <p>返回类型, 85</p> </li> <li> <p>范围, 107</p> </li> <li> <p>签名, 85, 97</p> </li> <li> <p>可变数量的参数, 136</p> </li> <li> <p>网站逻辑, 245</p> </li> <li> <p>G</p> </li> <li> <p>泛化, 332, 358</p> </li> <li> <p>地理编码, 652</p> </li> <li> <p>$_GET 数组, 204</p> </li> <li> <p>GET HTTP 方法, 178–181</p> </li> <li> <p>方法表单, 200–202</p> </li> <li> <p>请求, 197</p> </li> <li> <p>getInstance() 函数, 488</p> </li> <li> <p><em>«get/set»</em> 注释, 366</p> </li> <li> <p>getter 方法, 342, 354, 363, 476</p> </li> <li> <p>Twig, 403</p> </li> <li> <p>getTimestamp() 函数, 646</p> </li> <li> <p>getTimezone() 函数, 643</p> </li> <li> <p>gettype() 函数, 29</p> </li> <li> <p>Git, 599</p> </li> <li> <p>.<em>gitignore</em> 文件, 615</p> </li> <li> <p>全局变量, 82</p> </li> <li> <p>全局可见性, 491</p> </li> <li> <p>glob() 函数, 169</p> </li> <li> <p>Glyphicon 星星, 280</p> </li> <li> <p>大于运算符 (>), 35, 37, 119, 188</p> </li> <li> <p>大于或等于 (>=) 运算符, 37</p> </li> <li> <p>grep 函数, 463</p> </li> <li> <p>尖括号,UML 符号 (<em>« »</em>), 366</p> </li> <li> <p>GuzzleHttp 命名空间, 651</p> </li> <li> <p>Guzzle 库, 648</p> </li> <li> <p>H</p> </li> <li> <p>停止执行, 442</p> </li> <li> <p>硬编码</p> </li> <li> <p>数据库凭证, 596</p> </li> <li> <p>引用, 521</p> </li> <li> <p>值, 90</p> </li> <li> <p>哈希, 596, 608–609</p> </li> <li> <p>海量字符串, 55</p> </li> <li> <p>HEAD HTTP 方法, 179</p> </li> <li> <p>“Hello, world!” 脚本, 5–6, 15</p> </li> <li> <p>helper</p> </li> <li> <p>类, 622</p> </li> <li> <p>函数, 297</p> </li> <li> <p>方法们,591</p> </li> <li> <p>脚本,606</p> </li> <li> <p>heredocs,49–52</p> </li> <li> <p>运算符,49</p> </li> <li> <p>字符串,41,49,163</p> </li> <li> <p>缩进,50</p> </li> <li> <p>十六进制代码,48</p> </li> <li> <p>隐藏变量,577</p> </li> <li> <p>隐藏信息,331</p> </li> <li> <p>高阶函数,153</p> </li> <li> <p>高亮显示当前页面,249,251,305,420,434</p> </li> <li> <p>访问计数器,266</p> </li> <li> <p>Homebrew 包管理器,663</p> </li> <li> <p>.<em>htaccess</em> 文件,264</p> </li> <li> <p>HTML(超文本标记语言),7,49</p> </li> <li> <p>模板文本,244</p> </li> <li> <p>HTTP(超文本传输协议),20,178</p> </li> <li> <p>cookies,263–264</p> </li> <li> <p>头部,181</p> </li> <li> <p>方法,204</p> </li> <li> <p>请求,178,195,197</p> </li> <li> <p>响应,178</p> </li> <li> <p>状态码,179</p> </li> <li> <p>安全(HTTPS),181</p> </li> <li> <p>http_build_query() 函数,652</p> </li> <li> <p>超链接</p> </li> <li> <p>动态创建,213</p> </li> <li> <p>编码数据,211</p> </li> <li> <p>I</p> </li> <li> <p>IDE(集成开发环境),8</p> </li> <li> <p>相同代码,435</p> </li> <li> <p>恒等运算符(===),36,75,229</p> </li> <li> <p>标识符,16</p> </li> <li> <p>身份,36,75,150</p> </li> <li> <p>ID 索引数组,282</p> </li> <li> <p>id 属性,603</p> </li> <li> <p>if...else 语句,67</p> </li> <li> <p>if...elseif,68</p> </li> <li> <p>if...elseif...else,69</p> </li> <li> <p>嵌套,68</p> </li> <li> <p>if 语句,66</p> </li> <li> <p>替代语法,69</p> </li> <li> <p>在 Twig 中,406</p> </li> <li> <p>图像</p> </li> <li> <p>数据库,282,460</p> </li> <li> <p>徽标,306</p> </li> <li> <p>implode() 函数,122,135,220</p> </li> <li> <p>include 命令,83</p> </li> <li> <p>Twig,414</p> </li> <li> <p>增量运算符(++),23,117–118</p> </li> <li> <p>heredoc 字符串中的缩进,50</p> </li> <li> <p>索引,从零开始,54</p> </li> <li> <p><em>index.php</em> 文件,7,11–13</p> </li> <li> <p>无限循环,123</p> </li> <li> <p>信息披露,最小化,314</p> </li> <li> <p>信息隐藏,331</p> </li> <li> <p>继承,331,357,427,607</p> </li> <li> <p>增强行为,371–373</p> </li> <li> <p>多重,503–504</p> </li> <li> <p>面向对象编程(OOP),435</p> </li> <li> <p>层次结构,497</p> </li> <li> <p>Twig,415</p> </li> <li> <p>INI 文件,664</p> </li> <li> <p>INPUT_GET 参数,201</p> </li> <li> <p>INPUT_POST 参数,203</p> </li> <li> <p>输入提示,112</p> </li> <li> <p>INSERT 语句,660</p> </li> <li> <p>安装 PHP,663–667</p> </li> <li> <p>扩展,667</p> </li> <li> <p>实例,328,339</p> </li> <li> <p>实例化,339,479,498</p> </li> <li> <p>实例级别</p> </li> <li> <p>成员,508</p> </li> <li> <p>属性,478</p> </li> <li> <p>insteadof 关键字,525</p> </li> <li> <p>int 数据类型,28</p> </li> <li> <p>整数,27–28</p> </li> <li> <p>集成开发环境(IDE),8</p> </li> <li> <p>交互模式,28</p> </li> <li> <p>接口,497,502</p> </li> <li> <p>多重扩展,509</p> </li> <li> <p>实现,506–507</p> </li> <li> <p>解释器,6</p> </li> <li> <p>?int 可空整数类型,96</p> </li> <li> <p>intval() 函数,229</p> </li> <li> <p>InvalidArgumentException 类,449</p> </li> <li> <p>is_datatype 函数</p> </li> <li> <p>is_bool() 函数,31</p> </li> <li> <p>is_dir() 函数,161</p> </li> <li> <p>is_float() 函数,31</p> </li> <li> <p>is_int() 函数,31</p> </li> <li> <p>is_null() 函数,31</p> </li> <li> <p>is_numeric() 函数,32,228</p> </li> <li> <p>ISO 8601 标准,632</p> </li> <li> <p>isset() 函数,132,204,305</p> </li> <li> <p>is_string() 函数,31</p> </li> <li> <p>迭代器($i)变量,117</p> </li> <li> <p>J</p> </li> <li> <p>JavaScript 对象表示法(JSON),171,387,514,655</p> </li> <li> <p>JSON 格式的字符串,172</p> </li> <li> <p>json_decode() 函数,172</p> </li> <li> <p>json_encode() 函数,172</p> </li> <li> <p>K</p> </li> <li> <p>键</p> </li> <li> <p>访问,126,134</p> </li> <li> <p>API,473</p> </li> <li> <p>数组,125</p> </li> <li> <p>数据库,532</p> </li> <li> <p>外键,532,624</p> </li> <li> <p>整数,126</p> </li> <li> <p>负数,139</p> </li> <li> <p>非顺序,144</p> </li> <li> <p>唯一,128</p> </li> <li> <p>非整数,129</p> </li> <li> <p>主, 532</p> </li> <li> <p>字符串, 146</p> </li> <li> <p>键值映射, 126</p> </li> <li> <p>L</p> </li> <li> <p>最后一次迭代,处理, 120–122</p> </li> <li> <p>lcfirst() 函数, 54</p> </li> <li> <p>前导数字字符串, 34, 38</p> </li> <li> <p>拉斯穆斯·勒多夫, 632</p> </li> <li> <p>小于 (<) 运算符, 37, 119</p> </li> <li> <p>小于或等于 (<=) 运算符, 37, 119</p> </li> <li> <p>Linux, 664</p> </li> <li> <p>利斯科夫替代原则 (LSP), 370–371</p> </li> <li> <p>列表, 132, 144</p> </li> <li> <p>多重选择, 221</p> </li> <li> <p>单一选择, 220</p> </li> <li> <p>字面值, 17, 21</p> </li> <li> <p>负载均衡, 534</p> </li> <li> <p>本地主机, 12</p> </li> <li> <p>局部变量, 87, 107</p> </li> <li> <p>日志文件, 462–463, 467</p> </li> <li> <p>日志记录, 446, 459–460, 472–473</p> </li> <li> <p>云, 472</p> </li> <li> <p>接口, 464–465, 509</p> </li> <li> <p>Logger 类, 464, 466–474, 485–491, 509</p> </li> <li> <p>日志处理类, 465, 472</p> </li> <li> <p>逻辑比较运算符, 39, 70–73, 122</p> </li> <li> <p>登录</p> </li> <li> <p>认证令牌, 265</p> </li> <li> <p>表单, 301</p> </li> <li> <p>使用会话存储数据, 316</p> </li> <li> <p>用户名,显示, 321</p> </li> <li> <p>验证哈希密码, 608–609</p> </li> <li> <p>Logo 图像, 306</p> </li> <li> <p>注销功能, 319–321</p> </li> <li> <p>LOG_WARNING 常量, 463</p> </li> <li> <p>棒棒糖表示法, 504–505</p> </li> <li> <p>循环, 111</p> </li> <li> <p>替代语法, 122–123</p> </li> <li> <p>通过数组, 133</p> </li> <li> <p>条件, 112</p> </li> <li> <p>do...while, 111, 113</p> </li> <li> <p>for, 111, 117–122, 133</p> </li> <li> <p>foreach, 111, 134–136, 145</p> </li> <li> <p>无限, 123</p> </li> <li> <p>Twig, 433</p> </li> <li> <p>松耦合, 526</p> </li> <li> <p>小驼峰命名法, 19–20, 85, 216</p> </li> <li> <p>小写字母, 37, 53–54</p> </li> <li> <p>低级代码, 596</p> </li> <li> <p>ltrim() 函数, 59</p> </li> <li> <p>M</p> </li> <li> <p>macOS,663</p> </li> <li> <p>魔法常量,83,340,346</p> </li> <li> <p>魔法方法,346</p> </li> <li> <p>多对一关系,626</p> </li> <li> <p>映射,键值,126</p> </li> <li> <p>匹配语句,75–76,96–99</p> </li> <li> <p>类的成员,329</p> </li> <li> <p>消息</p> </li> <li> <p>弃用,88–89</p> </li> <li> <p>错误,160,368,575,587,611</p> </li> <li> <p>对象之间,327–328</p> </li> <li> <p>弹出确认,577</p> </li> <li> <p>警告,17</p> </li> <li> <p>元数据,596,619–620</p> </li> <li> <p>标签,619</p> </li> <li> <p>方法,329,339</p> </li> <li> <p>抽象,497,500–509,526</p> </li> <li> <p>访问器,341–342,363</p> </li> <li> <p>构造函数,346–348,433</p> </li> <li> <p>自定义,353</p> </li> <li> <p>声明为 final,375</p> </li> <li> <p>默认实现,500</p> </li> <li> <p>辅助函数,591</p> </li> <li> <p>魔法,346</p> </li> <li> <p>重写,333,357,368–376,498</p> </li> <li> <p>签名,370,497,527</p> </li> <li> <p>静态,480</p> </li> <li> <p>最小信息披露,314</p> </li> <li> <p>混入,522</p> </li> <li> <p>mkdir() 函数,161</p> </li> <li> <p>模型,240–241</p> </li> <li> <p>类,538,542,596,601</p> </li> <li> <p>模型-视图-控制器(MVC)架构,177,189,240,396,535</p> </li> <li> <p>modify() 函数,636</p> </li> <li> <p>取模,组合赋值和取模运算符(%=),23</p> </li> <li> <p>取模运算符(%),21–22,120</p> </li> <li> <p>Monolog 库,464</p> </li> <li> <p>多维数组,147–148,542</p> </li> <li> <p>多行注释(/<em>..</em>/),16</p> </li> <li> <p>多个属性,221</p> </li> <li> <p>多个控制器类,430–435</p> </li> <li> <p>多个异常类,449–454</p> </li> <li> <p>多重继承,503–504</p> </li> <li> <p>多个接口,扩展,509</p> </li> <li> <p>多级继承,361</p> </li> <li> <p>多个返回类型, 95</p> </li> <li> <p>多选列表, 221</p> </li> <li> <p>多个验证错误, 227</p> </li> <li> <p>乘法运算符 (*), 21</p> </li> <li> <p>MVC(模型-视图-控制器架构), 177, 189, 240, 396, 535</p> </li> <li> <p>MySQL, 533, 542, 667, 669</p> </li> <li> <p>日期, 655</p> </li> <li> <p>服务器, 599</p> </li> <li> <p>MYSQL_DATE_FORMAT_STRING 常量, 658</p> </li> <li> <p>N</p> </li> <li> <p>\n (换行符), 5, 10, 14–15, 42–43, 46, 158</p> </li> <li> <p>命名参数, 102</p> </li> <li> <p>命名空间, 381–384</p> </li> <li> <p>完全限定名, 383</p> </li> <li> <p>引用, 384–385</p> </li> <li> <p>根目录, 382, 444</p> </li> <li> <p>名称/值对, 198</p> </li> <li> <p>命名冲突, 381</p> </li> <li> <p>导航栏, 396</p> </li> <li> <p>查找字符串, 55</p> </li> <li> <p>新关键字, 339, 346</p> </li> <li> <p>Nominatim, 652</p> </li> <li> <p>非数字字符, 32–34</p> </li> <li> <p>非数字字符串, 35</p> </li> <li> <p>不等于运算符 (!=), 36, 150</p> </li> <li> <p>不等于运算符 (<>), 36, 150</p> </li> <li> <p>非严格相等运算符 (!==), 36, 150</p> </li> <li> <p>NOT 运算符 (!), 36, 70–71, 122</p> </li> <li> <p>nowdoc 字符串, 41, 52–53</p> </li> <li> <p>可空</p> </li> <li> <p>参数, 97</p> </li> <li> <p>类型, 95–98</p> </li> <li> <p>空合并运算符 (??), 77–78, 305</p> </li> <li> <p>NULL 类型, 28, 30–31, 37, 77–78</p> </li> <li> <p>连接, 573</p> </li> <li> <p>引用丢失的对象, 351–352</p> </li> <li> <p>返回, 91</p> </li> <li> <p>number_format 过滤器, 434, 564–565, 576, 589</p> </li> <li> <p>number_format() 函数, 100, 281, 288, 292, 481, 484</p> </li> <li> <p>数字</p> </li> <li> <p>字符, 32–34</p> </li> <li> <p>比较, 36</p> </li> <li> <p>字符串, 34, 38</p> </li> <li> <p>O</p> </li> <li> <p>对象获取模式,542,548</p> </li> <li> <p>对象运算符(->),45,329,339–340</p> </li> <li> <p>面向对象</p> </li> <li> <p>PHP,325,427</p> </li> <li> <p>编程,19–20,327,427</p> </li> <li> <p>继承,435,497</p> </li> <li> <p>网络应用,402,438</p> </li> <li> <p>对象关系映射(ORM),595–596,615</p> </li> <li> <p>对象,28,31,327,339</p> </li> <li> <p>资源,167</p> </li> <li> <p>在线编程环境,4</p> </li> <li> <p>开源项目,598</p> </li> <li> <p>OpenStreetMap,648</p> </li> <li> <p>操作数,21,32</p> </li> <li> <p>运算符,21</p> </li> <li> <p>算术,21–22,33</p> </li> <li> <p>数组,150–151</p> </li> <li> <p>赋值,17,101,124</p> </li> <li> <p>组合算术赋值,22–23</p> </li> <li> <p>比较,35</p> </li> <li> <p>串联赋值,45</p> </li> <li> <p>自减,23</p> </li> <li> <p>相等,35–36</p> </li> <li> <p>相同,36,75</p> </li> <li> <p>自增,23</p> </li> <li> <p>逻辑与,39,70,71–72</p> </li> <li> <p>逻辑非,36,70–71,122</p> </li> <li> <p>逻辑或,39</p> </li> <li> <p>空合并,77–78,305</p> </li> <li> <p>优先级顺序,22</p> </li> <li> <p>字符串连接,44</p> </li> <li> <p>三元,39,76–77</p> </li> <li> <p>一元,23</p> </li> <li> <p>可选参数,100</p> </li> <li> <p>ORM(对象关系映射),595–596,615</p> </li> <li> <p>或操作,70</p> </li> <li> <p>或运算符(||),39,70,72–73</p> </li> <li> <p>输出缓冲区,187</p> </li> <li> <p>重写</p> </li> <li> <p>方法,333,357,368–376,498</p> </li> <li> <p>防止,374</p> </li> <li> <p>Twig,417,434</p> </li> <li> <p>P</p> </li> <li> <p>Packagist(网站),392</p> </li> <li> <p>填充,61</p> </li> <li> <p>页面生成逻辑,430</p> </li> <li> <p>页面头部 HTML 模板,287</p> </li> <li> <p>参数,85–86</p> </li> <li> <p>可空,97</p> </li> <li> <p>可选的,100</p> </li> <li> <p>跳过,104</p> </li> <li> <p>括号(()),22,71</p> </li> <li> <p>parent 关键字,371</p> </li> <li> <p>解析字符串,35,46</p> </li> <li> <p>部分模板,287,414</p> </li> <li> <p>Pascal 命名法,338</p> </li> <li> <p>传值引用方法,105,130</p> </li> <li> <p>传值传递方法,105</p> </li> <li> <p>password_hash() 函数,608</p> </li> <li> <p>密码,112,601</p> </li> <li> <p>哈希,596</p> </li> <li> <p>登录时验证,608–609</p> </li> <li> <p>文本字段,302</p> </li> <li> <p>password_verify() 函数,609</p> </li> <li> <p>PATH 环境变量,665</p> </li> <li> <p>PATH_TO_TEMPLATES 常量,400</p> </li> <li> <p>pdo-crud-for-free-repositories 库,598</p> </li> <li> <p>权限,文件和目录,163</p> </li> <li> <p>PHP</p> </li> <li> <p>数据对象,541,670,675</p> </li> <li> <p>引擎,6,11,15</p> </li> <li> <p>扩展社区库,509</p> </li> <li> <p>扩展,667</p> </li> <li> <p>超文本预处理器,xxvi</p> </li> <li> <p>安装,663–667</p> </li> <li> <p>库,392</p> </li> <li> <p>面向对象,325,427</p> </li> <li> <p>标准推荐,81</p> </li> <li> <p>PSR-3,464</p> </li> <li> <p>PSR-4,388–389</p> </li> <li> <p>PSR-6,510</p> </li> <li> <p>PSR-16,510</p> </li> <li> <p>标签,187</p> </li> <li> <p>PHP_EOL 常量,44,47</p> </li> <li> <p>phpinfo() 函数,11</p> </li> <li> <p><em>php.ini</em> 文件,264,462,533,642</p> </li> <li> <p>加号(+),21</p> </li> <li> <p>弹出(栈),131</p> </li> <li> <p>弹出确认消息,577</p> </li> <li> <p>端口,12</p> </li> <li> <p>$_POST 数组,204</p> </li> <li> <p>回调脚本,230</p> </li> <li> <p>PostgreSQL,629</p> </li> <li> <p>POST HTTP 方法,178,291,571</p> </li> <li> <p>方法表单,202–204</p> </li> <li> <p>请求,197</p> </li> <li> <p>后增量值,24</p> </li> <li> <p>后-重定向-获取(PRG)模式,590</p> </li> <li> <p>幂运算符(**),21</p> </li> <li> <p>优先级,22,70–71</p> </li> <li> <p>前增量值,24</p> </li> <li> <p>预处理语句, 542, 545</p> </li> <li> <p>主键, 532</p> </li> <li> <p>print_r() 函数, 150</p> </li> <li> <p>打印语句, 10, 14</p> </li> <li> <p>print_timestamp() 函数, 646</p> </li> <li> <p>私有构造函数, 488</p> </li> <li> <p>private 关键字, 335, 362</p> </li> <li> <p>私有属性, 341</p> </li> <li> <p>过程式编程, 327</p> </li> <li> <p>进程 ID, 463</p> </li> <li> <p>处理表单数据, 201</p> </li> <li> <p>项目结构</p> </li> <li> <p>目录, 190</p> </li> <li> <p>文件, 304</p> </li> <li> <p>安全, 245</p> </li> <li> <p>类的属性, 329, 338–339</p> </li> <li> <p>protected 关键字, 342, 362, 436</p> </li> <li> <p>伪变量, 343</p> </li> <li> <p>PSR(PHP 标准推荐),81</p> </li> <li> <p>PSR-3, 464</p> </li> <li> <p>PSR-4, 388–389</p> </li> <li> <p>PSR-6, 510</p> </li> <li> <p>PSR-16, 510</p> </li> <li> <p>public 关键字, 335, 338, 362</p> </li> <li> <p>公共属性, 338–339, 341</p> </li> <li> <p>推送(栈), 131</p> </li> <li> <p>Q</p> </li> <li> <p>合格的名称, 384</p> </li> <li> <p>查询字符串变量, 197–198</p> </li> <li> <p>硬编码的 ID, 207</p> </li> <li> <p>超链接中, 206</p> </li> <li> <p>引号, 14, 21, 30, 88</p> </li> <li> <p>双引号(""), 6, 41, 50</p> </li> <li> <p>转义, 43</p> </li> <li> <p>单引号(''), 41–43</p> </li> <li> <p>R</p> </li> <li> <p>单选按钮, 215</p> </li> <li> <p>读取-评估-打印循环(REPL), 4</p> </li> <li> <p>readline() 函数, 113</p> </li> <li> <p>数据库中的记录, 532</p> </li> <li> <p>重定向, 580, 590</p> </li> <li> <p>引用, 传引用方法, 105, 130</p> </li> <li> <p>引用运算符(&), 138</p> </li> <li> <p>引用, 31, 329, 339, 351</p> </li> <li> <p>转换为数组, 137–139</p> </li> <li> <p>硬编码, 521</p> </li> <li> <p>引用命名空间, 384–385</p> </li> <li> <p>反射, 598</p> </li> <li> <p>关系型数据库, 532</p> </li> <li> <p>管理系统, 533</p> </li> <li> <p>关系</p> </li> <li> <p>数据库, 532</p> </li> <li> <p>ER 模型, 536</p> </li> <li> <p>对象之间, 330</p> </li> <li> <p>相对路径, 83</p> </li> <li> <p>rename() 函数, 165</p> </li> <li> <p>render() 函数, 397</p> </li> <li> <p>Replit, 673</p> </li> <li> <p><em>replit.nix</em> 文件, 674</p> </li> <li> <p>仓库类, 556, 570, 595, 622</p> </li> <li> <p>REQUEST_METHOD 关键字, 232</p> </li> <li> <p>请求-响应周期, HTTP, 178</p> </li> <li> <p>请求评论 (RFC), 89</p> </li> <li> <p>RFC 5424 等级, 460</p> </li> <li> <p>require 命令, 83</p> </li> <li> <p>require_once 命令, 82–84</p> </li> <li> <p>资源消耗操作, 488</p> </li> <li> <p>资源对象, 167</p> </li> <li> <p>返回语句, 86, 91</p> </li> <li> <p>返回类型, 85, 347</p> </li> <li> <p>多重, 95</p> </li> <li> <p>可重用性, 82</p> </li> <li> <p>反向地理编码, 652</p> </li> <li> <p>rewind() 函数, 168</p> </li> <li> <p>rmdir() 函数, 165</p> </li> <li> <p>根命名空间, 382, 444</p> </li> <li> <p>向下舍入, 40</p> </li> <li> <p>路由, 184–185</p> </li> <li> <p>rtrim() 函数, 59</p> </li> <li> <p>运行时通知, 88</p> </li> <li> <p>S</p> </li> <li> <p>数据清理, 205</p> </li> <li> <p>可扩展的 Web 应用程序, 239</p> </li> <li> <p>标量数据类型, 28, 39, 147, 150</p> </li> <li> <p>架构, 数据库, 533, 607</p> </li> <li> <p>范围</p> </li> <li> <p>函数的, 107</p> </li> <li> <p>变量的, 87, 247</p> </li> <li> <p>范围解析运算符 (:😃, 372, 478</p> </li> <li> <p>脚本, 5–6, 15, 388</p> </li> <li> <p>bootstrap, 623</p> </li> <li> <p>辅助工具, 606</p> </li> <li> <p>回调, 230</p> </li> <li> <p>安全性, 301</p> </li> <li> <p>最佳实践, 608</p> </li> <li> <p>凭证, 189</p> </li> <li> <p>数据库, 595, 615</p> </li> <li> <p>项目文件夹结构的, 245</p> </li> <li> <p>Twig, 399</p> </li> <li> <p>漏洞, 446</p> </li> <li> <p>self:: 前缀, 401, 478</p> </li> <li> <p>分号, 6</p> </li> <li> <p>敏感性, 大小写, 19, 57</p> </li> <li> <p>哨兵值, 478</p> </li> <li> <p>分离显示和逻辑文件, 242</p> </li> <li> <p>$_SERVER 数组, 232</p> </li> <li> <p>基于服务器的数据库管理系统 (DBMS), 533</p> </li> <li> <p>服务器, 195</p> </li> <li> <p>客户端通信,177,196–204</p> </li> <li> <p>文件,182</p> </li> <li> <p>$_SESSION 数组,265,275,317,591</p> </li> <li> <p>session.auto_start 配置设置,264</p> </li> <li> <p>session_destroy() 函数,270</p> </li> <li> <p>session_id() 函数,264</p> </li> <li> <p>会话,261–272,301</p> </li> <li> <p>销毁,270</p> </li> <li> <p>ID,262</p> </li> <li> <p>购物车,275</p> </li> <li> <p>存储登录数据,316</p> </li> <li> <p>超时,263</p> </li> <li> <p>session_start() 函数,264,298,593</p> </li> <li> <p>setDate() 函数,638</p> </li> <li> <p>setter 方法,335,342,354,363,476</p> </li> <li> <p>setTime() 函数,638</p> </li> <li> <p>setTimeStamp() 函数,647,652–653</p> </li> <li> <p>严重级别,463,474</p> </li> <li> <p>日志记录,460</p> </li> <li> <p>共享头文件模板,304</p> </li> <li> <p>共享静态资源,485</p> </li> <li> <p>购物车,13,16,211,275,397</p> </li> <li> <p>小计计算,288–290</p> </li> <li> <p>短标签输出,188</p> </li> <li> <p>副作用,82</p> </li> <li> <p>方法签名,370,497,527</p> </li> <li> <p>简单数组,125</p> </li> <li> <p>SimpleXMLElement 类,173</p> </li> <li> <p>simplexml_load_file() 函数,173</p> </li> <li> <p>单引号字符串,41–43</p> </li> <li> <p>nowdoc 字符串,52</p> </li> <li> <p>单选列表,220</p> </li> <li> <p>单例模式,488</p> </li> <li> <p>sizeof() 函数,132</p> </li> <li> <p>跳过的参数,104</p> </li> <li> <p>蛇形命名法,19–20,85</p> </li> <li> <p>数据库快照,460</p> </li> <li> <p>软件架构,430</p> </li> <li> <p>复杂数组,143</p> </li> <li> <p>sort() 函数,141</p> </li> <li> <p>排序数据,39</p> </li> <li> <p>空格替代字符 (%20),197</p> </li> <li> <p>太空船操作符 (<=>),38</p> </li> <li> <p>展开操作符 (...),137,150–151</p> </li> <li> <p>SQL(结构化查询语言),49,534,570</p> </li> <li> <p>删除表,607</p> </li> <li> <p>注入,542</p> </li> <li> <p>语句,534</p> </li> <li> <p>SQLite,533,542,667,675</p> </li> <li> <p>安装,671</p> </li> <li> <p>方括号([]),126–127</p> </li> <li> <p>src 目录,245,338</p> </li> <li> <p>栈,131</p> </li> <li> <p>堆栈追踪,446</p> </li> <li> <p>标准 PHP 库 (SPL),449</p> </li> <li> <p>语句,6,13,24</p> </li> <li> <p>分支,68</p> </li> <li> <p>选择,66</p> </li> <li> <p>条件,39,65</p> </li> <li> <p>组,67</p> </li> <li> <p>准备好的,542,545</p> </li> <li> <p>静态,475</p> </li> <li> <p>内容,183</p> </li> <li> <p>成员,367,476</p> </li> <li> <p>方法和属性,480</p> </li> <li> <p>状态码,179</p> </li> <li> <p>刻板印象,366</p> </li> <li> <p>粘性表单,230</p> </li> <li> <p>str_contains() 函数,72</p> </li> <li> <p>StreamHandler 类,464–465</p> </li> <li> <p>str_getcsv() 函数,173</p> </li> <li> <p>字符串连接运算符(.),35,44,84</p> </li> <li> <p>字符串数据类型,90</p> </li> <li> <p>字符串字面量值,21</p> </li> <li> <p>字符串,6,28,41</p> </li> <li> <p>字符数组,139</p> </li> <li> <p>比较,36</p> </li> <li> <p>函数,612</p> </li> <li> <p>拼接,45,126,542</p> </li> <li> <p>双引号,41,46–48</p> </li> <li> <p>表达式,42</p> </li> <li> <p>函数,41,53</p> </li> <li> <p>heredoc,41,49,163</p> </li> <li> <p>键,146</p> </li> <li> <p>负整数,139</p> </li> <li> <p>针和干草堆,55</p> </li> <li> <p>非数字,35</p> </li> <li> <p>nowdoc,41,52–53</p> </li> <li> <p>数字,34,38</p> </li> <li> <p>已解析,35,46</p> </li> <li> <p>替换,58</p> </li> <li> <p>搜索,54</p> </li> <li> <p>单引号,41,43</p> </li> <li> <p>未解析,52</p> </li> <li> <p>stristr() 函数,57</p> </li> <li> <p>strlen() 函数,55,73,112,228</p> </li> <li> <p>STR_PAD_BOTH 常量,61–62</p> </li> <li> <p>str_pad() 函数,61,90</p> </li> <li> <p>STR_PAD_LEFT 常量,61–62</p> </li> <li> <p>strpos() 函数,55</p> </li> <li> <p>str_repeat() 函数,61</p> </li> <li> <p>str_replace() 函数,58</p> </li> <li> <p>str_split() 函数,140</p> </li> <li> <p>strtolower() 函数,53</p> </li> <li> <p>strtoupper() 函数,53</p> </li> <li> <p>数据结构,299</p> </li> <li> <p>子类,331,357,451</p> </li> <li> <p>异常,456</p> </li> <li> <p>防止子类化,374</p> </li> <li> <p>sub() 方法(DateTimeImmutable),639</p> </li> <li> <p>提交按钮,200,208</p> </li> <li> <p>substr_count() 函数,55</p> </li> <li> <p>substr() 函数,56</p> </li> <li> <p>子字符串,54–56</p> </li> <li> <p>substr_replace() 函数,58</p> </li> <li> <p>减法,复合赋值和减法运算符(-=),22</p> </li> <li> <p>减法运算符(-),21</p> </li> <li> <p>超类,331,357,435</p> </li> <li> <p>抽象,498–499,526</p> </li> <li> <p>超全局数组,204,265</p> </li> <li> <p>switch 语句,73</p> </li> <li> <p>Symfony Web 框架,386</p> </li> <li> <p>syslog() 函数,460,462</p> </li> <li> <p>T</p> </li> <li> <p>\t(制表符转义字符),83</p> </li> <li> <p>表格,数据库,532</p> </li> <li> <p>标签,42</p> </li> <li> <p>标签,5,619</p> </li> <li> <p>PHP,187</p> </li> <li> <p>短 echo,188</p> </li> <li> <p>模板,185,395</p> </li> <li> <p>库,396</p> </li> <li> <p>脚本,281</p> </li> <li> <p>文本,13–14,244</p> </li> <li> <p>三元运算符(?),39,76–77</p> </li> <li> <p>textarea 输入,253</p> </li> <li> <p>文本框表单输入,200</p> </li> <li> <p>text/html 内容类型,181</p> </li> <li> <p>第三方库,382,390</p> </li> <li> <p>$this 关键字,20,335,343</p> </li> <li> <p>抛出异常,442–446</p> </li> <li> <p>throw 语句,442</p> </li> <li> <p>time() 函数,646</p> </li> <li> <p>时间,631</p> </li> <li> <p>夏令时,631,644</p> </li> <li> <p>操作,637</p> </li> <li> <p>偏移量,633</p> </li> <li> <p>timestamp() 函数,653</p> </li> <li> <p>时间戳, 40, 463, 656</p> </li> <li> <p>日志记录, 473</p> </li> <li> <p><em>time_t</em> 格式, 646</p> </li> <li> <p>时区, 631, 641</p> </li> <li> <p>标识符, 642</p> </li> <li> <p>时间攻击, 612</p> </li> <li> <p>__toString() 方法, 346, 349</p> </li> <li> <p>touch() 函数, 161</p> </li> <li> <p>特征, 497, 521</p> </li> <li> <p>解决冲突, 525</p> </li> <li> <p>trim() 函数, 59</p> </li> <li> <p>try...catch 语句, 446</p> </li> <li> <p>Twig, 395</p> </li> <li> <p>Composer, 398</p> </li> <li> <p>控制结构, 406</p> </li> <li> <p>继承, 415</p> </li> <li> <p>循环, 433</p> </li> <li> <p>安全, 399</p> </li> <li> <p>模板, 408</p> </li> <li> <p>变量, 397</p> </li> <li> <p>类型转换, 27, 39</p> </li> <li> <p>类型转换, 34</p> </li> <li> <p>TypeError 错误信息, 35, 88</p> </li> <li> <p>类型转换, 27, 30, 32–39, 89–90</p> </li> <li> <p>比较, 35–39</p> </li> <li> <p>逻辑, 39</p> </li> <li> <p>数字, 33–35</p> </li> <li> <p>字符串, 35</p> </li> <li> <p>类型, 数据, 27–28, 75, 85, 88, 492</p> </li> <li> <p>U</p> </li> <li> <p>\u{<hex>} Unicode 转义序列, 48</p> </li> <li> <p>Ubuntu, 665</p> </li> <li> <p>ucfirst() 函数, 54</p> </li> <li> <p>ucwords() 函数, 54</p> </li> <li> <p>一元运算符, 23</p> </li> <li> <p>未定义</p> </li> <li> <p>常量, 18</p> </li> <li> <p>变量, 17</p> </li> <li> <p>未定义的属性警告, 364</p> </li> <li> <p>Unicode 字符, 48</p> </li> <li> <p>统一建模语言(UML), 339</p> </li> <li> <p>图示, 365</p> </li> <li> <p>联合运算符 (+), 150</p> </li> <li> <p>联合类型, 98</p> </li> <li> <p>Unix 时间, 646</p> </li> <li> <p>unlink() 函数, 165</p> </li> <li> <p>未解析的字符串, 52</p> </li> <li> <p>unset() 函数, 31, 132, 149, 269</p> </li> <li> <p>取消设置变量, 40</p> </li> <li> <p>上驼峰命名法, 338</p> </li> <li> <p>大写字母, 53–54</p> </li> <li> <p>用户名/密码登录表单, 608</p> </li> <li> <p>显示已登录的用户名, 321</p> </li> <li> <p>use 语句, 384</p> </li> <li> <p>usort() 函数, 141</p> </li> <li> <p>UTC(协调世界时), 641</p> </li> <li> <p>工具类,482</p> </li> <li> <p>V</p> </li> <li> <p>验证,205,231,233,255</p> </li> <li> <p>错误,227</p> </li> <li> <p>表单数据,225–226,233</p> </li> <li> <p>基于数组的,235–237</p> </li> <li> <p>基于值的枚举(->value),494</p> </li> <li> <p>var_dump() 函数,30,607</p> </li> <li> <p>变量乘法运算符(*=),23</p> </li> <li> <p>变量,16–21</p> </li> <li> <p>布尔值,72</p> </li> <li> <p>计数器,117</p> </li> <li> <p>解构数组为,152</p> </li> <li> <p>全局,82</p> </li> <li> <p>隐藏,577</p> </li> <li> <p>本地,87,107</p> </li> <li> <p>名称,47</p> </li> <li> <p>伪,343</p> </li> <li> <p>查询字符串,197–198</p> </li> <li> <p>范围,87,247</p> </li> <li> <p>Twig,397</p> </li> <li> <p>未定义,17</p> </li> <li> <p>取消设置,40</p> </li> <li> <p>供应商文件夹,390</p> </li> <li> <p>视图,240</p> </li> <li> <p>模型-视图-控制器架构,189,396</p> </li> <li> <p>虚拟属性,354</p> </li> <li> <p>虚拟机,4</p> </li> <li> <p>可见性</p> </li> <li> <p>默认,342</p> </li> <li> <p>全局,491</p> </li> <li> <p>关键字,362</p> </li> <li> <p>空返回类型,90</p> </li> <li> <p>W</p> </li> <li> <p>warning() 函数,465</p> </li> <li> <p>警告,88,364</p> </li> <li> <p>消息,17</p> </li> <li> <p>web 应用程序</p> </li> <li> <p>架构,438</p> </li> <li> <p>多页,249</p> </li> <li> <p>面向对象,402,438</p> </li> <li> <p>可扩展的,239</p> </li> <li> <p>web 表单,195</p> </li> <li> <p>客户端/服务器通信,196–203</p> </li> <li> <p>编码数据,211–214</p> </li> <li> <p>过滤,203–206</p> </li> <li> <p>输入类型,214–223</p> </li> <li> <p>混合变量,207–208</p> </li> <li> <p>多个提交按钮,208–211</p> </li> <li> <p>不可编辑数据,206–207</p> </li> <li> <p>web 服务器,4,7–8,11–12</p> </li> <li> <p>内置,190</p> </li> <li> <p>动态,183</p> </li> <li> <p>安装不同的,192</p> </li> <li> <p>公共目录,676</p> </li> <li> <p>while 循环,111–112</p> </li> <li> <p>空白字符,33,42,188</p> </li> <li> <p>修剪,59</p> </li> <li> <p>Windows,665–667</p> </li> <li> <p>X</p> </li> <li> <p>XML(可扩展标记语言),173,599</p> </li> <li> <p>XOR(异或)操作,70,73</p> </li> <li> <p>Y</p> </li> <li> <p>YAML(YAML 不是标记语言),173,599</p> </li> <li> <p>YAML 函数</p> </li> <li> <p>yaml_emit(),173</p> </li> <li> <p>yaml_emit_file(),173</p> </li> <li> <p>yaml_parse(),173</p> </li> <li> <p>yaml_parse_file(),173</p> </li> <li> <p>Z</p> </li> <li> <p>零,数值,228</p> </li> <li> <p>从零开始的索引,54</p> </li> <li> <p>世界标准时间,641</p> </li> </ul>

    posted @ 2025-11-27 09:16  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报